Field guide as a 10-page A4 PDF, one page per step

Rebuilt the guide as something usable on site rather than a document to read
at a desk: strict order of operations, DO / VERIFY / IF NOT blocks per step,
pass-fail gates in a table, a printable sign-off sheet, and troubleshooting
keyed by symptom rather than by subsystem.

Includes real screenshots of the add-on's own Web UI - captured by running it
against stub data - showing the healthy state and the misconfigured one side
by side, because telling those apart at a glance is the single most useful
skill on site. Plus the ESPHome step from a live builder.

The text lives only in docs/build_guide.py, which renders the HTML and drives
headless Chrome to produce the PDF. One source: a Markdown copy would
inevitably drift from the PDF someone is holding in a cellar.

Pagination is enforced, not hoped for. Each section must render under the A4
printable height (1039 px at 96 dpi) or Chrome silently spills it onto a second
page and the page numbers stop matching the step numbers. Measured every
section rather than eyeballing it: the Web UI reference page was 1170 px and is
now 1017 px, and the PDF comes out at exactly one page per section.

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:44:08 +02:00
co-authored by Claude Opus 5
parent 0a1e61dbc9
commit 266684bed7
8 changed files with 1005 additions and 369 deletions
+513
View File
@@ -0,0 +1,513 @@
"""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="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}")