Repository navigation
Conversation
* feat(auth): connect an assistant without a terminal The house password, not the address, proves the owner. Any browser that reaches the box may set the first one, also through a Docker port or on the box itself, and choosing it signs that browser in. Settings → Access asks for it right above the assistant's code, and the setup wizard asks for it on a new box. Any non-empty password is accepted; a short one gets a warning. A forgotten password is turned off on the box with `ftw password reset` (`/app/ftw password reset` in Docker). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(auth): close the first-password race and sign in only with proof From an independent review of the previous commit: - A request Authenticate let in while the lock was still off can no longer replace the password the first browser just chose. - The sign-in cookie goes only to a direct local browser that chose the first password or proved the current one. The box itself may still replace a password it never proved, as it may change any setting, but that earns no sign-in. - Every change of the house password writes an audit line with the address. - Access no longer offers "choose a password" when the box's status could not be read, and a choose that meets a password set meanwhile turns into a sign-in. - The password limit counts characters, not bytes. - The docs say how to reset on a native box whose ftw command predates this release, and say plainly that loopback is not a wall against programs on the box. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…cfl#1835) * fix(mpc): the plan keeps each clock hour under the import ceiling The planner saw only the fuse. Under a 4.5 kW hourly import ceiling it planned the car at 4.83 kW from 09:00, 5.5 kWh an hour, and called the 13:00 goal met while Core held each hour to 4.5 kWh. control.PlanningImportCeiling turns Core's live import hour into the planner's input: the clock hour in progress, what it has used, and the mean a planned hour may hold, the ceiling less half a rise step. The car runs in whole amps and a rise keeps a step in hand, so hours end 0.4-0.6 kWh under the ceiling; closed-loop in sitesim the car then gets within 1-6 % of the promise. The planner lists each clock hour the plan spans. ValidatePlan holds every plan to them, the house's own import never failing an hour. The Go DP and the reserve plan spread each hour over its slots, and the battery covers the house so the car gets the hour's room. Energyplan gets the hours as hourly_import_caps when it announces the feature; otherwise a worker plan that overruns an hour falls back to Core's plan. A new ceiling replans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mpc): keep a plan when Core cannot stand in for an overrun hour Review of the first commit: - A worker plan that broke only an hour left no plan when Core cannot plan the site itself (battery outside its band, a fleet the DP cannot model). The hour check now runs last and carries the plan; the service publishes it with a warning, as before the hours, and the live limit still holds each hour. - The reserve plan could run a step part-time that needs the battery to discharge past the house, so the battery drove export beside the car and the plan failed its own rule. It takes a smaller step. - Only a worker that takes the hours gets them, only those its slots overlap, and only then the fleet budget. - docs/energyplan-contract.md follows srcfl/energyplan#78 on top of FTW's copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mpc): no car on the house floor of an overrun hour Agreed with srcfl/energyplan#78: in an hour where a car charges, the plan's import keeps to what the hour has left; the house-alone floor holds only while every car is off. With the floor, the reserve plan ran the car on the import the battery freed by covering the house, in an hour Core's live limit would stop the car. ValidatePlan, the DP, the reserve plan and its pricing follow the rule; the contract doc states it as one rule for Core and the worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mpc): the DP's car gives way when the battery runs short of an hour The DP plans on a SoC grid. Replayed from the real SoC, the battery can run short of the discharge that kept an hour under its budget; in the 25 October field case the 12:00 hour came to 4211 of 4155 Wh. The car now gives way in the replay, to the largest step that fits or none, as Core's live limit cuts the car first. A native test runs the field case through EnergyplanOptimizer with a worker that takes hourly_import_caps; it skips on the bundled 0.5.2 and passes against the worker built from srcfl/energyplan#78. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mpc): the worker's budget ends before Core's call deadline When the first slot's end clipped the Energyplan budget, the budget was the call's deadline itself, so a worker that spent all of it answered after Core had given up. The budget now ends 5 % of the time to the deadline, at least 30 ms, before it. The fail-fast at 500 ms left keeps the budget at 420 ms or more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bundle the worker from srcfl/energyplan e975bb9 (0.6.0, release v0.6.0-beta.1) for linux-arm64, linux-amd64 and darwin-arm64. The worker lists hourly_import_caps and keeps every clock hour under the owner's hourly import limit; with srcfl#1835 Core sends it the hours. Without caps its replies are those of 0.5.2. Licenses, notices and forecast schemas are unchanged. Protocol v1 is unchanged. Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…the charger's raw table (srcfl#1860) With no plan, withDeadlineReserve took its on-power from DeadlineNeed, which held it to LimitPower(st, st.AllowedStepsW): the charger's whole table. On a table with one-phase and three-phase levels, a car whose own maximum is below the lowest three-phase level (a 3,680 W plug-in hybrid) got a one-phase level, 3,680 W. Planned charging runs on three phases, so the controller snapped with the three-phase minimum, 4,140 W, and sent 0 W. The car is marked one-phase only once it draws, so it never was. The plan path gets PlanningLimits and orders 4,140 W. DeadlineNeed now takes the limits. Controller.PlanningLimits is PlanningLimits on the loadpoint's phase mode and the site fuse, read under the controller's and the manager's own locks. The controller's reserve check uses it, and main.go wires it into the new mpc.Service.PlanningLimits; nil keeps the charger's table. A marked one-phase car is reserved the third of a three-phase level it takes, so a 2.3 kW car on a three-phase table is ordered 6,900 W, not the charger's 11,040 W. A charger without a table still takes any power in its range. Tests fail on master 286ac14: DeadlineNeed on mixed and three-phase tables, the reserve through SlotDirectiveAt, and the controller's order on mixed tables (unset, auto, 3p). Of twelve new noplan field-matrix rows, the seven on mixed tables go idle on master (order 0 W); the five on three-phase tables pass on both. The known list stays empty. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore(release): version packages * docs(changelog): the bundled Energyplan takes the hours in 0.157.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(control): enforce configured battery charge ceilings * fix(control): end manual Charge at restart * test(control): seed the live fuse reading for the SOC ceiling check
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…rcfl#1872) Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
) A settings POST that omits control_interval_s stored 0, and the next boot panicked in time.NewTicker. Fill the two site timings that have no meaning at zero on every decoded document (YAML, stored settings and a plain settings POST), and give boot the tick's <=0 -> 2 s guard. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Boot read settings from state.db (config.Load) before state.Open ran the snapshot heal, so a damaged state.db stopped every start. Boot now restores state.db from a usable snapshot first when the settings read fails with SQLite corruption, even if the verified-good marker is present. Without a usable snapshot nothing changes. When state.Open restores an older snapshot after Load, the snapshot's settings run and /api/health reports the step back instead of Core exiting. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rcfl#1900) * fix(state): heal a damaged or missing history.db instead of exiting A damaged history.db made Core exit at boot, and systemd restarted it every 5 s with no driver running. Core now moves the file and its -wal/-shm aside as .corrupt-<ms>, starts an empty history bound to a new generation and reports a history heal event on /api/health and in the storage banner. A readable history.db from another generation is still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(state): heal converted-beta, empty and data-page history damage Review of srcfl#1900 found three cases where a damaged history.db still stopped Core: - Converted beta sites. The heal kept history_duckdb_generation, so the retry failed with "beta history needs conversion", and the preflight refused a missing or unreadable history.db before the heal could run. The heal now clears the beta key too (both heal paths need a bound SQLite generation, so conversion is done) and logs its value. The preflight lets a missing, empty or SQLITE_CORRUPT/NOTADB file through. - Damage first read by a later startup query (sqlite-v1 count, ledger version, source cursor batch, source day cache) went up unclassified. Those history-only steps now go through the same check. Steps that also read state.db or other files (chart recovery, raw retirement) count as damage only when quick_check of history.db fails. - A 0-byte history.db opened as an empty database and was refused as a foreign generation. A file with no history_migrations rows now heals like a missing one; a file with another generation is still refused. The failure cleanup also closes a handle that RetireRawHistory swapped in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(state): one unreadable Parquet file never stops boot AbsorbColdHistory returned the first read error, and main.go exits on it. The bad file stayed in place, so every restart failed the same way. Now an unreadable bucket file is logged, kept for recovery and listed as unreadable_cold_files in the history health report. The other days import, and the day's older legacy copy is used when it reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(state): move an unreadable bucket file out of the read path The first version kept a damaged DD.buckets.parquet at its normal name. aggregatePaths still found it, so every series query over that day failed with the Parquet error, on every boot. Rename the file to DD.buckets.parquet.unreadable instead. No reader, importer or converter opens a name that does not end in .parquet. List these files in history health on every boot. Keep the day's legacy copy on disk, unimported, while its original is unreadable. It serves reads for the day. Importing it would make a good copy of the original, restored later, conflict with the stored hours and stop boot with "archive differs from stored hour". Tests open the store with the same cold folder for reads and absorb, as main.go does, and cover a restored original. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…damaged (srcfl#1895) * fix(nativeupdate): fall back to the previous release when current is damaged Select always ended on Current, so a release with a missing file failed every start. When Current fails its release check, the previous release is healthy and both receipts name the same state schema, run Previous once as a trial and record Current as LastFailed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(nativeupdate): run the fallback trial and keep failed marks When a pending update failed to exec while current was damaged, the launcher's second Select returned the previous release as a trial, and launch() refused it. The next start then marked that unrun release as failed, so the box stayed in its restart loop. launch() now execs the fallback as a trial with one attempt and records a failure with FailTrial. The fallback no longer overwrites a LastFailed that is already set, such as a trial that just crashed. Updates would otherwise offer that release again. A fallback that dies still marks the previous release, so it runs only once. Log a warning when the launcher falls back, and say in the changeset that boxes need an installer refresh of ftw-launcher to get this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(drivers): retry a driver that fails to start A driver whose Add fails (a serial node not enumerated yet, a device still booting after a power cut) was logged once and forgotten, so a missing P1 meter blocked dispatch until a restart or a settings save. The registry now retries it after 10 s, doubling to 5 min, until it starts, leaves the config, is removed, or the registry shuts down. Running drivers are never torn down, and probes are not retried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(drivers): wait for an in-flight spawn retry in Reload and ShutdownAll add() deletes the retry entry before the driver reaches r.rec, so while a retry ran its name was in neither list Reload checks. A driver the user removed or disabled could then start, or schedule yet another retry. ShutdownAll could also return while the retry was still starting a driver. The retry now marks its name in flight under r.mu for the length of add. Reload sees the mark, takes the name's gate and removes what the retry left. ShutdownAll waits on the gate of each in-flight name before it lists drivers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(drivers): a slow success is a success A command that the device accepted after the caller's deadline was turned into DeadlineExceeded, and the actor then sent driver_default_mode. A driver slower than half the control interval flipped between forced and autonomous mode on every tick. The caller's deadline now bounds only its wait. A started command runs for at least 5 s; an explicit cancel, the lifecycle and SendDefault still stop it, and a failure or timeout still restores the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(drivers): a late wallbox-cycle step keeps its caller's deadline A wallbox-cycle ev_pause that succeeded after the caller's deadline set evPausePending, but the cycle owner had already given up and never sent the resume. Every later cycle pause was then refused as superseded until a default or an operator command cleared the latch. Cycle steps (cycleID != 0) now run under the caller's deadline, as before the slow-success change. A late cycle step fails, restores the default and leaves no pending pause. Other commands keep the 5 s run bound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* test(native): run the whole update chain with real builds TestUpdateChain builds Core twice, installs the previous release with ftw-launcher, updates through FTW_RELEASE_MIRROR, kills the candidate three times inside probation, retries it and takes a binary rollback. Slots, the running version, two settings, a saved charging goal and the history count are checked at each step. It runs only with FTW_UPDATE_CHAIN=1; make update-chain starts it from the last three betas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(native): fail update-chain when there is no previous release Without beta tags (shallow clone or fork) make update-chain passed an empty FTW_UPDATE_CHAIN_FROM, and the test quietly built the previous release from the candidate's own source. Now an empty list fails before any build and says how to fix it. FTW_UPDATE_CHAIN_FROM=tree (or UPDATE_CHAIN_FROM=tree) opts into the tree-only run, and the Makefile passes a caller's FTW_UPDATE_CHAIN_FROM through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A box installed from a beta tag stored no channel, so Core inferred it from the running version. After the box installed the promoted stable, the version had no beta suffix and the box moved to the stable channel without being asked. Save the inferred channel the first time a release build boots with none stored. Dev builds save nothing; SetChannel and the edge migration are unchanged. Item G1-fix of the stable plan. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rcfl#1897) * ci(release): a newer beta no longer blocks promoting a tested beta The native release-order check refused a stable whenever any newer beta was published. Betas ship several times a day, so a candidate that ran on sites for a week could never be promoted. A stable is now refused only when a newer stable exists; betas keep the old rule. AGENTS.md says so and describes the real hotfix path: the workflow runs only from master, so a fix ships as the next beta and promotes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(selfupdate): beta boxes take the highest release after a late stable A stable may now be promoted after newer betas ship, so GitHub lists it above a higher beta. resolveChannel took the first match, so every beta box got the stable and skipped the newest beta until the next beta shipped. The walk now keeps the higher of the first stable and the first beta, and stops at the first beta. The release-order check refuses any beta below a published release, so nothing listed after it can rank higher. Stable boxes still stop at the first stable. Tests: the reviewer's ordering fails on the old code and passes now, a paged list stops at the first beta, and the Python checker pins the rules the walk relies on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…cfl#1886) * fix(control): no SQL in the control tick for curtailment identity PendingDrivers looked up every driver's device identity on each tick, even with nothing curtailed, and each lookup was a context-free SQLite query on the four-connection state pool. Skip the lookups when no curtailed identity is stored, and make LookupDeviceByDriverName read the committed in-memory device cache instead of SQL. This also removes the silent fall back to the driver name in batteryModelKey on a read error. Item A8 of the stable plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): keep f.mu held in PendingDrivers when no identity is stored The previous commit read len(f.ids) under f.mu, dropped the lock, skipped the identity lookups, then took f.mu again. In that gap persist could move an active entry into f.ids, so the driver dropped out of the pending list and its release retry slipped a tick. TestForecastCurtailmentReleaseRequiresEvidence failed in about 3% of -race runs because of it. Now PendingDrivers drops f.mu only when it has identities to look up. Add a race test for the moved entry and a test that the device cache gives the same answer as the old SQL query across swaps, renames and a reopen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RefreshIdentity held forecastSiteConfig.mu across two SaveConfig calls, each up to 5 s against a busy SQLite writer. The control tick's Snapshot waited behind them. Compute under the lock, save after it, and wait 30 s before retrying a failed binding save. Configure's weather generation save moves out of the lock the same way. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…tings Pair fresh per-observation pilot evidence with Core feedback for srcfl#1874. Signed-off-by: Segran <goran@andersson-stehag.se>
…1882) The 5 s missing-plan retry cancelled any solve that took longer, so on a slow box no plan ever landed. Skip the retry while PlanSnapshot().Pending is true. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… first tick (srcfl#1880) * fix(control): supervise the control tick and commit a trial after the first tick A supervisor goroutine watches the time of the last completed tick. When it is older than max(3 x interval, 10 s), every driver gets its default mode in parallel; observe-only drivers are skipped. When it is older than max(60 s, twice that), Core exits 1 so the service restarts it and probation counts the exit. Every path out of a tick records it, including the stale-meter and setup-measurement paths. The first completed tick commits a native trial, instead of the moment HTTP is up. ComputeDispatch and SelfTuneDispatch run under a deferred ctrlMu unlock. Item A7 of the stable plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): measure the tick's age on the monotonic clock The supervisor stored the last tick as wall-clock nanoseconds, so its age followed the wall clock. A Pi without an RTC boots on a stale clock and NTP steps it forward, often while the loop already runs. A forward step of 60 s or more made a healthy Core send defaults and exit; a backward step hid a real stall. The supervisor now keeps a base time from newTickSupervisor and stores each tick as the time since that base, so ages use the monotonic reading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…es (srcfl#1887) The session seed called SetTarget with the vehicles[] profile target, which clears targetTime. A one-off goal is not re-rolled, so the car lost its ready-by time for the whole session. Apply the profile target only when the car has no goal. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
) The steady check had no baseline. A 3 kW load on L1 and solar on L2/L3 read [17, 4.5, 4.5] A next to a three-phase car at 6 A, which it took for a one-phase car. The order then tripled from 4140 W to 11040 W and swung back on the next rise test. Delete the steady check and keep the rise test, which compares against the meter before the rise. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(loadpoint): one unplug reading does not end Charge now The loadpoint cleared a manual hold on the first Connected=false reading, while the cars runtime keeps the session for UnplugGrace (30 s). Clear the hold only after the cable has read out for UnplugGrace, or when the next connected reading names another device or session. Both layers now share loadpoint.UnplugGrace. Item D3 of the stable plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(loadpoint): check a late session id against the one before the unplug rebindAfterUnplug carried the old session id into the binding, but restoreManualHoldForSession ran on the same tick and overwrote it with the empty id the charger reported. A charger that names the session one reading after the cable, as Easee Cloud does, then let a new session inherit Charge now at full power. Keep the last proven session id while the charger names none, so the later proof is compared with it. Add tests for another session and for the same session named one reading late. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rcfl#1896) * fix(mpc): deadline reserve waits for the latest start (srcfl#1718) Without an accepted plan, a saved goal reserved full charger power whatever the slack, so a restart or one failed replan started the car and the next plan paused it. The reserve now acts only once the rest of the goal at full power plus one hourly slot reaches the deadline; a running reserve keeps a second slot so a SoC step does not cycle it. DeadlineNeed checks the passed minW, so a loadpoint with min_charge_w 0 and steps gets the reserve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(mpc): count shared goals in the deadline reserve's latest start (srcfl#1718) Each car's latest start assumed it alone got its full peak. Two goal cars on one EV fuse allowance with no plan then both waited until about need/peak + 1 h, and the fuse budget, which serves the earliest deadline first, could not finish both. A goal's latest start now counts the hours at full power of every saved goal due at or before its deadline. With one car the rule is unchanged; with cars on separate fuse room they only start earlier, as before srcfl#1718. Adds TestDeadlineReserveSharedAllowance (two cars, shared 11 kW, earliest deadline first and even split) and TestDeadlineReserveZeroMinWithSteps, which covers the DeadlineNeed minW change at directive and controller level. Both fail without their fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…fl#1892) * fix(ocpp): run charger callbacks after the slot lock is released boundCall held a charger's slot lock while the BootNotification handler called identityReported, which main.go wires to a SQLite device write. Every write to that charger takes the same lock, so one stalled write blocked the SDK's single request pump, then every charger's commands, and once 20 requests queued, Server.Command past its deadline. Callback work a handler queues now runs after the unlock. The charging profile send runs on its own goroutine, so a command always returns by its context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ocpp): allow one unanswered charging profile per charger attemptLimit sent each profile from a new goroutine that ignored ctx. While the request pump was stalled, every control tick left another profile queued, and once the pump cleared the charger received every obsolete limit, one round trip each, before the current one. A fuse cut waited behind them. A one-place slot per charger now admits a single profile from submission until the library calls back (answer, its own timeout, write error or disconnect), or until the send fails. A command that cannot take the slot before its deadline returns ctx.Err(), so core reads it as unconfirmed, not as a refusal. TestStalledPumpDoesNotReplayObsoleteProfiles fails without this (20 goroutines left behind, 41 profiles delivered) and passes with it. Also adds the reviewer's tests for a re-entrant vehicle callback and for work queued through later during a boundCall. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…for a restart (srcfl#1960) * test(site): drop r3 and r4 known lines fixed on master, give r4 room for a restart A3 and A4 (srcfl#1876, srcfl#1878, srcfl#1916) and E1 (srcfl#1787) merged before the R1-R5 scenarios did, so r3-25 and r4-25 failed every full run since 28 Oct with "passes now: delete its known line". r4 also failed "the day's Home use kept within 1 %" in 1 run of 5. The day may have 10 % unknown time, counted in whole ledger minutes; when the car's unknown reading after the restart crossed a minute boundary, two minutes were marked and the 17 min day reached 12.2 %. r4 now waits 25 min instead of 10 before the hold, as s7 waits 15 min. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(site): r3 judges the measured SoC at the planner floor With its known lines gone, r3's floor criteria are gates, and both read Core's order. A battery handed back to the inverter reads as a 0 W order and would pass while it runs on to the inverter's 5 %. The measured SoC must now stay at or above the floor too. Review of srcfl#1960. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ware 7.x (srcfl#1962) Moves the pin to srcfl/device-drivers@e463c653 (srcfl#209). Only the Zap driver changes. The new tests use the payload a Zap on 7.0.3 sent for a simulated Solis. Signed-off-by: David Mozart <dmozartandraws@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…1964) * fix(config): a battery soc_max of 0 no longer blocks charging Settings saved a blank number field as 0. Since soc_max became the dispatch charge ceiling, a battery whose Max SoC was saved that way never charged, manual Charge included. Core treats soc_max <= 0 as unset, and Settings saves a cleared number field as unset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): leave the shared battery map alone and test the stored path Validate runs on shallow copies of the live config, so the zero drop clones the batteries map before it writes. A new test loads a zero that an older Core stored in the config database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…rcfl#1969) * fix(control): a battery that may not export holds the grid above 0 W In planner_self, planner_cheap and passive arbitrage the reactive path aimed a discharging battery at 0 W with a symmetric dead band. Each drop in house load then sold battery energy until the next correction, about as often as the site imported. While such a battery discharges more than the dead band, the target is now at least grid_tolerance_w, so the band ends at 0 W. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): bias the no-export target on the planned discharge, not the reading The bias keyed on the battery's measured power, so an idle battery under a legacy plan with a negative grid target discharged toward it for one tick and exported. It now applies whenever the plain target would have the battery discharge more than the dead band. Charging from PV surplus no longer sees the bias at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
) The budget sentence still said Core allows 500 ms or 5 s. Core also gives 1.5 s with an active car, skips the call when less than 500 ms of the first slot remain, caps time_limit_s at 5.6 s, and keeps a reply margin besides the 50 ms for validation and publication. State what go/internal/mpc/energyplan.go does today. Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… demand_elapsed_hours (srcfl#1966) With 15-minute prices Core replans at :15, :30 and :45, and bindDemandCharges sent only the tariff hours that start at or after slot 0. The hour already running was left out of the peak-power tariff the planner sees, so a plan could set a new monthly peak in it, and today's completed peak counted twice in the top-N mean. Core now sends the hour in progress with the import measured so far (the site meter's hourly counter, else the history; uncovered time counts at no less than slot 0's forecast import) and puts today's completed peak in today's group. Only a planner whose handshake announces demand_elapsed_hours gets this shape; any other planner, including the bundled one, gets exactly the request it got before. Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…l#1968) Core fixed time_limit_s from the budget at the frozen execution start. With forecast caution, the caution call follows the point call and its deadline counts from the time left in the slot now. After a point call that used its whole limit, the caution call could ask for 1.5 s with about 950 ms left before its deadline, so a worker that uses its full time limit answered after Core had given up, and Core published the point plan. Each call's time_limit_s now fits the time before that call's deadline, minus Core's reply margin, after request preparation. A call with its full budget left keeps it. Below Core's no-call threshold the worker is not started. Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
srcfl#1973) srcfl/device-drivers#203 skips the battery emit when every battery reading is 0, so a PV-only Growatt no longer draws a phantom battery beside the site's real one (srcfl#1669). The pin moves e463c653 to e07d2703; growatt is the only bundled driver that changes. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…srcfl#1972) xorath, 5 Nov 2026: a battery charged 2.3 kW while FTW asked it to discharge 812 W. The box's page said "Not following"; no phone heard. The new control.not_following rule reads the box's own answer to "Are we in control?" (the control_feedback GET /api/status serves) at most every 30 s on the HealthTick, and fires once a device function has been not_following for ten minutes. It fires once until the device follows again, with an hour's cooldown on top. On by default for owners with notifications on (Fredrik, 5 Nov 2026); a box whose stored rules predate the kind takes the default. The words come from the push catalogue. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…1977) verify.py skipped execution only on hosts it does not know. On a known host whose worker the bundle leaves out, such as macOS when only the required Linux workers ship, it failed with a KeyError instead of checking the bundle. It now verifies every file and skips execution on any host without its own worker; --host-binary prints nothing there, so make native-solver-test skips the process tests as before. Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Bundle the worker from srcfl/energyplan 847936a (0.7.0, tag v0.7.0-beta.1) for linux-arm64 and linux-amd64. The macOS worker is no longer bundled; srcfl#1977 lets verify.py check such a bundle and skip execution on a Mac. The worker lists demand_elapsed_hours, so Core (srcfl#1966) sends it the demand hour in progress and the day's completed peak in that day's group, and the native demand test now requires the feature. The contract describes the feature and the demand-charge fields, each call's time limit since srcfl#1968, the batteries' total recovery rule and optional reply diagnostics, and it stays identical to Energyplan's copy. The worker version pins and the README follow. Licenses and forecast schemas are unchanged; the notices list two more microlp modifications. Protocol v1 is unchanged. Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
segran2
marked this pull request as ready for review
November 6, 2026 22:42
miravoss26
reviewed
Nov 7, 2026
miravoss26
left a comment
Contributor
There was a problem hiding this comment.
Adds pilot-aware control feedback: a fresh, confirmed lower EV pilot current now shows as an informational charger_pilot_limit instead of a warning, with pilot_a and dynamic_limit_a exposed separately in the API and UI.
- Needs a look, medium: this PR shows no CI checks at all, so I can't confirm the Go build or tests ran. The new
commanded && cmd.Result == "accepted"guard on the lower-current branch also changes behaviour for the existing load-balancer verdict, not just pilot-aware drivers. Worth a green run before merge. - Behaviour change, by design: for a pilot-aware driver whose pilot is stale or zero,
d.LimitA/LimitedByare cleared, so a previously explained load-balancer limit falls back topower_below_target(warning). The tests cover this, just be sure that's what you want when the pilot goes stale for 90 s. - Correctness otherwise fine:
pilotReadingFreshguards nil, NaN/Inf, negatives, future confirmation and source-after-confirmation. The*d.PilotA == 0deref only runs afterpilotFreshor a nil short-circuit, so no nil panic. Tests cover the failure and pending-command cases. - Dependency: only useful with srcfl/device-drivers#200 (Easee 1.5.0). Old drivers keep the previous contract.
- Security screen: no secrets, no new hosts, no dependencies, no authz changes. JS adds only static strings.
Safe to merge from my read once CI has actually run.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
# Conflicts: # go/internal/control/sitesim_field_test.go
A fresh pilot reads as charger_pilot_limit only when it is 0.5 A below the order and within 0.5 A of the limit that explains the draw. A valid pilot past its 90 s window becomes unknown and leaves the driver's limit standing, so a late Easee cloud read no longer turns load balancing into a warning. The charger's own limit still explains a draw when a fresh pilot comes without a current limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Member
|
Maintainer commits on top (77d15b3, after merging current master; the
Tests for each case; each fails without its fix. These verdicts will be recorded by the new control journal, so a wrong cause would be stored as fact. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When Easee reads back a 15 A dynamic current setting but signals a 13 A pilot, FTW must distinguish the setting from the current actually offered to the car. This extension to #1537 adds fresh pilot evidence and displays the values separately for #1874.
Change
offered_acompatible but stop labelling it as an actual charger offer.charger_pilot_limit/info. A matching known static charger limit is also informational.Dependencies and scope
Stacked on #1537 at
84a3aada1c46a38b8a067c50a6577a64d4a57a73, targeting its existingeasee-load-balancer-limitedbranch. Paired driver extension: srcfl/device-drivers#200 (itself stacked on srcfl/device-drivers#194).Do not merge this stack directly past those dependencies. The generated driver snapshot is untouched; after driver integration a separate pin/snapshot update is required. The app that vendors
web/control-feedback.jsneeds the matching module. No battery diagnostics, native updater overlay or EV charge-cost changes are included.Related to #1874. Keep the issue open until the dependency stack and release integration are complete.
Validation
Exact stacked PR tree:
make verify: passed (Go tests, vet, build and repository checks). Docker-context and live Avahi checks skipped because Docker was unavailable.npm test: 887 passed, 0 failed.The same pilot extension integrated with official v0.157.2-beta.1 and prior local patches passed a separate full verification and 895 web tests before deployment. That deployed combination is not this older stacked base.
Live paired evidence, 27 October 2026, local v0.157.2-beta.1+local.2, Easee cloud charger and Audi e-tron:
limited/load_balancer_limit/info. The real EV sheet rendered “Limited by the charger’s load balancing” with separate 15 A setting and 13 A pilot, without a red warning.no_power_responseafter the response wait. Initial transientfollowing/power_observedwhile 0 W was still present is a separate observed transition issue, not fixed or treated as charging proof here.One-site hardware evidence does not establish other makes/firmware, precise load-balancer causality or all failure/recovery transitions. Charger model/firmware identifiers were not captured. Rendered browser checks and live screenshots were inspected locally; no raw household logs, private addresses or device identifiers are added to the repository. Keep draft pending dependency integration and CI/review.