Compare commits

..
11 Commits
Author SHA1 Message Date
admin ad374ae208 Deploy 0.18.2 to master (#200) 2026-10-17 01:59:54 +02:00
admin ec2b0c3de4 Deploy 0.18.1 to master (#197) 2026-10-16 15:05:06 +02:00
admin 3b7d6b7f05 Deploy 0.18.0 to master (#193) 2026-10-16 00:42:54 +02:00
admin 1b6f16dba2 Deploy 0.17.0 to master (#186) 2026-10-11 01:37:01 +02:00
admin df154a26d1 Deploy 0.16.0 to master (#179) 2026-10-08 01:43:37 +02:00
admin 21599938ea Merge pull request 'Deploy 0.15.0 to master' (#174) from deploy-0.15.0 into master
Deploy 0.15.0 to master (#174)
2026-10-05 10:17:38 +02:00
admin 3a54b253cb Merge pull request 'Deploy 0.14.0 to master' (#170) from deploy-0.14.0 into master
Deploy 0.14.0 to master (#170)
2026-10-03 12:32:16 +02:00
admin 8b9680fc35 Merge pull request 'Deploy 0.13.0 to master' (#166) from deploy-0.13.0 into master
Reviewed-on: #166
2026-10-02 15:26:46 +02:00
admin 3ce18708b6 PKG-18: ship 0.12.0 to master, so TEL-12's pv_entities can reach an installed 0.11.0 (#118) 2026-09-24 23:22:34 +02:00
adminandClaude Opus 5 78acb558ff PKG-17: ship 0.11.0 to master, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#115)
master is what the Home Assistant Supervisor clones, and the Supervisor
compares config.yaml version strings - so 0.11.0 could not be offered to an
installed 0.10.1 without this bump reaching master.

Carries SAFETY-56 (#112), TEL-11 (#113), the CI-9 follow-up (#102) and SEC-2's
docs/RELEASE.md (#108), by way of the 0.11.0 cut (#114).

NOT A FAST-FORWARD. master was not an ancestor of release/1.0: 0f14c2a, the
pre-SEC-2 direct push, exists only on master. Its tree is the same object as
the merge base b0b24e5 (c1fef26), so it carries no content delta, and
`git merge-tree --write-tree` over the two branches returns a tree bit-identical
to cccb2fa's own (6670502). This merge commit therefore contributes exactly
release/1.0's tree - master synthesises nothing.

Reviewed as the release decision, not a re-review of content. Verified from the
remote: slug goodwe_controller and every identity key unchanged (cross-checked
against the live box's Supervisor export), version 0.10.1 -> 0.11.0, and the
green is this PR's own - run 828 / job 1953, path checks.yml@refs/pull/115/head,
PRNUM 115, GRADED refs/pull/115/head merged into master, run_attempt 1, 31 test
files all passing in 8m01s of real work.

predeploy_check.py re-run against the owner's current options (exported
2026-09-24 20:02, 75 keys, pv_entity unset): CLEARED, exit 0. That retires the
stale-2026-09-07-export warning in the pull request body. Option surface
75 -> 76 with only pv_entity added, str? defaulting to "", no key removed, no
default moved, no rule added to validate.py - no SystemExit(78) on this hop.

Merging is not installing. No tag is cut here and `ha store reload` /
`ha addons update` have not been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 20:28:35 +02:00
glenn schrooyenandClaude Opus 5 0f14c2a5de PKG-16: ship 0.10.1 to master, so the Supervisor can offer SAFETY-57's fix
master is what Home Assistant clones. SAFETY-57 fixed a deadlock that parked
the reference install's battery for ~13 minutes while the site exported
2.4 kW, and the Supervisor compares config.yaml version strings - so the fix
could not reach an installed 0.10.0 without this bump reaching master.

Brings SAFETY-57 (#104) and the 0.10.1 cut (#105). master was a strict
ancestor; no conflict was possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:54:29 +02:00
18 changed files with 85 additions and 410 deletions
+3 -3
View File
@@ -4,9 +4,9 @@ The guide technicians carry is the PDF:
**`docs/GoodWe-RS485-Field-Guide.pdf`** — 10 pages, A4, print it double-sided.
⚠️ **Outdated: the content is still v0.1.0.** The PDF was rebuilt on 2026-10-17
for the EMS-port wording and the `slew_w` row only. Where the guide disagrees with
`goodwe_controller/DOCS.md`, DOCS.md wins.
⚠️ **Outdated: it is v0.1.0.** Its `slew_w` row is corrected in `build_guide.py`
and `field-guide.html`, but the PDF has not been rebuilt (see below). Where the
guide disagrees with `goodwe_controller/DOCS.md`, DOCS.md wins.
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
+1 -1
View File
@@ -156,7 +156,7 @@ that looks hung gets deleted.
**A GoodWe "-20" / G2 inverter cannot be driven by this.**
Platform comes from characters 6–8 of the serial number. Only
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era generation — are
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era meter bus — are
supported. SBP G2 (`SPB`/`SPN`) and ES G2 (`ESN`/`ESC`) are platform 745, a
different protocol. The reference unit is `BPS`.
+6 -6
View File
@@ -1,8 +1,8 @@
# GoodWe RS485 Controller — Home Assistant add-on repository
Replaces a GoodWe battery inverter's vendor controller with a local one: holds
net grid exchange at zero by sending the inverter a grid reading over its RS485
EMS port, and runs the monthly battery maintenance cycle the vendor box was doing.
net grid exchange at zero by emulating the inverter's smart meter over RS485,
and runs the monthly battery maintenance cycle the vendor box was doing.
```
repository.yaml add-on repository metadata
@@ -30,7 +30,7 @@ goodwe_controller/ the add-on
returns, run against its own release image
firmware/goodwe-master.yaml ESPHome config for the T-CAN485
estop/rs485_log.py optional RS485 e-stop / bus witness
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; rebuilt 2026-10-17, content still v0.1.0 and outdated)
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; outdated)
docs/build_guide.py its single source - rebuilds the HTML and PDF
FIELD-GUIDE.md how to edit and rebuild the guide
docs/RELEASE.md how a change reaches the battery - `master` is
@@ -118,17 +118,17 @@ the platform tag:
| tag | platform | verdict |
|---|---|---|
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era | **supported** |
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era meter bus | **supported** |
| `SPB SPN` | 745 — SBP **G2** (`GW…-SBP-20`) | no |
| `ESN ESC` | 745 — ES **G2** (`GW…-ES-20`) | no |
| `ETU EHU BTU …` | 205/745/753 — ET/EH/BT hybrids | no |
| `SDT DST MSU NSU …` | PV-only inverters — no battery to control | no |
A "-20" or "G2" suffix means a different platform with a different
A "-20" or "G2" suffix means a different platform with a different meter
protocol. The tags come from the `goodwe` library's `PLATFORM_105_MODELS`, which
is also what Home Assistant's own integration uses to pick a protocol.
- GoodWe **ES / BP family** inverters (AA55 generation)
- GoodWe **ES / BP family** inverters (AA55 / RS485 meter-bus generation)
- The protocol is **reverse-engineered**. There is no vendor contract, and a
firmware change on GoodWe's side could break every installation at once. Say
so when you sell it.
Binary file not shown.
+6 -6
View File
@@ -154,7 +154,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
<td><b>SUPPORTED</b></td></tr>
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
@@ -162,7 +162,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
</table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different
A “-20” or “G2” suffix in the model name means a different platform with a different meter
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div>
@@ -208,13 +208,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ 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 RS485 EMS port
<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 EMS-port wiring before touching it.</li>
<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>
@@ -226,7 +226,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<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 inverter's EMS port.</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>
@@ -237,7 +237,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<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 EMS port, plus its own supply.</li>
<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>
+6 -6
View File
@@ -119,7 +119,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<table style="margin-bottom:2mm">
<tr><th style="width:34mm">Tag</th><th>Platform</th><th style="width:30mm">Verdict</th></tr>
<tr><td><code>ESU EMU ESA BPS<br>BPU EMJ IJL</code></td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era meter bus</td>
<td><b>SUPPORTED</b></td></tr>
<tr><td><code>SPB SPN</code></td><td>SBP <b>G2</b> (GW…-SBP-20) — platform 745</td><td><b>NO</b></td></tr>
<tr><td><code>ESN ESC</code></td><td>ES <b>G2</b> (GW…-ES-20) — platform 745</td><td><b>NO</b></td></tr>
@@ -127,7 +127,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<tr><td><code>SDT DST MSU NSU …</code></td><td>PV-only inverters — <b>no battery to control</b></td><td><b>NO</b></td></tr>
</table>
<p style="margin:0"><small>Reference unit: <code>95000BPS225W0290</code> → <code>BPS</code> → supported.
A “-20” or “G2” suffix in the model name means a different platform with a different
A “-20” or “G2” suffix in the model name means a different platform with a different meter
protocol. <b>If the tag is not on the supported list, stop — do not quote the job.</b></small></p>
</div>
@@ -173,13 +173,13 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<!-- ============ 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 RS485 EMS port
<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 EMS-port wiring before touching it.</li>
<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>
@@ -191,7 +191,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<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 inverter's EMS port.</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>
@@ -202,7 +202,7 @@ figcaption { font-size:9pt; color:#5b6169; margin-top:1.2mm; }
<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 EMS port, plus its own supply.</li>
<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>
+5 -6
View File
@@ -1,9 +1,8 @@
# GoodWe RS485 Controller
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port: sends it a grid
reading to hold net grid exchange at zero, and runs a monthly battery
maintenance cycle so the BMS can balance cells and recalibrate its coulomb
counter.
Drives a GoodWe ES/BP battery inverter over its RS485 meter bus: holds net grid
exchange at zero, and runs a monthly battery maintenance cycle so the BMS can
balance cells and recalibrate its coulomb counter.
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
v0.1.0 and outdated; where it disagrees with this page, this page wins.
@@ -34,8 +33,8 @@ v0.1.0 and outdated; where it disagrees with this page, this page wins.
You need:
- A GoodWe **ES / BP family** inverter (AA55 generation)
- The vendor's controller **disconnected** from the inverter's EMS port
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
- The vendor's meter-emulating controller **disconnected** from the bus
- A control board: a T-CAN485 (ESP32) flashed with
`firmware/goodwe-master.yaml`
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
+38 -92
View File
@@ -22,13 +22,16 @@ from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels, _cover_table
from .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan
from .tariff import BILLING_TZ
from .uimodel import REGION_PRESETS, options_to_ui
# A battery at or under this many watts is resting, not charging or giving.
IDLE_W = 50.0
# Display only: the lowest third of today's price range is "cheap", the top
# third "expensive". A price range under this is flat, and nothing is marked.
FLAT_EUR_KWH = 0.01
# Severity words the page shows; the same four as STYLE.md section 3.
ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO
@@ -139,103 +142,55 @@ class DayPrices:
def __init__(self):
self.day = None
self.points = {}
self.kinds = {}
def see(self, points, day0, day1):
if self.day != day0:
self.day, self.points, self.kinds = day0, {}, {}
self.day, self.points = day0, {}
for p in points:
if day0 <= p.start < day1:
self.points[p.start] = p
return sorted(self.points.values(), key=lambda p: p.start)
def _bar_step(pts):
"""The series' own spacing: its smallest gap, never over an hour."""
gaps = [y.start - x.start for x, y in zip(pts, pts[1:])
if y.start > x.start]
return min(gaps + [timedelta(hours=1)])
def _bar_cover(pts):
"""`[(start, end, point)]`: what each point is the price of on the chart.
The add-on's own rule, not a copy: `price._cover_table()` (TARIFF-14), so
a point covers until the next one starts and never over an hour, and the
last one for the span of the one before it."""
return _cover_table(pts)
def price_bars(points, day0, day1, windows=(), now=None, kept=None):
"""One bar per price step of the day: a quarter for a 15-minute series
(96; 92 and 100 on the clock-change days), an hour for an hourly one or a
static tariff. A bar is the price of the point that covers it, never an
average, and a step with no price is a bar with `eur_kwh` None.
`kind` is "cheap" or "dear" where `windows` (`[(kind, start, end)]`, the
runs the add-on itself uses) cover the bar's start, else "mid". A bar that
is over keeps the kind it had while it was still ahead (`kept`, a dict
that lives a day). Display only."""
pts = sorted(points, key=lambda p: p.start)
step = _bar_step(pts)
cover = _bar_cover(pts)
n = int((day1 - day0) / step)
def hour_bars(points, day0, day1):
"""One bar per local hour: the mean of its prices, or None when unknown.
`kind` is cheap / dear / mid by thirds of today's range (display only).
The page calls these "low price" and "high price", not cheap hours: the
add-on trades in the one cheapest and one dearest hour, not in a third."""
n = int(round(_hours(day1, day0)))
bars = []
for k in range(n):
a = day0 + k * step
z = a + step
hit = next((p for s, e, p in cover if s <= a < e), None)
kind = None
if hit is not None:
if kept is not None and now is not None and z <= now:
kind = kept.get(a, "mid")
else:
kind = next((w[0] for w in windows if w[1] <= a < w[2]),
"mid")
if kept is not None:
kept[a] = kind
a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1)
inside = [p for p in points if a <= p.start < b]
price = (sum(p.eur_per_kwh for p in inside) / len(inside)
if inside else None)
bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
"end_label": hhmm(z),
"eur_kwh": None if hit is None
else round(hit.eur_per_kwh, 4),
"estimate": hit is not None and bool(hit.provisional),
"kind": kind})
"end_label": hhmm(b), "points": len(inside),
"eur_kwh": None if price is None else round(price, 4),
"estimate": bool(inside) and any(p.provisional
for p in inside),
"kind": None})
known = [b["eur_kwh"] for b in bars if b["eur_kwh"] is not None]
if known and max(known) - min(known) >= FLAT_EUR_KWH:
lo, hi = min(known), max(known)
third = (hi - lo) / 3.0
for b in bars:
p = b["eur_kwh"]
if p is None:
continue
b["kind"] = ("cheap" if p <= lo + third else
"dear" if p >= hi - third else "mid")
elif known:
for b in bars:
if b["eur_kwh"] is not None:
b["kind"] = "mid"
return bars
def _price_mode_in_use(c):
"""True when at least one battery is on and uses a price mode. The runs
belong to those batteries; with all on Solar first or off, nothing acts
on a price."""
for inv in c.inverters:
pm = c.pack_mode(inv)
if pm.use and pm.mode in PRICE_MODES:
return True
return False
def price_windows(c, plan, verdict):
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
(`c.mode_runs`): the cheap run when charging in it pays, and every give
run. With them off it is the arbitrage verdict's two points, the ones
`trade_runs()` draws. Nothing is classified here."""
if getattr(c, "modes_on", False):
rp = getattr(c, "mode_runs", None)
if rp is None or not rp.on or not _price_mode_in_use(c):
return []
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
if rp.charge_useful() else []
return out + [("dear", g.start, g.end) for g in rp.give]
if verdict is None:
return []
return [("cheap", verdict.cheapest.start,
_point_end(plan, verdict.cheapest)),
("dear", verdict.dearest.start,
_point_end(plan, verdict.dearest))]
def day_range(points):
"""(lowest, highest) of today's published points, as {eur_kwh, label},
or (None, None). The feed's own points, like the trade legs."""
or (None, None). The feed's own points, like the trade legs, not the
chart's hour averages."""
firm = [p for p in points if not p.provisional]
if not firm:
return None, None
@@ -596,15 +551,13 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
plan = static
except Exception: # noqa: BLE001
pass
days = _day_prices(c)
seen = days.see(_points(plan), day0, day1)
seen = _day_prices(c).see(_points(plan), day0, day1)
bars = hour_bars(seen, day0, day1)
limit_w = c.peak.cap_w # the limit the controller parsed at start
runs, lines = [], []
# The control cycle trades on the fresh plan only, so this does too.
verdict, trade_why = trade_verdict(c, fresh)
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
now, days.kinds)
modes_on = bool(getattr(c, "modes_on", False))
if running:
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
@@ -1478,10 +1431,3 @@ def _samples() -> list:
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")]
return out
def priced_hours(bars):
"""How many clock hours of the day have a price: distinct hours among the
bars that have one, so 96 quarters count 24, not 96. The setup wizard's
"N of 24 hours". A 23-hour day gives 23, a 25-hour day 24."""
return len({b["label"][:2] for b in bars if b.get("eur_kwh") is not None})
+1 -1
View File
@@ -3,7 +3,7 @@
WHAT THIS THING DOES, in one paragraph: it reads the house's net grid power from
Home Assistant, decides how hard the battery should charge or discharge to hold
that at zero, and writes that figure to an ESP32 which puts it on the inverter's
RS485 EMS port. Once a month it runs a full low->full battery cycle so the BMS
RS485 meter bus. Once a month it runs a full low->full battery cycle so the BMS
can balance cells and recalibrate its coulomb counter.
⚠️ THE ONE THING TO UNDERSTAND BEFORE CHANGING ANY OF THIS: the inverter holds
+1 -1
View File
@@ -29,7 +29,7 @@ _LOG = logging.getLogger("goodwe.mqtt")
DEVICE = {
"identifiers": ["goodwe_rs485_controller"],
"name": "GoodWe",
"manufacturer": "GoodWe (via RS485 EMS port)",
"manufacturer": "GoodWe (via RS485 meter emulation)",
"model": "ES/BP series",
}
-2
View File
@@ -78,8 +78,6 @@ h2{font-size:15px;margin:0 0 10px;font-weight:650}
.bars i.cheap{background:var(--cheap)}
/* not by colour alone: expensive hours are striped, estimates are faded */
.bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)}
.bars.fine{gap:1px}
.bars.fine i{border-radius:1px 1px 0 0}
.bars i.est{opacity:.55}
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important}
.lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px}
+1 -2
View File
@@ -177,12 +177,11 @@
const known = p.prices.filter((b) => b.eur_kwh != null);
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
bars.setAttribute("aria-label", t("plan.name"));
bars.classList.toggle("fine", p.prices.length > 48);
bars.replaceChildren(...p.prices.map((b) => {
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
const est = (b.estimate ? ", " + t("plan.estimate_tip") : "");
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
+2 -1
View File
@@ -66,7 +66,8 @@
"estimate_tip": "estimate from yesterday",
"none_tip": "No price known",
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
"list": "The plan in words"
"list": "The plan in words",
"average_tip": "hour average"
},
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
+2 -1
View File
@@ -63,7 +63,8 @@
"estimate_tip": "schatting op basis van gisteren",
"none_tip": "Geen prijs bekend",
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
"list": "Het plan in woorden"
"list": "Het plan in woorden",
"average_tip": "gemiddelde van het uur"
},
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
+2 -2
View File
@@ -50,7 +50,7 @@ from aiohttp import web
from . import messages, price, selfconfig, uipage, uitext
from .haentities import EntityCache
from .homeview import priced_hours, region_of
from .homeview import region_of
from .validate import config_notes, setup_checks
from .config_page import CONFIG_PAGE
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
@@ -328,7 +328,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
try:
prices = (controller.status_payload().get("plan") or {}).get(
"prices") or []
known = priced_hours(prices)
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
except Exception: # noqa: BLE001 - a display figure, never a failure
known = None
return setup_checks(options, entities=rows, running=controller.o,
+2 -2
View File
@@ -2,8 +2,8 @@ name: GoodWe RS485 Controller
version: "0.18.2"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
grid reading, holding net grid exchange at zero and running a monthly battery
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
meter, holding net grid exchange at zero and running a monthly battery
maintenance cycle.
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
arch:
+6 -275
View File
@@ -33,9 +33,7 @@ from app import homeview, web
from app.main import Controller
from app.peakguard import PeakDecision
from app.price import PricePoint, PriceProduct, PriceRead
from app.static_tariff import TariffPlan, static_plan
from app import packmode
from app.store import unit_key
from app.static_tariff import TariffPlan
from app.uimodel import REGION_PRESETS
from named_absence import field, nth, present
@@ -154,14 +152,6 @@ def quiet_guard(projected_w=1240.0):
ceiling_from="cap", projected_w=projected_w)
def set_modes(c, *modes):
"""File each battery's mode in the store (None leaves the default, which
follows the house)."""
for inv, m in zip(c.inverters, modes):
if m is not None:
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = m
def status(c, now=NOW):
s = c.status_payload()
# The status route builds at wall-clock time; rebuild the structured half
@@ -327,7 +317,7 @@ check("a plan with no published prices yet says so, not 'the price gap is "
_t == [] and homeview.line_no_trade("not_firm") in _l
and homeview.line_no_trade("not_worth") not in _l)
check("the price bars are today's 24 hours, from the real series, with the "
"cheap and dear hour marked",
"cheap and expensive thirds marked",
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
and s_arb["plan"]["prices"][19]["kind"] == "dear"
@@ -528,269 +518,10 @@ check("quarter-hour prices: the charge block, its price and the price line's "
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
and "the list" not in json.dumps(s_q["plan"]))
# UX-45: the chart draws the steps the add-on decides on.
_pq = s_q["plan"]["prices"]
check("UX-45: 96 quarter prices -> 96 bars, each one quarter, labelled "
"from its own start, with its own price and no average",
len(_pq) == 96 and _pq[0]["label"] == "00:00"
and _pq[0]["end_label"] == "00:15" and _pq[57]["label"] == "14:15"
and _pq[57]["end_label"] == "14:30"
and [b["eur_kwh"] for b in _pq] == [round(p.eur_per_kwh, 4)
for p in _q[:96]]
and all("points" not in b for b in _pq)
and s_q["plan"]["hours"] == 24)
check("...the quarters of one hour differ (the chart shows the steps)",
len({b["eur_kwh"] for b in _pq[52:56]}) > 1)
check("...and the page has no 'hour average' tip any more",
"average_tip" not in io.open(os.path.join(
HERE, "app", "static", "app.js"), encoding="utf-8").read()
and "average_tip" not in io.open(os.path.join(
HERE, "app", "ui_text", "en.json"), encoding="utf-8").read()
and "average_tip" not in io.open(os.path.join(
HERE, "app", "ui_text", "nl.json"), encoding="utf-8").read())
_cheap_q = [b["start"] for b in _pq if b["kind"] == "cheap"]
_dear_q = [b["start"] for b in _pq if b["kind"] == "dear"]
_hi = max(_q[:96], key=lambda p: p.eur_per_kwh)
check("...without battery modes the marks are the two quarters the "
"arbitrage verdict trades in, and no others",
_cheap_q == [_lo.start.isoformat()] and _dear_q == [_hi.start.isoformat()]
and sum(1 for b in _pq if b["kind"] == "mid") == 94)
# Battery modes on: the marks ARE the kept priceruns result.
from app import priceruns
_rp = priceruns.plan(tuple(_q), NOW, required_eur_kwh=0.05, charge_kwh=2.0,
charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
c_m = make(ARB)
c_m.tariff_plan, c_m.tariff_at = c_q.tariff_plan, NOW
c_m.peak_guard = quiet_guard()
c_m.modes_on, c_m.mode_runs = True, _rp
set_modes(c_m, packmode.SOLAR_FIRST, packmode.CHEAP_HOURS)
_pm = status(c_m)["plan"]["prices"]
_today = [(a, z) for a, z, _p in priceruns.firm_slots(_q, NOW)
if a < DAY0 + timedelta(hours=24)]
_want_c = {a.isoformat() for a, z in _today
if _rp.cheap.start <= a < _rp.cheap.end}
_want_d = {a.isoformat() for g in _rp.give for a, z in _today
if g.start <= a < g.end}
check("UX-45: with battery modes on, the cheap and dear quarters are the "
"priceruns cheap run and give runs for the same input",
_rp.on and _rp.charge_useful() and _want_c and _want_d
and {b["start"] for b in _pm if b["kind"] == "cheap"} == _want_c
and {b["start"] for b in _pm if b["kind"] == "dear"} == _want_d
and len(_pm) == 96)
c_m2 = make(ARB)
c_m2.tariff_plan, c_m2.tariff_at = c_q.tariff_plan, NOW
c_m2.peak_guard = quiet_guard()
c_m2.modes_on = True
set_modes(c_m2, packmode.CHEAP_HOURS, None)
c_m2.mode_runs = priceruns.plan(tuple(_q), NOW, required_eur_kwh=5.0,
charge_kwh=2.0, charge_kw=4.0, sell_kwh=0.0,
sell_kw=0.0)
check("...a run plan that says no trade marks nothing",
not c_m2.mode_runs.on
and {b["kind"] for b in status(c_m2)["plan"]["prices"]} == {"mid"})
# A quarter that is over keeps the mark it had while it was ahead.
# (`status()` also builds at wall-clock time, which starts the day's memory
# over, so the two builds below go straight to `homeview.build`.)
_later = NOW + timedelta(hours=4)
_s0 = c_q.status_payload()
homeview.build(c_q, level=_s0["level"], checks=_s0["checks"], now=NOW)
c_q.tariff_at = _later
_pl = homeview.build(c_q, level=_s0["level"], checks=_s0["checks"],
now=_later)["plan"]["prices"]
check("...and a quarter already over keeps the mark it had while ahead",
[b["kind"] for b in _pl if b["start"] == _lo.start.isoformat()]
== ["cheap"]
and [b["kind"] for b in _pl if b["label"] == "19:00"] == ["dear"])
c_q.tariff_at = NOW
# UX-45 round 2 (F2): a hole is covered by the add-on's own rule,
# `price._cover_table()`: the point before it runs on, never over an hour.
def _hole_bars(missing):
pts = [PricePoint(DAY0 + timedelta(minutes=15 * i), .10 + .001 * i, False)
for i in range(96)]
gone = {DAY0 + timedelta(hours=10, minutes=15 * (1 + j))
for j in range(missing)}
pts = [p for p in pts if p.start not in gone]
return homeview.price_bars(pts, DAY0, DAY0 + timedelta(hours=24))
_gap = {}
for _n in (1, 2, 3, 4, 5):
_bb = _hole_bars(_n)
_gap[_n] = ([b["eur_kwh"] is None for b in _bb[41:41 + _n]],
len(_bb))
_b1 = _hole_bars(1)
check("UX-45: 1, 2 or 3 missing quarters show the price before them (the "
"add-on's own cover rule), and nothing shifts",
all(len(_hole_bars(n)) == 96 for n in (1, 2, 3))
and _gap[1][0] == [False] and _gap[2][0] == [False, False]
and _gap[3][0] == [False, False, False]
and [b["eur_kwh"] for b in _hole_bars(3)[40:44]]
== [_hole_bars(3)[40]["eur_kwh"]] * 4
and _b1[42]["label"] == "10:30" and _b1[43]["label"] == "10:45"
and _b1[43]["eur_kwh"] == round(.10 + .001 * 43, 4))
check("...4 missing quarters leave 1 bar empty, 5 leave 2: a hole over an "
"hour is not filled past the hour",
_gap[4][0] == [False, False, False, True]
and _gap[5][0] == [False, False, False, True, True]
and sum(b["eur_kwh"] is None for b in _hole_bars(4)) == 1
and sum(b["eur_kwh"] is None for b in _hole_bars(5)) == 2)
# A cheap run that is not worth charging in is not marked; an estimate is.
import types
_pts = [PricePoint(DAY0 + timedelta(minutes=15 * i),
.30 if i < 4 else .10, False) for i in range(8)]
_rp2 = priceruns.plan(tuple(_pts), DAY0, required_eur_kwh=0.05,
charge_kwh=0.5, charge_kw=4.0, sell_kwh=0.0, sell_kw=0.0)
c_w2 = make(ARB)
c_w2.modes_on, c_w2.mode_runs = True, _rp2
set_modes(c_w2, packmode.LEFTOVER_SOLAR, None)
_w2 = homeview.price_windows(c_w2, None, None)
check("UX-45: a cheap run that comes after every dear slot is not worth a "
"charge, so it is not marked (the dear run is)",
_rp2.on and _rp2.cheap is not None and not _rp2.charge_useful()
and [w[0] for w in _w2] == ["dear"])
_est = [PricePoint(DAY0 + timedelta(minutes=15 * i), .2, False)
for i in range(80)] + [PricePoint(DAY0 + timedelta(hours=20), .25,
True)]
_be = homeview.price_bars(_est, DAY0, DAY0 + timedelta(hours=24))
check("UX-45: a carried (estimate) price after quarter prices is the last "
"point, so it covers one quarter (the add-on's tail rule), as an "
"estimate bar; the rest stays empty",
[b["estimate"] for b in _be[78:83]] == [False, False, True, False, False]
and _be[80]["eur_kwh"] == .25 and _be[81]["eur_kwh"] is None)
_est2 = _est[:80] + [PricePoint(DAY0 + timedelta(hours=20 + k), .25, True)
for k in range(3)]
_be2 = homeview.price_bars(_est2, DAY0, DAY0 + timedelta(hours=24))
check("...carried hourly points one after another cover an hour each",
[b["eur_kwh"] for b in _be2[80:92]] == [.25] * 12
and _be2[92]["eur_kwh"] is None)
# Hourly prices -> 24 bars (above); a day/night tariff -> 24 bars.
_ph1 = s_arb["plan"]["prices"]
check("UX-45: hourly prices -> 24 hour bars with the hour's own price",
len(_ph1) == 24 and _ph1[13]["end_label"] == "14:00"
and "points" not in _ph1[13])
c_dn = make(dict(ARB, static_profile=(
'{"periods": [{"name":"day","from":"07:00","to":"22:00","days":"all",'
'"eur_per_kwh":0.34},{"name":"night","from":"22:00","to":"07:00",'
'"days":"all","eur_per_kwh":0.26}]}')))
c_dn.peak_guard = quiet_guard()
_pd = status(c_dn)["plan"]["prices"]
check("UX-45: a day/night tariff -> 24 bars, 0.26 at night and 0.34 by day",
len(_pd) == 24
and [b["eur_kwh"] for b in _pd]
== [.26] * 7 + [.34] * 15 + [.26] * 2
and all(b["kind"] == "mid" for b in _pd))
# Clock-change days: 92 and 100 quarters.
_d0 = datetime(2026, 3, 28, 23, 0, tzinfo=timezone.utc)
_d1 = datetime(2026, 3, 29, 22, 0, tzinfo=timezone.utc)
_sp = [PricePoint(_d0 + timedelta(minutes=15 * i), .1, False)
for i in range(92)]
check("UX-45: a 23-hour day has 92 quarter bars, a 25-hour day 100",
len(homeview.price_bars(_sp, _d0, _d1)) == 92
and len(homeview.price_bars(
_sp, _d0, _d0 + timedelta(hours=25))) == 100)
_f0 = datetime(2026, 10, 24, 22, 0, tzinfo=timezone.utc) # 25-hour day
_fb = [PricePoint(_f0 + timedelta(minutes=15 * i), .1, False)
for i in range(100)]
# F5: the day/night chart with the stored static plan, as in production
# (the cycle stores `static_plan()` when the provider is off).
_PROF = ('{"periods": [{"name":"day","from":"07:00","to":"22:00",'
'"days":"all","eur_per_kwh":0.34},{"name":"night","from":"22:00",'
'"to":"07:00","days":"all","eur_per_kwh":0.26}]}')
def _dn(arb):
c = make(dict(ARB if arb else TWO, static_profile=_PROF))
c.peak_guard = quiet_guard()
c.tariff_plan = static_plan(c.o, NOW, NOW + timedelta(hours=36), now=NOW)
c.tariff_at = NOW
return c, status(c)["plan"]["prices"]
_c_off, _p_off = _dn(False)
_c_on, _p_on = _dn(True)
_v_on, _why_on = homeview.trade_verdict(_c_on, _c_on.fresh_tariff_plan(NOW))
check("UX-45: day/night tariff with the stored plan and price trading off: "
"24 bars, no cheap or dear mark",
len(_p_off) == 24 and {b["kind"] for b in _p_off} == {"mid"})
check("...and with price trading on, the marks are the verdict's cheapest "
"and dearest hour (one each)",
_v_on is not None and len(_p_on) == 24
and [datetime.fromisoformat(b["start"]) for b in _p_on
if b["kind"] == "cheap"] == [_v_on.cheapest.start]
and [datetime.fromisoformat(b["start"]) for b in _p_on
if b["kind"] == "dear"] == [_v_on.dearest.start]
and sum(b["kind"] == "mid" for b in _p_on) == 22)
# F3 / owner decision 2a: with battery modes on, marks only when a battery
# uses a price mode.
def _modes(*modes, use=(True, True)):
c = make(ARB)
c.tariff_plan, c.tariff_at = c_q.tariff_plan, NOW
c.peak_guard = quiet_guard()
c.modes_on, c.mode_runs = True, _rp
set_modes(c, *modes)
for inv, u in zip(c.inverters, use):
c.store.data[unit_key(inv.name, packmode.USE_KEY)] = u
return {b["kind"] for b in status(c)["plan"]["prices"]}
_F, _L, _C = (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR,
packmode.CHEAP_HOURS)
check("UX-45: battery modes on, every battery follows the house: no marks",
_modes(_F, _F) == {"mid"} and _modes(None, None) == {"mid"})
check("...one battery on Cheap hours or Leftover solar: the marks show",
{"cheap", "dear"} <= _modes(_F, _C) and {"cheap", "dear"} <= _modes(_L, _F))
check("...a price mode on a battery that is switched off does not count",
_modes(_F, _C, use=(True, False)) == {"mid"}
and {"cheap", "dear"} <= _modes(_F, _C, use=(False, True)))
# F4: the day's memory of past marks starts over on a new day.
_s4 = c_q.status_payload()
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=NOW)
_k0 = dict(c_q.home_prices.kinds)
_tom = NOW + timedelta(days=1)
c_q.tariff_at = _tom
homeview.build(c_q, level=_s4["level"], checks=_s4["checks"], now=_tom)
c_q.tariff_at = NOW
check("UX-45: past marks start over on a new day (nothing of yesterday is "
"kept)",
len(_k0) > 0 and c_q.home_prices.day != DAY0
and c_q.home_prices.kinds
and all(k >= DAY0 + timedelta(days=1) for k in c_q.home_prices.kinds)
and len(c_q.home_prices.kinds) <= 96)
# F1: the setup wizard counts hours, not bars.
from app.validate import setup_checks
_o = dict(ARB, price_provider="entsoe_be")
_say = lambda n: [x for x in map(repr, setup_checks(
_o, entities={}, running=_o, prices_known=n)) if "Prices for today" in x]
check("UX-45: 96 quarter prices count as 24 hours in the setup wizard, "
"not 96",
homeview.priced_hours(_pq) == 24
and homeview.priced_hours(s_arb["plan"]["prices"]) == 24
and "for 24 of 24 hours" in "".join(_say(homeview.priced_hours(_pq)))
and "96 of 24" not in "".join(_say(homeview.priced_hours(_pq))))
check("...a half-priced day counts its hours; a hole of one quarter loses "
"no hour",
homeview.priced_hours(_pq[:48]) == 12
and homeview.priced_hours(_hole_bars(5)) == 24
and homeview.priced_hours([dict(b, eur_kwh=None) for b in _pq]) == 0)
check("...a 23-hour day gives 23 and a 25-hour day 24 (clock hours)",
homeview.priced_hours(homeview.price_bars(_sp, _d0, _d1)) == 23
and homeview.priced_hours(homeview.price_bars(
_fb, _f0, _f0 + timedelta(hours=25))) == 24)
check("...and the wizard takes that count",
"known = priced_hours(prices)" in io.open(os.path.join(
HERE, "app", "web.py"), encoding="utf-8").read())
check("...and the hour bar says it is an hour average",
s_q["plan"]["prices"][13]["points"] == 4
and '"plan.average_tip"' in io.open(os.path.join(
HERE, "app", "static", "app.js"), encoding="utf-8").read())
# --------------------------------------------------------------------------- #
print("\n4. a fault in the Home view never costs the page its rows")
+3 -3
View File
@@ -104,7 +104,7 @@ def check(name, cond):
# release/1.0 rather than against release/1.0.
# ---------------------------------------------------------------------------
_DEV = ('{"identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", '
'"manufacturer": "GoodWe (via RS485 EMS port)", '
'"manufacturer": "GoodWe (via RS485 meter emulation)", '
'"model": "ES/BP series"}')
BASELINE = [
@@ -442,7 +442,7 @@ for c_ in unit_cfgs:
print(f" regression pin (sha256 of the eight payloads): {_pin}")
check("the per-unit payloads are the ones this PR was reviewed with "
"(REGRESSION PIN, not a correctness claim - see the comment)",
_pin == "b4274714d8557c37")
_pin == "eb5022b0455158d7")
# ---------------------------------------------------------------------------
print("\n4. zero overlap between A's entities and B's")
@@ -976,7 +976,7 @@ def announce_p2(units=(), omit=(), include=(), modes=(), retract=(),
# `git show ed739d4:goodwe_controller/app/mqtt.py` and this loop.
ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"],
["inverter-b", "Inverter B", "x", "y"], ["café"]]
ED739D4_OFF = "057be1d8ad7bb582" # DOC-8: ed739d4 digest with only the manufacturer string changed
ED739D4_OFF = "501a7d93782a260b"
_h, _n, _subs_off = hashlib.sha256(), 0, []
for _r in ROSTERS:
for _om in [(), ("p1_age",), ("p1_age", "grid")]: