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
6 changes: 4 additions & 2 deletions appleApp/FTW/UI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -137,14 +137,18 @@ extension SiteModel {
}

public func freshness(fid: Int) -> NodeFreshness {
NodeFreshness.of(
let f = NodeFreshness.of(
fid: fid,
session: session,
carrier: carrier,
streamQuietMs: sinceLastFrameMs,
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)
Comment on lines +150 to +151

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not let relay silence downgrade live cloud readings

When the relay has delivered at least one frame and then goes quiet for more than three seconds while FTW Cloud continues delivering live data, sinceLastFrameMs is calculated from shown.relayFrameAtMs, so NodeFreshness.of has already changed an otherwise-live cloud source to .lagging. This guard only applies the cloud grade when it is worse than that state, so a .live cloud grade cannot restore the node; the nodes again appear not live and the flow stops animating during the exact relay-failure scenario that the cloud path should cover. Cloud-backed freshness needs to derive its state from the field's cloud grade without applying the relay silence penalty.

Useful? React with 👍 / 👎.

}
}

Expand Down
35 changes: 19 additions & 16 deletions appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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? {
Expand Down
12 changes: 12 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
38 changes: 31 additions & 7 deletions appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
Loading