Skip to content

feat(watch): the watch on its own through FTW Cloud - #99

Merged
frahlg merged 7 commits into
mainfrom
claude/watch-cloud
Oct 23, 2026
Merged

frahlg merged 7 commits into
mainfrom
claude/watch-cloud

Conversation

@frahlg

@frahlg frahlg commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

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:

  • Noise, frames, CBOR and the messages;
  • CloudWire and CloudView;
  • CloudOnce, the one-shot request;
  • ExchangeSession, the box's exchange and the same cmd the 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. CloudPath and PathChooser sit behind the existing ReadPath and CommandPath. They are thin: the rules live in the package, with tests. WatchRoute (FTWShared) picks the path, and WatchCommander (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 cannot be reached;
    • the phone says it could not send it;
    • the phone has not passed on the box's ack within 3 s.

    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.

    • Once a command has left, it ends in the box's answer or in "no answer", which keeps the box's ack when there was one. "Too late" is the box's E_CMD_EXPIRED, or a command that never left.
    • A new session whose hello_ok clock is behind the first one's comes from a box that restarted. The box has forgotten the cmdId, so the watch does not send the command again and says no answer came.
    • 403 before anything left says "Add this watch again from your iPhone". 429 is asked again a second later.
    • The command's life runs on a monotonic clock from the tap.
  • No flicker. 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 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.

Now, live from FTW Cloud After the glow: ink with its time Car, phone off
Charge now: sending The box confirmed it Still confirmed after the read

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

  • S1. 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.
  • S3. Once a command has left, its end is the box's answer or "no answer", never "too late".
  • S5. A box that restarted is not sent a command again. Its command log is in memory, and feat(cloudwire): x carries a watch's exchange through FTW Cloud ftw#1818's docs say so.
  • S6. The rules moved into WatchRoute and WatchCommander. Their tests run against a fake box (Noise IK, hello_ok clock, a command log a restart forgets) and a fake service.
  • N3. An answer this build cannot read has its own step, and "no answer" keeps the ack.
  • N4. 403 maps to "Add this watch again from your iPhone". A first refused read waits 30 s, not 15 min.
  • N5. A command's life and every wait run on a monotonic clock.
  • N6. A tapped command cancels a read under way.

Proof

  • swift test: 867 tests green (588 FTWKit, 279 FTWShared) before main was merged in; 884 after.
    • New suites: CloudOnceTests, ExchangeTests, WatchCloudTests, WatchReachTests, WatchRouteTests and WatchCommanderTests.
    • WatchCommanderTests cases: 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.
    • WatchRouteTests cases: 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.
    • The existing watch, contract and words tests still pass, and every new watch word has Swedish.
  • Builds: watch (FTWWatch, watchOS Simulator) and iPhone (FTW, iOS Simulator), -jobs 4.
  • WatchTour.testChargeNowThroughFTWCloud against the rig (ftwcloudd from 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:
    • It passed 4 of 5 runs, among them a cold watch and a fresh install. Each pass left one hold on the box, SetBy:app:<watch id>. The phone said "command not delivered" and FTW Cloud carried the command.
    • The first run failed: the car stayed a placeholder, so no read had landed. The watch's saved log holds no failed or refused read. The miss did not come back in the next four runs, and its cause is not known.
    • It skips without FTW_RIG_ARGS, so CI is unaffected.
  • The rig's log showed watchOS rejecting the complication's background read: the watch's Info.plist did not list energy.ftw.cloud-read. Fixed in e82d692; the log now shows the task registered.
  • WatchSettingsTour.testTheWatchRow (pictures).
  • An earlier run caught two faults, both fixed with tests:
    • The simulator says the phone is reachable when it is off, so a command waited on WatchConnectivity.
    • The phone's late "could not" overwrote the box's confirmation.

Not proven / follow-ups

  • Real watch, LTE, a passcode (the key's Keychain class), and background refresh on a device. The simulator builds were unsigned, so the App Group cache and Keychain were not exercised there. Debug launch arguments stood in: -FTWWatchKey and -FTWWatchLink.
  • The plan dial stays the phone's: the box does not send plan to FTW Cloud yet.
  • With the readable copy off, the watch commands but does not read. now through the exchange is not built.
  • Widget push and alerts to the watch.
  • The phone does not yet notice that a watch was removed on the box.

Pairs

srcfl/ftw#1818, srcfl/ftw#1819, srcfl/ftw-cloud#49.

With #98 (history tiles). Main with #98 is merged in (2aa6af8). FTWApp.swift keeps both debug lines. 339f4cf moves the tiles' wire part into FTWWire, with the history types it builds on: Resolution, missingSample, HistQuery, HistChunk, HistEnd and HistoryGeometry. CloudTileStore stays in FTWKit, and so does the plan, whose charger names FTWKit's Loadpoint. On main with #98: swift test 884 green (605 FTWKit, 279 FTWShared), and the watch and iPhone apps build.

🤖 Generated with Claude Code

frahlg and others added 2 commits October 23, 2026 16:07
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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 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-23T15:06:15.301222Z c3301ab PR opened
ℹ️ 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: 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".

Comment on lines +58 to +60
func take(_ next: WatchLink?) {
guard let next, next != link else { return }
link = next

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +59 to +60
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))

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 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 👍 / 👎.

Comment on lines +36 to +38
if now.state == .live, let next = view.nextGradeChange(nowMs: at) {
liveForMs = Double(max(0, next - at))
ctx.now?.value.standsMs = liveForMs

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 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 👍 / 👎.

frahlg and others added 5 commits October 23, 2026 17:08
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>
@frahlg
frahlg merged commit 05b0d32 into main Oct 23, 2026
1 check passed
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