# Decision records Append-only. A new record goes here whenever we adopt a library or establish a core pattern. --- ## 2026-10-13 — `Forecasted` drops the session energy target too; the car's own limit is the ceiling **Context.** Issue #249. #217 took the daily EV target out of `Forecasted` and kept the **session energy target**, on the argument that a ceiling is not a request. That leaves the mode with one number in kilowatt-hours beside `Targeted`'s, and an owner can't see a difference between them that the UI never explains. So the three reasons it stayed were checked again: | #217's reason | Why it no longer holds | |---|---| | It stands in for "charge to 80%" on a charger that cannot read the car's SOC | The car has a charge limit of its own and stops drawing at it. That is the decisive action, and nothing here needs to predict it. With vehicle telemetry, `Targeted` takes an SOC directly. | | `Targeted` needs a departure, and "stop at 10 kWh, whenever that happens" has none | A departure can be up to `MaxHorizon` ahead. When the forecast covers the amount in that window, the pace is zero and the grid bridge is refused, because the plan imports nothing. The car waits for the sun and buys nothing. | | It resets per plug-in | So does a `Targeted` request. It ends when the target is reached or the departure passes. | **Decision — `Forecasted` takes no amount at all.** It reports what the day can give, and `Targeted` takes requests. The ceiling goes everywhere it appears: the hard stop in `ForecastedChargingController`, `ForecastedChargingOptions.SessionEnergyTargetWh`, `IForecastRuntimeSettings`, `ChargeControl:Forecast:SessionEnergyTargetKWh`, the Home Assistant number and the Forecasted tab's input. The mode is left with two runtime numbers, both protecting the battery: the SOC floor and the resume margin. Its hard stops are now the floor, the final guard and the evening guarantee — promises about the home battery, none of them about the car. **Nothing is lost, but one case moves out of reach of the web UI and Home Assistant.** *"Solar only, stop at X kWh, and I don't mind falling short"* is `Targeted` with `maxGridEnergyWh: 0`. The plan is then met from the roof or not at all, and the gap is reported as shortfall rather than bought. That limit is only settable through the API today (and so through the MCP server), and it still needs a departure, which can be up to `MaxHorizon` ahead. From the web UI or Home Assistant, the nearest thing is `Forecasted` plus the car's own charge limit. Consequences, deliberately accepted: - **The `session_energy_target` number is retired, not merely dropped.** Its config topic joins #217's in `RetiredDiscoveryTopics()`, so Home Assistant deletes the entity on its own. - **`SessionEnergyTargetKWh` is dropped from `appsettings.json` with no migration.** Bound options ignore unknown keys. - **Behaviour change:** a `Forecasted` session no longer stops on an amount. The car takes surplus until the plan, the floor, the final guard or its own charge limit stops it. - The **Session energy** sensor stays. It is a measurement, not a limit. The API never exposed the ceiling, so the OpenAPI contract does not change. --- ## 2026-09-23 — The loan is measured against what the pack cannot absorb, not against a constant **Context.** Issue #223, from a session chart: charging stopped for want of surplus while the home battery was full or nearly so. The instinct behind the report — *lend it and recharge the pack later* — is what the battery loan already exists for, and `RequiredSocFloorPercent` already is the "100 % by the end of daylight" condition, recomputed every poll. The wiring was what was wrong, in two ways. **The bug: a paused session could never restart on a loan.** The loan was sized to reach exactly `MinChargePowerWatts` (4140 W on three phases) and never more — deliberately, it is a bridge. But a restart had to clear `MinChargePowerWatts + ResumeHysteresisWatts` = 4340 W. Both rules were sensible and jointly fatal: **the loan could sustain a charge and could never start one**, whatever the state of the pack. No test caught it because every loan test passed `charging: true`; with these numbers the restart path had no behaviour to exercise. The loan now bridges to the threshold actually in force. The hysteresis exists to stop a marginal *surplus* flapping the charger, and a loan is not marginal surplus — it is a decision, steady for as long as it is granted. **Decision — the plan gains a spill term, and it is the loan's real question.** `SpillWh = max(0, Σ slice.SurplusWh − BatteryToFullWh)`: remaining surplus the pack has no room for. At 100 % SOC that is every watt still coming, and each of those watts goes to the car or out of the meter. The old reasoning behind `MinBridgeSurplusWatts` — "lending into no sun would be a battery-to-car transfer, paying a round trip for nothing" — assumes the energy lent would otherwise have been *kept*. On a spilling day it would not, so the round trip buys back energy that was leaving the house. Hence two floors: `MinBridgeSurplusWatts` (2 kW) when the pack has room, `SpillBridgeSurplusWatts` (400 W) when it has not. Not zero, because cycling the pack to chase noise is wear either way. **Decision — one helper for the effective minimum charge power.** `SolarDayPlanner.IsFeasible` counted a period chargeable from `MinChargePowerWatts − MaxLoanPowerWatts` (1.64 kW) while the controller refused to lend under 2 kW. Every slice between the two was **drawn as chargeable on the forecast page and silently was not**. `BatteryLoanRules` now owns both figures and both callers use it, so a window the plan promises is a window the controller enters. **Decision — the shortfall veto goes; the floor was already doing the work.** "No loan on a shortfall day" read as prudence and was mostly a veto on the midday spill: at 100 % SOC shortfall reduces to "the evening house load outruns the remaining sun", true on most afternoons after 16:00. Where it did describe a real inability to repay, the floor gate has already stopped the session — a day that cannot refill the pack puts the *trajectory* floor above the current SOC by construction, which is a harder stop than refusing a loan. Likewise the plan-level "no deliverable budget left today" stop, which ran *before* the loan was considered and wrote off exactly the day the loan exists for: it no longer applies while the day spills, because "nothing deliverable" is a statement about the forecast and a pack with no room is entitled to let the live surplus answer for itself. **Decision — the daily cap counts outstanding lending, not cumulative.** `MaxDailyLoanKWh` measured everything ever lent in the day, so a pack that lent 4 kWh in the morning, was refilled to 100 % by one o'clock and met a 3 kW afternoon was refused while physically in the state it started in. `BatteryLoanLedger` keeps both halves — lent, for the owner's figure, and lent minus recovered, for the cap. Recovery is clamped every cycle rather than netted over the day, so charging *before* a loan earns no credit against lending nobody has asked for. `MaxLoanPowerWatts` rises 2500 → 3800 in the same breath: at 2500 against a 4.14 kW floor nothing under 1.64 kW could ever charge, whatever the spill floor said. **Why no new safety argument is needed.** Lending down to the floor is, by that floor's definition, lending exactly what the day can repay. Repayment needs no machinery either: lending drops SOC, which grows `BatteryToFullWh`, which books more of the late production for the pack in the backward pass, which raises the floor, which squeezes the car out earlier. The loop was already there and already self-correcting; what was missing was permission to enter it. `BatteryLoanDaySimulationTests` runs the real planner and the real controller through a whole day at the poll cadence and checks the pack still ends it full — the floor is undershot by no more than the dwell timer is entitled to hold for, which is the one inherent slack in enforcing a floor per poll. **Observability, because this took a code read to answer.** `SpillWh` and the loan headroom are on the plan, in `SolarDayPlanResponse`, on the Forecasted tab, in the day-plan log line and as two Home Assistant sensors; the session chart draws the plan's floor beside the SOC it judges and shades the loan under the surplus. Every one of those figures was already on disk. --- ## 2026-09-23 — `Forecasted` stops asking how much the car wants; a request is what `Targeted` is for **Context.** Issue #217. `Forecasted` carried a **daily EV target** and `Targeted` takes an amount by a departure time. Both asked the owner the same question in the same units, and only one of them did anything with the answer: `Targeted` paces the charge to meet it, buys grid for the remainder, places that grid as late as it can and reports the gap when the time will not stretch. The daily target printed a sentence. It never stopped the charger, never paced anything and never bought a watt — past it the car went on taking whatever surplus the plan allowed, and lowering it sent the car no less. A yardstick wearing a request's clothes, and the clothes were the problem: they taught the owner that `Forecasted` takes requests, which it does not. **Decision — one mode takes a request; the other reports what the day can give.** The daily EV target is removed everywhere it appeared: `IForecastRuntimeSettings.DailyEvTargetWh` and its setter, `ChargeControl:Forecast:DailyEvTargetKWh`, the Home Assistant number, the web input and `SolarDayPlannerOptions.DailyEvTargetWh`. `evDeliveredTodayWh` goes with it — it was a parameter of `SolarDayPlanner.Plan` for the shortfall maths and nothing else. `DayPlanProvider` keeps its day integrator for the daily summary line ("the car took 9.4 kWh today" is worth having in the journal) but stops feeding it to the planner. What is left is the mode in one line: **charge the car from the surplus the forecast says the home battery will not need, and keep the battery above the floor that still gets it to 100% by the deadline.** Nothing load-bearing left with the target. The forecast slicing, the battery loan, the trajectory floor, the dwell timers and the evening deadline all implement that sentence already. **Decision — `DayOutlook` goes entirely, rather than shrinking to fit.** `Surplus`, `Tight` and `Shortfall` are verdicts on sufficiency; with no request there is nothing to be sufficient *for*, and two of the four became unreachable the moment the target was gone. What would have been left — is there a window, and how much is in it — is already on the plan as `NextFeasibleWindow` and `FeasibleEvEnergyWh`, so keeping a three-value enum that restates two existing fields would be the same overlap one layer down. `TightOutlookFraction`, `OutlookHysteresisFraction`, the `previousOutlook` parameter and `DayPlanProvider._lastOutlook` go with it. The hysteresis was earned — the outlook chattered on the boundary, three changes inside three minutes — but it was hysteresis on a verdict, and the verdict is what is being deleted. `DayPlanProvider`'s "material change" log trigger keyed on the outlook moving; it now keys on the plan becoming usable or unusable, the window appearing or going, and the budget moving by a whole kWh. **Decision — the two overlapping gates in `ForecastedChargingController` collapse into one, and it is soft.** The controller gated the car twice on the same fact: `Outlook == NoChargeToday` was a hard pause above the dwell timers, and `FeasibleEvEnergyWh <= 0` a soft one subject to them — and the hard gate already swallowed the soft one, since the expected energy it tested was `min(want, feasible)`. One gate now, on `NextFeasibleWindow is null || FeasibleEvEnergyWh <= 0`, and it is the **soft** one. "No window right now" is a weather condition like any other and belongs on the dwell timers with the rest of them; the hard stops above it are promises — the battery's evening guarantee, the final guard, the session ceiling — and weather is not a promise. **Behaviour change:** a day with no chargeable window used to end the session outright and now pauses through `MinRunTime` like a passing cloud. **Decision — the session energy target stays, because a ceiling is not a request.** A strict reading of "amounts live in `Targeted`" would take it too, and this is the reason it does not. It is a ceiling on one session rather than a request for an amount: it resets per plug-in, it stands in for "charge to 80%" on a charger that cannot see the car's own SOC, and `Targeted` cannot express it at all — `Targeted` needs a departure time, and *"stop at 10 kWh, whenever that happens"* has none. `Forecasted` is left with three runtime numbers, no two of which answer the same question: one ceiling, and two battery-protection numbers (the SOC floor and the resume margin). > **Superseded on 2026-10-13** by > [the record at the top](#2026-10-13--forecasted-drops-the-session-energy-target-too-the-cars-own-limit-is-the-ceiling): > the ceiling goes as well. The car's own charge limit is the "charge to 80%", and `Targeted` with a > far departure charges from the sun alone when the forecast covers the amount. **`ShortfallWh` loses its EV term** and becomes `expectedHouse + batteryToFull − remainingPv`: how far today falls short of the house plus a full battery, with above zero meaning the evening 100% is at risk. That is what the API contract's own doc comment already claimed it was; the code had drifted from the description, and this closes the gap rather than widening it. Consequences, deliberately accepted: - **A breaking API change.** `EvTargetWh`, `EvExpectedTodayWh` and `Outlook` leave `SolarDayPlanResponse`, and the `DayOutlook` enum leaves the document. The MCP server's checked-in OpenAPI copy regenerates with it. - **Three Home Assistant entities are retired, not merely dropped.** The `daily_ev_target` number, the `day_outlook` sensor and the `ev_expected_today` sensor are added to `RetiredDiscoveryTopics()`, the way the charge-mode select was in #89. Their configs are retained on the broker, so without that every existing installation would keep three ghosts nothing ever writes to again. - **`DailyEvTargetKWh` is dropped from `appsettings.json` with no migration.** Bound options ignore unknown keys, so a leftover entry in an existing config file is silently ignored. - **The Forecasted tab's headline changes kind.** It was a verdict — *"Today covers 9 of the 15 kWh you drive"* — and is now a forecast: *"Today gives the car about 9 kWh, in a window from 10:20 to 15:40."* Which is the only sentence the mode was ever entitled to. **The honest limit, written down rather than left to be re-discovered as a bug report.** "No grid" is only as tight as the meter. The mode never *decides* to import — it modulates against measured surplus and has no grid bridge at all. But on the reference installation the grid meter reads 0 W on phase T, so roughly a third of the EV's load is invisible and the surplus the mode acts on is optimistic while the car is drawing. Some import at the margin is a property of the measurement, not of the plan. --- ## 2026-09-22 — One secret store, owner-only on Linux, DPAPI on Windows, and no claim of encryption we cannot keep **Context.** Issue #215. Three files under the data directory are bearer-equivalent and were all plaintext, each with its own hand-rolled file I/O: `vw-website-session.json` (a live volkswagen.de session), `skoda-api-key.json` (a key that starts and stops charging), and — once #214 lands — a vehicle account password in `pv-system.json`. "Should we encrypt the password?" is the wrong question while the session cookies sit in plaintext beside it: whoever can read the directory reads the car either way. It is all three or none. **The constraint that decided the shape.** The controller must come back from a restart with nobody present — `Restart=on-failure`, and the point of #214's restart button — so the key that decrypts a stored secret has to be readable by this process, on this machine, at boot, unattended. Encryption a service can undo unattended is obfuscation. Rejected for that reason: a key file beside the data (file A encrypted with a key in file B, in the same backup), ASP.NET Data Protection (the same thing wearing a framework — on Linux its keyring is plaintext XML in the same directory), a passphrase at start (kills the unattended restart), and `systemd-creds`/TPM (the credential must be named in the unit, and `ProtectSystem=strict` stops the service writing under `/etc`, which is incompatible with editing it in the web UI). Full-disk encryption is the effective answer against a stolen SD card and is not ours to implement; it belongs in the install docs, where it now is. **Decision — one seam, `ISecretStore`, in `Gleanvolt.Core`.** `Read`/`Write`/`Delete`/`Describe`, by name. `SkodaApiKeyStore` and `VwWebsiteSessionStore` keep their own types and their own semantics — an expiring key, a cookie jar — and stop doing file I/O. Nothing throws out of the seam: a secret that cannot be read is null, which every caller already turns into *sign in again*, and a write that does not stick is false, which the caller reports as "a restart will ask for it again". **Decision — `FileSecretStore` is the default and not a stopgap.** Plaintext JSON, `0600`, atomic write, one file per secret named after it. On a Pi where the service user owns the data directory that defends against the one adversary a local key file would also defend against: another unprivileged account. Root is outside both. One file per secret, under the names the two hand-rolled stores already used, is also what makes the upgrade free — nothing moved, so nothing is migrated. **Decision — `DpapiSecretStore` on Windows installs, `CurrentUser`, and never in a container.** The zip and winget installs keep `data\` inside a package directory that an upgrade may replace and that people copy around wholesale; there DPAPI earns its place, because the key is the OS's and there is no key file to lose. `LocalMachine` scope is refused: it is decryptable by every account on the box, weaker than the `0600` it was meant to improve on. In a Windows container it is deliberately off — Nano Server runs under a built-in account that does not survive the container being recreated, and a secret sealed to it is *unrecoverable*, turning "my session expired" into "my session is gone forever". The selection is a pure function of (configured, isWindows, inContainer), logged with its reason, never inferred from `OperatingSystem.IsWindows()` alone; `Dockerfile.windows` sets `DOTNET_RUNNING_IN_CONTAINER` itself rather than trusting the base image. A payload marker (`GV-DPAPI-1:`) is what lets a plaintext file be recognised and re-sealed instead of being fed to `Unprotect` and coming back as a crypto exception. **Decision — honest naming, everywhere.** Nothing in the UI, the logs or the docs says *encrypted at rest*. `Describe()` returns what is true — *owner-only files (0600) in the data directory*, *Windows DPAPI, this user* — and the startup log, `/health` and the web health page all quote it. A claim of encryption that a local root can undo is worse than no claim, because it is the sentence someone repeats when deciding where to put a backup. Tests assert the absence of the word. **Consequences accepted.** `Vehicle:Website:SessionPath` and `Vehicle:Skoda:KeyPath` are retired in favour of one `Secrets:Directory`, and a build that still sets them refuses to start naming the replacement — silently reading secrets from somewhere else than the operator's file says would cost a fresh sign-in with nothing in the log to explain it. `System.Security.Cryptography.ProtectedData` is a new package reference (DPAPI is not in the shared framework, even on Windows). The DPAPI store's own behaviour can only be asserted on Windows; the decision that keeps it out of the container is a pure function, and that is tested everywhere. --- ## 2026-09-22 — One car, one feed: the live source when there is one, the Data Act portal otherwise **Context.** Issue #212. An ID.4 installation ran two manufacturer feeds, `vw-website` (live, only while charging, #170) and `vw-group` (the Data Act portal, a delayed batch), and a Škoda could do the same with `skoda` beside the portal. The holder kept the newest reading whoever produced it (#141), so the dashboard's source changed under the owner: volkswagen.de during a charge, the portal while parked. The feed health beside the reading was `FirstOrDefault()` of the registered services, which need not be the feed that produced it. The comparison those two feeds existed for (#141/#180) is over, and its answer was the live feed. **Decision — the configuration picks exactly one manufacturer feed.** `Vehicle:Website` configured means `vw-website`; `Vehicle:Skoda` configured means `skoda`; the portal runs only when neither is. A portal that is switched on beside a live feed is **skipped with one startup log line, not refused**, so an existing ID.4 `.env` with both keeps booting. The MQTT topic is out of scope and may still run beside the one feed; the holder's newest-wins rule stays for it. **Decision — the feed is a resolved type, `ConfiguredVehicleFeed`, not an enumerable a page picks from.** The dashboard, the sign-in banner, Health, Home Assistant and `IVehicleStateRefresh` all take it. It also carries the set-aside sentence the worker logs at startup. **Decision — owner-facing words live on the feed contract.** `IVehicleUpdateService` gains `DisplayName` (*volkswagen.de*, *MyŠkoda*, *Data Act portal*) and `OwnerAction`, the card's sentence for a feed blocked on its owner. Both are defaulted, and both are display only: nothing dispatches on them, which keeps the rule that `Manufacturer` is never dispatched on. **Decision — volkswagen.de is read continuously, and any failure stops it for the owner.** It is now the ID.4's only feed, and a plan is made before the charge, after a drive, so a feed gated to charging would leave plans on the last charge's SOC. It is read every `PollInterval` (5 min) while charging and every `IdlePollInterval` (15 min) otherwise. A one-time code wanted, an answer that is not a reading, or no answer at all sets *NeedsOwner* and the worker stops asking. The client counts successful sign-ins; the feed unblocks when the count moves, so signing in again on the vehicle page resumes it without a restart. Rejected: asking the car once before each plan, which spread the question over every planning surface (tabs, API, Home Assistant) instead of answering it in the feed. **Consequences.** - The ID.4 loses the car's own target SOC, which only the portal carried. Nothing reads it today, so it becomes unknown. - A lapsed volkswagen.de session now stops the feed even when no charge is running, so the owner is asked to sign in again more often than before, when only a charge could reveal a lapse. - `DeliversOnlyWhileCharging` is removed from the feed contract: no feed is gated to charging any more. - "What each feed says", `/vehicle-feeds`, `VehicleFeedComparison` and `VehicleFeedReport` are removed. With one feed there is nothing to put side by side, and the comparison had no other reader. --- ## 2026-09-21 — The installation is edited in the web UI into an overrides file, and applied by a restart **Context.** Issue #204. `/pv-system` was read-only because every value on it is resolved once at startup and the Modbus clients are built from it. Correcting a tilt angle or moving the charger to a new address meant SSH, an editor and `docker compose up -d`. **Decision — a save writes the configuration the next start reads; a restart applies it.** The running process never edits a decision it has already made. The web UI writes the edited keys to an overrides file, and the page shows *Saved, not applied* until a restart has picked them up. Scope is the `Pv` section as `/pv-system` shows it. Secrets, `ChargeControl`, `BatteryHold`, `Ev` and `HomeAssistant` can follow on the same mechanism later. **Decision — the file's location is a setting, `Pv:OverridesPath`.** The default is `data/pv-system.json`, resolved against the content root like the SQLite stores, which lands on the existing bind mount under Docker. The .deb's content root is a read-only `/opt` under `ProtectSystem=strict`, so its unit sets `/var/lib/gleanvolt/pv-system.json`. `PackagingTests` already requires a unit line for every `…Path` setting, which enforces it. Writing into `.env` or `/etc/gleanvolt/gleanvolt.env` was rejected: the container cannot see `.env`, and the service cannot write under `/etc`. **Decision — the file sits after environment variables and before the command line.** Compose always sets the device addresses and models from `.env`, falling back to defaults when `.env` is empty. If the environment won, the fields most worth editing could never be edited. The cost is that an `.env` edit can be silently shadowed. The file therefore holds only the keys that differ, each field names its source, **Revert** removes a key, and the startup log line names every key the file overrode. **Decision — a save runs the startup resolver on the merged candidate.** The editor predicts the next start by walking the process's own configuration providers in order, reading the file afresh where it sits. The merged result goes through `PvSystemResolver`, and a refusal writes nothing. The alternative was a file that stops the controller from starting, and the only remedy for that is SSH. **Decision — restart is its own exit code, 75 (`EX_TEMPFAIL`).** `restart: on-failure` and systemd's `Restart=on-failure` restart it, and it is distinguishable from 0 (Stop, stays down) and 143 (SIGTERM). It goes through the same graceful shutdown as Stop. Where nothing supervises the process (`dotnet run`, a plain `docker run`, the Windows zip) it simply exits, and the confirmation says so. Two alternatives were rejected. Relaunching our own executable would race the old process for the port and the Modbus connections. Rebuilding the host in a loop inside `Program.cs` would give up the fresh process that a restart is supposed to mean, since Serilog's static logger and the variables loaded from `.env` would carry over. **Decision — editing needs a login; restarting does not.** Device addresses decide where Modbus writes go, so with `Web:AuthenticationRequired` false the form is shown read-only and says why. Restart follows Stop, which an open UI already offers. **Consequence.** A changed device address is probed once before saving, with one read of a register whose range tells an inverter from a charger. It warns and never blocks, because a charger can legitimately be offline. After a restart the browser watches for the controller to go away and come back, then reloads, because Blazor's reconnect gives up on a circuit the new process never had. --- ## 2026-09-16 — Linux gets a .deb from the release, installed by one script; not AppImage, not an APT repository yet **Context.** Issue #205. A Linux user had the container image, which assumes Docker, a `.env` and chowned bind mounts, or a self-contained zip, which is a folder with no service, no fixed place for settings or data and no way to upgrade. Neither is an install. The controller is a headless service that has to start at boot, run as its own user and keep its settings across upgrades. **Decision — a `.deb` for amd64 and arm64, built by `release.yml`.** It is the same publish output the zip carries, wrapped by nfpm. The two Linux legs install it on their own runners, smoke-test the running systemd service (the same assertions as the zip, from the same script) and purge it, before anything is released. That covers Debian 12+, Ubuntu 22.04+ and Raspberry Pi OS 64-bit, which is every system the Pi and x64 deployments are realistically on. `.rpm`, musl and `armhf` are left out until someone asks. **Why not AppImage.** AppImage is for desktop programs a user starts. It creates no user, installs no unit, has no conffiles and needs FUSE, which Raspberry Pi OS Lite and Ubuntu 22.04+ do not install. An installer script would have to do everything a `.deb` does, by hand and with no upgrade or removal handling. For "run it anywhere" the zips already exist. **Decision — GitHub Releases, with `install.sh` attached under a fixed name.** `releases/latest/download/install.sh` never changes, and the script finds the package and its checksum by name in `SHA256SUMS`, so it needs no API call. Rerunning it is the upgrade. **An APT repository is deferred, not rejected.** It needs a signing key to guard and an index to rebuild on every release, and on a machine with unattended upgrades it would update charger-driving software with nobody watching. The script is the only entry point users type, so moving to a repository later changes what the script does, not what users run. **Decision — the default configuration, unedited.** Installing starts the service with the shipped `appsettings.json`. That means the web UI with no login, mode `Off`, the hold disabled, and no Home Assistant, MQTT or vehicle feed. `PackagingTests` checks that nothing in the package switches on a broker. The device addresses stay the reference install's, and the settings template and the post-install message name them as the first thing to change. Clearing them would have changed `dotnet run` for everyone. **Decision — the content root stays in `/opt`, and every writable path is set absolute in the unit.** `appsettings.json` and the Blazor assets are found through the content root, but relative data paths resolve against two different roots (the SQLite stores against the content root, the vehicle files against the working directory). The unit therefore sets `ASPNETCORE_CONTENTROOT=/opt/gleanvolt`, runs in `/var/lib/gleanvolt`, and gives each `…Path` setting an absolute value. `PackagingTests` fails if a new one appears in `appsettings.json` without a line in the unit. The unit uses `ProtectSystem=strict`, so a missing line fails on the first write rather than silently writing into `/opt`. **Decision — `Type=notify`, and the exit-code contract unchanged.** `AddSystemd()` makes a refused startup a failed `systemctl start`; outside systemd it does nothing. `Restart=on-failure` reads exit 0 (Stop from the UI) and 143 (SIGTERM) the way compose's `restart: on-failure` does. `SuccessExitStatus=143` keeps a normal `systemctl stop` from being logged as a failure. The maintainer scripts carry the rule through upgrades: a stopped service stays stopped, and a running one is stopped gracefully before its files are replaced and started again after. **Logs go to files as well as the journal.** The issue proposed the journal alone, with the package making it persistent. Changing journald's global storage is not a package's business, and the Pi's RAM journal is the reason the file log exists (`HostShutdown`). Serilog's file sink therefore stays, pointed at `/var/log/gleanvolt` with its own 14-day retention. --- ## 2026-09-16 — A Škoda is read through its owner API with a pasted key, and a feed a key unblocks resumes by itself **Context.** Issue #193. On 2026-08-31 Škoda published the MyŠkoda Public API: documented, keyed per owner and VIN, 20 requests an hour. `vw-website` is volkswagen.de and cannot read a Škoda, so a Škoda had only the Data Act portal's batch. The sign-in seam (`IVehicleAccountSignIn`) had one implementation and said it would stay unfrozen until a second one argued with it. **Decision — the contract grows by what a key needs.** `VehicleSignInStatus.KeyRequired` beside `CodeRequired`; `SubmitCodeAsync` becomes `SubmitAsync`, answering whichever the state asked for; the paragraph under the page's heading is the sign-in's `Explanation`. No credential-kind enum. The key lives in `data/skoda-api-key.json`, never in configuration: keys expire, and one place to renew one beats a `.env` edit plus a restart. **Decision — the feed is chosen by `Vehicle:Skoda`, and `Vehicle:Website` beside it is refused.** They are live sources for two different cars; an installation has one. `Ev:Vehicles[].Make` still selects nothing. **Decision — a blocked feed is re-checked, not only reminded about.** `VehicleUpdateWorker` used to park a `NeedsOwner` feed until restart. The Škoda feed stops being blocked the moment a key is pasted into the store it reads, so the worker now looks at `Health` once a minute (a property read, no request) and resumes when it clears. The VW feeds change health only inside a fetch, so they resume only if an on-demand ask succeeds — which is the owner having done their part too. **Decision — unlike `vw-website`, it polls between charges.** A key has no session to spend and an idle read costs one request; a Škoda has no other live source for the SOC a plan starts from. 15 min idle, 5 charging, the clock stops once three requests are left in the window, `Retry-After` honoured, `RateLimit-Remaining` read from every response rather than counted. **Not verified on a car.** Everything is built from the published spec with spec-built fixtures. The `state` → charge/plug mapping, the per-key-or-per-VIN quota and the charging capture cadence wait on a Škoda owner. --- ## 2026-09-16 — With a full pack, the hold's target reaches past PV, or it caps PV **Context.** On 2026-09-16 SolarGrid started a charge at 12:13 with the pack at 99% and 6.1 kW of sun. The hold armed on the car's shortfall and wrote `-min(house load, PV)`. A full pack takes nothing, so the target capped PV at the reading it came from. The pack's 326 W standby trickle let the next reading come in lower, and each target followed it down: Solar read 5992, 4995 … 1618 … 407, 177 and 116 W, and the targets were −5162, −4995 … −1618 … −407 and −177 W. For seven minutes the car took 4.2 kW from the grid under a clear sky. SolarGrid paused at 12:23:54, the hold released, and PV read 5.9 kW on the next poll. The 15-minute restart dwell then kept the car off. 2026-09-12 12:19 shows the same: 3777 → 792 W in four minutes, 3764 W on release. The 2026-07-26 record below left upstream's full-pack branch out "pending observation of whether PV curtailment actually occurs on this hardware". It does. **Decision — reach past PV while the pack is full.** When PV reads under the load, SOC is at or above `BatteryHold:FullPackSocPercent` (95) and the pack takes no more than 100 W, the push is `min(house load, PV + min(FullPackHeadroomWatts, forecast now − PV))`, with a 1000 W default. PV the target had capped climbs back by that margin at each write. If the sun really is that weak, a full pack covers the margin. The breach warning's allowance widens by the same amount, since that discharge was asked for. **Why bounded by the forecast.** A fixed offset lends from the pack at dusk and at night, when the hold runs under `FastNoBattery` for hours. There the forecast gap is zero. With no forecast for the moment there is no headroom, so a site without Solcast keeps the old behaviour. **Why not upstream's `-PV - 150` at SOC ≥ 98%.** 150 W is less than this pack's trickle, so the reading would still fall, only more slowly. **Why not drop the hold at a full pack.** A cloud edge would put the car on the pack, which is what #197 set out to stop. **Not changed.** With PV at or above the load the push is still the whole load, so a held full pack still exports nothing. --- ## 2026-09-15 — A mode that ends itself says why, and the session records that reason **Context.** On 2026-09-15 SolarGrid ended its session at 17:00 because the forecast had no sun left that day. The car was at 75%. The session was recorded as `SessionComplete` — *"The car finished charging"* — and the log said *"(charging finished)"*. `ChargingControlDecision.SessionComplete` was one flag that twelve different endings set, and the tracker read it before `CarConnected`, so even a car the mode saw unplugged was filed as finished (#198). **Decision — the decision carries the reason.** `ChargingControlDecision.EndsSession`, `ChargeControlCycleResult.EndsSession` and `ChargeControlStatus.SessionEndReason` replace the flags, and the old flag names remain only as properties computed from them, so a completion cannot be written without saying which one it is. The tracker takes the controller's reason first. **Decision — append to the enum, and keep `SessionComplete` for what its name says.** It now means the car stopped drawing on its own limit. `TargetReached`, `DeparturePassed`, `NoSunLeftToday` and `ChargerTakenOver` are new; a controller-detected unplug uses the existing `CarUnplugged`. The enum is stored by name, so rows written before this still parse. They keep `SessionComplete` whatever ended them, because nothing stored says which it was. **Decision — one wording.** `ChargingSessionEndReason.Describe()` is the clause used by the log (*"set to Off by SolarGrid (no sun was left to charge on today)"*), the session's end event and the session page. --- ## 2026-09-15 — The bridge hold arms on the car's live shortfall, and lets go after a dwell **Context.** The daylight check in #191 found the home battery giving 0.80 kWh to a 30.6 kWh SolarGrid charge, although the grid is the only source that mode promises for a bridge. The hold armed on `GridBridgeWatts > 0`, and the bridge is decided on the 3-minute average surplus. When a cloud cut the roof, the average stayed over the 4.14 kW floor for tens of seconds, the bridge read zero, and the inverter covered the car from the pack: 25 episodes, 0.49 kWh, down to −4.1 kW. Released on the first poll without a bridge, the hold also followed an average hovering on the floor: 22 arms and releases, five of them re-armed within 12 s, each a write to the inverter (#197). **Decision — arm on the live shortfall as well as the bridge.** In `SolarGrid` and `Targeted`, while the mode is charging, the hold arms when the car draws more than `BatteryHold:BridgeShortfallWatts` (300 W) beyond the instantaneous surplus. Charging decisions stay on the average; only the hold reads the live figure, because the hold is what keeps the pack out and has to act in the cycle the shortfall appears. Arming on the battery actually discharging was rejected: it is the direct evidence, but always a poll late by construction. **Decision — let go after a dwell, but only while charging.** The hold stays armed for `BatteryHold:BridgeReleaseDwell` (3 min, the averaging window) after the last bridge or shortfall. A charge that pauses, stands down or ends releases it in the same cycle, as before: with nothing being charged, holding the pack only puts the house on the grid. The cost is the house on the grid for up to three minutes after a shortfall — a few Wh, against kilowatts from the pack. **Not changed.** `Forecasted`'s SOC-floor hold, `FastNoBattery`'s switch-driven hold, and the hold's power target. The target is computed from the current poll, so a solar ramp before the next write can let the grid charge the pack for a poll or two (0.10 kWh that day); #197 records it to be measured again. --- ## 2026-09-13 — The restart dwell guards the running mode's own charge, in every mode that has one **Context.** `MinPauseTime` (15 min, 2026-08-08) keeps a paused charge from being restarted on every passing cloud: each stop and start is a contactor cycle and a vehicle wake. It was written as a *restart* guard, but each mode gated it its own way and each got the first start wrong differently. This charger starts a car by itself when it is plugged in; the owner plugged in at 10:37 and selected SolarGrid a minute later, and the mode's first decision found a car that "had drawn power" — a flag scoped to the plug-in — wrote 16 A → 0 A and waited with 2.3–4.4 kW of surplus (the same on 2026-09-12). `Forecasted` had no gate at all, so its dwell counted from the moment it was selected, every time. `Targeted` gated on energy delivered since activation, which also counts a charger-started draw. **Decision — one rule, `RestartDwell`, asked by every mode.** The wait holds only while paused, after the car has drawn power at this mode's request since it was selected, and for less than `MinPauseTime`. The coordinator tracks that as `ChargedThisMode`, reset on Off and on a switch between controlled modes, which does not pass through Off. SolarGrid, Forecasted and Targeted all ask the rule and none defines "restart" itself, so no mode can drift back into the lost start on its own. `EvDrewPower` keeps its meaning: telling a finished car from one that has not started is exactly where a charger-started charge should count. **Decision — where the rule is asked stays in the mode.** The coordinator does not turn a Charge into a Pause, because it cannot know why the mode wanted to charge. Forecasted's hard stops still pause regardless; Targeted still never defers a pace the charger can hold outright, and additionally requires a charge under the current target, so re-activating a target starts fresh. **Not changed: the 15 minutes.** No measurement or manufacturer limit backs the value. Changing it is a tuning decision for all three modes and belongs in its own change. --- ## 2026-09-12 — A blind meter phase is estimated from the inverter, not believed as zero **Context.** On the reference install the grid meter's L3 clamp (SolaX "T") reads 0 W: register 0x86 against the inverter's own 1.7–2.3 kW on that phase at 0x74. Everything on L3 is invisible — a third of a three-phase charge, and whatever the inverter exports there — so the house-load residual (PV + grid − EV − battery) went several kW negative while the car charged and sat at ~90% of PV on every sunny afternoon for at least twelve days. Every solar mode decides on it. SolarGrid showed it most plainly: paused on a cloud and never restarted with 4–5 kW on the roof. The "PV diverter" open question in the implementation log was this. **Decision — correct the grid figure in the reader, once, behind an explicit setting.** `Pv:Inverter:UnmeteredGridPhase` names the blind phase; with it set the reader replaces `GridPowerWatts` with an estimate and keeps the meter in `MeteredGridPowerWatts`, which the poll log prints beside it. Correcting the one input rather than each consumer means surplus, the house profile, the battery hold, energy intervals, session attribution and Home Assistant all read the same corrected number. Not auto-detected: a phase reading 0 W can also be a phase with nothing on it, and a guessed correction is far harder to spot than a configured one. **Decision — the blind phase's flow is the inverter's output there, the car's share, and half the metered house.** Only the house load on the blind phase is measured nowhere. Half of what the other two carry is wrong for any single large load and right for none, but it is bounded by the house — the kilowatts of inverter and charger power that made the old figure wrong are measured. The car is spread over the phases it charges on, a single-phase car on L1. **Decision — the real fix is the clamp.** This is a workaround for wiring and says so wherever it is documented; once the meter is repaired the setting goes back to empty and nothing else changes. --- ## 2026-09-12 — Solar with grid help: reality starts the car, the forecast only ends the day **Context.** A mode was asked for that takes both solar and grid, pauses when the sun is below an owner-set minimum — judged over several samples, the way the other modes ride out a cloud — and stops once the forecast has no energy left for the rest of the day. **Decision — the minimum is on the smoothed surplus, and the grid only bridges to the floor.** The same 3-minute average, hysteresis and dwell timers every solar mode uses; nothing new about clouds. Above the minimum the car follows the surplus, and a surplus under the 6 A floor is topped up to the floor and no further — the `Targeted` grid bridge, and the hold arms on it the same way. A minimum at or above the floor turns the bridge off without a second setting. **Decision — the forecast is consulted only once the live gate is closed.** It decides what an idle car does (wait, stand down, or end), never whether a car getting real sun may charge. Ending the day on a sunny afternoon because a forecast wrote it off is the expensive mistake, so the band defaults to the median, not the day plan's P10. **Decision — a stale forecast is believed about the evening.** The refresh worker stops spending calls once there is no sun worth refreshing for, so "stale after four hours" would have made the mode unable to end after dark — the one time it is sure to be right. An old forecast with no PV at all left today in its optimistic band still ends the day; one with any sun left is not believed. **Decision — it ends itself on a finished or unplugged car, unlike `Solar` and `Forecasted`.** A full car held at a bridged 6 A keeps the hold armed and the house on the grid for nothing; the pure solar modes have no such cost, and keep following the sun. **Decision — a forecast wait is a stand-down, a cloud is a pause.** Sun more than 15 minutes off is a wait in half-hours, which this wallbox will not spend in Fast at 0 A (#135). A shorter gap stays a pause, so a passing shadow costs no use-mode writes on a link that already drops ~45 times a day. --- ## 2026-09-09 — Two feeds is the steady state, and a feed declares its own silence (issue #180) **Context.** #141 ran the MQTT topic against the manufacturer's portal for a week and handed over. The plan after that was to delete `/vehicle-feeds`, on the assumption that a handover leaves one feed. **That was wrong.** What it left was two internal services with different jobs: | | `vw-group` (EU Data Act portal) | `vw-website` (volkswagen.de, #170) | |---|---|---| | Asked | always, every 15 min | only while a charge is running | | Lag behind the car | hours — its own batch cadence | seconds | | Plug state | never seen one | yes | | Target SOC, time left | yes | — | Neither carries everything, so both stay. The page keeps its subject and needed re-framing rather than deleting. **Decision — the page is an instrument, not a countdown.** Every sentence written for a bake-off with an end date is gone: the closing "when the week is boring" section, the hints that offered to decide which feed was right, and the framing that treated a second feed as temporary. What replaces them says what each feed is *for* before it says any number about it, because one of these feeds is meant to be idle and its figures mean nothing until you know that. **Decision — a feed declares whether it only delivers while charging; the page does not infer it.** `IVehicleUpdateService.DeliversOnlyWhileCharging`, defaulting to false. Only the service knows: volkswagen.de's gate is the *controller's* session — mode not Off, car connected, session not completed — and not the car's self-reported charge state, so nothing downstream could work it out from a reading. Inferring it from `VehicleState.ChargeState` would have measured the wrong predicate and mislabelled the portal, whose idle gaps really are dropouts. A row is matched to its service on the leading manufacturer name, which is how both services **compose** the source id rather than a guess about it — `vw-website` reports exactly that, the portal reports `vw-group …1234`. This is not the dispatch `Manufacturer` warns against: nothing is selected by the name, it only decides how a gap is described, and a feed that fails to match is described as an ordinary one. That is the safe way round — an unmarked designed silence is a puzzle, a marked dropout is a lie. **Decision — label the silence rather than exclude it.** The issue allowed either. Excluding would mean deciding, for each interval between two deliveries, whether a charge was running in the middle of it — and the comparison only ever sees the instants at its ends. Inventing a session model inside an observation-only instrument to remove numbers from a page is a poor trade against captioning the numbers that are there. So the marked feed's rows say *while charging only*, its longest gap says *between charges*, and the bands say that everything past two hours is one entry per charging session. **Decision — the regression column and the coverage table stay, and the page now says why.** With the MQTT feed off, the holder keeping the newest reading is the only thing refusing a backward one, and nothing else anywhere reports that it happened. Coverage stops being a week's finding and becomes the standing reference for which feed carries what — it is what showed the portal has never once reported a plug state. **Decision — every feed's readings are logged at the same level, and that level is Information.** They were not. The portal's every reading was `Information` and the MQTT feed's were `Debug`, which is off in production, so through the comparison week one feed was fully recorded and the other invisible — and the two were then compared as though the record were even. #141 reached a wrong verdict partly on that asymmetry. Information for all of them, because a feed logged at Debug is unmeasurable after the fact, and being able to audit the comparison is the whole point of keeping one. Not taken: deleting the page, and changing the `BothFeedsSeen` gate on the dashboard's per-feed sections. The gate is still right — two feeds is still the arrangement — but what the sections *mean* changed, so their wording did. --- ## 2026-09-09 — A stale car must not silently become a target (issue #179) **Context.** Reverses [a decision from 2026-08-22](#2026-08-22--the-car-answers-what-it-can-and-the-plan-is-quoted-before-it-is-promised), and the reversal is the interesting part: the original reasoning was sound and its premise expired. That record said a stale reading is *flagged, not withdrawn* — stale means "the feed may be dead", not "the number is wrong", since a parked car's SOC does not drift, and withdrawing the basis would only push the owner into doing the same arithmetic in their head from the same figure. That argument survived on an unstated condition: **two** feeds wrote to one holder and the newest won, so one of them was usually recent. [#141](https://github.com/mpospisil/gleanvolt/issues/141) removed the condition and measured what was left. The MQTT feed is off, `vw-website` is silent unless the car is charging, and for most of the day the portal is the only thing reporting — at a **median 5 h 10 m old when it arrived**, worst case **2 d 22 h**. There is no second opinion left to correct a stale or regressed reading, and `MaxAge` only ever greyed a number on a card. **Decision — a percentage target refuses to convert from a reading older than `Vehicle:MaxAge`.** In the terms it was asked in, with both figures and the setting name in the sentence: what the car said, how long ago, what the limit is. "I cannot tell you what 80% is in kWh right now" is a better answer than spending yesterday's percentage, because the amount is fixed at that moment and nothing downstream ever re-derives it. That one-shot rule is what makes the guard necessary rather than merely tidy — if the number is fixed for good, the reading it is fixed from has to be worth fixing on. **Decision — the guard lives on a `VehicleSocBasis`, not in the factories.** The percentage travels with its age and the limit it is judged against, replacing the bare `double?` both `TargetedChargeRequestFactory` and `FastChargeLimitFactory` used to take. A percentage on its own cannot answer the only question that matters at the moment of conversion — *is this still true?* — and the refusal wording lives there too, so the three doors (web tabs, HTTP API, Home Assistant button) cannot word it differently. `Vehicle:MaxAge` rides on the basis rather than on `VehiclePackLimits`, because it describes the **feed** and the pack figures describe the **car**: two configuration sections, and that split is deliberate. **Decision — absent is not stale, and is refused in its own words.** An installation with no feed is fully supported ([#137](https://github.com/mpospisil/gleanvolt/issues/137)) and sees no change at all. The two failures look alike from the arithmetic's side — neither yields a number — and want opposite things from an owner: one a feed, or asking in kilowatt-hours for good; the other the portal button pressed and the same request again. `VehicleSocBasis.None` is explicitly not stale, and a reading carrying range and a plug state but no percentage is not stale either, whatever its age. **Decision — it guards a conversion; it does not gate charging.** The feed stays advisory and nothing on a hardware path may depend on it — which was the second reason [#101's gates](#what-we-deliberately-did-not-build) were declined: *a gate on an advisory feed fails the wrong way*, refusing valid charges whenever the cloud session lapsed. This is not that gate. Asking in kilowatt-hours never reads the car, and `Full` on a fast charge asks it for nothing, so a dead feed cannot stop a charge — only a *percentage* one, and only by naming the reason. **Decision — a just-in-time tail on an energy request still splits from whatever reading there is.** The one place a stale percentage is deliberately still used. An energy request's amount is what the owner typed, so the reading can only shift *when* the held stretch lands, never how much is delivered. Refusing there would gate charging on the feed; silently dropping the hold would change behaviour without saying so. A plan that holds the wrong stretch back still delivers the energy asked for. **Decision — the form says so before the button does.** Both tabs' `SocConversionHint` went through the same guard. A hint reading *"From 42% now, that is about 32.5 kWh"* above a Start that refuses is the page contradicting itself, and the hint is where an owner reads the conversion first. Not taken: [#101](https://github.com/mpospisil/gleanvolt/issues/101)'s impossible-target gate. This gives it the trigger it was waiting for — the portal does carry `settings.target_soc` — but that is a different decision about a different number, and it is still deferred. --- ## 2026-09-03 — A download you can check, and an -rc that cannot take `latest` (issue #148) **Context.** Two defects in what the release step published, both cheap. Someone downloads a ~100 MB zip from a web page and runs it with the privileges of whatever it controls, and there was nothing to check it against. And `gh release create` infers nothing from a tag, so a `v0.1.0-rc1` would have published as a full release and taken `latest` — which is what `docker pull …:latest` and every "get the newest one" instinct then follow. **Decision — `SHA256SUMS` over the final artifact set.** Generated in the release job after the artifacts are collected and checked, written outside `artifacts/` and moved in, because a checksum file that lists itself is a checksum file that can never verify. It lists bare filenames rather than paths so that `sha256sum -c` works in whatever directory the download landed in, and the notes give the line with `--ignore-missing`, because a user has taken one file and not all thirteen. **Decision — `--prerelease` derived from a hyphen in the version.** SemVer's prerelease marker, and the build numbers this workflow derives never contain one — so this is a guard on the product line rather than a routine path. It is worth having precisely because the day it matters is the day nobody is looking. **Decision — build provenance attestation, which the issue left open.** Taken. `actions/attest-build-provenance` over each zip and `.nupkg`, costing `id-token: write` and `attestations: write` on the release job alone, and free for a public repository. The reasoning is that checksums and attestation answer different questions. `SHA256SUMS` proves a download matches what the release page claims; it says nothing about whether the release page is telling the truth, and it is served by the same host as the thing it vouches for. An attestation is signed by GitHub against the workflow run and the commit, and `gh attestation verify` checks it without trusting the page at all. That is the same argument `SourceRevisionId` already makes for the assembly — a version number is shared by every build that carries it, and the commit is what makes one build identifiable — carried out to the file someone actually downloads. For a binary that controls mains electrical hardware, that is worth four lines of YAML. Not over `SHA256SUMS` itself: an attestation there would add nothing the attestations over the artifacts do not already say. **Consequences.** - The release job now holds three permissions rather than one. Still only that job; the jobs that merely build a binary continue to run under `contents: read`. - The notes gained a "Verify your download" section carrying both commands, including the Windows one, since `win-x64` exists for people who have never heard of .NET and will not have `sha256sum`. It also says that PowerShell prints the hash in upper case and the file lists it in lower, which is the first thing that would otherwise look wrong. - `SHA256SUMS` covers the `.snupkg` symbol packages too. They are attached, so they are checksummed; only the attestation is narrowed to the things people consume. --- ## 2026-09-03 — A build nobody has started is not a build (issue #147) **Context.** Nothing in the pipeline had ever executed a produced binary. Everything the release workflow checked, a compiler had already checked. The failures that actually reach a user are the other kind: Blazor's client script not surviving a self-contained publish, a missing native dependency, ICU absent so a named timezone throws, an arm64 build that is quietly x64, or a binary reporting a version nobody built. **Decision — the publish matrix extends over runner as well as RID.** `linux-x64` on `ubuntu-latest`, `linux-arm64` on `ubuntu-24.04-arm`, `win-x64` on `windows-latest`, all free for a public repository. The publishes become native as a side effect, which is welcome but not the point; the point is that a binary can now be *started* on the platform it names. Cross-compiling for arm64 and never running it on arm64 was the specific hole. **Decision — the smoke test starts from the zip, not from the publish directory.** What a user downloads is the archive. Unpacking it and running what comes out proves the archive is complete, and on Linux that the executable bit survived — a zip that unpacks to a non-executable binary is a download that answers "Permission denied", and nothing else in the pipeline would notice. **Decision — one Python script, not a shell script per runner.** The four assertions are a contract, and two implementations of a contract drift. Only archiving is split per platform, because there the platforms genuinely differ: `zip` records Unix permission bits and `Compress-Archive` is what always exists on Windows. **Decision — the version assertion is equality with the number this run built.** The issue asked for "not `0.0.0-dev`", which was the right test when a placeholder existed. Since #145 the version is known before the build starts, and a dispatch legitimately produces `1.0.5-dev` — so "not -dev" would fail every dry run while still passing a build stamped with the wrong number. Equality is both correct in each mode and strictly stronger, and it is asserted in the two places the version appears, the startup log line and `/api/v1/health`, because different mechanisms stamp them. **Consequences.** - The controller is pointed at `127.0.0.1:1` for both inverter and charger. Refused immediately rather than routed and dropped, so the unreachable-hardware path is exercised in milliseconds. Surviving that is the first assertion: a controller that dies when its inverter is absent is broken in a way worth failing a release for. - `Controller__TimeZone` is set to a named IANA zone rather than left empty. It is the cheapest exercise of the ICU data a self-contained build has to carry, and it is one of the failure modes the issue names. - The smoke test blanks `Solcast__ApiKey` and `Weather__ApiKey` rather than assuming they are unset. CI has no `.env`, but a developer running the script locally has one somewhere above them, and Solcast's daily quota is small enough that spending a call on a smoke test is a real cost. - The cheap file-existence check on `blazor.web.js` added in #146 is **removed**, being strictly weaker than asking a running binary for the file. That is not a theoretical difference: deleting the file and re-running produced a **200 with an empty body**, not a 404, because the static asset manifest still lists it. The HTTP check therefore also asserts a non-zero length, which is the only thing that catches that case — and an empty 200 is exactly the dead page the `RequiresAspNetWebAssets` comment in `Gleanvolt.Worker.csproj` warns about. - The API is switched on for the test, because `/health` lives on it, which means a key must be configured: enabled with no key is a deliberate startup failure. --- ## 2026-09-03 — One job per runtime identifier, so a broken target names itself (issue #146) **Context.** All three self-contained publishes ran in one `for rid in ...` loop inside a single job. Three consequences, and the first is the one that matters: a `linux-arm64` failure read as "Release failed" in the checks list, and which target broke was a line in a log rather than a thing on the page. They were also serial when they are entirely independent, and all three built on the same runner — correct for a cross-publish, and the reason nothing could ever *start* a `win-x64` binary. **Decision — the workflow is four jobs, each answering one question.** `version` decides the number, `build` builds and tests and packs, `publish` is a matrix over the three RIDs, `release` tags and publishes. The split is by question rather than by convenience: reading the checks list should tell you what failed without opening anything. **Decision — `version` is its own job.** The publish matrix must not recompute the build number: three legs each reading the tag list is three chances to disagree, and the zips would be named differently from the packages. One job derives it, every other job is handed it. Same reasoning as the `version` job in `publish-image.yml`, and now the same shape. **Decision — `fail-fast: false`, stated rather than inherited.** The guarantee that matters — no release carrying two zips instead of three — comes entirely from the `release` job's `needs:` on the matrix, which a failed *or* cancelled leg breaks either way. Given that, cancelling the siblings buys a few runner minutes and costs the answer to "is this one target or all three?", which is the exact question splitting the job up was meant to answer. The issue suggested fail-fast contributed to the safety property; it does not, and `needs:` alone is load-bearing. **Consequences.** - Each leg uploads its own artifact (`release-zip-`), and the packages theirs (`release-packages`). The `release` job collects them with `pattern: release-*` and `merge-multiple: true` — a pattern rather than "everything", so an artifact added later does not silently become a release asset. - A new step asserts the three zips and five `.nupkg` files are actually present before the tag is pushed. The matrix cannot reach the release job half-finished, but it can be *edited* half-finished: a RID dropped from the list and left in the release notes would promise a download that is not attached. - `permissions` is now `contents: read` at the workflow level, with `contents: write` asked for by the `release` job alone. Nothing that merely builds a binary holds a token that can push a tag. - The publish still cross-compiles all three RIDs on `ubuntu-latest`. #147 extends the matrix with a runner per RID, which is what the matrix makes possible and the loop did not. --- ## 2026-09-02 — The product line is in the repository, the build number is not (issue #145) **Context.** The 2026-08-10 record below made the git tag the single source of truth and kept a `0.0.0-dev` placeholder in `Directory.Build.props`, reasoning that storing a real version "adds a commit per release whose only job is to agree with a tag". That reasoning holds for the *whole* version and fails for half of it. A tag name is typed by a human, so the number that reaches the packages, the image and the release is whatever that human typed — the placeholder guaranteed the parts agreed with each other, but nothing guaranteed any of them was the number intended. And `v*` as a trigger meant the only way to find out was to create something permanent. **Decision — the version is split by who decides it.** The major and minor are an editorial claim about the product, so they live in `Directory.Build.props` as ``, in a commit, reviewable. The build number is bookkeeping, so nobody types it: `release.yml` reads the tags already on that line and takes the lowest number not yet claimed. The old objection is answered rather than overridden — there is still no bump commit, because the half that would need one is the half a machine now supplies. **Decision — the tag is an output, not a trigger.** `release.yml` is `workflow_dispatch` only. It builds first and tags afterwards, with the number it just used, so a tag cannot name a build that does not exist. `push: tags: ['v*']` is gone: it was a second entrance that derived a version from a name, which is exactly what this removes. The `release` input separates the two things a dispatch can mean — off, it proves the build and creates nothing; on, it tags and publishes. **Decision — `VersionPrefix`, never `Version`.** The SDK derives `AssemblyVersion` and `FileVersion` from the prefix and composes `Version` as prefix + suffix. Setting `Version` outright leaves the other two reading the SDK's own `1.0.0` default, so a package called `1.0.7` would contain an assembly stamped `1.0.0` — agreement broken in the one place nobody looks. **Consequences.** - A build with no `-p:BuildNumber` is stamped `-dev` and is self-evidently local: `1.0.0-dev`. The suffix is set *before* `BuildNumber` is defaulted, so it reads the caller's value rather than the fallback. That single condition is the whole local-versus-CI distinction. - A dispatch that will not tag still builds the exact number it would have used, stamped `-dev`: `1.0.5-dev` proves the build-number path without producing a filename that claims a release. - Two release runs at once would read the same tag list, so the job is serialised by a `concurrency` group. The tag push is the backstop: it is rejected if the tag exists. - `.dockerignore` keeps `Directory.Build.props` out of the image build context, so both Dockerfiles mirror the product line as an `ARG VERSION` literal. A mirror drifts, and this one would drift silently — a locally built image reporting the previous line at startup is not a build error. `DockerfileTests` asserts the literal against the props, the same guard already used for the hand-written `COPY` list. - `publish-image.yml` off a tag now reports `.0-dev` rather than `0.0.0-dev`, which is the same string a developer's own build produces. - **A tag pushed with `GITHUB_TOKEN` starts no other workflow.** So `release.yml` tagging does not wake `publish-image.yml` on its own. Setting a `RELEASE_TAG_TOKEN` PAT makes it automatic; without one the release still completes and the run summary says the image needs a dispatch, rather than leaving the gap to be discovered by its absence. Supersedes the placeholder half of [2026-08-10](#2026-08-10--the-git-tag-is-the-version-the-build-carries-it-and-says-so). The other half of that record stands unchanged: the commit still travels with the version, and one invocation still makes every artifact. --- ## 2026-09-02 — Two feeds run together, the week is counted, and the handover is a setting (issue #141) Phase 3 of #137, and the only phase whose deliverable is a measurement. The MQTT source and `VwGroupUpdateService` run side by side on the reference install; nothing is decided until a week of real data exists; and whatever is decided, **no code is deleted**. ### The measurement is instrumented rather than eyeballed `VehicleFeedComparison` counts what each feed actually delivered, and `/vehicle-feeds` is where the figures are read off. Four questions, four sets of numbers: cadence, agreement, coverage, survival. Three decisions inside it are not obvious. **It lives inside `VehicleStateHolder` rather than beside it.** The holder keeps the newest reading and discards an older one — so a feed that is consistently *second* leaves no trace anywhere else in the process, and "consistently second" is exactly the finding that would decide the handover. The only place a losing reading is still visible is the method that throws it away. Owning it there also means it cannot be missed by being resolved out of the container after the first readings have gone past. **A reading is not a delivery.** The portal is asked every fifteen minutes and will hand back the bundle it handed back last time; a retained MQTT message is replayed on every reconnect. Counting those as deliveries would report a cadence the car never had, so the cadence is measured between *distinct* capture times and a re-delivery is counted separately as a repeat. This is the difference between measuring the car and measuring our own polling loop. **Everything is since the process started.** The temptation was to persist it, so that a week survives a redeployment. It was refused, and the reason is that it would make the measurement *worse*: a gap that spans a restart is the restart, and folding the two together turns a deploy into evidence against a portal. The claim being tested is an unattended week — the session count is already per-process for the same reason — so a week means a week of one process, and the page says how long it has been counting before it says anything else. ### Agreement is reported twice, and the parked half is the one that answers Where two feeds' capture times land within half an hour of each other, their states of charge are compared, and the mean keeps its **sign**: the finding is not that two numbers differ but that one differs the same way every time, and an unsigned average would hide precisely that. The pairing window is deliberately wide — the portal is a quarter-hour batch and the integration behind the topic has a lag of its own, so a narrow window would mostly measure which feed happened to land first. The cost is paid back by reporting the mean separation beside every figure, and by splitting out the readings where **neither feed said the car was charging**. A parked car's SOC does not drift, so a difference there is one of the two feeds being read wrong; a difference measured across twenty minutes of charging is the car doing its job. Without the split the second would be reported as the first. ### The handover is performed by configuration, and there is no irreversible step `Vehicle__Enabled=false`, stop the Home Assistant vehicle automation, leave `Vehicle__DataAct__Enabled=true`. That is the whole of it. `VehicleMqttWorker`, `VehicleTelemetryPayload`, `Vehicle__Broker*`, `Vehicle__Topic` and the per-car `Telemetry:Topic` stay in the tree and keep working, and are covered by tests that do not know the portal exists. Nothing is retired, for two reasons that outlive this site. If the portal disappoints in month two, the old feed is one setting away. And it remains the answer for every car nobody has written a service for — which is still most of them. One change was needed to make the week countable at all: a reading published to the MQTT topic without a `source` is now labelled `mqtt`. The payload contract is untouched — `source` was optional and stays optional — but a feed that names itself nothing cannot be told from another feed that names itself nothing, and the tally is grouped by that name. ### "Own a schema, not a client" is partly walked back, for one manufacturer Worth stating plainly rather than leaving in the diff. #73 set the rule: this codebase owns a *schema* — `captured_at`, `soc_percent`, and the rest — and every car's adaptation happens in Home Assistant, so a second car costs no code. `VwGroupUpdateService` is a client. It has credentials, a session, its own failure modes and a form flow that VW can change without notice, and it is exactly the kind of thing that rule existed to keep out. The justification is narrow and does not generalise: the EU Data Act portal is the **documented statutory interface**, not a reverse-engineered app API. It is the owner's own data by law, free of charge, and if it breaks it breaks in public with a regulator's attention on it — which is a different risk from an undocumented endpoint that can be withdrawn in a release note, as VW's WeConnect OAuth client was in May–June 2026. The rule still governs everything else. A manufacturer with no statutory portal, or one whose portal we have not written, is a Home Assistant automation publishing to a topic — and that path is still here, still tested, and still one setting away. ### What the week measured, and what it decided Both feeds ran from 2026-09-02 22:28 to 2026-09-08 21:24 — 5 d 23 h, and **not unattended**: ten feed starts, so the "seven consecutive days" the issue asked for was never achieved. Everything below is measured within continuous stretches and stands regardless, but the survival claim is weaker than intended and this record says so rather than rounding it up. | | VW portal (`vw-group …4196`) | |---|---| | Read attempts / produced a reading | 564 / 547 (97%) | | Failures | 13 `SessionExpired`, 3 `UnusableData`, 1 transient | | Distinct capture times | 76, of which 59 were forward deliveries | | Gap between deliveries — median / longest | 59 min / 14 h 16 m | | Gaps over 45 min | 30 of 58 | | Steps backwards | **132**, worst −2 d 20 h | | Sign-ins | **193** (median session 1 h 00 m, max 1 h 45 m) | | Reading superseded by the MQTT feed | 311 of 547 (57%) | | `settings.target_soc` | 80%, on every read | | Charge-time-remaining | carried on every read — the MQTT feed never carried it | | Plug state | **never** carried — 568 of 568 reads `Unknown` | | Charge state | carried on 557 of 568 | **Handover accepted.** `Vehicle__Enabled` off, the Home Assistant automation stopped, the portal feed left on. No code deleted, and the MQTT path is one setting away exactly as promised. **And by the time it was made, the portal was no longer the only internal feed.** #170 added `vw-website`, which reads volkswagen.de directly: it polls fast **while the car is charging** and returns nothing when it is not, and its readings arrive with roughly twenty seconds of lag rather than hours. So the site now runs two manufacturer services with different jobs, and the handover replaced one feed with a pair: | | `vw-group` (Data Act portal) | `vw-website` (volkswagen.de) | |---|---|---| | When | always, every 15 min | only while charging; idles at 1 min | | Lag | hours — the car's own batch cadence | ~20 s | | Plug state | never | yes | | Target SOC / time remaining | yes | — | That pairing is why the loss of the MQTT feed's plug state costs nothing in practice, and it is also why the two-feed comparison built for #141 keeps its subject: there are still two feeds to compare, they still disagree about the same battery, and one of them is silent most of the day by design. ### The reading that nearly went the other way, and why it was wrong The first pass over these numbers **declined** the handover, on the figure that looks most damning: the portal's reading was already a median of **5 h 10 m old when it arrived**, and only 3% of reads were less than half an hour old. Against a feed assumed to be live, that is disqualifying. The assumption was never checked, and it was false. The retained MQTT message at the moment of the decision was `2026-09-08T15:54:16Z` — 4 h 26 m old — while the portal was holding `19:17:18Z`, an hour old. **The portal was three and a half hours ahead of the feed it was being measured against.** Then the reason, which is the finding worth keeping: | MQTT capture | Nearest portal capture | Difference | |---|---|---| | 09-04 08:09:34 | 09-04 08:09:34 | 0 s | | 09-04 16:16:10 | 09-04 16:16:14 | 4 s | | 09-05 16:31:05 | 09-05 16:31:11 | 6 s | | 09-06 18:17:34 | 09-06 18:18:22 | 48 s | | 09-07 16:08:20 | 09-07 16:09:50 | 90 s | | 09-08 15:54:16 | 09-08 15:54:20 | 4 s | Six of seven within ninety seconds. **The two feeds carry the same car reports** — `volkswagen_connect` and the Data Act portal both ultimately surface the ID.4's own `car_captured_time`, by different roads. Neither can be fresher than the car chooses to be, and the five-hour median is the **car's** reporting cadence rather than either feed's lag. Two lessons, and the second is the expensive one: - **A comparison measures both sides or it measures nothing.** The instrument counted the portal exactly as designed and the analysis still reached the wrong verdict, because the benchmark was assumed rather than read. `VehicleFeedComparison` records per-source cadence honestly; what it could not do was stop somebody reading one column and calling it a comparison. - **The MQTT feed's per-reading log line is `Debug`**, so a week of it is invisible in a production log while the portal's is `Information`. That asymmetry is what made the wrong reading easy, and it is worth fixing before the next feed is judged this way. ### What is genuinely wrong with the portal, and survives the correction - **193 sign-ins in six days**, a session lasting an hour. Self-healing — every bounce recovered in about a second, no OTP and no CAPTCHA all week — and invisible to the owner, but "one sign-in is the healthy answer" was wrong by two orders of magnitude, and #138's question now has a real number. - **132 backward steps.** The feed repeatedly produced a capture time older than one it had already delivered. Through the week this was cosmetic: `VehicleStateHolder` keeps the newest capture and refuses an older one, so the dashboard never regressed — and it still refuses with one feed, which is what makes the handover safe. What it costs is deliveries: a feed that goes backwards is a feed not advancing. - **No plug state, ever.** 568 reads, 568 `Unknown`. The MQTT feed carried it and the portal does not, so the handover loses that field outright. The charger's own view of whether a car is connected is unaffected, and is the one that matters. ### "Own a schema, not a client" is partly walked back, for one manufacturer Worth stating plainly rather than leaving in the diff. #73 set the rule: this codebase owns a *schema* — `captured_at`, `soc_percent`, and the rest — and every car's adaptation happens in Home Assistant, so a second car costs no code. `VwGroupUpdateService` is a client. It has credentials, a session, its own failure modes and a form flow that VW can change without notice, and it is exactly the kind of thing that rule existed to keep out. The justification is narrow and does not generalise: the EU Data Act portal is the **documented statutory interface**, not a reverse-engineered app API. It is the owner's own data by law, free of charge, and if it breaks it breaks in public with a regulator's attention on it — which is a different risk from an undocumented endpoint that can be withdrawn in a release note, as VW's WeConnect OAuth client was in May–June 2026. The rule still governs everything else. A manufacturer with no statutory portal, or one whose portal we have not written, is a Home Assistant automation publishing to a topic — and that path is still here, still tested, and still one setting away. **That is the reason the MQTT feed is switched off rather than deleted**, and the reason it must stay that way: the day this becomes a two-car installation, it is the only thing that works. --- ## 2026-09-02 — The interval belongs to the service, and "needs you" is not a slower retry (issue #140) The VW portal now feeds the dashboard on its own clock. Three decisions in it are not obvious, and one of them reverses a smaller one from #139. ### The cadence is the service's, and is not configurable The host asks a service how long to wait and honours the answer. It does not own an interval and neither does the configuration file. The reason is that no single figure is right twice. VW's portal is a fifteen-minute batch delivery, so asking faster achieves literally nothing; polling a sleeping Tesla wakes it and costs its owner range, so asking *at all* has a price. A host-level `PollInterval` would have to be wrong for one of them, and whoever set it would have no way of knowing which. `NextDelay` is re-read after every fetch, so a backoff is the service moving its own number rather than the host inventing a policy for an API it knows nothing about. Not exposing it as a setting is the other half. An owner who shortens the interval to five minutes gets the same data three times and three times the sign-ins; the number is a fact about the portal, and the place to change a fact about the portal is the file that knows about the portal. ### A failure the owner must fix stops the feed, rather than retrying more slowly `SignInRejected`, `OwnerActionRequired` and `NoDataRequest` set `NextDelay` to `Timeout.InfiniteTimeSpan`, and the worker ends that service's loop until the process restarts. The tempting alternative — retry once an hour, or once a day — is worse in both directions. A refused password replayed on any schedule is how an account gets locked, and the schedule only decides how long that takes. A consent screen is a legal act that no number of retries answers. And the state that *deserves* the owner's attention is precisely the one a quiet hourly retry would keep looking like weather. What makes the full stop affordable is that a retry already exists and costs nothing: the `/vehicle-portal` button, with its own fresh session. Clear the screen, press the button, restart. **A state that never clears itself has to be said until it is fixed**, so it is said in four places: a band in the web UI's layout — every page, not only the dashboard card it belongs to — a `Car feed` row on Health, the `Car feed` entity for an automation to notify on, and a warning in the log **repeated every six hours**. The repetition is the part worth defending: a warning written once has scrolled out of `docker logs --tail` by the time anybody looks, and a stopped feed is otherwise silent in a way indistinguishable from a parked car. It is a log line and never a request, so "it stopped asking" remains literally true. An emailed one-time code is this case exactly, and it is also detected **structurally** rather than only by prose. `CanSignIn` — the guard that stops the word "consent" aborting a sign-in on a client literally named "GIS Consent Portal" — counts every rendered input, hidden ones included, so an OTP page carrying the address in a hidden `email` would have read as an ordinary sign-in page and been posted to. A field named `otp` or `emailOtp` says "a person is required" in every language a needle list would have to be translated into. `code` is deliberately excluded: it is OAuth's own parameter name, and a false positive would abort a working sign-in — worse than the false negative it prevents. `NoDataAvailable` is deliberately on the other side of the line and does not even back off: a newly created data request takes hours to fill, and backing off would take hours more to notice that it had. ### A reading is assembled from several deliveries, and dated by what contributed to it The first live read settled a question the fixtures could not. The portal's newest delivery for the reference ID.4 carried 47 fields — a real odometer (53,065), a real charge target (80), doors, windows, parking lights, climate consumption, charge-mode settings, error codes — and **no state of charge, no range, no plug state and no charging state**. Nothing was a sentinel and nothing was misspelled: the vocabulary matched what was there. The battery simply was not in that delivery. That is what `type: partial` means, and it had been read as "the newest delivery is the car". It is not: each delivery carries the reports that changed, so which report type you get is a coin toss. **So a read merges deliveries, and does it adaptively.** The newest first; older ones only while the merged reading still has no state of charge; never more than four, which is an hour of a fifteen-minute request and the span over which a parked car's SOC does not meaningfully move. A car whose newest delivery carries the battery downloads exactly one ZIP, as before — the budget is a ceiling, not a cost. Merging itself needed no new rules: several snapshots, sentinels filtered first, newest real value wins is what the mapper was already built to do, which is the whole reason #139 wrote those tie-breaks before anything needed them. **What merging did make load-bearing is the dating.** The mapper used to stamp a reading with the newest snapshot in the pile. Within one delivery that is minutes of slack and pedantic to argue about; across merged deliveries it is the difference between honest and not — a state of charge from 10:14 carrying a 10:29 status report's clock is a stale reading wearing a fresh face, and freshness is the one thing this feed exists to let an owner judge. A reading is now dated by the newest snapshot that actually **contributed a value** to it. **And the diagnostics grew the half they were missing.** The unrecognised-names list answers "what did you not read", and on its own it cannot distinguish "the bundle never carried that field" from "it carried it and the value was a sentinel" — opposite problems with opposite fixes. Both surfaces now also report what *was* recognised, with each field's raw value, quoted. The same reasoning applies to a report dropped for having no timestamp: it used to vanish whole, taking its field names out of the unrecognised list too, so a battery report under an unfamiliar timestamp spelling looked exactly like a bundle that never carried a battery. ### The energy contents are not a state of charge, and the measurement is why we know They were surfaced precisely to answer whether a percentage could be divided out of them for a delivery that carried no SOC. The live read answered it: `328.5` of `738.0` is **44.5%**, against the **50** the same car's `battery_state_report.soc` reported in the same read. The units are not kilowatt-hours and the pair plainly does not describe the same quantity the percentage does — a reserve below the usable window would explain it. Had that division shipped on the plausibility of the names alone, it would have been wrong by five and a half points in the one number every kilowatt-hour target is computed from, and wrong *silently*. The fields stay recognised and unread, which is what `KnownButUnused` is for. ### A merged reading is dated by its state of charge The merge budget was justified as "four deliveries is an hour of a fifteen-minute request". The live portal then offered **thirty** deliveries whose newest four spanned most of a day: it delivers when the car reports, not on a clock. So a state of charge can be hours older than the status report merged beside it, and dating the pair by the newer of them would show an old percentage as minutes fresh. The reading is therefore dated by the **state of charge's own snapshot**, falling back to the newest contributing field only when there is no SOC at all. Everything else on a `VehicleState` is display; the percentage is what a target is computed from and what `MaxAge` is judged against, so it is the field the timestamp belongs to. The budget bounds what a read costs over a domestic uplink and says nothing about how old the answer is — the answer says that itself. ### The session is held — reversing #139's "sign in afresh every time" The on-demand reader builds a cookie jar per press, and #139 recorded why: a held session that has quietly expired fails in a way that looks like bad credentials, and nobody knew how long one lived. On a clock that trade inverts. Signing in afresh every fifteen minutes means ninety-six password replays a day at a real identity provider, which is the thing #137 was most careful about. So the feed holds one session, and the client's existing "sign in when bounced" behaviour — already exactly once per call — handles expiry. **And it measures.** Nobody could answer #138's session-lifetime question because nothing had ever kept a session; the first thing that keeps one is the first thing that can measure one, so `VwGroupPortalClient` gained a `SignedIn` event and the service times the gaps. It is a lower bound by construction: a session is discovered dead only when it is next used. The client got no clock — it says *that* it signed in, and whoever cares what time it is timestamps it. ### Precedence between two sources belongs to the composition root `VehicleStateHolder` is last-write-wins, so "if both feeds are on, the manufacturer's wins" cannot be expressed inside either worker without one of them learning about the other. It is decided where both are registered: with `Vehicle:DataAct:Enabled` on, `VehicleMqttWorker` is not registered at all. Silence is the failure mode that mattered here, so `VehicleUpdateWorker` logs at startup when `Vehicle:Enabled` is still true — an MQTT automation that is publishing to a topic nobody subscribes to otherwise looks exactly like a working feed. ### The card shows the car because it is defined The vehicle section used to hide until a reading arrived, which made three situations identical: no feed, a feed that has not spoken yet, and a feed that has stopped. The car is configuration and the feed is an attachment, so the card shows the car and then names the feed's state — with *stale* and *sign-in required* rendered differently, because they need opposite things from the owner and a charger sitting idle is already the most convincing impersonation of a fault this controller can produce. ### What was kept small on purpose `IVehicleUpdateService` has five members and no credential abstraction, no capability taxonomy and no auth-model enum. What it exposes about authentication is not *how* a service authenticates but whether it currently can. One implementation cannot tell you what a second one wants, and the second one — a car that must be woken to answer — is the argument that should shape any of that. --- ## 2026-09-01 — The VW portal is reachable headlessly, and five of our assumptions about it were wrong (issues #137/#139) The EU Data Act client now signs in and reads the reference ID.4 from a button on `/vehicle-portal`. Getting there took a live trace of the whole flow, and almost every failure was ours rather than the portal's. Recorded because each one is invisible from the outside and expensive to rediscover. ### The sign-in - **`redirect_uri` is exactly `{portal}/login`.** A locale segment spliced in — `/de/en/login`, matching the paths the portal serves pages under — is answered `400 invalid_request, "Mismatching redirection URI"` before any credential is sent. The client id is registered against one string. - **`state` is not a nonce.** It is `country__language__BRAND` (e.g. `de__en__VOLKSWAGEN_PASSENGER_CARS`), and the portal's `/services/callbacklogin` decodes it to route the callback. With a random state the flow completes at the identity provider, lands back on the portal, and every `proxy_api` call then answers 401 with no session cookie set — a failure that looks like anything except a malformed request. - **The portal must be primed** with a `GET /` on the same cookie jar first; its callback needs the AEM cookies that sets. `prompt=login` is sent too, because the portal's own client sends it. - **The CSRF token is a JavaScript variable**, single-quoted, named by `csrf_parameterName` — not a hidden input and not in the template model. The identifier page *also* renders `_csrf` as an input, so step one works without reading it and hides the gap; the password page renders no inputs at all and answers `400` with a `generalErrorBranded` page. - **The password page ships no `
` and no ``.** `useClientRendering: true`, and everything is in `templateModel` — which a lazy `{.*?}` regex truncates at the first `}`, inside the nested `clientLegalEntityModel`. Its `postAction` is relative to the sign-in service's *client root*, not to the page. - **"Signed in" is a question of host, not path.** The landing page is `/de/en/user.html`; a check for `/login` in the path gets it wrong in both directions. - **This client is named "GIS Consent Portal".** Its ordinary email form therefore says "consent" six times — in the app name, the template model and the visible subtitle *"Welcome to Consent Portal"*. A consent-screen detector that searches page text aborted every sign-in before the first field was filled. The rule that replaced it: **a page you can sign in on is a sign-in page**, whatever words are printed on it. ### The delivery - The data request identifier lives on `/datarequest/vehicles/{vin}/metadata/partial` as `Identifier`. **It is not on the vehicle list**, and reading it from there reported "no data request" for a car that had had one since August. - The dataset list returns `{name, createdOn, size}` and **no download URL**. The download is a separate call whose dataset is chosen by a **`filename` request header**; `type: partial` is required on both. - `/proxy_api/consent/me/vehicles` requires `?viewPosition=FRONT_LEFT` or answers 400. ### The vocabulary, from a real bundle Ours was written from a description and was camelCase; the portal's is snake_case, so essentially nothing matched. A live capture (committed, sanitised, as `id4-live-capture.json`) corrected it: | | Real | |---|---| | State of charge | `battery_level_HV.value` — **not** the documented `hv_soc`, which this car does not send | | Odometer | `mileage.value` | | Charge state | `charging_state_report.current_charge_state` | | Target SOC | `settings.target_soc` | | Captured at | `car_captured_time` | **`remaining_charging_time_complete` is `"9900s"`** — seconds, unit attached to the digits, where the dictionary documents minutes. A plain numeric parse rejects it, and #73's present-but-unusable rule then discards **the whole bundle**: one suffix costing every field. Durations are now parsed unit-aware. VW's own Data Dictionary V5.0 (1,142 fields) is the authority, and evcc keeps a transcription at `vehicle/vw/eudataact/datadictionary.json`. It is worth consulting before guessing a field name — though note it documents what the *programme* can emit, not what a given car does. ### What this says about the bet in #137 **Unattended re-login works.** Six sign-ins from a cold cookie jar with only an email and a password, no OTP and no CAPTCHA. That is the fact the stored-credentials decision was resting on, and it holds — which also means the ~15 h expiry recorded in #73 is a property of how the session was kept, not of what the portal demands. Against that: five undocumented behaviours in one sign-in flow, none discoverable without a live trace, all of which VW can change without notice. The "own a schema, not a client" rule was walked back for this one manufacturer on the grounds that it is the statutory interface. That reasoning stands, and so does its price — this file is now part of the maintenance cost. --- ## 2026-09-01 — A configured secret reaches the UI only when the host has decided a login is enforced (issue #143) `/pv-system` had to show the MQTT broker credentials, and the same question is queued for the API key (issue #142). The web UI is an **open LAN dashboard by default**: no `Web:PasswordHash` means `WebOptions.AuthenticationRequired` is false, the default policy admits everyone, and the `[Authorize]` on the page permits an anonymous visitor. `MQTT_PASSWORD` is not a read-only credential — it is the account that publishes to the `.../set` topics, which start and stop the car. Rendering it on an unauthenticated page would hand the wallbox to anything that can reach port 8090, which is the outcome `HomeAssistant:Enabled = false` and the API's fail-fast on a missing key both exist to prevent. A page cannot be the reason a surface that defaults to closed ends up open. **So the decision is not shown in the markup.** The composition root knows both `WebOptions` and the secret; it builds the display record with a `null` secret unless a login is actually enforced, and the page renders what it was given. That is the same shape as `ReadOnlyModbusClient`, and for the same reason: a component that *cannot* do the wrong thing beats one that remembers not to. An `@if` in a Razor file is a rule someone deletes while rearranging markup, and its failure mode is silent and network-wide. Three consequences worth stating: - **Withheld and absent must not render alike.** "A password is set and you may not see it" and "the broker is anonymous" are different facts about the installation, so the record carries both the nullable secret and a `HasPassword` flag, and the withheld case names `Web__PasswordHash` as what makes it readable. - **Masked even when permitted.** A login is not a closed door; a shoulder and a screenshot are a different threat from the network. The value renders behind *Reveal*, with *Copy* beside it so the common task — pasting it into a config file — never requires putting it on screen. - **The name is not the secret.** Usernames, client ids and topics stay visible unconditionally. They are what makes the section useful for the "why is the broker refusing us?" question, and none of them is a credential. The next surface that has to print a secret should reuse this rather than re-deciding it: build the record in the host, gate it on `AuthenticationRequired`, and hand the page a value or a null. --- ## 2026-08-26 — An edited plan is constraints, not a schedule to replay (issue #128) The ask was "let the caller edit the plan it was quoted and send it back". The obvious implementation — accept the blocks and execute them — cannot work here, and the reason is the mode's whole point. **A plan is a derived value, rebuilt on every poll.** `TargetedChargeProvider` calls the planner each cycle with a refreshed forecast and the measured delivery. That is what lets a sunnier afternoon than forecast shrink the grid block before any of it is bought. A plan quoted at 22:00 is stale by 22:05, by design. Replaying one gives that up — and goes silently wrong besides: the blocks were computed against a `deliveredEnergyWh` and a forecast that stop being true immediately, so a replayed plan keeps buying grid for energy already in the car, and reports no shortfall, because shortfall is *also* a computed field of the plan it is no longer computing. So what a caller edits becomes `TargetedChargeConstraints` and the planner keeps planning inside them. The window is the owner's; what happens in the window is still the forecast's to decide. **Applied by narrowing the slices, not by post-processing the answer.** Everything in the planner flows from one list of forecast slices — the pace, the blocks, the solar/grid split, the ceiling, and therefore the shortfall. Trimming that list is the whole of honouring a time constraint, which is why a constraint cannot make the plan lie: there is no second path that ignores it. Two things had to be got right around that: - **Reserve before you narrow.** The home battery books its share against the whole window, then the car sees the subset it is allowed. Reserving inside a shrunken window concentrates the pack's entire need into the hours the owner happened to allow the car, and reads as a day with no surplus in it. - **Pace to the window that exists, not the deadline.** `hoursLeft` was measured to the deadline, so a `notAfter` that ended the window early computed an average over hours the charger was not allowed to run in — delivering a fraction of the target and calling it paced. Now the pass clamps its horizon to the last slice it was given, which is identical to the deadline whenever nothing has been cut. **The grid cap is spent as the pass goes, not subtracted from the total.** The first implementation capped by asking for less energy overall; that also cut the *sun's* share, which is the opposite of what "buy no more than this" means. It is now a budget the pacing draws down, so a cap of zero means sun-only rather than nothing-at-all. **Only the impossible is refused.** A limit that makes the request partial is a legitimate thing to ask for and comes back as a shortfall. What is refused, before anything starts, is a window with no time in it — which would otherwise start a mode that sits idle until the departure and then reports it delivered nothing. **And the shape is additive.** The limits ride on the existing request body, so the preview and the start take the identical body — what you quote is what you commit to, the limits included — and `/charging/start` gains the capability without an existing caller moving endpoints. The first attempt wrapped the preview body in a new object and would have broken every caller of it. --- ## 2026-08-26 — Documentation that cannot be found is not documentation (issue #126) Two faults, one symptom, and the symptom was that everything looked fine. **`GenerateDocumentationFile` set in `Directory.Build.targets` does nothing.** The flag evaluates to `true` and the compiler writes the `.xml` — into `obj/`. The SDK turns that flag into a `DocumentationFile` and into the `DocFileItem` that copies it beside the assembly, and it does both while importing its own targets, which happens *before* `Directory.Build.targets`. So the copy has nothing to copy. `Gleanvolt.Api` had worked around it in its own `.csproj` for long enough that the workaround's comment explained the cause correctly — and `Core`, `Hosting`, `Infrastructure` and `Web` never got the same treatment. It now lives in `Directory.Build.props`, **unconditionally**. Not conditioned on `IsPackable`, because a per-project opt-in is exactly what produced the trap, and a documentation file for a test project costs a file nobody reads. **An `IOpenApiSchemaTransformer` cannot describe an enum.** It is never invoked for one hoisted into `components/schemas`; the obvious implementation compiles, registers, runs, and changes nothing at all. That cost an hour, and the note is here so it costs nobody else one. A **document** transformer works: by then the component is an ordinary object in a dictionary. **The transformer only ever adds.** The built-in generator reads the same comments at compile time and does describe enum *types* once their XML is findable; what it never produces is the per-value legend, which is the half that matters — `one of: off, solar, forecasted, fastNoBattery, targeted` is a list of words to guess between. So the type summary is kept exactly as the generator left it and the legend is appended. Two consequences: this can never silently disagree with the compile-time reading of the same source, and an SDK that starts doing it properly leaves this with nothing to do rather than something to fight. **Every failure path ends in "no description."** Missing file, malformed file, trimmed publish. A document without descriptions is poorer; an API that refused to serve its own schema because a comment file was absent would be worse. **And it is asserted, not assumed.** The whole failure mode is that it looks correct from every angle the build can see — so the tests read the *served document* and check the descriptions are in it. One trap worth knowing when writing such a test: OpenAPI 3.1 renders a nullable reference as `oneOf: [null, $ref]` and hangs the property's description on the branch rather than on the property, so the naive check reports two dozen false positives. It reported mine. --- ## 2026-08-26 — The car is described once, and a car can only narrow a limit (issue #124) #111 gave the installation one description. The car never had one: its usable pack and charge efficiency sat in the `Vehicle` section — which is about an **MQTT feed** — and the three settings that actually decide how it is charged were the *charger's*, borrowed and used as though they were the car's. That last part is the reason this was not a tidy-up. `ChargePowerConverter` is built from `ChargeControl:Phases`, documented as the number of phases **the charger** charges on, and every watts↔amps conversion in the controller runs through it. A single-phase car behind a three-phase wallbox therefore had every power figure overstated threefold — #122's deferred charge starting hours late, the day plan budgeting energy the car could never take. Invisible on the reference install only because its car happens to match its wallbox. That is luck, not design. **The rule that took the most care is that a car can only ever *narrow* a limit.** The installation's maximum is the site's supply and the wiring in the wall; the car's is a second constraint, never a replacement. So `ChargingLimits.Intersect` takes the higher floor, the lower ceiling and the fewer phases — and lives in **one** place. That last part is not fastidiousness: the rule is three lines, it would be entirely reasonable to write inline at each of the six call sites, and that is precisely how six call sites come to disagree about whether "the maximum" means the wallbox's or the car's. **An unstated figure narrows nothing, and an absent section changes nothing.** `EvInfo.Unknown` is a first-class state rather than a null, so every consumer reads a car whether or not one was described. `IsConfigured` is judged on the values rather than on reference identity, because the two ways of saying nothing — no section at all, and one entry with every field defaulted — must read the same. The second is what the shipped `appsettings.json` produces, so it is the case every default install runs. **A band that inverts is a startup failure.** A car whose floor is above the installation's ceiling can never charge, and every symptom of that is silence: press the button, nothing happens, no error anywhere. Caught in the resolver naming both keys, which is the same posture `PvSystemResolver` takes and for the same reason. **The feed keeps its section.** `Enabled`, the broker, the credentials, `MaxAge` and `ReconnectInterval` describe the *feed*; only `Topic` moved onto the vehicle, because two cars on one broker are two topics. Moving the rest would have repeated the mistake being fixed — filing a thing's properties under whatever happens to report on it. --- ## 2026-08-26 — A fast charge can be deferred, and deferring is not planning (issue #122) The pack, not the price, is the reason. A lithium cell ages faster the longer it is held high, so the 20 points above 80% are worth buying as late as possible — and a fast charge pressed at 22:00 for an 07:00 departure spends nine hours at 95% to do two hours of work. **The obvious objection first: `Targeted` with `JustInTime` already does most of this.** It holds a tail back to land at the deadline, measured down from a rest SOC that defaults to 80%, and on a sunny day it is strictly better because it buys the first 80% from the roof. That was raised in the issue before anything was written, and the answer that carried was that the two are different tools: this is arithmetic on one line, forecast-free, works in December at 03:00, and lives on the tab somebody reaching for a fast charge is already looking at. If it ever grows blocks, or a forecast, or pacing, it has become the targeted mode and should be deleted rather than maintained. So `FastChargePlanner` is deliberately trivial: `remaining ÷ power`, subtracted from the departure. No surplus, no house load, no battery, no blocks. **Two things in it are not trivial, and both are about the power it divides by.** *It must be the car's, not the installation's.* An 11 kW wallbox in front of a car with a 7.4 kW on-board charger is a plan an hour short of the time it needs — and this is the mode with no slack by construction, because arriving *just* in time is the whole point. So the plan uses the observed draw once the car has drawn anything, and says (`PowerObserved`) which of the two figures it used, because presenting an estimate as a measurement is how somebody ends up trusting the first schedule. *And it must be remembered across the pauses.* While a deferred charge waits, the car draws nothing. A power read fresh each cycle would fall back to the installation's maximum every poll and forget, continuously, that this car only does 7.4 kW. That is a start time an hour late, arrived at by a component that looks correct in isolation. **Anything uncertain resolves to charging, never to waiting.** A zero or NaN power returns *no plan* rather than a nominal figure: dividing by something near zero defers the charge to the end of time, which produces no error, no charge, and a flat car at 07:00. The failure directions here are not symmetric and the code says so. **The start time is left in the past when there is not enough time**, rather than clamped forward. A plan clamped to now reads as punctual; a start time an hour ago is true and is what the shortfall is computed from. --- ## 2026-08-26 — The fast mode's hold arms on charging, not on mode entry (issue #122) #119 armed the discharge hold when `FastNoBattery` was selected. That was right when selecting the mode and charging were the same instant. With a departure they are hours apart, and arming at entry would lock the pack out of serving the house from 22:00 to 04:02 — six hours of the evening carried by the grid, nothing charging, and no line in the log connecting the two. So the arm moved to the first cycle that actually charges. Three properties had to survive the move: - **Still armed by default.** Keeping the pack out of the fastest charge the site can deliver is the mode's entire reason for existing; the owner never has to remember to. - **Still armed *through* the switch**, for the reason #119's record sets out at length: the HA switch publishes what is armed, not what was asked for, so a hold armed beside the selector leaves the owner's OFF as a `Set(false)` no-op that raises no event. - **Idempotent.** It now runs on every cycle rather than once, so it fires only on the transition — otherwise it would re-arm, every poll, a hold the owner had deliberately switched off mid-charge, and #119's central promise would be quietly undone by #122. The release did not move: it is still keyed on the mode no longer being `FastNoBattery`, which covers every ending without enumerating any of them. --- ## 2026-08-25 — A fast charge takes an amount, and the amount is a stopping condition (issue #119) `FastNoBattery` charged until the car said stop. *"I leave in an hour and I need 20 kWh"* could only be expressed as `Targeted`, which needs a departure it does not have, builds a forecast plan it does not want, and deliberately places its grid block **as late as it can** — the exact opposite of what somebody pressing *fast* is asking for. So the mode takes an amount: nothing (Full, the default and the old behaviour), an amount of energy, or a battery target converted to energy once at activation. Three things were decided along the way. **The amount is a stopping condition, not a plan.** No planner call, no forecast dependency, no solar/grid split, no shortfall arithmetic, no pacing. The setpoint is at the maximum from the first cycle to the last, and the amount decides only when to stop. `FastChargeLimit` is therefore a separate record from `TargetedChargeRequest` rather than a reuse of it: everything that makes that type what it is — `DepartBy`, `Priority`, `TailEnergyWh`, `RestSocPercent` — would be null here forever, and a shared type is an invitation to make this mode plan. **The SOC → kWh conversion happens once, at activation, and is not re-derived.** The rule `TargetedChargeRequest.TargetSocPercent` already argues at length, applied here without a word of change: a parked car's cloud SOC arrives when it feels like it, and a limit already half delivered must not move at 02:00 because the car finally phoned home. `VehicleTargetEnergy` is reused unchanged, and `FastChargeLimitFactory` is the one door all three surfaces compose through — the web tab, the HTTP API and the Home Assistant button must not be able to disagree about what is valid. **The limit is checked before the idle dwell.** A car that stops drawing at the moment it reaches the number would otherwise be reported as having hit *its own* limit. Once the mode reads `Off` the two are indistinguishable, and only one of them is true. --- ## 2026-08-25 — The fast mode's hold is armed through the switch, not beside it (issue #119) The mode armed the discharge hold by returning `true` from `AutoHold`, which the poll loop OR-ed over the owner's switch: ```csharp var hold = _batteryHold.Hold || AutoHold(...); ``` The OR's comment says what it defends — *"a hold the owner asked for is never released by a mode"* — and it is right about that direction. It says nothing about the other one, and in that direction the mode's `true` is a **floor**: flipping the switch off during a fast charge moved the switch in Home Assistant, logged nothing, and left the hold armed. A control that appears to work. **The trap underneath it is that the HA switch does not publish the owner's request.** It publishes `BatteryHoldActive` — what is actually armed — because a failed write should show as the switch springing back to `OFF`. So during a fast charge the switch reads `ON` while `IBatteryHoldSelector.Hold` is still `false`. The owner turns it off, the command path calls `Set(false, …)`, and `Set` is a no-op that raises no event when the value is unchanged. **The gesture evaporates before anything can act on it.** Any fix that waits on `Changed` without addressing the level mismatch reviews as correct and does nothing on the hardware. So the mode now arms **through** the selector on mode entry, and `AutoHold` returns false for it. The switch's level matches what is on screen, the owner's OFF is a real transition, and for the length of the charge the switch is the single input. **The release is keyed on the mode, not on the ending.** There are at least six ways a fast charge stops — Off from the web UI, from Home Assistant, from the API, the car's own limit, the amount, an unplug — and every one of them ends with this mode no longer selected. `ReleaseFastHoldIfEnded` runs once per cycle on that single condition, which is the arrangement `Targeted`'s hold already uses. Enumerating endings is how the seventh one gets missed, and the missed one leaves an armed hold on an inverter with nothing charging. One consequence is deliberate and documented rather than papered over: **a hold the owner had switched on before the charge is off when it ends.** Restoring a remembered prior value is bookkeeping a restart loses anyway, and it fails in the worse direction — the pack locked out of serving the house with nothing charging. One order matters and is easy to lose in a refactor: `AutoHold` is evaluated **before** the switch is read, not inlined into the `||`. Short-circuiting on a switch that is on would skip the release that runs inside it. --- ## 2026-08-25 — The system id names the topics; the unique id stays where it is (issue #111) Two installations could not share a broker. Every topic was `solax/solax_controller/…` with both segments hard-defaulted, so the second controller to connect overwrote the first, entity for entity. The fix is obvious — namespace the topics by the system's id — and it is one line. What took the thought is what *not* to rename at the same time. **Home Assistant keys an MQTT entity to its `unique_id`, and history follows the entity id.** A changed `unique_id` is a new entity: Home Assistant creates `sensor.…_2`, because the old entity id is still taken, and every graph on the dashboard starts again. Nothing announces it. So: - **`{BaseTopic}/{Pv:Id}/…` for every topic.** Free to change: the discovery configs are republished on the topics they always used, pointing at the new state topics, and Home Assistant re-subscribes. - **The device page is named after `Pv:Name`.** Also free: an entity id is assigned when the entity is first created and is not recomputed when the device is renamed. - **`unique_id` stays `{HomeAssistant:DeviceId}_{objectId}`, and `DeviceId` does not follow `Pv:Id`.** Empty means "take `Pv:Id`", which is right for a system being set up today; `deploy/docker-compose.yml` pins `solax_controller`, which is what every deployment that already exists created its entities under. The device's `identifiers` stays the unique-id root too, rather than following the system id. Moving an entity between devices is safe in itself, but keeping the device identity and the entity identity on the same value means there is one thing to reason about instead of two, and the issue's own rule — *the `unique_id` is the thing that must not change casually* — stays legible in the code. **A retained discovery config outlives the process that published it**, so a rename that is otherwise correct still leaves yesterday's device on the broker, to be re-created, permanently unavailable, at the next restart. `HomeAssistant:RetireDeviceIds` blanks those configs; `RetireTopicPrefixes` clears the retained *state* under a prefix no longer published on. Neither changes what the controller does — they change what someone reading `mosquitto_sub -t '#' -v` sees, which is the difference between a broker that can be understood and one that cannot. **`Pv:Id` is required only when the integration is on.** It fills a topic segment, so a system that publishes has to have one; a controller-only deployment publishes nothing and collides with nothing. Demanding it there would be a rule for its own sake. --- ## 2026-08-25 — The installation is described once, and the providers read it (issue #111) Where the array was lived in `Weather:Latitude`. What it was made of lived in `Solax:Inverter` and `Solax:EvCharger` — an address each, under a vendor's name, with no model number and a shape that could express exactly one charger because a single object cannot express two. What the array *is* — capacity, tilt, the direction it faces — lived nowhere at all: it existed only as a rooftop record inside Solcast's account, reachable through a resource id. Each of those is defensible on its own. Together they mean the controller cannot answer "which installation is this, and what is it talking to?" without someone reading three configuration sections and one web console. **One `Pv` section owns the installation**: identity, address, coordinates, azimuth, tilt, capacity, loss factor, install date, the inverter and a list of chargers. It lives in `Gleanvolt.Core`, because the weather client, the composition root, the API and the web UI all need it, and Core is the one assembly all of them may reference. It is resolved once at startup into a `PvSystemInfo` that everything downstream reads, so "which key won?" is a question asked in exactly one place. **A provider's handle for the roof stays with that provider.** `Solcast:ResourceId` did not move, and neither did `Weather:ApiKey`. The line is: the `Pv` section says what the array *is*; a provider section says how to reach *that provider* about it. A second forecast source added later gets its own section and its own handle, and reads the same geometry. **Where a device *is* moved; how often we ask it did not.** `Solax:PollIntervalSeconds` became `Controller:PollIntervalSeconds` and the vendor-named section went away entirely. The inverter has no opinion about its poll cadence — that is a controller setting, and it was the only thing keeping a section named after a manufacturer alive after the devices left it. This finishes what *the product is Gleanvolt; the vendor is still SolaX* (2026-08-16) started: a vendor's name is correct on a register map and on a model string, and wrong as the name of the section that configures the whole plant. **The move happened in two releases, and the second one refuses the first one's keys.** Phase 1 was additive: the new section existed and the older keys still won wherever they were set, so a deployment upgraded with an untouched `.env` and behaved identically, with one warning per key to say what to change. Phase 2 removed them — and a retired key that is still set is now a **startup failure naming its replacement**, not a silent ignore. That last choice is the one worth recording. Ignoring a key someone has set is the most expensive kind of quiet: the controller starts, polls, charges, and is simply pointed at a default address while the operator's file names another one. Nothing in the logs says so, because from the process's point of view nothing went wrong. A refused startup costs one restart and a message. We take the restart. **Configuration is validated at startup, all of it at once.** A latitude without a longitude, a tilt outside 0–90, an unparsable install date, two chargers, two chargers sharing an id: each is reported with its key, together, in one failure. Fixing configuration one restart per mistake is a miserable way to spend an evening — and a site that is *quietly* wrong forecasts plausibly, which is the failure mode this whole record exists to close. --- ## 2026-08-23 — The API says what it is before it asks who you are (issue #103) The API shipped with every route behind the key, the OpenAPI document included, and `/api/v1/` not mapped at all. The first thing that happened is the thing that always happens: somebody opened the base URL in a browser and got an **empty 404**, while every real endpoint answered 401, because no browser sends an `Authorization` header. A working API was indistinguishable from a broken one. **`GET /api/v1/` is now an index, and it needs no key.** It reports the product, the version, the running build, where the document is, how to authenticate, and every operation this build serves — read from the routing table rather than a list kept by hand, because a list that has to be maintained goes stale, and this one would go stale in the place a newcomer looks first. **The document is no longer behind the key either.** The original reasoning — no point handing the shape of a control surface to a caller who cannot use it — did not survive contact with what the document is *for*: a client is generated from it, and requiring a key to read it means it cannot be opened in a browser or handed to a generator without one. The disclosure avoided was slight next to that cost. The 401 already announces that the API exists, and on the same host and the same port the web UI is open on the LAN by default with every control on it, so keeping the document secret while the buttons are public was protecting the map and not the territory. **What stays behind the key is everything that reads telemetry or writes anything** — which is where the disclosure and all of the risk actually are. The index deliberately carries no site data: names of operations, and nothing about this house. A disabled API still maps nothing at all, index included: 404 rather than an index that announces a control surface nobody switched on. --- ## 2026-08-23 — A third surface, and the OpenAPI document is what it delivers (issue #103) Everything the controller knows was reachable by a human — the web UI renders it, Home Assistant publishes it — and by nothing else. The reason to change that is specific: an **MCP server**, so an LLM can answer questions about the installation and act on it. That server needs something to talk to, and what it needs is HTTP with a machine-readable contract, because its tool surface is *generated* from that contract rather than written against a moving target. **`Gleanvolt.Api` is a third first-class surface**, referencing `Gleanvolt.Core` and nothing else, on exactly the terms `Gleanvolt.Web` already sits on: it reads `ChargeControlStatusHolder`, drives the Core seams, and owns no decision logic. `Gleanvolt.Hosting` composes it. All three surfaces are independent — any, all, or none may run. ### One socket, because this is one appliance The API shares the UI's port rather than binding its own, and `Web:Port` is now the HTTP port for both. The "nothing is listening" fallback (`NoListenServer`) moved from "the UI is off" to "no surface wants a socket", which is what lets the API run with the UI switched off — a controller-only Pi that is driven entirely by a program. ### Off by default, and a key is mandatory when on The UI defaults to open on the LAN, and that is right: a browser with a person in front of it, and a fresh install has to produce something usable with no configuration. The API takes the opposite default on both counts — `Api:Enabled` is false, and enabling it with no key is a **startup failure** rather than an open control API. Two of its endpoints write to hardware, and the project's rule for anything that writes is that an operator switches it on knowingly. **Keys are stored as the secret, not a hash**, unlike `Web:PasswordHash`. The distinction is what the credential is: a password is chosen by a human and may be reused elsewhere, so the stored form must not be usable if the file leaks; an API key is generated, single-purpose and high-entropy, and a slow KDF on every request buys nothing against an attacker who can already reach the port. It is supplied out-of-band exactly like the broker password and the Solcast key. The key's *name* is not a credential — it is the action's source in the log and in the recorded session, which is the whole reason the map is `name → secret` rather than a list. **An endpoint filter rather than an authentication scheme.** A scheme would make the API depend on the UI being present, since the UI is what registers authentication and authorization at all, and it would share `DefaultPolicy` with the cookie — whose "no password configured means no login" rule is exactly the rule this must not follow. ### Composing a targeted request moved into Core The web form's `TryCompose` — the SOC → kWh conversion, the just-in-time split, the horizon check, the "the car is already there" refusal — became `TargetedChargeRequestFactory` in `Gleanvolt.Core`. Two doors onto the same promise cannot be allowed to disagree about what is valid or about where a conversion happens, and it is pure logic, so it belongs beside the planner it feeds rather than in a Razor component. The tab now translates its form into the factory's terms and does nothing else. ### The document is the deliverable, and its shape is pinned by a test For a human client the OpenAPI file is documentation. For a generated MCP tool surface it is the entire interface: the descriptions are what a model reads before choosing a tool, so a thin document produces a badly-behaved agent. Hence DTOs owned by the API rather than Core records serialised straight out, units in the field names, ISO-8601 with offsets, closed camel-cased string enums, and XML comments carried into the schema. That last one needed a build fix. `GenerateDocumentationFile` was being set in `Directory.Build.targets`, which MSBuild imports *after* the SDK targets that turn it into a `DocumentationFile` — so it was set after the only thing that reads it had already looked, and **no project in this repository was producing XML documentation at all**. `Gleanvolt.Api` sets the property in its own body, where the SDK still sees it. The repo-wide consequence (packages shipping without their documentation) is left alone here: fixing it globally surfaces about forty pre-existing unresolvable `cref` warnings, which is a separate change. The contract is pinned by a **projection**, not by the document's bytes: `tests/Gleanvolt.Api.Tests/OpenApiContract.json` records every operation id, parameter, response code and schema property with its type and nullability. A deliberate change is a reviewable diff; an accidental one fails. Byte-for-byte would have broken on an SDK patch bump — CI floats `10.0.x` — and a check that cries wolf is a check that gets regenerated without being read. ### What it deliberately cannot do Stopping the service (a deliberate physical act with a deploy-stack consequence — it stays stopped), raw register writes (a way to brick an inverter over the network), and TLS, users, per-key scopes and rate limiting (a LAN appliance with a shared secret, as the UI already is). A failed hardware write is reported as 200 with `succeeded: false` rather than an HTTP error: the call was understood and the controller is in exactly the state it was in before, which is not a protocol failure. --- ## 2026-08-23 — A full battery arrives just before departure, and the car's own choices are decisive (issue #101) `Targeted` optimised one thing: grid spend. It answered "when does the car reach the target?" by accident — whenever the sun and the pace happened to land it, which on a sunny day with an 07:00 departure is mid-afternoon, followed by fifteen hours sitting at 100%. Right for a partial charge, wrong for a full one. **A request now carries a priority.** `Cheapest` is the default and is the existing behaviour unchanged; `JustInTime` holds the last stretch back so the car finishes shortly before departure. ### Only the tail is held, and the split is made in watt-hours Delaying the whole charge would forfeit a sunny day to protect the top of the pack and then buy the lot from the grid at 04:00. So the request carries a `TailEnergyWh` — the energy above a rest state of charge — and everything below it is delivered exactly as `Cheapest` delivers it. The split is computed **once, at activation**, alongside the SOC → kWh conversion in `VehicleTargetEnergy` and for the identical reason: a cloud SOC arriving at 02:00 must not move a promise that is already half delivered. Past that point nothing reasons about state of charge. The planner has spent four phases keeping percentages out of its arithmetic and this does not smuggle them back in. ### The planner runs the same paced pass twice No new maths and no fifth `TargetedChargeStrategy`. `PaceOverWindow` runs over `[now, release]` for the free part and `[release, deadline]` for the tail, and the two are merged. `Within` prorates a slice that straddles the release — energies scale, powers do not, so `AvailableWatts` survives the cut and the pass does not know it is looking at a sub-window. `release = deadline − tail ÷ P_max − ReleaseSlack`. ### What we deliberately did not build A first draft of the issue proposed a `TailPowerFactor` to model the taper, then an apparatus for doing better: reading `settings.target_soc` off the EU Data Act channel to gate impossible targets, and deriving an empirical charge curve from the `active`/`actual`/`VehicleSocPercent` samples the session store has recorded since #32. All of it was dropped, on the owner's call, and the reasoning is worth keeping: - **The car's limit is not ours to predict.** If it is set to stop at 80%, it stops at 80% — and `TargetedChargingController` already reports that through `CompletionDwell`: *"car stopped drawing for 12 min at 12.4 kWh of 15.0 kWh — its own limit, short of the target."* That sentence is the honest answer. Nothing is improved by having guessed it in advance. - **A gate on an advisory feed fails the wrong way.** The vehicle source expires after ~15 hours and needs a human with an email OTP. A gate built on it would refuse valid charges whenever it lapsed, which is worse than the failure it prevents. - **The loop already self-corrects.** The plan is rebuilt every poll from measured delivery, so a slow tail raises its own pace until it saturates at maximum current, and `ShortfallWh` reports the gap before departure. `ReleaseSlack` is a plain margin, not a model. Both are recorded as deferred on #101, with the trigger that would justify them: recorded sessions showing the tail routinely missing its release for reasons the pace cannot recover from. ### The hold refuses the sun, and both surfaces say so This is the one place in the mode that turns real surplus down. Once the free part is delivered, `plan.IsHoldingAt(now)` is true and the controller pauses — taking a bright afternoon would put the car at its target by teatime, which is exactly what the priority exists to prevent. It costs money, so *Preview* runs the planner **both ways** and names the difference in kilowatt-hours before the button that commits it. Preview only: two plans per poll to report a counterfactual is not worth the cycles on a Pi. A charger sitting visibly idle for hours is the most convincing impersonation of a fault this controller can produce, so the controller's reason, the plan narrative and the README all say *"idle on purpose"* in as many words. ### The deadline outranks the priority The hold is abandoned — silently, back to an ordinary paced charge — when the release has already passed, when there is nothing above the rest point, or when holding would leave the free part more than the shortened window can deliver. Worth noting that with zero slack that last case can never fire: the free window loses exactly the time the tail gains. It is `ReleaseSlack` that costs room, which is why the test demonstrating it has to set one. ### Offered only when it can be honoured The rest point is a state of charge, so it needs the car's SOC *and* `Vehicle:BatteryCapacityKWh` — the same rule the **Battery target (%)** basis follows. Without both the web control does not appear, and a Home Assistant activation logs a warning and charges with nothing held rather than pretending. --- ## 2026-08-22 — The car answers what it can, and the plan is quoted before it is promised Issue #99. `Targeted` asked for kilowatt-hours because the planner's contract is kilowatt-hours (#80), and the reason still holds: a cloud SOC is routinely hours stale, and a promise built on it is a promise built on a guess. But the owner standing at the charger at 22:00 thinks *"it's on 42%, I want 80% by seven"*, and the car is plugged in and already telling us the 42%. **Decision — energy stays the contract; state of charge becomes a way of *asking*.** Everything downstream of the request reads `RequiredEnergyWh` and nothing else. `VehicleTargetEnergy` converts a percentage to watt-hours at the charger — `(target − now) / 100 × usable capacity ÷ charge efficiency` — and the result is what is recorded. **Decision — the conversion happens once, at activation, and is never re-derived.** A parked car reports when it feels like it. A SOC that jumps six points at 02:00 because the car finally phoned home would otherwise move a promise that is already half delivered — quietly, overnight, with nobody watching. The request keeps the target and the SOC it was measured from, but only so it can be *read back* as "42% → 80%". **Decision — no configured pack size, no SOC basis.** `Vehicle:BatteryCapacityKWh` has no default. Guessing a capacity would make every such target quietly wrong; refusing to offer the control makes the gap visible, and costs nothing else on the install. **Decision — a stale reading is flagged, not withdrawn.** Stale means "the feed may be dead", not "the number is wrong": a parked car's SOC does not drift. Withdrawing the basis would only push the owner into doing the same arithmetic in their head from the very same figure. > **Superseded on 2026-09-09** by > [A stale car must not silently become a target](#2026-09-09--a-stale-car-must-not-silently-become-a-target-issue-179). > The reasoning held while two feeds wrote to one holder and the newest won; with one feed reporting at > a median 5 h 10 m old, there is no second opinion left, and a percentage target now refuses to > convert from a reading past `Vehicle:MaxAge`. **Decision — range is display only, and is not recorded on the session.** Nothing here could compute it and nothing here should plan on it, but it is the figure that actually answers "is 80% enough for the trip?" while a target is being set. A session that has finished is not asked that question, so it gains no column. ### The plan is shown before the charger moves **Decision — Preview and Start are two presses, and the quote is a separate seam from the promise.** `ITargetedChargePreview` runs the same planner on the same telemetry with delivery at zero, and writes to nothing — no request, no mode, no device — so it can be pressed as often as the owner likes, including while a target is already running. `ITargetedChargeSelector` remains the only way to promise anything. The case it earns its keep on is the one that cannot be fixed afterwards: *"even flat out you get 24 of the 31 kWh you asked for; the departure that covers it is 05:40"* is worth knowing before the charger starts, not after. **Decision — the preview and the running plan render through one component.** What you confirm has to be what you then watch. A preview that summarised and a running plan that detailed would leave the owner comparing two descriptions instead of one plan against reality. **Decision — any edit drops the preview.** Otherwise a button that says start sits under a plan describing figures that have since changed. The Start button does not exist until a plan has been built, and stops existing the moment the form moves under it. **Decision — "nothing to plan from" is a sentence, not a locked button.** Before the first poll there is no SOC, no house load and no instant to anchor to, and a plan built on zeroes would look like an answer. The preview says so and still offers Start: a service that has only just come up can be given a target, and the plan appears on the first poll. --- ## 2026-08-22 — The web UI splits along "reports" and "decides" Issue #98. The UI had grown one page per feature as the features landed: telemetry and controls on `/`, the forecast plan on `/forecast`, the targeted mode on `/targeted`. Every page was defensible on the day it was written and the set was not: the numbers a control shapes were a nav item away from the control, and two of the five nav items were modes — as if `Solar` and `FastNoBattery` simply had no opinions worth a page. **Decision — one axis, not five: a page that reports and a page that decides.** `/` answers "what is happening", `/charging-plan` answers "what should happen". Every control lives on the second, every figure that is not part of a plan on the first. A page that does both is what produced the split in the first place. ### The dashboard groups by the question being asked **Decision — Energy, Vehicle, Charging session, in that order.** Fourteen equal tiles made the reader do the grouping, every time. The order is the order the questions arrive: the house is true whatever is plugged in, the car is next, and the session is last because it is the only one that may not exist. **Decision — the session section renders only while there is a session**, and its absence is a sentence naming the mode and the charger's state plus a link to start one — not a grid of dashes, which is eight tiles saying nothing eight times. **Decision — "is there a session" is not "is the mode `Off`".** A car drawing power under no mode at all — somebody put the charger into `Fast` at the wallbox — is precisely the case that must not be hidden behind "nothing is charging". ### The modes get tabs, not pages **Decision — a tab per mode, under one page.** Tabs are the shape of the underlying model: exactly one mode drives the charger, and the modes are alternatives to each other. Four nav items would have said they were four independent things you might do at once. **Decision — the tab is in the URL** (`/charging-plan/forecasted`), so a bookmark, a refresh and the back button all behave. State in a field would have made "the forecast plan" unlinkable, which it was not before this change. **Decision — `/charging-plan` with no tab opens on the running mode, and only on the way in.** Resolving it on every render would let a mode that ends itself — `FastNoBattery` and `Targeted` both do — pull the tab out from under whoever is reading it. **Decision — `Off` has no tab.** It is not a strategy with terms of its own; it is the button that ends whichever one is running, and it belongs with the state line it changes. ### What is common stays above the tabs **Decision — the battery discharge hold sits in the header, not in a tab.** It is not a property of any one strategy: `FastNoBattery` arms it for its whole run, `Targeted` arms it while the plan is importing, and the owner may want it under `Solar` for reasons no mode knows about. Putting it in a tab would have implied it belonged to that mode; repeating it in all four would have made four switches out of one setting. **Decision — the tabs do not hold `IChargeActions` themselves.** The page passes `Start` and `Stop` down as delegates and reports every outcome — the "started from the Web UI at 13:42" note, and any charger that refuses the use-mode write — beside the mode line those outcomes moved. Four tabs each with their own error paragraph would have been four places for the same message, and the message is about the mode, not about the tab. **Decision — `Minimum battery SOC` appears on two tabs.** Both planners work to it and both show it as a plan figure; reading a floor you cannot move from where you are reading it is the exact split this page was built to end. It is one runtime seam (`IForecastRuntimeSettings`), so the two cannot disagree — which is what makes the duplication safe rather than a second source of truth. --- ## 2026-08-22 — The weather goes on the session, twice, in columns Issue #96, and the other half of [the day-forecast curve](#2026-08-22--a-session-carries-the-whole-day-it-happened-on). That record gave a session what the sun was *expected* to do. This one gives it what the sky actually did — without which a session that under-delivered against a confident forecast has no explanation attached to it at all, and cloud, a cold morning and snow on the panels are indistinguishable afterwards. ### The endpoint chose itself The issue proposed OpenWeatherMap's One Call API. Against a real key it answers: ``` 401 — Please note that using One Call 3.0 requires a separate subscription to the One Call by Call plan. ``` `data/2.5/weather` on the same key answers `200` with **every figure this feature records**: `main.temp`, `main.pressure`, `main.humidity`, `clouds.all`, `visibility`, `weather[0].main`, `weather[0].description`, `sys.sunrise`, `sys.sunset` and `dt`. One Call's extra horizons are forecast data, and a session is recorded against what the sky *did*. **Decision — the free current-weather endpoint, and no abstraction over providers.** One provider, one client. A second one can be abstracted over when there is a second one. ### Two readings, and no series between them **Decision — one fetch when the session opens, one when it closes.** A six-hour session can finish in entirely different weather from the one it started in, and a single reading cannot say so; the *pair* can. It is the same shape the header already has for SOC, and it costs two API calls per session — a handful a day, inside any free plan. Not a per-sample series, deliberately. Weather moves in tens of minutes and is decoration on a record rather than an input to a decision; a reading every thirty seconds would be a hundredfold the storage and the quota to say the same thing. Not a background refresh worker either: that would spend calls on the hours when nothing is recording, which is most of them. **Decision — the fetch lives in `SessionRecordingWorker`, not in `ChargingSessionTracker`.** The tracker is a pure strategy with no I/O, exactly as it has no forecast service and no vehicle feed. The worker attaches the reading on its way to the store. **Decision — a weather call may never hurt a session.** `IWeatherService.GetCurrentAsync` returns null for every provider-side failure — bad key, rate limit, timeout, outage — and never throws. It bounds its own wait (5 s by default) because the closing call sits between a session ending and its totals being written, and no weather figure is worth delaying that. On the shutdown path a null `WeatherAtEnd` is the correct outcome rather than something to wait for. ### Columns, not a document **Decision — eighteen columns rather than one JSON blob**, which is the opposite of what the day-forecast curve does one record above. The difference is how each is read. A forecast curve is written once and read whole; cloud cover and temperature are the *axes* the analysis groups by, and "solar share against cloud cover" or "delivered energy by temperature band" should be SQL rather than a parse per row. **Decision — sunrise and sunset are stored once, not per reading.** They belong to the day, not to the moment, and would be identical in both fetches. A session crossing midnight carries the starting day's pair — the rule `StartedAt` and `DayForecast` already follow. They are worth storing at all because they are the denominator: they make an 8 kWh December session comparable with an 8 kWh June one, and they say whether a session ran out of sun or merely out of surplus. ### Off unless configured, and null when off Coordinates are `double?`, not `double`. Defaulted to 0 they would be a real place in the Atlantic, and an unconfigured site would silently record the weather in the Gulf of Guinea rather than recording none. With no key or no coordinates the service makes no HTTP call at all, and every weather column stays `NULL` — which must stay distinguishable from a genuine reading of 0 °C under a clear sky. Schema v4 and `schemaVersion` 4, additive; older rows keep `NULL`, which is the truth for them. --- ## 2026-08-22 — A session carries the whole day it happened on ### The problem A recorded session could be read against what the forecast expected *while it ran* — every sample carries `ForecastPowerWatts`, and the header carries `ForecastRemainingAtStartWh`. Neither answers the question that actually comes up: **was this a good session, or merely a good day?** 4 kWh from the roof means one thing on a day forecast at 6 kWh and something else entirely on a day forecast at 30, and nothing stored could tell the two apart after the fact. The obvious fix — "snapshot today's forecast onto the session" — does not work, because of what a forecast provider actually returns. ### Solcast only ever forecasts forward `rooftop_sites/{id}/forecasts` returns the periods still to come and nothing behind them. So the cached forecast is *self-erasing*: at 07:00 it holds the whole day, and by 16:00 it can no longer say what the morning was predicted to bring. A session opening at noon and snapshotting "today" would capture half a day, and the half it dropped is exactly the half already spent. **Decision — the controller retains what the provider forgets.** `SolarForecastHistory` (Core, pure, lock-guarded because the refresh worker writes it and the poll loop reads it) keeps every period any refresh has ever carried, keyed by period end, for 7 days. Each refresh upserts: future periods are replaced by the newer estimate, elapsed ones simply stay. What survives for a given period is therefore the **last prediction made about it before it happened** — the closest to a nowcast the provider ever gave, and the fairest thing to judge the roof against. **Decision — this is a new method, not a change to `GetForecastForToday`.** `GetDayForecast(DateOnly)` sits beside it on `ISolarForecastService`. Retained history in the existing method would have quietly changed what "today's forecast" means for the day planner, the accuracy tracker and the day summary — all of which mean *remaining* when they say today. A separate method makes the analysis surface explicitly not a control surface: nothing in charge control reads it. **Decision — one JSON column on the session header, not a periods table.** `sessions.day_forecast_json` (schema v3, `schemaVersion` 3, additive; older rows stay `NULL`). The curve is written once, read whole, and has to *travel with the session* when the document is published to object storage — none of which a join gives us. About 10 kB per session, against a 365-day retention: irrelevant next to the samples. **Decision — written at open, rewritten at close.** The day fills in behind us as it passes, so the version available when a session closes covers hours the version available when it opened could not. A closed session is still immutable; this is one more field the closing write sets, like the totals. ### What it does not do **A day the controller wasn't running for all of is short by that much.** The history is in memory, so a restart at 14:00 loses that morning permanently — nothing re-fetches the past, and doing so would mean a second Solcast endpoint and a second call against the daily quota. The curve is `null` rather than empty when nothing is held, because an empty curve sums to zero and would read as a day the sun never came up. The [energy history](../README.md#energy-history-the-energymonitor-section) store still holds forecast-versus-actual per quarter hour for those days, which is the fallback. --- ## 2026-08-22 — Rate, not timing, decides the solar share Supersedes the placement half of [2026-08-21](#2026-08-21--targeted-charging-imports-under-the-sun-not-after-it). That record moved the grid block onto the sunniest hours. This one deletes the block. ### What the site showed A 13 kWh target, a day peaking at 8.5 kW, and the session summary: ``` Session ended after 01:27: 13.02kWh delivered — 3.59 solar / 9.09 grid / 0.34 battery ``` Nine of thirteen kilowatt-hours bought, on a sunny day, by a mode whose entire purpose is not to do that. Placement was not the problem. **Rate** was: inside a grid block the controller commanded a flat 16 A, so the car drew 10.7 kW while the roof gave 3–5 kW, finished in 87 minutes, and stopped — missing the whole afternoon. No placement can fix that, because the charger can only use the sun that shines *while it runs*. ### The pace ``` pace = (energy still needed − sun forecast to reach the car) ÷ time left to the deadline charge at live surplus + pace, clamped to 6–16 A, recomputed every poll ``` Sum, not maximum: the pace is by construction what the sun will *not* cover, so the two are additive. Taking the greater lets a sunny window swallow the grid's share and arrive short — a bug the tests caught before the hardware did. Blocks disappear entirely, which is a simplification rather than a cost: there is nothing left to place, and the sunniest-first pass from the previous record goes with it. ### Two look-aheads, not one The subtle part, and the one that took two attempts. *Unaided* counts only half-hours whose surplus clears the charger's ~4.14 kW floor — sun that can run the car by itself. It answers **"need we buy anything at all?"** and must be strict: counting sub-floor sun here defers the start on strength that never arrives alone, and finishes short. *Assisted* counts every watt of surplus. It answers **"once the charger is running, how much does the roof cover?"** and must be generous: a 3.2 kW half-hour genuinely supplies 3.2 kW to a charger held at its floor. Using the strict figure for both was a real defect, caught by simulating the owner's own scenario — activate at 02:00 for 15 kWh by 17:00. On a 4.2 kW day carrying **17.5 kWh** of forecast surplus against a 15 kWh target, it scored the day at ~4 kWh of usable sun and planned to buy 11 kWh, starting at 02:00 in the dark, on a day the roof could have covered outright. With both figures it waits for daylight and buys roughly 3 kWh. ### What follows from it - **Sub-floor sun is harvested, not skipped.** Once the sun cannot finish alone, any slice with surplus runs right through at the floor: the roof supplies what it has, and only the difference is bought. Harvesting a 3.2 kW half-hour costs 0.9 kW of grid; buying the same energy after dark costs 4.14 kW. - **A sub-floor pace is never deferred.** It is energy the sun will not cover, so waiting for the pace to climb past the floor merely crams the remainder into the last minutes of the window, where a dropout has no room to recover. It self-regulates: delivering ahead drives the pace to zero and the car returns to living on surplus. - **The hold arms on what is actually imported.** Below the SOC floor the surplus belongs to the pack, so it must not be netted off the commanded power — doing so reported zero import, left the hold released, and had the pack quietly funding the pace. ### The cost we accept Pacing spends the slack: a charger dropout late in the window has less room than the old finish-early behaviour. The safety margin is the only buffer, and it is worth revisiting if a real session ever runs close. --- ## 2026-08-21 — Targeted charging imports under the sun, not after it Supersedes the placement half of [2026-08-20](#2026-08-20--the-grid-block-goes-as-late-as-it-can-and-the-home-battery-still-comes-first). The battery-priority half of that record stands unchanged. ### What was wrong with "as late as it can go" The old record argued the block should end at the deadline, because every poll before it is another chance for the sun to shrink it before a watt is bought. The optionality is real. What the argument missed is what late placement *costs*, and it turns out to be the same optionality, spent the other way round. Two cases, both reported from the site: **A poor forecast day with the departure this evening.** The plan concludes the sun cannot cover the target, so it books the grid block against the last hours before the deadline and the charger sits idle straight through the middle of the day. Meanwhile the roof is producing — just not enough per half-hour to clear the charger's 6 A minimum, which is exactly the condition that made the plan fall back on the grid in the first place. That surplus is exported for nothing, and the same energy is bought back at dusk. Placement *at* the peak turns it into an offset instead: the charger runs at maximum, the array supplies what it can at that instant, and only the difference is metered. **Any day at all, given a P10 forecast.** Planning on P10 means the plan is deliberately wrong in one direction — the day usually beats it. Late placement can only collect that upside by *cancelling* the block, which needs the surplus to have been chargeable on its own. Placement under the sun collects it as a smaller meter reading, whether or not any half-hour ever cleared the minimum. ### And when there is no sun in the window, start at once The 21:00 → 07:00 case the old record led with is the one where late placement looked most obviously right, and it is the one where it does the least. There is no sun between 21:00 and 07:00. Deferring the import until 05:00 does not expose it to a single additional watt of surplus; it buys precisely the same kilowatt-hours eight hours later. What deferral does buy, at that point, is risk: a charger that drops its connection, a car that limits itself to 3 kW, a departure moved forward to 05:30 — all of them now land with no slack at all. So a window with no forecast surplus in it, and a window with no usable forecast at all, both start immediately. ### The implementation is one ordering `AddGridBlocks` no longer walks the slices backwards from the deadline. It walks them by leftover forecast surplus, highest first, breaking ties earliest-first, and anchors each block at its slice's start. Both behaviours above fall out of that single comparison: a window with sun in it fills the sunny slices first, and a window without any ties at zero everywhere and is therefore filled from the front. Nothing else about the plan changes — the block is still sized by the same power-prorated arithmetic, still rebuilt every poll from the measured delivery, and still never committed to. `GridStart` is now the earliest of the grid blocks rather than the single start of one contiguous block, and the blocks may be scattered across the window instead of ending at the deadline. Nothing consumed it as "the last thing that happens" — the controller asks `IsInGridBlock(now)`, and the discharge hold follows the same predicate — so the arming window moves with the block and stays scoped to the importing part of the plan. ### The cost we accept An import placed at noon is bought eight hours earlier than one placed at 05:00, and a day that goes on to beat its forecast *after* noon cannot un-buy it. That is a real loss and it is smaller than the one it replaces: the block is still resized every poll, so only the part actually drawn before the sun arrives is lost, and against it we now collect sub-minimum surplus and forecast upside on every day rather than only on the days generous enough to cancel the block outright. ### The live half: sun is never refused, and the grid may bridge to the floor The placement rule above is a *forecast*-side fix, and it only reaches the surplus the forecast predicted. Two live cases were still throwing sun away, and the mode's own contract — deliver the energy, prefer the sun, the grid is allowed — says both should be taken. **Unplanned sun.** The controller only charged from surplus inside a block the planner had drawn. A half-hour the forecast said nothing about, or underestimated, found the car idle. The plan schedules *imports*; it has no business refusing sunshine, so the solar-block gate is gone. The car now takes live surplus whenever there is any, and the only thing that holds it back is the plan's SOC floor — which is the pack's priority talking, and is meant to. **Sub-floor surplus.** A 3.5 kW surplus cannot charge at all against a 4.14 kW floor, so the controller paused and the energy was exported. The **grid bridge** runs the charger at 6 A and buys the ~640 W difference instead. This reverses "there is no battery loan in this mode" only in its source, not in its principle: the pack is still untouched, and the argument for the grid is that a `SolarPlusGrid` plan has *already committed* to buying this energy — the only open question was when. Bridging pays `floor − surplus` now against the whole floor later, and shrinks the planned block watt for watt. It is refused where that argument fails: on a `Solar` plan (nothing committed), below the SOC floor, and under `MinBridgeSurplusWatts` (the sun is not really contributing). Sized to the floor and never past it — a bridge, not a booster. The bridge's one sharp edge is the discharge hold. It arms on `IsInGridBlock`, and a bridge is not inside a block. Left there, the controller commands 6 A against a surplus that cannot carry it and the *pack* silently funds the gap — a battery loan by accident, which is the one thing this mode promises never to do. So the decision now carries `GridBridgeWatts` out to the poll loop and the hold arms on it too. (On an installation with no `BATTERY_HOLD_*` configured the hold is a log warning rather than a write, and this drains the pack — that is pre-existing, and it is now one more reason to configure it.) --- ## 2026-08-21 — Charging is an action, and the action owns the charger's use-mode This reverses a promise the project made in as many words, in four places (`IEvChargerControl`, `EvChargerControl`, `ChargingControlCoordinator`, and three separate README claims): **the controller writes only the charge-current setpoint, never the use-mode, never a start/stop command.** ### Why that was right It was right for what the controller was: a *modulator*. The owner made the decision — car plugged in, charger set to Fast — and the controller's whole job was to move the current under a session somebody else had started. In that shape, writing the use-mode would have been the controller taking a decision that was not its to take, over hardware whose register map is transcribed from a third-party integration rather than from a SolaX document. The narrow write surface was the safety argument, and "the owner keeps the charger in Fast" was the price of it. ### Why it is wrong now Because the mode stopped being a *setting* and became a *thing you press*. A select is a statement of intent that waits for the world to agree with it: picked while the charger sits in Green it does nothing at all, and says so only through a `Control state` sensor reading `Idle` — five words down a dashboard, and the single most common "it isn't working" report this project produced. A button is a promise that something happens. It cannot keep that promise without the use-mode. Two surfaces already had the better shape and proved it: `Activate target` and `/targeted`'s `Cancel` are presses, and nobody has ever had to explain either of them. ### What the reversal is careful about - **`Fast` is written once, by the action, and nothing re-asserts it.** All four controllers keep their `if (use-mode != Fast) → Idle` precondition. A charger changed at the wallbox mid-session leaves the controller idle exactly as it does today. The owner has the last word on their own hardware; the controller does not fight them for it, poll after poll. - **`Off` writes `Stop` and does not touch the current setpoint.** `Stop` is what stops the car; re-writing the setpoint would be a second write to say the same thing. - **Shutdown is unchanged.** `PauseOnShutdownAsync` still pauses the current and does **not** write `Stop`: a container restart for a deploy is not the owner saying "stop". - **The self-off paths run the same action.** `FastNoBattery` and `Targeted` finishing now stop the charger too — otherwise a mode that switches itself off would leave the charger in Fast at the pause current, which is a different end state from the button and one a car can start drawing from again. - **There is still no enable flag, and nothing is written until a button is pressed.** That was already the property `ChargeControl:Enabled`'s removal bought; it is now more literally true. ### The one asymmetry, and why A failed use-mode write on **start** leaves the mode alone: nothing happened, and the surface says so. A failed write on **stop** still returns the mode to `Off`, and *also* reports the failure. Releasing control is the half of the button that cannot depend on the hardware answering — a controller left driving a charger it cannot reach would go on commanding currents at a wallbox whose owner has just said stop, which is the worse of the two failures by a distance. --- ## 2026-08-20 — The grid block goes as late as it can, and the home battery still comes first Issue #80 adds a fifth charge mode: an amount of energy, a departure time, and "use as little grid as you can". Two decisions in it are worth recording, because both look wrong at first glance. ### The grid block is placed as late as it possibly can be The obvious arrangement is the opposite: a car plugged in at 21:00 for an 07:00 departure could start importing immediately and be done by 23:00, and everyone sleeps easier. The plan does the reverse — it computes the import it still needs and places that block **ending at the deadline**, so nothing is imported until the last possible moment. The reason is that **the plan is rebuilt every poll and never committed to**. Between 21:00 and 05:00 there are thousands of chances for the forecast to improve, for the car to have taken more than expected, or for a sunny morning to appear inside the window. Every one of them shrinks the block — often to nothing — *before a single watt of it is bought*. Scheduling the import early converts all of that optionality into a purchase. Late placement costs nothing in exchange: the deadline is met either way, because the block is sized to meet it. The cost is that a failure late in the window has less time to recover. That is exactly what the comparison against `P_max × (departure − now)` is for: the moment the remaining need exceeds what the charger could physically deliver in the time left, the plan says so — with the shortfall and the departure that would have covered it — instead of quietly falling short. It also means the block must be found by a **backward pass**, not by `deficit / P_max`. Where the block reaches back over a sunny slice the grid only has to supply `P_max` minus what the sun is already giving, so the start is prorated inside that slice by power rather than by time. The naive division understates the block and misses the deadline on exactly the days the mode exists for. ### The home battery keeps its priority, and the grid pays for the gap The car has a deadline here and the pack does not, which is an argument for letting the car take the pack's surplus and refilling it tomorrow. It is rejected, for two reasons. The first is that the two deadlines are not comparable. The car's is a promise the owner made and can change — moving the departure by an hour is a keystroke. The pack's evening 100% is what carries the house through the night, and its cost when missed is paid by the house at the evening tariff, not by the person who set the target. The second is that the alternative is not free energy, only a **more expensive route to the same kilowatt-hours**. Energy taken from the pack and repaid tomorrow pays a round-trip loss and a cycle on both packs; the grid block pays the tariff and nothing else. Taking the pack's share would not reduce the import, only move it to the evening — which is why there is no battery loan in this mode, and why the discharge hold arms while the grid block runs. The pack is not a cheaper source here. It is the same source, borrowed at a loss. The hold is scoped to the importing part of the plan rather than to the whole mode, unlike `FastNoBattery`. Outside the grid block the car is running on surplus the pack has already been offered and declined, and holding the pack there would push the house onto the grid for nothing. ## 2026-08-16 — The product is Gleanvolt; the vendor is still SolaX **Context.** "SolaX Local Controller" put someone else's trademark in the project's name, its repository URL and the image everyone pulls. That is tolerable for a hobby project and not tolerable for anything sold, and it also mislabels the roadmap: the control logic is vendor-agnostic, and the intention is to support other inverter makes. **Decision — rename the product, not the vendor.** The split is the useful part of this change: | Renamed (product identity) | Kept (vendor identity) | |---|---| | Assemblies and namespaces `Solax.*` → `Gleanvolt.*` | The `Solax:` configuration section and `SolaxOptions` | | `AddSolaxController()` → `AddGleanvolt()`, `UseSolaxController()` → `UseGleanvolt()` | `SOLAX_*` variables in `.env` | | `SolaxPollingService` → `PollingService` | `Gleanvolt.Infrastructure/RegisterMaps/*` | | The solution, the image, the repository, the UI title, the HA display name | `InverterRegister`, `InverterControlRegister`, `EvChargerRegister` | | Container names and `/opt/solax` → `/opt/gleanvolt` | Every mention of SolaX hardware in prose | `Solax:Inverter:Host` configures a **SolaX inverter**, and when a second make is supported it will get its own section beside it rather than inheriting this one. Renaming it to `Gleanvolt:` would have been a breaking change to every deployment that also made the eventual multi-vendor layout worse. **Decision — Home Assistant keeps its identity.** `DeviceId` stays `solax_controller` and `BaseTopic` stays `solax`; only `DeviceName` becomes "Gleanvolt". Those two strings are the primary key of every entity Home Assistant has created: changing them renames every entity, orphans all recorded history, and breaks every dashboard and automation built on them — for a cosmetic gain, since the display name is the only part a user sees. The retained discovery topics would also need clearing from the broker or the old device would linger as a ghost. This is a change worth making once, at a major version, with a migration path — not as a side effect of a rename. **Decision — the repository was renamed in place, not recreated.** GitHub redirects the old URL permanently (verified: 301), so every existing clone, link and bookmark keeps working, and the issues and pull requests that carry most of this project's design rationale stay attached. Deleting the old repository would have broken all of that permanently while revoking nothing. `publish-image.yml` derives the image from `${{ github.repository }}`, so the image name followed the rename by itself. **The historical logs were left alone.** `DECISIONS.md` and `IMPLEMENTATION_LOG.md` are append-only and describe the tree as it was at the time; rewriting them to say "Gleanvolt" would make them describe a repository that never existed. The old repository URLs in them still resolve through GitHub's redirect. ## 2026-08-16 — Noncommercial from here on, and the terms ship inside the artifact **Context.** The intent is that the controller stays free for the people it was written for — someone running it on their own house — while commercial use, an installer deploying it for clients or a vendor bundling it with hardware, is licensed separately. MIT grants commercial use expressly, so the intent and the licence disagreed. **Decision — PolyForm Noncommercial 1.0.0, from this version onward.** Chosen over writing our own terms because the hard part is defining "commercial", and PolyForm's wording is drafted and tested; over BSL/FSL because a change date is a promise this project has no reason to make yet; and over AGPL-plus-exception because AGPL barely inconveniences a company running a controller on its own LAN, so it would not produce the conversation it exists to produce. **The change is not retroactive, and the repository says so.** Every version published under MIT stays MIT — `LICENSE-MIT` is kept in the tree and referenced from `LICENSE` and the README. A licence change cannot withdraw a permission already granted, and pretending otherwise in the README would be both wrong and a bad look. The practical consequence is that someone can fork the last MIT commit; that is the accepted cost, and it is small for a project whose value is register maps verified against real hardware. **Decision — the terms live inside the artifact, not only beside it.** Three places, because each one can be lost independently: | Where | Why | |---|---| | `/app/LICENSE` and `/app/LICENSE-MIT` in both images | A registry label is stripped by any re-tag or re-push; a file in the image is not. `.dockerignore` no longer excludes them. | | `org.opencontainers.image.licenses` label | The machine-readable form, for `docker inspect` and scanners. Set in the Dockerfile so both images carry it — the Windows image, built with plain `docker build`, previously had no OCI labels at all. | | The licence text inside the NuGet packages | The packages already carry `PackageLicenseFile` rather than an SPDX expression, so they pick this up with no change. See below. | `publish-image.yml` passes the same value to `metadata-action`'s `labels:` input. Left to itself, that action fills the field from GitHub's licence detection, which does not recognise PolyForm and would leave the image unlabelled — or still claiming MIT — while `build-push-action`'s `--label` silently overrode the Dockerfile. **Consequence — the packages needed no change, and could not have used an expression anyway.** They already declare `PackageLicenseFile`, so they ship whatever `LICENSE` says. That turns out to have been the only option open to them: NuGet accepts SPDX expressions only for licences approved by the OSI or the FSF, and `PolyForm-Noncommercial-1.0.0` is a real SPDX identifier approved by neither, so `PackageLicenseExpression` would have been rejected outright. The file form is what every commercially licensed package on nuget.org uses, and it has the same virtue as the image copy: the terms are inside the artifact. **What this costs.** The project is no longer OSI open source. That most likely rules out a HACS or Home Assistant add-on listing, changes what the GitHub repo signals to a passing developer, and makes the libraries a trial rather than a funnel. Accepted deliberately: the alternative was a stated policy with nothing behind it. **What it does not buy.** Nothing here detects anything. A container on someone's LAN reports home never, and there is no key to check. These terms are a basis for invoicing an organisation that cares about compliance; they are not a control, and should not be mistaken for one. ## 2026-08-16 — The composition root is a library; the executable is only a host **Context.** `Program.cs` had grown to 399 lines, and the only way to run the controller was to run `Solax.Worker`. That was an accident rather than a choice: of the 24 files in the project, exactly two — `Program.cs` and `DotEnv.cs` — were specific to *that* executable. The polling service, the coordinator, the selectors, the Home Assistant worker, the session recorder, the forecasting types and all seven options classes were host-agnostic library code that happened to live inside an `Microsoft.NET.Sdk.Web` project, and therefore could not be referenced, packaged or composed anywhere else. The DI wiring could not be exercised at all except by booting the process. **Decision — the wiring moves to `Solax.Hosting`, and the executable keeps only what makes it an executable.** `AddSolaxController()` holds every registration; `Solax.Worker` keeps the `.env` load, the Serilog configuration, the `hash-password` tool and the exit code, and lands at ~95 lines. The layer order becomes `Solax.Worker` → `Solax.Hosting` → `Solax.Infrastructure` → `Solax.Core`. `Solax.Worker` now references `Solax.Hosting` **and nothing else**. That is the enforcement, not the comment: host code cannot reach `Solax.Core` or `Solax.Infrastructure` directly any more, so the composition root cannot quietly grow a second half back inside the entry point. **Decision — the primary shape is `IServiceCollection`, not `WebApplicationBuilder`.** A host that is not built on `WebApplicationBuilder` — a console host, a desktop shell — should still be able to run the controller, so the registrations take an `IServiceCollection` and an `IConfiguration`. Two consequences: - The UI's port is set with `services.Configure(...)` rather than `builder.WebHost.ConfigureKestrel(...)`. These are the same registration; only the second needs a builder. - `UseStaticWebAssets()` is the one thing that genuinely cannot be expressed as a registration, so it is the only content of the `WebApplicationBuilder` overload. Without it, an unpublished build serves `blazor.web.js` as an empty 200 and the UI renders once and then dies — see the 2026-08-13 record. **Decision — the implicit usings are declared in the `.csproj`.** The moved code was written against the Web SDK's implicit usings, which a plain class library does not supply. They are re-declared as `` items rather than added as 22 files' worth of `using` directives, so that the move stays a move and the diff stays reviewable. **Decision — the four libraries are packages; the executable is not.** `IsPackable` defaults to `false` in `Directory.Build.props` and each library opts in, so a project (a test project especially) cannot start publishing itself by existing. The ids `Solax.Core`, `Solax.Infrastructure`, `Solax.Web` and `Solax.Hosting` were all unregistered on nuget.org when checked on 2026-08-16, so the package ids match the assembly names. The version is the same tag-derived number the image already uses, which is what makes a package, an image and a commit one release rather than three. **Consequence — `BuildInfo` reads `Solax.Hosting`'s attributes now, not `Solax.Worker`'s.** It reads its own assembly, and it moved. The reported string is unchanged because `Directory.Build.props` stamps every project in the repo with the same version and commit; a consumer that packages `Solax.Hosting` separately would see that assembly's version, which is the right answer for them. ## 2026-08-16 — The service can be stopped from its own surfaces, and the exit code is what keeps it stopped **Context.** The only way to take the controller down was to kill it — no control surface offered a stop, so it meant an ssh session and, in practice, `docker rm -f` or a pulled plug. That skips the host's shutdown, and the expensive part of skipping it is not the data: SQLite is crash-safe in WAL mode and the Serilog file sink is unbuffered, so a killed process loses almost nothing on disk. It is the hardware. `SolaxPollingService.StopAsync` exists precisely to drop the charger's setpoint to the pause current on the way out; kill the process and nothing revokes it, so **the car keeps drawing at whatever current we last wrote**. The open charging session is also left to be recovered as interrupted rather than closed as `ServiceStopped`. **Decision — a stop is a control like any other, driven through one Core seam.** `IServiceShutdown` (`RequestStop(string source)`) joins `IChargeControlModeSelector` and friends: the web UI's Health page and a Home Assistant `button` entity both drive it, neither owns any logic, and `HostShutdown` in the worker turns it into `IHostApplicationLifetime.StopApplication()`. The existing `StopAsync` implementations then do what they were always written to do. Nothing new had to be added to the shutdown path — only a way to trigger it that isn't a shell. **Decision — the process's exit code distinguishes "stopped" from "terminated", and the deploy stack reads it.** `restart: unless-stopped` cannot express what an operator means by "stop": it restarts on any exit, so a Stop button would be a Restart button with extra steps. `restart: on-failure` can express it, but only because the worker now sets its exit code deliberately: | How the run ended | Exit code | Docker's response | |---|---|---| | `RequestStop` from the UI or Home Assistant | `0` | stays down | | SIGTERM — reboot, daemon restart, `docker compose restart` | `143` | comes back | | Crash, OOM kill, power cut | non-zero | comes back | Without the 143 the two are indistinguishable — .NET exits 0 for a SIGTERM as well — and a Pi that rebooted would come up with no controller running and nothing anywhere saying why. That case is not hypothetical on this deployment: the box hard-stops on its own, and automatic recovery from it is a property we are not willing to trade for a Stop button. Verified against Docker 29.7.1: a container exiting 0 under `on-failure` stays exited, one exiting 143 is restarted, and a `docker compose stop` stays stopped regardless of the code. **Decision — the shutdown pause has a deadline, and the grace period has headroom.** Docker's default 10-second grace would SIGKILL the container part-way through the one thing a graceful stop exists to do. Measuring a real stop showed why the number has to be generous: it took **19 seconds** with nothing charging, because a Modbus read already in flight when the stop arrives cannot observe the cancellation until it times out, and an EV charger that has gone quiet costs 5 seconds per unanswered exchange. `stop_grace_period` is therefore 60s. That alone would not be enough, because the failure it guards against is the one that matters most: had a session been active, `PauseOnShutdownAsync` would then have done its own read-then-write against that same silent charger, and a shutdown that spends its whole budget waiting gets killed *during the pause write*, with a car still drawing. So the pause is bounded independently (`ChargingControlCoordinator.DefaultShutdownPauseTimeout`, 10s): a charger that answers needs well under a second, and one that doesn't is not going to start. Giving up on a stated deadline and saying so in the log leaves the operator a fact to act on; waiting forever leaves them a SIGKILL. **Decision — a run that ends properly says so, on its last line.** Until now a graceful stop left the log simply ending after the last poll, which is indistinguishable from the process dying mid-cycle. The closing line names which of the two cases it was, who asked for it, and the exit code the restart policy will read — so the *absence* of the line is now itself the diagnosis. This matters more here than it would elsewhere: the Pi's journal is RAM-only and the box hard-stops on its own, so the controller's log file is the only account of a run that survives the reboot. The line is written from `ApplicationStopped` rather than after `Run()` returns, because by then the service provider — and with it Serilog and its file sink — has been disposed and the write would be swallowed in silence. **Consequences accepted.** Stopping from the UI or Home Assistant is one-way from those surfaces: the service *is* both of them, so starting it again needs `docker compose start solax-controller` on the Pi. That is documented next to the stop rather than designed around. A standby mode — the process staying up, idle, with the UI serving a Start button — would avoid it, and is the obvious next step if the round trip through ssh becomes annoying; it was considered here and rejected as more machinery than the problem currently justifies. Also, with no `Web:PasswordHash` configured the stop control is as open as the rest of the UI, which is called out in the README rather than special-cased: a control surface that anyone on the LAN can drive is already the documented default. --- ## 2026-08-15 — The web UI's default port moves from 8080 to 8090 **Context.** 8080 is the most contended port on a general-purpose Linux box. The reference Pi turned out to have Kodi installed, whose HTTP remote-control interface defaults to 8080 as well; a Pi that also ran a proxy, a dev server, or any of the many appliances that assume 8080 would collide the same way. The failure is unpleasant to diagnose because it depends on boot ordering: whichever process binds first wins, and the loser reports only that the port was unavailable. **Decision — the default is 8090, in one place, and everything else follows it.** `WebOptions.Port` is the single source of truth; `appsettings.json` ships the same number, `docker-compose.yml` defaults `WEB_PORT` to it for both the published port and `Web__Port`, and both Dockerfiles' `EXPOSE` lines document it. 8090 is not special beyond being far less contended — the point is that the out-of-the-box experience should not require choosing a port, and 8080 could no longer deliver that. **This changes the URL of an existing deployment.** A Pi that never set `WEB_PORT` moves from `:8080` to `:8090` on the next deploy, because the default it was relying on changed underneath it. Anyone who wants the old URL sets `WEB_PORT=8080` in `.env` explicitly, which is exactly the knob that already existed for moving the port; nothing else needs to change. Note that `WEB_PORT` moves the host *and* container port together, so the container's internal port changes too — nothing binds 8080 anywhere afterwards unless it is asked to. --- ## 2026-08-13 — Home Assistant and the broker become compose profiles, and the controller stops depending on either **Context.** Issue #51, the last phase of #44. The web UI (phases 0–5) made Home Assistant optional in principle — the controller can be fully driven from its own dashboard — but `docker-compose.yml` still started all three containers unconditionally, and `MQTT_USERNAME`/`MQTT_PASSWORD` were required with no default. A Pi that only wanted the controller and its UI still paid for, and had to configure, a broker and a copy of Home Assistant it never used. **Decision — `mosquitto` and `homeassistant` carry compose `profiles:`; `solax-controller` does not.** A service outside the active profile set simply isn't created, which is what makes `docker compose up -d` with no active profiles start the controller alone. `COMPOSE_PROFILES` is docker compose's own environment variable, and it can come from `.env` beside the compose file — but see the next decision for why that isn't where this repo sets it. **Decision — which profiles are active is chosen by which deploy script runs, not by `.env`.** `deploy/deploy.sh` (full stack) and `deploy/deploy-controller-only.sh` (controller alone) each set `COMPOSE_PROFILES` explicitly for their own `docker compose pull`/`up`/`ps` invocations, overriding whatever the value already sitting in `.env` on the Pi happens to be. The first design put `COMPOSE_PROFILES=mosquitto,homeassistant` in `.env.example` instead and left both containers to a single `deploy.sh`; rejected once written down, because it made the deployed stack a function of a line in a file that nothing forces to match reality — run `deploy.sh` once with the full-stack line in `.env`, delete the line without redeploying, and the containers happily keep running against a `.env` that now claims they shouldn't exist. Two scripts make the choice a command someone actually runs, not a value that can go stale next to it. **Decision — soft dependencies, not hard ones.** `solax-controller`'s (and `homeassistant`'s) `depends_on: mosquitto` gained `required: false`. Without it, compose refuses to start a service that depends on one whose profile isn't active at all — the opposite of "optional". The controller's own tolerance for an unreachable broker (`HomeAssistantMqttWorker` already retries and logs) was already there; this only stops compose itself from getting in the way before that code ever runs. **Decision — `HomeAssistant:Enabled` gets its own switch, separate from whether the broker container exists.** `HOMEASSISTANT_ENABLED` (default `false`) drives it, distinct from `COMPOSE_PROFILES`. Tying the app setting directly to "is the `mosquitto` profile active" was rejected: compose has no way to expose "which profiles are active" as a value inside another service's environment, so the two would have needed to agree by convention regardless — making them two explicit settings is more honest than one setting pretending to control both. **Decision — the UI's port is published from a second compose file, not a `ports:` entry in the base one.** `Web:Enabled=false` was built (phase 0) to guarantee no listening socket **inside** the container, verified with `ss -ltnp`. An unconditional `ports:` mapping would have reintroduced exactly that hole one layer up: Docker's port-publish proxy binds the **host** port regardless of whether anything inside is listening, which is a real, checkable difference from "no socket at all" that the original guarantee was written to rule out. `docker-compose.web.yml` carries the mapping instead, merged in only via `.env`'s `COMPOSE_FILE=docker-compose.yml:docker-compose.web.yml` — a second file over a conditional line, because compose has no syntax for "this one attribute, only if this variable is truthy" within a single service block. **Consequences.** - **A pre-existing deployment must add `HOMEASSISTANT_ENABLED=true` to `.env` to keep publishing MQTT discovery after upgrading to this release** — it was already running `deploy.sh`, so the containers themselves need nothing (`deploy.sh` still means the full stack, unconditionally), but `HomeAssistant:Enabled` was previously hardcoded `true` in `docker-compose.yml` and is now this new, off-by-default environment variable. Flagged prominently in `.env.example` and `deploy/README.md`, the same way the `TZ` and timezone-fail-fast changes were before it. - **`MQTT_USERNAME`/`MQTT_PASSWORD` are no longer hard-required** (`${VAR:?message}` became `${VAR:-}`) — a controller-only `.env` no longer needs credentials for a broker it never starts. Home Assistant's own MQTT integration setup is unaffected; nothing there reads these two. - **The broker-password-file check moved into `deploy.sh` unconditionally, and out of `deploy-controller-only.sh` entirely** — each script only checks for what it might actually need, rather than one script grepping `.env` to guess. The Home Assistant config seed step stays unconditional on both — it only touches files on disk, costs nothing when unused, and means switching from `deploy-controller-only.sh` to `deploy.sh` later needs no extra step. - **The reference footprint drops from 848 MB to roughly 200–250 MB of the Pi 3 B+'s 905** with Home Assistant and the broker off — the number [issue #44](https://github.com/mpospisil/solax-controller/issues/44) projected at the start of this work, now the documented, deployable default for anyone who wants it. See `deploy/README.md`'s "Running without Home Assistant" for the full table. - **Both Dockerfiles already published from `dotnet/aspnet`, not `dotnet/runtime`**, and already reasoned about "Web:Enabled=false must mean no socket" — both landed with the UI itself in an earlier phase (see the record below). This phase only adds `EXPOSE 8080` (documentation; it opens nothing by itself) and extends that same reasoning from the container's network namespace to the Docker host's. --- ## 2026-08-13 — The web UI is a Blazor library the worker hosts, and the host is a web server only when asked **Context.** Home Assistant is the only control surface, and on the reference Pi 3 B+ it is also the binding constraint: HA alone accounts for 600 MB of the 848 MB the stack reserves out of 905 MB. A self-hosted UI (issue #44) lets the controller run useful on that hardware. It is explicitly *not* a replacement for the MQTT integration — both surfaces stay first-class, and all four combinations of the two must run. **Decision — a second adapter over the existing seam, not a second architecture.** The UI consumes exactly what the MQTT worker consumes: `ChargeControlStatusHolder` for state, and the Core selector interfaces for commands. No control logic moved, and nothing in `Solax.Core` changed semantically. The one refactor is that the holder moved from `Solax.Worker` to `Solax.Core`, because a second consumer in a second assembly cannot depend on the host. It is a plain object with an event, so Core's "no framework dependencies" rule is untouched. **Blazor with interactive server rendering, in a Razor Class Library (`src/Solax.Web`) that `Solax.Worker` hosts.** Three choices, each with an alternative that was rejected: - **Server-side, not WebAssembly.** The components run beside the services they read, so a page subscribes to `ChargeControlStatusHolder.Updated` directly and each completed poll pushes down the circuit. WebAssembly would need a REST API this project needs for nothing else, plus a first-load download onto a LAN appliance. With one or two browsers, the circuit's overhead is noise. - **Interactive, not static SSR.** A dashboard that shows a live system has to update itself; a page that only tells the truth at F5 is worse than no page. - **A library the worker hosts, not an executable of its own.** One process, one container, one entry point. Making `Solax.Web` the executable would churn both Dockerfiles, the CI publish matrix and the test projects for no benefit. **Consequences accepted.** - **The runtime image is now `dotnet/aspnet`, not `dotnet/runtime`** — roughly 25 MB more image on every platform, including for people who never switch the UI on. Unavoidable: the framework reference is a property of the build, not of the configuration, and the process will not start without the framework it was compiled against. - **Disabled has to mean no socket, and that took code.** Kestrel binds its default address when no endpoint is configured, so an ASP.NET host with nothing mapped would still open a port the operator never asked for. With `Web:Enabled` false the host registers `NoListenServer` in Kestrel's place, which starts nothing and accepts nothing; the process is then indistinguishable from the headless worker it was before. Verified with `ss -ltnp`: no listening socket in the process at all. - **The host project contains no `.razor` file, and the SDK infers Blazor support from exactly that.** Without `RequiresAspNetWebAssets=true` in `Solax.Worker.csproj`, `_framework/blazor.web.js` is never published: pages prerender correctly, no circuit is ever opened, and the only evidence is a 404 in the browser's console. It cost an hour to find, and it is the sort of thing that would otherwise be rediscovered on the Pi. - **Static assets must be asked for explicitly** (`builder.WebHost.UseStaticWebAssets()`). Outside the Development environment, assets that live in build output rather than a `wwwroot` folder are served as an empty HTTP 200 — the page renders and silently never updates. Free in a published app, where the manifest it reads is absent. - **`Solax.Web` takes a `FrameworkReference` on `Microsoft.AspNetCore.App`** rather than the `Microsoft.AspNetCore.Components.Web` package the RCL template offers. These components only ever run server-side, so WebAssembly compatibility buys nothing and a duplicated assembly costs. - **The UI ships off by default** (`Web:Enabled: false`), like the Home Assistant integration. Authentication is a later phase of #44, and an unauthenticated control surface must not appear on a LAN because somebody upgraded. --- ## 2026-08-12 — The Pi boots from an SD card because it cannot boot from the disk it runs on **Context.** The deploy host's USB SSD — an OCZ Vector 180 — began returning unrecoverable read errors. The failure was not subtle once located, but it presented as three unrelated symptoms over half an hour: `dpkg` refusing to read its own status file, a cached `.deb` failing to decompress, and Docker aborting a Home Assistant image layer with `failed to register layer: corrupt stream`. Each looked like a software fault. All three were `critical medium error` on new sectors each time. The drive was replaced with a Crucial P1 1 TB NVMe in a Realtek RTL9210 USB enclosure. **Hardware contradicted the plan: the Pi 3 will not boot that enclosure.** The image was correct — right `bcm2710-rpi-3-b-plus.dtb`, right `kernel8.img`, `cmdline.txt` naming a PARTUUID that existed, partition type `0xc`. It still would not boot. The Pi 3 boot ROM speaks only USB Bulk-Only Transport and allows roughly two seconds for enumeration; an NVMe behind a USB bridge does not answer in time. The previous SATA SSD did, which is why USB boot had always worked before and its failure now looked like a configuration mistake rather than a hardware limit. **Decision — split the boot.** `/boot/firmware` is a 512 MB FAT32 partition on an SD card; `/` is the M.2. The boot ROM reads the SD, which it has always been able to do, and `cmdline.txt` hands root straight to the NVMe, which the Linux USB stack drives without complaint. The alternatives were worse: burning `program_usb_timeout=1` is a permanent OTP write that might not have helped, and going back to an SD-rooted Pi puts every Docker layer and the session database on the component most likely to fail. **Consequences accepted.** - The boot device is now a single SD card with no redundancy. It holds 76 MB of firmware and kernel and is written only by kernel updates, so the wear profile that kills SD-rooted Pis does not apply — but losing it means the machine does not start, however healthy the NVMe is. - `/etc/fstab` must point `/boot/firmware` at the **SD's** PARTUUID. The M.2 keeps its own orphaned boot partition from the original image, and pointing at that one is silent: updates land somewhere never booted, and the failure surfaces weeks later at an upgrade. A first-boot resize also rewrites every PARTUUID on the M.2 — the firstboot script repairs `cmdline.txt` and the root line itself, and never the `/boot/firmware` line. - Anyone editing `cmdline.txt` per the cgroup step is editing the SD card. That is correct, and it is not obvious. **Not established:** whether the RTL9210 would boot with a longer ROM timeout or on a powered hub. Neither was tried, because the split boot removed the reason to care. Power draw remains the open risk — an NVMe in a bus-powered enclosure can brown out a Pi 3 under load. **Trixie made the swap step obsolete at the same time.** Raspberry Pi OS 13 does not ship `dphys-swapfile`, so the documented commands fail outright. The image configures zram instead — zstd-compressed swap in RAM at priority 100, with writeback to `/var/swap` on disk — which is better than the swapfile it replaces for this workload. The deploy guide now verifies swap rather than creating it, and offers a low-priority disk swapfile only as an optional overflow tier, and only because root is no longer on an SD card. --- ## 2026-08-10 — The git tag is the version; the build carries it, and says so **Context.** Nothing running could say what it was. The image tag knew, but the process did not, so a log file or a `docker logs` dump was untraceable to a build — and with three platforms now behind one manifest list, "which one is on the Pi" had become a fair question with no answer from inside. > **Partly superseded on 2026-09-02** by > [the record above](#2026-09-02--the-product-line-is-in-the-repository-the-build-number-is-not-issue-145): > the placeholder is gone and the repository now holds the major and minor, because a tag name is > still typed by a human. The version-and-commit half of this record is unchanged. **Decision — one source of truth, and it is the git tag.** `Directory.Build.props` carries `0.0.0-dev`, a deliberate placeholder. CI passes `-p:Version=` when building from a `v*` tag, so the number compiled into the binary and the number the image is tagged with come from the same place and cannot drift. Storing a real version in the repo and bumping it by hand was the alternative; it adds a commit per release whose only job is to agree with a tag. **Decision — the commit travels with the version.** `-p:SourceRevisionId=` makes the SDK emit `AssemblyInformationalVersion` as `1.0.0+`, which `BuildInfo` splits back apart. A version alone is not enough: several builds share `0.0.0-dev`, and during a release the same version may be built more than once. **Consequences.** - A build that did not come from a tag reports `0.0.0-dev` rather than claiming a number it does not have. Unstamped and un-versioned is the honest state for a local build, and it is visible at a glance in a log. - The version is logged as the first line at startup and published as Home Assistant's `sw_version`. The latter is device metadata, so it adds no entity and no row to the README's entity table. - The image tag string (`main`, `release-1.3`) and the assembly version are now computed separately in the workflow. They have different legal alphabets — no assembly version can be called `main` — and conflating them was the trap worth avoiding here. --- ## 2026-08-10 — One image name, three platforms, and a timezone that no longer comes from the OS **Context.** The image was built for `linux/arm64` alone, because the Pi was the only target. Wanting to run the controller on an x64 box and on a Windows host turns one build into three, and raises the question the single-platform world never had to answer: what is any of them *called*. **Decision — one package, one manifest list.** All three platforms publish under `ghcr.io/mpospisil/solax-controller`. `:1.0.0` is a manifest list, so the same string pulls the arm64 image on the Pi, the amd64 image on a server and the Nano Server image on Windows, with no per-host knowledge anywhere. Separate packages per platform were the alternative; they would have meant three things to keep in version step and would have thrown away the platform selection Docker does for free. Single-platform tags (`:1.0.0-linux-arm64`) still exist alongside, for pinning and for debugging "which one did it actually pull". **Decision — Windows means Nano Server ltsc2022, built in its own job.** A Dockerfile builds for one OS and a Windows runtime stage needs a Windows daemon, so `Dockerfile.windows` is a second file built on a `windows-2022` runner rather than a `--platform` on the existing one. ltsc2022 over ltsc2025 because an ltsc2022 container runs on both Server 2022 and Server 2025 hosts, while ltsc2025 demands a 2025 host — the newer base buys nothing here and costs reach. Nano Server over Server Core for ~300 MB against ~2 GB, accepting that Nano Server ships no ICU. **Decision — the local timezone is configuration, not an ambient property of the host.** This is what the Windows image forced. The controller's day boundary, the daily loan reset and the zone id stamped on every recorded session all came from `TimeZoneInfo.Local`, which on Linux is set by the container's `TZ`. **.NET on Windows ignores `TZ` entirely.** A Windows container would therefore have run in UTC whatever `docker-compose.yml` said, and silently filed evening sessions under the following day — visible only as inexplicably bad charging decisions days later. `Controller:TimeZone` now names the zone, resolved through a `ZonedTimeProvider` that overrides `LocalTimeZone` on the `TimeProvider` the services already inject, so there is exactly one answer to "what day is it here". **Consequences.** - An unresolvable zone id is fatal at startup. Falling back to UTC is the failure mode the setting exists to prevent, so it must not be the failure mode of the setting itself. - Empty stays the default and keeps today's behaviour. Linux deployments are unaffected; `TZ` in `deploy/docker-compose.yml` still does the job. - On Nano Server the value must be a **Windows** zone id (`Central Europe Standard Time`), not IANA. Mapping IANA to Windows is an ICU feature and Nano Server has no ICU. The worker logs a warning at startup when it is running on Windows with the zone unset. - `SolaxPollingService` reached for `TimeZoneInfo.Local` directly; it takes a `TimeProvider` now, like everything else that needed the zone. - The publish workflow grew from one job to four (version, linux × 2 arches, windows, manifest). The manifest job is what keeps `:latest` pointing at the previous release until every platform of the new one exists. --- ## 2026-08-09 — Charging sessions are stored in SQLite, and published as immutable documents **Context.** Everything the controller knew was live: a log line that scrolls past and a Home Assistant entity overwritten on the next poll. Nothing survived a restart, so the question the forecast-driven mode exists to answer — did the plan hold, and where did the car's energy actually come from? — had no data behind it. Issue #32. **Decision — SQLite (`Microsoft.Data.Sqlite`) as the local store.** The questions worth asking of this data are queries ("sessions in July", "solar share by mode"), which rules out flat JSONL; and it runs on a Raspberry Pi 3 B already giving 600 MB to Home Assistant, which rules out a second server process. One file is also one artefact to back up. WAL journalling with `synchronous=NORMAL`, batched commits, and a sample cadence coarser than the poll interval, because the binding constraint on that device is SD-card write amplification rather than size. `SQLitePCLRaw.bundle_e_sqlite3` is pinned to 2.1.12 as a direct reference: the version `Microsoft.Data.Sqlite` 10.0.10 pulls in transitively (2.1.11) carries GHSA-2m69-gcr7-jv3q. **Decision — the published document is not the table schema.** A closed session is immutable, so it can be rendered as one self-contained `ChargingSessionDocument` (header + samples + events) carrying an explicit `schemaVersion`. Local tables will churn as features are added; that document must not, because it is what a future upload writes to object storage and what a web app reads. Ids are locally-generated UUIDv7 — globally unique without a server, and time-ordered so they sort as object keys. **Decision — per-source energy is an attribution, not a measurement.** No meter says which electron went where. `ChargingSourceAttribution` fixes the rule (surplus PV first, then the battery, then the grid as residual) and guarantees the three shares sum to the measured draw. The measured battery share stays separate from the loan the forecast mode *commanded*. **Consequences.** - The store is on by default, unlike every other feature that needed a flag. Those flags exist because those features write to hardware; this one only observes. - Recording is a subscriber to `ChargeControlStatusHolder.Updated`, like the HA worker — the poll loop gains no dependency on a disk, and a store failure cannot stall a Modbus cycle. - `ChargeControlStatus` gained one field, `SessionCompleted`. By the time a status is published the loop has already returned the mode to `Off`, so without it "the car finished" and "somebody switched it off" are indistinguishable to any later reader. - Every sample carries the running totals, which is what lets a session interrupted by a power cut be closed at its last sample rather than discarded. - The container needs a `data/` bind mount, chowned to the image's uid — the same trap the logs directory hit. That change belongs to the deploy branch (#26), which is not yet merged. --- ## 2026-08-09 — Entity names stay short; the explanation lives in the README **Context.** Supersedes the record below, which put each entity's explanation into its friendly name so that HA's hover text would say something. In use that was worse than the problem: every dashboard row read as a truncated sentence, the entity list became hard to scan, and a name that is really a paragraph is confusing rather than informative. **Decision.** Entities keep their original short names — `Grid power`, `Charging now`, `Required SOC floor` — and the `description` attribute is dropped with them. What each entity means, including the sign conventions and the traps, is documented once in the README, under *Home Assistant (MQTT) → What each entity means*. **Consequences.** - Hovering an entity in HA still tells a reader nothing beyond its name. Accepted: a short label that scans is worth more on a dashboard than a tooltip, and the explanation is one link away. - One place to keep current. A new entity is documented by adding a row to that table; nothing in the discovery payload duplicates it, so the two cannot drift. - The tests that enforced a `Label — meaning` name and a description attribute are gone, since there is nothing in the payload left to enforce. - Names returned to what earlier installs already published, so `entity_id`s, dashboards and automations are unaffected either way — `entity_id` is fixed at first discovery and does not follow renames. --- ## 2026-08-08 — The entity name carries its explanation, because in HA the name is the tooltip > **Superseded on 2026-08-09** by the record above: the long names were confusing in practice, and the > explanations moved to the README. The finding about HA having no description or tooltip field still > holds. **Context.** The Home Assistant entities carried names and nothing else, so a reader looking at `Grid power` or `Required SOC floor` had no way to learn what the number means or which direction its sign runs without opening this repository. **What was found.** Home Assistant has no description or tooltip field for an entity. The hover text is the friendly name — the frontend sets it so a truncated name can still be read in full — and MQTT discovery has no `description` key. Adding one has been requested and not implemented. **Decision.** The name carries the explanation, because the name is the tooltip. Every entity is named `Label — what it means`, e.g. `Grid power — positive while importing from the grid, negative while exporting`. HA truncates the label in a card and shows the whole sentence on hover, which is the only tooltip mechanism it has. The detail that will not fit on one line is published as an entity **attribute**: `json_attributes_topic` points at the state topic every entity already subscribes to, and `json_attributes_template` is a *constant* JSON document carrying a `description` key. It renders identically on every state message and shows up under Attributes in the more-info dialog. **Consequences.** - The template is authored as text but must render valid JSON, so it is built with `JsonSerializer` (escaping anything in the prose that would break it). Descriptions must not contain Jinja delimiters; a test enforces that, along with every entity having both halves — a name in `Label — meaning` form, and a description that isn't merely the name repeated. - Dashboard labels are long and truncate. That is the point: a short name would leave the hover text saying nothing, which is the problem this record exists to solve. - Entity ids on a **fresh** install derive from the new names and will be long. Existing installs are unaffected — `entity_id` is fixed at first discovery and does not follow later name changes, so dashboards and automations survive a rename. - Attributes only appear once a state message has been received, and are cleared while the entity is unavailable. The state topic is retained and republished every `StatusInterval`, so this is only visible when the controller is not running at all. --- ## 2026-08-08 — A mode may end itself, and "the car is finished" is decided on power **Context.** Issue #28, the `FastNoBattery` mode. It creates the most expensive state this controller can ask for — maximum current, grid import, the home battery locked out of the house — for a goal that completes: the car reaches its own charge limit. Leaving that armed until somebody looks at Home Assistant is the obvious failure mode, so the mode has to be able to end itself. **Decision 1: a controller can say "this is over".** `ChargingControlDecision` gains `SessionComplete`, carried up through `ChargeControlCycleResult`, and the poll loop answers by writing the pause current and calling `IChargeControlModeSelector.Set(Off, …)`. The mode change is applied *before* the battery hold is reconciled in the same cycle, so the inverter release goes out on the same poll rather than the next one. This is the first time control flows from a strategy back into the mode selector. The alternative — a scheduler or timer outside the strategies — would have needed its own copy of "is the car still drawing", which is exactly what the strategy already sees. The selector's existing contract does the rest: `Set` logs and raises `Changed`, and the HA worker republishes the retained select state from `_mode.Mode` on its next status tick, so the mode flipping under the owner needs no new plumbing. **Decision 2: completion is a power judgement, corroborated by status.** The X1/X3-HAC's end-of-charge status is firmware-specific and **has not been observed here yet** — the mode ships before a full session has been logged through it. So the rule is built on the reading that cannot be misinterpreted: - idle = draw at or below `CompletionPowerThresholdWatts` (200 W), *or* status `SuspendedEv` / `Finishing`, which is the car declaring itself done even while trickling for conditioning; - finished = idle continuously for `CompletionDwell` (2 min); - and only once the car has drawn power at least once this session, which is what separates "finished" from "hasn't started". `ChargePaused` and `SuspendedEvse` are excluded on purpose: those are the *charger's* state, and our own pause write produces them. Including them would let the controller read its own pause back as a finished charge. **Consequences.** - The 200 W threshold sits in a wide gap — a charger's standby draw is tens of watts, its 6 A floor is 1.4 kW single-phase and 4.1 kW on three — so no realistic reading is ambiguous. - A car that pauses mid-session for longer than the dwell (thermal management, a utility signal) will be read as finished and the mode will end. Acceptable: ending returns control to the owner rather than doing anything to the car, and the owner reselects the mode. - **Still to verify on hardware:** what the charger actually reports as the car finishes. Log a full completed session and, if the observed transitions contradict the rule above, amend it here. --- ## 2026-08-02 — The Pi runs containers it did not build, and holds no state inside them **Context.** Issue #26: move the service off a developer laptop onto a Raspberry Pi 3 B (Raspberry Pi OS Lite 64-bit) so it runs unattended. Three containers — the controller, Home Assistant, and an MQTT broker. The board has **1 GB of RAM, an SD card, and an arm64 CPU**, and all three constraints shaped the design. **Decision 1 — CI builds the image; the Pi only pulls.** A `dotnet restore` + `publish` on a 1 GB Pi 3 B takes tens of minutes and can be OOM-killed. GitHub Actions builds `linux/arm64` and pushes to GHCR; the Pi runs `docker compose pull`. `sha-` tags make a rollback a one-line command. **Decision 2 — cross-compile, don't emulate.** The obvious way to build arm64 on an amd64 runner is QEMU, which works and is roughly ten times slower. Instead the SDK stage is pinned to the *builder's* architecture (`FROM --platform=$BUILDPLATFORM`) and targets the other one via `dotnet publish -a $TARGETARCH`, so the compiler runs natively and only the output is arm64. The runtime stage was then written with **no `RUN` instruction at all** — the logs directory is created in the build stage and `COPY --chown` sets ownership — so nothing arm64 ever executes at build time and the workflow needs no QEMU setup step. Measured: 75 s for a cold cross-build, of which the publish itself is 3.5 s. **Decision 3 — the Debian runtime image, not chiseled.** Chiseled is ~80 MB smaller, but it omits tzdata and a shell. Log timestamps and `SolarForecast.ForDate` are timezone-sensitive, and this is a headless box where the diagnostic path is `docker exec`. The disk saving is not worth either. **Decision 4 — no state inside any container.** Every container must be destroyable with `docker rm -f` and recreated with no loss; that is what makes upgrade and rollback routine rather than risky. All state is on **bind mounts under `/opt/solax`** — chosen over named volumes because the data is then visible to ordinary shell tools over SSH, without `docker volume inspect` indirection. The consequence accepted: bind mounts carry host uids, so the deploy documents chowning `logs/` to 1654 (the .NET image's non-root user) and `mosquitto/` to 1883. **Decision 5 — the production broker authenticates.** The dev stack's `allow_anonymous true` is not carried over. These topics include the charge-mode select and the battery-hold switch, so anonymous access is control of the inverter and charger. The broker also publishes **no host port** — only the compose network reaches it. No application change was needed: `HomeAssistantOptions` already had optional `Username`/`Password`, previously unused. **Consequences.** - **1 GB is the binding constraint, and Home Assistant is the risk.** Per-service `mem_limit`s (600/200/48 MB) leave ~170 MB for the OS. They only take effect if cgroup memory accounting is enabled in `cmdline.txt`, which Raspberry Pi OS ships **off** — an easy silent failure, so it is step 3 of the setup. If HA cannot be made to fit, the fallback is moving it to another host; the three services are independent precisely so that stays a compose edit. - **SD-card wear is the long-term failure mode.** Container logs are size-capped, the broker logs to stdout rather than its own file, and the seeded HA `recorder` config uses `purge_keep_days: 3` with `commit_interval: 30`. - **Serilog's `SelfLog` is now enabled** (`Program.cs`), the one `src/` change the deployment forced. Verified: with a logs bind mount the non-root user cannot write, the file sink fails and the process carries on — console logging normal, container healthy, `docker diff` empty, and the log files silently never created. That is the exact failure a bind mount invites (Docker auto-creates a missing mount source as root), so it must not be silent. `deploy.sh` also creates the directory and hands it to uid 1654 when it is missing or wrongly owned, fixing it before the stack starts rather than a month later. - **The controller is stateless, which costs one Solcast call per restart** — the forecast cache is in-memory. Normal operation is unaffected, but a crash-restart loop burns the free-tier daily quota, so restart counts are worth watching. Persisting the forecast is a possible follow-up. - **Deployment writes nothing to hardware.** Charge control boots in mode `Off` and takes control only when Home Assistant selects a mode; `BatteryHold` stays disabled and dry-run. The compose file passes those settings explicitly so the safety posture is visible rather than implied. - **The session store (#32) gets the `data/` bind mount its own record asked for.** `SessionStore:Path` resolves against the content root, so `data/sessions.db` lands in `/app/data` and is mounted from `/opt/solax/data`. Two consequences specific to SQLite: the *directory* must be writable by uid 1654 because the `-wal` and `-shm` files live beside the database, and a backup stops the controller first, since WAL makes a hot copy unlikely to tear rather than unable to. `deploy.sh` prepares `data/` as it does `logs/` — without a mount the app happily writes into the container and the history dies with it, which is the one loss here that cannot be undone by re-polling. --- ## 2026-07-29 — A failed Modbus exchange invalidates the connection **Context.** Issue #24: after roughly fifteen minutes of normal operation the service began failing every single poll with `Response was not of expected transaction ID. Expected 2426, received 2424`, and never recovered — 45 further minutes produced zero successful polls. Earlier logs show the same failure in smaller doses going back a week. **What was found.** A Modbus TCP response that arrives after its request has given up stays in the socket's receive buffer. The next request reads *that* reply, and every request after it is permanently one or more responses behind. NModbus retries a mismatch by re-sending, which heals a one-off glitch — but not this, because every subsequent response is offset too, so the retries are exhausted and the read throws. The connection is not the problem. Throughout all of it the TCP socket is open and healthy, so `ModbusTcpClient.IsConnected` returns true and the callers' `if (!IsConnected) ConnectAsync()` guard never fires. The poll loop dutifully catches the exception, logs it, and retries on the same poisoned stream, forever. Only restarting the process cleared it. **Decision.** Any failed exchange invalidates the connection: the master and the `TcpClient` are disposed and nulled, so the next call reconnects with a fresh stream and a fresh transaction counter. This is done in an exception filter, so the original NModbus exception still reaches the caller and the logs unchanged. Three supporting changes: - **Connect on demand.** Operations no longer throw "not connected"; they connect if needed. The callers' explicit guards still work but are no longer load-bearing. - **One request at a time,** via a `SemaphoreSlim`. Requests are sequential today, but a reconnect can now happen mid-call, and two requests sharing a stream is another route to the same desync. - **A minimum gap between requests** (`DeviceConfig.MinRequestInterval`). The SolaX protocol documents a second between instructions — a constraint noted in `InverterRegisterMap`'s own comment and then honoured nowhere. The poll loop issues about five requests per five-second cycle, four of them to the charger, which is where the failure was observed. **Why 250 ms and not the documented second.** A full second across four charger requests would consume most of a five-second poll. Recovery no longer depends on the spacing, so this value only affects how often the device is pushed into a state that needs recovering; it is per-device configuration, and raising it to `00:00:01` is the first thing to try if desyncs persist on other hardware. **Consequences.** - A transient glitch now costs one request instead of the process. The poll loop's existing catch-log-retry becomes sufficient rather than futile. - Reconnection is invisible to callers, so a burst of failures shows up as a few warnings rather than an outage — worth remembering when reading logs: absence of errors no longer proves the link was never disturbed. - Testing this needed a real socket. `FakeModbusTcpServer` speaks just enough MBAP to answer reads and writes and, on demand, to answer them with the wrong transaction id. Verified as a genuine regression test: with the invalidation disabled, three of the nine tests fail. --- ## 2026-07-27 — The forecast plans by power band, not by daily energy; the car absorbs any shortfall **Context.** Issue #22 adds a third charge mode, `Forecasted`, driven by the Solcast forecast, with one hard requirement: the home battery must be at 100 % by evening, while the car takes as much solar as it can and neither pack is degraded unnecessarily. **Decision 1 — plan in power bands, not in kilowatt-hours.** The obvious formulation, `EvBudget = forecast − house − battery`, is wrong on this hardware: it treats energy as fungible when the two consumers cannot accept the same power. The EV charger's floor is 6 A, which on three phases (and the X1/X3-HAC has no phase switching) is **~4.2 kW**; the home battery accepts anything down to a few hundred watts. So the day is split into *shoulder* production (below the floor — battery only) and *plateau* production (at or above it — the only time the car can charge), and the battery's need is booked against the shoulders first. Only what the shoulders cannot cover is claimed from the plateau. A budget expressed purely in energy would happily promise the car 3 kWh on a day whose surplus never once clears 4.2 kW. **Decision 2 — book the battery backwards from the deadline.** The booking walks the remaining forecast from `FullByTime` backwards, reserving the latest production first. That is what "100 % by evening, not by lunchtime" means, and it hands the car the *earliest* feasible plateau — when it is most likely to be plugged in, and when a forecast error still has the rest of the day to correct itself. Recomputed every poll, so a collapsing afternoon simply grows the reservation next cycle. **Decision 3 — the SOC floor counts all remaining surplus, not the booking.** An earlier draft derived the floor from the energy booked for the battery. That is degenerate: the booking is sized to the need *at the current SOC*, so the floor came out equal to the current SOC — "you may never discharge". The floor answers a different question ("how far may SOC fall and still recover by the deadline?"), and a deeper discharge simply grows a need the battery outranks the car to satisfy. It therefore counts every remaining watt of surplus, clamped by `MinBatterySocFloorPercent`. **Decision 4 — plan on p10, and measure the forecast against reality.** Planning a guarantee against the median means missing it about half the time, so the plan uses Solcast's `pv_estimate10` (`pv_estimate` was the only band parsed before). On top of that a realised bias (`actual ÷ forecast` for elapsed daylight) scales the remaining forecast, clamped asymmetrically to `[0.5, 1.2]`: under-production must be able to throttle the car, but a sunny morning must not be able to over-commit the afternoon. A sustained breach of `[0.6, 1.4]` abandons the plan for the day. The forecast refresh drops from 12 h to 3 h, skipped overnight — a 12-hour-old forecast cannot steer a deadline, and a fresh one at 02:00 cannot change any decision. **Decision 5 — the loan bridges a surplus; it never funds a session.** The battery may lend the difference between a real surplus and the 6 A floor, repaid later from sun that would otherwise be exported. It is refused below `MinBridgeSurplusWatts` (2 kW), on any shortfall day, once `MaxDailyLoanKWh` is spent, and near the floor. Lending 4.2 kW into no sun would be a battery-to-car transfer: a round trip and a cycle on both packs, buying nothing. Enforcement is not left to the arithmetic — at the floor the #20 discharge hold is armed automatically, so the grid covers an estimate error rather than the pack. > **Partly superseded on 2026-09-23** by > [the record above](#2026-09-23--the-loan-is-measured-against-what-the-pack-cannot-absorb-not-against-a-constant): > the loan is still a bridge and still never funds a session, but three of the four refusals have moved. > The shortfall veto is gone (the floor gate already stops that day, harder), the daily cap counts > outstanding rather than cumulative lending, and `MinBridgeSurplusWatts` applies only while the pack has > room for the surplus — on a day it cannot absorb, `SpillBridgeSurplusWatts` does. The floor, and the > hold armed at it, are unchanged. **Decision 6 — on a shortfall the car gives way, and we report rather than act.** Priority is fixed: house → battery to 100 % → EV. Chosen deliberately over the alternatives (grid top-up to a daily minimum; letting the battery finish below 100 %) because the owner's requirement is the evening 100 %, and because grid-charging an EV is a decision worth making deliberately rather than automatically. **No code path initiates grid charging.** What the controller owes the owner instead is early warning: `Day outlook`, `Projected shortfall` and `EV energy expected today` are published as soon as the day can be judged, so the decision — drive less, charge elsewhere, plug in on a night tariff — stays with a person. > **Partly superseded on 2026-09-23** by > [the record above](#2026-09-23--forecasted-stops-asking-how-much-the-car-wants-a-request-is-what-targeted-is-for): > the priority order and "no code path initiates grid charging" are unchanged, but the early warning > is no longer phrased as a verdict against a daily EV target. `Day outlook` and `EV energy expected > today` are gone; the charge window, the EV energy budget and a `Projected shortfall` that now > measures only the house plus a full battery are what is published. Consequences, deliberately accepted: - **Two new stateful pieces in the worker** (`DayPlanProvider`, and the session/loan integrators in `ChargingControlCoordinator`). Nothing is persisted, so a restart loses today's accumulated energies and the bias resets to 1.0 — consistent with the rest of the service, and self-correcting within a few forecast periods. - **The house baseline is a single rolling mean, not a per-hour profile.** A learned profile that resets on every deploy would be worse than an honest average. - **The dwell timers can briefly import.** Holding a session at 6 A through a surplus dip for up to `MinRunTime` may pull from the grid; the alternative is contactor cycling and vehicle wake cycles on every passing cloud. - **`Forecasted` degrades to `Solar`, never to something more permissive.** Missing forecast, stale forecast and broken trust all take the same path. --- ## 2026-07-26 — Battery discharge hold uses computed Power Control, not a device "No Discharge" mode **Context.** Issue #20 asks for a switch that stops the home battery discharging, so an EV charges from PV and grid but never from the battery, while the battery is still free to charge from PV surplus. The issue proposed writing `power_control = Enabled No Discharge` to the Modbus Power Control block at holding register `0x7C`, treating it as a fire-and-forget command: one write to arm it for 8 hours, one write to release it, and nothing in between. **What we found.** Desk verification against the upstream [`plugin_solax.py`](https://github.com/wills106/homeassistant-solax-modbus/blob/main/custom_components/solax_modbus/plugin_solax.py) map — the source the issue itself cites — contradicts that design in three places. 1. **`Enabled No Discharge` is not a device-level mode.** The `remotecontrol_power_control` entity is declared `WRITE_DATA_LOCAL`, meaning its option values (`11`, `12`, `110`, `120`, `130`) never reach the inverter — they are identifiers for client-side strategies. The device enum is only `0 = Disabled` and `1 = Enabled Power Control` (upstream lists `2 = Quantity Control` and `3 = SOC Target Control`, both commented out). Mode 8/9 at `0xA0` tells the same story: its `85: "Enabled No Discharge"` also resolves to a real device value of `8`. 2. **`active_power` is the mechanism, not an ignored field.** `autorepeat_function_remotecontrol_recompute` translates `Enabled No Discharge` into `power_control = Enabled Power Control` with `active_power = -min(house_load, pv_power)`. Because that target is derived from live house load and PV, it must be recomputed and rewritten continuously. A single 8-hour arming cannot express it. 3. **The block cannot be read back.** Holding register `0x7C` is overloaded: upstream *writes* the power-control command there but *reads* it as the inverter's ARM firmware version (`async_read_holding_registers(address=0x7B, count=2)`). No register exposes the active remote-control state — upstream tracks it with client-side timers only. **Decision.** Implement the hold the way the hardware actually supports it: write `power_control = Enabled Power Control` with `active_power = -min(house load, PV)`, recomputed each poll from telemetry, and reissued when the target moves past a threshold or the armed command nears expiry. This preserves both halves of the requirement — the battery is never asked to serve load (the inverter is only ever told to push out power it is already generating), and PV beyond the house load has nowhere to go but the battery, so surplus charging still works. Consequences, deliberately accepted: - **The "at most one write per 8 hours" acceptance criterion is dropped.** The write rate is instead bounded by `BatteryHold:TargetChangeThresholdWatts` (default 100 W) and the renewal interval. The command is not EEPROM-backed — upstream states these may be issued as often as desired — so this costs Modbus traffic, not hardware wear. - **`Duration` is 60 s, not 8 hours.** With per-poll reconciliation a short duration is a *better* failsafe: if the service stops, the inverter resumes normal operation within a minute instead of within eight hours. Renewal happens at half the duration so a slow poll never leaves a gap. The 8-hour figure survives only as the hardware ceiling (`u16`, 28,800 s) enforced in the encoder. - **The Home Assistant switch reports our own armed state, not a device read-back.** The acceptance criteria around reading the hold back, surviving a restart by reading device state, and correcting a manual change made in the SolaX app are not implementable — and the last is moot anyway, since this is a command rather than a stored setting the app could show or alter. - **Upstream's SOC ≥ 98 % branch is not implemented.** There, the target becomes `-pv_power - 150`, deliberately trickle-discharging the battery to keep SOC near 98 % and stop older inverters curtailing PV. That contradicts this issue's "battery power is never negative" requirement, so it is left out pending observation of whether PV curtailment actually occurs on this hardware. **Why not the alternatives.** Unchanged from the issue, and reinforced by the above: | Approach | Verdict | |---|---| | Computed Power Control (`0x7C`, mode 1) | **Chosen.** The only route that both blocks discharge and preserves PV → battery charging. Not EEPROM-backed. | | Raise discharge cut-off / min SOC | Rejected. Modifies a stored parameter on a ~100,000-cycle EEPROM, 1 % granular, and drifts as SOC moves. | | Battery Discharge Max Current = 0 | Rejected. Same EEPROM problem; also fights the inverter's own limits. | | Manual Mode → "Stop charge and discharge" | Rejected. Freezes the battery in *both* directions, so PV surplus exports instead of charging the battery. Remains the manual fallback if the Modbus route fails verification. | ### Verified on hardware, 2026-07-27 First live write to the inverter. Conditions: dusk, PV ~360 W, SOC 87 %, no EV charging, house load ~1.5–2.9 kW. **The mechanism works.** Arming the hold moved the house from battery to grid within one poll: | | Battery | Grid | Solar | |---|---|---|---| | Before the write | **−2846 W** (discharging) | 0 W | 366 W | | After the write | **−56 W** | **+1601 W** (importing) | 370 W | Confirmed by this run: - **`power_control = 1` with a computed `active_power` is accepted** and takes effect immediately — no Modbus exception, no rejected block. The encoded payload `[1,1,65170,65535,0,0,60,0,0,0,0,0,0]` at `0x7C` is correct as written. - **Renewal at half the duration works.** Renewals were issued at ~33 s and ~72 s with the hold remaining continuously effective; no lapse or gap was observed between them. - **PV was not curtailed.** Solar held steady at 358–370 W across the whole run, before, during and after arming. Weak evidence at 360 W in the evening — this needs repeating under strong midday sun before the curtailment risk can be closed. **A working hold still leaves a residual 50–65 W trickle out of the battery.** This is inverter standby draw, not load being served — it persisted regardless of house load swinging between 143 W and 2877 W. Two consequences: - Issue #20's acceptance criterion "`BatteryPowerWatts` is never negative" is **not literally achievable** on this hardware. The achievable guarantee is that the battery stops serving house load, which is what the feature is actually for. - The "hold armed but battery discharging" warning originally triggered on any negative value, so it fired every single poll and drowned out the signal it existed to give. It now uses a 150 W deadband. **Still to observe:** behaviour under strong PV (does the battery still charge from surplus while held, and is PV curtailed at full output), behaviour with the EV actually charging, and what the undocumented `timeout` field does relative to `duration`. `BatteryHold:Enabled` remains off by default and `DryRun` still defaults to `true`, since none of the above has been observed on any other firmware.