From 8caf83c8830aa71abbe7801ef8316483b62d4b65 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 16:08:03 +0200 Subject: [PATCH 1/6] feat(cloud): draw FTW Cloud's history tiles while the box is away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- appleApp/FTW/UI/NodeView.swift | 6 +- .../Sources/FTWKit/Carrier/CloudCarrier.swift | 9 + .../Sources/FTWKit/Carrier/CloudTiles.swift | 180 ++++++++++++++++++ .../Sources/FTWKit/State/HistoryModel.swift | 104 +++++++++- .../Sources/FTWKit/State/NodeWords.swift | 1 + .../Sources/FTWKit/State/SiteModel.swift | 52 ++++- .../Tests/FTWKitTests/CloudCarrierTests.swift | 20 ++ .../Tests/FTWKitTests/CloudHistoryTests.swift | 164 ++++++++++++++++ .../Tests/FTWKitTests/CloudTilesTests.swift | 174 +++++++++++++++++ .../Resources/cloud-tile-vectors.json | 177 +++++++++++++++++ .../FTWKitTests/SiteModelCloudTests.swift | 8 + 11 files changed, 890 insertions(+), 5 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-tile-vectors.json diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 4852377a..68a3be21 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -1043,7 +1043,11 @@ private enum PeriodWords { } guard let frame = models.history.frame, frame.points > 0 else { return models.history.problem ?? " " } let e = EnergyToday.sum(models.history.samples(node, fromMs: fromMs), fromMs: fromMs) - return NodeSummary.energy(node, e, today: period == .today) + let energy = NodeSummary.energy(node, e, today: period == .today) + // FTW Cloud's copy while the box is away: dated, on a line of + // its own. + guard let cloud = models.history.cloudNote(nowMs: site.minuteMs, zone: zone) else { return energy } + return energy + "\n" + cloud case .ahead: let now = site.nowMs // "No plan" only once the box has answered without one; until diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index 1e739878..a6a01d22 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -150,6 +150,15 @@ public final class CloudCarrier { ])) } + /// Ask for the box's history tiles this phone lacks: `have` names the + /// closed tiles it holds, by id and etag. The box's tiles come back as + /// envelopes, then `tiles.end`. False when the session is not open and + /// nothing went out. + @discardableResult + public func tilesGet(have: [String: String]) -> Bool { + send(CloudWire.msgTilesGet, CloudWire.tilesGet(have: have)) + } + /// The app came to the front or the network changed: dial now if the /// session is not open, after a refusal too. public func wake() { diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift new file mode 100644 index 00000000..05361783 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift @@ -0,0 +1,180 @@ +import Foundation + +/// FTW Cloud's tiles: the last 48 hours of the box's five-minute history, so +/// a phone can draw it while the box cannot answer (srcfl/ftw +/// `docs/protocol-cloud.md`, tiles). A port of Go's `cloudwire/tiles.go`; +/// `CloudTilesTests` replays its vectors. +/// +/// A tiles body is the box's `hist.chunk` with the common header, signed by +/// the box. It comes in three shapes, all at five minutes and stride 1: +/// closed (every point, the same bytes the box serves over Noise), whole (a +/// trailing tile with every point, missing where a bucket has not closed), +/// and an append (only the buckets that closed since the last piece). +extension CloudWire { + /// Phone to service, `{have}`: the closed tiles the phone holds, by id + /// and etag. The service answers with the box's tiles the phone lacks. + public static let msgTilesGet = "tiles.get" + /// Service to phone, `{n}`: the answer to `tiles.get` is complete. + public static let msgTilesEnd = "tiles.end" + + /// The columns the box sends, in this order: what the history asks the + /// box for, so a closed tile is the same bytes either way. + public static let tileSeries = ["grid_w", "pv_w", "battery_w", "load_w"] + /// How far back the service keeps tiles, and the phone holds them. + public static let tileKeepMs: Int64 = 48 * 3_600_000 + /// How many pieces of the trailing tile a holder keeps. + static let tilePiecesMax = 16 + static let tileSeriesMax = 16 + static let tileStepMs: Int64 = 300_000 + static let tileSpanMs: Int64 = 43_200_000 + static let tilePoints = 144 + + /// One tile, or one piece of the trailing tile. + public struct Tiles: Equatable, Sendable { + public var header: Header + public var tileId: String + public var etag: String + public var res: String + public var startMs: Int64 + public var stepMs: Int64 + public var series: [String] + /// int32 LE, one block per series, `missingSample` for a hole. + public var data: Bytes + public var partial: Bool + + /// The start of the tile this body belongs to. + public var tileStart: Int64 { startMs / CloudWire.tileSpanMs * CloudWire.tileSpanMs } + public var tileEnd: Int64 { tileStart + CloudWire.tileSpanMs } + /// Points per series. + public var points: Int { series.isEmpty ? 0 : data.count / 4 / series.count } + /// A closed tile, or a whole piece that replaces every piece of its + /// tile before it. + public var whole: Bool { startMs == tileStart } + + /// Whether this is one of the three shapes. Nothing else is held or + /// laid. + public func check() throws { + func bad(_ why: String) -> WireError { WireError(code: "E_CLOUD_TILE", message: why) } + guard res == Resolution.fiveMinutes.rawValue, stepMs == CloudWire.tileStepMs else { throw bad("not 5-minute steps") } + guard startMs >= 0, startMs % stepMs == 0 else { throw bad("start off the 5-minute grid") } + guard tileId == HistoryGeometry.tileID(.fiveMinutes, stride: 1, startMs: Double(tileStart)) else { + throw bad("tile id does not name the tile its start is in") + } + guard !series.isEmpty, series.count <= CloudWire.tileSeriesMax else { throw bad("series count") } + guard Set(series).count == series.count, series.allSatisfy({ !$0.isEmpty && $0.utf8.count <= 64 }) else { throw bad("series names") } + let n = points + guard n >= 1, data.count == n * 4 * series.count else { throw bad("data is not whole columns") } + guard Int((startMs - tileStart) / stepMs) + n <= CloudWire.tilePoints else { throw bad("data runs past the tile") } + guard partial || (whole && n == CloudWire.tilePoints) else { throw bad("a closed tile that is not the whole tile") } + guard etag == HistoryGeometry.etag(data) else { throw bad("etag") } + } + + /// The tile as the box session carries it. + public var chunk: HistChunk { + HistChunk(tileId: tileId, etag: etag, res: .fiveMinutes, startMs: Double(startMs), stepMs: Double(stepMs), series: series, data: data, partial: partial) + } + } + + /// `tiles.get`'s body. + static func tilesGet(have: [String: String]) -> CBOR { + .map([("have", .map(have.sorted { $0.key < $1.key }.map { ($0.key, CBOR.text($0.value)) }))]) + } + + /// Puts tiles back together in the order the box sent them: a closed tile + /// as it is; the trailing tile from its newest whole piece, with each + /// later append laid over it. A piece of a tile already closed changes + /// nothing, and neither does a body that fails `check`. One chunk per + /// tile, oldest first; a trailing tile stays partial, its etag that of + /// the data as laid, never one to offer as held. + public static func lay(_ bodies: [Tiles]) -> [HistChunk] { + var byID = [String: HistChunk]() + for t in bodies { + guard (try? t.check()) != nil else { continue } + var held = byID[t.tileId] + if let h = held, !h.partial { continue } + guard t.partial else { + byID[t.tileId] = t.chunk + continue + } + let per = tilePoints + if held == nil || t.whole || held?.series != t.series { + held = HistChunk(tileId: t.tileId, etag: "", res: .fiveMinutes, startMs: Double(t.tileStart), stepMs: Double(t.stepMs), + series: t.series, data: missing(t.series.count * per), partial: true) + } + guard var c = held else { continue } + let n = t.points + let at = Int((t.startMs - t.tileStart) / t.stepMs) + for s in t.series.indices { + c.data.replaceSubrange((s * per + at) * 4..<(s * per + at + n) * 4, with: t.data[(s * n * 4)..<((s + 1) * n * 4)]) + } + byID[t.tileId] = c + } + return byID.values.map { c -> HistChunk in + var c = c + if c.partial { c.etag = HistoryGeometry.etag(c.data) } + return c + }.sorted { $0.startMs < $1.startMs } + } + + /// `n` samples of `missingSample`. + private static func missing(_ n: Int) -> Bytes { + let u = UInt32(bitPattern: missingSample) + return Bytes((0..> 8 & 0xff), UInt8(u >> 16 & 0xff), UInt8(u >> 24)] }) + } +} + +extension CloudWire.Tiles { + public init(body: Bytes) throws { + let f = try CloudWire.Fields(body: body) + self.init( + header: try CloudWire.Header(f), + tileId: try f.text("tileId"), + etag: try f.text("etag"), + res: try f.text("res"), + startMs: try f.int("startMs"), + stepMs: try f.int("stepMs"), + series: try CloudWire.texts(f["series"], "series"), + data: try f.bytes("data"), + partial: try f.bool("partial") + ) + } +} + +/// FTW Cloud's tiles of one home as this phone holds them: each body as the +/// box signed it and the phone checked it, at most the last 48 hours, the +/// trailing tile's pieces in the order the box sent them. +struct CloudTileStore: Equatable, Sendable { + private(set) var bodies: [CloudWire.Tiles] = [] + + /// The order the box sent them: by its clock, then by `seq` within one + /// send time. Tiles go only while the box's clock is synced, so every + /// one carries `wallMs`. + private static func sent(_ t: CloudWire.Tiles) -> (Int64, UInt64) { (t.header.wallMs ?? 0, t.header.seq) } + + /// Keeps one checked body. A closed tile replaces every piece of its + /// tile; a whole piece every piece the box sent before it. A body held + /// already (a forwarded append that came again in an answer) changes + /// nothing. + mutating func take(_ t: CloudWire.Tiles, nowMs: Int64) { + bodies.removeAll { $0.tileEnd <= nowMs - CloudWire.tileKeepMs } + guard t.tileEnd > nowMs - CloudWire.tileKeepMs else { return } + if bodies.contains(where: { $0.header.boot == t.header.boot && $0.header.seq == t.header.seq }) { return } + if bodies.contains(where: { $0.tileId == t.tileId && !$0.partial }) { return } + if !t.partial { + bodies.removeAll { $0.tileId == t.tileId } + } else if t.whole { + bodies.removeAll { $0.tileId == t.tileId && Self.sent($0) < Self.sent(t) } + } + bodies.append(t) + bodies.sort { Self.sent($0) < Self.sent($1) } + let pieces = bodies.filter { $0.tileId == t.tileId && $0.partial } + if pieces.count > CloudWire.tilePiecesMax, let oldest = pieces.first { + bodies.removeAll { $0 == oldest } + } + } + + /// The tiles laid back together, oldest first. + var laid: [HistChunk] { CloudWire.lay(bodies) } + + var isEmpty: Bool { bodies.isEmpty } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift index ea5bfc26..75a2b9f6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift @@ -15,6 +15,12 @@ import Observation /// The window is primed once after the first live frame, newest tile /// first, and asked for again when the five minutes turn while a node's /// view is open. +/// +/// While the box cannot answer, FTW Cloud's copy can: it keeps the box's +/// signed tiles of the last 48 hours. They are asked for once per cloud +/// session and laid over what the phone holds, closed tiles being the same +/// bytes either way. They are dated, never live: `cloudUpToMs` says where +/// they end, and the box's own answer replaces them the moment it comes. @Observable @MainActor public final class HistoryModel { @@ -32,6 +38,12 @@ public final class HistoryModel { public private(set) var loading = false /// A sentence about the wire, never about the home. public private(set) var problem: String? + /// What is shown came partly from FTW Cloud's copy, since the box last + /// answered. + public private(set) var fromCloud = false + /// Where FTW Cloud's copy ends: the end of the newest five minutes with + /// a value. Nil unless what is shown came from it. + public private(set) var cloudUpToMs: Double? @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private var ask: LiveAsk? @@ -49,6 +61,11 @@ public final class HistoryModel { /// The load on the wire. A second caller waits for it rather than /// asking again. @ObservationIgnored private var running: Task? + /// The FTW Cloud session last asked for tiles, by when it opened, and + /// whether its answer is complete. + @ObservationIgnored private var cloudAsked: Double? + @ObservationIgnored private var cloudAsking = false + @ObservationIgnored private var cloudAnswered = false @ObservationIgnored let calendar: Calendar public init(site: SiteModel, calendar: Calendar = .current) { @@ -68,6 +85,8 @@ public final class HistoryModel { // in the name; otherwise the right edge says "now" all day. return "history \(Int(self.site.nowMs / Self.stepMs))" }, ask: { [weak self] in try await self?.load() }) + site.observeCloudTiles { [weak self] end in self?.cloudTiles(end: end) } + site.observe { [weak self] in self?.askCloud() } } /// Whether this box keeps history at all. Unknown until its hello. @@ -78,6 +97,7 @@ public final class HistoryModel { public func activate() { active += 1 ask?.evaluate() + askCloud() } public func deactivate() { @@ -128,6 +148,9 @@ public final class HistoryModel { loaded = true primed = true problem = nil + // The box's own answer: FTW Cloud's copy has nothing to add. + fromCloud = false + cloudUpToMs = nil } catch { guard mine == token else { return } problem = Self.failure(error, hadSome: frame != nil) @@ -155,6 +178,81 @@ public final class HistoryModel { } } + // MARK: FTW Cloud's copy + + /// The box streams: it answers for itself. + private var boxAnswers: Bool { site.session.phase == .streaming } + + /// While the box cannot answer, ask FTW Cloud once per session for the + /// tiles this phone lacks: the closed tiles held go as `have`. + private func askCloud() { + guard !boxAnswers, available, site.documentVisible, let since = site.cloudCopyOpenSinceMs, cloudAsked != since else { return } + cloudAsked = since + cloudAnswered = false + let (plan, _, _) = window() + var have = [String: String]() + for p in plan.tiles { if let t = tiles[p.tileId] { have[t.tileId] = t.etag } } + cloudAsking = site.askCloudTiles(have: have) + takeCloud() + } + + private func cloudTiles(end: Bool) { + if end, cloudAsking { + cloudAsking = false + cloudAnswered = true + } + takeCloud() + } + + /// FTW Cloud's tiles onto what is shown, unless the box answered while + /// it streams. A closed tile is the box's bytes and fills in; the tile + /// still filling is taken only when it reaches further than the one + /// shown. + private func takeCloud() { + guard !(boxAnswers && loaded) else { return } + let (plan, from, to) = window() + var took = false + for c in site.cloudTiles.laid where plan.tiles.contains(where: { $0.tileId == c.tileId }) { + if c.partial { + guard tiles[c.tileId] == nil, Self.reach(c) > open.map(Self.reach) ?? -.infinity else { continue } + open = c + } else { + guard tiles[c.tileId]?.etag != c.etag else { continue } + tiles[c.tileId] = c + if open?.tileId == c.tileId { open = nil } + } + took = true + } + if took { fromCloud = true } + show(plan, from, to) + cloudUpToMs = fromCloud ? frame.flatMap(Self.dataEndMs) : nil + } + + /// Where a tile's values end: the end of its newest step with a value + /// in any series, or its start. + nonisolated static func reach(_ c: HistChunk) -> Double { + let columns = HistoryGeometry.unpack(c.data, seriesCount: c.series.count) + let last = columns.compactMap { $0.lastIndex { $0 != missingSample } }.max() + return c.startMs + Double((last ?? -1) + 1) * c.stepMs + } + + /// The end of a frame's newest step with a value, nil when it has none. + nonisolated static func dataEndMs(_ frame: HistoryGeometry.Frame) -> Double? { + let last = frame.columns.compactMap { $0.lastIndex { $0 != missingSample } }.max() + return last.map { frame.time(at: $0) + frame.stepMs } + } + + /// "From FTW Cloud · up to 17:15": where the shown history came from + /// and where it ends, dated so it never reads as now. Nil unless it + /// came from FTW Cloud's copy. + public func cloudNote(nowMs: Double, zone: TimeZone = .current) -> String? { + guard let at = cloudUpToMs else { return nil } + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let today = cal.isDate(Date(timeIntervalSince1970: at / 1000), inSameDayAs: Date(timeIntervalSince1970: nowMs / 1000)) + return NodeWords.format("From FTW Cloud · up to %@", today ? AppWords.clock(at, zone: zone) : AppWords.day(at, nowMs: nowMs, zone: zone)) + } + /// The closed tiles and the open one, laid on the window. Past the open /// tile's newest value nothing is known yet: those steps have not /// arrived, so they read as on their way, not as a hole the box has. @@ -198,9 +296,9 @@ public final class HistoryModel { return Self.samples(frame, node: node, fromMs: fromMs, fetching: pending) } - /// A tile not here yet is on its way: while the box is asked, or - /// before it has answered once and nothing has failed. - var pending: Bool { loading || (!loaded && problem == nil) } + /// A tile not here yet is on its way: while the box or FTW Cloud is + /// asked, or before either has answered once and nothing has failed. + var pending: Bool { loading || cloudAsking || (!loaded && problem == nil && !cloudAnswered) } /// `fetching`: a hole whose tile has not arrived is on its way, not /// known to be empty. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 1c2a0394..8287df8a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -106,6 +106,7 @@ public enum NodeWords { "Your box keeps no history.": "Boxen sparar ingen historik.", "No history yet. Your box is out of reach.": "Ingen historik än. Boxen går inte att nå.", "Not up to date. Your box is out of reach.": "Inte uppdaterat. Boxen går inte att nå.", + "From FTW Cloud · up to %@": "Från FTW Cloud · fram till %@", "Your box can't send its history right now. Still trying.": "Boxen kan inte skicka sin historik just nu. Försöker igen.", "Couldn't get the plan from your box. Still trying.": "Fick ingen plan från boxen. Försöker igen.", "Your box couldn't plan ahead just now, so it runs on safe defaults.": "Boxen kunde inte planera framåt just nu, så den kör på säkra standardval.", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 9e79eae6..4bdde303 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -185,6 +185,11 @@ public final class SiteModel { /// When the newest cloud envelope arrived, for the time of a reading the /// anchor cannot place. @ObservationIgnored private var cloudArrivalMs: Double? + /// FTW Cloud's history tiles of this home, each signed by the box and + /// checked here, for the history while the box cannot answer. + @ObservationIgnored private(set) var cloudTiles = CloudTileStore() + /// Hear each tile taken, and with `true` each answer to `tiles.get`. + @ObservationIgnored private var tileListeners: [@MainActor (Bool) -> Void] = [] /// What the notification extension was last given for this home. @ObservationIgnored private var sharedCloudHome: CloudHome? /// Whether FTW Cloud's session takes the phone's APNs token, and the @@ -1156,6 +1161,7 @@ extension SiteModel { // carry the very session that brought this hello. if cloudHome != identity.home { closeCloud() } cloudView = CloudView(home: identity.home, boxKey: identity.pk) + cloudTiles = CloudTileStore() ruleU = nil } cloudIdentity = identity @@ -1178,6 +1184,7 @@ extension SiteModel { uninstallPushSink() cloudView = nil cloudIdentity = nil + cloudTiles = CloudTileStore() ruleU = nil cloudDirty = false if let siteId { files?.remove("cloud-\(siteId)") } @@ -1309,13 +1316,25 @@ extension SiteModel { private func onCloudSnapshot(_ snapshot: CloudWire.Snapshot, _ at: Double) { var any = false - for env in snapshot.envelopes where acceptCloud(env, at) { any = true } + for env in snapshot.envelopes where env.t != CloudWire.msgTiles && acceptCloud(env, at) { any = true } + for env in snapshot.envelopes where env.t == CloudWire.msgTiles { takeCloudTile(env) } first("cloud snapshot") if any { cloudRead(at) } else { publish() } scheduleClock() } private func onCloudEnvelope(_ env: CloudWire.Envelope, _ at: Double) { + // Tiles are history, not the newest of a class: they skip the + // freshness rules, and are checked and kept apart. + switch env.t { + case CloudWire.msgTiles: + takeCloudTile(env) + return + case CloudWire.msgTilesEnd: + for f in tileListeners { f(true) } + return + default: break + } guard acceptCloud(env, at) else { return } if env.t == CloudWire.msgLive { first("cloud live") } cloudRead(at) @@ -1334,6 +1353,37 @@ extension SiteModel { return true } + /// One history tile from FTW Cloud: kept only when the box signed it, + /// checked with the key the box gave this phone over Noise, and it is + /// one of the shapes the box sends. Whatever fails is never laid. + private func takeCloudTile(_ env: CloudWire.Envelope) { + guard let identity = cloudIdentity, CloudWire.verify(boxKey: identity.pk, home: identity.home, envelope: env), + let tile = try? CloudWire.Tiles(body: env.b), (try? tile.check()) != nil else { return } + cloudTiles.take(tile, nowMs: Int64(scheduler.nowMs)) + for f in tileListeners { f(false) } + } + + /// Hear FTW Cloud's tiles: `false` for each tile taken, `true` when an + /// answer to `tilesGet` is complete. + func observeCloudTiles(_ f: @escaping @MainActor (Bool) -> Void) { + tileListeners.append(f) + } + + /// When the FTW Cloud session that carries the readable copy opened, or + /// nil while there is none. Tiles come only with the copy. + var cloudCopyOpenSinceMs: Double? { + guard cloudOn, cloudIdentity != nil, case .open(let since)? = cloud?.status else { return nil } + return since + } + + /// Ask FTW Cloud for the tiles this phone lacks. False when nothing + /// went out. + @discardableResult + func askCloudTiles(have: [String: String]) -> Bool { + guard cloudCopyOpenSinceMs != nil else { return false } + return cloud?.tilesGet(have: have) ?? false + } + private func cloudRead(_ at: Double) { cloudArrivalMs = at if readingSource() == .cloud { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift index 43d3b947..56ded57e 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift @@ -11,6 +11,8 @@ final class FakeCloudService { let staticKey = Primitives.generateKeyPair() var roster: Set = [] var held: [CloudWire.Envelope] = [] + /// The box's tiles the service holds, as it answers `tiles.get`. + var tiles: [CloudWire.Envelope] = [] var online = true var latencyMs: Double = 10 /// Refuse every handshake made with this key, as a service that has @@ -77,6 +79,7 @@ final class FakeCloudService { } received.append(env) if env.t == CloudWire.msgTun, let tun = try? CloudWire.Tun(body: env.b) { phoneTun(tun, on: socket) } + if env.t == CloudWire.msgTilesGet { answerTiles(env, on: socket) } return } let home = URLComponents(url: socket.url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "home" }?.value ?? "" @@ -106,6 +109,23 @@ final class FakeCloudService { if let t = body["tun"], let tun = try? CloudWire.Tun(body: encodeCBOR(t)) { phoneTun(tun, on: socket) } } + /// `tiles.get`: each tile held that the phone lacks, then `tiles.end`. + private func answerTiles(_ ask: CloudWire.Envelope, on socket: FakeCloudSocket) { + var have = [String: String]() + for e in (try? decodeCBOR(ask.b))?["have"]?.entries ?? [] { + if let k = e.key.string, let v = e.value.string { have[k] = v } + } + var n = 0 + for env in tiles { + if let t = try? CloudWire.Tiles(body: env.b), !t.partial, have[t.tileId] == t.etag { continue } + socket.push(env) + n += 1 + } + if let end = try? CloudWire.Envelope.decode(CloudWire.encodeUnsigned(t: CloudWire.msgTilesEnd, body: .map([("n", .int(n))]))) { + socket.push(end) + } + } + /// One `tun` from a phone: to the box, or ended at once when there is /// no box to take it. private func phoneTun(_ tun: CloudWire.Tun, on socket: FakeCloudSocket) { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift new file mode 100644 index 00000000..e3e77955 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing +@testable import FTWKit + +/// The history while the box cannot answer: FTW Cloud's tiles, signed by the +/// box and dated, and the box's own answer the moment it comes back. +@MainActor +@Suite struct CloudHistoryTests { + static let span: Int64 = 43_200_000 + static let step: Int64 = 300_000 + static let utc = TimeZone(identifier: "UTC")! + + /// A tile of the box's four series as the box signs it: points + /// [first, first + count) of the tile that starts at `start`, each a + /// value below `filled` and missing from there. Series s, point i holds + /// `base` × (s + 1) + i. + static func tile(_ w: CloudSite, start: Int64, first: Int = 0, count: Int = 144, filled: Int = 144, + closed: Bool = false, base: Int32 = 100, etag: String? = nil) throws -> CloudWire.Envelope { + let data = HistoryGeometry.pack((0..<4).map { s in + (first..<(first + count)).map { i in i < filled ? base * Int32(s + 1) + Int32(i) : missingSample } + }) + let h = w.box.header(w.at) + return try w.box.seal(CloudWire.msgTiles, h + [ + ("tileId", .text(HistoryGeometry.tileID(.fiveMinutes, stride: 1, startMs: Double(start)))), + ("etag", .text(etag ?? HistoryGeometry.etag(data))), ("res", .text("5m")), + ("startMs", .int(start + Int64(first) * step)), ("stepMs", .int(step)), + ("series", .array(CloudWire.tileSeries.map { .text($0) })), ("data", .bytes(data)), ("partial", .bool(!closed)), + ]) + } + + /// The box's own tile over Noise, for when it answers again. + static func boxTile(start: Int64, filled: Int, closed: Bool, base: Int32) -> HistChunk { + let data = HistoryGeometry.pack((0..<4).map { s in (0..<144).map { i in i < filled ? base * Int32(s + 1) + Int32(i) : missingSample } }) + return HistChunk(tileId: HistoryGeometry.tileID(.fiveMinutes, stride: 1, startMs: Double(start)), etag: HistoryGeometry.etag(data), res: .fiveMinutes, + startMs: Double(start), stepMs: Double(step), series: CloudWire.tileSeries, data: data, partial: !closed) + } + + /// A home whose box is away: the phone knows its key from an earlier + /// launch, and FTW Cloud holds yesterday's closed tile, today's tile + /// whole up to 30 buckets and an append of six more. + static func awayBox() throws -> (CloudSite, HistoryModel, Int64) { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: nil) + let start = w.at / span * span + w.service.tiles = [ + try tile(w, start: start - span, closed: true), + try tile(w, start: start, filled: 30), + try tile(w, start: start, first: 30, count: 6), + ] + w.open() + let history = HistoryModel(site: w.site) + w.connectCloud() + return (w, history, start) + } + + @Test func aBoxAwayDrawsFTWCloudsTilesDated() async throws { + let (w, history, start) = try Self.awayBox() + await w.run(100) + + let asked = try #require(w.service.received.first { $0.t == CloudWire.msgTilesGet }) + #expect(try decodeCBOR(asked.b)["have"]?.entries?.isEmpty == true) + #expect(history.fromCloud) + #expect(w.relay.sent.isEmpty, "nothing went to a box that is not there") + + // Yesterday's tile and the six buckets appended to today's. + let grid = history.samples(.grid, fromMs: Double(start - Self.span)) + #expect(grid.first?.watts == 100) + let today = grid.filter { $0.startMs >= Double(start) } + #expect(today[35].watts == 135) + #expect(today[36].watts == nil) + // FTW Cloud has answered: what it lacks is not on its way. + #expect(!grid.contains { $0.fetching }) + + // Dated where the copy ends, never as now. + let upTo = Double(start + 36 * Self.step) + #expect(history.cloudUpToMs == upTo) + let note = try #require(history.cloudNote(nowMs: w.scheduler.nowMs, zone: Self.utc)) + #expect(note == "From FTW Cloud · up to \(AppWords.clock(upTo, zone: Self.utc))") + #expect(!note.lowercased().contains("live")) + #expect(!note.lowercased().contains("now")) + + // A closed tile from the copy is the box's bytes: it goes into the + // same file the box's tiles do, and is offered as held next time. + #expect(history.file?.tiles.contains { $0.tileId == HistoryGeometry.tileID(.fiveMinutes, stride: 1, startMs: Double(start - Self.span)) && !$0.partial } == true) + + // The box sends the next bucket as it closes; FTW Cloud forwards it. + w.service.push(try Self.tile(w, start: start, first: 36, count: 1)) + await w.run(50) + #expect(history.cloudUpToMs == Double(start + 37 * Self.step)) + #expect(history.samples(.grid, fromMs: Double(start))[36].watts == 136) + } + + @Test func theBoxsAnswerReplacesTheCopy() async throws { + let (w, history, start) = try Self.awayBox() + await w.run(100) + #expect(history.fromCloud) + + // The box comes back and answers for itself. + w.relay.history = [Self.boxTile(start: start - Self.span, filled: 144, closed: true, base: 500), + Self.boxTile(start: start, filled: 37, closed: false, base: 500)] + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity, caps: [Contract.capCloudLink, Contract.capAPIPassthrough, Contract.capHistory5m]) + w.relay.snap(uptimeMs: w.uptime, gridW: 900) + await w.run(200) + #expect(w.relay.sent.contains("hist.query")) + #expect(!history.fromCloud) + #expect(history.cloudUpToMs == nil) + #expect(history.cloudNote(nowMs: w.scheduler.nowMs) == nil) + #expect(history.samples(.grid, fromMs: Double(start))[36].watts == 536) + + // While the box streams and has answered, the copy adds nothing. + let before = history.frame + w.service.push(try Self.tile(w, start: start, first: 37, count: 1, base: 900)) + await w.run(50) + #expect(history.frame == before) + } + + @Test func aForgedOrUnsignedTileIsNeverLaid() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: nil) + let start = w.at / Self.span * Self.span + let real = try Self.tile(w, start: start, filled: 30) + let other = try Self.tile(w, start: start, filled: 30, base: 999) + w.service.tiles = [ + // Another tile's body under this tile's signature. + CloudWire.Envelope(t: CloudWire.msgTiles, b: other.b, sig: real.sig), + // No signature at all. + CloudWire.Envelope(t: CloudWire.msgTiles, b: real.b, sig: []), + // Signed, but not a tile: the etag does not match its data. + try Self.tile(w, start: start - Self.span, closed: true, etag: "00000000"), + ] + w.open() + let history = HistoryModel(site: w.site) + w.connectCloud() + await w.run(100) + #expect(w.service.received.contains { $0.t == CloudWire.msgTilesGet }) + #expect(w.site.cloudTiles.isEmpty) + #expect(!history.fromCloud) + #expect(history.samples(.grid, fromMs: Double(start - Self.span)).allSatisfy { $0.watts == nil }) + } + + @Test func withTheCopyOffNothingIsAsked() async throws { + let w = try CloudSite() + // The box linked for its tunnel, the readable copy off: no tables. + w.files.writeJSON("cloud-s1", CloudCache(identity: CloudIdentity(pk: w.box.pk, boot: w.box.boot), offset: nil)) + w.open() + let history = HistoryModel(site: w.site) + w.connectCloud() + await w.run(100) + #expect(!w.service.received.contains { $0.t == CloudWire.msgTilesGet }) + #expect(!history.fromCloud) + } + + /// A copy that ends on another day says which. + @Test func anOlderCopyNamesItsDay() async throws { + let (w, history, start) = try Self.awayBox() + await w.run(100) + let note = try #require(history.cloudNote(nowMs: Double(start) + 3 * 86_400_000, zone: Self.utc)) + #expect(note.hasPrefix("From FTW Cloud · up to ")) + #expect(!note.contains("today")) + #expect(note.hasSuffix(AppWords.clock(Double(start + 36 * Self.step), zone: Self.utc))) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift new file mode 100644 index 00000000..81dc5094 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift @@ -0,0 +1,174 @@ +import Foundation +import Testing +@testable import FTWKit + +/// srcfl/ftw `go/protocol/cloudwire/testdata/cloud-tile-vectors.json`, +/// copied unchanged: one tile of each shape signed by the vectors' box key, +/// a forged one, how pieces lay back together, and the bodies of +/// `tiles.get` and `tiles.end`. +struct CloudTileVectors: Decodable { + struct Tile: Decodable { + let name: String + let tileId: String + let etag: String + let startMs: Int64 + let points: Int + let partial: Bool + let seq: UInt64 + let body: String + let sig: String + let envelope: String + let frame: String + } + + struct Laid: Decodable { + let of: [String] + let tileId: String + let etag: String + let startMs: Int64 + let partial: Bool + let data: String + } + + struct Exchange: Decodable { + let body: String + let envelope: String + } + + let boxKey: CloudVectors.Key + let series: [String] + let tiles: [Tile] + let forged: Tile + let laid: [Laid] + let tilesGet: Exchange + let tilesEnd: Exchange + let have: [String: [String]] + + static func load() throws -> CloudTileVectors { + guard let url = Bundle.module.url(forResource: "cloud-tile-vectors", withExtension: "json", subdirectory: "Resources") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(CloudTileVectors.self, from: Data(contentsOf: url)) + } + + func envelope(_ t: Tile) throws -> CloudWire.Envelope { try CloudWire.Envelope.decode(Bytes(hex: t.envelope)) } + + func body(_ name: String) throws -> CloudWire.Tiles { + let t = try #require(tiles.first { $0.name == name }) + return try CloudWire.Tiles(body: Bytes(hex: t.body)) + } +} + +@Suite struct CloudTilesTests { + @Test func everyShapeVerifiesDecodesAndChecks() throws { + let v = try CloudTileVectors.load() + let pk = Bytes(hex: v.boxKey.pk) + let home = try #require(v.boxKey.home) + #expect(CloudWire.homeID(pk: pk) == home) + #expect(v.series == CloudWire.tileSeries) + for t in v.tiles { + let env = try v.envelope(t) + #expect(try CloudWire.decodeFrame(Bytes(hex: t.frame)) == Bytes(hex: t.envelope), "\(t.name)") + #expect(env.t == CloudWire.msgTiles) + #expect(CloudWire.verify(boxKey: pk, home: home, envelope: env), "\(t.name)") + let body = try CloudWire.Tiles(body: env.b) + try body.check() + #expect(body.tileId == t.tileId && body.etag == t.etag && body.startMs == t.startMs) + #expect(body.points == t.points && body.partial == t.partial && body.header.seq == t.seq) + } + } + + @Test func aForgedTileDoesNotVerify() throws { + let v = try CloudTileVectors.load() + let env = try v.envelope(v.forged) + #expect(!CloudWire.verify(boxKey: Bytes(hex: v.boxKey.pk), home: try #require(v.boxKey.home), envelope: env)) + // An unsigned copy of a real tile fails too. + var bare = try v.envelope(v.tiles[0]) + bare.sig = [] + #expect(!CloudWire.verify(boxKey: Bytes(hex: v.boxKey.pk), home: try #require(v.boxKey.home), envelope: bare)) + } + + @Test func layMatchesGoForEveryCase() throws { + let v = try CloudTileVectors.load() + var cases: [[String]: [CloudTileVectors.Laid]] = [:] + for l in v.laid { cases[l.of, default: []].append(l) } + #expect(cases.count == 5) + for (of, want) in cases { + let got = CloudWire.lay(try of.map { try v.body($0) }) + #expect(got.count == want.count, "\(of)") + for (g, w) in zip(got, want) { + #expect(g.tileId == w.tileId && g.etag == w.etag && g.partial == w.partial && Int64(g.startMs) == w.startMs, "\(of)") + #expect(g.data == Bytes(hex: w.data), "\(of) \(w.tileId)") + } + } + } + + @Test func tilesGetAndTilesEndAreGosBytes() throws { + let v = try CloudTileVectors.load() + let closed = try v.body("closed") + let env = CloudWire.encodeUnsigned(t: CloudWire.msgTilesGet, body: CloudWire.tilesGet(have: [closed.tileId: closed.etag])) + #expect(env == Bytes(hex: v.tilesGet.envelope)) + let end = try CloudWire.Envelope.decode(Bytes(hex: v.tilesEnd.envelope)) + #expect(end.t == CloudWire.msgTilesEnd) + #expect(try decodeCBOR(end.b)["n"]?.int == 3) + } + + @Test func checkRefusesWhatIsNotATile() throws { + let v = try CloudTileVectors.load() + let good = try v.body("append 17:00") + let cases: [String: (inout CloudWire.Tiles) -> Void] = [ + "etag": { $0.etag = "00000000" }, + "hourly": { $0.res = "1h" }, + "step": { $0.stepMs = 600_000 }, + "off the grid": { $0.startMs += 1 }, + "another tile's id": { $0.tileId = HistoryGeometry.tileID(.fiveMinutes, stride: 1, startMs: Double($0.tileStart - 43_200_000)) }, + "stride 2": { $0.tileId = HistoryGeometry.tileID(.fiveMinutes, stride: 2, startMs: Double($0.tileStart)) }, + "no series": { $0.series = [] }, + "twice a series": { $0.series = ["grid_w", "grid_w", "pv_w", "load_w"] }, + "closed but a piece": { $0.partial = false }, + "half a column": { t in + t.data.removeLast(2) + t.etag = HistoryGeometry.etag(t.data) + }, + "past the tile": { t in + t.startMs = t.tileStart + 143 * 300_000 + t.data += t.data + t.etag = HistoryGeometry.etag(t.data) + }, + ] + for (name, mutate) in cases { + var t = good + mutate(&t) + #expect(throws: CloudWire.WireError.self, "\(name)") { try t.check() } + } + } + + /// The store keeps what the box sent in its order: a body that came + /// twice once, a whole piece over the pieces before it, a closed tile + /// over every piece of it. + @Test func theStoreKeepsTheBoxsOrder() throws { + let v = try CloudTileVectors.load() + let closed = try v.body("closed"), whole = try v.body("whole") + let a1 = try v.body("append 17:00"), a2 = try v.body("append 17:05") + let now = whole.tileStart + 6 * 3_600_000 + let want = CloudWire.lay([closed, whole, a1, a2]) + + // A forwarded append that arrived before the answer, and the + // answer, which carries it again. + var s = CloudTileStore() + for t in [a2, whole, a1, a2, closed] { s.take(t, nowMs: now) } + #expect(s.laid == want) + #expect(s.bodies.count == 4) + + // A whole piece sent later drops the pieces before it. + var later = whole + later.header.seq = 99 + later.header.wallMs = (a2.header.wallMs ?? 0) + 1 + s.take(later, nowMs: now) + #expect(s.bodies.filter { $0.tileId == whole.tileId }.count == 1) + + // Two days on, both tiles have gone, and neither comes back. + s.take(later, nowMs: whole.tileEnd + CloudWire.tileKeepMs) + #expect(s.isEmpty) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-tile-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-tile-vectors.json new file mode 100644 index 00000000..67ce8a6d --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-tile-vectors.json @@ -0,0 +1,177 @@ +{ + "boxKey": { + "d": "6c50811aa47e688ecbb01b2c1e91aca4a2ad7c879526d1c9679ef3fd25560074", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8", + "home": "fjzyr67buzpyncqmjliwgezvde" + }, + "series": [ + "grid_w", + "pv_w", + "battery_w", + "load_w" + ], + "tiles": [ + { + "name": "closed", + "tileId": "5m/1/41476", + "etag": "986e18cd", + "startMs": 1791763200000, + "points": 144, + "partial": false, + "seq": 20, + "body": "ac6372657362356d637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06464617461590900200300001103000002030000f3020000e4020000d5020000c6020000b7020000a8020000990200008a0200007b0200006c0200005d0200004e0200003f02000030020000210200001202000003020000f4010000e5010000d6010000c7010000b8010000a90100009a0100008b0100007c0100006d0100005e0100004f0100004001000031010000220100001301000004010000f5000000e6000000d7000000c8000000b9000000aa0000009b0000008c0000007d0000006e0000005f000000500000004100000032000000230000001400000005000000f6ffffffe7ffffffd8ffffffc9ffffffbaffffffabffffff9cffffff8dffffff7effffff6fffffff60ffffff51ffffff42ffffff33ffffff24ffffff15ffffff06fffffff7feffffe8feffffd9feffffcafeffffbbfeffffacfeffff9dfeffff8efeffff7ffeffff70feffff61feffff52feffff43feffff34feffff25feffff16feffff07fefffff8fdffffe9fdffffdafdffffcbfdffffbcfdffffadfdffff9efdffff8ffdffff80fdffff71fdffff62fdffff53fdffff44fdffff35fdffff26fdffff17fdffff08fdfffff9fcffffeafcffffdbfcffffccfcffffbdfcffffaefcffff9ffcffff90fcffff81fcffff72fcffff63fcffff54fcffff45fcffff36fcffff27fcffff18fcffff09fcfffffafbffffebfbffffdcfbffffcdfbffffbefbffffaffbffffa0fbffff91fbffff82fbffff73fbffff64fbffff55fbffff46fbffff37fbffff28fbffff19fbffff0afbfffffbfaffffecfaffffddfaffffcefaffffbffaffff00000000b5ffffff6affffff1fffffffd4feffff8afeffff40fefffff6fdffffacfdffff63fdffff1afdffffd1fcffff89fcffff42fcfffffbfbffffb5fbffff70fbffff2bfbffffe7faffffa4faffff62faffff20faffffe0f9ffffa0f9ffff62f9ffff25f9ffffe8f8ffffadf8ffff73f8ffff3bf8ffff03f8ffffcdf7ffff98f7ffff64f7ffff32f7ffff01f7ffffd2f6ffffa4f6ffff78f6ffff4df6ffff24f6fffffcf5ffffd6f5ffffb1f5ffff8ef5ffff6df5ffff4ef5ffff30f5ffff14f5fffffaf4ffffe1f4ffffcaf4ffffb5f4ffffa2f4ffff91f4ffff81f4ffff74f4ffff68f4ffff5ef4ffff56f4ffff50f4ffff4bf4ffff49f4ffff48f4ffff49f4ffff4cf4ffff51f4ffff58f4ffff61f4ffff6cf4ffff78f4ffff86f4ffff96f4ffffa8f4ffffbcf4ffffd2f4ffffe9f4ffff02f5ffff1df5ffff3af5ffff58f5ffff78f5ffff9af5ffffbdf5ffffe2f5ffff09f6ffff31f6ffff5bf6ffff87f6ffffb3f6ffffe2f6ffff12f7ffff43f7ffff76f7ffffaaf7ffffdff7ffff16f8ffff4ef8ffff87f8ffffc1f8fffffdf8ffff39f9ffff77f9ffffb6f9fffff5f9ffff36faffff78faffffbafafffffefaffff42fbffff87fbffffcdfbffff13fcffff5afcffffa2fcffffeafcffff32fdffff7bfdffffc5fdffff0ffeffff59feffffa3feffffeefeffff38ffffff83ffffffceffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000900100008d0100008a0100008701000084010000810100007e0100007b0100007801000075010000720100006f0100006c010000690100006601000063010000600100005d0100005a0100005701000054010000510100004e0100004b0100004801000045010000420100003f0100003c010000390100003601000033010000300100002d0100002a0100002701000024010000210100001e0100001b0100001801000015010000120100000f0100000c01000009010000060100000301000000010000fd000000fa000000f7000000f4000000f1000000ee000000eb000000e8000000e5000000e2000000df000000dc000000d9000000d6000000d3000000d0000000cd000000ca000000c7000000c4000000c1000000be000000bb000000b8000000b5000000b2000000af000000ac000000a9000000a6000000a3000000a00000009d0000009a0000009700000094000000910000008e0000008b0000008800000085000000820000007f0000007c000000790000007600000073000000700000006d0000006a0000006700000064000000610000005e0000005b0000005800000055000000520000004f0000004c000000490000004600000043000000400000003d0000003a0000003700000034000000310000002e0000002b0000002800000025000000220000001f0000001c000000190000001600000013000000100000000d0000000a000000070000000400000001000000fefffffffbfffffff8fffffff5fffffff2ffffffefffffffecffffffe9ffffffe6ffffffe3ffffffc2010000c5010000c7010000ca010000cc010000cf010000d1010000d4010000d6010000d9010000db010000de010000e0010000e3010000e5010000e8010000ea010000ed010000ef010000f2010000f4010000f7010000f9010000fc010000fe010000010200000302000006020000080200000b0200000d020000100200001202000015020000170200001a0200000000008000000080000000800000008000000080000000802b0200002e020000300200003302000035020000380200003a0200003d0200003f020000420200004402000047020000490200004c0200004e020000510200005302000056020000580200005b0200005d020000600200006202000065020000670200006a0200006c0200006f020000710200007402000076020000790200007b0200007e020000800200008302000085020000880200008a0200008d0200008f020000920200009402000097020000990200009c0200009e020000a1020000a3020000a6020000a8020000ab020000ad020000b0020000b2020000b5020000b7020000ba020000bc020000bf020000c1020000c4020000c6020000c9020000cb020000ce020000d0020000d3020000d5020000d8020000da020000dd020000df020000e2020000e4020000e7020000e9020000ec020000ee020000f1020000f3020000f6020000f8020000fb020000fd020000000300000203000005030000070300000a0300000c0300000f030000110300001403000016030000190300001b0300001e030000200300002303000025030000280300006465746167683938366531386364667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437366677616c6c4d731b000001a12ffc5b30677061727469616cf46773746172744d731b000001a12d68b80068757074696d654d7319eb28", + "signedInputSha256": "3a55085d157a3c0db66c9463ece48a18f6d956eeaa6e403e8cabdfabf4dc7731", + "sig": "ef58627fcdebc178aa551ec1a64223d45d22488c58d699f0c26d9db6a989249248ac33233b4aa8d5196b4d5ff71046b54f700fc07c71084e08be200eb2af25d5", + "envelope": "a361625909b2ac6372657362356d637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06464617461590900200300001103000002030000f3020000e4020000d5020000c6020000b7020000a8020000990200008a0200007b0200006c0200005d0200004e0200003f02000030020000210200001202000003020000f4010000e5010000d6010000c7010000b8010000a90100009a0100008b0100007c0100006d0100005e0100004f0100004001000031010000220100001301000004010000f5000000e6000000d7000000c8000000b9000000aa0000009b0000008c0000007d0000006e0000005f000000500000004100000032000000230000001400000005000000f6ffffffe7ffffffd8ffffffc9ffffffbaffffffabffffff9cffffff8dffffff7effffff6fffffff60ffffff51ffffff42ffffff33ffffff24ffffff15ffffff06fffffff7feffffe8feffffd9feffffcafeffffbbfeffffacfeffff9dfeffff8efeffff7ffeffff70feffff61feffff52feffff43feffff34feffff25feffff16feffff07fefffff8fdffffe9fdffffdafdffffcbfdffffbcfdffffadfdffff9efdffff8ffdffff80fdffff71fdffff62fdffff53fdffff44fdffff35fdffff26fdffff17fdffff08fdfffff9fcffffeafcffffdbfcffffccfcffffbdfcffffaefcffff9ffcffff90fcffff81fcffff72fcffff63fcffff54fcffff45fcffff36fcffff27fcffff18fcffff09fcfffffafbffffebfbffffdcfbffffcdfbffffbefbffffaffbffffa0fbffff91fbffff82fbffff73fbffff64fbffff55fbffff46fbffff37fbffff28fbffff19fbffff0afbfffffbfaffffecfaffffddfaffffcefaffffbffaffff00000000b5ffffff6affffff1fffffffd4feffff8afeffff40fefffff6fdffffacfdffff63fdffff1afdffffd1fcffff89fcffff42fcfffffbfbffffb5fbffff70fbffff2bfbffffe7faffffa4faffff62faffff20faffffe0f9ffffa0f9ffff62f9ffff25f9ffffe8f8ffffadf8ffff73f8ffff3bf8ffff03f8ffffcdf7ffff98f7ffff64f7ffff32f7ffff01f7ffffd2f6ffffa4f6ffff78f6ffff4df6ffff24f6fffffcf5ffffd6f5ffffb1f5ffff8ef5ffff6df5ffff4ef5ffff30f5ffff14f5fffffaf4ffffe1f4ffffcaf4ffffb5f4ffffa2f4ffff91f4ffff81f4ffff74f4ffff68f4ffff5ef4ffff56f4ffff50f4ffff4bf4ffff49f4ffff48f4ffff49f4ffff4cf4ffff51f4ffff58f4ffff61f4ffff6cf4ffff78f4ffff86f4ffff96f4ffffa8f4ffffbcf4ffffd2f4ffffe9f4ffff02f5ffff1df5ffff3af5ffff58f5ffff78f5ffff9af5ffffbdf5ffffe2f5ffff09f6ffff31f6ffff5bf6ffff87f6ffffb3f6ffffe2f6ffff12f7ffff43f7ffff76f7ffffaaf7ffffdff7ffff16f8ffff4ef8ffff87f8ffffc1f8fffffdf8ffff39f9ffff77f9ffffb6f9fffff5f9ffff36faffff78faffffbafafffffefaffff42fbffff87fbffffcdfbffff13fcffff5afcffffa2fcffffeafcffff32fdffff7bfdffffc5fdffff0ffeffff59feffffa3feffffeefeffff38ffffff83ffffffceffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000900100008d0100008a0100008701000084010000810100007e0100007b0100007801000075010000720100006f0100006c010000690100006601000063010000600100005d0100005a0100005701000054010000510100004e0100004b0100004801000045010000420100003f0100003c010000390100003601000033010000300100002d0100002a0100002701000024010000210100001e0100001b0100001801000015010000120100000f0100000c01000009010000060100000301000000010000fd000000fa000000f7000000f4000000f1000000ee000000eb000000e8000000e5000000e2000000df000000dc000000d9000000d6000000d3000000d0000000cd000000ca000000c7000000c4000000c1000000be000000bb000000b8000000b5000000b2000000af000000ac000000a9000000a6000000a3000000a00000009d0000009a0000009700000094000000910000008e0000008b0000008800000085000000820000007f0000007c000000790000007600000073000000700000006d0000006a0000006700000064000000610000005e0000005b0000005800000055000000520000004f0000004c000000490000004600000043000000400000003d0000003a0000003700000034000000310000002e0000002b0000002800000025000000220000001f0000001c000000190000001600000013000000100000000d0000000a000000070000000400000001000000fefffffffbfffffff8fffffff5fffffff2ffffffefffffffecffffffe9ffffffe6ffffffe3ffffffc2010000c5010000c7010000ca010000cc010000cf010000d1010000d4010000d6010000d9010000db010000de010000e0010000e3010000e5010000e8010000ea010000ed010000ef010000f2010000f4010000f7010000f9010000fc010000fe010000010200000302000006020000080200000b0200000d020000100200001202000015020000170200001a0200000000008000000080000000800000008000000080000000802b0200002e020000300200003302000035020000380200003a0200003d0200003f020000420200004402000047020000490200004c0200004e020000510200005302000056020000580200005b0200005d020000600200006202000065020000670200006a0200006c0200006f020000710200007402000076020000790200007b0200007e020000800200008302000085020000880200008a0200008d0200008f020000920200009402000097020000990200009c0200009e020000a1020000a3020000a6020000a8020000ab020000ad020000b0020000b2020000b5020000b7020000ba020000bc020000bf020000c1020000c4020000c6020000c9020000cb020000ce020000d0020000d3020000d5020000d8020000da020000dd020000df020000e2020000e4020000e7020000e9020000ec020000ee020000f1020000f3020000f6020000f8020000fb020000fd020000000300000203000005030000070300000a0300000c0300000f030000110300001403000016030000190300001b0300001e030000200300002303000025030000280300006465746167683938366531386364667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437366677616c6c4d731b000001a12ffc5b30677061727469616cf46773746172744d731b000001a12d68b80068757074696d654d7319eb2861746574696c6573637369675840ef58627fcdebc178aa551ec1a64223d45d22488c58d699f0c26d9db6a989249248ac33233b4aa8d5196b4d5ff71046b54f700fc07c71084e08be200eb2af25d5", + "frame": "010100000a06a361625909b2ac6372657362356d637365711464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06464617461590900200300001103000002030000f3020000e4020000d5020000c6020000b7020000a8020000990200008a0200007b0200006c0200005d0200004e0200003f02000030020000210200001202000003020000f4010000e5010000d6010000c7010000b8010000a90100009a0100008b0100007c0100006d0100005e0100004f0100004001000031010000220100001301000004010000f5000000e6000000d7000000c8000000b9000000aa0000009b0000008c0000007d0000006e0000005f000000500000004100000032000000230000001400000005000000f6ffffffe7ffffffd8ffffffc9ffffffbaffffffabffffff9cffffff8dffffff7effffff6fffffff60ffffff51ffffff42ffffff33ffffff24ffffff15ffffff06fffffff7feffffe8feffffd9feffffcafeffffbbfeffffacfeffff9dfeffff8efeffff7ffeffff70feffff61feffff52feffff43feffff34feffff25feffff16feffff07fefffff8fdffffe9fdffffdafdffffcbfdffffbcfdffffadfdffff9efdffff8ffdffff80fdffff71fdffff62fdffff53fdffff44fdffff35fdffff26fdffff17fdffff08fdfffff9fcffffeafcffffdbfcffffccfcffffbdfcffffaefcffff9ffcffff90fcffff81fcffff72fcffff63fcffff54fcffff45fcffff36fcffff27fcffff18fcffff09fcfffffafbffffebfbffffdcfbffffcdfbffffbefbffffaffbffffa0fbffff91fbffff82fbffff73fbffff64fbffff55fbffff46fbffff37fbffff28fbffff19fbffff0afbfffffbfaffffecfaffffddfaffffcefaffffbffaffff00000000b5ffffff6affffff1fffffffd4feffff8afeffff40fefffff6fdffffacfdffff63fdffff1afdffffd1fcffff89fcffff42fcfffffbfbffffb5fbffff70fbffff2bfbffffe7faffffa4faffff62faffff20faffffe0f9ffffa0f9ffff62f9ffff25f9ffffe8f8ffffadf8ffff73f8ffff3bf8ffff03f8ffffcdf7ffff98f7ffff64f7ffff32f7ffff01f7ffffd2f6ffffa4f6ffff78f6ffff4df6ffff24f6fffffcf5ffffd6f5ffffb1f5ffff8ef5ffff6df5ffff4ef5ffff30f5ffff14f5fffffaf4ffffe1f4ffffcaf4ffffb5f4ffffa2f4ffff91f4ffff81f4ffff74f4ffff68f4ffff5ef4ffff56f4ffff50f4ffff4bf4ffff49f4ffff48f4ffff49f4ffff4cf4ffff51f4ffff58f4ffff61f4ffff6cf4ffff78f4ffff86f4ffff96f4ffffa8f4ffffbcf4ffffd2f4ffffe9f4ffff02f5ffff1df5ffff3af5ffff58f5ffff78f5ffff9af5ffffbdf5ffffe2f5ffff09f6ffff31f6ffff5bf6ffff87f6ffffb3f6ffffe2f6ffff12f7ffff43f7ffff76f7ffffaaf7ffffdff7ffff16f8ffff4ef8ffff87f8ffffc1f8fffffdf8ffff39f9ffff77f9ffffb6f9fffff5f9ffff36faffff78faffffbafafffffefaffff42fbffff87fbffffcdfbffff13fcffff5afcffffa2fcffffeafcffff32fdffff7bfdffffc5fdffff0ffeffff59feffffa3feffffeefeffff38ffffff83ffffffceffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000900100008d0100008a0100008701000084010000810100007e0100007b0100007801000075010000720100006f0100006c010000690100006601000063010000600100005d0100005a0100005701000054010000510100004e0100004b0100004801000045010000420100003f0100003c010000390100003601000033010000300100002d0100002a0100002701000024010000210100001e0100001b0100001801000015010000120100000f0100000c01000009010000060100000301000000010000fd000000fa000000f7000000f4000000f1000000ee000000eb000000e8000000e5000000e2000000df000000dc000000d9000000d6000000d3000000d0000000cd000000ca000000c7000000c4000000c1000000be000000bb000000b8000000b5000000b2000000af000000ac000000a9000000a6000000a3000000a00000009d0000009a0000009700000094000000910000008e0000008b0000008800000085000000820000007f0000007c000000790000007600000073000000700000006d0000006a0000006700000064000000610000005e0000005b0000005800000055000000520000004f0000004c000000490000004600000043000000400000003d0000003a0000003700000034000000310000002e0000002b0000002800000025000000220000001f0000001c000000190000001600000013000000100000000d0000000a000000070000000400000001000000fefffffffbfffffff8fffffff5fffffff2ffffffefffffffecffffffe9ffffffe6ffffffe3ffffffc2010000c5010000c7010000ca010000cc010000cf010000d1010000d4010000d6010000d9010000db010000de010000e0010000e3010000e5010000e8010000ea010000ed010000ef010000f2010000f4010000f7010000f9010000fc010000fe010000010200000302000006020000080200000b0200000d020000100200001202000015020000170200001a0200000000008000000080000000800000008000000080000000802b0200002e020000300200003302000035020000380200003a0200003d0200003f020000420200004402000047020000490200004c0200004e020000510200005302000056020000580200005b0200005d020000600200006202000065020000670200006a0200006c0200006f020000710200007402000076020000790200007b0200007e020000800200008302000085020000880200008a0200008d0200008f020000920200009402000097020000990200009c0200009e020000a1020000a3020000a6020000a8020000ab020000ad020000b0020000b2020000b5020000b7020000ba020000bc020000bf020000c1020000c4020000c6020000c9020000cb020000ce020000d0020000d3020000d5020000d8020000da020000dd020000df020000e2020000e4020000e7020000e9020000ec020000ee020000f1020000f3020000f6020000f8020000fb020000fd020000000300000203000005030000070300000a0300000c0300000f030000110300001403000016030000190300001b0300001e030000200300002303000025030000280300006465746167683938366531386364667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437366677616c6c4d731b000001a12ffc5b30677061727469616cf46773746172744d731b000001a12d68b80068757074696d654d7319eb2861746574696c6573637369675840ef58627fcdebc178aa551ec1a64223d45d22488c58d699f0c26d9db6a989249248ac33233b4aa8d5196b4d5ff71046b54f700fc07c71084e08be200eb2af25d5" + }, + { + "name": "whole", + "tileId": "5m/1/41477", + "etag": "7285c700", + "startMs": 1791806400000, + "points": 144, + "partial": true, + "seq": 21, + "body": "ac6372657362356d637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06464617461590900b0faffffa1faffff92faffff83faffff74faffff65faffff56faffff47faffff38faffff29faffff1afaffff0bfafffffcf9ffffedf9ffffdef9ffffcff9ffffc0f9ffffb1f9ffffa2f9ffff93f9ffff84f9ffff75f9ffff66f9ffff57f9ffff48f9ffff39f9ffff2af9ffff1bf9ffff0cf9fffffdf8ffffeef8ffffdff8ffffd0f8ffffc1f8ffffb2f8ffffa3f8ffff94f8ffff85f8ffff76f8ffff67f8ffff58f8ffff49f8ffff3af8ffff2bf8ffff1cf8ffff0df8fffffef7ffffeff7ffffe0f7ffffd1f7ffffc2f7ffffb3f7ffffa4f7ffff95f7ffff86f7ffff77f7ffff68f7ffff59f7ffff4af7ffff3bf7ffff000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080bcffffffb9ffffffb6ffffffb3ffffffb0ffffffadffffffaaffffffa7ffffffa4ffffffa1ffffff9effffff9bffffff98ffffff95ffffff92ffffff8fffffff8cffffff89ffffff86ffffff83ffffff80ffffff7dffffff7affffff77ffffff74ffffff71ffffff6effffff6bffffff68ffffff65ffffff62ffffff5fffffff5cffffff59ffffff56ffffff53ffffff50ffffff4dffffff4affffff47ffffff44ffffff41ffffff3effffff3bffffff38ffffff35ffffff32ffffff2fffffff0000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000802a0300002d0300002f030000320300003403000037030000390300003c0300003e030000410300004303000046030000480300004b0300004d030000500300005203000055030000570300005a0300005c0300005f030000610300006403000066030000690300006b0300006e030000700300007303000075030000780300007a0300007d0300007f030000820300008403000087030000890300008c0300008e030000910300009303000096030000980300009b0300009d030000a0030000a2030000a5030000a7030000aa030000ac030000af030000b1030000b4030000b6030000b9030000bb030000be0300000000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000806465746167683732383563373030667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a1310f03b0677061727469616cf56773746172744d731b000001a12ffbe60068757074696d654d7319eb32", + "signedInputSha256": "8b0f2e108c7c018c10927f7b960b3644316fd9a3a692f2eac230024589884d84", + "sig": "b48eeac0518263ea7e8115b94f495e9d86a4fde190243a54bd7450f55b7e1136c2fed27664d4a9c4d89560526f429574ac8242d7303fb8ce5500dd3e5c4ed861", + "envelope": "a361625909b2ac6372657362356d637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06464617461590900b0faffffa1faffff92faffff83faffff74faffff65faffff56faffff47faffff38faffff29faffff1afaffff0bfafffffcf9ffffedf9ffffdef9ffffcff9ffffc0f9ffffb1f9ffffa2f9ffff93f9ffff84f9ffff75f9ffff66f9ffff57f9ffff48f9ffff39f9ffff2af9ffff1bf9ffff0cf9fffffdf8ffffeef8ffffdff8ffffd0f8ffffc1f8ffffb2f8ffffa3f8ffff94f8ffff85f8ffff76f8ffff67f8ffff58f8ffff49f8ffff3af8ffff2bf8ffff1cf8ffff0df8fffffef7ffffeff7ffffe0f7ffffd1f7ffffc2f7ffffb3f7ffffa4f7ffff95f7ffff86f7ffff77f7ffff68f7ffff59f7ffff4af7ffff3bf7ffff000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080bcffffffb9ffffffb6ffffffb3ffffffb0ffffffadffffffaaffffffa7ffffffa4ffffffa1ffffff9effffff9bffffff98ffffff95ffffff92ffffff8fffffff8cffffff89ffffff86ffffff83ffffff80ffffff7dffffff7affffff77ffffff74ffffff71ffffff6effffff6bffffff68ffffff65ffffff62ffffff5fffffff5cffffff59ffffff56ffffff53ffffff50ffffff4dffffff4affffff47ffffff44ffffff41ffffff3effffff3bffffff38ffffff35ffffff32ffffff2fffffff0000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000802a0300002d0300002f030000320300003403000037030000390300003c0300003e030000410300004303000046030000480300004b0300004d030000500300005203000055030000570300005a0300005c0300005f030000610300006403000066030000690300006b0300006e030000700300007303000075030000780300007a0300007d0300007f030000820300008403000087030000890300008c0300008e030000910300009303000096030000980300009b0300009d030000a0030000a2030000a5030000a7030000aa030000ac030000af030000b1030000b4030000b6030000b9030000bb030000be0300000000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000806465746167683732383563373030667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a1310f03b0677061727469616cf56773746172744d731b000001a12ffbe60068757074696d654d7319eb3261746574696c6573637369675840b48eeac0518263ea7e8115b94f495e9d86a4fde190243a54bd7450f55b7e1136c2fed27664d4a9c4d89560526f429574ac8242d7303fb8ce5500dd3e5c4ed861", + "frame": "010100000a06a361625909b2ac6372657362356d637365711564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06464617461590900b0faffffa1faffff92faffff83faffff74faffff65faffff56faffff47faffff38faffff29faffff1afaffff0bfafffffcf9ffffedf9ffffdef9ffffcff9ffffc0f9ffffb1f9ffffa2f9ffff93f9ffff84f9ffff75f9ffff66f9ffff57f9ffff48f9ffff39f9ffff2af9ffff1bf9ffff0cf9fffffdf8ffffeef8ffffdff8ffffd0f8ffffc1f8ffffb2f8ffffa3f8ffff94f8ffff85f8ffff76f8ffff67f8ffff58f8ffff49f8ffff3af8ffff2bf8ffff1cf8ffff0df8fffffef7ffffeff7ffffe0f7ffffd1f7ffffc2f7ffffb3f7ffffa4f7ffff95f7ffff86f7ffff77f7ffff68f7ffff59f7ffff4af7ffff3bf7ffff000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080bcffffffb9ffffffb6ffffffb3ffffffb0ffffffadffffffaaffffffa7ffffffa4ffffffa1ffffff9effffff9bffffff98ffffff95ffffff92ffffff8fffffff8cffffff89ffffff86ffffff83ffffff80ffffff7dffffff7affffff77ffffff74ffffff71ffffff6effffff6bffffff68ffffff65ffffff62ffffff5fffffff5cffffff59ffffff56ffffff53ffffff50ffffff4dffffff4affffff47ffffff44ffffff41ffffff3effffff3bffffff38ffffff35ffffff32ffffff2fffffff0000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000802a0300002d0300002f030000320300003403000037030000390300003c0300003e030000410300004303000046030000480300004b0300004d030000500300005203000055030000570300005a0300005c0300005f030000610300006403000066030000690300006b0300006e030000700300007303000075030000780300007a0300007d0300007f030000820300008403000087030000890300008c0300008e030000910300009303000096030000980300009b0300009d030000a0030000a2030000a5030000a7030000aa030000ac030000af030000b1030000b4030000b6030000b9030000bb030000be0300000000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000806465746167683732383563373030667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a1310f03b0677061727469616cf56773746172744d731b000001a12ffbe60068757074696d654d7319eb3261746574696c6573637369675840b48eeac0518263ea7e8115b94f495e9d86a4fde190243a54bd7450f55b7e1136c2fed27664d4a9c4d89560526f429574ac8242d7303fb8ce5500dd3e5c4ed861" + }, + { + "name": "append 17:00", + "tileId": "5m/1/41477", + "etag": "229cca34", + "startMs": 1791824400000, + "points": 1, + "partial": true, + "seq": 22, + "body": "ac6372657362356d637365711664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06464617461502cf7ffff000000002cffffffc00300006465746167683232396363613334667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a131139790677061727469616cf56773746172744d731b000001a1310e8e8068757074696d654d7319eb3c", + "signedInputSha256": "31b4596a56ad6722bc4367cddf42a01c5d998cc12da5c5ed9ef5c2afc357aa97", + "sig": "547fd8fbd017964d5800bd918b35cf40ec97889659cc93ca571d0b6d0fe9ce5aca4f10c706b790026902c13e77a470b783c01d10e24949214b9902187101f422", + "envelope": "a3616258c0ac6372657362356d637365711664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06464617461502cf7ffff000000002cffffffc00300006465746167683232396363613334667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a131139790677061727469616cf56773746172744d731b000001a1310e8e8068757074696d654d7319eb3c61746574696c6573637369675840547fd8fbd017964d5800bd918b35cf40ec97889659cc93ca571d0b6d0fe9ce5aca4f10c706b790026902c13e77a470b783c01d10e24949214b9902187101f422", + "frame": "010100000113a3616258c0ac6372657362356d637365711664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06464617461502cf7ffff000000002cffffffc00300006465746167683232396363613334667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a131139790677061727469616cf56773746172744d731b000001a1310e8e8068757074696d654d7319eb3c61746574696c6573637369675840547fd8fbd017964d5800bd918b35cf40ec97889659cc93ca571d0b6d0fe9ce5aca4f10c706b790026902c13e77a470b783c01d10e24949214b9902187101f422" + }, + { + "name": "append 17:05", + "tileId": "5m/1/41477", + "etag": "0044f57f", + "startMs": 1791824700000, + "points": 2, + "partial": true, + "seq": 23, + "body": "ac6372657362356d637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646461746158201df7ffff0ef7ffff000000000000000029ffffff26ffffffc3030000c50300006465746167683030343466353766667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a1311cbf50677061727469616cf56773746172744d731b000001a13113226068757074696d654d7319eb46", + "signedInputSha256": "2988782538a7ec1f2d63315375a01d829ffd9dadf242a84e4730f1eebea80f76", + "sig": "720790a10b1eb0806b4cf20594c97745ab1f4edc3d5835e7188da3bb7a88efe46e832e9b867de8d539ed15f2165123a0087a158f9d128298b0f36667b3165cf5", + "envelope": "a3616258d1ac6372657362356d637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646461746158201df7ffff0ef7ffff000000000000000029ffffff26ffffffc3030000c50300006465746167683030343466353766667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a1311cbf50677061727469616cf56773746172744d731b000001a13113226068757074696d654d7319eb4661746574696c6573637369675840720790a10b1eb0806b4cf20594c97745ab1f4edc3d5835e7188da3bb7a88efe46e832e9b867de8d539ed15f2165123a0087a158f9d128298b0f36667b3165cf5", + "frame": "010100000124a3616258d1ac6372657362356d637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646461746158201df7ffff0ef7ffff000000000000000029ffffff26ffffffc3030000c50300006465746167683030343466353766667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a1311cbf50677061727469616cf56773746172744d731b000001a13113226068757074696d654d7319eb4661746574696c6573637369675840720790a10b1eb0806b4cf20594c97745ab1f4edc3d5835e7188da3bb7a88efe46e832e9b867de8d539ed15f2165123a0087a158f9d128298b0f36667b3165cf5" + } + ], + "forged": { + "name": "forged append", + "tileId": "5m/1/41477", + "etag": "86ff94ae", + "startMs": 1791824700000, + "points": 2, + "partial": true, + "seq": 23, + "body": "ac6372657362356d637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646461746158201cf7ffff0ef7ffff000000000000000029ffffff26ffffffc3030000c50300006465746167683836666639346165667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a1311cbf50677061727469616cf56773746172744d731b000001a13113226068757074696d654d7319eb46", + "signedInputSha256": "", + "sig": "720790a10b1eb0806b4cf20594c97745ab1f4edc3d5835e7188da3bb7a88efe46e832e9b867de8d539ed15f2165123a0087a158f9d128298b0f36667b3165cf5", + "envelope": "a3616258d1ac6372657362356d637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646461746158201cf7ffff0ef7ffff000000000000000029ffffff26ffffffc3030000c50300006465746167683836666639346165667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a1311cbf50677061727469616cf56773746172744d731b000001a13113226068757074696d654d7319eb4661746574696c6573637369675840720790a10b1eb0806b4cf20594c97745ab1f4edc3d5835e7188da3bb7a88efe46e832e9b867de8d539ed15f2165123a0087a158f9d128298b0f36667b3165cf5", + "frame": "010100000124a3616258d1ac6372657362356d637365711764626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646461746158201cf7ffff0ef7ffff000000000000000029ffffff26ffffffc3030000c50300006465746167683836666639346165667365726965738466677269645f776470765f7769626174746572795f77666c6f61645f7766737465704d731a000493e06674696c6549646a356d2f312f34313437376677616c6c4d731b000001a1311cbf50677061727469616cf56773746172744d731b000001a13113226068757074696d654d7319eb4661746574696c6573637369675840720790a10b1eb0806b4cf20594c97745ab1f4edc3d5835e7188da3bb7a88efe46e832e9b867de8d539ed15f2165123a0087a158f9d128298b0f36667b3165cf5" + }, + "laid": [ + { + "of": [ + "closed" + ], + "tileId": "5m/1/41476", + "etag": "986e18cd", + "startMs": 1791763200000, + "partial": false, + "data": "200300001103000002030000f3020000e4020000d5020000c6020000b7020000a8020000990200008a0200007b0200006c0200005d0200004e0200003f02000030020000210200001202000003020000f4010000e5010000d6010000c7010000b8010000a90100009a0100008b0100007c0100006d0100005e0100004f0100004001000031010000220100001301000004010000f5000000e6000000d7000000c8000000b9000000aa0000009b0000008c0000007d0000006e0000005f000000500000004100000032000000230000001400000005000000f6ffffffe7ffffffd8ffffffc9ffffffbaffffffabffffff9cffffff8dffffff7effffff6fffffff60ffffff51ffffff42ffffff33ffffff24ffffff15ffffff06fffffff7feffffe8feffffd9feffffcafeffffbbfeffffacfeffff9dfeffff8efeffff7ffeffff70feffff61feffff52feffff43feffff34feffff25feffff16feffff07fefffff8fdffffe9fdffffdafdffffcbfdffffbcfdffffadfdffff9efdffff8ffdffff80fdffff71fdffff62fdffff53fdffff44fdffff35fdffff26fdffff17fdffff08fdfffff9fcffffeafcffffdbfcffffccfcffffbdfcffffaefcffff9ffcffff90fcffff81fcffff72fcffff63fcffff54fcffff45fcffff36fcffff27fcffff18fcffff09fcfffffafbffffebfbffffdcfbffffcdfbffffbefbffffaffbffffa0fbffff91fbffff82fbffff73fbffff64fbffff55fbffff46fbffff37fbffff28fbffff19fbffff0afbfffffbfaffffecfaffffddfaffffcefaffffbffaffff00000000b5ffffff6affffff1fffffffd4feffff8afeffff40fefffff6fdffffacfdffff63fdffff1afdffffd1fcffff89fcffff42fcfffffbfbffffb5fbffff70fbffff2bfbffffe7faffffa4faffff62faffff20faffffe0f9ffffa0f9ffff62f9ffff25f9ffffe8f8ffffadf8ffff73f8ffff3bf8ffff03f8ffffcdf7ffff98f7ffff64f7ffff32f7ffff01f7ffffd2f6ffffa4f6ffff78f6ffff4df6ffff24f6fffffcf5ffffd6f5ffffb1f5ffff8ef5ffff6df5ffff4ef5ffff30f5ffff14f5fffffaf4ffffe1f4ffffcaf4ffffb5f4ffffa2f4ffff91f4ffff81f4ffff74f4ffff68f4ffff5ef4ffff56f4ffff50f4ffff4bf4ffff49f4ffff48f4ffff49f4ffff4cf4ffff51f4ffff58f4ffff61f4ffff6cf4ffff78f4ffff86f4ffff96f4ffffa8f4ffffbcf4ffffd2f4ffffe9f4ffff02f5ffff1df5ffff3af5ffff58f5ffff78f5ffff9af5ffffbdf5ffffe2f5ffff09f6ffff31f6ffff5bf6ffff87f6ffffb3f6ffffe2f6ffff12f7ffff43f7ffff76f7ffffaaf7ffffdff7ffff16f8ffff4ef8ffff87f8ffffc1f8fffffdf8ffff39f9ffff77f9ffffb6f9fffff5f9ffff36faffff78faffffbafafffffefaffff42fbffff87fbffffcdfbffff13fcffff5afcffffa2fcffffeafcffff32fdffff7bfdffffc5fdffff0ffeffff59feffffa3feffffeefeffff38ffffff83ffffffceffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000900100008d0100008a0100008701000084010000810100007e0100007b0100007801000075010000720100006f0100006c010000690100006601000063010000600100005d0100005a0100005701000054010000510100004e0100004b0100004801000045010000420100003f0100003c010000390100003601000033010000300100002d0100002a0100002701000024010000210100001e0100001b0100001801000015010000120100000f0100000c01000009010000060100000301000000010000fd000000fa000000f7000000f4000000f1000000ee000000eb000000e8000000e5000000e2000000df000000dc000000d9000000d6000000d3000000d0000000cd000000ca000000c7000000c4000000c1000000be000000bb000000b8000000b5000000b2000000af000000ac000000a9000000a6000000a3000000a00000009d0000009a0000009700000094000000910000008e0000008b0000008800000085000000820000007f0000007c000000790000007600000073000000700000006d0000006a0000006700000064000000610000005e0000005b0000005800000055000000520000004f0000004c000000490000004600000043000000400000003d0000003a0000003700000034000000310000002e0000002b0000002800000025000000220000001f0000001c000000190000001600000013000000100000000d0000000a000000070000000400000001000000fefffffffbfffffff8fffffff5fffffff2ffffffefffffffecffffffe9ffffffe6ffffffe3ffffffc2010000c5010000c7010000ca010000cc010000cf010000d1010000d4010000d6010000d9010000db010000de010000e0010000e3010000e5010000e8010000ea010000ed010000ef010000f2010000f4010000f7010000f9010000fc010000fe010000010200000302000006020000080200000b0200000d020000100200001202000015020000170200001a0200000000008000000080000000800000008000000080000000802b0200002e020000300200003302000035020000380200003a0200003d0200003f020000420200004402000047020000490200004c0200004e020000510200005302000056020000580200005b0200005d020000600200006202000065020000670200006a0200006c0200006f020000710200007402000076020000790200007b0200007e020000800200008302000085020000880200008a0200008d0200008f020000920200009402000097020000990200009c0200009e020000a1020000a3020000a6020000a8020000ab020000ad020000b0020000b2020000b5020000b7020000ba020000bc020000bf020000c1020000c4020000c6020000c9020000cb020000ce020000d0020000d3020000d5020000d8020000da020000dd020000df020000e2020000e4020000e7020000e9020000ec020000ee020000f1020000f3020000f6020000f8020000fb020000fd020000000300000203000005030000070300000a0300000c0300000f030000110300001403000016030000190300001b0300001e03000020030000230300002503000028030000" + }, + { + "of": [ + "whole" + ], + "tileId": "5m/1/41477", + "etag": "7285c700", + "startMs": 1791806400000, + "partial": true, + "data": "b0faffffa1faffff92faffff83faffff74faffff65faffff56faffff47faffff38faffff29faffff1afaffff0bfafffffcf9ffffedf9ffffdef9ffffcff9ffffc0f9ffffb1f9ffffa2f9ffff93f9ffff84f9ffff75f9ffff66f9ffff57f9ffff48f9ffff39f9ffff2af9ffff1bf9ffff0cf9fffffdf8ffffeef8ffffdff8ffffd0f8ffffc1f8ffffb2f8ffffa3f8ffff94f8ffff85f8ffff76f8ffff67f8ffff58f8ffff49f8ffff3af8ffff2bf8ffff1cf8ffff0df8fffffef7ffffeff7ffffe0f7ffffd1f7ffffc2f7ffffb3f7ffffa4f7ffff95f7ffff86f7ffff77f7ffff68f7ffff59f7ffff4af7ffff3bf7ffff000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080bcffffffb9ffffffb6ffffffb3ffffffb0ffffffadffffffaaffffffa7ffffffa4ffffffa1ffffff9effffff9bffffff98ffffff95ffffff92ffffff8fffffff8cffffff89ffffff86ffffff83ffffff80ffffff7dffffff7affffff77ffffff74ffffff71ffffff6effffff6bffffff68ffffff65ffffff62ffffff5fffffff5cffffff59ffffff56ffffff53ffffff50ffffff4dffffff4affffff47ffffff44ffffff41ffffff3effffff3bffffff38ffffff35ffffff32ffffff2fffffff0000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000802a0300002d0300002f030000320300003403000037030000390300003c0300003e030000410300004303000046030000480300004b0300004d030000500300005203000055030000570300005a0300005c0300005f030000610300006403000066030000690300006b0300006e030000700300007303000075030000780300007a0300007d0300007f030000820300008403000087030000890300008c0300008e030000910300009303000096030000980300009b0300009d030000a0030000a2030000a5030000a7030000aa030000ac030000af030000b1030000b4030000b6030000b9030000bb030000be030000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080" + }, + { + "of": [ + "whole", + "append 17:00", + "append 17:05" + ], + "tileId": "5m/1/41477", + "etag": "96002093", + "startMs": 1791806400000, + "partial": true, + "data": "b0faffffa1faffff92faffff83faffff74faffff65faffff56faffff47faffff38faffff29faffff1afaffff0bfafffffcf9ffffedf9ffffdef9ffffcff9ffffc0f9ffffb1f9ffffa2f9ffff93f9ffff84f9ffff75f9ffff66f9ffff57f9ffff48f9ffff39f9ffff2af9ffff1bf9ffff0cf9fffffdf8ffffeef8ffffdff8ffffd0f8ffffc1f8ffffb2f8ffffa3f8ffff94f8ffff85f8ffff76f8ffff67f8ffff58f8ffff49f8ffff3af8ffff2bf8ffff1cf8ffff0df8fffffef7ffffeff7ffffe0f7ffffd1f7ffffc2f7ffffb3f7ffffa4f7ffff95f7ffff86f7ffff77f7ffff68f7ffff59f7ffff4af7ffff3bf7ffff2cf7ffff1df7ffff0ef7ffff000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080bcffffffb9ffffffb6ffffffb3ffffffb0ffffffadffffffaaffffffa7ffffffa4ffffffa1ffffff9effffff9bffffff98ffffff95ffffff92ffffff8fffffff8cffffff89ffffff86ffffff83ffffff80ffffff7dffffff7affffff77ffffff74ffffff71ffffff6effffff6bffffff68ffffff65ffffff62ffffff5fffffff5cffffff59ffffff56ffffff53ffffff50ffffff4dffffff4affffff47ffffff44ffffff41ffffff3effffff3bffffff38ffffff35ffffff32ffffff2fffffff2cffffff29ffffff26ffffff0000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000802a0300002d0300002f030000320300003403000037030000390300003c0300003e030000410300004303000046030000480300004b0300004d030000500300005203000055030000570300005a0300005c0300005f030000610300006403000066030000690300006b0300006e030000700300007303000075030000780300007a0300007d0300007f030000820300008403000087030000890300008c0300008e030000910300009303000096030000980300009b0300009d030000a0030000a2030000a5030000a7030000aa030000ac030000af030000b1030000b4030000b6030000b9030000bb030000be030000c0030000c3030000c5030000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080" + }, + { + "of": [ + "closed", + "whole", + "append 17:00", + "append 17:05" + ], + "tileId": "5m/1/41476", + "etag": "986e18cd", + "startMs": 1791763200000, + "partial": false, + "data": "200300001103000002030000f3020000e4020000d5020000c6020000b7020000a8020000990200008a0200007b0200006c0200005d0200004e0200003f02000030020000210200001202000003020000f4010000e5010000d6010000c7010000b8010000a90100009a0100008b0100007c0100006d0100005e0100004f0100004001000031010000220100001301000004010000f5000000e6000000d7000000c8000000b9000000aa0000009b0000008c0000007d0000006e0000005f000000500000004100000032000000230000001400000005000000f6ffffffe7ffffffd8ffffffc9ffffffbaffffffabffffff9cffffff8dffffff7effffff6fffffff60ffffff51ffffff42ffffff33ffffff24ffffff15ffffff06fffffff7feffffe8feffffd9feffffcafeffffbbfeffffacfeffff9dfeffff8efeffff7ffeffff70feffff61feffff52feffff43feffff34feffff25feffff16feffff07fefffff8fdffffe9fdffffdafdffffcbfdffffbcfdffffadfdffff9efdffff8ffdffff80fdffff71fdffff62fdffff53fdffff44fdffff35fdffff26fdffff17fdffff08fdfffff9fcffffeafcffffdbfcffffccfcffffbdfcffffaefcffff9ffcffff90fcffff81fcffff72fcffff63fcffff54fcffff45fcffff36fcffff27fcffff18fcffff09fcfffffafbffffebfbffffdcfbffffcdfbffffbefbffffaffbffffa0fbffff91fbffff82fbffff73fbffff64fbffff55fbffff46fbffff37fbffff28fbffff19fbffff0afbfffffbfaffffecfaffffddfaffffcefaffffbffaffff00000000b5ffffff6affffff1fffffffd4feffff8afeffff40fefffff6fdffffacfdffff63fdffff1afdffffd1fcffff89fcffff42fcfffffbfbffffb5fbffff70fbffff2bfbffffe7faffffa4faffff62faffff20faffffe0f9ffffa0f9ffff62f9ffff25f9ffffe8f8ffffadf8ffff73f8ffff3bf8ffff03f8ffffcdf7ffff98f7ffff64f7ffff32f7ffff01f7ffffd2f6ffffa4f6ffff78f6ffff4df6ffff24f6fffffcf5ffffd6f5ffffb1f5ffff8ef5ffff6df5ffff4ef5ffff30f5ffff14f5fffffaf4ffffe1f4ffffcaf4ffffb5f4ffffa2f4ffff91f4ffff81f4ffff74f4ffff68f4ffff5ef4ffff56f4ffff50f4ffff4bf4ffff49f4ffff48f4ffff49f4ffff4cf4ffff51f4ffff58f4ffff61f4ffff6cf4ffff78f4ffff86f4ffff96f4ffffa8f4ffffbcf4ffffd2f4ffffe9f4ffff02f5ffff1df5ffff3af5ffff58f5ffff78f5ffff9af5ffffbdf5ffffe2f5ffff09f6ffff31f6ffff5bf6ffff87f6ffffb3f6ffffe2f6ffff12f7ffff43f7ffff76f7ffffaaf7ffffdff7ffff16f8ffff4ef8ffff87f8ffffc1f8fffffdf8ffff39f9ffff77f9ffffb6f9fffff5f9ffff36faffff78faffffbafafffffefaffff42fbffff87fbffffcdfbffff13fcffff5afcffffa2fcffffeafcffff32fdffff7bfdffffc5fdffff0ffeffff59feffffa3feffffeefeffff38ffffff83ffffffceffffff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000900100008d0100008a0100008701000084010000810100007e0100007b0100007801000075010000720100006f0100006c010000690100006601000063010000600100005d0100005a0100005701000054010000510100004e0100004b0100004801000045010000420100003f0100003c010000390100003601000033010000300100002d0100002a0100002701000024010000210100001e0100001b0100001801000015010000120100000f0100000c01000009010000060100000301000000010000fd000000fa000000f7000000f4000000f1000000ee000000eb000000e8000000e5000000e2000000df000000dc000000d9000000d6000000d3000000d0000000cd000000ca000000c7000000c4000000c1000000be000000bb000000b8000000b5000000b2000000af000000ac000000a9000000a6000000a3000000a00000009d0000009a0000009700000094000000910000008e0000008b0000008800000085000000820000007f0000007c000000790000007600000073000000700000006d0000006a0000006700000064000000610000005e0000005b0000005800000055000000520000004f0000004c000000490000004600000043000000400000003d0000003a0000003700000034000000310000002e0000002b0000002800000025000000220000001f0000001c000000190000001600000013000000100000000d0000000a000000070000000400000001000000fefffffffbfffffff8fffffff5fffffff2ffffffefffffffecffffffe9ffffffe6ffffffe3ffffffc2010000c5010000c7010000ca010000cc010000cf010000d1010000d4010000d6010000d9010000db010000de010000e0010000e3010000e5010000e8010000ea010000ed010000ef010000f2010000f4010000f7010000f9010000fc010000fe010000010200000302000006020000080200000b0200000d020000100200001202000015020000170200001a0200000000008000000080000000800000008000000080000000802b0200002e020000300200003302000035020000380200003a0200003d0200003f020000420200004402000047020000490200004c0200004e020000510200005302000056020000580200005b0200005d020000600200006202000065020000670200006a0200006c0200006f020000710200007402000076020000790200007b0200007e020000800200008302000085020000880200008a0200008d0200008f020000920200009402000097020000990200009c0200009e020000a1020000a3020000a6020000a8020000ab020000ad020000b0020000b2020000b5020000b7020000ba020000bc020000bf020000c1020000c4020000c6020000c9020000cb020000ce020000d0020000d3020000d5020000d8020000da020000dd020000df020000e2020000e4020000e7020000e9020000ec020000ee020000f1020000f3020000f6020000f8020000fb020000fd020000000300000203000005030000070300000a0300000c0300000f030000110300001403000016030000190300001b0300001e03000020030000230300002503000028030000" + }, + { + "of": [ + "closed", + "whole", + "append 17:00", + "append 17:05" + ], + "tileId": "5m/1/41477", + "etag": "96002093", + "startMs": 1791806400000, + "partial": true, + "data": "b0faffffa1faffff92faffff83faffff74faffff65faffff56faffff47faffff38faffff29faffff1afaffff0bfafffffcf9ffffedf9ffffdef9ffffcff9ffffc0f9ffffb1f9ffffa2f9ffff93f9ffff84f9ffff75f9ffff66f9ffff57f9ffff48f9ffff39f9ffff2af9ffff1bf9ffff0cf9fffffdf8ffffeef8ffffdff8ffffd0f8ffffc1f8ffffb2f8ffffa3f8ffff94f8ffff85f8ffff76f8ffff67f8ffff58f8ffff49f8ffff3af8ffff2bf8ffff1cf8ffff0df8fffffef7ffffeff7ffffe0f7ffffd1f7ffffc2f7ffffb3f7ffffa4f7ffff95f7ffff86f7ffff77f7ffff68f7ffff59f7ffff4af7ffff3bf7ffff2cf7ffff1df7ffff0ef7ffff000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080bcffffffb9ffffffb6ffffffb3ffffffb0ffffffadffffffaaffffffa7ffffffa4ffffffa1ffffff9effffff9bffffff98ffffff95ffffff92ffffff8fffffff8cffffff89ffffff86ffffff83ffffff80ffffff7dffffff7affffff77ffffff74ffffff71ffffff6effffff6bffffff68ffffff65ffffff62ffffff5fffffff5cffffff59ffffff56ffffff53ffffff50ffffff4dffffff4affffff47ffffff44ffffff41ffffff3effffff3bffffff38ffffff35ffffff32ffffff2fffffff2cffffff29ffffff26ffffff0000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000802a0300002d0300002f030000320300003403000037030000390300003c0300003e030000410300004303000046030000480300004b0300004d030000500300005203000055030000570300005a0300005c0300005f030000610300006403000066030000690300006b0300006e030000700300007303000075030000780300007a0300007d0300007f030000820300008403000087030000890300008c0300008e030000910300009303000096030000980300009b0300009d030000a0030000a2030000a5030000a7030000aa030000ac030000af030000b1030000b4030000b6030000b9030000bb030000be030000c0030000c3030000c5030000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080" + }, + { + "of": [ + "append 17:00", + "whole" + ], + "tileId": "5m/1/41477", + "etag": "7285c700", + "startMs": 1791806400000, + "partial": true, + "data": "b0faffffa1faffff92faffff83faffff74faffff65faffff56faffff47faffff38faffff29faffff1afaffff0bfafffffcf9ffffedf9ffffdef9ffffcff9ffffc0f9ffffb1f9ffffa2f9ffff93f9ffff84f9ffff75f9ffff66f9ffff57f9ffff48f9ffff39f9ffff2af9ffff1bf9ffff0cf9fffffdf8ffffeef8ffffdff8ffffd0f8ffffc1f8ffffb2f8ffffa3f8ffff94f8ffff85f8ffff76f8ffff67f8ffff58f8ffff49f8ffff3af8ffff2bf8ffff1cf8ffff0df8fffffef7ffffeff7ffffe0f7ffffd1f7ffffc2f7ffffb3f7ffffa4f7ffff95f7ffff86f7ffff77f7ffff68f7ffff59f7ffff4af7ffff3bf7ffff000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080bcffffffb9ffffffb6ffffffb3ffffffb0ffffffadffffffaaffffffa7ffffffa4ffffffa1ffffff9effffff9bffffff98ffffff95ffffff92ffffff8fffffff8cffffff89ffffff86ffffff83ffffff80ffffff7dffffff7affffff77ffffff74ffffff71ffffff6effffff6bffffff68ffffff65ffffff62ffffff5fffffff5cffffff59ffffff56ffffff53ffffff50ffffff4dffffff4affffff47ffffff44ffffff41ffffff3effffff3bffffff38ffffff35ffffff32ffffff2fffffff0000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000802a0300002d0300002f030000320300003403000037030000390300003c0300003e030000410300004303000046030000480300004b0300004d030000500300005203000055030000570300005a0300005c0300005f030000610300006403000066030000690300006b0300006e030000700300007303000075030000780300007a0300007d0300007f030000820300008403000087030000890300008c0300008e030000910300009303000096030000980300009b0300009d030000a0030000a2030000a5030000a7030000aa030000ac030000af030000b1030000b4030000b6030000b9030000bb030000be030000000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080000000800000008000000080" + } + ], + "tilesGet": { + "body": "a16468617665a16a356d2f312f3431343736683938366531386364", + "envelope": "a36162581ba16468617665a16a356d2f312f343134373668393836653138636461746974696c65732e6765746373696740" + }, + "tilesEnd": { + "body": "a1616e03", + "envelope": "a3616244a1616e0361746974696c65732e656e646373696740" + }, + "notes": { + "forged": "The last append with its data changed after signing: it must not verify, and is never held or laid.", + "have": "The tiles.get have that holds the closed tile: the service answers it with the pieces alone.", + "laid": "What Lay makes of the named tiles, in that order. A trailing tile stays partial; its etag is that of the data as laid.", + "tiles": "One tile of each shape, in the order the box sends them, signed by boxKey. Each passes Tiles.Check." + }, + "have": { + "answer": [ + "whole", + "append 17:00", + "append 17:05" + ] + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift index 32a312a2..ff5042b7 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift @@ -16,12 +16,20 @@ final class ScriptedBox: Carrier { /// status and JSON out. var api: ((String, String, String) -> (Int, String))? private(set) var calls: [(method: String, path: String, body: String)] = [] + /// The box's history: each `hist.query` gets these tiles, then + /// `hist.end`. Nil answers nothing. + var history: [HistChunk]? @discardableResult func send(_ frame: Bytes) -> Bool { guard status.isOpen else { return false } guard let f = try? Frame.decode(frame) else { return true } sent.append(f.envelope.t) + if f.envelope.t == "hist.query", let id = f.envelope.id, let history { + for c in history { reply("hist.chunk", id, c.cbor) } + reply("hist.end", id, .map([("resActual", .text("5m")), ("gaps", .array([]))])) + return true + } guard f.envelope.t == "api.req", let id = f.envelope.id, let b = f.envelope.b else { return true } let method = b["method"]?.string ?? "", path = b["path"]?.string ?? "" let body = b["body"]?.byteString.map { String(decoding: $0, as: UTF8.self) } ?? "" From b452aec7c9f3e7124cefcbaedb77c87e1feaa383 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 16:15:00 +0200 Subject: [PATCH 2/6] feat(demo): a box that goes away, its history from FTW Cloud's copy -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 --- appleApp/FTW/FTWApp.swift | 3 + .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 34 ++++- .../Sources/FTWDemo/SimulatedCars.swift | 6 +- .../Sources/FTWDemo/SimulatedCloud.swift | 141 ++++++++++++++++++ .../Sources/FTWKit/State/SiteModel.swift | 13 ++ .../Tests/FTWKitTests/CloudHistoryTests.swift | 41 +++++ 6 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWDemo/SimulatedCloud.swift diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index 627e59d6..707ed7d8 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -70,6 +70,9 @@ struct FTWApp: App { model.demoSharesWidgets = UserDefaults.standard.bool(forKey: "FTWDemoWidgets") model.demoKeepsCache = UserDefaults.standard.bool(forKey: "FTWDemoCache") if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() } + // `-FTWDemoBoxAway YES`: the box goes away three seconds in, and + // its history comes from FTW Cloud's copy, through the real session. + if let cloud = SimulatedCloud.current { model.site?.connectDemoCloud(endpoint: cloud.endpoint, makeSocket: cloud.factory) } // For end-to-end tests: `-FTWPairLink ` offers a pairing link // as a scanned QR would, where the simulator has no camera. if let link = UserDefaults.standard.string(forKey: "FTWPairLink") { model.offer(link) } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index b5ce6ad4..a604995d 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -108,6 +108,11 @@ public final class SimulatedBox { /// A box linked to FTW Cloud: hello carries its session information, /// with the table of opaque car ids for the cars it keeps then. public var linkedToCloud = false + /// The key the box names as its own for FTW Cloud, when a demo FTW + /// Cloud holds its signed copy; a placeholder otherwise. + var cloudKey: Bytes? + /// False for a box that never answers `hist.query`, as one going away. + var answersHistory = true public var statisticsAvailable = true private var statisticsOn = false private var cloudCopyOn = true @@ -168,6 +173,7 @@ public final class SimulatedBox { guard let from = Self.wholeMs(body["fromMs"]), let to = Self.wholeMs(body["toMs"]) else { return } sendBulk(Envelope(t: "price", id: env.id, b: pricesCBOR(from: from, to: to))) case "hist.query": + guard answersHistory else { return } // The box refuses a malformed query by name and answers the rest // tile by tile, then once more to say it is done. guard let q = HistQuery(cbor: body), !q.series.isEmpty, q.toMs > q.fromMs else { @@ -909,6 +915,15 @@ public final class LoopbackCarrier: Carrier { box.tick() } + /// The box goes away, as the phone sees it. + public func drop() { + guard !closed else { return } + closed = true + box.stop() + status = .closed(reason: "gone", retryable: true) + onStatus(status) + } + public func close(reason: String) { if closed { return } closed = true @@ -973,7 +988,24 @@ extension SimulatedBox { } #endif current = box - return LoopbackCarrier(box: box, scheduler: scheduler) + let carrier = LoopbackCarrier(box: box, scheduler: scheduler) + #if DEBUG + // For pictures of a box that goes away: `-FTWDemoBoxAway YES` + // streams for three seconds and sends no history, then goes. FTW + // Cloud's copy (`SimulatedCloud`) holds its signed tiles of the 48 + // hours before. + if UserDefaults.standard.bool(forKey: "FTWDemoBoxAway") { + let cloud = SimulatedCloud(box: box, scheduler: scheduler) + box.linkedToCloud = true + box.cloudKey = cloud.pk + box.answersHistory = false + scheduler.after(3_000) { [weak carrier] in + cloud.awayAtMs = scheduler.nowMs + carrier?.drop() + } + } + #endif + return carrier } /// Plug the demo car in or out. diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift index 469cc64c..6574ca40 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift @@ -104,9 +104,11 @@ extension SimulatedBox { /// its boot, and each car under an opaque id. The key is a fixed point, /// which the demo never signs with. func cloudSession() -> CBOR { - let pk: Bytes = [0x04] + Bytes(repeating: 0x2a, count: 64) + let pk: Bytes = cloudKey ?? [0x04] + Bytes(repeating: 0x2a, count: 64) let table = (cars ?? []).map { (Self.opaque($0.id), CBOR.text($0.id)) } - return .map([("pk", .bytes(pk)), ("boot", .bytes(Bytes(repeating: 0x0b, count: 16))), ("sources", .map([(String, CBOR)]())), + // A demo FTW Cloud holds a copy, so the box names its meter. + let sources: [(String, CBOR)] = cloudKey == nil ? [] : [("o-sdm630", .text("sdm630"))] + return .map([("pk", .bytes(pk)), ("boot", .bytes(Bytes(repeating: 0x0b, count: 16))), ("sources", .map(sources)), ("loadpoints", .map([("o-garage", .text("garage"))])), ("cars", .map(table))]) } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedCloud.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedCloud.swift new file mode 100644 index 00000000..e1fabd92 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedCloud.swift @@ -0,0 +1,141 @@ +#if DEBUG +import CryptoKit +import Foundation +import FTWKit + +/// FTW Cloud for pictures of a box that goes away (`-FTWDemoBoxAway YES`): +/// the app session at the service, over loopback, through the real +/// handshake, frames and checks. It holds the demo house's tiles of the 48 +/// hours before the box went away, signed with the key the demo box names as +/// its own in hello, and answers `tiles.get` as the service does. It holds +/// no readings: the box has gone. +@MainActor +public final class SimulatedCloud { + /// The demo's one FTW Cloud, while a box that goes away has one. + public private(set) static var current: SimulatedCloud? + + let box: SimulatedBox + let scheduler: Scheduler + /// When the box went away, by the phone's clock. + var awayAtMs: Double? + private let serviceKey = Primitives.generateKeyPair() + private let boxKey = P256.Signing.PrivateKey() + private var seq: UInt64 = 0 + + init(box: SimulatedBox, scheduler: Scheduler) { + self.box = box + self.scheduler = scheduler + Self.current = self + } + + /// The box's key, as it names it in hello. + var pk: Bytes { Bytes(boxKey.publicKey.x963Representation) } + + public var endpoint: CloudEndpoint { + CloudEndpoint(url: URL(string: "wss://cloud.demo/v1/app")!, serviceKeys: [serviceKey.publicKey]) + } + + public var factory: WebSocketFactory { + { [self] _, events in SimulatedCloudSocket(cloud: self, events: events) } + } + + func handshake() -> HandshakeState { + HandshakeState.responder(staticKey: serviceKey, prologue: CloudWire.appPrologue(home: CloudWire.homeID(pk: pk))) + } + + /// The tiles of the 48 hours up to the last bucket that closed before + /// the box went away: the closed ones, and the one still filling whole, + /// each as the box signs it. + func tiles() -> [CloudWire.Envelope] { + let away = awayAtMs ?? scheduler.nowMs + let step = 300_000.0, span = 43_200_000.0 + let end = ((away - 30_000) / step).rounded(.down) * step + var out = [CloudWire.Envelope]() + var start = HistoryGeometry.tileStart(.fiveMinutes, end - Double(CloudWire.tileKeepMs)) + while start < end { + let columns = CloudWire.tileSeries.map { name in + (0..<144).map { i -> Int32 in + let at = start + Double(i) * step + guard at + step <= end, let w = box.house.bucket(at, nowMs: away)?.value(name) else { return missingSample } + return Int32(w.rounded()) + } + } + let data = HistoryGeometry.pack(columns) + seq += 1 + let body = encodeCBOR(.map([ + ("seq", .unsigned(seq)), ("boot", .bytes(Bytes(repeating: 0x0b, count: 16))), ("uptimeMs", .ms(box.uptimeMs)), + ("wallMs", .ms(end + 30_000)), + ("tileId", .text(HistoryGeometry.tileID(.fiveMinutes, stride: 1, startMs: start))), ("etag", .text(HistoryGeometry.etag(data))), + ("res", .text("5m")), ("startMs", .ms(start)), ("stepMs", .ms(step)), + ("series", .array(CloudWire.tileSeries.map { .text($0) })), ("data", .bytes(data)), ("partial", .bool(start + span > end)), + ])) + let home = CloudWire.homeID(pk: pk) + if let sig = try? boxKey.signature(for: Data(CloudWire.signedInput(home: home, t: CloudWire.msgTiles, b: body))) { + out.append(CloudWire.Envelope(t: CloudWire.msgTiles, b: body, sig: Bytes(sig.rawRepresentation))) + } + start += span + } + return out + } +} + +/// One phone's app session with the demo's FTW Cloud. +@MainActor +final class SimulatedCloudSocket: WebSocketConnection { + private unowned let cloud: SimulatedCloud + private let events: WebSocketEvents + private var transport: NoiseTransport? + private var closed = false + private let latencyMs: Double = 40 + + init(cloud: SimulatedCloud, events: WebSocketEvents) { + self.cloud = cloud + self.events = events + cloud.scheduler.after(latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.events.onOpen() + } + } + + func send(_ data: Bytes) { + guard !closed else { return } + guard let transport else { + let hs = cloud.handshake() + let snapshot = CloudWire.encodeUnsigned(t: CloudWire.msgSnapshot, body: .map([ + ("envelopes", .array([])), ("lastSeenMs", .ms(cloud.awayAtMs ?? cloud.scheduler.nowMs)), ("online", .bool(false)), ("more", .int(0)), + ])) + guard (try? hs.readMessage(data)) != nil, let msg2 = try? hs.writeMessage(snapshot), let keys = try? hs.split() else { return } + self.transport = NoiseTransport(keys) + deliver(msg2) + return + } + guard let frame = try? transport.decrypt(data), let payload = try? CloudWire.decodeFrame(frame), + let env = try? CloudWire.Envelope.decode(payload), env.t == CloudWire.msgTilesGet else { return } + var have = [String: String]() + for e in (try? decodeCBOR(env.b))?["have"]?.entries ?? [] { + if let k = e.key.string, let v = e.value.string { have[k] = v } + } + var n = 0 + for tile in cloud.tiles() { + if let t = try? CloudWire.Tiles(body: tile.b), !t.partial, have[t.tileId] == t.etag { continue } + push(tile.encode()) + n += 1 + } + push(CloudWire.encodeUnsigned(t: CloudWire.msgTilesEnd, body: .map([("n", .int(n))]))) + } + + func close() { closed = true } + + private func push(_ envelope: Bytes) { + guard let transport, let frame = try? CloudWire.encodeFrame(envelope), let msg = try? transport.encrypt(frame) else { return } + deliver(msg) + } + + private func deliver(_ bytes: Bytes) { + cloud.scheduler.after(latencyMs) { [weak self] in + guard let self, !self.closed else { return } + self.events.onBinary(bytes) + } + } +} +#endif diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 4bdde303..e8ca975b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1098,6 +1098,19 @@ extension SiteModel { startCloud() } + #if DEBUG + /// The demo's FTW Cloud (`SimulatedCloud`), for pictures of a box that + /// goes away. It asks the person nothing: no alerts. + public func connectDemoCloud(endpoint: CloudEndpoint, makeSocket: @escaping WebSocketFactory) { + guard isDemo, !destroyed else { return } + pushTokens = PushTokenSource(install: { _ in }, uninstall: {}) + cloudDevice = Primitives.generateKeyPair() + cloudSocket = makeSocket + cloudEndpoint = endpoint + startCloud() + } + #endif + /// The box has not spoken to this phone, now or at an earlier launch. var neverHeard: Bool { !core.state.heardFromBox && kept?.hello == nil } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift index e3e77955..b0e2c9de 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing @testable import FTWKit +@testable import FTWDemo /// The history while the box cannot answer: FTW Cloud's tiles, signed by the /// box and dated, and the box's own answer the moment it comes back. @@ -152,6 +153,46 @@ import Testing #expect(!history.fromCloud) } + /// The demo's picture of a box that goes away (`-FTWDemoBoxAway`) runs + /// the same path: the box names its key in hello and goes, and FTW + /// Cloud's signed tiles draw, dated. + @Test func theDemosAwayBoxDrawsFromTheCopy() async throws { + final class Parts { + var cloud: SimulatedCloud? + var carrier: LoopbackCarrier? + } + let parts = Parts() + let scheduler = ManualScheduler(nowMs: GridTodayShareTests.evening) + let app = AppModel(store: MemoryStore(), files: nil, shared: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { s in + let box = SimulatedBox(scheduler: s) + DemoCars.one.apply(to: box, nowMs: s.nowMs) + let cloud = SimulatedCloud(box: box, scheduler: s) + box.linkedToCloud = true + box.cloudKey = cloud.pk + box.answersHistory = false + parts.cloud = cloud + let carrier = LoopbackCarrier(box: box, scheduler: s) + parts.carrier = carrier + return carrier + }) + app.startDemo() + let cloud = try #require(parts.cloud) + let site = try #require(app.site) + site.connectDemoCloud(endpoint: cloud.endpoint, makeSocket: cloud.factory) + let history = try #require(app.home?.nodes.history) + await GridTodayShareTests().run(scheduler, 3_000) + #expect(site.cloudCopyOpenSinceMs != nil) + #expect(!history.fromCloud, "the box still streams") + + cloud.awayAtMs = scheduler.nowMs + parts.carrier?.drop() + await GridTodayShareTests().run(scheduler, 1_000) + #expect(history.fromCloud) + let upTo = try #require(history.cloudUpToMs) + #expect(upTo <= cloud.awayAtMs! - 30_000 && upTo > cloud.awayAtMs! - 10 * 60_000) + #expect(history.samples(.solar, fromMs: GridTodayShareTests.evening - 24 * 3_600_000).contains { $0.watts != nil }) + } + /// A copy that ends on another day says which. @Test func anOlderCopyNamesItsDay() async throws { let (w, history, start) = try Self.awayBox() From 2baace3938151079e5626dc7b2b34037dab60690 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 16:18:37 +0200 Subject: [PATCH 3/6] fix(cloud): date FTW Cloud's history before its sum 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 --- appleApp/FTW/UI/NodeView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 68a3be21..286f6c61 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -1045,9 +1045,9 @@ private enum PeriodWords { let e = EnergyToday.sum(models.history.samples(node, fromMs: fromMs), fromMs: fromMs) let energy = NodeSummary.energy(node, e, today: period == .today) // FTW Cloud's copy while the box is away: dated, on a line of - // its own. + // its own, first, so a long line cuts the sum and never the date. guard let cloud = models.history.cloudNote(nowMs: site.minuteMs, zone: zone) else { return energy } - return energy + "\n" + cloud + return cloud + "\n" + energy case .ahead: let now = site.nowMs // "No plan" only once the box has answered without one; until From 42488d9c94d26f73037963c483e54fdc4a0b2342 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 16:28:31 +0200 Subject: [PATCH 4/6] fix(cloud): the time after FTW Cloud's copy ends is not a hole 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 --- .../FTWKit/Sources/FTWKit/State/HistoryModel.swift | 12 +++++++----- .../FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift | 7 +++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift index 75a2b9f6..2ce52d00 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift @@ -293,7 +293,7 @@ public final class HistoryModel { /// with a hole where the box recorded nothing. public func samples(_ node: EnergyNode, fromMs: Double) -> [HistorySample] { guard let frame else { return [] } - return Self.samples(frame, node: node, fromMs: fromMs, fetching: pending) + return Self.samples(frame, node: node, fromMs: fromMs, untilMs: cloudUpToMs, fetching: pending) } /// A tile not here yet is on its way: while the box or FTW Cloud is @@ -301,14 +301,16 @@ public final class HistoryModel { var pending: Bool { loading || cloudAsking || (!loaded && problem == nil && !cloudAnswered) } /// `fetching`: a hole whose tile has not arrived is on its way, not - /// known to be empty. - nonisolated static func samples(_ frame: HistoryGeometry.Frame, node: EnergyNode, fromMs: Double, fetching: Bool = false) -> [HistorySample] { + /// known to be empty. `untilMs`: where FTW Cloud's copy ends. What + /// follows is not in the copy, which says nothing about whether the box + /// recorded it, so it is left out rather than counted as a hole. + nonisolated static func samples(_ frame: HistoryGeometry.Frame, node: EnergyNode, fromMs: Double, untilMs: Double? = nil, fetching: Bool = false) -> [HistorySample] { guard frame.names.contains(node.series) else { return [] } var out = [HistorySample]() out.reserveCapacity(frame.points) for i in 0.. fromMs else { continue } + guard t + frame.stepMs > fromMs, t < untilMs ?? .infinity else { continue } let watts = frame.value(node.series, at: i).map { node.shown(Double($0)) } out.append(HistorySample(startMs: t, stepMs: frame.stepMs, watts: watts, fetching: watts == nil && fetching && !frame.hasArrived(i))) } @@ -323,7 +325,7 @@ public final class HistoryModel { /// Energy since midnight, from the same tiles the chart draws. public func today(_ node: EnergyNode) -> EnergyToday? { guard let frame, frame.names.contains(node.series) else { return nil } - return EnergyToday.sum(Self.samples(frame, node: node, fromMs: midnightMs), fromMs: midnightMs) + return EnergyToday.sum(Self.samples(frame, node: node, fromMs: midnightMs, untilMs: cloudUpToMs), fromMs: midnightMs) } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift index b0e2c9de..99a44a4d 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift @@ -68,9 +68,12 @@ import Testing #expect(grid.first?.watts == 100) let today = grid.filter { $0.startMs >= Double(start) } #expect(today[35].watts == 135) - #expect(today[36].watts == nil) - // FTW Cloud has answered: what it lacks is not on its way. + // FTW Cloud has answered: what it lacks is not on its way. Nor is + // the time after the copy ends a hole the box confirmed: it is left + // out, so the sum counts nothing as not recorded. #expect(!grid.contains { $0.fetching }) + #expect(today.count == 36) + #expect(EnergyToday.sum(today, fromMs: Double(start)).missingMs == 0) // Dated where the copy ends, never as now. let upTo = Double(start + 36 * Self.step) From 85049f0fdd473f13875d17b24092b922d9874cb2 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 16:39:37 +0200 Subject: [PATCH 5/6] fix(cloud): stay dated after a relaunch; a later closed tile replaces 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 --- .../Sources/FTWKit/Carrier/CloudTiles.swift | 14 +++++++---- .../Sources/FTWKit/State/HistoryModel.swift | 6 ++++- .../Tests/FTWKitTests/CloudHistoryTests.swift | 22 +++++++++++++++++ .../Tests/FTWKitTests/CloudTilesTests.swift | 24 +++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift index 05361783..aed9be37 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift @@ -82,8 +82,9 @@ extension CloudWire { /// Puts tiles back together in the order the box sent them: a closed tile /// as it is; the trailing tile from its newest whole piece, with each - /// later append laid over it. A piece of a tile already closed changes - /// nothing, and neither does a body that fails `check`. One chunk per + /// later append laid over it; a later closed tile replaces an earlier + /// one. A piece of a tile already closed changes nothing, and neither + /// does a body that fails `check`. One chunk per /// tile, oldest first; a trailing tile stays partial, its etag that of /// the data as laid, never one to offer as held. public static func lay(_ bodies: [Tiles]) -> [HistChunk] { @@ -91,7 +92,9 @@ extension CloudWire { for t in bodies { guard (try? t.check()) != nil else { continue } var held = byID[t.tileId] - if let h = held, !h.partial { continue } + // A piece of a tile already closed. A closed tile sent again, + // after the box's ledger changed under it, replaces the first. + if let h = held, !h.partial, t.partial { continue } guard t.partial else { byID[t.tileId] = t.chunk continue @@ -152,14 +155,15 @@ struct CloudTileStore: Equatable, Sendable { private static func sent(_ t: CloudWire.Tiles) -> (Int64, UInt64) { (t.header.wallMs ?? 0, t.header.seq) } /// Keeps one checked body. A closed tile replaces every piece of its - /// tile; a whole piece every piece the box sent before it. A body held + /// tile, and an earlier closed one; a whole piece every piece the box + /// sent before it. A body held /// already (a forwarded append that came again in an answer) changes /// nothing. mutating func take(_ t: CloudWire.Tiles, nowMs: Int64) { bodies.removeAll { $0.tileEnd <= nowMs - CloudWire.tileKeepMs } guard t.tileEnd > nowMs - CloudWire.tileKeepMs else { return } if bodies.contains(where: { $0.header.boot == t.header.boot && $0.header.seq == t.header.seq }) { return } - if bodies.contains(where: { $0.tileId == t.tileId && !$0.partial }) { return } + if t.partial, bodies.contains(where: { $0.tileId == t.tileId && !$0.partial }) { return } if !t.partial { bodies.removeAll { $0.tileId == t.tileId } } else if t.whole { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift index 2ce52d00..e91b83f7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift @@ -223,7 +223,11 @@ public final class HistoryModel { } took = true } - if took { fromCloud = true } + // Nothing new, as after a launch with the box still away and the + // copy's tiles already in the file: what is shown is FTW Cloud's + // copy all the same until the box answers, and is dated as one. + let confirms = !loaded && cloudAnswered && site.cloudTiles.laid.contains { c in plan.tiles.contains { $0.tileId == c.tileId } } + if took || confirms { fromCloud = true } show(plan, from, to) cloudUpToMs = fromCloud ? frame.flatMap(Self.dataEndMs) : nil } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift index 99a44a4d..9e031c87 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift @@ -94,6 +94,28 @@ import Testing #expect(history.samples(.grid, fromMs: Double(start))[36].watts == 136) } + /// A launch after one that drew FTW Cloud's copy, with the box still + /// away: the file draws it at once, the copy's answer brings nothing + /// new, and what is shown stays dated as the copy. + @Test func aRelaunchWithTheBoxStillAwayStaysDated() async throws { + let (w, history, start) = try Self.awayBox() + await w.run(100) + #expect(history.fromCloud) + w.site.persistNow(wait: true) + w.site.destroy() + + w.open() + let again = HistoryModel(site: w.site) + #expect(again.frame != nil, "the file draws before the copy answers") + w.connectCloud() + await w.run(100) + #expect(again.fromCloud) + #expect(again.cloudUpToMs == Double(start + 36 * Self.step)) + #expect(again.cloudNote(nowMs: w.scheduler.nowMs, zone: Self.utc) != nil) + let today = again.samples(.grid, fromMs: Double(start)) + #expect(EnergyToday.sum(today, fromMs: Double(start)).missingMs == 0) + } + @Test func theBoxsAnswerReplacesTheCopy() async throws { let (w, history, start) = try Self.awayBox() await w.run(100) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift index 81dc5094..d68b8d16 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift @@ -171,4 +171,28 @@ struct CloudTileVectors: Decodable { s.take(later, nowMs: whole.tileEnd + CloudWire.tileKeepMs) #expect(s.isEmpty) } + + /// The box sends a closed tile again when its ledger changed under it. + /// The later one replaces the first; a piece of a closed tile still + /// changes nothing. + @Test func aLaterClosedTileReplacesTheFirst() throws { + let v = try CloudTileVectors.load() + let closed = try v.body("closed") + var fixed = closed + fixed.data[0] ^= 1 + fixed.etag = HistoryGeometry.etag(fixed.data) + fixed.header.seq += 10 + fixed.header.wallMs = (closed.header.wallMs ?? 0) + 3_600_000 + var piece = try v.body("whole") + piece.tileId = closed.tileId + piece.startMs = closed.startMs + piece.header.wallMs = (fixed.header.wallMs ?? 0) + 1 + + #expect(CloudWire.lay([closed, fixed, piece]).map(\.etag) == [fixed.etag]) + + var s = CloudTileStore() + let now = closed.tileEnd + 3_600_000 + for t in [closed, fixed, piece] { s.take(t, nowMs: now) } + #expect(s.bodies == [fixed]) + } } From 8e758648d295371e6d453f8e7b02878e241191c9 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 17:57:59 +0200 Subject: [PATCH 6/6] fix(cloud): tiles after review: four series, ordered closed tiles, the 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 --- .../Sources/FTWKit/Carrier/CloudTiles.swift | 14 +++--- .../Sources/FTWKit/State/HistoryModel.swift | 23 +++++++-- .../Tests/FTWKitTests/CloudHistoryTests.swift | 47 +++++++++++++++++++ .../Tests/FTWKitTests/CloudTilesTests.swift | 11 +++++ 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift index aed9be37..a9f1cdf4 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift @@ -17,14 +17,14 @@ extension CloudWire { /// Service to phone, `{n}`: the answer to `tiles.get` is complete. public static let msgTilesEnd = "tiles.end" - /// The columns the box sends, in this order: what the history asks the - /// box for, so a closed tile is the same bytes either way. + /// The columns the box sends, in this order, and the only ones a tile + /// may carry: what the history asks the box for, so a closed tile is the + /// same bytes either way. public static let tileSeries = ["grid_w", "pv_w", "battery_w", "load_w"] /// How far back the service keeps tiles, and the phone holds them. public static let tileKeepMs: Int64 = 48 * 3_600_000 /// How many pieces of the trailing tile a holder keeps. static let tilePiecesMax = 16 - static let tileSeriesMax = 16 static let tileStepMs: Int64 = 300_000 static let tileSpanMs: Int64 = 43_200_000 static let tilePoints = 144 @@ -60,8 +60,7 @@ extension CloudWire { guard tileId == HistoryGeometry.tileID(.fiveMinutes, stride: 1, startMs: Double(tileStart)) else { throw bad("tile id does not name the tile its start is in") } - guard !series.isEmpty, series.count <= CloudWire.tileSeriesMax else { throw bad("series count") } - guard Set(series).count == series.count, series.allSatisfy({ !$0.isEmpty && $0.utf8.count <= 64 }) else { throw bad("series names") } + guard series == CloudWire.tileSeries else { throw bad("series are not tileSeries") } let n = points guard n >= 1, data.count == n * 4 * series.count else { throw bad("data is not whole columns") } guard Int((startMs - tileStart) / stepMs) + n <= CloudWire.tilePoints else { throw bad("data runs past the tile") } @@ -155,8 +154,8 @@ struct CloudTileStore: Equatable, Sendable { private static func sent(_ t: CloudWire.Tiles) -> (Int64, UInt64) { (t.header.wallMs ?? 0, t.header.seq) } /// Keeps one checked body. A closed tile replaces every piece of its - /// tile, and an earlier closed one; a whole piece every piece the box - /// sent before it. A body held + /// tile, and a closed one the box sent before it, never one it sent + /// after; a whole piece every piece the box sent before it. A body held /// already (a forwarded append that came again in an answer) changes /// nothing. mutating func take(_ t: CloudWire.Tiles, nowMs: Int64) { @@ -165,6 +164,7 @@ struct CloudTileStore: Equatable, Sendable { if bodies.contains(where: { $0.header.boot == t.header.boot && $0.header.seq == t.header.seq }) { return } if t.partial, bodies.contains(where: { $0.tileId == t.tileId && !$0.partial }) { return } if !t.partial { + if let held = bodies.first(where: { $0.tileId == t.tileId && !$0.partial }), Self.sent(t) < Self.sent(held) { return } bodies.removeAll { $0.tileId == t.tileId } } else if t.whole { bodies.removeAll { $0.tileId == t.tileId && Self.sent($0) < Self.sent(t) } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift index e91b83f7..59db05e8 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift @@ -63,6 +63,9 @@ public final class HistoryModel { @ObservationIgnored private var running: Task? /// The FTW Cloud session last asked for tiles, by when it opened, and /// whether its answer is complete. + /// The closed tiles shown that came from FTW Cloud's copy, not from the + /// box. The box's own tiles win; a copy's tile replaces only a copy's. + @ObservationIgnored private var fromCopy = Set() @ObservationIgnored private var cloudAsked: Double? @ObservationIgnored private var cloudAsking = false @ObservationIgnored private var cloudAnswered = false @@ -165,6 +168,7 @@ public final class HistoryModel { self.open = chunk } else { self.tiles[chunk.tileId] = chunk + self.fromCopy.remove(chunk.tileId) if self.open?.tileId == chunk.tileId { self.open = nil } } // Drawn as the tiles arrive, a display frame at a time: a tile @@ -217,18 +221,27 @@ public final class HistoryModel { guard tiles[c.tileId] == nil, Self.reach(c) > open.map(Self.reach) ?? -.infinity else { continue } open = c } else { - guard tiles[c.tileId]?.etag != c.etag else { continue } + if let held = tiles[c.tileId] { + // The box's own tile wins over the copy's. + guard held.etag != c.etag, fromCopy.contains(c.tileId) else { continue } + } tiles[c.tileId] = c + fromCopy.insert(c.tileId) if open?.tileId == c.tileId { open = nil } } took = true } + if took { fromCloud = true } + show(plan, from, to) // Nothing new, as after a launch with the box still away and the // copy's tiles already in the file: what is shown is FTW Cloud's - // copy all the same until the box answers, and is dated as one. - let confirms = !loaded && cloudAnswered && site.cloudTiles.laid.contains { c in plan.tiles.contains { $0.tileId == c.tileId } } - if took || confirms { fromCloud = true } - show(plan, from, to) + // copy all the same until the box answers, and is dated as one; + // unless the file reaches further than the copy, which then is the + // box's. + if !fromCloud, !loaded, cloudAnswered, let end = frame.flatMap(Self.dataEndMs) { + let reach = site.cloudTiles.laid.filter { c in plan.tiles.contains { $0.tileId == c.tileId } }.map(Self.reach).max() + if let reach, reach >= end { fromCloud = true } + } cloudUpToMs = fromCloud ? frame.flatMap(Self.dataEndMs) : nil } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift index 9e031c87..ee688b04 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift @@ -116,6 +116,53 @@ import Testing #expect(EnergyToday.sum(today, fromMs: Double(start)).missingMs == 0) } + /// The box answered, then went away again: a closed tile from the copy + /// with other values does not replace the box's own. + @Test func theBoxsOwnClosedTileWinsOverTheCopys() async throws { + let (w, history, start) = try Self.awayBox() + await w.run(100) + w.relay.history = [Self.boxTile(start: start - Self.span, filled: 144, closed: true, base: 500), + Self.boxTile(start: start, filled: 37, closed: false, base: 500)] + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity, caps: [Contract.capCloudLink, Contract.capAPIPassthrough, Contract.capHistory5m]) + w.relay.snap(uptimeMs: w.uptime, gridW: 900) + await w.run(200) + #expect(history.samples(.grid, fromMs: Double(start - Self.span)).first?.watts == 500) + + w.relay.drop() + await w.run(50) + w.service.push(try Self.tile(w, start: start - Self.span, closed: true, base: 700)) + await w.run(50) + #expect(history.samples(.grid, fromMs: Double(start - Self.span)).first?.watts == 500) + } + + /// A launch whose file holds the box's own history, further than the + /// copy reaches: the box away, the copy's answer adds nothing, and what + /// is shown is not labelled as the copy. + @Test func theBoxsCachedHistoryIsNotLabelledTheCopy() async throws { + let (w, history, start) = try Self.awayBox() + await w.run(100) + w.relay.history = [Self.boxTile(start: start - Self.span, filled: 144, closed: true, base: 500), + Self.boxTile(start: start, filled: 37, closed: false, base: 500)] + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity, caps: [Contract.capCloudLink, Contract.capAPIPassthrough, Contract.capHistory5m]) + w.relay.snap(uptimeMs: w.uptime, gridW: 900) + await w.run(200) + #expect(!history.fromCloud) + w.site.persistNow(wait: true) + w.site.destroy() + + w.open() + let again = HistoryModel(site: w.site) + w.connectCloud() + await w.run(100) + #expect(!again.fromCloud) + #expect(again.cloudNote(nowMs: w.scheduler.nowMs, zone: Self.utc) == nil) + #expect(again.samples(.grid, fromMs: Double(start))[36].watts == 536) + } + @Test func theBoxsAnswerReplacesTheCopy() async throws { let (w, history, start) = try Self.awayBox() await w.run(100) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift index d68b8d16..3a29226d 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift @@ -125,6 +125,7 @@ struct CloudTileVectors: Decodable { "stride 2": { $0.tileId = HistoryGeometry.tileID(.fiveMinutes, stride: 2, startMs: Double($0.tileStart)) }, "no series": { $0.series = [] }, "twice a series": { $0.series = ["grid_w", "grid_w", "pv_w", "load_w"] }, + "another series": { $0.series = ["grid_w", "pv_w", "battery_w", "ev_w"] }, "closed but a piece": { $0.partial = false }, "half a column": { t in t.data.removeLast(2) @@ -194,5 +195,15 @@ struct CloudTileVectors: Decodable { let now = closed.tileEnd + 3_600_000 for t in [closed, fixed, piece] { s.take(t, nowMs: now) } #expect(s.bodies == [fixed]) + + // The first version, late: it replaces nothing. + s.take(closed, nowMs: now) + #expect(s.bodies == [fixed]) + } + + /// The history asks the box for the series a tile carries, so a closed + /// tile from the copy and one from the box are the same bytes. + @Test func theHistoryAsksForTheTilesSeries() { + #expect(HistoryModel.series == CloudWire.tileSeries) } }