Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
44e6438
feat(contract): add the FTW Cloud names
frahlg Oct 12, 2026
4896e61
feat(protocol): add the FTW Cloud wire format
frahlg Oct 12, 2026
b0e4970
feat(cloudlink): stream a signed copy of the box to FTW Cloud
frahlg Oct 12, 2026
8a030e2
feat(cloud): grant routes, roster and adapters for the FTW Cloud uplink
frahlg Oct 12, 2026
9bf7d78
fix(cloudlink): send lp on change of state, not on every reading
frahlg Oct 12, 2026
a87386a
perf(cloudlink): read the box no faster than a message can go
frahlg Oct 12, 2026
8dbc3bd
Merge remote-tracking branch 'origin/master' into claude/cloud-c2-a453be
frahlg Oct 12, 2026
0698908
fix(cloudlink): take the purge receipt before the service hangs up
frahlg Oct 12, 2026
cc6b36c
feat(protocol): FTW Cloud app session wire and a Noise initiator
frahlg Oct 13, 2026
7490c64
feat(appwire): name the initiator's key after message 1
frahlg Oct 13, 2026
8cca420
feat(protocol): signed events, push registration and the alert payload
frahlg Oct 13, 2026
c85761d
feat(cloud): session information in hello_ok, cloud.link and statisti…
frahlg Oct 13, 2026
c6301a0
fix(cloudwire): a dropped message leaves the phone's view as it was
frahlg Oct 13, 2026
19ea338
feat(cloud): send the box's notifications to FTW Cloud as signed events
frahlg Oct 13, 2026
fe35932
test(sim-ocpp): a charger that stalls with the car still asking
frahlg Oct 13, 2026
5036d09
Merge remote-tracking branch 'origin/claude/cloud-c4-core' into claud…
frahlg Oct 13, 2026
a9e6f66
feat(cloudwire): an alert can be anchored by rule U
frahlg Oct 13, 2026
0ff1837
Merge branch 'master' into claude/cloud-c3c4-core
frahlg Oct 13, 2026
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
5 changes: 5 additions & 0 deletions .changeset/ftw-cloud-app-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Let phones read their home from FTW Cloud. While FTW Cloud is on, the box tells every paired phone, over its own encrypted session, the key it signs with and the names behind its anonymous ids, so a phone can check what the service forwards. Boxes that can run the uplink say so to the app. `POST` and `DELETE /api/cloud/stats` record and withdraw an owner's consent to statistics; nothing is sent for it yet.
5 changes: 5 additions & 0 deletions .changeset/ftw-cloud-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

With FTW Cloud on and events in the grant, the box sends its notifications as signed events for the owners' phones; names never leave the house. A restart the box chooses, an update among them, tells FTW Cloud so it holds back "box out of reach". A new notification, "The car may not be ready", tells once per plug-in when the box knows the car's level and the plan cannot reach the goal by the ready time. A car that reports nothing itself gets "Car charged" in kWh once the box's estimate from a level someone set reaches the target. "Car plugged in" now waits 90 seconds and is sent only when the box needs the level or a goal. Each notification transport now has its own 10-second limit, so one that hangs no longer delays the others.
5 changes: 5 additions & 0 deletions .changeset/ftw-cloud-uplink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Add the FTW Cloud uplink, off unless an owner turns it on. `GET /api/cloud` shows the grant and the bytes sent in the last 24 hours, `GET /api/cloud/preview` shows the exact bytes that leave the house, and `POST` and `DELETE /api/cloud/grant` turn it on and off. Release builds name no FTW Cloud service yet, so the box refuses a grant and never dials. Backups no longer include `cloud.key`.
24 changes: 24 additions & 0 deletions .github/scripts/check-cloudlink-imports.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# go/internal/cloudlink streams the box's state to FTW Cloud, and FTW Cloud
# reads and never controls. This check keeps the uplink's build graph honest:
# it may import go/protocol and nothing else of the box's own, so no control,
# API, loadpoint, planner or driver code can be reached from the code that
# talks to the service. Its tests are held to the same rule.

set -euo pipefail

cd "${1:-go}"

reached=$(go list -deps -test -f '{{.ImportPath}}' ./internal/cloudlink | awk '
$1 ~ /^github\.com\/srcfl\/ftw\/go\// &&
$1 !~ /^github\.com\/srcfl\/ftw\/go\/protocol(\/|$)/ &&
$1 !~ /^github\.com\/srcfl\/ftw\/go\/internal\/cloudlink(\.test| |$)/ { print $1 }
' | sort -u)

if [ -n "${reached}" ]; then
echo "go/internal/cloudlink may import only go/protocol from the box. It reaches:" >&2
printf '%s\n' "${reached}" >&2
exit 1
fi

echo "go/internal/cloudlink: $(go list -deps ./internal/cloudlink | wc -l | tr -d ' ') packages, none from the box but go/protocol"
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ jobs:
go test -count=1 ./...
- name: Protocol module imports nothing from the box
run: bash .github/scripts/check-protocol-boundary.sh go/protocol
# FTW Cloud reads and never controls. The uplink's build graph says so:
# it reaches no control, API or loadpoint code.
- name: FTW Cloud uplink imports nothing that controls
run: bash .github/scripts/check-cloudlink-imports.sh go

web:
name: web
Expand Down
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

.PHONY: help test compose-migration-test container-boundary-test release-workflow-test build build-arm64 build-amd64 release release-linux release-linux-arm64 release-linux-amd64 \
run-sim sim-ocpp dev fmt vet clean e2e ci ci-ui ci-hw-pi docs \
verify verify-all install-hooks driver-repository-validate driver-versions protocol-boundary \
verify verify-all install-hooks driver-repository-validate driver-versions protocol-boundary cloudlink-boundary \
drivers drivers-present driver-versions-across-pin

VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
Expand Down Expand Up @@ -48,6 +48,7 @@ help:
@echo " ci-hw-pi deploy candidate to Pi CI slot + browser smoke"
@echo " fmt vet Go format + static checks"
@echo " protocol-boundary prove go/protocol imports nothing from the box"
@echo " cloudlink-boundary prove the FTW Cloud uplink reaches no control code"
@echo " drivers fetch drivers/ from the pinned device-drivers commit"
@echo " clean nuke build artifacts"

Expand Down Expand Up @@ -147,7 +148,7 @@ ci-hw-pi:
#
# verify-all checks both supported Linux targets on the developer host.

verify: test compose-migration-test container-boundary-test release-workflow-test native-solver-test protocol-boundary
verify: test compose-migration-test container-boundary-test release-workflow-test native-solver-test protocol-boundary cloudlink-boundary
cd go && go vet -tags=$(GO_TAGS) ./...
cd go/protocol && go vet ./...
cd go && go build -tags=$(GO_TAGS) ./...
Expand Down Expand Up @@ -247,6 +248,9 @@ vet:
protocol-boundary:
bash .github/scripts/check-protocol-boundary.sh go/protocol

cloudlink-boundary:
bash .github/scripts/check-cloudlink-imports.sh go

clean:
rm -rf bin release
cd go && go clean
Expand Down
15 changes: 15 additions & 0 deletions contract/push-catalogue.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ events:
- kind: charging.session_complete
title: Car charged
body: "{kwh} kWh delivered — ready to go."
- kind: charging.goal_at_risk
# Only when the box knows the car's level, from the car or from someone
# who set it, and the plan cannot reach the goal by the ready time. An
# assumed level would make this a guess. Once per plug-in.
title: The car may not be ready
body: "It will not reach {target} % by {by} at this pace."
- kind: charging.interrupted
# Only after the hysteresis window the box applies: a charge that had
# been running steadily and then stopped without finishing. A flapping
Expand All @@ -45,3 +51,12 @@ events:
# has. The box encrypts it before it ever leaves home.
title: Your box is out of reach
body: "It stopped answering. Power cut, or the internet at home is down."
- kind: cloud.granted
# To every owner, when an owner turns FTW Cloud on. Sharing a readable
# copy of the house is a choice every owner should hear about.
title: FTW Cloud is on
body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."
- kind: cloud.revoked
# To every owner, when FTW Cloud is turned off at either door.
title: FTW Cloud is off
body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."
27 changes: 24 additions & 3 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
# The single source for every name shared between this app and the box, and
# the same file in both repositories — byte for byte.
#
# In srcfl/ftw it generates Go constants: go/internal/appproto/contract_gen.go
# and go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`.
# In srcfl/ftw it generates Go constants: go/protocol/contract_gen.go and
# go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`.
# In srcfl/ftw-webapp there is no generator — src/lib/protocol/contract.ts and
# the error table in src/lib/protocol/messages.ts are written by hand and read
# back against this file by tests/registry-contract.test.ts.
Expand Down Expand Up @@ -76,6 +76,10 @@ capabilities:
# history.5m. Present does not mean every path is reachable: reads and
# configuration go through, anything that moves energy stays on cmd.
- api.passthrough
# The box can run the FTW Cloud uplink. Absent means the app uses the relay
# only and offers no FTW Cloud. It says nothing about whether the owner
# turned FTW Cloud on; the box's grant decides that.
- cloud.link

# ---------------------------------------------------------------------------
# Scopes. One object axis, two verb axes: <object>.<read|write>.
Expand All @@ -87,6 +91,7 @@ scopes:
- { name: ftw.plan.read, desc: Dispatch plan and its reasoning }
- { name: ftw.health.read, desc: Box and driver health }
- { name: ftw.assets.read, desc: Devices and their capabilities }
- { name: ftw.events.read, desc: Events behind notifications }
- { name: ftw.dispatch.write, desc: Control energy assets }
- { name: ftw.mode.write, desc: Change site operating mode }
- { name: ftw.members.read, desc: See who has access }
Expand All @@ -100,6 +105,22 @@ roles:
label: Viewer
scopes: [ftw.live.read]

# ---------------------------------------------------------------------------
# FTW Cloud. What an owner can let the box share with FTW Cloud, and why.
# docs/protocol-cloud.md in srcfl/ftw is the wire format.
#
# cloud_scopes are the scopes a grant may name: the box sends a class only
# when the grant holds its scope, and a device reads it only when its own
# role carries the scope too. ftw.assets.read is left out on purpose: the
# box sends no device inventory, names or capabilities to FTW Cloud.
#
# cloud_purposes are what the data may be used for. serve is the owner's own
# phones and widgets. stats is household statistics, a separate consent that
# is off unless the owner turns it on.
# ---------------------------------------------------------------------------
cloud_scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read]
cloud_purposes: [serve, stats]

# ---------------------------------------------------------------------------
# Command operations. What a `cmd` frame may name, and the scope the grant
# must carry before the box acts on it.
Expand Down Expand Up @@ -219,7 +240,7 @@ client_errors:
# the two into one enum cannot express "connected, but the inverter is quiet".
# ---------------------------------------------------------------------------
source_states: [live, lagging, stale, down, never]
carrier_states: [webrtc, relay, cache, none]
carrier_states: [webrtc, relay, cloud, cache, none]

# ---------------------------------------------------------------------------
# History resolutions backed by what the box actually stores.
Expand Down
10 changes: 7 additions & 3 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ func startAppLink(
siteMeterStale time.Duration,
gateway *lateAPI,
webPush *notifications.WebPush,
cloud *cloudSession,
) (*appenroll.Identity, *appuplink.Uplink, bool, error) {
enabled := cfg != nil && cfg.AppLink.On()

Expand Down Expand Up @@ -610,9 +611,12 @@ func startAppLink(
API: gateway,
Caller: caller,
Grants: grants,
Caps: caps,
Codec: appuplink.Codec(),
Sender: sender,
// FTW Cloud is asked per session: its link is bound after
// this returns, and its grant moves while the box runs.
Caps: cloud.caps(caps),
Cloud: cloud.info,
Codec: appuplink.Codec(),
Sender: sender,
// The three frozen power fields point at the source whose
// freshness governs them. The site meter is the only one the
// box can name without knowing the site's hardware.
Expand Down
Loading
Loading