"""TEL-11 - the site's PV production, read from a Home Assistant entity. 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_entity` therefore takes whatever `sensor.*` the site's own PV integration provides. 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. 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. """ 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. `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 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