Packaged as a Home Assistant add-on, with a field guide

Turns the reference RS485 controller into something a technician can install
at a client site: a typed config form instead of YAML, an ingress UI that
names misconfiguration in words, and persistent state that cannot be broken by
a timezone.

Why an add-on rather than YAML packages or blueprints:

- Blueprints cannot create helpers, and the maintenance cycle is a state
  machine whose phase and completion date must survive restarts.
- YAML packages need filesystem access, a configuration.yaml edit and a
  restart - none of which belong in a client install.
- Add-ons authenticate with SUPERVISOR_TOKEN, so there is no long-lived token
  to generate, store or leak on someone else's machine.
- Requires HA OS/Supervised. Container and Core installs cannot run add-ons at
  all, which is a market decision, not an oversight.

The control law and the maintenance machine are pure functions with no Home
Assistant imports, and both ship with runnable checks (22 and 22 assertions).
Every assertion corresponds to a rule whose absence caused an observed failure
on hardware - the saturation duration term, the clamp-before-slew ordering, the
deadband, the sign convention.

One behaviour deliberately differs from the implementation it replaces: when
its inputs go missing this commands 0 W rather than replaying the last
setpoint. The reference version kept replaying, which the hardware watchdog
cannot catch - from the ESP32's side, Home Assistant is still talking to it.

Includes the ESPHome firmware (now parameterised: node name, inverter rating,
watchdog timeout) and the optional RS485 e-stop. FIELD-GUIDE.md carries the
commissioning gates, all judged on the wire rather than on how Home Assistant
looks, plus the written statement a site without an e-stop needs signed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
This commit is contained in:
2026-08-23 01:15:25 +02:00
co-authored by Claude Opus 5
commit 0a1e61dbc9
24 changed files with 2737 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Passive RS485 frame logger for the GoodWe bus.
The Pi takes over as witness during stage 1 (NOTES 18.4), when the T-CAN485
sniffer board is reflashed as the master and stops listening.
python3 rs485_log.py --selftest # no hardware needed
sudo python3 rs485_log.py --out bus.log # sudo: see tune_latency()
One line per frame: 21:00:29.412 ok F7 10 05 6F 00 01 02 05 DC D7 62
Hex is space separated and CRC-suffixed exactly like master/frames.txt, so
frame_check.py and the existing analysis take this file with an awk of $3-.
The log ROTATES (--max-mb / --keep): the Pi's SD card is finite and this file
grows ~23 MB/day. History lands in bus.log.1 .. bus.log.N, oldest discarded.
With --panic it stops being purely passive: after --panic-after seconds of TOTAL
bus silence it writes the 0 W frame itself. See panic_frame() for why that is
safe and why 0 W is the only thing it may ever send.
"""
import argparse, itertools, logging, logging.handlers, pathlib, sys, time
from datetime import datetime
BAUD = 9600
GAP = 0.004 # 3.5 char times @ 9600 8N1 = 3.65 ms. Modbus RTU frame boundary.
# The atomic "grid = import, 0 W" write - NOTES §5.3/§5.5. The inverter has ACKed
# this exact frame several hundred times. Byte-for-byte identical to
# master/frame_check.py::setpoint_atomic(0); the selftest checks its CRC.
ZERO_FRAME = bytes.fromhex("F710056E000204000300 00A6D0".replace(" ", ""))
def crc16(data: bytes) -> int:
"""Modbus RTU CRC16, little-endian on the wire. Same as master/frame_check.py."""
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc
def crc_ok(frame: bytes) -> bool:
return len(frame) > 2 and crc16(frame[:-2]) == int.from_bytes(frame[-2:], "little")
def frames(ser, idle=None):
"""Split the byte stream on the inter-frame gap. ser.timeout must be GAP.
ponytail: byte-at-a-time. 960 B/s is nothing for a Pi 3, and it keeps the
gap detection honest - a batched read cannot tell you where the silence was.
`idle` is called on every empty read, i.e. roughly every GAP while the bus is
quiet. Without it the caller never regains control during silence - which is
exactly the condition the panic writer has to notice.
"""
buf = bytearray()
while True:
b = ser.read(1) # blocks up to GAP, then returns b""
if b:
buf += b
if len(buf) < 260: # 256 payload + header; runaway guard
continue
if buf:
yield time.time(), bytes(buf)
buf.clear()
elif idle:
idle()
def tune_latency(port: str) -> str:
"""USB serial chips buffer before handing bytes up; FTDI defaults to 16 ms,
which smears the 3.65 ms gap that framing depends on. 1 ms is the minimum.
Needs root, hence sudo. Not all chips expose it - CRC rate is the real check.
"""
p = pathlib.Path("/sys/bus/usb-serial/devices") / pathlib.Path(port).name / "latency_timer"
try:
now = p.read_text().strip()
except OSError:
return "latency_timer: chip does not expose it; watch the bad-CRC count"
if now == "1":
return "latency_timer = 1 ms"
try: # needs root; the udev rule is the durable fix
p.write_text("1")
return "latency_timer = 1 ms (was %s)" % now
except OSError:
return ("latency_timer = %s ms and NOT writable as this user - frames will be "
"glued together. Install the udev rule, or run under sudo." % now)
def open_sink(path, max_mb, keep):
"""Size-rotating log sink: bus.log, bus.log.1 .. bus.log.N, oldest dropped.
stdlib RotatingFileHandler, deliberately. logrotate would need root (a
password someone has to type) plus a cron that actually ran - and a cron that
silently stopped running is a full SD card and a dead witness.
ponytail: no gzip. Compressing 25 MB on a Pi 3 blocks this single-threaded
loop for seconds and the tap would miss frames while it worked. Disk is the
cheap resource here (25 GB free against 23 MB/day); the witness's continuity
is not. Add compression only if space ever actually gets tight.
"""
h = logging.handlers.RotatingFileHandler(path, maxBytes=int(max_mb * 1_000_000),
backupCount=keep, encoding="utf-8")
h.setFormatter(logging.Formatter("%(message)s")) # no level/date prefix:
log = logging.getLogger("bus") # the line format is the
log.setLevel(logging.INFO) # documented interface
log.addHandler(h)
log.propagate = False
return log
class _FakeSerial:
def __init__(self, chunks): self.q = list(chunks)
def read(self, _n=1): return self.q.pop(0) if self.q else b""
def selftest() -> int:
real = [bytes.fromhex("F710056E0001020003947B"),
bytes.fromhex("F710056F00010205DCD762")]
assert all(crc_ok(f) for f in real), "known-good frame failed CRC"
assert not crc_ok(real[1][:-3] + b"\x00" + real[1][-2:]), "corrupt frame passed CRC"
# the framer: two frames separated by one gap, split on silence not on length
fake = _FakeSerial([bytes([b]) for b in real[0]] + [b""] +
[bytes([b]) for b in real[1]] + [b""])
got = [f for _, f in itertools.islice(frames(fake), 2)]
assert got == real, [g.hex(" ") for g in got]
assert crc_ok(ZERO_FRAME), "the panic frame's own CRC is wrong"
assert ZERO_FRAME.hex(" ").upper() == "F7 10 05 6E 00 02 04 00 03 00 00 A6 D0"
slave, fc, hi, lo, _, cnt, nbytes, dhi, dlo, mhi, mlo = ZERO_FRAME[:11]
assert (slave, fc, (hi << 8) | lo, cnt, nbytes) == (0xF7, 0x10, 0x056E, 2, 4)
assert (dhi << 8) | dlo == 3 and (mhi << 8) | mlo == 0, "panic frame is not 0 W"
# the idle callback must fire during silence, or the panic writer never runs
ticks = []
class _Stop(Exception): pass
def _idle():
ticks.append(1)
if len(ticks) == 3:
raise _Stop
try:
for _ in frames(_FakeSerial([]), idle=_idle):
pass
except _Stop:
pass
assert len(ticks) == 3, ticks
print("selftest ok: crc, corruption, gap framing, panic frame, idle tick")
return 0
def open_port(serial, port, rts=False, dtr=False):
"""rts/dtr OFF: pyserial asserts RTS on open, and many USB-RS485 adapters use it
as driver-enable - that would make a "passive" tap drive the bus. (Tested
2026-08-19: not the cause of the frame corruption, but correct regardless.)"""
s = serial.Serial(port, BAUD, bytesize=8, parity="N", stopbits=1, timeout=GAP,
rtscts=False, dsrdtr=False)
s.rts = rts
s.dtr = dtr
return s
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--port", default="/dev/ttyUSB0")
ap.add_argument("--out", default="bus.log")
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--rts", choices=["on", "off"], default="off",
help="driver-enable polarity differs per adapter; test both")
ap.add_argument("--seconds", type=float, default=0.0, help="stop after N s (0 = forever)")
ap.add_argument("--max-mb", type=float, default=25.0, help="rotate at this size")
ap.add_argument("--keep", type=int, default=30, help="how many rotated files to keep")
ap.add_argument("--panic", action="store_true",
help="EMERGENCY STOP: write 0 W if the bus goes totally silent")
ap.add_argument("--panic-after", type=float, default=30.0,
help="seconds of silence before the panic write (NOTES §4.6)")
a = ap.parse_args()
if a.selftest:
return selftest()
import serial
ser = open_port(serial, a.port, rts=(a.rts == "on"))
print(tune_latency(a.port), file=sys.stderr)
print(f"logging {a.port} -> {a.out}", file=sys.stderr)
sink = open_sink(a.out, a.max_mb, a.keep)
print("rotating at %g MB, keeping %d (cap ~%g MB)"
% (a.max_mb, a.keep, a.max_mb * (a.keep + 1)), file=sys.stderr)
if a.panic:
print("PANIC ARMED: 0 W after %gs of silence" % a.panic_after, file=sys.stderr)
good = bad = panics = 0
t0 = time.time()
last_frame = time.time()
def stamp_now():
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
def idle():
"""Total bus silence means no master is alive - see NOTES §3.6/§4.6.
Silence is the discriminator precisely because a healthy master makes the
bus busy every 5 s: if anything is driving, this never fires, so the tap
cannot become a second master. And 0 W is the ONLY frame it may send, so
even a false positive (say this adapter wedges while the bus is actually
fine) costs at most one setpoint dip that the real master overwrites
within its next 5 s cycle. A panic button that could send anything else
would have to be as trustworthy as the master it replaces. This one does
not.
"""
nonlocal last_frame, panics
if not a.panic or time.time() - last_frame < a.panic_after:
return
ser.write(ZERO_FRAME)
panics += 1
sink.info("%s TX %s" % (stamp_now(), ZERO_FRAME.hex(" ").upper()))
print("PANIC #%d: bus silent %gs, wrote 0 W" % (panics, a.panic_after),
file=sys.stderr, flush=True)
# The inverter's ACK lands in ~150 ms and resets this anyway; setting it
# here makes the repeat cadence panic_after either way. Repeats matter:
# §3.6 says never stop writing, a latched inverter is the failure mode.
last_frame = time.time()
while True: # a replug re-enumerates the adapter and
try: # invalidates the fd; do not just die
for ts, fr in frames(ser, idle=idle):
ok = crc_ok(fr)
good, bad = good + ok, bad + (not ok)
last_frame = ts # ANY traffic means a master is alive
sink.info("%s %s %s" % (datetime.fromtimestamp(ts).strftime("%H:%M:%S.%f")[:-3],
"ok " if ok else "BAD", fr.hex(" ").upper()))
if (good + bad) % 100 == 0: # not every frame: often a file
print(good, "frames,", bad, "bad CRC", file=sys.stderr, flush=True)
if a.seconds and time.time() - t0 > a.seconds:
print("RESULT rts=%s: %d ok, %d bad, %d panic" % (a.rts, good, bad, panics),
file=sys.stderr, flush=True)
return 0
except serial.SerialException as e:
print(f"lost {a.port}: {e}", file=sys.stderr, flush=True)
try:
ser.close()
except Exception:
pass
while True: # udev has to reapply latency_timer too,
time.sleep(2) # so there is no point hammering it
try:
ser = open_port(serial, a.port, rts=(a.rts == "on"))
break
except Exception:
pass
last_frame = time.time() # a 60 s replug is OUR blindness, not bus
# silence - do not panic-write on reopen
print("reopened", a.port, "|", tune_latency(a.port), file=sys.stderr, flush=True)
if __name__ == "__main__":
sys.exit(main())