USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
1 contributor
412 lines | 11.888kb
//
//  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 completed
    case abandoned

    var title: String {
        rawValue.capitalized
    }
}

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 {
    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 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 status: ChargeSessionStatus
    let sourceMode: ChargeSessionSourceMode
    let chargingTransportMode: ChargingTransportMode
    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 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
    }
}

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 supportsWiredCharging: Bool
    let supportsWirelessCharging: Bool
    let preferredChargingTransportMode: ChargingTransportMode
    let wirelessChargingProfile: WirelessChargingProfile
    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: { $0.status == .active })
    }

    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
    }

    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 chargingTransportMode: ChargingTransportMode) -> Double? {
        switch chargingTransportMode {
        case .wired:
            return wiredChargeCompletionCurrentAmps
        case .wireless:
            return wirelessChargeCompletionCurrentAmps
        }
    }

    func resolvedCompletionCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
        configuredCompletionCurrentAmps(for: chargingTransportMode)
            ?? 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
}