Skip to content

feat(cloud): draw FTW Cloud's history tiles while the box is away (C6) - #98

Merged
frahlg merged 6 commits into
mainfrom
feat/cloud-c6-tiles
Oct 23, 2026
Merged

frahlg merged 6 commits into
mainfrom
feat/cloud-c6-tiles

Conversation

@frahlg

@frahlg frahlg commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Step C6 of ADR 0008, the app's part. While the box cannot answer, a node's history comes from FTW Cloud's copy of the box's signed 5-minute tiles. It is dated, never shown as live.

Paired with srcfl/ftw#1817 (wire format and box) and srcfl/ftw-cloud#48 (the service). The vectors in Resources/cloud-tile-vectors.json come from srcfl/ftw 7dedcbf4.

What changes

  • Carrier/CloudTiles.swift: CloudWire.Tiles (decode, check()), tilesGet(have:), tiles.end, and lay(_:), a port of Go's cloudwire.Lay. It also adds CloudTileStore, which keeps the checked tiles for 48 hours in the order the box sent them.
  • CloudCarrier.tilesGet(have:) sends tiles.get.
  • SiteModel: tiles and tiles.end skip CloudView.accept's seq rule, since tiles are history, not the newest of a class.
    • Each tile must verify with the box key the phone got from the box over Noise, never a key from the service.
    • It must pass check() before it is kept.
    • The store empties when the copy goes off or the box key changes.
  • HistoryModel: when the box session does not stream and the cloud session with the copy is open, it asks FTW Cloud once per cloud session. It sends tiles.get with the closed tiles it holds as have.
    • Appends that come every five minutes are laid on at once.
    • Closed cloud tiles are the box's own bytes, so they share the tiles-<site> file. Cache first still draws frame one.
    • When the box answers again, its tiles replace the cloud's, and the label goes.
  • The label: under the chart, "From FTW Cloud · up to 16:10" ("Från FTW Cloud · fram till 16:10"). On another day it names the day too.
    • The label comes first, so a long sum is what wraps, never the date.
    • The header still says "AS OF" and never live.
  • Demo seam, Debug only: -FTWDemo YES -FTWDemoBoxAway YES runs a demo box that streams for 3 s and then goes away. Its history then comes through a simulated FTW Cloud, over the real Noise, signature, check and lay path. I used it for the screenshots.

Widgets: the grid widget does not use cloud tiles. It shows the box's own daily totals and cost from /api/savings/daily, which nothing can provide while the box is away. Mixing a cloud chart with no totals would break the widget's rule that every number is the box's.

NodeView.swift, which draft #93 also touches, gets three added lines in PeriodWords.summary and no other change.

Merge order with #99: #99 moves CloudWire.swift into a new FTWWire module. Carrier/CloudTiles.swift here extends CloudWire and uses its internal Fields and texts. Whichever merges second moves CloudTiles.swift's wire part (Tiles, check, lay, tilesGet) into FTWWire beside CloudWire.swift, or makes those two members public.

Tests

  • swift test in appleApp/FTWKit: 578 tests in 86 suites pass.
  • CloudTilesTests:
    • every vector verifies and passes check;
    • the forged vector fails, and so does an unsigned copy;
    • lay gives Go's exact tile id, etag and data in all five cases;
    • the tiles.get and tiles.end bytes match Go's;
    • check refuses 11 wrong shapes.
  • CloudHistoryTests:
    • with the box away, cloud tiles draw, dated and never "live";
    • an append moves the end;
    • when the box comes back, its own answer replaces them and the label goes;
    • forged, unsigned or wrong-etag tiles are never laid;
    • with the copy off, nothing is asked;
    • an older copy names its day;
    • after a relaunch with the box still away, the history stays dated as the copy;
    • a later closed tile replaces the first, in lay and in the store.

Screenshots, box away

Taken in a simulator (iPhone 17 Pro, iOS 26.5, Debug) with -FTWDemo YES -FTWDemoBoxAway YES. They are not attached here; they are in the FTW-stack shared notes folder, cloud-c6-shots/:

  • solar-48h-light.png: "From FTW Cloud · up to 16:25" above "48 h: made 20 kWh". The header says "AS OF 16:27" and the footer "NOT CONNECTED".
  • grid-today-dark.png: dark mode, the Today view.
  • battery-48h-sv-default.png: Swedish at the default text size. Both lines fit.
  • battery-48h-sv-xxxl.png: Swedish at the largest text size. The date line comes first and stays whole.

The sum leaves out the time after the copy ends (42488d9). It used to count those minutes as "not recorded", although the copy says nothing about them.

After review

  • check takes the four series and no other. A test ties HistoryModel.series to CloudWire.tileSeries.
  • The tile store keeps a closed tile the box sent later over one it sent before, whatever order they come in.
  • A closed tile from FTW Cloud never replaces one the box sent itself, only an earlier one from the copy.
  • After a relaunch, what is shown is dated as the copy only when the copy reaches as far. The box's own cached history is not labelled as the copy.

🤖 Generated with Claude Code

frahlg and others added 4 commits October 23, 2026 16:08
FTW Cloud keeps the box's signed 5-minute tiles of the last 48 hours
(srcfl/ftw docs/protocol-cloud.md, tiles). The phone checks each with the
key the box gave it over Noise, lays the pieces back together as Go's
cloudwire.Lay does, and asks with tiles.get once per cloud session while
the box session cannot answer. Closed tiles are the box's bytes, so they
share the tiles file. The line under the chart dates the copy ("From FTW
Cloud · up to 17:15"); the box's own answer replaces it the moment it
comes. cloud-tile-vectors.json from srcfl/ftw replays here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-FTWDemoBoxAway YES (Debug only): the demo box names its cloud key in
hello, streams for three seconds without answering hist.query, then goes.
SimulatedCloud runs the app session over loopback with the real Noise
handshake and answers tiles.get with tiles the demo box signed, so the
picture shows the real path: verify, lay, draw, dated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A long sum under the chart wraps into the second reserved line. With the
date first, that cuts the sum, never the line that says the history is a
copy and how old it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sum under the chart counted every step after the copy's last bucket
as "not recorded": at 16:25 with a copy up to 16:20 it said "10 min
not recorded", though the box may well have recorded them. The copy says
nothing about that time, so the history leaves it out; holes inside the
copy still count.

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-23T14:31:08.847182Z 42488d9 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: 42488d9c94

ℹ️ 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 thread appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift
… the first

After a relaunch with the box still away, the tiles file already held
the copy's tiles, FTW Cloud's answer brought nothing new, and the date
line went. Until the box answers, an answer that holds the tiles shown
now dates them as the copy.

lay and the tile store kept the first closed tile they saw. The box
sends a closed tile again when its ledger changed under it, so the later
one now replaces it, as srcfl/ftw#1817's Lay does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 23, 2026
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>
…e box wins

- check takes the four series and no other, as srcfl/ftw#1817's Check.
- The tile store keeps a closed tile the box sent later over one it sent
  before, whatever order they come in.
- A closed tile from FTW Cloud never replaces one the box sent itself,
  only an earlier one from the copy.
- After a relaunch, what is shown is dated as the copy only when the copy
  reaches as far; the box's own cached history is not labelled as it.
- A test ties HistoryModel.series to CloudWire.tileSeries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 607379d into main Oct 23, 2026
1 check passed
frahlg added a commit that referenced this pull request Oct 23, 2026
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>
frahlg added a commit that referenced this pull request Oct 23, 2026
…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 added a commit that referenced this pull request Oct 23, 2026
* Split the wire into FTWWire, which the watch can link

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>

* feat(watch): the watch on its own through FTW Cloud

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>

* Open CloudWire's body helpers to the package

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>

* fix(watch): a command keeps its path until the box acks, and never runs 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>

* fix(watch): watchOS accepts the complication's background read

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>

* refactor(wire): FTW Cloud's tiles and the history chunk move into FTWWire

#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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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