Skip to content

fix(feedback): distinguish fresh EV pilot limits from current settings - #1905

Merged
frahlg merged 86 commits into
srcfl:easee-load-balancer-limitedfrom
segran2:fix/confirmed-pilot-feedback-1874
Nov 8, 2026
Merged

frahlg merged 86 commits into
srcfl:easee-load-balancer-limitedfrom
segran2:fix/confirmed-pilot-feedback-1874

Conversation

@segran2

@segran2 segran2 commented Oct 27, 2026

Copy link
Copy Markdown
Contributor

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

  • Export actual pilot current, its source timestamp and confirmation time separately from the dynamic setting. Keep legacy offered_a compatible but stop labelling it as an actual charger offer.
  • A pilot-aware driver needs finite, nonnegative and freshly confirmed evidence (at most 90 seconds old, no future confirmation/source). Invalid or zero pilot cannot explain an ongoing charging shortfall. Globally stale rows clear the new fields.
  • When fresh positive pilot evidence explains the measured delivery, retain informational load-balancing limits; without a proven internal cause use charger_pilot_limit/info. A matching known static charger limit is also informational.
  • Preserve failed/pending/unconfirmed command precedence and warnings for no delivery or genuine delivery below the pilot. Legacy driver behavior remains compatible.
  • Add API and rendered-text regressions plus a patch changeset.

Dependencies and scope

Stacked on #1537 at 84a3aada1c46a38b8a067c50a6577a64d4a57a73, targeting its existing easee-load-balancer-limited branch. 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.js needs 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.
  • Added API regressions cover known/unknown/static pilot limits, stale/future/missing evidence, zero pilot, no delivery, genuine underperformance and command-state precedence.
  • Git whitespace and tree checks passed.

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:

  • 15 A requested / 15 A pilot / approximately 10.46 kW: Following FTW.
  • 15 A request and dynamic setting / 13 A pilot and circuit allocation / approximately 8.93 kW: six consecutive settled samples over approximately 54 seconds classified 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.
  • Releasing the temporary hold restored plan, 0 A pilot and 0 W. Durable goals/protection settings were unchanged.
  • An earlier test with the car already at its own limit delivered 0 W and retained no_power_response after the response wait. Initial transient following/power_observed while 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.

frahlg and others added 29 commits October 25, 2026 19:09
* 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>
frahlg and others added 17 commits November 4, 2026 11:23
…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
segran2 marked this pull request as ready for review November 6, 2026 22:42

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/LimitedBy are cleared, so a previously explained load-balancer limit falls back to power_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: pilotReadingFresh guards nil, NaN/Inf, negatives, future confirmation and source-after-confirmation. The *d.PilotA == 0 deref only runs after pilotFresh or 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.

frahlg and others added 3 commits November 8, 2026 06:54
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>
@frahlg

frahlg commented Nov 8, 2026

Copy link
Copy Markdown
Member

Maintainer commits on top (77d15b3, after merging current master; the sitesim_field_test.go conflict keeps master's probe/car2300W cases and simFuseSafetyA, plus this PR's load-balancer case and cap):

  • charger_pilot_limit now needs the pilot below the order (pilot_a + 0.5 < requested_a) and within 0.5 A of current_limit_a. A pilot of 20 A against a 15 A order no longer reads as the charger's limit; it falls to offered_current_lower.
  • A valid pilot older than 90 s drops only the pilot and keeps the driver's limit and cause, as for a 1.4.1 driver. easee_cloud re-reads REST once a minute, so a late read can pass 90 s; before, that turned an explained load_balancer_limit (info) into power_below_target (warning).
  • device_limit is skipped only when both a fresh pilot and current_limit_a are present.

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.

@frahlg
frahlg merged commit 6cb20ab into srcfl:easee-load-balancer-limited Nov 8, 2026
1 check passed
frahlg added a commit that referenced this pull request Nov 8, 2026
…er charge (#1537, #1905)

Lands #1537 (Easee load balancer limit) and #1905 (pilot evidence, #1874), with the review fixes from 8 Nov 2026.

Co-authored-by: segran2 <goran@andersson-stehag.se>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants