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
514 lines
28 KiB
Python
514 lines
28 KiB
Python
"""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 & 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 & 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 & configure the add-on</td><td>All checks green</td></tr>
|
||
<tr><td><b>6</b></td><td>Commissioning gates 1–6</td><td>Every gate passes</td></tr>
|
||
<tr><td><b>7</b></td><td>E-stop: fit & 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 & 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 (3–5 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 1–3 — 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.<node>_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.<node>_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.<node>_goodwe_inverter_ac_power</code></td></tr>
|
||
<tr><td><code>setpoint_entity</code></td><td><code>number.<node>_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 & 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 1–6 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 & 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 > 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 > 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 3–6 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}")
|