Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions appleApp/FTW/FTWApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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) }
Expand Down
6 changes: 5 additions & 1 deletion appleApp/FTW/UI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))])
}

Expand Down
141 changes: 141 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedCloud.swift
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading