PONGo_ios/DeviceModels.swift
Jon Vanvik 203465913c Apply code-review fixes: identity/pagination, load races, TLS pin, leaks
Addresses a multi-agent code review (26 confirmed findings + a URLSession
leak) across correctness, concurrency, security, and cleanup:

- MongoDocument.id: deterministic fallback (was UUID() on every access) to stop
  SwiftUI List/ForEach identity churn; getAll pagination drives its cursor off
  the real _id via documentId, so a missing _id stops paging instead of looping
  on a fabricated cursor.
- View models: per-load generation guard (+ captured-tab guard on ONU detail)
  so a cancelled or overlapping load can't commit stale results or land data on
  the wrong tab.
- JSONValue.intValue: guard Int(d) against NaN/inf/out-of-range (crash).
- ServerTrustEvaluator: hash the real SubjectPublicKeyInfo (prepend the
  algorithm-specific ASN.1 SPKI header) so a standard openssl-captured
  public-key pin actually matches; unknown key types fail closed.
- AppEnvironment.configure: clear stale cookies + Keychain creds on host change.
- FirmwareFile.isCompatible: empty metadata no longer passes as compatible-for-all.
- APIClient: invalidate URLSession on deinit (per-configure/probe leak).
- Dashboard best-effort sections keep last-good on transient failure; unified
  optical thresholds; LoginView prefill-once; populatedBuckets unique ForEach id;
  Format rounding rollover; dead-code/docstring/dedup cleanups.

Verification pending on the Mac (no Swift toolchain on the Linux dev box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 16:36:49 +02:00

434 lines
19 KiB
Swift

import Foundation
// MARK: - Enums (tolerant decoding with `.unknown` fallback)
/// Device states for PON Controllers, OLTs, and ONUs (dashboard-level).
enum DeviceState: String, Decodable, CaseIterable {
case online = "Online"
case offline = "Offline"
case unattached = "Unattached"
case preProvisioned = "Pre-provisioned"
case unknown
init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = DeviceState(rawValue: raw) ?? .unknown
}
}
/// ONU registration state. These are the buckets MCMS keeps on the **OLT-STATE**
/// document under `"ONU States"` not a field on ONU-STATE (dev guide
/// Procedure 2). Raw values match the bucket names exactly.
enum ONULifecycleState: String, Decodable, CaseIterable {
case registered = "Registered"
case unspecified = "Unspecified"
case unprovisioned = "Unprovisioned"
case deregistered = "Deregistered"
case dyingGasp = "Dying Gasp"
case disabled = "Disabled"
case disallowedAdmin = "Disallowed Admin"
case disallowedError = "Disallowed Error"
case disallowedRegID = "Disallowed Reg ID"
case unknown
init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = ONULifecycleState(rawValue: raw) ?? .unknown
}
/// Per dev guide Procedure 2: an ONU in `Registered` or `Unspecified` is online.
var isOnline: Bool { self == .registered || self == .unspecified }
}
/// Automation roll-up states shown on the dashboard.
enum AutomationState: String, Decodable, CaseIterable {
case done = "Done"
case error = "Error"
case waitingForInput = "Waiting For Input"
case waitingForDevice = "Waiting For Device"
case disabled = "Disabled"
case unknown
init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = AutomationState(rawValue: raw) ?? .unknown
}
}
/// Syslog-style alarm/severity levels (lower rank = more severe).
enum AlarmSeverity: String, Decodable, CaseIterable, Comparable {
case emergency, alert, critical, error, warning, notice, info, debug
case unknown
var rank: Int {
switch self {
case .emergency: return 0
case .alert: return 1
case .critical: return 2
case .error: return 3
case .warning: return 4
case .notice: return 5
case .info: return 6
case .debug: return 7
case .unknown: return 8
}
}
init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = AlarmSeverity(rawValue: raw.lowercased()) ?? .unknown
}
static func < (lhs: AlarmSeverity, rhs: AlarmSeverity) -> Bool { lhs.rank < rhs.rank }
/// Read a severity from a Mongo doc that may key it as `Severity` or `severity`.
static func from(_ raw: JSONValue) -> AlarmSeverity {
guard let s = raw["Severity"]?.stringValue ?? raw["severity"]?.stringValue else { return .unknown }
return AlarmSeverity(rawValue: s.lowercased()) ?? .unknown
}
}
// MARK: - Flexible documents
//
// Full MongoDB document schemas (ONU-CFG, ONU-STATE, OLT-CFG, CNTL-CFG, ) are
// large and version-specific. Rather than hand-transcribe every field (and risk
// getting it wrong), each document decodes its full body into `raw: JSONValue`
// and exposes a few convenience accessors. The accessor key paths below are
// best-effort guesses RECONCILE THEM WITH `openapi.json` and a live response,
// then promote the fields you actually use into typed properties.
protocol MongoDocument: Decodable, Identifiable {
var raw: JSONValue { get }
}
extension MongoDocument {
/// The document's real Mongo `_id`, or nil when absent. Use THIS for the
/// pagination cursor (`getAll`) never the `id` fallback below.
var documentId: String? { raw["_id"]?.stringValue }
}
// NOTE on `id`: each document's `id` falls back to `raw.compactDescription` when
// `_id` is missing a deterministic value derived from the content, NOT a fresh
// `UUID()`. `id` is a *computed* property, so a random UUID would change on every
// access and hand SwiftUI a new identity each render, tearing down and rebuilding
// List/ForEach rows (lost scroll position, broken animations). The content-derived
// key is stable for the same instance, so identity holds across re-renders.
/// nil when the string is nil or empty, else the string. Shared by the document
/// accessors that should treat "" as "absent".
private func nonEmpty(_ s: String?) -> String? { (s?.isEmpty == false) ? s : nil }
/// Single source of truth for ONU optical thresholds (dBm, user guide §7). Shared
/// by the dashboard abnormal-Rx filter and the detail-view tinting so the two
/// screens can't give the operator contradicting verdicts for the same reading.
enum OpticalThreshold {
static let rxGreenFloor = -28.0 // at/above (and rxHigh) is in-spec / green
static let rxRedFloor = -30.0 // below this is critical / red
static let rxHigh = -10.0 // above this is abnormal (too hot)
static let txGreenFloor = 3.0
/// Whether an RX reading (dBm) is abnormal: too weak or too hot.
static func rxAbnormal(_ dBm: Double) -> Bool { dBm < rxGreenFloor || dBm > rxHigh }
}
/// ONU state document (`ONU-STATE`).
struct ONUStateDoc: MongoDocument {
let raw: JSONValue
init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) }
var id: String { raw["_id"]?.stringValue ?? raw.compactDescription }
var serialNumber: String? { raw["_id"]?.stringValue }
/// Parent OLT MAC, from `OLT.MAC Address` (dev guide Procedure 2 §2).
var parentOLT: String? { raw["OLT"]?["MAC Address"]?.stringValue }
var lastUpdate: String? { raw["Time"]?.stringValue }
/// Registration state is **not** carried on ONU-STATE it lives in the
/// OLT-STATE buckets. The list/dashboard view models resolve it via
/// `OLTRepository.registrationMap()` and stamp it here. `nil` until resolved.
var resolvedLifecycle: ONULifecycleState?
var lifecycle: ONULifecycleState { resolvedLifecycle ?? .unknown }
/// Operator-set label from ONU-CFG (`ONU.Name`); resolved + stamped by the
/// list VM via `ONURepository.nameMap()`. `nil` until resolved / if unset.
var resolvedName: String?
/// Best label to show: the operator name if set, else the serial/MAC id.
var displayName: String {
if let name = resolvedName, !name.isEmpty { return name }
return id
}
// Live optical + FEC. Current values live on ONU-STATE.STATS, not on the
// /onus/stats/ time-series (field guide §3.12 / §7).
private func ponStat(_ pon: String, _ key: String) -> Double? {
raw["STATS"]?[pon]?[key]?.doubleValue
}
var rxOpticalDBm: Double? { ponStat("ONU-PON", "RX Optical Level") }
var txOpticalDBm: Double? { ponStat("ONU-PON", "TX Optical Level") }
var onuPreFEC: Double? { ponStat("ONU-PON", "RX Pre-FEC BER") }
var onuPostFEC: Double? { ponStat("ONU-PON", "RX Post-FEC BER") }
var oltPreFEC: Double? { ponStat("OLT-PON", "RX Pre-FEC BER") }
var oltPostFEC: Double? { ponStat("OLT-PON", "RX Post-FEC BER") }
}
/// ONU config document (`ONU-CFG`).
struct ONUConfigDoc: MongoDocument {
let raw: JSONValue
init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) }
var id: String { raw["_id"]?.stringValue ?? raw.compactDescription }
/// Operator-set label (`ONU.Name`, §5.1).
var name: String? { raw["ONU"]?["Name"]?.stringValue }
var netconfName: String? { raw["Netconf"]?["Name"]?.stringValue }
}
/// OLT config document (`OLT-CFG`).
struct OLTConfigDoc: MongoDocument {
let raw: JSONValue
init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) }
var id: String { raw["_id"]?.stringValue ?? raw.compactDescription } // MAC address
/// Operator label from `OLT.Name` (confirmed against a live OLT-CFG, e.g.
/// "OLT69-RIN"). Note `NETCONF.Name` defaults to the MAC, so it's *not* the
/// friendly name. Returns nil when unset.
var name: String? {
guard let value = raw["OLT"]?["Name"]?.stringValue, !value.isEmpty else { return nil }
return value
}
}
/// OLT state document (`OLT-STATE`). Carries the per-OLT ONU registration
/// buckets used to resolve each ONU's lifecycle (dev guide Procedure 2).
struct OLTStateDoc: MongoDocument {
let raw: JSONValue
init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) }
var id: String { raw["_id"]?.stringValue ?? raw.compactDescription } // OLT MAC address
var lastUpdate: String? { raw["Time"]?.stringValue }
/// Operator name from OLT-CFG (`OLT.Name` NOT `Netconf.Name`, which defaults
/// to the MAC); resolved + stamped by the list VM via `OLTRepository.nameMap()`.
/// `nil` until resolved / if unset.
var resolvedName: String?
/// Best label to show: the operator name if set, else the MAC id.
var displayName: String {
if let name = resolvedName, !name.isEmpty { return name }
return id
}
/// `"ONU States"` bucket name [ONU IDs].
var onuStatesByBucket: [String: [String]] {
guard let obj = raw["ONU States"]?.objectValue else { return [:] }
return obj.mapValues { $0.arrayValue?.compactMap(\.stringValue) ?? [] }
}
var totalONUs: Int { onuStatesByBucket.values.reduce(0) { $0 + $1.count } }
/// Online = Registered + Unspecified (dev guide Procedure 2).
var onlineONUs: Int {
onuStatesByBucket.reduce(0) { sum, entry in
(ONULifecycleState(rawValue: entry.key)?.isOnline ?? false) ? sum + entry.value.count : sum
}
}
/// Non-empty registration buckets, ordered by lifecycle severity. Carries the
/// raw bucket `name` so callers key `ForEach` on it keying on `state` would
/// collide when two unrecognized bucket names both map to `.unknown`.
var populatedBuckets: [(name: String, state: ONULifecycleState, ids: [String])] {
onuStatesByBucket
.filter { !$0.value.isEmpty }
.map { (name: $0.key, state: ONULifecycleState(rawValue: $0.key) ?? .unknown, ids: $0.value.sorted()) }
.sorted {
let order = ONULifecycleState.allCases
let a = order.firstIndex(of: $0.state) ?? .max
let b = order.firstIndex(of: $1.state) ?? .max
return (a, $0.name) < (b, $1.name) // name tiebreak stable order for unknowns
}
}
}
/// PON Controller config document (`CNTL-CFG`).
struct ControllerConfigDoc: MongoDocument {
let raw: JSONValue
init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) }
var id: String { raw["_id"]?.stringValue ?? raw.compactDescription } // MAC address
var version: String? { raw["CNTL"]?["Version"]?.stringValue }
}
/// Alarm-history document (`*-ALARM-HIST-STATE`).
struct AlarmHistoryDoc: MongoDocument {
let raw: JSONValue
init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) }
var id: String { raw["_id"]?.stringValue ?? raw.compactDescription }
// TODO: confirm key paths.
var severity: AlarmSeverity { AlarmSeverity.from(raw) }
var message: String? { raw["Message"]?.stringValue ?? raw["message"]?.stringValue }
var raisedAt: String? { raw["Time"]?.stringValue ?? raw["timestamp"]?.stringValue }
}
/// One statistics sample (`STATS-*`). Timestamp + arbitrary counters.
struct StatSample: MongoDocument {
let raw: JSONValue
init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) }
var id: String { raw["_id"]?.stringValue ?? raw.compactDescription }
var timestamp: String? { raw["Time"]?.stringValue ?? raw["timestamp"]?.stringValue }
/// Pull a numeric counter by key for charting.
func metric(_ key: String) -> Double? {
switch raw[key] {
case .some(.double(let d)): return d
case .some(.int(let i)): return Double(i)
default: return nil
}
}
}
/// Syslog line (`SYSLOG-*`).
struct LogEntry: MongoDocument {
let raw: JSONValue
init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) }
var id: String { raw["_id"]?.stringValue ?? raw.compactDescription }
var severity: AlarmSeverity { AlarmSeverity.from(raw) }
var message: String? { raw["Message"]?.stringValue ?? raw["message"]?.stringValue }
var time: String? { raw["Time"]?.stringValue ?? raw["timestamp"]?.stringValue }
}
/// A CPE behind an ONU, with its DHCP lease from the `/cpe/onu/<id>/` helper.
struct CPEState: MongoDocument {
let raw: JSONValue
init(from decoder: Decoder) throws { raw = try JSONValue(from: decoder) }
init(raw: JSONValue) { self.raw = raw }
var id: String { raw["_id"]?.stringValue ?? raw.compactDescription }
var mac: String? { raw["_id"]?.stringValue }
// IPv4 (DHCP)
var dhcpState: String? { nonEmpty(raw["DHCP"]?["Client State"]?.stringValue) }
var ipv4: String? { nonEmpty(raw["DHCP"]?["Client IP Addr"]?.stringValue) }
var dhcpServer: String? { nonEmpty(raw["DHCP"]?["Server ID"]?.stringValue) }
var dhcpLeaseSeconds: Int? { raw["DHCP"]?["Lease Time"]?.intValue }
var circuitId: String? { nonEmpty(raw["DHCP"]?["Circuit ID"]?.stringValue) }
var dhcpLastSuccess: String? { nonEmpty(raw["DHCP"]?["Last Success Time"]?.stringValue) }
// IPv6 (DHCPv6)
var dhcpv6State: String? { nonEmpty(raw["DHCPV6"]?["Client State"]?.stringValue) }
var ipv6: String? { nonEmpty(raw["DHCPV6"]?["Client IP Addr"]?.stringValue) }
var ipv6ValidLifetime: String? { nonEmpty(raw["DHCPV6"]?["Valid Lifetime"]?.stringValue) }
var ipv6Prefix: String? {
guard let entry = raw["DHCPV6"]?["Prefix Delegation"]?.arrayValue?.first,
let prefixObj = entry["Prefixes"]?.arrayValue?.first,
let prefix = prefixObj["Prefix"]?.stringValue,
let len = prefixObj["Prefix Length"]?.intValue else { return nil }
return "\(prefix)/\(len)"
}
var hasLease: Bool { ipv4 != nil || ipv6 != nil }
}
// MARK: - ONU-STATE detail accessors (status / firmware / UNI / traffic)
/// One UNI port parsed from a `UNI-ETH N` / `UNI-POTS N` entry on ONU-STATE.
struct UNIPort: Identifiable {
let name: String
let raw: JSONValue
var id: String { name }
var isEthernet: Bool { name.localizedCaseInsensitiveContains("ETH") }
var enabled: Bool? { raw["Enable"]?.boolValue }
var speed: String? { raw["Speed"]?.stringValue }
var duplex: String? { raw["Duplex"]?.stringValue }
/// Resolved link state string on ETH ports, e.g. "10G Ethernet Full Duplex".
var state: String? { raw["State"]?.stringValue }
}
/// Per-service traffic parsed from an `OLT-PON Service N` entry on ONU-STATE.STATS.
struct PONServiceStats: Identifiable {
let name: String
let raw: JSONValue
var id: String { name }
var rxRateBps: Double? { raw["RX Rate bps"]?.doubleValue }
var txRateBps: Double? { raw["TX Rate bps"]?.doubleValue }
var rxTotalOctets: Double? { raw["RX Total Octets"]?.doubleValue }
var txTotalOctets: Double? { raw["TX Total Octets"]?.doubleValue }
}
extension ONUStateDoc {
private var onu: JSONValue? { raw["ONU"] }
// Status / identity
var registrationState: String? { onu?["Registration State"]?.stringValue }
var equipmentId: String? { onu?["Equipment ID"]?.stringValue }
var vendor: String? { onu?["Vendor"]?.stringValue }
var ponMode: String? { onu?["PON Mode"]?.stringValue }
var onlineTime: String? { onu?["Online Time"]?.stringValue }
var temperatureC: Double? { raw["GPON"]?["Temperature"]?.doubleValue }
// Firmware
var fwVersion: String? { onu?["FW Version"]?.stringValue }
var fwBankVersions: [String] { onu?["FW Bank Versions"]?.arrayValue?.compactMap(\.stringValue) ?? [] }
var fwBankPtr: Int? { onu?["FW Bank Ptr"]?.intValue }
/// Non-empty only while an upgrade is in progress / recorded.
var fwUpgradeStatus: [String: JSONValue]? {
let obj = onu?["FW Upgrade Status"]?.objectValue
return (obj?.isEmpty == false) ? obj : nil
}
/// UNI ports, from the top-level `UNI-*` entries.
var uniPorts: [UNIPort] {
guard let obj = raw.objectValue else { return [] }
return obj.keys.filter { $0.hasPrefix("UNI-") }.sorted()
.compactMap { key in obj[key].map { UNIPort(name: key, raw: $0) } }
}
/// Per-service traffic, from `STATS["OLT-PON Service N"]`.
var ponServices: [PONServiceStats] {
guard let stats = raw["STATS"]?.objectValue else { return [] }
return stats.keys.filter { $0.hasPrefix("OLT-PON Service ") }.sorted()
.compactMap { key in stats[key].map { PONServiceStats(name: key, raw: $0) } }
}
}
// MARK: - OLT-STATE detail accessors (uptime / traffic / environment / uplink)
extension OLTStateDoc {
private var olt: JSONValue? { raw["OLT"] }
private var ponStats: JSONValue? { raw["STATS"]?["OLT-PON"] }
var onlineTime: String? { olt?["Online Time"]?.stringValue }
var firmwareVersion: String? { olt?["FW Version"]?.stringValue }
var serialNumber: String? { olt?["Serial Number"]?.stringValue }
var model: String? { olt?["Model"]?.stringValue }
var ponMode: String? { olt?["PON Mode"]?.stringValue }
var laserShutdown: String? { olt?["Laser Shutdown"]?.stringValue }
/// True when the OLT laser isn't "Laser ON" (shut down / inactive). Only
/// meaningful when the field is present.
var isLaserOff: Bool {
guard let value = laserShutdown else { return false }
return value.caseInsensitiveCompare("Laser ON") != .orderedSame
}
var statsTotalONUs: Int? { ponStats?["Total ONUs Count"]?.intValue }
var statsOnlineONUs: Int? { ponStats?["Online ONUs Count"]?.intValue }
var statsOfflineONUs: Int? { ponStats?["Offline ONUs Count"]?.intValue }
var rxUtilPercent: Int? { ponStats?["RX BW Total Util"]?.intValue }
var txUtilPercent: Int? { ponStats?["TX BW Total Util"]?.intValue }
var rxRateBps: Double? { ponStats?["RX BW Ethernet Rate bps"]?.doubleValue }
var txRateBps: Double? { ponStats?["TX BW Ethernet Rate bps"]?.doubleValue }
var asicTempC: Int? { raw["STATS"]?["OLT-TEMP"]?["ASIC"]?.intValue }
var voltage: Double? { raw["STATS"]?["OLT-ENV"]?["Voltage"]?.doubleValue }
// Upstream switch (LLDP neighbour).
var switchName: String? { nonEmpty(raw["Switch"]?["System Name"]?.stringValue) }
var switchPort: String? { nonEmpty(raw["Switch"]?["Port ID"]?.stringValue) }
var switchAddress: String? { nonEmpty(raw["Switch"]?["IPv4 Address"]?.stringValue) }
}
// MARK: - OLT action payloads
/// Body for `PUT /olts/<OLT>/disable-onu/<ONU>/` sent WITHOUT the data wrapper.
struct DisableONURequest: Encodable {
let disable: Bool
}
/// OLT action responses return `{ "Pending": true|false }`.
struct PendingStatus: Decodable {
let pending: Bool
enum CodingKeys: String, CodingKey { case pending = "Pending" }
}