chore(release): version packages - #824
Conversation
a824735 to
820fccd
Compare
miravoss26
left a comment
There was a problem hiding this comment.
Standard changeset version release PR: consumes 14 pending changesets, bumps ftw 1.16.1 → 2.0.0, and writes the aggregated CHANGELOG entries. The major bump is correct — one of the consumed changesets (Home Link removal) is a documented breaking change, so major is the right semver jump, not a minor/patch miscount.
- Correctness: version numbers in
package.json/package-lock.jsonand the CHANGELOG entry agree; changeset files are deleted only (no edits), which is whatchangeset versionis supposed to do. - Security: no functional code touched, nothing to screen.
- No CI checks reported on this branch (expected for a
changeset-release/*branch in this repo, not a gap specific to this PR).
Safe to merge from my read.
4185f43 to
20be3d5
Compare
miravoss26
left a comment
There was a problem hiding this comment.
Standard Changesets release aggregation (auto-opened, updates on every new changeset on master) — CHANGELOG.md + package.json/package-lock.json version bump, changeset files consumed. No hand-written code in the diff, nothing to security-screen.
Worth flagging before merge: this rolls up to ftw@2.0.0, a major bump — the headline breaking change is Home Link's removal (passkeys stop verifying, <name>.home.sourceful.energy and /api/home-link/* are gone, no migration). Merging this is what publishes that release, so it reads as a product decision on timing, not a code-review call. No checks configured on this branch (no checks reported on the 'changeset-release/master' branch) — nothing to gate on there either way.
20be3d5 to
fa36f86
Compare
miravoss26
left a comment
There was a problem hiding this comment.
Changesets release PR (bot-generated): rolls up 24 pending changesets into CHANGELOG.md for ftw@2.0.0 and bumps package.json/package-lock.json to match. No source changes, just changelog aggregation + version bump — the underlying changes were already reviewed as their own PRs.
One thing worth a human glance before merging: this is a major bump (2.0.0), driven by the Home Link removal changeset (breaking change: WebAuthn/passkey remote access stops working, no migration path). That's a real user-facing break, so the version jump is correct, but merging this PR is what publishes it as the next release — worth a deliberate go rather than an auto-merge.
No CI checks have reported yet on this head (empty status rollup) — worth confirming they run green before merging. Nothing to flag on the diff itself; safe to merge from my read once checks are in.
7309f02 to
aeb5221
Compare
aeb5221 to
0179176
Compare
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or setup this action to publish automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to master, this PR will be updated.
Releases
ftw@2.0.0
Major Changes
3de15a0: Home Link is removed. Remote access is the FTW app.
What you lose. Every passkey enrolled with Home Link stops working, and
there is no migration: the box is no longer a WebAuthn relying party, so those
credentials now verify against nothing. The browser route is gone with them —
your
<name>.home.sourceful.energyaddress, the page it served and the relaybehind it no longer exist. The local UI's Remote tab and the
/api/home-link/*endpoints are gone. If you were reading your house from abrowser away from home, that stops working on this release and the app is the
only way back.
What you get. One remote path instead of two. The app pairs by scanning the
box's QR code, which carries the box's key optically so no server ever sees it,
and afterwards reconnects on its own key — a photographed code cannot pair a
second phone. The relay holds no keys and cannot read a frame, and the name the
box joins under changes every hour, so the relay operator cannot tell which
household is online or follow one across the day. Nothing about local control,
setup, history or fallback planning changes.
What is not there yet. The box mints its pairing code at boot but nothing
renders it in the local UI, so pairing a phone still needs the terminal.
Revoking one phone means rotating the rendezvous secret, which re-pairs the
whole household; per-device revocation is owed.
An existing
home_link:block in yourconfig.yamlis ignored — the box bootswithout you editing anything. Home Link's rows in
state.dbare left wherethey are and authorise nothing.
ftw home-link-adoptis nowftw gateway-identity-adopt; it does the same thing to the same files, and themachine identity it binds is untouched by any of this.
Minor Changes
8c84981: The box now counts itself. Once a day it tells Sourceful its FTW version and channel, which driver types it runs, roughly how much battery it has, its price zone and roughly how old the install is — enough to answer "of the boxes on beta, how many run sungrow", which is what decides where engineering effort goes.
The ping carries no gateway ID, no key, no serial, no site name, no counter and no timestamp: nothing in the message says which box sent it. Values are bucketed rather than reported, the version travels only when it is a release tag, so a developer's build reports as unknown rather than as itself, and the send time is drawn fresh each day rather than sitting in one slot. It goes straight to Sourceful over HTTPS, never through the relay, and a failed send is forgotten rather than retried.
Driver names have their own rule, because a driver file is called whatever the thing that installed it called it, and anyone can install their own. A name travels only if it is on one of two lists, and neither list is the contents of a directory on the box. One is the drivers this build ships with, compiled into the binary from the bundled-driver pin rather than found on disk, so every box on a release carries the same list and nothing a running box does adds to it. The other is the box's own record of the install, asked file by file: did this exact filename arrive under a signature made with FTW's own key? Everything else reports as "other": a driver somebody wrote, a file copied into place, a file renamed afterwards, one from any repository but FTW's however carefully it signs its own manifests, and — until it is installed again — one that was already on the box before this release started keeping the record. Settings can say where installed drivers are kept, including inside the directory the bundled ones sit in, and it changes nothing: a directory can take a name off the second list, never put one on it. The one way through is editing the box's own database by hand, which this does not claim to stop.
Two limits are stated rather than glossed over, in the package doc and on the screen. The six fields still describe a household, so a beta box in a small price zone with a big battery may be the only one of its kind; and Sourceful sees the address the box sends from, as any website does. The payload is what this design makes useless as an identifier, not the connection. Settings → Fleet ping shows the exact message this box would send, built by the same call that sends it, so the claim is checkable instead of promised; while the ping is switched off that screen says nothing is being sent. On by default; the switch takes effect without a restart.
3de15a0: Pair a phone from Settings → FTW app.
The box could mint an enrollment payload but nothing showed it, so there was no
way to pair a phone at all. There is now a button that mints a single-use code
and draws it as a QR, and a local-only endpoint behind it.
The code is minted on request rather than shown continuously: a QR left on a
screen for a week is a QR that everyone who walked past has photographed. It is
never offered as copyable text either — the payload carries the pairing code and
the rendezvous secret, so the camera is the only path it should take.
3de15a0: The box now speaks the FTW app's protocol for real. It mints its own Noise static key, a long-lived rendezvous secret and single-use pairing codes, prints them as the QR payload the app parses, and holds an outbound connection to the blind relay under a handle that changes every hour — derived from the secret, so the relay cannot follow a household from one epoch to the next. Up to four phones share the uplink; each gets its own encrypted session, and the box tells them apart by which one's key authenticates a frame, because the relay cannot say. A phone is let in by a pairing code once and by its own key thereafter, so reconnecting never needs a new code and a photographed code cannot pair a second device.
Turn it on with
app_link.enabled: true.controlRevnow means something: it is a fingerprint of the site's controllable state, so it moves when the mode or a target does and holds still through the per-tick churn. A command that expected an older revision is refused, and a session that falls behind is resynced with a fresh snapshot rather than left refusing every command until it reconnects. Site mode changes from every door now go through one function, so none can arrive having set the mode without dropping the manual hold and resetting the PI integrator.309f131: Add the FoxESS H3-Smart and 1K5 driver to the offline recovery bundle. Its telemetry and control paths have been tested on 1K5-HI-10-V1 hardware; H3-Smart hardware remains untested.
bbc92ee: An EV charging schedule can now be saved from the app. The schedule gets its own route —
PUT /api/loadpoints/{id}/schedulestores one,DELETEclears it — priced Configure where its sibling target route stays Actuate. The split is the point: a schedule is a standing instruction about future days, so one that arrives late is the same instruction, only later, which is what Configure means. The one-shot fields on the target route — target, soc, force_start — move energy now, so they stay on Actuate and the passthrough keeps refusing them. The on-box page keeps saving through the target route unchanged; both routes share one apply path.A schedule also learns which days it covers.
daysis a 7-bit mask, bit 0 Monday through bit 6 Sunday; omitted or zero means every day, so every stored schedule and every old client keeps its behaviour. Without it, "full by 07:00" on a work commute also charged for Saturday. The weekday is the household's, read in the box's own time zone: a deadline at 00:30 Saturday in Stockholm is still Friday in UTC, and skipping Saturday has to mean the household's Saturday. The stored time-of-day stays UTC minutes, so a DST change shifts the local deadline by an hour until the schedule is saved again; storing local minutes instead needs a migration and a UI save-path change, and is deferred on purpose.e29f8d7: New
sim-pcsdevelopment simulator: a Modbus TCP server modeling a multi-rack commercial battery plant, one rack per Modbus unit ID, each with first-order power response, SoC integration with efficiency and full/empty cutoffs, and a documented SunSpec-style register map. A control HTTP port injects rack faults, comms loss and SoC pins so the upcoming plant module and e2e suite can exercise per-unit allocation and derating without hardware. Wired intomake buildandmake run-sim.Patch Changes
fb34584: The FTW app can reach the box's own HTTP API over its session, and the box now knows which phone is asking. An
api.reqcarries a method, a path under/api/, a parsed query and an optional body; the answer comes back as a status, then chunks, then an end. It runs in process through the same handler the LAN listener serves, trust boundary included. This is a security improvement rather than a relaxation: that API is already served on the home LAN with no authentication at all, and this door is pinned to an enrolled device.Who is asking now exists as a value.
appenroll.Authorisereturns the grant it has always known — device, role, enrolment epoch — instead of throwing it away on a yes-or-no answer, and it reaches the HTTP layer on the request context, never on the wire:api.reqhas no headers field, so there is no client byte that could become a caller claim.api.SecureMutationsbecomesapi.Authenticate, which keeps a caller a session already authenticated and mints a local owner for anything off the LAN. That second branch writes down what the LAN already is; authenticating it later is a change to that one branch, because every handler from here on readsapiauth.From.A viewer cannot write, and the box is what refuses it.
cmdfinally checks the scope its own operation table has declared since the day it was written and never read, sosite.mode.setfrom a viewer is rejected with the mode controller untouched. Configuration through the HTTP door needs the owner role and a step-up.Every one of the 132 routes names what it costs, beside the handler it governs, and the request's method is never consulted. 55 are reads, 40 configuration, 22 actuation and 15 local. The method is not asked because it does not know:
GET /api/caldav/credentialshands out a password that is a write channel back into dispatch, andPOST /api/self_tune/startpauses control and drives every battery through ±3000 W for minutes. Both read as ordinary from their verb alone.Anything that moves energy stays on
cmd, naming the command to send instead where one exists — a command carries an expiry and the box revalidates against fresh state, and an HTTP request carries neither. Fifteen routes are local: their answer holds a credential or a whole file, or doing them needs somebody standing at the box, and the app is told so withE_LOCAL_ONLY.POST /api/configis refused for a third reason: it replaces the whole configuration, so a phone a year behind the box would silently drop every field it never knew about.The cost of naming every route is that a read view written in the app next year needs the box to have heard of the path. That is the direction worth being wrong in, and two things hold it there:
api.handletakes the tier as a required argument, so leaving it out does not compile and an unknown value stops the box at startup; and a route that reaches the gate with no tier the gate knows is refused rather than served. The gate is one switch with a branch for every tier and a closed default — it replaced a chain of cases with no read branch at all, which is how a credential reached a shared viewer's phone.Revocation bites at once. The grant is re-read on every privileged request, so a socket cannot outlive a revoke, and tearing down a session now cancels the call it is making rather than only the next one. An answer in a media type the session cannot carry is refused before a byte streams, an oversized one stops at the ceiling and says it was truncated, and a handler that panics costs one request instead of the box.
da6a101: Paired phones can be seen and removed. Settings → The FTW app now lists every paired device — a short key prefix with paired/last-seen stamps, most recently seen first — with a Remove that takes effect immediately: the key is forgotten and any live session is terminated with the reason the app already knows how to show. The stored device list gains timestamps; the old bare-key file still reads, so an update never unpairs a house. The count endpoint's "never a list" stance is deliberately reversed: this page can already mint a pairing code, the strictly stronger power, and without a list there was no way to lock a stray key out at all.
41324a5: The FTW app gets history, and the plan no longer kills the session. History tiles are served from the energy ledger over the app protocol — same tile geometry, ids and etags as the app computes, so unchanged tiles are never resent; mean power per bucket, MISSING where nothing was metered, retention gaps named instead of served as silence. The plan answer is truncated to the slots that fit the largest bulk bucket: the optimizer's 193 slots encode past 16 kB, and the encode error used to drop the whole session — which the app experienced as the box hanging up whenever the plan view opened.
450b2b5: The FTW app can be turned on from Settings instead of by hand-editing YAML. The app tab now carries a checkbox, and saving it raises the restart dialog the box already shows for settings that only take effect at startup. Because the uplink is connected at startup, what is saved and what is running can differ for a minute, and the tab says which of the two it is in rather than going quiet: the pairing button stays disabled until the box reports the uplink actually running, so nobody presses it and gets nothing back.
71b2be4: The FTW app can draw electricity prices. The box answers
price.getover the app protocol with the slots covering a window, spot and total as whole minor units per kWh — rounded once, here, so the app and the box's own dashboard cannot end up disagreeing about what 18.7 öre is.price.spotis advertised only when a zone is configured and rows are stored, so a house with no price feed gets no empty chart instead of a broken one.An answer that does not cover the window asked for says so, at either edge or in the middle. Tomorrow's rates publish in the afternoon, a failed midday fetch leaves a hole, and a box that first heard from the market at breakfast holds nothing for the hours before it; all three come back marked stale rather than as a market that went quiet. A window is read eight days at a time — the price table is never pruned and the app derives its window from the phone's clock, so a wrong clock used to mean a query over every row the box had ever stored.
fb34584: A household can share its home, and the box is what enforces the difference. An enrolment now carries a role —
ownerorviewer, fromcontract/registry.yaml, with the role table generated rather than hand-written on either side. A row loaded from a file written before roles existed reads as an owner, so an update never silently demotes every paired phone.An invite is not a new cryptographic object. It is the same single-use pairing code with a different role behind it, so the QR payload does not change shape and the app's scanner learns nothing about sharing: a guest scans what an owner scans and is told what they are in
hello_ok. The role is remembered by the box and stamped when the code is spent, never carried in the payload, because a role its holder can edit is not a role. One code is live at a time, across kinds, so asking for a guest pass cancels a pairing code still on a screen.Two rules stop a household locking itself out, both in
appenrollrather than in the API layer — otherwise the box's own page could do what the app cannot. The first enrolment on a box is an owner whatever code it used, because a box with no owner can never be administered again. The last owner cannot be removed or stepped down, and the device list says so on the row instead of offering a button that fails.The last-owner refusal now carries a code as well as a sentence. Two audiences read these bodies: the box's own page prints the sentence, and the app owns every word it shows and needs a name to branch on. A 409 alone is a conflict and nothing more specific, so the app read a
codekey — which this floor had never sent, leaving the one refusal a household can meet through the app as the one refusal it could not explain. The code isE_LAST_OWNER_PROTECTEDfromcontract/registry.yaml, through the generated constant, never a literal at the call site.Sharing has no screen of its own: a guest's phone is a paired phone, so it is a row in the same device list, with the same Remove. Locking out a stray key and taking a guest's access away are one action. A role change takes effect on a session that is already open, because both doors re-read the grant on every privileged request — a demoted owner loses their writes at the next one and keeps the readings they still have every right to see.
A household does all of that from the app, and the box decides what the app may hand out. These routes have two doors and the doors prove different things: the LAN proves somebody is in the building, and an app session proves a phone is enrolled while saying nothing about where it is. So a session sees the roster, invites a viewer and locks a phone out — gated on
ftw.members.readandftw.members.write, neither of which a viewer's grant carries — and making another owner, by minting an owner's code or by promoting a row, still needs somebody at the box.The role is no longer defaulted anywhere. A request that named none used to mint an owner, on the reasoning that a page which has not been updated should keep meaning what it used to mean. What that reasoning costs is a default that hands over a house whenever a field goes missing, and the field did go missing: the app sent its role in a query string this endpoint does not read, so every "invite someone to view" arrived here naming no role. It is a 400 now. What a caller did not say is a question, not a blank to fill in on their behalf.
There is now a way back in without a camera: a code the box shows,
XXXX-XXXX, forty bits in Crockford base32 so I, L and O fold back to 1 and 0 for whoever wrote down what they heard. It is redeemed where a scanned code is, inside Noise handshake message 1, so there is no new endpoint and no new carrier. It re-admits a phone that already knows this box; a phone that has never seen it still has to scan, because the box's own key travels only in the square, and the page says that rather than offering a path that cannot work. What makes forty spoken bits safe is not their size: it is minted only on the LAN, shown only on the box's own page, spent once, five minutes long, and burned by five wrong guesses. The counter is on the code and not on the caller, because an address-keyed counter would inherit the relay's own bug, where the documented TLS terminator makes the whole fleet one address.9d170ac: The box can now speak the FTW web app's client protocol. A new
appprotopackage handles the handshake, the telemetry stream, commands and the dispatch plan: version negotiation degrades an old app to a frozen field subset instead of refusing it, telemetry rides a fixed-size lane that sends a tick even when nothing changed, source freshness travels in deltas so a device that goes quiet mid-session is visible immediately, and commands are separated into a receipt from the dispatcher and a result read back from the driver. Shared names — field ids, capabilities, scopes and error codes — are generated fromcontract/registry.yamlrather than typed out, and a test fails when the two drift. Nothing is wired into the running process yet; this is the protocol layer on its own.dcfc1c8: The box can now speak the app's wire: frame codec, Noise IK responder and encrypted transport, in
go/internal/appwire. Telemetry frames are padded to a fixed bucket and refuse to grow, so a 1 Hz stream does not leak the household's load pattern through its length. Unknown message types and unknown map keys are ignored in both directions, so an app pinned in a service worker degrades instead of showing a white screen. Verified against the official Noise test vector and against bytes generated by the app itself.49f6369: Move the bundled-driver pin to device-drivers@f4ab266e. The recovery snapshot
now carries the reviewed version bumps for CTEK API v1 and v2 (0.3.2) and
Sungrow (1.5.8). These upstream changes only update driver versions; telemetry
and control code stays unchanged.
836880d: EV charging is on the app wire. Field 10,
ev_w, carries the summed charger draw — frozen in meaning like fields 1–9, conditional in presence like battery_soc: a site without a charger sends nothing and the app draws no EV node rather than a dead one. Charger drivers now also appear in the app's source table, so their freshness is visible. The energy-flow component gains astaticattribute that holds every animation still — the FTW app sets it while showing a cached snapshot, because a moving particle is a claim that power is flowing right now.71b2be4: The price chart can now be handed its data instead of fetching it. A
fedattribute turns off both the request to /api/prices and the five-minute poll behind it, andsetPrices()pushes a window in. The FTW app draws this same chart over its encrypted session to the box and has no HTTP origin to fetch from, so without this it would have to keep a fork — and a forked chart is one that stops matching the box's own.A slot fed with its own total is shown as sent rather than recomputed from the box's tariff and VAT, since the caller may hold different ones, and the subtitle says "total to import" rather than naming parts it cannot see. The dashboard is unchanged: without the attribute the component fetches, polls and labels exactly as before.
152dc84: Give HTTP drivers a hardware-stable device identity. An HTTP driver's MAC was
never resolved, so a LAN device that had not yet reported a serial fell back to
no identity at all and its persistent state (battery model, RLS twin,
calibration history) was orphaned whenever its address changed. The registry now
ARP-resolves the driver's own configured address, exactly as it already does for
MQTT and Modbus.
Only the driver's own
host/urlis probed, never the merged HTTP allowlist,so vendor cloud endpoints are never treated as identity candidates. ARP lookups
are also now skipped for addresses that provably cannot appear in an ARP table
(loopback, carrier-grade-NAT/tunnel, multicast and broadcast), saving 150 ms of
pointless dialing. Public addresses stay eligible, so devices on a segment with
an ISP-routed block keep the MAC-derived identity they have today.
55d7f37: A mode change from the app is confirmed as soon as it is applied. The forced replan runs off the command path: the Python optimizer can take longer than the app waits for a result, so a change that had already been applied and read back was reported "unconfirmed" purely because the planner was slow. The replan keeps running under a context that survives the session, so a phone dropping its socket right after tapping cannot abort it.
f55eaa2: The price chart is shorter in the FTW app on a phone. It kept the shape the dashboard uses, which is right for a page about prices and wrong for the app's Plan screen, where a sentence, the mode choice and the hour-by-hour timeline share the screen with it: the chart alone painted 247 px of a 375×812 phone, so nothing else was ever on screen with it. It now paints 151 px, and the whole price block has gone from 57 % of the viewport to 45 %. Only the viewBox height moves — the rendered scale comes from the width — so the axis figures, the NOW marker and the peak and low markers come out at exactly the size they did before.
fedis what tells the app apart from the dashboard, and the dashboard's Energy tab is unchanged at every width.Fixed for both: the bottom y-axis figure lost its leading zero on a phone, rendering "0.00 ö" as ".00 ö". Each label is anchored just inside the left gutter and grows leftwards, and the gutter was a fixed 84 units while the phone's axis font is nearly three times the desktop one — so the widest label ran past the edge of the SVG and the browser clipped it. Even "335 ö" lost a hairline. The gutter is now measured from the labels the chart is about to write, which also covers the currencies whose unit is three characters wide.
fdef0da: The price chart's tooltip now reads the day it is drawing. The chart shows the horizon its toggle selects — today, tomorrow, or both — but the tooltip looked its slot up in every slot the box had sent, so with Tomorrow showing, the bar under your finger was tomorrow's and the price beside it was today's. Both days start at midnight, so the clock time printed correctly over the wrong day's number: on a phone, a bar at the top of a 96 öre axis read "9.00 öre". The slots being drawn now travel with the geometry that hit-tests them, since those are the two things that have to agree.
434122a: Require remote API authentication before listing or downloading backups or revealing managed CalDAV credentials.
c61f42f: The pairing QR can be scanned. The vendored encoder produced symbols no reader could decode above type 6: version information was never written, which the standard requires from type 7, and the timing line was drawn before the alignment patterns, so the centres that sit on row 6 and column 6 were skipped as already taken. Both faults are invisible — the symbol looks like a QR code and decodes as nothing. It stayed hidden because every payload the box had until now was short; the calendar link is a type 4, and the app pairing URL is 165 characters and lands on type 9. The settings tab also called the encoder as if it were a constructor, so it drew nothing at all. Covered by a test that decodes what the encoder produces, rather than counting modules — which is what the previous tests did, and why they passed throughout.
daded6e: The contract registry now names the command operations.
site.mode.setandbattery.holdwere hand-written twice — here beside the dispatcher, and in the app's simulator — with the scope each demands written twice more, and nothing compared the four. The registry's newopsblock is the one place the pair is written down: the generator renders it asRegistryOps, and a test holdsdefaultOps()to it, the same arrangement mode tiers already have. Containment rather than equality, deliberately — the registry may name an op ahead of the box, andbattery.holdis exactly that today: an app may say it, this box rejects it withE_UNKNOWN_OP, and nothing acts.The registry also takes in the last of the app's own error codes. Its header has always said every app-raised code has a home there; three did not —
E_NO_ACK,E_NO_ANSWERandE_BAD_BODYsat in the app alone, outside the check that keeps prose and retry rules honest. They now sit inclient_errorsbesideE_RESPONSE_TOO_LARGE. The box generates nothing from that block and must never send one of them; the copy here exists because the file is one file in two repositories, byte for byte.efd0f08: The stable software identity resolves again. It is anchored on the permanent MAC of whichever interface carries the box's uplink, and the question was asked against
uplink.home.sourceful.energy— Home Link, retired in August 2026, DNS record and all — so every resolution died at the lookup before a route was ever read. The question now goes torelay.ftw.energy, which the box dials anyway. The MAC is the identity and the host never was, so the same box derives the same identity over the live name.ab18152: Notice when a new Debian stable leaves the container base behind.
The images pin a codename (
debian:trixie-slim,python:3.12-slim-trixie)rather than
stable, so a major-version jump can never arrive silently on arebuild. Nothing noticed when a new stable shipped, and Dependabot cannot: it
orders numeric tags, and a suite codename has no numeric component to order.
A weekly check now reads the pin out of the three Dockerfiles and compares it
with Debian's own
stablerelease, failing when the pin falls behind or whenthe three images stop agreeing on one suite.
No runtime behaviour changes.