Renames every type and identifier we built for the solar charge controller bridge from generic "Solar" naming to the product name VotronicSolarESP: VanAlignSolarProtocol -> VotronicSolarESPProtocol, SolarSession -> VotronicSolarESPSession, SolarState -> VotronicSolarESPState, plus the dependent properties, parameters, and user-facing discovery/empty-state strings. Purely a Swift-side rename - the underlying BLE service/characteristic UUIDs are untouched, so it has no effect on communicating with the actual firmware. DeviceRole.solar's case name, and DemoData's device instance variables, are left as-is since they describe the role/demo device rather than this specific integration code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
161 lines
6.0 KiB
Swift
161 lines
6.0 KiB
Swift
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()
|
||
}
|
||
}
|