Skip to content

feat(cloud): FTW Cloud C4: the box's notifications as signed events - #1602

Closed
frahlg wants to merge 3 commits into
claude/cloud-c2-a453befrom
claude/cloud-c4-core
Closed

frahlg wants to merge 3 commits into
claude/cloud-c2-a453befrom
claude/cloud-c4-core

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

FTW Cloud step C4 on the box: notifications go to FTW Cloud as signed events, so a home's phones get them through APNs. The service wraps them unchanged; the phone checks the box's signature, writes its own words and picks the buttons from the verified kind. FTW Cloud never chooses what a user can do. Builds on #1595 (C2); based on its branch.

Contract-pair: srcfl/ftw-webapp@claude/cloud-c4-catalogue

Paired pull requests

What changes

go/protocol/cloudwire/push.go (Apache-2.0):

  • Event: the event class with eventId, ts, kind, severity, args, src and lp, plus the header's seq, boot, uptimeMs and wallMs. The field allowlist gains its row.
  • PushRegister/PushUnregister, which a phone sends over its app session.
  • AlertPayload: the APNs payload. It has the same aps for every alert (localised generic line, mutable-content: 1) and the box's envelope under ftw.event. box.unreachable is the only ftw.kind a phone takes from the service.
  • ReadAlert is the reference for the phone's extension: a box-signed event, anchored by wallMs (at most 2 s ahead of the phone), at most 10 minutes old, of a kind in the catalogue. Anything else gets the generic line.
  • Vectors: cloud-vectors.json gains three signed events. The existing signatures are unchanged. push-vectors.json holds 14 payloads, the phone's clock and the verdict each must get; the app replays them byte for byte.

K5 in notifications:

  • A catalogue message carries Kind (dispatchCatalogue set none), Args, the device it concerns and when it happened.
  • Every transport publishes side by side under its own 10 s limit, so a gateway that hangs costs only itself.
  • CloudPublisher only queues.

cloudlink:

  • QueueEvent keeps up to 32 events and never blocks.
  • The writer signs and sends them ahead of anything else due, past the byte budget too.
  • It cuts opaque ids for the driver and the loadpoint and drops name arguments: driver.offline goes with src, never name.
  • An event that waited longer than 10 minutes is dropped and counted (GET /api/cloud → eventsDropped). The grant's ftw.events.read gates it all.
  • Link.Bye sends bye{why:"update"} for a restart the box chose (the API restart, which a native update also uses) and bye{why:"shutdown"} on SIGTERM, then stops dialling. FTW Cloud holds back "box out of reach" for an hour after the first.

New and changed kinds (B3 in the iPhone plan):

  • charging.goal_at_risk (new, catalogue paired): once per plug-in, from a plan that is neither pending nor outdated, when the shortfall is at least 1 kWh, the ready time is ahead and the level is not assumed. Opt-in like the other charging rules.
  • charging.session_complete for a car that reports nothing itself: when the box's estimate from a level someone set reaches the target. Said in kWh, once, and never after a restart beside a car that is already done (it must have drawn power in this process).
  • charging.connected waits 90 s, then goes only when the level is assumed or no goal is set. A car that reports its own level within that time and has a goal needs no question.
  • sim-ocpp gains POST /charger/{id}/stall and /resume, a charger that stops delivering while it still reports Charging, which the end-to-end test uses for charging.interrupted.

For #1589's protocol text (not edited here):

  1. event gains ts: the moment of the event, which can be earlier than wallMs when it waited for the uplink.
  2. The alert payload shape and AlertMaxAgeMs above.
  3. Events wait in the ring across a reconnect for at most 10 minutes. Events that already left are never sent again.

Tests

go/protocol:            go test ./...   ok (cloudwire: golden and push vectors, allowlist)
go/internal/cloudlink:  go test -race   ok (5 new: signed events without names, scope gate, 10-minute drop, ring of 32, bye update stops the link)
go/internal/notifications: go test -race ok (K5 kind and args, a hanging transport does not delay another, cloud publisher, plug-in gating, goal at risk words)
go/internal/loadpoint:  ok (5 new: done by estimate, assumed never done, a car with its own data, restart beside a finished car, goal at risk once per plug-in)
go/internal/api, go/cmd/ftw: ok
.github/scripts/check-cloudlink-imports.sh: none from the box but go/protocol
.github/scripts/check-protocol-boundary.sh: none from the box

End-to-end, on this laptop

Before the APNs key existed

Setup

  • Core from srcfl/ftw claude/cloud-c4-core (19ea338c, fe359327), with the endpoint, a local service key and a dev root set by -ldflags -X.
  • ftwcloudd from srcfl/ftw-cloud claude/cloud-c4-eb03d7, built with -tags ftwclouddev and -dev-apns-capture. That flag writes each alert to a file instead of sending it to Apple.
  • The house is simulated: sim-ferroamp, sim-sungrow, and sim-ocpp as an Easee charger.
  • The app from srcfl/ftw-app claude/ios-m3-notify (Debug), on the simulator "FTW M3" (iPhone 17, iOS 26.5, Swedish).
    • It was paired with that Core over the production relay (-FTWPairLink).
    • Its sandbox APNs token was registered through the dev route.
    • The box key and loadpoint table were seeded with -FTWCloudHome …, standing in for C3's K11.
  • Delivery: a script hands each captured payload to the simulator with xcrun simctl push.
Step What happened
Grant POST /api/cloud/grant {scopes: [ftw.live.read, ftw.events.read]} on loopback. The uplink connected; home fid4fo4j….
Plug in, 08:35:37 Core sent charging.connected at 08:37:09: level assumed, no goal, after the 90 s settle. The service sent the alert to 1 phone 11 ms later.
The payload cloudwire.ReadAlert with the box's key: verdict=event kind=charging.connected lp=7edYjyHw-MQ seq=108. The aps is the generic one, and the event is the box's envelope unchanged.
80 % in the background On the lock screen the notification showed 20 %, 50 %, 80 %, Öppna (category added as the extension sets it). Tapped 80 % at 08:40:46. The app checked the payload again, mapped 7edYjyHw-MQ → garage and sent loadpoint.soc.set. The box's result came in 1,002 ms: setLevel(pct: 80.0) answered in 1002 ms, push: ftw.level.80: done: FTW planerar nu från 80 %. The box then read soc_source: inferred, current_soc: 0.80.
A forged payload One byte of the signed body flipped (charging.connected → charging.bonnected). ReadAlert gave verdict=generic. In the app, a tap on 20 % logged push: ftw.level.20 not sent: generic, and the box stayed at 80 %.
charging.interrupted Charge now at 11 kW from 08:41:40. The charger stalled at 08:52:05 with the car still asking. Core sent the event at 08:53:39 (90 s to confirm), and the service sent it 3 ms later. ReadAlert gave verdict=event kind=charging.interrupted.
Planned update POST /api/restart at 08:54:00. Core sent bye{why:"update"} and exited; it has no supervisor here. 11.5 minutes later no alert had left the service.
A crash, for contrast Core came back, then kill -9 at 09:06:18. The service logged a box went away bye="" out_of_reach_after=10m0s, and at 09:16:18 a box is out of reach; telling its phones, with the alert to 1 phone. Its payload is {"ftw":{"v":1,"home":"fid4fo4j…","kind":"box.unreachable"}}, and ReadAlert gives verdict=unreachable.

Through Apple's sandbox, with the real key

The same house, with ftwcloudd sending to api.sandbox.push.apple.com. Key K9299SY46H, team L66M368PZ6, read from SSM Parameter Store into a temporary folder and deleted afterwards; nothing printed it. The extension (FTWNotify) now ran for every push.

Step What happened
box.unreachable (sent with /dev/push/raw) Apple 200. The extension logged push: unreachable. The lock screen showed TIDSKÄNSLIG · Din box går inte att nå – "Den har slutat svara. Strömavbrott, eller så är internet hemma nere."
Plug in, 09:31:29 Core notifications: sent event=charging.connected at 09:32:59.870. The service sent to Apple at 09:33:00.032, and the extension logged push: event charging.connected at 09:33:00.138, 268 ms after Core. On screen: Bilen är inkopplad – "Öppna för att se laddningen, kolla bilens nivå och välja klartid."
Buttons from the extension 20 %, 50 %, 80 % and Öppna, from the verified kind; nothing in the payload names a category.
80 % in the background, 09:34:09 setLevel(pct: 80.0) answered in 990 ms, push: ftw.level.80: done: FTW planerar nu från 80 %. The box went from soc_source: assumed, current_soc: 0.20 to inferred, 0.80.
A forged payload through Apple The last real event from the box's preview, one byte flipped (charging.bonnected), sent with /dev/push/raw. Apple 200; the extension logged push: generic. The lock screen showed "Något hände hemma / Öppna FTW för att se vad.", and "Visa" opened it with no buttons.
charging.interrupted Charge now from 09:35:07, charger stalled at 09:45:30. Core sent at 09:47:01.877, and the extension logged push: event charging.interrupted at 09:47:02.076. On screen: TIDSKÄNSLIG · Laddningen avbröts – "Bilen slutade ladda innan den var klar." Buttons: Ladda nu, Öppna.
Ladda nu in the background iOS started the app cold. chargeNow answered in 9433 ms, push: ftw.chargeNow: done: FTW har startat laddningen. Väntar på att bilen tar ström.

The planned update and the crash ran in the first part. They never reach Apple: no alert left the service for the update, and the crash's box.unreachable is the payload shown in the first row here. Screenshots are kept with the test, outside the repository.

Not done

  • expect.session on loadpoint.soc.set (B4), so a notification button cannot correct a later plug-in. The 10-minute window for buttons covers most of it.
  • update.installed and the other kinds go as before. Only the charging kinds changed.

🤖 Generated with Claude Code

frahlg and others added 3 commits October 13, 2026 07:49
FTW Cloud C4 carries notifications. cloudwire gains the event class body
(eventId, ts, kind, severity, args, src, lp), the phone's push.register
and push.unregister, and the APNs payload the service builds: the same
aps for every alert, and the box's envelope unchanged under "ftw".
ReadAlert is the reference for what a phone's notification extension
makes of a payload: a fresh, anchored, box-signed event of a known kind,
the service's own box.unreachable, or the generic line.

The catalogue gains charging.goal_at_risk. cloud-vectors.json gains three
signed events; push-vectors.json holds fourteen payloads, the phone's
clock and the verdict each must get, for the service and the app to
replay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
K5 and the Core half of FTW Cloud C4.

- A catalogue message carries its kind, its arguments, the device it
  concerns and when it happened (dispatchCatalogue set no kind before).
- Every transport publishes side by side under its own 10 s limit, so a
  gateway that hangs costs only itself.
- notifications.CloudPublisher only queues. cloudlink.QueueEvent keeps up
  to 32 events; the writer signs each as an event with eventId, ts and
  seq, cuts opaque ids for the driver and loadpoint, and drops name
  arguments. An event older than ten minutes is dropped and counted in
  GET /api/cloud (eventsDropped). The grant's events scope gates it all.
- Link.Bye sends bye{why:"update"} for a restart the box chose, and
  bye{why:"shutdown"} on SIGTERM, then stops dialling, so FTW Cloud holds
  back box.unreachable through an update.
- charging.goal_at_risk: once per plug-in, from a fresh plan's shortfall
  (at least 1 kWh), only on a level that is not assumed and a ready time
  still ahead. Opt-in like the other charging rules.
- charging.session_complete for a car that reports nothing: when the
  box's estimate from a level someone set reaches the target, in kWh,
  once, and not after a restart beside a car that is already done.
- charging.connected waits 90 s and goes only when the level is assumed
  or no goal is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /charger/{id}/stall makes the simulated charger deliver nothing
while it still reports Charging, as a tripped relay does; /resume lets it
go on. It is what charging.interrupted is for, and the FTW Cloud C4
end-to-end test raises one with it.

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

frahlg commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Included in #1607 (FTW Cloud C2, C3 and C4 joined), squash-merged to master.

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

1 participant