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.