Compare commits

...
19 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 a4a7ff695e DOC-8 review: say the field guide PDF is rebuilt, still v0.1.0
checks / unit (pull_request) Successful in 40m29s
infra-pin / fresh (pull_request) Successful in 1s
README and FIELD-GUIDE.md said the PDF was not rebuilt; it was, on
2026-10-17. Its content is still v0.1.0 and outdated. Rewrap the
DOCS.md intro line. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:27:35 +02:00
glenn schrooyenandClaude b8b357b552 DOC-8: update test_mqtt digest pins for the new manufacturer string
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 40m23s
Both digests hash the discovery payloads, which carry the manufacturer.
Values regenerated; nothing else changes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 02:16:34 +02:00
glenn schrooyenandClaude 4584502adb DOC-8: HA manufacturer says EMS port; rebuild field guide PDF
checks / unit (pull_request) Failing after 41m1s
infra-pin / fresh (pull_request) Successful in 1s
Device id (identifiers) is unchanged; only the display text moves.
test_mqtt baseline updated for that one string.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:53:33 +02:00
glenn schrooyenandClaude fd08090360 DOC-8: say EMS port, not meter bus
checks / unit (pull_request) Successful in 40m22s
infra-pin / fresh (pull_request) Successful in 1s
The add-on is wired to the inverter's RS485 port labelled EMS. Reword
README, DOCS, config description, field guide source and HTML. The HA
manufacturer string is left unchanged: test_mqtt pins it byte for byte.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-17 01:09:42 +02:00
admin c67d0cdb67 Merge pull request 'Release 0.18.2' (#199) from release-0.18.2 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m25s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-16 22:31:09 +02:00
glenn schrooyenandClaude Opus 5.5 ea77c63552 CHANGELOG 0.18.2: say how a no-unit forecast shows; who needs a look
checks / unit (pull_request) Successful in 40m19s
infra-pin / fresh (pull_request) Successful in 1s
Review of PR #199. "Warns once" left out the Home row and the Settings
note, which stay while the fault lasts. "No option change" now points to
the one case that needs a look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:48:05 +02:00
glenn schrooyenandClaude 75f4359aac Release 0.18.2
checks / unit (pull_request) Successful in 40m23s
infra-pin / fresh (pull_request) Successful in 2s
Version bump to 0.18.2 and a CHANGELOG section for SAFETY-118 (#198).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 20:54:29 +02:00
admin 09a54859f2 Merge pull request 'SAFETY-118: read the peak forecast in its own unit' (#198) from SAFETY-118 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-16 19:53:54 +02:00
glenn schrooyenandClaude 7d52dc1c0f SAFETY-118: a missing peak forecast reading keeps the unit-fault state
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 40m19s
A missing reading (unavailable, unknown, failed read, non-numeric) no longer
clears the fault: no false 'used again' INFO, no repeated WARNING, the status
row stays. Adds pv.has_reading(). DECISIONS wording fixed; tests added.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 18:19:44 +02:00
glenn schrooyenandClaude f0b4e68e49 SAFETY-118: read the peak forecast in its own unit
checks / unit (pull_request) Successful in 40m20s
infra-pin / fresh (pull_request) Successful in 1s
peak_forecast_entity is read through pv.watts() by its unit_of_measurement;
the abs(raw) < 50 size rule is gone. No unit or a non-power unit means the
forecast is not used, with one warning per cause. The validator no longer
claims a kW sensor is taken as watts for this entity.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 16:00:34 +02:00
admin 780c1f67bb Merge pull request 'Release 0.18.1' (#196) from release-0.18.1 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 40m21s
infra-pin / fresh (pull_request) Successful in 1s
Release 0.18.1
2026-10-16 13:46:24 +02:00
glenn schrooyenandClaude Opus 5.5 0ee3c3468b Release 0.18.1 review: CHANGELOG text fixes
checks / unit (pull_request) Successful in 40m18s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-118 line: the add-on already turns a kW forecast into W
(main.py read_inputs, abs(raw) < 50), so only the settings-page warning is
wrong. Rounding note and rows: the new settings page; UX-44 also changed
one DOCS line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:03:37 +02:00
glenn schrooyenandClaude dec8e7b69f Release 0.18.1
checks / unit (pull_request) Successful in 40m39s
infra-pin / fresh (pull_request) Successful in 2s
Version 0.18.0 -> 0.18.1 (patch, no option changes) and a closed 0.18.1 CHANGELOG section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 12:08:58 +02:00
admin 8e02a0928b Merge pull request 'UX-44: contract label says every 15 minutes, not hourly' (#195) from UX-44 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-16 06:59:19 +02:00
glenn schrooyenandClaude 1bd2ba1397 UX-44: contract label says every 15 minutes, not hourly
checks / unit (pull_request) Successful in 40m37s
infra-pin / fresh (pull_request) Successful in 2s
The settings page contract choice read "Dynamic, hourly" / "Dynamisch,
per uur". Belgian day-ahead prices are per 15 minutes since 2025-10-01.
Now "Dynamic, every 15 minutes" / "Dynamisch, per kwartier". One DOCS
line that called dynamic pricing "hourly pricing" now says dynamic.
test_ui_text.py part 7 fails if either label says hourly again.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-16 05:26:32 +02:00
admin b703aaa6d5 UX-43: tariff prices take five decimals (#194)
infra-pin / fresh (push) Successful in 2s
2026-10-16 04:30:09 +02:00
UX-43 review 92a3d3400a UX-43 review: plain wording for the DOCS line on five-decimal prices
checks / unit (pull_request) Successful in 40m23s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:47:28 +02:00
glenn schrooyenandClaude 8c954acb36 UX-43: keep each field description to one sentence (STYLE)
checks / unit (pull_request) Successful in 40m31s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-16 02:29:31 +02:00
glenn schrooyenandClaude 8f72ab00d7 UX-43: tariff price fields take five decimals
ui.fixed_price, ui.day_price, ui.night_price and ui.periods step 0.00001
EUR/kWh (the period editor's price input took a hard-coded 0.01). A short
c-EUR/kWh hint is added to the four field descriptions, EN and NL.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-16 01:40:40 +02:00
26 changed files with 752 additions and 69 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: 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.
⚠️ **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.
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 meter bus — are
`ESU EMU ESA BPS BPU EMJ IJL` — platform 105, the AA55-era generation — 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 emulating the inverter's smart meter over RS485,
and runs the monthly battery maintenance cycle the vendor box was doing.
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.
```
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; outdated)
docs/GoodWe-RS485-Field-Guide.pdf the guide techs carry (10pp, A4; rebuilt 2026-10-17, content still v0.1.0 and 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 meter bus | **supported** |
| `ESU EMU ESA BPS BPU EMJ IJL` | 105 — ES/EM/BP, AA55-era | **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 meter
A "-20" or "G2" suffix means a different platform with a different
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 / RS485 meter-bus generation)
- GoodWe **ES / BP family** inverters (AA55 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 meter bus</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</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 meter
A “-20” or “G2” suffix in the model name means a different platform with a different
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 meter bus
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
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><b>Photograph</b> the EMS-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 meter port.</li>
<li>Disconnect the vendor controller from the inverter's EMS 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 meter port, plus its own supply.</li>
<li>Wire <b>A→A, B→B</b> to the EMS 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 meter bus</td>
<td><b>Platform 105</b> — ES / EM / BP, AA55-era</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 meter
A “-20” or “G2” suffix in the model name means a different platform with a different
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 meter bus
<div class="warn"><b>Qualified persons only.</b> Mains AC and battery DC are present. The RS485 EMS port
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><b>Photograph</b> the EMS-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 meter port.</li>
<li>Disconnect the vendor controller from the inverter's EMS 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 meter port, plus its own supply.</li>
<li>Wire <b>A→A, B→B</b> to the EMS 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>
+125
View File
@@ -1,5 +1,130 @@
# Changelog
## 0.18.2
**A patch bump: one fix to how the 15-minute demand forecast sensor is read, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.18.1 also
start 0.18.2. Only a forecast sensor with no power unit needs a look (below).
The change below applies with one inverter or two.
**A forecast sensor with no power unit.** If your 15-minute demand forecast
sensor has no unit, or a unit that is not power (for example kWh), the add-on
now ignores that forecast. The log warns once, and the Home and Settings pages
say so while it lasts. Pick a sensor that reads in W or kW.
Contents of this release, in merge order since 0.18.1 (`780c1f6`):
| | |
|---|---|
| `09a5485` | The 15-minute demand forecast sensor is now read in its own unit, W or kW, and the old rule that took any value below 50 as kW is gone. With no unit, or a unit that is not power, the forecast is not used, and the log and the pages say so; the settings page no longer says a kW sensor is taken as watts ([[SAFETY-118]], #198) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.18.1
**A patch bump: two small fixes to the settings page, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.18.0 also
start 0.18.1. The changes below apply with one inverter or two.
**Price rounding on 0.18.0.** On 0.18.0, the new settings page saved the fixed,
day and night prices rounded to 2 decimals (0.34512 became 0.35); prices in time
windows kept their decimals. If you typed more than 2 decimals in those
fields on 0.18.0, enter them again.
Contents of this release, in merge order since 0.18.0 (`a0e091c`):
| | |
|---|---|
| `b703aaa` | The four tariff price fields on the new settings page (fixed, day, night and time windows) take 5 decimals, for example 0.34512 €/kWh, and are saved as typed. A short hint says to divide the c€/kWh on your bill by 100 ([[UX-43]], #194) |
| `8e02a09` | The dynamic contract choice on the new settings page now says "Dynamic, every 15 minutes" ("Dynamisch, per kwartier") instead of "hourly". The choice works as before, and one line in the docs no longer calls dynamic prices hourly ([[UX-44]], #195) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- If the 15-minute demand forecast sensor reads in kW, the settings page warns that its number is taken as watts and asks for a sensor in W. The add-on does turn kW into W, so a kW sensor works ([[SAFETY-118]]).
- The price chart on the Home page shows one bar per hour, the average of four 15-minute prices, while the add-on decides on the 15-minute prices ([[UX-45]]).
### If you are crossing from a version older than 0.10.0
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
bare options (`jq '.data.options'`) first.
## 0.18.0
**A minor bump: new behaviour, three new options and one new value for an
@@ -0,0 +1,18 @@
# SAFETY-118 - the peak forecast is read in its own unit
**Status: for review.** Tests: `test_safety118.py`.
`peak_forecast_entity` was read as a bare number, and anything under 50 was
multiplied by 1000 ("accept kW or W"). A W sensor reading 17 to 45 W, which a
real site did 430 times in one morning, would be taken as 17 to 45 kW. The
validator also called a kW sensor wrong, which was false for this entity.
Decision: the size rule is gone. The entity is read through `pv.watts()`, the
same unit table and reader as the solar entities, so W, kW, MW and the rest
convert by the sensor's own `unit_of_measurement`. No unit, or a unit that is
not power (kWh), means the forecast is not used, exactly as if the option were
empty: the loop cap and every non-forecast peak step still apply. One WARNING
per cause, and a status row and a settings note say the same.
No size rule can be right in both units: 30 W and 30 kW are both ordinary.
The read is still one `GET /api/states/<id>` per cycle, as `hass.number()` made.
+12 -10
View File
@@ -1,8 +1,9 @@
# GoodWe RS485 Controller
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.
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.
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.
@@ -33,8 +34,8 @@ v0.1.0 and outdated; where it disagrees with this page, this page wins.
You need:
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
- The vendor's meter-emulating controller **disconnected** from the bus
- A GoodWe **ES / BP family** inverter (AA55 generation)
- The vendor's controller **disconnected** from the inverter's EMS port
- 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
@@ -210,7 +211,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit. |
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
@@ -1697,7 +1698,7 @@ DECISIONS/MAINT-13-full-pack-is-complete.md.
| option | meaning |
|---|---|
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Read in its own unit (W, kW or any power unit). No unit, or a unit that is not power, means it is not used, as if empty (SAFETY-118). Empty = no cap |
| `peak_cap_w` | The site's capacity-tariff target |
| `peak_margin_w` (50) | Watts the peak guard aims under `peak_cap_w` (SAFETY-79). Reports and the bill use `peak_cap_w` itself |
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
@@ -3113,15 +3114,16 @@ second set of 23- and 25-hour-day bugs.
### Static day/night and fully-fixed tariffs (`static_profile`)
Most Belgian households are **not** on a dynamic contract, and an EMS that only
works under hourly pricing is an EMS for a minority. `static_profile` is how a
works under dynamic pricing is an EMS for a minority. `static_profile` is how a
two-period day/night tariff or a single fixed price becomes a first-class
configuration rather than "turn the price feature off and hope".
On the add-on page, choose **Other time windows** under Settings, Tariff and
prices, or in Setup step 4. Add one row per time window, with its start, end,
days and price. The page names any overlap, gap or empty price before you can
save. You only need the JSON below for days the page cannot choose, such as
Monday and Wednesday only.
save. Prices take five decimals: divide the c€/kWh on your bill by 100, so
34.512 c€/kWh is 0.34512 €/kWh. You only need the JSON below for days the page
cannot choose, such as Monday and Wednesday only.
It is one option, holding a JSON object, on one line:
+33 -4
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 meter bus. Once a month it runs a full low->full battery cycle so the BMS
RS485 EMS port. 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
@@ -1202,6 +1202,9 @@ class Controller:
# `grid` is P1, one meter, one connection, and belongs here.
self.grid = None
self.peak_fc = None
# SAFETY-118: the unit fault last logged for the peak forecast (None =
# no fault), so the warning comes once and again only if it changes.
self._peak_fc_fault = None
self.cheap = False
self._price_gated = False # MAINT-13: is a price gate set up
# PRICE-01. Which step of the price chain the prices come from (logged
@@ -3358,6 +3361,22 @@ class Controller:
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
list(zip(roster, states)), pv_read.inverted(self.o))
def _peak_fc_unit_log(self, fault) -> None:
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
in watts, and again only if the unit changes; the unit is on DEBUG."""
if fault == self._peak_fc_fault:
return
self._peak_fc_fault = fault
if fault is None:
_LOG.info("The 15-minute demand forecast is used again.")
elif fault == "":
_LOG.warning("The 15-minute demand forecast has no unit, so it "
"is not used.")
else:
_LOG.warning("The 15-minute demand forecast is not in a power "
"unit, so it is not used.")
_LOG.debug("The 15-minute demand forecast reads in %r.", fault)
async def _read_forecast(self) -> None:
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
failed or stale read is no forecast, never a zero."""
@@ -3448,11 +3467,17 @@ class Controller:
_reads.append(self._read_forecast())
await asyncio.gather(*_reads)
if o.get("peak_forecast_entity"):
raw = await self.hass.number(o["peak_forecast_entity"])
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
self.peak_fc = None if raw is None else (raw * 1000 if abs(raw) < 50 else raw)
# SAFETY-118: read in its own unit, like the PV path. One state
# read, as `hass.number()` made. No unit or a unit that is not
# power: not used, as if the option were empty.
st = await self.hass.state(o["peak_forecast_entity"])
self.peak_fc, _why = pv_read.watts(st)
# A missing reading leaves the unit-fault state as it was.
if pv_read.has_reading(st):
self._peak_fc_unit_log(pv_read.unit_fault(st))
else:
self.peak_fc = None
self._peak_fc_fault = None
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
if now_e and avg_e:
@@ -7240,6 +7265,10 @@ class Controller:
# is both is a check nobody reads.
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_no_peak_forecast()})
elif getattr(self, "_peak_fc_fault", None) is not None:
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_peak_forecast_unit(
self._peak_fc_fault)})
# OPT-17: a forecast summed from several entities names, by position,
# each member with no usable reading. A caution: control goes on.
gaps = getattr(self, "solar_fc_gaps", ())
+21
View File
@@ -788,6 +788,25 @@ def row_no_peak_forecast() -> str:
"the peak limit")
def peak_forecast_unit(unit, lang="en") -> str:
"""SAFETY-118. The 15-minute demand forecast has a number but no power
unit, so it is not used. `unit` is "" for none. One sentence, for the log
line, the status row and the settings page. Log lines stay English."""
if lang == "nl":
return ("De kwartierprognose heeft geen eenheid, dus ze wordt niet "
"gebruikt." if not unit else
"De kwartierprognose heeft geen vermogenseenheid, dus ze "
"wordt niet gebruikt.")
return ("The 15-minute demand forecast has no unit, so it is not used."
if not unit else
"The 15-minute demand forecast is not in a power unit, so it is "
"not used.")
def row_peak_forecast_unit(unit) -> str:
return peak_forecast_unit(unit)
def row_peak_guard_waiting() -> str:
return "Peak guard: no decision yet, control has not run"
@@ -1229,6 +1248,8 @@ def status_samples() -> list:
("row_no_estop", AMBER, row_no_estop()),
("row_estop_fitted", GREEN, row_estop_fitted()),
("row_no_peak_forecast", AMBER, row_no_peak_forecast()),
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("")),
("row_peak_forecast_unit", AMBER, row_peak_forecast_unit("kWh")),
("row_peak_guard_waiting", AMBER, row_peak_guard_waiting()),
("row_pv", GREEN, row_pv(3200, 2)),
("row_pv", GREEN, row_pv(1800, 1, True)),
+1 -1
View File
@@ -29,7 +29,7 @@ _LOG = logging.getLogger("goodwe.mqtt")
DEVICE = {
"identifiers": ["goodwe_rs485_controller"],
"name": "GoodWe",
"manufacturer": "GoodWe (via RS485 meter emulation)",
"manufacturer": "GoodWe (via RS485 EMS port)",
"model": "ES/BP series",
}
+31 -6
View File
@@ -93,12 +93,12 @@ def watts(state: dict | None) -> tuple:
unknown" are different claims and the page has to be able to make the first.
⚠️ THE UNIT IS READ OFF THE ENTITY AND NOT ASSUMED, AND THERE IS NO
MAGNITUDE HEURISTIC. `read_inputs()` guesses kW-or-W for
`peak_forecast_entity` with `abs(raw) < 50`, which is safe there because
nobody's quarter-hour demand forecast is 50 W. It is NOT safe here: 30 W of
real production at dusk is an ordinary reading, and that heuristic would
publish it as 30 kW - a fabricated number, in the channel whose entire
purpose is to be believed a week later. A sensor whose unit this cannot read
MAGNITUDE HEURISTIC. 30 W of real production at dusk is an ordinary reading,
and a "small number means kW" rule would publish it as 30 kW - a fabricated
number, in the channel whose entire purpose is to be believed a week later.
SAFETY-118 removed the last such rule, on `peak_forecast_entity`, for the
same reason (DECISIONS/SAFETY-118-peak-forecast-unit.md): the peak forecast
now reads through this function too. A sensor whose unit this cannot read
is REFUSED rather than guessed at.
"""
if state is None:
@@ -126,6 +126,31 @@ def watts(state: dict | None) -> tuple:
return value * WATTS_PER[unit], unit
def has_reading(state: dict | None) -> bool:
"""SAFETY-118. True when `state` holds a number, so its unit can be judged.
A missing, unavailable, unknown or non-numeric state is a missing reading."""
if state is None:
return False
raw = str(state.get("state", "")).strip()
if raw.lower() in BAD_STATES:
return False
try:
float(raw)
except ValueError:
return False
return True
def unit_fault(state: dict | None):
"""SAFETY-118. `None` unless `state` holds a number whose unit cannot be
read in watts, then that unit (`""` for none). `None` also means a missing
reading: use `has_reading()` to tell it from a good unit."""
if not has_reading(state):
return None
unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip()
return None if unit in WATTS_PER else unit
# --- TEL-12: the ROSTER, and the SITE figure over it --------------------------
def entities(opts: dict) -> tuple:
+1 -1
View File
@@ -311,7 +311,7 @@
const days = h("select", { class: "sel", "aria-label": say(P.days) });
for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label));
days.addEventListener("change", () => set("days", days.value));
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01",
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: String(f.step || 0.01),
value: row.price == null ? "" : row.price, "aria-label": say(P.price) });
price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value));
const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids);
+5 -5
View File
@@ -23,13 +23,13 @@
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."},
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill."}
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together: divide c€/kWh from the bill by 100, so 34.512 c€/kWh is 0.34512."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."},
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill: divide c€/kWh by 100, so 34.512 c€/kWh is 0.34512."}
},
"choices": {
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, every 15 minutes", "custom": "Other time windows"},
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
+5 -5
View File
@@ -23,13 +23,13 @@
"contract": {"name": "Uw contract", "description": "Hoe uw leverancier elke kWh prijst die u van het net afneemt."},
"region": {"name": "Regio", "description": "Vult nettarieven, heffingen en btw in voor het getoonde jaar, nog niet voor Wallonië of Brussel, dus kijk uw factuur na."},
"fallback": {"name": "Prijs als de feed wegvalt", "description": "Gebruikt als de prijsbron niet antwoordt."},
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen."},
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur."},
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur."},
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur."}
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen: deel c€/kWh van de factuur door 100, dus 34,512 c€/kWh is 0,34512."},
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."},
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."},
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur: deel c€/kWh door 100, dus 34,512 c€/kWh is 0,34512."}
},
"choices": {
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per uur", "custom": "Andere tijdvakken"},
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per kwartier", "custom": "Andere tijdvakken"},
"fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"},
"region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"},
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
+5 -5
View File
@@ -164,13 +164,13 @@ GROUPS = (
wizard=4),
Field("price_export_formula", TEXT),
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "fixed"),), wizard=4),
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
Field("ui.day_price", NUMBER, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
Field("ui.night_price", NUMBER, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01,
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.00001,
show=(("ui.fallback", "==", "custom"),), wizard=4),
)),
Part("selling", (
@@ -194,7 +194,7 @@ GROUPS = (
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
simple=True, wizard=5, note="month_peak"),
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
Field("peak_forecast_entity", ENTITY, kind="power", wizard=5),
Field("peak_soc_floor", LOCKED, "%"),
)),
Part("start", (
+7 -2
View File
@@ -1741,11 +1741,12 @@ def _card_note(option: str, spec) -> str:
# UX-13: entity options whose number the add-on takes as watts, with no unit
# conversion. pv.py converts each solar entity's own unit, so those are not here.
# conversion. pv.py converts each solar entity's own unit, so those are not here,
# and SAFETY-118 moved the peak forecast there too.
WATT_ENTITY_KEYS = (
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
"p1_phase_net_entities", "p1_phase_import_entities",
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
"p1_phase_export_entities", "batt_entity")
# The meter entities each `meter_source` reads through Home Assistant.
@@ -1829,6 +1830,10 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
_advise(notes, "warn", (key,),
messages.peak_forecast_unit(unit) + " Pick a sensor in "
"W or kW.")
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
_advise(notes, "warn", (key,),
f"{entity} does not read in kWh, so pick a solar "
+3 -3
View File
@@ -1,9 +1,9 @@
name: GoodWe RS485 Controller
version: "0.18.0"
version: "0.18.2"
slug: goodwe_controller
description: >-
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
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
maintenance cycle.
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
arch:
+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 meter emulation)", '
'"manufacturer": "GoodWe (via RS485 EMS port)", '
'"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 == "eb5022b0455158d7")
_pin == "b4274714d8557c37")
# ---------------------------------------------------------------------------
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 = "501a7d93782a260b"
ED739D4_OFF = "057be1d8ad7bb582" # DOC-8: ed739d4 digest with only the manufacturer string changed
_h, _n, _subs_off = hashlib.sha256(), 0, []
for _r in ROSTERS:
for _om in [(), ("p1_age",), ("p1_age", "grid")]:
+375
View File
@@ -0,0 +1,375 @@
"""SAFETY-118: the peak forecast is read in its own unit. `python3 -B test_safety118.py`
`peak_forecast_entity` used to be read as a bare number and anything under 50
was multiplied by 1000, so a W sensor reading 17 W was taken as 17 kW. It is
now read through `pv.watts()` by its own `unit_of_measurement`. No unit, or a
unit that is not power: the forecast is not used, as if the option were empty.
1. The reader through the real `read_inputs()`: W, kW, MW, no unit, kWh, bad state.
2. The warning: once, again only when the cause changes, never every cycle.
3. One state read per cycle, as before.
4. The maintenance charge headroom and the charge-only clamp act on the
converted value (real `cycle()`).
5. The validator, the status row and the strings.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import logging
from pathlib import Path
from app import main as _main
from app import messages, pv, validate
from app.main import Controller
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def st(value, unit=None):
attrs = {} if unit is None else {"unit_of_measurement": unit}
return {"state": str(value), "attributes": attrs}
class _Store:
def __init__(self, data=None):
self.data = dict(data or {})
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
def set_time(self, *a):
pass
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class _Hass:
def __init__(self, fc=None, prices=None):
self.fc = fc
self.prices = prices or {}
self.reads = []
self.writes = []
async def limits(self, entity):
return (-5000.0, 5000.0, 1.0)
async def state(self, entity):
self.reads.append(entity)
if entity == "sensor.fc":
return self.fc
v = {"sensor.grid": 0.0, "sensor.soc": 50.0, "sensor.batt": 0.0,
**self.prices}.get(entity)
return None if v is None else st(v, "W")
async def number(self, entity, invert=False):
obj = await self.state(entity)
try:
return float(obj["state"]) if obj else None
except ValueError:
return None
async def set_number(self, entity, value):
self.writes.append(float(value))
return True
async def call(self, domain, service, data):
return True
OPTS = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15,
"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "deadband_w": 15,
"step_w": 10, "target_grid_w": -10.0, "stale_input_s": 15,
"peak_cap_w": 5000, "peak_margin_w": 0,
"peak_forecast_entity": "sensor.fc"}
class _Rec(logging.Handler):
def __init__(self):
super().__init__(logging.DEBUG)
self.rows = []
def emit(self, r):
self.rows.append((r.levelno, r.getMessage()))
def make(fc, store=None, prices=None, **over):
hass = _Hass(fc, prices)
c = Controller(dict(OPTS, **over), hass, _Store(store), _Pub(),
stop=asyncio.Event())
c.auto, c.dev_min, c.dev_max = True, -5000.0, 5000.0
return c, hass
def reads(c):
asyncio.run(c.read_inputs())
return c.peak_fc
def peak_of(state):
c, _h = make(state)
return reads(c)
print("1. the reader")
CASES = [("W 17", st(17, "W"), 17.0), ("W 820", st(820, "W"), 820.0),
("kW 0.82", st(0.82, "kW"), 820.0), ("kW 0.017", st(0.017, "kW"), 17.0),
("MW 0.0008", st(0.0008, "MW"), 800.0), ("W 49", st(49, "W"), 49.0),
("W 50", st(50, "W"), 50.0), ("kW 30", st(30, "kW"), 30000.0),
("W 0", st(0, "W"), 0.0)]
for label, s, want in CASES:
got = peak_of(s)
check(f"{label} -> {want} W (got {got})",
got is not None and abs(got - want) < 1e-6)
for label, s in [("no unit", st(820)), ("empty unit", st(820, "")),
("kWh", st(820, "kWh")), ("percent", st(20, "%")),
("unavailable", st("unavailable", "W")),
("unknown", st("unknown", "W")), ("missing entity", None),
("not a number", st("sunny", "W"))]:
check(f"{label} -> None (got {peak_of(s)})", peak_of(s) is None)
c, _h = make(st(820, "W"), peak_forecast_entity="")
check("option empty -> None and the entity is never read",
reads(c) is None and "sensor.fc" not in _h.reads)
print("2. the warning")
rec = _Rec()
lg = logging.getLogger("goodwe")
lg.addHandler(rec)
lg.setLevel(logging.DEBUG)
def warns():
return [m for lv, m in rec.rows if lv >= logging.WARNING]
c, h = make(st(820, "kWh"))
for _ in range(5):
reads(c)
check(f"kWh: five cycles, one WARNING ({warns()})", len(warns()) == 1)
check("...short, plain, and says it is not used",
warns()[0] == "The 15-minute demand forecast is not in a power unit, so "
"it is not used." and len(warns()[0].split()) <= 25)
h.fc = st(820, "MWh")
reads(c)
check("the same cause with another unit warns again (the cause changed)",
len(warns()) == 2)
h.fc = st(820)
reads(c)
check("no unit is a different cause and warns", len(warns()) == 3
and "has no unit" in warns()[2])
reads(c)
check("...and only once", len(warns()) == 3)
USED = (logging.INFO, "The 15-minute demand forecast is used again.")
def used():
return [r for r in rec.rows if r == USED]
h.fc = st("unavailable")
reads(c)
check("unavailable adds no warning and no 'used again'",
len(warns()) == 3 and not used())
h.fc = st(820, "W")
reads(c)
check("a good unit is read again: one INFO, no new warning",
c.peak_fc == 820.0 and len(warns()) == 3 and len(used()) == 1
and rec.rows[-1] == USED)
n = len(warns())
for s in (st(820, "W"), st(0.8, "kW")):
h.fc = s
reads(c)
check("W and kW never warn", len(warns()) == n)
rows = [r for r in c.checks() if r.get("id") == "peak_forecast"]
check("a good forecast has no peak_forecast row", not rows)
h.fc = st(820, "kWh")
reads(c)
rows = [r for r in c.checks() if r.get("id") == "peak_forecast"]
check("the status page carries a caution row for the unit fault",
len(rows) == 1 and rows[0]["warn"] and not rows[0]["ok"]
and rows[0]["text"] == messages.row_peak_forecast_unit("kWh"))
lg.removeHandler(rec)
print("3. one state read per cycle")
c, h = make(st(0.82, "kW"))
reads(c)
check(f"one read of the forecast entity per read_inputs() ({h.reads})",
h.reads.count("sensor.fc") == 1)
print("4. maintenance headroom and the charge-only clamp use watts")
seen = []
_real = _main.maintenance_charge_floor
def spy(charge_w, fc, cap):
out = _real(charge_w, fc, cap)
seen.append((charge_w, fc, cap, out))
return out
_main.maintenance_charge_floor = spy
try:
def cycle(fc, **kw):
c, h = make(fc, store={"phase": "charge"},
prices={"sensor.pnow": 1.0, "sensor.pavg": 2.0},
price_now_entity="sensor.pnow",
price_avg_entity="sensor.pavg", **kw)
seen.clear()
asyncio.run(c.read_inputs())
asyncio.run(c.cycle())
return c, h
# cap 2500 W, charge rate 2000 W: 820 W of forecast leaves 1680 W, 17 W
# leaves the whole 2000 W (the size rule read 17 as 17 kW and left 0).
c, h = cycle(st(0.82, "kW"), peak_cap_w=2500)
check(f"cheap-window headroom is cap - 820 W = 1680 W ({seen})",
len(seen) == 1 and seen[0][1] == 820.0 and seen[0][0] == 2000.0
and abs(seen[0][3] - 1680.0) < 1e-6)
c, h = cycle(st(17, "W"), peak_cap_w=2500)
check(f"a 17 W forecast leaves the full 2000 W charge rate ({seen})",
len(seen) == 1 and seen[0][1] == 17.0 and abs(seen[0][3] - 2000.0) < 1e-6)
# charge-only clamp: not cheap, shaping suspended only if the forecast is
# over the aim. 6 kW is over 5000 W; 17 W is not (the old rule read it
# as 17 kW and suspended).
def clamp(fc):
c, h = make(fc, store={"phase": "charge"})
asyncio.run(c.read_inputs())
asyncio.run(c.cycle())
return c._maint_shape_suspended
check("kW 6.0 (6000 W, over the 5000 W aim) suspends the charge-only clamp",
clamp(st(6.0, "kW")) is True)
check("W 17 leaves the charge-only clamp in place",
clamp(st(17, "W")) is False)
check("kW 0.017 leaves the charge-only clamp in place",
clamp(st(0.017, "kW")) is False)
check("no unit: forecast unused, clamp in place, as with the option empty",
clamp(st(6000)) is False)
finally:
_main.maintenance_charge_floor = _real
print("5. validator and strings")
def notes(unit, key="peak_forecast_entity"):
ent = {"sensor.fc": {"entity_id": "sensor.fc", "unit": unit,
"state": "1"}}
return [n for n in validate.config_notes(
{key: "sensor.fc", "meter_source": "off"}, entities=ent)
if key in n.keys]
check("validator: W no note", not notes("W"))
check("validator: kW no note", not notes("kW"))
check("validator: MW no note", not notes("MW"))
nu, kw = notes(""), notes("kWh")
check(f"validator: no unit warns ({nu})", len(nu) == 1 and nu[0].tier == "warn"
and "has no unit" in nu[0].text)
check(f"validator: kWh warns ({kw})", len(kw) == 1 and kw[0].tier == "warn"
and "not in a power unit" in kw[0].text)
check("validator: the false 'taken as watts' claim is gone for this entity",
not any("taken as watts" in n.text for n in nu + kw + notes("kW")))
check("validator: another watt key keeps its claim",
len(notes("kW", "batt_entity")) == 1
and "taken as watts" in notes("kW", "batt_entity")[0].text)
check("WATT_ENTITY_KEYS no longer holds the peak forecast",
"peak_forecast_entity" not in validate.WATT_ENTITY_KEYS
and "batt_entity" in validate.WATT_ENTITY_KEYS)
print(" EN no unit :", messages.peak_forecast_unit(""))
print(" EN bad unit:", messages.peak_forecast_unit("kWh"))
print(" NL no unit :", messages.peak_forecast_unit("", "nl"))
print(" NL bad unit:", messages.peak_forecast_unit("kWh", "nl"))
check("the strings differ by cause and by language",
len({messages.peak_forecast_unit(u, l) for u in ("", "kWh")
for l in ("en", "nl")}) == 4)
print("6. a missing reading leaves the unit fault as it was (F1)")
lg.addHandler(rec)
def prow(c):
return [r for r in c.checks() if r.get("id") == "peak_forecast"]
def flap(first, gap, last):
"""first, gap, first again (or last): the log rows and the status row
after each cycle."""
rec.rows.clear()
c, h = make(first)
out = []
for s in (first, gap, last):
h.fc = s
reads(c)
out.append((len(warns()), len(used()), len(prow(c)), c.peak_fc))
return out
for label, first, gap in [
("kWh -> unavailable -> kWh", st(820, "kWh"), st("unavailable", "kWh")),
("kWh -> unknown -> kWh", st(820, "kWh"), st("unknown", "kWh")),
("kWh -> failed read -> kWh", st(820, "kWh"), None),
("kWh -> non-numeric -> kWh", st(820, "kWh"), st("sunny", "kWh")),
("no unit -> failed read -> no unit", st(820), None),
("no unit -> unavailable -> no unit", st(820), st("unavailable"))]:
r = flap(first, gap, first)
check(f"{label}: one WARNING, no 'used again', row on all 3 ({r})",
[x[0] for x in r] == [1, 1, 1] and [x[1] for x in r] == [0, 0, 0]
and [x[2] for x in r] == [1, 1, 1]
and [x[3] for x in r] == [None, None, None])
r = flap(st(820, "kWh"), st("unavailable", "kWh"), st(820, "W"))
check(f"kWh -> unavailable -> W: the fault clears only on the W reading ({r})",
[x[1] for x in r] == [0, 0, 1] and [x[2] for x in r] == [1, 1, 0]
and [x[0] for x in r] == [1, 1, 1] and r[2][3] == 820.0)
rec.rows.clear()
c, h = make(st(820, "W"))
for s in (st(820, "W"), st("unavailable", "W"), None, st("sunny", "W")):
h.fc = s
reads(c)
check(f"good unit, reading {s and s['state']!r}: no row, no log",
not prow(c) and not warns() and not used())
rec.rows.clear()
c, h = make(st(820, "kWh"))
reads(c)
for s in (st("unavailable", "kWh"), None):
h.fc = s
reads(c)
check("fault row stays while the reading is missing",
len(prow(c)) == 1 and prow(c)[0]["text"]
== messages.row_peak_forecast_unit("kWh"))
c, h = make(st(820, "W"))
h.fc = st("unavailable", "W")
reads(c)
check("good-unit sensor only unavailable: the fault row stays away",
not prow(c))
lg.removeHandler(rec)
check("has_reading: numbers only",
pv.has_reading(st(0, "kWh")) and pv.has_reading(st("1.5"))
and not pv.has_reading(None) and not pv.has_reading(st("unavailable"))
and not pv.has_reading(st("unknown")) and not pv.has_reading(st("x")))
check("pv.unit_fault: None for a good unit and for a missing reading (has_reading tells them apart)",
pv.unit_fault(st(1, "kW")) is None and pv.unit_fault(None) is None
and pv.unit_fault(st("unavailable")) is None
and pv.unit_fault(st(1)) == "" and pv.unit_fault(st(1, "kWh")) == "kWh")
src = (Path(__file__).parent / "app" / "main.py").read_text(encoding="utf-8")
check("the size rule is gone from main.py", "abs(raw) < 50" not in src)
print()
print("FAILED: " + "; ".join(fails) if fails else "all checks passed")
raise SystemExit(1 if fails else 0)
+16
View File
@@ -286,6 +286,22 @@ check("app/uitext.py falls back to English per key and reads nl-BE as nl",
and uitext._merge({"a": {"b": 1, "c": 2}}, {"a": {"b": 3}})
== {"a": {"b": 3, "c": 2}})
# ============================================================================ #
print("\n7. the dynamic contract choice says 15 minutes, never hourly (UX-44)")
# Belgian day-ahead prices are per 15 minutes since 2025-10-01.
HOURLY = re.compile(r"hourly|per hour|\bhour\b|per uur|\buur\b|uurprijs",
re.I)
_dyn = {"en": EN["choices"]["contract"]["dynamic"],
"nl": NL["choices"]["contract"]["dynamic"]}
check(f"the label reads 'Dynamic, every 15 minutes' / 'Dynamisch, per "
f"kwartier' ({_dyn})",
_dyn == {"en": "Dynamic, every 15 minutes",
"nl": "Dynamisch, per kwartier"})
check("neither language calls the dynamic contract hourly",
not any(HOURLY.search(t) for t in _dyn.values()))
check("mutation: the old labels are caught",
all(HOURLY.search(t) for t in ("Dynamic, hourly", "Dynamisch, per uur")))
print()
if fails:
print(f"{len(fails)} FAILED")
+60
View File
@@ -327,6 +327,66 @@ check("days the editor has no choice for stay in the Configuration tab, and "
["ui.periods"] is None and edit(dict(SITE, static_profile=_raw), gain=0.5)
["static_profile"] == _raw)
print("\n8. UX-43: tariff prices take five decimals")
from app import uilayout as _L # noqa: E402
_PX = 0.34512
_TARIFF = ("ui.fixed_price", "ui.day_price", "ui.night_price", "ui.periods")
_steps = {k: _L.field(k).step for k in _TARIFF}
check("the four tariff price fields step 0.00001 EUR/kWh",
sorted(_steps) == sorted(_TARIFF)
and all(v == 0.00001 for v in _steps.values()))
_f5 = edit(DEFAULTS, ui__contract="fixed", ui__fixed_price=_PX)
check("fixed price 0.34512 is saved exactly and read back exactly",
'"eur_per_kwh": 0.34512}' in _f5["static_profile"]
and U.options_to_ui(_f5)["ui.fixed_price"] == _PX
and trip(_f5) == _f5)
_d5 = edit(SITE, ui__contract="daynight", ui__day_price=_PX,
ui__night_price=0.26789)
check("day price 0.34512 and night price 0.26789 are saved and read back exactly",
'"eur_per_kwh": 0.34512' in _d5["static_profile"]
and '"eur_per_kwh": 0.26789' in _d5["static_profile"]
and U.options_to_ui(_d5)["ui.day_price"] == _PX
and U.options_to_ui(_d5)["ui.night_price"] == 0.26789
and not validate_startup_config(_d5))
_n5 = edit(SITE, ui__contract="daynight", ui__day_price=0.3, ui__night_price=_PX)
check("night price 0.34512 alone is saved and read back exactly",
U.options_to_ui(_n5)["ui.night_price"] == _PX)
_r3 = rows(("00:00", "07:00", "mon-sun", 0.12345), ("07:00", "22:00", "mon-sun", _PX),
("22:00", "24:00", "mon-sun", 0.05001))
_p3 = periods(_r3)
check("three windows with five-decimal prices save and read back exactly",
[p.eur_per_kwh for p in parse_static_profile(_p3["static_profile"]).periods]
== [0.12345, _PX, 0.05001]
and [r["price"] for r in U.options_to_ui(_p3)["ui.periods"]]
== [0.12345, _PX, 0.05001] and trip(_p3) == _p3
and "e-" not in _p3["static_profile"])
check("a two-decimal price still works", U.options_to_ui(
edit(DEFAULTS, ui__contract="fixed", ui__fixed_price=0.31))
["ui.fixed_price"] == 0.31)
_old = dict(SITE, price_provider="off", static_profile=json.dumps({"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": 0.34},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": 0.26},
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
"eur_per_kwh": 0.26}]}))
check("a stored profile with two-decimal prices is unchanged by load and save",
trip(_old) == _old)
_js = open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
check("the window editor's price input takes its step from the field, not a "
"hard-coded 0.01", 'step: "0.01"' not in _js
and "step: String(f.step || 0.01)" in _js)
for _lang in ("en", "nl"):
with open(os.path.join(HERE, "app", "ui_text", f"{_lang}.json"),
encoding="utf-8") as _fh:
_flds = json.load(_fh)["fields"]
_hint = "34.512" if _lang == "en" else "34,512"
_eq = "0.34512" if _lang == "en" else "0,34512"
check(f"[{_lang}] the c€/kWh hint sits on all four tariff price fields",
all(_hint in _flds[k]["description"] and _eq in _flds[k]["description"]
for k in ("fixed_price", "day_price", "night_price", "periods")))
print()
if fails:
print(f"{len(fails)} FAILED")
@@ -105,6 +105,13 @@ class _Hass:
return self.power(u)
return None
async def state(self, e):
"""SAFETY-118: the peak forecast is read as a state object; every
number this rig serves is in watts."""
v = await self.number(e)
return None if v is None else {
"state": str(v), "attributes": {"unit_of_measurement": "W"}}
async def set_number(self, e, v):
self.cmd[e[-1]] = float(v)
return True
+1 -1
View File
@@ -165,7 +165,7 @@ configuration:
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
peak_forecast_entity:
name: "Limits · 15-minute demand forecast entity (W)"
description: "Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit."
description: "Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit."
peak_soc_floor:
name: "Limits · Peak reserve (%)"
description: "Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged."
+1 -1
View File
@@ -146,7 +146,7 @@ configuration:
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
peak_forecast_entity:
name: "Grenzen · Entiteit kwartierprognose (W)"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier. Laat u dit leeg, dan is er geen piekgrens."
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier, in W of kW. Zonder vermogenseenheid wordt ze niet gebruikt. Laat u dit leeg, dan is er geen piekgrens."
peak_soc_floor:
name: "Grenzen · Piekreserve (%)"
description: "De piekbeperking laat de batterij niet onder dit niveau zakken, gewoon verbruik wel. Afgeleide waarde, laat ze ongewijzigd."