Skip to content

iPhone M3/M4: notifications you can act on, through FTW Cloud - #18

Closed
frahlg wants to merge 11 commits into
mainfrom
claude/ios-m3-notify
Closed

frahlg wants to merge 11 commits into
mainfrom
claude/ios-m3-notify

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

iPhone M3 and M4: notifications you can act on, through FTW Cloud (C4). The box signs each event. FTW Cloud wraps it unchanged in an APNs alert, and a notification service extension on the phone does three things:

  • checks the box's signature with the key in the App Group;
  • writes the words itself in Swedish or English;
  • picks the buttons from the verified kind alone.

Anything that does not check out shows a generic line with no buttons. Every command from a notification needs the phone unlocked, and runs the M2 command path to the box over Noise. FTW Cloud never sends a command and never chooses a button. On main, after M2 (#17) landed; the merge of main kept #16, #17 and #22 as they are.

Paired pull requests

  • srcfl/ftw: srcfl/ftw#1602 (the event class, signed on the box; go/protocol/cloudwire/push.go is the reference this ports, with the vectors replayed here)
  • srcfl/ftw-cloud: srcfl/ftw-cloud#4 (APNs, registrations, dead man's switch)
  • srcfl/ftw-webapp: srcfl/ftw-webapp#82 (the catalogue source for charging.goal_at_risk)
  • C3 (the app reads from FTW Cloud), in flight: srcfl/ftw-app#19. It fills CloudKeys from the box's session info (K11) and becomes the PushTokenSink that sends push.register. Until it lands, a debug build seeds both by launch argument.

What changes

FTWShared:

  • PushAlert is the Swift port of cloudwire.ReadAlert, with a small, strict CBOR reader (PushCBOR).
  • PushText holds Swedish and English for all ten kinds plus the generic line. A test holds the English to protocol/push-catalogue.yaml, copied byte for byte. kWh uses the reader's decimal mark and is never shown as a percentage.
  • PushNotice and PushActions decide the rest.
    • The level category (20 %, 50 %, 80 %, Öppna) is for charging.connected.
    • Charge now (Ladda nu, Öppna) is for charging.interrupted and charging.goal_at_risk.
    • Every other kind and the generic line get no buttons.
    • .timeSensitive is for box.unreachable and charging.interrupted; everything else is .active. Nothing comes from the payload.
  • CloudKeys: per home, the box's FTW Cloud key and the tables from opaque ids to loadpoint ids and names, in the App Group. They are public, not secrets.
  • PushSeen: a repeated or older seq for a (home, boot) shows the generic line quietly, with no buttons.

FTWNotify (new target, a Notification Service Extension):

  • It links FTWShared only and holds the App Group and nothing else.
  • It builds new content every time and always sets the category: empty unless the event is verified, so a service-supplied aps.category never gives buttons.
  • When it runs out of time it shows the generic line.

App:

  • Categories registered at launch. The buttons carry .authenticationRequired and no .foreground, so they run in the background.
  • The button handler checks the original payload again with the time it arrived, maps the event's opaque loadpoint to the charger, and runs soc.set or Charge now through IntentRunner.
  • Push registration through a tiny UIApplicationDelegateAdaptor, plus PushTokenSink/PushTokens for C3.
  • aps-environment and Time Sensitive entitlements.
  • Debug launch arguments for the end-to-end test: -FTWPushRegister, -FTWCloudHome/BoxKey/Loadpoint/Source/Site and -FTWPairLink.

Tests

cd appleApp/FTWKit && swift test     FTWShared 89 tests in 9 suites passed; FTWKit 179 tests in 34 suites passed (after merging main)
xcodebuild -scheme FTW (iOS Simulator)   build succeeded; FTW.app/PlugIns holds FTWNotify.appex and FTWWidgets.appex

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.

Needs a person

  • Apple Developer:
    • register energy.ftw.app.notify with the App Group;
    • turn on Push Notifications and Time Sensitive Notifications for energy.ftw.app;
    • make the APNs key (srcfl/ftw-cloud deploy/apns.md).
      Automatic signing does the first two when Xcode archives.
  • A run on a real phone. The simulator run through Apple's sandbox is above; xcrun simctl push alone never starts a service extension.

Not done

  • Joining C3 and C4 (agreed with C3), after iPhone M3/M4: notifications you can act on, through FTW Cloud #18 and feat(cloud): the iPhone app reads its home from FTW Cloud first (C3) #19 land:
    • SiteModel.cloudHello writes CloudKeys after adoptCloud, and removes the entry when the grant is off. Never in the demo.
    • CloudCarrier gets a public pushRegister(apnsToken:environment:) and becomes the PushTokenSink. It sends when the carrier is .open, and again at each reopening.
    • The rule-U offset (CloudCache.Offset) goes into CloudKeys, so the extension can also anchor a box without NTP.
    • FTWKit's CloudWire.verify can call the FTWShared check, so there is one ES256 check.
  • Tapping a notification, or Öppna, opens Now rather than the car view.
  • A button press logs the box's answer. There is no follow-up notification yet.

🤖 Generated with Claude Code

frahlg and others added 10 commits October 12, 2026 20:22
…e Activity

Charge now is a Live Activity intent in FTWShared, so the Control in the
widget extension and the Live Activity's Back to the plan run in the app,
which holds the key. supportedModes is background; iOS 27 also names the
app as the only execution target. Every command needs the unlocked phone.
"Is the car ready?" answers a locked phone with the gist only, and its
card offers Charge now or Back to the plan.

LoadpointsModel starts the Live Activity on the box's cmd.result for
Charge now and ends it on Back to the plan, Pause or a read without the
hold. Static words and a timer, no power, stale after an hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS 26.5's linkd treats an app whose metadata lists an included package
(extract.packagedata) as having no App Intents at all
(aggregateMetadataIsEmpty), so Siri, Shortcuts and the Control had
nothing to run. Xcode 27 already copies the FTWShared intents into the
app's and the extension's metadata without it, and iOS 27 reads both.

The Live Activity's title stays on one line, and the expanded island
puts it below the camera. -FTWDemoChargeNow presses Charge now in the
demo for screenshots (Debug only).

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

CarLevel is 20/40/60/80, as the car view's chips: each case is a tile in
Shortcuts and Spotlight, and ten were clutter. The card under "Is the car
ready?" no longer repeats the answer Siri just gave; it shows the headline
again only once a button on it has changed the state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a watch's Smart Stack the card is narrow: the timer takes the top
line next to the icon, and "Charging now since 14:02" the line below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PR check builds with Xcode 26.6, whose SDK has no
IntentExecutionTargets. The iOS 27 declaration now needs Swift 6.4,
which ships with that SDK; the release build (Xcode 27.0) keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FTWShared reads a push from FTW Cloud the way the Go reference
(cloudwire.ReadAlert) does: a strict CBOR reader for the envelope and
the event, ES256 over the bytes received with the box key the phone
holds, the box clock checked against the phone's, and the catalogue's
kinds only. The tests replay all 14 push vectors and the 16 messages of
cloud-vectors.json from srcfl/ftw, then sign edge cases with the vector
box key.

PushText writes every kind in Swedish and English; the English is the
push catalogue's, copied byte for byte to protocol/push-catalogue.yaml
and held to it by a test. PushActions picks buttons from a verified
kind alone. CloudKeys keeps each home's box key and id tables in the
App Group, PushSeen drops a repeated or older seq per home and boot.

The app's own strings carry the generic line under the payload's loc
keys, for when the extension does not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FTWNotify (energy.ftw.app.notify) links FTWShared only and holds the
App Group alone. It checks each push with PushAlert, drops one PushSeen
has shown, and delivers new content: the words from PushText in the
person's language, the category set every time ("" when the event is
not verified), the interruption level from the kind, one thread per
home, and the checked fields beside the original payload in userInfo.
Out of time, it shows the generic line with no buttons.

The target follows the widget's: a synchronized folder, an Info.plist
that reads the group from FTW_APP_GROUP, embedded in the app, so the
shared scheme builds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The extension first handed iOS empty content for an event it had shown.
In the simulator, `simctl push` never starts a service extension, so
whether iOS then shows nothing could not be checked there. Without
Apple's filtering entitlement, iOS is documented to show the payload's
own alert instead, and that one could carry a category the service
chose.

So a repeat gets the generic line with the category set to none, no
sound and the passive level: it lands in Notification Center and
interrupts nobody. Dropping it outright needs the filtering
entitlement, which Apple grants on request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At launch the app registers two categories in Swedish or English:
20 %, 50 % and 80 % with Open after "Car plugged in", and Charge now
with Open after "Charging stopped early" or "The car may not be ready".
The commands need the phone unlocked and run in the background; Open
brings the app up.

A pressed button checks the original payload again with PushAlert, at
the time the notification arrived, and acts only for a verified event
whose kind offers that button. CloudKeys maps the event's opaque
loadpoint to the charger (the only one, if the event names none), and
the command goes only to the home the app opens. It takes the path
Siri and the Control take: open the session, wait for the stream,
send, follow cmd.result. The box's answer is logged, nothing more.
`perform` takes the charger a notification names and never swaps it
for another; the level was already any whole percent.

The push token: a tiny app delegate (the one UIKit piece besides the
camera) hands it to PushTokens in FTWKit, which gives it to a
PushTokenSink once one is installed. FTW Cloud's channel (C3) will be
that sink, and installing it is when the person is asked; until then
a release build asks nothing. The app gets aps-environment and the
time-sensitive entitlement.

Debug builds take launch arguments for the end-to-end test:
-FTWPushRegister YES asks and registers at launch and logs the token,
and -FTWCloudHome, -FTWCloudBoxKey, -FTWCloudLoadpoint,
-FTWCloudSource and -FTWCloudSite seed one home's CloudKeys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`-FTWPairLink <url>` offers the link as a scanned QR would. The simulator
has no camera, and the FTW Cloud C4 end-to-end test pairs a simulator with
a local Core this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main has M2 (#17, squashed), Now detail (#16) and #22. Every conflict
took main's side, then this branch's own changes since the M2 tip it was
cut from (2c946bf) went back on top:
- IntentRunner and LoadpointsModel keep main's guards for another home's
  card and the shared-record flush, and gain the notification's
  loadpointID.
- The Swedish strings keep main's lines and add the push fallback.
- project.pbxproj is main's (with FTWUITests and FTWCharts) plus the
  FTWNotify target's entries, placed at the end of each section.

swift test: FTWShared 89 tests, FTWKit 179 tests, all pass. The simulator
build embeds FTWNotify.appex and FTWWidgets.appex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg marked this pull request as ready for review October 13, 2026 14:13
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-13T14:17:00.196634Z 42071e4 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42071e4a0d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +32 to +36
static var environment: PushEnvironment {
#if DEBUG
return .sandbox
#else
return .production

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive the APNs environment from the signed entitlement

When the build configuration and provisioning profile do not match—such as running a Release configuration with a development profile—APNs issues a sandbox token, but this code labels it as production, so FTW Cloud sends it to the wrong APNs endpoint and notifications never arrive. Apple documents that the aps-environment value is determined by the current provisioning profile, not the DEBUG compilation condition; read the effective entitlement or otherwise keep the reported environment aligned with signing. Apple APS Environment Entitlement

Useful? React with 👍 / 👎.

frahlg added a commit that referenced this pull request Oct 13, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 13, 2026
…d first (#18, #19, #21, #20)

One squash for four stacked PRs:
- #18: M3/M4, notifications you can act on, delivered through FTW Cloud, with the FTWNotify extension that checks the box's signature.
- #19: C3, the app reads its home from FTW Cloud first and falls back to the relay. It also passes the box's time zone to the shared prices and gives a cancelled unlock its own text, in English and Swedish.
- #21: release builds dial FTW Cloud with its production Noise key.
- #20: C3 + C4, the FTW Cloud session carries the push token.

A live run of a release build against cloud.ftw.energy showed "Live via FTW Cloud" at a median of 262 ms from a cold start, against 611 ms through the relay.

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

frahlg commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Included in #20, merged as 6a4e2ba (#20 merged this branch in whole).

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