mirror of
https://github.com/esphome/esphome.git
synced 2026-10-23 06:15:58 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
034a980796 | ||
|
|
c3067b315d | ||
|
|
4f8c4e4edc | ||
|
|
f71c6b6a3b | ||
|
|
ba5cf04bfd |
No files matched your search
@@ -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, loopback harness driver with number and binary sensor states and TX frame summarizer), `api/` (raw plaintext native API client for user defined actions, store_yaml recovery client over Noise or plaintext), `http_request/` (update manifest server, update state driver), `modem/` (button and sensor driver over the plaintext API, also used for the BLE MIDI boards), `dfrobot_sen0395/` (API driver and case runner), `homeassistant_light/` (direct bulb driver, held-report case driver), `usb_audio/` (URL player), `speaker_media_player/` (API player driver, pipeline task case runner), `web_server/` (SSE client, request mix, SSE and HTTPD case runners), `mqtt/` (broker config with TLS, client-certificate and WebSocket listeners, test CA with server and client certificates, transport case runner, flash-and-drive runner, CLI TLS check without a board), `udp/` (datagram recorder, `udp.write` payload checker and case driver), `entity_icons/` (icon and device class readback over the API, MQTT discovery, web_server and dump_config, with a comparer).
|
||||
|
||||
## Templates in `$SKILL/templates`
|
||||
|
||||
@@ -132,7 +132,10 @@ capture decoder), `api_outgoing/` (wake cycle and Wi-Fi drop timing readers), `l
|
||||
`globals-storage-dut.yaml`, `usb-audio-waveshare-s3-eth-dut.yaml`, `usb-audio-sendspin-s3-eth-dut.yaml`,
|
||||
`cast-pro-1-sendspin-control.yaml`, `aux-2-preprod-stock-dut.yaml`, `aux-2-preprod-usb-audio-dut.yaml`,
|
||||
`dfrobot-sen0395-dut.yaml`, `aux-2-preprod-speaker-player-dut.yaml`, `web-server-sse-dut.yaml`,
|
||||
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`.
|
||||
`mqtt-transport-dut.yaml`, `mqtt-tls-dut.yaml`, `udp-write-dut.yaml`, `entity-icon-dut.yaml`,
|
||||
and the directories `store_yaml/` (package with secrets one directory up, per-board files),
|
||||
`http_request_update/` (manifest URL setter shapes, per-board files), `modem/` (modem only and modem plus
|
||||
Wi-Fi boot checks) and `ble_midi/` (fake BLE MIDI instrument and the client).
|
||||
|
||||
## When something looks wrong
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ Each flow names the pull requests it was used on, so their result tables show wh
|
||||
|
||||
One file per component, named after it:
|
||||
|
||||
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client
|
||||
- [`api.md`](api.md): noise handshake and OTA encryption on the ESP8266, outgoing connection wake timing on deep sleep and after a Wi-Fi drop, user defined action listing and execution with a raw plaintext client, store_yaml recovery over Noise or plaintext
|
||||
- [`ble_midi.md`](ble_midi.md): BLE MIDI client against a fake ESP32 instrument, every packet shape and the send actions
|
||||
- [`captive_portal.md`](captive_portal.md): fallback AP and /wifisave hammer
|
||||
- [`cse7766.md`](cse7766.md): Sonoff S31 parser check over the API
|
||||
- [`espnow.md`](espnow.md): ESP8266 broadcast receive
|
||||
- [`hdmi_cec.md`](hdmi_cec.md): two-board wire
|
||||
- [`http_request.md`](http_request.md): update manifest URL changed at run time, every setter shape, a swap under a running check
|
||||
- [`ld2450.md`](ld2450.md): polygon zones, zone number round trip through the radar's readback, ESP8266 loopback harness without a radar
|
||||
- [`ld2412.md`](ld2412.md): output pin level through the basic configuration command, held back writes
|
||||
- [`libretiny.md`](libretiny.md): rtc preference emulation, BK72xx crash report on the next boot
|
||||
- [`modem.md`](modem.md): PPPoS modem component boot check with no modem attached, loop blocking and disable timing
|
||||
- [`mqtt.md`](mqtt.md): broker bench, QoS, reconnect, crash log over MQTT, WebSocket transports with a local and a public broker, TLS trust sources (own CA, client certificate, certificate bundle, no verification) with the CLI check
|
||||
- [`nrf52.md`](nrf52.md): dongle board support
|
||||
- [`online_image.md`](online_image.md): stalled and malformed downloads
|
||||
|
||||
@@ -40,3 +40,19 @@ length delimited strings and `int[]` as repeated sint32 (field 7); `ALLOW_MISSIN
|
||||
defaults) and `templates/api-actions-strings-dut.yaml` (required strings) log what arrived. String arguments are
|
||||
`StringRef`s that are not null terminated: copy them with `snprintf("%.*s", size, c_str())` into a buffer before
|
||||
logging, and do not pass `%.*s` to `logger.log`, whose validator counts it as one pattern.
|
||||
|
||||
## store_yaml recovery over the native API (#16445)
|
||||
|
||||
`scripts/api/syrecover.py HOST KEY OUTDIR [ORIGDIR]` (KEY `-` for plaintext) builds the client library's
|
||||
`APINoiseFrameHelper` around a stub `APIConnection` subclass (the compiled helper type checks the connection, and
|
||||
`ConnectionParams` needs every field), sends Hello, Authentication (never answered without a password) and
|
||||
`DeviceCapabilitiesRequest`, then `GetYamlRequest` (161), collects `GetYamlResponse` (162) frames by field number
|
||||
(1 data, 2 done, 3 total_size, 4 encoding) until done, requests again, decompresses with `compression.zstd` (Python 3.14, or the `zstandard` package) and
|
||||
unpacks the envelope with the tree's `unpack_envelope` (run with `PYTHONPATH` on that tree). Configs in
|
||||
`templates/store_yaml/`: entry files in `boards/`, a package one directory above them with `!secret` and an inline
|
||||
OTA password,
|
||||
so the redaction and the `..` paths are exercised. Repetitive YAML compresses into one chunk even at 22 KB; a 6 KB
|
||||
include of random base64 lines with `include_secrets: true` gives four chunks (1390 byte packets). Rough edge at
|
||||
0bd3738430: `include_secrets: true` fails the build when a package in another directory uses `!secret` and has no
|
||||
`secrets.yaml` beside it; a copy next to the package works around it.
|
||||
Results: https://github.com/esphome/esphome/pull/16445#issuecomment-6335435397
|
||||
@@ -0,0 +1,19 @@
|
||||
# ble_midi
|
||||
|
||||
## BLE MIDI client against a fake instrument (#18508)
|
||||
|
||||
No MIDI hardware needed: `templates/ble_midi/midiperi.yaml` turns a second ESP32 (an S3 with ethernet, so the
|
||||
API stays reachable while BLE is busy) into a BLE MIDI peripheral with `esp32_ble_server`: the MIDI service
|
||||
advertised, the MIDI characteristic with read, write, write without response and notify, an `on_write` that logs
|
||||
the bytes, buttons that notify hand built packets (channel messages, two messages in one packet, running status,
|
||||
SysEx in one packet, split over two, oversize, cut short, clock, aftertouch, orphan data bytes, a 50 packet burst)
|
||||
and a restart button for the disconnect test. `midicli.yaml` is the component test config on a classic devkit with
|
||||
`wifi:`, `api:`, the peripheral's BLE address in the `peri_mac` substitution, given on the command line with
|
||||
`-s peri_mac <mac>` (the base MAC plus 2, printed by `esp32_ble` in `dump_config`), `on_sysex` and `on_message` lambdas that print the bytes, debug sensors and out of range send
|
||||
buttons. Drive both over the plaintext API with `scripts/modem/mdrive.py HOST LOGFILE press:NAME wait:S` 3 s
|
||||
apart and compare the peripheral's `TX` and `RX` lines with the client's decoded lines. Gotcha:
|
||||
`ble_server.characteristic.set_value` latches the value of its first play and re-applies it before every notify,
|
||||
so the peripheral script sets the value from a lambda (`id(midi_io).set_value(std::vector<uint8_t>(pkt));
|
||||
id(midi_io).notify();`). At VERBOSE the client logs four lines per message, so a burst shows an
|
||||
`esp32_ble took a long time` of a couple of seconds that is the serial port, not the parser.
|
||||
Results: https://github.com/esphome/esphome/pull/18508#issuecomment-6348272717
|
||||
@@ -0,0 +1,18 @@
|
||||
# http_request
|
||||
|
||||
## Update manifest URL changed at run time (#21373)
|
||||
|
||||
`scripts/http_request/manifest_server.py PORT [DELAY]` serves `stable.json`, `beta.json` and `slow.json` (answered
|
||||
after DELAY s) with both chip families and logs every request. `templates/http_request_update/upd-common.yaml`
|
||||
(secret `manifest_base` is the server URL) has the update entity on the stable manifest, buttons for every
|
||||
`set_source_url` shape (`std::string` local, literal, temporary, `.c_str()` of a local, the current URL back in),
|
||||
`Check Then Swap` (ESP32 task path: check the slow manifest and replace the URL in the same lambda), `Arm Swap In
|
||||
Check` plus a `logger: on_message` lambda that replaces the URL from inside the inline check (ESP8266; the log line
|
||||
from inside `on_message` is swallowed, judge by the next check's result), and a `Heap` button. `upd-base-common.yaml`
|
||||
keeps only the shapes that compile on a tree with the bare pointer setter (for the base build, copy a board file
|
||||
and point its `common` include at `../upd-base-common.yaml` with a different `name:`); `boards/upd8266.yaml` and `boards/upd32.yaml` are the
|
||||
per-board files, one directory below the package their `../` include points at. Drive with `scripts/http_request/updrive.py HOST KEY LOGFILE press:NAME wait:S`, which prints every
|
||||
update state (latest version, summary). The ESP8266 Arduino client gets `Error reading manifest: -1` on roughly a
|
||||
quarter of the fetches from this Python server on every build, so count checks against the base before blaming a
|
||||
PR. `http_request` on the ESP8266 needs `verify_ssl: false` even for plain http URLs.
|
||||
Results: https://github.com/esphome/esphome/pull/21373#issuecomment-6337544933
|
||||
@@ -0,0 +1,18 @@
|
||||
# modem
|
||||
|
||||
## PPPoS modem component with no modem attached (#6721)
|
||||
|
||||
A boot check on a classic ESP32 devkit with the modem UART pins left open. `templates/modem/modem-common.yaml` is
|
||||
the package (`api:`, debug `loop_time` and `free` sensors, uptime, buttons for `modem.enable`, `modem.disable` and
|
||||
`modem.send_at`, a 20 s alive log); `modemo.yaml` is the modem only build with `reboot_timeout: 2min` and the
|
||||
component test config's `on_boot` block at `priority: -100` (earlier, `modem.send_at` runs before the modem's
|
||||
`setup()` and logs `Modem DCE not ready`); `modemw.yaml` adds `wifi:` with `reboot_timeout: 0s` so the API stays
|
||||
reachable. Expect `Modem not responding at 115200 baud` once per pass with each pass blocking the loop about 1 s,
|
||||
the reboot at the configured timeout (`SW_CPU_RESET`), and a `modem.disable` with no modem answering to block
|
||||
the loop about 37 s (`Failed to leave data mode`, then the radio off timeout) without tripping the 30 s watchdog
|
||||
floor because the component feeds it in between. Drive the Wi-Fi build with
|
||||
`scripts/modem/mdrive.py HOST LOGFILE list press:NAME wait:S` (plaintext API; prints sensor and text sensor
|
||||
states, saves the device log). Before 8cc8df4a1f a modem only config with `api:` failed validation because a
|
||||
config dependent `AUTO_LOAD` added `network` after the dependency check; an explicit `network:` block was the
|
||||
workaround. Results: https://github.com/esphome/esphome/pull/6721#issuecomment-6347395159 and
|
||||
https://github.com/esphome/esphome/pull/6721#issuecomment-6348520416
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Pull the store_yaml blob off a device over the native API (plaintext or Noise) and unpack it.
|
||||
|
||||
usage: syrecover.py HOST KEY OUTDIR [ORIGDIR] (KEY of - means a plaintext API)
|
||||
Prints the capability flag, chunk count, advertised and received sizes, encoding, the envelope's files, and
|
||||
when ORIGDIR is given compares each recovered file with ORIGDIR/<path>.
|
||||
Run with PYTHONPATH pointing at the esphome tree that has store_yaml (for unpack_envelope). Needs Python 3.14
|
||||
(compression.zstd) or the zstandard package.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import api_pb2
|
||||
from aioesphomeapi._frame_helper.noise import APINoiseFrameHelper
|
||||
from aioesphomeapi._frame_helper.plain_text import APIPlaintextFrameHelper
|
||||
from aioesphomeapi.connection import APIConnection, ConnectionParams
|
||||
from aioesphomeapi.zeroconf import ZeroconfManager
|
||||
|
||||
from esphome.components.store_yaml import unpack_envelope
|
||||
|
||||
try:
|
||||
from compression import zstd
|
||||
except ImportError:
|
||||
try:
|
||||
import zstandard as zstd
|
||||
except ImportError:
|
||||
sys.exit("needs Python 3.14 (compression.zstd) or the zstandard package")
|
||||
|
||||
HOST, KEY, OUT = sys.argv[1], sys.argv[2], Path(sys.argv[3])
|
||||
ORIG = Path(sys.argv[4]) if len(sys.argv) > 4 else None
|
||||
HELLO, HELLO_RESP, CONNECT, CONNECT_RESP = 1, 2, 3, 4
|
||||
DISCONNECT_REQ = 5
|
||||
FATAL = -1 # queued by report_fatal_error so a waiting expect() stops at once
|
||||
CAPS_REQ, CAPS_RESP = 149, 150
|
||||
GET_YAML_REQ, GET_YAML_RESP = 161, 162
|
||||
|
||||
|
||||
def decode_varint(buf: bytes, pos: int) -> tuple[int, int]:
|
||||
value = shift = 0
|
||||
while True:
|
||||
b = buf[pos]
|
||||
pos += 1
|
||||
value |= (b & 0x7F) << shift
|
||||
if not b & 0x80:
|
||||
return value, pos
|
||||
shift += 7
|
||||
|
||||
|
||||
def fields(payload: bytes) -> dict[int, list]:
|
||||
out: dict[int, list] = {}
|
||||
pos = 0
|
||||
while pos < len(payload):
|
||||
key, pos = decode_varint(payload, pos)
|
||||
fnum, wt = key >> 3, key & 7
|
||||
if wt == 0:
|
||||
v, pos = decode_varint(payload, pos)
|
||||
elif wt == 2:
|
||||
ln, pos = decode_varint(payload, pos)
|
||||
v = payload[pos : pos + ln]
|
||||
pos += ln
|
||||
elif wt == 5:
|
||||
v = payload[pos : pos + 4]
|
||||
pos += 4
|
||||
else:
|
||||
raise ValueError(f"wire type {wt}")
|
||||
out.setdefault(fnum, []).append(v)
|
||||
return out
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
queue: asyncio.Queue[tuple[int, bytes]] = asyncio.Queue()
|
||||
errors: list[Exception] = []
|
||||
|
||||
class Shim(APIConnection):
|
||||
"""Only the two callbacks the frame helper uses; the real connection logic is bypassed."""
|
||||
|
||||
def process_packet(self, msg_type_proto, data) -> None:
|
||||
queue.put_nowait((msg_type_proto, bytes(data)))
|
||||
|
||||
def report_fatal_error(self, err: Exception) -> None:
|
||||
errors.append(err)
|
||||
queue.put_nowait((FATAL, b""))
|
||||
|
||||
params = ConnectionParams(
|
||||
addresses=[HOST],
|
||||
port=6053,
|
||||
password=None,
|
||||
client_info="syrecover",
|
||||
keepalive=15,
|
||||
zeroconf_manager=ZeroconfManager(),
|
||||
noise_psk=None if KEY == "-" else KEY,
|
||||
expected_name=None,
|
||||
expected_mac=None,
|
||||
timezone=None,
|
||||
provide_time=True,
|
||||
)
|
||||
shim = Shim(params, None, False, HOST)
|
||||
if KEY == "-":
|
||||
helper = APIPlaintextFrameHelper(
|
||||
connection=shim, client_info="syrecover", log_name=HOST
|
||||
)
|
||||
else:
|
||||
helper = APINoiseFrameHelper(
|
||||
connection=shim,
|
||||
noise_psk=KEY,
|
||||
expected_name=None,
|
||||
expected_mac=None,
|
||||
client_info="syrecover",
|
||||
log_name=HOST,
|
||||
)
|
||||
await loop.create_connection(lambda: helper, HOST, 6053)
|
||||
try:
|
||||
await asyncio.wait_for(helper.ready_future, 10)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
sys.exit(f"handshake failed: {exc!r} {errors}")
|
||||
print(f"transport {'plaintext' if KEY == '-' else 'noise'}: handshake done")
|
||||
|
||||
async def expect(msg_type: int, timeout: float = 10) -> bytes:
|
||||
while True:
|
||||
t, d = await asyncio.wait_for(queue.get(), timeout)
|
||||
if t == msg_type:
|
||||
return d
|
||||
if t == FATAL:
|
||||
sys.exit(
|
||||
f"transport error while waiting for message {msg_type}: {errors}"
|
||||
)
|
||||
if t == DISCONNECT_REQ:
|
||||
sys.exit(
|
||||
f"device closed the connection while waiting for message {msg_type}"
|
||||
)
|
||||
print(f"skipping message type {t} while waiting for {msg_type}")
|
||||
|
||||
hello = api_pb2.HelloRequest(
|
||||
client_info="syrecover", api_version_major=1, api_version_minor=10
|
||||
)
|
||||
helper.write_packets([(HELLO, hello.SerializeToString())], False)
|
||||
await expect(HELLO_RESP)
|
||||
# Sent like the client library does; a device without a password never answers it
|
||||
helper.write_packets(
|
||||
[(CONNECT, api_pb2.AuthenticationRequest().SerializeToString())], False
|
||||
)
|
||||
helper.write_packets(
|
||||
[(CAPS_REQ, api_pb2.DeviceCapabilitiesRequest().SerializeToString())], False
|
||||
)
|
||||
caps = fields(await expect(CAPS_RESP))
|
||||
supported = None
|
||||
if 7 in caps:
|
||||
supported = fields(caps[7][0]).get(1) == [1]
|
||||
print(f"capabilities: store_yaml field present={7 in caps} supported={supported}")
|
||||
|
||||
t0 = time.monotonic()
|
||||
helper.write_packets([(GET_YAML_REQ, b"")], False)
|
||||
chunks: list[bytes] = []
|
||||
total = encoding = None
|
||||
done = False
|
||||
while not done:
|
||||
f = fields(await expect(GET_YAML_RESP, 20))
|
||||
if 1 in f:
|
||||
chunks.append(f[1][0])
|
||||
done = f.get(2) == [1]
|
||||
if 3 in f:
|
||||
total = f[3][0]
|
||||
if 4 in f:
|
||||
encoding = f[4][0].decode()
|
||||
dt = time.monotonic() - t0
|
||||
compressed = b"".join(chunks)
|
||||
print(f"chunks {len(chunks)} sizes {[len(c) for c in chunks]}")
|
||||
print(
|
||||
f"received {len(compressed)} bytes, advertised {total}, encoding {encoding!r}, {dt:.2f} s"
|
||||
)
|
||||
# a second request right after: the device must answer again, not stay stuck
|
||||
helper.write_packets([(GET_YAML_REQ, b"")], False)
|
||||
again = 0
|
||||
done = False
|
||||
while not done:
|
||||
f = fields(await expect(GET_YAML_RESP, 20))
|
||||
again += len(f.get(1, [b""])[0])
|
||||
done = f.get(2) == [1]
|
||||
print(f"second request: {again} bytes")
|
||||
helper.close()
|
||||
if errors:
|
||||
sys.exit(f"transport errors: {errors}")
|
||||
if encoding != "zstd":
|
||||
sys.exit(f"unexpected encoding {encoding!r}, expected 'zstd'")
|
||||
if total is not None and len(compressed) != total:
|
||||
sys.exit(f"received {len(compressed)} bytes but the device advertised {total}")
|
||||
if again != len(compressed):
|
||||
sys.exit(
|
||||
f"the second request returned {again} bytes, the first {len(compressed)}"
|
||||
)
|
||||
envelope = zstd.decompress(compressed)
|
||||
files = unpack_envelope(envelope)
|
||||
print(
|
||||
f"envelope {len(envelope)} bytes, {len(files)} files, ratio {len(compressed) / len(envelope):.2f}"
|
||||
)
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for rel, content in files.items():
|
||||
target = OUT / rel.replace("../", "up_/")
|
||||
if not target.resolve().is_relative_to(OUT.resolve()):
|
||||
print("skipping a path outside the output directory:", rel)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
note = ""
|
||||
if ORIG is not None:
|
||||
orig = (ORIG / rel).resolve()
|
||||
if orig.exists():
|
||||
note = (
|
||||
"IDENTICAL to original"
|
||||
if orig.read_bytes() == content
|
||||
else f"DIFFERS from original ({orig.stat().st_size} bytes)"
|
||||
)
|
||||
else:
|
||||
note = "no original"
|
||||
print(f" {rel}: {len(content)} bytes {note}")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Serve update manifests: stable.json, beta.json and slow.json (answered after a delay); logs every request."""
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
PORT = int(sys.argv[1])
|
||||
DELAY = float(sys.argv[2]) if len(sys.argv) > 2 else 4.0
|
||||
|
||||
|
||||
def manifest(name: str, version: str) -> bytes:
|
||||
builds = [
|
||||
{
|
||||
"chipFamily": fam,
|
||||
"ota": {
|
||||
"path": f"/{name}.bin",
|
||||
"md5": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"summary": f"{name} channel",
|
||||
},
|
||||
}
|
||||
for fam in ("ESP8266", "ESP32")
|
||||
]
|
||||
return json.dumps(
|
||||
{"name": f"Bench {name}", "version": version, "builds": builds}
|
||||
).encode()
|
||||
|
||||
|
||||
DOCS = {
|
||||
"/stable.json": manifest("stable", "1.0.0"),
|
||||
"/beta.json": manifest("beta", "2.0.0-beta1"),
|
||||
"/slow.json": manifest("slow", "3.0.0-slow"),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
body = DOCS.get(self.path)
|
||||
print(
|
||||
f"{time.strftime('%H:%M:%S')} {self.client_address[0]} GET {self.path} -> {404 if body is None else 200}",
|
||||
flush=True,
|
||||
)
|
||||
if body is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
if self.path == "/slow.json":
|
||||
time.sleep(DELAY)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args) -> None: # quiet default log
|
||||
return
|
||||
|
||||
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Drive the http_request update harness over the API: press buttons, print every update state and save the log.
|
||||
usage: updrive.py HOST KEY(- for plaintext) LOGFILE OP [OP ...]
|
||||
OP: list | press:NAME | set:NAME=VALUE | sel:NAME=OPTION | wait:SECONDS
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
BinarySensorState,
|
||||
ButtonInfo,
|
||||
LogLevel,
|
||||
NumberInfo,
|
||||
NumberState,
|
||||
SelectInfo,
|
||||
SelectState,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
UpdateState,
|
||||
)
|
||||
|
||||
HOST, KEY, LOGFILE = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
OPS = sys.argv[4:]
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return f"{time.time() - T0:7.3f}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||
c = APIClient(HOST, 6053, None, noise_psk=None if KEY == "-" else KEY)
|
||||
await c.connect(login=True)
|
||||
info = await c.device_info()
|
||||
print(
|
||||
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
|
||||
)
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||
selects = {e.name: e for e in ents if isinstance(e, SelectInfo)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None:
|
||||
return
|
||||
if isinstance(s, TextSensorState):
|
||||
print(
|
||||
f"{stamp()} TXT {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, UpdateState):
|
||||
print(
|
||||
f"{stamp()} UPD {e.name} latest={s.latest_version!r} title={s.title!r} summary={s.release_summary!r} in_progress={s.in_progress}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, BinarySensorState):
|
||||
print(
|
||||
f"{stamp()} BIN {e.name} = {s.state}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, NumberState):
|
||||
print(
|
||||
f"{stamp()} NUM {e.name} = {s.state:.0f}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, SelectState):
|
||||
print(
|
||||
f"{stamp()} SEL {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, SensorState) and isinstance(e, SensorInfo):
|
||||
print(f"{stamp()} SEN {e.name} = {s.state:.0f}", flush=True)
|
||||
|
||||
def on_log(msg) -> None:
|
||||
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERBOSE)
|
||||
await asyncio.sleep(1.5)
|
||||
for op in OPS:
|
||||
if op == "list":
|
||||
for n in sorted(buttons):
|
||||
print("button:", n)
|
||||
for n in sorted(selects):
|
||||
print("select:", n, selects[n].options)
|
||||
for e in ents:
|
||||
if isinstance(e, TextSensorInfo):
|
||||
print("text_sensor:", e.name)
|
||||
continue
|
||||
kind, _, rest = op.partition(":")
|
||||
if kind == "wait":
|
||||
await asyncio.sleep(float(rest))
|
||||
continue
|
||||
if kind == "press":
|
||||
if rest not in buttons:
|
||||
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
|
||||
print(f"{stamp()} >>> press {rest}", flush=True)
|
||||
log.write(f"{stamp()} >>> press {rest}\n")
|
||||
c.button_command(buttons[rest].key)
|
||||
elif kind == "set":
|
||||
name, _, value = rest.partition("=")
|
||||
numbers = {e.name: e for e in ents if isinstance(e, NumberInfo)}
|
||||
if name not in numbers:
|
||||
sys.exit(f"no number named {name!r}")
|
||||
print(f"{stamp()} >>> set {name} = {value}", flush=True)
|
||||
log.write(f"{stamp()} >>> set {name} = {value}\n")
|
||||
c.number_command(numbers[name].key, float(value))
|
||||
elif kind == "sel":
|
||||
name, _, value = rest.partition("=")
|
||||
if name not in selects:
|
||||
sys.exit(f"no select named {name!r}; have {sorted(selects)}")
|
||||
print(f"{stamp()} >>> select {name} = {value}", flush=True)
|
||||
log.write(f"{stamp()} >>> select {name} = {value}\n")
|
||||
c.select_command(selects[name].key, value)
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(2.0)
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Drive the modem test build over the API: press buttons, print sensor states, save the device log.
|
||||
usage: mdrive.py HOST LOGFILE OP [OP ...]
|
||||
OP: list | press:NAME | wait:SECONDS
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
ButtonInfo,
|
||||
LogLevel,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
)
|
||||
|
||||
HOST, LOGFILE = sys.argv[1], sys.argv[2]
|
||||
OPS = sys.argv[3:]
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return f"{time.time() - T0:7.3f}"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
with pathlib.Path(LOGFILE).open("w", encoding="utf-8") as log:
|
||||
c = APIClient(HOST, 6053, None)
|
||||
await c.connect(login=True)
|
||||
info = await c.device_info()
|
||||
print(
|
||||
f"device {info.name} esphome {info.esphome_version} compiled {info.compilation_time}"
|
||||
)
|
||||
ents, _ = await c.list_entities_services()
|
||||
by_key = {e.key: e for e in ents}
|
||||
buttons = {e.name: e for e in ents if isinstance(e, ButtonInfo)}
|
||||
|
||||
def on_state(s) -> None:
|
||||
e = by_key.get(s.key)
|
||||
if e is None:
|
||||
return
|
||||
if isinstance(s, TextSensorState):
|
||||
print(
|
||||
f"{stamp()} TXT {e.name} = {s.state!r}{' (missing)' if s.missing_state else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif isinstance(s, SensorState) and isinstance(e, SensorInfo):
|
||||
print(f"{stamp()} SEN {e.name} = {s.state:.1f}", flush=True)
|
||||
|
||||
def on_log(msg) -> None:
|
||||
log.write(f"{stamp()} {msg.message.decode(errors='replace')}\n")
|
||||
log.flush()
|
||||
|
||||
c.subscribe_states(on_state)
|
||||
c.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG)
|
||||
await asyncio.sleep(1.5)
|
||||
for op in OPS:
|
||||
if op == "list":
|
||||
for n in sorted(buttons):
|
||||
print("button:", n)
|
||||
for e in ents:
|
||||
if isinstance(e, (TextSensorInfo, SensorInfo)):
|
||||
print("entity:", type(e).__name__, e.name)
|
||||
continue
|
||||
kind, _, rest = op.partition(":")
|
||||
if kind == "wait":
|
||||
await asyncio.sleep(float(rest))
|
||||
continue
|
||||
if kind == "press":
|
||||
if rest not in buttons:
|
||||
sys.exit(f"no button named {rest!r}; have {sorted(buttons)}")
|
||||
print(f"{stamp()} >>> press {rest}", flush=True)
|
||||
log.write(f"{stamp()} >>> press {rest}\n")
|
||||
c.button_command(buttons[rest].key)
|
||||
else:
|
||||
sys.exit(f"unknown op {op!r}")
|
||||
await asyncio.sleep(2.0)
|
||||
await asyncio.sleep(1)
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,150 @@
|
||||
substitutions:
|
||||
# the fake instrument's BLE address; pass it with `-s peri_mac AA:BB:CC:DD:EE:FF`
|
||||
peri_mac: "00:00:00:00:00:00"
|
||||
|
||||
esphome:
|
||||
name: midicli
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
level: VERBOSE
|
||||
logs:
|
||||
esp32_ble_tracker: DEBUG
|
||||
ble_client: DEBUG
|
||||
esp32_ble: DEBUG
|
||||
api: DEBUG
|
||||
scheduler: DEBUG
|
||||
sensor: DEBUG
|
||||
wifi: DEBUG
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
ble_client:
|
||||
- mac_address: ${peri_mac}
|
||||
id: midi_client
|
||||
|
||||
ble_midi:
|
||||
id: test_midi
|
||||
ble_client_id: midi_client
|
||||
max_sysex_size: 64
|
||||
pair: false
|
||||
on_connect:
|
||||
- logger.log: "MIDI device connected"
|
||||
- ble_midi.send_program_change:
|
||||
id: test_midi
|
||||
program: 0
|
||||
on_disconnect:
|
||||
- logger.log: "MIDI device disconnected"
|
||||
on_note_on:
|
||||
- logger.log:
|
||||
format: "Note on %u velocity %u channel %u"
|
||||
args: [note, velocity, channel]
|
||||
on_note_off:
|
||||
- logger.log:
|
||||
format: "Note off %u channel %u"
|
||||
args: [note, channel]
|
||||
on_control_change:
|
||||
- logger.log:
|
||||
format: "CC %u = %u on channel %u"
|
||||
args: [control_number, value, channel]
|
||||
on_program_change:
|
||||
- logger.log:
|
||||
format: "Program %u on channel %u"
|
||||
args: [program, channel]
|
||||
on_pitch_bend:
|
||||
- logger.log:
|
||||
format: "Pitch bend %d on channel %u"
|
||||
args: [value, channel]
|
||||
on_sysex:
|
||||
- lambda: |-
|
||||
ESP_LOGI("midi", "SysEx of %u bytes: %s", (unsigned) data.size(), format_hex_pretty(data.data(), data.size()).c_str());
|
||||
on_message:
|
||||
- lambda: |-
|
||||
ESP_LOGI("midi", "Raw message of %u bytes: %s", (unsigned) data.size(), format_hex_pretty(data.data(), data.size()).c_str());
|
||||
|
||||
sensor:
|
||||
- platform: debug
|
||||
free:
|
||||
name: Heap Free
|
||||
loop_time:
|
||||
name: Loop Time
|
||||
|
||||
debug:
|
||||
update_interval: 30s
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "MIDI send tests"
|
||||
on_press:
|
||||
- ble_midi.send_note_on:
|
||||
id: test_midi
|
||||
note: 60
|
||||
- ble_midi.send_note_on:
|
||||
id: test_midi
|
||||
note: !lambda "return 60;"
|
||||
velocity: !lambda "return 100;"
|
||||
channel: !lambda "return 1;"
|
||||
- ble_midi.send_note_off:
|
||||
id: test_midi
|
||||
note: 60
|
||||
velocity: 0
|
||||
channel: 1
|
||||
- ble_midi.send_control_change:
|
||||
id: test_midi
|
||||
control_number: 7
|
||||
value: 64
|
||||
- ble_midi.send_pitch_bend:
|
||||
id: test_midi
|
||||
value: -8192
|
||||
- ble_midi.send_sysex:
|
||||
id: test_midi
|
||||
payload: [0x7E, 0x7F, 0x06, 0x01]
|
||||
- ble_midi.send_raw:
|
||||
id: test_midi
|
||||
data: !lambda "return std::vector<uint8_t>{0x90, 0x3C, 0x40};"
|
||||
- if:
|
||||
condition:
|
||||
ble_midi.connected:
|
||||
id: test_midi
|
||||
then:
|
||||
- logger.log: "Still connected"
|
||||
else:
|
||||
- logger.log: "Not connected"
|
||||
- platform: template
|
||||
name: "MIDI send out of range"
|
||||
on_press:
|
||||
- ble_midi.send_note_on:
|
||||
id: test_midi
|
||||
note: !lambda "return 200;"
|
||||
- ble_midi.send_control_change:
|
||||
id: test_midi
|
||||
control_number: 1
|
||||
value: 1
|
||||
channel: !lambda "return 16;"
|
||||
- ble_midi.send_pitch_bend:
|
||||
id: test_midi
|
||||
value: 8191
|
||||
- ble_midi.send_sysex:
|
||||
id: test_midi
|
||||
payload: [0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7]
|
||||
- ble_midi.send_raw:
|
||||
id: test_midi
|
||||
data: !lambda "return std::vector<uint8_t>(140, 0x00);"
|
||||
- platform: template
|
||||
name: "MIDI send long sysex"
|
||||
on_press:
|
||||
- ble_midi.send_sysex:
|
||||
id: test_midi
|
||||
payload: !lambda "return std::vector<uint8_t>(100, 0x11);"
|
||||
@@ -0,0 +1,177 @@
|
||||
esphome:
|
||||
name: midiperi
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
flash_size: 16MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
logger:
|
||||
hardware_uart: USB_SERIAL_JTAG
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: GPIO13
|
||||
mosi_pin: GPIO11
|
||||
miso_pin: GPIO12
|
||||
cs_pin: GPIO14
|
||||
interrupt_pin: GPIO10
|
||||
reset_pin: GPIO9
|
||||
|
||||
esp32_ble_server:
|
||||
id: ble_server
|
||||
manufacturer: FakeMIDI
|
||||
on_connect:
|
||||
- lambda: ESP_LOGI("peri", "central connected %d", id);
|
||||
on_disconnect:
|
||||
- lambda: ESP_LOGI("peri", "central disconnected %d", id);
|
||||
services:
|
||||
- uuid: 03B80E5A-EDE8-4B33-A751-6CE34EC4C700
|
||||
advertise: true
|
||||
characteristics:
|
||||
- id: midi_io
|
||||
uuid: 7772E5DB-3868-4112-A1A9-F2669D106BF3
|
||||
read: true
|
||||
write: true
|
||||
write_no_response: true
|
||||
notify: true
|
||||
on_write:
|
||||
- lambda: |-
|
||||
ESP_LOGI("peri", "RX %u bytes: %s", (unsigned) x.size(), format_hex_pretty(x.data(), x.size()).c_str());
|
||||
|
||||
script:
|
||||
- id: emit
|
||||
parameters:
|
||||
pkt: std::vector<uint8_t>
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("peri", "TX %u bytes: %s", (unsigned) pkt.size(), format_hex_pretty(pkt.data(), pkt.size()).c_str());
|
||||
id(midi_io).set_value(std::vector<uint8_t>(pkt));
|
||||
id(midi_io).notify();
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
name: P Restart
|
||||
- platform: template
|
||||
name: P Note On
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x3C, 0x64};'
|
||||
- platform: template
|
||||
name: P Note Off
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x80, 0x3C, 0x00};'
|
||||
- platform: template
|
||||
name: P Control Change
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xB0, 0x07, 0x40};'
|
||||
- platform: template
|
||||
name: P Program Change
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xC5, 0x05};'
|
||||
- platform: template
|
||||
name: P Pitch Bend
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xE0, 0x7F, 0x7F};'
|
||||
- platform: template
|
||||
name: P Two Notes One Packet
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x3C, 0x64, 0x81, 0x91, 0x3E, 0x50};'
|
||||
- platform: template
|
||||
name: P Running Status
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x40, 0x64, 0x41, 0x64};'
|
||||
- platform: template
|
||||
name: P Sysex One Packet
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF0, 0x7E, 0x7F, 0x06, 0x01, 0x80, 0xF7};'
|
||||
- platform: template
|
||||
name: P Sysex Split
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF0, 0x41, 0x10, 0x42};'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x12, 0x00, 0x40, 0x80, 0xF7};'
|
||||
- platform: template
|
||||
name: P Sysex Oversize
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'std::vector<uint8_t> v{0x80, 0x80, 0xF0}; for (int i = 0; i < 17; i++) v.push_back(i); return v;'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'std::vector<uint8_t> v{0x80}; for (int i = 0; i < 19; i++) v.push_back(0x20 + i); return v;'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'std::vector<uint8_t> v{0x80}; for (int i = 0; i < 19; i++) v.push_back(0x40 + i); return v;'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'std::vector<uint8_t> v{0x80}; for (int i = 0; i < 19; i++) v.push_back(0x60 + i); return v;'
|
||||
- delay: 200ms
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF7};'
|
||||
- platform: template
|
||||
name: P Sysex Cut Short
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF0, 0x41, 0x10, 0x80, 0x90, 0x3C, 0x64};'
|
||||
- platform: template
|
||||
name: P Clock
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xF8};'
|
||||
- platform: template
|
||||
name: P Aftertouch
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0xD3, 0x45};'
|
||||
- platform: template
|
||||
name: P Orphan Data Byte
|
||||
on_press:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x3C, 0x64};'
|
||||
- platform: template
|
||||
name: P Burst
|
||||
on_press:
|
||||
- repeat:
|
||||
count: 50
|
||||
then:
|
||||
- script.execute:
|
||||
id: emit
|
||||
pkt: !lambda 'return {0x80, 0x80, 0x90, 0x30, 0x40, 0x80, 0x80, 0x30, 0x00};'
|
||||
- delay: 20ms
|
||||
@@ -0,0 +1,10 @@
|
||||
esphome:
|
||||
name: upd32
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
packages:
|
||||
common: !include ../upd-common.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
esphome:
|
||||
name: upd8266
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
packages:
|
||||
common: !include ../upd-common.yaml
|
||||
@@ -0,0 +1,52 @@
|
||||
substitutions:
|
||||
base: !secret manifest_base
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
timeout: 10s
|
||||
|
||||
ota:
|
||||
- platform: http_request
|
||||
id: ota_http
|
||||
|
||||
update:
|
||||
- platform: http_request
|
||||
id: upd
|
||||
name: Firmware
|
||||
source: ${base}/stable.json
|
||||
update_interval: 1h
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Check
|
||||
on_press:
|
||||
- lambda: id(upd).check();
|
||||
- platform: template
|
||||
name: URL Slow Cstr
|
||||
on_press:
|
||||
- lambda: |-
|
||||
std::string url = "${base}/slow.json";
|
||||
id(upd).set_source_url(url.c_str());
|
||||
- platform: template
|
||||
name: URL Stable Literal
|
||||
on_press:
|
||||
- lambda: id(upd).set_source_url("${base}/stable.json");
|
||||
- platform: template
|
||||
name: Heap
|
||||
on_press:
|
||||
- lambda: |-
|
||||
#ifdef USE_ESP8266
|
||||
ESP_LOGI("harness", "heap free %u", ESP.getFreeHeap());
|
||||
#else
|
||||
ESP_LOGI("harness", "heap free %u", (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
|
||||
#endif
|
||||
@@ -0,0 +1,95 @@
|
||||
substitutions:
|
||||
base: !secret manifest_base
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
on_message:
|
||||
- level: DEBUG
|
||||
then:
|
||||
- lambda: |-
|
||||
// Inline check path: replace the URL from inside the check (ESP8266 runs the check on the main loop)
|
||||
if (id(swap_in_check) && strstr(message, "Content-Length") != nullptr) {
|
||||
id(swap_in_check) = false;
|
||||
std::string url = "${base}/beta.json";
|
||||
id(upd).set_source_url(url);
|
||||
ESP_LOGI("harness", "URL replaced from inside the check");
|
||||
}
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
http_request:
|
||||
verify_ssl: false
|
||||
timeout: 10s
|
||||
|
||||
ota:
|
||||
- platform: http_request
|
||||
id: ota_http
|
||||
|
||||
update:
|
||||
- platform: http_request
|
||||
id: upd
|
||||
name: Firmware
|
||||
source: ${base}/stable.json
|
||||
update_interval: 1h
|
||||
|
||||
globals:
|
||||
- id: swap_in_check
|
||||
type: bool
|
||||
initial_value: "false"
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Check
|
||||
on_press:
|
||||
- lambda: id(upd).check();
|
||||
- platform: template
|
||||
name: URL Beta String
|
||||
on_press:
|
||||
- lambda: |-
|
||||
std::string url = "${base}/beta.json";
|
||||
id(upd).set_source_url(url);
|
||||
- platform: template
|
||||
name: URL Slow Cstr
|
||||
on_press:
|
||||
- lambda: |-
|
||||
std::string url = "${base}/slow.json";
|
||||
id(upd).set_source_url(url.c_str());
|
||||
- platform: template
|
||||
name: URL Stable Literal
|
||||
on_press:
|
||||
- lambda: id(upd).set_source_url("${base}/stable.json");
|
||||
- platform: template
|
||||
name: URL Temporary
|
||||
on_press:
|
||||
- lambda: id(upd).set_source_url(std::string("${base}/beta.json"));
|
||||
- platform: template
|
||||
name: Same URL Back
|
||||
on_press:
|
||||
- lambda: id(upd).set_source_url(id(upd).get_source_url());
|
||||
- platform: template
|
||||
name: Check Then Swap
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// ESP32: the check task reads slow.json while the URL is replaced under it
|
||||
id(upd).check();
|
||||
std::string url = "${base}/beta.json";
|
||||
id(upd).set_source_url(url);
|
||||
- platform: template
|
||||
name: Arm Swap In Check
|
||||
on_press:
|
||||
- lambda: id(swap_in_check) = true;
|
||||
- platform: template
|
||||
name: Heap
|
||||
on_press:
|
||||
- lambda: |-
|
||||
#ifdef USE_ESP8266
|
||||
ESP_LOGI("harness", "heap free %u", ESP.getFreeHeap());
|
||||
#else
|
||||
ESP_LOGI("harness", "heap free %u", (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
|
||||
#endif
|
||||
@@ -0,0 +1,65 @@
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
watchdog_timeout: 30s
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
api:
|
||||
reboot_timeout: 0s
|
||||
|
||||
modem:
|
||||
id: atmodem
|
||||
rx_pin: GPIO26
|
||||
tx_pin: GPIO27
|
||||
baud_rate: 115200
|
||||
model: SIM7600
|
||||
apn: internet
|
||||
pin_code: "0000"
|
||||
init_at:
|
||||
- ATI
|
||||
urc:
|
||||
name: Modem URC
|
||||
reboot_timeout: ${modem_reboot_timeout}
|
||||
on_connect:
|
||||
logger.log: "modem connected"
|
||||
on_disconnect:
|
||||
logger.log: "modem disconnected"
|
||||
on_sync:
|
||||
logger.log: "modem synced"
|
||||
|
||||
sensor:
|
||||
- platform: uptime
|
||||
name: Uptime
|
||||
update_interval: 30s
|
||||
- platform: debug
|
||||
free:
|
||||
name: Heap Free
|
||||
loop_time:
|
||||
name: Loop Time
|
||||
|
||||
debug:
|
||||
update_interval: 30s
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Modem Enable
|
||||
on_press:
|
||||
- modem.enable
|
||||
- platform: template
|
||||
name: Modem Disable
|
||||
on_press:
|
||||
- modem.disable
|
||||
- platform: template
|
||||
name: Modem Send AT
|
||||
on_press:
|
||||
- modem.send_at: "AT+CGSN"
|
||||
|
||||
interval:
|
||||
- interval: 20s
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGI("alive", "uptime %u s enabled=%d connected=%d", (unsigned) (millis() / 1000),
|
||||
id(atmodem).is_enabled(), id(atmodem).is_connected());
|
||||
@@ -0,0 +1,26 @@
|
||||
substitutions:
|
||||
modem_reboot_timeout: 2min
|
||||
|
||||
esphome:
|
||||
name: modemo
|
||||
on_boot:
|
||||
priority: -100
|
||||
then:
|
||||
- modem.disable
|
||||
- modem.enable
|
||||
- if:
|
||||
condition:
|
||||
modem.enabled:
|
||||
then:
|
||||
modem.send_at: "AT+CGSN"
|
||||
else:
|
||||
modem.enable:
|
||||
- if:
|
||||
condition:
|
||||
not:
|
||||
modem.connected:
|
||||
then:
|
||||
logger.log: modem not connected at boot
|
||||
|
||||
packages:
|
||||
common: !include modem-common.yaml
|
||||
@@ -0,0 +1,12 @@
|
||||
substitutions:
|
||||
modem_reboot_timeout: 0s
|
||||
|
||||
esphome:
|
||||
name: modemw
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
packages:
|
||||
common: !include modem-common.yaml
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
name: sy8266
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
packages:
|
||||
common: !include ../sy-common.yaml
|
||||
|
||||
store_yaml:
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: sy8266s
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_key
|
||||
reboot_timeout: 0s
|
||||
|
||||
packages:
|
||||
common: !include ../sy-common.yaml
|
||||
|
||||
store_yaml:
|
||||
include_secrets: true
|
||||
@@ -0,0 +1,24 @@
|
||||
# shared package for the store_yaml hardware test
|
||||
substitutions:
|
||||
greeting: "hello from the package"
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_ssid
|
||||
password: !secret wifi_password
|
||||
power_save_mode: none
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
password: recoverme123
|
||||
|
||||
sensor:
|
||||
- platform: template
|
||||
name: Package Sensor
|
||||
lambda: return 42.0;
|
||||
update_interval: 60s
|
||||
|
||||
packages:
|
||||
extra: !include sy-extra.yaml
|
||||
@@ -0,0 +1,6 @@
|
||||
# second level include
|
||||
text_sensor:
|
||||
- platform: template
|
||||
name: Greeting
|
||||
lambda: return std::string("${greeting}");
|
||||
update_interval: 60s
|
||||
@@ -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
|
||||
|
||||
@@ -446,8 +446,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`)
|
||||
|
||||
+1
-15
@@ -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
|
||||
@@ -175,11 +174,6 @@ esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/ds248x/* @tomwellnitz
|
||||
esphome/components/ds3231/* @linkedupbits
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
esphome/components/duco/* @kokx
|
||||
esphome/components/duco/number/* @kokx
|
||||
esphome/components/duco/select/* @kokx
|
||||
esphome/components/duco/sensor/* @kokx
|
||||
esphome/components/duco/text_sensor/* @kokx
|
||||
esphome/components/duty_time/* @dudanov
|
||||
esphome/components/ecocomfort2/* @gledian
|
||||
esphome/components/ee895/* @Stock-M
|
||||
@@ -331,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
|
||||
@@ -363,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
|
||||
@@ -374,13 +366,11 @@ 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
|
||||
esphome/components/max17043/* @blacknell
|
||||
esphome/components/max17048/* @B48D81EFCC @ryansch
|
||||
esphome/components/max170xx/* @ryansch
|
||||
esphome/components/max17048/* @B48D81EFCC
|
||||
esphome/components/max31865/* @DAVe3283
|
||||
esphome/components/max31888/* @JiriPrchal
|
||||
esphome/components/max44009/* @berfenger
|
||||
@@ -468,8 +458,6 @@ esphome/components/openthread/* @mrene
|
||||
esphome/components/opt3001/* @ccutrer
|
||||
esphome/components/ota/* @esphome/core
|
||||
esphome/components/output/* @esphome/core
|
||||
esphome/components/packet_interface/* @clydebarrow
|
||||
esphome/components/packet_interface/packet_transport/* @clydebarrow
|
||||
esphome/components/packet_transport/* @clydebarrow
|
||||
esphome/components/pca6416a/* @Mat931
|
||||
esphome/components/pca9505/* @ajfriesen @ssieb
|
||||
@@ -549,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
|
||||
@@ -690,7 +677,6 @@ esphome/components/tuya/water_heater/* @iago-veiga
|
||||
esphome/components/uart/* @esphome/core
|
||||
esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
esphome/components/uart/packet_interface/* @clydebarrow
|
||||
esphome/components/uart/packet_transport/* @clydebarrow
|
||||
esphome/components/uart_mux/* @kbx81
|
||||
esphome/components/uart_tcp/* @Bascht74
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "ch422g.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ch422g {
|
||||
@@ -60,7 +59,11 @@ bool CH422GComponent::digital_read(uint8_t pin) {
|
||||
}
|
||||
|
||||
void CH422GComponent::digital_write(uint8_t pin, bool value) {
|
||||
assign_bits(this->output_bits_, 1 << pin, value);
|
||||
if (value) {
|
||||
this->output_bits_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_bits_ &= ~(1 << pin);
|
||||
}
|
||||
this->write_outputs_();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "ch423.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
@@ -63,7 +62,11 @@ bool CH423Component::digital_read(uint8_t pin) {
|
||||
}
|
||||
|
||||
void CH423Component::digital_write(uint8_t pin, bool value) {
|
||||
assign_bits(this->output_bits_, 1 << pin, value);
|
||||
if (value) {
|
||||
this->output_bits_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_bits_ &= ~(1 << pin);
|
||||
}
|
||||
this->write_outputs_();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -1,87 +0,0 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import time, uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DISCOVERY, CONF_ID, CONF_TIME, CONF_TIME_ID
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kokx"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
DOMAIN = "duco"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_DISABLE_CRC = "disable_crc"
|
||||
CONF_DUCO_ID = "duco_id"
|
||||
|
||||
duco_ns = cg.esphome_ns.namespace("duco")
|
||||
Duco = duco_ns.class_("Duco", cg.Component, uart.UARTDevice)
|
||||
# Base of every class that sends requests through the hub
|
||||
DucoDevice = duco_ns.class_(
|
||||
"DucoDevice", cg.PollingComponent, cg.Parented.template(Duco)
|
||||
)
|
||||
DucoDiscovery = duco_ns.class_("DucoDiscovery", DucoDevice)
|
||||
DucoTime = duco_ns.class_("DucoTime", cg.PollingComponent, cg.Parented.template(Duco))
|
||||
|
||||
# Entities of the platforms take the hub with this
|
||||
DUCO_COMPONENT_SCHEMA = cv.Schema({cv.GenerateID(CONF_DUCO_ID): cv.use_id(Duco)})
|
||||
|
||||
DISCOVERY_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(DucoDiscovery)}).extend(
|
||||
cv.COMPONENT_SCHEMA
|
||||
)
|
||||
|
||||
TIME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DucoTime),
|
||||
cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
|
||||
}
|
||||
).extend(cv.polling_component_schema("15min"))
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(Duco),
|
||||
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_DISCOVERY): DISCOVERY_SCHEMA,
|
||||
cv.Optional(CONF_TIME): TIME_SCHEMA,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
DOMAIN,
|
||||
baud_rate=57600,
|
||||
require_tx=True,
|
||||
require_rx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
_request_device_slot = cg.slot_counter("DUCO_DEVICE_COUNT")
|
||||
|
||||
|
||||
async def register_duco_device(var: cg.MockObj, config: ConfigType, hub_id: ID) -> None:
|
||||
"""Register a device that sends requests, and give it an open request slot on its hub."""
|
||||
await cg.register_component(var, config)
|
||||
hub = await cg.get_variable(hub_id)
|
||||
await cg.register_parented(var, hub)
|
||||
_request_device_slot(str(hub_id))
|
||||
cg.add(hub.register_device(var))
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await uart.new_uart_component(config)
|
||||
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
|
||||
|
||||
if (discovery := config.get(CONF_DISCOVERY)) is not None:
|
||||
discovery_var = cg.new_Pvariable(discovery[CONF_ID])
|
||||
await register_duco_device(discovery_var, discovery, config[CONF_ID])
|
||||
|
||||
if (time_config := config.get(CONF_TIME)) is not None:
|
||||
clock = await cg.get_variable(time_config[CONF_TIME_ID])
|
||||
time_var = cg.new_Pvariable(time_config[CONF_ID], clock)
|
||||
await cg.register_component(time_var, time_config)
|
||||
await cg.register_parented(time_var, var)
|
||||
@@ -1,273 +0,0 @@
|
||||
#include "duco.h"
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "duco");
|
||||
|
||||
static constexpr uint8_t HEADER_START = 0xAA;
|
||||
static constexpr uint8_t HEADER_END = 0x55;
|
||||
static constexpr uint8_t STUFFING = 0x01; // follows an 0xAA inside a message, not part of it
|
||||
static constexpr uint32_t FRAME_GAP_MS = 50;
|
||||
|
||||
static constexpr uint32_t DISCOVERY_START_ID = 0;
|
||||
static constexpr uint32_t DISCOVERY_STEP_ID = 1;
|
||||
static constexpr uint32_t DISCOVERY_START_MS = 16000;
|
||||
static constexpr uint32_t DISCOVERY_STEP_MS = 1600;
|
||||
static constexpr uint8_t DISCOVERY_MAX_ATTEMPTS = 3;
|
||||
|
||||
void Duco::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
// Read first: after a slow loop pass the rest of a message is already waiting, which is not silence
|
||||
this->read_available([this, now](uint8_t byte) {
|
||||
if (this->parse_byte_(byte)) {
|
||||
this->last_byte_ = now;
|
||||
} else {
|
||||
this->reset_frame_();
|
||||
}
|
||||
});
|
||||
|
||||
// The last message of a burst has no header after it, so it ends on silence
|
||||
if (this->rx_size_ != 0 && now - this->last_byte_ > FRAME_GAP_MS) {
|
||||
this->finalize_message_();
|
||||
this->reset_frame_();
|
||||
}
|
||||
}
|
||||
|
||||
void Duco::reset_frame_() {
|
||||
this->rx_size_ = 0;
|
||||
this->escaped_ = false;
|
||||
}
|
||||
|
||||
void Duco::close_request(DucoDevice *device) {
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.device == device) {
|
||||
pending.function = 0;
|
||||
pending.missed = false;
|
||||
this->update_warning_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Duco::parse_byte_(uint8_t byte) {
|
||||
const size_t at = this->rx_size_;
|
||||
if (at >= MAX_FRAME_SIZE) {
|
||||
ESP_LOGV(TAG, "Message longer than %u bytes dropped", static_cast<unsigned>(MAX_FRAME_SIZE));
|
||||
return false;
|
||||
}
|
||||
const uint8_t prev = at > 0 ? this->rx_buffer_[at - 1] : 0;
|
||||
|
||||
if (at == 1 && prev == HEADER_START && byte == HEADER_END)
|
||||
return false; // header of the next message
|
||||
|
||||
if (at >= 2) {
|
||||
const bool escaped = this->escaped_;
|
||||
this->escaped_ = false;
|
||||
if (prev == HEADER_START && !escaped) {
|
||||
if (byte == STUFFING) {
|
||||
this->escaped_ = true;
|
||||
return true;
|
||||
}
|
||||
if (at >= 3 && byte == HEADER_END) {
|
||||
this->rx_size_--; // drop the 0xAA of the header
|
||||
this->finalize_message_();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this->rx_buffer_[this->rx_size_++] = byte;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Duco::finalize_message_() {
|
||||
const uint8_t *raw = this->rx_buffer_.data();
|
||||
// length (function, id and data), function, id, data, CRC low, CRC high
|
||||
if (this->rx_size_ < 5 || raw[0] < 2 || this->rx_size_ < raw[0] + 3u) {
|
||||
ESP_LOGV(TAG, "Malformed message dropped: %u bytes, length byte %u", this->rx_size_, raw[0]);
|
||||
return;
|
||||
}
|
||||
const uint8_t length = raw[0];
|
||||
|
||||
const uint16_t computed_crc = crc16(raw, length + 1);
|
||||
const uint16_t remote_crc = encode_uint16(raw[length + 2], raw[length + 1]);
|
||||
if (computed_crc != remote_crc) {
|
||||
if (!this->disable_crc_) {
|
||||
ESP_LOGV(TAG, "CRC check failed: %04X != %04X", computed_crc, remote_crc);
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "CRC check failed, ignored: %04X != %04X", computed_crc, remote_crc);
|
||||
}
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
// The box first acknowledges a request with function + 1, then answers with function + 2
|
||||
const DucoMessage message{raw[2], raw + 3, static_cast<uint8_t>(length - 2)};
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.function != 0 && pending.id == message.id && pending.function + 2 == raw[1]) {
|
||||
pending.function = 0;
|
||||
pending.missed = false;
|
||||
this->update_warning_();
|
||||
pending.device->receive_response(message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Acknowledgements (odd function codes) are expected and never match
|
||||
if ((raw[1] & 1) == 0) {
|
||||
ESP_LOGV(TAG, "Answer %02X with id %u matches no open request", raw[1], message.id);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void Duco::update_warning_() {
|
||||
for (const auto &pending : this->pending_) {
|
||||
if (pending.missed) {
|
||||
this->status_set_warning(LOG_STR("No answer from the box"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->status_clear_warning();
|
||||
}
|
||||
#endif
|
||||
|
||||
void Duco::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Duco:\n"
|
||||
" CRC Disabled: %s",
|
||||
YESNO(this->disable_crc_));
|
||||
}
|
||||
|
||||
uint8_t Duco::next_id_() {
|
||||
do {
|
||||
this->last_id_++;
|
||||
} while (this->last_id_ == HEADER_START || this->last_id_ == HEADER_END);
|
||||
return this->last_id_;
|
||||
}
|
||||
|
||||
void Duco::send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data) {
|
||||
if (data.size() > MAX_REQUEST_DATA) {
|
||||
ESP_LOGE(TAG, "Request of %u bytes is too long", static_cast<unsigned>(data.size()));
|
||||
return;
|
||||
}
|
||||
// Not byte stuffed, as in the author's original and no capture shows a request with 0xAA. An 0xAA followed by any
|
||||
// byte but 0x01 or 0x55 reads the same with or without unstuffing on the box, so only those rare pairs are
|
||||
// unverified.
|
||||
const size_t size = data.size();
|
||||
const uint8_t id = this->next_id_();
|
||||
uint8_t frame[5 + MAX_REQUEST_DATA + 2] = {HEADER_START, HEADER_END, static_cast<uint8_t>(size + 2), function, id};
|
||||
std::copy_n(data.begin(), size, frame + 5);
|
||||
const uint16_t crc = crc16(frame + 2, size + 3);
|
||||
frame[size + 5] = crc & 0xFF;
|
||||
frame[size + 6] = crc >> 8;
|
||||
this->write_array(frame, size + 7);
|
||||
this->flush();
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.device == device) {
|
||||
pending.missed = pending.function != 0;
|
||||
if (pending.missed) {
|
||||
ESP_LOGV(TAG, "No answer to request %02X with id %u", pending.function, pending.id);
|
||||
this->update_warning_();
|
||||
}
|
||||
pending.id = id;
|
||||
pending.function = function;
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
ESP_LOGV_HEX(TAG, frame + 2, size + 5, sizeof(frame), "Sent %s");
|
||||
}
|
||||
|
||||
static const LogString *node_type_name(uint8_t type) {
|
||||
switch (type) {
|
||||
case 8:
|
||||
return LOG_STR("UCBAT");
|
||||
case 9:
|
||||
return LOG_STR("UC");
|
||||
case 10:
|
||||
return LOG_STR("UCRH");
|
||||
case 12:
|
||||
return LOG_STR("UCCO2");
|
||||
case 13:
|
||||
return LOG_STR("VLV");
|
||||
case 17:
|
||||
return LOG_STR("BOX");
|
||||
case 18:
|
||||
return LOG_STR("SWITCH");
|
||||
default:
|
||||
return LOG_STR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
void DucoDiscovery::setup() {
|
||||
// Give the box time to start before walking its node addresses
|
||||
this->set_timeout(DISCOVERY_START_ID, DISCOVERY_START_MS, [this]() {
|
||||
this->set_interval(DISCOVERY_STEP_ID, DISCOVERY_STEP_MS, [this]() { this->update(); });
|
||||
});
|
||||
}
|
||||
|
||||
void DucoDiscovery::update() {
|
||||
if (this->attempts_ == DISCOVERY_MAX_ATTEMPTS) {
|
||||
ESP_LOGV(TAG, "No answer for node %u", this->next_node_);
|
||||
this->next_node_++;
|
||||
this->attempts_ = 0;
|
||||
}
|
||||
if (this->next_node_ >= NODE_COUNT) {
|
||||
this->cancel_interval(DISCOVERY_STEP_ID);
|
||||
this->parent_->close_request(this);
|
||||
return;
|
||||
}
|
||||
// Without an answer by the next step the same node is asked again; a retry is not a missed answer
|
||||
if (this->attempts_ > 0)
|
||||
this->parent_->close_request(this);
|
||||
ESP_LOGV(TAG, "Discovering node %u", this->next_node_);
|
||||
this->attempts_++;
|
||||
this->parent_->send(this, FUNCTION_NODE, {0x01, this->next_node_});
|
||||
}
|
||||
|
||||
void DucoDiscovery::receive_response(const DucoMessage &message) {
|
||||
// Only the latest attempt is open, so a late answer to an earlier one never arrives here
|
||||
if (message.size >= 1 && this->next_node_ < NODE_COUNT) {
|
||||
if (message.data[0] != 0) {
|
||||
ESP_LOGV(TAG, "Found node %u: type %u", this->next_node_, message.data[0]);
|
||||
}
|
||||
this->node_types_[this->next_node_++] = message.data[0];
|
||||
this->attempts_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void DucoDiscovery::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Discovered nodes:");
|
||||
for (uint8_t node = 0; node < NODE_COUNT; node++) {
|
||||
const uint8_t type = this->node_types_[node];
|
||||
if (type != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Node %u: type %u (%s)", node, type, LOG_STR_ARG(node_type_name(type)));
|
||||
}
|
||||
}
|
||||
if (this->next_node_ < NODE_COUNT) {
|
||||
ESP_LOGCONFIG(TAG, " Still discovering, at node %u", this->next_node_);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_TIME
|
||||
void DucoTime::update() {
|
||||
const ESPTime now = this->clock_->now();
|
||||
if (!now.is_valid()) {
|
||||
ESP_LOGV(TAG, "Time is not valid yet");
|
||||
return;
|
||||
}
|
||||
const uint32_t ts = now.timestamp;
|
||||
this->parent_->send(nullptr, FUNCTION_PARAMETER,
|
||||
{0x05, static_cast<uint8_t>(ts), static_cast<uint8_t>(ts >> 8), static_cast<uint8_t>(ts >> 16),
|
||||
static_cast<uint8_t>(ts >> 24), 0x00, 0x00, 0x00, 0x00});
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,113 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <initializer_list>
|
||||
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_TIME
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
// Function codes of the requests; the box answers a request with function + 2
|
||||
static constexpr uint8_t FUNCTION_NODE = 0x0c; // discovery, status and mode of a node
|
||||
static constexpr uint8_t FUNCTION_NODE_DATA = 0x10; // a block of a node's data (sensors, serial number)
|
||||
static constexpr uint8_t FUNCTION_PARAMETER = 0x24; // read or write a box parameter, set the clock
|
||||
|
||||
/// A received message; data points into the receive buffer and is valid only during the callback.
|
||||
struct DucoMessage {
|
||||
uint8_t id;
|
||||
const uint8_t *data;
|
||||
uint8_t size;
|
||||
};
|
||||
|
||||
class DucoDevice;
|
||||
|
||||
/// The open request of one device: the answer to it is function + 2 with the same message id.
|
||||
struct DucoPendingRequest {
|
||||
DucoDevice *device;
|
||||
uint8_t id;
|
||||
uint8_t function; // 0 while nothing is open
|
||||
bool missed; // the request before this one got no answer; sits in padding
|
||||
};
|
||||
|
||||
class Duco : public uart::UARTDevice, public Component {
|
||||
public:
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void register_device(DucoDevice *device) { this->pending_.push_back({device, 0, 0, false}); }
|
||||
#endif
|
||||
|
||||
/// Forget device's open request, for a device that will not send again.
|
||||
void close_request(DucoDevice *device);
|
||||
|
||||
/// Send a request; its answer goes to device, or nowhere if nullptr. A device's new request replaces its
|
||||
/// open one, so an answer is never lost to another device's traffic.
|
||||
void send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data);
|
||||
|
||||
protected:
|
||||
// The longest answer to a request sent here is 25 bytes (node status), the longest request 9
|
||||
static constexpr size_t MAX_FRAME_SIZE = 32;
|
||||
static constexpr size_t MAX_REQUEST_DATA = 9;
|
||||
|
||||
bool parse_byte_(uint8_t byte);
|
||||
void reset_frame_();
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void update_warning_(); // warn while any device's previous request went unanswered
|
||||
#endif
|
||||
void finalize_message_();
|
||||
uint8_t next_id_();
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
StaticVector<DucoPendingRequest, DUCO_DEVICE_COUNT> pending_;
|
||||
#endif
|
||||
std::array<uint8_t, MAX_FRAME_SIZE> rx_buffer_{};
|
||||
uint32_t last_byte_{0};
|
||||
uint8_t rx_size_{0};
|
||||
uint8_t last_id_{10};
|
||||
bool escaped_{false}; // the last 0xAA was followed by a stuffing byte, so it starts no header
|
||||
bool disable_crc_{false};
|
||||
};
|
||||
|
||||
/// Every entity polls the box; deriving from PollingComponent shares its vtable.
|
||||
class DucoDevice : public PollingComponent, public Parented<Duco> {
|
||||
public:
|
||||
virtual void receive_response(const DucoMessage &message) = 0;
|
||||
};
|
||||
|
||||
/// Walks the node addresses once after boot; the nodes found are listed in the config dump.
|
||||
class DucoDiscovery : public DucoDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override; // one discovery step, run by the step interval
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
static constexpr uint8_t NODE_COUNT = 0x45;
|
||||
|
||||
std::array<uint8_t, NODE_COUNT> node_types_{}; // 0 when no node answered at that address
|
||||
uint8_t next_node_{0};
|
||||
uint8_t attempts_{0};
|
||||
};
|
||||
|
||||
#ifdef USE_TIME
|
||||
class DucoTime : public PollingComponent, public Parented<Duco> {
|
||||
public:
|
||||
explicit DucoTime(time::RealTimeClock *clock) : clock_(clock) {}
|
||||
void update() override;
|
||||
|
||||
protected:
|
||||
time::RealTimeClock *clock_;
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,34 +0,0 @@
|
||||
from esphome.components import number
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DUCO_ID,
|
||||
DUCO_COMPONENT_SCHEMA,
|
||||
DucoDevice,
|
||||
duco_ns,
|
||||
register_duco_device,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["duco"]
|
||||
CODEOWNERS = ["@kokx"]
|
||||
|
||||
DucoComfortTemperature = duco_ns.class_(
|
||||
"DucoComfortTemperature", DucoDevice, number.Number
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
number.number_schema(
|
||||
DucoComfortTemperature,
|
||||
unit_of_measurement=UNIT_CELSIUS,
|
||||
device_class=DEVICE_CLASS_TEMPERATURE,
|
||||
)
|
||||
.extend(DUCO_COMPONENT_SCHEMA)
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await number.new_number(config, min_value=10.0, max_value=25.5, step=0.1)
|
||||
await register_duco_device(var, config, config[CONF_DUCO_ID])
|
||||
@@ -1,26 +0,0 @@
|
||||
#include "number.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
static constexpr uint8_t PARAMETER_COMFORT_TEMPERATURE = 0x12;
|
||||
static constexpr uint8_t GROUP_COMFORT_TEMPERATURE = 0x0a;
|
||||
|
||||
void DucoComfortTemperature::update() {
|
||||
this->parent_->send(this, FUNCTION_PARAMETER, {0x00, PARAMETER_COMFORT_TEMPERATURE, GROUP_COMFORT_TEMPERATURE});
|
||||
}
|
||||
|
||||
void DucoComfortTemperature::receive_response(const DucoMessage &message) {
|
||||
// Reads and writes get the same answer: one byte in tenths of a degree, below 10.0 is invalid
|
||||
if (message.size >= 4 && message.data[3] >= 100)
|
||||
this->publish_state(message.data[3] / 10.0f);
|
||||
}
|
||||
|
||||
void DucoComfortTemperature::control(float value) {
|
||||
const auto tenths = static_cast<uint8_t>(lroundf(value * 10.0f));
|
||||
this->parent_->send(this, FUNCTION_PARAMETER,
|
||||
{0x01, PARAMETER_COMFORT_TEMPERATURE, GROUP_COMFORT_TEMPERATURE, tenths, 0x00, 0x00, 0x00});
|
||||
}
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/number/number.h"
|
||||
|
||||
#include "../duco.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
class DucoComfortTemperature : public DucoDevice, public number::Number {
|
||||
public:
|
||||
void update() override;
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
void control(float value) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,45 +0,0 @@
|
||||
from esphome.components import select
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DUCO_ID,
|
||||
DUCO_COMPONENT_SCHEMA,
|
||||
DucoDevice,
|
||||
duco_ns,
|
||||
register_duco_device,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["duco"]
|
||||
CODEOWNERS = ["@kokx"]
|
||||
|
||||
# Same order as MODE_CODES in select.cpp
|
||||
DUCO_MODE_OPTIONS = [
|
||||
"AUTO",
|
||||
"MAN1",
|
||||
"MAN2",
|
||||
"MAN3",
|
||||
"EMPT",
|
||||
"CNT1",
|
||||
"CNT2",
|
||||
"CNT3",
|
||||
"MAN1x2",
|
||||
"MAN2x2",
|
||||
"MAN3x2",
|
||||
"MAN1x3",
|
||||
"MAN2x3",
|
||||
"MAN3x3",
|
||||
]
|
||||
|
||||
DucoSelect = duco_ns.class_("DucoSelect", DucoDevice, select.Select)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
select.select_schema(DucoSelect)
|
||||
.extend(cv.polling_component_schema("3s"))
|
||||
.extend(DUCO_COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await select.new_select(config, options=DUCO_MODE_OPTIONS)
|
||||
await register_duco_device(var, config, config[CONF_DUCO_ID])
|
||||
@@ -1,47 +0,0 @@
|
||||
#include "select.h"
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "duco.select");
|
||||
|
||||
// Box code of each option, in the order of DUCO_MODE_OPTIONS in __init__.py
|
||||
static constexpr uint8_t MODE_CODES[] PROGMEM = {
|
||||
0x00, // AUTO
|
||||
0x04, // MAN1
|
||||
0x05, // MAN2
|
||||
0x06, // MAN3
|
||||
0x07, // EMPT
|
||||
0x08, // CNT1
|
||||
0x09, // CNT2
|
||||
0x0a, // CNT3
|
||||
0x84, // MAN1x2
|
||||
0x85, // MAN2x2
|
||||
0x86, // MAN3x2
|
||||
0xc4, // MAN1x3
|
||||
0xc5, // MAN2x3
|
||||
0xc6, // MAN3x3
|
||||
};
|
||||
|
||||
void DucoSelect::update() { this->parent_->send(this, FUNCTION_NODE, {0x02, 0x01}); }
|
||||
|
||||
void DucoSelect::receive_response(const DucoMessage &message) {
|
||||
if (message.size < 1)
|
||||
return;
|
||||
for (size_t i = 0; i < sizeof(MODE_CODES); i++) {
|
||||
if (progmem_read_byte(&MODE_CODES[i]) == message.data[0]) {
|
||||
this->publish_state(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGV(TAG, "Unknown mode %02X", message.data[0]);
|
||||
}
|
||||
|
||||
void DucoSelect::control(size_t index) {
|
||||
// The next update reads the new mode back
|
||||
this->parent_->send(nullptr, FUNCTION_NODE, {0x04, 0x01, progmem_read_byte(&MODE_CODES[index])});
|
||||
}
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/select/select.h"
|
||||
|
||||
#include "../duco.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
class DucoSelect : public DucoDevice, public select::Select {
|
||||
public:
|
||||
void update() override;
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
void control(size_t index) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,156 +0,0 @@
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ADDRESS,
|
||||
CONF_CO2,
|
||||
CONF_HUMIDITY,
|
||||
CONF_TEMPERATURE,
|
||||
DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
DEVICE_CLASS_DURATION,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_TEMPERATURE,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_CELSIUS,
|
||||
UNIT_PARTS_PER_MILLION,
|
||||
UNIT_PERCENT,
|
||||
UNIT_SECOND,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DUCO_ID,
|
||||
DUCO_COMPONENT_SCHEMA,
|
||||
DucoDevice,
|
||||
duco_ns,
|
||||
register_duco_device,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["duco"]
|
||||
CODEOWNERS = ["@kokx"]
|
||||
|
||||
UNIT_DAYS = "d"
|
||||
|
||||
CONF_BYPASS = "bypass"
|
||||
CONF_FILTER_REMAINING = "filter_remaining"
|
||||
CONF_FLOW_LEVEL = "flow_level"
|
||||
CONF_TEMPERATURE_EHA = "temperature_eha"
|
||||
CONF_TEMPERATURE_ETA = "temperature_eta"
|
||||
CONF_TEMPERATURE_ODA = "temperature_oda"
|
||||
CONF_TEMPERATURE_SUP = "temperature_sup"
|
||||
CONF_TIME_REMAINING = "time_remaining"
|
||||
|
||||
DucoSensor = duco_ns.class_("DucoSensor", DucoDevice, sensor.Sensor)
|
||||
DucoReading = duco_ns.enum("DucoReading", is_class=True)
|
||||
|
||||
BOX_NODE = 1
|
||||
|
||||
|
||||
def _schema(update_interval: str = "60s", **kwargs) -> cv.Schema:
|
||||
return sensor.sensor_schema(
|
||||
DucoSensor, state_class=STATE_CLASS_MEASUREMENT, **kwargs
|
||||
).extend(cv.polling_component_schema(update_interval))
|
||||
|
||||
|
||||
def _node_schema(**kwargs) -> cv.All:
|
||||
"""A list of sensors, one per node address."""
|
||||
return cv.ensure_list(
|
||||
_schema(**kwargs).extend({cv.Required(CONF_ADDRESS): cv.int_range(0, 68)})
|
||||
)
|
||||
|
||||
|
||||
_TEMPERATURE = {
|
||||
"unit_of_measurement": UNIT_CELSIUS,
|
||||
"accuracy_decimals": 1,
|
||||
"device_class": DEVICE_CLASS_TEMPERATURE,
|
||||
}
|
||||
|
||||
# key: (what is read, request argument or None for the node address, schema)
|
||||
SENSORS = {
|
||||
CONF_CO2: (
|
||||
DucoReading.DUCO_READING_CO2,
|
||||
None,
|
||||
_node_schema(
|
||||
unit_of_measurement=UNIT_PARTS_PER_MILLION,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
),
|
||||
),
|
||||
CONF_TEMPERATURE: (
|
||||
DucoReading.DUCO_READING_NODE_TEMPERATURE,
|
||||
None,
|
||||
_node_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_HUMIDITY: (
|
||||
DucoReading.DUCO_READING_HUMIDITY,
|
||||
None,
|
||||
_node_schema(
|
||||
unit_of_measurement=UNIT_PERCENT,
|
||||
accuracy_decimals=1,
|
||||
device_class=DEVICE_CLASS_HUMIDITY,
|
||||
),
|
||||
),
|
||||
CONF_FILTER_REMAINING: (
|
||||
DucoReading.DUCO_READING_FILTER_REMAINING,
|
||||
0x30,
|
||||
_schema(
|
||||
"1200s",
|
||||
unit_of_measurement=UNIT_DAYS,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
),
|
||||
),
|
||||
CONF_FLOW_LEVEL: (
|
||||
DucoReading.DUCO_READING_FLOW_LEVEL,
|
||||
BOX_NODE,
|
||||
_schema(unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0),
|
||||
),
|
||||
CONF_TIME_REMAINING: (
|
||||
DucoReading.DUCO_READING_TIME_REMAINING,
|
||||
BOX_NODE,
|
||||
_schema(
|
||||
unit_of_measurement=UNIT_SECOND,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
),
|
||||
),
|
||||
CONF_TEMPERATURE_ODA: (
|
||||
DucoReading.DUCO_READING_BOX_TEMPERATURE,
|
||||
0x00,
|
||||
_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_TEMPERATURE_SUP: (
|
||||
DucoReading.DUCO_READING_BOX_TEMPERATURE,
|
||||
0x01,
|
||||
_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_TEMPERATURE_ETA: (
|
||||
DucoReading.DUCO_READING_BOX_TEMPERATURE,
|
||||
0x02,
|
||||
_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_TEMPERATURE_EHA: (
|
||||
DucoReading.DUCO_READING_BOX_TEMPERATURE,
|
||||
0x03,
|
||||
_schema(**_TEMPERATURE),
|
||||
),
|
||||
CONF_BYPASS: (
|
||||
DucoReading.DUCO_READING_BYPASS,
|
||||
0x10,
|
||||
_schema(unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0),
|
||||
),
|
||||
}
|
||||
|
||||
CONFIG_SCHEMA = DUCO_COMPONENT_SCHEMA.extend(
|
||||
{cv.Optional(key): schema for key, (_, _, schema) in SENSORS.items()}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
for key, (reading, argument, _) in SENSORS.items():
|
||||
if (confs := config.get(key)) is None:
|
||||
continue
|
||||
for conf in confs if isinstance(confs, list) else [confs]:
|
||||
var = await sensor.new_sensor(
|
||||
conf, reading, conf.get(CONF_ADDRESS, argument)
|
||||
)
|
||||
await register_duco_device(var, conf, config[CONF_DUCO_ID])
|
||||
@@ -1,78 +0,0 @@
|
||||
#include "sensor.h"
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "duco.sensor");
|
||||
|
||||
enum class DucoValueFormat : uint8_t {
|
||||
DUCO_VALUE_FORMAT_UINT8,
|
||||
DUCO_VALUE_FORMAT_UINT16,
|
||||
DUCO_VALUE_FORMAT_INT16,
|
||||
};
|
||||
|
||||
/// A little endian field at offset in the answer. Values outside [minimum, maximum] are dropped;
|
||||
/// the box reports those for sensors that are missing or still starting.
|
||||
struct ReadingLayout {
|
||||
uint8_t function;
|
||||
uint8_t offset;
|
||||
DucoValueFormat format;
|
||||
uint8_t divisor;
|
||||
int16_t minimum;
|
||||
uint16_t maximum;
|
||||
};
|
||||
|
||||
// Indexed by DucoReading
|
||||
static constexpr ReadingLayout READINGS[] PROGMEM = {
|
||||
{FUNCTION_NODE_DATA, 4, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 1, 300, 10000}, // CO2
|
||||
{FUNCTION_NODE_DATA, 2, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 10, 0, 1000}, // node temperature
|
||||
{FUNCTION_NODE_DATA, 6, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 100, 0, 10000}, // humidity
|
||||
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 255}, // filter remaining
|
||||
{FUNCTION_NODE, 2, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 255}, // flow level
|
||||
{FUNCTION_NODE, 12, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 1, 0, 65535}, // time remaining
|
||||
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_INT16, 10, -999, 1000}, // box temperature
|
||||
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 100}, // bypass
|
||||
};
|
||||
|
||||
static ReadingLayout layout_of(DucoReading reading) {
|
||||
ReadingLayout layout;
|
||||
progmem_memcpy(&layout, &READINGS[static_cast<uint8_t>(reading)], sizeof(layout));
|
||||
return layout;
|
||||
}
|
||||
|
||||
void DucoSensor::update() {
|
||||
switch (layout_of(this->reading_).function) {
|
||||
case FUNCTION_NODE_DATA:
|
||||
this->parent_->send(this, FUNCTION_NODE_DATA, {0x01, this->argument_, 0x00, 0x49, 0x04});
|
||||
break;
|
||||
case FUNCTION_PARAMETER:
|
||||
this->parent_->send(this, FUNCTION_PARAMETER, {0x00, this->argument_, 0x09});
|
||||
break;
|
||||
case FUNCTION_NODE:
|
||||
this->parent_->send(this, FUNCTION_NODE, {0x02, this->argument_});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void DucoSensor::receive_response(const DucoMessage &message) {
|
||||
const ReadingLayout layout = layout_of(this->reading_);
|
||||
const bool wide = layout.format != DucoValueFormat::DUCO_VALUE_FORMAT_UINT8;
|
||||
if (message.size < layout.offset + (wide ? 2 : 1)) {
|
||||
ESP_LOGV(TAG, "Answer of %u bytes is too short", message.size);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t *field = message.data + layout.offset;
|
||||
int32_t value = field[0];
|
||||
if (layout.format == DucoValueFormat::DUCO_VALUE_FORMAT_UINT16) {
|
||||
value = encode_value_le<uint16_t>(field);
|
||||
} else if (layout.format == DucoValueFormat::DUCO_VALUE_FORMAT_INT16) {
|
||||
value = encode_value_le<int16_t>(field);
|
||||
}
|
||||
if (value >= layout.minimum && value <= layout.maximum)
|
||||
this->publish_state(static_cast<float>(value) / layout.divisor);
|
||||
}
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,34 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
#include "../duco.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
/// What a sensor reads; the request and where the value is in the answer are in READINGS in sensor.cpp.
|
||||
enum class DucoReading : uint8_t {
|
||||
DUCO_READING_CO2,
|
||||
DUCO_READING_NODE_TEMPERATURE,
|
||||
DUCO_READING_HUMIDITY,
|
||||
DUCO_READING_FILTER_REMAINING,
|
||||
DUCO_READING_FLOW_LEVEL,
|
||||
DUCO_READING_TIME_REMAINING,
|
||||
DUCO_READING_BOX_TEMPERATURE,
|
||||
DUCO_READING_BYPASS,
|
||||
};
|
||||
|
||||
class DucoSensor : public DucoDevice, public sensor::Sensor {
|
||||
public:
|
||||
/// argument is the node address, or the parameter number for a box parameter.
|
||||
DucoSensor(DucoReading reading, uint8_t argument) : reading_(reading), argument_(argument) {}
|
||||
|
||||
void update() override;
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
DucoReading reading_;
|
||||
uint8_t argument_;
|
||||
};
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,27 +0,0 @@
|
||||
from esphome.components import text_sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DUCO_ID,
|
||||
DUCO_COMPONENT_SCHEMA,
|
||||
DucoDevice,
|
||||
duco_ns,
|
||||
register_duco_device,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["duco"]
|
||||
CODEOWNERS = ["@kokx"]
|
||||
|
||||
DucoSerial = duco_ns.class_("DucoSerial", DucoDevice, text_sensor.TextSensor)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
text_sensor.text_sensor_schema(DucoSerial)
|
||||
.extend(cv.polling_component_schema("300s"))
|
||||
.extend(DUCO_COMPONENT_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await text_sensor.new_text_sensor(config)
|
||||
await register_duco_device(var, config, config[CONF_DUCO_ID])
|
||||
@@ -1,17 +0,0 @@
|
||||
#include "serial.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
void DucoSerial::update() { this->parent_->send(this, FUNCTION_NODE_DATA, {0x01, 0x01, 0x00, 0x1a, 0x10}); }
|
||||
|
||||
void DucoSerial::receive_response(const DucoMessage &message) {
|
||||
if (message.size > 2) {
|
||||
// The serial number starts at byte 2 and is padded with zeros
|
||||
const char *serial = reinterpret_cast<const char *>(message.data + 2);
|
||||
this->publish_state(serial, strnlen(serial, message.size - 2));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,15 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
|
||||
#include "../duco.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
class DucoSerial : public DucoDevice, public text_sensor::TextSensor {
|
||||
public:
|
||||
void update() override;
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "epaper_spi.h"
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -38,42 +37,6 @@ bool EPaperBase::init_buffer_(size_t buffer_length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EPaperBase::init_sent_frame_(size_t length) {
|
||||
if (!this->is_using_partial_update_())
|
||||
return false;
|
||||
if (this->sent_.init(length))
|
||||
return true;
|
||||
ESP_LOGW(TAG, "No memory for the previous frame; partial updates will send the whole drawn area");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t EPaperBase::buffer_hash_() const {
|
||||
uint32_t hash = FNV1_OFFSET_BASIS;
|
||||
size_t index = 0;
|
||||
while (index != this->buffer_.size()) {
|
||||
size_t length;
|
||||
const uint8_t *data = this->buffer_.get_span(index, length);
|
||||
for (size_t i = 0; i != length; i++)
|
||||
hash = fnv1a_hash_extend(hash, data[i]);
|
||||
index += length;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
void EPaperBase::set_full_bounds_() {
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
|
||||
void EPaperBase::reset_bounds_() {
|
||||
this->x_low_ = this->width_;
|
||||
this->x_high_ = 0;
|
||||
this->y_low_ = this->height_;
|
||||
this->y_high_ = 0;
|
||||
}
|
||||
|
||||
void EPaperBase::setup_pins_() const {
|
||||
for (auto *pin : this->enable_pins_) {
|
||||
pin->setup();
|
||||
@@ -270,28 +233,17 @@ void EPaperBase::process_state_() {
|
||||
// Refresh the whole panel even if nothing was drawn
|
||||
this->full_update_requested_ = false;
|
||||
this->update_count_ = 0;
|
||||
this->set_full_bounds_();
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) {
|
||||
this->set_state_(EPaperState::IDLE);
|
||||
return;
|
||||
}
|
||||
if (this->is_using_partial_update_()) {
|
||||
const uint32_t frame_hash = this->buffer_hash_();
|
||||
if (this->update_count_ != 0 && this->shown_hash_valid_ && frame_hash == this->shown_hash_) {
|
||||
ESP_LOGD(TAG, "Frame unchanged, refresh skipped");
|
||||
this->reset_bounds_();
|
||||
this->set_state_(EPaperState::IDLE);
|
||||
return;
|
||||
}
|
||||
this->shown_hash_ = frame_hash;
|
||||
}
|
||||
this->drawn_since_hash_ = false;
|
||||
// Valid again only once this frame is on the panel, so an abandoned update leaves nothing to match
|
||||
this->shown_hash_valid_ = false;
|
||||
// A full update resets update_count_ to zero, so a non-zero count marks a partial one
|
||||
this->partial_ = this->update_count_ != 0;
|
||||
this->sent_count_ = 0;
|
||||
this->set_state_(EPaperState::RESET);
|
||||
break;
|
||||
case EPaperState::INITIALISE:
|
||||
@@ -304,15 +256,10 @@ void EPaperBase::process_state_() {
|
||||
if (!this->transfer_data()) {
|
||||
return; // Not done yet, come back next loop
|
||||
}
|
||||
if (this->sent_.is_valid() && this->sent_count_ == this->sent_.size())
|
||||
this->sent_valid_ = true;
|
||||
// A draw during the chunked push may not have reached the panel, so the next update sends the whole frame
|
||||
this->sent_hash_intact_ = this->is_using_partial_update_() && !this->drawn_since_hash_;
|
||||
if (this->drawn_since_hash_) {
|
||||
this->set_full_bounds_();
|
||||
} else {
|
||||
this->reset_bounds_();
|
||||
}
|
||||
this->x_low_ = this->width_;
|
||||
this->x_high_ = 0;
|
||||
this->y_low_ = this->height_;
|
||||
this->y_high_ = 0;
|
||||
this->set_state_(EPaperState::POWER_ON);
|
||||
break;
|
||||
case EPaperState::POWER_ON:
|
||||
@@ -333,7 +280,6 @@ void EPaperBase::process_state_() {
|
||||
break;
|
||||
case EPaperState::DEEP_SLEEP:
|
||||
this->deep_sleep();
|
||||
this->shown_hash_valid_ = this->sent_hash_intact_;
|
||||
this->status_clear_warning();
|
||||
this->set_state_(EPaperState::IDLE);
|
||||
ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_);
|
||||
@@ -419,7 +365,6 @@ bool EPaperBase::rotate_coordinates_(int &x, int &y) {
|
||||
this->x_high_ = clamp_at_least(this->x_high_, x + 1);
|
||||
this->y_low_ = clamp_at_most(this->y_low_, y);
|
||||
this->y_high_ = clamp_at_least(this->y_high_, y + 1);
|
||||
this->drawn_since_hash_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -541,7 +486,6 @@ void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_star
|
||||
this->x_high_ = clamp_at_least(this->x_high_, x_high);
|
||||
this->y_low_ = clamp_at_most(this->y_low_, y_low);
|
||||
this->y_high_ = clamp_at_least(this->y_high_, y_high);
|
||||
this->drawn_since_hash_ = true;
|
||||
}
|
||||
|
||||
// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the
|
||||
|
||||
@@ -99,7 +99,10 @@ class EPaperBase : public Display,
|
||||
|
||||
// We store 8 pixels per byte
|
||||
this->buffer_.fill(pixel_color);
|
||||
this->mark_all_drawn_();
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
}
|
||||
|
||||
void clear() override {
|
||||
@@ -136,25 +139,6 @@ class EPaperBase : public Display,
|
||||
void wait_for_idle_(bool should_wait);
|
||||
void abandon_update_();
|
||||
bool init_buffer_(size_t buffer_length);
|
||||
// Allocates the copy of the previous frame; only drivers that diff against it (needs_previous_frame) call this
|
||||
bool init_sent_frame_(size_t length);
|
||||
void record_sent_(size_t index, const uint8_t *data, size_t length) {
|
||||
if (this->sent_.is_valid()) {
|
||||
this->sent_.write(index, data, length);
|
||||
this->sent_count_ += length;
|
||||
}
|
||||
}
|
||||
// Whether the driver reads the previous frame (sent_) for its differential refresh
|
||||
virtual bool needs_previous_frame() const { return false; }
|
||||
// FNV-1a hash of the current frame buffer, to detect an unchanged frame without a full copy
|
||||
uint32_t buffer_hash_() const;
|
||||
void reset_bounds_();
|
||||
void set_full_bounds_();
|
||||
// For a draw that writes the whole buffer without going through rotate_coordinates_()
|
||||
void mark_all_drawn_() {
|
||||
this->set_full_bounds_();
|
||||
this->drawn_since_hash_ = true;
|
||||
}
|
||||
void update_effective_transform_();
|
||||
bool rotate_coordinates_(int &x, int &y);
|
||||
void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, bool big_endian,
|
||||
@@ -216,13 +200,6 @@ class EPaperBase : public Display,
|
||||
size_t buffer_length_;
|
||||
size_t current_data_index_{}; // used by data transfer to track progress
|
||||
split_buffer::SplitBuffer buffer_{};
|
||||
split_buffer::SplitBuffer sent_{}; // the previous frame, kept only by drivers that diff against it
|
||||
bool sent_valid_{}; // sent_ holds a whole frame
|
||||
size_t sent_count_{}; // bytes recorded into sent_ by the update in progress
|
||||
uint32_t shown_hash_{}; // hash of the frame on the panel, to skip an unchanged update
|
||||
bool shown_hash_valid_{}; // shown_hash_ holds the hash of a frame whose refresh completed
|
||||
bool sent_hash_intact_{}; // the buffer was unchanged by the end of the push of shown_hash_
|
||||
bool drawn_since_hash_{}; // something was drawn after shown_hash_ was taken
|
||||
GPIOPin *dc_pin_{};
|
||||
GPIOPin *busy_pin_{};
|
||||
GPIOPin *reset_pin_{};
|
||||
|
||||
@@ -18,7 +18,11 @@ void EPaper4bpp::fill(Color color) {
|
||||
// We store 2 pixels per byte
|
||||
this->buffer_.fill(pixel_color + (pixel_color << 4));
|
||||
|
||||
this->mark_all_drawn_();
|
||||
// Whole buffer just changed; mark the entire canvas dirty.
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
|
||||
void EPaper4bpp::clear() {
|
||||
|
||||
@@ -71,7 +71,10 @@ void EPaperInkplate2::fill(Color color) {
|
||||
for (size_t i = half_buffer; i < this->buffer_length_; i++)
|
||||
this->buffer_[i] = red_byte;
|
||||
|
||||
this->mark_all_drawn_();
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
|
||||
void EPaperInkplate2::clear() { this->fill(COLOR_ON); }
|
||||
|
||||
@@ -30,7 +30,6 @@ void EPaperJD79660::fill(Color color) {
|
||||
|
||||
// We store 4 pixels per byte
|
||||
this->buffer_.fill(pixel_color | (pixel_color << 2) | (pixel_color << 4) | (pixel_color << 6));
|
||||
this->drawn_since_hash_ = true;
|
||||
}
|
||||
|
||||
void HOT EPaperJD79660::draw_pixel_at(int x, int y, Color color) {
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
namespace esphome::epaper_spi {
|
||||
ESPHOME_LOG_TAG(TAG, "epaper_spi.mono");
|
||||
|
||||
void EPaperMono::setup() {
|
||||
EPaperBase::setup();
|
||||
if (!this->is_failed() && this->needs_previous_frame())
|
||||
this->init_sent_frame_(this->sent_frame_length());
|
||||
}
|
||||
|
||||
void EPaperMono::refresh_screen(bool partial) {
|
||||
ESP_LOGV(TAG, "Refresh screen");
|
||||
this->cmd_data(0x22, {partial ? (uint8_t) 0xFF : (uint8_t) 0xF7});
|
||||
@@ -72,8 +66,7 @@ bool HOT EPaperMono::transfer_data() {
|
||||
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis());
|
||||
this->start_data_();
|
||||
while (this->current_data_index_ != this->y_high_) {
|
||||
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
|
||||
size_t data_idx = row_start;
|
||||
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
|
||||
for (size_t i = 0; i != row_length; i++) {
|
||||
bytes_to_send[i] = this->send_red_ && !this->send_red_as_image_ ? 0 : this->buffer_[data_idx++];
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ class EPaperMono : public EPaperBase {
|
||||
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1)
|
||||
: EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {}
|
||||
|
||||
void setup() override;
|
||||
|
||||
protected:
|
||||
void refresh_screen(bool partial) override;
|
||||
void power_on() override {}
|
||||
@@ -21,8 +19,6 @@ class EPaperMono : public EPaperBase {
|
||||
void deep_sleep() override;
|
||||
bool reset() override;
|
||||
virtual void set_window();
|
||||
// Bytes in the comparison frame; the buffer's size unless a subclass sends the frame in another layout
|
||||
virtual size_t sent_frame_length() const { return this->buffer_length_; }
|
||||
bool transfer_data() override;
|
||||
bool send_red_{true};
|
||||
bool software_reset_{true}; // send 0x12 after the hardware reset
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::epaper_spi {
|
||||
ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1677");
|
||||
|
||||
void EPaperSSD1677::setup() {
|
||||
EPaperMono::setup();
|
||||
if (!this->is_failed())
|
||||
this->init_comparison_frame_();
|
||||
}
|
||||
|
||||
void EPaperSSD1677::init_comparison_frame_() {
|
||||
if (!this->is_using_partial_update_())
|
||||
return;
|
||||
if (!this->sent_.init(this->plane_row_length_() * this->height_)) {
|
||||
ESP_LOGW(TAG, "No memory for the comparison frame; partial updates will degrade unchanged areas");
|
||||
}
|
||||
}
|
||||
|
||||
void EPaperSSD1677::plane_row(size_t y, uint8_t *out) {
|
||||
const size_t row_length = this->plane_row_length_();
|
||||
@@ -67,7 +82,7 @@ bool HOT EPaperSSD1677::transfer_data() {
|
||||
}
|
||||
this->write_array(data, length);
|
||||
if (this->plane_ == 1)
|
||||
this->record_sent_(this->current_data_index_, data, length);
|
||||
this->sent_.write(this->current_data_index_, data, length);
|
||||
this->current_data_index_ += length;
|
||||
if (this->current_data_index_ != plane_length && millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
// Let the main loop run and come back next loop
|
||||
|
||||
@@ -26,12 +26,12 @@ class EPaperSSD1677 : public EPaperMono {
|
||||
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1)
|
||||
: EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {}
|
||||
|
||||
void setup() override;
|
||||
|
||||
protected:
|
||||
// Reads the previous frame to copy it into the other RAM bank on a full refresh
|
||||
bool needs_previous_frame() const override { return true; }
|
||||
// Allocates the comparison frame as setup() would, for tests that run without a bus.
|
||||
void init_comparison_frame_() { this->init_sent_frame_(this->sent_frame_length()); }
|
||||
size_t sent_frame_length() const override { return this->plane_row_length_() * this->height_; }
|
||||
// Allocates the comparison frame when partial updates are enabled. Separate from setup() so it
|
||||
// can run without a bus.
|
||||
void init_comparison_frame_();
|
||||
// Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds.
|
||||
size_t plane_row_length_() const { return (this->width_ + 7) / 8; }
|
||||
// Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white.
|
||||
@@ -39,7 +39,8 @@ class EPaperSSD1677 : public EPaperMono {
|
||||
bool reset() override;
|
||||
bool transfer_data() override;
|
||||
|
||||
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
|
||||
split_buffer::SplitBuffer sent_{}; // the frame last sent to 0x24, i.e. what the panel shows
|
||||
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
|
||||
};
|
||||
|
||||
} // namespace esphome::epaper_spi
|
||||
@@ -29,7 +29,10 @@ void EPaperSSD1677Gray4::fill(Color color) {
|
||||
}
|
||||
const uint8_t level = color_to_gray4(color);
|
||||
this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6)));
|
||||
this->mark_all_drawn_();
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
}
|
||||
|
||||
void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) {
|
||||
@@ -80,14 +83,14 @@ bool HOT EPaperSSD1677Gray4::transfer_data() {
|
||||
this->start_data_();
|
||||
while (this->current_data_index_ != this->y_high_) {
|
||||
const size_t src_row = this->current_data_index_ * this->row_width_ + this->x_low_ / 4;
|
||||
for (size_t i = 0; i != plane_row_length; i++)
|
||||
bytes_to_send[i] = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
|
||||
// What the next partial update compares against: the high bits, as a black-and-white frame.
|
||||
if (first_pass)
|
||||
this->record_sent_(this->current_data_index_ * plane_row_length, bytes_to_send, plane_row_length);
|
||||
// The OTP grayscale waveform treats data as inverted relative to monochrome
|
||||
for (size_t i = 0; i != plane_row_length; i++)
|
||||
bytes_to_send[i] = (uint8_t) ~bytes_to_send[i];
|
||||
for (size_t i = 0; i != plane_row_length; i++) {
|
||||
const uint8_t plane = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
|
||||
// The OTP grayscale waveform treats data as inverted relative to monochrome
|
||||
bytes_to_send[i] = (uint8_t) ~plane;
|
||||
// What the next partial update compares against: the high bits, as a black-and-white frame.
|
||||
if (first_pass && this->sent_.is_valid())
|
||||
this->sent_[this->current_data_index_ * plane_row_length + i] = plane;
|
||||
}
|
||||
++this->current_data_index_;
|
||||
this->write_array(bytes_to_send, plane_row_length);
|
||||
if (millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
|
||||
@@ -1,50 +1,10 @@
|
||||
#include "epaper_spi_ssd1681.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::epaper_spi {
|
||||
ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1681");
|
||||
|
||||
void EPaperSSD1681::bounds_from_changes_() {
|
||||
if (!this->sent_valid_ || this->sent_.size() != this->buffer_.size())
|
||||
return;
|
||||
bool changed = false;
|
||||
uint16_t row_low = this->height_, row_high = 0, col_low = this->row_width_, col_high = 0;
|
||||
for (uint16_t row = 0; row != this->height_; row++) {
|
||||
const size_t base = row * this->row_width_;
|
||||
const size_t end = base + this->row_width_;
|
||||
size_t index = base;
|
||||
while (index != end) {
|
||||
size_t new_length, old_length;
|
||||
const uint8_t *new_bytes = this->buffer_.get_span(index, new_length);
|
||||
const uint8_t *old_bytes = this->sent_.get_span(index, old_length);
|
||||
const size_t length = std::min({new_length, old_length, end - index});
|
||||
if (std::memcmp(new_bytes, old_bytes, length) != 0) {
|
||||
for (size_t i = 0; i != length; i++) {
|
||||
if (new_bytes[i] == old_bytes[i])
|
||||
continue;
|
||||
const auto col = static_cast<uint16_t>(index + i - base);
|
||||
changed = true;
|
||||
row_low = std::min(row_low, row);
|
||||
row_high = std::max<uint16_t>(row_high, row + 1);
|
||||
col_low = std::min(col_low, col);
|
||||
col_high = std::max<uint16_t>(col_high, col + 1);
|
||||
}
|
||||
}
|
||||
index += length;
|
||||
}
|
||||
}
|
||||
if (!changed)
|
||||
return;
|
||||
this->x_low_ = col_low * 8;
|
||||
this->x_high_ = std::min<uint16_t>(col_high * 8, this->width_);
|
||||
this->y_low_ = row_low;
|
||||
this->y_high_ = row_high;
|
||||
}
|
||||
|
||||
bool HOT EPaperSSD1681::transfer_data() {
|
||||
if (this->current_data_index_ == 0 && this->send_red_) {
|
||||
if (!this->partial_) {
|
||||
@@ -55,7 +15,6 @@ bool HOT EPaperSSD1681::transfer_data() {
|
||||
this->y_high_ = this->height_;
|
||||
} else {
|
||||
// 0x26 already holds the image on the panel: the controller copies 0x24 into it after each refresh
|
||||
this->bounds_from_changes_();
|
||||
this->set_window();
|
||||
this->send_red_ = false;
|
||||
}
|
||||
|
||||
@@ -16,10 +16,6 @@ class EPaperSSD1681 : public EPaperSSD1683 {
|
||||
}
|
||||
|
||||
protected:
|
||||
// Reads the previous frame to compute the changed window for a partial refresh
|
||||
bool needs_previous_frame() const override { return true; }
|
||||
// Shrinks the update bounds to the bytes that differ from the frame last sent
|
||||
void bounds_from_changes_();
|
||||
bool transfer_data() override;
|
||||
void refresh_screen(bool partial) override;
|
||||
};
|
||||
|
||||
@@ -71,15 +71,12 @@ bool HOT EPaperSSD1683::transfer_data() {
|
||||
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis());
|
||||
this->start_data_();
|
||||
while (this->current_data_index_ != this->y_high_) {
|
||||
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_;
|
||||
size_t data_idx = row_start;
|
||||
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_;
|
||||
for (size_t i = 0; i != row_length; i++) {
|
||||
bytes_to_send[i] = this->buffer_[data_idx++];
|
||||
}
|
||||
++this->current_data_index_;
|
||||
this->write_array(&bytes_to_send.front(), row_length); // NOLINT
|
||||
if (!this->send_red_)
|
||||
this->record_sent_(row_start, &bytes_to_send.front(), row_length);
|
||||
if (millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
// Let the main loop run and come back next loop
|
||||
this->disable();
|
||||
|
||||
@@ -232,7 +232,6 @@ void EPaperT133A01::fill(Color color) {
|
||||
}
|
||||
auto pixel_color = color_to_index(color);
|
||||
this->buffer_.fill(pixel_color + (pixel_color << 4));
|
||||
this->drawn_since_hash_ = true;
|
||||
}
|
||||
|
||||
void EPaperT133A01::draw_pixel_at(int x, int y, Color color) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "epaper_waveshare_bwr.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -37,9 +36,17 @@ void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) {
|
||||
|
||||
const auto bwr = color_to_bwr(color);
|
||||
|
||||
assign_bits(this->buffer_[pos], bit, bwr == BwrState::BWR_BLACK);
|
||||
if (bwr == BwrState::BWR_BLACK) {
|
||||
this->buffer_[pos] |= bit;
|
||||
} else {
|
||||
this->buffer_[pos] &= ~bit;
|
||||
}
|
||||
|
||||
assign_bits(this->buffer_[red_offset + pos], bit, bwr == BwrState::BWR_RED);
|
||||
if (bwr == BwrState::BWR_RED) {
|
||||
this->buffer_[red_offset + pos] |= bit;
|
||||
} else {
|
||||
this->buffer_[red_offset + pos] &= ~bit;
|
||||
}
|
||||
}
|
||||
|
||||
void EPaperWaveshareBWR::fill(Color color) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "es8156.h"
|
||||
#include "es8156_const.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
@@ -101,7 +100,11 @@ bool ES8156::set_mute_state_(bool mute_state) {
|
||||
|
||||
ESP_LOGV(TAG, "Read ES8156_REG13_DAC_MUTE: %u", reg13);
|
||||
|
||||
assign_bits(reg13, BIT(1) | BIT(2), mute_state);
|
||||
if (mute_state) {
|
||||
reg13 |= BIT(1) | BIT(2);
|
||||
} else {
|
||||
reg13 &= ~(BIT(1) | BIT(2));
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Setting ES8156_REG13_DAC_MUTE to %u (muted: %s)", reg13, YESNO(mute_state));
|
||||
return this->write_byte(ES8156_REG13_DAC_MUTE, reg13);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "es8311.h"
|
||||
#include "es8311_const.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
@@ -155,7 +154,11 @@ bool ES8311::configure_clock_() {
|
||||
// Register 0x06
|
||||
uint8_t reg06;
|
||||
ES8311_ERROR_CHECK(this->read_byte(ES8311_REG06_CLK_MANAGER, ®06));
|
||||
assign_bits(reg06, BIT(5), this->sclk_inverted_);
|
||||
if (this->sclk_inverted_) {
|
||||
reg06 |= BIT(5);
|
||||
} else {
|
||||
reg06 &= ~BIT(5);
|
||||
}
|
||||
reg06 &= 0xE0;
|
||||
if (coefficient->bclk_div < 19) {
|
||||
reg06 |= (coefficient->bclk_div - 1) << 0;
|
||||
@@ -220,7 +223,11 @@ bool ES8311::set_mute_state_(bool mute_state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
assign_bits(reg31, BIT(6) | BIT(5), mute_state);
|
||||
if (mute_state) {
|
||||
reg31 |= BIT(6) | BIT(5);
|
||||
} else {
|
||||
reg31 &= ~(BIT(6) | BIT(5));
|
||||
}
|
||||
|
||||
return this->write_byte(ES8311_REG31_DAC, reg31);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include <cinttypes>
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::es8388 {
|
||||
@@ -185,7 +184,11 @@ bool ES8388::set_mute_state_(bool mute_state) {
|
||||
// DAC settings that must be preserved. Previously muting overwrote the whole
|
||||
// register with 0x3C and unmuting never cleared the bit, so once muted the DAC
|
||||
// could not be unmuted again.
|
||||
assign_bits(value, ES8388_DACCONTROL3_DAC_MUTE, mute_state);
|
||||
if (mute_state) {
|
||||
value |= ES8388_DACCONTROL3_DAC_MUTE;
|
||||
} else {
|
||||
value &= ~ES8388_DACCONTROL3_DAC_MUTE;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state));
|
||||
return this->write_byte(ES8388_DACCONTROL3, value);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -84,11 +83,6 @@ 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:
|
||||
@@ -97,14 +91,6 @@ def _check_firmware_hash(data: bytes, expected: str, source: str | Path) -> None
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
@@ -123,9 +109,10 @@ def _validate_firmware(config: ConfigType) -> 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:
|
||||
if target.is_file():
|
||||
actual = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
if actual == expected:
|
||||
return
|
||||
# Named after its digest, so a mismatch means the file was damaged or edited
|
||||
_LOGGER.warning(
|
||||
"Cached firmware %s hashes to %s, downloading it again", target, actual
|
||||
@@ -136,20 +123,10 @@ def _validate_firmware(config: ConfigType) -> None:
|
||||
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)
|
||||
try:
|
||||
candidate.replace(target)
|
||||
except OSError as err:
|
||||
raise cv.Invalid(f"Could not cache the firmware from {url}: {err}") from err
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_firmware
|
||||
|
||||
@@ -362,7 +362,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 +372,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,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "gree.h"
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::gree {
|
||||
|
||||
@@ -25,7 +24,11 @@ void GreeClimate::set_model(Model model) {
|
||||
}
|
||||
|
||||
void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
|
||||
assign_bits(this->mode_bits_, bit_mask, enabled);
|
||||
if (enabled) {
|
||||
this->mode_bits_ |= bit_mask;
|
||||
} else {
|
||||
this->mode_bits_ &= ~bit_mask;
|
||||
}
|
||||
this->transmit_state();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "hitachi_ac344.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::hitachi_ac344 {
|
||||
|
||||
@@ -17,6 +16,15 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
|
||||
*dst |= ((data & mask) << offset);
|
||||
}
|
||||
|
||||
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
|
||||
uint8_t mask = 1 << position;
|
||||
if (on) {
|
||||
*data |= mask;
|
||||
} else {
|
||||
*data &= ~mask;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
|
||||
for (uint16_t i = 1; i < length; i += 2) {
|
||||
// Code done this way to avoid a compiler warning bug.
|
||||
@@ -116,7 +124,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC3
|
||||
|
||||
void HitachiClimate::set_swing_v_(bool on) {
|
||||
set_swing_v_toggle_(on); // Set the button value.
|
||||
assign_bits(this->remote_state_[HITACHI_AC344_SWINGV_BYTE], 1 << HITACHI_AC344_SWINGV_OFFSET, on);
|
||||
set_bit(&remote_state_[HITACHI_AC344_SWINGV_BYTE], HITACHI_AC344_SWINGV_OFFSET, on);
|
||||
}
|
||||
|
||||
bool HitachiClimate::get_swing_v_() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "hitachi_ac424.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::hitachi_ac424 {
|
||||
|
||||
@@ -17,6 +16,15 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
|
||||
*dst |= ((data & mask) << offset);
|
||||
}
|
||||
|
||||
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
|
||||
uint8_t mask = 1 << position;
|
||||
if (on) {
|
||||
*data |= mask;
|
||||
} else {
|
||||
*data &= ~mask;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
|
||||
for (uint16_t i = 1; i < length; i += 2) {
|
||||
// Code done this way to avoid a compiler warning bug.
|
||||
@@ -116,7 +124,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC4
|
||||
|
||||
void HitachiClimate::set_swing_v_(bool on) {
|
||||
set_swing_v_toggle_(on); // Set the button value.
|
||||
assign_bits(this->remote_state_[HITACHI_AC424_SWINGV_BYTE], 1 << HITACHI_AC424_SWINGV_OFFSET, on);
|
||||
set_bit(&remote_state_[HITACHI_AC424_SWINGV_BYTE], HITACHI_AC424_SWINGV_OFFSET, on);
|
||||
}
|
||||
|
||||
bool HitachiClimate::get_swing_v_() {
|
||||
|
||||
@@ -45,15 +45,14 @@ void HmacSHA256::get_hex(char *output) {
|
||||
}
|
||||
|
||||
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
|
||||
return constant_time_equals(this->digest_, expected, SHA256_DIGEST_SIZE);
|
||||
return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0;
|
||||
}
|
||||
|
||||
bool HmacSHA256::equals_hex(const char *expected) {
|
||||
uint8_t parsed[SHA256_DIGEST_SIZE];
|
||||
if (parse_hex(expected, SHA256_DIGEST_SIZE * 2, parsed, SHA256_DIGEST_SIZE) != SHA256_DIGEST_SIZE * 2) {
|
||||
return false;
|
||||
}
|
||||
return this->equals_bytes(parsed);
|
||||
char hex_output[SHA256_DIGEST_SIZE * 2 + 1];
|
||||
this->get_hex(hex_output);
|
||||
hex_output[SHA256_DIGEST_SIZE * 2] = '\0';
|
||||
return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0;
|
||||
}
|
||||
|
||||
#elif defined(USE_HMAC_SHA256_MBEDTLS)
|
||||
@@ -78,15 +77,14 @@ void HmacSHA256::get_hex(char *output) {
|
||||
}
|
||||
|
||||
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
|
||||
return constant_time_equals(this->digest_, expected, SHA256_DIGEST_SIZE);
|
||||
return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0;
|
||||
}
|
||||
|
||||
bool HmacSHA256::equals_hex(const char *expected) {
|
||||
uint8_t parsed[SHA256_DIGEST_SIZE];
|
||||
if (parse_hex(expected, SHA256_DIGEST_SIZE * 2, parsed, SHA256_DIGEST_SIZE) != SHA256_DIGEST_SIZE * 2) {
|
||||
return false;
|
||||
}
|
||||
return this->equals_bytes(parsed);
|
||||
char hex_output[SHA256_DIGEST_SIZE * 2 + 1];
|
||||
this->get_hex(hex_output);
|
||||
hex_output[SHA256_DIGEST_SIZE * 2] = '\0';
|
||||
return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
Loaded 100 of 416 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user