From fc542b69e849bcb83fa2083ffae32fbd6cca356d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 17:50:12 +0200 Subject: [PATCH 1/2] fix(ios): a reading through FTW Cloud is live; the house is not called by its meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On 0.3.0 through FTW Cloud every node said "Not live, just now", Now showed a clock as if waiting, and the flow stood still. NodeFreshness.live knew only the relay and WebRTC. It now counts the cloud too, and a node read through the cloud is never fresher than the cloud's own grade for its field: anchored and inside the age rules. That grade is the one the band already uses. The house's line said "Live · sungrow": the box reads the house from the meter driver, and the line named that driver as if it were the house. The house now shows no source name. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/NodeView.swift | 6 ++++-- .../FTWKit/Sources/FTWKit/State/EnergyNode.swift | 8 ++++++-- appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift | 10 ++++++++++ appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift | 12 ++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) 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..894c6ca 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1158,6 +1158,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) { From c6aa8a7157a4e0199138e1dc21d5849e6350e012 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 17:54:56 +0200 Subject: [PATCH 2/2] fix(ios): the box's own live stream wins over the FTW Cloud copy readingSource() took FTW Cloud whenever its copy was live, and kept it once chosen, so with both paths up Now ran at the cloud's pace: every 5 s while the box did not hear a phone was looking, and at best once a second. The box session's live stream now wins whenever it is live. FTW Cloud gives the first frame before that stream is up and carries on at once when it drops. Fredrik's rule: the path does not matter, only that it is fast. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/State/SiteModel.swift | 25 +++++------- .../FTWKitTests/SiteModelCloudTests.swift | 38 +++++++++++++++---- 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 894c6ca..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 } 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