diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift index f0f9ed53..87c45a9a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Flow.swift @@ -37,7 +37,8 @@ public enum Flow { } public struct Readings: Equatable, Sendable { - public var loadKw: Double + /// Core's qualified load, or nil when no load was sent. + public var loadKw: Double? public var nodes: [Node] public var selfPoweredPctToday: Double? } @@ -82,7 +83,7 @@ public enum Flow { if let e = fields[Contract.FID.evW] { nodes.append(Node(id: "ev", role: .ev, corner: .bottomRight, title: "EV CHARGER", kw: e / 1000, toHub: false, tone: tone(.ev, e), sub: idle(e) ? "idle" : "charging", dailyParts: [], tappable: true)) } - return Readings(loadKw: (fields[Contract.FID.loadW] ?? 0) / 1000, nodes: nodes, selfPoweredPctToday: nil) + return Readings(loadKw: fields[Contract.FID.loadW].map { $0 / 1000 }, nodes: nodes, selfPoweredPctToday: nil) } /// Nodes from GET /api/status: per-driver bubbles and energy today, the @@ -117,7 +118,7 @@ public enum Flow { } } let selfPowered: Double? = loadTotal > 0.001 ? max(0, min(100, (1 - imp / loadTotal) * 100)) : nil - return Readings(loadKw: (status["load_w"]?.number ?? 0) / 1000, nodes: nodes, selfPoweredPctToday: selfPowered) + return Readings(loadKw: status["load_w"]?.number.map { $0 / 1000 }, nodes: nodes, selfPoweredPctToday: selfPowered) } /// Charger watts the box's HTTP API knows, summed. @@ -125,14 +126,13 @@ public enum Flow { points.reduce(0) { $0 + ($1.powerW > idleW ? $1.powerW : 0) } } - /// Put the car on its own node when the 1 Hz stream folded it into the - /// house. A box that already sends field 10 is left alone. + /// Add the charger's reading when the stream has no active EV field. + /// Core owns the house load: keep it unchanged, including nil and zero. public static func withLoadpointEV(_ fields: [Int: Double], evW: Double) -> [Int: Double] { guard evW > idleW else { return fields } if let wire = fields[Contract.FID.evW], abs(wire) > idleW { return fields } var out = fields out[Contract.FID.evW] = evW.rounded() - out[Contract.FID.loadW] = max(0, (fields[Contract.FID.loadW] ?? 0) - evW) return out } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift index b54570a8..2685bbcd 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift @@ -26,13 +26,13 @@ public struct NodeReading: Equatable, Sendable, Identifiable { } public enum NowFlow { - /// The nodes Now draws, in its order. The grid is always there, because - /// a home that cannot see its own meter should see that gap; any other - /// node the box sends no field for is left out rather than drawn at zero, + /// The nodes Now draws, in its order. Grid and home stay visible so + /// a missing meter or qualified load reads as unknown. Any other node + /// the box sends no field for is left out rather than drawn at zero, /// unless the home is `known` to have it. public static func nodes(fields: [Int: Double], hasCar: Bool, known: [EnergyNode] = []) -> [EnergyNode] { EnergyNode.allCases.filter { node in - node == .grid || fields[node.fid] != nil || (node == .car && hasCar) || known.contains(node) + node == .grid || node == .home || fields[node.fid] != nil || (node == .car && hasCar) || known.contains(node) } } @@ -115,9 +115,8 @@ extension NowFlow { let chargeW = Flow.loadpointChargeW(points) var fields = site.session.fields let wire = fields[Contract.FID.evW] - // The car comes off the house only when both are as old: a fresh - // read beside the stream, or both from the cache. An old read never - // changes a live figure. + // The charger can supply its own display reading when fresh, or + // alongside a cached stream. It never changes Core's house load. if !loadpoints.stale || site.lastFrameAtMs == nil { fields = Flow.withLoadpointEV(fields, evW: chargeW) } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CoreLoadPreservationTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CoreLoadPreservationTests.swift new file mode 100644 index 00000000..1a3041c4 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CoreLoadPreservationTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import FTWKit + +@MainActor +@Suite struct CoreLoadPreservationTests { + typealias F = Contract.FID + + @Test func chargerDisplayKeepsTheCoreLoadUnchangedOrAbsent() { + for load: Double? in [nil, 0, 25.75, 9_000, -20] { + for wireEV: Double? in [nil, 0, 7_400] { + var original = [F.gridW: 3_700.0, F.pvW: -1_200] + original[F.loadW] = load + original[F.evW] = wireEV + let shown = Flow.withLoadpointEV(original, evW: 7_400) + #expect(shown[F.loadW] == load) + #expect(shown[F.evW] == 7_400) + #expect(shown[F.gridW] == original[F.gridW]) + #expect(shown[F.pvW] == original[F.pvW]) + } + } + } + + @Test func bothInputFormatsKeepUnknownDistinctFromARecordedZero() throws { + let missingStream: Double? = Flow.readings(fields: [:]).loadKw + let missingStatus: Double? = Flow.readings(status: try JSON(parsing: Array("{}".utf8))).loadKw + #expect(missingStream == nil) + #expect(missingStatus == nil) + let nullStatus: Double? = Flow.readings(status: try JSON(parsing: Array("{\"load_w\":null}".utf8))).loadKw + #expect(nullStatus == nil) + #expect(Flow.readings(fields: [F.loadW: 0]).loadKw == 0) + #expect(Flow.readings(status: try JSON(parsing: Array("{\"load_w\":0}".utf8))).loadKw == 0) + #expect(Flow.readings(fields: [F.loadW: 1_250]).loadKw == 1.25) + } + + @Test func aHomeWithNoCoreLoadStillHasAnUnknownReading() throws { + let readings = NowFlow.readings(fields: [F.gridW: 3_700], freshness: { _ in + NodeFreshness(carrier: .relay, srcState: .never, ageMs: nil, sourceName: nil) + }) + let home = try #require(readings.first { $0.node == .home }) + #expect(home.watts == nil) + #expect(!home.flowing) + let watch = WatchShare.reading(home) + #expect(watch.watts == nil) + #expect(watch.way == .noReading) + #expect(!watch.live) + } + + @Test func nowAndWatchKeepCoreZeroAndNeverSubtractTheCharger() throws { + for load: Double? in [nil, 0, 1_600] { + var fields = [F.gridW: 9_000.0] + fields[F.loadW] = load + fields = Flow.withLoadpointEV(fields, evW: 7_400) + let readings = NowFlow.readings(fields: fields, freshness: { fid in + NodeFreshness(carrier: .relay, srcState: fields[fid] == nil ? .never : .live, ageMs: 0, sourceName: nil) + }, hasCar: true) + let home = try #require(readings.first { $0.node == .home }) + #expect(home.watts == load) + let watch = WatchShare.reading(home) + #expect(watch.watts == load) + #expect(watch.way == (load == nil ? .noReading : .using)) + #expect(watch.live == (load != nil)) + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift index 007ebf32..dca6c31b 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift @@ -84,7 +84,7 @@ import Testing #expect(!r.nodes.contains { $0.role == .ev }) let overlaid = Flow.withLoadpointEV([F.loadW: 9000, F.evW: 0], evW: 7400) #expect(overlaid[F.evW] == 7400) - #expect(overlaid[F.loadW] == 1600) + #expect(overlaid[F.loadW] == 9000) } }