Skip to content

chore(feedback): integrate current master into #1537 - #1823

Closed
segran2 wants to merge 44 commits into
srcfl:easee-load-balancer-limitedfrom
segran2:integrate/load-balancer-current-master
Closed

segran2 wants to merge 44 commits into
srcfl:easee-load-balancer-limitedfrom
segran2:integrate/load-balancer-current-master

Conversation

@segran2

@segran2 segran2 commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

#1537 conflicts with current master, blocking dependent #1762. This is a proposed integration into #1537's existing branch, not a replacement for #1537 or a separate feature PR.

Based on #1537 at b6d93cd, merge master 2d7c8eb in signed integration commit e225339. Preserve all earlier commits and authors. The contributor cannot push to the upstream dependency branch, so this draft supplies a concrete integration for its maintainer.

Resolve feedback conflicts by preserving both #1537's named load-balancer explanation and master's one-phase/hourly-import explanations, alarm escalation and response-wait constants. No Core dispatch, fuse/load protection or waiting times change. #1762's site-headroom projection and battery diagnostic accessor are deliberately absent.

Advance the driver pin to the combined device-drivers#183 head a7b6a3d873f7e5e1fdf63d8a80e576aa1f858754. It contains #172's required reporting plus the existing published 1.3.8 start-boost safeguards and #183's source-ordering checks, candidate version 1.3.9. The Core driver snapshot is generated; no Lua was hand-edited here. Driver #172/#183 still need review and merge; use their final merged commit for the release pin. Do not merge this stack independently or publish this draft as a release.

The PR diff against the older feature base includes inherited master changes. Against master 2d7c8e, only six files differ: the original #1537 feedback code/tests/changeset and the updated pin (81 additions, 4 deletions). The integration does not adopt #1789's separate fuse-policy work.

Validation on the integrated tree:

  • Full make verify passed: Go suites, script checks, native worker verification, vet and build.
  • 878 web tests passed; zero failed/skipped.
  • Focused API race tests passed for control feedback, named load-balancer behavior and control status.
  • Diff whitespace check against current master passed; clean working tree.
  • Docker-context and live Avahi checks were skipped because Docker was unavailable.
  • No full GitHub CI acceptance is claimed for this feature-branch target.

The one-phase and hourly-import renderer paths are identical to the integrated #1762 version the owner visually accepted on 25 October 2026. That five-case acceptance does not cover the named load-balancer card; retain #1537's outstanding human/affected-hardware acceptance. No installed FTW, history, control setting or goal was changed. No reviewers are automatically requested.

Once this integration is accepted into #1537, refresh the final driver pin and run full master-target CI, then resolve #1762's remaining dependency and client-copy steps.

frahlg-agents Bot and others added 30 commits October 21, 2026 08:19
…two series (srcfl#1768)

* fix(live): Home use from the site meter, quality separate from history

* fix(live): darkness stands in only for unread PV, not malformed readings

* fix(live): show the balance as is; log display load apart from training load; HA energy-average sensor

* fix(live): leave duplicated flows out of the live balance

---------

Co-authored-by: frahlg-agents[bot] <frahlg-agents[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…1671)

On 13 October 2026 systemd stopped Core on the home box mid-backup. Its
raw copy stayed in /dev/shm (1.37 GB), so on 16 October the next copy went
to the SD card and ran past the two-hour limit.

A backup now holds a shared flock on each folder where it keeps scratch.
At start Core takes that lock exclusively without waiting. When it gets
it, no backup is running there, so it removes ftw-backup-<n>.raw and its
journal from /dev/shm, and stage folders, verification folders and
unfinished archives from the backups folder. It logs each path and size.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The home box's backup on 16 October 2026 stopped at the two-hour limit
for a live copy and logged nothing. Its raw copy had fallen back to the
SD card because a backup cut short on 13 October left 1.37 GB in
/dev/shm.

Core now logs when a backup starts, ends and fails, with the phase,
table and rows it reached. A backup stopped by the limit names it.
GET /api/backups keeps the failed run's table and rows, and a new run
clears the old record. ftw backup prints the last failure before it
starts. A raw copy that does not fit in /dev/shm logs a warning with the
space earlier backups' scratch files still take there.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(state): expose longest failure phase step and count

* Time archive failure phases before cleanup
* fix(drivers): restart a driver that stays offline

A driver that went offline or failed to start stayed down until a config
reload or a manual restart. The watchdog retries with backoff, and leaves
disabled, observe-only, waiting push, and running cloud drivers alone.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* fix(drivers): skip a restart when config changed under the gate

The watchdog copied the driver list, dropped the config lock, and could
then start that copy after a reload had already disabled or edited the
driver. It now re-reads the installed config after the lifecycle gate
and leaves a disabled, observe-only, or changed driver to reload.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
…nnect (srcfl#1615)

* fix(backup): cancel full backup when HTTP client exits

* fix(backup): cancel full archive inspection with its caller
…" (srcfl#1771)

This reverts commit b271a76.

srcfl#1628 has not shipped. Review found that it breaks the driver offline
alert and restarts sleeping devices without limit:

- A restart calls tel.Remove, which deletes the driver's health. The
  notifier skips a driver that has never succeeded, so the "driver
  offline" push never fires for a device that stays down.
- Backoff ends at every 5 minutes with no cap. An inverter that sleeps
  at night is torn down and started again all night, and each restart
  sends driver_default_mode to a dead device and clears its readings.
- A failed add of a cloud driver runs driver_init every 5 minutes.

The problem it set out to fix stays real: a driver that fails to start
at boot is never retried. A smaller change will retry only that case.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

/api/status always sent load_estimate, an energy-averaged Home use, and
the energy flow treated its presence as "estimated". Every box therefore
showed "≈" on Home use and never showed "% self-powered now", even with
every device read. The page now reads Core's load_quality only.

HomeUseEstimate came in on 18 October as the live Home use and was left
behind when srcfl#1768 made Home use the plain balance. It fed only
load_estimate and a second Home Assistant sensor that has not shipped.
Remove it, its observation buffers in the telemetry store and the HA
sensor, so Core has one Home use value.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* test(control): closed-loop site simulation with a real fuse

New package go/internal/sitesim: a house behind a gG main fuse, stepped
one second at a time on a pinned clock. House load per phase, PV, a
battery with latency, an Easee-like charger with command delay, readback
lag and an optional load balancer, a car, a site meter that may drop the
sign or go silent, and a timeline of restarts and outages.

go/internal/control/sitesim_test.go runs the real loadpoint controller,
EVFuseBudget and ComputeDispatch against it in main.go's order and checks
three invariants every second: the fuse holds and Core's own limit breaks
only for the response time plus 3 s; Charge now with room charges within
a minute; a meter older than 60 s raises nothing.

Scenarios that fail on master are skipped with the finding, so each fix
deletes one line. No production code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): known site scenarios run as expected failures

Review of srcfl#1775:

- Readings carry the plant's clock for SoC times too. store.Update stamps
  SoCUpdatedAt with the wall clock, so the battery-full pause worked only
  when plant time was near wall time. TestSiteSimIgnoresWallClock runs the
  day across the wall clock and a day either side; every command matches.
- Known rows run in CI: each must keep failing, and fails once it passes
  until its fix deletes the known line.
- Invariant (a) is split. A phase over the fuse less its margin after a
  raise we sent may last 3 s; an outside change gets the fastest lever's
  response time plus 3 s. The 60 s charger now fails as known, and a new
  known row has the battery stop at its 5 % floor under Charge now. The
  day's battery is 20 kWh so it lasts the evening.
- The srcfl#1761 replay checks it reached the field state before the car takes
  its offer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ers (srcfl#1774)

* fix(loadpoint): one phase choice for the fuse budget and the command

The loadpoint decided the phase mode twice per tick. The fuse budget
passed an empty surplus dwell and treated a saved battery-SoC unlock as
active; the command used the dwell and the armed state. When they
differed, the budget counted the last offer as 0 and measured new room
on one phase, every tick. The cap then snapped with the config minimum,
so a one-phase-sized cap went out labelled 3p.

chooseTick now steps the unlock, picks the surplus steps and resolves
the phase mode once per loadpoint, before the budget freezes. The budget
and the command read that value. The cap snaps to the smallest legal
step on the commanded phases (6 A per phase). phaseModeForFuse and
tickFusePhases are removed.

Charge now with an unarmed unlock on a 25 A site: 4,830 / 0 / 0 W ->
11,040 W. Surplus at 9 kW with an empty or auto phase mode: 2,300-3,450 W
labelled 3p -> 8,970 W on 3p. Charge now on a one-phase site:
4,830 / 0 / 0 W -> 4,830 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* fix(loadpoint): the site-meter limit is the only fuse guard for chargers

Fredrik's direction (21 Oct 2026): only measured site-meter current counts.
The loadpoint kept a second fuse layer on top of control.EVFuseBudget: its
own per-phase room from the meter, the last offer per phase mode as a
base, and a two-tick wait after a pause. It decided phases apart from the
command, which caused R1 and R2.

Remove that layer: offer peaks, sharedPhaseIncreaseW, fuseResumeTicks and
the fuse_cooldown reason. freezeFuseBudget now splits the control layer's
cap between chargers by priority, snapped to legal steps on each command's
phases.

EVFuseBudget counted phases from the config and relied on the removed
layer for runtime auto or 1p offers. main.go now passes FuseConfigs: the
configs with the phase mode each command names this tick, so the cap is
measured on those phases. Without it, an auto hold at 20 A of house load
got 2,070 W and put 29 A on one phase of a 25 A fuse.

Tests that only pinned the removed layer are deleted. SetPerPhaseMeterAmps
is a no-op until the last test caller, in a file another branch is
editing, is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* fix(loadpoint): an empty phase mode keeps the charger's own minimum

The 6 A-per-phase floor treated an empty phase mode as 3p. A one-phase
charger left on empty with a 3,680 W maximum then had a 4,140 W floor and
never charged. An empty mode names no phases; the charger decides, so its
configured minimum stands. Only an explicit 3p needs 6 A on each phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

* test(control): the site simulation measures room on the commanded phases

The adapter passes FuseConfigs, as main.go does, and drops the no-op
per-phase reader. Three known rows now pass and lose their known line:
phase mode empty and auto with 9 kW surplus, and Charge now with a saved
but unarmed surplus_unlock_bat_soc. The auto row needs FuseConfigs; with
the configured "auto" the cap counts one phase.

The export rows still fail on control's own unsigned phase current; their
known line no longer cites the deleted loadpoint layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>

---------

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…hem (srcfl#1776)

The EV budget counted watts and trusted the meter before a charger could
have applied a command. A slow cloud charger stacked rises (25-28 A on a
25 A fuse at a 60-120 s delay), a 3p to 1p switch at the same watts put
36 A on one phase, and a raised car took a second car's share before its
cut landed (27 A).

Fuse protection now reads the measured current per phase plus our own
commands that cannot have shown yet. Each loadpoint logs its offers with
the current per phase its command names (State.CommandedPhases). Against
a reading, a charger's pending current is the highest offer sent since
its response time before the reading, less the lowest the reading may
show: the longer of 20 s and its declared power_max_age_s, or 180 s when
it declares none. A level set before that window is in the reading, so a
cut needs no reservation. EVFusePhaseRoomA gives the split between cars
the room left on the busiest phase; a car keeps its credited current and
takes a share of that room in priority order. Cuts read the meter alone
and stay immediate. The sitesim adapter wires the room as main.go does;
slow cloud charger 60 s and 120 s and the battery floor case now pass.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
When an inverter stopped answering, Core kept its last PV and battery
power and still called Home use measured until the watchdog took the
driver offline. In the simulator run of 21 October this showed 4,140 W
measured (true 2,070 W) and "50% self-powered now" with every watt from
the grid. A driver in fault counts as live, so only the age limit
stopped it.

A flow now counts as measured only while its driver's latest read
succeeded. After a failed read its last value is still shown until the
age limit, but Home use is estimated with read_failed:<driver>:<kind>.
ConsecutiveErrors already says this: failed polls raise it, a fresh
emit resets it, and refused commands never touch it.

RecordError also lifted an offline driver to degraded on every failed
poll, so its held readings came back until the next watchdog scan, which
then took it offline and sent its default mode again. Home use flipped
every few seconds between 63 and 87 s. An offline driver now stays
offline until it delivers data.

History and forecast qualification are unchanged: the new reason only
reaches live Home use quality.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
* fix(ocpp): an Available charger is a measured 0 W

An OCPP charger with no car sends no MeterValues, so its power stayed
unknown and live Home use was marked estimated on most OCPP sites; the
day's Home use in history was incomplete for the same reason.

Available means no vehicle, so no current flows. The status now records
a known 0 W in forecast_power, and each heartbeat confirms it while the
status holds on a live socket. A newer meter sample still wins; one
from before the status cannot bring back the car's draw. Unavailable
and Faulted stay unknown. Preparing, Suspended and Finishing keep the
last sample, which ages out as before.

The meter-time gate moves to its own field so a heartbeat on Core's
clock cannot reject the next session's samples from a charger whose
clock runs behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ocpp): connector 0 Available is not a cable status

In OCPP 1.6 connector 0 is the charge point itself. Its Available, sent
at boot and on reconnect, said the whole charger had no car: it set
connected=false and cleared power, and with the held 0 W it would also
have claimed a measured zero while connector 1 charged. It now changes
neither. Unavailable and Faulted on connector 0 still cover the charger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ocpp): hold only a zero, and only while Available

Review of srcfl#1779:
- A heartbeat re-stamped whatever the meter last read. It now confirms
  only a 0 W reading; a higher reading ages out.
- The gate against late meter samples used Core's clock, so a charger
  whose clock runs behind lost the next session's first samples. It
  now uses the status time the charger sent, and stays put without one.
- Leaving Available (any other status, or a transaction other than an
  Idle 2.0.1 update) drops the 0 W to unknown at once instead of letting
  it age for up to 90 s while the car draws.
- Any message from the charger confirms the zero, not only heartbeats.
  Boot, a new socket and messages from the car itself do not.

A test pins that the chart keeps the source rows' quality when the
time-aligned balance reports time_skew for an ageing zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ocpp): ask chargers for a heartbeat every 30 s by default

An idle charger often sends nothing but heartbeats. The watchdog and the
history source rows both allow 60 s, so a 60 s heartbeat left no margin:
one late beat marked the charger offline and its Available 0 W stale,
and training dropped about half of all quarter-hours at 100 ms jitter.

Only the default changes. An unset ocpp.heartbeat_interval_s stays unset
through a save; an explicit value, 60 included, is kept. Traffic: one
more small Heartbeat request and reply a minute per charger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A restart restored Charge now as a pause. The saved choice needs proof
that the same car is still on the charger, and the restore asked for a
session ID that most chargers never report (OCPP and every bundled
driver except easee_cloud). It answered "unconfirmed" and paused.

A saved choice without a session ID is now checked by hardware identity
alone; an observed unplug still ends it. A record with a session ID
waits for the charger to report one instead of pausing, and a socket
reconnect during that wait no longer saves a pause over it. A different
session still pauses as before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…l#1786)

* feat(ha): use Home Assistant ingress authentication for the web UI

* fix(ha): preserve the vendor callback origin policy in ingress
…e_limit (srcfl#1783)

* fix(control): the hourly import ceiling reports import_limit, not fuse_limit

Core folded the clock hour's import ceiling into the EV cap and the
battery allocator under the fuse's name. A car held back by the hour's
budget said fuse_limit, a boost it stopped said "Fuse protection", and
the web page and apps blamed the main fuse while the phases were low.

EVFuseBudget now records which limit left less room at each site
reading, and EVLimitReason names it. The loadpoint reads it once per
tick with the cap (SetFuseLimitReason) and labels a capped command
import_limit or fuse_limit. The allocator marks FuseSaturatedByImport
when the hour's limit is the ceiling it hit, and the boost stops with
import_limit_block. fuse_limit now means the main fuse alone; the web
page says so and gives the hour's limit its own words.

The site simulation's new row is a night with a 6 kW hourly ceiling, a
heavy first 50 minutes, a Core restart at 02:09 and phases at 1.5 A
near the end of the hour. On master the idle car says fuse_limit; now
it says import_limit and starts within 16 s of the new hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(control): an idle car held by the hour's start test says import_limit

Review of srcfl#1783 (Codex): after a quiet part of the hour, the hour's limits
can sit above what the fuse allows, so the label compared them and said
fuse_limit. An idle car is then kept off by the ceiling's own start test,
which caps the start level at the ceiling itself. Under a measured hour
the idle start now names the limit by that test: the start level against
this reading, and import_limit when the dwell's mean import fails it.

TestImportCeilingStartTestSaysImportLimit: 40 quiet minutes under a
7.5 kW ceiling, then an 8 kW house and a car plugged in. It said
fuse_limit; it says import_limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): a night under an hourly ceiling never says fuse_limit

Review of srcfl#1783: the field case as a test. A 7.5 kW hourly ceiling, a
25 A fuse, a light first half hour and a car plugged in at 22:40 beside
6 kW, and 8 kW, of house load. Every tick to 23:00 is checked; on
cb55a26 it said fuse_limit from 22:40:01, with 1a79330 it says
import_limit throughout.

The site simulation's last-quarter rule now states its 15 minutes
instead of copying importHourRiseMinLeft, so a wrong constant shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…l#1790)

ocpp-go's server closes a socket that has sent nothing for PingWait,
60 s by default, and resets the timer only after a handled message or a
WebSocket ping. An idle charger that sends only heartbeats every 60 s
lands on that edge: the end-to-end simulator run on 21 October saw the
socket closed 20 ms after the first heartbeat arrived, in 2 of 3 runs,
followed by a reconnect and a few seconds of "≈" on Home use.

Set PingWait to five heartbeat intervals (150 s with the default 30 s).
The telemetry watchdog still decides when a quiet charger is offline.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…han it supports (srcfl#1791)

When Core sends phase_mode 1p to a loadpoint that can run three phases,
max_amps_per_phase was the site fuse. The surplus 1p lock hands back the
whole step table, so a 16 A charger on a 25 A fuse got 18-25 A on one
phase (simulatorprov 21 Oct 2026, case s5s).

The order's per-phase limit now stops at the most the charger is known to
take on one phase: max_charge_w over three phases, or the phase split,
whichever is larger. It only ever lowers the limit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…it (srcfl#1793)

srcfl#1776 counts an EV order as flowing until the charger can have applied
it, using the highest order sent within the charger's response time.
That included orders a later cut had already replaced. With Easee
Cloud's declared 180 s, a charge at 10,350 W, a 25 s pause and a
4,140 W request kept 10,350 W reserved: the restart waited 2 min 47 s
with fuse_limit while the phases read about 1.3 A. Reported on a real
Easee/Pixii site by segran2 in srcfl#1763, reproduced in evMeterSite.

The charger follows its latest order, and the meter shows its draw
until a cut lands, so a cut ends the reservation of the higher orders
before it. A run of rises still counts its highest, which is its
latest, so stacked rises stay covered. The restart now comes after
12 s.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…otection (srcfl#1800)

* test(control): field matrix for EV charging and fuse protection

v0.155.0-beta.1 passed our tests and failed on field sites the same
night. We tested our defaults: fixed 3p, no step table, planned charging
stubbed, and chargers that applied an order at once or exactly when they
declared. Easee Cloud applies in 30-60 s and declares 180 s.

TestSiteSimFieldMatrix runs Core's EV charging and fuse protection in the
closed loop against 314 cases: the field reports (the help report of
22 Oct, the site in srcfl#1763, the home box) with every charger timing,
a sweep over fuse, phase mode, step table, car phases and house load, and
a timing sweep over every request. Planned charging now runs through the
real controller: a minimal planner sends the slot's Wh and on-power, from
the steps main.go gives the planner.

Each case runs in a synctest bubble, so command evidence, the API's
verdict and live Home use run on the plant's clock. Invariants, every
second: the fuse holds; FTW's limit as before; no fuse cut while every
phase is under the limit; no second cut while an unapplied cut covers the
overage; a planned charge delivers 90 % of what the fuse allows; planned
charging and Charge now never put a three-phase charger on one phase; a
charger drawing against its order reads not following; Home use stays
measured while every device reads.

testdata/sitesim_field_known.txt lists the rows that fail on master and
the fix that turns each green. The sim gains the Easee driver's auto
split and phase hold, and outside steps no longer count as our raise.
The matrix skips -race builds: it finds nothing there and runs 15 times
slower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): the write mode logs what it would fail

* test(control): probe rows from the three-phase review

Twenty-four rows from srcfl#1799's review and fix D: a charger whose
table holds only one-phase levels (max 5,520 W, auto), phase mode unset
with a goal and a 2,300 W plan slot, surplus-only with a goal and 2.5 kW
of export, a held 1p order raised to 3p beside a phase at 20 A, the help
report's site with a one-phase car, and a long L1 step under a
three-phase car.

Surplus cases get the planner's PV forecast as pickSurplusSteps reads it,
so the one-phase rule is judged as Core makes it: one phase only when the
next 30 min stay below three phases at 6 A. Surplus cuts follow the
export and are no longer judged as fuse cuts. The trace also logs a
change of reason.

New label hold3p: the app sends Charge now as 3p for an auto loadpoint,
and a charger whose table stops below 4,140 W gets 0 W.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… cuts a running order (srcfl#1799)

* fix(loadpoint): planned charging runs on three phases, and room never cuts a running order

Field report, v0.155.0-beta.1, 3x20 A, Easee, phase_mode auto, a step
table with one-phase levels: a three-phase car charged on one phase at
about 3.3 kW all night, cut to 1,600 W and 0 W with every phase under
its limit.

Planned charging and Charge now run on three phases (Fredrik, 22 Oct
2026). auto is 3p; only surplus charging without a schedule may use one
phase (day lock, dwell, then auto). An unset mode stays the driver's
default. The planner gets explicit steps at or above 6 A on the car's
phases, or the three-phase ladder; LimitPower's minimum is the lowest
step. The controller snaps the plan to steps legal on its command's
phases.

The split between cars kept no credit for a one-phase order once the
site cap passed the split, and cut it to the room on the car's own
phase. Room now limits rises only: the current order is kept when the
order it allows lands on as many phases or more. Only a measured
overage cuts.

The closed-loop sim splits auto as the Easee driver does and replays
the night: master 1.92 kWh in 40 min with 5 cuts with room, now 6.79
kWh on three phases with none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loadpoint): review findings on three-phase charging and the kept order

A1: the split keeps the current order only when the order it allows
lands on the same phases. 5,000 W moved from one phase to three adds
7.2 A to two phases that carried none of it.

B1: a solar-only loadpoint with a goal charges from surplus, so its
schedule no longer forces three phases; 2.5 kW of surplus goes out on
one phase again instead of an order Easee turns into 3.3 A.

B2, B4: threePhaseCapable decides from the step table, or the maximum,
whether the charger reaches 6 A on three phases. If not, it is a
one-phase charger: its steps and orders stay one-phase. If so, unset
means auto: three phases, with orders raised to the 3p minimum, never
a 2,300 W unset order Easee runs at 3.3 A.

Each review probe is a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): field rows after three-phase charging

srcfl#1799 turns 192 B rows, 20 A rows and one srcfl#1795 row green. It makes 35
one-phase-car rows with planned charging fail on energy, verdict or a
second cut: a one-phase car takes a third of a three-phase order, which
srcfl#1801 (D) handles. One 32 A one-phase charger probe now shows the second
cut of the netting row class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ing (srcfl#1795)

* fix(feedback): a charger that draws against FTW's order is not following

A Core limit returned "limited" before the charger's readings were
compared with the order. In 0.155.0 an Easee Cloud charger went on
drawing 3.1 kW after FTW cut it to 0 W for the main fuse, and the
verdict stayed "Limited by the main fuse". A limit explains a lower
order, not a draw that differs from it. A settled mismatch now goes to
the normal comparison: drawing above the order is power_while_idle or
power_above_target, a warning that becomes an alarm after ten more
minutes; drawing below it is power_below_target, a warning, as under
a plan order.

Command evidence kept the last eight orders. Core sends a charger its
order on every tick, so the two-minute response window shrank to 16 to
40 s and a slow charger was judged on an order it could not have
applied yet. A repeat of the last order is no longer stored again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(feedback): the overdraw alarm counts only time drawing above the order

PowerMismatchSince covers both directions. A car that drew 3 kW against
a 7 kW order for 13 minutes and then rose to 9 kW was an alarm on the
first reading above the order. Command evidence now keeps the start of
the run above every command in force (PowerOverSince), and the alarm
waits two minutes plus ten from there.

Tests: a cut obeyed at 175 s stays Limited; under then over warns at
once and alarms after 12 minutes of overdraw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): delete the field rows srcfl#1795 turns green

On master with srcfl#1799: all 28 rows labelled srcfl#1795 pass, and 16 D rows whose
only failure was the verdict now read not following. No row fails that
did not before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…cfl#1802)

* fix(control): a cut on its way covers the overage it was sent for

Easee Cloud applies a cut after 30-120 s, but the EV fuse cap took each
cut from the highest offer of the last 20 s. Once that window passed, the
meter still showed the old draw and Core cut again: in the field matrix
10,400 -> 6,900 W, then 0 W after a 6 A kettle step. The same window
missed a raise still on its way, so a cut could leave the phase over the
limit once the raise landed.

The cut now counts our own orders that cannot have shown yet, both ways,
from the per-charger, per-phase log of srcfl#1776/srcfl#1793. A cut on its way
covers the overage it was sent for until its charger can have applied it
(max(20 s, declared power_max_age_s), 180 s undeclared); Core cuts only
what it does not cover. A raise on its way adds to the cut. A cut and
every order after it count as not shown; of the orders before it, a raise
counts from the lowest. An offer found in force at a restart, which Core
did not send in this run, counts as shown. Once a phase's filtered
current passes 1.25 x In, nothing on its way holds back a cut. Over the
fuse the cap never rises above the latest offers. Rises, starts and the
hour's import ceiling are unchanged.

Field matrix: 93 of 99 netting rows pass. The other six are cut by the
split, which floors 7,600 W to 6,900 W (7600/690*690 = 7599.999...);

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(control): a cut covers only its own run, on known phases

Review of the first commit found three ways a cut on its way could cover
more than it removes:

- A cut that landed covered a later step on the same phase for the
  charger's whole response time: 180 s for a fast charger that declares
  nothing, such as OCPP. A cut now covers only within the run over the
  limit it was sent in. The run ends once every phase has read under the
  working limit for evSettle; a short dip, as battery relief gives, does
  not end it. Probe: 2 min 35 s over the limit becomes 22 s, as master.
- A car that draws less than its order retried each cut after 180 s
  (10 min 36 s over the limit in the review's case). Once the run's first
  cut has outlasted its charger's response time, cuts cover for evSettle,
  as before: 5 min 16 s.
- A change of phases read as a cut in amps per phase: 15 A on one phase
  to 6 A on three covered 9 A on every phase. Orders now carry their phase
  count; across a change the latest order counts in full as a raise. A
  single-phase cut covers no phase when other chargers share the pool.

Without phase currents Core cannot see the hottest phase, so there a cut
covers for evSettle, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): field rows after the cut netting on master 5ae590a

FTW_FIELD_KNOWN=write removes 99 over-cut netting, 1 limit netting and
4 over-cut D rows and adds none. 71 of 338 cases still fail, all listed
on master: energy 40 (39 D, 1 A), idle 25 (export, readback, hold3p),
own-raise 6 (relief).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…nned and ordered as one (srcfl#1801)

* fix(loadpoint): a one-phase car on a three-phase charger is seen, planned and ordered as one

A one-phase car draws a three-phase order's current per phase on one
phase. On the field table the planner got [0, 4800], the car took
1,600 W of a 4,800 W order, and the feedback said not following.

The loadpoint now tests each rise of its three-phase order on the site
meter: one phase rising by about the order's rise, the other two still,
for 30 s within the charger's response time, while the charger's own
reading shows that draw. That marks the session's car one-phase
(State.CarPhases, the field a car profile can set later); a rise on all
three phases clears it, and unplugging resets it. A house load does not
follow the orders, and a two-phase car moves two phases, so neither is
marked.

Marked, the planner gets the car's one-phase levels, 6 A up to the
charger's current per phase. The charger stays on three phases and the
order is the plan's power times three, so its current per phase is what
the car should take. The command carries expected_w, a third of the
order, and command evidence compares the car with that. The verdict is
Limited, "Charging on one phase, at most 3.7 kW". The fuse limit is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loadpoint): one-phase car tests on fast and undeclared chargers; field rows

The field matrix showed two gaps. A charger that applies in 2 s has
already taken a raise by the next reading, and a fuse ramp climbs 1 A at
a time, so a test's baseline must be the meter read before the first
raise since the order it measures from. A charger that declares no
response time is now given 3 minutes, as the fuse limit counts it, not
one.

Field matrix on srcfl#1799: 55 D rows, 28 srcfl#1795 rows and one A row turn
green. Every one-phase-car row now meets its energy, verdict and
one-phase invariants. 25 of them still cut twice when a house step on
the car's phase meets the second cut of the netting class; they are
labelled netting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loadpoint): one-phase car check compares changes, guards a clear, and survives a restart

Review findings on srcfl#1801:

M1: the check compared the charger's whole draw with the meter's rise,
so only a rise from about 0 A could mark a car. It now compares the
change in the charger's draw since the baseline.

M2: a three-phase verdict had no draw check, so a heat pump starting at
2 A a phase during a 2 A rise cleared a correct mark. It now needs the
charger's draw to have risen by about the three phases' sum.

M3: the first reading of a session had a zero baseline, and the mark
lives in memory, so after a Core restart with the car charging it was
never marked again. A reading with no baseline is no longer a test, and
an unmarked car whose charger's whole draw shows on one phase while the
other two carry less than a third of it, for 30 s, is marked. No three-
or two-phase car can show that.

Each review probe is a test, and each fails on the previous head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): judge a one-phase car after limits, setpoint and waiting

"Charging on one phase" ran before the limit guard, so a fuse limit,
import limit, fuse cooldown, stale site meter, changed setpoint or a
reading older than the order all read as limited/one_phase. The branch
now runs after the waiting check; those verdicts come first, and a draw
beyond tolerance is judged as for any car.

The field matrix judged the car's draw against the full order and let
only one_phase excuse a third of it. It now judges the draw against the
command's expected_w, as Core does, so a marked car under a fuse limit
reads fuse_limit without failing the verdict check. The known list does
not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(control): rewrite the field matrix's known list on srcfl#1802

On master with srcfl#1802, the 39 one-phase car energy rows (D) and the one
energy row labelled A pass with this branch; their lines and labels go.
No row is new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… run three (srcfl#1804)

An app sends Charge now with the phases the API reports, and the API
reported three for an auto loadpoint. On a charger whose table stops
below 6 A on three phases, a 3p order has no step and went out as 0 W:
the field matrix's two hold3p rows, car idle with room.

A client's 3p on such a charger is not a pin: the loadpoint's own mode
decides, so auto and unset run on one phase. A loadpoint pinned to 3p
keeps it. The API reports one phase for such a charger unless it is
pinned to 3p, as Core runs it.

Both hold3p rows pass; their lines and label go.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…1805)

* fix(control): a battery order on its way counts on each phase

A one-phase charger's room is one phase's, but Core took the battery's
whole unshown order off it, so a three-phase battery counted three times.
With 4,703 W of battery order the car's phase had 661 W left, under the
1,610 W a start needs, and Charge now sat at 0 W (fuse_limit) with every
phase near 0 A. The order now goes beside the chargers' unshown offers:
in total import, and the same current on each phase. A three-phase
charger's cap does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(control): the idle ceiling label counts the battery order on both sides

The fuse's room now holds our unshown battery order, so the hourly idle
start's label compared the ceiling's room without it against the fuse's
room with it, and a car the ceiling held read fuse_limit. Both sides now
count the order, as the start test itself does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 14 commits October 22, 2026 14:16
… at it (srcfl#1806)

* fix(loadpoint): plan a one-phase car above the charger's minimum, not at it

Once a car was marked one-phase, the planner got its one-phase levels
(1,380-3,680 W) with the charger's own minimum, 4,140 W on a charger
set up for three phases. The planner drops every level below its
minimum, so it had only 0 and the car stopped charging: device-simulator
rig s14 on 2e84710, 0 W for the last 15 min with the goal not reached.

The order for a one-phase car is three times the plan, so the plan's
minimum is a third of the charger's: 6 A on one phase. Its maximum is
the top one-phase level. loadpoint.PlanningLimits holds this, and
main.go and the field matrix both use it.

The field matrix planned with PlanningSteps alone: it never took the
one-phase branch in main.go and ignored the minimum the planner drops
levels below. It now uses PlanningLimits and drops levels outside the
minimum and maximum as mpc does; without the fix 24 sweep rows (one-phase
car, planned, a charger with a 4,140 W minimum) fail on energy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loadpoint): a one-phase car's levels are thirds of the charger's three-phase steps

Review of srcfl#1806: a third of the charger's minimum gave the planner a
1,380 W level on the field table, whose minimum (1,600 W) is a one-phase
one. Three times it, 4,140 W, is not on the table, so a plan slot at that
level with the plan's peak went out as 0 W; on coarse tables the car drew
up to 30 % less than the plan counted.

A marked one-phase car's levels are now the charger's three-phase
planning steps divided by three, up to its one-phase maximum, so each is
a third of an order the table and minimum allow. Without a table nothing
changes: 4,140 W gives 1,380 W, 4,800 W gives 1,610 W. PlanningLimits
also needs a charger that can run three phases (threePhaseCapable, which
includes a three-phase site).

TestOnePhaseCarLevelsAreOrders ticks the controller at every level the
planner keeps, for five chargers and two fuses. Two field-matrix rows ask
a small goal of the one-phase car on the help report's table; both were
idle with room on the previous head.

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>
…1809)

Each ev_set_current order now carries cap_amps_per_phase: the most
current per phase Core allows this charger on the phases the order
names. It is the charger's share of the EV fuse/import cap and of the
busiest phase's room after the split between chargers, within the
order's max_amps_per_phase and the charger's maximum, and not limited
by what the plan or a hold wants. Room that no charger took goes to
the first charger with a share, so two chargers are never told the
same room. Omitted on a 0 W order, without a fuse budget, or without
a voltage.

max_amps_per_phase is the fuse rating. easee_cloud (srcfl/device-drivers#190)
needs the room left to start a 6 A order at 7 A only when 7 A fits.
No order changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…srcfl#1811)

Move the device-drivers pin from 5cc7bb1a to 219c462e
(srcfl/device-drivers#190 and srcfl#191). Between the two pins only
easee_cloud changed.

An Easee Cloud charger given a 6 A order now starts at 7 A per phase
when Core's cap_amps_per_phase (srcfl#1809) allows 7 A, which avoids
Easee's roughly five-minute delay at 6 A, and steps down to 6 A once
the charger reports charging or after 90 s. 1.3.8 adds two review
fixes: no 7 A before the first poll, and a pause or stand-down cannot
leave 7 A for the phaseMode re-assert.

Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

sitesim drops its port of the old gG curve and steps each phase through
github.com/srcfl/device-simulator/fuse (iec_gg) at 736051f, the thermal
model the site rig uses. Fuse.Preheat starts a phase warm; the field
matrix's 2-20-2 probe uses it. No verdict changes in the scenarios or the
field matrix.

TestSiteSimRandomHouse runs seeds 1-5 for an hour each: random house
steps up to the fuse less 2 A, a warm fuse, a car on Charge now. The fuse
never blows. Seed 2 breaks invariant (a) and is listed as known: a cut
that landed 94 s earlier still nets against the next overage.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… machine of its own (srcfl#1797)

The end-to-end check from the 21 October simulator test becomes a
maintained part of the stack, split as Fredrik asked: the house lives in
srcfl/device-simulator (its headless run builds it from a spec, applies
a timeline and faults, and records what flows through each fuse), and
FTW keeps what is FTW's.

go/test/site (test files only) holds the scenario pack: the house spec,
FTW's configuration and requests, and criteria read from the house's
record and FTW's API, for every case of the report plus s11 (pause, then
restart), s9r, s5t, s12 (the one-phase night), s13 (socket reconnect)
and s14 (a one-phase car). A criterion that fails on master is marked
known; when a fix makes it pass the run fails until the mark goes. Each
case names the fault-catalog entries it covers and its sources.

FTW_SITE picks short, full, names or random:<seed>:<count>; lanes run
several cases at once on ports 100 apart. A random seed draws FTW's side
and the house draws its own timeline from the same seed; eight
invariants are checked every second, and a failing seed is cut after
its first violation and kept for the full set. make siterig,
siterig-full, siterig-random and siterig-coverage run it.

go/test/site/runner is siterig-runner: on its own machine it runs the
short set on pull requests that touch control, loadpoint, OCPP or
telemetry code and the full set on each master commit, and posts
siterig/short, siterig/full and siterig/random commit statuses. Nothing
runs in GitHub Actions. Core is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#1814)

* fix(control): interchangeable batteries share the plan's total

In planner modes with two or more batteries, Core followed each battery's
own planned energy per slot. The planner pays the same for a Wh on either
of two equal batteries, so Energyplan often put a whole slot on one and the
next slot on the other. Two identical Pixii batteries took turns, their SoC
drifted apart, and a large load got one battery's planned rate while the
rest came from the grid.

Batteries with the same charge and discharge power per Wh of capacity
(within 5 %) now use the capacity split that self-consumption and 2.x use.
Batteries that differ, such as one on a smaller inverter, still follow their
own planned energy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(control): hand a blocked twin's share over, state the SoC limit

A test covers a battery that cannot charge passing its planned share to
its twin. The changeset and comment now say that the capacity split does
not keep each battery inside the plan's SoC band, and that a fleet with
one different battery keeps following the plan per battery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(control): interchangeable batteries share by available energy

Split the live target across interchangeable batteries by the energy each
can still give (above Core's 5 % floor) or take (below full), so a SoC gap
shrinks and the batteries reach the floor or full together. A battery at
its power limit stays there and the others take the rest; a blocked one
gets nothing. This runs only where the plan names each battery, so plans
without per-battery energy keep the capacity split and the golden corpus
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(control): honest clamp flags, idle slots and unknown SoC keep capacity split

From the second review: mark a target clamped only when a battery is
blocked, empty, full or at its limit; let idle slots keep the local
controller and its inverter-group routing; fall back to the capacity split
when an interchangeable battery has no fresh SoC instead of weighing it as
empty; track pinning with a flag. Tests cover a charge-side pin, three
batteries, a blocked battery and the clamp flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…target (srcfl#1815)

splitByCapacityAndPV summed the PV of every inverter group, including a
group with no battery in the split (offline, faulted or charge-blocked).
That group's local share went to no battery, so the fleet charged up to
that PV short. Golden seeded_siblings/039 charged 6,284 W of 9,600 W.

Count only groups with a battery in the split. Their PV reaches the
other batteries over the AC bus, which split the whole target by
capacity. Re-record seeded_siblings: 8 device_fault records move.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(cloudwire): the tiles class, its three shapes and tiles.get

A tiles body is the app protocol's hist.chunk with the common header:
closed tiles match the box's Noise answer byte for byte, and the trailing
tile travels whole or as appends that Lay puts back together.
cloud-tile-vectors.json freezes one of each shape for FTW Cloud and the
phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cloudlink): send FTW Cloud 48 hours of history tiles

With ftw.history.read in the owner's grant and NTP time, the box sends
the trailing 5-minute tile as its buckets close (an append, whole once
an hour), each tile as it closes, and at connect or a resync the tiles
of the last 48 hours that hello_ok's have lacks. Closed tiles are packed
from the same ledger read as the app's Noise history, so they are the
same bytes. Tiles go last in a tick and count against the byte budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(cloud): pieces lay in the order the box sent them, by wallMs then seq

seq starts again when the box does, and a phone may still hold pieces
from before the restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloudwire): a later closed tile replaces an earlier one in Lay

The box sends a closed tile again when its ledger changed under it, as
in the hourly pass. Lay kept the first, so a receiver drew the old gap.
Only a piece of a closed tile is ignored now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): tiles after review: exact series, a 4 KiB bound, history back in the grant

- Check takes the four series and no other, from the registry's names,
  and TileEnvelopeMax bounds an envelope, so a box cannot pad its tiles.
- When history leaves the grant the link forgets what it sent; when it
  comes back every tile goes again on the same link.
- The ledger is read at most once a tick, one closed tile at a time, and
  the grant is checked again before each tile goes.
- cloudlink.PackTile is the one packing path; a test holds it to the
  Noise tile through appproto's buildTile.
- The doc says what a phone really gets: tiles until the roster runs out
  while the box is away, and when FTW Cloud sends resync for tiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…l#1818)

* feat(cloudwire): x carries a watch's exchange through FTW Cloud

The relay stopped on 17 October 2026, and with it the socket that carried
a watch's exchange with its box (ADR 0009). This is the wire that carries
it through FTW Cloud instead: a watch's one-shot request carries x {d}, the
service sends the box x {dev, id, d}, and the box answers under the same dev
and id. The service reads neither d and keeps nothing after the answer.

- MsgX, CapX, X, AppX, XDataMax, MaxXFrame, XAnswerWithin, XInFlight.
- Downlink and OnceMessages gain x; the allowlist lists x unheaded.
- Golden vectors: testdata/cloud-once-x-vectors.json.
- protocol-cloud.md "Exchange", and ADR 0009 "Through FTW Cloud"
  (proposed): the phone adds its watch by key, and a watch has tun.

The box's half is the paired PR stacked on srcfl#1691; srcfl/ftw-cloud and
srcfl/ftw-app pin this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(changeset): x in FTW Cloud's protocol

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(cloud): the exchange's limits at the box, a restart, and the watch's one op

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(adr): Fredrik accepted Through FTW Cloud on 23 October 2026

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(cloud): a watch's exchange rides FTW Cloud

The relay stopped on 17 October 2026, and with it the socket that carried
a watch's exchange (ADR 0009). The exchange sessions live on the box and
never needed the relay; only the carrier did.

- appuplink: the exchange table keeps its sessions; its relay socket gives
  way to an Exchange the cloud link delivers x to. A request is tried only
  against the sessions of the device FTW Cloud names, and a handshake that
  proves another key gets silence, which goes back as an empty d.
- cloudlink: reads x, sends it with the tun lane 0 frames, says x in
  hello.caps beside tun.
- The roster gives a watch tun; the box says cmd.exchange while the link
  takes x.
- POST /api/app-link/pairing {"role":"watch","key":...}: an owner's phone
  adds its watch by key, with the same step-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): replays end nothing, x keeps its share, a watch charges only

From the review of srcfl#1819:
- A message 1 that FTW Cloud names under a device that is no watch costs
  nothing. Handshakes: 12 a minute per watch, 60 in all.
- CheckWatch decides and changes nothing; a session commits (stamps the
  visit) once its first transport message decrypts. A watch holds two
  sessions and the box eight; the oldest unproved goes first, never the
  newcomer, so replays end no working session and no other watch's.
- The watch-code path is gone: a watch request without a key is refused,
  and a hello with a code gets silence.
- x answers keep to a tenth of the tunnel budget and stop at nine tenths;
  the box takes 120 requests a minute. A request whose answer could not
  leave is dropped and runs nothing.
- The exchange takes loadpoint.hold alone; other ops get E_OP_NOT_GRANTED.
- Tests for each, a revoke mid-session, and a restart's clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(api): each refused watch key gets its own request id

TestAKeyThatIsNoWatchIsRefused sent two requests with id 1 on one
session, in map order, and CI's race run read a mixed answer. Each
request now has its own id, in a fixed order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…gh an OHTTP relay (srcfl#1820)

* feat(stats): send an anonymous daily energy report through an OHTTP relay

Once per closed UTC day, 1 to 12 hours after it closes, the box sends the
date and six whole-kWh sums (solar, grid import and export, battery charge
and discharge, vehicle charge), sealed with Oblivious HTTP (RFC 9458) to FTW
Cloud's pinned gateway key and posted to an independent relay. The relay
never sees the report and FTW Cloud never sees the box's address.

- go/protocol/ohttp: RFC 9458 on crypto/hpke for one suite, with known-length
  Binary HTTP; the RFC's Appendix A vectors pass byte for byte.
- go/protocol/energywire: the report and its strict decoder.
- go/internal/energyreport: the complete-day rule on the energy ledger, the
  schedule and the sender. The relay URL and the gateway key are
  compile-time pins, both empty, so this release sends nothing.
- The fleet report's answer is the gate, shared in one function; a site a
  partner controls is left out. Settings shows the report beside the fleet
  report.
- ADR 0008 records what each party sees, the limits and the open ePrivacy
  question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(stats): one statistics answer, and both daily reports through the relay

Fredrik's change of scope, 24 October 2026: statistics are one answer on
the box, and every report goes the same way.

- One answer for both anonymous reports (cloudlink.SharesStatistics): part
  of FTW Cloud under the current terms, with no off switch; on for a box
  without Cloud until its owner turns it off. Nobody is asked. Stored state
  is read once (MigrateStatistics): a local No, a fleet report switched off
  in the config, or Cloud turned off on an earlier release stays off; a
  local yes stays yes; every other box is on by default. fleet_ping.enabled
  is no longer a switch. A box without Cloud no longer starts a
  statistics-only Cloud link.
- A box that shares only by default posts nothing straight to FTW Cloud,
  which would see its address; it waits for the relay.
- go/internal/statsrelay holds the relay and gateway pins and sends sealed
  reports; the fleet report goes through it once a release names a relay,
  the energy report always. Each report travels alone at its own time.
- go/protocol/reportwire: one padded inner request for both kinds
  (1,536 bytes); the gateway picks the decoder by schema.
- Settings: Anonymous statistics with the one switch beside both messages;
  the Cloud panel no longer asks local boxes.
- ADR 0008 and the protocol doc record the model, the two-report rule and
  the open ePrivacy question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(adr): a public energy sum needs five reports that had any of it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(statsrelay): each report carries an id and the day it was sent

From review of srcfl/ftw-cloud#50: the gateway counted a replayed request
again, and a fleet report, which has no date, could be replayed on any later
day. Each inner request now carries a random report id and the UTC date it
was sent, as Binary HTTP header fields inside the sealed message, at the
same fixed length. reportwire.Fresh refuses a report sent before yesterday;
the gateway keeps an id hash per date to count each report once.

ADR 0008 also states the open limit: the floor of five counts reports, and
anyone with the key configuration can post them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stats): the hourly upload follows the one answer; whole days with new sessions

From review of srcfl#1820:
- The hourly C8 upload read only the grant's stats purpose. It now reads
  the one answer too: off, or Cloud under older terms, sends no hour. Under
  older terms, asking for statistics is refused (ErrCloudTermsFirst, 409)
  instead of adding the stats purpose beside the old terms; Status says so
  (statisticsNeedTerms) and Settings shows it. After the migration,
  ReconcileStatistics takes the stats purpose out of a grant whose answer is
  off, and ends a statistics-only link, as turning statistics off does.
- A stored grant counts only through the running link, which honours it
  only with its key (Link.SharesStatistics, Link.SaidYesToStatistics). With
  no link, the box's own answer alone.
- While a Cloud that was turned off waits for its deletion receipt, the
  local answer can still change.
- energyreport.Day: a charger's session counter starts again at every
  plug-in, which the ledger marks as a reset and covers from power; that no
  longer drops the day. An asset first seen after the day ended is not part
  of it.
- statsrelay: FTW Cloud's host is refused in any case and with a trailing
  dot.
- ADR 0008 records Fredrik's decisions: a default box waits for the relay,
  and the floor of five counts reports, accepted, with Privacy Pass later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say what goes beside a sealed report, send hours only on a given yes

The settings text and docs now say that a report sent through the relay
carries two sealed things beside it: a random number drawn new for each
report, and the UTC day it was sent. Settings shows both under each
report.

The hourly C8 upload now needs a given yes (Cloud under the current terms
or a local yes). A box that shares only by default sends no hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…report (srcfl#1821)

* feat(stats): send an anonymous daily energy report through an OHTTP relay

Once per closed UTC day, 1 to 12 hours after it closes, the box sends the
date and six whole-kWh sums (solar, grid import and export, battery charge
and discharge, vehicle charge), sealed with Oblivious HTTP (RFC 9458) to FTW
Cloud's pinned gateway key and posted to an independent relay. The relay
never sees the report and FTW Cloud never sees the box's address.

- go/protocol/ohttp: RFC 9458 on crypto/hpke for one suite, with known-length
  Binary HTTP; the RFC's Appendix A vectors pass byte for byte.
- go/protocol/energywire: the report and its strict decoder.
- go/internal/energyreport: the complete-day rule on the energy ledger, the
  schedule and the sender. The relay URL and the gateway key are
  compile-time pins, both empty, so this release sends nothing.
- The fleet report's answer is the gate, shared in one function; a site a
  partner controls is left out. Settings shows the report beside the fleet
  report.
- ADR 0008 records what each party sees, the limits and the open ePrivacy
  question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(stats): one statistics answer, and both daily reports through the relay

Fredrik's change of scope, 24 October 2026: statistics are one answer on
the box, and every report goes the same way.

- One answer for both anonymous reports (cloudlink.SharesStatistics): part
  of FTW Cloud under the current terms, with no off switch; on for a box
  without Cloud until its owner turns it off. Nobody is asked. Stored state
  is read once (MigrateStatistics): a local No, a fleet report switched off
  in the config, or Cloud turned off on an earlier release stays off; a
  local yes stays yes; every other box is on by default. fleet_ping.enabled
  is no longer a switch. A box without Cloud no longer starts a
  statistics-only Cloud link.
- A box that shares only by default posts nothing straight to FTW Cloud,
  which would see its address; it waits for the relay.
- go/internal/statsrelay holds the relay and gateway pins and sends sealed
  reports; the fleet report goes through it once a release names a relay,
  the energy report always. Each report travels alone at its own time.
- go/protocol/reportwire: one padded inner request for both kinds
  (1,536 bytes); the gateway picks the decoder by schema.
- Settings: Anonymous statistics with the one switch beside both messages;
  the Cloud panel no longer asks local boxes.
- ADR 0008 and the protocol doc record the model, the two-report rule and
  the open ePrivacy question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(adr): a public energy sum needs five reports that had any of it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(statsrelay): each report carries an id and the day it was sent

From review of srcfl/ftw-cloud#50: the gateway counted a replayed request
again, and a fleet report, which has no date, could be replayed on any later
day. Each inner request now carries a random report id and the UTC date it
was sent, as Binary HTTP header fields inside the sealed message, at the
same fixed length. reportwire.Fresh refuses a report sent before yesterday;
the gateway keeps an id hash per date to count each report once.

ADR 0008 also states the open limit: the floor of five counts reports, and
anyone with the key configuration can post them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stats): the hourly upload follows the one answer; whole days with new sessions

From review of srcfl#1820:
- The hourly C8 upload read only the grant's stats purpose. It now reads
  the one answer too: off, or Cloud under older terms, sends no hour. Under
  older terms, asking for statistics is refused (ErrCloudTermsFirst, 409)
  instead of adding the stats purpose beside the old terms; Status says so
  (statisticsNeedTerms) and Settings shows it. After the migration,
  ReconcileStatistics takes the stats purpose out of a grant whose answer is
  off, and ends a statistics-only link, as turning statistics off does.
- A stored grant counts only through the running link, which honours it
  only with its key (Link.SharesStatistics, Link.SaidYesToStatistics). With
  no link, the box's own answer alone.
- While a Cloud that was turned off waits for its deletion receipt, the
  local answer can still change.
- energyreport.Day: a charger's session counter starts again at every
  plug-in, which the ledger marks as a reset and covers from power; that no
  longer drops the day. An asset first seen after the day ended is not part
  of it.
- statsrelay: FTW Cloud's host is refused in any case and with a trailing
  dot.
- ADR 0008 records Fredrik's decisions: a default box waits for the relay,
  and the floor of five counts reports, accepted, with Privacy Pass later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say what goes beside a sealed report, send hours only on a given yes

The settings text and docs now say that a report sent through the relay
carries two sealed things beside it: a random number drawn new for each
report, and the UTC day it was sent. Settings shows both under each
report.

The hourly C8 upload now needs a given yes (Cloud under the current terms
or a local yes). A box that shares only by default sends no hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(fleetping): say coarsely how the box ran in the anonymous daily report

The fleet report (ftw.fleet/2) adds four buckets: the slowest plan of the
last 24 hours, how many of those plans fell back, the share of driver
polls that failed, and the newest update of the last 7 days. Core reads
them from the plans it keeps (planner_diagnostics, PlannerDay), a new
24-hour count of polls in the telemetry store (PollsLastDay), and the
updater's status. Each bucket is at most four words wide and describes
the last day or week, not the house. A box that does not update itself
says none, so the install method does not become a field.

fleetwire accepts ftw.fleet/1 and ftw.fleet/2, and a day counts health
only from reports that carry it. Same switch as the rest of the report;
Settings names the new fields and shows them in the preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(fleetping): count only driver polls, and only once the count covers six hours

Review of srcfl#1821: OCPP errors fed the poll count through
RecordDriverError while OCPP successes did not, so an OCPP-only site
with one faulted connector read 1%+. Only the driver registry's poll
loop counts now (telemetry CountPoll), a failed poll and a good one
alike; health records are not polls.

The count lives in memory, so after an update's restart it covered
minutes and a start-up burst of failed polls could read as trouble.
PollsLastDay now says how much of the day it covers, and the report
says none until that is six hours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…asee fixes

Signed-off-by: Segran <goran@andersson-stehag.se>
@segran2
segran2 marked this pull request as ready for review October 24, 2026 22:21
frahlg added a commit that referenced this pull request Oct 25, 2026
#1537 (602aa0f, e33726b) on current master, as Segran integrated it
in #1823 (e225339): the feedback code keeps master's one-phase and
hourly-import paths beside the load-balancer reason. The driver pin
from both PRs is left out; it moves only to a merged driver commit.

Co-authored-by: Segran <goran@andersson-stehag.se>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
frahlg added a commit that referenced this pull request Oct 25, 2026
The two commits below, e06b728 and 5c2f2ec, are #1537 on master
286ac14 as Segran integrated it in #1823, then the rework for easee_cloud
1.4.1. This merge keeps the branch's earlier history instead of
rewriting it; its tree is the rebased tree. The driver pin returns to
master's: it moves only to a merged driver commit.

Co-authored-by: Segran <goran@andersson-stehag.se>
Signed-off-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg

frahlg commented Oct 25, 2026

Copy link
Copy Markdown
Member

Thank you, Göran. I took this integration into #1537: #1537

Commit e06b728 applies your merge's diff against master 2d7c8eb onto current master, with you as co-author. I left out the pin to #183's head, as you asked: the pin moves only to a merged driver commit, in its own PR. The branch keeps its history; a merge commit (f5a3432) brings in the rebased tree.

#1537 now also reads Easee's allocated current and pilot from easee_cloud 1.4.1, in srcfl/device-drivers#194. That PR carries your REST ordering check and its tests from #183, with one change: replies that go back for 30 s start the order again, so a stamp from the future cannot keep the driver silent until the car unplugs.

I have not closed this PR or #183; that is for you or Fredrik.

@frahlg

frahlg commented Oct 28, 2026

Copy link
Copy Markdown
Member

Thank you, @segran2. Closing: #1537 already holds this merge of master, so this PR has nothing left to add.

🤖 Generated with Claude Code

@frahlg frahlg closed this Oct 28, 2026
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.

2 participants