USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
1 contributor
631 lines | 17.725kb
//
//  ChargeInsightsModel.swift
//  USB Meter
//
//  Created by Codex on 10/04/2026.
//

import Foundation

enum ChargedDeviceClass: String, CaseIterable, Identifiable {
    case iphone
    case watch
    case powerbank
    case charger
    case other

    var id: String { rawValue }

    var title: String {
        switch self {
        case .iphone:
            return "iPhone"
        case .watch:
            return "Watch"
        case .powerbank:
            return "Powerbank"
        case .charger:
            return "Charger"
        case .other:
            return "Other"
        }
    }

    var symbolName: String {
        switch self {
        case .iphone:
            return "iphone"
        case .watch:
            return "applewatch"
        case .powerbank:
            return "battery.100.bolt"
        case .charger:
            return "bolt.badge.clock"
        case .other:
            return "shippingbox"
        }
    }
}

enum ChargeSessionStatus: String {
    case active
    case paused
    case completed
    case abandoned

    var title: String {
        switch self {
        case .active:
            return "Active"
        case .paused:
            return "Paused"
        case .completed:
            return "Completed"
        case .abandoned:
            return "Abandoned"
        }
    }

    var isOpen: Bool {
        switch self {
        case .active, .paused:
            return true
        case .completed, .abandoned:
            return false
        }
    }
}

enum ChargeSessionSourceMode: String {
    case live
    case offline
    case blended

    var title: String {
        switch self {
        case .live:
            return "Live"
        case .offline:
            return "Offline Counters"
        case .blended:
            return "Blended"
        }
    }
}

enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
    case wired
    case wireless

    var id: String { rawValue }

    var title: String {
        switch self {
        case .wired:
            return "Wired"
        case .wireless:
            return "Wireless"
        }
    }

    var symbolName: String {
        switch self {
        case .wired:
            return "cable.connector"
        case .wireless:
            return "dot.radiowaves.left.and.right"
        }
    }
}

enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
    case on
    case off

    var id: String { rawValue }

    var title: String {
        switch self {
        case .on:
            return "On"
        case .off:
            return "Off"
        }
    }

    var description: String {
        switch self {
        case .on:
            return "Device stays powered on while charging."
        case .off:
            return "Device is powered off while charging."
        }
    }
}

enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
    case onOnly
    case onOrOff
    case offOnly

    var id: String { rawValue }

    var title: String {
        switch self {
        case .onOnly:
            return "On Only"
        case .onOrOff:
            return "On or Off"
        case .offOnly:
            return "Off Only"
        }
    }

    var description: String {
        switch self {
        case .onOnly:
            return "The device can be recorded only while it is powered on."
        case .onOrOff:
            return "The session must specify whether the device is on or off."
        case .offOnly:
            return "The device can be recorded only while it is powered off."
        }
    }

    var supportedModes: [ChargingStateMode] {
        switch self {
        case .onOnly:
            return [.on]
        case .onOrOff:
            return [.on, .off]
        case .offOnly:
            return [.off]
        }
    }

    var supportsMultipleModes: Bool {
        supportedModes.count > 1
    }

    var supportsChargingWhileOff: Bool {
        self != .onOnly
    }

    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
        supportsChargingWhileOff ? .onOrOff : .onOnly
    }
}

enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
    case wiredOn
    case wiredOff
    case wirelessOn
    case wirelessOff

    var id: String { rawValue }

    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
        switch (chargingTransportMode, chargingStateMode) {
        case (.wired, .on):
            self = .wiredOn
        case (.wired, .off):
            self = .wiredOff
        case (.wireless, .on):
            self = .wirelessOn
        case (.wireless, .off):
            self = .wirelessOff
        }
    }

    var chargingTransportMode: ChargingTransportMode {
        switch self {
        case .wiredOn, .wiredOff:
            return .wired
        case .wirelessOn, .wirelessOff:
            return .wireless
        }
    }

    var chargingStateMode: ChargingStateMode {
        switch self {
        case .wiredOn, .wirelessOn:
            return .on
        case .wiredOff, .wirelessOff:
            return .off
        }
    }

    var title: String {
        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
    }

    var shortTitle: String {
        "\(chargingTransportMode.title) \(chargingStateMode.title)"
    }
}

enum WirelessChargingProfile: String, CaseIterable, Identifiable {
    case magsafe
    case genericQi

    var id: String { rawValue }

    var title: String {
        switch self {
        case .magsafe:
            return "MagSafe"
        case .genericQi:
            return "Generic Qi"
        }
    }

    var description: String {
        switch self {
        case .magsafe:
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
        case .genericQi:
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
        }
    }
}

struct ChargeCheckpointSummary: Identifiable, Hashable {
    let id: UUID
    let sessionID: UUID
    let chargedDeviceID: UUID
    let timestamp: Date
    let batteryPercent: Double
    let measuredEnergyWh: Double
    let measuredChargeAh: Double
    let currentAmps: Double
    let voltageVolts: Double?
    let label: String?
}

struct ChargeSessionSampleSummary: Identifiable, Hashable {
    let sessionID: UUID
    let chargedDeviceID: UUID
    let bucketIndex: Int
    let timestamp: Date
    let averageCurrentAmps: Double
    let averageVoltageVolts: Double?
    let averagePowerWatts: Double
    let measuredEnergyWh: Double
    let measuredChargeAh: Double
    let sampleCount: Int

    var id: String {
        "\(sessionID.uuidString)-\(bucketIndex)"
    }
}

struct ChargeSessionSummary: Identifiable, Hashable {
    let id: UUID
    let chargedDeviceID: UUID
    let chargerID: UUID?
    let meterMACAddress: String?
    let meterName: String?
    let meterModel: String?
    let startedAt: Date
    let endedAt: Date?
    let lastObservedAt: Date
    let pausedAt: Date?
    let status: ChargeSessionStatus
    let sourceMode: ChargeSessionSourceMode
    let chargingTransportMode: ChargingTransportMode
    let chargingStateMode: ChargingStateMode
    let autoStopEnabled: Bool
    let measuredEnergyWh: Double
    let effectiveBatteryEnergyWh: Double?
    let measuredChargeAh: Double
    let minimumObservedCurrentAmps: Double?
    let maximumObservedCurrentAmps: Double?
    let maximumObservedPowerWatts: Double?
    let maximumObservedVoltageVolts: Double?
    let selectedSourceVoltageVolts: Double?
    let completionCurrentAmps: Double?
    let stopThresholdAmps: Double
    let startBatteryPercent: Double?
    let endBatteryPercent: Double?
    let capacityEstimateWh: Double?
    let wirelessEfficiencyFactor: Double?
    let usesEstimatedWirelessEfficiency: Bool
    let shouldWarnAboutLowWirelessEfficiency: Bool
    let supportsChargingWhileOff: Bool
    let usedOfflineMeterCounters: Bool
    let targetBatteryPercent: Double?
    let targetBatteryAlertTriggeredAt: Date?
    let requiresCompletionConfirmation: Bool
    let completionConfirmationRequestedAt: Date?
    let completionContradictionPercent: Double?
    let selectedDataGroup: UInt8?
    let checkpoints: [ChargeCheckpointSummary]
    let aggregatedSamples: [ChargeSessionSampleSummary]

    var sessionKind: ChargeSessionKind {
        ChargeSessionKind(
            chargingTransportMode: chargingTransportMode,
            chargingStateMode: chargingStateMode
        )
    }

    var duration: TimeInterval {
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
    }

    var effectiveOrMeasuredEnergyWh: Double {
        effectiveBatteryEnergyWh ?? measuredEnergyWh
    }

    var batteryDeltaPercent: Double? {
        guard let startBatteryPercent, let endBatteryPercent else { return nil }
        return endBatteryPercent - startBatteryPercent
    }

    var canAutoStop: Bool {
        autoStopEnabled && stopThresholdAmps > 0
    }

    var isPaused: Bool {
        status == .paused
    }

    var isOpen: Bool {
        status.isOpen
    }
}

struct BatteryLevelPrediction: Hashable {
    let predictedPercent: Double
    let estimatedCapacityWh: Double
    let anchorPercent: Double
    let anchorEnergyWh: Double
    let anchorDescription: String
}

struct CapacityTrendPoint: Identifiable, Hashable {
    let sessionID: UUID
    let timestamp: Date
    let capacityWh: Double
    let chargingTransportMode: ChargingTransportMode

    var id: UUID { sessionID }
}

struct TypicalChargeCurvePoint: Identifiable, Hashable {
    let percentBin: Int
    let averageEnergyWh: Double
    let averageChargeAh: Double
    let sampleCount: Int

    var id: Int { percentBin }
}

struct ChargedDeviceSummary: Identifiable, Hashable {
    let id: UUID
    let qrIdentifier: String
    let name: String
    let deviceClass: ChargedDeviceClass
    let supportsChargingWhileOff: Bool
    let chargingStateAvailability: ChargingStateAvailability
    let supportsWiredCharging: Bool
    let supportsWirelessCharging: Bool
    let preferredChargingTransportMode: ChargingTransportMode
    let wirelessChargingProfile: WirelessChargingProfile
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
    let wirelessChargerEfficiencyFactor: Double?
    let wiredChargeCompletionCurrentAmps: Double?
    let wirelessChargeCompletionCurrentAmps: Double?
    let chargerObservedVoltageSelections: [Double]
    let chargerIdleCurrentAmps: Double?
    let chargerEfficiencyFactor: Double?
    let chargerMaximumPowerWatts: Double?
    let notes: String?
    let minimumCurrentAmps: Double?
    let estimatedBatteryCapacityWh: Double?
    let wiredMinimumCurrentAmps: Double?
    let wirelessMinimumCurrentAmps: Double?
    let wiredEstimatedBatteryCapacityWh: Double?
    let wirelessEstimatedBatteryCapacityWh: Double?
    let lastAssociatedMeterMAC: String?
    let createdAt: Date
    let updatedAt: Date
    let sessions: [ChargeSessionSummary]
    let capacityHistory: [CapacityTrendPoint]
    let typicalCurve: [TypicalChargeCurvePoint]

    var isCharger: Bool {
        deviceClass == .charger
    }

    var activeSession: ChargeSessionSummary? {
        sessions.first(where: \.isOpen)
    }

    var recentCompletedSessions: [ChargeSessionSummary] {
        sessions.filter { $0.status == .completed }
    }

    var sessionCount: Int {
        sessions.count
    }

    var supportedChargingModes: [ChargingTransportMode] {
        var modes: [ChargingTransportMode] = []
        if supportsWiredCharging {
            modes.append(.wired)
        }
        if supportsWirelessCharging {
            modes.append(.wireless)
        }
        return modes.isEmpty ? [preferredChargingTransportMode] : modes
    }

    var supportedChargingStateModes: [ChargingStateMode] {
        chargingStateAvailability.supportedModes
    }

    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
        if let matchingSession = sessions.first(where: {
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
        }) {
            return matchingSession.chargingStateMode
        }
        return chargingStateAvailability.supportedModes.first ?? .on
    }

    func sessionKind(
        for chargingTransportMode: ChargingTransportMode,
        chargingStateMode: ChargingStateMode? = nil
    ) -> ChargeSessionKind {
        ChargeSessionKind(
            chargingTransportMode: chargingTransportMode,
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
        )
    }

    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
        switch chargingTransportMode {
        case .wired:
            return wiredEstimatedBatteryCapacityWh
        case .wireless:
            return wirelessEstimatedBatteryCapacityWh
        }
    }

    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
        switch chargingTransportMode {
        case .wired:
            return wiredMinimumCurrentAmps
        case .wireless:
            return wirelessMinimumCurrentAmps
        }
    }

    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
            return explicitCurrent
        }

        switch sessionKind.chargingTransportMode {
        case .wired:
            return wiredChargeCompletionCurrentAmps
        case .wireless:
            return wirelessChargeCompletionCurrentAmps
        }
    }

    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
            return learnedCurrent
        }

        switch sessionKind.chargingTransportMode {
        case .wired:
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
        case .wireless:
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
        }
    }

    func resolvedCompletionCurrentAmps(
        for chargingTransportMode: ChargingTransportMode,
        chargingStateMode: ChargingStateMode? = nil
    ) -> Double? {
        let sessionKind = sessionKind(
            for: chargingTransportMode,
            chargingStateMode: chargingStateMode
        )

        return configuredCompletionCurrentAmps(for: sessionKind)
            ?? learnedCompletionCurrentAmps(for: sessionKind)
            ?? minimumCurrentAmps(for: chargingTransportMode)
            ?? minimumCurrentAmps
    }

    func batteryLevelPrediction(for session: ChargeSessionSummary) -> BatteryLevelPrediction? {
        let estimatedCapacityWh = session.capacityEstimateWh
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
            ?? estimatedBatteryCapacityWh

        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
            return nil
        }

        let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh

        struct Anchor {
            let percent: Double
            let energyWh: Double
            let description: String
        }

        var anchors: [Anchor] = []

        if let startBatteryPercent = session.startBatteryPercent {
            anchors.append(
                Anchor(
                    percent: startBatteryPercent,
                    energyWh: 0,
                    description: "session start"
                )
            )
        }

        anchors.append(
            contentsOf: session.checkpoints
                .sorted { lhs, rhs in
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
                    }
                    return lhs.timestamp < rhs.timestamp
                }
                .map { checkpoint in
                    let trimmedLabel = checkpoint.label?.trimmingCharacters(in: .whitespacesAndNewlines)
                    return Anchor(
                        percent: checkpoint.batteryPercent,
                        energyWh: checkpoint.measuredEnergyWh,
                        description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint"
                    )
                }
        )

        guard !anchors.isEmpty else {
            return nil
        }

        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
        let anchor = eligibleAnchors.last ?? anchors.first!
        let energyDeltaWh = max(effectiveEnergyWh - anchor.energyWh, 0)
        let predictedPercent = min(
            100,
            max(
                0,
                anchor.percent + ((energyDeltaWh / estimatedCapacityWh) * 100)
            )
        )

        return BatteryLevelPrediction(
            predictedPercent: predictedPercent,
            estimatedCapacityWh: estimatedCapacityWh,
            anchorPercent: anchor.percent,
            anchorEnergyWh: anchor.energyWh,
            anchorDescription: anchor.description
        )
    }
}

struct ChargingMonitorSnapshot {
    let meterMACAddress: String
    let meterName: String
    let meterModel: String
    let observedAt: Date
    let voltageVolts: Double
    let currentAmps: Double
    let powerWatts: Double
    let selectedDataGroup: UInt8?
    let meterChargeCounterAh: Double?
    let meterEnergyCounterWh: Double?
    let fallbackStopThresholdAmps: Double
}