diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index d967999..bfe2b53 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -110,7 +110,7 @@ private struct NodeHero: View { VStack(alignment: .leading, spacing: 8) { if let reading { HeroLine(reading: reading, devices: beside ?? []).equatable() - HeroFreshness(freshness: worst(reading.freshness, sources.devices[node] ?? [])) + HeroFreshness(freshness: worst(reading.freshness, sources.devices[node] ?? []), showsSource: node != .home) if beside == nil, let split { DeviceRows(site: site, devices: split, readAtMs: sources.readAtMs, tint: Theme.color(reading.tone)) } @@ -239,6 +239,8 @@ private struct HeroLine: View, Equatable { private struct HeroFreshness: View { let freshness: NodeFreshness + /// The house is no device: the meter the box reads it from is not its name. + var showsSource = true var body: some View { HStack(spacing: 6) { @@ -256,7 +258,7 @@ private struct HeroFreshness: View { /// "Live · sungrow", or how old and why not live. private var line: String { - let name = freshness.sourceName.map { " · \($0)" } ?? "" + let name = showsSource ? freshness.sourceName.map { " · \($0)" } ?? "" : "" if freshness.live { return L("Live") + name } if freshness.carrier == .cache { return NodeWords.format("Saved on this phone, %@", NodeLook.age(freshness)) + name } switch freshness.srcState { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift index 8ef4fc8..8514e2f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift @@ -91,7 +91,7 @@ public struct NodeFreshness: Equatable, Sendable { } public var live: Bool { - (carrier == .relay || carrier == .webrtc) && srcState == .live + (carrier == .relay || carrier == .webrtc || carrier == .cloud) && srcState == .live } /// From the session as it stands: the field's source in the box's @@ -137,7 +137,7 @@ extension SiteModel { } public func freshness(fid: Int) -> NodeFreshness { - NodeFreshness.of( + let f = NodeFreshness.of( fid: fid, session: session, carrier: carrier, @@ -145,6 +145,10 @@ extension SiteModel { shelfMs: carrier == .cache ? cachedAtMs.map { max(0, nowMs - $0) } : nil, fallback: lastFrameAtMs == nil ? .stale : srcState ) + // Through FTW Cloud a reading is no fresher than the cloud's own + // grade for its field: anchored, and inside the age rules. + guard carrier == .cloud, let cap = cloudGrade(fid: fid), cap > f.srcState else { return f } + return NodeFreshness(carrier: f.carrier, srcState: cap, ageMs: f.ageMs, sourceName: f.sourceName) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 89bbd3c..cd6f7b2 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1089,28 +1089,21 @@ extension SiteModel { scheduleClock() } - /// The cloud is where readings come from while its session is open and it - /// holds a live, unless the box session streams live readings and the - /// cloud's are not live. Numbers never step back in time: a copy that is - /// not live takes over only when it is at least as new as what is on - /// screen. With neither streaming, the newer copy stays. + /// The box's own stream is where live readings come from: straight from + /// the box, at its own rate. FTW Cloud gives the first frame before that + /// stream is up, and stands in when it falls silent. Numbers never step + /// back in time: a copy that is not live takes over only when it is at + /// least as new as what is on screen. With neither live, the newer copy + /// stays. private func readingSource() -> ReadingSource { guard let view = cloudView, view.live != nil else { return .relay } let now = scheduler.nowMs let relayStreaming = relay.phase == .streaming && relayFrameAtMs != nil + if relayStreaming, relaySrcState == .live { return .relay } let cloudLive = Self.cloudState(view, nowMs: now) == .live - if relayStreaming, relaySrcState == .live, !cloudLive { - return .relay - } + if cloud?.status.isOpen == true, cloudLive { return .cloud } let cloudAt = view.anchorSentMs(nowMs: Int64(now)).map(Double.init) - let relayAt = relayFrameAtMs ?? cachedAtMs - if cloud?.status.isOpen == true { - if cloudLive || source == .cloud { return .cloud } - guard let relayAt else { return .cloud } - return (cloudAt ?? -.infinity) >= relayAt ? .cloud : .relay - } - if relayStreaming { return .relay } - guard let relayAt else { return .cloud } + guard let relayAt = relayFrameAtMs ?? cachedAtMs else { return .cloud } return (cloudAt ?? -.infinity) >= relayAt ? .cloud : .relay } @@ -1158,6 +1151,16 @@ extension SiteModel { return worst } + /// The cloud's grade for one field, never live without an anchor. Nil + /// when Now is not reading through FTW Cloud. + func cloudGrade(fid: Int) -> SourceState? { + guard shown.source == .cloud, let view = shown.cloudView else { return nil } + let g = view.grade(nowMs: Int64(nowMs)) + var grade = g.fields[String(fid)] ?? g.sources.values.max() ?? .live + if !g.anchored { grade = max(grade, .stale) } + return grade + } + /// The age of the oldest reading Now draws, by the anchor. Nil when the /// copy cannot be anchored: unverified, shown as "—". static func cloudAge(_ view: CloudView, nowMs: Double) -> Double? { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 0e9c210..6400760 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -145,6 +145,18 @@ import FTWShared #expect(computed.srcState == .lagging) } + @Test func aLiveReadingThroughFTWCloudIsLive() { + // 0.3.0 said "Not live, just now" on every node read through FTW + // Cloud, and Now's flow stood still: the rule knew only the relay. + for carrier: CarrierKind in [.relay, .webrtc, .cloud] { + #expect(NodeFreshness(carrier: carrier, srcState: .live, ageMs: 0, sourceName: nil).live, "\(carrier)") + #expect(!NodeFreshness(carrier: carrier, srcState: .stale, ageMs: 0, sourceName: nil).live, "\(carrier)") + } + for carrier: CarrierKind in [.cache, .none] { + #expect(!NodeFreshness(carrier: carrier, srcState: .live, ageMs: 0, sourceName: nil).live, "\(carrier)") + } + } + @Test func nowDrawsEachNodeWithItsOwnFreshness() { let s = Self.session(state: .stale, lastOkAgo: 40_000) let readings = AppWords.$language.withValue(.en) { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift index a838b08..32a312a 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift @@ -49,6 +49,12 @@ final class ScriptedBox: Carrier { status = .closed(reason: reason, retryable: false) } + /// The box session drops, as the phone sees it. + func drop() { + status = .closed(reason: "gone", retryable: true) + onStatus(status) + } + func open(_ at: Double) { status = .open(sinceMs: at) onStatus(status) @@ -240,9 +246,10 @@ final class CloudSite { #expect(w.site.ageMs != nil) } - /// The cloud fails or is out of reach: the box session's readings take - /// over under its own name, and the cloud takes back over when it heals. - @Test func theRelayTakesOverWhileTheCloudIsDown() async throws { + /// Both paths live: the box's own stream wins, straight from the box at + /// its own rate. When it closes, FTW Cloud carries on at once, and when + /// the cloud fails the box session stays. + @Test func theFastestLivePathWins() async throws { let w = try CloudSite() w.open() w.connectCloud() @@ -252,17 +259,34 @@ final class CloudSite { w.relay.snap(uptimeMs: w.uptime, gridW: 700) w.service.held = [try w.live(gridW: 1000)] await w.run(60) - #expect(w.site.carrier == .cloud) - #expect(w.site.session.fields[Contract.FID.gridW] == 1000) + #expect(w.site.carrier == .relay) + #expect(w.site.session.fields[Contract.FID.gridW] == 700) w.service.close(code: 1006) await w.run(20) #expect(w.site.carrier == .relay) - #expect(w.site.session.fields[Contract.FID.gridW] == 700) #expect(w.site.freshness(noCarrier: false).message == "Live via encrypted relay") + } + + /// The box session closes while FTW Cloud is live: the cloud's readings + /// take over at once, under its own name. + @Test func theCloudCarriesOnWhenTheBoxSessionCloses() async throws { + let w = try CloudSite() + w.open() + w.connectCloud() + w.site.connect(w.relay) + w.relay.open(w.scheduler.nowMs) + w.relay.helloOk(uptimeMs: w.uptime, cloud: w.identity) + w.relay.snap(uptimeMs: w.uptime, gridW: 700) + w.service.held = [try w.live(gridW: 1000)] + await w.run(60) + #expect(w.site.carrier == .relay) - await w.run(600) + w.relay.drop() + w.service.push(try w.live(gridW: 1000)) + await w.run(20) #expect(w.site.carrier == .cloud) + #expect(w.site.session.fields[Contract.FID.gridW] == 1000) } /// A box session that streams live wins over a cloud copy that is not