mirror of
https://github.com/esphome/esphome.git
synced 2026-10-23 22:48:48 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5ace9c21 | ||
|
|
202087f919 | ||
|
|
9ed59c29e2 | ||
|
|
d9548d142c | ||
|
|
34ef64186e | ||
|
|
e8a8d6cf95 | ||
|
|
1babcbf371 | ||
|
|
f9ca09ab30 | ||
|
|
ff56667929 | ||
|
|
ebdd9fe07e | ||
|
|
5a4025ee12 | ||
|
|
2b024114cf | ||
|
|
a1316e1bfa | ||
|
|
9beb41e2ff | ||
|
|
b9f973ba7f | ||
|
|
3d42180dcb | ||
|
|
8262e336cc | ||
|
|
616100803f | ||
|
|
8fb9dba023 | ||
|
|
c57a2b9f73 | ||
|
|
9f2be8f195 | ||
|
|
aa6c06ac03 | ||
|
|
2caf6eb6d0 | ||
|
|
4db3f0407d | ||
|
|
7e226093a5 | ||
|
|
3956a5b7ad | ||
|
|
5f90fb501b | ||
|
|
dfe87daa40 | ||
|
|
29cb1e9039 | ||
|
|
831f10fdeb | ||
|
|
8a2358d0e6 | ||
|
|
f416e1edd4 | ||
|
|
8156de9613 | ||
|
|
89c2cdb592 | ||
|
|
cb57148178 |
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@44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d # v3.1.0
|
||||
with:
|
||||
prek-version: ${{ steps.prek.outputs.version }}
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
pip3 install build
|
||||
python3 -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
|
||||
@@ -649,7 +649,13 @@ file does, and it is the authority when they disagree. The most useful starting
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`, `cv.only_with_esp_idf`.
|
||||
* **Framework-Specific:** `cv.only_with_framework(...)`, `cv.only_with_arduino`. On the ESP32, Arduino is
|
||||
built as a component of ESP-IDF, so an ESP-IDF API, sdkconfig option or version floor is there on both
|
||||
frameworks: gate on `esp32.require_idf_version(cv.Version(5, 5, 3), "psram_dma")`, which checks the
|
||||
platform and the ESP-IDF under either framework, as `spi`, `usb_host` and `wifi` do. Reserve
|
||||
`cv.only_with_framework("esp-idf")` for a build that has been tried on Arduino and fails there, and write
|
||||
the actual failure next to it (`sendspin`: arduino-esp32 below ESP-IDF 6.0 bundles a second libsodium,
|
||||
and from 6.0 sendspin-cpp's enumerators collide with `Arduino.h` macros such as `DISPLAY`).
|
||||
* **Schema Extensions:**
|
||||
```python
|
||||
CONFIG_SCHEMA = cv.Schema({ ... })
|
||||
|
||||
+11
-1
@@ -158,6 +158,7 @@ esphome/components/dashboard_import/* @esphome/core
|
||||
esphome/components/datetime/* @jesserockz @rfdarter
|
||||
esphome/components/debug/* @esphome/core
|
||||
esphome/components/delonghi/* @grob6000
|
||||
esphome/components/delta_wallbox/* @epicRE
|
||||
esphome/components/dew_point/* @CFlix
|
||||
esphome/components/dfplayer/* @glmnet
|
||||
esphome/components/dfrobot_sen0395/* @niklasweber
|
||||
@@ -175,6 +176,11 @@ esphome/components/ds2484/* @mrk-its
|
||||
esphome/components/ds248x/* @tomwellnitz
|
||||
esphome/components/ds3231/* @linkedupbits
|
||||
esphome/components/dsmr/* @glmnet @PolarGoose
|
||||
esphome/components/duco/* @kokx
|
||||
esphome/components/duco/number/* @kokx
|
||||
esphome/components/duco/select/* @kokx
|
||||
esphome/components/duco/sensor/* @kokx
|
||||
esphome/components/duco/text_sensor/* @kokx
|
||||
esphome/components/duty_time/* @dudanov
|
||||
esphome/components/ecocomfort2/* @gledian
|
||||
esphome/components/ee895/* @Stock-M
|
||||
@@ -374,7 +380,8 @@ esphome/components/m5stack_8angle/* @rnauber
|
||||
esphome/components/mapping/* @clydebarrow
|
||||
esphome/components/matrix_keypad/* @ssieb
|
||||
esphome/components/max17043/* @blacknell
|
||||
esphome/components/max17048/* @B48D81EFCC
|
||||
esphome/components/max17048/* @B48D81EFCC @ryansch
|
||||
esphome/components/max170xx/* @ryansch
|
||||
esphome/components/max31865/* @DAVe3283
|
||||
esphome/components/max31888/* @JiriPrchal
|
||||
esphome/components/max44009/* @berfenger
|
||||
@@ -462,6 +469,8 @@ esphome/components/openthread/* @mrene
|
||||
esphome/components/opt3001/* @ccutrer
|
||||
esphome/components/ota/* @esphome/core
|
||||
esphome/components/output/* @esphome/core
|
||||
esphome/components/packet_interface/* @clydebarrow
|
||||
esphome/components/packet_interface/packet_transport/* @clydebarrow
|
||||
esphome/components/packet_transport/* @clydebarrow
|
||||
esphome/components/pca6416a/* @Mat931
|
||||
esphome/components/pca9505/* @ajfriesen @ssieb
|
||||
@@ -682,6 +691,7 @@ esphome/components/tuya/water_heater/* @iago-veiga
|
||||
esphome/components/uart/* @esphome/core
|
||||
esphome/components/uart/button/* @ssieb
|
||||
esphome/components/uart/event/* @eoasmxd
|
||||
esphome/components/uart/packet_interface/* @clydebarrow
|
||||
esphome/components/uart/packet_transport/* @clydebarrow
|
||||
esphome/components/uart_mux/* @kbx81
|
||||
esphome/components/uart_tcp/* @Bascht74
|
||||
|
||||
@@ -44,7 +44,6 @@ from esphome.platformio.library import (
|
||||
normalize_dependencies,
|
||||
parse_library_json,
|
||||
parse_library_properties,
|
||||
warn_properties_depends,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -283,7 +282,6 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
|
||||
"resolved automatically; add them with add_library() if needed",
|
||||
name,
|
||||
)
|
||||
warn_properties_depends(name, data)
|
||||
build = data.get("build")
|
||||
if isinstance(build, dict) and build.get("extraScript"):
|
||||
# Scripts only run on the converted path; building without
|
||||
|
||||
@@ -3,7 +3,6 @@ from logging import getLogger
|
||||
from esphome import automation, core
|
||||
from esphome.automation import maybe_simple_id
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import mqtt, web_server, zigbee
|
||||
from esphome.components.const import CONF_ON_STATE_CHANGE
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
@@ -29,7 +28,6 @@ from esphome.const import (
|
||||
CONF_STATE,
|
||||
CONF_TIMING,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_WEB_SERVER,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_BATTERY_CHARGING,
|
||||
DEVICE_CLASS_CARBON_MONOXIDE,
|
||||
@@ -61,10 +59,12 @@ from esphome.const import (
|
||||
DEVICE_CLASS_VIBRATION,
|
||||
DEVICE_CLASS_WINDOW,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority, entity_helpers
|
||||
from esphome.core.entity_helpers import (
|
||||
SubEntities,
|
||||
entity_duplicate_validator,
|
||||
lazy_load_validator,
|
||||
mqtt_component_class,
|
||||
queue_entity_register,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
@@ -433,14 +433,14 @@ def validate_publish_initial_state(value):
|
||||
|
||||
|
||||
_BINARY_SENSOR_SCHEMA = (
|
||||
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
|
||||
cv.ENTITY_BASE_SCHEMA.extend(entity_helpers.WEBSERVER_SORTING_SCHEMA)
|
||||
.extend(cv.MQTT_COMPONENT_SCHEMA)
|
||||
.extend(zigbee.BINARY_SENSOR_SCHEMA)
|
||||
.extend(entity_helpers.ZIGBEE_BINARY_SENSOR_SCHEMA)
|
||||
.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BinarySensor),
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
|
||||
mqtt.MQTTBinarySensorComponent
|
||||
mqtt_component_class("MQTTBinarySensorComponent")
|
||||
),
|
||||
cv.Exclusive(
|
||||
CONF_PUBLISH_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
|
||||
@@ -508,7 +508,7 @@ _BINARY_SENSOR_SCHEMA = (
|
||||
|
||||
|
||||
_BINARY_SENSOR_SCHEMA.add_extra(entity_duplicate_validator("binary_sensor"))
|
||||
_BINARY_SENSOR_SCHEMA.add_extra(zigbee.validate_binary_sensor)
|
||||
_BINARY_SENSOR_SCHEMA.add_extra(lazy_load_validator("zigbee", "validate_binary_sensor"))
|
||||
|
||||
|
||||
def binary_sensor_schema(
|
||||
@@ -619,14 +619,12 @@ async def setup_binary_sensor_core_(var, config):
|
||||
|
||||
CORE.add_job(_build_binary_sensor_automations, var, config)
|
||||
|
||||
if mqtt_id := config.get(CONF_MQTT_ID):
|
||||
mqtt_ = cg.new_Pvariable(mqtt_id, var)
|
||||
await mqtt.register_mqtt_component(mqtt_, config)
|
||||
await entity_helpers.setup_entity_integrations(var, config)
|
||||
|
||||
if web_server_config := config.get(CONF_WEB_SERVER):
|
||||
await web_server.add_entity_config(var, web_server_config)
|
||||
if "zigbee" in CORE.loaded_integrations:
|
||||
from esphome.components import zigbee
|
||||
|
||||
await zigbee.setup_binary_sensor(var, config)
|
||||
await zigbee.setup_binary_sensor(var, config)
|
||||
|
||||
|
||||
async def register_binary_sensor(var, config):
|
||||
|
||||
@@ -20,6 +20,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
platform="bp1658cj", hub_key=CONF_BP1658CJ_ID
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await output.register_output(var, config)
|
||||
|
||||
@@ -21,6 +21,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
platform="bp5758d", hub_key=CONF_BP5758D_ID
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await output.register_output(var, config)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ch422g.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::ch422g {
|
||||
@@ -59,11 +60,7 @@ bool CH422GComponent::digital_read(uint8_t pin) {
|
||||
}
|
||||
|
||||
void CH422GComponent::digital_write(uint8_t pin, bool value) {
|
||||
if (value) {
|
||||
this->output_bits_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_bits_ &= ~(1 << pin);
|
||||
}
|
||||
assign_bits(this->output_bits_, 1 << pin, value);
|
||||
this->write_outputs_();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ch423.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
|
||||
@@ -62,11 +63,7 @@ bool CH423Component::digital_read(uint8_t pin) {
|
||||
}
|
||||
|
||||
void CH423Component::digital_write(uint8_t pin, bool value) {
|
||||
if (value) {
|
||||
this->output_bits_ |= (1 << pin);
|
||||
} else {
|
||||
this->output_bits_ &= ~(1 << pin);
|
||||
}
|
||||
assign_bits(this->output_bits_, 1 << pin, value);
|
||||
this->write_outputs_();
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
platform="dac7678", hub_key=CONF_DAC7678_ID
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> MockObj:
|
||||
paren = await cg.get_variable(config[CONF_DAC7678_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import ble_client, time
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_PASSWORD, CONF_PIN, CONF_TIME_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@epicRE"]
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
DOMAIN = "delta_wallbox"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_DELTA_WALLBOX_ID = "delta_wallbox_id"
|
||||
CONF_USER = "user"
|
||||
|
||||
delta_wallbox_ns = cg.esphome_ns.namespace("delta_wallbox")
|
||||
DeltaWallbox = delta_wallbox_ns.class_(
|
||||
"DeltaWallbox", ble_client.BLEClientNode, cg.PollingComponent
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.COMPONENT_SCHEMA.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DeltaWallbox),
|
||||
cv.Required(CONF_PASSWORD): cv.All(cv.string_strict, cv.Length(max=22)),
|
||||
cv.Optional(CONF_PIN): cv.int_range(min=0, max=999999),
|
||||
cv.Optional(CONF_USER, default="admin"): cv.All(
|
||||
cv.string_strict, cv.Length(max=6)
|
||||
),
|
||||
cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
|
||||
}
|
||||
)
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA)
|
||||
.extend(cv.polling_component_schema("15s"))
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await ble_client.new_ble_node_component(
|
||||
config, config[CONF_USER], config[CONF_PASSWORD]
|
||||
)
|
||||
cg.add_optional(var.set_pin, config.get(CONF_PIN))
|
||||
await cg.add_variable(var.set_time_id, config.get(CONF_TIME_ID))
|
||||
@@ -0,0 +1,30 @@
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
|
||||
|
||||
DEPENDENCIES = ["delta_wallbox"]
|
||||
|
||||
CONF_START_CHARGING = "start_charging"
|
||||
CONF_STOP_CHARGING = "stop_charging"
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
|
||||
cv.Optional(CONF_START_CHARGING): button.button_schema(
|
||||
button.ApplyButton,
|
||||
icon="mdi:play-circle-outline",
|
||||
),
|
||||
cv.Optional(CONF_STOP_CHARGING): button.button_schema(
|
||||
button.ApplyButton,
|
||||
icon="mdi:stop-circle-outline",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
buttons = button.sub_buttons(config, parent=config[CONF_DELTA_WALLBOX_ID])
|
||||
await buttons(CONF_START_CHARGING, None, apply="start_charging()")
|
||||
await buttons(CONF_STOP_CHARGING, None, apply="stop_charging()")
|
||||
@@ -0,0 +1,691 @@
|
||||
#include "delta_wallbox.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/progmem.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::delta_wallbox {
|
||||
ESPHOME_LOG_TAG(TAG, "delta_wallbox");
|
||||
// Modbus function codes
|
||||
static constexpr uint8_t FC_READ_HOLDING_REGISTERS = 0x03;
|
||||
static constexpr uint8_t FC_WRITE_SINGLE_REGISTER = 0x06;
|
||||
static constexpr uint8_t FC_WRITE_MULTIPLE_REGISTERS = 0x10;
|
||||
static constexpr uint8_t FC_EXCEPTION_FLAG = 0x80;
|
||||
|
||||
// Response lengths in bytes, CRC included
|
||||
static constexpr size_t READ_RESPONSE_OVERHEAD = 5;
|
||||
static constexpr size_t WRITE_RESPONSE_LEN = 8;
|
||||
static constexpr size_t EXCEPTION_RESPONSE_LEN = 5;
|
||||
|
||||
// Frame bodies without CRC
|
||||
static constexpr size_t LOGIN_BODY_LEN = 35;
|
||||
static constexpr size_t LOGIN_PASS_LEN = 22;
|
||||
static constexpr size_t LOGIN_USER_LEN = 6;
|
||||
static constexpr size_t TIME_SYNC_BODY_LEN = 15;
|
||||
|
||||
static constexpr uint8_t REGISTER_BYTE_COUNT = 2;
|
||||
static constexpr uint8_t TWO_REGISTER_BYTE_COUNT = 4;
|
||||
static constexpr uint8_t CHARGING_INFO_BYTE_COUNT = 18;
|
||||
static constexpr uint8_t SERIAL_TEXT_BYTE_COUNT = 14;
|
||||
static constexpr uint32_t RETRANSMIT_TIMEOUT_MS = 4000;
|
||||
static constexpr uint32_t RETRANSMIT_TIMEOUT_ID = 0;
|
||||
static constexpr uint8_t MAX_PAIRING_ATTEMPTS = 5;
|
||||
static constexpr uint16_t PAIRING_STATUS_READY = 0x0007;
|
||||
static constexpr uint16_t PAIRING_STATUS_WAITING = 0x0008;
|
||||
static constexpr uint8_t LOGIN_ACCEPTED = 0x01;
|
||||
|
||||
static constexpr uint8_t CMD_POLL_CHARGING[] = {0x01, 0x03, 0x01, 0x1B, 0x00, 0x09};
|
||||
static constexpr uint8_t CMD_GET_MODEL[] = {0x01, 0x03, 0x01, 0x85, 0x00, 0x0E};
|
||||
static constexpr uint8_t CMD_GET_BT_FW_VERSION[] = {0x02, 0x03, 0x01, 0x00, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_CHECK_PAIRING[] = {0x02, 0x03, 0x01, 0x00, 0x00, 0x03};
|
||||
static constexpr uint8_t CMD_GET_LOGIN_USER[] = {0x01, 0x03, 0x01, 0x7D, 0x00, 0x03};
|
||||
static constexpr uint8_t CMD_READ_LOGIN_STATUS[] = {0x01, 0x03, 0x01, 0x80, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_GET_LOGIN_ROLE[] = {0x01, 0x03, 0x01, 0x81, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_GET_CHARGER_FW_VERSION[] = {0x01, 0x03, 0x01, 0x10, 0x00, 0x02};
|
||||
static constexpr uint8_t CMD_GET_MAX_CURRENT[] = {0x01, 0x03, 0x01, 0x12, 0x00, 0x02};
|
||||
static constexpr uint8_t CMD_GET_SERIAL_NUMBER[] = {0x01, 0x03, 0x01, 0x09, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_GET_ERROR_CODE[] = {0x01, 0x03, 0x01, 0xB5, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_START_CHARGING[] = {0x01, 0x06, 0x01, 0x02, 0x00, 0x01};
|
||||
static constexpr uint8_t CMD_STOP_CHARGING[] = {0x01, 0x06, 0x01, 0x03, 0x00, 0x01};
|
||||
|
||||
// Indexed by DeltaState
|
||||
PROGMEM_STRING_TABLE(DeltaStateStrings, "CHECK_PAIRING", "CONNECTED", "GET_CHARGER_FW_VERSION", "GET_BT_FW_VERSION",
|
||||
"GET_LOGIN_RESULT", "GET_LOGIN_RESULT0", "GET_LOGIN_ROLE", "GET_LOGIN_USER", "GET_MODEL",
|
||||
"GET_SERIAL_NUMBER", "HALTED", "IDLE", "SEND_LOGIN_CREDENTIALS", "TIME_SYNC");
|
||||
static_assert(DeltaStateStrings::LAST_INDEX == static_cast<uint8_t>(DeltaState::DELTA_STATE_TIME_SYNC),
|
||||
"DeltaStateStrings must list every DeltaState");
|
||||
static const LogString *delta_state_to_string(DeltaState state) {
|
||||
return DeltaStateStrings::get_log_str(static_cast<uint8_t>(state), DeltaStateStrings::LAST_INDEX);
|
||||
}
|
||||
|
||||
// The function code a handshake step waits for; 0 where any frame is handled
|
||||
static uint8_t expected_function_code(DeltaState state) {
|
||||
switch (state) {
|
||||
case DeltaState::DELTA_STATE_IDLE:
|
||||
case DeltaState::DELTA_STATE_HALTED:
|
||||
case DeltaState::DELTA_STATE_CONNECTED:
|
||||
return 0;
|
||||
case DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS:
|
||||
case DeltaState::DELTA_STATE_TIME_SYNC:
|
||||
return FC_WRITE_MULTIPLE_REGISTERS;
|
||||
default:
|
||||
return FC_READ_HOLDING_REGISTERS;
|
||||
}
|
||||
}
|
||||
|
||||
// UUID references
|
||||
static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID =
|
||||
esp32_ble_tracker::ESPBTUUID::from_raw("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
|
||||
static const esp32_ble_tracker::ESPBTUUID TX_UUID =
|
||||
esp32_ble_tracker::ESPBTUUID::from_raw("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
|
||||
static const esp32_ble_tracker::ESPBTUUID RX_UUID =
|
||||
esp32_ble_tracker::ESPBTUUID::from_raw("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
|
||||
|
||||
// Appends the little-endian Modbus CRC-16 to the len bytes in frame and returns the new length.
|
||||
static size_t append_crc(uint8_t *frame, size_t len) {
|
||||
const uint16_t crc = crc16(frame, static_cast<uint16_t>(len));
|
||||
frame[len] = static_cast<uint8_t>(crc & 0xFF);
|
||||
frame[len + 1] = static_cast<uint8_t>(crc >> 8);
|
||||
return len + 2;
|
||||
}
|
||||
|
||||
static bool verify_crc(const uint8_t *data, size_t len) {
|
||||
if (len < EXCEPTION_RESPONSE_LEN)
|
||||
return false;
|
||||
const uint16_t calculated = crc16(data, static_cast<uint16_t>(len - 2));
|
||||
const uint16_t received = data[len - 2] | (data[len - 1] << 8);
|
||||
return calculated == received;
|
||||
}
|
||||
|
||||
// Login write body: 22-byte password and 6-byte user, both left-padded with zeros
|
||||
static std::array<uint8_t, LOGIN_BODY_LEN> build_login_body(StringRef user, StringRef password) {
|
||||
std::array<uint8_t, LOGIN_BODY_LEN> frame = {
|
||||
0x01, 0x10, 0x01, 0x72, 0x00, 0x0E, 0x1C, // Unit, FC16, register 0x0172, 14 registers, 28 bytes
|
||||
};
|
||||
|
||||
const size_t pass_len = std::min(password.size(), LOGIN_PASS_LEN);
|
||||
std::copy_n(password.c_str(), pass_len, frame.begin() + 7 + (LOGIN_PASS_LEN - pass_len));
|
||||
|
||||
uint8_t *user_chunk = frame.data() + 7 + LOGIN_PASS_LEN;
|
||||
bool is_digit = !user.empty();
|
||||
for (size_t i = 0; i < user.size() && is_digit; i++) {
|
||||
is_digit = isdigit(static_cast<unsigned char>(user.c_str()[i]));
|
||||
}
|
||||
if (is_digit) {
|
||||
const uint64_t val = strtoull(user.c_str(), nullptr, 10);
|
||||
for (size_t i = 0; i < LOGIN_USER_LEN; i++) {
|
||||
user_chunk[LOGIN_USER_LEN - 1 - i] = static_cast<uint8_t>((val >> (8 * i)) & 0xFF);
|
||||
}
|
||||
} else {
|
||||
const size_t user_len = std::min(user.size(), LOGIN_USER_LEN);
|
||||
std::copy_n(user.c_str(), user_len, user_chunk + LOGIN_USER_LEN - user_len);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
static std::array<uint8_t, TIME_SYNC_BODY_LEN> build_time_sync_body(int year, int month, int day, int hour, int minute,
|
||||
int second, int weekday) {
|
||||
const uint8_t weekday_bitmap = static_cast<uint8_t>(1 << weekday); // Sunday=bit0, Mon=bit1...
|
||||
return {
|
||||
0x01,
|
||||
0x10,
|
||||
0x01,
|
||||
0x14,
|
||||
0x00,
|
||||
0x04,
|
||||
0x08, // Unit, FC16, register 0x0114, 4 registers, 8 bytes
|
||||
static_cast<uint8_t>(year % 100),
|
||||
static_cast<uint8_t>(month),
|
||||
weekday_bitmap,
|
||||
static_cast<uint8_t>(day),
|
||||
static_cast<uint8_t>(hour),
|
||||
static_cast<uint8_t>(minute),
|
||||
static_cast<uint8_t>(second),
|
||||
0x00,
|
||||
};
|
||||
}
|
||||
|
||||
void DeltaWallbox::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Delta Wallbox:");
|
||||
ESP_LOGCONFIG(TAG, " User: %s", this->user_.c_str());
|
||||
ESP_LOGCONFIG(TAG, " BLE PIN configured: %s", this->has_pin_ ? LOG_STR_LITERAL("YES") : LOG_STR_LITERAL("NO"));
|
||||
LOG_UPDATE_INTERVAL(this);
|
||||
}
|
||||
|
||||
void DeltaWallbox::update() {
|
||||
if (this->is_connected()) {
|
||||
// Start poll flow sequence
|
||||
ESP_LOGD(TAG, "Polling charging info...");
|
||||
this->send_frame_(CMD_POLL_CHARGING, sizeof(CMD_POLL_CHARGING));
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::write_frame_(const uint8_t *frame, size_t len) {
|
||||
if (this->tx_char_handle_ == 0) {
|
||||
ESP_LOGW(TAG, "TX characteristic handle is not ready while in state %s. Dropping %u-byte frame.",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), (unsigned int) len);
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Sending %u-byte frame in state %s (conn_id=%u, tx_handle=0x%04X).", (unsigned int) len,
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), this->parent_->get_conn_id(), this->tx_char_handle_);
|
||||
auto status =
|
||||
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->tx_char_handle_, len,
|
||||
const_cast<uint8_t *>(frame), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
|
||||
if (status != ESP_GATT_OK) {
|
||||
ESP_LOGW(TAG, "esp_ble_gattc_write_char failed, status=%d", status);
|
||||
}
|
||||
}
|
||||
|
||||
// Copies body into a stack buffer, appends the CRC and writes the frame.
|
||||
void DeltaWallbox::send_frame_(const uint8_t *body, size_t len) {
|
||||
std::array<uint8_t, MAX_FRAME_LEN> frame{};
|
||||
std::copy_n(body, len, frame.begin());
|
||||
this->write_frame_(frame.data(), append_crc(frame.data(), len));
|
||||
}
|
||||
|
||||
void DeltaWallbox::drop_rx_bytes_(size_t count) {
|
||||
count = std::min(count, this->rx_len_);
|
||||
std::memmove(this->rx_buffer_.data(), this->rx_buffer_.data() + count, this->rx_len_ - count);
|
||||
this->rx_len_ -= count;
|
||||
}
|
||||
|
||||
void DeltaWallbox::send_next_state_command_() {
|
||||
ESP_LOGD(TAG, "Preparing command for state %s.", LOG_STR_ARG(delta_state_to_string(this->state_)));
|
||||
switch (this->state_) {
|
||||
case DeltaState::DELTA_STATE_GET_MODEL:
|
||||
ESP_LOGI(TAG, "Requesting charger model name...");
|
||||
this->send_frame_(CMD_GET_MODEL, sizeof(CMD_GET_MODEL));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_BT_FW_VERSION:
|
||||
ESP_LOGI(TAG, "Requesting BT firmware version...");
|
||||
this->send_frame_(CMD_GET_BT_FW_VERSION, sizeof(CMD_GET_BT_FW_VERSION));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_CHECK_PAIRING:
|
||||
ESP_LOGI(TAG, "Checking pairing status...");
|
||||
this->send_frame_(CMD_CHECK_PAIRING, sizeof(CMD_CHECK_PAIRING));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_USER:
|
||||
ESP_LOGI(TAG, "Querying user profile...");
|
||||
this->send_frame_(CMD_GET_LOGIN_USER, sizeof(CMD_GET_LOGIN_USER));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT0:
|
||||
ESP_LOGI(TAG, "Checking pre-login status register...");
|
||||
this->send_frame_(CMD_READ_LOGIN_STATUS, sizeof(CMD_READ_LOGIN_STATUS));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS: {
|
||||
ESP_LOGI(TAG, "Transmitting login credentials...");
|
||||
const auto body = build_login_body(this->user_, this->password_);
|
||||
this->send_frame_(body.data(), body.size());
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT:
|
||||
ESP_LOGI(TAG, "Verifying login result...");
|
||||
this->send_frame_(CMD_READ_LOGIN_STATUS, sizeof(CMD_READ_LOGIN_STATUS));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_ROLE:
|
||||
ESP_LOGI(TAG, "Checking role privileges...");
|
||||
this->send_frame_(CMD_GET_LOGIN_ROLE, sizeof(CMD_GET_LOGIN_ROLE));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_TIME_SYNC: {
|
||||
#ifdef USE_TIME
|
||||
// Only a valid time is written; the charger keeps its own clock otherwise
|
||||
if (this->time_id_ != nullptr) {
|
||||
auto tm = this->time_id_->now();
|
||||
if (tm.is_valid()) {
|
||||
ESP_LOGI(TAG, "Synchronizing wallbox clock...");
|
||||
// ESPTime day_of_week is 1 (Sunday) to 7 (Saturday); the charger counts from 0
|
||||
const auto body = build_time_sync_body(tm.year, tm.month, tm.day_of_month, tm.hour, tm.minute, tm.second,
|
||||
tm.day_of_week - 1);
|
||||
this->send_frame_(body.data(), body.size());
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION:
|
||||
ESP_LOGI(TAG, "Requesting charger main firmware version...");
|
||||
this->send_frame_(CMD_GET_CHARGER_FW_VERSION, sizeof(CMD_GET_CHARGER_FW_VERSION));
|
||||
break;
|
||||
case DeltaState::DELTA_STATE_GET_SERIAL_NUMBER:
|
||||
ESP_LOGI(TAG, "Requesting wallbox serial number...");
|
||||
this->send_frame_(CMD_GET_SERIAL_NUMBER, sizeof(CMD_GET_SERIAL_NUMBER));
|
||||
break;
|
||||
default:
|
||||
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
||||
return;
|
||||
}
|
||||
this->set_timeout(RETRANSMIT_TIMEOUT_ID, RETRANSMIT_TIMEOUT_MS, [this]() { this->retransmit_(); });
|
||||
}
|
||||
|
||||
void DeltaWallbox::retransmit_() {
|
||||
ESP_LOGW(TAG, "No answer in login state %s within %u ms. Sending the command again...",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), (unsigned int) RETRANSMIT_TIMEOUT_MS);
|
||||
this->send_next_state_command_();
|
||||
}
|
||||
|
||||
void DeltaWallbox::advance_to_(DeltaState next) {
|
||||
ESP_LOGD(TAG, "State transition: %s -> %s", LOG_STR_ARG(delta_state_to_string(this->state_)),
|
||||
LOG_STR_ARG(delta_state_to_string(next)));
|
||||
this->state_ = next;
|
||||
this->send_next_state_command_();
|
||||
}
|
||||
|
||||
void DeltaWallbox::handle_frame_(const uint8_t *frame, size_t len) {
|
||||
ESP_LOGD(TAG, "Received valid frame in state %s: func=0x%02X len=%u",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), frame[1], (unsigned int) len);
|
||||
if (frame[1] & FC_EXCEPTION_FLAG) {
|
||||
ESP_LOGE(TAG, "Modbus exception received in state %s! Error code: 0x%02X",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), frame[2]);
|
||||
if (this->state_ == DeltaState::DELTA_STATE_TIME_SYNC) {
|
||||
// The clock write is optional, so a charger that refuses it is used without it
|
||||
ESP_LOGW(TAG, "Charger refused the clock sync; continuing without it");
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION);
|
||||
} else if (expected_function_code(this->state_) != 0) {
|
||||
// The charger refused a handshake step; sending the same request again would not change that
|
||||
this->halt_(LOG_STR("Modbus exception during login"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const uint8_t expected = expected_function_code(this->state_);
|
||||
if (expected != 0 && frame[1] != expected) {
|
||||
// A late answer to a retransmitted request; the pending one is answered or sent again by the timer
|
||||
ESP_LOGD(TAG, "Ignoring func=0x%02X reply while waiting for 0x%02X", frame[1], expected);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this->state_) {
|
||||
case DeltaState::DELTA_STATE_IDLE:
|
||||
break; // No command is pending; ignore the frame.
|
||||
case DeltaState::DELTA_STATE_GET_MODEL: {
|
||||
char model[MAX_FRAME_LEN];
|
||||
const size_t data_len = std::min<size_t>(frame[2], len - READ_RESPONSE_OVERHEAD);
|
||||
size_t model_len = 0;
|
||||
for (size_t i = 0; i < data_len; i++) {
|
||||
if (frame[3 + i] != '\0')
|
||||
model[model_len++] = static_cast<char>(frame[3 + i]);
|
||||
}
|
||||
model[model_len] = '\0';
|
||||
ESP_LOGI(TAG, "Discovered Wallbox Model Name: %s", model);
|
||||
if (this->model_name_text_sensor_ != nullptr) {
|
||||
this->model_name_text_sensor_->publish_state(model);
|
||||
}
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_BT_FW_VERSION);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_BT_FW_VERSION: {
|
||||
if (frame[2] >= REGISTER_BYTE_COUNT) {
|
||||
uint8_t major = frame[3] % 16;
|
||||
uint8_t minor = frame[4];
|
||||
char ver[16];
|
||||
snprintf(ver, sizeof(ver), "%d.%d", major, minor);
|
||||
ESP_LOGI(TAG, "Discovered Wallbox BT FW: %s", ver);
|
||||
if (this->bt_fw_version_text_sensor_ != nullptr) {
|
||||
this->bt_fw_version_text_sensor_->publish_state(ver);
|
||||
}
|
||||
}
|
||||
this->advance_to_(DeltaState::DELTA_STATE_CHECK_PAIRING);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_CHECK_PAIRING: {
|
||||
if (frame[2] >= REGISTER_BYTE_COUNT) {
|
||||
uint16_t status = (frame[3] << 8) | frame[4];
|
||||
ESP_LOGI(TAG, "Pairing Status Register: 0x%04X", status);
|
||||
if (status == PAIRING_STATUS_READY) {
|
||||
this->pairing_in_progress_ = false;
|
||||
this->pairing_attempts_ = 0;
|
||||
ESP_LOGI(TAG, "Pairing ready and confirmed!");
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_USER);
|
||||
} else if (status == PAIRING_STATUS_WAITING) {
|
||||
ESP_LOGW(TAG, "Wallbox is waiting to be paired by the system controller (attempt %u, in_progress=%s).",
|
||||
this->pairing_attempts_ + 1,
|
||||
this->pairing_in_progress_ ? LOG_STR_LITERAL("YES") : LOG_STR_LITERAL("NO"));
|
||||
this->pairing_attempts_++;
|
||||
if (!this->pairing_in_progress_) {
|
||||
ESP_LOGI(TAG, "Initiating BLE pairing with wallbox.");
|
||||
auto err = this->parent_->pair();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to initiate BLE pairing, err=%d", err);
|
||||
} else {
|
||||
this->pairing_in_progress_ = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Unexpected pairing status 0x%04X", status);
|
||||
this->pairing_attempts_++;
|
||||
}
|
||||
if (this->pairing_attempts_ >= MAX_PAIRING_ATTEMPTS) {
|
||||
this->halt_(LOG_STR("Too many failed/waiting pairing checks"));
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Pairing status response too short: byte_count=%u frame_len=%u", frame[2], (unsigned int) len);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_USER: {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_RESULT0);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT0: {
|
||||
if (frame[2] == REGISTER_BYTE_COUNT) {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_SEND_LOGIN_CREDENTIALS: {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_RESULT);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_RESULT: {
|
||||
// Only the one-register status answer counts; a late reply to another read has a different length
|
||||
if (frame[2] == REGISTER_BYTE_COUNT) {
|
||||
uint8_t login_val = frame[4]; // respondent data_lo
|
||||
if (login_val == LOGIN_ACCEPTED) {
|
||||
ESP_LOGI(TAG, "Credentials accepted! Logged in as %s.", this->user_.c_str());
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_LOGIN_ROLE);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Login validation failed (0x%02X). Check password.", login_val);
|
||||
this->halt_(LOG_STR("Bad credentials"));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_HALTED: {
|
||||
break; // Authentication is halted; ignore any late replies.
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_LOGIN_ROLE: {
|
||||
if (frame[2] == REGISTER_BYTE_COUNT) {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_TIME_SYNC);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_TIME_SYNC: {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_CHARGER_FW_VERSION: {
|
||||
if (frame[2] >= TWO_REGISTER_BYTE_COUNT) {
|
||||
char ver[16];
|
||||
snprintf(ver, sizeof(ver), "%d.%d", frame[3], frame[4]);
|
||||
ESP_LOGI(TAG, "Discovered Charger Main FW: %s", ver);
|
||||
if (this->fw_version_text_sensor_ != nullptr) {
|
||||
this->fw_version_text_sensor_->publish_state(ver);
|
||||
}
|
||||
}
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_SERIAL_NUMBER);
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_GET_SERIAL_NUMBER: {
|
||||
// Serial read (0x0109) returns 14 ASCII bytes instead of one register
|
||||
if (frame[2] >= SERIAL_TEXT_BYTE_COUNT) {
|
||||
char serial[SERIAL_TEXT_BYTE_COUNT + 1];
|
||||
size_t serial_len = 0;
|
||||
for (size_t i = 0; i < SERIAL_TEXT_BYTE_COUNT; i++) {
|
||||
if (frame[3 + i] != '\0')
|
||||
serial[serial_len++] = static_cast<char>(frame[3 + i]);
|
||||
}
|
||||
serial[serial_len] = '\0';
|
||||
ESP_LOGI(TAG, "Wallbox serial text: %s", serial);
|
||||
if (this->serial_number_text_sensor_ != nullptr)
|
||||
this->serial_number_text_sensor_->publish_state(serial);
|
||||
}
|
||||
this->advance_to_(DeltaState::DELTA_STATE_CONNECTED);
|
||||
ESP_LOGI(TAG, "Authentication complete. Core parameters loaded.");
|
||||
this->status_clear_warning();
|
||||
this->update(); // perform first poll update immediately
|
||||
break;
|
||||
}
|
||||
case DeltaState::DELTA_STATE_CONNECTED: {
|
||||
// Connected polling responses
|
||||
if (frame[1] == FC_READ_HOLDING_REGISTERS) {
|
||||
uint8_t byte_count = frame[2];
|
||||
if (byte_count == CHARGING_INFO_BYTE_COUNT) {
|
||||
// Parsing 0x011B charging info (9 registers)
|
||||
uint32_t power_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
||||
uint32_t energy_raw = (frame[7] << 24) | (frame[8] << 16) | (frame[9] << 8) | frame[10];
|
||||
uint8_t minutes = frame[15];
|
||||
uint8_t hours = frame[16];
|
||||
uint8_t state_byte = frame[17];
|
||||
|
||||
float power_kw = power_raw / 1000.0f;
|
||||
float energy_kwh = energy_raw / 1000.0f;
|
||||
float charging_minutes = hours * 60 + minutes;
|
||||
|
||||
if (this->power_sensor_ != nullptr)
|
||||
this->power_sensor_->publish_state(power_kw);
|
||||
if (this->energy_sensor_ != nullptr)
|
||||
this->energy_sensor_->publish_state(energy_kwh);
|
||||
if (this->charging_time_sensor_ != nullptr)
|
||||
this->charging_time_sensor_->publish_state(charging_minutes);
|
||||
if (this->charger_state_sensor_ != nullptr)
|
||||
this->charger_state_sensor_->publish_state(state_byte);
|
||||
|
||||
// Retrieve maximum current output setting next
|
||||
this->send_frame_(CMD_GET_MAX_CURRENT, sizeof(CMD_GET_MAX_CURRENT));
|
||||
} else if (byte_count == TWO_REGISTER_BYTE_COUNT) {
|
||||
// Parsing 0x0112 max output setting (2 registers)
|
||||
uint32_t current_raw = (frame[3] << 24) | (frame[4] << 16) | (frame[5] << 8) | frame[6];
|
||||
float amps = current_raw / 10.0f;
|
||||
if (this->max_current_sensor_ != nullptr) {
|
||||
this->max_current_sensor_->publish_state(amps);
|
||||
}
|
||||
|
||||
// Poll the error code register every cycle
|
||||
this->send_frame_(CMD_GET_ERROR_CODE, sizeof(CMD_GET_ERROR_CODE));
|
||||
} else if (byte_count == REGISTER_BYTE_COUNT) {
|
||||
// Error status register (0x01B5)
|
||||
uint16_t reg_val = (frame[3] << 8) | frame[4];
|
||||
char hex_str[16];
|
||||
snprintf(hex_str, sizeof(hex_str), "%04X", reg_val);
|
||||
if (this->error_code_text_sensor_ != nullptr)
|
||||
this->error_code_text_sensor_->publish_state(hex_str);
|
||||
}
|
||||
} else if (frame[1] == FC_WRITE_SINGLE_REGISTER) {
|
||||
// Confirmation frame of Start/Stop write actions
|
||||
ESP_LOGI(TAG, "Wallbox charging command successfully acknowledged.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::reset_state_() {
|
||||
ESP_LOGW(TAG, "Resetting component state from %s. pairing_attempts=%u, pairing_in_progress=%s, rx_buffer=%u bytes",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)), this->pairing_attempts_,
|
||||
this->pairing_in_progress_ ? LOG_STR_LITERAL("YES") : LOG_STR_LITERAL("NO"), (unsigned int) this->rx_len_);
|
||||
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
||||
this->rx_len_ = 0;
|
||||
this->state_ = DeltaState::DELTA_STATE_IDLE;
|
||||
this->pairing_attempts_ = 0;
|
||||
this->pairing_in_progress_ = false;
|
||||
this->status_set_warning();
|
||||
}
|
||||
|
||||
void DeltaWallbox::halt_(const LogString *reason) {
|
||||
ESP_LOGE(TAG, "Halting authentication: %s. A reconnect is required to retry.", LOG_STR_ARG(reason));
|
||||
this->cancel_timeout(RETRANSMIT_TIMEOUT_ID);
|
||||
this->rx_len_ = 0;
|
||||
this->state_ = DeltaState::DELTA_STATE_HALTED;
|
||||
this->pairing_attempts_ = 0;
|
||||
this->pairing_in_progress_ = false;
|
||||
this->status_set_warning(reason);
|
||||
}
|
||||
|
||||
void DeltaWallbox::set_charging_state(bool state) {
|
||||
if (!this->is_connected()) {
|
||||
ESP_LOGW(TAG, "Cannot send charging command while wallbox is not fully connected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state) {
|
||||
ESP_LOGI(TAG, "Sending remote START charging command...");
|
||||
this->send_frame_(CMD_START_CHARGING, sizeof(CMD_START_CHARGING));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Sending remote STOP charging command...");
|
||||
this->send_frame_(CMD_STOP_CHARGING, sizeof(CMD_STOP_CHARGING));
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::start_charging() { this->set_charging_state(true); }
|
||||
|
||||
void DeltaWallbox::stop_charging() { this->set_charging_state(false); }
|
||||
|
||||
void DeltaWallbox::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GATTC_DISCONNECT_EVT: {
|
||||
ESP_LOGW(TAG, "Disconnected from Bluetooth wallbox client (conn_id=%u, reason=%d). Resetting state.",
|
||||
param->disconnect.conn_id, param->disconnect.reason);
|
||||
this->reset_state_();
|
||||
this->node_state = esp32_ble_tracker::ClientState::IDLE;
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_OPEN_EVT: {
|
||||
ESP_LOGI(TAG, "GATT open event received: status=%d conn_id=%u", param->open.status, param->open.conn_id);
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_SEARCH_CMPL_EVT: {
|
||||
ESP_LOGI(TAG, "GATT service discovery completed: status=%d", param->search_cmpl.status);
|
||||
auto *tx_char = this->parent_->get_characteristic(SERVICE_UUID, TX_UUID);
|
||||
auto *rx_char = this->parent_->get_characteristic(SERVICE_UUID, RX_UUID);
|
||||
if (tx_char == nullptr || rx_char == nullptr) {
|
||||
ESP_LOGE(TAG, "Could not discover matching NUS TX/RX service on this BLE device.");
|
||||
this->reset_state_();
|
||||
break;
|
||||
}
|
||||
this->tx_char_handle_ = tx_char->handle;
|
||||
this->rx_char_handle_ = rx_char->handle;
|
||||
|
||||
esp_err_t err = this->parent_->register_for_notify(this->rx_char_handle_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "register_for_notify failed, status=%d", err);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
|
||||
ESP_LOGI(TAG, "Register-for-notify event: status=%d handle=0x%04X", param->reg_for_notify.status,
|
||||
param->reg_for_notify.handle);
|
||||
if (param->reg_for_notify.handle == this->rx_char_handle_) {
|
||||
ESP_LOGI(TAG, "Subscribed to RX notification handle 0x%04X successfully.", this->rx_char_handle_);
|
||||
this->node_state = esp32_ble_tracker::ClientState::ESTABLISHED;
|
||||
if (this->state_ == DeltaState::DELTA_STATE_IDLE) {
|
||||
ESP_LOGI(TAG, "GATT connection established. Starting authentication state machine.");
|
||||
this->advance_to_(DeltaState::DELTA_STATE_GET_MODEL);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_NOTIFY_EVT: {
|
||||
if (param->notify.handle != this->rx_char_handle_)
|
||||
break;
|
||||
|
||||
const size_t value_len = param->notify.value_len;
|
||||
ESP_LOGD(TAG, "Notification received on handle 0x%04X with %u bytes. Buffer before append=%u bytes.",
|
||||
param->notify.handle, param->notify.value_len, (unsigned int) this->rx_len_);
|
||||
if (value_len > MAX_FRAME_LEN - this->rx_len_) {
|
||||
ESP_LOGW(TAG, "Notification overflows the %u-byte RX buffer. Discarding buffered data.",
|
||||
(unsigned int) MAX_FRAME_LEN);
|
||||
this->rx_len_ = 0;
|
||||
if (value_len > MAX_FRAME_LEN)
|
||||
break;
|
||||
}
|
||||
std::memcpy(this->rx_buffer_.data() + this->rx_len_, param->notify.value, value_len);
|
||||
this->rx_len_ += value_len;
|
||||
|
||||
bool progress = true;
|
||||
while (progress && this->rx_len_ >= 3) {
|
||||
progress = false;
|
||||
uint8_t func_code = this->rx_buffer_[1];
|
||||
size_t expected_len = 0;
|
||||
|
||||
if (func_code == FC_READ_HOLDING_REGISTERS) {
|
||||
expected_len = this->rx_buffer_[2] + READ_RESPONSE_OVERHEAD;
|
||||
} else if (func_code == FC_WRITE_MULTIPLE_REGISTERS || func_code == FC_WRITE_SINGLE_REGISTER) {
|
||||
expected_len = WRITE_RESPONSE_LEN;
|
||||
} else if (func_code == (FC_EXCEPTION_FLAG | FC_READ_HOLDING_REGISTERS) ||
|
||||
func_code == (FC_EXCEPTION_FLAG | FC_WRITE_MULTIPLE_REGISTERS) ||
|
||||
func_code == (FC_EXCEPTION_FLAG | FC_WRITE_SINGLE_REGISTER)) {
|
||||
expected_len = EXCEPTION_RESPONSE_LEN;
|
||||
}
|
||||
|
||||
if (expected_len > 0) {
|
||||
if (this->rx_len_ >= expected_len) {
|
||||
if (verify_crc(this->rx_buffer_.data(), expected_len)) {
|
||||
// The frame stays in rx_buffer_ during handle_frame_(); it is dropped afterwards.
|
||||
this->handle_frame_(this->rx_buffer_.data(), expected_len);
|
||||
this->drop_rx_bytes_(expected_len);
|
||||
progress = true;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "CRC mismatch while parsing notification data in state %s. Dropping one byte from buffer.",
|
||||
LOG_STR_ARG(delta_state_to_string(this->state_)));
|
||||
// Shift by one byte to realign with the next possible frame start
|
||||
this->drop_rx_bytes_(1);
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG,
|
||||
"Unsupported function code 0x%02X while parsing notification data in state %s. Advancing buffer.",
|
||||
func_code, LOG_STR_ARG(delta_state_to_string(this->state_)));
|
||||
this->drop_rx_bytes_(1);
|
||||
progress = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void DeltaWallbox::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
|
||||
switch (event) {
|
||||
case ESP_GAP_BLE_PASSKEY_REQ_EVT: {
|
||||
if (!this->parent_->check_addr(param->ble_security.ble_req.bd_addr))
|
||||
return;
|
||||
|
||||
if (!this->has_pin_) {
|
||||
ESP_LOGW(TAG, "Wallbox requested a BLE passkey, but no pin is configured. Set pin: under delta_wallbox:.");
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Wallbox requested a BLE passkey. Replying with configured YAML pin.");
|
||||
esp_err_t err = esp_ble_passkey_reply(param->ble_security.ble_req.bd_addr, true, this->pin_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Failed to send BLE passkey reply: %s", esp_err_to_name(err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ESP_GAP_BLE_AUTH_CMPL_EVT:
|
||||
if (!this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr))
|
||||
return;
|
||||
|
||||
this->pairing_in_progress_ = false;
|
||||
if (param->ble_security.auth_cmpl.success) {
|
||||
ESP_LOGI(TAG,
|
||||
"BLE pairing completed successfully. auth_mode=%d addr_type=%d. Re-checking wallbox pairing status.",
|
||||
param->ble_security.auth_cmpl.auth_mode, param->ble_security.auth_cmpl.addr_type);
|
||||
this->pairing_attempts_ = 0;
|
||||
if (this->node_state == esp32_ble_tracker::ClientState::ESTABLISHED &&
|
||||
this->state_ == DeltaState::DELTA_STATE_CHECK_PAIRING) {
|
||||
this->advance_to_(DeltaState::DELTA_STATE_CHECK_PAIRING);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "BLE pairing failed, reason=%d", param->ble_security.auth_cmpl.fail_reason);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::delta_wallbox
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,103 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <array>
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/ble_client/ble_client.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#ifdef USE_TIME
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::delta_wallbox {
|
||||
// Largest FC03 response: unit, function, byte count, 255 data bytes, CRC.
|
||||
static constexpr size_t MAX_FRAME_LEN = 260;
|
||||
enum class DeltaState : uint8_t {
|
||||
DELTA_STATE_CHECK_PAIRING,
|
||||
DELTA_STATE_CONNECTED,
|
||||
DELTA_STATE_GET_CHARGER_FW_VERSION,
|
||||
DELTA_STATE_GET_BT_FW_VERSION,
|
||||
DELTA_STATE_GET_LOGIN_RESULT,
|
||||
DELTA_STATE_GET_LOGIN_RESULT0,
|
||||
DELTA_STATE_GET_LOGIN_ROLE,
|
||||
DELTA_STATE_GET_LOGIN_USER,
|
||||
DELTA_STATE_GET_MODEL,
|
||||
DELTA_STATE_GET_SERIAL_NUMBER,
|
||||
DELTA_STATE_HALTED,
|
||||
DELTA_STATE_IDLE,
|
||||
DELTA_STATE_SEND_LOGIN_CREDENTIALS,
|
||||
DELTA_STATE_TIME_SYNC,
|
||||
};
|
||||
|
||||
class DeltaWallbox : public esphome::ble_client::BLEClientNode, public PollingComponent {
|
||||
public:
|
||||
DeltaWallbox(const char *user, const char *password) : user_(user), password_(password) {}
|
||||
void update() override;
|
||||
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
|
||||
esp_ble_gattc_cb_param_t *param) override;
|
||||
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_pin(uint32_t pin) {
|
||||
this->pin_ = pin;
|
||||
this->has_pin_ = true;
|
||||
}
|
||||
#ifdef USE_TIME
|
||||
void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; }
|
||||
#endif
|
||||
|
||||
SUB_SENSOR(power)
|
||||
SUB_SENSOR(energy)
|
||||
SUB_SENSOR(charging_time)
|
||||
SUB_SENSOR(charger_state)
|
||||
SUB_SENSOR(max_current)
|
||||
SUB_TEXT_SENSOR(serial_number)
|
||||
SUB_TEXT_SENSOR(fw_version)
|
||||
SUB_TEXT_SENSOR(bt_fw_version)
|
||||
SUB_TEXT_SENSOR(model_name)
|
||||
SUB_TEXT_SENSOR(error_code)
|
||||
void set_charging_state(bool state);
|
||||
void start_charging();
|
||||
void stop_charging();
|
||||
|
||||
bool is_connected() {
|
||||
return this->node_state == esp32_ble_tracker::ClientState::ESTABLISHED &&
|
||||
this->state_ == DeltaState::DELTA_STATE_CONNECTED;
|
||||
}
|
||||
|
||||
protected:
|
||||
void write_frame_(const uint8_t *frame, size_t len);
|
||||
void send_frame_(const uint8_t *body, size_t len);
|
||||
void send_next_state_command_();
|
||||
void retransmit_();
|
||||
void advance_to_(DeltaState next);
|
||||
void handle_frame_(const uint8_t *frame, size_t len);
|
||||
void drop_rx_bytes_(size_t count);
|
||||
void reset_state_();
|
||||
// Stops the handshake until the next BLE disconnect; used for failures a retry can't fix.
|
||||
void halt_(const LogString *reason);
|
||||
|
||||
// Members ordered by size so nothing is padded
|
||||
StringRef user_;
|
||||
StringRef password_;
|
||||
uint32_t pin_{0};
|
||||
#ifdef USE_TIME
|
||||
time::RealTimeClock *time_id_{nullptr};
|
||||
#endif
|
||||
size_t rx_len_{0};
|
||||
|
||||
uint16_t rx_char_handle_{0};
|
||||
uint16_t tx_char_handle_{0};
|
||||
DeltaState state_{DeltaState::DELTA_STATE_IDLE};
|
||||
uint8_t pairing_attempts_{0};
|
||||
bool has_pin_{false};
|
||||
bool pairing_in_progress_{false};
|
||||
std::array<uint8_t, MAX_FRAME_LEN> rx_buffer_{};
|
||||
};
|
||||
} // namespace esphome::delta_wallbox
|
||||
#endif
|
||||
@@ -0,0 +1,66 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ENERGY,
|
||||
CONF_MAX_CURRENT,
|
||||
CONF_POWER,
|
||||
DEVICE_CLASS_DURATION,
|
||||
DEVICE_CLASS_ENERGY,
|
||||
DEVICE_CLASS_POWER,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
UNIT_AMPERE,
|
||||
UNIT_KILOWATT,
|
||||
UNIT_KILOWATT_HOURS,
|
||||
UNIT_MINUTE,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
|
||||
|
||||
DEPENDENCIES = ["delta_wallbox"]
|
||||
|
||||
CONF_CHARGING_TIME = "charging_time"
|
||||
CONF_CHARGER_STATE = "charger_state"
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
|
||||
cv.Optional(CONF_CHARGER_STATE): sensor.sensor_schema(
|
||||
accuracy_decimals=0,
|
||||
),
|
||||
cv.Optional(CONF_MAX_CURRENT): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_AMPERE,
|
||||
accuracy_decimals=1,
|
||||
),
|
||||
cv.Optional(CONF_POWER): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_POWER,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_ENERGY): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_KILOWATT_HOURS,
|
||||
accuracy_decimals=2,
|
||||
device_class=DEVICE_CLASS_ENERGY,
|
||||
state_class=STATE_CLASS_TOTAL_INCREASING,
|
||||
),
|
||||
cv.Optional(CONF_CHARGING_TIME): sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_MINUTE,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DURATION,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
|
||||
sensors = sensor.sub_sensors(config)
|
||||
await sensors(CONF_CHARGER_STATE, hub.set_charger_state_sensor)
|
||||
await sensors(CONF_MAX_CURRENT, hub.set_max_current_sensor)
|
||||
await sensors(CONF_POWER, hub.set_power_sensor)
|
||||
await sensors(CONF_ENERGY, hub.set_energy_sensor)
|
||||
await sensors(CONF_CHARGING_TIME, hub.set_charging_time_sensor)
|
||||
@@ -0,0 +1,34 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import text_sensor
|
||||
from esphome.components.const import CONF_ERROR_CODE, CONF_SERIAL_NUMBER
|
||||
import esphome.config_validation as cv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import CONF_DELTA_WALLBOX_ID, DeltaWallbox
|
||||
|
||||
DEPENDENCIES = ["delta_wallbox"]
|
||||
|
||||
CONF_FW_VERSION = "fw_version"
|
||||
CONF_BT_FW_VERSION = "bt_fw_version"
|
||||
CONF_MODEL_NAME = "model_name"
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_DELTA_WALLBOX_ID): cv.use_id(DeltaWallbox),
|
||||
cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(),
|
||||
cv.Optional(CONF_FW_VERSION): text_sensor.text_sensor_schema(),
|
||||
cv.Optional(CONF_BT_FW_VERSION): text_sensor.text_sensor_schema(),
|
||||
cv.Optional(CONF_MODEL_NAME): text_sensor.text_sensor_schema(),
|
||||
cv.Optional(CONF_ERROR_CODE): text_sensor.text_sensor_schema(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
hub = await cg.get_variable(config[CONF_DELTA_WALLBOX_ID])
|
||||
text_sensors = text_sensor.sub_text_sensors(config)
|
||||
await text_sensors(CONF_SERIAL_NUMBER, hub.set_serial_number_text_sensor)
|
||||
await text_sensors(CONF_FW_VERSION, hub.set_fw_version_text_sensor)
|
||||
await text_sensors(CONF_BT_FW_VERSION, hub.set_bt_fw_version_text_sensor)
|
||||
await text_sensors(CONF_MODEL_NAME, hub.set_model_name_text_sensor)
|
||||
await text_sensors(CONF_ERROR_CODE, hub.set_error_code_text_sensor)
|
||||
@@ -0,0 +1,87 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import time, uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_DISCOVERY, CONF_ID, CONF_TIME, CONF_TIME_ID
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@kokx"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
DOMAIN = "duco"
|
||||
MULTI_CONF = True
|
||||
|
||||
CONF_DISABLE_CRC = "disable_crc"
|
||||
CONF_DUCO_ID = "duco_id"
|
||||
|
||||
duco_ns = cg.esphome_ns.namespace("duco")
|
||||
Duco = duco_ns.class_("Duco", cg.Component, uart.UARTDevice)
|
||||
# Base of every class that sends requests through the hub
|
||||
DucoDevice = duco_ns.class_(
|
||||
"DucoDevice", cg.PollingComponent, cg.Parented.template(Duco)
|
||||
)
|
||||
DucoDiscovery = duco_ns.class_("DucoDiscovery", DucoDevice)
|
||||
DucoTime = duco_ns.class_("DucoTime", cg.PollingComponent, cg.Parented.template(Duco))
|
||||
|
||||
# Entities of the platforms take the hub with this
|
||||
DUCO_COMPONENT_SCHEMA = cv.Schema({cv.GenerateID(CONF_DUCO_ID): cv.use_id(Duco)})
|
||||
|
||||
DISCOVERY_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(DucoDiscovery)}).extend(
|
||||
cv.COMPONENT_SCHEMA
|
||||
)
|
||||
|
||||
TIME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(DucoTime),
|
||||
cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
|
||||
}
|
||||
).extend(cv.polling_component_schema("15min"))
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(Duco),
|
||||
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_DISCOVERY): DISCOVERY_SCHEMA,
|
||||
cv.Optional(CONF_TIME): TIME_SCHEMA,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
DOMAIN,
|
||||
baud_rate=57600,
|
||||
require_tx=True,
|
||||
require_rx=True,
|
||||
data_bits=8,
|
||||
parity="NONE",
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
|
||||
_request_device_slot = cg.slot_counter("DUCO_DEVICE_COUNT")
|
||||
|
||||
|
||||
async def register_duco_device(var: cg.MockObj, config: ConfigType, hub_id: ID) -> None:
|
||||
"""Register a device that sends requests, and give it an open request slot on its hub."""
|
||||
await cg.register_component(var, config)
|
||||
hub = await cg.get_variable(hub_id)
|
||||
await cg.register_parented(var, hub)
|
||||
_request_device_slot(str(hub_id))
|
||||
cg.add(hub.register_device(var))
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await uart.new_uart_component(config)
|
||||
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
|
||||
|
||||
if (discovery := config.get(CONF_DISCOVERY)) is not None:
|
||||
discovery_var = cg.new_Pvariable(discovery[CONF_ID])
|
||||
await register_duco_device(discovery_var, discovery, config[CONF_ID])
|
||||
|
||||
if (time_config := config.get(CONF_TIME)) is not None:
|
||||
clock = await cg.get_variable(time_config[CONF_TIME_ID])
|
||||
time_var = cg.new_Pvariable(time_config[CONF_ID], clock)
|
||||
await cg.register_component(time_var, time_config)
|
||||
await cg.register_parented(time_var, var)
|
||||
@@ -0,0 +1,273 @@
|
||||
#include "duco.h"
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "duco");
|
||||
|
||||
static constexpr uint8_t HEADER_START = 0xAA;
|
||||
static constexpr uint8_t HEADER_END = 0x55;
|
||||
static constexpr uint8_t STUFFING = 0x01; // follows an 0xAA inside a message, not part of it
|
||||
static constexpr uint32_t FRAME_GAP_MS = 50;
|
||||
|
||||
static constexpr uint32_t DISCOVERY_START_ID = 0;
|
||||
static constexpr uint32_t DISCOVERY_STEP_ID = 1;
|
||||
static constexpr uint32_t DISCOVERY_START_MS = 16000;
|
||||
static constexpr uint32_t DISCOVERY_STEP_MS = 1600;
|
||||
static constexpr uint8_t DISCOVERY_MAX_ATTEMPTS = 3;
|
||||
|
||||
void Duco::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
// Read first: after a slow loop pass the rest of a message is already waiting, which is not silence
|
||||
this->read_available([this, now](uint8_t byte) {
|
||||
if (this->parse_byte_(byte)) {
|
||||
this->last_byte_ = now;
|
||||
} else {
|
||||
this->reset_frame_();
|
||||
}
|
||||
});
|
||||
|
||||
// The last message of a burst has no header after it, so it ends on silence
|
||||
if (this->rx_size_ != 0 && now - this->last_byte_ > FRAME_GAP_MS) {
|
||||
this->finalize_message_();
|
||||
this->reset_frame_();
|
||||
}
|
||||
}
|
||||
|
||||
void Duco::reset_frame_() {
|
||||
this->rx_size_ = 0;
|
||||
this->escaped_ = false;
|
||||
}
|
||||
|
||||
void Duco::close_request(DucoDevice *device) {
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.device == device) {
|
||||
pending.function = 0;
|
||||
pending.missed = false;
|
||||
this->update_warning_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Duco::parse_byte_(uint8_t byte) {
|
||||
const size_t at = this->rx_size_;
|
||||
if (at >= MAX_FRAME_SIZE) {
|
||||
ESP_LOGV(TAG, "Message longer than %u bytes dropped", static_cast<unsigned>(MAX_FRAME_SIZE));
|
||||
return false;
|
||||
}
|
||||
const uint8_t prev = at > 0 ? this->rx_buffer_[at - 1] : 0;
|
||||
|
||||
if (at == 1 && prev == HEADER_START && byte == HEADER_END)
|
||||
return false; // header of the next message
|
||||
|
||||
if (at >= 2) {
|
||||
const bool escaped = this->escaped_;
|
||||
this->escaped_ = false;
|
||||
if (prev == HEADER_START && !escaped) {
|
||||
if (byte == STUFFING) {
|
||||
this->escaped_ = true;
|
||||
return true;
|
||||
}
|
||||
if (at >= 3 && byte == HEADER_END) {
|
||||
this->rx_size_--; // drop the 0xAA of the header
|
||||
this->finalize_message_();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this->rx_buffer_[this->rx_size_++] = byte;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Duco::finalize_message_() {
|
||||
const uint8_t *raw = this->rx_buffer_.data();
|
||||
// length (function, id and data), function, id, data, CRC low, CRC high
|
||||
if (this->rx_size_ < 5 || raw[0] < 2 || this->rx_size_ < raw[0] + 3u) {
|
||||
ESP_LOGV(TAG, "Malformed message dropped: %u bytes, length byte %u", this->rx_size_, raw[0]);
|
||||
return;
|
||||
}
|
||||
const uint8_t length = raw[0];
|
||||
|
||||
const uint16_t computed_crc = crc16(raw, length + 1);
|
||||
const uint16_t remote_crc = encode_uint16(raw[length + 2], raw[length + 1]);
|
||||
if (computed_crc != remote_crc) {
|
||||
if (!this->disable_crc_) {
|
||||
ESP_LOGV(TAG, "CRC check failed: %04X != %04X", computed_crc, remote_crc);
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "CRC check failed, ignored: %04X != %04X", computed_crc, remote_crc);
|
||||
}
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
// The box first acknowledges a request with function + 1, then answers with function + 2
|
||||
const DucoMessage message{raw[2], raw + 3, static_cast<uint8_t>(length - 2)};
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.function != 0 && pending.id == message.id && pending.function + 2 == raw[1]) {
|
||||
pending.function = 0;
|
||||
pending.missed = false;
|
||||
this->update_warning_();
|
||||
pending.device->receive_response(message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Acknowledgements (odd function codes) are expected and never match
|
||||
if ((raw[1] & 1) == 0) {
|
||||
ESP_LOGV(TAG, "Answer %02X with id %u matches no open request", raw[1], message.id);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void Duco::update_warning_() {
|
||||
for (const auto &pending : this->pending_) {
|
||||
if (pending.missed) {
|
||||
this->status_set_warning(LOG_STR("No answer from the box"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->status_clear_warning();
|
||||
}
|
||||
#endif
|
||||
|
||||
void Duco::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Duco:\n"
|
||||
" CRC Disabled: %s",
|
||||
YESNO(this->disable_crc_));
|
||||
}
|
||||
|
||||
uint8_t Duco::next_id_() {
|
||||
do {
|
||||
this->last_id_++;
|
||||
} while (this->last_id_ == HEADER_START || this->last_id_ == HEADER_END);
|
||||
return this->last_id_;
|
||||
}
|
||||
|
||||
void Duco::send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data) {
|
||||
if (data.size() > MAX_REQUEST_DATA) {
|
||||
ESP_LOGE(TAG, "Request of %u bytes is too long", static_cast<unsigned>(data.size()));
|
||||
return;
|
||||
}
|
||||
// Not byte stuffed, as in the author's original and no capture shows a request with 0xAA. An 0xAA followed by any
|
||||
// byte but 0x01 or 0x55 reads the same with or without unstuffing on the box, so only those rare pairs are
|
||||
// unverified.
|
||||
const size_t size = data.size();
|
||||
const uint8_t id = this->next_id_();
|
||||
uint8_t frame[5 + MAX_REQUEST_DATA + 2] = {HEADER_START, HEADER_END, static_cast<uint8_t>(size + 2), function, id};
|
||||
std::copy_n(data.begin(), size, frame + 5);
|
||||
const uint16_t crc = crc16(frame + 2, size + 3);
|
||||
frame[size + 5] = crc & 0xFF;
|
||||
frame[size + 6] = crc >> 8;
|
||||
this->write_array(frame, size + 7);
|
||||
this->flush();
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
for (auto &pending : this->pending_) {
|
||||
if (pending.device == device) {
|
||||
pending.missed = pending.function != 0;
|
||||
if (pending.missed) {
|
||||
ESP_LOGV(TAG, "No answer to request %02X with id %u", pending.function, pending.id);
|
||||
this->update_warning_();
|
||||
}
|
||||
pending.id = id;
|
||||
pending.function = function;
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
ESP_LOGV_HEX(TAG, frame + 2, size + 5, sizeof(frame), "Sent %s");
|
||||
}
|
||||
|
||||
static const LogString *node_type_name(uint8_t type) {
|
||||
switch (type) {
|
||||
case 8:
|
||||
return LOG_STR("UCBAT");
|
||||
case 9:
|
||||
return LOG_STR("UC");
|
||||
case 10:
|
||||
return LOG_STR("UCRH");
|
||||
case 12:
|
||||
return LOG_STR("UCCO2");
|
||||
case 13:
|
||||
return LOG_STR("VLV");
|
||||
case 17:
|
||||
return LOG_STR("BOX");
|
||||
case 18:
|
||||
return LOG_STR("SWITCH");
|
||||
default:
|
||||
return LOG_STR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
|
||||
void DucoDiscovery::setup() {
|
||||
// Give the box time to start before walking its node addresses
|
||||
this->set_timeout(DISCOVERY_START_ID, DISCOVERY_START_MS, [this]() {
|
||||
this->set_interval(DISCOVERY_STEP_ID, DISCOVERY_STEP_MS, [this]() { this->update(); });
|
||||
});
|
||||
}
|
||||
|
||||
void DucoDiscovery::update() {
|
||||
if (this->attempts_ == DISCOVERY_MAX_ATTEMPTS) {
|
||||
ESP_LOGV(TAG, "No answer for node %u", this->next_node_);
|
||||
this->next_node_++;
|
||||
this->attempts_ = 0;
|
||||
}
|
||||
if (this->next_node_ >= NODE_COUNT) {
|
||||
this->cancel_interval(DISCOVERY_STEP_ID);
|
||||
this->parent_->close_request(this);
|
||||
return;
|
||||
}
|
||||
// Without an answer by the next step the same node is asked again; a retry is not a missed answer
|
||||
if (this->attempts_ > 0)
|
||||
this->parent_->close_request(this);
|
||||
ESP_LOGV(TAG, "Discovering node %u", this->next_node_);
|
||||
this->attempts_++;
|
||||
this->parent_->send(this, FUNCTION_NODE, {0x01, this->next_node_});
|
||||
}
|
||||
|
||||
void DucoDiscovery::receive_response(const DucoMessage &message) {
|
||||
// Only the latest attempt is open, so a late answer to an earlier one never arrives here
|
||||
if (message.size >= 1 && this->next_node_ < NODE_COUNT) {
|
||||
if (message.data[0] != 0) {
|
||||
ESP_LOGV(TAG, "Found node %u: type %u", this->next_node_, message.data[0]);
|
||||
}
|
||||
this->node_types_[this->next_node_++] = message.data[0];
|
||||
this->attempts_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void DucoDiscovery::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Discovered nodes:");
|
||||
for (uint8_t node = 0; node < NODE_COUNT; node++) {
|
||||
const uint8_t type = this->node_types_[node];
|
||||
if (type != 0) {
|
||||
ESP_LOGCONFIG(TAG, " Node %u: type %u (%s)", node, type, LOG_STR_ARG(node_type_name(type)));
|
||||
}
|
||||
}
|
||||
if (this->next_node_ < NODE_COUNT) {
|
||||
ESP_LOGCONFIG(TAG, " Still discovering, at node %u", this->next_node_);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_TIME
|
||||
void DucoTime::update() {
|
||||
const ESPTime now = this->clock_->now();
|
||||
if (!now.is_valid()) {
|
||||
ESP_LOGV(TAG, "Time is not valid yet");
|
||||
return;
|
||||
}
|
||||
const uint32_t ts = now.timestamp;
|
||||
this->parent_->send(nullptr, FUNCTION_PARAMETER,
|
||||
{0x05, static_cast<uint8_t>(ts), static_cast<uint8_t>(ts >> 8), static_cast<uint8_t>(ts >> 16),
|
||||
static_cast<uint8_t>(ts >> 24), 0x00, 0x00, 0x00, 0x00});
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <initializer_list>
|
||||
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#ifdef USE_TIME
|
||||
#include "esphome/components/time/real_time_clock.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::duco {
|
||||
|
||||
// Function codes of the requests; the box answers a request with function + 2
|
||||
static constexpr uint8_t FUNCTION_NODE = 0x0c; // discovery, status and mode of a node
|
||||
static constexpr uint8_t FUNCTION_NODE_DATA = 0x10; // a block of a node's data (sensors, serial number)
|
||||
static constexpr uint8_t FUNCTION_PARAMETER = 0x24; // read or write a box parameter, set the clock
|
||||
|
||||
/// A received message; data points into the receive buffer and is valid only during the callback.
|
||||
struct DucoMessage {
|
||||
uint8_t id;
|
||||
const uint8_t *data;
|
||||
uint8_t size;
|
||||
};
|
||||
|
||||
class DucoDevice;
|
||||
|
||||
/// The open request of one device: the answer to it is function + 2 with the same message id.
|
||||
struct DucoPendingRequest {
|
||||
DucoDevice *device;
|
||||
uint8_t id;
|
||||
uint8_t function; // 0 while nothing is open
|
||||
bool missed; // the request before this one got no answer; sits in padding
|
||||
};
|
||||
|
||||
class Duco : public uart::UARTDevice, public Component {
|
||||
public:
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void register_device(DucoDevice *device) { this->pending_.push_back({device, 0, 0, false}); }
|
||||
#endif
|
||||
|
||||
/// Forget device's open request, for a device that will not send again.
|
||||
void close_request(DucoDevice *device);
|
||||
|
||||
/// Send a request; its answer goes to device, or nowhere if nullptr. A device's new request replaces its
|
||||
/// open one, so an answer is never lost to another device's traffic.
|
||||
void send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data);
|
||||
|
||||
protected:
|
||||
// The longest answer to a request sent here is 25 bytes (node status), the longest request 9
|
||||
static constexpr size_t MAX_FRAME_SIZE = 32;
|
||||
static constexpr size_t MAX_REQUEST_DATA = 9;
|
||||
|
||||
bool parse_byte_(uint8_t byte);
|
||||
void reset_frame_();
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
void update_warning_(); // warn while any device's previous request went unanswered
|
||||
#endif
|
||||
void finalize_message_();
|
||||
uint8_t next_id_();
|
||||
|
||||
#ifdef DUCO_DEVICE_COUNT
|
||||
StaticVector<DucoPendingRequest, DUCO_DEVICE_COUNT> pending_;
|
||||
#endif
|
||||
std::array<uint8_t, MAX_FRAME_SIZE> rx_buffer_{};
|
||||
uint32_t last_byte_{0};
|
||||
uint8_t rx_size_{0};
|
||||
uint8_t last_id_{10};
|
||||
bool escaped_{false}; // the last 0xAA was followed by a stuffing byte, so it starts no header
|
||||
bool disable_crc_{false};
|
||||
};
|
||||
|
||||
/// Every entity polls the box; deriving from PollingComponent shares its vtable.
|
||||
class DucoDevice : public PollingComponent, public Parented<Duco> {
|
||||
public:
|
||||
virtual void receive_response(const DucoMessage &message) = 0;
|
||||
};
|
||||
|
||||
/// Walks the node addresses once after boot; the nodes found are listed in the config dump.
|
||||
class DucoDiscovery : public DucoDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override; // one discovery step, run by the step interval
|
||||
void receive_response(const DucoMessage &message) override;
|
||||
|
||||
protected:
|
||||
static constexpr uint8_t NODE_COUNT = 0x45;
|
||||
|
||||
std::array<uint8_t, NODE_COUNT> node_types_{}; // 0 when no node answered at that address
|
||||
uint8_t next_node_{0};
|
||||
uint8_t attempts_{0};
|
||||
};
|
||||
|
||||
#ifdef USE_TIME
|
||||
class DucoTime : public PollingComponent, public Parented<Duco> {
|
||||
public:
|
||||
explicit DucoTime(time::RealTimeClock *clock) : clock_(clock) {}
|
||||
void update() override;
|
||||
|
||||
protected:
|
||||
time::RealTimeClock *clock_;
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace esphome::duco
|
||||
@@ -0,0 +1,34 @@
|
||||
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])
|
||||
@@ -0,0 +1,26 @@
|
||||
#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
|
||||
@@ -0,0 +1,18 @@
|
||||
#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
|
||||
@@ -0,0 +1,45 @@
|
||||
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])
|
||||
@@ -0,0 +1,47 @@
|
||||
#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
|
||||
@@ -0,0 +1,18 @@
|
||||
#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
|
||||
@@ -0,0 +1,156 @@
|
||||
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])
|
||||
@@ -0,0 +1,78 @@
|
||||
#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
|
||||
@@ -0,0 +1,34 @@
|
||||
#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
|
||||
@@ -0,0 +1,27 @@
|
||||
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])
|
||||
@@ -0,0 +1,17 @@
|
||||
#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
|
||||
@@ -0,0 +1,15 @@
|
||||
#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
|
||||
@@ -80,13 +80,12 @@ def final_validate(config: ConfigType) -> None:
|
||||
full_config = fv.full_config.get()
|
||||
|
||||
# Count sensors registered to this hub (IDs are resolved at final_validate stage)
|
||||
hub_id = str(config[CONF_ID])
|
||||
sensor_count = sum(
|
||||
1
|
||||
for s in full_config.get(SENSOR_DOMAIN, [])
|
||||
if s.get("platform") == DOMAIN and str(s.get(CONF_EMONTX_ID)) == hub_id
|
||||
hub_id = config[CONF_ID]
|
||||
_get_data().sensor_counts[str(hub_id)] = len(
|
||||
fv.platform_entries(
|
||||
domain=SENSOR_DOMAIN, platform=DOMAIN, match={CONF_EMONTX_ID: hub_id}
|
||||
)
|
||||
)
|
||||
_get_data().sensor_counts[hub_id] = sensor_count
|
||||
|
||||
# Ensure UART RX buffer size is large enough to handle data bursts from firmware
|
||||
for uart_conf in full_config[UART_DOMAIN]:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "epaper_spi.h"
|
||||
#include <algorithm>
|
||||
#include <cinttypes>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -37,6 +38,42 @@ 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();
|
||||
@@ -233,17 +270,28 @@ void EPaperBase::process_state_() {
|
||||
// Refresh the whole panel even if nothing was drawn
|
||||
this->full_update_requested_ = false;
|
||||
this->update_count_ = 0;
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
this->set_full_bounds_();
|
||||
}
|
||||
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:
|
||||
@@ -256,10 +304,15 @@ void EPaperBase::process_state_() {
|
||||
if (!this->transfer_data()) {
|
||||
return; // Not done yet, come back next loop
|
||||
}
|
||||
this->x_low_ = this->width_;
|
||||
this->x_high_ = 0;
|
||||
this->y_low_ = this->height_;
|
||||
this->y_high_ = 0;
|
||||
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->set_state_(EPaperState::POWER_ON);
|
||||
break;
|
||||
case EPaperState::POWER_ON:
|
||||
@@ -280,6 +333,7 @@ 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_);
|
||||
@@ -365,6 +419,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -486,6 +541,7 @@ 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,10 +99,7 @@ class EPaperBase : public Display,
|
||||
|
||||
// We store 8 pixels per byte
|
||||
this->buffer_.fill(pixel_color);
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->mark_all_drawn_();
|
||||
}
|
||||
|
||||
void clear() override {
|
||||
@@ -139,6 +136,25 @@ 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,
|
||||
@@ -200,6 +216,13 @@ 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,11 +18,7 @@ void EPaper4bpp::fill(Color color) {
|
||||
// We store 2 pixels per byte
|
||||
this->buffer_.fill(pixel_color + (pixel_color << 4));
|
||||
|
||||
// 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_;
|
||||
this->mark_all_drawn_();
|
||||
}
|
||||
|
||||
void EPaper4bpp::clear() {
|
||||
|
||||
@@ -71,10 +71,7 @@ void EPaperInkplate2::fill(Color color) {
|
||||
for (size_t i = half_buffer; i < this->buffer_length_; i++)
|
||||
this->buffer_[i] = red_byte;
|
||||
|
||||
this->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
this->mark_all_drawn_();
|
||||
}
|
||||
|
||||
void EPaperInkplate2::clear() { this->fill(COLOR_ON); }
|
||||
|
||||
@@ -30,6 +30,7 @@ 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,6 +7,12 @@
|
||||
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});
|
||||
@@ -66,7 +72,8 @@ 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_) {
|
||||
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
|
||||
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
|
||||
size_t data_idx = row_start;
|
||||
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,6 +12,8 @@ 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 {}
|
||||
@@ -19,6 +21,8 @@ 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,21 +6,6 @@
|
||||
#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_();
|
||||
@@ -82,7 +67,7 @@ bool HOT EPaperSSD1677::transfer_data() {
|
||||
}
|
||||
this->write_array(data, length);
|
||||
if (this->plane_ == 1)
|
||||
this->sent_.write(this->current_data_index_, data, length);
|
||||
this->record_sent_(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:
|
||||
// Allocates the comparison frame when partial updates are enabled. Separate from setup() so it
|
||||
// can run without a bus.
|
||||
void init_comparison_frame_();
|
||||
// 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_; }
|
||||
// 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,8 +39,7 @@ class EPaperSSD1677 : public EPaperMono {
|
||||
bool reset() override;
|
||||
bool transfer_data() override;
|
||||
|
||||
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
|
||||
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
|
||||
};
|
||||
|
||||
} // namespace esphome::epaper_spi
|
||||
@@ -29,10 +29,7 @@ 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->x_low_ = 0;
|
||||
this->y_low_ = 0;
|
||||
this->x_high_ = this->width_;
|
||||
this->y_high_ = this->height_;
|
||||
this->mark_all_drawn_();
|
||||
}
|
||||
|
||||
void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) {
|
||||
@@ -83,14 +80,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++) {
|
||||
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;
|
||||
}
|
||||
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];
|
||||
++this->current_data_index_;
|
||||
this->write_array(bytes_to_send, plane_row_length);
|
||||
if (millis() - start_time > MAX_TRANSFER_TIME) {
|
||||
|
||||
@@ -1,10 +1,50 @@
|
||||
#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_) {
|
||||
@@ -15,6 +55,7 @@ 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,6 +16,10 @@ 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,12 +71,15 @@ 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_) {
|
||||
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_;
|
||||
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_;
|
||||
size_t data_idx = row_start;
|
||||
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,6 +232,7 @@ 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,4 +1,5 @@
|
||||
#include "epaper_waveshare_bwr.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -36,17 +37,9 @@ void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) {
|
||||
|
||||
const auto bwr = color_to_bwr(color);
|
||||
|
||||
if (bwr == BwrState::BWR_BLACK) {
|
||||
this->buffer_[pos] |= bit;
|
||||
} else {
|
||||
this->buffer_[pos] &= ~bit;
|
||||
}
|
||||
assign_bits(this->buffer_[pos], bit, bwr == BwrState::BWR_BLACK);
|
||||
|
||||
if (bwr == BwrState::BWR_RED) {
|
||||
this->buffer_[red_offset + pos] |= bit;
|
||||
} else {
|
||||
this->buffer_[red_offset + pos] &= ~bit;
|
||||
}
|
||||
assign_bits(this->buffer_[red_offset + pos], bit, bwr == BwrState::BWR_RED);
|
||||
}
|
||||
|
||||
void EPaperWaveshareBWR::fill(Color color) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "es8156.h"
|
||||
#include "es8156_const.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
@@ -100,11 +101,7 @@ bool ES8156::set_mute_state_(bool mute_state) {
|
||||
|
||||
ESP_LOGV(TAG, "Read ES8156_REG13_DAC_MUTE: %u", reg13);
|
||||
|
||||
if (mute_state) {
|
||||
reg13 |= BIT(1) | BIT(2);
|
||||
} else {
|
||||
reg13 &= ~(BIT(1) | BIT(2));
|
||||
}
|
||||
assign_bits(reg13, BIT(1) | BIT(2), mute_state);
|
||||
|
||||
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,6 +1,7 @@
|
||||
#include "es8311.h"
|
||||
#include "es8311_const.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
@@ -154,11 +155,7 @@ bool ES8311::configure_clock_() {
|
||||
// Register 0x06
|
||||
uint8_t reg06;
|
||||
ES8311_ERROR_CHECK(this->read_byte(ES8311_REG06_CLK_MANAGER, ®06));
|
||||
if (this->sclk_inverted_) {
|
||||
reg06 |= BIT(5);
|
||||
} else {
|
||||
reg06 &= ~BIT(5);
|
||||
}
|
||||
assign_bits(reg06, BIT(5), this->sclk_inverted_);
|
||||
reg06 &= 0xE0;
|
||||
if (coefficient->bclk_div < 19) {
|
||||
reg06 |= (coefficient->bclk_div - 1) << 0;
|
||||
@@ -223,11 +220,7 @@ bool ES8311::set_mute_state_(bool mute_state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mute_state) {
|
||||
reg31 |= BIT(6) | BIT(5);
|
||||
} else {
|
||||
reg31 &= ~(BIT(6) | BIT(5));
|
||||
}
|
||||
assign_bits(reg31, BIT(6) | BIT(5), mute_state);
|
||||
|
||||
return this->write_byte(ES8311_REG31_DAC, reg31);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <cinttypes>
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::es8388 {
|
||||
@@ -184,11 +185,7 @@ 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.
|
||||
if (mute_state) {
|
||||
value |= ES8388_DACCONTROL3_DAC_MUTE;
|
||||
} else {
|
||||
value &= ~ES8388_DACCONTROL3_DAC_MUTE;
|
||||
}
|
||||
assign_bits(value, ES8388_DACCONTROL3_DAC_MUTE, mute_state);
|
||||
|
||||
ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state));
|
||||
return this->write_byte(ES8388_DACCONTROL3, value);
|
||||
|
||||
@@ -3828,6 +3828,20 @@ def _write_sdkconfig():
|
||||
clean_build(clear_pio_cache=False)
|
||||
|
||||
|
||||
def _arduino_provides(name: str) -> bool:
|
||||
"""Whether arduino-esp32 bundles the library a manifest depends on, so it is
|
||||
never fetched by name; it still has to be enabled to build."""
|
||||
if name not in ARDUINO_DISABLED_LIBRARIES:
|
||||
return False
|
||||
if name not in CORE.data[KEY_ESP32][KEY_ARDUINO_LIBRARIES]:
|
||||
_LOGGER.warning(
|
||||
"A library depends on the Arduino library %s, which is not enabled; "
|
||||
"add it under esphome: libraries:",
|
||||
name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _write_idf_component_yml():
|
||||
yml_path = CORE.relative_build_path("src/idf_component.yml")
|
||||
dependencies: dict[str, dict] = {}
|
||||
@@ -3849,7 +3863,11 @@ def _write_idf_component_yml():
|
||||
# IDF sees the same requirement twice; converted components reach it through
|
||||
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
|
||||
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
|
||||
converted = generate_idf_components(libraries, managed=managed)
|
||||
converted = generate_idf_components(
|
||||
libraries,
|
||||
managed=managed,
|
||||
provides=_arduino_provides if CORE.using_arduino else None,
|
||||
)
|
||||
# IDF names a component after its directory and a later registration of the
|
||||
# same name replaces the earlier one, so a stub beside a converted library of
|
||||
# the same name (espressif/libsodium vs esphome/libsodium) would win or lose
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
"""Pin checks shared by the ESP32 variant validators."""
|
||||
|
||||
from collections.abc import Iterable, Mapping, Set as AbstractSet
|
||||
import logging
|
||||
from logging import Logger
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from esphome.components.const import CONF_HOLD_STATE
|
||||
from esphome.const import CONF_NUMBER
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_MODE,
|
||||
CONF_NUMBER,
|
||||
CONF_OUTPUT,
|
||||
CONF_PULLDOWN,
|
||||
CONF_PULLUP,
|
||||
)
|
||||
from esphome.pins import check_strapping_pin
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_NO_PINS: Mapping[int, str] = MappingProxyType({})
|
||||
|
||||
_INPUT_ONLY_REJECTED_MODES = (
|
||||
(CONF_OUTPUT, "does not support output pin mode."),
|
||||
(CONF_PULLUP, "does not support pullups."),
|
||||
(CONF_PULLDOWN, "does not support pulldowns."),
|
||||
)
|
||||
|
||||
|
||||
def check_usb_jtag_pin(num: int, usb_jtag_pins: set[int], logger: Logger) -> None:
|
||||
def check_usb_jtag_pin(
|
||||
num: int, usb_jtag_pins: AbstractSet[int], logger: Logger
|
||||
) -> None:
|
||||
if num in usb_jtag_pins:
|
||||
logger.warning(
|
||||
"GPIO%d is used by the USB-Serial-JTAG interface."
|
||||
@@ -17,10 +41,104 @@ def check_usb_jtag_pin(num: int, usb_jtag_pins: set[int], logger: Logger) -> Non
|
||||
|
||||
|
||||
def check_usb_jtag_hold(
|
||||
conf: dict[str, Any], usb_jtag_pins: set[int], logger: Logger
|
||||
conf: dict[str, Any], usb_jtag_pins: AbstractSet[int], logger: Logger
|
||||
) -> None:
|
||||
num = conf[CONF_NUMBER]
|
||||
if conf.get(CONF_HOLD_STATE) and num in usb_jtag_pins:
|
||||
logger.warning(
|
||||
"GPIO%d cannot hold at low level during wakeup from deep sleep.", num
|
||||
)
|
||||
|
||||
|
||||
def spi_psram_errors(variant: str, functions: Mapping[int, str]) -> dict[int, str]:
|
||||
"""Error messages for pins taken by the SPI flash/PSRAM interface."""
|
||||
return {
|
||||
num: f"This pin cannot be used on {variant}s and is already used by the"
|
||||
f" SPI/PSRAM interface (function: {function})"
|
||||
for num, function in functions.items()
|
||||
}
|
||||
|
||||
|
||||
def unusable_errors(variant: str, pins: Iterable[int]) -> dict[int, str]:
|
||||
"""Error messages for pins that are missing from the GPIO matrix."""
|
||||
return {num: f"The pin GPIO{num} is not usable on {variant}s." for num in pins}
|
||||
|
||||
|
||||
class ESP32PinSpec:
|
||||
"""Pin facts of one ESP32 variant, with the two validators built from them.
|
||||
|
||||
`error_pins` fail pin validation, which `ignore_pin_validation_error` bypasses;
|
||||
`fatal_pins` and pins above `max_pin` fail both validators, so the option cannot bypass them.
|
||||
`input_only_label` is inserted after "GPIOn" in the input-only errors.
|
||||
A plain class, not a dataclass: building a dataclass costs ~0.5 ms of import.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"error_pins",
|
||||
"fatal_pins",
|
||||
"input_only_label",
|
||||
"input_only_pins",
|
||||
"max_pin",
|
||||
"no_hold_pins",
|
||||
"strapping_pins",
|
||||
"usb_jtag_pins",
|
||||
"warning_pins",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_pin: int,
|
||||
strapping_pins: frozenset[int],
|
||||
usb_jtag_pins: frozenset[int] = frozenset(),
|
||||
error_pins: Mapping[int, str] = _NO_PINS,
|
||||
fatal_pins: Mapping[int, str] = _NO_PINS,
|
||||
warning_pins: Mapping[int, str] = _NO_PINS,
|
||||
input_only_pins: frozenset[int] = frozenset(),
|
||||
input_only_label: str = "",
|
||||
no_hold_pins: frozenset[int] = frozenset(),
|
||||
) -> None:
|
||||
self.max_pin = max_pin
|
||||
self.strapping_pins = strapping_pins
|
||||
self.usb_jtag_pins = usb_jtag_pins
|
||||
self.error_pins = error_pins
|
||||
self.fatal_pins = fatal_pins
|
||||
self.warning_pins = warning_pins
|
||||
self.input_only_pins = input_only_pins
|
||||
self.input_only_label = input_only_label
|
||||
self.no_hold_pins = no_hold_pins
|
||||
|
||||
def _check_pin_exists(self, num: int) -> None:
|
||||
if num < 0 or num > self.max_pin:
|
||||
raise cv.Invalid(f"Invalid pin number: {num} (must be 0-{self.max_pin})")
|
||||
if (message := self.fatal_pins.get(num)) is not None:
|
||||
raise cv.Invalid(message)
|
||||
|
||||
def validate_gpio_pin(self, value: int) -> int:
|
||||
self._check_pin_exists(value)
|
||||
if (message := self.error_pins.get(value)) is not None:
|
||||
raise cv.Invalid(message)
|
||||
if (message := self.warning_pins.get(value)) is not None:
|
||||
_LOGGER.warning("%s", message)
|
||||
check_usb_jtag_pin(value, self.usb_jtag_pins, _LOGGER)
|
||||
return value
|
||||
|
||||
def validate_supports(self, value: ConfigType) -> ConfigType:
|
||||
num = value[CONF_NUMBER]
|
||||
self._check_pin_exists(num)
|
||||
hold = value.get(CONF_HOLD_STATE)
|
||||
if num in self.input_only_pins:
|
||||
prefix = f"GPIO{num}{self.input_only_label}"
|
||||
mode = value[CONF_MODE]
|
||||
for key, problem in _INPUT_ONLY_REJECTED_MODES:
|
||||
if mode[key]:
|
||||
raise cv.Invalid(f"{prefix} {problem}", [CONF_MODE, key])
|
||||
if hold:
|
||||
raise cv.Invalid(
|
||||
f"{prefix} is input-only and cannot be held.", [CONF_HOLD_STATE]
|
||||
)
|
||||
if hold and num in self.no_hold_pins:
|
||||
raise cv.Invalid(f"GPIO{num} has no hold function.", [CONF_HOLD_STATE])
|
||||
check_usb_jtag_hold(value, self.usb_jtag_pins, _LOGGER)
|
||||
check_strapping_pin(value, self.strapping_pins, _LOGGER)
|
||||
return value
|
||||
@@ -23,7 +23,7 @@ from esphome.components.openthread.const import CONF_POLL_PERIOD
|
||||
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
|
||||
from esphome.components.zigbee import DOMAIN as ZIGBEE_DOMAIN
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_OPENTHREAD, CONF_PLATFORM
|
||||
from esphome.const import CONF_ID, CONF_OPENTHREAD
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
@@ -204,11 +204,16 @@ async def to_code(config: ConfigType) -> None:
|
||||
add_idf_sdkconfig_option("CONFIG_IEEE802154_SLEEP_ENABLE", True)
|
||||
|
||||
|
||||
_validate_single_instance = fv.unique_platform_key(
|
||||
domain=power_management.DOMAIN,
|
||||
platform=DOMAIN,
|
||||
message=f"Only one {DOMAIN} instance is allowed",
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
_validate_single_instance(config)
|
||||
full_config = fv.full_config.get()
|
||||
entries = full_config.get(power_management.DOMAIN, [])
|
||||
if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in entries) > 1:
|
||||
raise cv.Invalid(f"Only one {DOMAIN} instance is allowed")
|
||||
boot_freq = full_config[esp32.DOMAIN][esp32.CONF_CPU_FREQUENCY]
|
||||
boot_mhz = int(boot_freq[:-3])
|
||||
max_mhz = config.get(CONF_MAX_FREQUENCY)
|
||||
|
||||
@@ -21,6 +21,11 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
platform="gp8403", hub_key=CONF_GP8403_ID
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "gree.h"
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::gree {
|
||||
|
||||
@@ -24,11 +25,7 @@ void GreeClimate::set_model(Model model) {
|
||||
}
|
||||
|
||||
void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
|
||||
if (enabled) {
|
||||
this->mode_bits_ |= bit_mask;
|
||||
} else {
|
||||
this->mode_bits_ &= ~bit_mask;
|
||||
}
|
||||
assign_bits(this->mode_bits_, bit_mask, enabled);
|
||||
this->transmit_state();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate_ir, remote_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_PLATFORM
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -21,26 +20,13 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend(
|
||||
)
|
||||
|
||||
|
||||
def _final_validate_unit(config: ConfigType) -> None:
|
||||
# Each transmitter may drive at most one unit A and one unit B
|
||||
climates = fv.full_config.get()["climate"]
|
||||
seen = set()
|
||||
|
||||
for climate in climates:
|
||||
if CONF_PLATFORM in climate and climate[CONF_PLATFORM] == "haier_tundra":
|
||||
unit_tx_pair = (
|
||||
climate[CONF_UNIT],
|
||||
climate[remote_base.CONF_TRANSMITTER_ID],
|
||||
)
|
||||
|
||||
if unit_tx_pair in seen and climate[CONF_ID] == config[CONF_ID]:
|
||||
raise cv.Invalid(
|
||||
"Only one of each unit is allowed per transmitter", path=[CONF_UNIT]
|
||||
)
|
||||
seen.add(unit_tx_pair)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate_unit
|
||||
# Each transmitter may drive at most one unit A and one unit B
|
||||
FINAL_VALIDATE_SCHEMA = fv.unique_platform_key(
|
||||
domain="climate",
|
||||
platform="haier_tundra",
|
||||
keys=(remote_base.CONF_TRANSMITTER_ID, CONF_UNIT),
|
||||
message="Only one of each unit is allowed per transmitter",
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "hitachi_ac344.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::hitachi_ac344 {
|
||||
|
||||
@@ -16,15 +17,6 @@ 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.
|
||||
@@ -124,7 +116,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.
|
||||
set_bit(&remote_state_[HITACHI_AC344_SWINGV_BYTE], HITACHI_AC344_SWINGV_OFFSET, on);
|
||||
assign_bits(this->remote_state_[HITACHI_AC344_SWINGV_BYTE], 1 << HITACHI_AC344_SWINGV_OFFSET, on);
|
||||
}
|
||||
|
||||
bool HitachiClimate::get_swing_v_() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "hitachi_ac424.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::hitachi_ac424 {
|
||||
|
||||
@@ -16,15 +17,6 @@ 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.
|
||||
@@ -124,7 +116,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.
|
||||
set_bit(&remote_state_[HITACHI_AC424_SWINGV_BYTE], HITACHI_AC424_SWINGV_OFFSET, on);
|
||||
assign_bits(this->remote_state_[HITACHI_AC424_SWINGV_BYTE], 1 << HITACHI_AC424_SWINGV_OFFSET, on);
|
||||
}
|
||||
|
||||
bool HitachiClimate::get_swing_v_() {
|
||||
|
||||
@@ -45,14 +45,15 @@ void HmacSHA256::get_hex(char *output) {
|
||||
}
|
||||
|
||||
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
|
||||
return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0;
|
||||
return constant_time_equals(this->digest_, expected, SHA256_DIGEST_SIZE);
|
||||
}
|
||||
|
||||
bool HmacSHA256::equals_hex(const char *expected) {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
#elif defined(USE_HMAC_SHA256_MBEDTLS)
|
||||
@@ -77,14 +78,15 @@ void HmacSHA256::get_hex(char *output) {
|
||||
}
|
||||
|
||||
bool HmacSHA256::equals_bytes(const uint8_t *expected) {
|
||||
return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0;
|
||||
return constant_time_equals(this->digest_, expected, SHA256_DIGEST_SIZE);
|
||||
}
|
||||
|
||||
bool HmacSHA256::equals_hex(const char *expected) {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
@@ -26,11 +26,10 @@ from esphome.const import (
|
||||
CONF_BITS_PER_SAMPLE,
|
||||
CONF_CHANNEL,
|
||||
CONF_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_SPEAKER,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
@@ -284,19 +283,13 @@ def _assign_ports() -> None:
|
||||
next_port += 1
|
||||
|
||||
|
||||
def _bus_devices(full_config: ConfigType, domain: str, bus_id: str) -> list[ConfigType]:
|
||||
return [
|
||||
device
|
||||
for device in full_config.get(domain, [])
|
||||
if device.get(CONF_PLATFORM) == DOMAIN
|
||||
and str(device[CONF_I2S_AUDIO_ID]) == bus_id
|
||||
]
|
||||
|
||||
|
||||
def _validate_full_duplex(full_config: ConfigType, bus_id: str) -> None:
|
||||
def _validate_full_duplex(bus_id: ID) -> None:
|
||||
"""Check that a full duplex bus has one microphone and speakers that can share its clocks and TX channel."""
|
||||
microphones = _bus_devices(full_config, MICROPHONE_DOMAIN, bus_id)
|
||||
speakers = _bus_devices(full_config, CONF_SPEAKER, bus_id)
|
||||
on_bus = {CONF_I2S_AUDIO_ID: bus_id}
|
||||
microphones = fv.platform_entries(
|
||||
domain=MICROPHONE_DOMAIN, platform=DOMAIN, match=on_bus
|
||||
)
|
||||
speakers = fv.platform_entries(domain=CONF_SPEAKER, platform=DOMAIN, match=on_bus)
|
||||
if len(microphones) != 1 or not speakers:
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_FULL_DUPLEX}' requires exactly one i2s_audio microphone and at least one "
|
||||
@@ -346,9 +339,9 @@ def _final_validate(_: ConfigType) -> None:
|
||||
data = _get_data()
|
||||
for config in i2s_audio_configs:
|
||||
if config[CONF_FULL_DUPLEX]:
|
||||
bus_id = str(config[CONF_ID])
|
||||
_validate_full_duplex(full_config, bus_id)
|
||||
data.full_duplex_buses.add(bus_id)
|
||||
bus_id = config[CONF_ID]
|
||||
_validate_full_duplex(bus_id)
|
||||
data.full_duplex_buses.add(str(bus_id))
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
@@ -2,7 +2,7 @@ from esphome import preferences
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import text
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_PLATFORM, ENTITY_CATEGORY_CONFIG
|
||||
from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -69,12 +69,11 @@ CONFIG_SCHEMA = cv.Schema(
|
||||
|
||||
def _final_validate(config: ConfigType) -> None:
|
||||
"""Each polygon zone must have exactly one presence binary sensor, on the same LD2450."""
|
||||
full_config = fv.full_config.get()
|
||||
# Polygon zone id -> the LD2450 id of each presence binary sensor that uses it
|
||||
references: dict[str, list[str]] = {}
|
||||
for binary_sensor_conf in full_config.get("binary_sensor", []):
|
||||
if binary_sensor_conf.get(CONF_PLATFORM) != "ld2450":
|
||||
continue
|
||||
for binary_sensor_conf in fv.platform_entries(
|
||||
domain="binary_sensor", platform="ld2450"
|
||||
):
|
||||
hub_id = binary_sensor_conf[CONF_LD2450_ID].id
|
||||
for presence_conf in binary_sensor_conf.get(CONF_POLYGON_ZONES, []):
|
||||
zone_id = presence_conf[CONF_POLYGON_ZONE_ID].id
|
||||
|
||||
@@ -10,13 +10,13 @@ DEPENDENCIES = ["libretiny"]
|
||||
libretinypwm_ns = cg.esphome_ns.namespace("libretiny_pwm")
|
||||
LibreTinyPWM = libretinypwm_ns.class_("LibreTinyPWM", output.FloatOutput, cg.Component)
|
||||
|
||||
validate_frequency = cv.All(cv.frequency, cv.float_range(min=0, min_included=False))
|
||||
|
||||
CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(LibreTinyPWM),
|
||||
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema,
|
||||
cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.All(
|
||||
cv.frequency, cv.float_range(min=0, min_included=False)
|
||||
),
|
||||
cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
@@ -34,7 +34,7 @@ automation.register_apply_action(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(LibreTinyPWM),
|
||||
cv.Required(CONF_FREQUENCY): cv.templatable(cv.int_),
|
||||
cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency),
|
||||
}
|
||||
),
|
||||
automation.ApplyField(CONF_FREQUENCY, "update_frequency", cg.float_),
|
||||
|
||||
@@ -11,7 +11,6 @@ from esphome.const import (
|
||||
CONF_NUM_LEDS,
|
||||
CONF_OUTPUT_ID,
|
||||
CONF_PIN,
|
||||
CONF_PLATFORM,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
@@ -128,14 +127,12 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
# The strip owns SPI0 and its DMA channel, so only one can exist.
|
||||
FINAL_VALIDATE_SCHEMA = fv.unique_platform_key(
|
||||
domain=light.DOMAIN,
|
||||
platform=DOMAIN,
|
||||
message=f"Only one {DOMAIN} is supported (it uses SPI0)",
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
|
||||
@@ -2,7 +2,6 @@ import esphome.codegen as cg
|
||||
from esphome.components import output
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_CHANNEL, CONF_CURRENT, CONF_ID
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import LP5562Output, lp5562_ns
|
||||
@@ -48,20 +47,10 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
# Two outputs on one channel would fight over its PWM, and the last current would win
|
||||
used = fv.full_config.get().data.setdefault("lp5562_channels", set())
|
||||
hub, channel = str(config[CONF_LP5562_ID]), str(config[CONF_CHANNEL])
|
||||
if (hub, channel) in used:
|
||||
raise cv.Invalid(
|
||||
f"Channel '{channel}' of '{hub}' is already used by another output",
|
||||
[CONF_CHANNEL],
|
||||
)
|
||||
used.add((hub, channel))
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
# A second output on a channel would also set its current, and the last one would win
|
||||
FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel(
|
||||
platform="lp5562", hub_key=CONF_LP5562_ID
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_CHANNEL, CONF_PLATFORM
|
||||
from esphome.const import CONF_CHANNEL
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -19,25 +19,13 @@ CONFIG_SCHEMA = switch.switch_schema(
|
||||
)
|
||||
|
||||
|
||||
def _validate_unique_channel(config: ConfigType) -> ConfigType:
|
||||
# The hub has one slot per channel, so a second switch on it would take over the first
|
||||
hub, channel = config[CONF_M5STACK_4RELAY_ID], config[CONF_CHANNEL]
|
||||
users = [
|
||||
other
|
||||
for other in fv.full_config.get().get(switch.DOMAIN, [])
|
||||
if other.get(CONF_PLATFORM) == DOMAIN
|
||||
and other[CONF_M5STACK_4RELAY_ID] == hub
|
||||
and other[CONF_CHANNEL] == channel
|
||||
]
|
||||
if len(users) > 1:
|
||||
raise cv.Invalid(
|
||||
f"Channel {channel} of {hub} is used by more than one switch",
|
||||
path=[CONF_CHANNEL],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_unique_channel
|
||||
# The hub has one slot per channel, so a second switch on it would take over the first
|
||||
FINAL_VALIDATE_SCHEMA = fv.unique_platform_key(
|
||||
domain=switch.DOMAIN,
|
||||
platform=DOMAIN,
|
||||
keys=(CONF_M5STACK_4RELAY_ID, CONF_CHANNEL),
|
||||
message="Channel {channel} of {m5stack_4relay_id} is used by more than one switch",
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
|
||||
@@ -3,67 +3,36 @@
|
||||
|
||||
namespace esphome::max17043 {
|
||||
|
||||
// MAX174043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
|
||||
// MAX17043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
|
||||
// Consult the datasheet at https://www.analog.com/en/products/max17043.html
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "max17043");
|
||||
|
||||
static const uint8_t MAX17043_VCELL = 0x02;
|
||||
static const uint8_t MAX17043_SOC = 0x04;
|
||||
static const uint8_t MAX17043_CONFIG = 0x0c;
|
||||
|
||||
static const uint16_t MAX17043_CONFIG_POWER_UP_DEFAULT = 0x971C;
|
||||
static const uint16_t MAX17043_CONFIG_SAFE_MASK = 0xFF1F; // mask out sleep bit (7), unused bit (6) and alert bit (4)
|
||||
static const uint16_t MAX17043_CONFIG_SLEEP_MASK = 0x0080;
|
||||
|
||||
void MAX17043Component::update() {
|
||||
uint16_t raw_voltage, raw_percent;
|
||||
if (!this->ready_for_update_())
|
||||
return;
|
||||
|
||||
uint16_t raw;
|
||||
bool ok = true;
|
||||
|
||||
if (this->voltage_sensor_ != nullptr) {
|
||||
if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) {
|
||||
this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL"));
|
||||
if (this->read_byte_16(max170xx::REG_VCELL, &raw)) {
|
||||
// 12 bits in the top of the word at 1.25 mV
|
||||
this->voltage_sensor_->publish_state((raw >> 4) * 1.25e-3f);
|
||||
} else {
|
||||
float voltage = (1.25f * (float) (raw_voltage >> 4)) / 1000.0f;
|
||||
this->voltage_sensor_->publish_state(voltage);
|
||||
this->status_clear_warning();
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (this->battery_remaining_sensor_ != nullptr) {
|
||||
if (!this->read_byte_16(MAX17043_SOC, &raw_percent)) {
|
||||
this->status_set_warning(LOG_STR("Unable to read MAX17043_SOC"));
|
||||
} else {
|
||||
float percent = (float) ((raw_percent >> 8) + 0.003906f * (raw_percent & 0x00ff));
|
||||
if (this->read_byte_16(max170xx::REG_SOC, &raw)) {
|
||||
float percent = (float) ((raw >> 8) + 0.003906f * (raw & 0x00ff));
|
||||
this->battery_remaining_sensor_->publish_state(percent);
|
||||
this->status_clear_warning();
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MAX17043Component::setup() {
|
||||
uint16_t config_reg;
|
||||
if (this->write(&MAX17043_CONFIG, 1) != i2c::ERROR_OK) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this->read(reinterpret_cast<uint8_t *>(&config_reg), 2) != i2c::ERROR_OK) {
|
||||
this->status_set_warning();
|
||||
return;
|
||||
}
|
||||
|
||||
config_reg = i2c::i2ctohs(config_reg) & MAX17043_CONFIG_SAFE_MASK;
|
||||
ESP_LOGV(TAG, "MAX17043 CONFIG register reads 0x%X", config_reg);
|
||||
|
||||
if (config_reg != MAX17043_CONFIG_POWER_UP_DEFAULT) {
|
||||
this->mark_failed(LOG_STR("Device does not appear to be a MAX17043"));
|
||||
return;
|
||||
}
|
||||
|
||||
// need to write back to config register to reset the sleep bit
|
||||
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT)) {
|
||||
this->mark_failed(LOG_STR("sleep reset failed"));
|
||||
return;
|
||||
}
|
||||
this->status_comm_result(ok, LOG_STR("Register read failed"));
|
||||
}
|
||||
|
||||
void MAX17043Component::dump_config() {
|
||||
@@ -74,13 +43,4 @@ void MAX17043Component::dump_config() {
|
||||
LOG_SENSOR(" ", "Battery Level", this->battery_remaining_sensor_);
|
||||
}
|
||||
|
||||
void MAX17043Component::sleep_mode() {
|
||||
if (!this->is_failed()) {
|
||||
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT | MAX17043_CONFIG_SLEEP_MASK)) {
|
||||
ESP_LOGW(TAG, "Unable to write the sleep bit to config register");
|
||||
this->status_set_warning();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::max17043
|
||||
@@ -1,21 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/max170xx/max170xx.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
|
||||
namespace esphome::max17043 {
|
||||
|
||||
class MAX17043Component final : public PollingComponent, public i2c::I2CDevice {
|
||||
class MAX17043Component final : public max170xx::MAX170xxComponent<max170xx::Model::MODEL_MAX17043> {
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override;
|
||||
void sleep_mode();
|
||||
|
||||
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
|
||||
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; }
|
||||
void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) {
|
||||
battery_remaining_sensor_ = battery_remaining_sensor;
|
||||
this->battery_remaining_sensor_ = battery_remaining_sensor;
|
||||
}
|
||||
|
||||
protected:
|
||||
|
||||
@@ -16,6 +16,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["max170xx"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
max17043_ns = cg.esphome_ns.namespace("max17043")
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
CODEOWNERS = ["@B48D81EFCC"]
|
||||
CODEOWNERS = ["@B48D81EFCC", "@ryansch"]
|
||||
DOMAIN = "max17048"
|
||||
@@ -8,41 +8,27 @@ namespace esphome::max17048 {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "max17048");
|
||||
|
||||
static constexpr uint8_t REG_VCELL = 0x02; // cell voltage
|
||||
static constexpr uint8_t REG_SOC = 0x04; // state of charge
|
||||
static constexpr uint8_t REG_VERSION = 0x08; // 0x001x
|
||||
static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed
|
||||
|
||||
static constexpr uint16_t VERSION_MASK = 0xFFF0;
|
||||
static constexpr uint16_t VERSION_FAMILY = 0x0010;
|
||||
static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed
|
||||
|
||||
static constexpr float VCELL_VOLTS_PER_LSB = 78.125e-6f;
|
||||
static constexpr float SOC_PERCENT_PER_LSB = 1.0f / 256.0f;
|
||||
static constexpr float CRATE_PERCENT_PER_HOUR_PER_LSB = 0.208f;
|
||||
|
||||
void MAX17048Component::setup() {
|
||||
uint16_t version;
|
||||
if (!this->read_byte_16(REG_VERSION, &version)) {
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
if ((version & VERSION_MASK) != VERSION_FAMILY) {
|
||||
ESP_LOGW(TAG, "Unexpected version 0x%04X", version);
|
||||
}
|
||||
}
|
||||
|
||||
void MAX17048Component::update() {
|
||||
if (!this->ready_for_update_())
|
||||
return;
|
||||
|
||||
bool ok = true;
|
||||
uint16_t raw;
|
||||
if (this->battery_voltage_sensor_ != nullptr) {
|
||||
if (this->read_byte_16(REG_VCELL, &raw)) {
|
||||
if (this->read_byte_16(max170xx::REG_VCELL, &raw)) {
|
||||
this->battery_voltage_sensor_->publish_state(raw * VCELL_VOLTS_PER_LSB);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (this->battery_level_sensor_ != nullptr) {
|
||||
if (this->read_byte_16(REG_SOC, &raw)) {
|
||||
if (this->read_byte_16(max170xx::REG_SOC, &raw)) {
|
||||
this->battery_level_sensor_->publish_state(raw * SOC_PERCENT_PER_LSB);
|
||||
} else {
|
||||
ok = false;
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/max170xx/max170xx.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
|
||||
namespace esphome::max17048 {
|
||||
|
||||
class MAX17048Component final : public PollingComponent, public i2c::I2CDevice {
|
||||
class MAX17048Component final : public max170xx::MAX170xxComponent<max170xx::Model::MODEL_MAX17048> {
|
||||
SUB_SENSOR(battery_voltage)
|
||||
SUB_SENSOR(battery_level)
|
||||
SUB_SENSOR(rate)
|
||||
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
void update() override;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from esphome import automation
|
||||
from esphome.automation import maybe_simple_id
|
||||
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_ID,
|
||||
CONF_RATE,
|
||||
DEVICE_CLASS_BATTERY,
|
||||
DEVICE_CLASS_VOLTAGE,
|
||||
@@ -14,6 +17,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["max170xx"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
|
||||
UNIT_PERCENT_PER_HOUR = "%/h"
|
||||
@@ -61,3 +65,15 @@ async def to_code(config: ConfigType) -> None:
|
||||
await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor)
|
||||
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
|
||||
await sensors(CONF_RATE, var.set_rate_sensor)
|
||||
|
||||
|
||||
MAX17048_ACTION_SCHEMA = maybe_simple_id(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MAX17048Component),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"max17048.sleep_mode", MAX17048_ACTION_SCHEMA, automation.ApplyCall("sleep_mode()")
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
CODEOWNERS = ["@ryansch"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
DOMAIN = "max170xx"
|
||||
@@ -0,0 +1,87 @@
|
||||
#include "max170xx.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::max170xx {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "max170xx");
|
||||
|
||||
template<Model MODEL> void MAX170xxComponent<MODEL>::setup() {
|
||||
// A silent bus is retried from update(): some boards gate the I2C rail until after setup.
|
||||
// An early on_boot may already have configured the gauge and put it to sleep, so this must not configure again.
|
||||
this->ready_for_update_();
|
||||
}
|
||||
|
||||
template<Model MODEL> bool MAX170xxComponent<MODEL>::ready_for_update_() {
|
||||
if (this->configured_ || this->configure_())
|
||||
return true;
|
||||
this->status_set_warning(LOG_STR("Gauge did not answer"));
|
||||
return false;
|
||||
}
|
||||
|
||||
template<Model MODEL> bool MAX170xxComponent<MODEL>::configure_() {
|
||||
uint16_t config;
|
||||
if (!this->read_byte_16(REG_CONFIG, &config))
|
||||
return false;
|
||||
|
||||
const bool was_asleep = (config & CONFIG_SLEEP) != 0;
|
||||
// RCOMP and ATHD survive on the battery, so an earlier firmware's values are reset here, not treated as a fault
|
||||
if ((config & CONFIG_SAFE_MASK) != CONFIG_POWER_UP_DEFAULT) {
|
||||
ESP_LOGW(TAG, "CONFIG reads 0x%04X, expected 0x%04X; resetting it", config & CONFIG_SAFE_MASK,
|
||||
CONFIG_POWER_UP_DEFAULT);
|
||||
}
|
||||
|
||||
uint16_t version;
|
||||
if (this->read_byte_16(REG_VERSION, &version)) {
|
||||
ESP_LOGCONFIG(TAG, "IC version: 0x%04X", version);
|
||||
}
|
||||
|
||||
// Write back the power-up value to clear a stale sleep bit; a failed write is retried like a silent bus
|
||||
if (!this->write_byte_16(REG_CONFIG, CONFIG_POWER_UP_DEFAULT))
|
||||
return false;
|
||||
|
||||
this->configured_ = true;
|
||||
// Waking from sleep raises a false low-battery alert; a real one at an ordinary boot is left alone
|
||||
if (was_asleep)
|
||||
this->set_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID, WAKE_ALERT_CLEAR_DELAY_MS, [this]() { this->clear_wake_alert_(); });
|
||||
return true;
|
||||
}
|
||||
|
||||
template<Model MODEL> void MAX170xxComponent<MODEL>::clear_wake_alert_() {
|
||||
uint16_t config;
|
||||
if (!this->read_byte_16(REG_CONFIG, &config) || !this->write_byte_16(REG_CONFIG, config & ~CONFIG_ALRT)) {
|
||||
this->status_set_warning(LOG_STR("Wake alert clear failed"));
|
||||
return;
|
||||
}
|
||||
if constexpr (MODEL == Model::MODEL_MAX17048) {
|
||||
uint16_t status;
|
||||
if (!this->read_byte_16(REG_STATUS, &status) || !this->write_byte_16(REG_STATUS, status & ~STATUS_HD))
|
||||
this->status_set_warning(LOG_STR("Wake alert clear failed"));
|
||||
}
|
||||
}
|
||||
|
||||
template<Model MODEL> void MAX170xxComponent<MODEL>::sleep_mode() {
|
||||
if (this->is_failed())
|
||||
return;
|
||||
// The deferred configuration clears the sleep bit, so it has to run before the gauge is put to sleep
|
||||
if (!this->ready_for_update_())
|
||||
return;
|
||||
|
||||
// A clear still pending would otherwise run against a sleeping gauge
|
||||
if (this->cancel_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID))
|
||||
this->clear_wake_alert_();
|
||||
|
||||
if constexpr (MODEL == Model::MODEL_MAX17048) {
|
||||
if (!this->write_byte_16(REG_MODE, MODE_EN_SLEEP)) {
|
||||
this->status_set_warning(LOG_STR("Unable to enable sleep mode in the mode register"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this->write_byte_16(REG_CONFIG, CONFIG_POWER_UP_DEFAULT | CONFIG_SLEEP))
|
||||
this->status_set_warning(LOG_STR("Unable to write the sleep bit to config register"));
|
||||
}
|
||||
|
||||
template class MAX170xxComponent<Model::MODEL_MAX17043>;
|
||||
template class MAX170xxComponent<Model::MODEL_MAX17048>;
|
||||
|
||||
} // namespace esphome::max170xx
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/i2c/i2c.h"
|
||||
|
||||
namespace esphome::max170xx {
|
||||
|
||||
// Shared base for the ModelGauge fuel gauges.
|
||||
// MAX17043: https://www.analog.com/en/products/max17043.html
|
||||
// MAX17048: https://www.analog.com/en/products/max17048.html
|
||||
// The MAX17048 is register-compatible with the MAX17043, and adds a STATUS register and a MODE.EnSleep gate in
|
||||
// front of CONFIG.SLEEP.
|
||||
|
||||
static constexpr uint8_t REG_VCELL = 0x02;
|
||||
static constexpr uint8_t REG_SOC = 0x04;
|
||||
static constexpr uint8_t REG_MODE = 0x06;
|
||||
static constexpr uint8_t REG_VERSION = 0x08;
|
||||
static constexpr uint8_t REG_CONFIG = 0x0C;
|
||||
static constexpr uint8_t REG_STATUS = 0x1A;
|
||||
|
||||
static constexpr uint16_t CONFIG_POWER_UP_DEFAULT = 0x971C;
|
||||
// Mask out SLEEP (7), ALRT (5), and bit 6, which is a don't-care on the MAX17043 but ALSC on the MAX17048.
|
||||
static constexpr uint16_t CONFIG_SAFE_MASK = 0xFF1F;
|
||||
static constexpr uint16_t CONFIG_SLEEP = 0x0080;
|
||||
static constexpr uint16_t CONFIG_ALRT = 0x0020;
|
||||
|
||||
// CONFIG.SLEEP is ignored unless MODE.EnSleep (bit 13) is set first.
|
||||
static constexpr uint16_t MODE_EN_SLEEP = 0x2000;
|
||||
static constexpr uint16_t STATUS_HD = 0x1000;
|
||||
|
||||
static constexpr uint32_t WAKE_ALERT_CLEAR_TIMEOUT_ID = 0;
|
||||
// The gauge raises its false wake alert some time after the wake write; on hardware it rose 0.8 to 1.0 s later
|
||||
static constexpr uint32_t WAKE_ALERT_CLEAR_DELAY_MS = 2000;
|
||||
|
||||
enum class Model : uint8_t {
|
||||
MODEL_MAX17043,
|
||||
MODEL_MAX17048,
|
||||
};
|
||||
|
||||
/// Defined in max170xx.cpp, which instantiates both models.
|
||||
template<Model MODEL> class MAX170xxComponent : public PollingComponent, public i2c::I2CDevice {
|
||||
public:
|
||||
void setup() override;
|
||||
void sleep_mode();
|
||||
|
||||
protected:
|
||||
/// True once the gauge is configured; otherwise retries the configuration and warns if the gauge did not answer.
|
||||
/// Call first in update().
|
||||
bool ready_for_update_();
|
||||
/// Verify and wake the gauge; false if it did not answer.
|
||||
bool configure_();
|
||||
/// Clear the false alert raised on waking, leaving every other CONFIG and STATUS bit as it is.
|
||||
void clear_wake_alert_();
|
||||
|
||||
bool configured_{false};
|
||||
};
|
||||
|
||||
extern template class MAX170xxComponent<Model::MODEL_MAX17043>;
|
||||
extern template class MAX170xxComponent<Model::MODEL_MAX17048>;
|
||||
|
||||
} // namespace esphome::max170xx
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "mcp23016.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <cstdio>
|
||||
|
||||
@@ -79,11 +80,7 @@ void MCP23016::update_reg_(uint8_t pin, bool pin_value, uint8_t reg_addr) {
|
||||
this->read_reg_(reg_addr, ®_value);
|
||||
}
|
||||
|
||||
if (pin_value) {
|
||||
reg_value |= 1 << pin;
|
||||
} else {
|
||||
reg_value &= ~(1 << pin);
|
||||
}
|
||||
assign_bits(reg_value, 1 << pin, pin_value);
|
||||
|
||||
this->write_reg_(reg_addr, reg_value);
|
||||
|
||||
|
||||
Loaded 100 of 346 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user