diff --git a/tools/debug-journal.md b/tools/debug-journal.md index 0e0835d18..ace1e8fb3 100644 --- a/tools/debug-journal.md +++ b/tools/debug-journal.md @@ -108,18 +108,18 @@ Grep for the named symbol rather than trusting a line number. | SolaX (`solax.py`) | Code `10402` is a token/auth failure, retried in-request rather than waiting for the next cycle. SolaX clamps battery minimum SOC at 10% (`SOLAX_MIN_RESERVE_PERCENT`), so `battery_min_soc` is auto-configured to stop Predbat writing limits the inverter will reject. **Follow-up on the GH#4993 thread (2026-09-21, from a 23-hour reporter log):** `10402` repeating every cycle with data still flowing is **server-side token invalidation between cycles**, not broken auth or lost data — Predbat's in-request refresh recovers every cycle. The tell: the same token value succeeds mid-cycle and is rejected next cycle while Predbat holds it untouched in memory between cycles (the auth-error branch of `_request_get_impl()` clears it, and `request_wrapper()` retries with a fresh token instead of failing the fetch), and a stated ~30-day `expires_in` against a real ~1-cycle token lifetime. Prime suspect is a second client sharing the same `solax_client_id`/`solax_client_secret` — the reporter ran the separate SolaX Developer API HA integration — **suspected, not proven** (one-active-token-per-app is common but could not be confirmed against SolaX docs); the decisive test is disabling the second client for an hour or registering each client its own app. Count `error 10402` lines against `Fetched ... successfully` lines before calling data lost, and note each 10402 also increments the component's lifetime error counter (`solax.py` auth-error branch), which is what accumulates in the Components panel — do not "fix" this by removing the in-request retry. Secondary, same thread: `Warn: SolarAPI: No solar forecast data was returned from HA sensors.` with the SolaX Cloud template means **no PV forecast source is configured at all** — with `forecast_solar`/`open_meteo`/`solcast_host`+`solcast_api_key` all absent, `fetch_pv_forecast()` falls through to the HA-sensor branch (`solcast.py` sets `active_source = "ha_sensors"`) and the template's `pv_forecast_today`-style `re:` regexes (`templates/solax_cloud.yaml`) match nothing unless the Solcast HA integration is installed. Check which source the log names ("Obtaining solar forecast from Forecast Solar API" vs "Using Solcast integration from inside HA") — a working source dropped during a template migration looks exactly like this. **Plant `totalYield` overstates PV on a mixed plant (GH#5356, code-verified on main 2026-10-02, payload semantics rest on the reporter's plant+device captures):** `automatic_config()` binds `pv_today` to the plant-level `sensor._solax__total_yield` (solax.py:447), `publish_plant_info()` fills that sensor straight from plant realtime `totalYield` (solax.py:2640), and `total_load` adds the same `totalYield` to imported+discharged−exported−charged (solax.py:2709) — but SolaX's plant total is PV **plus** every deviceType-1 inverter's AC output, so on a plant mixing a PV-only inverter with a separate AC-coupled battery inverter, battery discharge is counted twice (once in `totalDischarged`, once inside `totalYield`), PV history is inflated (reporter's calibration scaled 2.89x, ~1.6x legitimate), and the class presents as "calibration too high / overnight under-charging / wrong savings". `pv_power` is unaffected (per-device `pvMap`/`mpptMap` sums, solax.py:2276-2285). The ingredient a fix needs all already exist: a device-level `...__total_yield` sensor (solax.py:2366), failed reads keep the previous value (solax.py:2245), and `plant_inverters[plant_id]` holds every deviceType-1 device (solax.py:1483). Not a regression — the plant wiring dates to original SolaX Cloud development (#3140). The reporter's proposed fix direction (maintainer call pending): sum device `totalYield` over `plant_inverters` into a new `pv_today` sensor; caveat for whoever builds it — the plant **lifetime** total does not decompose as PV+discharge on that site, so derive from the daily relationship (which matched exactly on three days), not the lifetime number. **PR #5357:** `pv_today` is now `..._pv_yield`, summed by `get_plant_pv_yield()` from each inverter's **last known** `totalYield` (kept per inverter, seeded after a restart from its own `...__total_yield` sensor), so a failed read or a dead inverter does not drop out of the sum; an inverter with no `totalYield` of its own counts as 0, and the value is held only when an inverter that `onlineStatus` says should answer failed its read and has never given a yield. `pv_power`/`grid_power` are plant sensors summed over every inverter, no longer the first inverter listed. Device `dailyYield` reads 0.0 once the inverter sleeps in the evening, so it is no use as a daily counter. On a live plant of two DC-coupled hybrids with panels (2026-10-04) device `dailyYield` (23.3, 2.6) and `dailyACOutput` (24.1, 9.5) were separate counters and the plant `dailyYield` (33.6) was exactly the sum of the AC outputs, so the plant figure is inverter AC output on every plant shape seen and device yield is not; device yield was not checked against an independent PV measurement. Still wrong after the fix: a plant whose only inverter is an X1-AC (no `totalYield`, empty `mpptMap`/`pvMap`) falls back to the plant figure, which there is battery discharge (`dailyYield` = `dailyACOutput` = `dailyDischarged`), and its `load_power` goes negative when a third-party PV inverter SolaX cannot see is exporting. `plant_inverters` is keyed by the plant ID as SolaX gives it while entity names use the lowercased form - look up with the raw ID. **GH#5388 (2026-10-04):** a plant lifetime counter can dip and recover within a day (`totalYield` 4437.8 → 4260.4 → 4441.7, `totalDischarged` with it) and, published as received, the recovery was recorded as 181.3 kWh of PV that day - an absurd `pv_today` or `export_today` on one day followed by a PV forecast several times the array size is this, via calibration. An upward step of thousands of kWh on the same counters did not show in `pv_today`. `hold_counter_dip()` now holds the five plant totals and each inverter's `total_yield` at the last published value and takes a lower value as a genuine reset only after `SOLAX_COUNTER_DIP_HOLD_HOURS`; how long a real dip lasts was not measured. After a restart the last value is seeded from the sensor, or from the `counters` entry in storage when the sensor is gone (Predbat's sensors do not survive a Home Assistant restart); the time a dip started is saved there too, without it a restart began the hold again and a system restarted daily would never accept a genuine reset. A plant where no inverter has PV inputs (X1-AC only: no `totalYield`, empty `mpptMap`/`pvMap`) now publishes **0** for `pv_yield` instead of the plant total, with a one-off `has no inverter with PV inputs` warning; `total_load` then has no PV term, so on a site with third-party PV it under-reads by what that PV supplies and can fall while the site exports. Nothing in the SolaX data measures that PV: a 12 hour dump of `/openapi/v2/device/history_data` (params `snList`, `deviceType`, `startTime`/`endTime` in ms, `timeInterval` minutes, `businessType`) carried the same fields as realtime, yield null and `gridPowerM2` 0.0 throughout daylight, and no plant checked had a `deviceType=3` meter device. `M2` fields are meter 2, positive = export per the SolaX API text; `solax.py` does not read them, and a meter 2 that was once fitted shows as a non-zero `totalExportEnergyM2` that no longer moves. A hybrid with no panels still reports a populated `mpptMap` of zeros, so it counts as having PV inputs and is not covered by the 0. | `solax` | | Sigenergy (`sigenergy.py`) | Lifetime/history totals are cumulative server-side and reset around EU midnight, which showed up as an overnight dip; `fetch_history_totals` applies a monotonic clamp. "Energy totals went backwards overnight" starts here. Separately, a user `apps.yaml` override of `inverter.has_reserve_soc: true` (stock `SIG` default is `False`, deliberately — reverted in `5bfc5c80` after #2873/#3124) bypasses two guards that normally make reserve writes inert for Sigenergy (`execute.py:947-950`, `inverter.py:547-549`), so `adjust_reserve()` writes straight to `number.sigen_plant_ess_discharge_cut_off_state_of_charge`. Confirmed from a reporter's log: zero occurrences of the "Inverter does not support reserve" disable message the first guard would normally log, plus 56 reserve writes landing on that register (GH#4728). The stock template's `reserve:` line also points at that same entity rather than `ess_backup_state_of_charge`, which is what makes the override immediately destructive rather than merely inert. `Applying mode=eco ... duration=720min` is emitted only by the no-window fall-through branch (`sigenergy.py:2199`), so seeing it while a plan export window is live means the component's control snapshot never saw the window. Two code-verified routes to that, both still live: with `sigenergy_automatic: False`, `automatic_config()` - the only wiring of the component's control entities into Predbat - never runs, so Predbat's plan writes land on its own simulated dummy entities (`inverter.py:539-545`) and a `True == True` read-back is Predbat comparing against itself rather than reading a register; and `fetch_controls` runs on the first tick only (`if first:`, `sigenergy.py:2658`), so a single missed `call_service` event leaves the component's internal state permanently out of step while Predbat's write-skip comparison guarantees the healing write is never re-issued (GH#4894, probe-verified in `test_sigenergy.py`). `_publish_mqtt` logged the live `accessToken` on every command until PR #4926 added `SigenergyAPI.redact()`. The docs template still writes `number.sigen_plant_grid_import_limitation` to 0 in both freeze branches (`docs/inverter-setup.md:1936-1940` and `:1971-1975`), and Sigenergy's own EVAC/EVDC chargers are EMS-throttled to the plant import limit - so the block lands exactly when Predbat asks for Freeze Charging to hold for a car (GH#4911). On the **stock** `has_reserve_soc: False` shape the planned reserve floor is model-only and the battery drains to the inverter's own cut-off — see the "Plan shows a flat hold at the reserve" symptom row (GH#5358). **Every power lever on SIGCLOUD has been silently inert (GH#5376, hardware-verified by the reporter's live MQTT log and battery data; **fixed in PR #5377, merged 2026-10-03 and first shipped in v9.3.5** — the six optional fields are now built into the command object, `sigenergy.py` — keep the pre-#5377 reading for older logs):** `send_battery_command()` writes its six optional power/priority fields — `chargingPower`, `pvPower`, `maxSellPower`, `maxPurchasePower`, `chargePriorityType`, `dischargePriorityType` — at the **top level** of the payload while the required command fields sit inside `commands[0]` (since `cba12cc3`, first released v8.36.11); the API reads the levers from the command objects, so the top-level fields are silently ignored, and planned charge rate, bidirectional export cap, freeze-charge/`freeze_export_hold` `chargingPower: 0` and both priority types (all set in `apply_controls()`, `sigenergy.py`) have never reached the hardware. The suite passed while the bug was live because `test_sigenergy.py` asserted `payload["chargingPower"]` at the top level while checking `commands[0]` for the required fields — **a test that locks in the implementation's current output makes suite-green meaningless for that surface**; the #5377 fix rewrote the test to assert inside `commands[0]` (its "optional fields that were not passed are omitted" cases are the in-tree example of testing the fixed shape). Suspect, not verified: the fixed surface has never been hardware-tested by the triage bot, so a post-v9.3.5 regression report about holds/exports on SIGCLOUD may be that surface going live for the first time; pre-#5377, the #4761 freeze-export reasoning and the `freeze_export_hold` branch never acted with real effect. | `sigenergy` | | AlphaESS (`alphaess.py`) | Once any discharge window is configured (`ctrDis=1`), AlphaESS firmware allows discharge *only* inside that window — outside it the battery is charge-from-PV-only, with no self-consumption fallback (AlphaESS's own documented behaviour, per GH#4701). Predbat's generic handling of what happens outside a configured discharge window (`execute.py`) has no AlphaESS-specific case and assumes normal demand mode resumes there, which holds for other brands but not this one — the next best export window Predbat schedules can be hours away, leaving the battery locked out of discharge until then (GH#4723). A separate write-ordering bug, **fixed in PR #4776 (merged 2026-08-27)**: the periodic API commits a schedule in stages — window, then enable switch, then target SoC — pressing the write button after each, so the *first* commit of a cycle always carried the *previous* cycle's target while `alphaess_min_write_interval` (default 300s) held the correcting write back for the rest of that pacing window; a manual charge on an already-live slot could run at the stale target for up to five minutes (GH#4769, confirmed from the reporter's log, not inferred). Also: the legacy `/updateDisChargeConfigInfo` endpoint's `batUseCap` field (the reserve/export-SoC value) has no floor on the Predbat side (`alphaess.py:1103`/`:1143`), while the periodic path clamps it to AlphaESS's documented `[10,100]` range — a reserve/export-SoC entity set below 10 gets every legacy write rejected with an undocumented `10001` (GH#4748); that errno is the signature to recognise this class by. **The single most important thing to know here, documented on `main` since PR #4744: the AlphaESS Open API cannot control export at all, so Predbat cannot make it export.** There is no forced-export, working-mode or dispatch endpoint - only a grid-charge window with a target SoC, a discharge window with an SoC floor, or both together on entitled systems. Force Export therefore exports nothing beyond genuine solar surplus, and Freeze Export comes out byte-identical to Demand mode because `gridCharge` gates only *timed grid charging* and nothing can stop the battery charging from solar. The documented answer is to set `select.predbat_mode` to `Control charge` on AlphaESS; left on `Control charge & discharge` Predbat plans exports that never happen *and* programs the discharge window up to `plan_interval_minutes` early, which on permission-window semantics bars the battery from covering house load until the window opens (that is GH#4723's mechanism). Forced export is reachable over local Modbus, never from the cloud API. Separately, PR #4867 changed how a hold is expressed: Predbat now writes a synthetic **enabled 10% target at 100 W** on a stable daily window (`ALPHAESS_HOLD_SOC`/`ALPHAESS_HOLD_POWER`, `alphaess_const.py:183-184`) rather than the old zero-rate hold, because zero power disables charging in Predbat and AlphaESS may discard such a profile. A genuine grid charge is not replaced by it (`alphaess.py:1106`). Note the consequence, raised in review and not tested on hardware: unlike a zero-rate hold this profile is not energy-neutral by construction, so an install sitting below 10% SoC has a standing 100 W charge request while the hold is in force. | `alphaess_control` | -| Ohme (`ohme.py`) | A vendored, version-pinned copy of `dan-r/ohmepy` (`ohme.py:33`, `VERSION`). GH#4719 (2026-08-25) found it several minor versions behind upstream with the control routes (max-charge, session rule) pointing at a withdrawn `/v1/chargeSessions/{id}/rule` — a 404 with Spring's `NoResourceFoundException` — while GET/pause/resume/approve kept working since those stayed on v1 upstream. **Fixed on `main` since** (confirmed 2026-08-27): the vendored client now calls the v2 routes upstream moved those endpoints to. A reporter on an older Predbat version seeing control-only failures (GETs/pause/resume fine, target time/percent/preconditioning silently not applying) is hitting this, not a new bug. Separately, and **still live** (GH#4952): `slot_list()` derives slot energy as `watts * hours` (`ohme.py:124`), treating `watts` as a sustained rate when the observed 16A -> 10A -> 6A taper shows it is a per-slot cap - a reporter measured 48.49 kWh modelled against 19.02 kWh of real charge, 2.55x. Ohme publishes `estimatedSoc`, which would give the true delta, and `ohme.py` never reads it anywhere. The same slots are published as dispatches with `location: "AT_HOME"` and no `source` (`ohme.py:670`), so `rate_add_io_slots()` stamps the whole plug-in-to-target block at the low rate. Note the existing tests assert the current `watts * hours` semantics, and `estimatedSoc` is itself extrapolated when the car has no SoC telemetry - so neither side is a drop-in swap. See the car-charging row for why the `predict()` clamp has to be trimmed *after* this, not before. | `ohme` | +| Ohme (`ohme.py`) | A vendored, version-pinned copy of `dan-r/ohmepy` (`ohme.py:33`, `VERSION`). GH#4719 (2026-08-25) found it several minor versions behind upstream with the control routes (max-charge, session rule) pointing at a withdrawn `/v1/chargeSessions/{id}/rule` — a 404 with Spring's `NoResourceFoundException` — while GET/pause/resume/approve kept working since those stayed on v1 upstream. **Fixed on `main` since** (confirmed 2026-08-27): the vendored client now calls the v2 routes upstream moved those endpoints to. A reporter on an older Predbat version seeing control-only failures (GETs/pause/resume fine, target time/percent/preconditioning silently not applying) is hitting this, not a new bug. Separately, and **still live** (GH#4952): `slot_list()` derives slot energy as `watts * hours` (`ohme.py:124`), treating `watts` as a sustained rate when the observed 16A -> 10A -> 6A taper shows it is a per-slot cap - a reporter measured 48.49 kWh modelled against 19.02 kWh of real charge, 2.55x. Ohme publishes `estimatedSoc`, which would give the true delta, and `ohme.py` never reads it anywhere. The same slots are published as dispatches with `location: "AT_HOME"` and no `source` (`ohme.py:670`), so `rate_add_io_slots()` stamps the whole plug-in-to-target block at the low rate. Note the existing tests assert the current `watts * hours` semantics, and `estimatedSoc` is itself extrapolated when the car has no SoC telemetry - so neither side is a drop-in swap. See the car-charging row for why the `predict()` clamp has to be trimmed *after* this, not before. **Two ownership/executor findings on the same install shape are both fixed on main (PRs #5401 and #5405, merged 2026-10-05, unreleased at the time of writing - keep the pre-fix mechanisms for logs on v9.3.5-):** with `ohme_automatic` on and `ohme_control` off on a non-Intelligent tariff, Predbat's own car plan had **no executor** - `control_charge()` is the only executor and `enable_control()` returns early without `ohme_control`, every other consumer of `car_charging_slots` is model/display-side, and no load-only mode existed, so the charger followed Ohme's schedule while Predbat's plan was a fiction (GH#5399). Now Ohme's session slots become the car plan through the same path Intelligent dispatches take, labelled a charger schedule so they earn no cheap rate (import rates untouched; `reprice_charger_schedule_slots()` re-prices them on this cycle's rates because `load_octopus_slots()` runs before rates are built), and the dispatch/schedule/Predbat-led choice is re-made on every poll instead of once at start-up. And slot-ownership auto-detection was tariff-only - `octopus_intelligent_wanted()` never looked at which Intelligent device Octopus actually drives, so on an Intelligent tariff with a car-linked device (BMW/Mini/VW, `is_charger: false`) Ohme claimed slots Octopus schedules through the car itself (GH#5402). Detection now looks at the Octopus component's live non-suspended Intelligent devices (`is_ohme_charger()` = `is_charger` + provider "ohme"): an Ohme among them takes the slots; Octopus driving another device leaves them to Octopus and `ohme_control` stands down; every device suspended or an Intelligent tariff with no device keeps Ohme's schedule as the plan; an explicit `ohme_automatic_octopus_intelligent` still wins. `test_ohme.py`'s older tariff-only cases encode the pre-#5405 detection, so a green suite there proves nothing about a device-check change. | `ohme` | | Solcast (`solcast.py`) | `max_kwh` — the array-capacity ceiling used to sanity-check the forecast — is initialised to `9999` (`solcast.py:1339`) and is only ever reassigned on the Forecast.Solar and Open-Meteo branches. On the direct-API and HA-sensor Solcast paths it stays `9999`, so the "Raw forecast exceeds the array ceiling" warning (`solcast.py:1191`) can never fire for Solcast users — confirmed by reading the assignments (GH#4730). That warning is exactly the diagnostic an "impossible PV predicted" report needs, so its absence isn't evidence the ceiling wasn't exceeded. Branch order at `solcast.py:1368` also means a configured `solcast_api_key` always wins over `pv_forecast_*` HA sensors, so comparing Predbat's number against the HA entity compares against the wrong source when both are set. Two more. `solcast_poll_hours: 4.8` is silently truncated to 4: component args come from `COMPONENT_LIST`'s int defaults via `get_arg()`, which does `int(float(value))` (`userinterface.py:266`), and GH#4441's fix lives on the CONFIG_ITEMS route that component args bypass - the reporter's log showed real fetches at exactly 4h and 18x `code 429` against a 10/day hobbyist limit, while `docs/apps-yaml.md` recommends 4.8 for two-array accounts (GH#4925). And `publish_pv_stats()` reads the shared, briefly-rewound `midnight_utc` live (GH#4804) - see "The shared clock is rewound for ~0.8s every hour" above, which has since been confirmed as a real bug class by a separate merged fix. PV calibration self-reference (GH#5116, **fixed in PR #5121** — mechanism kept for older logs): `sensor._pv_forecast_h0` publishes the **calibrated** `power_nowCL` as its state whenever calibration is on, with the raw value only in the `now` attribute, so anything reading the state history gets calibrated output — and calibration itself used to read that history as its "past forecast" while applying its factors to the raw series, which settles the factor at **√(actual/raw)** and corrects only ~half the bias (logged 0.9073x against a measured 0.82). #5121 replaced the read with a dedicated `sensor._pv_forecast_h0_uncalibrated`, published unconditionally and never scaled by `pv_scaling`; `pv_forecast_history()` reads it (scaled by the *current* `pv_scaling`, so a change takes effect over the whole window at once), falls back per-point to the h0 `now` attribute and then the h0 state for points predating the sensor (`now` was added in the same commit that made the state calibrated, so a point without `now` recorded the raw state), and the web chart plots the uncalibrated series. The h0 state-vs-`now` distinction stays live for other consumers. Two general facts from the same PR's review: `history_attribute(attributes=True)` drops any point whose attributes lack the key unless `fallback_to_state=True` (the state path filters `unavailable`/`unknown`; the attribute path only skips them inside the fallback read — `utils.py`), and the DB mirror returns rows with `attributes = {}` on JSON decode failure (`db_engine.py` — the row is still appended), so an empty attribute read on a `db_primary` history does not prove the data predates the attribute. Anything comparing h0 attributes against Predbat's internal PV series must also account for `pv_scaling`, which sits on one side only (`now` is pre-scaling, the internal series post-scaling). Test-fidelity trap: a calibration test that patches `history_attribute_to_minute_data` cannot see which series calibration learned from — the real path needs a `get_history_wrapper` mock with HA-shaped entries wrapped in `patch_now_utc_exact()`, because `now_utc_exact` is real wall-clock while `minutes_now`/`midnight_utc` are frozen; `prune_today`'s default `group=15` also drops history points closer than 15 minutes apart. `pv_calibration()`'s past-forecast history additionally flows through the **module-global** `history_attribute_to_minute_data` (solcast.py imports it by name), so an instance-level override cannot intercept it — patch `solcast.history_attribute_to_minute_data` and restore it in a `finally`, and re-patch before a second call in the same test. **Open-Meteo shading is client-side (GH#5224):** the Open-Meteo `v1/forecast` API has no `horizon` parameter (closest solar-geometry options are `tilt`/`azimuth` for GTI), so any horizon feature — Predbat's or a user asking for one — must be applied client-side to the downloaded irradiance, which is exactly what the HA Open-Meteo Solar Forecast integration (rany2) does; Predbat's existing knob is the static per-array monthly `shading_factors` applied in `gti_hourly_to_period_kwh()` (`solar_model.py`), which cannot express geometric blocking. *Suspected, not verified:* that integration's published attribute names could not be confirmed compatible with Predbat's HA-sensor PV path (`detailedForecast`/`forecast` with `pv_estimate` entries), so do not recommend pointing Predbat at its sensors as a workaround without checking the entity shape. **Band built from the wrong series with calibration off (GH#5345, fixed in PR #5347 \[v9.3.4\]: `pv_calibration()` now takes `calibrate_band` and builds the band on the P50 it is returned with — the switch-off path swaps both p50 and the band back to raw, and Open-Meteo keeps its own ensemble band instead of a synthesised `create_pv10` one; keep the pre-#5347 mechanism for older logs):** on a `create_pv10` source (Open-Meteo, Forecast.Solar) with `metric_pv_calibration_enable` off, `pv_calibration()` built `pv_forecast_minute10`/`90` and the published `pv_estimate10`/`90` from the **calibrated** series and only swapped P50 back to raw at its final `return`, so P10 could exceed P50 and P90 fall below it, and `get_cloud_factor`'s (ΣP50 − ΣP10) gap was arbitrary - reported as "the saw tooth has gone". Signature in `sensor._pv_today` `detailedForecast`: `pv_estimate10 / pv_estimateCL` and `pv_estimate90 / pv_estimateCL` constant in every slot while `pv_estimateCL / pv_estimate` varies. Dead end: it is not Open-Meteo's ensemble P10 being "confident" - at the time that download was fetched and then overwritten by `pv_calibration()`, because every Open-Meteo branch set `create_pv10`; the same change makes `fetch_pv_forecast()` keep an ensemble band (`create_pv10` False, `calibrate_band` True) and fall back to the history-based band only when the ensemble download fails. The band is the ensemble's P10 and P90 **as ratios of the ensemble's own median**, applied to the deterministic P50, not the ensemble's absolute values: the P50 comes from the best-match deterministic call and the spread from `icon_seamless`, two different model runs that can disagree on a whole day's level - live on 2 Oct 2026 the absolute ensemble P10 sat above the deterministic P50 for a day three ahead (clamped, leaving a 2% downside) and at 0.28 of it elsewhere. Known weakness of the ratio: the deterministic forecast is re-issued between ensemble runs and moved one site's next-day total from 1635 to 3887 Wh/m2 in ten minutes, above the ensemble's own P90, and P50 x (P90/median) then overshoots anything the sky can deliver - only the `1.2 x kWp` power ceiling bounds it, there is no clear-sky cap. So an implausibly high `pv_estimate90` on an Open-Meteo site maps here. Test trap from that work: the solar test mock returns the first response whose key is a substring of the URL, and `api.open-meteo.com` is a substring of `ensemble-api.open-meteo.com`, so a test registering both in that order silently serves the forecast body to the ensemble call and exercises the no-ensemble path. The in-function `enabled_calibration` flag means "enough history", not the user switch, so the fixed 0.7/1.3 band does not apply with the switch off. Routine SolarAPI Info lines are not in shipped logs; read the `pv_today`/`pv_forecast_h0` attributes instead. A source that publishes a 0-PV day is not a safe fallback either — the counter-argument to any "just publish 0" suggestion: a 0-yield day is a `pv_calibration()` down-day (actual < 10% of forecast) and ≤ 2 valid days switches calibration off entirely, leaving the raw forecast (GH#5388 context, code-read on main; `solcast.py`). | `solcast` | | HA write/verify (`ha.py`, `inverter.py`) | `get_state(refresh=True)` is a no-op in a normal HA add-on install: it only re-reads when `not self.ha_key`, and `ha_key` is set in that case, so verification reads the websocket cache, never a fresh value (`ha.py:798`, since PR #2342). Separately, `write_and_poll_value()` treats `domain == "sensor"` as Predbat-owned and POSTs `/api/states/` directly (`inverter.py:2155`, `ha.py:1071-1077`) instead of calling the integration. Probed empirically across all four write-path/domain combinations (GH#4738): a control entity that resolves to a `sensor.*` domain by mistake gets a direct state overwrite that reads back as success, with zero corresponding lines in the real integration's log. `call_service_wrapper()`'s return value used to be discarded at every `inverter.py` write site, so a rejected HA service call (e.g. a Modbus "Illegal Function" error) never reached the verify logic. That is no longer true everywhere: PR #4878 made `call_service_template()` check it (`inverter.py:2817`) and log "was not accepted, it will be retried next cycle". The other eleven `call_service_wrapper()` call sites in that file still discard it, so check the specific write path before repeating the general claim. Later triage extended this row rather than replacing it. `write_and_poll_switch()` builds its service name as `domain + "/turn_..."` straight from the entity's own domain with no validation (`inverter.py:2102`), and `adjust_inverter_mode()` routes every `has_ge_eco_toggle` inverter through it - so an `inverter_mode` pointed at a `select.*` entity produces `select.turn_on`, an HA-invalid service retried 10x at 10s per attempt, and the string coercion just below (`inverter.py:2110-2111`) maps any select state to False, so the force-export direction reports success having written nothing (GH#4909, still live — though on 3-phase GEC the real problem is one stage deeper: no eco register exists at all, and the write path is skipped with `No entity_id for ECO Toggle`; see the GE Cloud row). `adjust_force_export`/`adjust_charge_window` format their times at plan precision with no snapping (`TIME_FORMAT_HMS`, `inverter.py:2608`/`:2615`), so against an integration whose select only offers 15-minute options (Solar Assistant/Deye) Predbat writes e.g. `20:55` and `write_and_poll_option` retries the impossible value 10x every cycle; nothing anywhere reads a select's `options` attribute, and `charge_time_entity_is_option` only decides *how* an entity is written, never which values are legal. The fix precedent is already in tree - AlphaESS's `snap_time_grid()` (`alphaess_const.py:215`), snap start forward and end backward with a collapse check (GH#4947). A second, independent way plan-precision times fail on the same select path: `write_and_poll_option()` only strips the seconds from an 8-char `HH:MM:SS` write when the *previous* state is a healthy 5-char `HH:MM:SS` read — the read feeding it returns `None` for an entity missing from the state cache (`ha.py:817`) or the literal `"unknown"`/`"unavailable"` when degraded (`ha.py:815`), either skips the strip and the raw `HH:MM:SS` goes to `select_option`, which HA rejects; all 10 retries re-read and fail identically, and the log reads `didn't complete got unknown` (GH#5009, probe-verified on main). That degraded-read shape can also be **permanent**: an SA slot select can report `unknown` for days — every write read-back `unknown`, zero verified writes, across an SA rebuild and both native-API and MQTT transports — so both symptoms (read path `unable to read Export window - ... returned no data` every cycle via `time_string_to_stamp("unknown")` → `None`, and the write path above) fire continuously; state reporting varies by SA backend (GH#5214). **A user edit of `config.py`'s INVERTER_DEF `charge_time_format` to anything ≠ exactly `"HH:MM:SS"` is a masking workaround, not a fix (GH#5214):** the `!= "HH:MM:SS"` branch (`inverter.py`) swaps apps.yaml entities for Predbat's own `sensor.predbat_{type}_{id}_*` dummies — reads parse Predbat's own echo and writes become direct `set_state` POSTs that never reach the inverter — so the log goes quiet while SA window control is silently severed. Tells, all observed in #5214's attachments: a `Creating dummy entity sensor.predbat_{TYPE}_{id}_charge_start_time` line in the log (`create_entity()` only logs when the entity is missing from HA state, so its presence proves the ≠ branch ran); the debug yaml's live `args` naming dummy ids while `args_from_apps_yaml` still lists the real `select.*` entities; and warnings stopping after a user restart then resuming after an addon update (which overwrites the edited `config.py`, wiping it). Also: pre-#3533 SA shipped `charge_time_format: "S"` (dummy time entities, no SA select wiring), so "SA time warnings started after updating" can mean the user crossed the #3533 template wiring rather than a format regression. The fix that serves both is reading the select's `options` attribute — nothing in the codebase does today. Six of the eight `adjust_*` writers discard the write helper's boolean and fire `call_notify()`/`mqtt_message()` regardless, and `rest_setReserve()` has the same gap (GH#4845). `call_service_template()` used to record its dedup hash *before* the call, so a silently failed `select_option` was skipped as "previously called" on every later cycle; fixed (it now records only on success and drops the record on failure), though it still deliberately returns True so callers cannot downgrade a freeze into a stop (GH#4876). From that same report, a false-alarm class worth recognising: a `didn't complete got X` where X is exactly the *previous* cycle's target, on an integration that republishes its entities slowly, is the verify window reading HA's cache - the write had already landed by the next cycle. That window is arithmetic, not a constant: up to `INVERTER_MAX_RETRY` (10) writes, each polled through one `inv_write_and_poll_sleep` budget with a doubling read interval - **~40s on GS, ~100s on GE/GEC/GEE** (`write_and_poll_sleep` is 10 for those types, 4 for GS). **Per-type since PR #5297 (merged 2026-09-29):** `INVERTER_DEF[type]["write_max_retry"]`/`["write_backoff"]` (consumed by `_write_attempts()`/`_write_backoff_result()`), and currently only `GWMQTT` deviates - 3 attempts, 10s apart - plus a per-control degraded state for those types: a write of the *same* target failing `INVERTER_WRITE_BACKOFF_FAILURES` (2) times in a row drops to one attempt per `INVERTER_WRITE_DEGRADED_INTERVAL` (300s) until a write verifies, the read-back matches or a different target arrives (full ladder restored at once); a degraded control still reads back, logs and records the failure every call - nothing is skipped silently. Every other type keeps 10 attempts and its existing sleep. A settling bounce that outlasts it is a second false-alarm shape (GH#5130): a HA `number.*` entity can step through intermediates (0 → a float → the settled int) for tens of seconds after a timed-slot write (~44s observed on solax_modbus), so a `didn't complete got ` whose value sits *between* the old and target values is a settling read-back, not a failed write - it self-heals next cycle when the initial read matches. The bounce reads feed only `value_matched()` (keep polling vs rewrite) and only the final read reaches the control ledger (`record_write` on match, `clear` on failure), so the worst case is a redundant same-value rewrite plus the false warning; the in-repo precedent for re-reading after settling is Solis Cloud's `verify_settle_seconds` re-read. And the discharge-stop fallback (GH#5094, still live): `adjust_charge_immediate()` issues a "stop discharge" service step before the charge/freeze step, and when `discharge_stop_service` is not configured it falls back to `charge_stop_service` with `domain="discharge"` - a documented fallback dating to #1614. On Tesla service-hook setups `charge_stop_service` maps to `self_consumption` + backup reserve 0, which on a Powerwall is **active discharge to load** (the EV charger counts as load), not a stop - so every freeze/hold cycle Predbat sends "discharge normally" and then re-commands "hold" ~2s later, and Tesla occasionally applies the first and drops the second, so the battery discharges into the car until the next 5-minute cycle. Verified from the reporter's log: the exact pair fires from the carHolding branch ("Disabling battery discharge whilst car 0 is charging" → `adjust_charge_immediate(soc_percent, freeze=True)`); the reporter had commented out `discharge_stop_service` (and `discharge_start_service`) when disabling export control, which is what exposed the fallback. Workaround: define `discharge_stop_service` in apps.yaml with commands that actually stop discharge (for a no-export setup, the same commands as `charge_freeze_service`). Caveat on the reporter's own `repeat: False` idea: enabling the service dedup means accepted calls are not re-sent (see the GH#4876 entry above - HA accepts the call but Tesla firmware can still drop it), so dropped freeze commands would never be retried. Both this and the fixed export clobber below stem from the same property of the Tesla template set: `charge_stop_service` and the freeze/discharge templates all write the same `operation_mode` select, and the templates mean opposite things on this hardware. **A sibling of the same family, over the reserve register rather than the mode selects (GH#5300, triaged 2026-09-29 against d951201e, reporter on a hand-built apps.yaml):** inside one 5-minute pass the hook charge (`adjust_charge_immediate()` issues `charge_freeze_service`/`charge_start_service` via `call_service_template`, inverter.py) and `execute.py`'s reserve reset (`if self.set_reserve_enable and resetReserve: inverter.adjust_reserve(0)`) both touch the Powerwall's reserve and the reset has **no isCharging guard** - `resetReserve` initialises True whenever `set_charge_window or set_export_window` and is cleared only by the freeze / hold-charging / car / iBoost hold branches - while `adjust_reserve()` floors at `reserve_percent` and writes through `write_and_poll_value()`, so the reset always ends up holding the register, against hook writes that are fire-and-forget. A service hook that uses the reserve register as its charge signal loses that race on every charge-window cycle. The shipped template is immune by two mechanisms, both worth checking before triaging any "Tesla reserve overwritten" report: `templates/tesla_powerwall.yaml` sets `has_reserve_soc: False` in the `inverter:` block (`fetch_inverter_data()` then force-disables `set_reserve_enable`/`set_reserve_hold`, logging `Note: Inverter does not support reserve - disabling reserve functions`), and with no `reserve:` arg wired Predbat dummies the arg (`create_missing_arg`/`create_entity`, inverter.py) so `adjust_reserve` writes land on a Predbat placeholder - the Sigenergy dummy-arg shape again. Discriminator for real-vs-dummy reserve wiring: the `Inverter N Current Reserve is X% ... and new target is Y%` and `Wrote X to reserve, successfully now Y` lines name the register, not the entity - a non-zero current read means the `reserve:` arg resolves to a real wired entity; a dummy reads 0 and at an equal target prints `already at target` instead. Dump-config tells from the same report: a `set_reserve_enable` switch showing `value: null` in a debug dump can still resolve True (expert-mode-switched item, hidden and entity not created while expert mode is off), and unknown keys in the dump's `args:` mean a hand-built config - do not assume the shipped template. | `inverter` | | GE Cloud (`gecloud.py`) | Gateway fields can come back null. `merge_non_null` stops nulls overwriting good values, and the publish path guards null containers — GH#4656 was a null crash in that area. `number_event` clamps an out-of-range write silently and does not report the clamped value back, while `write_and_poll_value()` keeps comparing against the original pre-clamp target - a permanent false `had_errors` and an unbounded retry (GH#4826). PR #4828 clamped inside `adjust_reserve()` only; the shared write helper still has no min/max awareness and the other `inverter.py` write sites still pass unclamped targets (GH#4831). The error-code path has since been reworked (`9560c61a`, `d6e5998c`) after GH#4896 showed `success: false` nulled `data` before the code branch could read it, so codes GivEnergy documents as never succeeding on a repeat (-3/-4/-7) were retried the full budget and `message` was never surfaced. Separately, **fixed in PR #4954 (merged 2026-09-06)** - keep the mechanism for anyone holding an older log: for `GE`/`GEC`/`GEE` the max battery rate was taken solely from the `charge_rate` entity's `max` attribute, and the `elif "battery_rate_max"` branch below it was unreachable for those types. Percentage-rated models (the 3-phase units) have no absolute charge power register, so `ge_cloud_automatic` left `charge_rate` unset and the attribute read returned the 2600 W default; both the correct rate GECloud writes into `battery_rate_max` and the user's own apps.yaml value went unread, and every rate was clamped to 2600 W. On those models it also corrupted the writes, since the percentage registers are set as `new_rate / battery_rate_max_raw * 100` against a denominator 3.8x too small. The fix falls back to `battery_rate_max` only when `charge_rate` resolves to nothing (GH#4908). GH#4198 is the same code shape on SolaX and is not covered by that fix. A model-string parser gap sits above that path (GH#5136): **the integer-rating half was fixed in PR #5293 (merged 2026-09-30, v9.3.3)** — `get_max_inverter_rate_from_model()` now parses an integer segment straight after the `3HY` token (`GIV-3HY-11` → 11 kW) *before* the decimal pass, so a later decimal segment cannot hide it; it also guards a null model, strips trailing punctuation (`GIV-HY-8.0-G3-HV.`) and warns readably on an unparseable rating ("using max charge rate instead - set inverter_limit in apps.yaml"); keep the pre-#5293 signature (decimal-only match, so an integer-rated `GIV-3HY-11` fell back to `max_charge_rate` — which the GE Cloud API can change independently, 11000 → 9984 W observed). **Still live:** `publish_info()` reads only `max_charge_rate`, never the `max_discharge_rate` in the same payload, and `battery_rate_max` is bound to the `_max_charge_rate` entity — a model whose discharge rating differs keeps the wrong rate. Distinct from GH#4908 (the absent-`charge_rate` 2600 W default). Start on any "GEC inverter rate is wrong" report by checking the model string for a decimal point; the tests now cover the integer-3HY cases too. `automatic_config()` binds `inverter_limit` to the resulting `_max_inverter_rate` sensors and `prediction.py` clips PV against `inverter_limit`, so the error reaches the plan. **Mixed fleets: the rate read/write gates test key existence, not per-slot state (GH#5359, probe-verified on main b161949d via `tools/triage_test.sh`):** `get_current_charge_rate()`/`get_current_discharge_rate()` (`inverter.py`, the `"charge_rate_percent" in self.base.args` branch) and `adjust_charge_rate()`/`adjust_discharge_rate()` (which write **both** keys, each gated only on key existence) interrogate whether the key exists **anywhere in the fleet's args**, not whether *this* inverter's slot is set — and `get_arg()` on a `None` slot applies the caller's default quietly (`userinterface.py`, float branch) — so in a fleet mixing GEC percent-controlled units (3-phase `GIV-3HY` has no `battery_charge_power` watts register; `automatic_config()` then sets `charge_rate_percent` and `build_entities()` leaves per-device `None` slots, deleting `charge_rate` when no device has the register) with watts-controlled units, a percent slot that is `None` reads quietly as 100% → `battery_rate_max_raw`, and every rate write for the power-mode sibling lands on a missing entity (`Warn: ... No entity_id for charge_rate to write ...` + `record_status(had_errors=True)`, so "Read-Only with Errors" too). The low-power rate search, which steps down relative to the read-back, then restarts from the maximum every cycle and never converges — #3311's fault extended to mixed fleets (the single-inverter shape is in the low-power symptom row). PR #4645 (open, for #3311) creates its per-slot rate dummies but does not touch these four gates — the #4908 battery-sizing read (`get_arg("charge_rate", indirect=False, index=self.id)` before falling back, inverter.py:583) is the per-entry pattern the gates lack. One API-shape trap on the site endpoint (`GE_API_SITE`, `site/{uuid}`, added for PR #4978): the published OpenAPI spec types `limits.import`/`limits.export` as a string with a null example and never shows a populated one, so the shape had to be learned from live accounts. A populated limit is `{'enabled': bool, 'power': {'watts': int, 'amps': float}}`, and **`enabled` is the thing that matters** - a site describes its connection's declared capacity in exactly the same structure as a curtailment, marked disabled, so `power.watts` alone tells you nothing about whether anything is being restricted. Site 64744 gave `{'import': {'enabled': False, 'power': {'watts': 46000, 'amps': 200}}, 'export': {'enabled': True, 'power': {'watts': 4500, 'amps': 19.6}}}` - a 200A supply whose export really is curtailed to 4.5kW - while site 46714's disabled 6kW export is its declared capacity and no restriction at all. A fleet survey confirmed the flag across accounts, so only an enabled limit is applied; an enabled zero is a real zero-export connection. A site with no limit reports a null import/export. For a fleet survey use `GET /v1/site`, the list endpoint, which returns every site's `limits` and the inverters under it (with `info.max_discharge_rate`) in one response - no second call per site. Two GEC triage tools and one clobber trap (GH#5017). The component publishes an entity for every `/settings` entry with no name filter (`async_get_inverter_settings()`/`publish_registers()`, `regname_to_ha()` is plain lowercasing), so **absence of an entity in the reporter's debug yaml is proof the API never listed the register** — "the app can control it" does not imply the settings API exposes it, because the app/GivTCP write via local control paths rather than the `/settings` list (a GIV-3HY-20 had slot 1/2 lower-SoC registers absent while 3..10 existed). Caveat: the settings list is fetched once per process start (`register_list` cache) and only the parsed flags are logged, so the debug yaml is the only observable. To capture the raw register list, `python3 apps/predbat/gecloud.py --api-key --write-entity number.probe_nothing 0` makes `test_gecloud_direct()` print the full "Available entities/registers" list with raw cloud names and setting IDs and performs no write — but the harness's startup pass also runs `enable_default_options()` against the real inverter (floors reset, unused windows zeroed), so say so when asking a user to run it. Clobber trap: the missing-feature branch `set_arg("discharge_target_soc", None)` *deletes* the key, including an explicit apps.yaml entry the user wrote; `set_arg_auto(..., overwrite=False)` (component_base.py) is the in-repo pattern for letting an explicit value win — check the `has_*` detection branch on any "automatic config deleted my setting" report. The `inverter_mode` half of this class was fixed in PR #5059 (`885c637d`) — it now binds via `set_arg_auto(..., overwrite=False)` like `export_limit` — but the other missing-feature deletes are still plain `set_arg(..., None)` on main: `pause_mode`, `pause_start_time`, `pause_end_time`, `discharge_target_soc`, `charge_rate_percent`, `discharge_rate_percent`, `givtcp_rest`. A companion test-fidelity trap (GH#5056, verified on main): the component test mocks (`MockBase.set_arg` in `test_ge_cloud.py`; same shape in `test_ohme.py` and `test_alphaess_api.py`, whose mock `set_arg_auto` also writes unconditionally) store the value instead of mirroring the real `set_arg()`'s None-deletes, and their `args_from_apps_yaml` is empty so `set_arg_auto()` falls through to the plain path — so the "feature not detected" assertions (`config_args.get(...) is None`) pass identically whether the code stored None or deleted the key, and a green suite cannot distinguish fixed from unfixed. Any test for this class must populate `args_from_apps_yaml` and assert the manual value survives — #5059's Test 10 in `test_ge_cloud.py` is now the in-repo precedent. And the deeper half of GH#4909 (still live): the 3-phase GEC class exposes **no** eco/demand/mode register in `inverter/{sn}/settings` — verified by enumerating the `predbat_gecloud_*` entities in the reporter's debug dump — yet the `GEC` def sets `has_ge_eco_toggle: True` unconditionally (`config.py`) and auto-config maps `inverter_mode` via `build_entities("switch", ["enable_eco_mode"])` (gecloud.py), which emits nothing when the register is missing, so `adjust_inverter_mode()` logs `No entity_id for ECO Toggle` and returns without writing every plan cycle and freeze export silently no-ops (force-discharge paths work fine). Fix wrinkle for the maintainer: the flag is one per-inverter-type bool while `build_entities` works per-device. The freeze-export half of the same class (GH#5082, follow-up to GH#4909, code-verified on main): `inv_has_timed_pause` is probed at runtime (inverter.py flips it False when the `pause_mode` entity is absent/unavailable) but `inv_support_discharge_freeze` is read only from the static `INVERTER_DEF` entry (`config.py` sets it True for GEC), so `execute.py` never force-disables `set_export_freeze` for the eco-less/pause-less 3-phase class and a freeze-export plan can never be executed. Check both flags on any "freeze silently didn't work on GEC" report - and do not grep only for `No entity_id for ECO Toggle`: that warning comes from the eco branch, while freeze export on this class fails primarily through the pause/rate branch, so the eco warning being absent says nothing about the freeze path. Fix direction: mirror the `inv_has_timed_pause` presence-probe; the manual-freeze-export override path already degrades correctly when `set_export_freeze` is False (plan.py drops to demand). Hardware-reported on the GIV-3HY class: `charge_power_rate` limits AC (grid) charging only - `battery_power` stays at the full discharge figure while the charge register reads 1% - so the `adjust_charge_rate(0)` fallback in the freeze branch is a no-op for PV charging there; note `charge_discharge_with_rate` is False for GEC, so the first `adjust_charge_rate(0)` in each branch is skipped and the write only happens via the `has_timed_pause`-else fallback. Fix precedent for building a freeze from registers the device actually has: `inv_support_feedin_first` + Fox Cloud freeze export (PR #5038); the GIV-3HY exposes `enable_force_discharge` + `discharge_down_to_percent` + `battery_reserve_percent`, the raw material for the #4909/#5082 option-2 freeze. And since #5056 a manual `inverter_mode` apps.yaml override is silently deleted by automatic config, so "just map the eco toggle manually" is not currently viable either. The neighbouring **charge-side** gap (GH#5040: `scheduled_charge_enable` had no `enable_force_charge` candidate, so a 3-phase GEC that gates timed grid charge behind both charge switches logged real charge states while importing nothing) is **fixed in PR #5042** (`a7121135`, v9.0.2): auto-config now binds `scheduled_charge_enable` to `enable_force_charge` with `ac_charge_enable`/`enable_ac_charge` as fallbacks, and `enable_default_options()` holds `enable_ac_charge` on as a static enable when the force register exists. Keep the mechanism for pre-v9.0.2 logs, where the signature is a 3-phase GEC showing plan charge states against flat grid power with no warning anywhere. The #5042 gate itself had a second gap (**GH#5269, fixed in PR #5270, merged 2026-09-27** — keep the pre-#5270 signature): `enable_ac_charge` was written in exactly one place, `enable_default_options()` — first cycle after start-up, then once per 24h (`default_options_stamp`) — while Predbat's own `scheduled_charge_enable` write (`switch_event()`) checked nothing about the gate, so any external writer (an Axle VPP clean-up, the GE app, another integration) could clear `enable_ac_charge` and Predbat re-asserted it only on that 24h pass or a restart; "restarted and it worked" is part of the pre-fix signature, as is `enable_force_charge` on with `Charging target …%` for hours, SoC flat and no error. #5270 re-asserts the gate from `switch_event()` when a force-charge write succeeds (`ensure_ac_charge_gate`, with Axle's clean-up read as read-only for the 24h pass and a binding guard), so the "gate cleared between cache refreshes" residual window is the remaining maintainer call on that fix. So "3-phase GEC plans a charge, status says Charging, battery imports nothing" now has **two** signatures: pre-v9.0.2 = never bound (#5040), v9.0.2+ pre-#5270 = externally cleared and not re-asserted (#5269); on a post-#5270 system check whether the force switch Predbat drove is actually the `enable_force_charge` register. GE Cloud EMS plants (GH#5103, **fixed in PR #5109, merged 2026-09-24** — keep the mechanism for pre-#5109 logs): with an EMS discovered `polling_mode` is set False (`gecloud.py`), and the settings refresh used to re-read each battery inverter's `/settings` only on the first tick of the process — so the AC3 registers, including the DC-discharge slot windows, were read once and published stale for the rest of the run, while live status/meter polling kept looking normal. #5109 re-reads settings hourly (`SETTINGS_SLOW_REFRESH_SECONDS`, `gecloud.py`), refreshes the EMS and gateway devices every cycle, and runs `check_ems_inverter_slots()` on every snapshot — which now *warns* when a battery inverter's slot 1 is not the full-day window (the #3781 rule: `Inverter X slot 1 is not 00:00-23:59 ... it will override the EMS and can stop charging or discharging early`), once per episode — recorded in `ems_slot_warned` so a standing misconfiguration does not inflate the error counter on every refresh, so the non-00:00–23:59 slot 1 fault is a visible warning on current main and silent only pre-#5109 (pre-fix signature: plan/status look right but the batteries hold 0 W past a fixed wall-clock time every night). Under EMS auto-config Predbat still writes mode/schedule-enable/rates to the AC3s. Nuance: the startup read itself can still be skipped when the storage settings cache is under 10 minutes old (`settings_from_cache`), so even a restart is not guaranteed to re-snapshot the registers. **The option-validation parser used to crash-loop the whole component (GH#5217, fixed in PR #5218, merged 2026-09-26 — keep the signature for pre-#5218 logs):** `select_event()` and `publish_registers()` both `split("(")` a cloud `Value must be one of: (...)` string, so an option label containing `(` raised `ValueError: too many values to unpack (expected 2)` — the component does not die (`ComponentBase.start()` catches it), it *crash-loops*: `api_started` never set, automatic config never runs, no plan, `Error: GECloudDirect: too many values to unpack (expected 2)` every cycle. #5218 added the module-level `parse_validation_options()` (`gecloud.py`): split on the first `(` and strip only the final `)` (so inner brackets in labels survive), and `None` for a string with no `(` triggers a warning and falls back to the setting's `in:` validation-rule options instead of raising — the paren-less guard is required, not optional, because `split("(", 1)` alone fails the other direction. Which GE Cloud setting actually returns a multi-`(` validation string was never captured (the storage cache saved at the settings write would be the fixture to ask for). **`refresh_discovery()` is not device re-discovery, and GE Cloud re-discovers hourly since PR #5295 (GH#5294, merged 2026-09-29 - the symbols below were read against the pre-fix tree):** `ComponentBase.refresh_discovery()` only rebuilds the capability-catalogue *report* from data already in hand (a component opts in via `build_discovery()`) and never calls the component's API - the *similarly named* `refresh_devices()` is the thing that calls it. Pre-#5295 that device discovery (`async_get_devices()`, EV chargers, EMS/gateway selection, one-shot automatic config) ran exactly once inside `if first:` and `first` flips off permanently after the first successful run, so on any "GE Cloud didn't pick up my new inverter/charger" or "removed inverter still shows data" report a restart was the only mechanism - and a removed device kept its last good SoC/power feeding the plan (`merge_non_null()` hands back the previous reading on nulls and failed polls) and kept having registers written to. **Post-#5295:** `refresh_devices()` re-reads the device list hourly (`DEVICE_REFRESH_SECONDS`, 60*60, `gecloud.py`; a multiple of 60 because `run()` is called once a minute, and a cycle that finds a change polls and reads settings whatever else is due), `select_poll_devices()`/`apply_devices()` decide what is polled from the fresh list, and adopting a changed set drops every per-device store - `status`, `meter`, `info`, `settings`, `pending_writes`, `register_list`, the EV-charger stores, `ems_slot_warned` and `register_entity_map` - so a removed device stops publishing and being written to. The review round also: the change signature includes `battery_meters`, so a CT rewiring re-runs automatic config; a failed register-list fetch is retried on the next settings read (previously stored as `None` and never fetched again - every later read raised `TypeError`, newly reachable from rediscovery); a device read that would leave zero battery inverters is not adopted (the warning says restart if the hardware really was removed; charger-only sites stay quiet); `ge_cloud_data` returns to its pre-EMS value when the EMS leaves; the EMS slot-override warning re-arms when the EMS changes; and default options for a device discovered while read-only is set stay pending across cycles, applied once read-only ends. Caveats that survive the fix: the 5-day `last_updated` skip inside `async_get_devices()` treats a device silent over 5 days as non-functional wherever that runs - the hourly re-read included - and `merge_non_null()` still hands a *polled* device its last good SoC/power on nulls and failed polls. In-tree fix precedents (GE Cloud itself now among them): Fox re-fetches its device list every 24h (`FOX_REFRESH_STATIC`), SolaX gates plant/device-info re-reads with `data_is_due`, and `num_inverters` is re-read by the core every cycle so a re-run automatic config takes effect next plan. **The "disable" writes on the unused numbered slots leave them live (GH#5371, reporter field log from a GIV-3HY-11 Beta site, code-verified on main):** `enable_default_options()` - run at startup, once per 24h and on newly-found devices, gated only by `read_only_now()` so manual-config GEC sites get the resets too - writes every unused AC-charge and DC-discharge slot 2-10's **times** to `00:00` "to disable" (`gecloud.py`, the numbered-slot branch of the limit/time resets), and on 3-phase GIV-3HY hardware `00:00-00:00` is **live** 00:00:00-00:00:59 (minute bounds), so every "disabled" unused slot is live for one minute past midnight; with force charge on at that minute (a planned charge window spanning midnight, or the v9.1.0 stuck-on case) the unused slots fire at full rate toward their 100% upper limit (discharge mirror: full rate toward the 4% lower floor). The same pass overwrites user-set spare-slot times (the 03:00 workaround) once per pass, per the reporter's inverter-settings history log. Fix direction (maintainer's call): have the **limits** carry the disable instead (unused slots' upper→4%/reserve floor, lower→100%); the constraint is that the two generic limit branches (`*_lower_soc_percent_limit`→4%, `_upper_soc_percent_limit`/`ac_charge_upper_percent_limit`→100%) also match the numbered slots and would write straight back on the next pass, so the fix must special-case slots 2-10 inside those branches - and #5017's outcome could later drive exports through slot 3 on GIV-3HY, so the "unused" set needs a guard there. `test_ge_cloud.py`'s 00:00-reset tests stay valid under this; the fix needs numbered-slot limit assertions added. | `ge_cloud` | | Teslemetry / Powerwall (`teslemetry.py`) | Battery model is inferred from site `nameplate_power / battery_count`. A nameplate fallback combined with `inverter_hybrid: True` once produced a false 5 kW inverter limit and spurious morning export. Tariff writes push a whole TOU schedule every cycle, so a setting changed by hand in the Tesla app is reverted on the next cycle (GH#4600, GH#4610). Three later findings. `build_tariff()`/`_render_side` carve a *single* ON_PEAK interval priced with one scalar (`teslemetry.py:1117`/`:1036`/`:1074`), so no intra-window price gradient can exist and Tesla's own TBC is free to defer the whole export to the end of the window; the no-rate-data fallback branch also skips the boost entirely (GH#4887). The charge path sends no rate at all - `evaluate_schedule` (`teslemetry.py:539`) asserts mode `backup` plus reserve = charge target plus grid charging - so a slow charge ramp is Tesla firmware, not a Predbat write bug (GH#4892); Tesla now snaps a backup reserve of 81-99% down to 80% while Predbat still forwards any 0-100 target. The site_info limit mapping was reworked in PR #5276 (GH#5275, merged 2026-09-28): `inverter_limit` comes from `nameplate_power` (the Powerwall's own AC rating; `max_site_meter_power_ac` is the site's supply limit at the meter, not the inverter's), `export_limit` from `min_site_meter_power_ac` (kW, possibly fractional, with the ±1e9 "no limit" sentinel skipped and 0 published as a real 0 W limit), `inverter_limit_charge` at **5 kW per battery unit** capped at nameplate (Tesla reports no usable charge rating — a Powerwall 3 lists only expansion packs with every rating zero, and fleet data fits the rule), `soc_max` from the gateway's `nameplate_energy_watts` before the `battery_count` estimate, and `inverter_hybrid` now defaults from the Powerwall model (Powerwall 3 on, every other model off) with a `teslemetry_hybrid` override for a PW3 beside an existing string inverter. All of those are wired with `set_arg_auto(overwrite=False)`, so **an apps.yaml value wins** — on a pre-#5276 version `automatic_config()` overwrote a manually set `battery_rate_max` whenever `teslemetry_automatic` was on, which was the standing "my override does nothing" answer on this component. Since PR #4976 there is a Predbat-side lever for exactly that slow ramp: `teslemetry_tbc_control` — **on by default since PR #5188** (GH#5186; `DEFAULT_TBC_CONTROL = True` in `teslemetry.py`, mirrored in `COMPONENT_LIST`, with `teslemetry_tbc_control: False` as the opt-out) — switches `evaluate_schedule()` to `evaluate_schedule_tbc()`, which pushes a **signal tariff** (`build_signal_tariff()` - fixed 0/50/100p bands over the committed charge and export windows) so Tesla's own optimiser runs the charge and reaches the full rate reserve-driven charging cannot; mode goes to `autonomous` in every state, reserve becomes an actual reserve rather than a charge signal, and `_settable_reserve()` maps requests onto reserves the Powerwall honours - an 81-99% request now rounds **up** to 100 rather than being forwarded raw into the snap-to-80 band. The same PR stopped `plan.py` planning a manual freeze export on inverters that cannot do it. **Consequences of the default flip:** a Teslemetry user who never set the key is on the signal tariff with autonomous mode, so "the Tesla app shows 0p/50p/100p instead of my rates" or "my charge target isn't honoured" is the default path working, not a bug; and the 81-99% `set_reserve_min` limitation becomes default-on behaviour with it. (`MockTeslemetryAPI` still defaults `tbc_control = False` on purpose, so a green teslemetry test run exercises the real-rate path unless a test opts in.) And a separate scoping fact from the GH#5186 triage: `teslemetry_automatic` gates only `automatic_config()` — the scheduler emulator (`sync_tariff()` + `assert_device_state(evaluate_schedule(...))`) runs for every non-read-only user with no `automatic` check, so "Predbat changed my Tesla tariff/mode but I never turned on automatic" is that scoping, not a bug; `set_read_only` is the only switch that stops the writes. A related pricing bug in the same `build_tariff()` family was fixed in PR #4977: an export window ending at midnight priced the whole of tomorrow at peak. And `optimization_strategy: "economics"` *is* pushed with `tariff_content_v2` on every `set_tariff()`, unconditionally, since v8.49.0 / PR #4603 - a request to "add" it is asking for something already shipped (GH#4918). **GH#5157** (reporter's evidence, not independently probed — Powerwall 2, fw 26.26.4): the unit sat in the idle branch of `evaluate_schedule` and simply stopped acting on a standing device tuple for ~4h while `site_info` read back the *correct* configuration — drift invisible to every field the API exposes, with only a Predbat restart recovering it; the stall state is the one branch Predbat legitimately holds for hours, which is why transition-based self-heal never fires. A "Powerwall plateaued / stopped discharging until I restarted Predbat" report maps here first. **Implemented on main** (2026-09-19): `run()` re-asserts the whole device tuple — export rule, grid charging, reserve, mode, not the tariff — every `FORCED_ASSERT_SECONDS` (2h) with the write-on-change dedupe bypassed (`force=True`), the timer advancing only on a fully successful forced assert; the Info line `Teslemetry control drift-correction is transition-based ..., backed by a forced re-assert of the full device tuple every 120 minutes` names it, so on current main the stall is bounded at ~2h. **Freeze export is fully implemented in the planner and disabled for TESLA at exactly one point (GH#5225, enhancement):** `INVERTER_DEF["TESLA"]` declares `support_discharge_freeze: False` (`config.py`), read into `inv_support_discharge_freeze` (`inverter.py`), whose only consumer is the first-inverter lockstep block in `execute.py` that force-disables `set_export_freeze`/`set_export_freeze_only` (`Note: Inverter does not support discharge freeze - disabled`) — `EXPORT_MODE_FREEZE` never enters the export ladder, and PR #4976 additionally drops manual freeze-export windows to demand with a Warn. **Flipping the flag alone is not enough:** the component must also map the freeze-target discharge window to a device-side hold, or the plan's freeze assumption fails on the device — the non-TBC `evaluate_schedule()` writes the *raw* discharge target as the reserve, so a 99 target lands in the 81-99 band Tesla silently snaps to 80 (`_settable_reserve()` exists for exactly that and the non-TBC path doesn't route through it), while the default-TBC `evaluate_schedule_tbc()` discharge branch yields `pv_only` with the standing reserve but no hold — the battery still serves house load and drifts down. The device primitive exists in-tree: the TBC charge-hold tuple (`pv_only` + reserve 100 + grid charging off + autonomous) holds the battery while PV surplus exports, but a freeze *export* hold must sit at the current SoC (reserve at/above SoC through `_settable_reserve()`), not 100 — reserve 100 lets solar recharge the battery instead of exporting the surplus. Every `INVERTER_DEF` entry with `support_discharge_freeze: False` (the cloud-emulator types: SunsynkCloud, AlphaESSCloud, DeyeCloud, SolaxCloud, SolisCloud…) shares this two-part requirement — capability flag **plus** device-side freeze mapping — so enabling freeze export on any of them is a two-file change, not a one-line flag flip; each component's schedule emulator needs its own hold tuple designed against what its hardware honours (*suspected*, not verified per component). | `teslemetry` | -| Sunsynk / DEYE (`sunsynk.py`, `deye.py`) | Freeze export is gated by the per-slot power register (`sellTime{n}Pac` on Sunsynk), confirmed on live hardware — setting the energy mode alone had no effect, and with slot power at zero the battery still charged. `read_only` was ignored by the reconcile loops until `_is_read_only()` gated `_reconcile_control()` (GH#4436). DEYE cloud telemetry keys are one hardcoded spelling per metric — `DEYE_TELEMETRY_KEYS["pv_power"] = "TotalSolarPower"` (`deye_const.py`), confirmed against a single live 3-phase hybrid — and a SUN-8K-SG05LP1-EU-AM2-P instead exposes `TotalDCInputPower` + `DCPowerPV1/2/3`, neither anywhere in the tree. Because `pv_power` is in `DEYE_TELEMETRY_REQUIRED`, the miss fires the `device/latest missing expected keys [...] - telemetry will read zero` warning every cycle and the Cloud PV sensor publishes 0.0 while a manual `pv_power` arg to a local sensor may still protect the planner. Reproduced by swapping the test fixture's single PV key (`tests/test_deye_api.py` `LIVE_DATA_LIST`). Whether the alternate key is unit-equivalent (vs summing `DCPowerPV1-3`) needs a live capture from an affected model — suspected, not verified. CONFIRMED live (Sunsynk 2211093089, 2026-09-19, via `sunsynk.py --tou-test`) that the settings object exposes the TOU register block a SECOND time under unrelated names, so several keys that read like independent settings are the same memory at a different scale: `volt1`-`volt4` are `sellTime3`-`sellTime6` as minutes-since-midnight / 50, `volt5`-`volt10` are `sellTime1Pac`-`sellTime6Pac` / 10, and `volt11`, `volt12`, `current1`-`current4` are `sellTime1Volt`-`sellTime6Volt` x 10 — a contiguous 16-value window, matched 16/16 in both the before and after reads of one write. Two consequences. It is independent proof a TOU write reached the real registers rather than a cloud-side echo, which is the cheapest hardware verification available. And it is a trap: anything that wrote `volt*`/`current*` would silently corrupt the charge programme. Predbat is safe today only because `SUNSYNK_SYSTEM_MODE_FIELDS` excludes them, so treat that exclusion as load-bearing rather than incidental. The alias does NOT extend to what the write payload must contain — `time{n}On` (capital O) is a derived echo of `time{n}on` and is correctly excluded via `SUNSYNK_DERIVED_SLOT_FIELDS`. **GH#5138**: the `control cache is X minutes old (limit 15)` forced-rewrite check is **startup-only** - it lives in `restore_state()`, called only from `run()`'s `if first:` block - so a reporter reading `limit 15` as "rechecks every 15 minutes" is wrong; it never runs again after the first cycle. The issue's core premise (idle slots never clear `sellTime{n}En`) was disproved: every slot `_owned_payload` write carries an explicit sell flag, idle slots are written with `sell: 0`, and the TOU build places a sell-0 baseline segment at each window's end (probe-verified; a `sellTime5En: 1` read-back just after an export window can be *correct*). The real gap it exposed - a write the cloud API acknowledged but the dongle never collected was trusted forever, because the owned-field change gate suppresses identical re-sends and `note_settle` only warned - is **fixed in PR #5142 (merged 2026-09-19)**: sustained settle divergence now clears `applied_payload[sn]` so the next write cycle re-applies. Keep the mechanism for pre-#5142 logs, where a stuck export slot needed a restart to clear. No debug yaml was ever attached, so the reporter's exact overrun is unconfirmed; the `has not applied Predbat's settings after N settings polls` warning is the tell. Two component-side traps since. **PR #5150** (merged 2026-09-19): `control_active` is now persisted and restored alongside `applied_payload` under the same 15-minute bound (`SUNSYNK_RESTORE_MAX_CONTROL`/`DEYE_RESTORE_MAX_CONTROL`), so a restart **inside** the bound no longer strands an armed inverter in silence; on the upgrade path a cache carrying `applied_payload` with no `control_active` key infers the missing half from the payload keys — a safe lower bound, since `applied_payload[sn]` is written only by `apply_settings`/`apply_dynamic_control`, whose production callers arm `control_active` first (the CLI paths apply with `force=True` and never save). And the in-code comment on the Sunsynk write-button handler claiming Predbat "presses this on every cycle" is wrong about frequency: both press sites in `inverter.py` are change-gated (target SoC presses only when `current_soc != soc`, the export window only when `schedule_changed`), and the one unconditional post-restart re-commit is `is_hm_format`-only, which excludes these types' `HH:MM:SS` — so after a restart with the plan already matching the inverter, nothing is pressed for hours. Read the press gates in `inverter.py` before using that comment to dismiss a "component stopped writing after a restart" report. **GH#5349 (live, 2026-10-02) joins the two halves end to end:** an HA outage restarted Predbat with the control cache 35.9 minutes old, restore logged `Info: Sunsynk control cache is 35.9 minutes old (limit 15), forcing a rewrite` and dropped both halves — but nothing performs that rewrite on its own: `_reconcile_control()` gates on `control_active`, `number_event` reaches Predbat only as in-memory `update_local_schedule()` updates (no apply), and both press sites stayed change-gated, so **zero settings POSTs fired from 00:59 to 03:02** (counted from the reporter's full-API-logging log) while Predbat re-armed the hold every 5-minute cycle (reserve chasing SoC+1) and the inverter ran its 00:59 programme's idle-cap-5 slots, draining ~9 kWh (82% → 24%) into the EV until a charge-window plan change finally pressed the button at 03:02 — recovery then ran #5142's settle detection (`Warn: Sunsynk ... has not applied Predbat's settings after 4 settings polls`, fired at 03:59) and re-applied. Reading the "forcing a rewrite" line as if a rewrite follows is the trap: it is the notice that the cache was **dropped**. So a "holds lost for hours after an HA blip / battery drained while Predbat showed holds armed" report maps here even on a #5150+ version. Device-visible hold legs on these types (same log): the TOU slot cap is the only one — a hold is `cap = SoC+1` in the programme (cap 65 held the battery at 64% while the car drew from grid; cap 5 drained it toward the inverter floor), and the `discharge_rate 0` entity write is symbolic on this type, so a "wrote 0 to discharge_rate but the battery drained" report reads the cap, not the entity. Also normal for this type: Sunsynk TOU charge windows cannot cross midnight (`can_span_midnight: False`) — a 23:00–00:30 window is written 23:00–23:59 and re-rolled to 00:00–00:30 at midnight (log-confirmed; not a bug). **GH#5156** (padding truncating any TOU window crossing 04:00/08:00/12:00, `build_tou_slots()` — now shared by both components in `tou_schedule.py`) is **fixed**: padding slots now carry the state of the slot they follow and sit just after it, an inert interval that cannot end a window early (`_padding_segments()` documents the issue). The pre-fix tell was "Deye stops charging at 00:00/04:00/08:00/12:00/16:00/20:00 while the Plan page still shows charging", freshly generated each cycle — not a stale default slot. **GH#5193 (cloud component, load telemetry):** on Sunsynk M3.3.8.9 the `totalPower` field (`load_power`, `SUNSYNK_TELEMETRY`) read 0–100 W through a whole overnight cycling stretch while the battery was active — including mode-2 ("Limited to Home") stretches — and read plausibly the adjacent morning with the battery idle, so the collapse correlates with the battery being active, not cleanly with the work mode; what gates it is unresolved (suspected; the reporter's manual A/B blamed the mode flip, the log's mode-2 stretches say something else does too — a controlled live test on the affected firmware is the only settle). `dailyUsed` (`load_today`) kept counting at a plausible coarse rate through the same stretch, so a balance-derived load remains feasible — and `fill_load_from_power()` is the repair that gets nothing while `totalPower` is zero. Predbat's own export control writes the whole-system `sysWorkMode` to `selling_first` for every planned export window (`derive_control_state()`, applied in the settings payload), and the component auto-wires both load args through `set_arg_auto()`'s default `overwrite=True`, so a manual apps.yaml override does NOT win — only `sunsynk_automatic: False` opts out. The in-file docstring on main records the mode/battery-export reading plus the live counter-evidence (11.1 kWh exported in a day under "Limited to Home" with `solarSell` on; a separate live test showed battery export under mode 2 with the per-slot sell flag), so the docstring's "the mode governs battery export" claim is DEYE-inferred and not settled — a control-side alternative (stay in mode 2, drive `sellTime{n}En`) stays open. The component logs every API request/response in full in `predbat.log`, so a whole Sunsynk-cloud investigation can run from the log alone; when mining logged JSON widen the field width before trusting a truncated number (`0.4` prints as `0.` under a 14-char `substr`). **Automatic config maps a battery-side current into `battery_rate_max`, and `inverter_limit` does not bound the written rate (GH#5238, code-read on main 2026-09-25/29):** `battery_rate_max()` derives watts from `chargeCurrentLimit`/`maxChargeCurrentLimit` × nominal pack voltage (`SUNSYNK_CHARGE_CURRENT_FIELDS`, first-positive-wins — a battery/BMS-side limit; GH#5238's 280 A BMS derived 14.3 kW against an 8 kW inverter), `automatic_config()` maps it to `battery_rate_max` and `ratePower` to `inverter_limit` but **never sets `inverter_limit_charge`/`inverter_limit_discharge`**, and `inverter.py` defaults both of those to `battery_rate_max_raw` when unset — so `battery_rate_max_charge = min(raw, raw) = raw`, and that is the per-slot power the plan writes. `prediction.py` clamps only *predicted* flow to `inverter_limit`; nothing feeds `inverter_limit` into the written rate. `deye.py`'s `battery_rate_max()` docstring states the same wrong assumption outright ("inverter_limit constrains the AC side separately" while deriving from `maxChargeCurrent`), and its `automatic_config()` has the same two-arg shape — so treat any "automatic config shows/writes an impossible rate" report for a cloud component through this lens first, and a fix has to map `inverter_limit_charge`/`_discharge` (to `min(ratePower, derived battery rate)`); deriving the true inverter-side current setting needs a telemetry mapping Sunsynk does not currently expose. Workaround semantics: `inverter_limit_charge`/`inverter_limit_discharge` are safe apps.yaml overrides under `sunsynk_automatic` (the component never sets them), while `battery_rate_max` is not — `set_arg_auto(overwrite=True)` clobbers an apps.yaml value every cycle. | `sunsynk_control`, `deye_control` | +| Sunsynk / DEYE (`sunsynk.py`, `deye.py`) | Freeze export is gated by the per-slot power register (`sellTime{n}Pac` on Sunsynk), confirmed on live hardware — setting the energy mode alone had no effect, and with slot power at zero the battery still charged. `read_only` was ignored by the reconcile loops until `_is_read_only()` gated `_reconcile_control()` (GH#4436). DEYE cloud telemetry keys are one hardcoded spelling per metric — `DEYE_TELEMETRY_KEYS["pv_power"] = "TotalSolarPower"` (`deye_const.py`), confirmed against a single live 3-phase hybrid — and a SUN-8K-SG05LP1-EU-AM2-P instead exposes `TotalDCInputPower` + `DCPowerPV1/2/3`, neither anywhere in the tree. Because `pv_power` is in `DEYE_TELEMETRY_REQUIRED`, the miss fires the `device/latest missing expected keys [...] - telemetry will read zero` warning every cycle and the Cloud PV sensor publishes 0.0 while a manual `pv_power` arg to a local sensor may still protect the planner. Reproduced by swapping the test fixture's single PV key (`tests/test_deye_api.py` `LIVE_DATA_LIST`). Whether the alternate key is unit-equivalent (vs summing `DCPowerPV1-3`) needs a live capture from an affected model — suspected, not verified. CONFIRMED live (Sunsynk 2211093089, 2026-09-19, via `sunsynk.py --tou-test`) that the settings object exposes the TOU register block a SECOND time under unrelated names, so several keys that read like independent settings are the same memory at a different scale: `volt1`-`volt4` are `sellTime3`-`sellTime6` as minutes-since-midnight / 50, `volt5`-`volt10` are `sellTime1Pac`-`sellTime6Pac` / 10, and `volt11`, `volt12`, `current1`-`current4` are `sellTime1Volt`-`sellTime6Volt` x 10 — a contiguous 16-value window, matched 16/16 in both the before and after reads of one write. Two consequences. It is independent proof a TOU write reached the real registers rather than a cloud-side echo, which is the cheapest hardware verification available. And it is a trap: anything that wrote `volt*`/`current*` would silently corrupt the charge programme. Predbat is safe today only because `SUNSYNK_SYSTEM_MODE_FIELDS` excludes them, so treat that exclusion as load-bearing rather than incidental. The alias does NOT extend to what the write payload must contain — `time{n}On` (capital O) is a derived echo of `time{n}on` and is correctly excluded via `SUNSYNK_DERIVED_SLOT_FIELDS`. **GH#5138**: the (pre-#5360 wording) `control cache is X minutes old (limit 15)` forced-rewrite check is **startup-only** - it lives in `restore_state()`, called only from `run()`'s `if first:` block - so a reporter reading `limit 15` as "rechecks every 15 minutes" is wrong; it never runs again after the first cycle. The issue's core premise (idle slots never clear `sellTime{n}En`) was disproved: every slot `_owned_payload` write carries an explicit sell flag, idle slots are written with `sell: 0`, and the TOU build places a sell-0 baseline segment at each window's end (probe-verified; a `sellTime5En: 1` read-back just after an export window can be *correct*). The real gap it exposed - a write the cloud API acknowledged but the dongle never collected was trusted forever, because the owned-field change gate suppresses identical re-sends and `note_settle` only warned - is **fixed in PR #5142 (merged 2026-09-19)**: sustained settle divergence now clears `applied_payload[sn]` so the next write cycle re-applies. Keep the mechanism for pre-#5142 logs, where a stuck export slot needed a restart to clear. No debug yaml was ever attached, so the reporter's exact overrun is unconfirmed; the `has not applied Predbat's settings after N settings polls` warning is the tell. Two component-side traps since. **PR #5150** (merged 2026-09-19): `control_active` is now persisted and restored alongside `applied_payload` under the same 15-minute bound (`SUNSYNK_RESTORE_MAX_CONTROL`/`DEYE_RESTORE_MAX_CONTROL`), so a restart **inside** the bound no longer strands an armed inverter in silence; on the upgrade path a cache carrying `applied_payload` with no `control_active` key infers the missing half from the payload keys — a safe lower bound, since `applied_payload[sn]` is written only by `apply_settings`/`apply_dynamic_control`, whose production callers arm `control_active` first (the CLI paths apply with `force=True` and never save). **PR #5360 (merged 2026-10-05, unreleased at the time of writing, fixes GH#5349 below) has since split the two halves into separate caches with separate bounds:** `applied_payload` keeps the 15-minute bound (`SUNSYNK_RESTORE_MAX_CONTROL`/`DEYE_RESTORE_MAX_CONTROL`) while `control_active` gets 8 hours (`SUNSYNK_RESTORE_MAX_CONTROL_ACTIVE`/`DEYE_RESTORE_MAX_CONTROL_ACTIVE`, in a cache of its own, with a migration path from the pre-#5360 combined cache), and the restore log line splits to match - `Info: Sunsynk control payload cache is X minutes old (limit 15), forcing a rewrite` drops only the payload cache; a new `Info: Sunsynk control ownership cache is X minutes old (limit 480), requiring a fresh write` is the one that drops the ownership half. And the in-code comment on the Sunsynk write-button handler claiming Predbat "presses this on every cycle" is wrong about frequency: both press sites in `inverter.py` are change-gated (target SoC presses only when `current_soc != soc`, the export window only when `schedule_changed`), and the one unconditional post-restart re-commit is `is_hm_format`-only, which excludes these types' `HH:MM:SS` — so after a restart with the plan already matching the inverter, nothing is pressed for hours. Read the press gates in `inverter.py` before using that comment to dismiss a "component stopped writing after a restart" report. **GH#5349 (2026-10-02, fixed in PR #5360, merged 2026-10-05 - keep the mechanism for pre-#5360 logs) joined the two halves end to end:** on a pre-#5360 build an HA outage restarted Predbat with the (single) control cache 35.9 minutes old, restore dropped both `applied_payload` and `control_active`, and **nothing performed the forced rewrite on its own** - `_reconcile_control()` gates on `control_active`, `number_event` reaches Predbat only as in-memory `update_local_schedule()` updates (no apply), and both press sites stayed change-gated - so zero settings POSTs fired from 00:59 to 03:02 (counted from the reporter's full-API-logging log) while the hold re-armed every cycle and the inverter's idle-cap-5 programme drained ~9 kWh into the EV until a plan change finally pressed the write-button at 03:02. #5360 closes it by keeping `control_active` for 8 hours (see above), so the reconcile path can own the re-apply within that window. Reading the "forcing a rewrite" line as if a rewrite follows is still the trap - it is the notice that the payload cache was **dropped**; a "holds lost for hours after an HA blip / battery drained while Predbat showed holds armed" report on a pre-#5360 (v9.3.5-) build maps here, and on post-#5360 builds the `has not applied Predbat's settings after N settings polls` settle path is the remaining backstop. Device-visible hold legs on these types (same log): the TOU slot cap is the only one — a hold is `cap = SoC+1` in the programme (cap 65 held the battery at 64% while the car drew from grid; cap 5 drained it toward the inverter floor), and the `discharge_rate 0` entity write is symbolic on this type, so a "wrote 0 to discharge_rate but the battery drained" report reads the cap, not the entity. Also normal for this type: Sunsynk TOU charge windows cannot cross midnight (`can_span_midnight: False`) — a 23:00–00:30 window is written 23:00–23:59 and re-rolled to 00:00–00:30 at midnight (log-confirmed; not a bug). **GH#5156** (padding truncating any TOU window crossing 04:00/08:00/12:00, `build_tou_slots()` — now shared by both components in `tou_schedule.py`) is **fixed**: padding slots now carry the state of the slot they follow and sit just after it, an inert interval that cannot end a window early (`_padding_segments()` documents the issue). The pre-fix tell was "Deye stops charging at 00:00/04:00/08:00/12:00/16:00/20:00 while the Plan page still shows charging", freshly generated each cycle — not a stale default slot. **GH#5193 (cloud component, load telemetry):** on Sunsynk M3.3.8.9 the `totalPower` field (`load_power`, `SUNSYNK_TELEMETRY`) read 0–100 W through a whole overnight cycling stretch while the battery was active — including mode-2 ("Limited to Home") stretches — and read plausibly the adjacent morning with the battery idle, so the collapse correlates with the battery being active, not cleanly with the work mode; what gates it is unresolved (suspected; the reporter's manual A/B blamed the mode flip, the log's mode-2 stretches say something else does too — a controlled live test on the affected firmware is the only settle). `dailyUsed` (`load_today`) kept counting at a plausible coarse rate through the same stretch, so a balance-derived load remains feasible — and `fill_load_from_power()` is the repair that gets nothing while `totalPower` is zero. Predbat's own export control writes the whole-system `sysWorkMode` to `selling_first` for every planned export window (`derive_control_state()`, applied in the settings payload), and the component auto-wires both load args through `set_arg_auto()`'s default `overwrite=True`, so a manual apps.yaml override does NOT win — only `sunsynk_automatic: False` opts out. The in-file docstring on main records the mode/battery-export reading plus the live counter-evidence (11.1 kWh exported in a day under "Limited to Home" with `solarSell` on; a separate live test showed battery export under mode 2 with the per-slot sell flag), so the docstring's "the mode governs battery export" claim is DEYE-inferred and not settled — a control-side alternative (stay in mode 2, drive `sellTime{n}En`) stays open. The component logs every API request/response in full in `predbat.log`, so a whole Sunsynk-cloud investigation can run from the log alone; when mining logged JSON widen the field width before trusting a truncated number (`0.4` prints as `0.` under a 14-char `substr`). **Automatic config maps a battery-side current into `battery_rate_max`, and `inverter_limit` does not bound the written rate (GH#5238, code-read on main 2026-09-25/29):** `battery_rate_max()` derives watts from `chargeCurrentLimit`/`maxChargeCurrentLimit` × nominal pack voltage (`SUNSYNK_CHARGE_CURRENT_FIELDS`, first-positive-wins — a battery/BMS-side limit; GH#5238's 280 A BMS derived 14.3 kW against an 8 kW inverter), `automatic_config()` maps it to `battery_rate_max` and `ratePower` to `inverter_limit` but **never sets `inverter_limit_charge`/`inverter_limit_discharge`**, and `inverter.py` defaults both of those to `battery_rate_max_raw` when unset — so `battery_rate_max_charge = min(raw, raw) = raw`, and that is the per-slot power the plan writes. `prediction.py` clamps only *predicted* flow to `inverter_limit`; nothing feeds `inverter_limit` into the written rate. `deye.py`'s `battery_rate_max()` docstring states the same wrong assumption outright ("inverter_limit constrains the AC side separately" while deriving from `maxChargeCurrent`), and its `automatic_config()` has the same two-arg shape — so treat any "automatic config shows/writes an impossible rate" report for a cloud component through this lens first, and a fix has to map `inverter_limit_charge`/`_discharge` (to `min(ratePower, derived battery rate)`); deriving the true inverter-side current setting needs a telemetry mapping Sunsynk does not currently expose. Workaround semantics: `inverter_limit_charge`/`inverter_limit_discharge` are safe apps.yaml overrides under `sunsynk_automatic` (the component never sets them), while `battery_rate_max` is not — `set_arg_auto(overwrite=True)` clobbers an apps.yaml value every cycle. | `sunsynk_control`, `deye_control` | | Grid sign / arrow direction | `grid_power_invert` is owned by some integrations and not others. With two systems configured, one integration setting it `True` bleeds into the other's entities and inverts the arrows. The fix is an explicit `False` in the automatic config of both. | `sunsynk_config`, `teslemetry` | | Enphase (`enphase.py`) | Unofficial Enlighten endpoints. Accounts with MFA cannot log in at all. Discharge-to-grid schedules are required for export control. Writes need a double-submit CSRF token or return 403. Using the Enphase app at the same time can trip session limits. | `enphase_api` | | myenergi (`myenergi.py`) | `myenergi_automatic_zappi` (PR #4997) gates the Zappi half of automatic config; with it off, Zappis stay monitor-only and `control_active` is never set. `release_zappis()` has exactly one caller — `control_tick()` — reached only under `if self.control_active:` in `run()`, and `control_active` is latched at the first tick: every `enable_control()` refusal (zappi_control → automatic → automatic_zappi → enable_controls) returns *before* setting it, and the gate re-runs only when a fresh instance starts. So after a component restart with any prerequisite off, a Zappi already held in 'Stopped' stays Stopped and the `switch.*_myenergi_zappi_control` entity is not even republished (it is gated on `control_active` too) — the symptom for a future report is "car won't charge after I turned off Predbat" with a Zappi. Note `control_tick()` itself *does* release on read-only mode and on the control switch being turned off; it is the config-gate half that strands. Since PR #5150 (merged 2026-09-19) sunsynk/deye instead persist `control_active` across a restart (and re-infer it from a pre-upgrade cache), so a restart cannot strand an already-armed inverter there — the myenergi strand is the opposite direction (the gate never latching in the first place) and remains. Two adjacent lifecycle facts: published entities are **never removed** — `dashboard_item()`/`set_state()` are upsert-only (`output.py`, `ha.py` POST `/api/states`), so when a gate stops `publish_data()` publishing a switch the old entity lingers frozen at its last state until HA restarts, and there is no entity-removal path anywhere in the codebase; and `set_arg`/`set_arg_auto` writes survive a component-only restart, because `Components.restart()` stops and re-creates only the instance and never resets the shared `base.args` from apps.yaml — auto-wired values (e.g. myenergi's `car_charging_*`) persist until a full process restart. Distinguish the two restarts when checking arg lifecycle. | `myenergi` | -| Wallbox (`wallbox.py`) | Cloud API reimplemented from the Home Assistant `wallbox` integration and the `wallbox` PyPI library, over aiohttp. Findings from the first live runs (5 Oct 2026, a Pulsar Plus on firmware 6.7.43): sign in works with `User-Agent: Predbat`. **A charger run by an OCPP backend** (here Octopus, for Intelligent Octopus Go) reports `config_data.operation_mode: "ocpp"`, sits in status 209 Locked, and an API unlock (`PUT v2/charger/` `{"locked": 0}`) is accepted but has no effect - so "the lock switch flips back" on such a charger is the backend, not a bug. On that locked charger a pause returned **HTTP 403** and a resume **HTTP 409**, from an account whose profile is super-admin: 403 on a control does *not* prove missing admin rights, whatever the HA integration assumes. `held_by_lock()` therefore sends neither while locked, and `held_by_ocpp()` keeps plan-led control off such a charger. `GET v3/chargers//ocpp-configuration` returns the backend address and the charge point credentials - **its reply contains a password, so never paste `--get` output unredacted**; no OCPP on/off write call is known. `locked` arrives as `1`/`0`, not a boolean, and `state_of_charge` is always null (a Type 2 connector cannot report it). Charger N is car N through `car_order`, which is numeric and append-only, so a status failure or a charger added later cannot shift it. Not yet verified on hardware when this was written: pause/resume on an unlocked, charging, non-OCPP charger; the real status ids for charging and paused; whether a 120s poll stays clear of HTTP 429. `python3 wallbox.py --username --raw` is the capture tool (it prompts for the password). Release bookkeeping: `paused_by_predbat` and `schedule_pending` are stored through Storage *before* a pause is sent, a record is dropped only on a fresh status that settles it (unplugged, or connected and no longer paused), and each charger's control step is isolated in `per_charger()` so one charger's failure cannot block another - if "a charger stayed paused after control was turned off" is reported, read `release_one()` first. | +| Wallbox (`wallbox.py`) | Component landed on main in PR #5410 (merged 2026-10-05, unreleased at the time of writing). Cloud API reimplemented from the Home Assistant `wallbox` integration and the `wallbox` PyPI library, over aiohttp. Findings from the first live runs (5 Oct 2026, a Pulsar Plus on firmware 6.7.43): sign in works with `User-Agent: Predbat`. **A charger run by an OCPP backend** (here Octopus, for Intelligent Octopus Go) reports `config_data.operation_mode: "ocpp"`, sits in status 209 Locked, and an API unlock (`PUT v2/charger/` `{"locked": 0}`) is accepted but has no effect - so "the lock switch flips back" on such a charger is the backend, not a bug. On that locked charger a pause returned **HTTP 403** and a resume **HTTP 409**, from an account whose profile is super-admin: 403 on a control does *not* prove missing admin rights, whatever the HA integration assumes. `held_by_lock()` therefore sends neither while locked, and `held_by_ocpp()` keeps plan-led control off such a charger. `GET v3/chargers//ocpp-configuration` returns the backend address and the charge point credentials - **its reply contains a password, so never paste `--get` output unredacted**; no OCPP on/off write call is known. `locked` arrives as `1`/`0`, not a boolean, and `state_of_charge` is always null (a Type 2 connector cannot report it). Charger N is car N through `car_order`, which is numeric and append-only, so a status failure or a charger added later cannot shift it. Not yet verified on hardware when this was written: pause/resume on an unlocked, charging, non-OCPP charger; the real status ids for charging and paused; whether a 120s poll stays clear of HTTP 429. `python3 wallbox.py --username --raw` is the capture tool (it prompts for the password). Release bookkeeping: `paused_by_predbat` and `schedule_pending` are stored through Storage *before* a pause is sent, a record is dropped only on a fresh status that settles it (unplugged, or connected and no longer paused), and each charger's control step is isolated in `per_charger()` so one charger's failure cannot block another - if "a charger stayed paused after control was turned off" is reported, read `release_one()` first. | | Gateway MQTT (`gateway.py`) | Control writes are MQTT commands the hub acknowledges. **The ack design arrived with PR #5220 and was refined by PR #5231 (both merged 2026-09-26)** — both postdate every earlier gateway log, so keep a publish-per-call reading for pre-#5220 logs: `_subscribe_acks()` subscribes to `predbat/devices//ack/+`, tolerating a broker that refuses it (a single warn via `_ack_subscribe_warned`; without the subscription or before any ack, `_send_control()` publishes exactly as before); once acks are seen, an identical command (same entity, command, payload) is published once per `_COMMAND_ACK_WINDOW` (30s), re-sent once after the unanswered window, and if that re-send is also unanswered `_acks_seen` drops so writes fall back to publish-every-call until telemetry confirms; `_process_ack()` matches any tracked id of the current value, a refusal outranks an ok (a multi-unit dispatch acks once per unit), and replay errors are handled; ids come from a clock-seeded monotonic counter (`_next_command_id()`, `PBAT`), so they never restart at `PBAT1` after a restart, and the kept-id list (`_COMMAND_ACK_IDS_KEPT`) is pruned only after the new send is out, with an ack that arrives during a failing publish still counting. **PR #5172 is a separate, still-open implementation of the same feature** (`_subscribe_command_acks`, `uuid4().hex` ids, typed outcomes threaded through service dispatch/HTTP/inverter verifier) — its symbols are not on main until it rebases, so do not start a gateway-ack triage from its names. **Two gaps around serial-less and empty status, probed on main 2026-09-25 (GH#5227, enhancement — probed with temporary tests in `test_gateway.py`, reverted after):** (1) a sole slot publishing `serial=""` (shipped firmware 1.0.0 publishes every slot, including a GivEnergy slot whose serial discovery failed) is bound as the control target: `_needs_reconfigure()` treats `""` as a newly discovered inverter, `_needs_reconfigure()` treats `""` as a newly discovered inverter and auto-config binds it — note the last-resort branch that used to fall back to `candidate_aios or list(all_inverters)` was removed in `eaef6e61` (2026-10-04), which now defers auto-config entirely with a warn when no battery-capable telemetry has arrived (`_auto_configured` stays False so `_needs_reconfigure()` retries); an empty-serial slot that does report battery telemetry is still bound, so the trap survives on that shape, and the result is empty-suffix entities (`select.predbat_gateway__charge_slot1_start`) and commands addressed to `""`, which the firmware rejects (`dongle_serial required`) — a real fleet incident on 2026-09-16 (~22 min of failed commands); an empty-serial guard in `_needs_reconfigure()`/`automatic_config()` would close it, and #5227's proposed `dongle_count` deferral does *not* cover this state (`dongle_count == len(inverters)` when the empty slot is the only one). (2) `if len(status.inverters) == 0: return` (`gateway.py`) fires **before** `_inject_entities()` (EV chargers, gateway-online) and before `_last_telemetry_time`/`update_success_timestamp()` — on a hub where all GivEnergy slots are withheld (post-predbat-gateway#335, e.g. a single-inverter hub), every status message is dropped: EV data freezes and the component fails the 60-minute staleness test (`components.py`). Symptom pointer: "gateway EV sensors froze / gateway component unhealthy while the hub is up" → this early return. Removals staying bound and reappearance triggering re-config are already the behaviour (probed). **Probe trap:** the topology branch in `automatic_config()` depends on the *whole* visible set — a probe that adds a Gateway alongside the `""` slot drops the `""` entry via the battery-presence filter (`aios` requires `battery.ByteSize() > 0`) so it never reaches the last-resort branch; construct the exact visible set the scenario implies, and note the filter silently hides data-less slots from the count in several branches (the same mechanism can make a Gateway+2-AIO fleet collapse to AIO-direct control). Existing precedent for topology probes: `TestGatewayUnitControlBinding` (`test_gateway.py`). | `gateway` | -| Octopus (`octopus.py`, `fetch.py`) | Intelligent Go tariffs are detected via `is_intelligent_go_tariff()`, and IOG-prefixed tariffs must be skipped when updating intelligent devices. Saving-session auto-join rebinding regressed when `joined_events` was empty (GH#4573). `octopus_slots_signature()` deliberately omits the time-drifting fields of active dispatch slots so a replan is not forced every cycle. `car_charging_threshold` is a strict fallback gated on `not self.car_charging_energy` (`fetch.py:228`, and `load_ml_component.py:348-361`) — it never runs as a second filter alongside a real `car_charging_energy` sensor (GH#4717). Saving-session reporting credits the full saving rate to every minute of the session on both rate tables (`load_saving_slot()`, `octopus.py:2786`); the slot dict has no baseline field to subtract (GH#2090) — a complaint that a saving session's reported total looks inflated starts here, not in a rate-fetch bug. A tariff with no `standard_unit_rates` link (IOG-TOU, GO) goes down `async_get_day_night_rates()`, which infers the off-peak window from 7 days of measurement TOU labels by plurality vote. On IOG those labels include ad-hoc dispatch slots, so a bonus slot recurring on 4+ of 7 nights became a permanent nightly cheap window and Predbat charged into it at the day rate (PR #4854 skips the inference for IOG). A "cheap slot Octopus has never heard of" report where the dispatch feeds are *empty* is this, not phantom dispatches — check the log for `Using off-peak windows [...] from measurement TOU labels`. TOU labels are UTC instants, so windows derived from them must be re-anchored for a local wall-clock tariff or they drift an hour at DST. Free-session slots have their own family of gates, separate from the saving-session ones and each found the hard way. `octopus_free_session` events used to be dropped when `code` was null, which is exactly how Octopus publishes auto-joined Weekend Happy Hours - fixed, the gate is now `start and end` and the log falls back to the event id (GH#4835). The free/saving-session args name **event** entities (`attribute="events"`/`"joined_events"`, `octopus.py`), and the BottlecapDave integration exposes Octoplus sessions as calendar + event pairs of which only the event entity carries those state attributes - the calendars have none at all, and both sides ship **disabled by default** in the integration. "The documented sensor name does not exist" plus a proposal to point at the `calendar.` twin is almost always the disabled-default trap, not a rename (GH#5370, verified against the integration's source): enable the event entity; the calendar twin cannot back these args. Matching calendar/event names differ by the `_events` suffix, so a pattern built from one domain's names mistranslates, and the deprecated pre-v17 names are scheduled for removal in January 2027. `load_free_slot()` bounded itself with `start_minutes < self.forecast_minutes` while rate minutes are indexed from `midnight_utc`, so any event more than `forecast_minutes` past midnight was dropped with no log line at all - fixed in `3bacc6e8`, the gate and the end clamp now both use `forecast_minutes + minutes_now` like `load_saving_slot()` (GH#4931). In the same function the start/end range was only updated on a successful decode while the apply block ran unconditionally, so an undecodable slot re-applied its rate over the *previous* slot's minute range (PR #4935). The join side has its own: the `joined_events` guard still ends in `saving_rate > 0` (`octopus.py:3542`), so `octopoints_per_kwh: 0` - a genuine free hour - yields no slot of any kind; probe-verified (0 gives nothing, 500 gives a saving slot, null gives nothing), and `git blame` puts that guard in `1b8c9136`, the fix for null-rate sessions (GH#3079), so it is an oversight rather than a deliberate exclusion (GH#4851). On dispatches, `rate_add_io_slots()`'s `location` check used to apply to *completed* dispatches too, and `rate_import` is rebuilt every cycle, so a retroactive AT_HOME to AWAY relabel silently un-stamped the cheap rate and `today_cost()` re-priced the whole day at the day rate (GH#4946) - **fixed in PR #4957**, which added `dispatch_billed_off_peak()` (`octopus.py:2977`): a completed dispatch is billed off-peak regardless of location, a straddling one keeps the location test, and the midday budget cap still applies. Keep the mechanism in mind when reading a log from before that merge, where the whole day reprices at the day rate. A neighbouring cap bug had no entry here at all: the daily low-rate slot budget was keyed on the *loop* minute rather than the slot start, so a window straddling noon drew a fresh 12-slot budget at 12:00 and stamped the afternoon cheap (GH#4950, **fixed in PR #4951**). The midday boundary itself is deliberate (`337db867`); only the keying was wrong. Worth knowing because "a cheap slot at an hour Octopus never offered" has at least three distinct causes in this row alone. Compare has its own rate-fetch gap: `download_octopus_rates_func()` (`octopus.py:2725`) reads only `standard-unit-rates`, and the newer IOG-SMB-FIX products are `four_rate_ev` - that endpoint answers 200 with empty results and the rates live on `day-unit-rates`/`night-unit-rates`. The main OctopusAPI component already auto-detects that product shape; compare never got the same treatment (GH#4921, probed against the live API). Lastly, `{dno_region}` in a tariff URL is substituted by `resolve_arg`'s `.format(**self.args)` (`userinterface.py:106-116`), so a missing `-` before the placeholder glues the region letter onto the product code and yields a plausible-looking 404 - check the literal URL in apps.yaml before believing a tariff has been withdrawn. Two later additions to the free/saving-session picture, one of them a correction. **eventType is carried only by the legacy `savingSessions` query path — the flexibility feed drops it (GH#4548, re-verified on main 2026-09-29 by reading `async_get_flexibility_events()`):** that function extracts only `code`/`startAt`/`endAt` from `customerFlexibilityCampaignEvents` and maps every saving event into `joinedEvents` with **no eventType**, so on the Direct path with an MPAN the joined-event loop's free-slot branch (`event_type == "WEEKEND_HAPPY_HOUR"`) can never fire and a joined Happy Hour falls through to the rewarded-saving-slot branch whenever `octopus_saving_session_rate` is set above 0. The legacy query carries eventType (the `savingSessions` GraphQL request names it and its map stores it) and is reached on the Direct path only as the flexibility path's own fallback (no MPAN, or the flexibility API returned no saving events). eventType remains the only sound discriminator between a Power Down saving session and a free Power Up/Happy Hour — the field GH#4851's `saving_rate > 0` problem needed and did not have — but *which query path served the events* decides whether you have it: "free Power Up priced as a paid saving session" on the flexibility path is first a question of provenance, and waiting for the BottlecapDave API before building on the newer feed is deliberate, not an oversight. The Direct join mutation is likewise still the legacy `joinSavingSessionsEvent`. And a genuinely counter-intuitive ordering constraint, worth reading before touching that function: Weekend Happy Hours are now skipped from `available_events` (they cannot be joined through the API - Octopus allocates them or the user books on the website), but **the skip has to sit after the reward/code/type maps are populated**, because those maps are built from the same events list and a joined Happy Hour looks its own type up there. Skip too early and the joined event has no type, takes the injected default reward, and the planner prices a free hour as an **80p/kWh saving session** (`c0fb4e9c`; the test fails if the skip is moved above the maps). Two rate-provenance reports from mid-September. **GH#5012** (car unplugged, plan still charged the house battery in the phantom cheap window): `octopus_intelligent_ignore_unplugged` correctly removed the planned dispatches, but the cheap price came from the integration's own rate sensor, not Predbat's stamping — parse the debug yaml and compare, per suspect minute, `rate_import_base` vs `rate_import_no_io` vs `io_adjusted`: a cheap price present in base and no_io with `io_adjusted` empty is the integration's rate sensor; present only after no_io is Predbat's `rate_add_io_slots()` stamping (#4516/#4950 family); `io_adjusted` populated means the minutes were flagged as adjusted — but **since PR #5304 (v9.3.3) Predbat's own `rate_add_io_slots()` flags the minutes it lowers too** (it used to write no marker at all; it mirrors the integration's `is_intelligent_adjusted`, never the fixed off-peak, time over the daily cap, or a cancelled car), so a populated `io_adjusted` no longer discriminates the integration's stamp from Predbat's own; the base-vs-`no_io` comparison still does, and `rate_replicate()` still refuses to copy `io_adjusted`-flagged minutes into future days. This attributes "integration feeds the wrong price" vs "Predbat stamps the wrong price" in one step. Nothing reverts `io_adjusted`-flagged minutes when the car is unplugged + ignore_unplugged is on — that fix would be an enhancement, blocked in practice by the integration not flagging stale adjusted entries (`io_adjusted` was empty in this dump). **`io_adjusted` itself was for a while wiped by the export/gas fetch (GH#5286, fixed in PR #5290, merged 2026-09-28):** `fetch_octopus_rates()` replaced `self.io_adjusted` on every call, so the export and gas fetches that follow the import fetch reset it to `{}` whenever `metric_octopus_export`/`metric_octopus_gas` were set — regression from #2826 dropping the old "only when adjust_key is set" guard — and with no markers left, `dynamic_load_car_strip_feed_rates()` had nothing to strip, so a car cancelled out of a dispatch still left it priced cheap and the house battery planned into it. The fix makes `self.io_adjusted` replaced only when the fetch carries an `adjust_key`; keep the mechanism for pre-#5290 logs. A companion fix (same PR) stops a compared tariff inheriting the live tariff's dispatch markers — each compared tariff now starts with none, and one left on the live import rates gets a copy. A user who instead wants the house battery limited to minutes the car actually charges has no clean knob (GH#5065): `octopus_intelligent_charging` off does **not** stop the stamping — the switch gates only car planning and vehicle prefs, planned slots are collected into `self.octopus_slots` regardless (gated only by `octopus_intelligent_ignore_unplugged`, which covers unplugged, not plugged-in-but-deferred) and are consumed unconditionally by `rate_add_io_slots()`. `octopus_slot_max: 0` is not selective either — the cap is applied in `load_octopus_slots()` as well as `rate_add_io_slots()`, so it removes the car's charging slots too. The only complete workaround is unsetting `octopus_intelligent_slot`, which loses completed-slot tracking and Octopus car planning with it. **GH#5018** (tariff switched mid-day; tomorrow's export rates stayed on the old tariff and Nordpool estimates never appeared): Predbat re-reads Octopus rate events from HA every fetch cycle (`fetch_octopus_rates`), so stale export rates are the HA integration still serving the old contract — reload the integration. Two Predbat-side traps in the same report: `futurerate` only fills *missing* minutes (the `if minute not in rates` gate in fetch.py), so stale-but-present rates always win over Nordpool estimates; and `futurerate_adjust_auto` re-runs at every FutureRate init (one per fetch cycle) and persists via `set_arg`, so mid-switch it can persist `False` over the user's manual `futurerate_adjust_export: true` — log signature `FutureRate: No futurerate adjustment enabled, skipping futurerate analysis` repeating every cycle. The debug yaml does **not** contain the Octopus rate events (grep for `day_rates` comes up empty), so replays can't reproduce integration-served rate data; use the log's `Export rates: min/max/average` lines as the provenance check (identical min/max across days = static served data, and a fixed tariff has a fixed shape while a variable one doesn't). `self.mpan` is the **import** MPAN only — `async_find_tariffs()` sets it once from the first active import agreement's `meterPoint` and never overwrites it; an account with export has import and export as separate agreements with different MPANs, and the export one is retained nowhere (PR #4972 review). The saving-session/free-electricity GraphQL queries use `self.mpan` deliberately because those campaigns are import-side; any feature that needs to identify a *meter* cannot take `self.mpan` (PR #4972, merged 2026-09-19, adds `self.tariffs[direction]["mpan"]`). Symptom: two things that should describe different supply points coming out identical — that is this, not a deduplication bug. **GH#5144** (fixed in PR #5145): the REST tariff endpoints return overlapping `DIRECT_DEBIT`/`NON_DIRECT_DEBIT` rows for the same validity window and `minute_data()` writes each row over its range, so whichever row came last in the response won — and the order is not stable across periods, so the displayed rate could flip between variants from one period to the next ("always the higher rate" was an artefact of that ordering, not a property of the bug). `filter_payment_method()` (`utils.py`) now keeps one variant at the parse point on the component, day/night, annual and minute-data paths — preferring `DIRECT_DEBIT`, keeping rows with no `payment_method` (Agile) untouched, and leaving single-variant tariffs unchanged; null `payment_method` is the common case, so any future filter must keep nulls. **The daily cheap-slot cap is derived per account but enforced per car (GH#5215, semantics unresolved):** `get_octopus_slot_max()` (`octopus.py`) takes no `car_n` — it resolves the cap from the *account's* import tariff code via `has_six_hour_cap()` (matches `IOG-SMB` → 12) or a single apps.yaml integer — while both enforcement counters are locals (`slots_per_day` in `rate_add_io_slots()` and in `load_octopus_slots()`), and both callers run once per car, so each car draws a fresh budget: up to `octopus_slot_max × num_cars` cheap half-hours priced per day. Do **not** assume the docs' per-car sentence is simply wrong: the derivation is account-level but Octopus's own blog says "*Your car gets up to 6 hours of off-peak charging per day*" (quoted in GH#4830, the multi-car confusion thread), so the code is internally inconsistent and the intended semantics is genuinely open. Exposure is invisible outside IOG-SMB — uncapped tariffs default `octopus_slot_max` to 48 — so only capped multi-car installs can see the doubling; existing tests encode the per-car behaviour, and `octopus_slot_max` is read without `index=` so per-car configuration does not exist either way. **The IOG slot-confirmation strip removes the house's cheap rate too when a car's dispatch is cancelled (GH#5335, with the #5317 family - code chain on main 57ec7bf1):** with `octopus_intelligent_dynamic` on (default), `dynamic_load_car_check()` (`plan.py`) cancels every slot of a car that sits inside a started dispatch but is not seen charging after the confirmation grace (log: `car 0 is in a dispatch but not charging, cancelling its slots`), and `dynamic_load_car_strip_feed_rates()` (`octopus.py`) re-prices the house's view of those minutes back to `rate_max_base` when the cheapness came from the integration feed, **exempting the fixed IOG band** (`OCTOPUS_NIGHT_RATE_WINDOWS["iog"]` = 23:30–05:30) — so a house battery that had planned into the dispatch loses its cheap stamp as well, and PV10 re-prices the minutes at `rate_max` outright (prediction.py's `dispatch_gone` line, `minute > 30`). Version tell: the strip is v9.3.2+ code — a reporter's deliberate mid-log downgrade left the A/B in one `predbat.log`: v9.3.3 logged `Octopus Intelligent: removed the dispatch rate from 355 minutes of cars [0] which are not charging` while v9.3.1 kept the 6.57p dispatch rate (`Charging target 2%-100%`); workaround (semantics read, not live-tested): `octopus_intelligent_dynamic` off = no confirmation and no strip, ≈9.3.1 behaviour at the cost of #5229's low-load protection. Check the car side first, though: Predbat must *see* the car charging to confirm the slot, and a `plug_status` string (`eco`/`boost`) that never equals `car_charging_now_response: charging` leaves the car "not charging" through a real charge — check the sensor's own history before blaming the planner. **#5316 is the mid-dispatch variant, fixed in PR #5319 (merged 2026-10-03) — keep the pre-#5319 signature:** a car that stops part-way through a running dispatch half hour used to have the rest of that half hour re-priced at the day rate; the strip paths (`rate_add_io_slots()`, `dynamic_load_car_strip_feed_rates()`) now start from `dynamic_load_car_strip_from()` (`plan.py`) — the end of the half hour the car was last seen charging in, which is billed off-peak in full by Octopus — and the confirmation is never cleared, only outlived. Review-round refinements ride along: a sensor reading confirms a half hour only from 2 minutes into it, and the grace clock is not kept across a restart (saved state is judged on the skewed clock). The bounce symptom itself — the battery flipping Charge↔Demand while a car charges in bursts — is the supervision working as coded (GH#5390, log-verified): a non-"charging" reading counts after `DYNAMIC_LOAD_CAR_START_MINUTES` (3 min) inside the dispatch, the cheap dispatch rate is stripped from the car's still-future slots (house plan re-prices those minutes) and it resumes on the next "charging" reading — ground-truth with the hourly `Last hour: ... car X kWh` log lines against Octopus's own per-slot amounts before calling it a bug. | `octopus_*`, `saving_session*` | +| Octopus (`octopus.py`, `fetch.py`) | Intelligent Go tariffs are detected via `is_intelligent_go_tariff()`, and IOG-prefixed tariffs must be skipped when updating intelligent devices. Saving-session auto-join rebinding regressed when `joined_events` was empty (GH#4573). `octopus_slots_signature()` deliberately omits the time-drifting fields of active dispatch slots so a replan is not forced every cycle. `car_charging_threshold` is a strict fallback gated on `not self.car_charging_energy` (`fetch.py:228`, and `load_ml_component.py:348-361`) — it never runs as a second filter alongside a real `car_charging_energy` sensor (GH#4717). Saving-session reporting credits the full saving rate to every minute of the session on both rate tables (`load_saving_slot()`, `octopus.py:2786`); the slot dict has no baseline field to subtract (GH#2090) — a complaint that a saving session's reported total looks inflated starts here, not in a rate-fetch bug. A tariff with no `standard_unit_rates` link (IOG-TOU, GO) goes down `async_get_day_night_rates()`, which infers the off-peak window from 7 days of measurement TOU labels by plurality vote. On IOG those labels include ad-hoc dispatch slots, so a bonus slot recurring on 4+ of 7 nights became a permanent nightly cheap window and Predbat charged into it at the day rate (PR #4854 skips the inference for IOG). A "cheap slot Octopus has never heard of" report where the dispatch feeds are *empty* is this, not phantom dispatches — check the log for `Using off-peak windows [...] from measurement TOU labels`. TOU labels are UTC instants, so windows derived from them must be re-anchored for a local wall-clock tariff or they drift an hour at DST. Free-session slots have their own family of gates, separate from the saving-session ones and each found the hard way. `octopus_free_session` events used to be dropped when `code` was null, which is exactly how Octopus publishes auto-joined Weekend Happy Hours - fixed, the gate is now `start and end` and the log falls back to the event id (GH#4835). The free/saving-session args name **event** entities (`attribute="events"`/`"joined_events"`, `octopus.py`), and the BottlecapDave integration exposes Octoplus sessions as calendar + event pairs of which only the event entity carries those state attributes - the calendars have none at all, and both sides ship **disabled by default** in the integration. "The documented sensor name does not exist" plus a proposal to point at the `calendar.` twin is almost always the disabled-default trap, not a rename (GH#5370, verified against the integration's source): enable the event entity; the calendar twin cannot back these args. Matching calendar/event names differ by the `_events` suffix, so a pattern built from one domain's names mistranslates, and the deprecated pre-v17 names are scheduled for removal in January 2027. `load_free_slot()` bounded itself with `start_minutes < self.forecast_minutes` while rate minutes are indexed from `midnight_utc`, so any event more than `forecast_minutes` past midnight was dropped with no log line at all - fixed in `3bacc6e8`, the gate and the end clamp now both use `forecast_minutes + minutes_now` like `load_saving_slot()` (GH#4931). In the same function the start/end range was only updated on a successful decode while the apply block ran unconditionally, so an undecodable slot re-applied its rate over the *previous* slot's minute range (PR #4935). The join side has its own: the `joined_events` guard still ends in `saving_rate > 0` (`octopus.py:3542`), so `octopoints_per_kwh: 0` - a genuine free hour - yields no slot of any kind; probe-verified (0 gives nothing, 500 gives a saving slot, null gives nothing), and `git blame` puts that guard in `1b8c9136`, the fix for null-rate sessions (GH#3079), so it is an oversight rather than a deliberate exclusion (GH#4851). On dispatches, `rate_add_io_slots()`'s `location` check used to apply to *completed* dispatches too, and `rate_import` is rebuilt every cycle, so a retroactive AT_HOME to AWAY relabel silently un-stamped the cheap rate and `today_cost()` re-priced the whole day at the day rate (GH#4946) - **fixed in PR #4957**, which added `dispatch_billed_off_peak()` (`octopus.py:2977`): a completed dispatch is billed off-peak regardless of location, a straddling one keeps the location test, and the midday budget cap still applies. Keep the mechanism in mind when reading a log from before that merge, where the whole day reprices at the day rate. A neighbouring cap bug had no entry here at all: the daily low-rate slot budget was keyed on the *loop* minute rather than the slot start, so a window straddling noon drew a fresh 12-slot budget at 12:00 and stamped the afternoon cheap (GH#4950, **fixed in PR #4951**). The midday boundary itself is deliberate (`337db867`); only the keying was wrong. Worth knowing because "a cheap slot at an hour Octopus never offered" has at least three distinct causes in this row alone. Compare has its own rate-fetch gap: `download_octopus_rates_func()` (`octopus.py:2725`) reads only `standard-unit-rates`, and the newer IOG-SMB-FIX products are `four_rate_ev` - that endpoint answers 200 with empty results and the rates live on `day-unit-rates`/`night-unit-rates`. The main OctopusAPI component already auto-detects that product shape; compare never got the same treatment (GH#4921, probed against the live API). Lastly, `{dno_region}` in a tariff URL is substituted by `resolve_arg`'s `.format(**self.args)` (`userinterface.py:106-116`), so a missing `-` before the placeholder glues the region letter onto the product code and yields a plausible-looking 404 - check the literal URL in apps.yaml before believing a tariff has been withdrawn. Two later additions to the free/saving-session picture, one of them a correction. **eventType is carried only by the legacy `savingSessions` query path — the flexibility feed drops it (GH#4548, re-verified on main 2026-09-29 by reading `async_get_flexibility_events()`):** that function extracts only `code`/`startAt`/`endAt` from `customerFlexibilityCampaignEvents` and maps every saving event into `joinedEvents` with **no eventType**, so on the Direct path with an MPAN the joined-event loop's free-slot branch (`event_type == "WEEKEND_HAPPY_HOUR"`) can never fire and a joined Happy Hour falls through to the rewarded-saving-slot branch whenever `octopus_saving_session_rate` is set above 0. The legacy query carries eventType (the `savingSessions` GraphQL request names it and its map stores it) and is reached on the Direct path only as the flexibility path's own fallback (no MPAN, or the flexibility API returned no saving events). eventType remains the only sound discriminator between a Power Down saving session and a free Power Up/Happy Hour — the field GH#4851's `saving_rate > 0` problem needed and did not have — but *which query path served the events* decides whether you have it: "free Power Up priced as a paid saving session" on the flexibility path is first a question of provenance, and waiting for the BottlecapDave API before building on the newer feed is deliberate, not an oversight. The Direct join mutation is likewise still the legacy `joinSavingSessionsEvent`. And a genuinely counter-intuitive ordering constraint, worth reading before touching that function: Weekend Happy Hours are now skipped from `available_events` (they cannot be joined through the API - Octopus allocates them or the user books on the website), but **the skip has to sit after the reward/code/type maps are populated**, because those maps are built from the same events list and a joined Happy Hour looks its own type up there. Skip too early and the joined event has no type, takes the injected default reward, and the planner prices a free hour as an **80p/kWh saving session** (`c0fb4e9c`; the test fails if the skip is moved above the maps). Two rate-provenance reports from mid-September. **GH#5012** (car unplugged, plan still charged the house battery in the phantom cheap window): `octopus_intelligent_ignore_unplugged` correctly removed the planned dispatches, but the cheap price came from the integration's own rate sensor, not Predbat's stamping — parse the debug yaml and compare, per suspect minute, `rate_import_base` vs `rate_import_no_io` vs `io_adjusted`: a cheap price present in base and no_io with `io_adjusted` empty is the integration's rate sensor; present only after no_io is Predbat's `rate_add_io_slots()` stamping (#4516/#4950 family); `io_adjusted` populated means the minutes were flagged as adjusted — but **since PR #5304 (v9.3.3) Predbat's own `rate_add_io_slots()` flags the minutes it lowers too** (it used to write no marker at all; it mirrors the integration's `is_intelligent_adjusted`, never the fixed off-peak, time over the daily cap, or a cancelled car), so a populated `io_adjusted` no longer discriminates the integration's stamp from Predbat's own; the base-vs-`no_io` comparison still does, and `rate_replicate()` still refuses to copy `io_adjusted`-flagged minutes into future days. Since PR #5396 (merged 2026-10-05, unreleased at the time of writing) a minute is not marked `io_adjusted` at all when the dispatch price is within `IO_RATE_TOLERANCE` (0.01p) of what it replaced - the unrounded tariff's 5.2314p off-peak receiving a 5.23p dispatch is dp2 rounding, not a dispatch (GH#5392). This attributes "integration feeds the wrong price" vs "Predbat stamps the wrong price" in one step. Nothing reverts `io_adjusted`-flagged minutes when the car is unplugged + ignore_unplugged is on — that fix would be an enhancement, blocked in practice by the integration not flagging stale adjusted entries (`io_adjusted` was empty in this dump). **`io_adjusted` itself was for a while wiped by the export/gas fetch (GH#5286, fixed in PR #5290, merged 2026-09-28):** `fetch_octopus_rates()` replaced `self.io_adjusted` on every call, so the export and gas fetches that follow the import fetch reset it to `{}` whenever `metric_octopus_export`/`metric_octopus_gas` were set — regression from #2826 dropping the old "only when adjust_key is set" guard — and with no markers left, `dynamic_load_car_strip_feed_rates()` had nothing to strip, so a car cancelled out of a dispatch still left it priced cheap and the house battery planned into it. The fix makes `self.io_adjusted` replaced only when the fetch carries an `adjust_key`; keep the mechanism for pre-#5290 logs. A companion fix (same PR) stops a compared tariff inheriting the live tariff's dispatch markers — each compared tariff now starts with none, and one left on the live import rates gets a copy. A user who instead wants the house battery limited to minutes the car actually charges has no clean knob (GH#5065): `octopus_intelligent_charging` off does **not** stop the stamping — the switch gates only car planning and vehicle prefs, planned slots are collected into `self.octopus_slots` regardless (gated only by `octopus_intelligent_ignore_unplugged`, which covers unplugged, not plugged-in-but-deferred) and are consumed unconditionally by `rate_add_io_slots()`. `octopus_slot_max: 0` is not selective either — the cap is applied in `load_octopus_slots()` as well as `rate_add_io_slots()`, so it removes the car's charging slots too. The only complete workaround is unsetting `octopus_intelligent_slot`, which loses completed-slot tracking and Octopus car planning with it. **GH#5018** (tariff switched mid-day; tomorrow's export rates stayed on the old tariff and Nordpool estimates never appeared): Predbat re-reads Octopus rate events from HA every fetch cycle (`fetch_octopus_rates`), so stale export rates are the HA integration still serving the old contract — reload the integration. Two Predbat-side traps in the same report: `futurerate` only fills *missing* minutes (the `if minute not in rates` gate in fetch.py), so stale-but-present rates always win over Nordpool estimates; and `futurerate_adjust_auto` re-runs at every FutureRate init (one per fetch cycle) and persists via `set_arg`, so mid-switch it can persist `False` over the user's manual `futurerate_adjust_export: true` — log signature `FutureRate: No futurerate adjustment enabled, skipping futurerate analysis` repeating every cycle. The debug yaml does **not** contain the Octopus rate events (grep for `day_rates` comes up empty), so replays can't reproduce integration-served rate data; use the log's `Export rates: min/max/average` lines as the provenance check (identical min/max across days = static served data, and a fixed tariff has a fixed shape while a variable one doesn't). `self.mpan` is the **import** MPAN only — `async_find_tariffs()` sets it once from the first active import agreement's `meterPoint` and never overwrites it; an account with export has import and export as separate agreements with different MPANs, and the export one is retained nowhere (PR #4972 review). The saving-session/free-electricity GraphQL queries use `self.mpan` deliberately because those campaigns are import-side; any feature that needs to identify a *meter* cannot take `self.mpan` (PR #4972, merged 2026-09-19, adds `self.tariffs[direction]["mpan"]`). Symptom: two things that should describe different supply points coming out identical — that is this, not a deduplication bug. **GH#5144** (fixed in PR #5145): the REST tariff endpoints return overlapping `DIRECT_DEBIT`/`NON_DIRECT_DEBIT` rows for the same validity window and `minute_data()` writes each row over its range, so whichever row came last in the response won — and the order is not stable across periods, so the displayed rate could flip between variants from one period to the next ("always the higher rate" was an artefact of that ordering, not a property of the bug). `filter_payment_method()` (`utils.py`) now keeps one variant at the parse point on the component, day/night, annual and minute-data paths — preferring `DIRECT_DEBIT`, keeping rows with no `payment_method` (Agile) untouched, and leaving single-variant tariffs unchanged; null `payment_method` is the common case, so any future filter must keep nulls. **The daily cheap-slot cap is derived per account but enforced per car (GH#5215, semantics unresolved):** `get_octopus_slot_max()` (`octopus.py`) takes no `car_n` — it resolves the cap from the *account's* import tariff code via `has_six_hour_cap()` (matches `IOG-SMB` → 12) or a single apps.yaml integer — while both enforcement counters are locals (`slots_per_day` in `rate_add_io_slots()` and in `load_octopus_slots()`), and both callers run once per car, so each car draws a fresh budget: up to `octopus_slot_max × num_cars` cheap half-hours priced per day. Do **not** assume the docs' per-car sentence is simply wrong: the derivation is account-level but Octopus's own blog says "*Your car gets up to 6 hours of off-peak charging per day*" (quoted in GH#4830, the multi-car confusion thread), so the code is internally inconsistent and the intended semantics is genuinely open. Exposure is invisible outside IOG-SMB — uncapped tariffs default `octopus_slot_max` to 48 — so only capped multi-car installs can see the doubling; existing tests encode the per-car behaviour, and `octopus_slot_max` is read without `index=` so per-car configuration does not exist either way. **The IOG slot-confirmation strip removes the house's cheap rate too when a car's dispatch is cancelled (GH#5335, with the #5317 family - code chain on main 57ec7bf1):** with `octopus_intelligent_dynamic` on (default), `dynamic_load_car_check()` (`plan.py`) cancels every slot of a car that sits inside a started dispatch but is not seen charging after the confirmation grace (log: `car 0 is in a dispatch but not charging, cancelling its slots`), and `dynamic_load_car_strip_feed_rates()` (`octopus.py`) re-prices the house's view of those minutes back to `rate_max_base` when the cheapness came from the integration feed, **exempting the fixed IOG band** (`OCTOPUS_NIGHT_RATE_WINDOWS["iog"]` = 23:30–05:30) — so a house battery that had planned into the dispatch loses its cheap stamp as well, and PV10 re-prices the minutes at `rate_max` outright (prediction.py's `dispatch_gone` line, `minute > 30`). Version tell: the strip is v9.3.2+ code — a reporter's deliberate mid-log downgrade left the A/B in one `predbat.log`: v9.3.3 logged `Octopus Intelligent: removed the dispatch rate from 355 minutes of cars [0] which are not charging` while v9.3.1 kept the 6.57p dispatch rate (`Charging target 2%-100%`); workaround (semantics read, not live-tested): `octopus_intelligent_dynamic` off = no confirmation and no strip, ≈9.3.1 behaviour at the cost of #5229's low-load protection. Check the car side first, though: Predbat must *see* the car charging to confirm the slot, and a `plug_status` string (`eco`/`boost`) that never equals `car_charging_now_response: charging` leaves the car "not charging" through a real charge — check the sensor's own history before blaming the planner. **#5316 is the mid-dispatch variant, fixed in PR #5319 (merged 2026-10-03) — keep the pre-#5319 signature:** a car that stops part-way through a running dispatch half hour used to have the rest of that half hour re-priced at the day rate; the strip paths (`rate_add_io_slots()`, `dynamic_load_car_strip_feed_rates()`) now start from `dynamic_load_car_strip_from()` (`plan.py`) — the end of the half hour the car was last seen charging in, which is billed off-peak in full by Octopus — and the confirmation is never cleared, only outlived. Review-round refinements ride along: a sensor reading confirms a half hour only from 2 minutes into it, and the grace clock is not kept across a restart (saved state is judged on the skewed clock). The bounce symptom itself — the battery flipping Charge↔Demand while a car charges in bursts — is the supervision working as coded (GH#5390, log-verified): a non-"charging" reading counts after `DYNAMIC_LOAD_CAR_START_MINUTES` (3 min) inside the dispatch, the cheap dispatch rate is stripped from the car's still-future slots (house plan re-prices those minutes) and it resumes on the next "charging" reading — ground-truth with the hourly `Last hour: ... car X kWh` log lines against Octopus's own per-slot amounts before calling it a bug. **The completed-dispatch cheap stamp lives only as long as the integration's dispatch feed carries the session (GH#5413, reporter log + yaml, 2026-10-05):** `rate_add_io_slots()` stamps completed dispatches deliberately (`dispatch_billed_off_peak()` exempts `end_minutes <= minutes_now`, and the fetch merges `completed_dispatches` unconditionally), but the integration withdrawing the dispatches when a session closes removes the stamp retroactively (`io_adjusted` reads empty, `rate_import`/`rate_max` all back at the day rate hours later) and the plan-History re-render derives past minutes from the **current** rate tables (`history_to_future_rates(self.rate_import, ...)`, and `today_cost()` re-prices today-so-far), so the flip is the display of the un-stamped arrays - no persistence of applied rates exists (open GH#2785, PR #3340 parked); the narrower fix is persisting the stamped/completed minutes. A tell from the same log worth keeping: those midday dispatches were visible only from their own start minute, so a "cheap slot Octopus never offered" question on an *ad-hoc* dispatch is different from the overnight band, which is visible hours ahead. **Octopus Intelligent devices freeze in place once the account leaves an Intelligent tariff (GH#5412, probe-verified on main 2026-10-05, still live):** `async_update_intelligent_devices()` returns before polling when the import tariffCode is not Intelligent, so the live-set pruning never runs off-IOG and everything built while on the Intelligent tariff persists - `automatic_config()` wires from `self.intelligent_devices` tariff-blind, the storage cache round-trips the devices, `fetch_sensor_data_car_planning()` keeps taking the Intelligent branch and rebuilding `self.octopus_slots` from the frozen entities' dispatch attributes every cycle, and `rate_add_io_slots()` keeps stamping completed dispatches (with the ~96h look-back) - the Ohme half flips `octopus_intelligent_wanted()` false on the new tariff but `charger_slots_wanted()` refuses while `octopus_intelligent_slot` still points at the frozen Octopus entity, so the #5401 charger-schedule mode stays blocked until the wiring is cleared by hand. #5405's owner re-wire (`car_slots_released_to_us()`) treats the not-refreshed device list as a live constraint on purpose - it refuses to re-wire off an Intelligent tariff, which is the same freeze acknowledged, not removed. **A related clobber to read before any "my manual octopus_intelligent_slot keeps reverting" report:** with `octopus_automatic: true`, `set_arg()` overwrites unconditionally and `automatic_config()` re-wires whenever the device dict is non-empty, so a user's own apps.yaml entity is replaced on the first run after every restart; since #5405 only the *empty-set clearing* respects foreign wiring (it checks the wiring is still what Octopus itself last wrote) - "the user is not affected" holds only under `octopus_automatic: false`. **Weekend Happy Hour / Power Up booking is not implemented (GH#5404, enhancement, 2026-10-05):** Predbat reads only the booked/allocated side (the free-session `events` attribute and `joined_events` with `event_type` WEEKEND_HAPPY_HOUR → `octopus_free_slots`); `available_events` is consumed only by the Power Down auto-join loop (`join_octoplus_power_down_session_event` plus the deprecated saving-session fallback, #4593) and WHH entries never reach it - they are skipped at the source (cannot be joined through the API - Octopus allocates them or the user books on the website) and Power Up entries ride in `available_events` at 0 octopoints, skipped by the min-octopoints guard; no WHH join service exists anywhere. If a join half is ever built, the saving-session auto-join block is the template and Power Up needs a slot *choice* among 2-3 candidates, with an explicit rule for weekend slots released Thursday that partly sit beyond the 48h horizon; the `events` attribute carries both available (code set) and auto-joined (`code` null) entries and the free-session path zero-rates them all regardless of booking state. | `octopus_*`, `saving_session*` | | Kraken / EDF (`kraken.py`) | EDF Kraken answers a day/night-structured tariff with **HTTP 400** on `standard-unit-rates/` (`{"detail": "This tariff has day and night rates, not standard."}`) while `day-unit-rates`/`night-unit-rates` return 200 and standing charges 200 — where Octopus four-rate products (GH#4921) answer the same-shaped URL **200 with empty results**. So the "empty results, then data empty" signature belongs to `octopus.py` and a hard 400 to `kraken.py`; do not carry the expectation across (GH#5166, probed against the live API). A nonexistent product still 404s, so 404 and 400 are both live "REST can't serve this tariff" signals on EDF — probe the exact URL from the reporter's log before assuming either. **Fixed in PR #5167** (`async_fetch_rates()`): the GraphQL `applicableRates` fallback now gates on `KRAKEN_REST_RATES_UNAVAILABLE_STATUSES` = (400, 404, 410), per direction (404 covers both a private product and one retired from the REST API; the authenticated retry stays 404/410 because auth cannot change a 400), and only counts a failure when the fallback comes back empty without counting one — `async_graphql_query()` counts its own failures, so a caller that also counts scores 2 per down cycle; snapshot the counter around the call. The standing-charge fallback stays deliberately 404/410-only (`kraken.py` — "400 is a rates-endpoint-only answer"). Two traps from the fix's review: `_fetch_rates_rest()` returns `(None, None)` on a network error, so `err is None` does **not** mean success — success is `(results, None)`; the merged code stashes the public attempt's status and restores it when the authenticated retry hits a network error (pre-fix symptom: a private-product tariff's rates vanish for one cycle with no fallback log line, while an identical 404 one cycle later recovers). The day/night endpoints' rows carry the same `payment_method` variant overlap as GH#5144 — suspected, no probe; check `minute_data`'s handling before consuming them directly. Also benign: `Warn: Kraken: Auth not available for find-tariffs` repeating on early restarts before the first `Tariff discovered` line is setup-phase, not a second bug. | `kraken` | | Axle (`axle.py`) | Export sessions have to boost the import rate as well as the export rate - introduced deliberately by PR #4520 (first release v8.48.2, mirrored on Octopus saving sessions), documented in docs/energy-rates.md, and upheld by the maintainer on #5060. Two reports of the same design now exist (#5060 closed as dup-of-#4277 with the by-design answer given; #5175 triaged as enhancement, no regression - `tools/triage_test.sh axle` asserts the dual boost), and **no config knob disables just the import side** (`axle_pence_per_kwh` scales both directions; config.py has only `axle_api_key`/`axle_pence_per_kwh`/`axle_automatic`/`axle_control`). The "textual plans / colour coding skewed" half of such reports belongs to the #5050 threshold row (boosts before `rate_scan()`), not here. State is published unconditionally from `run()` so a fetch failure does not freeze the sensor at a stale value. `load_axle_slot()` has no lower time bound - it checks only `start_minutes < forecast_minutes + minutes_now`, not the `start_minutes >= 0` guard `load_free_slot()` has (octopus.py) - so a closed Axle event writes dead keys at negative minutes through `rate_dict.get(minute, 0)` (GH#5036, replay-verified against the reporter's dump: the +100 boosts sit only at the two closed events' actual UTC start/end times). The dead keys are inert in the plan - `rate_minmax()` and the window scans start at `minutes_now` - so "a dead event projected forward as free export" is not what the code does. The comment at octopus.py:2859 saying `load_saving_slot()` and `load_axle_slot()` "both bound themselves that way" is half-wrong: axle bounds only its end, and `load_saving_slot()` also lacks the start guard but is harmless there because its inner loop writes only `if minute in rate_dict`. Two September reports extend this row. **GH#5060** (closed as a duplicate of #4277, the same defect reported a year earlier and never root-caused — #4277 covers Octopus saving sessions too): event boosts are applied *before* user rate overrides on both sides — `load_axle_slot(..., export=True)` runs ahead of `basic_rates(rates_export_override, ...)` (import mirrors it: `load_saving_slot`/`load_axle_slot` then the import override) — so a full-horizon `rates_export_override` rewrites the event minutes and re-marks them `user`. That reporter had no `rates_export` key in apps.yaml at all, so the override was their only export source, which is why the import-side boost survived while the export-side +100 did not. Workaround (maintainer's suggestion on #4277, checked viable against the code): move the fixed schedule from `rates_export_override` into `rates_export` — it becomes the base tariff via `basic_rates` before the boosts run, so the event stacks on top and nothing downstream rewrites it (assuming manual export rates are empty, since `apply_manual_rates` also runs after the boosts). Forensic technique: in a debug yaml, the `rate_*_replicated` dict discriminates the two failure modes in one step — mark `user` through the event window ⇒ the boost ran and a later override clobbered it; the `saving` marks missing while the boost is missing ⇒ the boost call never ran (or, per GH#5036 above, wrote dead keys). Compare against `rate_*_base` for the tariff's own price; same idea as the GH#5012 base/no_io/adjusted comparison in the Octopus row. **GH#5050** (shared with the Octopus row): the same boosts run before `rate_scan()`, so the automatic low-rate threshold classifies the whole day low — see the symptom-table row on `set_rate_thresholds()`. | `axle` | | History fetch / memory (`ha.py`) | History is fetched in `HISTORY_CHUNK_DAYS`-sized chunks with boundary dedup — records landing exactly on a chunk start inside a data gap corrupted smoothing before that was fixed. The largest memory peak in a run is ML load-predictor training (`load_predictor.py`), not the plan. The same chunking is the REST-call amplifier that can trip the fatal API-error counter at startup — see the "Too many API errors" symptom row. | `history_chunking` | @@ -133,7 +133,7 @@ Grep for the named symbol rather than trusting a line number. | Savings & metrics (`output.py`, `predbat.py`) | `savings_total_predbat` accumulates the **unadjusted** `saving` (`predbat.py:1183`, fed from `self.savings_today_predbat = saving` in `output.py`) while `savings_yesterday_predbat` publishes `saving_adjusted` (`output.py:3380`) — the two sensors answer different questions and can disagree in sign on the same day (confirmed from a reporter's dump: `saving_real: +86.14p` vs `saving_adjusted: -25.66p`, GH#3894). Both are still current on `main`. Separately, the battery-value adjustment bills the SoC **level** at day-end against the counterfactual baseline rather than the **change** in SoC over the day — a day that force-exports through midnight can be energy-neutral yet still get charged the full baseline-vs-actual SoC gap as if it were lost value. | none | | GivTCP REST (`inverter.py`) | **Structurally stale as written, and left here for the mechanism only:** PR #4864 moved REST handling out into `givtcp.py`/`givtcp_rest.py`, so `update_status()` (now `inverter.py:1333`) no longer reads `Power.Power` at `inverter.py:1435` at all. What survives is the same trap one layer up - the component's auto-config claims the power keys unless `givtcp_rest_power_ignore` is set, and it now logs an Info line when you opt out (`givtcp.py:766-767`). PR #4959 also lets an apps.yaml-named energy sensor win over auto-configuration for the history-read keys. Historically, with `givtcp_rest` configured `update_status()` read PV/Grid/Load power straight out of the REST `Power.Power` block; the apps.yaml entity lists - including any `0` placeholders put there deliberately to zero a duplicate reading - are only consulted on the non-REST `else` branch, and `execute.py` then sums every inverter's REST readings. On a hybrid + AIO pair that presents as the AIO's hybrid-fed PV port counted as solar at night, and both units' shared-CT grid readings summed (~7.1 kW shown for ~3.6 kW of real export). The per-inverter `givtcp_rest_power_ignore: true` restores the apps.yaml lists and is already documented in `docs/apps-yaml.md` - the gap is that nothing warns when a `0` placeholder is silently bypassed (GH#4883). Two v9.0.x notes since the refactor. **GH#4993 (still live on main, b8996659):** the component's required `rest_urls` arg is resolved straight from `givtcp_rest` (`components.py`) and the stock `config/apps.yaml` ships `givtcp_rest:` uncommented with example URLs, while the registry's `"inverter": True` flag is documentation-only and nothing reads it - so every install using the template, GivEnergy or not, starts a GivTCP REST component that fails discovery (`num_entities: 0`), hammers two dead URLs and shows "GivTCP REST: Error / Never updated" in the Components panel. Discovered inverters stay at zero so `automatic_config()` skips and no Predbat args get hijacked - the damage is log spam and panel error, not wrong control. Workaround: comment out the whole `givtcp_rest:` block, and any uncommented `givtcp_automatic:` line with it, because leaving that set while `givtcp_rest` is absent trips the `Warn: Skipping GivTCP REST interface, missing required configuration: givtcp_rest` gate. **GH#4984 (fixed by the refactor, keep for older logs):** `Warn: Inverter N REST failed to setDischargeRate to got ` where *got equals the requested rate* is a zero verification tolerance, not a failed write - pre-#4864 the read-back tolerance was sized from `battery_rate_max_discharge`/`charge`, which an `inverter_limit_discharge`/`inverter_limit_charge` API override of 0 (users reach for it as "manual rate 0"; it is in `CONFIG_API_OVERRIDE`) drives to 0 against a strict `<`, so an exact read-back fails, burns the 5 `INVERTER_MAX_RETRY_REST` posts (~2s apart, the 4-5 runs users see in the GivTCP log) and flags the whole run "Demand with Errors". Only fires when something else set the real rate away from the target first, otherwise `adjust_discharge_rate`'s change-gate skips the write. The refactor re-sized the tolerance from `write_tolerance_watts()` - the max rate GivTCP itself reports (`Invertor_Max_Bat_Rate`), falling back to 2600W - and the entity write path uses `<=`, so an exact match passes; the charge-rate variant #2882 (still open) does not match this trap's signature — it is a ~1 kW read-back miss (3600 written, 2560 got), not got==requested — and maps instead to the GH#5386 family appended below. **GH#5101 (still live):** `initialize()` builds one `InverterRestState(id=n, rest_api=url)` per `givtcp_rest` entry with no URL-shape validation (`givtcp.py`), so a YAML mapping indented into the list during an apps.yaml edit becomes `rest_api=` and `read_data()`'s first line (`url = inverter.rest_api + "/" + api`, `givtcp_rest.py`) raises `TypeError: unsupported operand type(s) for +: 'dict' and 'str'`, caught by `ComponentBase.start()` and logged with no index or URL. Signature: the `discovered N inverter(s) from M configured REST endpoint(s)` line never appears anywhere in the log (discovery settles only after the poll loop completes), and the malformed entry itself produces no `Errno 22` retries because it dies before `get_data()` — valid-but-dead string entries ahead of it produce the full ladder, so Errno-22 count = 5 attempts × completed poll cycles. The component never reports started; the main loop keeps working off the user's own apps.yaml entities. The whole class is new with the v9 component — v8.55 read `givtcp_rest` with `index=self.id`, so stray extra entries were inert on single-inverter installs. Also from GH#5099: nothing in the repo sets `battery_calibration` automatically — the component publishes the sensor from GivTCP's own detection but expects an apps.yaml mapping — and Predbat reads the arg's state as calibrating only when it is literally `on`/`On`/`true`/`True` (`inverter.py`), while GivTCP's detection is `Battery_Calibration != "Off"` (`givtcp_rest.py`); anyone wiring a multi-valued select into `battery_calibration` must reconcile the two. and because `refresh_config()` re-samples the arg every cycle (the objects persist since PR #5126, but the per-cycle re-read survives — see the args trap in "Traps when investigating"), `in_calibration` is re-sampled per cycle, not init-only. Another manual-vs-auto split (GH#5132): the `discharge_target_soc` write is the force-export hardware backstop - always `int(self.reserve_percent)`, never the plan's per-window export limit (that floor is software-only in `execute.py`) - and the #4517 unsupported-model guard (`DISCHARGE_TARGET_UNSUPPORTED_MODELS`/`_TYPES`, givtcp.py) runs only in the **auto-config** path, where it stops Predbat publishing the entity at all. A user on the **manual** entity-list path gets the entity from the GivTCP add-on instead, so the guard never engages: on an AIO whose add-on write handler for `number.givtcp_*_discharge_target_soc_1` is a silent no-op (diagnosed live - manual `number.set_value` did nothing and produced zero GivTCP log lines), Predbat warned `didn't complete got 4.0` every cycle for 14h. The write is skipped when the arg is absent by design, so **removing `discharge_target_soc` from apps.yaml is the first workaround on any version and either path**. Setup tell: manual-path apps.yaml lists `discharge_target_soc: - number.givtcp_*`; auto-config users don't. (Version trap from the same issue: the issue body said v8.54.3, the attached log's own version lines said v9.0.3 - the log wins.) **GH#5178 (enhancement, live on main):** whenever `givtcp_rest` is configured the component publishes its **full** entity surface unconditionally — ~44 entities per inverter — regardless of whether the GivTCP HA integration already provides equivalents; deliberate, so REST-only setups have entities at all. A "Predbat created N duplicate GivTCP entities" report maps here and is a feature request: the count is checkable as (GIVTCP_SENSORS + GIVTCP_CONTROLS − per-fleet withheld) × inverter count (44×3 = 132 in #5178, matched), the user-side workaround is an HA recorder exclusion glob `*.predbat_givtcp_*` plus hiding (safe — the four history keys keep the user's own sensors, USER_WINS), and `docs/components.md` has no GivTCP REST section yet. Related live gap (code-read, #5178's reporter's debug yaml): `automatic_config()` claims `discharge_target_soc` gated on rest_v3 alone (`givtcp.py`), while `publish_data()` additionally withholds the entity for `DISCHARGE_TARGET_UNSUPPORTED_MODELS`/`_TYPES` — on an unsupported-model fleet the arg points at `number.predbat_givtcp_N_discharge_target_soc` entities that are never created; benign so far (inverter.py leaves an unreadable discharge target alone, `No current discharge target to read`), fix shape = consult the same unsupported-model set or `published_discovery` like the discovery keys do. **GH#5209 (fixed in PR #5216; mechanism kept for pre-#5216 logs):** pre-#5216 `automatic_config()` filled every per-inverter arg list **positionally** from `discovered`, so an endpoint down at the discovery pass shifted every later endpoint down one slot, and `rediscover()`'s deliberate append-only recovery turned the shift into a rotation on recovery — recovery did not heal. Writes route by the index embedded in the entity id (`_parse_entity`), so the misroute reaches hardware; log signature `Inverter 1 current charge rate is 3000W and new target is 2600W` paired with `GivTCP: Inverter 2 set charge rate 2600 via REST successful` alternating each cycle, plus `battery_calibration ... has 2 entries, expected 3` / `Out of range index 2` for claimed-but-user-unconfigured keys (`_keep_configured_tail()` returned the short list unchanged when `_configured_value()` was None — only those keys warned while the user's own apps.yaml lists got their tail preserved). Debug-yaml diagnosis: any `predbat_givtcp__*` entry whose list position is not ``. Workarounds at any version: restart Predbat once every endpoint answers, or `givtcp_automatic: False`. Design note on the fix: at startup a dead leading endpoint cannot be told apart from a leading placeholder URL, and #5216 chooses identity-by-index — `givtcp_rest: [dead, live]` becomes two inverters, not one (`num_inverters` cannot arbitrate; the GivTCP template tells REST users to delete it). **One design rule left by #5216's own development:** during the PR's cleanup it was found that the re-probe adoption pass judged every `published_discovery` gate before the adopted endpoint had published anything — `run()` calls `publish_data()`, then `rediscover()` appends the newly answering index, then `automatic_config()` re-runs because `discovered != configured_for`, so each `all(key in self.published_discovery.get(n, set()) for n in discovered)` gate failed on the adoption cycle and discovery-gated keys (`soc_max`, `battery_calibration`, `inverter_limit`, …) were skipped or handed back while the gates reading `rest_data` directly (`rest_v3`, `pause_mode_supported`, …) judged correctly. The merged PR re-publishes after a fleet-growing `rediscover()` (`givtcp.py`, the `if rediscover:` block) and adds a claim hand-back (`self.claimed_from`), and that ordering is the rule for anyone touching the path: **any gate over `published_discovery` must not be judged in the cycle where `rediscover()` appended an index.** On pre-#5216 builds (no hand-back, no wait) a *tail* endpoint adopted late never has its discovery keys claimed at all — suspected, unverified against a build without the PR — so a "late GivTCP inverter shows 8 kWh / no calibration sensor until restart" report on an older build maps here. **A missing inverter-details block silently stales the published sensors and fakes a clock-skew alarm (GH#5334, code-read on main 57ec7bf1):** `inverter_details()` (givtcp_rest.py) has no log and returns `{}` when `Invertor_Details` is empty (normal on v3) and `raw.invertor.serial_number` is null, which is exactly the Gateway/EMS shape upstream omits from `/readData` (britkat1980/giv_tcp#597 is the upstream fix, unmerged at triage time); `publish_data()` then gates each detail-block sensor on presence — `inverter_time`, `soc_max`, `battery_rate_max`, `inverter_limit` — so HA keeps the entity's *last* state frozen and stale looks identical to live, while `serial_number` itself is published unconditionally: **the discriminating tell is the serial sensor reading unknown while inverter_time stays frozen**. Predbat reads that frozen `inverter_time` and `check_clock_skew()` (inverter.py) crosses the ≥30-min restart threshold every cycle — repeating `Warn: Inverter time is , Predbat computer time , this is minutes skewed` and `Warn: Inverter control auto restart trigger: Clock skew >= 30 minutes command []` (empty `command` = no `auto_restart` configured) with the inverter clock actually fine — not the clock, not HA time sync. Fix shapes discussed, maintainer's call: a per-cycle warn when the details block resolves empty; a fallback lookup over top-level dict blocks for `Invertor_Serial_Number`/`Invertor_Time` (unambiguous only at exactly one candidate); or the cheapest anti-misdiagnosis — publish an explicit `unknown`/`unavailable` `inverter_time` instead of skipping, which `Inverter` already treats as "no reading" and skips skew detection for without a restart trigger. Any "Predbat-published entity looks frozen but claims to be live" report on a REST-backed component is a candidate for this same `if value:` publish-gate class. **GH#5386 (2026-10-04, open) — a read-back miss larger than the REST verify tolerance never converges:** the REST verify compares within `write_tolerance_watts()/12` (charge) and `/25` (discharge), where `write_tolerance_watts()` is the max rate GivTCP itself reports (`Invertor_Max_Bat_Rate`, 2600 W fallback); the reporter's 6000 W write read back 5100 while GivTCP's own log said the POST succeeded, so every ~2s retry and every cycle re-warns, and the entity-level deadband (`rate_tolerances()`'s one-step `fuzzy_below`) cannot cover a ~9-step miss either. Predbat's REST mirror entities **are** the read-back, so the debug yaml alone (mirror state vs the target) proves non-convergence without GivTCP logs. Why the register sits at 5100 is open — a stale cached Control read vs a firmware/BMS cap (the mirror's own max attribute says 6000, inconsistent with a pure cap) — and the suspected fix shape (accept GivTCP's POST success, a Solis-style `verify_settle_seconds` re-read, or sizing the tolerance to the firmware cap) is untested. Family so far: #5324 (exactly one rounding step; fixed by PR #5348's `fuzzy_below`), #2882 (~1 kW miss, open), #5386 (900 W miss, open). | `inverter` | | Compare (`compare.py`) | `apply_hardware_overrides()` overrode four attributes and not `battery_rate_max_export`, which is the one the export prediction path actually uses (`prediction.py:906`), so an `override_battery_rate_max_discharge_kw` left force-export slots pinned at the real hardware rate - probe-verified against the reporter's debug yaml, fixed in PR #4897 with an explicit fifth key (GH#4895). `battery_rate_max_export` itself is `min(inverter_limit_export, battery_rate_max_raw)` (`inverter.py:431`), and the prediction still clamps export draw at the un-overridden `inverter_limit`, so a "model a bigger inverter" scenario needs `override_inverter_limit_kw` as well or it is inert. A negative "True cost" alongside zero import is **not** phantom export revenue: compare reports `metric_end - metric_start`, and `compute_metric()` credits the end-of-scenario battery at the *replacement import* rate (`plan.py:1769`), deliberately ignoring the export rate - so a scenario ending 25 kWh fuller on free PV scores negative by construction. Likewise a non-zero Export column under `rates_export: 0` is forced PV-clipping export, not a planned export slot; banning export outright is a missing feature (GH#2446), not a compare bug (GH#4881, confirmed twice, the second time against the reporter's own detailed plans). GH#2033 (bumped 2026-09-18, code-verified): the table's Import and Export columns are whole-horizon totals, not plan-slot sums — `run_prediction()` accumulates `import_kwh_battery`/`import_kwh_house`/`export_kwh` for every modelled minute of grid flow and seeds `export_kwh` from `export_today_now`, so PV export outside slots, scenario-plan arbitrage and today-so-far export all count, and a planned discharge slot exports regardless of load vs solar. Deliberate: compare/annual pass `include_manual_api=False` to `basic_rates()` so live manual_api overrides never leak into simulated tariffs, while a tariff's own `rates_import`/`rates_export` replaces the live rates wholesale (no `prev` passed) — per-tariff `rates_*_override` blocks are the workaround; and #2033's "identical friendly names" premise is contradicted by the code (`"Compare " + name`), so duplicate names likely come from duplicate `name:` values in `compare_list`. GH#5133 (fixed on **open PR #5143, unmerged** - keep the mechanism for logs on stock main): `publish_data()` built the entity id by plain concatenation with no slug sanitising, so a `compare_list` id containing `/` produced `POST /api/states/predbat.compare_tariff_IGO/Prime` - and that 404 is the route not matching, not a missing entity. The warns arrive **in pairs** - `set_state()` does the POST and then a read-back `update_state()` GET, and both warn - so two identical 404 warns per cycle anywhere on `/api/states/...` means one bad write, not two problems. And `comparisons.yaml` was never pruned against `compare_list`, so a renamed or deleted tariff id kept republishing for as long as the file survived: the bad id existed only in the persisted yaml (the reporter's current apps.yaml looked fine), so grepping the attached config proves nothing - ask for `comparisons.yaml`, delete the stale entry and restart. The PR adds `tariff_entity_slug()` and `prune_comparisons()`. From the same PR's review (still unmerged, so observations about stock main): the tree has four ad-hoc entity-id slugify helpers with different semantics - `gateway.py`'s `_ev_suffix` drops non-`[a-z0-9_]` characters, `sigenergy.py`'s `_system_slug` folds only `-`, web.py's `re.sub` neither collapses runs nor strips, the PR's `tariff_entity_slug` collapses and strips - and `dashboard_item()` validates nothing, so on any "invalid entity id / 404 on publish" report check which slugify helper built the id; none caps length, and HA rejects object ids over 64 characters. A history lookup that passes an unknown entity id returns `None` silently - a wasted round-trip, **not** a 404 - so don't accept "it 404s" about the history path. With `compare_list` commented out, stored tariffs are republished **once per Predbat start** (only `load_yaml` reaches `publish_data` without the `compare_list` early return), so the tell for "sensors for a tariff I deleted keep coming back" is a restart, not the 5-minute cycle; and stored compare results are looked up by the raw `compare_list` id everywhere (`run_all`'s prior-SoC carry-forward, `select_best`, web.py's Compare page), so a fix that normalises tariff identity must *move* the stored entry to the configured id - keeping the old key preserved nothing, because nothing looked it up under the new id. **GH#5180 (code-read on main, still live):** the /compare tab's `Existing` badge is broken by any event stamped into the live rate tables at the moment compare runs — `Compare.run_all()` snapshots its baseline from the live `self.pb.rate_import`/`rate_export` (`compare.py:598-599`), which by then carry the IO/saving/free/Axle stamps, while `fetch_rates()` refetches the compared tariff's plain rate card and never re-applies any event — so the minute compare differs at every event minute and `result["existing_tariff"]` goes False, withholding the badge (web.py) for that whole run. Compare fires daily at midnight or on the `compare_active` switch, so an event active at that moment is enough. Cost figures are unaffected: every scenario runs on its own refetched plain card. In-tree fix baselines already exist: `rate_import_no_io` (also excludes manual overrides — but the compared refetch never applies manual rates either, so override users already fail this comparison today; the new base makes that consistent rather than opening a gap) and `rate_export_base` (comment says saving sessions and overrides stay out of it). `test_compare` has zero setup for saving/free/IO slots and never asserts `existing_tariff` — a green compare test says nothing about the event-active path. Suspected, not verified: the `run_all()` finally block restores everything it saved **except** `rate_import`/`rate_export`, so the live tables may hold the last compared tariff's rates until the next `fetch_sensor_data()` rebuild — if "the live plan used a different tariff's rates right after running a comparison" is reported, start there. | `compare` | -| Car charging (`plan.py`, `fetch.py`, `execute.py`) | `car_charge_slot_kwh()` returns the raw slot kWh with no limit awareness and feeds the HTML Car kWh column, the JSON plan, the status sentence and the `car_charging_slot` attributes, while `prediction.py:799` and `prediction_kernel.cpp:827` clamp unconditionally - so "car kWh is displayed but the cost never moves" is a display-vs-model disagreement, not a planning bug. The *rate* side of beyond-cap IOG energy is already handled (`car_charge_slot_rate`/`car_rate_premium`); only the volume side is untrimmed, and `octopus_intelligent_consider_full` (default False, expert-hidden) is the only thing that trims it (GH#4888). The manual car SoC is prediction-fed and never a measurement: `predbat.py` exposes `car_charging_soc_next`, which `prediction.py` sets from the modelled first-step car SoC, so it never resets on its own (GH#4889). `update_car_charging_power()` (`execute.py`) sums *every* entity listed under `car_charging_power` - that list describes chargers, not cars, so two per-car template sensors reading one shared charger are added together; the summing is confirmed, the reporter's doubled figure was not (GH#4879). Per-car rate config pre-#5383: `car_charging_rate` was read by suffixed name as `float(get_arg("car_charging_rate" + postfix))` (`fetch.py`), so an apps.yaml **list** raised `float(list)` — a `TypeError` every main-loop cycle while the car fell back to 7.4 kW — and a fresh item (the HA input_number not yet created) had `load_user_config()` seed `item["default"] = self.args[name]` with no type validation, letting the invalid seed survive (`userinterface.py`; a comma-string value such as `car_charging_rate: "11.0, 11.0"` hits the same `float()` — suspected, not reproduced). **Fixed in PR #5383 (merged 2026-10-04, unreleased at the time of writing, post-v9.3.5) — keep the pre-#5383 mechanism for older logs:** `car_list_arg()` (`userinterface.py`) now splits a base-key list across the per-car slots (`car_charging_rate: [11.0, 6.5]` → car 0 11.0 / car 1 6.5), a per-car suffixed scalar (`car_charging_rate_1: 3.6`) wins over its list slice, and a list on a per-car suffix is ignored in favour of the base list's slice (warned when it stands alone) — `test_fetch_config_options.py` pins all three. The list-vs-postfix asymmetry is closed: `car_charging_limit`/`car_charging_battery_size` had always accepted lists (read with `index=car_n`) and `car_charging_rate` now accepts them. The `car_charging_slots` refresh gate that checked only `car_charging_planned` now also checks `car_charging_now` (`fetch.py:1337`, GH#4795 / PR #4796). **GH#4967 is now fixed (PR #4971, commit `4a045e07`) - the mechanism is kept for anyone reading a pre-#4971 log.** The prediction used to clamp modelled car load at the real `car_charging_limit` unconditionally, via an identical line in both `prediction.py` and `prediction_kernel.cpp`, with the discharge hold below each gated on `car_load_scale > 0` - so once the modelled car filled part-way through a slot the hold released for the rest of the window, regardless of the `octopus_intelligent_consider_full` switch, which never reached `predict()` at all (its only live uses were the read, the `False` default and the gated trim inside `load_octopus_slots()`). The fix routes the prediction through a model-facing `car_charging_limit_model` instead: with `octopus_intelligent_consider_full` off (the default) cars carrying IOG slots get `CAR_CHARGING_LIMIT_UNCAPPED`, so the fill clamp is deliberately inert and the hold releases only when the modelled car actually fills, while the real `car_charging_limit` is left untouched for `execute.py`'s "car already charged" decision, `plan_car_charging()` and `load_octopus_slots()` (it is taken **by reference** by `Prediction`, which is why the fix assigns a separate value rather than mutating it). Both engines take the model limit from the same attribute so they cannot diverge here, `update_car_manual_soc()` caps the manual car SoC write-back at the real limit because the modelled SoC can now overshoot, and `annual.py`/`compare.py` reset the attribute so an override cannot leak into their re-planning; `test_multi_car_iog.py` now asserts the switch's effect on the prediction. One interaction to re-check when the GH#4952 Ohme `watts x hours` overestimate is fixed: with consider_full off the model limit is uncapped for IOG-slot cars, so the fill clamp no longer bounds modelled car load for them either. The History view's separate reconstruction of car slots from the car energy sensor had its own faked-clock bug — GH#5004, under "The shared clock" above, fixed in PR #5025 (v9.0.2). The "Hold for car" discharge hold has **two different gate conditions** (GH#5146): live execution (`execute.py`'s carHolding block) requires an active car slot with `kwh > 0`, the car not already at limit, and skips entirely while an export window is executing (`if not isExporting:`), while the plan model (`prediction.py`, `discharge_rate_now = battery_rate_min`) holds on `(car_load_scale > 0) and (not car_charging_from_battery) and set_charge_window` — no live state, no kwh check, and `set_charge_window` is the plan's charge-window flag, not "this minute is in one". So the plan can hold in slots the live code charges through and vice versa: verify a "plan says X, status says Y" report against the right gate — and since PR #5147 the plan-table display *does* take `hold_for_car` from the prediction's own record (`predict_car_hold_best`, shown for at least half a slot on Demand rows), so a display disagreement is a version question, not a gate question. The export-window exemption is deliberate and test-codified (`discharge_car_full_bat2` in `test_execute.py` asserts export + car slot + the switch off → Exporting, no pause), so it is not a #2380 regression; if the reporter has a working `car_charging_now` sensor the overlapping export window is dropped on the next replan, so *sustained* discharge means the now-slot was never created — on pre-#5245 builds a numeric power sensor read as "not charging", but since PR #5245/#5267 `car_charging_now` can *be* a charging power sensor (a number in watts counts as charging from `CAR_CHARGING_NOW_POWER_W`, `car_charging_now_value()` in `fetch.py`), so check the version before reading a power sensor as absent evidence. Read-only users cannot see the status sensor's "Hold for car" at all — `predbat.status` is forced to "Read-Only" first (`execute.py`). **"Hold for car" is the hold working as designed, and there is no native EV-SoC or price gate anywhere (GH#4572, read on main 57ec7bf1):** the hold fires when `set_charge_window` is on and `car_charging_from_battery` is off (config.py) whenever a car is charging now or inside a planned slot; it stops the battery *feeding the car* (pause mode, else rate 0) and never blocks the car charging from the grid, so "battery idle while the EV charges on PV-then-grid" is the intended steady state and the charger's start/stop is outside the hold's scope. The full car config surface (`car_charging_energy_scale`/`_threshold`/`_rate`/`_loss`/`_hold`, `car_energy_reported_load`, `_manual_soc(_kwh)`, `_plan_smart`, `_plan_max_price`, `_from_battery`, `_plan_time`) has **no parameter gating charging on the EV's SoC and no enforceable price threshold** — `car_charging_soc`/`car_charging_limit` in apps.yaml are the car's *input* sensors, not thresholds — so "charge the EV only when X" is an enhancement ask; the maintainer-side answer is external HA automation, and the in-tree tooling direction is open PR #3791 (`predbat.solar_surplus_power` = `min(max(0, grid + car_power - max(0, battery)), max(0, pv))` + `binary_sensor.predbat_force_export_slot`, sensor-only after being pared back, still unmerged with an owner review round outstanding 2026-10-02). Two slot-selection facts from October 2026: the smart car planner ranks slots by **import price only** — `plan_car_charging()` reads `window["average"]`, while each `low_rates` window already carries an `export` average (`rate_scan_window(..., alt_rates=self.rate_export)`, `fetch.py`) that only `plan_iboost_smart()` consumes — so a cheap import band overlapping daylight is mis-ranked exactly when the export price beats it, and extra car kWh served from surplus PV costs the band's export price, not its import price (GH#5384, enhancement). And a `re:` config arg matches the **first** matching entity only — `resolve_arg_re()` breaks on the first hit (`userinterface.py`) — so a `car_charging_now: re:(sensor.wallbox_portal_status_description|sensor.myenergi_zappi_[0-9a-z]+_plug_status)` alternation consults exactly one detection sensor and the other integration's is never read (GH#5390, dump-verified: the resolved args carried a single plug_status entity); an alternation does not combine sensors, name one entity per arg. | `octopus_*`, `car_charging` | +| Car charging (`plan.py`, `fetch.py`, `execute.py`) | `car_charge_slot_kwh()` returns the raw slot kWh with no limit awareness and feeds the HTML Car kWh column, the JSON plan, the status sentence and the `car_charging_slot` attributes, while `prediction.py:799` and `prediction_kernel.cpp:827` clamp unconditionally - so "car kWh is displayed but the cost never moves" is a display-vs-model disagreement, not a planning bug. The *rate* side of beyond-cap IOG energy is already handled (`car_charge_slot_rate`/`car_rate_premium`); only the volume side is untrimmed, and `octopus_intelligent_consider_full` (default False, expert-hidden) is the only thing that trims it (GH#4888). The manual car SoC is prediction-fed and never a measurement: `predbat.py` exposes `car_charging_soc_next`, which `prediction.py` sets from the modelled first-step car SoC, so it never resets on its own (GH#4889). `update_car_charging_power()` (`execute.py`) sums *every* entity listed under `car_charging_power` - that list describes chargers, not cars, so two per-car template sensors reading one shared charger are added together; the summing is confirmed, the reporter's doubled figure was not (GH#4879). Per-car rate config pre-#5383: `car_charging_rate` was read by suffixed name as `float(get_arg("car_charging_rate" + postfix))` (`fetch.py`), so an apps.yaml **list** raised `float(list)` — a `TypeError` every main-loop cycle while the car fell back to 7.4 kW — and a fresh item (the HA input_number not yet created) had `load_user_config()` seed `item["default"] = self.args[name]` with no type validation, letting the invalid seed survive (`userinterface.py`; a comma-string value such as `car_charging_rate: "11.0, 11.0"` hits the same `float()` — suspected, not reproduced). **Fixed in PR #5383 (merged 2026-10-04, unreleased at the time of writing, post-v9.3.5) — keep the pre-#5383 mechanism for older logs:** `car_list_arg()` (`userinterface.py`) now splits a base-key list across the per-car slots (`car_charging_rate: [11.0, 6.5]` → car 0 11.0 / car 1 6.5), a per-car suffixed scalar (`car_charging_rate_1: 3.6`) wins over its list slice, and a list on a per-car suffix is ignored in favour of the base list's slice (warned when it stands alone) — `test_fetch_config_options.py` pins all three. The list-vs-postfix asymmetry is closed: `car_charging_limit`/`car_charging_battery_size` had always accepted lists (read with `index=car_n`) and `car_charging_rate` now accepts them. The `car_charging_slots` refresh gate that checked only `car_charging_planned` now also checks `car_charging_now` (`fetch.py:1337`, GH#4795 / PR #4796). **GH#4967 is now fixed (PR #4971, commit `4a045e07`) - the mechanism is kept for anyone reading a pre-#4971 log.** The prediction used to clamp modelled car load at the real `car_charging_limit` unconditionally, via an identical line in both `prediction.py` and `prediction_kernel.cpp`, with the discharge hold below each gated on `car_load_scale > 0` - so once the modelled car filled part-way through a slot the hold released for the rest of the window, regardless of the `octopus_intelligent_consider_full` switch, which never reached `predict()` at all (its only live uses were the read, the `False` default and the gated trim inside `load_octopus_slots()`). The fix routes the prediction through a model-facing `car_charging_limit_model` instead: with `octopus_intelligent_consider_full` off (the default) cars carrying IOG slots get `CAR_CHARGING_LIMIT_UNCAPPED`, so the fill clamp is deliberately inert and the hold releases only when the modelled car actually fills, while the real `car_charging_limit` is left untouched for `execute.py`'s "car already charged" decision, `plan_car_charging()` and `load_octopus_slots()` (it is taken **by reference** by `Prediction`, which is why the fix assigns a separate value rather than mutating it). Both engines take the model limit from the same attribute so they cannot diverge here, `update_car_manual_soc()` caps the manual car SoC write-back at the real limit because the modelled SoC can now overshoot, and `annual.py`/`compare.py` reset the attribute so an override cannot leak into their re-planning; `test_multi_car_iog.py` now asserts the switch's effect on the prediction. One interaction to re-check when the GH#4952 Ohme `watts x hours` overestimate is fixed: with consider_full off the model limit is uncapped for IOG-slot cars, so the fill clamp no longer bounds modelled car load for them either. The History view's separate reconstruction of car slots from the car energy sensor had its own faked-clock bug — GH#5004, under "The shared clock" above, fixed in PR #5025 (v9.0.2). The "Hold for car" discharge hold has **two different gate conditions** (GH#5146): live execution (`execute.py`'s carHolding block) requires an active car slot with `kwh > 0`, the car not already at limit, and skips entirely while an export window is executing (`if not isExporting:`), while the plan model (`prediction.py`, `discharge_rate_now = battery_rate_min`) holds on `(car_load_scale > 0) and (not car_charging_from_battery) and set_charge_window` — no live state, no kwh check, and `set_charge_window` is the plan's charge-window flag, not "this minute is in one". So the plan can hold in slots the live code charges through and vice versa: verify a "plan says X, status says Y" report against the right gate — and since PR #5147 the plan-table display *does* take `hold_for_car` from the prediction's own record (`predict_car_hold_best`, shown for at least half a slot on Demand rows), so a display disagreement is a version question, not a gate question. The export-window exemption is deliberate and test-codified (`discharge_car_full_bat2` in `test_execute.py` asserts export + car slot + the switch off → Exporting, no pause), so it is not a #2380 regression; if the reporter has a working `car_charging_now` sensor the overlapping export window is dropped on the next replan, so *sustained* discharge means the now-slot was never created — on pre-#5245 builds a numeric power sensor read as "not charging", but since PR #5245/#5267 `car_charging_now` can *be* a charging power sensor (a number in watts counts as charging from `CAR_CHARGING_NOW_POWER_W`, `car_charging_now_value()` in `fetch.py`), so check the version before reading a power sensor as absent evidence. Read-only users cannot see the status sensor's "Hold for car" at all — `predbat.status` is forced to "Read-Only" first (`execute.py`). **"Hold for car" is the hold working as designed, and there is no native EV-SoC or price gate anywhere (GH#4572, read on main 57ec7bf1):** the hold fires when `set_charge_window` is on and `car_charging_from_battery` is off (config.py) whenever a car is charging now or inside a planned slot; it stops the battery *feeding the car* (pause mode, else rate 0) and never blocks the car charging from the grid, so "battery idle while the EV charges on PV-then-grid" is the intended steady state and the charger's start/stop is outside the hold's scope. The full car config surface (`car_charging_energy_scale`/`_threshold`/`_rate`/`_loss`/`_hold`, `car_energy_reported_load`, `_manual_soc(_kwh)`, `_plan_smart`, `_plan_max_price`, `_from_battery`, `_plan_time`) has **no parameter gating charging on the EV's SoC and no enforceable price threshold** — `car_charging_soc`/`car_charging_limit` in apps.yaml are the car's *input* sensors, not thresholds — so "charge the EV only when X" is an enhancement ask; the maintainer-side answer is external HA automation, and the in-tree tooling direction is open PR #3791 (`predbat.solar_surplus_power` = `min(max(0, grid + car_power - max(0, battery)), max(0, pv))` + `binary_sensor.predbat_force_export_slot`, sensor-only after being pared back, still unmerged with an owner review round outstanding 2026-10-02). Two slot-selection facts from October 2026: the smart car planner ranks slots by **import price only** — `plan_car_charging()` reads `window["average"]`, while each `low_rates` window already carries an `export` average (`rate_scan_window(..., alt_rates=self.rate_export)`, `fetch.py`) that only `plan_iboost_smart()` consumes — so a cheap import band overlapping daylight is mis-ranked exactly when the export price beats it, and extra car kWh served from surplus PV costs the band's export price, not its import price (GH#5384, enhancement). And a `re:` config arg matches the **first** matching entity only — `resolve_arg_re()` breaks on the first hit (`userinterface.py`) — so a `car_charging_now: re:(sensor.wallbox_portal_status_description|sensor.myenergi_zappi_[0-9a-z]+_plug_status)`alternation consults exactly one detection sensor and the other integration's is never read (GH#5390, dump-verified: the resolved args carried a single plug_status entity); an alternation does not combine sensors, name one entity per arg. **`car_charging_plan_time` caps the non-IOG car plan horizon at the next ready time (GH#1172, code-verified on main 2026-10-05):** `plan_car_charging()` clips every low-rate window at `end = min(window["end"], ready_minutes)` with a past ready time wrapped +24h, and windows starting past the deadline fail `end <= start` and are dropped - so the horizon is never more than ~24h even though `low_rates` is scanned from minute 0 with tomorrow's cheap windows already in it. The select ships only 00:00-23:55 (no "off"/null option; unparseable falls back to 07:00 with a warning), so no sentinel disables the deadline; IOG cars bypass it entirely (ready time overwritten by `octopus_ready_time`, slots from dispatches). GH#1172 (remove the deadline) and GH#3890 (future-date dropdown keeping one) are complementary, partly opposed completions - link both before closing either. **Every car's`plan_details` car sentence, and every car's `planned` sensor attribute, has a display-side reading worth knowing (GH#3017/#5407, verified on main):** `short_textual_plan()`'s "Your car is currently charging." fires when`car_charge_slot_kwh(minutes_now, minutes_now + 5) > 0` and never consults `car_charging_now` (the rest of that section is likewise plan-derived) - and `publish_car_plan()` builds **one** `plan` list ahead of the per-car loop and publishes that same list object as the `planned` attribute of *every* car's `binary_sensor.predbat_car_charging_slot{postfix}`; a second Zappi/EVC under Predbat-led control parses its own sensor's`planned`(`refresh_car_windows()`,`refresh_evc_car_windows()`) and charges in car 0's windows as well as its own. Single-car installs are unaffected, which is why it went unreported; whether the real HA entity's attributes are serialised per publish instant was not verified (in-process stores only), and the agreed fix direction is moving the list inside the loop with a two-car assertion that the *existing* probe (`planned[0]`only) cannot make. **`octopus_charge_limit`reads a charger's "% to add" target as an absolute SoC (GH#3034, verified on main 2026-10-05; same family GH#3060):** the arg has one read path (`get_arg(..., index=car_n)` → `× car_charging_battery_size / 100` → `min()` into `car_charging_limit`) and no config item expresses "target is % to add", so a charger-linked device's target reads as absolute SoC and`execute.py`'s "Car N is already charged, ignoring additional charging slot" check (deliberately the real limit, see #4967 above) skips the dispatch - while`load_octopus_slots()`still keeps Octopus's kWh (`octopus_intelligent_consider_full`off is the default, so PR #5403 changes nothing here - only the hold/slot decision distorts, the dispatch energy is not trimmed by it). Integration-side semantics corroborated in-thread only. Fix shape: a per-car absolute-vs-%-to-add mode switch (the reporter's own proposal); workaround = template sensor computing SoC + add% capped. **`octopus_intelligent_consider_full`, when on, now also withholds the cheap rate from future dispatch minutes no car needs (PR #5403, merged 2026-10-05, unreleased at the time of writing):**`octopus_surplus_minutes()` rounds unneeded minutes out to half hours outside the fixed 23:30-05:30 band, `rate_add_io_slots()`skips stamping them and the feed-side strip removes the discount; the plan is re-requested when the surplus set changes (`octopus_surplus_changed()`). Behaviour is unchanged with it off - so the "cheap rate missing in the back of a dispatch" report that arrives with consider_full on maps here before being called a stamping bug (it was #4482's ask). | `octopus_*`, `car_charging` | | Web, MCP and Chat (`web.py`, `web_mcp.py`, `chat.py`) | `html_plan_override` responds `{"success": true}` unconditionally and discards the override write's result, and the `run_in_executor()` it goes through submits to a thread pool without ever calling `.result()` on the future (`hass.py`), so an exception while persisting a slot override is swallowed with no log line anywhere. That code path is confirmed by reading; its link to the "manual override partially ignored" report it was found under is **not** (GH#3078). The MCP OAuth endpoint advertises `client_secret_basic`, but `oauth_token`/`_handle_authorization_code` only ever read the POST body and never the `Authorization` header, so a client authenticating via HTTP Basic looks like it is missing `client_id`; and `oauth_metadata_mcp` sets `issuer` to the bare host rather than `{base_url}/mcp`, which RFC 8414 requires when the metadata is served under a path suffix (GH#4799). Chat's provider table (`PROVIDERS`, `chat.py`) has no first-class Gemini entry; `type: openai` against Google's OpenAI-compatible endpoint works for plain chat but not for tool calling, because the tool-call accumulator keys fragments solely on `fragment.get("index", 0)`, so index-less SSE fragments collapse every parallel call into one garbled slot - the sibling `reasoning_details` accumulator in the same function already handles index-less fragments - and `thought_signature` appears nowhere in `chat.py` while the assistant message is replayed verbatim, so Gemini's required signature echo-back is dropped (GH#4904, both confirmed by reading, neither tested against the live API). Also `ChatRequestError.friendly()` hardcodes OpenRouter wording as its generic fallback (`OpenRouter returned HTTP N`), and so do the 402 and 429 branches — for any provider a non-401 HTTP failure is surfaced to the user as an OpenRouter error, confirmed live in GH#5074 where a direct-OpenAI 400 reached the reporter as "OpenRouter returned HTTP 400"; read the reporter's provider entry in apps.yaml, not the message. `_stream_chunks()` always appends `/chat/completions` to the user-configurable base URL and the catalogue fetch `/models`, so there is no config-only route to another path. The header status icons read **published entities, not live power**: `get_battery_status_icon()` (`web.py`) builds the SoC+icons string from `predbat.soc_kw` and the states of `binary_sensor._charging`/`_exporting`, which `set_charge_export_status()` (`output.py`) publishes from the end of `execute_plan()` — there is no path from the icon code back to the plan, so anything the header should distinguish has to be published by execute first (GH#5125/PR #5131). One string, three render sites — the page header, the dash Status table SoC row, and `html_api_get_status`'s `battery_html` JSON — all through `get_header_html`; page CSS lives in `get_header_html()` in `web_helper.py`, not a stylesheet, and the established pattern for anything coloured is a base rule plus a `body.dark-mode .foo` override, so an inline `style=` on generated markup cannot follow dark mode. **The chat model picker's "Default (…)" row was a silent no-op after any manual pick (GH#5230, fixed in PR #5274, merged 2026-09-27 — keep the mechanism for pre-#5274 logs):** `html_chat_model()` (`web_chat.py`) used to treat the empty id the Default row posts as "clear the conversation override" but called `set_selected_model()` only behind `if model_id:` — so the per-provider *remembered* selection (which outranks the apps.yaml default in `resolve_model()`'s chain: conversation override → remembered selection → default) kept winning in both the server and the browser's `effectiveModel()` mirror, and Default was unreachable. Editing the provider's `model:` in apps.yaml did **not** work around it (the remembered choice outranks it); clearing Predbat's chat storage did. #5274 calls `set_selected_model()` unconditionally (`chat_store.py` already pops the entry on a falsy id, so the default is not pinned) and clears the JS mirror's remembered copy before the picker is redrawn, and the new tests finally cover the empty-id Default path. Two /apps editor traps (PR #5243 review, code-read on main — the PR itself merged as 6d394b7a): `resolve_arg()` resolves templates with `value.format(**self.args)` and catches **only `KeyError`**, so an unbalanced brace (`abc{def`) raises `ValueError` and a positional `{0}` raises `IndexError` — `WebInterface.resolve_value_raw()` calls it for every string containing `{` on the `/apps` page render path, so a non-credential value like that is *suspected, not reproduced* to 500 the whole page (credentials escape on the page only because `mask_secret_args()` has already turned them into `xxx`); and the editor addresses nested values by dotted path (`parse_yaml_path()` splits on `.` and `[n]`) while `render_type()` builds `data-nested-path` from the literal key, so a dict key that literally contains `.` or `[` cannot be edited correctly — the save targets the wrong node or raises. The quote-bearing-value truncation (`value="${currentValue}"` in the JS) that #5243 fixed — input values now set via `input.value` and `escapeHtml` — matters only on pre-#5243 trees. **Plugins: endpoints work, nav is hardcoded (GH#5367, code-verified on main).** Plugin `on_web_start` hooks fire in `WebInterface.start()` (`web.py`) *before* the `registered_endpoints` loop mounts routes — the plugin system is initialised early (`predbat.py`) exactly for that — so a plugin page that 404s is an `on_web_start` problem, not a routing one. But `get_header_html()` (`web_helper.py`) hardcodes the nav menu with no plugin extension point, and `registered_endpoints` is consumed only by `start()` — no page lists them — so a plugin page is reachable only by typing its URL, despite `docs/plugins.md` promising "additional web interfaces and dashboards". "My plugin's page loads but I can't get to it from the Predbat UI" → the gap is nav/UI exposure, not the endpoint mechanism; fix shape is an opt-in plugin nav API (label+path, not auto-listing `registered_endpoints` — plugins register API-only paths too), matching the existing conditional-nav precedents (the Chat link's gate, "Metrics"). | `web_*`, `web_mcp`, `web_chat` | | Self-update (`github.py`, `download.py`) | The whole update path calls the GitHub REST API **unauthenticated** - the release check (`github.py:126`) and the file listing (`download.py:79`) - so it shares the 60-requests-per-hour-per-IP unauthenticated quota with everything else behind that egress IP. Behind CGNAT, a VPN or a shared proxy that 403s even though Predbat's own volume is one check per two hours; failures are deliberately never cached, so it then retries every 5-minute cycle. The listing request's error reaches only the addon stdout via `print()`, not the HA log a user would paste, which is why the report reads as "it just won't update" (GH#4886). A phone-hotspot A/B is the cheap differential test. | none | | Cloud / divergence modelling (`fetch.py`, `plan.py`) | Until v8.55.0 `step_data_history()`'s modulation was inert for every caller but one, through operator precedence: `int(...) + 1 if flip else 0` parses as `(int(...) + 1) if flip else 0`, a constant `0` whenever `flip` is False - and `plan.py`'s PV10 call was the sole `flip=True` caller. So `metric_cloud_enable` only ever perturbed the PV10 scenario, and `metric_load_divergence` - genuinely computed from load std-dev/mean in `output.py` - did nothing at all, on defaults-on settings, for as long as the block existed (GH#4870, evaluated directly rather than inferred; fixed in `f6c925d8` and then reworked again by the envelope model in `79f28f8f`). Worth knowing when reading a pre-v8.55.0 log or replaying an older debug dump: neither knob can be the explanation there. The review of the fix also found that switching the block *on* is the risky half - an empty `pv_forecast_minute10` pins `metric_cloud_coverage` at exactly 0.5 regardless of the weather, which is every non-Solcast user. | @@ -167,7 +167,7 @@ Grep for the named symbol rather than trusting a line number. | Battery pinned at 100% while the house imports at peak | Rule the plan out from the log before opening `plan.py`. `Completed run status ` says what Predbat asked for and `Inverter N count register writes` says whether it changed anything: `Demand` on every cycle, zero writes, and a flat SoC together mean Predbat asked for discharge and something outside it refused - look at the reserve/min-SoC entity binding for that integration. Three successive analyses of GH#4961 theorised about a charge window spanning the peak that the reporter's own log shows never existed. | | Battery charges to 100% at Predbat start, on a `pred_bat_mode` change or on a `set_read_only` change (an inverter whose target register carries a floor, e.g. Victron ESS via `inverter_type: SK` or any profile with `has_charge_enable_time: False`) | `PredBat.reset_inverter()` (`execute.py`): "safe mode" writes `adjust_battery_target(100.0, False)` with no `inv_has_charge_enable_time` consult - on such a profile the target register carries the ESS minimum SoC, so 100% is a charge-to-100 order, not an idle value. Fire conditions: the 13 `reset_inverter: True` CONFIG items set `inverter_needs_reset` on any value change *including* None→value at startup (`userinterface.py`), and the two `reset_inverter_force` items (mode select, `set_read_only`) additionally set the force name on a user event - the forced paths run the reset even in Read-Only, where no further writes happen afterwards (the head of `reset_inverter()` gates on it, and `execute_plan()` skips the loop). Normally the 100 is corrected later the same `execute_plan()` cycle (`adjust_battery_target_multi(inverter, 0, ...)` for `has_charge_enable_time: False` inverters outside a window), so it stands only when nothing competes: entering Read-Only, or Predbat stopping right after the reset. Other 100% writes: the calibration branch (`execute.py`) and `self_test()` (only under the `INVERTER_TEST` dev constant). Fixture trap: the identically named `reset_inverter(my_predbat)` in `tests/test_infra.py` resets test state and is unrelated - grep-based test-coverage checks get fooled, and no test module covers the real write path. Docs trap: `docs/inverter-setup.md` links `templates/victron.yaml`, which does not exist (404, confirmed against the raw URL; the Victron route exists in prose and GH#2846's discussion only) - a "user following the docs" Victron report is following the issue discussion. | `inverter` | | `Hold charging target X%-Y%` while the house still imports at a peak price, or a peak-price charge window in the plan | Three code paths, all verified by reading on main (GH#2716; the family covers #1690 and #5083, and the maintainer marked the original #2716 case fixed in Nov 2025): (1) *plan side* — the high-price gate (`plan.py`, `price_key > best_price_charge_level`) guards only windows whose limit is still 0, so a window already carrying a limit from the levelling pass is exempt and `discard_unused_charge_slots()` keeps any `limit > 0` slot with no price re-check — a peak-price charge window in the plan is not by itself gate evidence, run the config-before-code checks first; (2) *"Hold charging" can be an armed-window fallback, not a hold* — the window-disabling hold needs `set_reserve_hold` + `set_reserve_enable` + `reserve_max >= target` or `inv_has_timed_pause`, and without either the fallback re-arms the charge window while the status still reads "Hold charging"; (3) *the pre-window freeze write* — a freeze-flavoured window inside `set_soc_minutes` before charging starts logs `not yet in charge freeze, holding target SoC at 100%` and deliberately writes a 100% target, so a 100% slot visible in the inverter portal during a displayed hold is that write, not a rogue one. Forensic decoder: the status suffix in `Hold charging target X%-Y%` is the **SoC–target pair**, not window start–end; and while charging the executor writes `max(charge_limit_best[0], reserve)` as the target, so SoC rising past the target means the writes never landed or the inverter ignores mid-window limit reductions (unresolved — needs the brand). | `inverter`, `plan` | -| Predbat charged the car at peak rate, or planned a small house-battery top-up inside a peak window, with car charging enabled | Non-smart car planning is price-blind by design: **`car_charging_plan_smart` defaulted false until PR #5251 (GH#5237, merged 2026-09-26, unreleased at the time of writing) flipped the default to true** — `car_charging_plan_max_price` defaults 0 (never filters) either way, so pre-#5251 the car filled from the earliest low-rate-scan window onward regardless of price, and a plug-in mid-peak charged at peak (GH#2716, log-confirmed; smart mode is what re-sorts by price and honours the max-price skip, `plan.py`). On a #5251+ build a peak-rate car charge is no longer the default path — check the version before citing this explanation. Two more non-bug shapes from the same report: a saving-session reward is stamped onto the **import** rate too, which can push that hour above the low-rate scan threshold (`Rate thresholds ... import = rate_max - 0.5`), so the hour vanishes from `low_rates` and the car slot list shows a gap there — do not read that gap as a planning bug; and the in-progress peak-window house-battery top-up is the optimiser's keep response (`car_charging_from_battery: true` + `best_soc_keep` > 0 + the end-of-record SoC credit at replacement cost), same family as the "Hold charging target" row above — the high-price gate only skips windows whose limit is still 0, so a deliberately-selected window is exempt by design. Traps: `best_soc_keep` can be time-varying (automation or user), so the incident log and a later debug dump can disagree (4.0 vs 5.0 observed); and the `Calculate Best options` log line carries the plan-relevant config (`best_soc_keep`, `metric_battery_value_scaling`, ...) so you often don't need the yaml. | `car_charging` | +| Predbat charged the car at peak rate, or planned a small house-battery top-up inside a peak window, with car charging enabled | Non-smart car planning is price-blind by design: **`car_charging_plan_smart` defaulted false until PR #5251 (GH#5237, merged 2026-09-26, released in v9.3.0) flipped the default to true** — `car_charging_plan_max_price` defaults 0 (never filters) either way, so pre-#5251 the car filled from the earliest low-rate-scan window onward regardless of price, and a plug-in mid-peak charged at peak (GH#2716, log-confirmed; smart mode is what re-sorts by price and honours the max-price skip, `plan.py`). On a #5251+ build a peak-rate car charge is no longer the default path — check the version before citing this explanation. Two more non-bug shapes from the same report: a saving-session reward is stamped onto the **import** rate too, which can push that hour above the low-rate scan threshold (`Rate thresholds ... import = rate_max - 0.5`), so the hour vanishes from `low_rates` and the car slot list shows a gap there — do not read that gap as a planning bug; and the in-progress peak-window house-battery top-up is the optimiser's keep response (`car_charging_from_battery: true` + `best_soc_keep` > 0 + the end-of-record SoC credit at replacement cost), same family as the "Hold charging target" row above — the high-price gate only skips windows whose limit is still 0, so a deliberately-selected window is exempt by design. Traps: `best_soc_keep` can be time-varying (automation or user), so the incident log and a later debug dump can disagree (4.0 vs 5.0 observed); and the `Calculate Best options` log line carries the plan-relevant config (`best_soc_keep`, `metric_battery_value_scaling`, ...) so you often don't need the yaml. | `car_charging` | | Battery sits idle ("Hold for car") while the car charges from the grid on PV-then-grid — "why isn't the battery covering the house during the car's slot?" | Expected, not a bug: the hold (`execute.py`'s carHolding block) fires on `set_charge_window` with `car_charging_from_battery` off whenever a car is charging now or inside a planned slot, and it only stops the *battery feeding the car* — the car's grid charging and the charger's start/stop are outside its scope (GH#4572). "Charge the EV only when X" has no native knob at all: no EV-SoC gate, and the price knobs shape which slots are planned, not the charger — see the car-charging row above and open PR #3791 for the tooling direction. | | "Why does Predbat discharge to the house during cheap hours?" / low-rate hold requests | A structural model gap, not an optimiser mistake (GH#5122): any minute outside a selected charge AND export window falls into the `ECO Mode` branch of `predict()` (`prediction.py`), where the battery is modelled supplying whatever house load PV cannot cover — there is **no modelled action meaning "import from grid instead of discharging the battery"** outside a selected window. The only zero-discharge-outside-window modelled states are EV slots (`car_charging_from_battery: false`) and iBoost (`iboost_prevent_discharge`); all freeze/hold config is scoped to selected windows or manual periods. Also: a charge window covering a cheap gap saves only the round-trip loss on the cycled kWh — often ~2p, right at the default `metric_min_improvement_plan` gate of 2.0p — so a marginal window can legitimately fail to clear it (analysis, not verified against a real plan). | none | | "Predbat reports a GivEnergy error but I use Solis / Fox / GE-Cloud" | `inverter_type` was never set. It defaults to `GE` (`inverter.py`'s `get_arg("inverter_type", "GE")`, warning to the log only) and component auto-config is what actually writes it — roughly a dozen components (`solis.py`, `gecloud.py`, `fox.py`, `givtcp.py`, `sigenergy.py`, `gateway.py`, `teslemetry.py`, `deye.py`, `sunsynk.py`, `alphaess.py`, `solax.py`, `enphase.py`) — but auto-config only runs after discovery succeeds, while the component is registered as active the moment it is instantiated. So an outage or credential failure at startup leaves a non-GivEnergy inverter driven with GivEnergy capability flags, and every source read failure surfaces as `unable to read ... window - GivEnergy returned no data, check the GivEnergy credentials`, because `inverter_source_name()` prefers `INVERTER_DEF[inverter_type]["name"]` and `GE` is named "GivEnergy". Confirm from the debug yaml (no `inverter_type` key) plus the log line `Warn: ... inverter_type is not set in apps.yaml`, and treat any GE-specific behaviour in that log as assumed rather than configured. Self-heals on comms recovery; a manual `inverter_type` avoids it. The warning now names the assumed type and the component that should have set it — PR #4992 merged (`bdeb4e05`) after GH#4990, so on a current version the message reads `assuming ()` rather than a bare "assuming GE" (GH#4990). | @@ -175,9 +175,9 @@ Grep for the named symbol rather than trusting a line number. | `Best export window @ 0p` on a non-zero export tariff | Stale inverter-programmed discharge window read back from the inverter, not a rate: `update_status()` pre-fills `export_window` from the inverter's programmed discharge start/end times with `window["average"] = 0` (`Rates are not known yet`, `inverter.py`) and repeats it +24h for every forecast day; with `calculate_best_export: false` the planner uses those read-back windows verbatim as `export_window_best` (GH#5036, replay-verified). Grep the replay's config tail for `calculate_best_export`/`set_export_window` first. Related disambiguation: `Wrote N to discharge_rate` plus a reserve write right after a charge window closes is the routine restore/reset path (`execute.py`), not an export command. | | Inverter setting not applied | `execute.py` into the component's reconcile loop; check whether `read_only` is set. | | `Note: Inverter does not support charge/discharge freeze - disabled` although the user set that flag — or "the `has_service_api`/`has_rest_api` flag is on/off and Predbat behaves the same" | Two INVERTER_DEF flag families that do not do what their names suggest. The capability flags are per-inverter-type table values, not top-level config: `INVERTER_DEF[type]["support_charge_freeze"]` is copied to `inv_support_charge_freeze` in `Inverter.__init__` and `execute.py` force-disables the feature from those, so a top-level `support_charge_freeze:` in apps.yaml is read by nothing — it must go inside the `inverter:` block, which is merged into `INVERTER_DEF[type]` before the flags are read (the pattern `templates/luxpower.yaml`/`fronius.yaml`/`huawei.yaml` already use; docs/inverter-setup.md documents it — GH#1406, Sofar `SF`/`SFMB` ship both flags False; a custom `inverter_type` is seeded from a copy of the `GE` definition before the user's `inverter:` block overrides it key-by-key, so anything the block leaves out inherits GivEnergy's flags — GH#5066). Residual on #1406: a `templates/sofar.yaml` shipping the `inverter:` override itself (plus commented `charge_freeze_service`/`discharge_freeze_service` examples) is the open fix idea; whether Sofar's `battery_save` mode meets Predbat's charge-freeze contract is an unverified hardware question. Opposite direction: `has_service_api` is not an `INVERTER_DEF` key at all and `has_rest_api` is set on every definition but never read anywhere — REST is enabled by the `givtcp_rest` args and the service path by the presence of the `*_service` keys in apps.yaml (`call_service_template()` returns False when the key is absent); only `has_mqtt_api` is actually consumed (GH#5066). Two service-path facts a "can Predbat call integration X's service?" question needs (GH#5203, where the bot first wrongly answered "no mechanism" and had to post a correction): the six `*_service` config items (`charge_start_service` … `discharge_freeze_service`, `config.py`) are free-form service routes on the immediate-control path (`adjust_charge_immediate`/`adjust_export_immediate` through `call_service_template()`), where a dict-form entry names any HA service plus arbitrary data keys and `{placeholder}` values are `.format()`-resolved from the call's data (`device_id`, `target_soc`, `power`, the start/end times); identical calls are deduplicated by hash unless `repeat: True`/`always: True` (the GH#4876 interplay), and shipped templates now carry these keys across two dozen inverter templates. Window programming stays entity-based — no service hook — and there is no `set_charge_window`/`set_discharge_window` config item to stop window programming entirely. Upstream note from the same issue: Predbat's supported Solis Modbus path (wills106 solax-modbus + `templates/ginlong_solis*.yaml`) exposes TOU slots + commit button only, hultenvp/solis-sensor exposes no dispatch either, while the dedicated HACS integration **Pho3niX90/solis_modbus** does expose remote dispatch (`solis_modbus.solis_dispatch`/`_stop`/`_schedule`, capability-gated on register 34502 = 0xAA55) — reachable from Predbat today through the `*_service` keys, so Solis "remote dispatch" is a template/config question, not a Predbat code gap. | -| Reserve keeps ratcheting up to a high value and never comes back | `battery_min_soc` floors `set_reserve_min` **one-way**: `Inverter.__init__` bumps the reserve min up to it and nothing ever lowers it back — the log line is `Increasing set_reserve_min from X% to battery_min_soc of Y%` (`inverter.py`). If Y tracks the battery's live SoC or flaps within minutes, the user's `battery_min_soc` is bound to a dynamic entity (the stock Solar Assistant Growatt SPH template ships one) and the ratchet pins the reserve at the highest value ever read — 97% observed, leaving no export headroom on an 11.5 kWh battery (GH#4013). If `battery_min_soc` is unset it defaults to `set_reserve_min` and nothing ratchets, which is why "removed it and it works" reports check out. `battery_scaling_auto` only rescales `soc_max`, never reserve — a red herring in the same log. Broader reading for "reserve stuck at N%": **the first knob to check is `set_reserve_min` (default 4), because `battery_min_soc` is a raise-only floor *on* it, not an independent floor** — with `battery_min_soc` scalar `0` set and `set_reserve_enable` on (`has_reserve_soc` types), `reserve_percent` is the `set_reserve_min` default of 4 while `reserve_percent_current` correctly reads 0, so the observed "ignored `battery_min_soc`, stuck at 4%" is the other knob, not the scalar being dropped (GH#5232, probe-verified: `get_arg(..., index=0)` returns scalars fine — `resolve_arg()` extracts-by-index only for *lists*, so scalars are silently accepted). The resolution for "I want a 0% floor" is `set_reserve_min: 0`. | +| Reserve keeps ratcheting up to a high value and never comes back | `battery_min_soc` floors `set_reserve_min` **one-way**: `Inverter.__init__` bumps the reserve min up to it and nothing ever lowers it back — the log line is `Increasing set_reserve_min from X% to battery_min_soc of Y%` (`inverter.py`). If Y tracks the battery's live SoC or flaps within minutes, the user's `battery_min_soc` is bound to a dynamic entity (the stock Solar Assistant Growatt SPH template ships one) and the ratchet pins the reserve at the highest value ever read — 97% observed, leaving no export headroom on an 11.5 kWh battery (GH#4013). If `battery_min_soc` is unset it defaults to `set_reserve_min` and nothing ratchets, which is why "removed it and it works" reports check out. `battery_scaling_auto` only rescales `soc_max`, never reserve — a red herring in the same log. Broader reading for "reserve stuck at N%": **the first knob to check is `set_reserve_min` (default 4), because `battery_min_soc` is a raise-only floor *on* it, not an independent floor** — with `battery_min_soc` scalar `0` set and `set_reserve_enable` on (`has_reserve_soc` types), `reserve_percent` is the `set_reserve_min` default of 4 while `reserve_percent_current` correctly reads 0, so the observed "ignored `battery_min_soc`, stuck at 4%" is the other knob, not the scalar being dropped (GH#5232, probe-verified: `get_arg(..., index=0)` returns scalars fine — `resolve_arg()` extracts-by-index only for *lists*, so scalars are silently accepted). The resolution for "I want a 0% floor" is `set_reserve_min: 0`. **A display-only sibling (GH#5400, code-verified 2026-10-05):** the device's own register floor (`reserve_device_bounds()`, read from the reserve entity's min/max attributes - PR #4956's modelling fix reaches only `reserve_percent`) never raises the *shown* `set_reserve_min`, which is raised one-way from `battery_min_soc` alone in `Inverter.__init__` - so a component publishing an entity `min` without binding `battery_min_soc` (GE Cloud publishes entity min/max from the register's static validation rule and binds no arg; by contrast GivTCP publishes GE's assumed 4% floor lowered-only by the user's `battery_min_soc`) leaves the log's `Reserve min: 4%` line below the floor actually in force. Cosmetic: the clamp corrects `reserve_percent` itself, `self.reserve_min` feeds just the log - and feeding `set_reserve_min` from this static register metadata is the lower-risk direction for that failure than the dynamic-entity ratchet above, so it is a maintainer call, not a defect report answer. | | Both `Validation ... is not a list, but requires N entries based on num_inverters` **and** `Return bad int value []` repeat each cycle, "Read-Only with Errors" | A per-inverter arg whose reader fetches without `index=`: the schema's `entries: num_inverters` requirement and the reader's list-less read disagree, so *only the scalar form works* while the validator demands the list form. The list form fails `get_arg()`'s int/float coercion and silently falls back to the default **and** sets `record_status(had_errors=True)` every cycle. Instance: `inverter_reserve_max` — schema (`config.py`) demands `entries`, reader (`inverter.py:504`) reads `get_arg("inverter_reserve_max", 100)` with no index (unchanged since `d43f16a9`, Jun 2024, predating the schema), so the list form silently leaves the written-reserve cap (`inverter.py`, only use) at 100; the documented scalar form (docs/apps-yaml.md, faq) reads fine but the validator flags it, because the `entries` check runs before the integer type-check normalises a bare int to a one-element list and `continue`s past it — a fix has to decide together whether a scalar means "one value for all inverters" or the docs/validator change (GH#5369, probe-verified via `tools/triage_test.sh`). Fix shape: `index=self.id`, like the sibling `inverter_battery_rate_min` (`inverter.py:621`). | -| Plan shows a flat hold at the reserve but the battery drains well past it — on Sigenergy down to the inverter's own 0% cut-off | For `has_reserve_soc: False` types (stock `SIG`; #4832 is the GE Cloud flavour, open) the planned floor is **model-only**: `reserve_percent = reserve_min` (`inverter.py`, deliberate since commit `5caa9363` — use reserve_min as the planning floor for non-managed inverters) feeds only the plan's prediction, `execute.py` logs `Note: Inverter does not support reserve - disabling reserve functions` every cycle and never writes one, and the template's `reserve:` mapping is inert because for `has_reserve_soc: False` types the `Inverter.__init__` dummy block swaps `args["reserve"]` for a predbat dummy entity. The only device-visible stops are (1) the execute export-hold poll (`Export Hold (Demand mode) as export is now at/below target...`) when SoC reads at/below the target — a 5-minute poll that can land a hair **past** the floor — and (2) once the mode returns to eco/self-consumption, house load drains the battery to the inverter's own cut-off (SIG default 0%), which is the bulk of the drift (GH#5358, plan/log-verified 2026-10-02). Log tells: the "does not support reserve" disable line every cycle = `has_reserve_soc` False in effect (which also rules out the GH#4728 override shape that makes reserve writes destructive); `Export Hold (Demand mode) ... current SoC XkWh and target YkWh` = the poll stop at the model floor. Workaround: raise the device's own cut-off (`number.sigen_plant_ess_discharge_cut_off_state_of_charge`) so the inverter holds the floor itself — caveat: the canned docs-template automations re-pin the entity (edit the Freeze-Charging-excepted branch), and SIG's confirmed firmware bug (import-to-charge when SoC reads below the cut-off, docs/inverter-setup.md) only bites at the boundary. See also the reserve-ratchet row above for the `set_reserve_min`/`battery_min_soc` knob family. | +| Plan shows a flat hold at the reserve but the battery drains well past it — on Sigenergy down to the inverter's own 0% cut-off | For `has_reserve_soc: False` types (stock `SIG`; the GE Cloud flavour, #4832, is closed - the reserve-register modelling landed as PR #4956, merged 2026-09-06, fixing #4953) the planned floor is **model-only**: `reserve_percent = reserve_min` (`inverter.py`, deliberate since commit `5caa9363` — use reserve_min as the planning floor for non-managed inverters) feeds only the plan's prediction, `execute.py` logs `Note: Inverter does not support reserve - disabling reserve functions` every cycle and never writes one, and the template's `reserve:` mapping is inert because for `has_reserve_soc: False` types the `Inverter.__init__` dummy block swaps `args["reserve"]` for a predbat dummy entity. The only device-visible stops are (1) the execute export-hold poll (`Export Hold (Demand mode) as export is now at/below target...`) when SoC reads at/below the target — a 5-minute poll that can land a hair **past** the floor — and (2) once the mode returns to eco/self-consumption, house load drains the battery to the inverter's own cut-off (SIG default 0%), which is the bulk of the drift (GH#5358, plan/log-verified 2026-10-02). Log tells: the "does not support reserve" disable line every cycle = `has_reserve_soc` False in effect (which also rules out the GH#4728 override shape that makes reserve writes destructive); `Export Hold (Demand mode) ... current SoC XkWh and target YkWh` = the poll stop at the model floor. Workaround: raise the device's own cut-off (`number.sigen_plant_ess_discharge_cut_off_state_of_charge`) so the inverter holds the floor itself — caveat: the canned docs-template automations re-pin the entity (edit the Freeze-Charging-excepted branch), and SIG's confirmed firmware bug (import-to-charge when SoC reads below the cut-off, docs/inverter-setup.md) only bites at the boundary. See also the reserve-ratchet row above for the `set_reserve_min`/`battery_min_soc` knob family. | | First cheap window charges to 0% / the charge target lands later than the cheap band | A metric tie in the charge-window SoC scan: the running-best adoption is non-strict `<=` (`plan.py`, `(metric + min_improvement_scaled) <= best_metric_first and metric <= best_metric`) and `min_improvement_scaled` gates only against the first (full-charge) candidate, so an exact tie lets the later, lower-SoC candidate replace the fuller one. Ties are realistic because `metric_battery_cycle` defaults 0, making charging 1 kWh cost ≈ its stored-energy credit (GH#5069). Neither knob fixes it: `metric_min_improvement` doesn't cover the running best, and raising `metric_battery_cycle` raises the stored-energy credit too. | | I set a config item above its maximum and it stays capped — no YAML workaround | An `APPS_SCHEMA` item's `max` is enforced as a hard clamp on **every** value source, not just the HA UI: the input_number branch of `userinterface.py` re-clamps after `float()` (deliberate — an apps.yaml override bypasses the HA entity's own min/max; `Warn: Config item ... clamping to ...` is the diagnostic). GH#5070 instance: `debug_history_count` max 50 while the capture path itself is uncapped (retention is `interval × count` in `_capture_debug_history()`), so widening the window was a schema change — PR #5071 (merged 2026-09-19) raised the max from 50 to 500, with the storage warning in docs/customisation.md (the top of the range is ~2.5 GB on disk at 2-5 MB per snapshot). | | Hundreds of identical `Trying to write N to X didn't complete got M` warnings overnight | Before concluding control is broken, check whether the planner even had a window in that period: the writes may be the routine idle reset — `execute.py` re-writes charge/discharge rate to max every cycle while not charging so PV can still charge (GH#5073, where the planner's own overnight windows were empty and no charging was lost — pure log noise). `write_and_poll_value()` has no failure dedup: after `INVERTER_MAX_RETRY` attempts it warns once, flags `had_errors` and clears the ledger entry, so the next cycle re-attempts from scratch — a firmware state that permanently refuses a register (SolarEdge preserve-charge holding its charge-limit register at 0 is the first confirmed instance) produces ~10 refused writes + a warning per cycle, indefinitely; any brand with a refuse-silently register maps here. Exception since PR #5297: on the `GWMQTT` (Predbat hub) type the retry policy is 3 attempts, and a same-target control failing twice degrades to one write per 5 minutes until it verifies - the "indefinitely" reading is a non-gateway-type reading only. **A stable quantised read-back below the verify tolerance never converges (GH#5324, verified on main):** the entity-write tolerance is `battery_rate_max_charge * MINUTE_WATT / 20` — 5% of the **allocated** rate, and `battery_rate_max_charge = min(inverter_limit_charge, battery_rate_max_raw)/MINUTE_WATT` (`inverter.py`) — so an `inverter_limit_charge` cap (including the API override, which is in `CONFIG_API_OVERRIDE`) below ~5 register steps makes a quantised read-back (GE: a 1300 W write sits stably at 1206) fail verify forever: 10 retries plus a full repeat every cycle, while the identical write at the uncapped rate verifies (any allocation below ~1.9 kW on a register-quantised rate is the risky band). The REST layer's tolerance for the same write is sized differently — `write_tolerance_watts()/12` from `Invertor_Max_Bat_Rate` with a 2600 W fallback (`givtcp_rest.py`) — and is immune to the cap, so two layers with different tolerances answer one write and the narrower one decides. The signature is a read-back **stable** at the same value off-target every cycle (never self-heals) - distinct from the settling bounce above, where the value sits between old and target only transiently. Workaround keeps the cap exactly: pick a register-exact override value. **Fixed in PR #5348 (v9.3.4):** `rate_tolerances()` (`inverter.py`) now widens the read-back tolerance **below** the written rate to one hardware register step — `fuzzy_below = max(fuzzy, step + 1)` where step is `INVERTER_DEF`'s `rate_step_percent_of_capacity` (GE ships 1) × `nominal_capacity` — mirroring the REST layer's step, and the GivTCP REST and Gateway MQTT paths got the same rule (`49b90af6`, `e7ca9b6a`), so a rounded-down quantised read-back within one step of the write verifies instead of restarting the retry loop; a read-back over the rate is never that rounding and keeps the plain fuzzy (one step **up** is still written down to a 0W hold), and where no `rate_step_percent_of_capacity` is declared the pre-#5348 5%-of-allocated-rate fuzziness still decides. Two case notes from that investigation: the preserve-mode hypothesis could not be verified from the dump because Predbat reads no SE battery-state entity, so the preserve flag is invisible in debug dumps; and a second execute pass ~46s after the first roughly doubled the warning volume in that log — observed only, mechanism never pinned down. `'Connection to inverter ID N failed'` on SE comes from the HA SolarEdge Modbus Multi integration, not Predbat. | @@ -208,10 +208,11 @@ Grep for the named symbol rather than trusting a line number. | Two charge windows meeting at exactly 00:00 inside one continuous cheap import period (optionally with an export window squeezed between) | `calc_dawn()`'s one-way light latch resets at each calendar-day boundary (deliberate — a polar-night day must stay all-dark and a polar-day day all-light; the docstring on `calc_dawn` records why), so an evening after that morning's real dawn is latched "light" while the following pre-dawn buckets classify "dark" even though both are physically dark with zero PV: `pv_light_dark` steps 1→0 at every midnight, and `find_charge_window()` treats any in-window light/dark transition as a boundary (GH#5205, replay-verified against a v9.1.0 dump). Fires only with `combine_charge_slots: true` — with it off, windows are already broken every `charge_slot_split` minutes before a dawn boundary can be reached (`calc_pv_light_dark()` docstring). Once split, a charge-export-charge cycle has to clear the usual export economics gate, so the cycle is not itself a bug — the artificial split is. Fix direction is reclassifying the evening hours, not removing the reset (origin: PR #4726's dawn split). | | `apps.yaml has N errors` naming a `sensor.___` entity that always reads a constant (Eco / on / 100) | Predbat's own dummy placeholder failing `validate_config()` because the `modify: True` exemption is hardcoded to the default prefix: `if sensor.startswith("sensor.predbat_")` (`predbat.py`) while `prefix` is a user-settable option, so any custom prefix (e.g. `predbatsolcast`) makes Predbat's own control placeholders fail validation (GH#5242, verified by reading on main and v9.1.0 — identical). A `sensor.{prefix}_{type}_{id}_*` entity with a constant state is Predbat's own placeholder (inverters without GE-style mode control get one for `inverter_mode` = "Eco", `inverter.py`), not the integration's entity; idle-time dummies are not in `APPS_SCHEMA` so never validated. Impact is cosmetic — `arg_errors` feeds only the `config_valid`/`config_warnings` sensors and the Apps-page counter; it does not block Active/control. The exemption dates to #2137, the dummy path to #1674; fix direction is exempting `sensor.{self.prefix}_` instead of the literal. | | "I can only override one plan slot at a time" / the override box closes after each set | Working as designed on the plan UI: each slot's override box sends **one** `POST /rate_override` per action and the JS reloads the page 1s after success (`web_helper.py` `handleRateOverride`/`handleLoadOverride`; the reload closing the box is deliberate, and the handler sets `update_pending`/`plan_valid` so the plan recomputes) — `html_rate_override` (`web.py`) writes exactly one `"Wed 23:00=0.15"`-style option per call into `manual_import_rates`/`manual_export_rates`/`manual_load_adjust`. Batch overrides already exist outside the plan UI (GH#5289): `rates_import_override`/`rates_export_override` in apps.yaml take a list of `{start, end, rate}` windows, and the manual API keeps each no-index command as a separate stackable window (`docs/energy-rates.md`, `docs/manual-api.md`; shape in `tests/test_basic_rates.py` rate4/rate5) — point a reporter at those before treating it as a feature request needing new plumbing. | +| Turning `debug_enable` on changes the plan (or a "the kernel wasn't active" claim contradicts its own scan count) | Deliberate engine switch, present since the kernel was introduced (#4169), not a v9.x regression: `kernel_supported()` (`prediction_kernel.py`) requires `not save and not pred.debug_enable and kernel_handle != 0`, so with the debug switch on **every** trial prediction takes the Python engine (the batch path applies the same check) - and with the kernel dispatching, the SoC min/max range of charge-window jobs is computed inline by the kernel, so Python `Prediction.scan_soc_range` is never reached. The tells: a debug-off run's 0 `scan_soc_range` count *requires* a non-zero `kernel_handle` (the Python fallback would call it for every min/max job in both configurations), and the committed per-architecture libraries load from the module's own directory with no installer - so "the kernel was not present unless the installer ran" is self-contradicting when their own debug-off scan count is 0 (GH#5408, reproduced at probe level on main 2026-10-05). The plan flip itself needs a near-tie: #4453's diagnosis is that the levels pass requests a coarse step (`enable_fast_mode_levels`, plan.py, default on) that the Python engine honours while the kernel always simulates at full 5-minute resolution, so the fast pass changes candidate inputs and can tip near-tied candidates (measured there: 0.0056p on a ~1605p plan) - identical plans debug-on/off are the expected result on a non-tie fixture, and the 2h auto-disable (`DEBUG_ENABLE_MAX_HOURS`, `const.py`) bounds how long the switch keeps the slow engine. Two loose ends live on main: `predbat.py`'s `_debug_enable_auto_scope()` docstring calls the Python path "a more accurate but far slower prediction path" while `kernel_supported()`'s docstring says kernel runs are both faster *and more accurate* than a coarse-step Python run - one of the two comments is wrong (the #4453 analysis supports the kernel docstring), and no user-facing doc says enabling debug re-runs planning on a different engine. | ## Traps when investigating -- **Stale kernel binary.** The `prediction_kernel_lib_*.so` binaries are committed and CI has a job for them. The warning above means the checkout falls back to the Python engine, which is fine for triage. Never rebuild or commit binaries while triaging. +- **Stale kernel binary.** The `prediction_kernel_lib_*.so` binaries are committed and CI has a job for them. The warning above means the checkout falls back to the Python engine, which is fine for triage. Never rebuild or commit binaries while triaging. Two engine-active tells when triaging a plan-difference question: the libraries load from the module's own directory with no installer step (so "the kernel was not installed" is usually wrong in a checkout), and **a 0 `scan_soc_range` (`prediction.py`) call count over a full plan means the C++ kernel served it** - the Python fallback calls it for every min/max job, so a non-zero count proves the Python engine ran (GH#5408; a report claiming "the kernel was not active" whose debug-off scan count is 0 is self-contradicting). - **`minutes_now = 0` hides time-of-day bugs.** The load-forecast tests set `minutes_now = 0` in their shared setup, which made GH#4732's holiday off-by-one invisible for years: the faulty `tod <= minutes_now` was then true only for the midnight slot. Anything indexing history as `(minutes_now - tod) + d * 1440` needs a test at @@ -226,6 +227,7 @@ Grep for the named symbol rather than trusting a line number. - **Build test dates from Predbat's clock, not the machine's.** Predbat runs on the timezone set in apps.yaml (Europe/London in the harness), not the host's, so a fixture built with `datetime.now()` is on a different date from the code under test for part of every day. `test_saving_session` and `test_alert_feed` failed exactly that way under `TZ=America/Los_Angeles` at 21:30 local - and the clearest case is instructive: `load_free_slot()` measures a slot against `midnight_utc`, so a window built for the machine's "today" starts before Predbat's day began and is dropped entirely. Anchor fixture dates to `my_predbat.midnight_utc` (`96e1073c`, PR #4877). Hardcoded future dates in test data have the same shelf-life problem and were pushed out to 2099 (`d850365b`). Newest instance: PR #4971's `multi_car_iog` tests force `now_utc` to 12:00 on the *machine's* UTC date while `midnight_utc` stays on the real clock, so between 23:00 UTC and midnight the forced slot falls behind local midnight and is dropped - observed as `multi_car_iog_load_slots_regression` and `multi_car_iog_model_limit_fetch_4967` both failing with "car 0 should have IOG slots, got none" at 00:06 local, on a clean docs-only tree (mechanism reasoned from the code, not reproduced at a passing hour) - **fixed in PR #4998** by pinning `midnight_utc` in those tests, keep the mechanism for the next test that pins only one of the two clocks. Newest instance (2026-09-20 00:12 BST): `test_teslemetry_local_weekday_follows_the_base_clock` asserts its no-base fallback against the *host's* `datetime.now().weekday()` while the fallback itself is `datetime.now(timezone.utc)` — the two clocks disagree in the hour after local midnight (UTC is still the previous day), so the module crashes in that window; observed on a clean docs-only tree, and seen again 2026-09-22 00:09 BST aborting the quick suite mid-run. Counterweight for a different clock: storage-cache expiry is compared against the **real** clock (see the Storage row in the per-integration table), so anything producing or asserting an expiry must use the real one — the discriminator is which clock the code under test compares against. **Newest instance (2026-10-01 00:15 BST, found while flushing this queue):** `solax` fails the quick suite 00:00-01:00 local in summer — `test_query_plant_statistics_daily_main` asserts the queried month as `datetime.now()` (local) while the component derives it with `datetime.now(timezone.utc)` (`query_plant_statistics_daily()`, `solax.py`), and the two disagree while UTC is still on the previous day; nothing aborts, the module just reports `SolaX API tests FAILED:` with an **empty detail** after a long run of green subtests (the silently-set `failed` flag trips an `assert not failed` gate without a message), so grep the module output for the `**** ERROR:` lines — the month-prefix mismatch line is the only pointer. - **`midnight_utc` is local midnight, and `now_utc` is local time.** Both names are wrong in the same direction and it has misled at least one review into hunting for a UTC-vs-local frame mismatch that does not exist. `update_time()` sets `now_utc_real = datetime.now(self.local_tz)` (`predbat.py:654`) - already local, not UTC - and `midnight_utc` is that value with the time zeroed (`predbat.py:655`), i.e. the local midnight of Predbat's configured timezone as a timezone-aware datetime. Anything comparing a slot against `midnight_utc` is therefore working in local wall-clock, which is usually what you want and never what the name says. Before concluding a rate or slot bug is a timezone frame error, check which of the two frames the code is actually in. Corollary for reading logs: `time_abs_str()` renders against `self.midnight`, so a line quoting a past/negative minute key prints in the local frame and will not line up with the UTC timestamps an integration's sensor publishes (GH#5036) — recompute the minutes from the sensor's own timestamps instead of keying evidence off the printed date. - **Test ordering.** Since PR #5102 (merged 2026-09-15) every registry test builds its own `PredBat` (GH#5079), so the shared-fixture pollution class is fixed at the source — a test that fails in a full run but passes alone is now likelier to be one of the remaining hand-rolled restores (test_inverter.py, test_agent_tools.py; see the GH#5079 section) or a genuinely order-dependent case, and running only the targeted test still isolates. `run_debug_cases` builds a fresh instance per case for its own reason: each case replays a full dump. The wall-clock half is closed too: `create_predbat()` now pins `minutes_now`/`now_utc` from `FIXTURE_MINUTES_NOW` (`test_infra.py`), so no module inherits either the wall clock or another module's residue into those two fields — the fixture-level pin the GH#5026 note anticipated has landed and closes that class (keep the mechanism for pre-#5102 logs: a suite run used to sit at `reset_inverter`'s noon residue while a standalone run inherited the wall clock, and a time-of-day-dependent test could pass one way and fail the other — `prediction_batch` failed exactly that way, the ten-trial SoC-cost precondition collapsing onto one value at seed 5's window/rate alignment; equivalence-style subtests are `minutes_now`-insensitive, only precondition checks on seeded-scenario economics are sensitive). The per-module pins that preceded the fixture pin: PR #5028 (merged, `9290055d`) pinned the clock per-module - the third per-module pin of this class, after PR #5013 pinned dates and #4971's tests pinned `midnight_utc` - with `minutes_now` added to `SCENARIO_STATE_ATTRS`. Two caveats outlive the fixture pin: a pin-guard test is **vacuous in suite configuration when the pin value equals the residue value** (suite ambient was already 720), so a guard test must capture the pre-pin entry value rather than compare against the pin constant; and `update_time()` still takes `midnight_utc`/`now_utc_real` from the host clock, so the date-frame half of this trap is unchanged. +- **`./run_all --test ` resolves names through `TEST_REGISTRY` (`unit_test.py`), not module or file names - a green run proves nothing when the probe was never executed.** `--test dynamic_load_car` maps to `tests/test_dynamic_load.py::test_dynamic_load_car_slot_cancellation` (which runs only that module's Tests 1/2/5), while `tests/test_dynamic_load_car.py` is registered under the name `dynamic_load_car_not_charging` - so probing the car-not-charging tests under the obviously-correct-looking name is a green run with zero new assertions executed (GH#5407 triage). Check `TEST_REGISTRY` before building a probe; and inside `test_dynamic_load_car.py` the `_run_edges`/`_run_rates`/`_run_poll`/... group runs only when the dynamic-load-car args are enabled in the fixture, so a probe wired into that block silently never runs under the default fixture either - wire it into the module entry function after `_run()`. - **A textually clean auto-merge can still be a semantic conflict.** Merging `origin/main` into an old PR branch reported no conflicts while main's GivTCP refactor (#4864) had deleted `Inverter.rest_data`, which the PR both tested on and passed to the constructor - the merged tree crashed the first `Inverter()` construction in two test modules, only one of them the PR's own (PR #4645). For PR-cleanup work the full `run_pre_commit` suite is the detector, not the PR's own module. Corollary: `rest_data` is gone since #4864 and `inverter_source_active()` (`inverter.py`) is the replacement question for "is this inverter component/REST-backed" - anything written against `self.rest_data` predates the refactor. And when a test plants a components stub, construction-time calls run against whatever stub the *previous* test module left behind, so a new components-consulting call in `Inverter.__init__` needs a `getattr` guard on the registry surface, not a fake patched into one module. Correction to what this bullet itself used to imply: `inverter_source_active()` answers a **fleet-wide** question, not a per-inverter one — it takes no index and consults no per-inverter state, so one component-backed inverter at index 0 makes it true for a hand-configured, script-driven inverter at index 1 (its docstring says the scope is deliberate). There is no per-index source API; the usable proxy was the shape of the per-inverter args list itself, because auto-config wrote slots `0..n_discovered-1` and `_keep_configured_tail()` left the rest as the user wrote them — a list that names some inverters but not this one meant the source had said what it covers and this index was outside it (PR #4645, still open, gates on that proxy). **The proxy weakened with PR #5216 (GH#5209):** GivTCP now fills slot n from REST endpoint n via `_per_endpoint_values()`, so a gap slot below the highest discovered endpoint is filled even when nothing is configured for it (with that endpoint's own entities), and a full-length list no longer implies every index is component-covered. Only indices past `max(discovered)` still follow the old rule; a per-index gate has to compare against the component's entity-id prefix (`_givtcp__`) rather than the list length. Related: `create_missing_arg()` pads short per-inverter lists with `None`, not the default, precisely so an unfilled slot is not read back as an entity id — a hand-rolled `while len(...) <= id: append(default)` loop next to it is both dead and wrong about the padding value. - **Check a PR's base staleness before reviewing it.** A long-open PR's stored base commit can be well behind main, and a conflicting PR's headline fix may already be on main: PR #4756's `plan_iboost_smart()` fix duplicated the merged GH#4817 window-average fix, and resolving its `test_iboost.py` conflict in the PR's favour would have silently deleted main's `run_iboost_smart_average_test` — resurrecting the bug. Check `git merge-base --is-ancestor main` and diff any test-file changes against main's current tests, not the PR base, before analysing the "fix". - **GitNexus is indexed from main, so a PR branch is off-index.** `impact()` returns "Target not found" for PR-only symbols, and `detect_changes()` pins line-shifted hunks on unchanged neighbours - read its output as "which files", not "which symbols", and grep for callers instead (observed reviewing PR #5143). Same session: the review-creation API response reports `comments: 0` even when inline comments attach - verify with `gh api .../reviews//comments --jq 'length'` before resubmitting.