Repository navigation
Conversation
…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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| static var environment: PushEnvironment { | ||
| #if DEBUG | ||
| return .sandbox | ||
| #else | ||
| return .production |
There was a problem hiding this comment.
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 👍 / 👎.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
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:
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 ofmainkept #16, #17 and #22 as they are.Paired pull requests
go/protocol/cloudwire/push.gois the reference this ports, with the vectors replayed here)charging.goal_at_risk)CloudKeysfrom the box's session info (K11) and becomes thePushTokenSinkthat sendspush.register. Until it lands, a debug build seeds both by launch argument.What changes
FTWShared:
PushAlertis the Swift port ofcloudwire.ReadAlert, with a small, strict CBOR reader (PushCBOR).PushTextholds Swedish and English for all ten kinds plus the generic line. A test holds the English toprotocol/push-catalogue.yaml, copied byte for byte. kWh uses the reader's decimal mark and is never shown as a percentage.PushNoticeandPushActionsdecide the rest.charging.connected.charging.interruptedandcharging.goal_at_risk..timeSensitiveis forbox.unreachableandcharging.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 olderseqfor a (home, boot) shows the generic line quietly, with no buttons.FTWNotify (new target, a Notification Service Extension):
aps.categorynever gives buttons.App:
.authenticationRequiredand no.foreground, so they run in the background.soc.setor Charge now throughIntentRunner.UIApplicationDelegateAdaptor, plusPushTokenSink/PushTokensfor C3.aps-environmentand Time Sensitive entitlements.-FTWPushRegister,-FTWCloudHome/BoxKey/Loadpoint/Source/Siteand-FTWPairLink.Tests
End-to-end, on this laptop
Before the APNs key existed
Setup
claude/cloud-c4-core(19ea338c,fe359327), with the endpoint, a local service key and a dev root set by-ldflags -X.ftwclouddfrom srcfl/ftw-cloudclaude/cloud-c4-eb03d7, built with-tags ftwclouddevand-dev-apns-capture. That flag writes each alert to a file instead of sending it to Apple.sim-ferroamp,sim-sungrow, andsim-ocppas an Easee charger.claude/ios-m3-notify(Debug), on the simulator "FTW M3" (iPhone 17, iOS 26.5, Swedish).-FTWPairLink).-FTWCloudHome …, standing in for C3's K11.xcrun simctl push.POST /api/cloud/grant {scopes: [ftw.live.read, ftw.events.read]}on loopback. The uplink connected; homefid4fo4j….charging.connectedat 08:37:09: level assumed, no goal, after the 90 s settle. The service sent the alert to 1 phone 11 ms later.cloudwire.ReadAlertwith the box's key:verdict=event kind=charging.connected lp=7edYjyHw-MQ seq=108. Theapsis the generic one, and the event is the box's envelope unchanged.7edYjyHw-MQ→garageand sentloadpoint.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 readsoc_source: inferred, current_soc: 0.80.charging.connected→charging.bonnected).ReadAlertgaveverdict=generic. In the app, a tap on 20 % loggedpush: ftw.level.20 not sent: generic, and the box stayed at 80 %.charging.interruptedReadAlertgaveverdict=event kind=charging.interrupted.POST /api/restartat 08:54:00. Core sentbye{why:"update"}and exited; it has no supervisor here. 11.5 minutes later no alert had left the service.kill -9at 09:06:18. The service loggeda box went away bye="" out_of_reach_after=10m0s, and at 09:16:18a 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"}}, andReadAlertgivesverdict=unreachable.Through Apple's sandbox, with the real key
The same house, with
ftwclouddsending toapi.sandbox.push.apple.com. KeyK9299SY46H, teamL66M368PZ6, read from SSM Parameter Store into a temporary folder and deleted afterwards; nothing printed it. The extension (FTWNotify) now ran for every push.box.unreachable(sent with/dev/push/raw)200. The extension loggedpush: 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."notifications: sent event=charging.connectedat 09:32:59.870. The service sent to Apple at 09:33:00.032, and the extension loggedpush: event charging.connectedat 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."setLevel(pct: 80.0) answered in 990 ms,push: ftw.level.80: done: FTW planerar nu från 80 %.The box went fromsoc_source: assumed, current_soc: 0.20toinferred, 0.80.charging.bonnected), sent with/dev/push/raw. Apple200; the extension loggedpush: 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.interruptedpush: event charging.interruptedat 09:47:02.076. On screen: TIDSKÄNSLIG · Laddningen avbröts – "Bilen slutade ladda innan den var klar." Buttons: Ladda nu, Öppna.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.unreachableis the payload shown in the first row here. Screenshots are kept with the test, outside the repository.Needs a person
energy.ftw.app.notifywith the App Group;energy.ftw.app;deploy/apns.md).Automatic signing does the first two when Xcode archives.
xcrun simctl pushalone never starts a service extension.Not done
SiteModel.cloudHellowritesCloudKeysafteradoptCloud, and removes the entry when the grant is off. Never in the demo.pushRegister(apnsToken:environment:)and becomes thePushTokenSink. It sends when the carrier is.open, and again at each reopening.CloudCache.Offset) goes intoCloudKeys, so the extension can also anchor a box without NTP.CloudWire.verifycan call the FTWShared check, so there is one ES256 check.🤖 Generated with Claude Code