From 0dd48fb26c46b5838cb3e38b817155a08618a381 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 18:45:34 +0200 Subject: [PATCH] iPhone: the grid's three phases against the main fuse (WP16) The grid node view gains one row under the range line: L1, L2 and L3 as 10-cell meters against the main fuse, each with its current in mono, and the phase nearest the fuse in an inverse tag. The values are the box's extra fields 11 to 14 from protocol/registry.yaml, copied byte for byte from srcfl/ftw-webapp. With no phases on the wire the row is not there; while they are not live it keeps its room and shows no number. The live ring leaves the phases and the fuse out, since no trace draws them. The demo box sends phases under a 20 A fuse. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/GridPhasesRow.swift | 65 +++++++++++++++ appleApp/FTW/UI/NodeView.swift | 4 + .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 18 +++++ .../Sources/FTWKit/Protocol/Contract.swift | 10 +++ .../Sources/FTWKit/State/GridPhases.swift | 81 +++++++++++++++++++ .../Sources/FTWKit/State/LiveTrace.swift | 17 ++-- .../Sources/FTWKit/State/NodeWords.swift | 5 ++ .../Tests/FTWKitTests/ContractTests.swift | 13 +++ .../Tests/FTWKitTests/GridPhasesTests.swift | 77 ++++++++++++++++++ .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 1 + protocol/registry.yaml | 23 ++++++ 11 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 appleApp/FTW/UI/GridPhasesRow.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/GridPhases.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/GridPhasesTests.swift diff --git a/appleApp/FTW/UI/GridPhasesRow.swift b/appleApp/FTW/UI/GridPhasesRow.swift new file mode 100644 index 0000000..1dc8417 --- /dev/null +++ b/appleApp/FTW/UI/GridPhasesRow.swift @@ -0,0 +1,65 @@ +import FTWKit +import SwiftUI + +/// The grid's one extra row: L1, L2 and L3 as 10-cell meters against the +/// main fuse, each with its current in mono, and the phase nearest the fuse +/// in an inverse tag. Absent when the box sends no phases. While they are +/// not live the row keeps its room and shows no number. +struct GridPhasesRow: View { + let phases: GridPhases + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + let layout = typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 10)) + : AnyLayout(HStackLayout(alignment: .top, spacing: 16)) + VStack(alignment: .leading, spacing: 6) { + Text(phases.kicker) + .font(Theme.mono(.caption2)) + .tracking(0.5) + .foregroundStyle(Theme.ink3) + layout { + ForEach(Array(phases.phases.enumerated()), id: \.offset) { i, phase in + PhaseMeter(phase: phase, reading: phases.reading(phase), nearest: i == phases.nearest, live: phases.live) + } + } + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .frame(maxWidth: .infinity, alignment: .leading) + .animation(Theme.spring, value: phases.nearest) + .accessibilityElement(children: .ignore) + .accessibilityLabel(phases.kicker) + .accessibilityValue(phases.spoken) + .accessibilityIdentifier("node.grid.phases") + } +} + +/// One phase: `L2 6,0 A` over its meter, a cell for each tenth of the fuse. +private struct PhaseMeter: View { + let phase: GridPhases.Phase + let reading: String + let nearest: Bool + let live: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 4) { + // Every name keeps the tag's inset, so the mark can move + // from phase to phase without moving a figure. + Text(verbatim: phase.name) + .padding(.horizontal, 3) + .foregroundStyle(nearest ? Theme.ground : Theme.ink2) + .background(nearest ? Theme.ink : .clear, in: RoundedRectangle(cornerRadius: 2)) + Text(reading) + .foregroundStyle(live ? Theme.ink : Theme.ink3) + .contentTransition(.numericText(value: phase.amps ?? 0)) + } + .font(Theme.mono(.footnote)) + .lineLimit(1) + CellMeter(pct: (phase.ofFuse ?? 0) * 100, tint: Theme.grid, live: live) + .frame(maxWidth: 96, minHeight: 4, maxHeight: 4) + .padding(.leading, 3) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 5fec267..c466ffc 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -177,6 +177,10 @@ struct NodeView: View { CarRow(loadpoints: home.loadpoints, zoom: zoom) .padding(.horizontal, 20) .padding(.top, 4) + } else if node == .grid, let phases = site.gridPhases { + GridPhasesRow(phases: phases) + .padding(.horizontal, 20) + .padding(.top, 8) } if fill { Spacer(minLength: 8) } NodeFooter(site: site, node: node, live: models.live) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 3efc5fe..68413b2 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -199,6 +199,20 @@ public final class SimulatedBox { Contract.FID.loadW: house.baseLoadW, ] f[Contract.FID.evW] = house.evWatts + f.merge(phases) { $1 } + return f + } + + /// The grid's phases as Core sends them, in tenths of an ampere and + /// signed by the site convention, under a 20 A fuse. The inverter and + /// the car spread over all three; the house leans on L1. + var phases: [Int: Double] { + let even = (house.batteryW + house.pvW + house.evWatts) / 3 + let house = [0.45, 0.33, 0.22].map { $0 * self.house.baseLoadW } + var f = [Contract.FID.gridFuseDA: 200.0] + for (fid, load) in zip(Contract.FID.gridPhases, house) { + f[fid] = ((load + even) / 230 * 10).rounded() + } return f } @@ -223,6 +237,10 @@ public final class SimulatedBox { ("5", .map([("name", .text("battery_soc")), ("unit", .text("permille")), ("srcId", .text("sungrow"))])), ("6", .map([("name", .text("load_w")), ("unit", .text("W")), ("srcId", .text("sdm630"))])), ("10", .map([("name", .text("ev_w")), ("unit", .text("W")), ("srcId", .text("easee"))])), + ("11", .map([("name", .text("grid_l1_da")), ("unit", .text("dA")), ("srcId", .text("sdm630"))])), + ("12", .map([("name", .text("grid_l2_da")), ("unit", .text("dA")), ("srcId", .text("sdm630"))])), + ("13", .map([("name", .text("grid_l3_da")), ("unit", .text("dA")), ("srcId", .text("sdm630"))])), + ("14", .map([("name", .text("grid_fuse_da")), ("unit", .text("dA")), ("srcId", .null)])), ] sendBulk(Envelope(t: "snap", b: .map([ ("uptimeMs", .number(uptimeMs)), diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index cd0534f..2a7b14d 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -104,6 +104,16 @@ public enum Contract { public static let srcBattery = 9 /// Present only on a site with a charger. public static let evW = 10 + + /// The registry's extra fields: depth for a node's own view, sent + /// only while the box has the reading. The grid's current on L1, + /// L2 and L3 in tenths of an ampere, signed by the site convention, + /// and the main fuse per phase as their scale. + public static let gridL1DA = 11 + public static let gridL2DA = 12 + public static let gridL3DA = 13 + public static let gridFuseDA = 14 + public static let gridPhases = [gridL1DA, gridL2DA, gridL3DA] } /// Whether a code is worth retrying, from the registry's `errors` and diff --git a/appleApp/FTWKit/Sources/FTWKit/State/GridPhases.swift b/appleApp/FTWKit/Sources/FTWKit/State/GridPhases.swift new file mode 100644 index 0000000..3e76ca8 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/GridPhases.swift @@ -0,0 +1,81 @@ +import Foundation +import FTWShared + +/// The grid's phases against the main fuse, from the box's extra fields 11 +/// to 14. +/// +/// Nil when the box sends no phase: a meter that gives none shows nothing, +/// never three idle meters. While the phases are not live the view keeps +/// their room and shows no number, because an old phase current says +/// nothing about the fuse now. +public struct GridPhases: Equatable, Sendable { + public struct Phase: Equatable, Sendable { + /// "L1", "L2", "L3". + public let name: String + /// The current's size in amps, nil while not live. The breaker + /// trips on size, whichever way the current flows. + public let amps: Double? + /// The share of the fuse, nil while not live or with no fuse. + public let ofFuse: Double? + } + + public let phases: [Phase] + /// The main fuse per phase, in amps. + public let fuseA: Double? + /// The phase nearest the fuse, by its place in `phases`, while live. + public let nearest: Int? + public let live: Bool + + public static func read(fields: [Int: Double], live: Bool) -> GridPhases? { + let present = Contract.FID.gridPhases.enumerated().compactMap { i, fid in + fields[fid].map { (name: "L\(i + 1)", deciamps: $0) } + } + guard !present.isEmpty else { return nil } + let fuseA = fields[Contract.FID.gridFuseDA].flatMap { $0 > 0 ? $0 / 10 : nil } + let phases = present.map { p -> Phase in + guard live else { return Phase(name: p.name, amps: nil, ofFuse: nil) } + let amps = abs(p.deciamps) / 10 + return Phase(name: p.name, amps: amps, ofFuse: fuseA.map { amps / $0 }) + } + // The first of equals: max(by:) keeps the earliest maximum. + let nearest = live && fuseA != nil ? phases.indices.max { (phases[$0].ofFuse ?? 0) < (phases[$1].ofFuse ?? 0) } : nil + return GridPhases(phases: phases, fuseA: fuseA, nearest: nearest, live: live) + } + + /// "PHASES · FUSE 20 A", the row's kicker. + public var kicker: String { + guard let fuseA else { return NodeWords.text("PHASES") } + return NodeWords.format("PHASES · FUSE %@", Self.amps(fuseA, decimals: 0)) + } + + /// "6,0 A", or a dash while not live. + public func reading(_ phase: Phase) -> String { + phase.amps.map { Self.amps($0, decimals: 1) } ?? "—" + } + + /// One sentence for VoiceOver: each phase, which one is nearest the + /// fuse, and the fuse. + public var spoken: String { + guard live else { return NodeWords.text("The phases are not live.") } + let each = phases.enumerated().map { i, p in + var s = p.name + " " + reading(p) + if i == nearest { s += ", " + NodeWords.text("nearest the fuse") } + return s + } + return each.joined(separator: "; ") + } + + static func amps(_ a: Double, decimals: Int) -> String { + AppWords.decimal(String(format: "%.\(decimals)f", a)) + "\u{00A0}A" + } +} + +extension SiteModel { + /// The grid's phases as the node view draws them, live by the site + /// meter's own freshness: the phases are its readings. + public var gridPhases: GridPhases? { + let fields = session.fields + guard let fid = Contract.FID.gridPhases.first(where: { fields[$0] != nil }) else { return nil } + return GridPhases.read(fields: fields, live: freshness(fid: fid).live) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift index 49168d4..4a9a646 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift @@ -141,19 +141,19 @@ public final class LiveTrace { lastFrameAtMs = at guard let stamp = site.readingStampMs else { return } var live = [Int: Double]() - for (fid, v) in site.session.fields where !Self.notReadings.contains(fid) && site.freshness(fid: fid).live { + for (fid, v) in site.session.fields where !Self.untraced.contains(fid) && site.freshness(fid: fid).live { live[fid] = v } guard !live.isEmpty else { return } record(live, atMs: stamp, gapMs: site.readingGapMs) } - /// One reading's values, taken by the box at `atMs`. The mode and the - /// source references are not readings and are not kept. A value no - /// newer than what the ring holds is not a new reading. + /// One reading's values, taken by the box at `atMs`. Only what a trace + /// draws is kept (`untraced`). A value no newer than what the ring + /// holds is not a new reading. func record(_ fields: [Int: Double], atMs: Double, gapMs: Double = LiveTrace.gapMs) { var any = false - for (fid, v) in fields where !Self.notReadings.contains(fid) { + for (fid, v) in fields where !Self.untraced.contains(fid) { var ring = rings[fid] ?? RingBuffer(capacity: Self.capacity) let last = ring.last if let last, atMs <= last.t { continue } @@ -200,7 +200,7 @@ public final class LiveTrace { func restore(_ file: RingFile) { var newest: Double? for (key, lane) in file.lanes { - guard let fid = Int(key), !Self.notReadings.contains(fid), lane.dt.count == lane.v.count, !lane.v.isEmpty else { continue } + guard let fid = Int(key), !Self.untraced.contains(fid), lane.dt.count == lane.v.count, !lane.v.isEmpty else { continue } var ring = RingBuffer(capacity: Self.capacity) var t = lane.t0 for i in lane.v.indices { @@ -214,7 +214,10 @@ public final class LiveTrace { if let newest { resumed = Resumed(fromMs: newest, toMs: nil) } } - nonisolated static let notReadings: Set = [Contract.FID.mode, Contract.FID.srcGrid, Contract.FID.srcPV, Contract.FID.srcBattery] + /// Not kept: the mode and the source references are not readings, the + /// fuse is config, and the phases are drawn as meters of now, never as + /// a trace. + nonisolated static let untraced: Set = Set([Contract.FID.mode, Contract.FID.srcGrid, Contract.FID.srcPV, Contract.FID.srcBattery, Contract.FID.gridFuseDA] + Contract.FID.gridPhases) /// The time the app missed, while restored points still show in the /// window that ends at `nowMs`. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 24523de..a3ca0bd 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -54,6 +54,11 @@ public enum NodeWords { "VIA FTW CLOUD": "VIA FTW CLOUD", "SAVED ON THIS PHONE": "SPARAD PÅ TELEFONEN", "NOT CONNECTED": "INTE ANSLUTEN", + // The grid's phases against the main fuse. + "PHASES": "FASER", + "PHASES · FUSE %@": "FASER · SÄKRING %@", + "nearest the fuse": "närmast säkringen", + "The phases are not live.": "Faserna är inte live.", "READS EVERY %@ S": "LÄSER VAR %@ S", "LAST 5 MIN": "SENASTE 5 MIN", "LIVE · LAST 5 MIN": "LIVE · SENASTE 5 MIN", diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift index e300bfe..74c5c22 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift @@ -79,6 +79,19 @@ import FTWDemo } } + @Test func extraFieldIDsAreTheRegistrys() { + typealias F = Contract.FID + let ids: [(Int, String)] = [ + (F.gridL1DA, "grid_l1_da"), (F.gridL2DA, "grid_l2_da"), (F.gridL3DA, "grid_l3_da"), + (F.gridFuseDA, "grid_fuse_da"), + ] + let extra = section("extra_fields").filter { $0.contains("{ name:") } + #expect(extra.count == ids.count) + for (id, name) in ids { + #expect(extra.contains { $0.trimmingCharacters(in: .whitespaces).hasPrefix("\(id): { name: \(name),") }, "\(id) \(name)") + } + } + /// The demo box is not the box, but it may not invent the box's names. @Test func theDemoBoxSpeaksOnlyRegistryNames() { let modes = Set(Contract.modes.map(\.key)) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/GridPhasesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/GridPhasesTests.swift new file mode 100644 index 0000000..10e4cf4 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/GridPhasesTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWShared + +/// The grid's phases against the main fuse: what the node view draws from +/// the box's extra fields. +@Suite struct GridPhasesTests { + typealias F = Contract.FID + + /// The home box on 14 October 2026: L1 and L3 sold while L2 bought, + /// under a 20 A fuse. + let home: [Int: Double] = [F.gridW: 112, F.gridL1DA: -37, F.gridL2DA: 60, F.gridL3DA: -29, F.gridFuseDA: 200] + + @Test func eachPhaseIsItsSizeAgainstTheFuse() throws { + let p = try #require(GridPhases.read(fields: home, live: true)) + #expect(p.phases.map(\.name) == ["L1", "L2", "L3"]) + #expect(p.phases.map(\.amps) == [3.7, 6.0, 2.9]) + #expect(p.fuseA == 20) + #expect(abs((p.phases[1].ofFuse ?? 0) - 0.3) < 1e-9) + #expect(p.nearest == 1) + } + + /// A meter that gives no phases shows nothing: not three idle meters, + /// and not the fuse on its own. + @Test func noPhasesNoRow() { + #expect(GridPhases.read(fields: [F.gridW: 1200], live: true) == nil) + #expect(GridPhases.read(fields: [F.gridW: 1200, F.gridFuseDA: 200], live: true) == nil) + } + + /// While not live the row keeps its room but shows no number: an old + /// phase current says nothing about the fuse now. + @Test func notLiveShowsNoNumber() throws { + let p = try #require(GridPhases.read(fields: home, live: false)) + #expect(p.phases.count == 3) + #expect(p.phases.allSatisfy { $0.amps == nil && $0.ofFuse == nil }) + #expect(p.nearest == nil) + #expect(p.phases.map(p.reading) == ["—", "—", "—"]) + } + + /// A one-phase site sends L1 alone, and the row draws one meter. + @Test func onlyThePhasesTheBoxSends() throws { + let p = try #require(GridPhases.read(fields: [F.gridL1DA: 95, F.gridFuseDA: 250], live: true)) + #expect(p.phases.map(\.name) == ["L1"]) + #expect(p.nearest == 0) + } + + /// Two phases equally near: the first is marked, so the mark does not + /// flicker between them. + @Test func theFirstOfEqualsIsNearest() throws { + let p = try #require(GridPhases.read(fields: [F.gridL1DA: 40, F.gridL2DA: -60, F.gridL3DA: 60, F.gridFuseDA: 160], live: true)) + #expect(p.nearest == 1) + } + + /// Without a fuse there is no scale, so nothing is marked. + @Test func noFuseNoMark() throws { + let p = try #require(GridPhases.read(fields: [F.gridL1DA: 40], live: true)) + #expect(p.fuseA == nil) + #expect(p.nearest == nil) + #expect(p.phases[0].ofFuse == nil) + #expect(p.phases[0].amps == 4) + } + + @Test func wordsInSwedishAndEnglish() throws { + let p = try #require(GridPhases.read(fields: home, live: true)) + AppWords.$language.withValue(.sv) { + #expect(p.kicker == "FASER · SÄKRING 20\u{00A0}A") + #expect(p.reading(p.phases[1]) == "6,0\u{00A0}A") + #expect(p.spoken == "L1 3,7\u{00A0}A; L2 6,0\u{00A0}A, närmast säkringen; L3 2,9\u{00A0}A") + #expect(GridPhases.read(fields: home, live: false)?.spoken == "Faserna är inte live.") + } + AppWords.$language.withValue(.en) { + #expect(p.kicker == "PHASES · FUSE 20\u{00A0}A") + #expect(p.reading(p.phases[0]) == "3.7\u{00A0}A") + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index de856e2..ed8c0c8 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -759,6 +759,7 @@ import FTWShared "appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift", "appleApp/FTW/UI/PriceView.swift", "appleApp/FTW/UI/StatusLine.swift", "appleApp/FTW/UI/ControlSheet.swift", "appleApp/FTW/UI/ModeMenu.swift", "appleApp/FTWKit/Sources/FTWKit/State/StatusLine.swift", + "appleApp/FTWKit/Sources/FTWKit/State/GridPhases.swift", "appleApp/FTW/UI/GridPhasesRow.swift", ] let pattern = #/(?:\bL|NodeWords\.(?:text|format)|AppWords\.(?:text|format))\("((?:[^"\\]|\\.)*)"/# var english = Set() diff --git a/protocol/registry.yaml b/protocol/registry.yaml index 18f3cd5..0cfdaec 100644 --- a/protocol/registry.yaml +++ b/protocol/registry.yaml @@ -49,6 +49,29 @@ frozen_fields: # by the site convention: the charger consumes like any other load. 10: { name: ev_w, kind: power, unit: W } +# --------------------------------------------------------------------------- +# Field ids after the frozen set. They travel like the frozen ids, in lane +# 0's delta and in FTW Cloud's live body, under the same rules: integers, +# sent only while the box has the reading, and an id never changes meaning. +# They are depth for a node's own view, not the core view, so an app draws +# Now without them and a box may stop sending one. Lane 0 drops the highest +# id first when a delta does not fit its bucket, so these give way first. +# +# The grid's phase currents come from the site meter in tenths of an ampere, +# signed by the site convention: positive while the phase draws from the +# grid. A meter that gives magnitudes and no power per phase sends positive +# values both ways, so a positive current alone does not prove import. The +# box sends only the phases the site has, each while the meter is online and +# reports it, and their freshness is the grid meter's. grid_fuse_da is the +# main fuse per phase from the box's config, sent with the phases as their +# scale. +# --------------------------------------------------------------------------- +extra_fields: + 11: { name: grid_l1_da, kind: current, unit: dA } + 12: { name: grid_l2_da, kind: current, unit: dA } + 13: { name: grid_l3_da, kind: current, unit: dA } + 14: { name: grid_fuse_da, kind: current, unit: dA } + # --------------------------------------------------------------------------- # Capabilities. A set of names, not version numbers. Absent means the box does # not have it; the UI hides the feature and never crashes.