Repository navigation
feat(watch): the watch on its own through FTW Cloud - #99
Conversation
The watch must soon speak the box's wire itself, but the wire lived in FTWKit, which the watch may not link. FTWWire is a new library target that holds the wire and nothing else. It depends on FTWShared alone and builds for iOS, macOS and watchOS 26. Moved with git mv into Sources/FTWWire: - Crypto: Noise, Primitives, Transport - Protocol: CBOR, Frame, Messages, and Contract and Cars, which Messages needs - Carrier: CloudWire, CloudView - Identity: CloudKeys (CloudIdentity is part of hello_ok) - Support: Bytes, Base64url, and JSON, which Cars needs Cars.swift is split. The car and charger values the box sends stay in FTWWire; the car's words, CarLevel, Car.level and Car.shownName stay in FTWKit as Format/CarText.swift. Messages no longer reads the main-actor Session: the 8 MiB answer cap is now APIRequest.maxBytes, and Session.apiMaxBytes reads it. FTWKit depends on FTWWire and re-exports it with @_exported import, so no app, widget or test source needs a new import. Internals FTWKit uses are now package. Tests that reach wire internals add @testable import FTWWire. The FTWWatch target links the FTWWire product. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the phone away, the Apple Watch reads its home from FTW Cloud and charges the car through the box's exchange, each one HTTPS request (srcfl/ftw ADR 0009, "Through FTW Cloud"). The relay that was to carry the exchange stopped on 17 October 2026. - FTWWire: CloudOnce (the one-shot request), ExchangeSession (the box's exchange, the cmd the phone's car view sends, the box's answer) and WatchCloud (one read as the watch's records, never newer or more live than the box graded it). Swift replays srcfl/ftw's once, once-x and exchange vectors byte for byte. - The watch: CloudPath and PathChooser behind ReadPath and CommandPath. The phone when it answers; FTW Cloud once on each appearance when it does not, and for a command the phone has not taken within 3 s, under the same cmdId. WatchReach says whether commands can leave only when that changes, so the buttons hold still while the paths change places. A receipt the box confirmed stands against the other path's failure. The mode and "Which car?" stay with the phone. One background read per complication refresh, no timer while on screen. - The phone: the watch offers its public key over WatchConnectivity; an owner adds it with a passkey step-up (Settings, Apple Watch), and the context carries the watch's link. - Tests: CloudOnceTests, ExchangeTests, WatchCloudTests, WatchReachTests; watch UI test testChargeNowThroughFTWCloud against a local FTW Cloud and box (skipped without FTW_RIG_ARGS); phone UI tour of the watch row. Pairs with srcfl/ftw (wire and box) and srcfl/ftw-cloud. 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: c3301abe2f
ℹ️ 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".
| func take(_ next: WatchLink?) { | ||
| guard let next, next != link else { return } | ||
| link = next |
There was a problem hiding this comment.
Clear the cloud link when the phone signs out or changes homes
After a linked phone signs out, PathChooser.fromPhone passes the sign-out context's nil link here, but this guard retains the previous home's link. Although WatchModel.take initially clears the displayed home, the next background refresh or offline appearance reads that old home through CloudPath, and WatchContext.merging accepts it as a home again, restoring its records and controls. Switching to another home that has no watch link has the same problem. Handle explicit sign-out and home changes by clearing the previous link and invalidating outstanding reads, while preserving it only for an opening or partial context for the same home.
Useful? React with 👍 / 👎.
| ctx.cars = CacheRecord(siteId: link.site, carrier: .cloud, srcState: answering ? .live : .stale, readAtMs: Double(sent), | ||
| value: CarEntry(cars: cars, owned: keptCars?.owned, asking: keptCars?.asking)) |
There was a problem hiding this comment.
Update owned-car values when constructing cloud car records
When the phone has already supplied a nonempty owned list, copying it unchanged causes WatchGlance.cars to display the cached owned car's socPct, target and loadpoint association instead of the freshly decoded charger values. For example, a car last seen at 20% on the phone continues showing 20% during subsequent standalone cloud reads even when the signed snapshot reports 60%, while the enclosing record receives a new timestamp. Reconcile the owned entries with the cloud data, or fall back to the refreshed charger representation rather than allowing stale owned values to override it.
Useful? React with 👍 / 👎.
| if now.state == .live, let next = view.nextGradeChange(nowMs: at) { | ||
| liveForMs = Double(max(0, next - at)) | ||
| ctx.now?.value.standsMs = liveForMs |
There was a problem hiding this comment.
Treat cloud freshness duration as a hard glow deadline
If a cloud snapshot arrives shortly before its source becomes lagging, liveForMs can be less than four seconds, but assigning it to standsMs does not enforce that limit. CloudPath.read forwards this record as a live frame, and WatchModel.frame feeds it into WatchGlance.Glow, whose duration is the maximum of four seconds, the inferred stream interval and standsMs + 1000. Consequently, a reading with only 100 ms of box-graded freshness can remain visibly live for at least four seconds without another read. Carry the cloud expiry separately or cap the glow duration so phone-stream grace periods cannot extend the box's freshness deadline.
Useful? React with 👍 / 👎.
FTWKit's tiles (#98) read their body with Fields, texts, Header and WireError beside the app's models. With CloudWire in FTWWire those need package access, whichever of the two merges first. The two merged build and pass 861 tests; FTWApp.swift then keeps both debug lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns twice after a restart The review of #99 found the watch's command rules spread through two views of the paths, where no test could reach them. They now live in the package: - WatchRoute (FTWShared) picks the path. A command stays the phone's until the box's ack comes back through it. When the phone says it could not send it, FTW Cloud carries it under the same cmdId (S1). - WatchCommander (FTWWire) runs one command through the box's exchange. Once a command has left, it ends in the box's answer or "no answer"; "too late" is the box's E_CMD_EXPIRED or a command that never left (S3). A new session whose hello_ok clock is behind the first one's comes from a box that restarted, and the command is not sent again (S5). 403 before sending asks for the watch to be added again; 429 is asked again a second later (N4). - An answer the build cannot read has its own step, and "no answer" keeps the box's ack (N3). - A command's life and every wait run on a monotonic clock (N5). - A tapped command cancels a read under way (N6), and a first refused read waits 30 s, not 15 min (N4). Tests run both against a fake box and service: silence, a lost answer, a restart, 403 before and after sending, 429, expiry before and after sending, an unsettled command, and the phone's failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
watchOS rejected the `energy.ftw.cloud-read` app refresh because the watch app's Info.plist did not list it, so a complication never read FTW Cloud in the background. The simulator log now shows the task registered instead of "Registration rejected". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FTWApp.swift keeps both debug lines: the watch row's `-FTWWatchKeyOffered` and the demo's `-FTWDemoBoxAway`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Wire #98's CloudTiles.swift read its bodies with CloudWire's helpers, which #99 moved into FTWWire. The wire part follows them: the tile messages, `CloudWire.Tiles`, `lay`, and the history types they build on (`Resolution`, `missingSample`, `HistQuery`, `HistChunk`, `HistEnd`, `HistoryGeometry`). `CloudTileStore` stays in FTWKit, and so does the plan, whose charger names FTWKit's `Loadpoint`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
With the iPhone away (LTE, or the phone at home), the Apple Watch showed only its cache and could not charge the car. The relay exchange that ADR 0009 planned for the watch stopped on 17 October 2026. Now FTW Cloud carries it: reads and commands each go as one HTTPS request.
What
FTWWire. A new SwiftPM module holds the wire that the phone and the watch both speak. FTWKit re-exports it, and the watch links it, never FTWKit. It holds:
CloudWireandCloudView;CloudOnce, the one-shot request;ExchangeSession, the box's exchange and the samecmdthe car view sends;WatchCloud, which turns one read into the watch's records, never newer or more live than the box graded them.Swift replays srcfl/ftw's once, once-x and exchange vectors byte for byte.
Watch.
CloudPathandPathChoosersit behind the existingReadPathandCommandPath. They are thin: the rules live in the package, with tests.WatchRoute(FTWShared) picks the path, andWatchCommander(FTWWire) runs a command through FTW Cloud.Reads. The phone when it answers. Otherwise FTW Cloud, once each time the app comes on screen. A live Now glows only as long as the box's grade holds, then shows its time. No timer while on screen. One read per complication refresh, within watchOS's budget. A first refused read waits 30 s, in case it beat the roster; later ones wait 15 min.
Commands. Through the phone when it is reachable, until the box's ack comes back through it. FTW Cloud carries the command too, under the same
cmdId(the box acts once), when:The phone simulator says it is reachable when it is off, and the 3 s rule covers that. A tapped command cancels a read under way, so it never waits behind one.
A command's end.
E_CMD_EXPIRED, or a command that never left.hello_okclock is behind the first one's comes from a box that restarted. The box has forgotten thecmdId, so the watch does not send the command again and says no answer came.No flicker.
WatchReachsays whether commands can leave only when that changes, so the buttons hold still while the paths change places. A receipt the box confirmed is not undone by the other path saying it could not carry it.The mode and "Which car?" stay with the phone.
Phone. The watch offers its public key over WatchConnectivity. An owner adds it in Settings → Apple Watch, with a passkey step-up. The row shows only when the box says
cmd.exchange. After that, the watch's context carries its link: the box's keys, FTW Cloud's home id and tables, and what Charge now asks of each charger.Pictures
These come from the watch simulator against a local
ftwcloudd(srcfl/ftw-cloud#49) and the box (srcfl/ftw#1819). The paired iPhone simulator was shut down; the struck-out phone icon is at the top of 3–6.The box recorded the hold as
SetBy:app:JQelTyna, the watch's own device id.The phone's new row, in the demo:
Review fixes
cmdId.WatchRouteandWatchCommander. Their tests run against a fake box (Noise IK,hello_okclock, a command log a restart forgets) and a fake service.Proof
swift test: 867 tests green (588 FTWKit, 279 FTWShared) before main was merged in; 884 after.CloudOnceTests,ExchangeTests,WatchCloudTests,WatchReachTests,WatchRouteTestsandWatchCommanderTests.WatchCommanderTestscases: silence before and after sending, a lost answer (asked again, run once), a restart (not sent again), 403 before and after sending, 429, expiry before and after sending, an unsettled command, the box's own expiry, a revoked grant, an unreadable answer, and the mode costing no request.WatchRouteTestscases: the phone's failure hands the command to FTW Cloud, the 3 s rule, without FTW Cloud, without the phone, life left, and one read per look.FTWWatch, watchOS Simulator) and iPhone (FTW, iOS Simulator),-jobs 4.WatchTour.testChargeNowThroughFTWCloudagainst the rig (ftwclouddfrom feat(once): carry a watch's exchange to its box as x ftw-cloud#49 at efee312, the box from feat(cloud): a watch's exchange rides FTW Cloud ftw#1819 at f681e28d), with the iPhone shut down mid-run, after the review fixes:SetBy:app:<watch id>. The phone said "command not delivered" and FTW Cloud carried the command.FTW_RIG_ARGS, so CI is unaffected.energy.ftw.cloud-read. Fixed in e82d692; the log now shows the task registered.WatchSettingsTour.testTheWatchRow(pictures).Not proven / follow-ups
-FTWWatchKeyand-FTWWatchLink.planto FTW Cloud yet.nowthrough the exchange is not built.Pairs
srcfl/ftw#1818, srcfl/ftw#1819, srcfl/ftw-cloud#49.
With #98 (history tiles). Main with #98 is merged in (2aa6af8).
FTWApp.swiftkeeps both debug lines. 339f4cf moves the tiles' wire part into FTWWire, with the history types it builds on:Resolution,missingSample,HistQuery,HistChunk,HistEndandHistoryGeometry.CloudTileStorestays in FTWKit, and so does the plan, whose charger names FTWKit'sLoadpoint. On main with #98:swift test884 green (605 FTWKit, 279 FTWShared), and the watch and iPhone apps build.🤖 Generated with Claude Code