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//` 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//disable-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" } }