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/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 4852377a..286f6c61 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, 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 cloud + "\n" + energy case .ahead: let now = site.nowMs // "No plan" only once the box has answered without one; until 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/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..a9f1cdf4 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudTiles.swift @@ -0,0 +1,184 @@ +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, 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 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 == 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") } + 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 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] { + var byID = [String: HistChunk]() + for t in bodies { + guard (try? t.check()) != nil else { continue } + var held = byID[t.tileId] + // 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 + } + 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, 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) { + 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 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) } + } + 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..59db05e8 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,14 @@ 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. + /// 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 @ObservationIgnored let calendar: Calendar public init(site: SiteModel, calendar: Calendar = .current) { @@ -68,6 +88,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 +100,7 @@ public final class HistoryModel { public func activate() { active += 1 ask?.evaluate() + askCloud() } public func deactivate() { @@ -128,6 +151,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) @@ -142,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 @@ -155,6 +182,94 @@ 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 { + 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; + // 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 + } + + /// 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. @@ -195,22 +310,24 @@ 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 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. - 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))) } @@ -225,7 +342,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/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..e8ca975b 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 @@ -1093,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 } @@ -1156,6 +1174,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 +1197,7 @@ extension SiteModel { uninstallPushSink() cloudView = nil cloudIdentity = nil + cloudTiles = CloudTileStore() ruleU = nil cloudDirty = false if let siteId { files?.remove("cloud-\(siteId)") } @@ -1309,13 +1329,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 +1366,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..ee688b04 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudHistoryTests.swift @@ -0,0 +1,277 @@ +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. +@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) + // 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) + #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) + } + + /// 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) + } + + /// 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) + #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) + } + + /// 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() + 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..3a29226d --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudTilesTests.swift @@ -0,0 +1,209 @@ +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"] }, + "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) + 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) + } + + /// 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]) + + // 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) + } +} 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) } ?? ""