import CoreBluetooth import Foundation /// Hält die Verbindung zum Solarladeregler. /// /// Einfacher noch als `LevelSession`: die Firmware bietet für keine /// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also /// immer im Takt abgefragt statt abonniert. final class VotronicSolarESPSession: NSObject { static let serviceUUID = CBUUID(string: VotronicSolarESPProtocol.serviceUUID) private static let batteryVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.batteryVoltageUUID) private static let pvVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.pvVoltageUUID) private static let pvCurrentUUID = CBUUID(string: VotronicSolarESPProtocol.pvCurrentUUID) private static let pvPowerUUID = CBUUID(string: VotronicSolarESPProtocol.pvPowerUUID) private static let controllerTempUUID = CBUUID(string: VotronicSolarESPProtocol.controllerTempUUID) private static let statusFlagsUUID = CBUUID(string: VotronicSolarESPProtocol.statusFlagsUUID) let deviceID: UUID private let queue: DispatchQueue private let peripheral: CBPeripheral private let onUpdate: (DeviceSnapshot) -> Void private let onStateChange: (DeviceLinkState) -> Void private let onVotronicSolarESPState: (VotronicSolarESPState) -> Void private var characteristics: [CBUUID: CBCharacteristic] = [:] private var state = VotronicSolarESPState() private var pollTimer: DispatchSourceTimer? /// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern – /// deutlich seltener als beim Ausrichten mit dem Neigungsmesser. var pollInterval: TimeInterval = 5 init(deviceID: UUID, peripheral: CBPeripheral, queue: DispatchQueue, onUpdate: @escaping (DeviceSnapshot) -> Void, onStateChange: @escaping (DeviceLinkState) -> Void, onVotronicSolarESPState: @escaping (VotronicSolarESPState) -> Void) { self.deviceID = deviceID self.queue = queue self.peripheral = peripheral self.onUpdate = onUpdate self.onStateChange = onStateChange self.onVotronicSolarESPState = onVotronicSolarESPState super.init() peripheral.delegate = self } // MARK: - Lebenszyklus func start() { onStateChange(.connecting) peripheral.discoverServices([Self.serviceUUID]) } func stop() { pollTimer?.cancel() pollTimer = nil characteristics.removeAll() } func handleDisconnect() { pollTimer?.cancel() pollTimer = nil characteristics.removeAll() } // MARK: - Abfrage private func startPolling() { guard pollTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now(), repeating: pollInterval) timer.setEventHandler { [weak self] in self?.readAll() } timer.resume() pollTimer = timer } private func readAll() { guard peripheral.state == .connected else { return } for characteristic in characteristics.values where characteristic.properties.contains(.read) { peripheral.readValue(for: characteristic) } } private func publish() { guard state.hasReading else { return } onStateChange(.live) onVotronicSolarESPState(state) onUpdate(state.snapshot(deviceID: deviceID, rssi: nil)) } } // MARK: - CBPeripheralDelegate extension VotronicSolarESPSession: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { if let error { onStateChange(.failed(error.localizedDescription)) return } guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else { onStateChange(.failed("Solarladeregler-Dienst nicht gefunden")) return } peripheral.discoverCharacteristics( [Self.batteryVoltageUUID, Self.pvVoltageUUID, Self.pvCurrentUUID, Self.pvPowerUUID, Self.controllerTempUUID, Self.statusFlagsUUID], for: service ) } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { guard error == nil, let found = service.characteristics else { onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden")) return } for characteristic in found { characteristics[characteristic.uuid] = characteristic } guard !characteristics.isEmpty else { onStateChange(.failed("Solarwerte nicht gefunden")) return } startPolling() readAll() } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error == nil, let value = characteristic.value else { return } switch characteristic.uuid { case Self.batteryVoltageUUID: state.batteryVoltage = VotronicSolarESPProtocol.float(from: value) case Self.pvVoltageUUID: state.pvVoltage = VotronicSolarESPProtocol.float(from: value) case Self.pvCurrentUUID: state.pvCurrent = VotronicSolarESPProtocol.float(from: value) case Self.pvPowerUUID: state.pvPower = VotronicSolarESPProtocol.float(from: value) case Self.controllerTempUUID: state.controllerTemperature = VotronicSolarESPProtocol.float(from: value) case Self.statusFlagsUUID: guard let flags = VotronicSolarESPProtocol.statusFlags(from: value) else { return } state.isBatteryCharging = flags.isBatteryCharging state.isBatteryDischarging = flags.isBatteryDischarging state.isControllerActive = flags.isControllerActive state.isCurrentLimited = flags.isCurrentLimited default: return } publish() } }