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
+27 -368
View File
@@ -1,382 +1,41 @@
# GoodWe RS485 Controller — Field Guide # Field Guide
For installing technicians. Read section 1 before the first site visit; work The guide technicians carry is the PDF:
through sections 511 in order at the site; do not sign off until section 12 is
complete.
--- **`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
## 1. The one fact this whole product is built around It covers, in the order the work is done: pre-visit checks, hardware, firmware,
add-on install and configuration, how to read the Web UI, the six commissioning
gates, the e-stop (and the statement a site without one must sign), proving a
maintenance cycle, handover with a sign-off sheet, and troubleshooting.
**The inverter holds the last command it understood, forever.** It has no ## Editing it
meter-timeout of its own. If whatever is driving it stops talking, it does not
fall back to idle, to a safe value, or to anything else — it keeps doing exactly
what it was last told, indefinitely.
This is not a theory. On the reference system a controller went silent The text lives in `docs/build_guide.py` — deliberately one source, so a Markdown
mid-command and the inverter held **5 kW of discharge for 113 seconds**, until a copy cannot drift from the PDF someone is holding in a cellar.
person noticed and intervened.
Everything else in this guide follows from that:
| layer | covers | where it lives |
|---|---|---|
| 1 — watchdog | controller alive but silent | ESP32 firmware: no fresh setpoint for 30 s → command 0 W **and keep commanding it** |
| 2 — wind-down | planned firmware update | ESP32 firmware: 0 W written *before* the update starts |
| 3 — e-stop | **the controller or its host is dead** | optional RS485 e-stop: writes 0 W after 30 s of total bus silence |
**Layer 3 is the only thing that covers the Home Assistant machine dying.**
Nothing running on that machine can cover its own death. On a site without the
e-stop, a failed Pi or a pulled plug leaves the battery running at whatever it
was last commanded until a human intervenes. Section 10 tells you what to say
about that, in writing.
> Stopping is the failure mode, not the fix. Anything in this system that finds
> itself in doubt must command **0 W** — never hold the last value "to be safe".
---
## 2. Before you go: what to confirm with the client
Do not treat these as formalities. Each has ended an installation.
- [ ] **Inverter is a GoodWe ES / BP family unit** (the AA55 / RS485 meter-bus
generation). Other GoodWe families use a different protocol and are not
supported.
- [ ] **There is an existing controller on the meter bus** (vendor box emulating
a smart meter). Confirm what it is, and that the client accepts it being
**disconnected**.
- [ ] **Warranty and installer agreement.** Replacing the vendor controller may
affect both. Get the client's written acknowledgement. This is a
commercial question, and it is cheaper to ask than to discover.
- [ ] **Grid connection rules.** In Belgium the installation is governed
(Synergrid C10/11). Modifying how the inverter is driven can touch the DSO
agreement. Confirm the client's position before quoting.
- [ ] **Any active vendor subscription** — cancel *after* the replacement is
proven, not before.
- [ ] **Home Assistant is HA OS or Supervised.** Add-ons cannot be installed on
HA Container or Core. Check Settings → System → Repairs → System
information. **If it says Container, this product cannot be installed.**
- [ ] **A working grid-power sensor exists in HA** with a fast update
(≤ ~10 s). HomeWizard P1, DSMR, Shelly EM are all fine. Note its update
rate — see §9.
- [ ] **E-stop fitted or not** — and if not, that the client has signed the
statement in §10.
---
## 3. What is in the box
**Base SKU**
- T-CAN485 (ESP32 + RS485 transceiver), pre-flashed or flashed on site
- Wiring loom to the inverter's meter port
- The add-on (installed from your repository URL)
**E-stop upgrade**
- Raspberry Pi (any model with USB) + USB-RS485 adapter
- Pre-loaded `rs485_log.py`
- Tap wiring to the same bus
---
## 4. How the pieces fit
```
grid meter ──► Home Assistant ──► GoodWe RS485 Controller add-on
(client's) (client's) │
│ number.<node>_goodwe_setpoint_w
T-CAN485 (ESP32)
│ RS485, 9600 8N1, AA55/Modbus
GoodWe inverter meter port
│ passive tap + emergency write
Pi e-stop (optional)
```
The add-on never talks to the inverter directly. It writes one number; the ESP32
turns that into meter frames at a steady cadence and owns the safety timing.
---
## 5. Site survey and safety
⚠️ **Qualified persons only.** The inverter carries mains AC and battery DC.
The meter bus itself is low-voltage, but you are working inside an energised
installation.
1. Photograph the existing wiring at the inverter's meter port **before**
touching anything.
2. Identify the RS485 pair (A/B) going to the vendor controller.
3. Note the inverter model and serial from its label.
4. Record the battery: capacity (Ah), nominal voltage, and the inverter's
depth-of-discharge setting if visible.
**Never do these:**
- ❌ Do not flash third-party firmware onto the inverter's WiFi dongle. The
widely circulated image targets different hardware and will brick it.
- ❌ Do not long-press (35 s) the inverter's WiFi Reset/Reload button. It
factory-resets the dongle and you lose network access to it. A short press
(~1 s) is safe.
- ❌ Do not connect our controller while the vendor controller is still
attached. **Two masters on one bus is the one configuration that can produce
contradictory commands.** Disconnect the vendor box first, at 0 W.
---
## 6. Hardware installation
1. **Bring the system to 0 W.** Set the vendor controller to idle if it allows
it, or simply confirm on the inverter display that the battery is neither
charging nor discharging.
2. **Disconnect the vendor controller** from the meter port. Leave it physically
installed but disconnected if the client wants a reversible install — that
also gives you a rollback story if you ever need one.
3. **Wire the T-CAN485** to the meter port: A→A, B→B, plus its own power supply.
Observe polarity; swapped A/B produces a completely silent bus, not an error.
4. If fitting the **e-stop**, connect the USB-RS485 adapter to the *same* pair,
in parallel. It is passive until it decides to act.
5. Power up the T-CAN485 **last**.
⚠️ It begins transmitting within seconds of boot. Never power it up while the
vendor controller is still connected.
---
## 7. Flash the ESP32
Use the ESPHome Device Builder add-on on the client's HA, or your own laptop.
1. Copy `firmware/goodwe-master.yaml` into ESPHome.
2. Set the three substitutions at the top — **and only those**:
- `name` — the node name. Write it on the commissioning sheet. **Changing it
later renames every entity in HA and silently breaks the add-on's config.**
- `max_w` — the inverter's continuous rating (e.g. `5000`). This is a hard
firmware limit, independent of anything HA asks for, and it is the last
line of defence against a controller bug.
- `wd_ms` — watchdog timeout, default `30000`. Must stay comfortably above
the add-on's heartbeat.
3. Add the client's WiFi credentials to ESPHome's `secrets.yaml`.
4. Install. First flash is by USB; everything after that is over the air.
⚠️ **A firmware update that lands new shutdown-path code still runs the OLD code
on the way out.** If you change the wind-down or watchdog behaviour, upload
twice before believing a test result.
---
## 8. Install and configure the add-on
1. Settings → Add-ons → Add-on store → ⋮ → **Repositories** → add your repo URL.
2. Install **GoodWe RS485 Controller**. Do not start it yet.
3. Open **Configuration** and fill in:
| option | what to put | notes |
|---|---|---|
| `meter_entity` | the client's grid power sensor | **+ must mean importing.** If theirs is the other way round, set `meter_invert` |
| `soc_entity` | `sensor.<node>_goodwe_battery_soc` | **the ESP32's own read**, not the inverter's cloud/dongle sensor |
| `batt_entity` | `sensor.<node>_goodwe_inverter_ac_power` | same — the ESP32's read |
| `setpoint_entity` | `number.<node>_goodwe_setpoint_w` | what the add-on writes |
| `max_w` | start at **1000** for commissioning | raise after §9 passes |
| `estop_fitted` | true only if you actually fitted one | drives the warning banner |
| `peak_forecast_entity` | capacity-tariff sites only | leave empty elsewhere |
| `price_now_entity` / `price_avg_entity` | dynamic-tariff sites only | leave empty on fixed tariffs |
⚠️ **Get the entity ids exactly right.** Home Assistant prefixes entity ids with
the device's *area* at creation time, so the same firmware produces
`sensor.goodwe_master_...` on one site and `sensor.cellar_goodwe_master_...` on
another. **A wrong entity id is not an error anywhere in HA** — it simply never
produces a value. On the reference install two safety alarms pointed at
non-existent entities and were dead for a day while their state read "on".
Copy ids from Developer Tools → States. Do not type them from memory.
4. Start the add-on and open its **Web UI** (ingress panel).
5. Work down the **Commissioning** list until every line is green. It tells you
what is wrong in words. Do not proceed while anything is red.
---
## 9. Commissioning — the acceptance gates
**Every gate is judged on the wire or the meter, never on "it looks right in
Home Assistant".** If you fitted the e-stop, its log is your witness; if not,
use the inverter's own display and the client's meter.
### Gate 1 — the inverter answers us
Add-on running, control **stopped**. Expect a steady 0 W command being written,
the inverter idle, no errors in the add-on log.
If the e-stop is fitted, `bus.log` shows our write frames being ACKed:
```
F7 10 05 6E 00 02 04 00 03 00 00 A6 D0 our write, 0 W
F7 10 05 6E 00 02 34 4F inverter ACK
```
✅ Pass: frames ACKed, no CRC errors, inverter idle.
### Gate 2 — we can move power, both directions
With control still stopped, set the setpoint by hand from Developer Tools
(`number.set_value` on the setpoint entity):
- **+300 W** → battery discharges ~300 W within ~10 s
- **300 W** → battery charges ~300 W
✅ Pass: both signs work and the inverter's reported power follows within ~10 s.
❌ If the sign is inverted, fix `batt_invert` / your wiring — **do not**
"compensate" in the tuning.
### Gate 3 — telemetry agrees
Compare the ESP32's readings against the inverter display or the vendor app:
state of charge should match exactly; power within a few percent (conversion
loss). ✅ Pass: SoC identical, power within ~5 %.
### Gate 4 — the watchdog (**the important one**)
Set +300 W by hand, confirm it is running, then **stop the add-on**.
✅ Pass: the inverter reaches **0 W within ~30 s** and stays there.
❌ Fail: anything still moving after a minute. Stop the installation and
investigate — without this, nothing else in this product is safe.
Restart the add-on afterwards.
### Gate 5 — the closed loop
Set `max_w` to 1000, start control from the Web UI, and watch the client's grid
power.
✅ Pass: grid settles to within a few tens of watts of zero and *stays* there,
with the command resting rather than hunting continuously.
### Gate 6 — a real load step
Switch on a kettle or oven (~2 kW).
✅ Pass: grid returns to near zero within ~20 s, and the command does **not**
keep climbing after the battery has caught up. A command that keeps rising while
the battery is pinned is runaway — stop immediately and see §13.
Then raise `max_w` to the value the site is sold with (typically the inverter
rating) and repeat Gate 6 once.
### Gate 7 — the maintenance cycle
See §11. **Do not sign off without it.**
---
## 10. The e-stop, and what it means when it is absent
**Fitted:** connect the Pi to the same RS485 pair, power it, and run:
```bash ```bash
python3 rs485_log.py --out /home/pi/bus.log --panic python3 docs/build_guide.py # regenerates field-guide.html and the PDF
``` ```
Confirm it prints `PANIC ARMED`. Test it: with the battery at +300 W, cut power Requires Chrome or Chromium (used headless for print-to-PDF). Screenshots live
to the T-CAN485. Within ~35 s the log must show a ` TX ` line and the inverter in `docs/img/` and are base64-embedded, so `field-guide.html` is standalone.
must go to 0 W.
⚠️ Arm the panic write **only** while our controller owns the bus. Pointed at a ⚠️ **Every section must stay under the A4 printable height** (1039 px at 96 dpi
bus somebody else is driving, it is unrequested interference. with the configured margins). Chrome silently spills an over-long section onto a
second page, and the pagination stops matching the step numbers. After editing,
open `field-guide.html` and check:
⚠️ Expect it to fire during every firmware update — the upload silence exceeds ```js
30 s. That is correct behaviour, not a fault. Do not raise the threshold to document.querySelectorAll('.page').forEach(el =>
silence it. console.log(el.getBoundingClientRect().height)) // each must be < 1039
```
**Not fitted — put this in front of the client, in writing:** The PDF should always come out at exactly **one page per section**.
> Without the RS485 e-stop, if the Home Assistant machine fails, loses power, or ## Regenerating the screenshots
> its storage fails, the battery inverter will continue charging or discharging
> at whatever level it was last commanded, indefinitely, until someone
> intervenes manually. The inverter has no automatic fallback of its own. The
> e-stop is the only component that prevents this.
Have them acknowledge it. Note it on the commissioning sheet. `docs/img/ui-running.png` and `ui-notready.png` are real captures of the add-on's
own Web UI, taken by running it against stub data. If the UI changes, retake them
--- — a field guide showing a screen that no longer exists is worse than one with no
screenshots at all.
## 11. Prove the maintenance cycle
The monthly cycle takes the battery low, then charges it fully and holds it
there. It exists so the BMS can **balance cells** and **recalibrate its coulomb
counter**. Skipping it breaks nothing visibly — it degrades the pack over months,
and the first symptom is a state-of-charge reading nobody can trust.
**Do not wait a month to discover the schedule does not fire.** Force one:
1. Web UI → **Force maintenance cycle**.
2. Watch the phase go `drain → charge → hold → idle`.
3. Confirm the inverter actually exports during `drain`, actually charges during
`charge`, and sits at 0 W during `hold`.
A full cycle takes hours. If the client cannot spare the time on the day,
temporarily set `maintenance_soc_floor` just below current SoC and
`maintenance_soc_target` just below it again, plus `maintenance_hold_min: 5`
that walks the whole state machine in about ten minutes. **Put the real values
back afterwards and note it on the sheet as a partial test.**
Finally set `maintenance_enabled: true`.
---
## 12. Handover and sign-off
- [ ] All gates in §9 passed, on the wire or the meter
- [ ] Maintenance cycle proven (full or partial — state which)
- [ ] E-stop fitted and tested, **or** client acknowledgement signed (§10)
- [ ] `max_w` set to the agreed value
- [ ] Node name, entity ids and add-on version written on the sheet
- [ ] Client shown: the Web UI, the start/stop button, and what "stopped" means
- [ ] Client told: **if anything looks wrong, stop the add-on** — that commands
0 W and the battery idles safely
- [ ] Vendor subscription cancelled only *after* the above
---
## 13. Troubleshooting
| symptom | likely cause | what to do |
|---|---|---|
| Web UI says **NOT READY** | an entity id is wrong or the sensor is unavailable | The banner names the failing item. Copy the id from Developer Tools → States |
| Everything looks configured but nothing moves | control is stopped | Press start in the Web UI; the banner should turn green |
| Inverter does nothing, no errors anywhere | A/B swapped, or the vendor controller is still connected | Swapped RS485 gives a *silent* bus, not an error |
| HA shows one setpoint, the inverter does another | the firmware `max_w` is lower than the add-on's `max_w` | The firmware wins by design. Raise it there, or lower it in the add-on |
| Writes rejected in the log (HTTP 400) | value outside the number entity's range | Lower `max_w`; check the firmware substitution |
| Command keeps climbing while the battery is pinned | **runaway** — sign inverted, or saturation detection defeated | **Stop the add-on immediately.** Verify `meter_invert` with a known load |
| Grid hunts continuously, never rests | deadband too small for that meter, or the meter is slow | Raise `deadband_w`; if the meter updates slower than ~10 s, lower `gain` |
| Battery goes flat overnight and stays flat | maintenance `drain` phase never exited | Check the SoC entity is the ESP32's, not a cached cloud value |
| Maintenance never runs | schedule disabled, or never became due | Check `maintenance_enabled`, and the "Maintenance due" row in the Web UI |
| E-stop fires during every update | expected — update silence exceeds 30 s | Nothing to fix. Do not raise the threshold |
| Add-on will not install | HA is Container/Core, not OS/Supervised | Not supportable. See §2 |
**When in doubt: stop the add-on.** That commands 0 W, the watchdog holds it
there, and nothing is at risk while you think.
---
## 14. Why the tuning is what it is
Do not change these without measuring. Every value came from hardware.
- **`gain` 0.6 per cycle** — a cycle is one meter update (~5 s). The inverter
needs 36 s to settle: ~1.4 s dead time, 94 % of a step by 3.3 s. So the loop's
next correction lands just as the plant arrives. **0.6 is at the limit — do
not raise it**, and do not shorten the cycle below the meter's update rate.
- **`slew_w` 1000 W per cycle** — most of a correction in the first cycle
without letting the command run far ahead of the hardware.
- **`deadband_w` 15 W** — measured residual while regulating: mean 15.4 W, max
27 W. At 10 W, ~69 % of cycles act and the command never rests. A resting
command is a diagnostic asset: "flat for 70 s" is how you recognise a healthy
loop at a glance.
- **`saturation_w` 500 W over `saturation_cycles` 3** — command and reading
diverging means the inverter is at a limit; then the magnitude may fall but
never rise. **The 3-cycle duration term is essential**: tested instantaneously
it fires on every large correction, because the plant itself lags.
- **`step_w` 10 W** — the register is 1 W, but the inverter's response lands on
a coarser ladder (~17.6 W measured at ~900 W). 10 W just avoids a visible
staircase; finer is meaningless.
- **`heartbeat_s` 10 s against a 30 s watchdog** — three chances to be heard
before the hardware takes over.
The failure this tuning is designed against is real: the vendor controller
commanded **14 547 W** against an inverter reporting 5250 W, and kept climbing
for six minutes, because its integrator never stopped.
+3 -1
View File
@@ -21,7 +21,9 @@ goodwe_controller/ the add-on
DOCS.md the add-on's documentation tab DOCS.md the add-on's documentation tab
firmware/goodwe-master.yaml ESPHome config for the T-CAN485 firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness estop/rs485_log.py optional RS485 e-stop / bus witness
FIELD-GUIDE.md installation, commissioning gates, troubleshooting docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
``` ```
## Install ## Install
Binary file not shown.
+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}")
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB