mirror of
https://github.com/esphome/esphome.git
synced 2026-10-23 06:15:58 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0473e7419 | ||
|
|
3325de5e28 | ||
|
|
afffe3ba36 | ||
|
|
6bf3e07a74 | ||
|
|
c6c1fce2bf | ||
|
|
4293d12008 | ||
|
|
51819d40cb | ||
|
|
e7ad8f9c80 |
@@ -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, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
|
||||
capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `ld2450/` (zone number driver), `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,9 +7,6 @@ Ports are not listed on purpose: they follow the USB hub port and change on ever
|
||||
|
||||
- Opening the port usually resets the board through DTR, but not always; `serial_capture.py --reset`
|
||||
pulses RTS to capture from boot. 74880 baud shows the ROM boot messages (`rst cause`, `boot mode`).
|
||||
- UART0 can be looped back onto itself (`USC0(0) |= (1 << UCLBE)` in an `on_boot` lambda) so a UART
|
||||
component is testable without its device: frames written with `uart.write` arrive on RX. Log over the
|
||||
API with `logger: baud_rate: 0`, since the serial port carries the component's bytes.
|
||||
- `esphome logs` on some CH340s emits thousands of duplicated garbage lines right after the RTS reset;
|
||||
a plain pyserial capture at 115200 is reliable.
|
||||
- A failed esptool write can wedge the adapter (`termios` error 22, or an open that hangs) until replug.
|
||||
|
||||
@@ -4,13 +4,12 @@ Each flow names the pull requests it was used on, so their result tables show wh
|
||||
|
||||
One file per component, named after it:
|
||||
|
||||
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client
|
||||
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop
|
||||
- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer
|
||||
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
|
||||
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
|
||||
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
|
||||
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
|
||||
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
|
||||
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback
|
||||
- [`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
|
||||
@@ -21,7 +20,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), WISP bridge with an ethernet upstream
|
||||
- [`wifi.md`](wifi.md): reboot_timeout with a fallback AP, DHCP lease cache, Easy Connect (DPP)
|
||||
- [`zephyr.md`](zephyr.md): preferences on the nRF52840 dongle
|
||||
- [`zephyr_coredump.md`](zephyr_coredump.md): crash and decode on nRF52 boards
|
||||
- [`zwave_proxy.md`](zwave_proxy.md): frame checksums on a live proxy
|
||||
|
||||
@@ -28,15 +28,3 @@ connected first, and the loop-time maxima. For the loop cost of polling, repeat
|
||||
so after a drop every dial is closed and HA's incoming reconnect wins 11 to 33 s later; the measurable effect is the
|
||||
first dial landing in the loop the network flag flips.
|
||||
Results: https://github.com/esphome/esphome/pull/21085#issuecomment-6254742791
|
||||
|
||||
## User defined action listing and execution without a client library (#18882)
|
||||
|
||||
`scripts/api/rawactions.py HOST list` speaks the plaintext native API over a raw socket and decodes every
|
||||
`ListEntitiesServicesResponse` by field number (argument fields 1 name, 2 type, 3 description, 4 example, 5 optional,
|
||||
6 default_value), so new wire fields show up without waiting for the client library. `rawactions.py HOST exec ACTION
|
||||
k=v ...` sends `ExecuteServiceRequest` with `sint32` ints (field 5, not the legacy int32 field 2), fixed32 floats,
|
||||
length delimited strings and `int[]` as repeated sint32 (field 7); `ALLOW_MISSING=1` leaves arguments out on purpose
|
||||
(the device then drops the call). `templates/api-actions-dut.yaml` (D1 mini, three actions, optional variables with
|
||||
defaults) and `templates/api-actions-strings-dut.yaml` (required strings) log what arrived. String arguments are
|
||||
`StringRef`s that are not null terminated: copy them with `snprintf("%.*s", size, c_str())` into a buffer before
|
||||
logging, and do not pass `%.*s` to `logger.log`, whose validator counts it as one pattern.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# ld2412
|
||||
|
||||
## Output pin level and the basic configuration command (#21353)
|
||||
|
||||
`templates/ld2412-select-only-dut.yaml` (ESP32-S3, the module on GPIO43 and GPIO44 at 115200, only the output pin
|
||||
level select, the module's OUT pin on GPIO38 as a binary sensor plus the presence sensor) with `uart: debug:
|
||||
direction: BOTH`. Drive the select with `scripts/ld2450/lddrive.py HOST KEY LOGFILE sel:NAME=OPTION` and read the TX
|
||||
dump for command `02` carrying the level byte, then the OUT pin binary sensor against presence: with level high the
|
||||
pin follows presence, with level low it is inverted. A module that never answers the boot query (no `<<<` lines)
|
||||
only shows the held back path: `Command 02 held back until the module reports its settings`, a query `12`, and after
|
||||
2 s `Command 02 not sent, the module did not report its settings` with the select republished to the last known
|
||||
level. With the full set of numbers configured a select change also needs every number to have a state, so wait
|
||||
for the boot query answer before judging.
|
||||
@@ -13,30 +13,3 @@ 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,17 +23,3 @@ Only an Android phone configures: Settings, network details, Add device, Share W
|
||||
QR scanner makes the phone an enrollee). Render the URI with the `qrcode` module and open the PNG.
|
||||
`esp32: framework: log_level: DEBUG` plus `sdkconfig_options: CONFIG_WPA_DEBUG_PRINT: y` prints the
|
||||
supplicant state machine.
|
||||
|
||||
## WISP bridge with an ethernet upstream (#7147)
|
||||
|
||||
`templates/wisp-ethernet-dut.yaml` (ESP32-S3 with a W5500, `wifi: ap:` with `ap_mode: WISP` and no station) and
|
||||
`templates/wisp-client.yaml` (D1 mini joining the bridge, `http_request` to a web server on the wired LAN and to
|
||||
`http://example.com/` every 15 s, `time: sntp`). Secrets: `wisp_psk` and `lan_http` (serve a text file on the host
|
||||
with `python -m http.server 8787 --bind 0.0.0.0` from a scratch directory). Judge by the client's `GET status 200`
|
||||
lines, the web server log showing the device's ethernet address as the source (NAT), the client's DHCP info (DNS1 is
|
||||
the LAN resolver) and `SNTP synced`. The device logs `Starting AP` and `DHCP DNS server: <resolver>` on its USB
|
||||
console; flashing over the air reboots it for repeat boots. Before the AP address and DHCP server moved ahead of the
|
||||
AP start (the fix in that PR), the first AP start failed on every boot with `esp_netif_dhcps_option failed: 20484`
|
||||
(DHCP server already started by the AP start event) and `Failed to enable NAT`, and the bridge only came up on the
|
||||
retry 90 s later; a client that cannot find the SSID for the first minute on an older build is that. With the fix
|
||||
the bridge is up 6 s after boot. Any change to the ESP-IDF AP start should also rerun the captive portal hammer.
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
"""Plaintext native API client that lists user actions with their raw argument fields and executes one.
|
||||
|
||||
usage: rawactions.py HOST list
|
||||
rawactions.py HOST exec ACTION key=value [...] (every argument must be given; types from the listing)
|
||||
Fields of ListEntitiesServicesArgument: 1 name, 2 type, 3 description, 4 example, 5 optional, 6 default_value.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
HOST = sys.argv[1]
|
||||
CMD = sys.argv[2]
|
||||
ARGS = sys.argv[3:]
|
||||
TYPE_NAMES = {
|
||||
0: "bool",
|
||||
1: "int",
|
||||
2: "float",
|
||||
3: "string",
|
||||
4: "bool[]",
|
||||
5: "int[]",
|
||||
6: "float[]",
|
||||
7: "string[]",
|
||||
}
|
||||
|
||||
|
||||
def varint(v: int) -> bytes:
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = v & 0x7F
|
||||
v >>= 7
|
||||
if v:
|
||||
out.append(b | 0x80)
|
||||
else:
|
||||
out.append(b)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def svarint(v: int) -> bytes:
|
||||
return varint((v << 1) ^ (v >> 63))
|
||||
|
||||
|
||||
def decode_varint(buf: bytes, pos: int) -> tuple[int, int]:
|
||||
value = shift = 0
|
||||
while True:
|
||||
b = buf[pos]
|
||||
pos += 1
|
||||
value |= (b & 0x7F) << shift
|
||||
if not b & 0x80:
|
||||
return value, pos
|
||||
shift += 7
|
||||
|
||||
|
||||
def fields(payload: bytes) -> list[tuple[int, int, object]]:
|
||||
out = []
|
||||
pos = 0
|
||||
while pos < len(payload):
|
||||
key, pos = decode_varint(payload, pos)
|
||||
fnum, wt = key >> 3, key & 7
|
||||
if wt == 0:
|
||||
v, pos = decode_varint(payload, pos)
|
||||
elif wt == 2:
|
||||
ln, pos = decode_varint(payload, pos)
|
||||
v = payload[pos : pos + ln]
|
||||
pos += ln
|
||||
elif wt == 5:
|
||||
v = payload[pos : pos + 4] # fixed32, caller unpacks
|
||||
pos += 4
|
||||
else:
|
||||
raise ValueError(f"wire type {wt}")
|
||||
out.append((fnum, wt, v))
|
||||
return out
|
||||
|
||||
|
||||
def frame(mtype: int, payload: bytes) -> bytes:
|
||||
return b"\x00" + varint(len(payload)) + varint(mtype) + payload
|
||||
|
||||
|
||||
def field_bytes(fnum: int, data: bytes) -> bytes:
|
||||
return varint((fnum << 3) | 2) + varint(len(data)) + data
|
||||
|
||||
|
||||
def field_varint(fnum: int, v: int) -> bytes:
|
||||
return varint((fnum << 3) | 0) + varint(v)
|
||||
|
||||
|
||||
def recv_frames(
|
||||
sock: socket.socket, until: set[int], limit: float = 5.0
|
||||
) -> list[tuple[int, bytes]]:
|
||||
sock.settimeout(limit)
|
||||
buf = bytearray()
|
||||
frames = []
|
||||
while True:
|
||||
try:
|
||||
chunk = sock.recv(4096)
|
||||
except TimeoutError:
|
||||
return frames
|
||||
if not chunk:
|
||||
return frames
|
||||
buf += chunk
|
||||
while True:
|
||||
if len(buf) < 3:
|
||||
break
|
||||
if buf[0] != 0:
|
||||
raise ValueError("not plaintext")
|
||||
try:
|
||||
ln, p = decode_varint(buf, 1)
|
||||
mt, p = decode_varint(buf, p)
|
||||
except IndexError:
|
||||
break
|
||||
if len(buf) < p + ln:
|
||||
break
|
||||
frames.append((mt, bytes(buf[p : p + ln])))
|
||||
del buf[: p + ln]
|
||||
if mt in until:
|
||||
return frames
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sock = socket.create_connection((HOST, 6053), timeout=5)
|
||||
sock.sendall(
|
||||
frame(
|
||||
1, field_bytes(1, b"rawactions") + field_varint(2, 1) + field_varint(3, 10)
|
||||
)
|
||||
) # HelloRequest
|
||||
recv_frames(sock, {2})
|
||||
sock.sendall(frame(3, b"")) # ConnectRequest, no password
|
||||
recv_frames(sock, {4})
|
||||
sock.sendall(frame(11, b"")) # ListEntitiesRequest
|
||||
frames = recv_frames(sock, {19})
|
||||
services = {}
|
||||
for mt, payload in frames:
|
||||
if mt != 41:
|
||||
continue
|
||||
name = key = None
|
||||
args = []
|
||||
for fnum, _, v in fields(payload):
|
||||
if fnum == 1:
|
||||
name = v.decode()
|
||||
elif fnum == 2:
|
||||
key = struct.unpack("<I", v)[0]
|
||||
elif fnum == 3:
|
||||
a = {
|
||||
"name": None,
|
||||
"type": 0,
|
||||
"description": None,
|
||||
"example": None,
|
||||
"optional": None,
|
||||
"default": None,
|
||||
}
|
||||
for af, _, av in fields(v):
|
||||
if af == 1:
|
||||
a["name"] = av.decode()
|
||||
elif af == 2:
|
||||
a["type"] = av
|
||||
elif af == 3:
|
||||
a["description"] = av.decode()
|
||||
elif af == 4:
|
||||
a["example"] = av.decode()
|
||||
elif af == 5:
|
||||
a["optional"] = bool(av)
|
||||
elif af == 6:
|
||||
a["default"] = av.decode()
|
||||
else:
|
||||
a[f"field{af}"] = av
|
||||
args.append(a)
|
||||
services[name] = (key, args)
|
||||
if CMD == "list":
|
||||
for name, (key, args) in services.items():
|
||||
print(f"action {name} key={key}")
|
||||
for a in args:
|
||||
extra = {
|
||||
k: v
|
||||
for k, v in a.items()
|
||||
if k not in ("name", "type") and v is not None
|
||||
}
|
||||
print(f" {a['name']}: {TYPE_NAMES.get(a['type'], a['type'])} {extra}")
|
||||
return
|
||||
name = ARGS[0]
|
||||
key, args = services[name]
|
||||
payload = varint((1 << 3) | 5) + struct.pack("<I", key)
|
||||
given = dict(kv.split("=", 1) for kv in ARGS[1:])
|
||||
for a in args:
|
||||
if a["name"] not in given:
|
||||
if os.environ.get("ALLOW_MISSING"):
|
||||
continue # leave the argument out of the request on purpose
|
||||
sys.exit(f"missing {a['name']}")
|
||||
raw = given[a["name"]]
|
||||
t = a["type"]
|
||||
if t == 0:
|
||||
av = field_varint(1, 1 if raw.lower() == "true" else 0)
|
||||
elif t == 1:
|
||||
av = varint((5 << 3) | 0) + svarint(int(raw)) # sint32 int_
|
||||
elif t == 2:
|
||||
av = varint((3 << 3) | 5) + struct.pack("<f", float(raw))
|
||||
elif t == 3:
|
||||
av = field_bytes(4, raw.encode())
|
||||
elif t == 5:
|
||||
av = b"".join(
|
||||
varint((7 << 3) | 0) + svarint(int(x)) for x in raw.split(",") if x
|
||||
) # repeated sint32 int_array
|
||||
else:
|
||||
sys.exit(f"type {t} not supported here")
|
||||
payload += field_bytes(2, av)
|
||||
sock.sendall(frame(42, payload)) # ExecuteServiceRequest
|
||||
print(f"sent {name} with {given}")
|
||||
sock.close()
|
||||
|
||||
|
||||
main()
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Drive an LD24xx harness over the API: press buttons, set selects and numbers, print state updates, save the log.
|
||||
|
||||
usage: lddrive.py HOST KEY LOGFILE OP [OP ...] (KEY of - means a plaintext API)
|
||||
OP: list | press:NAME | sel:NAME=OPTION | set:NAME=VALUE | wait:SECONDS
|
||||
Steps are 2 s apart: the API log stream drops lines while the hub blocks in send_command_.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
BinarySensorState,
|
||||
ButtonInfo,
|
||||
LogLevel,
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
SelectInfo,
|
||||
SelectState,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
)
|
||||
|
||||
HOST, KEY, LOGFILE = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
OPS = sys.argv[4:]
|
||||
T0 = time.time()
|
||||
STEP_GAP_S = 2.0
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return f"{time.time() - T0:7.3f}"
|
||||
|
||||
|
||||
async def run(c: APIClient, log) -> None:
|
||||
info = await c.device_info()
|
||||
print(
|
||||
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
|
||||
)
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
|
||||
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None:
|
||||
return
|
||||
missing = " (missing)" if getattr(s, "missing_state", False) else ""
|
||||
if isinstance(s, TextSensorState):
|
||||
print(f"{stamp()} TXT {e.name} = {s.state!r}{missing}", flush=True)
|
||||
elif isinstance(s, BinarySensorState):
|
||||
print(f"{stamp()} BIN {e.name} = {s.state}{missing}", flush=True)
|
||||
elif isinstance(s, NumberState):
|
||||
print(f"{stamp()} NUM {e.name} = {s.state:.0f}{missing}", flush=True)
|
||||
elif isinstance(s, SelectState):
|
||||
print(f"{stamp()} SEL {e.name} = {s.state!r}{missing}", flush=True)
|
||||
elif (
|
||||
isinstance(s, SensorState)
|
||||
and isinstance(e, SensorInfo)
|
||||
and "Count" in e.name
|
||||
):
|
||||
print(f"{stamp()} CNT {e.name} = {s.state:.0f}", flush=True)
|
||||
|
||||
def on_log(msg) -> None:
|
||||
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
|
||||
await asyncio.sleep(1.5)
|
||||
for op in OPS:
|
||||
if op == "list":
|
||||
for n in sorted(buttons):
|
||||
print("button:", n)
|
||||
for n in sorted(selects):
|
||||
print("select:", n, selects[n].options)
|
||||
for e in ents:
|
||||
if isinstance(e, TextSensorInfo):
|
||||
print("text_sensor:", e.name)
|
||||
continue
|
||||
kind, _, rest = op.partition(":")
|
||||
if kind == "wait":
|
||||
await asyncio.sleep(float(rest))
|
||||
continue
|
||||
if kind == "press":
|
||||
if rest not in buttons:
|
||||
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
|
||||
print(f"{stamp()} >>> press {rest}", flush=True)
|
||||
log.write(f"{stamp()} >>> press {rest}\n")
|
||||
c.button_command(buttons[rest].key)
|
||||
elif kind == "set":
|
||||
name, _, value = rest.partition("=")
|
||||
if name not in numbers:
|
||||
sys.exit(f"no number named {name!r}; have {sorted(numbers)}")
|
||||
print(f"{stamp()} >>> set {name} = {value}", flush=True)
|
||||
log.write(f"{stamp()} >>> set {name} = {value}\n")
|
||||
c.number_command(numbers[name].key, float(value))
|
||||
elif kind == "sel":
|
||||
name, _, value = rest.partition("=")
|
||||
if name not in selects:
|
||||
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
|
||||
print(f"{stamp()} >>> select {name} = {value}", flush=True)
|
||||
log.write(f"{stamp()} >>> select {name} = {value}\n")
|
||||
c.select_command(selects[name].key, value)
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(STEP_GAP_S)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||
await c.connect(login=True)
|
||||
with Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||
await run(c, log)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Summarize an lddrive.py log: for each step, the LD2450 TX frames, commands and publishes.
|
||||
|
||||
usage: txsum.py LOGFILE (diff the output of two builds)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
out: list[str] = []
|
||||
with Path(sys.argv[1]).open(encoding="utf-8", errors="replace") as log:
|
||||
for raw in log:
|
||||
line = ANSI.sub("", raw.rstrip("\n"))
|
||||
m = re.match(r"\s*[\d.]+ (.*)", line)
|
||||
if not m:
|
||||
continue
|
||||
text = m.group(1)
|
||||
if text.startswith(">>>"):
|
||||
out.append(f"\n# {text[4:]}")
|
||||
elif "[uart_debug" in text:
|
||||
out.append(f" TX {text.split('>>> ')[1]}")
|
||||
elif "[ld2450" in text and (
|
||||
"Sending COMMAND" in text
|
||||
or "zone type" in text.lower()
|
||||
or "Query zone" in text
|
||||
):
|
||||
out.append(f" LOG {text.split(']: ', 1)[1]}")
|
||||
elif "[select:" in text and ">>" in text:
|
||||
out.append(f" SEL {text.split(']: ', 1)[1]}")
|
||||
elif "[text_sensor:098]" in text:
|
||||
out.append(f" TXT {text.split(']: ', 1)[1]}")
|
||||
print("\n".join(out))
|
||||
@@ -1,65 +0,0 @@
|
||||
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()"]
|
||||
@@ -1,28 +0,0 @@
|
||||
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);
|
||||
@@ -1,57 +0,0 @@
|
||||
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"
|
||||
@@ -1,258 +0,0 @@
|
||||
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
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
@@ -1,40 +0,0 @@
|
||||
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
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@af4d4fcb6922b8e6f65c0426947125fcc38794ce # v3.0.2
|
||||
uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1
|
||||
with:
|
||||
prek-version: ${{ steps.prek.outputs.version }}
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
|
||||
@@ -205,21 +205,19 @@ 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))`. 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.
|
||||
`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.
|
||||
* **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`, `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`.
|
||||
`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`.
|
||||
* **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with
|
||||
`LineReader<N>`, fixed-length binary frames with `FrameReader<N, HEADER...>`; 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. A child sensor that skips repeated
|
||||
values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
|
||||
`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.
|
||||
A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`.
|
||||
* **An unset float is `NAN`**, not `optional<float>`; the optional costs 4 to 8 bytes of padding per member.
|
||||
* **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the
|
||||
end of `setup()` when the entities need a state at boot.
|
||||
@@ -447,8 +445,6 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
switches = switch.sub_switches(config, parent=hub)
|
||||
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
|
||||
```
|
||||
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
|
||||
to still drive a state when the restore mode is disabled.
|
||||
|
||||
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
|
||||
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
|
||||
@@ -608,9 +604,7 @@ 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. 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
|
||||
the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with
|
||||
`cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values.
|
||||
|
||||
* **Conditions:**
|
||||
|
||||
@@ -147,7 +147,6 @@ esphome/components/cst816/* @clydebarrow
|
||||
esphome/components/cst9220/* @clydebarrow
|
||||
esphome/components/ct_clamp/* @jesserockz
|
||||
esphome/components/current_based/* @djwmarcx
|
||||
esphome/components/cw2017/* @exciton
|
||||
esphome/components/d01/* @ch604
|
||||
esphome/components/dac7678/* @NickB1
|
||||
esphome/components/daikin_arc/* @MagicBear
|
||||
@@ -326,7 +325,6 @@ esphome/components/internal_temperature/* @Mat931
|
||||
esphome/components/interval/* @esphome/core
|
||||
esphome/components/ir_rf_base/* @bdraco @kbx81
|
||||
esphome/components/ir_rf_proxy/* @kbx81
|
||||
esphome/components/islandaire/* @ipopov
|
||||
esphome/components/it8951/* @koosoli @limengdu @Passific
|
||||
esphome/components/jsn_sr04t/* @Mafus1
|
||||
esphome/components/json/* @esphome/core
|
||||
@@ -358,7 +356,6 @@ esphome/components/lm75b/* @beormund
|
||||
esphome/components/ln882h_ble/* @Bl00d-B0b
|
||||
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/ln882x/* @lamauny
|
||||
esphome/components/ln882x_spi_led_strip/* @sebo-b
|
||||
esphome/components/lock/* @esphome/core
|
||||
esphome/components/logger/* @esphome/core
|
||||
esphome/components/logger/select/* @clydebarrow
|
||||
@@ -369,7 +366,6 @@ esphome/components/ltr390/* @latonita @sjtrny
|
||||
esphome/components/ltr501/* @latonita
|
||||
esphome/components/ltr_als_ps/* @latonita
|
||||
esphome/components/lvgl/* @clydebarrow
|
||||
esphome/components/m5stack_4relay/* @KoenBreeman
|
||||
esphome/components/m5stack_8angle/* @rnauber
|
||||
esphome/components/mapping/* @clydebarrow
|
||||
esphome/components/matrix_keypad/* @ssieb
|
||||
@@ -541,7 +537,6 @@ esphome/components/sdp3x/* @Azimath
|
||||
esphome/components/seeed_mr24hpc1/* @limengdu
|
||||
esphome/components/seeed_mr60bha2/* @limengdu
|
||||
esphome/components/seeed_mr60fda2/* @limengdu
|
||||
esphome/components/seeed_multi_channel_relay/* @KoenBreeman
|
||||
esphome/components/selec_meter/* @sourabhjaiswal
|
||||
esphome/components/select/* @esphome/core
|
||||
esphome/components/sen0321/* @notjj
|
||||
@@ -705,7 +700,6 @@ 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
|
||||
|
||||
+16
-124
@@ -24,7 +24,6 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda
|
||||
from esphome.cpp_generator import (
|
||||
CallExpression,
|
||||
Expression,
|
||||
LambdaExpression,
|
||||
MockObj,
|
||||
@@ -60,15 +59,10 @@ 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(*_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
|
||||
cg.add(set_static(progmem_bytes(table_name, value), len(value)))
|
||||
|
||||
|
||||
def maybe_simple_id(*validators):
|
||||
@@ -362,33 +356,6 @@ 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)
|
||||
@@ -447,17 +414,9 @@ 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 reference keeps its spelling, so a mutable
|
||||
# one stays mutable; a value is taken by const reference, so a std::string is never copied.
|
||||
# The generated function's parameters; a std::string arg is never copied.
|
||||
return [
|
||||
(
|
||||
(
|
||||
t
|
||||
if str(cg.safe_exp(t)).rstrip().endswith("&")
|
||||
else cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &")
|
||||
),
|
||||
arg,
|
||||
)
|
||||
(cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), arg)
|
||||
for t, arg in args
|
||||
]
|
||||
|
||||
@@ -529,7 +488,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 | ApplyBytes,
|
||||
*fields: ApplyField | ApplyCall,
|
||||
call: str | None = None,
|
||||
id_key: str = CONF_ID,
|
||||
) -> None:
|
||||
@@ -545,7 +504,7 @@ def register_apply_action(
|
||||
def apply_action_builder(
|
||||
name: str,
|
||||
schema: cv.Schema,
|
||||
*fields: ApplyField | ApplyCall | ApplyBytes,
|
||||
*fields: ApplyField | ApplyCall,
|
||||
call: str | None = None,
|
||||
id_key: str = CONF_ID,
|
||||
) -> Callable[..., Awaitable[MockObj]]:
|
||||
@@ -558,20 +517,17 @@ 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)
|
||||
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 _, members in statements_spec:
|
||||
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,
|
||||
@@ -583,15 +539,7 @@ def apply_action_builder(
|
||||
lambda_args = _apply_lambda_args(args)
|
||||
receiver = "apply_call." if call else f"{parent}->"
|
||||
statements: list[str] = []
|
||||
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
|
||||
for target, members in statements_spec:
|
||||
values = _apply_values(config, members)
|
||||
if not _apply_call_active(members, values):
|
||||
continue
|
||||
@@ -612,62 +560,6 @@ 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,9 +111,7 @@ class ToolOutput(RedirectText):
|
||||
|
||||
def _emit_line(self, line: str) -> None:
|
||||
if self._progress and _PROGRESS.match(line):
|
||||
if self._is_filtered(line):
|
||||
self._drop_line(line)
|
||||
else:
|
||||
if not self._is_filtered(line):
|
||||
text = _fit_terminal(line.strip("\r\n"))
|
||||
self._write_color_replace(f"\r{text}\x1b[K")
|
||||
self._on_progress_line = True
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from ipaddress import IPv4Address, IPv6Address
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
@@ -29,7 +27,6 @@ from esphome.const import (
|
||||
CONF_CAPTURE_RESPONSE,
|
||||
CONF_DATA,
|
||||
CONF_DATA_TEMPLATE,
|
||||
CONF_DEFAULT,
|
||||
CONF_DELAY,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_EVENT,
|
||||
@@ -139,7 +136,6 @@ 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"
|
||||
@@ -251,66 +247,12 @@ def _validate_supports_response(value: Any) -> str:
|
||||
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
|
||||
|
||||
|
||||
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,
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
# Accepts the plain `name: type` shorthand or the full mapping form
|
||||
@@ -522,28 +464,7 @@ def _has_action_metadata(actions: list[ConfigType]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
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]:
|
||||
def _action_strings(conf: ConfigType, has_metadata: 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]]
|
||||
@@ -556,15 +477,6 @@ def _action_strings(
|
||||
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
|
||||
|
||||
|
||||
@@ -573,7 +485,9 @@ def _validate_wizard(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_wizard
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
_validate_wizard,
|
||||
)
|
||||
|
||||
|
||||
def _add_action_strings(index: int, strings: list[str | None]) -> MockObj:
|
||||
@@ -632,9 +546,6 @@ 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):
|
||||
@@ -678,7 +589,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, has_optional)
|
||||
strings = _action_strings(conf, has_metadata)
|
||||
table = _add_action_strings(index, strings)
|
||||
# Template args: supports_response mode, then user service arg types
|
||||
templ = cg.TemplateArguments(supports_response, *service_template_args)
|
||||
|
||||
@@ -608,7 +608,6 @@ message ListEntitiesCoverResponse {
|
||||
bool supports_stop = 12;
|
||||
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
|
||||
bool supports_stop_tilt = 14;
|
||||
bool supports_toggle = 15;
|
||||
}
|
||||
|
||||
// Deprecated in API version 1.1
|
||||
@@ -671,7 +670,6 @@ message CoverCommandRequest {
|
||||
bool stop = 8;
|
||||
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
|
||||
bool stop_tilt = 10;
|
||||
bool toggle = 11;
|
||||
}
|
||||
|
||||
// ==================== FAN ====================
|
||||
@@ -1174,10 +1172,6 @@ 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;
|
||||
@@ -1789,8 +1783,6 @@ 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;
|
||||
|
||||
@@ -555,7 +555,6 @@ uint16_t APIConnection::try_send_cover_info(EntityBase *entity, APIConnection *c
|
||||
msg.supports_tilt = traits.get_supports_tilt();
|
||||
msg.supports_stop = traits.get_supports_stop();
|
||||
msg.supports_stop_tilt = traits.get_supports_stop_tilt();
|
||||
msg.supports_toggle = traits.get_supports_toggle();
|
||||
return fill_and_encode_entity_info_with_device_class(cover, msg, msg.device_class, conn, remaining_size);
|
||||
}
|
||||
void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
|
||||
@@ -568,8 +567,6 @@ void APIConnection::on_cover_command_request(const CoverCommandRequest &msg) {
|
||||
call.set_command_stop();
|
||||
if (msg.stop_tilt)
|
||||
call.set_command_stop_tilt();
|
||||
if (msg.toggle)
|
||||
call.set_command_toggle();
|
||||
call.perform();
|
||||
}
|
||||
#endif
|
||||
@@ -1131,7 +1128,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -484,7 +484,6 @@ uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__rest
|
||||
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_id);
|
||||
#endif
|
||||
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.supports_stop_tilt);
|
||||
pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.supports_toggle);
|
||||
return pos;
|
||||
}
|
||||
uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
|
||||
@@ -507,7 +506,6 @@ uint32_t ListEntitiesCoverResponse::calc_size_msg(const void *self) {
|
||||
size += ProtoSize::calc_uint32(1, msg.device_id);
|
||||
#endif
|
||||
size += ProtoSize::calc_bool(1, msg.supports_stop_tilt);
|
||||
size += ProtoSize::calc_bool(1, msg.supports_toggle);
|
||||
return size;
|
||||
}
|
||||
uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) {
|
||||
@@ -567,9 +565,6 @@ void CoverCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *
|
||||
case proto_tag(10, WIRE_TYPE_VARINT):
|
||||
msg.stop_tilt = value.as_bool();
|
||||
break;
|
||||
case proto_tag(11, WIRE_TYPE_VARINT):
|
||||
msg.toggle = value.as_bool();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -1369,12 +1364,6 @@ 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;
|
||||
}
|
||||
@@ -1388,12 +1377,6 @@ 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;
|
||||
}
|
||||
@@ -2385,7 +2368,6 @@ 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) {
|
||||
@@ -2396,7 +2378,6 @@ 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,
|
||||
|
||||
@@ -911,7 +911,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage {
|
||||
class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 13;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 61;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 59;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); }
|
||||
#endif
|
||||
@@ -921,7 +921,6 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
|
||||
ProgmemStringRef device_class{};
|
||||
bool supports_stop{false};
|
||||
bool supports_stop_tilt{false};
|
||||
bool supports_toggle{false};
|
||||
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
|
||||
@@ -959,7 +958,7 @@ class CoverStateResponse final : public StateResponseProtoMessage {
|
||||
class CoverCommandRequest final : public CommandProtoMessage {
|
||||
public:
|
||||
static constexpr uint16_t MESSAGE_TYPE = 30;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 29;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 27;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("cover_command_request"); }
|
||||
#endif
|
||||
@@ -969,7 +968,6 @@ class CoverCommandRequest final : public CommandProtoMessage {
|
||||
float tilt{0.0f};
|
||||
bool stop{false};
|
||||
bool stop_tilt{false};
|
||||
bool toggle{false};
|
||||
void decode(const uint8_t *buffer, size_t length) {
|
||||
ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field);
|
||||
}
|
||||
@@ -1593,12 +1591,6 @@ 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 {
|
||||
@@ -2269,7 +2261,6 @@ 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);
|
||||
|
||||
@@ -1246,7 +1246,6 @@ const char *ListEntitiesCoverResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("supports_stop_tilt"), this->supports_stop_tilt);
|
||||
dump_field(out, ESPHOME_PSTR("supports_toggle"), this->supports_toggle);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *CoverStateResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -1272,7 +1271,6 @@ const char *CoverCommandRequest::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("stop_tilt"), this->stop_tilt);
|
||||
dump_field(out, ESPHOME_PSTR("toggle"), this->toggle);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
@@ -1643,12 +1641,6 @@ 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();
|
||||
}
|
||||
@@ -2105,7 +2097,6 @@ 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,13 +31,6 @@ 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,20 +40,13 @@ 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); 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.
|
||||
// the name and every argument is (name, description, example). Unset metadata 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_BASE_STRINGS = 3;
|
||||
static constexpr size_t USER_ACTION_ARG_STRINGS = 3;
|
||||
#else
|
||||
static constexpr size_t USER_ACTION_HEADER_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;
|
||||
static constexpr size_t USER_ACTION_ARG_STRINGS = 1;
|
||||
#endif
|
||||
class UserServiceStatic : public UserServiceDescriptor {
|
||||
public:
|
||||
|
||||
@@ -295,6 +295,11 @@ 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)})
|
||||
)
|
||||
@@ -333,11 +338,23 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
|
||||
)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
@automation.register_action(
|
||||
"cc1101.send_packet",
|
||||
SendPacketAction,
|
||||
SEND_PACKET_ACTION_SCHEMA,
|
||||
automation.ApplyBytes(CONF_DATA, "transmit_packet({})"),
|
||||
synchronous=True,
|
||||
)
|
||||
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,4 +132,13 @@ 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,10 +79,6 @@ 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.
|
||||
@@ -103,10 +99,6 @@ 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);
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ ESPHOME_LOG_TAG(TAG, "copy.lock");
|
||||
void CopyLock::setup() {
|
||||
source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); });
|
||||
|
||||
this->traits = this->source_->traits;
|
||||
traits.set_assumed_state(source_->traits.get_assumed_state());
|
||||
traits.set_requires_code(source_->traits.get_requires_code());
|
||||
traits.set_supported_states_mask(source_->traits.get_supported_states_mask());
|
||||
traits.set_supports_open(source_->traits.get_supports_open());
|
||||
|
||||
this->publish_state(source_->state);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::cover {
|
||||
|
||||
class CoverTraits {
|
||||
public:
|
||||
CoverTraits() = default;
|
||||
|
||||
bool get_is_assumed_state() const { return this->flags_.is_assumed_state; }
|
||||
void set_is_assumed_state(bool is_assumed_state) { this->flags_.is_assumed_state = is_assumed_state; }
|
||||
bool get_supports_position() const { return this->flags_.supports_position; }
|
||||
void set_supports_position(bool supports_position) { this->flags_.supports_position = supports_position; }
|
||||
bool get_supports_tilt() const { return this->flags_.supports_tilt; }
|
||||
void set_supports_tilt(bool supports_tilt) { this->flags_.supports_tilt = supports_tilt; }
|
||||
bool get_supports_toggle() const { return this->flags_.supports_toggle; }
|
||||
void set_supports_toggle(bool supports_toggle) { this->flags_.supports_toggle = supports_toggle; }
|
||||
bool get_supports_stop() const { return this->flags_.supports_stop; }
|
||||
void set_supports_stop(bool supports_stop) { this->flags_.supports_stop = supports_stop; }
|
||||
bool get_supports_stop_tilt() const { return this->flags_.supports_stop_tilt; }
|
||||
void set_supports_stop_tilt(bool supports_stop_tilt) { this->flags_.supports_stop_tilt = supports_stop_tilt; }
|
||||
bool get_is_assumed_state() const { return this->is_assumed_state_; }
|
||||
void set_is_assumed_state(bool is_assumed_state) { this->is_assumed_state_ = is_assumed_state; }
|
||||
bool get_supports_position() const { return this->supports_position_; }
|
||||
void set_supports_position(bool supports_position) { this->supports_position_ = supports_position; }
|
||||
bool get_supports_tilt() const { return this->supports_tilt_; }
|
||||
void set_supports_tilt(bool supports_tilt) { this->supports_tilt_ = supports_tilt; }
|
||||
bool get_supports_toggle() const { return this->supports_toggle_; }
|
||||
void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; }
|
||||
bool get_supports_stop() const { return this->supports_stop_; }
|
||||
void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; }
|
||||
bool get_supports_stop_tilt() const { return this->supports_stop_tilt_; }
|
||||
void set_supports_stop_tilt(bool supports_stop_tilt) { this->supports_stop_tilt_ = supports_stop_tilt; }
|
||||
|
||||
protected:
|
||||
// Bit-packed flags to save memory (1 byte instead of 6)
|
||||
struct CoverTraitFlags {
|
||||
uint8_t is_assumed_state : 1;
|
||||
uint8_t supports_position : 1;
|
||||
uint8_t supports_tilt : 1;
|
||||
uint8_t supports_toggle : 1;
|
||||
uint8_t supports_stop : 1;
|
||||
uint8_t supports_stop_tilt : 1;
|
||||
uint8_t reserved : 2;
|
||||
} flags_{};
|
||||
bool is_assumed_state_{false};
|
||||
bool supports_position_{false};
|
||||
bool supports_tilt_{false};
|
||||
bool supports_toggle_{false};
|
||||
bool supports_stop_{false};
|
||||
bool supports_stop_tilt_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::cover
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
CODEOWNERS = ["@exciton"]
|
||||
DOMAIN = "cw2017"
|
||||
@@ -1,196 +0,0 @@
|
||||
#include "cw2017.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
#include "esphome/components/deep_sleep/deep_sleep_component.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::cw2017 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "cw2017");
|
||||
|
||||
static constexpr uint8_t REG_VERSION = 0x00;
|
||||
static constexpr uint8_t REG_VCELL = 0x02;
|
||||
static constexpr uint8_t REG_SOC = 0x04;
|
||||
static constexpr uint8_t REG_MODE = 0x08;
|
||||
static constexpr uint8_t REG_SOC_ALERT = 0x0B;
|
||||
static constexpr uint8_t REG_BATINFO = 0x10;
|
||||
|
||||
static constexpr uint8_t MODE_NORMAL = 0x00;
|
||||
static constexpr uint8_t MODE_SLEEP = 0x30;
|
||||
static constexpr uint8_t UPDATE_FLAG = 0x80;
|
||||
static constexpr uint8_t RESTART_MODES[] PROGMEM = {0xF0, MODE_SLEEP, MODE_NORMAL};
|
||||
static constexpr uint8_t RESTART_STEPS = sizeof(RESTART_MODES);
|
||||
|
||||
static constexpr uint16_t VCELL_MASK = 0x3FFF;
|
||||
static constexpr float VCELL_VOLTS_PER_LSB = 312.5e-6f;
|
||||
|
||||
static constexpr uint32_t STEP_DELAY_MS = 20;
|
||||
static constexpr uint32_t READY_POLL_MS = 100;
|
||||
static constexpr uint8_t READY_ATTEMPTS = 40;
|
||||
static constexpr uint32_t INIT_RETRY_MS = 1000;
|
||||
static constexpr uint8_t INIT_ATTEMPTS = 3;
|
||||
|
||||
static bool version_is_running(uint8_t version) { return (version & 0xFD) == 0x0D; }
|
||||
|
||||
// The configured profile only changes with new firmware, and flashing never ends in a deep-sleep wake.
|
||||
static bool woke_from_deep_sleep() {
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
return deep_sleep::woke_from_deep_sleep();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool CW2017::read_profile_matches_(bool &matches) {
|
||||
uint8_t stored[PROFILE_SIZE];
|
||||
uint8_t expected[PROFILE_SIZE];
|
||||
if (!this->read_bytes(REG_BATINFO, stored, PROFILE_SIZE))
|
||||
return false;
|
||||
progmem_memcpy(expected, this->profile_, PROFILE_SIZE);
|
||||
matches = std::memcmp(stored, expected, PROFILE_SIZE) == 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CW2017::upload_profile_() {
|
||||
// Built here with the register first, so the bus layer does not heap-allocate a buffer this size.
|
||||
uint8_t buffer[1 + PROFILE_SIZE] = {REG_BATINFO};
|
||||
progmem_memcpy(buffer + 1, this->profile_, PROFILE_SIZE);
|
||||
return this->write(buffer, sizeof(buffer)) == i2c::ERROR_OK && this->write_byte(REG_SOC_ALERT, UPDATE_FLAG);
|
||||
}
|
||||
|
||||
void CW2017::setup() { this->init_(); }
|
||||
|
||||
// Retried because another device resetting on the same bus can disturb an early transfer.
|
||||
void CW2017::init_failed_(const LogString *reason) {
|
||||
if (++this->init_attempts_ >= INIT_ATTEMPTS) {
|
||||
this->mark_failed(reason);
|
||||
return;
|
||||
}
|
||||
this->set_timeout(INIT_RETRY_MS, [this]() { this->init_(); });
|
||||
}
|
||||
|
||||
void CW2017::init_() {
|
||||
// A profile upload only takes effect after a restart, so a failed restart is retried, not skipped.
|
||||
if (this->restart_pending_) {
|
||||
this->restart_();
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t mode = 0;
|
||||
uint8_t version = 0;
|
||||
uint8_t config = 0;
|
||||
if (!this->read_byte(REG_MODE, &mode) || !this->read_byte(REG_VERSION, &version) ||
|
||||
!this->read_byte(REG_SOC_ALERT, &config)) {
|
||||
this->init_failed_(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
const bool has_profile = (config & UPDATE_FLAG) != 0;
|
||||
bool matches = has_profile && woke_from_deep_sleep();
|
||||
if (has_profile && !matches && !this->read_profile_matches_(matches)) {
|
||||
this->init_failed_(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matches) {
|
||||
ESP_LOGI(TAG, "Uploading battery profile");
|
||||
if (!this->upload_profile_()) {
|
||||
this->init_failed_(LOG_STR("Battery profile upload failed"));
|
||||
return;
|
||||
}
|
||||
this->restart_();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == MODE_SLEEP) {
|
||||
// The gauge keeps its profile and state through its own sleep, so a wake is a mode write, not a restart.
|
||||
if (!this->write_byte(REG_MODE, MODE_NORMAL)) {
|
||||
this->init_failed_(LOG_STR("Gauge wake failed"));
|
||||
return;
|
||||
}
|
||||
this->set_timeout(STEP_DELAY_MS, [this]() { this->wait_ready_(0); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode != MODE_NORMAL || !version_is_running(version)) {
|
||||
this->restart_();
|
||||
return;
|
||||
}
|
||||
|
||||
this->wait_ready_(0);
|
||||
}
|
||||
|
||||
void CW2017::restart_() {
|
||||
this->restart_pending_ = true;
|
||||
this->set_timeout(STEP_DELAY_MS, [this]() { this->restart_step_(0); });
|
||||
}
|
||||
|
||||
void CW2017::restart_step_(uint8_t step) {
|
||||
if (!this->write_byte(REG_MODE, progmem_read_byte(&RESTART_MODES[step]))) {
|
||||
this->init_failed_(LOG_STR("Restart failed"));
|
||||
return;
|
||||
}
|
||||
if (step + 1 < RESTART_STEPS) {
|
||||
this->set_timeout(STEP_DELAY_MS, [this, step]() { this->restart_step_(step + 1); });
|
||||
} else {
|
||||
this->restart_pending_ = false;
|
||||
this->set_timeout(STEP_DELAY_MS, [this]() { this->wait_ready_(0); });
|
||||
}
|
||||
}
|
||||
|
||||
void CW2017::wait_ready_(uint8_t attempt) {
|
||||
uint8_t version = 0;
|
||||
uint8_t soc = 0;
|
||||
if (this->read_byte(REG_VERSION, &version) && version_is_running(version) && this->read_byte(REG_SOC, &soc) &&
|
||||
soc <= 100) {
|
||||
ESP_LOGD(TAG, "Gauge ready %u ms after boot", (unsigned) millis());
|
||||
this->ready_ = true;
|
||||
this->update();
|
||||
return;
|
||||
}
|
||||
if (attempt + 1 >= READY_ATTEMPTS) {
|
||||
this->init_failed_(LOG_STR("Gauge did not become ready"));
|
||||
return;
|
||||
}
|
||||
this->set_timeout(READY_POLL_MS, [this, attempt]() { this->wait_ready_(attempt + 1); });
|
||||
}
|
||||
|
||||
void CW2017::update() {
|
||||
if (!this->ready_)
|
||||
return;
|
||||
|
||||
if (this->battery_level_sensor_ != nullptr) {
|
||||
uint8_t soc = 0;
|
||||
if (this->read_byte(REG_SOC, &soc) && soc <= 100) {
|
||||
this->battery_level_sensor_->publish_state(soc);
|
||||
} else {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->battery_voltage_sensor_ != nullptr) {
|
||||
uint16_t raw = 0;
|
||||
if (this->read_byte_16(REG_VCELL, &raw)) {
|
||||
this->battery_voltage_sensor_->publish_state((raw & VCELL_MASK) * VCELL_VOLTS_PER_LSB);
|
||||
} else {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->status_clear_warning();
|
||||
}
|
||||
|
||||
void CW2017::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "CW2017:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
|
||||
LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_sensor_);
|
||||
}
|
||||
|
||||
} // namespace esphome::cw2017
|
||||
@@ -1,39 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::cw2017 {
|
||||
|
||||
static constexpr uint8_t PROFILE_SIZE = 80;
|
||||
|
||||
class CW2017 : public PollingComponent, public i2c::I2CDevice {
|
||||
public:
|
||||
explicit CW2017(const uint8_t *profile) : profile_(profile) {}
|
||||
|
||||
void set_battery_level_sensor(sensor::Sensor *sensor) { this->battery_level_sensor_ = sensor; }
|
||||
void set_battery_voltage_sensor(sensor::Sensor *sensor) { this->battery_voltage_sensor_ = sensor; }
|
||||
|
||||
void setup() override;
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
|
||||
protected:
|
||||
void init_();
|
||||
void init_failed_(const LogString *reason);
|
||||
bool read_profile_matches_(bool &matches);
|
||||
bool upload_profile_();
|
||||
void restart_();
|
||||
void restart_step_(uint8_t step);
|
||||
void wait_ready_(uint8_t attempt);
|
||||
|
||||
const uint8_t *profile_;
|
||||
sensor::Sensor *battery_level_sensor_{nullptr};
|
||||
sensor::Sensor *battery_voltage_sensor_{nullptr};
|
||||
bool ready_{false};
|
||||
bool restart_pending_{false};
|
||||
uint8_t init_attempts_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::cw2017
|
||||
@@ -1,61 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BATTERY_LEVEL,
|
||||
CONF_BATTERY_VOLTAGE,
|
||||
CONF_RAW_DATA_ID,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_PERCENT,
|
||||
UNIT_VOLT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
CONF_BATTERY_PROFILE = "battery_profile"
|
||||
PROFILE_SIZE = 80
|
||||
|
||||
cw2017_ns = cg.esphome_ns.namespace("cw2017")
|
||||
CW2017 = cw2017_ns.class_("CW2017", cg.PollingComponent, i2c.I2CDevice)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(CW2017),
|
||||
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
|
||||
cv.Required(CONF_BATTERY_PROFILE): cv.All(
|
||||
cv.ensure_list(cv.hex_uint8_t),
|
||||
cv.Length(min=PROFILE_SIZE, max=PROFILE_SIZE),
|
||||
),
|
||||
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_BATTERY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_VOLT,
|
||||
accuracy_decimals=3,
|
||||
device_class=DEVICE_CLASS_VOLTAGE,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
.extend(i2c.i2c_device_schema(0x63))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
profile = cg.progmem_array(config[CONF_RAW_DATA_ID], config[CONF_BATTERY_PROFILE])
|
||||
var = await i2c.new_i2c_component(config, profile)
|
||||
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
|
||||
await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor)
|
||||
@@ -7,6 +7,7 @@ namespace esphome::deep_sleep {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "deep_sleep");
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
WakeupCause get_wakeup_cause() {
|
||||
switch (lt_get_reboot_reason()) {
|
||||
case REBOOT_REASON_SLEEP_GPIO:
|
||||
@@ -19,6 +20,7 @@ WakeupCause get_wakeup_cause() {
|
||||
return WAKEUP_CAUSE_NONE;
|
||||
}
|
||||
}
|
||||
#endif // USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ struct WakeupCauseToRunDuration {
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
/// Why the device woke from deep sleep. Passed to on_wake automations.
|
||||
enum WakeupCause : uint8_t {
|
||||
/// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart).
|
||||
@@ -77,11 +79,6 @@ enum WakeupCause : uint8_t {
|
||||
/// Return why the device woke from deep sleep. Implemented per platform.
|
||||
WakeupCause get_wakeup_cause();
|
||||
|
||||
/// Return true when this boot is a wake from deep sleep.
|
||||
inline bool woke_from_deep_sleep() { return get_wakeup_cause() != WAKEUP_CAUSE_NONE; }
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
/** Setup priority of on_wake triggers.
|
||||
*
|
||||
* Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at
|
||||
|
||||
@@ -30,6 +30,7 @@ namespace esphome::deep_sleep {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "deep_sleep");
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
WakeupCause get_wakeup_cause() {
|
||||
switch (esp_sleep_get_wakeup_cause()) {
|
||||
case ESP_SLEEP_WAKEUP_EXT0:
|
||||
@@ -46,6 +47,7 @@ WakeupCause get_wakeup_cause() {
|
||||
return WAKEUP_CAUSE_UNKNOWN;
|
||||
}
|
||||
}
|
||||
#endif // USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
optional<uint32_t> DeepSleepComponent::get_run_duration_() const {
|
||||
if (this->wakeup_cause_to_run_duration_.has_value()) {
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
|
||||
#include <Esp.h>
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
}
|
||||
#endif
|
||||
|
||||
namespace esphome::deep_sleep {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "deep_sleep");
|
||||
|
||||
#ifdef USE_DEEP_SLEEP_ON_WAKE
|
||||
WakeupCause get_wakeup_cause() {
|
||||
// The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST).
|
||||
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
|
||||
@@ -19,6 +22,7 @@ WakeupCause get_wakeup_cause() {
|
||||
}
|
||||
return WAKEUP_CAUSE_NONE;
|
||||
}
|
||||
#endif // USE_DEEP_SLEEP_ON_WAKE
|
||||
|
||||
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ static bool wakeable_delay_feed_wdt(uint32_t ms) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A wake from System OFF is not told apart from a reset yet.
|
||||
WakeupCause get_wakeup_cause() { return WAKEUP_CAUSE_NONE; }
|
||||
|
||||
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
|
||||
|
||||
void DeepSleepComponent::dump_config_platform_() {}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#include "dfrobot_sen0465_uart.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace esphome::dfrobot_sen0465_uart {
|
||||
|
||||
using dfrobot_sen0465_base::FRAME_HEAD;
|
||||
using dfrobot_sen0465_base::FRAME_SIZE;
|
||||
using dfrobot_sen0465_base::RESPONSE_TIMEOUT_ID;
|
||||
|
||||
@@ -15,21 +18,30 @@ void DFRobotSEN0465UARTComponent::setup() {
|
||||
}
|
||||
|
||||
void DFRobotSEN0465UARTComponent::loop() {
|
||||
// The tail of a reading the probe sent on its own may precede the answer; the reader skips to the frame head.
|
||||
const uint8_t *frame = nullptr;
|
||||
this->read_available<FRAME_SIZE>([&](uint8_t c) { return (frame = this->reader_.feed(c)) == nullptr; });
|
||||
if (frame == nullptr)
|
||||
// The tail of a reading the probe sent on its own may precede the answer; start at the next frame head.
|
||||
uint8_t byte;
|
||||
while (this->rx_count_ == 0 && this->peek_byte(&byte) && byte != FRAME_HEAD)
|
||||
this->read_byte(&byte);
|
||||
const size_t available = this->available();
|
||||
if (available == 0)
|
||||
return;
|
||||
const size_t len = std::min<size_t>(available, FRAME_SIZE - this->rx_count_);
|
||||
if (!this->read_array(this->rx_buffer_ + this->rx_count_, len))
|
||||
return;
|
||||
this->rx_count_ += len;
|
||||
if (this->rx_count_ < FRAME_SIZE)
|
||||
return;
|
||||
|
||||
this->rx_count_ = 0;
|
||||
this->cancel_timeout(RESPONSE_TIMEOUT_ID);
|
||||
this->disable_loop();
|
||||
this->handle_response_(frame);
|
||||
this->handle_response_(this->rx_buffer_);
|
||||
}
|
||||
|
||||
bool DFRobotSEN0465UARTComponent::write_frame(const uint8_t *frame) {
|
||||
// Drop anything left over, such as the answer to the mode change or readings sent before passive mode was set
|
||||
this->discard_available();
|
||||
this->reader_.reset();
|
||||
this->rx_count_ = 0;
|
||||
this->write_array(frame, FRAME_SIZE);
|
||||
return true;
|
||||
}
|
||||
@@ -38,7 +50,7 @@ void DFRobotSEN0465UARTComponent::read_response() {
|
||||
this->enable_loop();
|
||||
this->set_timeout(RESPONSE_TIMEOUT_ID, RESPONSE_TIMEOUT_MS, [this]() {
|
||||
this->disable_loop();
|
||||
this->reader_.reset();
|
||||
this->rx_count_ = 0;
|
||||
this->handle_no_response_();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include "esphome/components/dfrobot_sen0465_base/dfrobot_sen0465_base.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/frame_reader.h"
|
||||
|
||||
namespace esphome::dfrobot_sen0465_uart {
|
||||
|
||||
@@ -18,7 +17,8 @@ class DFRobotSEN0465UARTComponent final : public dfrobot_sen0465_base::DFRobotSE
|
||||
bool write_frame(const uint8_t *frame) override;
|
||||
void read_response() override;
|
||||
|
||||
FrameReader<dfrobot_sen0465_base::FRAME_SIZE, dfrobot_sen0465_base::FRAME_HEAD> reader_;
|
||||
uint8_t rx_buffer_[dfrobot_sen0465_base::FRAME_SIZE];
|
||||
uint8_t rx_count_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::dfrobot_sen0465_uart
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "ds1603l.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
@@ -8,28 +10,35 @@ namespace esphome::ds1603l {
|
||||
ESPHOME_LOG_TAG(TAG, "ds1603l.sensor");
|
||||
|
||||
void DS1603L::loop() {
|
||||
this->read_available([this](uint8_t byte) {
|
||||
const uint8_t *frame = this->reader_.feed(byte);
|
||||
if (frame == nullptr) {
|
||||
if (this->reader_.length() == 0) {
|
||||
ESP_LOGV(TAG, "Skipping byte 0x%02X while looking for header", byte);
|
||||
}
|
||||
return;
|
||||
// Assemble frames one byte at a time so a stream that starts mid-frame can realign
|
||||
uint8_t byte;
|
||||
while (this->available() > 0 && this->read_byte(&byte)) {
|
||||
if (this->rx_count_ == 0 && byte != HEADER_BYTE) {
|
||||
ESP_LOGV(TAG, "Skipping byte 0x%02X while looking for header", byte);
|
||||
continue;
|
||||
}
|
||||
if (!this->parse_data_(frame)) {
|
||||
|
||||
this->rx_buffer_[this->rx_count_++] = byte;
|
||||
if (this->rx_count_ < FRAME_SIZE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this->parse_data_()) {
|
||||
this->rx_count_ = 0;
|
||||
} else {
|
||||
// The header byte was part of the payload of a misaligned frame, so realign instead of dropping everything
|
||||
this->reader_.resync();
|
||||
this->resync_();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void DS1603L::dump_config() { LOG_SENSOR("", "DS1603L", this); }
|
||||
|
||||
bool DS1603L::parse_data_(const uint8_t *frame) {
|
||||
uint8_t header = frame[0];
|
||||
uint8_t data_h = frame[1];
|
||||
uint8_t data_l = frame[2];
|
||||
uint8_t checksum = frame[3];
|
||||
bool DS1603L::parse_data_() {
|
||||
uint8_t header = this->rx_buffer_[0];
|
||||
uint8_t data_h = this->rx_buffer_[1];
|
||||
uint8_t data_l = this->rx_buffer_[2];
|
||||
uint8_t checksum = this->rx_buffer_[3];
|
||||
|
||||
uint8_t computed_checksum = (header + data_h + data_l) & 0xFF;
|
||||
|
||||
@@ -44,4 +53,16 @@ bool DS1603L::parse_data_(const uint8_t *frame) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void DS1603L::resync_() {
|
||||
// Drop the byte that was treated as the header, then look for the next candidate header in what is left
|
||||
size_t start = 1;
|
||||
while (start < this->rx_count_ && this->rx_buffer_[start] != HEADER_BYTE) {
|
||||
start++;
|
||||
}
|
||||
this->rx_count_ -= start;
|
||||
if (this->rx_count_ > 0) {
|
||||
memmove(this->rx_buffer_, this->rx_buffer_ + start, this->rx_count_);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ds1603l
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/frame_reader.h"
|
||||
|
||||
namespace esphome::ds1603l {
|
||||
|
||||
@@ -19,10 +18,13 @@ class DS1603L final : public sensor::Sensor, public Component, public uart::UART
|
||||
static constexpr uint8_t HEADER_BYTE = 0xFF;
|
||||
static constexpr size_t FRAME_SIZE = 4;
|
||||
|
||||
// Validates the checksum of the frame and publishes it. Returns false if the frame is invalid.
|
||||
bool parse_data_(const uint8_t *frame);
|
||||
// Validates the checksum of the frame in rx_buffer_ and publishes it. Returns false if the frame is invalid.
|
||||
bool parse_data_();
|
||||
// Drops the first buffered byte and realigns the buffer on the next possible header byte.
|
||||
void resync_();
|
||||
|
||||
FrameReader<FRAME_SIZE, HEADER_BYTE> reader_;
|
||||
uint8_t rx_buffer_[FRAME_SIZE]; // Buffer for the frame being assembled
|
||||
size_t rx_count_{0}; // Number of bytes currently in rx_buffer_
|
||||
};
|
||||
|
||||
} // namespace esphome::ds1603l
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
from esphome import automation, pins
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
from esphome.components.const import CONF_ENABLED
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_FREQUENCY,
|
||||
CONF_HOUR,
|
||||
CONF_INTERRUPT_PIN,
|
||||
CONF_MINUTE,
|
||||
CONF_MODE,
|
||||
CONF_OFFSET,
|
||||
CONF_SECOND,
|
||||
CONF_STATE,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@linkedupbits"]
|
||||
@@ -21,245 +10,23 @@ MULTI_CONF = True
|
||||
DOMAIN = "ds3231"
|
||||
|
||||
CONF_DS3231_ID = "ds3231_id"
|
||||
CONF_ALARM = "alarm"
|
||||
CONF_ALARM_CHECK_INTERVAL = "alarm_check_interval"
|
||||
CONF_AGING_OFFSET = "aging_offset"
|
||||
CONF_AUTO_CLEAR_ALARMS = "auto_clear_alarms"
|
||||
CONF_BATTERY_BACKED_SQUARE_WAVE = "battery_backed_square_wave"
|
||||
CONF_DAY_OF_MONTH = "day_of_month"
|
||||
CONF_DAY_OF_WEEK = "day_of_week"
|
||||
CONF_ON_ALARM_1 = "on_alarm_1"
|
||||
CONF_ON_ALARM_2 = "on_alarm_2"
|
||||
CONF_OUTPUT_32KHZ = "output_32khz"
|
||||
CONF_SQUARE_WAVE_FREQUENCY = "square_wave_frequency"
|
||||
|
||||
ds3231_ns = cg.esphome_ns.namespace("ds3231")
|
||||
DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice)
|
||||
|
||||
AlarmMode = ds3231_ns.enum("AlarmMode")
|
||||
ALARM_1_MODES = AlarmMode.options(
|
||||
"every_second",
|
||||
"every_minute",
|
||||
"hourly",
|
||||
"daily",
|
||||
"monthly",
|
||||
"weekly",
|
||||
prefix="ALARM_MODE_",
|
||||
)
|
||||
# Alarm 2 has no seconds register.
|
||||
ALARM_2_MODES = {k: v for k, v in ALARM_1_MODES.items() if k != "every_second"}
|
||||
|
||||
SquareWaveFrequency = ds3231_ns.enum("SquareWaveFrequency")
|
||||
# Keyed by the frequency in Hz; cv.frequency turns "1024Hz", "1.024kHz" or 1024 into it.
|
||||
SQUARE_WAVE_FREQUENCIES = {
|
||||
1: SquareWaveFrequency.SQUARE_WAVE_1HZ,
|
||||
1024: SquareWaveFrequency.SQUARE_WAVE_1024HZ,
|
||||
4096: SquareWaveFrequency.SQUARE_WAVE_4096HZ,
|
||||
8192: SquareWaveFrequency.SQUARE_WAVE_8192HZ,
|
||||
}
|
||||
validate_square_wave_frequency = cv.All(
|
||||
cv.frequency, cv.enum(SQUARE_WAVE_FREQUENCIES, int=True)
|
||||
)
|
||||
|
||||
validate_aging_offset = cv.int_range(min=-128, max=127)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DS3231Component),
|
||||
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
|
||||
cv.Optional(CONF_SQUARE_WAVE_FREQUENCY): validate_square_wave_frequency,
|
||||
cv.Optional(CONF_BATTERY_BACKED_SQUARE_WAVE): cv.boolean,
|
||||
cv.Optional(CONF_OUTPUT_32KHZ): cv.boolean,
|
||||
cv.Optional(CONF_AGING_OFFSET): validate_aging_offset,
|
||||
cv.Optional(CONF_AUTO_CLEAR_ALARMS, default=True): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_ALARM_CHECK_INTERVAL, default="1s"
|
||||
): cv.positive_time_period_milliseconds,
|
||||
cv.Optional(CONF_ON_ALARM_1): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_ON_ALARM_2): automation.validate_automation(single=True),
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)})
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(i2c.i2c_device_schema(0x68)),
|
||||
cv.has_at_most_one_key(CONF_INTERRUPT_PIN, CONF_SQUARE_WAVE_FREQUENCY),
|
||||
.extend(i2c.i2c_device_schema(0x68))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await i2c.new_i2c_component(config)
|
||||
await i2c.new_i2c_component(config)
|
||||
|
||||
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
|
||||
cg.add_optional(
|
||||
var.set_square_wave_frequency, config.get(CONF_SQUARE_WAVE_FREQUENCY)
|
||||
)
|
||||
cg.add_optional(
|
||||
var.set_battery_backed_square_wave, config.get(CONF_BATTERY_BACKED_SQUARE_WAVE)
|
||||
)
|
||||
cg.add_optional(var.set_output_32khz_config, config.get(CONF_OUTPUT_32KHZ))
|
||||
cg.add_optional(var.set_aging_offset_config, config.get(CONF_AGING_OFFSET))
|
||||
cg.add(var.set_auto_clear_alarms(config[CONF_AUTO_CLEAR_ALARMS]))
|
||||
cg.add(var.set_alarm_check_interval(config[CONF_ALARM_CHECK_INTERVAL]))
|
||||
# The alarm flags are only read when something uses them; the binary sensor platform
|
||||
# turns this on as well. With an interrupt pin the check also releases INT/SQW.
|
||||
if any(
|
||||
key in config for key in (CONF_INTERRUPT_PIN, CONF_ON_ALARM_1, CONF_ON_ALARM_2)
|
||||
):
|
||||
cg.add(var.set_monitor_alarms(True))
|
||||
|
||||
# One callback for both alarms; each automation runs only for its own alarm number.
|
||||
for conf_key, alarm in ((CONF_ON_ALARM_1, 1), (CONF_ON_ALARM_2, 2)):
|
||||
if (conf := config.get(conf_key)) is not None:
|
||||
await automation.build_callback_automation(
|
||||
var,
|
||||
"add_on_alarm_callback",
|
||||
[],
|
||||
conf,
|
||||
params=[(cg.uint8, "alarm")],
|
||||
forward=[],
|
||||
when=f"alarm == {alarm}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Actions
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
DS3231_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
{cv.GenerateID(): cv.use_id(DS3231Component)}
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ds3231.force_temperature_conversion",
|
||||
DS3231_ACTION_SCHEMA,
|
||||
automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}),
|
||||
automation.ApplyCall("force_temperature_conversion()"),
|
||||
)
|
||||
|
||||
|
||||
def _validate_alarm_day(config: ConfigType) -> ConfigType:
|
||||
mode = config[CONF_MODE]
|
||||
if mode == "monthly" and CONF_DAY_OF_MONTH not in config:
|
||||
raise cv.Invalid(f"'{CONF_DAY_OF_MONTH}' is required in monthly mode")
|
||||
if mode == "weekly" and CONF_DAY_OF_WEEK not in config:
|
||||
raise cv.Invalid(f"'{CONF_DAY_OF_WEEK}' is required in weekly mode")
|
||||
# The unused day still has to be a valid register value.
|
||||
config.setdefault(CONF_DAY_OF_MONTH, 1)
|
||||
config.setdefault(CONF_DAY_OF_WEEK, 1)
|
||||
return config
|
||||
|
||||
|
||||
def _alarm_schema(modes: dict, with_second: bool) -> cv.Schema:
|
||||
schema = {
|
||||
cv.GenerateID(): cv.use_id(DS3231Component),
|
||||
cv.Required(CONF_MODE): cv.enum(modes, lower=True),
|
||||
cv.Optional(CONF_MINUTE, default=0): cv.templatable(
|
||||
cv.int_range(min=0, max=59)
|
||||
),
|
||||
cv.Optional(CONF_HOUR, default=0): cv.templatable(cv.int_range(min=0, max=23)),
|
||||
cv.Optional(CONF_DAY_OF_MONTH): cv.templatable(cv.int_range(min=1, max=31)),
|
||||
# 1 = Sunday ... 7 = Saturday, as in ESPHome's time component.
|
||||
cv.Optional(CONF_DAY_OF_WEEK): cv.templatable(cv.int_range(min=1, max=7)),
|
||||
cv.Optional(CONF_ENABLED, default=True): cv.templatable(cv.boolean),
|
||||
}
|
||||
if with_second:
|
||||
schema[cv.Optional(CONF_SECOND, default=0)] = cv.templatable(
|
||||
cv.int_range(min=0, max=59)
|
||||
)
|
||||
return cv.All(cv.Schema(schema), _validate_alarm_day)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"ds3231.set_alarm_1",
|
||||
_alarm_schema(ALARM_1_MODES, with_second=True),
|
||||
automation.ApplyCall(
|
||||
"set_alarm_1({}, {}, {}, {}, {}, {}, {})",
|
||||
(
|
||||
(CONF_MODE, AlarmMode),
|
||||
(CONF_SECOND, cg.uint8),
|
||||
(CONF_MINUTE, cg.uint8),
|
||||
(CONF_HOUR, cg.uint8),
|
||||
(CONF_DAY_OF_MONTH, cg.uint8),
|
||||
(CONF_DAY_OF_WEEK, cg.uint8),
|
||||
(CONF_ENABLED, cg.bool_),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ds3231.set_alarm_2",
|
||||
_alarm_schema(ALARM_2_MODES, with_second=False),
|
||||
automation.ApplyCall(
|
||||
"set_alarm_2({}, {}, {}, {}, {}, {})",
|
||||
(
|
||||
(CONF_MODE, AlarmMode),
|
||||
(CONF_MINUTE, cg.uint8),
|
||||
(CONF_HOUR, cg.uint8),
|
||||
(CONF_DAY_OF_MONTH, cg.uint8),
|
||||
(CONF_DAY_OF_WEEK, cg.uint8),
|
||||
(CONF_ENABLED, cg.bool_),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
DS3231_ALARM_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(DS3231Component),
|
||||
cv.Required(CONF_ALARM): cv.templatable(cv.int_range(min=1, max=2)),
|
||||
}
|
||||
)
|
||||
|
||||
for _name, _method in (
|
||||
("ds3231.enable_alarm", "enable_alarm"),
|
||||
("ds3231.disable_alarm", "disable_alarm"),
|
||||
("ds3231.clear_alarm", "clear_alarm"),
|
||||
):
|
||||
automation.register_apply_action(
|
||||
_name,
|
||||
DS3231_ALARM_ACTION_SCHEMA,
|
||||
automation.ApplyField(CONF_ALARM, _method, cg.uint8),
|
||||
)
|
||||
|
||||
automation.register_apply_condition(
|
||||
"ds3231.is_alarm_triggered",
|
||||
DS3231_ALARM_ACTION_SCHEMA,
|
||||
automation.ApplyCall("is_alarm_triggered({})", ((CONF_ALARM, cg.uint8),)),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ds3231.enable_square_wave",
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(DS3231Component),
|
||||
cv.Required(CONF_FREQUENCY): validate_square_wave_frequency,
|
||||
}
|
||||
),
|
||||
automation.ApplyField(CONF_FREQUENCY, "enable_square_wave", SquareWaveFrequency),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ds3231.disable_square_wave",
|
||||
DS3231_ACTION_SCHEMA,
|
||||
automation.ApplyCall("disable_square_wave()"),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ds3231.set_32khz_output",
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(DS3231Component),
|
||||
cv.Required(CONF_STATE): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
automation.ApplyField(CONF_STATE, "set_output_32khz", cg.bool_),
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ds3231.set_aging_offset",
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(DS3231Component),
|
||||
cv.Required(CONF_OFFSET): cv.templatable(validate_aging_offset),
|
||||
}
|
||||
),
|
||||
automation.ApplyField(CONF_OFFSET, "set_aging_offset", cg.int8),
|
||||
)
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import binary_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_DS3231_ID, DS3231Component
|
||||
|
||||
DEPENDENCIES = ["ds3231"]
|
||||
|
||||
CONF_ALARM_1 = "alarm_1"
|
||||
CONF_ALARM_2 = "alarm_2"
|
||||
CONF_OSCILLATOR_STOPPED = "oscillator_stopped"
|
||||
|
||||
ICON_ALARM = "mdi:alarm"
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
|
||||
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
|
||||
cv.Optional(CONF_ALARM_1): binary_sensor.binary_sensor_schema(
|
||||
icon=ICON_ALARM
|
||||
),
|
||||
cv.Optional(CONF_ALARM_2): binary_sensor.binary_sensor_schema(
|
||||
icon=ICON_ALARM
|
||||
),
|
||||
cv.Optional(CONF_OSCILLATOR_STOPPED): binary_sensor.binary_sensor_schema(
|
||||
device_class=DEVICE_CLASS_PROBLEM,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_ALARM_1, CONF_ALARM_2, CONF_OSCILLATOR_STOPPED),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_DS3231_ID])
|
||||
binary_sensors = binary_sensor.sub_binary_sensors(config)
|
||||
await binary_sensors(CONF_ALARM_1, hub.set_alarm_1_binary_sensor)
|
||||
await binary_sensors(CONF_ALARM_2, hub.set_alarm_2_binary_sensor)
|
||||
await binary_sensors(
|
||||
CONF_OSCILLATOR_STOPPED, hub.set_oscillator_stopped_binary_sensor
|
||||
)
|
||||
if CONF_ALARM_1 in config or CONF_ALARM_2 in config:
|
||||
cg.add(hub.set_monitor_alarms(True))
|
||||
@@ -9,184 +9,29 @@ namespace esphome::ds3231 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ds3231");
|
||||
|
||||
static constexpr uint8_t DS3231_REG_SECONDS = 0x00;
|
||||
static constexpr uint8_t DS3231_REG_ALARM_1 = 0x07;
|
||||
static constexpr uint8_t DS3231_REG_ALARM_2 = 0x0B;
|
||||
static constexpr uint8_t DS3231_REG_CONTROL = 0x0E;
|
||||
static constexpr uint8_t DS3231_REG_STATUS = 0x0F;
|
||||
static constexpr uint8_t DS3231_REG_AGING_OFFSET = 0x10;
|
||||
static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11;
|
||||
|
||||
static constexpr uint8_t ALARM_MASK_BIT = 0x80; // AxMy: this register is ignored when matching
|
||||
static constexpr uint8_t ALARM_DAY_OF_WEEK = 0x40; // DY/DT: match day of week instead of date
|
||||
|
||||
/// How long an alarm binary sensor stays on when the flag is cleared automatically.
|
||||
static constexpr uint32_t ALARM_PULSE_MS = 1000;
|
||||
|
||||
static const LogString *alarm_state_to_string(uint8_t control, uint8_t alarm) {
|
||||
return (control & (alarm == 1 ? 0x01 : 0x02)) != 0 ? LOG_STR("enabled") : LOG_STR("disabled");
|
||||
}
|
||||
|
||||
static const LogString *square_wave_to_string(SquareWaveFrequency frequency) {
|
||||
switch (frequency) {
|
||||
case SQUARE_WAVE_1HZ:
|
||||
return LOG_STR("1 Hz");
|
||||
case SQUARE_WAVE_1024HZ:
|
||||
return LOG_STR("1.024 kHz");
|
||||
case SQUARE_WAVE_4096HZ:
|
||||
return LOG_STR("4.096 kHz");
|
||||
case SQUARE_WAVE_8192HZ:
|
||||
return LOG_STR("8.192 kHz");
|
||||
default:
|
||||
return LOG_STR("unknown");
|
||||
}
|
||||
}
|
||||
|
||||
void IRAM_ATTR DS3231Component::interrupt_handler(DS3231Component *arg) { arg->enable_loop_soon_any_context(); }
|
||||
|
||||
void DS3231Component::setup() {
|
||||
if (!this->read_control_status_()) {
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the oscillator running on battery. INT/SQW is the alarm interrupt line unless a square
|
||||
// wave is configured. The alarm interrupt enables (A1IE/A2IE) are kept as they are: the alarms
|
||||
// live on the chip and survive a reboot of the ESP on battery.
|
||||
uint8_t mask = CONTROL_EOSC | CONTROL_INTCN;
|
||||
uint8_t value = CONTROL_INTCN;
|
||||
if (this->square_wave_configured_) {
|
||||
mask |= CONTROL_RS_MASK;
|
||||
value = this->square_wave_frequency_ << CONTROL_RS_SHIFT;
|
||||
}
|
||||
if (this->battery_backed_square_wave_ >= 0) {
|
||||
mask |= CONTROL_BBSQW;
|
||||
assign_bits(value, CONTROL_BBSQW, this->battery_backed_square_wave_ != 0);
|
||||
}
|
||||
if (!this->update_control_(mask, value)) {
|
||||
this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery
|
||||
// INT/SQW is the (unused) interrupt line rather than a square-wave output.
|
||||
this->control_reg_ |= CONTROL_INTCN;
|
||||
if (!this->write_control_()) {
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->output_32khz_ >= 0) {
|
||||
assign_bits(this->status_reg_, STATUS_EN32KHZ, this->output_32khz_ != 0);
|
||||
if (!this->write_status_(0)) {
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->aging_offset_configured_ && !this->write_aging_offset_(this->aging_offset_)) {
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((this->status_reg_ & STATUS_OSF) != 0) {
|
||||
ESP_LOGW(TAG, "Oscillator was stopped (battery empty or missing?), the stored time is invalid.");
|
||||
}
|
||||
this->publish_oscillator_stopped_();
|
||||
|
||||
if (!this->monitor_alarms_) {
|
||||
this->disable_loop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start from a known state; an alarm that fired while the ESP was off is picked up by the first
|
||||
// check, which runs from the loop right after setup.
|
||||
this->publish_alarm_state_(1, false);
|
||||
this->publish_alarm_state_(2, false);
|
||||
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
this->interrupt_pin_->setup();
|
||||
// INT/SQW is open drain and active low: a falling edge means an enabled alarm matched.
|
||||
// The handler only wakes the loop; the flags are read from there.
|
||||
this->interrupt_pin_->attach_interrupt(&DS3231Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE);
|
||||
}
|
||||
// Without an interrupt pin this is how alarms are seen. With one it is a fallback: it picks up an
|
||||
// edge that was missed, and latched flags (auto_clear_alarms: false) that were cleared elsewhere.
|
||||
this->set_interval(this->alarm_check_interval_, [this]() { this->check_alarms_(); });
|
||||
}
|
||||
|
||||
void DS3231Component::loop() {
|
||||
// Only runs once after setup and when woken by the interrupt pin.
|
||||
this->disable_loop();
|
||||
this->check_alarms_();
|
||||
}
|
||||
|
||||
void DS3231Component::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "DS3231:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
if (this->is_failed())
|
||||
return;
|
||||
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
|
||||
if ((this->control_reg_ & CONTROL_INTCN) == 0) {
|
||||
ESP_LOGCONFIG(TAG, " INT/SQW: square wave %s",
|
||||
LOG_STR_ARG(square_wave_to_string(
|
||||
static_cast<SquareWaveFrequency>((this->control_reg_ & CONTROL_RS_MASK) >> CONTROL_RS_SHIFT))));
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " INT/SQW: alarm interrupt");
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Battery-backed square wave: %s\n"
|
||||
" 32 kHz output: %s\n"
|
||||
" Alarm 1: %s\n"
|
||||
" Alarm 2: %s\n"
|
||||
" Auto clear alarms: %s",
|
||||
YESNO(this->control_reg_ & CONTROL_BBSQW), ONOFF(this->status_reg_ & STATUS_EN32KHZ),
|
||||
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 1)),
|
||||
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 2)), YESNO(this->auto_clear_alarms_));
|
||||
if (this->aging_offset_configured_) {
|
||||
ESP_LOGCONFIG(TAG, " Aging offset: %d", this->aging_offset_);
|
||||
}
|
||||
if (this->monitor_alarms_) {
|
||||
ESP_LOGCONFIG(TAG, " Alarm check interval: %" PRIu32 " ms", this->alarm_check_interval_);
|
||||
}
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
LOG_BINARY_SENSOR(" ", "Alarm 1", this->alarm_1_binary_sensor_);
|
||||
LOG_BINARY_SENSOR(" ", "Alarm 2", this->alarm_2_binary_sensor_);
|
||||
LOG_BINARY_SENSOR(" ", "Oscillator Stopped", this->oscillator_stopped_binary_sensor_);
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Clock
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
bool DS3231Component::read_calendar(uint8_t *raw) {
|
||||
if (this->is_failed() || !this->status_comm_result(this->read_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
|
||||
return false;
|
||||
// Without a valid status the oscillator-stop flag is unknown, so the time can't be trusted.
|
||||
if (!this->read_control_status_())
|
||||
return false;
|
||||
this->publish_oscillator_stopped_();
|
||||
if ((this->status_reg_ & STATUS_OSF) != 0) {
|
||||
ESP_LOGW(TAG, "Oscillator stop flag set, the RTC time is invalid. Write a valid time first.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DS3231Component::write_calendar(const uint8_t *raw) {
|
||||
if (this->is_failed() || !this->status_comm_result(this->write_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
|
||||
return false;
|
||||
|
||||
// The time is valid again: clear the oscillator stop flag. If that fails, the RTC would keep
|
||||
// reporting its time as invalid, so the write is not reported as successful.
|
||||
if (!this->read_control_status_())
|
||||
return false;
|
||||
if ((this->status_reg_ & STATUS_OSF) != 0) {
|
||||
if (!this->write_status_(STATUS_OSF))
|
||||
return false;
|
||||
this->status_reg_ &= ~STATUS_OSF;
|
||||
}
|
||||
this->publish_oscillator_stopped_();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Temperature
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
bool DS3231Component::read_temperature(float &out) {
|
||||
uint8_t raw[2];
|
||||
if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) {
|
||||
@@ -208,280 +53,26 @@ void DS3231Component::force_temperature_conversion() {
|
||||
ESP_LOGW(TAG, "A temperature conversion is already in progress.");
|
||||
return;
|
||||
}
|
||||
// CONV is a one-shot command bit, so it is not kept in the cached value.
|
||||
this->status_comm_result(this->write_byte(DS3231_REG_CONTROL, this->control_reg_ | CONTROL_CONV));
|
||||
this->control_reg_ |= CONTROL_CONV;
|
||||
this->write_control_();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Alarms
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
void DS3231Component::set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
|
||||
uint8_t day_of_week, bool enabled) {
|
||||
if (second > 59 || minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 ||
|
||||
day_of_week > 7 || mode > ALARM_MODE_WEEKLY) {
|
||||
ESP_LOGE(TAG, "Invalid alarm 1 setting.");
|
||||
return;
|
||||
}
|
||||
// Mask bits A1M1..A1M4: a set bit means the register is ignored when matching.
|
||||
uint8_t regs[4] = {
|
||||
uint8_to_bcd(second),
|
||||
uint8_to_bcd(minute),
|
||||
uint8_to_bcd(hour),
|
||||
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
|
||||
};
|
||||
switch (mode) {
|
||||
case ALARM_MODE_EVERY_SECOND:
|
||||
regs[0] |= ALARM_MASK_BIT;
|
||||
[[fallthrough]];
|
||||
case ALARM_MODE_EVERY_MINUTE:
|
||||
regs[1] |= ALARM_MASK_BIT;
|
||||
[[fallthrough]];
|
||||
case ALARM_MODE_HOURLY:
|
||||
regs[2] |= ALARM_MASK_BIT;
|
||||
[[fallthrough]];
|
||||
case ALARM_MODE_DAILY:
|
||||
regs[3] |= ALARM_MASK_BIT;
|
||||
break;
|
||||
default: // MONTHLY / WEEKLY: everything matches
|
||||
break;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting alarm 1: mode %u, %02u:%02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
|
||||
second, day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
|
||||
this->write_alarm_(1, regs, sizeof(regs), enabled);
|
||||
}
|
||||
|
||||
void DS3231Component::set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month,
|
||||
uint8_t day_of_week, bool enabled) {
|
||||
if (minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 || day_of_week > 7 ||
|
||||
mode == ALARM_MODE_EVERY_SECOND || mode > ALARM_MODE_WEEKLY) {
|
||||
ESP_LOGE(TAG, "Invalid alarm 2 setting.");
|
||||
return;
|
||||
}
|
||||
// Mask bits A2M2..A2M4.
|
||||
uint8_t regs[3] = {
|
||||
uint8_to_bcd(minute),
|
||||
uint8_to_bcd(hour),
|
||||
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
|
||||
};
|
||||
switch (mode) {
|
||||
case ALARM_MODE_EVERY_MINUTE:
|
||||
regs[0] |= ALARM_MASK_BIT;
|
||||
[[fallthrough]];
|
||||
case ALARM_MODE_HOURLY:
|
||||
regs[1] |= ALARM_MASK_BIT;
|
||||
[[fallthrough]];
|
||||
case ALARM_MODE_DAILY:
|
||||
regs[2] |= ALARM_MASK_BIT;
|
||||
break;
|
||||
default: // MONTHLY / WEEKLY: everything matches
|
||||
break;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting alarm 2: mode %u, %02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
|
||||
day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
|
||||
this->write_alarm_(2, regs, sizeof(regs), enabled);
|
||||
}
|
||||
|
||||
void DS3231Component::write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled) {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
if (!this->status_comm_result(this->write_bytes(alarm == 1 ? DS3231_REG_ALARM_1 : DS3231_REG_ALARM_2, regs, len)))
|
||||
return;
|
||||
// Changing the registers may have matched on the way: start from a clean flag. Enabling the
|
||||
// alarm with a stale flag would assert INT/SQW right away.
|
||||
if (!this->clear_alarm_flag_(alarm)) {
|
||||
ESP_LOGW(TAG, "Alarm %u written, but its flag could not be cleared; not changing its enable.", alarm);
|
||||
return;
|
||||
}
|
||||
this->set_alarm_enabled_(alarm, enabled);
|
||||
}
|
||||
|
||||
void DS3231Component::set_alarm_enabled_(uint8_t alarm, bool enabled) {
|
||||
if (alarm != 1 && alarm != 2) {
|
||||
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
|
||||
return;
|
||||
}
|
||||
if (this->is_failed())
|
||||
return;
|
||||
const uint8_t bit = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
|
||||
if (!this->update_control_(bit, enabled ? bit : 0))
|
||||
return;
|
||||
if (enabled && (this->control_reg_ & CONTROL_INTCN) == 0) {
|
||||
ESP_LOGW(TAG, "Alarm %u enabled, but INT/SQW is a square-wave output: only the flag will be set.", alarm);
|
||||
}
|
||||
ESP_LOGD(TAG, "Alarm %u %s.", alarm, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
|
||||
}
|
||||
|
||||
void DS3231Component::enable_alarm(uint8_t alarm) {
|
||||
// The chip sets the flag on every match even while the alarm is disabled; enabling with it set
|
||||
// would fire the trigger (and INT/SQW) for that old match right away.
|
||||
if (this->clear_alarm_flag_(alarm))
|
||||
this->set_alarm_enabled_(alarm, true);
|
||||
}
|
||||
|
||||
void DS3231Component::clear_alarm(uint8_t alarm) { this->clear_alarm_flag_(alarm); }
|
||||
|
||||
bool DS3231Component::clear_alarm_flag_(uint8_t alarm) {
|
||||
if (alarm != 1 && alarm != 2) {
|
||||
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
|
||||
return false;
|
||||
}
|
||||
if (this->is_failed() || !this->read_control_status_())
|
||||
return false;
|
||||
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
|
||||
if ((this->status_reg_ & flag) != 0) {
|
||||
// On failure the flag is still set on the chip: keep reporting the alarm as it is.
|
||||
if (!this->write_status_(flag))
|
||||
return false;
|
||||
this->status_reg_ &= ~flag;
|
||||
}
|
||||
this->alarm_active_[alarm - 1] = false;
|
||||
this->publish_alarm_state_(alarm, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DS3231Component::is_alarm_triggered(uint8_t alarm) {
|
||||
if (this->is_failed() || !this->read_control_status_())
|
||||
return false;
|
||||
return (this->status_reg_ & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) != 0;
|
||||
}
|
||||
|
||||
void DS3231Component::check_alarms_() {
|
||||
if (this->is_failed() || !this->read_control_status_())
|
||||
return;
|
||||
this->publish_oscillator_stopped_();
|
||||
|
||||
uint8_t to_clear = 0;
|
||||
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
|
||||
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
|
||||
const uint8_t enable = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
|
||||
const bool triggered = (this->status_reg_ & flag) != 0;
|
||||
const bool enabled = (this->control_reg_ & enable) != 0;
|
||||
bool &active = this->alarm_active_[alarm - 1];
|
||||
|
||||
if (!triggered) {
|
||||
if (active && !this->auto_clear_alarms_)
|
||||
this->publish_alarm_state_(alarm, false);
|
||||
active = false;
|
||||
continue;
|
||||
}
|
||||
// The flag is set on every match even when the alarm is disabled; only enabled alarms count.
|
||||
if (enabled && !active) {
|
||||
ESP_LOGI(TAG, "Alarm %u triggered.", alarm);
|
||||
active = true;
|
||||
this->publish_alarm_state_(alarm, true);
|
||||
this->alarm_callback_.call(alarm);
|
||||
}
|
||||
if (this->auto_clear_alarms_)
|
||||
to_clear |= flag;
|
||||
}
|
||||
|
||||
if (to_clear != 0 && this->write_status_(to_clear)) {
|
||||
this->status_reg_ &= ~to_clear;
|
||||
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
|
||||
if ((to_clear & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) == 0 || !this->alarm_active_[alarm - 1])
|
||||
continue;
|
||||
this->alarm_active_[alarm - 1] = false;
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
// Keep the binary sensor on long enough for the state to be seen, then turn it off again.
|
||||
this->set_timeout(ALARM_PULSE_TIMEOUT_ID + alarm - 1, ALARM_PULSE_MS,
|
||||
[this, alarm]() { this->publish_alarm_state_(alarm, false); });
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DS3231Component::publish_alarm_state_(uint8_t alarm, bool state) {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
auto *sensor = alarm == 1 ? this->alarm_1_binary_sensor_ : this->alarm_2_binary_sensor_;
|
||||
if (sensor != nullptr)
|
||||
sensor->publish_state(state);
|
||||
#endif
|
||||
}
|
||||
|
||||
void DS3231Component::publish_oscillator_stopped_() {
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
if (this->oscillator_stopped_binary_sensor_ != nullptr)
|
||||
this->oscillator_stopped_binary_sensor_->publish_state((this->status_reg_ & STATUS_OSF) != 0);
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Outputs and calibration
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
void DS3231Component::enable_square_wave(SquareWaveFrequency frequency) {
|
||||
if (this->interrupt_pin_ != nullptr) {
|
||||
ESP_LOGE(TAG, "Can't enable the square wave: INT/SQW is used as the alarm interrupt pin.");
|
||||
return;
|
||||
}
|
||||
if (this->is_failed())
|
||||
return;
|
||||
if (this->update_control_(CONTROL_INTCN | CONTROL_RS_MASK, frequency << CONTROL_RS_SHIFT)) {
|
||||
ESP_LOGD(TAG, "Square wave enabled at %s.", LOG_STR_ARG(square_wave_to_string(frequency)));
|
||||
}
|
||||
}
|
||||
|
||||
void DS3231Component::disable_square_wave() {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
if (this->update_control_(CONTROL_INTCN, CONTROL_INTCN)) {
|
||||
ESP_LOGD(TAG, "Square wave disabled, INT/SQW is the alarm interrupt.");
|
||||
}
|
||||
}
|
||||
|
||||
void DS3231Component::set_output_32khz(bool enabled) {
|
||||
if (this->is_failed() || !this->read_control_status_())
|
||||
return;
|
||||
assign_bits(this->status_reg_, STATUS_EN32KHZ, enabled);
|
||||
if (this->write_status_(0)) {
|
||||
ESP_LOGD(TAG, "32 kHz output %s.", ONOFF(enabled));
|
||||
}
|
||||
}
|
||||
|
||||
void DS3231Component::set_aging_offset(int8_t offset) {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
if (!this->write_aging_offset_(offset))
|
||||
return;
|
||||
ESP_LOGD(TAG, "Aging offset set to %d.", offset);
|
||||
// The new offset takes effect with the next temperature conversion.
|
||||
this->force_temperature_conversion();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Register access
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
bool DS3231Component::read_control_status_() {
|
||||
uint8_t raw[2];
|
||||
if (!this->status_comm_result(this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))))
|
||||
if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) {
|
||||
ESP_LOGW(TAG, "Can't read control/status registers.");
|
||||
return false;
|
||||
this->control_reg_ = raw[0] & ~CONTROL_CONV;
|
||||
}
|
||||
this->control_reg_ = raw[0];
|
||||
this->status_reg_ = raw[1];
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DS3231Component::update_control_(uint8_t mask, uint8_t value) {
|
||||
// CONV is a one-shot command bit: always write it back as 0 so a control change never starts
|
||||
// a temperature conversion.
|
||||
if (!this->status_comm_result(this->update_register(DS3231_REG_CONTROL, mask | CONTROL_CONV, value)))
|
||||
bool DS3231Component::write_control_() {
|
||||
if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) {
|
||||
ESP_LOGW(TAG, "Can't write control register.");
|
||||
return false;
|
||||
this->control_reg_ = (this->control_reg_ & ~mask) | (value & mask);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DS3231Component::write_status_(uint8_t clear) {
|
||||
// The flags can only be written to 0; writing 1 leaves them unchanged, so a flag that was set
|
||||
// between the read and this write is never lost.
|
||||
const uint8_t value = ((this->status_reg_ & STATUS_EN32KHZ) | STATUS_FLAGS) & ~clear;
|
||||
return this->status_comm_result(this->write_byte(DS3231_REG_STATUS, value));
|
||||
}
|
||||
|
||||
bool DS3231Component::write_aging_offset_(int8_t offset) {
|
||||
if (!this->status_comm_result(this->write_byte(DS3231_REG_AGING_OFFSET, static_cast<uint8_t>(offset))))
|
||||
return false;
|
||||
this->aging_offset_ = offset;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,163 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
#include "esphome/components/binary_sensor/binary_sensor.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::ds3231 {
|
||||
|
||||
/// When an alarm fires. Alarm 1 supports every mode, alarm 2 has no seconds register and so no EVERY_SECOND.
|
||||
enum AlarmMode : uint8_t {
|
||||
ALARM_MODE_EVERY_SECOND = 0, ///< Alarm 1 only.
|
||||
ALARM_MODE_EVERY_MINUTE, ///< Alarm 1: when the seconds match. Alarm 2: at second 00 of every minute.
|
||||
ALARM_MODE_HOURLY, ///< When minutes (and seconds) match.
|
||||
ALARM_MODE_DAILY, ///< When hours, minutes (and seconds) match.
|
||||
ALARM_MODE_MONTHLY, ///< When date, hours, minutes (and seconds) match.
|
||||
ALARM_MODE_WEEKLY, ///< When day of week, hours, minutes (and seconds) match.
|
||||
};
|
||||
|
||||
/// Frequency of the INT/SQW pin when it is a square-wave output (RS2:RS1 in the control register).
|
||||
enum SquareWaveFrequency : uint8_t {
|
||||
SQUARE_WAVE_1HZ = 0,
|
||||
SQUARE_WAVE_1024HZ = 1,
|
||||
SQUARE_WAVE_4096HZ = 2,
|
||||
SQUARE_WAVE_8192HZ = 3,
|
||||
};
|
||||
|
||||
/// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the
|
||||
/// chip through this class.
|
||||
class DS3231Component final : public Component, public i2c::I2CDevice {
|
||||
public:
|
||||
/// Seconds through year, the 7-byte BCD calendar the time platform decodes with time::DS_BCD_CALENDAR.
|
||||
static constexpr size_t CALENDAR_LEN = 7;
|
||||
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
// ---- Configuration (set from codegen) ----
|
||||
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
|
||||
void set_square_wave_frequency(SquareWaveFrequency frequency) {
|
||||
this->square_wave_frequency_ = frequency;
|
||||
this->square_wave_configured_ = true;
|
||||
}
|
||||
void set_battery_backed_square_wave(bool enabled) { this->battery_backed_square_wave_ = enabled ? 1 : 0; }
|
||||
void set_output_32khz_config(bool enabled) { this->output_32khz_ = enabled ? 1 : 0; }
|
||||
void set_aging_offset_config(int8_t offset) {
|
||||
this->aging_offset_ = offset;
|
||||
this->aging_offset_configured_ = true;
|
||||
}
|
||||
void set_auto_clear_alarms(bool auto_clear) { this->auto_clear_alarms_ = auto_clear; }
|
||||
void set_alarm_check_interval(uint32_t interval) { this->alarm_check_interval_ = interval; }
|
||||
void set_monitor_alarms(bool monitor) { this->monitor_alarms_ = monitor; }
|
||||
|
||||
/// Register a void(uint8_t alarm) callback that runs when an enabled alarm (1 or 2) matches.
|
||||
template<typename F> void add_on_alarm_callback(F &&callback) {
|
||||
this->alarm_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
SUB_BINARY_SENSOR(alarm_1)
|
||||
SUB_BINARY_SENSOR(alarm_2)
|
||||
SUB_BINARY_SENSOR(oscillator_stopped)
|
||||
#endif
|
||||
|
||||
// ---- Clock registers (used by the time platform) ----
|
||||
/// Read the calendar registers into @p raw. Fails if the oscillator was stopped since the time was last set.
|
||||
bool read_calendar(uint8_t *raw);
|
||||
/// Write the calendar registers from @p raw and clear the oscillator-stop flag.
|
||||
bool write_calendar(const uint8_t *raw);
|
||||
|
||||
// ---- Temperature ----
|
||||
bool read_temperature(float &out);
|
||||
void force_temperature_conversion();
|
||||
|
||||
// ---- Alarms ----
|
||||
/// Program alarm 1. @p day_of_month is used in MONTHLY mode, @p day_of_week (1 = Sunday) in WEEKLY mode.
|
||||
void set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
|
||||
uint8_t day_of_week, bool enabled);
|
||||
/// Program alarm 2 (minute resolution, fires at second 00).
|
||||
void set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month, uint8_t day_of_week,
|
||||
bool enabled);
|
||||
/// Enable @p alarm. A match from while it was disabled is cleared first, so only the next one fires.
|
||||
void enable_alarm(uint8_t alarm);
|
||||
void disable_alarm(uint8_t alarm) { this->set_alarm_enabled_(alarm, false); }
|
||||
/// Clear the alarm flag, which also releases the INT/SQW line.
|
||||
void clear_alarm(uint8_t alarm);
|
||||
/// True while the alarm flag of @p alarm (1 or 2) is set on the chip.
|
||||
bool is_alarm_triggered(uint8_t alarm);
|
||||
|
||||
// ---- Outputs and calibration ----
|
||||
/// Switch INT/SQW to a square-wave output at @p frequency. Alarms then no longer drive the pin.
|
||||
void enable_square_wave(SquareWaveFrequency frequency);
|
||||
/// Switch INT/SQW back to the (active-low) alarm interrupt output.
|
||||
void disable_square_wave();
|
||||
void set_output_32khz(bool enabled);
|
||||
/// Aging offset in LSB (about 0.1 ppm each at 25 degC); positive values slow the clock down.
|
||||
void set_aging_offset(int8_t offset);
|
||||
|
||||
protected:
|
||||
static constexpr uint8_t CONTROL_A1IE = 0x01;
|
||||
static constexpr uint8_t CONTROL_A2IE = 0x02;
|
||||
static constexpr uint8_t CONTROL_INTCN = 0x04;
|
||||
static constexpr uint8_t CONTROL_RS_MASK = 0x18;
|
||||
static constexpr uint8_t CONTROL_RS_SHIFT = 3;
|
||||
static constexpr uint8_t CONTROL_CONV = 0x20;
|
||||
static constexpr uint8_t CONTROL_BBSQW = 0x40;
|
||||
static constexpr uint8_t CONTROL_EOSC = 0x80;
|
||||
|
||||
static constexpr uint8_t STATUS_A1F = 0x01;
|
||||
static constexpr uint8_t STATUS_A2F = 0x02;
|
||||
static constexpr uint8_t STATUS_BSY = 0x04;
|
||||
static constexpr uint8_t STATUS_EN32KHZ = 0x08;
|
||||
static constexpr uint8_t STATUS_OSF = 0x80;
|
||||
static constexpr uint8_t STATUS_FLAGS = STATUS_A1F | STATUS_A2F | STATUS_OSF;
|
||||
|
||||
/// Scheduler ids: one pulse timer per alarm (id + alarm - 1).
|
||||
static constexpr uint32_t ALARM_PULSE_TIMEOUT_ID = 0;
|
||||
|
||||
static void interrupt_handler(DS3231Component *arg);
|
||||
|
||||
/// Read the control and status registers into the cached copies. Sets the warning flag on failure.
|
||||
bool read_control_status_();
|
||||
/// Replace the bits in @p mask of the control register with @p value, on the chip and in the cache.
|
||||
bool update_control_(uint8_t mask, uint8_t value);
|
||||
/// Write the status register, clearing the flags in @p clear (writing 1 to a flag leaves it unchanged).
|
||||
bool write_status_(uint8_t clear);
|
||||
bool write_aging_offset_(int8_t offset);
|
||||
/// Clear the flag of @p alarm and report it off. Returns false (state unchanged) if that fails.
|
||||
bool clear_alarm_flag_(uint8_t alarm);
|
||||
void set_alarm_enabled_(uint8_t alarm, bool enabled);
|
||||
/// Write the alarm registers, clear the stale flag and set the interrupt enable.
|
||||
void write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled);
|
||||
/// Read the flags, fire triggers and update the binary sensors.
|
||||
void check_alarms_();
|
||||
void publish_alarm_state_(uint8_t alarm, bool state);
|
||||
void publish_oscillator_stopped_();
|
||||
bool write_control_();
|
||||
|
||||
InternalGPIOPin *interrupt_pin_{nullptr};
|
||||
LazyCallbackManager<void(uint8_t)> alarm_callback_;
|
||||
|
||||
uint32_t alarm_check_interval_{1000};
|
||||
uint8_t control_reg_{0};
|
||||
uint8_t status_reg_{0};
|
||||
int8_t aging_offset_{0};
|
||||
SquareWaveFrequency square_wave_frequency_{SQUARE_WAVE_1HZ};
|
||||
/// -1: leave the chip as it is, 0: off, 1: on.
|
||||
int8_t battery_backed_square_wave_{-1};
|
||||
int8_t output_32khz_{-1};
|
||||
bool square_wave_configured_{false};
|
||||
bool aging_offset_configured_{false};
|
||||
bool auto_clear_alarms_{true};
|
||||
bool monitor_alarms_{false};
|
||||
/// Set while an alarm flag has been reported, so a latched flag fires its trigger only once.
|
||||
bool alarm_active_[2]{false, false};
|
||||
};
|
||||
|
||||
} // namespace esphome::ds3231
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import time
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns
|
||||
|
||||
DEPENDENCIES = ["ds3231"]
|
||||
|
||||
DS3231Time = ds3231_ns.class_(
|
||||
"DS3231Time", time.RealTimeClock, cg.Parented.template(DS3231Component)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DS3231Time),
|
||||
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
|
||||
}
|
||||
)
|
||||
|
||||
DS3231_TIME_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
{cv.GenerateID(): cv.use_id(DS3231Time)}
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ds3231.write_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("write_time()")
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"ds3231.read_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("read_time()")
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_DS3231_ID])
|
||||
await time.register_time(var, config)
|
||||
@@ -1,48 +0,0 @@
|
||||
#include "ds3231_time.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ds3231 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ds3231.time");
|
||||
|
||||
void DS3231Time::update() { this->read_time(); }
|
||||
|
||||
void DS3231Time::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "DS3231 Time:");
|
||||
RealTimeClock::dump_config();
|
||||
}
|
||||
|
||||
void DS3231Time::read_time() {
|
||||
uint8_t raw[DS3231Component::CALENDAR_LEN];
|
||||
if (!this->status_comm_result(this->parent_->read_calendar(raw), LOG_STR("No valid time from the RTC")))
|
||||
return;
|
||||
const ESPTime rtc_time = time::decode_bcd_calendar<time::DS_BCD_CALENDAR>(raw);
|
||||
ESP_LOGD(TAG, "Read UTC time: %04u-%02u-%02u %02u:%02u:%02u (day of week %u)", rtc_time.year, rtc_time.month,
|
||||
rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second, rtc_time.day_of_week);
|
||||
// The day of week is a free-running counter on the DS3231 and may have been set with a different
|
||||
// numbering by other software, so it is not used to validate the time.
|
||||
if (!this->status_comm_result(rtc_time.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false),
|
||||
LOG_STR("Invalid RTC time")))
|
||||
return;
|
||||
this->synchronize_epoch_(rtc_time.timestamp);
|
||||
}
|
||||
|
||||
void DS3231Time::write_time() {
|
||||
auto now = this->utcnow();
|
||||
if (!now.is_valid()) {
|
||||
ESP_LOGE(TAG, "Invalid system time, not syncing to RTC.");
|
||||
return;
|
||||
}
|
||||
if (now.year > 2199) {
|
||||
ESP_LOGE(TAG, "Year %u is out of the DS3231 range (2000-2199).", now.year);
|
||||
return;
|
||||
}
|
||||
uint8_t raw[DS3231Component::CALENDAR_LEN];
|
||||
time::encode_bcd_calendar<time::DS_BCD_CALENDAR>(now, raw);
|
||||
if (!this->status_comm_result(this->parent_->write_calendar(raw)))
|
||||
return;
|
||||
ESP_LOGD(TAG, "Wrote UTC time: %04u-%02u-%02u %02u:%02u:%02u", now.year, now.month, now.day_of_month, now.hour,
|
||||
now.minute, now.second);
|
||||
}
|
||||
|
||||
} // namespace esphome::ds3231
|
||||
@@ -1,22 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
|
||||
#include "../ds3231.h"
|
||||
|
||||
namespace esphome::ds3231 {
|
||||
|
||||
class DS3231Time final : public time::RealTimeClock, public Parented<DS3231Component> {
|
||||
public:
|
||||
void update() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// Set the system clock from the RTC.
|
||||
void read_time();
|
||||
/// Store the system clock in the RTC.
|
||||
void write_time();
|
||||
};
|
||||
|
||||
} // namespace esphome::ds3231
|
||||
@@ -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 = 0;
|
||||
size_t len;
|
||||
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, 1, 0):
|
||||
raise cv.Invalid("Only ESP-IDF 5.1+ is supported.")
|
||||
if version < cv.Version(5, 0, 0):
|
||||
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
|
||||
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
|
||||
if version != recommended:
|
||||
@@ -2213,22 +2213,6 @@ def idf_version() -> cv.Version:
|
||||
return CORE.data[KEY_ESP32][KEY_IDF_VERSION]
|
||||
|
||||
|
||||
def require_idf_version(
|
||||
minimum: cv.Version, feature: str = "This feature"
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Validator for an option that needs the underlying ESP-IDF at ``minimum`` or newer."""
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
cv.only_on_esp32(value)
|
||||
if (version := idf_version()) < minimum:
|
||||
raise cv.Invalid(
|
||||
f"{feature} requires ESP-IDF {minimum} or newer, got {version}"
|
||||
)
|
||||
return value
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def require_fatfs() -> None:
|
||||
"""Mark that FATFS support is required by a component.
|
||||
|
||||
@@ -2867,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_ESP_COEX_SW_COEXIST_ENABLE", True)
|
||||
set_idf_sdkconfig_default("CONFIG_SW_COEXIST_ENABLE", True)
|
||||
elif wifi_disabled:
|
||||
set_idf_sdkconfig_default("CONFIG_ESP_COEX_SW_COEXIST_ENABLE", False)
|
||||
set_idf_sdkconfig_default("CONFIG_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:
|
||||
@@ -3047,9 +3031,10 @@ 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, which every supported IDF ships.
|
||||
cg.add_build_flag("-D__FILE__=__FILE_NAME__")
|
||||
cg.add_build_flag("-Wno-builtin-macro-redefined")
|
||||
# __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")
|
||||
# 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,11 +473,17 @@ 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)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", 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)
|
||||
|
||||
# Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client)
|
||||
has_ble_client = (
|
||||
|
||||
@@ -14,10 +14,15 @@
|
||||
#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
|
||||
}
|
||||
#include <eh_host_bt.h>
|
||||
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
|
||||
#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>
|
||||
@@ -241,28 +246,44 @@ bool ESP32BLE::ble_setup_() {
|
||||
}
|
||||
ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
|
||||
|
||||
// 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) {
|
||||
#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) {
|
||||
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;
|
||||
}
|
||||
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));
|
||||
|
||||
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);
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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
|
||||
#endif
|
||||
|
||||
err = esp_bluedroid_init();
|
||||
@@ -419,11 +440,23 @@ bool ESP32BLE::ble_dismantle_() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#else
|
||||
#elif defined(CONFIG_ESP_HOSTED_HOST_FEAT_BT)
|
||||
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,25 +8,33 @@ 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"
|
||||
@@ -64,7 +72,7 @@ _MAX_CUSTOM_MSG_HANDLERS = 8
|
||||
BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True),
|
||||
cv.Optional(CONF_ACTIVE_HIGH): cv.boolean,
|
||||
cv.Required(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,
|
||||
}
|
||||
@@ -75,7 +83,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.Required(CONF_D1_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Optional(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),
|
||||
@@ -87,26 +95,9 @@ 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_D2_PIN, CONF_D3_PIN):
|
||||
for pin in (CONF_D1_PIN, CONF_D2_PIN, CONF_D3_PIN):
|
||||
if pin not in config:
|
||||
raise cv.Invalid(
|
||||
f"{pin} is required when bus_width is 4",
|
||||
@@ -159,26 +150,121 @@ def _validate_spi(config: ConfigType) -> ConfigType:
|
||||
|
||||
CONFIG_SCHEMA = cv.typed_schema(
|
||||
{
|
||||
"sdio": cv.All(SDIO_SCHEMA, _validate_sdio, _validate_active_high),
|
||||
"spi": cv.All(SPI_SCHEMA, _validate_spi, _validate_active_high),
|
||||
"sdio": cv.All(SDIO_SCHEMA, _validate_sdio),
|
||||
"spi": cv.All(SPI_SCHEMA, _validate_spi),
|
||||
},
|
||||
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:
|
||||
if (idf_ver := esp32.idf_version()) < cv.Version(5, 5, 0):
|
||||
# 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):
|
||||
raise cv.Invalid(
|
||||
f"esp32_hosted requires ESP-IDF 5.5 or newer, got {idf_ver}. "
|
||||
f"esp32_hosted requires ESP-IDF 5.3 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.5."
|
||||
"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."
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def _configure_sdio(config: ConfigType) -> None:
|
||||
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:
|
||||
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]}",
|
||||
@@ -197,11 +283,10 @@ def _configure_sdio(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]
|
||||
)
|
||||
@@ -214,7 +299,55 @@ def _configure_sdio(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _configure_spi(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:
|
||||
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]
|
||||
@@ -259,28 +392,59 @@ def _configure_spi(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN]
|
||||
f"CONFIG_ESP_HOSTED_{transport_prefix}_GPIO_RESET_SLAVE", # NOLINT
|
||||
config[CONF_RESET_PIN],
|
||||
)
|
||||
# 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
|
||||
# Slave variant # NOLINT
|
||||
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(config)
|
||||
_configure_sdio_3x(config)
|
||||
else:
|
||||
_configure_spi(config)
|
||||
_configure_spi_3x(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(
|
||||
@@ -291,6 +455,16 @@ async def to_code(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
|
||||
@@ -304,14 +478,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.5 or newer,
|
||||
# Library versions; this component set requires ESP-IDF 5.3 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="3.0.10")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.13")
|
||||
esp32.add_extra_script(
|
||||
"post",
|
||||
"esp32_hosted.py",
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import time
|
||||
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, CONF_URL
|
||||
from esphome.const import CONF_ID, CONF_PATH, CONF_SOURCE, CONF_TYPE
|
||||
from esphome.core import CORE, ID, HexInt
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@swoboda1337"]
|
||||
AUTO_LOAD = ["sha256", "watchdog", "json"]
|
||||
@@ -50,15 +40,11 @@ BASE_SCHEMA = update.update_schema(Esp32HostedUpdate, device_class="firmware").e
|
||||
cv.polling_component_schema("6h")
|
||||
)
|
||||
|
||||
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),
|
||||
EMBEDDED_SCHEMA = BASE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_PATH): cv.file_,
|
||||
cv.Required(CONF_SHA256): _validate_sha256,
|
||||
}
|
||||
)
|
||||
|
||||
HTTP_SCHEMA = BASE_SCHEMA.extend(
|
||||
@@ -84,72 +70,19 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
# The other build's rename finishes within milliseconds; a few short checks cover it
|
||||
CACHE_RACE_RETRIES = 5
|
||||
CACHE_RACE_RETRY_S = 0.05
|
||||
|
||||
|
||||
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 {source}: expected {expected}, got {calculated}"
|
||||
)
|
||||
|
||||
|
||||
def _cached_image_digest(target: Path) -> str | None:
|
||||
"""The SHA256 of the cached image, or None while it is missing or another build still writes it."""
|
||||
try:
|
||||
return hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
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."""
|
||||
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()
|
||||
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 (actual := _cached_image_digest(target)) == expected:
|
||||
return
|
||||
if actual is not None:
|
||||
# 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
|
||||
if calculated != expected:
|
||||
raise cv.Invalid(
|
||||
f"SHA256 mismatch for {config[CONF_PATH]}: expected {expected}, got {calculated}"
|
||||
)
|
||||
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)
|
||||
for attempt in range(CACHE_RACE_RETRIES + 1):
|
||||
try:
|
||||
candidate.replace(target)
|
||||
return
|
||||
except OSError as err:
|
||||
# Windows refuses to replace a file another build holds at that moment; retry, or
|
||||
# take that build's copy: named after its digest, a verified one is the same image
|
||||
if _cached_image_digest(target) == expected:
|
||||
return
|
||||
if attempt == CACHE_RACE_RETRIES:
|
||||
raise cv.Invalid(
|
||||
f"Could not cache the firmware from {url}: {err}"
|
||||
) from err
|
||||
time.sleep(CACHE_RACE_RETRY_S)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_firmware
|
||||
@@ -159,7 +92,9 @@ async def to_code(config: dict[str, Any]) -> None:
|
||||
var = await update.new_update(config)
|
||||
|
||||
if config[CONF_TYPE] == TYPE_EMBEDDED:
|
||||
firmware_data = _firmware_path(config).read_bytes()
|
||||
path = config[CONF_PATH]
|
||||
with CORE.relative_config_path(path).open("rb") as f:
|
||||
firmware_data = f.read()
|
||||
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");
|
||||
|
||||
// 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;
|
||||
// Older coprocessor firmware versions have a 1500-byte limit per RPC call
|
||||
constexpr size_t CHUNK_SIZE = 1500;
|
||||
// OTA begin blocks while the coprocessor erases its partition, end while it validates the image
|
||||
constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000;
|
||||
|
||||
|
||||
@@ -222,13 +222,10 @@ 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
|
||||
#endif
|
||||
{
|
||||
} else {
|
||||
#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,7 +46,6 @@ from esphome.const import (
|
||||
CONF_TYPE,
|
||||
CONF_USE_ADDRESS,
|
||||
CONF_VALUE,
|
||||
CONF_WIFI,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
Platform,
|
||||
@@ -362,7 +361,7 @@ def _validate(config: ConfigType) -> ConfigType:
|
||||
from esphome.components.esp32 import (
|
||||
VARIANT_ESP32S31,
|
||||
get_esp32_variant,
|
||||
require_idf_version,
|
||||
idf_version,
|
||||
)
|
||||
|
||||
eth_type = config[CONF_TYPE]
|
||||
@@ -372,9 +371,10 @@ def _validate(config: ConfigType) -> ConfigType:
|
||||
f"The '{eth_type}' (RGMII) PHY is only supported on gigabit-capable "
|
||||
f"variants (ESP32-S31), not {variant}"
|
||||
)
|
||||
require_idf_version(cv.Version(6, 0, 0), f"The '{eth_type}' (RGMII) PHY")(
|
||||
config
|
||||
)
|
||||
if idf_version() < cv.Version(6, 0, 0):
|
||||
raise cv.Invalid(
|
||||
f"The '{eth_type}' (RGMII) PHY requires ESP-IDF 6.0 or newer."
|
||||
)
|
||||
elif config[CONF_TYPE] != "OPENETH":
|
||||
from esphome.components.esp32 import (
|
||||
VARIANT_ESP32,
|
||||
@@ -562,25 +562,6 @@ 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
|
||||
@@ -865,7 +846,22 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
"""Final validation for Ethernet component."""
|
||||
_final_validate_wifi(config)
|
||||
# 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_spi(config)
|
||||
_final_validate_rmii_pins(config)
|
||||
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
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(
|
||||
button.ApplyButton,
|
||||
FactoryResetButton,
|
||||
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:
|
||||
await button.sub_buttons(config).create(
|
||||
config, None, apply="factory_reset::factory_reset_device()"
|
||||
)
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await button.register_button(var, config)
|
||||
|
||||
@@ -13,20 +13,24 @@ namespace esphome::factory_reset {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "factory_reset.button");
|
||||
|
||||
static void reset_and_reboot() {
|
||||
global_preferences->reset();
|
||||
App.safe_reboot();
|
||||
}
|
||||
|
||||
void factory_reset_device() {
|
||||
void FactoryResetButton::dump_config() { LOG_BUTTON("", "Factory Reset Button", this); }
|
||||
void FactoryResetButton::press_action() {
|
||||
ESP_LOGI(TAG, "Resetting");
|
||||
// Let MQTT settle a bit
|
||||
delay(100); // NOLINT
|
||||
#ifdef USE_OPENTHREAD
|
||||
openthread::global_openthread_component->on_factory_reset(reset_and_reboot);
|
||||
openthread::global_openthread_component->on_factory_reset(FactoryResetButton::factory_reset_callback);
|
||||
#else
|
||||
reset_and_reboot();
|
||||
global_preferences->reset();
|
||||
App.safe_reboot();
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_OPENTHREAD
|
||||
void FactoryResetButton::factory_reset_callback() {
|
||||
global_preferences->reset();
|
||||
App.safe_reboot();
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::factory_reset
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#include "esphome/components/button/button.h"
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::factory_reset {
|
||||
|
||||
/// Run by the factory reset button.
|
||||
void factory_reset_device();
|
||||
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;
|
||||
};
|
||||
|
||||
} // 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> inline ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
|
||||
template<typename T> ESPHOME_ALWAYS_INLINE void atomic_set(std::atomic<T> &atom, T value) {
|
||||
atom.store(value, std::memory_order_release);
|
||||
}
|
||||
template<typename T> inline ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
|
||||
template<typename T> ESPHOME_ALWAYS_INLINE T atomic_get(const std::atomic<T> &atom) {
|
||||
return atom.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
|
||||
@@ -203,8 +203,12 @@ 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
|
||||
this->state_->clear_called();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
this->schedule_apply_();
|
||||
}
|
||||
@@ -253,9 +257,8 @@ 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, 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()) {
|
||||
// place of the values it ends at; a trait or a repeated report has nothing newer to say
|
||||
if (this->report_in_transition_) {
|
||||
this->report_held_ = true;
|
||||
return;
|
||||
}
|
||||
@@ -265,9 +268,20 @@ 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
|
||||
@@ -277,10 +291,11 @@ void HomeassistantLight::update_state(LightState *state) {
|
||||
}
|
||||
this->initial_seen_ = true;
|
||||
}
|
||||
// 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()) {
|
||||
// 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()) {
|
||||
this->report_held_ = false;
|
||||
this->report_in_transition_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +338,11 @@ 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 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;
|
||||
}
|
||||
this->transitioned_ = true;
|
||||
return;
|
||||
}
|
||||
@@ -331,6 +350,7 @@ 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,6 +48,8 @@ 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_();
|
||||
@@ -61,12 +63,14 @@ 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 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
|
||||
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_static(config[CONF_SOURCE]))
|
||||
cg.add(var.set_source_url(config[CONF_SOURCE]))
|
||||
|
||||
ota.request_ota_state_listeners()
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ 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;
|
||||
@@ -62,37 +68,6 @@ 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");
|
||||
@@ -100,37 +75,28 @@ void HttpRequestUpdate::update() {
|
||||
}
|
||||
this->cancel_interval(INITIAL_CHECK_INTERVAL_ID);
|
||||
#ifdef USE_ESP32
|
||||
if (this->task_result_ != nullptr) {
|
||||
if (this->update_task_handle_ != nullptr) {
|
||||
ESP_LOGW(TAG, "Update check already in progress");
|
||||
return;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_);
|
||||
#else
|
||||
this->update_task(result);
|
||||
this->end_check_(result);
|
||||
this->update_task(this);
|
||||
#endif
|
||||
}
|
||||
|
||||
void HttpRequestUpdate::update_task(void *params) {
|
||||
auto *result = static_cast<TaskResult *>(params);
|
||||
HttpRequestUpdate *this_update = result->update;
|
||||
auto *info = &result->info;
|
||||
const char *source_url = result->source_url;
|
||||
HttpRequestUpdate *this_update = (HttpRequestUpdate *) params;
|
||||
|
||||
auto container = this_update->request_parent_->get(source_url);
|
||||
// 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 *info = &result->info;
|
||||
|
||||
auto container = this_update->request_parent_->get(this_update->source_url_);
|
||||
|
||||
if (container == nullptr || container->status_code != HTTP_STATUS_OK) {
|
||||
ESP_LOGE(TAG, "Failed to fetch manifest from %s", source_url);
|
||||
ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_);
|
||||
if (container != nullptr)
|
||||
container->end();
|
||||
result->error_str = LOG_STR("Failed to fetch manifest");
|
||||
@@ -210,14 +176,14 @@ void HttpRequestUpdate::update_task(void *params) {
|
||||
allocator.deallocate(data, content_length);
|
||||
|
||||
if (!valid) {
|
||||
ESP_LOGE(TAG, "Failed to parse JSON from %s", source_url);
|
||||
ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->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 = source_url;
|
||||
const char *source = this_update->source_url_;
|
||||
const size_t source_len = strlen(source);
|
||||
size_t prefix_len;
|
||||
if (info->firmware_url[0] == '/') {
|
||||
@@ -249,8 +215,7 @@ 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->task_result_ = nullptr;
|
||||
this_update->end_check_(result);
|
||||
this_update->update_task_handle_ = nullptr;
|
||||
#endif
|
||||
if (result->error_str != nullptr) {
|
||||
this_update->status_set_error(result->error_str);
|
||||
|
||||
@@ -13,21 +13,6 @@
|
||||
|
||||
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;
|
||||
@@ -36,18 +21,7 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
|
||||
void perform(bool force) override;
|
||||
void check() override { this->update(); }
|
||||
|
||||
~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_source_url(const char *source_url) { this->source_url_ = 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; }
|
||||
@@ -62,21 +36,12 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo
|
||||
|
||||
static void update_task(void *params);
|
||||
#ifdef USE_ESP32
|
||||
TaskResult *task_result_{nullptr}; // set while a check runs on the update task
|
||||
TaskHandle_t update_task_handle_{nullptr};
|
||||
#endif
|
||||
uint8_t initial_check_remaining_{0};
|
||||
|
||||
private:
|
||||
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};
|
||||
const char *source_url_{nullptr}; // literal from codegen
|
||||
};
|
||||
|
||||
} // namespace esphome::http_request
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DOMAIN = "islandaire"
|
||||
@@ -1,27 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir
|
||||
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_SENSOR
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["climate_ir"]
|
||||
CODEOWNERS = ["@ipopov"]
|
||||
|
||||
islandaire_ns = cg.esphome_ns.namespace("islandaire")
|
||||
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(IslandaireClimate).extend(
|
||||
{
|
||||
# The unit has no auto (heat/cool) mode
|
||||
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
|
||||
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
|
||||
cv.Optional(CONF_SENSOR): cv.invalid(
|
||||
"'sensor' is not supported by the Islandaire climate component"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
await climate_ir.new_climate_ir(config)
|
||||
@@ -1,199 +0,0 @@
|
||||
#include "islandaire.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::islandaire {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "islandaire.climate");
|
||||
|
||||
// 14-byte frame, LSB first:
|
||||
// Bytes 0-4: 0x22 preamble
|
||||
// Byte 5: power (0x20 off, 0x24 on); bit 3 (0x08) set while the auto-off timer is active
|
||||
// Byte 6: mode (0x01 heat, 0x03 cool, 0x04 fan only)
|
||||
// Byte 7: target temperature in °F (61-88)
|
||||
// Byte 8: fan speed (0x00 auto, 0x02 low, 0x04 high, 0x01 read as low too); bit 6 (0x40) set while the
|
||||
// timer is active
|
||||
// Byte 9: 0x00
|
||||
// Byte 10: auto-off timer in hours (1-12, 0 = off)
|
||||
// Bytes 11-12: 0x00
|
||||
// Byte 13: sum of bytes 0-12
|
||||
// The auto-off timer is not implemented: the unit switches itself off with no feedback, which
|
||||
// would leave the climate state stale.
|
||||
static constexpr uint8_t STATE_LENGTH = 14;
|
||||
static constexpr uint8_t PREAMBLE_LENGTH = 5;
|
||||
static constexpr uint8_t PREAMBLE = 0x22;
|
||||
|
||||
static constexpr uint8_t POWER_OFF = 0x20;
|
||||
static constexpr uint8_t POWER_MASK = 0x04;
|
||||
static constexpr uint8_t POWER_ON = POWER_OFF | POWER_MASK;
|
||||
|
||||
static constexpr uint8_t MODE_HEAT = 0x01;
|
||||
static constexpr uint8_t MODE_COOL = 0x03;
|
||||
static constexpr uint8_t MODE_FAN = 0x04;
|
||||
|
||||
static constexpr uint8_t FAN_AUTO = 0x00;
|
||||
static constexpr uint8_t FAN_LOW_ALT = 0x01;
|
||||
static constexpr uint8_t FAN_LOW = 0x02;
|
||||
static constexpr uint8_t FAN_HIGH = 0x04;
|
||||
static constexpr uint8_t FAN_TIMER_FLAG = 0x40;
|
||||
|
||||
static constexpr uint8_t TEMP_DEFAULT = 72;
|
||||
|
||||
static constexpr uint16_t HEADER_MARK = 3608;
|
||||
static constexpr uint16_t HEADER_SPACE = 1520;
|
||||
static constexpr uint16_t BIT_MARK = 560;
|
||||
static constexpr uint16_t ONE_SPACE = 1208;
|
||||
static constexpr uint16_t ZERO_SPACE = 408;
|
||||
static constexpr uint32_t CARRIER_FREQUENCY = 38000;
|
||||
static constexpr remote_base::BitTimings BIT_TIMINGS{
|
||||
.bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero_space = ZERO_SPACE};
|
||||
|
||||
void IslandaireClimate::setup() {
|
||||
ClimateIR::setup();
|
||||
// The base class default assumes Celsius
|
||||
if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN)
|
||||
this->target_temperature = TEMP_DEFAULT;
|
||||
this->target_temperature = std::min<float>(this->target_temperature, TEMP_MAX);
|
||||
}
|
||||
|
||||
void IslandaireClimate::dump_config() {
|
||||
// ClimateIR::dump_config() is not called: it prints the temperature range in Celsius
|
||||
LOG_CLIMATE("", "Islandaire Climate", this);
|
||||
ESP_LOGCONFIG(TAG, " Temperature range: %u to %u °F", TEMP_MIN, TEMP_MAX);
|
||||
}
|
||||
|
||||
climate::ClimateTraits IslandaireClimate::traits() {
|
||||
auto traits = ClimateIR::traits();
|
||||
traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT);
|
||||
return traits;
|
||||
}
|
||||
|
||||
void IslandaireClimate::transmit_state() {
|
||||
uint8_t remote_state[STATE_LENGTH] = {0};
|
||||
|
||||
std::fill_n(remote_state, PREAMBLE_LENGTH, PREAMBLE);
|
||||
|
||||
remote_state[5] = this->mode == climate::CLIMATE_MODE_OFF ? POWER_OFF : POWER_ON;
|
||||
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_HEAT:
|
||||
remote_state[6] = MODE_HEAT;
|
||||
break;
|
||||
case climate::CLIMATE_MODE_FAN_ONLY:
|
||||
remote_state[6] = MODE_FAN;
|
||||
break;
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
default:
|
||||
remote_state[6] = MODE_COOL;
|
||||
break;
|
||||
}
|
||||
|
||||
// Whole degrees within the unit's range, stored as well so the published state matches what was sent
|
||||
const auto temperature =
|
||||
static_cast<uint8_t>(std::clamp<float>(std::round(this->target_temperature), TEMP_MIN, TEMP_MAX));
|
||||
this->target_temperature = temperature;
|
||||
remote_state[7] = temperature;
|
||||
|
||||
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) {
|
||||
case climate::CLIMATE_FAN_LOW:
|
||||
remote_state[8] = FAN_LOW;
|
||||
break;
|
||||
case climate::CLIMATE_FAN_HIGH:
|
||||
remote_state[8] = FAN_HIGH;
|
||||
break;
|
||||
case climate::CLIMATE_FAN_AUTO:
|
||||
default:
|
||||
remote_state[8] = FAN_AUTO;
|
||||
break;
|
||||
}
|
||||
|
||||
remote_state[STATE_LENGTH - 1] = sum_bytes(remote_state, STATE_LENGTH - 1);
|
||||
|
||||
ESP_LOGV_HEX(TAG, remote_state, STATE_LENGTH, STATE_LENGTH, "Sending: %s");
|
||||
|
||||
auto transmit = this->transmitter_->transmit();
|
||||
auto *data = transmit.get_data();
|
||||
|
||||
data->set_carrier_frequency(CARRIER_FREQUENCY);
|
||||
|
||||
data->reserve(2 + STATE_LENGTH * 16 + 1);
|
||||
data->item(HEADER_MARK, HEADER_SPACE);
|
||||
data->item_bytes<BIT_TIMINGS>(remote_state, STATE_LENGTH);
|
||||
data->mark(BIT_MARK);
|
||||
|
||||
transmit.perform();
|
||||
}
|
||||
|
||||
bool IslandaireClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||
if (!data.expect_item(HEADER_MARK, HEADER_SPACE)) {
|
||||
ESP_LOGVV(TAG, "Header fail");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t remote_state[STATE_LENGTH];
|
||||
if (!data.expect_bytes<BIT_TIMINGS>(remote_state, STATE_LENGTH)) {
|
||||
ESP_LOGVV(TAG, "Data fail");
|
||||
return false;
|
||||
}
|
||||
if (!data.expect_mark(BIT_MARK)) {
|
||||
ESP_LOGVV(TAG, "Footer fail");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sum_bytes(remote_state, STATE_LENGTH - 1) != remote_state[STATE_LENGTH - 1]) {
|
||||
ESP_LOGVV(TAG, "Checksum fail");
|
||||
return false;
|
||||
}
|
||||
|
||||
for (uint8_t i = 0; i < PREAMBLE_LENGTH; i++) {
|
||||
if (remote_state[i] != PREAMBLE)
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGV_HEX(TAG, remote_state, STATE_LENGTH, STATE_LENGTH, "Received: %s");
|
||||
|
||||
if ((remote_state[5] & POWER_MASK) == 0) {
|
||||
this->mode = climate::CLIMATE_MODE_OFF;
|
||||
} else {
|
||||
switch (remote_state[6]) {
|
||||
case MODE_HEAT:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT;
|
||||
break;
|
||||
case MODE_COOL:
|
||||
this->mode = climate::CLIMATE_MODE_COOL;
|
||||
break;
|
||||
case MODE_FAN:
|
||||
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
|
||||
break;
|
||||
default:
|
||||
ESP_LOGV(TAG, "Unknown mode 0x%02X", remote_state[6]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const uint8_t temp_f = remote_state[7];
|
||||
if (temp_f >= TEMP_MIN && temp_f <= TEMP_MAX)
|
||||
this->target_temperature = temp_f;
|
||||
|
||||
switch (remote_state[8] & ~FAN_TIMER_FLAG) {
|
||||
case FAN_HIGH:
|
||||
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||
break;
|
||||
case FAN_LOW:
|
||||
case FAN_LOW_ALT:
|
||||
this->fan_mode = climate::CLIMATE_FAN_LOW;
|
||||
break;
|
||||
case FAN_AUTO:
|
||||
default:
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
break;
|
||||
}
|
||||
|
||||
this->publish_state();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::islandaire
|
||||
@@ -1,25 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/climate_ir/climate_ir.h"
|
||||
|
||||
namespace esphome::islandaire {
|
||||
|
||||
class IslandaireClimate : public climate_ir::ClimateIR {
|
||||
public:
|
||||
// The unit works in whole degrees Fahrenheit (see traits())
|
||||
static constexpr uint8_t TEMP_MIN = 61;
|
||||
static constexpr uint8_t TEMP_MAX = 88;
|
||||
|
||||
IslandaireClimate()
|
||||
: ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, false, true,
|
||||
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_HIGH}, {}) {}
|
||||
|
||||
protected:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
climate::ClimateTraits traits() override;
|
||||
void transmit_state() override;
|
||||
bool on_receive(remote_base::RemoteReceiveData data) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::islandaire
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
@@ -19,23 +20,6 @@ namespace esphome::ld2450 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ld2450");
|
||||
|
||||
enum BaudRate : uint8_t {
|
||||
BAUD_RATE_9600 = 1,
|
||||
BAUD_RATE_19200 = 2,
|
||||
BAUD_RATE_38400 = 3,
|
||||
BAUD_RATE_57600 = 4,
|
||||
BAUD_RATE_115200 = 5,
|
||||
BAUD_RATE_230400 = 6,
|
||||
BAUD_RATE_256000 = 7,
|
||||
BAUD_RATE_460800 = 8
|
||||
};
|
||||
|
||||
enum ZoneType : uint8_t {
|
||||
ZONE_DISABLED = 0,
|
||||
ZONE_DETECTION = 1,
|
||||
ZONE_FILTER = 2,
|
||||
};
|
||||
|
||||
enum PeriodicData : uint8_t {
|
||||
TARGET_X = 4,
|
||||
TARGET_Y = 6,
|
||||
@@ -54,61 +38,20 @@ enum AckData : uint8_t {
|
||||
COMMAND_STATUS = 7,
|
||||
};
|
||||
|
||||
// Memory-efficient lookup tables
|
||||
struct StringToUint8 {
|
||||
const char *str;
|
||||
const uint8_t value;
|
||||
};
|
||||
// Index + 1 is the BaudRate value; the select options use the same order
|
||||
PROGMEM_STRING_TABLE(BaudRateStrings, "9600", "19200", "38400", "57600", "115200", "230400", "256000", "460800");
|
||||
static_assert(BAUD_RATE_9600 == 1 && BAUD_RATE_460800 == BaudRateStrings::COUNT, "BaudRate is index + 1");
|
||||
// Index is the Direction value
|
||||
PROGMEM_STRING_TABLE(DirectionStrings, "Approaching", "Moving away", "Stationary", "NA");
|
||||
static_assert(DIRECTION_APPROACHING == 0 && DIRECTION_MOVING_AWAY == 1 && DIRECTION_STATIONARY == 2 &&
|
||||
DIRECTION_NA == 3,
|
||||
"Direction is the table index");
|
||||
// Index is the ZoneType value; the select options use the same order
|
||||
PROGMEM_STRING_TABLE(ZoneTypeStrings, "Disabled", "Detection", "Filter");
|
||||
static_assert(ZONE_DISABLED == 0 && ZONE_DETECTION == 1 && ZONE_FILTER == 2, "ZoneType is the table index");
|
||||
|
||||
struct Uint8ToString {
|
||||
const uint8_t value;
|
||||
const char *str;
|
||||
};
|
||||
|
||||
constexpr StringToUint8 BAUD_RATES_BY_STR[] = {
|
||||
{"9600", BAUD_RATE_9600}, {"19200", BAUD_RATE_19200}, {"38400", BAUD_RATE_38400},
|
||||
{"57600", BAUD_RATE_57600}, {"115200", BAUD_RATE_115200}, {"230400", BAUD_RATE_230400},
|
||||
{"256000", BAUD_RATE_256000}, {"460800", BAUD_RATE_460800},
|
||||
};
|
||||
|
||||
constexpr Uint8ToString DIRECTION_BY_UINT[] = {
|
||||
{DIRECTION_APPROACHING, "Approaching"},
|
||||
{DIRECTION_MOVING_AWAY, "Moving away"},
|
||||
{DIRECTION_STATIONARY, "Stationary"},
|
||||
{DIRECTION_NA, "NA"},
|
||||
};
|
||||
|
||||
constexpr Uint8ToString ZONE_TYPE_BY_UINT[] = {
|
||||
{ZONE_DISABLED, "Disabled"},
|
||||
{ZONE_DETECTION, "Detection"},
|
||||
{ZONE_FILTER, "Filter"},
|
||||
};
|
||||
|
||||
constexpr StringToUint8 ZONE_TYPE_BY_STR[] = {
|
||||
{"Disabled", ZONE_DISABLED},
|
||||
{"Detection", ZONE_DETECTION},
|
||||
{"Filter", ZONE_FILTER},
|
||||
};
|
||||
|
||||
// Baud rates in the same order as BAUD_RATES_BY_STR for index-based lookup
|
||||
constexpr uint32_t BAUD_RATES[] = {9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800};
|
||||
|
||||
// Helper functions for lookups
|
||||
template<size_t N> uint8_t find_uint8(const StringToUint8 (&arr)[N], const std::string &str) {
|
||||
for (const auto &entry : arr) {
|
||||
if (str == entry.str)
|
||||
return entry.value;
|
||||
}
|
||||
return 0xFF; // Not found
|
||||
}
|
||||
|
||||
template<size_t N> const char *find_str(const Uint8ToString (&arr)[N], uint8_t value) {
|
||||
for (const auto &entry : arr) {
|
||||
if (value == entry.value)
|
||||
return entry.str;
|
||||
}
|
||||
return ""; // Not found
|
||||
}
|
||||
// Baud rates in the same order as BaudRateStrings for index-based lookup
|
||||
static constexpr uint32_t BAUD_RATES[] PROGMEM = {9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800};
|
||||
|
||||
// LD2450 UART Serial Commands
|
||||
static constexpr uint8_t CMD_ENABLE_CONF = 0xFF;
|
||||
@@ -546,7 +489,12 @@ void LD2450Component::handle_periodic_data_() {
|
||||
if (this->direction_dedup_[index].next(direction)) {
|
||||
text_sensor::TextSensor *tsd = this->direction_text_sensors_[index];
|
||||
if (tsd != nullptr) {
|
||||
tsd->publish_state(find_str(ld2450::DIRECTION_BY_UINT, direction));
|
||||
char direction_str[DirectionStrings::MAX_LENGTH + 1];
|
||||
ESPHOME_strncpy_P(
|
||||
direction_str,
|
||||
reinterpret_cast<ESPHOME_PGM_P>(DirectionStrings::get_progmem_str(direction, DirectionStrings::LAST_INDEX)),
|
||||
sizeof(direction_str));
|
||||
tsd->publish_state(direction_str);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -864,8 +812,15 @@ void LD2450Component::set_bluetooth(bool enable) {
|
||||
|
||||
// Set Baud rate
|
||||
void LD2450Component::set_baud_rate(const char *state) {
|
||||
const int16_t index = progmem_string_table_find<BaudRateStrings>(state, strlen(state));
|
||||
if (index >= 0) {
|
||||
this->set_baud_rate(static_cast<BaudRate>(index + 1));
|
||||
}
|
||||
}
|
||||
|
||||
void LD2450Component::set_baud_rate(BaudRate rate) {
|
||||
this->set_config_mode_(true);
|
||||
const uint8_t cmd_value[2] = {find_uint8(BAUD_RATES_BY_STR, state), 0x00};
|
||||
const uint8_t cmd_value[2] = {rate, 0x00};
|
||||
this->send_command_(CMD_SET_BAUD_RATE, cmd_value, sizeof(cmd_value));
|
||||
this->set_timeout(200, [this]() { this->restart_(); });
|
||||
}
|
||||
@@ -873,8 +828,14 @@ void LD2450Component::set_baud_rate(const char *state) {
|
||||
// Set Zone Type - one of: Disabled, Detection, Filter
|
||||
void LD2450Component::set_zone_type(const char *state) {
|
||||
ESP_LOGV(TAG, "Set zone type: %s", state);
|
||||
uint8_t zone_type = find_uint8(ZONE_TYPE_BY_STR, state);
|
||||
this->zone_type_ = zone_type;
|
||||
const int16_t index = progmem_string_table_find<ZoneTypeStrings>(state, strlen(state));
|
||||
if (index >= 0) {
|
||||
this->set_zone_type(static_cast<ZoneType>(index));
|
||||
}
|
||||
}
|
||||
|
||||
void LD2450Component::set_zone_type(ZoneType type) {
|
||||
this->zone_type_ = type;
|
||||
this->send_set_zone_command_();
|
||||
}
|
||||
|
||||
@@ -882,7 +843,7 @@ void LD2450Component::set_zone_type(const char *state) {
|
||||
void LD2450Component::publish_zone_type() {
|
||||
#ifdef USE_SELECT
|
||||
if (this->zone_type_select_ != nullptr) {
|
||||
this->zone_type_select_->publish_state(find_str(ZONE_TYPE_BY_UINT, this->zone_type_));
|
||||
this->zone_type_select_->publish_state(static_cast<size_t>(this->zone_type_));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -55,6 +55,23 @@ enum Direction : uint8_t {
|
||||
DIRECTION_UNDEFINED = 4,
|
||||
};
|
||||
|
||||
enum BaudRate : uint8_t {
|
||||
BAUD_RATE_9600 = 1,
|
||||
BAUD_RATE_19200 = 2,
|
||||
BAUD_RATE_38400 = 3,
|
||||
BAUD_RATE_57600 = 4,
|
||||
BAUD_RATE_115200 = 5,
|
||||
BAUD_RATE_230400 = 6,
|
||||
BAUD_RATE_256000 = 7,
|
||||
BAUD_RATE_460800 = 8
|
||||
};
|
||||
|
||||
enum ZoneType : uint8_t {
|
||||
ZONE_DISABLED = 0,
|
||||
ZONE_DETECTION = 1,
|
||||
ZONE_FILTER = 2,
|
||||
};
|
||||
|
||||
// Target coordinate struct
|
||||
struct Target {
|
||||
int16_t x;
|
||||
@@ -121,7 +138,9 @@ class LD2450Component : public Component, public uart::UARTDevice {
|
||||
void set_bluetooth(bool enable);
|
||||
void set_multi_target(bool enable);
|
||||
void set_baud_rate(const char *state);
|
||||
void set_baud_rate(BaudRate rate);
|
||||
void set_zone_type(const char *state);
|
||||
void set_zone_type(ZoneType type);
|
||||
void publish_zone_type();
|
||||
void factory_reset();
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
|
||||
@@ -35,6 +35,7 @@ CONFIG_SCHEMA = {
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_LD2450_ID])
|
||||
selects = select.sub_selects(config, parent=hub)
|
||||
# Option lists follow the order of the C++ enums; the hub maps by index
|
||||
await selects(
|
||||
CONF_BAUD_RATE,
|
||||
hub.set_baud_rate_select,
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace esphome::ld2450 {
|
||||
|
||||
void BaudRateSelect::control(size_t index) {
|
||||
this->publish_state(index);
|
||||
this->parent_->set_baud_rate(this->option_at(index));
|
||||
this->parent_->set_baud_rate(static_cast<BaudRate>(index + 1));
|
||||
}
|
||||
|
||||
} // namespace esphome::ld2450
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace esphome::ld2450 {
|
||||
|
||||
void ZoneTypeSelect::control(size_t index) {
|
||||
this->publish_state(index);
|
||||
this->parent_->set_zone_type(this->option_at(index));
|
||||
this->parent_->set_zone_type(static_cast<ZoneType>(index));
|
||||
}
|
||||
|
||||
} // namespace esphome::ld2450
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <memory>
|
||||
@@ -24,10 +25,17 @@
|
||||
|
||||
namespace esphome::ld24xx {
|
||||
|
||||
// Helper to find index of value in constexpr array
|
||||
template<size_t N> optional<size_t> find_index(const uint32_t (&arr)[N], uint32_t value) {
|
||||
// Index of value in a constant table that may live in flash, or none
|
||||
template<typename T, size_t N> optional<size_t> find_index(const T (&arr)[N], T value) {
|
||||
static_assert(sizeof(T) == 1 || sizeof(T) == 4, "flash tables are read as bytes or aligned 32 bit words");
|
||||
for (size_t i = 0; i < N; i++) {
|
||||
if (arr[i] == value)
|
||||
T entry;
|
||||
if constexpr (sizeof(T) == 1) {
|
||||
entry = progmem_read_byte(reinterpret_cast<const uint8_t *>(&arr[i]));
|
||||
} else {
|
||||
entry = arr[i]; // an aligned 32 bit read from flash is safe on ESP8266
|
||||
}
|
||||
if (entry == value)
|
||||
return i;
|
||||
}
|
||||
return {};
|
||||
|
||||
@@ -285,8 +285,12 @@ void LD6002BComponent::setup() {
|
||||
#ifdef USE_SWITCH
|
||||
if (this->target_display_switch_ != nullptr) {
|
||||
target_display_controlled = true;
|
||||
// Nothing reports this switch back, so its restored state is the only state there is.
|
||||
this->target_display_switch_->restore(true);
|
||||
// Nothing reports this switch back, so its restored state is the only state
|
||||
// there is. Restoring through the switch keeps its inversion in the path:
|
||||
// the restored value is logical, and driving the switch is what turns it
|
||||
// into the raw command, the published state and the stream flag.
|
||||
const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true);
|
||||
this->target_display_switch_->control(state);
|
||||
}
|
||||
#endif
|
||||
if (!target_display_controlled) {
|
||||
@@ -303,7 +307,10 @@ void LD6002BComponent::setup() {
|
||||
#ifdef USE_SWITCH
|
||||
if (this->point_cloud_switch_ != nullptr) {
|
||||
point_cloud_controlled = true;
|
||||
this->point_cloud_switch_->restore(false);
|
||||
// The switch owns the stream, so it is also what applies the restored state:
|
||||
// driving it rather than the module keeps the entity's inversion in the path.
|
||||
const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false);
|
||||
this->point_cloud_switch_->control(state);
|
||||
}
|
||||
#endif
|
||||
if (!point_cloud_controlled) {
|
||||
@@ -342,9 +349,11 @@ void LD6002BComponent::setup() {
|
||||
#ifdef USE_SWITCH
|
||||
bool want_low_power = this->low_power_switch_ != nullptr;
|
||||
if (want_low_power) {
|
||||
// The query below confirms what the module took; until it lands, the work mode
|
||||
// fallback runs on the restored value.
|
||||
this->low_power_switch_->restore(false);
|
||||
// The module reports this one back, so the query below confirms what it took.
|
||||
// Driving the switch applies its inversion; it also marks the restored value
|
||||
// as reported, so the work mode fallback runs on that until the query lands.
|
||||
const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false);
|
||||
this->low_power_switch_->control(state);
|
||||
}
|
||||
#else
|
||||
bool want_low_power = false;
|
||||
|
||||
@@ -169,9 +169,7 @@ 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) || \
|
||||
(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)
|
||||
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
PERIPH_RCC_ATOMIC() {
|
||||
ledc_ll_enable_reset_reg(true);
|
||||
ledc_ll_enable_reset_reg(false);
|
||||
|
||||
@@ -13,7 +13,6 @@ from esphome.const import (
|
||||
CONF_COLD_WHITE_COLOR_TEMPERATURE,
|
||||
CONF_COLOR_CORRECT,
|
||||
CONF_DEFAULT_TRANSITION_LENGTH,
|
||||
CONF_EFFECT,
|
||||
CONF_EFFECTS,
|
||||
CONF_ENTITY_CATEGORY,
|
||||
CONF_FLASH_TRANSITION_LENGTH,
|
||||
@@ -32,21 +31,12 @@ from esphome.const import (
|
||||
CONF_RESTORE_MODE,
|
||||
CONF_RESTORE_STATE,
|
||||
CONF_RGB_ORDER,
|
||||
CONF_STATE,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_WARM_WHITE_COLOR_TEMPERATURE,
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
ID,
|
||||
CoroPriority,
|
||||
EsphomeError,
|
||||
HexInt,
|
||||
coroutine_with_priority,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
RESTORE_STATE_NONE,
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
@@ -66,6 +56,7 @@ from .effects import (
|
||||
)
|
||||
from .restore_state import (
|
||||
LEGACY_RESTORE_MODES,
|
||||
RESTORE_STATE_NONE,
|
||||
RESTORE_STATE_SCHEMA,
|
||||
_build_state_lambda,
|
||||
_initial_state_overridden_by_legacy_mode,
|
||||
@@ -200,11 +191,9 @@ def _get_or_create_gamma_table(gamma_correct: float, table_id: ID) -> cg.RawExpr
|
||||
def find_effect_index(effects: list, effect_name: str) -> int | None:
|
||||
"""Find the 1-based index of an effect by name (case-insensitive).
|
||||
|
||||
Returns 0 for "None" (no effect), the 1-based index if found, or None if not found.
|
||||
Returns the 1-based index if found, or None if not found.
|
||||
"""
|
||||
effect_name_lower = effect_name.lower()
|
||||
if effect_name_lower == "none":
|
||||
return 0
|
||||
for i, effect_conf in enumerate(effects):
|
||||
key = next(iter(effect_conf))
|
||||
if effect_conf[key][CONF_NAME].lower() == effect_name_lower:
|
||||
@@ -220,14 +209,6 @@ def available_effects_str(effects: list) -> str:
|
||||
return ", ".join(f"'{name}'" for name in available) if available else "none"
|
||||
|
||||
|
||||
def effect_not_found_message(effect_name: str, effects: list, light: str) -> str:
|
||||
"""Return the error for an effect name that `light` (e.g. "this light") lacks."""
|
||||
return (
|
||||
f"Effect '{effect_name}' not found for {light}. "
|
||||
f"Available effects: {available_effects_str(effects)}"
|
||||
)
|
||||
|
||||
|
||||
# Accepted values of the deprecated `rgb_order` key.
|
||||
RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG")
|
||||
|
||||
@@ -335,12 +316,9 @@ def _final_validate(config: ConfigType) -> None:
|
||||
platform entries across the file, not a single light's own config.
|
||||
"""
|
||||
for light_config in config:
|
||||
initial_state = light_config.get(CONF_INITIAL_STATE) or {}
|
||||
cold_boot_state = initial_state.get(CONF_STATE, False)
|
||||
restore_mode = light_config.get(CONF_RESTORE_MODE)
|
||||
if restore_mode is not None:
|
||||
legacy = LEGACY_RESTORE_MODES[restore_mode]
|
||||
cold_boot_state = legacy.cold_boot_state
|
||||
if _initial_state_overridden_by_legacy_mode(
|
||||
legacy, light_config.get(CONF_INITIAL_STATE)
|
||||
):
|
||||
@@ -352,19 +330,6 @@ def _final_validate(config: ConfigType) -> None:
|
||||
restore_mode,
|
||||
"ON" if legacy.cold_boot_state else "OFF",
|
||||
)
|
||||
effect_name = initial_state.get(CONF_EFFECT, "none")
|
||||
if (
|
||||
effect_name.lower() != "none"
|
||||
and not cold_boot_state
|
||||
and not light_config.get(CONF_RESUME_EFFECT)
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"[%s] 'initial_state: effect: %s' does not start at boot because the "
|
||||
"light boots OFF; add 'resume_effect: true' to start it at the first "
|
||||
"turn-on",
|
||||
light_config.get(CONF_NAME) or light_config[CONF_ID],
|
||||
effect_name,
|
||||
)
|
||||
|
||||
data = _get_data()
|
||||
if not data.effect_refs and not data.effect_cycle_refs:
|
||||
@@ -390,9 +355,9 @@ def _final_validate(config: ConfigType) -> None:
|
||||
|
||||
if find_effect_index(effects, ref.effect_name) is None:
|
||||
raise cv.FinalExternalInvalid(
|
||||
effect_not_found_message(
|
||||
ref.effect_name, effects, f"light '{ref.light_id}'"
|
||||
),
|
||||
f"Effect '{ref.effect_name}' not found for light "
|
||||
f"'{ref.light_id}'. "
|
||||
f"Available effects: {available_effects_str(effects)}",
|
||||
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
|
||||
)
|
||||
|
||||
@@ -420,13 +385,6 @@ FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
DEFAULT_FLASH_TRANSITION_LENGTH = "0s"
|
||||
CONF_TRANSITION_STATE_PUBLISH_INTERVAL = "transition_state_publish_interval"
|
||||
|
||||
# `effect` stays out of LIGHT_STATE_SCHEMA: light.control defines it templatable and transformer-exclusive
|
||||
INITIAL_STATE_SCHEMA = LIGHT_STATE_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(CONF_EFFECT): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
LIGHT_SCHEMA = (
|
||||
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
|
||||
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
|
||||
@@ -456,7 +414,7 @@ LIGHT_SCHEMA = (
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LightStateTrigger),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_INITIAL_STATE): INITIAL_STATE_SCHEMA,
|
||||
cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA,
|
||||
cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
@@ -464,35 +422,6 @@ LIGHT_SCHEMA = (
|
||||
|
||||
LIGHT_SCHEMA.add_extra(entity_duplicate_validator("light"))
|
||||
|
||||
|
||||
def _validate_initial_state_effect(config: ConfigType) -> ConfigType:
|
||||
"""Check that `initial_state: effect` names an effect of this light."""
|
||||
initial_state = config.get(CONF_INITIAL_STATE) or {}
|
||||
if (effect_name := initial_state.get(CONF_EFFECT)) is not None:
|
||||
effects = config.get(CONF_EFFECTS, [])
|
||||
if find_effect_index(effects, effect_name) is None:
|
||||
raise cv.Invalid(
|
||||
effect_not_found_message(effect_name, effects, "this light"),
|
||||
path=[CONF_INITIAL_STATE, CONF_EFFECT],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
LIGHT_SCHEMA.add_extra(_validate_initial_state_effect)
|
||||
|
||||
|
||||
def _initial_effect_index(config: ConfigType) -> int:
|
||||
"""Resolve `initial_state: effect` to its effect index, 0 when not set."""
|
||||
effect_name = (config.get(CONF_INITIAL_STATE) or {}).get(CONF_EFFECT, "none")
|
||||
effects = config.get(CONF_EFFECTS, [])
|
||||
if (index := find_effect_index(effects, effect_name)) is not None:
|
||||
return index
|
||||
# Safety net for a light whose schema skipped _validate_initial_state_effect
|
||||
raise EsphomeError(
|
||||
effect_not_found_message(effect_name, effects, f"light '{config[CONF_ID]}'")
|
||||
)
|
||||
|
||||
|
||||
BINARY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend(
|
||||
{
|
||||
cv.Optional(CONF_EFFECTS): validate_effects(BINARY_EFFECTS),
|
||||
@@ -628,9 +557,7 @@ async def setup_light_core_(light_var, config, output_var):
|
||||
# ways to build the same state callback and save_enabled flag that LightState's
|
||||
# runtime actually understands.
|
||||
initial_state_config = config.get(CONF_INITIAL_STATE)
|
||||
initial_statements = await _initial_state_statements(
|
||||
initial_state_config, _initial_effect_index(config)
|
||||
)
|
||||
initial_statements = await _initial_state_statements(initial_state_config)
|
||||
if config[CONF_RESUME_EFFECT]:
|
||||
cg.add_define("USE_LIGHT_RESUME_EFFECT")
|
||||
cg.add(light_var.set_resume_effect(True))
|
||||
|
||||
@@ -250,8 +250,10 @@ def _resolve_effect_index(config: ConfigType, original_name: str) -> int:
|
||||
Effect index 0 means "None" (no effect). Effects are 1-indexed matching
|
||||
the C++ convention in LightState.
|
||||
"""
|
||||
from . import effect_not_found_message, find_effect_index
|
||||
from . import available_effects_str, find_effect_index
|
||||
|
||||
if original_name.lower() == "none":
|
||||
return 0
|
||||
light_id = config[CONF_ID]
|
||||
light_path = CORE.config.get_path_for_id(light_id)[:-1]
|
||||
light_config = CORE.config.get_config_for_path(light_path)
|
||||
@@ -262,7 +264,8 @@ def _resolve_effect_index(config: ConfigType, original_name: str) -> int:
|
||||
# Should never reach here — effect names are validated during config
|
||||
# validation in FINAL_VALIDATE_SCHEMA. This is a safety net.
|
||||
raise EsphomeError(
|
||||
effect_not_found_message(original_name, effects, f"light '{light_id}'")
|
||||
f"Effect '{original_name}' not found for light '{light_id}'. "
|
||||
f"Available effects: {available_effects_str(effects)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -76,8 +76,6 @@ 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,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "light_color_values.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
@@ -134,8 +133,6 @@ 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).
|
||||
|
||||
@@ -83,13 +83,9 @@ void LightState::setup() {
|
||||
call.set_color_temperature_if_supported(recovered.color_temp);
|
||||
call.set_cold_white_if_supported(recovered.cold_white);
|
||||
call.set_warm_white_if_supported(recovered.warm_white);
|
||||
if (recovered.state && recovered.effect != 0) {
|
||||
if (recovered.effect != 0) {
|
||||
call.set_effect(recovered.effect);
|
||||
} else {
|
||||
#ifdef USE_LIGHT_RESUME_EFFECT
|
||||
// An off light cannot start its effect now; keep it for the first turn-on instead
|
||||
this->previous_effect_index_ = recovered.effect;
|
||||
#endif // USE_LIGHT_RESUME_EFFECT
|
||||
call.set_transition_length_if_supported(0);
|
||||
}
|
||||
call.perform();
|
||||
|
||||
@@ -90,11 +90,6 @@ 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,
|
||||
@@ -421,7 +416,6 @@ 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
|
||||
|
||||
@@ -11,14 +11,6 @@ import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_COLOR_MODE, CONF_STATE
|
||||
from esphome.core import Lambda
|
||||
from esphome.core.entity_helpers import (
|
||||
RESTORE_STATE_INITIAL,
|
||||
RESTORE_STATE_INVERT,
|
||||
RESTORE_STATE_KEEP,
|
||||
RESTORE_STATE_NONE,
|
||||
restore_state_keyword,
|
||||
restore_state_schema,
|
||||
)
|
||||
from esphome.cpp_generator import call_lambda
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
@@ -26,6 +18,12 @@ from esphome.types import ConfigType
|
||||
from .automation import LIGHT_STATE_FIELDS, validate_light_state
|
||||
from .types import ColorMode, LightStateRTCState
|
||||
|
||||
RESTORE_STATE_KEEP = "KEEP"
|
||||
RESTORE_STATE_INVERT = "INVERT"
|
||||
RESTORE_STATE_INITIAL = "INITIAL"
|
||||
RESTORE_STATE_ALL = "ALL"
|
||||
RESTORE_STATE_NONE = "NONE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyRestoreMode:
|
||||
@@ -58,11 +56,7 @@ _STATE_STRUCT_FIELDS: tuple[tuple[str, str], ...] = tuple(
|
||||
)
|
||||
# Canonical struct-member order, used only to make generated code deterministic --
|
||||
# these are independent field assignments, so the actual order never affects behavior.
|
||||
_MEMBER_ORDER: tuple[str, ...] = (
|
||||
*(member for _, member in _ALL_STATE_FIELDS),
|
||||
# Not a LightStateField: its value is a name looked up in this light's effects: list
|
||||
"effect",
|
||||
)
|
||||
_MEMBER_ORDER: tuple[str, ...] = tuple(member for _, member in _ALL_STATE_FIELDS)
|
||||
|
||||
# A pending `s.<member> = <value>;` statement, tagged with the member it writes.
|
||||
StateStatement = tuple[str, str]
|
||||
@@ -176,13 +170,12 @@ async def _process_value(value: Any, member: str) -> str:
|
||||
|
||||
|
||||
async def _initial_state_statements(
|
||||
initial_state_config: ConfigType | None, effect_index: int = 0
|
||||
initial_state_config: ConfigType | None,
|
||||
) -> list[StateStatement]:
|
||||
"""
|
||||
Create assignments for every field the user set in `initial_state:`, in canonical
|
||||
struct-member order. A field given as `!lambda` is resolved and called immediately,
|
||||
the same way `light.control`'s own field lambdas are. `effect_index` is the resolved
|
||||
`initial_state: effect`; 0 (no effect) is the struct default and emits nothing.
|
||||
the same way `light.control`'s own field lambdas are.
|
||||
"""
|
||||
if not initial_state_config:
|
||||
return []
|
||||
@@ -195,8 +188,6 @@ async def _initial_state_statements(
|
||||
)
|
||||
if (inferred := _inferred_color_mode(initial_state_config)) is not None:
|
||||
statements.append(("color_mode", f"s.color_mode = {inferred};"))
|
||||
if effect_index:
|
||||
statements.append(("effect", f"s.effect = {effect_index};"))
|
||||
return statements
|
||||
|
||||
|
||||
@@ -299,12 +290,10 @@ def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
||||
except cv.Invalid:
|
||||
inner = ()
|
||||
return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner)
|
||||
if (
|
||||
keyword := restore_state_keyword(
|
||||
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL)
|
||||
)
|
||||
) is not None:
|
||||
return keyword
|
||||
if isinstance(value, str):
|
||||
upper = value.strip().upper()
|
||||
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL):
|
||||
return upper
|
||||
return validator(value)
|
||||
|
||||
return validate
|
||||
@@ -319,12 +308,10 @@ def _validate_restore_state_state(value: Any) -> str | bool:
|
||||
RESTORE_STATE_INITIAL,
|
||||
*validate_light_state(SCHEMA_EXTRACT),
|
||||
)
|
||||
if (
|
||||
keyword := restore_state_keyword(
|
||||
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL)
|
||||
)
|
||||
) is not None:
|
||||
return keyword
|
||||
if isinstance(value, str):
|
||||
upper = value.strip().upper()
|
||||
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL):
|
||||
return upper
|
||||
return validate_light_state(value)
|
||||
|
||||
|
||||
@@ -340,7 +327,19 @@ _RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
# A mapping of per-field overrides, `all`, or `none`, which stays the keyword for to_code
|
||||
RESTORE_STATE_SCHEMA = restore_state_schema(
|
||||
_RESTORE_STATE_FIELDS_SCHEMA, RESTORE_STATE_NONE
|
||||
)
|
||||
@schema_extractor("schema")
|
||||
def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str:
|
||||
"""
|
||||
The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`.
|
||||
"""
|
||||
if value == SCHEMA_EXTRACT:
|
||||
# The `all`/`none` string shorthands have no representation in the extracted
|
||||
# docs schema; only the per-field mapping form is walked here.
|
||||
return _RESTORE_STATE_FIELDS_SCHEMA
|
||||
if isinstance(value, str):
|
||||
upper = value.strip().upper()
|
||||
if upper == RESTORE_STATE_ALL:
|
||||
value = {}
|
||||
elif upper == RESTORE_STATE_NONE:
|
||||
return RESTORE_STATE_NONE
|
||||
return _RESTORE_STATE_FIELDS_SCHEMA(value)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DOMAIN = "ln882x_spi_led_strip"
|
||||
@@ -1,210 +0,0 @@
|
||||
#include "led_strip.h"
|
||||
|
||||
#ifdef USE_LN882X
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
extern "C" {
|
||||
// Skip the SDK register headers: their unused *_unpack inline functions copy volatile unions, which C++ rejects.
|
||||
// Same as LibreTiny's sdk_private.h; the HAL headers below use nothing from them.
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
|
||||
#define __REG_DMA_H__
|
||||
#define __REG_GPIO_H__
|
||||
#define __REG_SPI_H__
|
||||
#define __REG_SYSC_AWO_H__
|
||||
#define __REG_SYSC_CMP_H__
|
||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
|
||||
#include "hal/hal_clock.h"
|
||||
#include "hal/hal_dma.h"
|
||||
#include "hal/hal_gpio.h"
|
||||
#include "hal/hal_spi.h"
|
||||
}
|
||||
|
||||
namespace esphome::ln882x_spi_led_strip {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "ln882x_spi_led_strip");
|
||||
|
||||
// light.py derives the SPI encoding from this APB0 clock, which the SDK's SystemInit() sets.
|
||||
static constexpr uint32_t APB0_HZ = 40000000;
|
||||
// Each LED data bit is sent as one SPI byte, MSB first: the leading 1-bits are the high time.
|
||||
static constexpr uint8_t SPI_BITS_PER_LED_BIT = 8;
|
||||
// SPI0 TX is hard-wired to DMA channel 4 (see hal_dma.h).
|
||||
static constexpr uint32_t LED_STRIP_DMA_CH = DMA_CH_4;
|
||||
|
||||
static uint8_t spi_pattern(uint8_t high_bits) { return uint8_t(0xFF00 >> high_bits); }
|
||||
|
||||
static void spi_dma_init(uint8_t pin, uint8_t prescaler) {
|
||||
// LibreTiny numbers pins as (port << 4) | index. Every pin the LN882x boards expose
|
||||
// (PA0-PA12, PB3-PB9) can be muxed to SPI0 MOSI.
|
||||
const uint32_t gpio_base = (pin >> 4) ? GPIOB_BASE : GPIOA_BASE;
|
||||
const auto gpio_pin = static_cast<gpio_pin_t>(1U << (pin & 0xF));
|
||||
hal_gpio_pin_afio_select(gpio_base, gpio_pin, SPI0_MOSI);
|
||||
hal_gpio_pin_afio_en(gpio_base, gpio_pin, HAL_ENABLE);
|
||||
|
||||
// Only MOSI is routed to a pin; NSS is left unrouted with its output disabled.
|
||||
spi_init_type_def spi_init{};
|
||||
spi_init.spi_direction = SPI_DIRECTION_2LINES_FULLDUPLEX;
|
||||
spi_init.spi_mode = SPI_MODE_MASTER;
|
||||
spi_init.spi_data_size = SPI_DATASIZE_8B;
|
||||
spi_init.spi_cpol = SPI_CPOL_LOW;
|
||||
spi_init.spi_cpha = SPI_CPHA_1EDGE;
|
||||
spi_init.spi_nss_mode = SPI_NSS_HARD;
|
||||
spi_init.spi_baud_rate_prescaler = static_cast<spi_baud_rate_precaler_t>(prescaler);
|
||||
spi_init.spi_first_bit = SPI_FIRST_BIT_MSB;
|
||||
hal_spi_init(SPI0_BASE, &spi_init);
|
||||
hal_spi_en(SPI0_BASE, HAL_ENABLE);
|
||||
hal_spi_ssoe_en(SPI0_BASE, HAL_DISABLE);
|
||||
|
||||
dma_init_t_def dma_init{};
|
||||
dma_init.dma_mem_to_mem_en = DMA_MEM_TO_MEM_DIS;
|
||||
dma_init.dma_pri_lev = DMA_PRI_LEV_HIGH;
|
||||
dma_init.dma_mem_size = DMA_MEM_SIZE_8_BIT;
|
||||
dma_init.dma_p_size = DMA_P_SIZE_8_BIT;
|
||||
dma_init.dma_mem_inc_en = DMA_MEM_INC_EN;
|
||||
dma_init.dma_p_inc_en = DMA_P_INC_DIS;
|
||||
dma_init.dma_circ_mode_en = DMA_CIRC_MODE_DIS;
|
||||
dma_init.dma_dir = DMA_READ_FORM_MEM;
|
||||
dma_init.dma_p_addr = SPI0_DATA_REG;
|
||||
hal_dma_init(LED_STRIP_DMA_CH, &dma_init);
|
||||
hal_dma_en(LED_STRIP_DMA_CH, HAL_DISABLE);
|
||||
}
|
||||
|
||||
static void spi_dma_transmit(const uint8_t *data, uint16_t len) {
|
||||
hal_dma_en(LED_STRIP_DMA_CH, HAL_DISABLE);
|
||||
hal_spi_dma_en(SPI0_BASE, SPI_DMA_TX_EN, HAL_DISABLE);
|
||||
hal_dma_set_mem_addr(LED_STRIP_DMA_CH, reinterpret_cast<uint32_t>(data));
|
||||
hal_dma_set_data_num(LED_STRIP_DMA_CH, len);
|
||||
hal_spi_dma_en(SPI0_BASE, SPI_DMA_TX_EN, HAL_ENABLE);
|
||||
hal_dma_en(LED_STRIP_DMA_CH, HAL_ENABLE);
|
||||
}
|
||||
|
||||
// The count reaches zero once the last byte is in the SPI data register. The tail of every
|
||||
// transfer is the reset gap, so starting the next one while that byte shifts out is harmless.
|
||||
static bool spi_dma_busy() { return hal_dma_get_data_num(LED_STRIP_DMA_CH) != 0; }
|
||||
|
||||
LN882xSPILEDStripLightOutput::LN882xSPILEDStripLightOutput(uint8_t pin, uint16_t num_leds,
|
||||
light::ChannelColors channel_colors, uint8_t prescaler,
|
||||
uint8_t bit0_high_bits, uint8_t bit1_high_bits,
|
||||
uint16_t reset_bytes)
|
||||
: color_data_bytes_(num_leds * channel_colors.bytes_per_led()),
|
||||
// The tail of the DMA buffer stays zero: it is the reset gap.
|
||||
dma_data_bytes_(color_data_bytes_ * SPI_BITS_PER_LED_BIT + reset_bytes),
|
||||
channel_colors_(channel_colors),
|
||||
num_leds_(num_leds),
|
||||
pin_(pin),
|
||||
prescaler_(prescaler),
|
||||
bit0_high_bits_(bit0_high_bits),
|
||||
bit1_high_bits_(bit1_high_bits) {}
|
||||
|
||||
void LN882xSPILEDStripLightOutput::setup() {
|
||||
const uint32_t apb0_hz = hal_clock_get_apb0_clk();
|
||||
if (apb0_hz != APB0_HZ) {
|
||||
ESP_LOGE(TAG, "APB0 clock is %" PRIu32 " Hz", apb0_hz);
|
||||
this->mark_failed(LOG_STR("APB0 clock is not 40 MHz"));
|
||||
return;
|
||||
}
|
||||
|
||||
// One block for the color, effect and DMA data.
|
||||
RAMAllocator<uint8_t> allocator;
|
||||
uint8_t *buffer = allocator.allocate(this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_);
|
||||
if (buffer == nullptr) {
|
||||
this->mark_failed(LOG_STR("Allocation error"));
|
||||
return;
|
||||
}
|
||||
memset(buffer, 0, this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_);
|
||||
this->color_data_ = buffer;
|
||||
this->effect_data_ = buffer + this->color_data_bytes_;
|
||||
this->dma_data_ = this->effect_data_ + this->num_leds_;
|
||||
|
||||
spi_dma_init(this->pin_, this->prescaler_);
|
||||
}
|
||||
|
||||
void LN882xSPILEDStripLightOutput::dump_config() {
|
||||
const uint32_t spi_bit_ns = (1000000000 / APB0_HZ) << this->prescaler_;
|
||||
char channel_colors[5];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"LN882x SPI LED Strip:\n"
|
||||
" Pin: %u\n"
|
||||
" Channel colors: %s\n"
|
||||
" Number of LEDs: %" PRIu16 "\n"
|
||||
" Max refresh rate: %" PRIu32 " us\n"
|
||||
" SPI frequency: %" PRIu32 " Hz\n"
|
||||
" Bit 0 / bit 1 high: %" PRIu32 " / %" PRIu32 " ns of %" PRIu32 " ns",
|
||||
this->pin_, this->channel_colors_.to_string(channel_colors), this->num_leds_, this->max_refresh_rate_,
|
||||
APB0_HZ >> this->prescaler_, this->bit0_high_bits_ * spi_bit_ns, this->bit1_high_bits_ * spi_bit_ns,
|
||||
SPI_BITS_PER_LED_BIT * spi_bit_ns);
|
||||
}
|
||||
|
||||
float LN882xSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
light::LightTraits LN882xSPILEDStripLightOutput::get_traits() {
|
||||
auto traits = light::LightTraits();
|
||||
if (this->channel_colors_.has_white()) {
|
||||
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
|
||||
} else {
|
||||
traits.set_supported_color_modes({light::ColorMode::RGB});
|
||||
}
|
||||
return traits;
|
||||
}
|
||||
|
||||
void LN882xSPILEDStripLightOutput::clear_effect_data() {
|
||||
if (this->effect_data_ != nullptr)
|
||||
memset(this->effect_data_, 0, this->num_leds_);
|
||||
}
|
||||
|
||||
void LN882xSPILEDStripLightOutput::write_state(light::LightState *state) {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
|
||||
// protect from refreshing too often
|
||||
const uint32_t now = micros();
|
||||
if (this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < this->max_refresh_rate_) {
|
||||
// try again next loop iteration, so that this change won't get lost
|
||||
this->schedule_show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (spi_dma_busy()) {
|
||||
// Busy sending the last frame, try again later.
|
||||
this->schedule_show();
|
||||
return;
|
||||
}
|
||||
|
||||
this->last_refresh_ = now;
|
||||
this->mark_shown_();
|
||||
|
||||
const uint8_t bit0 = spi_pattern(this->bit0_high_bits_);
|
||||
const uint8_t bit1 = spi_pattern(this->bit1_high_bits_);
|
||||
const uint8_t *color_data = this->color_data_;
|
||||
uint8_t *dma_data = this->dma_data_;
|
||||
for (size_t i = 0; i < this->color_data_bytes_; i++) {
|
||||
const uint8_t color_byte = *(color_data++);
|
||||
for (uint8_t mask = 0x80; mask != 0; mask >>= 1) {
|
||||
*(dma_data++) = (color_byte & mask) ? bit1 : bit0;
|
||||
}
|
||||
}
|
||||
|
||||
spi_dma_transmit(this->dma_data_, this->dma_data_bytes_);
|
||||
}
|
||||
|
||||
light::ESPColorView LN882xSPILEDStripLightOutput::get_view_internal(int32_t index) const {
|
||||
const light::ChannelColors &colors = this->channel_colors_;
|
||||
// After a failed setup() effects and partitions still write pixels, so they get a scratch pixel.
|
||||
static uint8_t scratch[5];
|
||||
const bool ready = this->color_data_ != nullptr;
|
||||
uint8_t *led = ready ? this->color_data_ + (index * colors.bytes_per_led()) : scratch;
|
||||
return {led + colors.r,
|
||||
led + colors.g,
|
||||
led + colors.b,
|
||||
colors.has_white() ? led + colors.w : nullptr,
|
||||
ready ? &this->effect_data_[index] : scratch + 4,
|
||||
&this->correction_};
|
||||
}
|
||||
|
||||
} // namespace esphome::ln882x_spi_led_strip
|
||||
|
||||
#endif // USE_LN882X
|
||||
@@ -1,55 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_LN882X
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/components/light/addressable_light.h"
|
||||
#include "esphome/components/light/channel_colors.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::ln882x_spi_led_strip {
|
||||
|
||||
class LN882xSPILEDStripLightOutput final : public light::AddressableLight {
|
||||
public:
|
||||
/// The SPI encoding comes from light.py: SPI clock APB0 / 2^prescaler, the number of high SPI bits for a
|
||||
/// 0 and a 1 bit, and the zero bytes of the reset gap.
|
||||
LN882xSPILEDStripLightOutput(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors, uint8_t prescaler,
|
||||
uint8_t bit0_high_bits, uint8_t bit1_high_bits, uint16_t reset_bytes);
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
light::LightTraits get_traits() override;
|
||||
int32_t size() const override { return this->num_leds_; }
|
||||
void clear_effect_data() override;
|
||||
void write_state(light::LightState *state) override;
|
||||
|
||||
/// Set a maximum refresh rate in µs as some lights do not like being updated too often.
|
||||
void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; }
|
||||
|
||||
protected:
|
||||
light::ESPColorView get_view_internal(int32_t index) const override;
|
||||
|
||||
const size_t color_data_bytes_;
|
||||
const size_t dma_data_bytes_;
|
||||
uint8_t *color_data_{nullptr};
|
||||
uint8_t *effect_data_{nullptr};
|
||||
uint8_t *dma_data_{nullptr};
|
||||
uint32_t last_refresh_{0};
|
||||
uint32_t max_refresh_rate_{0};
|
||||
|
||||
const light::ChannelColors channel_colors_;
|
||||
const uint16_t num_leds_;
|
||||
const uint8_t pin_;
|
||||
const uint8_t prescaler_;
|
||||
const uint8_t bit0_high_bits_;
|
||||
const uint8_t bit1_high_bits_;
|
||||
};
|
||||
|
||||
} // namespace esphome::ln882x_spi_led_strip
|
||||
|
||||
#endif // USE_LN882X
|
||||
@@ -1,156 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from esphome import pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light
|
||||
from esphome.components.const import CONF_CHANNEL_COLORS
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CHIPSET,
|
||||
CONF_MAX_REFRESH_RATE,
|
||||
CONF_NUM_LEDS,
|
||||
CONF_OUTPUT_ID,
|
||||
CONF_PIN,
|
||||
CONF_PLATFORM,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
CODEOWNERS = ["@sebo-b"]
|
||||
DEPENDENCIES = ["ln882x"]
|
||||
|
||||
ln882x_spi_led_strip_ns = cg.esphome_ns.namespace("ln882x_spi_led_strip")
|
||||
LN882xSPILEDStripLightOutput = ln882x_spi_led_strip_ns.class_(
|
||||
"LN882xSPILEDStripLightOutput", light.AddressableLight
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LEDStripTimings:
|
||||
bit0_high: int # ns
|
||||
bit0_low: int
|
||||
bit1_high: int
|
||||
bit1_low: int
|
||||
|
||||
|
||||
# As esp32_rmt_led_strip.
|
||||
CHIPSETS = {
|
||||
"WS2811": LEDStripTimings(300, 1090, 1090, 320),
|
||||
"WS2812": LEDStripTimings(400, 1000, 1000, 400),
|
||||
"SK6812": LEDStripTimings(300, 900, 600, 600),
|
||||
"APA106": LEDStripTimings(350, 1360, 1360, 350),
|
||||
"SM16703": LEDStripTimings(300, 900, 900, 300),
|
||||
}
|
||||
|
||||
# SystemInit() in the SDK sets APB0 to 40 MHz; led_strip.cpp checks it at boot.
|
||||
APB0_HZ = 40_000_000
|
||||
# SPI0 runs at APB0 / 2^prescaler, prescaler 1 (/2) to 8 (/256).
|
||||
SPI_PRESCALER_MIN = 1
|
||||
SPI_PRESCALER_MAX = 8
|
||||
# Each LED data bit is sent as one SPI byte, MSB first: the leading 1-bits are the high time.
|
||||
SPI_BITS_PER_LED_BIT = 8
|
||||
# At most this many high bits, so every LED bit ends with at least two low SPI bits.
|
||||
SPI_HIGH_BITS_MAX = SPI_BITS_PER_LED_BIT - 2
|
||||
# Low time after the data that latches it. Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5.
|
||||
RESET_DURATION_NS = 300_000
|
||||
# One DMA transfer moves at most this many bytes: the whole frame plus the reset gap.
|
||||
DMA_MAX_TRANSFER = 0xFFFF
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SPIEncoding:
|
||||
prescaler: int
|
||||
bit0_high_bits: int
|
||||
bit1_high_bits: int
|
||||
reset_bytes: int
|
||||
|
||||
|
||||
def _spi_bit_ns(prescaler: int) -> int:
|
||||
return (1_000_000_000 << prescaler) // APB0_HZ
|
||||
|
||||
|
||||
def spi_encoding(timings: LEDStripTimings) -> SPIEncoding:
|
||||
"""SPI clock and bit patterns that reproduce the chipset timings."""
|
||||
# The prescaler whose 8-bit frame is closest to the bit period. Each step doubles the frame,
|
||||
# so step on while the period is at least 1.5 frames (a tie takes the longer frame).
|
||||
bit_period_ns = max(
|
||||
timings.bit0_high + timings.bit0_low, timings.bit1_high + timings.bit1_low
|
||||
)
|
||||
prescaler = SPI_PRESCALER_MIN
|
||||
while (
|
||||
prescaler < SPI_PRESCALER_MAX
|
||||
and 3 * SPI_BITS_PER_LED_BIT * _spi_bit_ns(prescaler) <= 2 * bit_period_ns
|
||||
):
|
||||
prescaler += 1
|
||||
bit_ns = _spi_bit_ns(prescaler)
|
||||
|
||||
def high_bits(duration_ns: int, max_bits: int) -> int:
|
||||
# Rounded half up, from 1 to max_bits.
|
||||
return max(1, min(max_bits, (2 * duration_ns + bit_ns) // (2 * bit_ns)))
|
||||
|
||||
# Bit 0 stops one below the maximum, so bit 1 can always be longer.
|
||||
bit0_high_bits = high_bits(timings.bit0_high, SPI_HIGH_BITS_MAX - 1)
|
||||
bit1_high_bits = max(
|
||||
high_bits(timings.bit1_high, SPI_HIGH_BITS_MAX), bit0_high_bits + 1
|
||||
)
|
||||
reset_bytes = -(-RESET_DURATION_NS // (SPI_BITS_PER_LED_BIT * bit_ns))
|
||||
return SPIEncoding(prescaler, bit0_high_bits, bit1_high_bits, reset_bytes)
|
||||
|
||||
|
||||
def _validate_num_leds(config: ConfigType) -> ConfigType:
|
||||
reset_bytes = spi_encoding(CHIPSETS[config[CONF_CHIPSET]]).reset_bytes
|
||||
bytes_per_led = len(config[CONF_CHANNEL_COLORS])
|
||||
max_num_leds = (
|
||||
(DMA_MAX_TRANSFER - reset_bytes) // SPI_BITS_PER_LED_BIT // bytes_per_led
|
||||
)
|
||||
if config[CONF_NUM_LEDS] > max_num_leds:
|
||||
raise cv.Invalid(
|
||||
f"The maximum number of LEDs for this configuration is {max_num_leds}.",
|
||||
path=[CONF_NUM_LEDS],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
light.ADDRESSABLE_LIGHT_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(LN882xSPILEDStripLightOutput),
|
||||
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
|
||||
cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors,
|
||||
cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
|
||||
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
|
||||
}
|
||||
),
|
||||
_validate_num_leds,
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
# The strip owns SPI0 and its DMA channel, so only one can exist.
|
||||
lights = fv.full_config.get().get(light.DOMAIN, [])
|
||||
if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in lights) > 1:
|
||||
raise cv.Invalid(f"Only one {DOMAIN} is supported (it uses SPI0)")
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
encoding = spi_encoding(CHIPSETS[config[CONF_CHIPSET]])
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_OUTPUT_ID],
|
||||
config[CONF_PIN],
|
||||
config[CONF_NUM_LEDS],
|
||||
light.channel_colors_struct(config[CONF_CHANNEL_COLORS]),
|
||||
encoding.prescaler,
|
||||
encoding.bit0_high_bits,
|
||||
encoding.bit1_high_bits,
|
||||
encoding.reset_bytes,
|
||||
)
|
||||
await light.register_light(var, config)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add_optional(var.set_max_refresh_rate, config.get(CONF_MAX_REFRESH_RATE))
|
||||
@@ -35,12 +35,12 @@ class LockTraits {
|
||||
public:
|
||||
LockTraits() = default;
|
||||
|
||||
bool get_supports_open() const { return this->flags_.supports_open; }
|
||||
void set_supports_open(bool supports_open) { this->flags_.supports_open = supports_open; }
|
||||
bool get_requires_code() const { return this->flags_.requires_code; }
|
||||
void set_requires_code(bool requires_code) { this->flags_.requires_code = requires_code; }
|
||||
bool get_assumed_state() const { return this->flags_.assumed_state; }
|
||||
void set_assumed_state(bool assumed_state) { this->flags_.assumed_state = assumed_state; }
|
||||
bool get_supports_open() const { return this->supports_open_; }
|
||||
void set_supports_open(bool supports_open) { this->supports_open_ = supports_open; }
|
||||
bool get_requires_code() const { return this->requires_code_; }
|
||||
void set_requires_code(bool requires_code) { this->requires_code_ = requires_code; }
|
||||
bool get_assumed_state() const { return this->assumed_state_; }
|
||||
void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; }
|
||||
|
||||
bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); }
|
||||
void set_supported_states(std::initializer_list<LockState> states) {
|
||||
@@ -54,13 +54,9 @@ class LockTraits {
|
||||
void add_supported_state(LockState state) { supported_states_mask_ |= (1 << state); }
|
||||
|
||||
protected:
|
||||
// Bit-packed flags to save memory (1 byte instead of 3)
|
||||
struct LockTraitFlags {
|
||||
uint8_t supports_open : 1;
|
||||
uint8_t requires_code : 1;
|
||||
uint8_t assumed_state : 1;
|
||||
uint8_t reserved : 5;
|
||||
} flags_{};
|
||||
bool supports_open_{false};
|
||||
bool requires_code_{false};
|
||||
bool assumed_state_{false};
|
||||
uint8_t supported_states_mask_{(1 << LOCK_STATE_NONE) | (1 << LOCK_STATE_LOCKED) | (1 << LOCK_STATE_UNLOCKED)};
|
||||
};
|
||||
|
||||
@@ -180,8 +176,8 @@ class Lock : public EntityBase {
|
||||
*/
|
||||
virtual void control(const LockCall &call) = 0;
|
||||
|
||||
Deduplicator<LockState> publish_dedup_; // packs with state and traits ahead of the pointer members
|
||||
LazyCallbackManager<void(LockState)> state_callback_{};
|
||||
Deduplicator<LockState> publish_dedup_;
|
||||
ESPPreferenceObject rtc_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import i2c
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@KoenBreeman"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
DOMAIN = "m5stack_4relay"
|
||||
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_M5STACK_4RELAY_ID = "m5stack_4relay_id"
|
||||
|
||||
m5stack_4relay_ns = cg.esphome_ns.namespace("m5stack_4relay")
|
||||
M5Stack4Relay = m5stack_4relay_ns.class_("M5Stack4Relay", cg.Component, i2c.I2CDevice)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(M5Stack4Relay),
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(i2c.i2c_device_schema(0x26))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
await i2c.new_i2c_component(config)
|
||||
@@ -1,50 +0,0 @@
|
||||
#include "m5stack_4relay.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::m5stack_4relay {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "m5stack_4relay");
|
||||
|
||||
static constexpr uint8_t REG_MODE = 0x10;
|
||||
static constexpr uint8_t REG_RELAY = 0x11;
|
||||
static constexpr uint8_t MODE_SYNC = 1;
|
||||
|
||||
void M5Stack4Relay::setup() {
|
||||
if (!this->write_byte(REG_MODE, MODE_SYNC) || !this->read_byte(REG_RELAY, &this->relays_)) {
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
#ifdef USE_SWITCH
|
||||
for (size_t channel = 0; channel < this->switches_.size(); channel++) {
|
||||
switch_::Switch *sw = this->switches_[channel];
|
||||
if (sw == nullptr)
|
||||
continue;
|
||||
// The chip keeps the relays across an ESP reboot, so a switch with restore mode DISABLED keeps what it
|
||||
// holds. Every mode goes through control(), so inverted: applies and the first publish is the saved one.
|
||||
sw->restore(((this->relays_ >> channel) & 1) != sw->is_inverted());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void M5Stack4Relay::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "M5Stack 4 Relay:");
|
||||
LOG_I2C_DEVICE(this);
|
||||
}
|
||||
|
||||
bool M5Stack4Relay::set_relay(uint8_t channel, bool on) {
|
||||
if (this->is_failed())
|
||||
return false;
|
||||
uint8_t value = this->relays_;
|
||||
assign_bits(value, 1 << channel, on);
|
||||
if (!this->status_comm_result(this->write_byte(REG_RELAY, value)))
|
||||
return false;
|
||||
this->relays_ = value;
|
||||
#ifdef USE_SWITCH
|
||||
if (this->switches_[channel] != nullptr)
|
||||
this->switches_[channel]->publish_state(on);
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::m5stack_4relay
|
||||
@@ -1,34 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_SWITCH
|
||||
#include "esphome/components/switch/switch.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::m5stack_4relay {
|
||||
|
||||
class M5Stack4Relay : public Component, public i2c::I2CDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// Switch relay `channel` (0-3) on or off; returns false when the write failed.
|
||||
bool set_relay(uint8_t channel, bool on);
|
||||
|
||||
#ifdef USE_SWITCH
|
||||
void register_switch(uint8_t channel, switch_::Switch *sw) { this->switches_[channel] = sw; }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
uint8_t relays_{0};
|
||||
#ifdef USE_SWITCH
|
||||
std::array<switch_::Switch *, 4> switches_{};
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::m5stack_4relay
|
||||
@@ -1,50 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_CHANNEL, CONF_PLATFORM
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_M5STACK_4RELAY_ID, DOMAIN, M5Stack4Relay
|
||||
|
||||
DEPENDENCIES = ["m5stack_4relay"]
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema(
|
||||
switch.ApplySwitch, default_restore_mode="DISABLED"
|
||||
).extend(
|
||||
{
|
||||
cv.GenerateID(CONF_M5STACK_4RELAY_ID): cv.use_id(M5Stack4Relay),
|
||||
cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=4),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_unique_channel(config: ConfigType) -> ConfigType:
|
||||
# The hub has one slot per channel, so a second switch on it would take over the first
|
||||
hub, channel = config[CONF_M5STACK_4RELAY_ID], config[CONF_CHANNEL]
|
||||
users = [
|
||||
other
|
||||
for other in fv.full_config.get().get(switch.DOMAIN, [])
|
||||
if other.get(CONF_PLATFORM) == DOMAIN
|
||||
and other[CONF_M5STACK_4RELAY_ID] == hub
|
||||
and other[CONF_CHANNEL] == channel
|
||||
]
|
||||
if len(users) > 1:
|
||||
raise cv.Invalid(
|
||||
f"Channel {channel} of {hub} is used by more than one switch",
|
||||
path=[CONF_CHANNEL],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_unique_channel
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_M5STACK_4RELAY_ID])
|
||||
channel = config[CONF_CHANNEL] - 1
|
||||
await switch.sub_switches(config, parent=hub).create(
|
||||
config,
|
||||
lambda sw: hub.register_switch(channel, sw),
|
||||
apply=f"set_relay({channel}, state)",
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user