"""TEL-11/TEL-12 - the site's PV production, read from Home Assistant entities. WHY THIS IS AN ENTITY AND NOT A REGISTER. This add-on's RS485 bus carries the BATTERY inverter it drives. A household's PV inverter is, on nearly every site, a different box from a different vendor that reports into Home Assistant through its own integration - so the only place a product can read production for an arbitrary install is an HA entity. `pv_entities` therefore takes whatever `sensor.*` the site's own PV integration provides. ⚠️ AND THERE IS USUALLY MORE THAN ONE OF THEM, WHICH IS TEL-12. TEL-11 shipped `pv_entity` as a single string and the owner of the reference site reported within minutes of 0.11.0 going live that they have SEVERAL PV inverters and could configure one. That is not a property of one house: a multi-string or multi-orientation roof routinely carries two or three inverters, each publishing its own power sensor. `entities()` below is the roster and `total()` is the site figure over it. ⚠️ AND A PARTIAL TOTAL IS WORSE THAN NO TOTAL, WHICH IS THE WHOLE OF TEL-12. Three inverters configured, one `unavailable`, and the sum of the other two is A SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Fed into OPT-10's attribution that reads as "there was less PV than there was" - which is the "no irradiance" conclusion the card exists to TEST rather than to manufacture. So `total()` answers `None` the moment ANY configured entity is absent. See the note on `total()` for the alternative that was weighed and why it lost. WHY THE CHANNEL EXISTS AT ALL. OPT-10 measured that 20.5 kWh of surplus left the site in 16 days while the pack had room, 12.6 kWh of it with the battery not charging at all - and could not say WHY, because PV production is recorded NOWHERE. Three causes need three different fixes (no irradiance: nothing to fix; a charge-power limit; the optimiser declining to charge - the only software defect), and guessing between them is how an optimiser gets tuned to solve a cloud problem. This module is the missing measurement and NOTHING ELSE. ⚠️ NOTHING HERE MAY EVER REACH A CONTROL DECISION, AND THAT IS THE CARD'S FIRST CONSTRAINT RATHER THAN A STYLE NOTE. The add-on regulates a real battery; adding production as a term of the control law would be an unrequested behaviour change AND it would destroy the measurement, because the loop would then be reacting to the quantity being measured. `tools/tel11_pv_trace.py` is what says the path is untouched: it grades a control document that contains none of this material on both trees and with `pv_entity` set and unset. ⚠️ ABSENCE IS NEVER ZERO, AND THAT IS THE SECOND CONSTRAINT. Every refusal below returns `None` with a REASON, never `0.0`. A PV channel that reported 0 W for a dead sensor would manufacture exactly the "there was no surplus to store" conclusion the card exists to TEST - and it would do it silently, in a week of recorder history nobody would re-derive. `app/hass.py`'s own note on `state()` is the same rule one layer down. ⚠️ TEL-12 EXTENDS THAT RULE RATHER THAN WEAKENING IT: at a roster of three, "one of them is absent" is an absence of the 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, 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 # Home Assistant's OWN power units, each as the number of watts one of it is. # ⚠️ READ OUT OF HA, NOT LISTED FROM MEMORY: `UnitOfPower` in # `homeassistant/const.py` (github.com/home-assistant/core, `dev`, retrieved # 2026-09-24) declares mW, W, kW, MW, GW, TW and BTU/h. Every factor here is an # exact power of ten, so none of them is a number anybody chose. # # ⚠️ BTU/h IS IN HA's ENUM AND DELIBERATELY NOT HERE. Its factor (0.293071…) # is a measured conversion rather than a decimal shift, no PV integration # publishes in it, and a wrong constant in the one channel that exists to # attribute a 20.5 kWh shortfall is worse than a refusal an operator can read. # It lands in the "not a power unit" refusal below, BY NAME. WATTS_PER = { "mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1000000.0, "GW": 1000000000.0, "TW": 1000000000000.0, } # What `watts()` answers when there is nothing to report. ⚠️ THE REASON IS PART # OF THE RETURN VALUE, not a log line: it is what the status page prints, and an # absence whose cause is invisible sends an operator to look at the wrong box. UNIT_KEY = "unit_of_measurement" def watts(state: dict | None) -> tuple: """`(watts, why)` from a Home Assistant state object. `watts` is `None` whenever there is no reading - and `why` then says which of the five refusals it was, in words an operator can act on. On a good read `why` is the unit the value was read in, because "1234 W" and "1234, unit 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. 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: # `hass.state()` answers None for a non-existent entity, a 404 and a # transport failure alike. All three are "no reading", and HA does not # consider a non-existent entity an error - see `hass.state()`. return None, "no state (entity missing, or the read failed)" raw = str(state.get("state", "")).strip() if raw.lower() in BAD_STATES: # ⚠️ THE SAME LIST `hass.number()` REFUSES ON, IMPORTED RATHER THAN # RESPELLED. A second copy of it is how one path starts treating # `unavailable` as a number the other one rejects. return None, f"entity is {raw or 'empty'}" try: value = float(raw) except ValueError: return None, f"state {raw!r} is not numeric" unit = str((state.get("attributes") or {}).get(UNIT_KEY) or "").strip() if not unit: return None, ("no unit_of_measurement on the entity - it cannot be " "read in watts") if unit not in WATTS_PER: return None, (f"unit {unit!r} is not one of " f"{', '.join(WATTS_PER)} - it cannot be read in watts") 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: """Every PV entity this configuration names, in configured order. TEL-12. ⚠️ ONE ANSWER TO "WHICH ENTITIES ARE THE PV CHANNEL", and it is the answer BOTH sides read. `Controller.pv_roster` is this call and `mqtt.optional_keys()` is `bool()` of this call, so the thing that decides whether the row is ANNOUNCED, the thing that decides whether it is READ and the thing that decides HOW MANY entities are summed cannot disagree. TEL-11 already had that discipline for one entity and spelled the predicate TWICE - `str(opts.get("pv_entity") or "").strip()` in `main.py` and again in `mqtt.py`. Two spellings of one predicate is how an entity gets announced that nothing feeds; a roster makes it how a SUM gets taken over a different list from the one that was announced. It is one function now. ⚠️ `pv_entities` REPLACES `pv_entity` RATHER THAN EXTENDING IT, and that is CFG-04's shipped precedent (`inverters:` replaces the six flat keys) applied to the only upgrade path that matters. The install this card exists for HAS `pv_entity` set, to inverter A. Opening the Configuration tab, the natural action is to put ALL the inverters in the new repeatable field - and under a UNION rule that double-counts A unless the operator also remembers to clear the old key. A doubled total is the same defect as a partial one in the other direction, in the one channel that exists to be believed a week later. Under REPLACE the effective roster is exactly what one key says, and no arithmetic depends on a de-duplication rule the operator has to know about. ⚠️ `Controller.checks()` says WHICH key is in use whenever both are set, so the replacement is stated on the page rather than being silent. ⚠️ AND IT DE-DUPLICATES WITHIN THE LIST, ORDER PRESERVED. A site cannot have two sensors with one entity_id, so a repeated id in the list is never a second inverter - it is a typo that would DOUBLE that inverter's production in the total. `dict.fromkeys` is the order-preserving spelling. """ listed = [e for e in (str(x or "").strip() for x in (opts.get("pv_entities") or [])) if e] if listed: return tuple(dict.fromkeys(listed)) one = str(opts.get("pv_entity") or "").strip() return (one,) if one else () def entity_is_ignored(opts: dict) -> bool: """Is a set `pv_entity` being REPLACED by a set `pv_entities`? TEL-12. ⚠️ HERE RATHER THAN IN `Controller.checks()`, for the reason `entities()` exists: the rule and the sentence that reports the rule must not be two readings of the options. This is the negative half of `entities()`' first branch and it is spelled once, beside it, where an edit to one is in front of whoever is editing the other. """ return bool(str(opts.get("pv_entity") or "").strip() and [x for x in (opts.get("pv_entities") or []) if str(x or "").strip()]) 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 per configured entity, each `state` shaped as `hass.state()` returns. `parts` is `[(entity_id, watts, why), ...]` - EVERY entity's own answer from `watts()`, kept because the operator surfaces have to be able to name which sensor is missing. `watts` is the sum when every entity answered, and `None` otherwise; `why` always says which of those it was, in words a status page can print. ⚠️ ONE OR MORE ABSENT -> THE TOTAL IS ABSENT. NOT THE PARTIAL SUM. This is the decision the card turns on and it was taken deliberately against the alternative, which was to publish the partial sum ALONGSIDE a count of how many entities contributed. Both are defensible; this one wins on the ONE question that matters, which is who reads the number and when: * the MQTT row is read back COLD, a week later, by OPT-10's attribution and by whatever plots `sensor.goodwe_pv_power` over that week. A partial sum there is a smaller number indistinguishable from a real one, and the count that would have qualified it lives in a SECOND entity with its own history - so believing the total requires joining two recorder series by timestamp to find out which samples to distrust. Nobody re-derives that, which is the same "nobody will re-derive it" TEL-11's note names. And it is worse than a gap in exactly the direction that matters: a gap makes the attribution say "I do not know", a partial sum makes it say "there was less sun than there was". * the STATUS PAGE is read LIVE, by a human, who wants the diagnostic. So the partial information is not thrown away - `Controller.checks()` names every entity that DID report, with its watts, and every one that did not, with its reason, in a row that states plainly that there is no site total. Different reader, different instant, different rule. A count published beside the total would also carry no information in the state this function actually admits: the total is published ONLY when every entity contributed, so the count would be the constant `len(parts)`. ⚠️ AND `0 W` IS STILL A READING AT EVERY ROSTER SIZE - the test is `watts is None`, never falsiness. An inverter honestly reporting 0 W at dusk beside two that are producing must give the SUM of the three, not blank the 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, 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. return None, "not configured", parts if len(parts) == 1: # ⚠️ AT ONE ENTITY THIS IS `watts()` AND NOTHING ELSE: the same value # and the SAME `why` string, passed through rather than re-composed, so # 0.11.0's single-entity surfaces are byte-identical rather than # similar. `tools/tel11_pv_trace.py` grades that identity across trees. return parts[0][1], parts[0][2], parts missing = [(ent, why) for ent, w, why in parts if w is None] if missing: return None, ( f"only {len(parts) - len(missing)} of {len(parts)} entities are " f"reporting and a partial sum would understate production: " + "; ".join(f"{ent} - {why}" for ent, why in missing)), parts return (sum(w for _e, w, _y in parts), f"{len(parts)} entities", parts)