Files
goodwe-addon/docs/build_guide.py
adminandClaude Opus 5 1abca15520 Compatibility gate: check the serial tag, not the model name
Platform is identified by characters 6-8 of the serial number, and only
platform 105 (ES/EM/BP - ESU EMU ESA BPS BPU EMJ IJL) speaks the AA55-era
meter bus this product emulates. The reference unit is 95000BPS225W0290 -> BPS.

Everything with a "-20"/G2 suffix is a different platform: SBP G2 (SPB/SPN) and
ES G2 (ESN/ESC) are platform 745, ET/EH/BT are 205/745/753. PV-only inverters
(SDT/DST/MSU/NSU) have no battery to control at all.

Added as the first item in Step 0 of the field guide, because it is a 60-second
check that decides whether the job can be quoted, and as a table in the README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 01:56:20 +02:00

534 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Build the field guide PDF: python3 docs/build_guide.py
HTML -> headless Chrome -> A4 PDF. The guide's text lives in this file, so there
is exactly one copy of it and no chance of a Markdown version drifting from the
PDF a technician actually carries.
Screenshots in docs/img/ are base64-embedded, so field-guide.html is standalone.
⚠️ Keep every section under the A4 printable height (1039 px at 96 dpi with the
11 mm margins set below), or Chrome silently spills it onto a second page and
the pagination stops matching the steps. To check after editing:
document.querySelectorAll(".page").forEach(el =>
console.log(el.getBoundingClientRect().height)) // must each be < 1039
"""
import base64
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
SCRATCH = os.path.join(HERE, "img")
OUT_DIR = HERE
CHROME = r"C:/Program Files/Google/Chrome/Application/chrome.exe"
def img(name):
with open(os.path.join(SCRATCH, name), "rb") as fh:
return "data:image/png;base64," + base64.b64encode(fh.read()).decode()
UI_OK = img("ui-running.png")
UI_BAD = img("ui-notready.png")
ESPHOME = img("esphome.png")
HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>GoodWe RS485 Controller — Field Guide</title>
<style>
@page { size: A4; margin: 11mm 11mm 11mm; }
* { box-sizing: border-box; }
body { margin:0; font: 9.5pt/1.35 "Segoe UI", system-ui, sans-serif; color:#14161a; }
h1 { font-size: 19pt; margin:0 0 2mm; letter-spacing:-.3px; }
h2 { font-size: 12pt; margin:0 0 2.5mm; padding-bottom:1.2mm; border-bottom:2px solid #14161a; }
h3 { font-size: 11pt; margin:0 0 1.5mm; }
p { margin:0 0 2.5mm; }
ul,ol { margin:0 0 2.5mm; padding-left:5mm; }
li { margin:.8mm 0; }
small { font-size:9pt; color:#5b6169; }
.sub { color:#5b6169; font-size:10pt; margin-bottom:5mm; }
.page { page-break-after: always; }
.page:last-child { page-break-after: auto; }
.step { border:1px solid #d7dae0; border-radius:2mm; padding:2.8mm 3.2mm; margin-bottom:3mm;
page-break-inside: avoid; }
.step h3 { display:flex; align-items:center; gap:2.5mm; }
.num { display:inline-flex; align-items:center; justify-content:center; min-width:7mm; height:7mm;
border-radius:50%; background:#14161a; color:#fff; font-size:10pt; font-weight:700; }
.do { margin:0 0 2mm; }
.verify, .ifnot { padding:1.6mm 2.4mm; border-radius:1.5mm; margin-top:1.6mm; font-size:9pt; }
.verify { background:#e8f5ea; border-left:3px solid #137333; }
.ifnot { background:#fdeceb; border-left:3px solid #c5221f; }
.verify b { color:#0d5c28; } .ifnot b { color:#a1201d; }
.warn { background:#fff5e0; border-left:3px solid #b25e02; padding:2.5mm 3mm; border-radius:1.5mm;
margin:0 0 3mm; }
.crit { background:#fdeceb; border:1.5px solid #c5221f; border-radius:2mm; padding:3.5mm 4mm;
margin:0 0 4mm; }
.crit h3 { color:#a1201d; }
table { width:100%; border-collapse:collapse; margin:0 0 2.5mm; font-size:8.8pt; }
th { text-align:left; background:#f1f3f5; padding:1.2mm 1.6mm; border-bottom:1.5px solid #c9ced6; }
td { padding:1.2mm 1.6mm; border-bottom:.5px solid #e3e6ea; vertical-align:top; }
code { font-family:Consolas,monospace; font-size:9pt; background:#f1f3f5; padding:.3mm 1mm;
border-radius:1mm; }
pre { font-family:Consolas,monospace; font-size:8.5pt; background:#f1f3f5; padding:2.5mm;
border-radius:1.5mm; margin:0 0 3mm; white-space:pre-wrap; }
figure { margin:0 0 3mm; page-break-inside:avoid; }
figure img { display:block; width:100%; height:auto;
border:1px solid #d7dae0; border-radius:1.5mm; }
figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
.uiref figure img { width:66%; margin:0 auto; }
.uiref figcaption { font-size:8.6pt; }
.cols { display:flex; gap:4mm; }
.cols > * { flex:1; }
.box { border:1px solid #d7dae0; border-radius:2mm; padding:3mm 3.5mm; }
.tick { list-style:none; padding-left:0; }
.tick li { padding-left:6mm; position:relative; }
.tick li:before { content:"☐"; position:absolute; left:0; font-size:12pt; line-height:1; }
.sign { border:1px solid #14161a; border-radius:2mm; padding:4mm; margin-top:3mm; }
.sign .line { border-bottom:1px solid #9aa0a6; height:8mm; margin:4mm 0 1mm; }
.flow { counter-reset:f; }
.flow li { margin:1.5mm 0; }
.hdr { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:4mm;
border-bottom:3px solid #14161a; padding-bottom:2mm; }
</style></head><body>
<!-- ============ PAGE 1 ============ -->
<div class="page" id="p1">
<div class="hdr">
<div><h1>GoodWe RS485 Controller</h1>
<div class="sub">Field installation &amp; commissioning guide — v0.1.0</div></div>
<small>Installer copy</small>
</div>
<div class="crit">
<h3>Read this before touching anything</h3>
<p><b>The inverter holds its last command forever.</b> It has no meter-timeout. If the
controller stops talking, the battery keeps charging or discharging at whatever it was last
told — indefinitely.</p>
<p style="margin:0"><b>Measured:</b> a controller went silent mid-command and the inverter held
<b>5 kW of discharge for 113 seconds</b> until a person intervened.</p>
</div>
<div class="warn">
<b>If anything looks wrong at any point: STOP THE ADD-ON.</b> That commands 0 W and the
hardware watchdog holds it there. Nothing is at risk while you think.
</div>
<h2>Order of operations</h2>
<p class="sub" style="margin-bottom:3mm">Do not reorder. Each step assumes the previous one passed.</p>
<table>
<tr><th style="width:12mm">Step</th><th>Action</th><th style="width:34mm">Gate to pass</th></tr>
<tr><td><b>0</b></td><td>Pre-visit checks (client, warranty, HA type)</td><td>All boxes ticked</td></tr>
<tr><td><b>1</b></td><td>Survey &amp; photograph existing wiring</td><td>Photos taken</td></tr>
<tr><td><b>2</b></td><td>Bring system to 0 W, disconnect vendor controller</td><td>Battery idle</td></tr>
<tr><td><b>3</b></td><td>Wire and power the T-CAN485</td><td>Board online</td></tr>
<tr><td><b>4</b></td><td>Flash the ESP32 firmware</td><td>Entities appear in HA</td></tr>
<tr><td><b>5</b></td><td>Install &amp; configure the add-on</td><td>All checks green</td></tr>
<tr><td><b>6</b></td><td>Commissioning gates 16</td><td>Every gate passes</td></tr>
<tr><td><b>7</b></td><td>E-stop: fit &amp; test, or get signature</td><td>Tested or signed</td></tr>
<tr><td><b>8</b></td><td>Prove a maintenance cycle</td><td>Phases observed</td></tr>
<tr><td><b>9</b></td><td>Handover &amp; sign-off</td><td>Sheet complete</td></tr>
</table>
<h2>The safety model</h2>
<table>
<tr><th style="width:26mm">Layer</th><th>Covers</th><th style="width:40mm">Where it lives</th></tr>
<tr><td><b>1 Watchdog</b></td><td>Controller alive but silent → 0 W, and keeps writing it</td><td>ESP32 firmware</td></tr>
<tr><td><b>2 Wind-down</b></td><td>Planned firmware update → 0 W before it starts</td><td>ESP32 firmware</td></tr>
<tr><td><b>3 E-stop</b></td><td><b>The HA machine itself dying</b> → 0 W after 30 s of bus silence</td><td>Optional Pi + RS485</td></tr>
</table>
<div class="warn" style="margin-top:1mm">
<b>Only layer 3 covers the host dying.</b> Nothing running on the HA machine can cover its own
death. On a site without the e-stop, a failed host leaves the battery latched until someone
intervenes manually. See Step 7.
</div>
</div>
<!-- ============ PAGE 2 ============ -->
<div class="page" id="p2">
<h2>Step 0 — Before you travel</h2>
<div class="crit">
<h3>Compatibility gate — 60 seconds, do this first</h3>
<p>Read the inverter's <b>serial number</b> (label, app, or portal) and take
<b>characters 68</b>. That three-letter tag is the platform.</p>
<table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
<td><b>SUPPORTED</b></td></tr>
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ETU EHU BTU …</code></td><td>ET / EH / BT hybrids — platform 205/745/753</td><td><b>NO</b></td></tr>
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
</table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different meter
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div>
<div class="cols">
<div class="box">
<h3>Confirm with the client</h3>
<ul class="tick">
<li>Inverter is <b>GoodWe ES / BP family</b></li>
<li>Vendor controller may be <b>disconnected</b></li>
<li>Warranty / installer agreement acknowledged <b>in writing</b></li>
<li>Grid rules checked (BE: Synergrid C10/11)</li>
<li>Vendor subscription — cancel only <b>after</b> sign-off</li>
</ul>
</div>
<div class="box">
<h3>Confirm on their system</h3>
<ul class="tick">
<li>HA is <b>OS or Supervised</b> (Settings → System → Repairs → ⋮ → System information)</li>
<li>Grid power sensor exists, updates ≤ 10 s</li>
<li>Note its entity id and update rate</li>
<li>E-stop in the van, or signature form printed</li>
</ul>
</div>
</div>
<div class="crit" style="margin-top:4mm">
<h3>Stop conditions</h3>
<p style="margin:0">If HA is <b>Container</b> or <b>Core</b>, add-ons cannot be installed and this
product cannot go in. If the grid sensor updates slower than ~10 s, the default tuning is wrong
for the site — do not proceed without adjusting <code>gain</code>.</p>
</div>
<h2 style="margin-top:5mm">Never do these</h2>
<table>
<tr><th style="width:52%">Never</th><th>Because</th></tr>
<tr><td>Flash third-party firmware on the inverter's WiFi dongle</td><td>Common image targets different hardware — bricks it</td></tr>
<tr><td>Long-press (35 s) the inverter's WiFi Reset button</td><td>Factory-resets the dongle, loses network access. Short press (~1 s) is safe</td></tr>
<tr><td>Connect our controller while the vendor box is still attached</td><td>Two masters on one bus — contradictory commands</td></tr>
<tr><td>"Compensate" for an inverted meter sign in the tuning</td><td>Fix <code>meter_invert</code> or the wiring. A wrong sign drives the grid away from zero at full gain</td></tr>
<tr><td>Raise the e-stop's 30 s threshold to stop it firing during updates</td><td>Firing during an update is correct behaviour</td></tr>
</table>
</div>
<!-- ============ PAGE 3 ============ -->
<div class="page" id="p3">
<h2>Steps 13 — Hardware</h2>
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The meter bus
is low-voltage but sits inside an energised installation.</div>
<div class="step">
<h3><span class="num">1</span> Survey and record</h3>
<ol class="do">
<li><b>Photograph</b> the meter-port wiring before touching it.</li>
<li>Identify the RS485 pair (A/B) running to the vendor controller.</li>
<li>Record inverter model + serial, battery capacity (Ah) and nominal voltage.</li>
</ol>
<div class="verify"><b>VERIFY</b> — photos on file; A/B pair positively identified.</div>
</div>
<div class="step">
<h3><span class="num">2</span> Disconnect the vendor controller</h3>
<ol class="do">
<li>Bring the system to <b>0 W</b> — battery neither charging nor discharging (check the
inverter display).</li>
<li>Disconnect the vendor controller from the meter port.</li>
<li>Leave it physically mounted but unplugged — that keeps the install reversible and gives
you a rollback story.</li>
</ol>
<div class="verify"><b>VERIFY</b> — battery at 0 W, vendor box disconnected.</div>
<div class="ifnot"><b>IF NOT</b> — do not continue while the vendor box can still write to the bus.</div>
</div>
<div class="step">
<h3><span class="num">3</span> Fit and power the T-CAN485</h3>
<ol class="do">
<li>Wire <b>A→A, B→B</b> to the meter port, plus its own supply.</li>
<li>If fitting the e-stop, connect its USB-RS485 adapter to the <b>same pair, in parallel</b>.</li>
<li>Power the T-CAN485 <b>last</b>.</li>
</ol>
<div class="verify"><b>VERIFY</b> — board joins WiFi and appears in ESPHome.</div>
<div class="ifnot"><b>IF NOT</b> — swapped A/B gives a <b>completely silent bus, not an error</b>.
Swap and retry before suspecting anything else.</div>
</div>
<div class="warn"><b>It transmits within seconds of boot.</b> Never power it up while the vendor
controller is connected.</div>
</div>
<!-- ============ PAGE 4 ============ -->
<div class="page" id="p4">
<h2>Step 4 — Flash the firmware</h2>
<div class="step">
<h3><span class="num">4</span> ESPHome</h3>
<ol class="do">
<li>Open the <b>ESPHome Device Builder</b> add-on on the client's HA.</li>
<li>Create a device from <code>firmware/goodwe-master.yaml</code>.</li>
<li>Set the <b>three substitutions</b> at the top — and nothing else.</li>
<li>Add the client's WiFi to ESPHome's <code>secrets.yaml</code>.</li>
<li>Install: first flash by USB, everything after that over the air.</li>
</ol>
<div class="verify"><b>VERIFY</b> — device shows <b>Online</b>, and
<code>number.&lt;node&gt;_goodwe_setpoint_w</code> exists in HA.</div>
</div>
<table>
<tr><th style="width:22mm">Substitution</th><th>Set to</th><th>Why it matters</th></tr>
<tr><td><code>name</code></td><td>Node name, e.g. <code>goodwe-master</code></td>
<td><b>Write it on the sheet.</b> Changing it later renames every entity in HA and silently breaks the add-on</td></tr>
<tr><td><code>max_w</code></td><td>Inverter continuous rating, e.g. <code>5000</code></td>
<td>Hard firmware limit, independent of HA. Last line of defence against a controller bug</td></tr>
<tr><td><code>wd_ms</code></td><td><code>30000</code></td>
<td>Watchdog timeout. Must stay well above the add-on heartbeat (10 s)</td></tr>
</table>
<figure>
<img src="{ESPHOME}" alt="ESPHome Device Builder">
<figcaption>ESPHome Device Builder — the node must read <b>Online</b> before you continue.
(Reference bench shown.)</figcaption>
</figure>
<div class="warn"><b>Testing firmware changes:</b> an update that lands new shutdown-path code
still runs the <b>old</b> code on the way out. If you change wind-down or watchdog behaviour,
<b>upload twice</b> before believing a test result.</div>
</div>
<!-- ============ PAGE 5 ============ -->
<div class="page" id="p5">
<h2>Step 5 — Install and configure the add-on</h2>
<div class="step">
<h3><span class="num">5</span> Add-on</h3>
<ol class="do">
<li>Settings → Add-ons → Add-on store → ⋮ → <b>Repositories</b> → add the repository URL.</li>
<li>Install <b>GoodWe RS485 Controller</b>. <b>Do not start it yet.</b></li>
<li>Open <b>Configuration</b> and fill in the table below.</li>
<li>Start the add-on, open its <b>Web UI</b>.</li>
<li>Work down the <b>Commissioning</b> list until every line is green.</li>
</ol>
<div class="verify"><b>VERIFY</b> — banner is green or amber, no red lines.</div>
<div class="ifnot"><b>IF NOT</b> — the banner names the failing item. Copy entity ids from
<b>Developer Tools → States</b>. Do not type them from memory.</div>
</div>
<table>
<tr><th style="width:34mm">Option</th><th>Set to</th></tr>
<tr><td><code>meter_entity</code></td><td>Client's grid power sensor. <b>Positive must mean importing</b> — else set <code>meter_invert</code></td></tr>
<tr><td><code>soc_entity</code></td><td><code>sensor.&lt;node&gt;_goodwe_battery_soc</code> — <b>the ESP32's own read</b>, never the cloud/dongle sensor</td></tr>
<tr><td><code>batt_entity</code></td><td><code>sensor.&lt;node&gt;_goodwe_inverter_ac_power</code></td></tr>
<tr><td><code>setpoint_entity</code></td><td><code>number.&lt;node&gt;_goodwe_setpoint_w</code></td></tr>
<tr><td><code>max_w</code></td><td><b>1000</b> for commissioning. Raise at the end of Step 6</td></tr>
<tr><td><code>estop_fitted</code></td><td>true only if you actually fitted one</td></tr>
<tr><td><code>peak_forecast_entity</code></td><td>Capacity-tariff sites only, else leave empty</td></tr>
<tr><td><code>price_now_entity</code> / <code>price_avg_entity</code></td><td>Dynamic-tariff sites only, else leave empty</td></tr>
</table>
<div class="crit">
<h3>Entity ids are the #1 install error</h3>
<p style="margin:0">HA prefixes entity ids with the device's <b>area</b> at creation time, so the
same firmware yields <code>sensor.goodwe_master_…</code> on one site and
<code>sensor.cellar_goodwe_master_…</code> on another. <b>A wrong entity id is not an error
anywhere in HA</b> — it simply never produces a value. On the reference system two safety alarms
pointed at non-existent entities and were dead for a day while reading “on”.</p>
</div>
</div>
<!-- ============ UI REFERENCE ============ -->
<div class="page uiref">
<h2>Reading the Web UI</h2>
<p class="sub" style="margin-bottom:2.5mm">Two states you must tell apart at a glance. The
banner is the summary; the <b>Commissioning</b> list is the detail.</p>
<figure>
<img src="{UI_OK}" alt="Web UI running normally">
<figcaption><b>HEALTHY.</b> Green banner. All checks ✓. <i>Why</i> reads <code>tracking</code>.
<b>Grid near zero</b> and the commanded figure <b>resting</b> between updates — a command that
moves every single cycle means the deadband is too small for this meter.</figcaption>
</figure>
<figure style="margin-top:4mm">
<img src="{UI_BAD}" alt="Web UI showing NOT READY">
<figcaption><b>NOT READY.</b> Red = must fix before going further; the banner names the first
failing item. Amber = permitted, but the client must have signed for it — here, no e-stop
fitted. Note <i>Why</i> reads <code>inputs-missing</code> and <b>Commanded</b> has gone to
<b>0 W</b>: with a source missing, the controller zeroes the inverter rather than guessing.</figcaption>
</figure>
</div>
<!-- ============ PAGE 6 ============ -->
<div class="page" id="p6">
<h2>Step 6 — Commissioning gates</h2>
<p class="sub" style="margin-bottom:3mm">Judge every gate on the <b>wire or the meter</b> — never on
how Home Assistant looks.</p>
<table>
<tr><th style="width:9mm">#</th><th style="width:38%">Do</th><th>Pass</th><th style="width:26%">If it fails</th></tr>
<tr><td><b>1</b></td><td>Add-on running, control <b>stopped</b></td>
<td>Steady 0 W written, inverter idle, no errors</td><td>Check wiring and Step 5</td></tr>
<tr><td><b>2</b></td><td>By hand (Developer Tools → <code>number.set_value</code>):
<b>+300 W</b>, then <b>300 W</b></td>
<td>Battery discharges ~300 W, then charges ~300 W, within ~10 s</td>
<td>Sign inverted → fix <code>batt_invert</code>/wiring, <b>not</b> the tuning</td></tr>
<tr><td><b>3</b></td><td>Compare ESP32 readings to inverter display</td>
<td>SoC identical; power within ~5 %</td><td>Wrong entity, or dongle sensor used by mistake</td></tr>
<tr><td><b>4</b></td><td><b>Set +300 W, then STOP the add-on</b></td>
<td><b>Inverter reaches 0 W within ~30 s</b> and stays</td>
<td><b>STOP THE INSTALL.</b> Nothing here is safe without this</td></tr>
<tr><td><b>5</b></td><td><code>max_w</code>=1000, start control</td>
<td>Grid settles within tens of watts of zero and <b>rests</b></td>
<td>Hunting → raise <code>deadband_w</code>; slow meter → lower <code>gain</code></td></tr>
<tr><td><b>6</b></td><td>Switch on a kettle/oven (~2 kW)</td>
<td>Grid back near zero within ~20 s; command <b>stops</b> rising once the battery catches up</td>
<td>Command keeps climbing = <b>runaway</b>. Stop the add-on immediately</td></tr>
</table>
<div class="verify"><b>THEN</b> — raise <code>max_w</code> to the value the site is sold with and
repeat gate 6 once.</div>
</div>
<!-- ============ PAGE 7 ============ -->
<div class="page" id="p7">
<h2>Step 7 — The e-stop</h2>
<div class="step">
<h3><span class="num">7a</span> If fitted</h3>
<ol class="do">
<li>Connect the Pi to the same RS485 pair; power it.</li>
<li>Run: <code>python3 rs485_log.py --out /home/pi/bus.log --panic</code></li>
<li>Confirm it prints <code>PANIC ARMED</code>.</li>
<li><b>Test it:</b> with the battery at +300 W, cut power to the T-CAN485.</li>
</ol>
<div class="verify"><b>VERIFY</b> — within ~35 s the log shows a <code>TX</code> line and the
inverter goes to <b>0 W</b>.</div>
<div class="ifnot"><b>IF NOT</b> — check the adapter is on the correct pair and that the tap was
started with <code>--panic</code>.</div>
</div>
<div class="warn">
<b>Arm the panic write only while our controller owns the bus.</b> Pointed at a bus somebody else
drives, it is unrequested interference.<br>
<b>It will fire during every firmware update</b> — the upload silence exceeds 30 s. That is
correct. Do not raise the threshold.
</div>
<h3 style="margin-top:5mm">7b — If not fitted: client acknowledgement</h3>
<p>Read this to the client and have them sign. Note it on the sign-off sheet.</p>
<div class="sign">
<p style="margin:0 0 2mm"><b>Without the RS485 e-stop:</b> if the Home Assistant machine fails,
loses power, or its storage fails, the battery inverter will continue charging or discharging at
whatever level it was last commanded, <b>indefinitely</b>, until someone intervenes manually. The
inverter has no automatic fallback of its own. The e-stop is the only component that prevents
this.</p>
<div class="cols">
<div><div class="line"></div><small>Client name</small></div>
<div><div class="line"></div><small>Signature</small></div>
<div><div class="line"></div><small>Date</small></div>
</div>
</div>
<h2 style="margin-top:6mm">Step 8 — Prove a maintenance cycle</h2>
<div class="step">
<h3><span class="num">8</span> Do not wait a month to find out it never fires</h3>
<ol class="do">
<li>Web UI → <b>Force maintenance cycle</b>.</li>
<li>Watch the phase go <code>drain → charge → hold → idle</code>.</li>
<li>Confirm the inverter <b>exports</b> during drain, <b>charges</b> during charge, sits at
<b>0 W</b> during hold.</li>
<li>Set <code>maintenance_enabled: true</code>.</li>
</ol>
<div class="verify"><b>SHORT VERSION (~10 min)</b> if the client cannot spare hours: set
<code>maintenance_soc_floor</code> just below current SoC, <code>maintenance_soc_target</code>
just below that, <code>maintenance_hold_min: 5</code>. <b>Restore real values afterwards</b> and
record it as a partial test.</div>
</div>
<p><small>Why it matters: the full charge is when the BMS <b>balances cells</b>; the deep discharge
<b>recalibrates the coulomb counter</b>. Skipping it breaks nothing visibly — it degrades the pack
over months, and the first symptom is a state-of-charge reading nobody can trust.</small></p>
</div>
<!-- ============ PAGE 8 ============ -->
<div class="page" id="p8">
<h2>Step 9 — Handover &amp; sign-off</h2>
<div class="box">
<h3>Tell the client, in these words</h3>
<ul>
<li>“This is the Web UI. Green means it is working.”</li>
<li>“<b>If anything looks wrong, press stop.</b> That sets the battery to zero and it idles
safely.”</li>
<li>“Once a month it will deliberately empty and then fill the battery. That is normal and it
keeps the battery healthy.”</li>
</ul>
</div>
<div class="sign">
<h3 style="margin-bottom:3mm">Commissioning record</h3>
<table>
<tr><td style="width:42mm">Site / client</td><td><div class="line" style="height:6mm;margin:0"></div></td></tr>
<tr><td>Inverter model / serial</td><td><div class="line" style="height:6mm;margin:0"></div></td></tr>
<tr><td>ESPHome node name</td><td><div class="line" style="height:6mm;margin:0"></div></td></tr>
<tr><td>Add-on version / <code>max_w</code></td><td><div class="line" style="height:6mm;margin:0"></div></td></tr>
<tr><td>Meter entity + update rate</td><td><div class="line" style="height:6mm;margin:0"></div></td></tr>
</table>
<ul class="tick" style="margin-top:3mm">
<li>Gates 16 all passed <b>on the wire or the meter</b></li>
<li>Gate 4 (watchdog) personally observed reaching 0 W</li>
<li>Maintenance cycle proven — <b>full / partial</b> (circle one)</li>
<li>E-stop fitted &amp; tested <b>OR</b> §7b signed</li>
<li>Client shown the Web UI and the stop button</li>
<li>Vendor subscription cancelled (only after the above)</li>
</ul>
<div class="cols" style="margin-top:3mm">
<div><div class="line"></div><small>Technician</small></div>
<div><div class="line"></div><small>Signature</small></div>
<div><div class="line"></div><small>Date</small></div>
</div>
</div>
</div>
<!-- ============ PAGE 9 ============ -->
<div class="page" id="p9">
<h2>Troubleshooting</h2>
<table>
<tr><th style="width:31%">Symptom</th><th style="width:31%">Likely cause</th><th>Do this</th></tr>
<tr><td>Web UI says <b>NOT READY</b></td><td>Wrong entity id, or sensor unavailable</td><td>Banner names it. Copy the id from Developer Tools → States</td></tr>
<tr><td>Configured, but nothing moves</td><td>Control is stopped</td><td>Press start; banner turns green</td></tr>
<tr><td>Inverter does nothing, no errors</td><td>A/B swapped, or vendor box still connected</td><td>Swapped RS485 gives a <b>silent</b> bus, not an error</td></tr>
<tr><td>HA shows one setpoint, inverter does another</td><td>Firmware <code>max_w</code> below add-on <code>max_w</code></td><td>Firmware wins by design. Align them</td></tr>
<tr><td>Log shows writes rejected (HTTP 400)</td><td>Value outside the number entity's range</td><td>Lower <code>max_w</code>; check the firmware substitution</td></tr>
<tr><td><b>Command keeps climbing while the battery is pinned</b></td><td><b>Runaway</b> — sign inverted or saturation defeated</td><td><b>Stop the add-on now.</b> Verify <code>meter_invert</code> with a known load</td></tr>
<tr><td>Grid hunts, never rests</td><td>Deadband too small, or meter too slow</td><td>Raise <code>deadband_w</code>; if meter &gt; 10 s, lower <code>gain</code></td></tr>
<tr><td>Battery flat overnight and stays flat</td><td>Maintenance drain never exited</td><td>Check the SoC entity is the ESP32's, not a cached cloud value</td></tr>
<tr><td>Maintenance never runs</td><td>Disabled, or not yet due</td><td>Check <code>maintenance_enabled</code> and the “Maintenance due” row</td></tr>
<tr><td>E-stop fires on every update</td><td>Expected — upload silence &gt; 30 s</td><td>Nothing to fix. Do not raise the threshold</td></tr>
<tr><td>Add-on will not install</td><td>HA is Container/Core</td><td>Not supportable — see Step 0</td></tr>
</table>
<h2 style="margin-top:5mm">Why the tuning is what it is</h2>
<p class="sub" style="margin-bottom:2mm">Every value came from hardware. Do not change them without measuring.</p>
<table>
<tr><th style="width:30mm">Setting</th><th style="width:16mm">Value</th><th>Reason</th></tr>
<tr><td><code>gain</code></td><td>0.6</td><td>Inverter needs 36 s to settle (1.4 s dead time, 94 % by 3.3 s) against a ~5 s cycle. <b>At the limit — do not raise</b></td></tr>
<tr><td><code>slew_w</code></td><td>1000</td><td>Most of a correction in the first cycle without running far ahead of the hardware</td></tr>
<tr><td><code>deadband_w</code></td><td>15</td><td>Measured residual: mean 15.4 W, max 27 W. At 10 W the command never rests</td></tr>
<tr><td><code>saturation_w</code> / <code>cycles</code></td><td>500 / 3</td><td>Divergence = inverter at a limit → magnitude may fall, never rise. <b>The 3-cycle term is essential</b>: tested instantly it fires on every large correction</td></tr>
<tr><td><code>step_w</code></td><td>10</td><td>Register is 1 W but response lands on a ~17.6 W ladder. Finer is meaningless</td></tr>
<tr><td><code>heartbeat_s</code></td><td>10</td><td>Three chances to be heard before the 30 s watchdog acts</td></tr>
</table>
<div class="warn" style="margin-top:1mm">The failure this guards against is real: the vendor
controller commanded <b>14 547 W</b> against an inverter reporting 5250 W, and kept climbing for
six minutes, because its integrator never stopped.</div>
</div>
</body></html>
"""
HTML = HTML.replace("{UI_OK}", UI_OK).replace("{UI_BAD}", UI_BAD).replace("{ESPHOME}", ESPHOME)
os.makedirs(OUT_DIR, exist_ok=True)
html_path = os.path.join(OUT_DIR, "field-guide.html")
pdf_path = os.path.join(OUT_DIR, "GoodWe-RS485-Field-Guide.pdf")
with open(html_path, "w", encoding="utf-8") as fh:
fh.write(HTML)
print(f"html: {os.path.getsize(html_path):,} bytes")
subprocess.run([
CHROME, "--headless=new", "--disable-gpu", "--no-pdf-header-footer",
f"--print-to-pdf={pdf_path}", "file:///" + html_path.replace("\\", "/"),
], check=True, capture_output=True, timeout=180)
print(f"pdf : {os.path.getsize(pdf_path):,} bytes -> {pdf_path}")