TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive for consumption and negative for production. `pv_invert_entities` lists the ones to flip; the flip is applied per member inside pv.total(), before the sum, so the MQTT `pv` row and the status page carry the corrected figure. Default [] is byte-identical to 0.12.0. Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0): a parallel list validates the 0.12.0 blob unchanged and renders as its own translated repeatable field; dicts on pv_entities refuse 0.12.0's stored strings; a prefix is invisible syntax; a global bool cannot describe a mixed roster. Status page marks flipped entities and cautions per entity below -50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged. Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6e6527224a
commit
7559d21eda
@@ -38,6 +38,8 @@ the trade [[product-not-one-house]] names.
|
||||
|
||||
## 2. The sign convention: **positive is production**, and there is no `pv_invert`
|
||||
|
||||
> Reversed by TEL-14 (flag and negative caution): `DECISIONS/TEL-14-pv-sign.md`.
|
||||
|
||||
### The convention is derived, not assumed
|
||||
|
||||
Two sign rules already ship, and both are recorded in `DOCS.md`'s *Sources*
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# TEL-14 — the PV sign, declared per entity
|
||||
|
||||
Owner report on 0.12.0: `sensor.espressif_vermogen` (ESPHome clamp) and
|
||||
`sensor.kwh_meter_actueel_vermogen` (kWh meter in the PV circuit) read
|
||||
**positive for consumption, negative for production**. At night the page said
|
||||
"✓ PV production: 13 W" — standby draw. By day production would publish negative.
|
||||
|
||||
## 1. This reverses TEL-11 §2, and why
|
||||
|
||||
TEL-11 §2 refused a `pv_invert` because (1) PV is read by nothing in the control
|
||||
path, and (2) a reversed sensor "announces itself as −4 kW at noon" and should be
|
||||
"fixed where it is produced". (1) still holds: the flip is applied inside
|
||||
`pv.total()`, which only feeds the MQTT `pv` row and the status page;
|
||||
`test_pv.py` part 12 re-runs part 6's 40-cycle identity with a flip set, and
|
||||
`tools/tel11_pv_trace.py` on release/1.0 vs this tree gives identical `control`
|
||||
documents for `unset`, `one` and `multi`; the only telemetry change is the new
|
||||
caution in the `negative` (−4200 W) cells. Its `--compare` verdict itself now
|
||||
REFUSES on any honest run, because both trees know `pv_entities` — TEL-12 §3's
|
||||
discriminator has aged out, as TEL-11's did; not fixed here. (2) did
|
||||
not survive contact with a real site: these sensors are not *wrong*, they
|
||||
follow a different convention; at night they read a plausible small positive
|
||||
number, so nothing announces anything; and "fix it where it is produced" means a
|
||||
template sensor, which TEL-12 §2 already names as the place an `unavailable`
|
||||
source goes quietly wrong.
|
||||
|
||||
## 2. The shape: a parallel list, `pv_invert_entities` — measured
|
||||
|
||||
`tools/supervisor_shape_probe.py` §H, Supervisor 2026.08.0, against a 0.12.0
|
||||
blob (`pv_entities` set, no new key):
|
||||
|
||||
```
|
||||
== H. TEL-14: the SHAPE of a per-entity PV sign option ==
|
||||
PASS (a) PREFIX: `-sensor.pv_meter` validates under the shipped `- str` - Supervisor does not look inside the string, ...
|
||||
PASS (b) ⭐ PARALLEL LIST, SHIPPED: the 0.12.0 blob (no `pv_invert_entities` key) validates unchanged and the new key defaults to []
|
||||
PASS ...and one entity marked inverted validates
|
||||
PASS ⭐ ...and the shipped `options:` block with ONLY `pv_invert_entities` removed is REFUSED BY NAME ...
|
||||
Missing option 'pv_invert_entities' in root in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS ...and the Configuration tab renders it as a REPEATABLE string field with its own top-level (translated) label
|
||||
[{"name": "pv_invert_entities", "multiple": true, "required": true, "type": "string"}]
|
||||
PASS (c) LIST OF DICTS ON `pv_entities`: the STRINGS a 0.12.0 install stored are REFUSED ...
|
||||
Invalid dict for option 'pv_entities' in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS (c2) ...as a THIRD key it validates, but renders as a type-schema repeatable section whose per-entry keys reach the screen RAW ...
|
||||
PASS (d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one sign for a mixed roster), not on the schema
|
||||
== all cases passed ==
|
||||
```
|
||||
|
||||
| shape | schema | why it lost / won |
|
||||
|---|---|---|
|
||||
| (a) `-sensor.x` prefix | accepted | invisible: the sign is a syntax only the description explains, in a field an installer fills with the entity picker's text |
|
||||
| (b) parallel list | accepted, 0.12.0 blob unchanged | **ships**: its own labelled, translated field ("PV entities whose sign is reversed"); an installer lists the odd ones out |
|
||||
| (c) dicts on `pv_entities` | **refuses 0.12.0's stored strings** | bricks the upgrade — TEL-12's trap again |
|
||||
| (c2) dicts as a third key | accepted | per-entry labels render raw (`entity`, `invert`) — §F; and a third PV surface |
|
||||
| (d) global bool | accepted | one sign cannot describe inverter + meter in one roster |
|
||||
|
||||
(b)'s known cost is a typo in the second list: `pv.stray_inverts()` turns it into
|
||||
a named caution. `[]` in `options:` is load-bearing (§H, isolated counterfactual).
|
||||
Default `[]` flips nothing, so every 0.12.0 install is byte-identical (part 12).
|
||||
|
||||
## 3. The negative caution: per entity, below −50 W
|
||||
|
||||
The owner's two meters draw ~13 W at night, so a *correctly* signed meter reads
|
||||
about −13 W then. −50 W clears that with room for a few such devices behind one
|
||||
entity; a reversed sensor by day reads hundreds to thousands of watts below
|
||||
zero. It is tested **per entity, not on the total** (a deviation from the brief):
|
||||
three correct meters at −20 W sum to −60 W and must stay quiet, while a reversed
|
||||
−1500 W hidden inside a positive total must not. It is a caution, never `bad`;
|
||||
a wrong threshold costs a spurious or missed caution, never data. The text says
|
||||
ADD (unflipped) or REMOVE (already flipped).
|
||||
|
||||
## 4. History
|
||||
|
||||
OPT-10's `sensor.goodwe_pv_power` recorded since 2026-09-24 on an install with
|
||||
such sensors carries the old sign; nothing rewrites it.
|
||||
|
||||
## 5. CHANGELOG entry for the next PKG cut
|
||||
|
||||
Not written into `CHANGELOG.md`: `test_version_gate.py` 11b/11d refuse an
|
||||
`## Unreleased` heading (measured on this branch: 4 reds). For PKG to paste:
|
||||
|
||||
> **`pv_invert_entities` (TEL-14).** A PV sensor that reads negative while
|
||||
> producing — typically a kWh meter or power clamp in the PV circuit — can now
|
||||
> be listed under *PV entities whose sign is reversed* in the Configuration tab;
|
||||
> its sign is flipped before the sum and the status page marks it. Empty (the
|
||||
> default) changes nothing. ⚠️ PV history recorded before you set it keeps the
|
||||
> old sign.
|
||||
@@ -141,6 +141,7 @@ either direction, and the check is mutation-tested both ways.
|
||||
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
|
||||
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
|
||||
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) |
|
||||
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities-tel-14) |
|
||||
|
||||
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
||||
those serve cached values, and a stale reading here ends the maintenance charge
|
||||
@@ -456,21 +457,15 @@ Only the third is a software defect, and **none of them can be told apart
|
||||
without a record of production.** This entity is that record. The attribution
|
||||
itself is OPT-10's, with a week of history behind it.
|
||||
|
||||
#### The sign, and why there is no `pv_invert`
|
||||
#### The sign, and `pv_invert_entities` (TEL-14)
|
||||
|
||||
**Positive must mean producing.** That is the same convention as the two sign
|
||||
rules the add-on already ships — `meter_entity` is positive when **importing**
|
||||
and `batt_entity` is positive when **discharging**, both of which are "power
|
||||
arriving at the house" — and production arrives there too.
|
||||
|
||||
`meter_invert` and `batt_invert` exist because those two quantities are
|
||||
**bipolar and are read by the control path**: a reversed battery sensor is a
|
||||
perfectly plausible number that the per-inverter sign failsafe then acts on.
|
||||
Production is unipolar and is read by **nothing**, so a reversed sensor here
|
||||
cannot mislead a decision — and it announces itself immediately as −4 kW at
|
||||
noon in the very history this entity creates. A flag would only hide it, so
|
||||
there is not one. If your sensor really is inverted, fix it where it is
|
||||
produced.
|
||||
**Positive must mean producing.** Some sensors — typically a kWh meter or an
|
||||
ESPHome power clamp in the PV circuit — read negative while producing and a few
|
||||
watts positive at night: list each of those in `pv_invert_entities`, spelled as
|
||||
in `pv_entity`/`pv_entities`, and its sign is flipped before the sum; the status
|
||||
page marks it `[sign inverted]` and cautions on any entity reading below −50 W.
|
||||
History recorded before you set it keeps the old sign. See
|
||||
`DECISIONS/TEL-14-pv-sign.md`.
|
||||
|
||||
#### The unit is read off the entity
|
||||
|
||||
|
||||
@@ -1726,7 +1726,7 @@ class Controller:
|
||||
states = await asyncio.gather(
|
||||
*(self.hass.state(entity) for entity in roster))
|
||||
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
||||
list(zip(roster, states)))
|
||||
list(zip(roster, states)), pv_read.inverted(self.o))
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
@@ -5075,6 +5075,13 @@ class Controller:
|
||||
# have to say something the single-entity ones never could.
|
||||
roster = self.pv_roster
|
||||
if roster:
|
||||
# TEL-14: each flipped entity is marked where its watts are printed;
|
||||
# an unflipped one gets "", so 0.12.0's sentences are unchanged.
|
||||
flip = pv_read.inverted(self.o)
|
||||
|
||||
def inv(ent):
|
||||
return " [sign inverted]" if ent in flip else ""
|
||||
|
||||
if len(roster) == 1:
|
||||
if self.pv_w is None:
|
||||
out.append({"ok": False, "warn": True,
|
||||
@@ -5089,7 +5096,7 @@ class Controller:
|
||||
out.append({"ok": True, "warn": False,
|
||||
"text": f"PV production: {self.pv_w:.0f} W "
|
||||
f"from {roster[0]} (reported "
|
||||
f"in {self.pv_why})"})
|
||||
f"in {self.pv_why})" + inv(roster[0])})
|
||||
elif self.pv_w is None:
|
||||
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
|
||||
# THOSE WORDS. `pv.total()`'s note is the argument; this is
|
||||
@@ -5104,7 +5111,7 @@ class Controller:
|
||||
"text": "PV production: NO SITE TOTAL - "
|
||||
+ self.pv_why + ". Reporting: "
|
||||
+ (", ".join(
|
||||
f"{ent} {watt:.0f} W"
|
||||
f"{ent} {watt:.0f} W" + inv(ent)
|
||||
for ent, watt, _y in self.pv_parts
|
||||
if watt is not None) or "nothing")})
|
||||
else:
|
||||
@@ -5117,6 +5124,7 @@ class Controller:
|
||||
f"{len(self.pv_parts)} entities ("
|
||||
+ ", ".join(
|
||||
f"{ent} {watt:.0f} W in {why}"
|
||||
+ inv(ent)
|
||||
for ent, watt, why in self.pv_parts)
|
||||
+ ")"})
|
||||
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
|
||||
@@ -5136,6 +5144,26 @@ class Controller:
|
||||
f"{len(roster)} entities in the "
|
||||
"list are what is summed. Clear "
|
||||
"`pv_entity` to remove this caution"})
|
||||
# TEL-14: a sign that looks reversed, per entity rather than on the
|
||||
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
|
||||
for ent, watt, _y in self.pv_parts:
|
||||
if watt is not None and watt < pv_read.NEGATIVE_W:
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"PV production: {ent} reads "
|
||||
f"{watt:.0f} W - far below zero, so "
|
||||
f"its sign is probably reversed. If "
|
||||
f"it is producing now, "
|
||||
+ ("REMOVE it from "
|
||||
"`pv_invert_entities`"
|
||||
if ent in flip else
|
||||
"ADD it to `pv_invert_entities`")
|
||||
+ " in the Configuration tab"})
|
||||
for ent in pv_read.stray_inverts(self.o):
|
||||
out.append({"ok": False, "warn": True,
|
||||
"text": f"PV production: `pv_invert_entities` "
|
||||
f"names {ent}, which is not a PV entity in "
|
||||
f"use, so it flips nothing - check the "
|
||||
f"spelling against `pv_entities`"})
|
||||
return out
|
||||
|
||||
def status_payload(self) -> dict:
|
||||
|
||||
+32
-17
@@ -50,21 +50,10 @@ SITE figure, and it publishes as an absence. A partial sum is a second way of
|
||||
reporting 0-where-there-was-production, one layer up, and it is refused for the
|
||||
same reason.
|
||||
|
||||
THE SIGN CONVENTION: POSITIVE IS PRODUCTION. Derived from the two conventions
|
||||
this add-on already ships rather than assumed:
|
||||
|
||||
`meter_entity` positive = IMPORTING from the grid (DOCS.md, Sources)
|
||||
`batt_entity` positive = DISCHARGING the pack (DOCS.md, Sources)
|
||||
|
||||
Both are "positive = power arriving at the house's AC bus", and production
|
||||
arrives there too. There is deliberately NO `pv_invert` to go with
|
||||
`meter_invert` and `batt_invert`: those two quantities are BIPOLAR and are READ
|
||||
BY THE CONTROL PATH - a reversed `batt_entity` feeds SAFETY-30's per-unit sign
|
||||
check and SAFETY-31's circulating-power sensor with a plausible number the site
|
||||
then acts on - whereas production is unipolar and is read by nothing. A reversed
|
||||
PV sensor announces itself as -4 kW at noon in the very history this card
|
||||
creates, and flipping it here would only hide it. See
|
||||
`DECISIONS/TEL-11-pv-production.md` §2.
|
||||
THE SIGN CONVENTION: POSITIVE IS PRODUCTION, as for `meter_entity` and
|
||||
`batt_entity` ("power arriving at the house's AC bus"). TEL-14: a kWh meter or
|
||||
power clamp in the PV circuit reads the other way, so `pv_invert_entities`
|
||||
flips the sign per entity. See `DECISIONS/TEL-14-pv-sign.md`.
|
||||
"""
|
||||
|
||||
from .hass import BAD_STATES
|
||||
@@ -193,7 +182,28 @@ def entity_is_ignored(opts: dict) -> bool:
|
||||
if str(x or "").strip()])
|
||||
|
||||
|
||||
def total(readings) -> tuple:
|
||||
def inverted(opts: dict) -> frozenset:
|
||||
"""The entity ids `pv_invert_entities` flips. TEL-14."""
|
||||
return frozenset(e for e in (str(x or "").strip()
|
||||
for x in (opts.get("pv_invert_entities") or []))
|
||||
if e)
|
||||
|
||||
|
||||
def stray_inverts(opts: dict) -> list:
|
||||
"""`pv_invert_entities` entries naming no entity in the roster - a typo
|
||||
that would otherwise leave a sign unflipped with nothing said. TEL-14."""
|
||||
roster = set(entities(opts))
|
||||
return sorted(e for e in inverted(opts) if e not in roster)
|
||||
|
||||
|
||||
# TEL-14: a reading below this is not standby draw. A correctly signed meter in
|
||||
# the PV circuit reads a few watts NEGATIVE at night (the owner's: 13 W); -50 W
|
||||
# clears that with room for a couple of such devices on one entity, while a
|
||||
# reversed sensor in daylight reads hundreds to thousands of watts below zero.
|
||||
NEGATIVE_W = -50.0
|
||||
|
||||
|
||||
def total(readings, flip=frozenset()) -> tuple:
|
||||
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
|
||||
|
||||
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
|
||||
@@ -237,8 +247,13 @@ def total(readings) -> tuple:
|
||||
site: shading, a tripped string and nightfall are all real 0s, and a roster
|
||||
that went to `unknown` whenever one member read zero would report an absence
|
||||
on every clear evening and defeat the channel from the other end.
|
||||
|
||||
⚠️ TEL-14: `flip` is `inverted(opts)`; each member's sign is applied HERE,
|
||||
before the sum, so every surface reads the corrected figure. `0.0 - w`
|
||||
rather than `-w`, so a flipped 0 W is `0.0` and never `-0.0` on the wire.
|
||||
"""
|
||||
parts = [(ent, *watts(st)) for ent, st in readings]
|
||||
parts = [(ent, w if w is None or ent not in flip else 0.0 - w, why)
|
||||
for ent, (w, why) in ((e, watts(st)) for e, st in readings)]
|
||||
if not parts:
|
||||
# No roster. `Controller` does not call this in that state - the gate is
|
||||
# `pv_roster` - but a reader of `parts` should never have to wonder.
|
||||
|
||||
@@ -159,12 +159,8 @@ options:
|
||||
# So this takes whatever `sensor.*` the site's PV integration provides, any
|
||||
# brand Home Assistant can read.
|
||||
#
|
||||
# ⚠️ POSITIVE IS PRODUCTION, and there is deliberately no `pv_invert` beside
|
||||
# `meter_invert` and `batt_invert`. Those two quantities are bipolar AND are
|
||||
# read by the control path, so a reversed sensor there is a plausible number
|
||||
# the site acts on; production is unipolar and is read by nothing, so a
|
||||
# reversed sensor announces itself as -4 kW at noon in the history this key
|
||||
# exists to create. See DECISIONS/TEL-11-pv-production.md.
|
||||
# POSITIVE IS PRODUCTION. An entity that reads the other way round goes in
|
||||
# `pv_invert_entities` below - see DECISIONS/TEL-14-pv-sign.md.
|
||||
#
|
||||
# ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW
|
||||
# and the rest of Home Assistant's power units. A sensor with no unit, or with
|
||||
@@ -215,6 +211,12 @@ options:
|
||||
# which is also empty" - i.e. no PV channel at all, which is every install.
|
||||
pv_entities: []
|
||||
|
||||
# TEL-14. Every PV entity listed here (in `pv_entity` or `pv_entities`) has
|
||||
# its sign flipped, for a kWh meter or power clamp that reads production as
|
||||
# negative. Per entity, because one roster can mix both conventions. `[]` is
|
||||
# load-bearing like `pv_entities: []`; see DECISIONS/TEL-14-pv-sign.md.
|
||||
pv_invert_entities: []
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
# ==========================================================================
|
||||
@@ -972,6 +974,10 @@ schema:
|
||||
# `p1_phase_import_entities` is the shipped precedent for the pair.
|
||||
pv_entities:
|
||||
- str
|
||||
# TEL-14. A parallel list, not a prefix or a dict: measured by
|
||||
# tools/supervisor_shape_probe.py section H, DECISIONS/TEL-14-pv-sign.md.
|
||||
pv_invert_entities:
|
||||
- str
|
||||
|
||||
# ==========================================================================
|
||||
# METER - where the grid reading comes from
|
||||
|
||||
@@ -56,7 +56,8 @@ absence every clear evening.
|
||||
Part 8 ⭐⭐ THE SUM, AND WHAT ONE ABSENT MEMBER DOES TO IT.
|
||||
Part 9 ⭐ publication at a roster of three, as payload bytes.
|
||||
Part 10 the operator surfaces at three - and 0.11.0's, unchanged, at one.
|
||||
Part 11 what this file cannot see, with sizes, and both mutation tables.
|
||||
Part 11 what this file cannot see, with sizes, and the mutation tables.
|
||||
Part 12 TEL-14: the sign, declared per entity (runs before part 11).
|
||||
|
||||
⚠️ MUTATING A MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you: it
|
||||
@@ -663,7 +664,9 @@ NEEDLES = ("pv_entity", "pv_w", "pv_why", "optional_keys", "ABSENT_AS_UNKNOWN",
|
||||
# TEL-12's own names. `pv_entities` and `pv_entity` share a prefix so
|
||||
# the first would have caught it anyway; spelled out because a scope
|
||||
# that only works by accident stops working when a name is renamed.
|
||||
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored")
|
||||
"pv_entities", "pv_parts", "pv_roster", "entity_is_ignored",
|
||||
# TEL-14's.
|
||||
"pv_invert_entities", "stray_inverts", "NEGATIVE_W")
|
||||
|
||||
|
||||
def touches(src: str) -> bool:
|
||||
@@ -1016,6 +1019,193 @@ check("...and an install with NO PV roster still adds no row of any kind - the "
|
||||
and run(cycles=1, pv_entities=[" "])[1].state_calls == [])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("12. TEL-14: the sign, declared PER ENTITY")
|
||||
# ==============================================================================
|
||||
# Some installs' PV entities read POSITIVE for consumption and NEGATIVE for
|
||||
# production (a kWh meter or an ESPHome clamp in the PV circuit). The fixture is
|
||||
# a MIXED roster, because that is the case a global flag gets wrong:
|
||||
# E1 an inverter, positive = producing 1200 W -> 1200
|
||||
# E2 a meter, NEGATIVE = producing, flipped -2.1 kW -> 2100
|
||||
# E3 a meter at night, correctly signed, NOT flipped: -30 W standby -> -30
|
||||
# and the values keep every wrong reduction apart:
|
||||
# right 3270; no flip -930; flip all 930; abs() of every member 3330;
|
||||
# flip E3 instead of E2 -870.
|
||||
# ⚠️ EVERY CHECK BELOW READS THROUGH `_at`/`_row` AND NEVER INDEXES BLIND, so a
|
||||
# missing value is a NAMED FAIL rather than a traceback that hides the rest.
|
||||
|
||||
|
||||
def _at(seq, i):
|
||||
"""`seq[i]`, or None when there is no such element."""
|
||||
try:
|
||||
return seq[i]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _row(c, needle):
|
||||
"""Every status-row dict whose text contains `needle` (a list, maybe [])."""
|
||||
try:
|
||||
return [r for r in c.checks() if needle in r.get("text", "")]
|
||||
except Exception as e: # noqa: BLE001 - named, below
|
||||
return [{"text": f"checks() raised {e!r}", "ok": None, "warn": None}]
|
||||
|
||||
|
||||
MIX = {E1: [state("1200", "W")], E2: [state("-2.1", "kW")],
|
||||
E3: [state("-30", "W")]}
|
||||
MIX_TOTAL = 3270.0
|
||||
_MIX_WRONG = {"no flip": -930.0, "flip all": 930.0, "abs": 3330.0,
|
||||
"flip E3 not E2": -870.0}
|
||||
PV_FLIP = dict(PV_MANY, pv_invert_entities=[E2])
|
||||
check(f"PRECONDITION: the mixed fixture's right answer {MIX_TOTAL:g} W is apart "
|
||||
f"from every wrong reduction {_MIX_WRONG}",
|
||||
MIX_TOTAL not in _MIX_WRONG.values()
|
||||
and len(set(_MIX_WRONG.values())) == len(_MIX_WRONG))
|
||||
|
||||
# --- the option ---------------------------------------------------------------
|
||||
check("inverted() is the stripped, non-empty entries of `pv_invert_entities`, "
|
||||
"and a 0.12.0 blob (no key at all) flips nothing",
|
||||
pv.inverted({"pv_invert_entities": [" sensor.a ", "", None, "sensor.b"]})
|
||||
== frozenset({"sensor.a", "sensor.b"})
|
||||
and pv.inverted({}) == frozenset()
|
||||
and pv.inverted({"pv_invert_entities": None}) == frozenset())
|
||||
|
||||
# --- the arithmetic -----------------------------------------------------------
|
||||
_t_mix = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)],
|
||||
pv.inverted(PV_FLIP))
|
||||
_t_raw = pv.total([(e, _at(MIX[e], 0)) for e in (E1, E2, E3)])
|
||||
print(f" mixed, E2 flipped: {_t_mix[0]} no flip: {_t_raw[0]}")
|
||||
check(f"⭐ A MIXED ROSTER: only the listed entity is flipped, before the sum - "
|
||||
f"{MIX_TOTAL:g} W, not any of {_MIX_WRONG}",
|
||||
_t_mix[0] == MIX_TOTAL
|
||||
and [w for _e, w, _y in _t_mix[2]] == [1200.0, 2100.0, -30.0])
|
||||
check("⭐ DEFAULT IS 0.12.0: with no flip `total()` answers exactly what it "
|
||||
"always did - the raw sum, the same `why`, the same parts",
|
||||
_t_raw == (-930.0, "3 entities",
|
||||
[(E1, 1200.0, "W"), (E2, -2100.0, "kW"), (E3, -30.0, "W")]))
|
||||
_t_zero = pv.total([(E2, state("0", "W"))], frozenset({E2}))
|
||||
check("a flipped 0 W is +0.0, never -0.0 - so the wire says `0.0` and the page "
|
||||
"says `0 W`, not `-0`",
|
||||
_t_zero[0] == 0.0 and str(_t_zero[0]) == "0.0"
|
||||
and f"{_t_zero[0]:.0f}" == "0")
|
||||
_t_gone = pv.total([(E1, state("1200")), (E2, None)], frozenset({E2}))
|
||||
check("a flipped member that is ABSENT is still an absence of the total - the "
|
||||
"flip never turns None into a number",
|
||||
_t_gone[0] is None and _at(_at(_t_gone[2], 1), 1) is None)
|
||||
check("a single flipped entity keeps 0.11.0's pass-through `why` (the unit)",
|
||||
pv.total([(E2, state("-13", "W"))], frozenset({E2}))
|
||||
== (13.0, "W", [(E2, 13.0, "W")]))
|
||||
|
||||
# --- the read path, the wire and the page -------------------------------------
|
||||
_c_mix, _, _p_mix = run(cycles=1, by_entity=MIX, **PV_FLIP)
|
||||
_f_mix = _at(_p_mix.frames, -1) or {}
|
||||
print(f" controller pv_w {_c_mix.pv_w} wire {pv_msg(_f_mix)}")
|
||||
check("⭐ END TO END: the controller reads the option, and the MQTT `pv` row "
|
||||
"carries the corrected site total",
|
||||
_c_mix.pv_w == MIX_TOTAL
|
||||
and pv_msg(_f_mix) == ("goodwe_ctl/pv", "3270.0", False))
|
||||
_mix_row = [r["text"] for r in _row(_c_mix, "W from 3 entities")]
|
||||
print(f" {_mix_row}")
|
||||
check("the status row prints each entity's watts AFTER the sign and marks "
|
||||
"ONLY the flipped one",
|
||||
_mix_row == [f"PV production: 3270 W from 3 entities ({E1} 1200 W in W, "
|
||||
f"{E2} 2100 W in kW [sign inverted], {E3} -30 W in W)"])
|
||||
check("...and the page's PV row is the corrected total",
|
||||
("PV", "3 270 W") in page_rows(_c_mix))
|
||||
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP)
|
||||
check("a holed roster's 'Reporting:' list marks the flipped entity too",
|
||||
[r["text"] for r in _row(_c_hole_f, "Reporting:")]
|
||||
and f"{E2} 2100 W [sign inverted]" in
|
||||
(_at(_row(_c_hole_f, "Reporting:"), 0) or {}).get("text", ""))
|
||||
|
||||
# The owner's report, at a roster of one: a meter reading +13 W at night.
|
||||
_OWN = "sensor.pv_meter_power"
|
||||
_c_own, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
|
||||
pv_entity=_OWN, pv_invert_entities=[_OWN])
|
||||
_c_own0, _, _ = run(cycles=1, by_entity={_OWN: [state("13", "W")]},
|
||||
pv_entity=_OWN)
|
||||
check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is "
|
||||
"-13 W once flipped, the row says so and marks it - and it raises NO "
|
||||
"negative caution, because -13 W is standby and not a reversed sign",
|
||||
_c_own.pv_w == -13.0
|
||||
and [r["text"] for r in _row(_c_own, "PV production: -13 W")]
|
||||
== [f"PV production: -13 W from {_OWN} (reported in W) [sign inverted]"]
|
||||
and _row(_c_own, "probably reversed") == [])
|
||||
check("...and WITHOUT the option it is 0.12.0's row, byte for byte",
|
||||
[r["text"] for r in _row(_c_own0, "PV production:")]
|
||||
== [f"PV production: 13 W from {_OWN} (reported in W)"])
|
||||
|
||||
# --- the negative caution -------------------------------------------------------
|
||||
def _neg(watts_by_entity, flip=()):
|
||||
opts = dict(PV_MANY, pv_invert_entities=list(flip))
|
||||
c, _, _ = run(cycles=1, by_entity={e: [state(str(w), "W")]
|
||||
for e, w in watts_by_entity.items()},
|
||||
**opts)
|
||||
return _row(c, "probably reversed")
|
||||
|
||||
|
||||
_n_add = _neg({E1: 1200, E2: -1500, E3: 450})
|
||||
_n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2])
|
||||
print(f" {[r['text'] for r in _n_add + _n_rm]}")
|
||||
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it, its "
|
||||
"watts, and says to ADD it to `pv_invert_entities`",
|
||||
[r["text"] for r in _n_add]
|
||||
== [f"PV production: {E2} reads -1500 W - far below zero, so its sign "
|
||||
"is probably reversed. If it is producing now, ADD it to "
|
||||
"`pv_invert_entities` in the Configuration tab"])
|
||||
check("...and a FLIPPED one far below zero is told to REMOVE it instead",
|
||||
len(_n_rm) == 1 and "REMOVE it from `pv_invert_entities`"
|
||||
in (_at(_n_rm, 0) or {}).get("text", ""))
|
||||
check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot "
|
||||
"put a battery site into NOT READY",
|
||||
bool(_n_add) and all(r.get("ok") is False and r.get("warn") is True
|
||||
for r in _n_add + _n_rm))
|
||||
check(f"the threshold is {pv.NEGATIVE_W:g} W per ENTITY: -50 W is standby and "
|
||||
"stays quiet, -51 W cautions",
|
||||
pv.NEGATIVE_W == -50.0
|
||||
and _neg({E1: -50, E2: 0, E3: 0}) == []
|
||||
and len(_neg({E1: -51, E2: 0, E3: 0})) == 1)
|
||||
check("⭐ ...PER ENTITY, NOT ON THE TOTAL: three correctly signed meters at "
|
||||
"-20 W standby each (total -60 W) raise nothing, and a reversed -1500 W "
|
||||
"hidden in a positive total (+1650 W) is still caught",
|
||||
_neg({E1: -20, E2: -20, E3: -20}) == []
|
||||
and len(_neg({E1: 2700, E2: -1500, E3: 450})) == 1)
|
||||
|
||||
# --- the stray entry ------------------------------------------------------------
|
||||
_c_typo, _, _ = run(cycles=1, by_entity=FULL,
|
||||
**dict(PV_MANY, pv_invert_entities=["sensor.pv_wset"]))
|
||||
check("a `pv_invert_entities` entry naming no PV entity in use is a named "
|
||||
"caution (a typo would otherwise leave the sign silently unflipped)",
|
||||
[(r["text"], r.get("ok"), r.get("warn"))
|
||||
for r in _row(_c_typo, "flips nothing")]
|
||||
== [("PV production: `pv_invert_entities` names sensor.pv_wset, which "
|
||||
"is not a PV entity in use, so it flips nothing - check the "
|
||||
"spelling against `pv_entities`", False, True)]
|
||||
and _row(_c_mix, "flips nothing") == [])
|
||||
|
||||
# --- default: byte-identical to 0.12.0 ------------------------------------------
|
||||
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
|
||||
_c_d1, _, _p_d1 = run(cycles=1, by_entity=MIX,
|
||||
**dict(PV_MANY, pv_invert_entities=[]))
|
||||
check("⭐ DEFAULT: `pv_invert_entities: []` and a 0.12.0 blob with no such key "
|
||||
"give the same rows, the same frame and the raw sum",
|
||||
[r["text"] for r in _c_d0.checks()] == [r["text"] for r in _c_d1.checks()]
|
||||
and _at(_p_d0.frames, -1) == _at(_p_d1.frames, -1)
|
||||
and _c_d0.pv_w == -930.0)
|
||||
|
||||
# --- still not a control input --------------------------------------------------
|
||||
_BIG_FLIP = {E1: [state("1500", "W")], E2: [state("-4.5", "kW")],
|
||||
E3: [state("2000", "W")]}
|
||||
_c_bf, _h_bf, _ = run(**_stim, by_entity=_BIG_FLIP,
|
||||
**dict(PV_MANY, pv_invert_entities=[E2]))
|
||||
check("⭐ NOT A CONTROL INPUT, WITH A FLIP: 40 cycles against the settled 600 W "
|
||||
"import write exactly what `unset` writes, and the flip really did move "
|
||||
"the reading (8000 W, part 6's shift precondition)",
|
||||
_h_bf.writes == _w_none and _c_bf.target == _c_none.target
|
||||
and _c_bf.pv_w == BIG_TOTAL)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("11. what this file CANNOT see, with sizes - and the mutation table")
|
||||
@@ -1028,10 +1218,9 @@ for line in [
|
||||
" (mqtt/const.py PAYLOAD_NONE, mqtt/sensor.py's `_message_received`,",
|
||||
" retrieved 2026-09-24) and quoted in app/mqtt.py. No HA runs in this",
|
||||
" suite, so what is graded here is the BYTES this add-on publishes.",
|
||||
"Whether the operator's PV sensor is telling the truth. A sensor wired",
|
||||
" backwards reads -4 kW at noon; this file asserts that the sign is",
|
||||
" published unchanged, never that it is right. That is what the history",
|
||||
" the card creates is FOR - see DECISIONS/TEL-11-pv-production.md §2.",
|
||||
"Whether the operator's PV sensor is telling the truth. TEL-14 lets the",
|
||||
" installer declare a reversed one; this file grades the flip and the",
|
||||
" -50 W caution, never that the declaration is right.",
|
||||
"The control-path claim OUTSIDE app/. `tools/`, the ESPHome firmware and",
|
||||
" the ingress page are not scanned by part 6's census.",
|
||||
"A roster of more than one INVERTER (CFG-04's battery kind). Every",
|
||||
@@ -1138,6 +1327,28 @@ print(" time - see DECISIONS/TEL-11-pv-production.md §5 for the transcript."
|
||||
print(" ⚠️ A RED COUNT IS A PROPERTY OF THE SUITE AS MUCH AS OF THE MUTATION:")
|
||||
print(" these name THIS tree. Re-measure rather than inherit.")
|
||||
|
||||
# --- TEL-14's battery: fresh copy each, no __pycache__, CI image, :ro, --------
|
||||
# --network none. Every red is a NAMED check in part 12; no traceback.
|
||||
print()
|
||||
print(" TEL-14 mutation bytes reds")
|
||||
print(" ------------------------------------------------ ----- --------")
|
||||
for row in [
|
||||
("T1 the flip ignored in total()", "-45 B", "9 here"),
|
||||
("T2 the flip applied to EVERY member", "-7 B", "6 here"),
|
||||
("T3 `0.0 - w` -> `-w` (a -0.0 leaks)", "-5 B", "1 here"),
|
||||
("T4 the flip written as abs()", "-1 B", "2 here"),
|
||||
("T5 _read_pv() stops passing the flip", "-26 B", "7 here"),
|
||||
("T6 caution threshold -50 W -> 0 W", "-17 B", "3 here"),
|
||||
("T7 the [sign inverted] marker dropped", "-39 B", "3 here"),
|
||||
("T8 the negative caution made `bad`", "+1 B", "1 here"),
|
||||
("T9 stray_inverts() always empty", "-50 B", "1 here"),
|
||||
("T10 inverted() stops stripping", "-8 B", "1 here"),
|
||||
("T11 ADD/REMOVE advice swapped", "+4 B", "2 here"),
|
||||
("T12 `pv_invert_entities: []` deleted from options:", "-25 B",
|
||||
"2 test_translations + 17 probe"),
|
||||
]:
|
||||
print(f" {row[0]:<48} {row[1]:>5} {row[2]}")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
|
||||
@@ -683,6 +683,72 @@ except vol.Invalid as e:
|
||||
str(e))
|
||||
|
||||
|
||||
print("\n== H. TEL-14: the SHAPE of a per-entity PV sign option ==")
|
||||
# Four candidate shapes, each against a 0.12.0 blob (`pv_entities` set, no new
|
||||
# key). Transcript and decision: DECISIONS/TEL-14-pv-sign.md.
|
||||
PV12 = {"pv_entity": "", "pv_entities": ["sensor.pv_inverter", "sensor.pv_meter"]}
|
||||
_pv12 = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(PV12))
|
||||
_nk = "pv_invert_entities"
|
||||
|
||||
|
||||
def _ui_of(sch, name):
|
||||
return [e for e in UiOptions(None)(sch) if e.get("name") == name]
|
||||
|
||||
|
||||
# (a) a `-` prefix on a list item, under the shipped `- str`.
|
||||
ok_a, out_a = validate(dict(_pv12, pv_entities=["sensor.pv_inverter",
|
||||
"-sensor.pv_meter"]))
|
||||
show("(a) PREFIX: `-sensor.pv_meter` validates under the shipped `- str` - "
|
||||
"Supervisor does not look inside the string, so the sign would live in a "
|
||||
"syntax that only the description text explains",
|
||||
ok_a and out_a.get("pv_entities") == ["sensor.pv_inverter",
|
||||
"-sensor.pv_meter"],
|
||||
"" if ok_a else out_a)
|
||||
|
||||
# (b) a parallel list - THE SHIPPED SHAPE.
|
||||
ok_b, out_b = validate(_pv12)
|
||||
show("(b) ⭐ PARALLEL LIST, SHIPPED: the 0.12.0 blob (no `pv_invert_entities` "
|
||||
"key) validates unchanged and the new key defaults to []",
|
||||
ok_b and out_b.get(_nk) == []
|
||||
and out_b.get("pv_entities") == PV12["pv_entities"],
|
||||
"" if ok_b else out_b)
|
||||
ok_b2, out_b2 = validate(dict(_pv12, **{_nk: ["sensor.pv_meter"]}))
|
||||
show("...and one entity marked inverted validates",
|
||||
ok_b2 and out_b2.get(_nk) == ["sensor.pv_meter"], "" if ok_b2 else out_b2)
|
||||
ok_b3, out_b3 = validate({k: v for k, v in defaults.items() if k != _nk})
|
||||
show("⭐ ...and the shipped `options:` block with ONLY `pv_invert_entities` "
|
||||
"removed is REFUSED BY NAME - its `[]` default is load-bearing, as "
|
||||
"`pv_entities: []` is",
|
||||
(not ok_b3) and f"Missing option '{_nk}' in root" in str(out_b3), out_b3)
|
||||
_ui_b = _ui_of(schema, _nk)
|
||||
show("...and the Configuration tab renders it as a REPEATABLE string field "
|
||||
"with its own top-level (translated) label",
|
||||
len(_ui_b) == 1 and _ui_b[0].get("multiple") is True
|
||||
and _ui_b[0].get("type") == "string", json.dumps(_ui_b))
|
||||
|
||||
# (c) a list of dicts - on `pv_entities` itself, and as a new key.
|
||||
_s_dicts = dict(schema, pv_entities=[{"entity": "str", "invert": "bool?"}])
|
||||
ok_c, out_c = validate_with(_s_dicts, _pv12)
|
||||
show("(c) LIST OF DICTS ON `pv_entities`: the STRINGS a 0.12.0 install stored "
|
||||
"are REFUSED - that shape would stop the add-on starting on upgrade",
|
||||
(not ok_c) and "pv_entities" in str(out_c), out_c)
|
||||
_s_src = dict(schema, pv_sources=[{"entity": "str", "invert": "bool?"}])
|
||||
ok_c2, out_c2 = validate_with(_s_src, dict(_pv12, pv_sources=[]))
|
||||
_ui_c = _ui_of(_s_src, "pv_sources")
|
||||
show("(c2) ...as a THIRD key it validates, but renders as a type-schema "
|
||||
"repeatable section whose per-entry keys reach the screen RAW "
|
||||
"(`entity`, `invert`) on this frontend - see section F",
|
||||
ok_c2 and len(_ui_c) == 1 and _ui_c[0].get("type") == "schema"
|
||||
and _ui_c[0].get("multiple") is True,
|
||||
json.dumps(_ui_c)[:200] if ok_c2 else out_c2)
|
||||
|
||||
# (d) one global bool.
|
||||
_s_bool = dict(schema, pv_invert="bool")
|
||||
ok_d, out_d = validate_with(_s_bool, dict(_pv12, pv_invert=False))
|
||||
show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
|
||||
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
|
||||
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -86,10 +86,13 @@ configuration:
|
||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
||||
pv_entity:
|
||||
name: "Hardware · PV production entity (W)"
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING - if yours reads negative while producing, list it under 'sign is reversed' below. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
pv_entities:
|
||||
name: "Hardware · PV production entities, one per inverter (W)"
|
||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page names which entity is missing, why, and what the others reported. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
||||
pv_invert_entities:
|
||||
name: "Hardware · PV entities whose sign is reversed"
|
||||
description: "Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a power clamp in the PV circuit - read NEGATIVE while producing and a few watts POSITIVE at night. Add each such entity here, spelled exactly as it is above, and its sign is flipped before it is added up; leave the others out. The status page marks every flipped entity. History recorded before you set this keeps the old sign."
|
||||
meter_source:
|
||||
name: "Meter · Where the meter reading comes from"
|
||||
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
|
||||
|
||||
@@ -66,10 +66,13 @@ configuration:
|
||||
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
|
||||
pv_entity:
|
||||
name: "Hardware · Zonneproductie-entiteit (W)"
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN - leest de uwe negatief tijdens productie, zet hem dan ook bij 'teken omgekeerd' hieronder. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
pv_entities:
|
||||
name: "Hardware · Zonneproductie-entiteiten, één per omvormer (W)"
|
||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Gebruik dit in plaats van de losse entiteit hierboven wanneer uw dak MEERDERE zonne-omvormers heeft: voeg één rij per omvormer toe, elk verwijzend naar haar eigen vermogenssensor, en de add-on registreert het totaal van de installatie. De eenheid van elke entiteit wordt afzonderlijk gelezen, dus een omvormer in kW naast een in W telt correct op. ⚠️ ALS U DIT INVULT WORDT DE LOSSE ENTITEIT HIERBOVEN GENEGEERD - maak die leeg, anders verschijnt er een waarschuwing op de statuspagina die dat meldt. ⚠️ EN ALS ÉÉN VAN DEZE ENTITEITEN NIETS MEER DOORGEEFT, IS ER GEEN TOTAAL: de sensor gaat naar 'unknown' en de geschiedenis bewaart een gat, in plaats van de som van de entiteiten die wél antwoordden te publiceren. Twee omvormers van de drie tellen op tot een kleiner getal dat precies op een echt getal lijkt, en een week later leest dat als 'er was minder zon dan er was' - net de conclusie die deze registratie moet kunnen testen. De statuspagina noemt welke entiteit ontbreekt, waarom, en wat de andere doorgaven. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
||||
pv_invert_entities:
|
||||
name: "Hardware · Zonneproductie-entiteiten met omgekeerd teken"
|
||||
description: "Optioneel, UITSLUITEND EEN METING. Sommige sensoren - meestal een kWh-meter of een stroomtang in het PV-circuit - lezen NEGATIEF tijdens productie en 's nachts enkele watt POSITIEF. Zet elke zulke entiteit hier, exact gespeld zoals hierboven, en haar teken wordt omgedraaid vóór het optellen; laat de andere weg. De statuspagina markeert elke omgedraaide entiteit. Geschiedenis van vóór deze instelling behoudt het oude teken."
|
||||
meter_source:
|
||||
name: "Meter · Waar de meterstand vandaan komt"
|
||||
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."
|
||||
|
||||
Reference in New Issue
Block a user