Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .changeset/anonymous-fleet-ping.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

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.
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. 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.
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. The Fleet statistics panel in Settings 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 sharing is switched off that screen says nothing is being sent. On by default; the switch takes effect without a restart.
2 changes: 1 addition & 1 deletion .changeset/app-api-passthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Who is asking now exists as a value. `appenroll.Authorise` returns the grant it

A viewer cannot write, and the box is what refuses it. `cmd` finally checks the scope its own operation table has declared since the day it was written and never read, so `site.mode.set` from 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/credentials` hands out a password that is a write channel back into dispatch, and `POST /api/self_tune/start` pauses control and drives every battery through ±3000 W for minutes. Both read as ordinary from their verb alone.
Every registered route names what it costs, beside the handler it governs, and the request's method is never consulted. The method is not asked because it does not know: `GET /api/caldav/credentials` hands out a password that is a write channel back into dispatch, and `POST /api/self_tune/start` pauses 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 with `E_LOCAL_ONLY`. `POST /api/config` is 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.

Expand Down
4 changes: 2 additions & 2 deletions .changeset/app-roles-sharing-and-box-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ A household can share its home, and the box is what enforces the difference. An

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 `appenroll` rather 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.
Two rules stop a household locking itself out, both in `appenroll` rather than in the API layer. 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 stepped down through local Settings or an app session. An app session also cannot remove it; local Settings can, because the same page can pair a new phone.

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 `code` key — 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 is `E_LAST_OWNER_PROTECTED` from `contract/registry.yaml`, through the generated constant, never a literal at the call site.
The app's last-owner refusal now carries a code as well as a sentence. 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 `code` key — which the API had never sent, leaving the one refusal a household can meet through the app as the one refusal it could not explain. The code is `E_LAST_OWNER_PROTECTED` from `contract/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.

Expand Down
2 changes: 1 addition & 1 deletion .changeset/appproto-client-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"ftw": patch
---

The box can now speak the FTW web app's client protocol. A new `appproto` package 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 from `contract/registry.yaml` rather 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.
The box can now speak the FTW web app's client protocol. A new `appproto` package 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 from `contract/registry.yaml` rather than typed out, and a test fails when the two drift. This is the protocol used by the app link in this release.