Solar-Integration und dev_watch-Geräte zusammenführen

solar-integration (aus dem separaten VanAligneiOS-Repo) und dev_watch
haben unabhängige Git-Historien, decken aber überlappende und sich
ergänzende Funktionen ab. Übernommen aus solar-integration: Votronic-
Solar-ESP-Anbindung samt Geräterolle, die Live-Activity/Widget-Extension
fürs Sperrbildschirm/Dynamic-Island/CarPlay, das Querformat-Layout für
Libelle/Fahrzeug-Ansicht und Ausrichtungs-Assistent, sowie die
mehreren Fahrzeuggrafik-Stile (Vanster/California). Beibehalten aus
dev_watch: alle zusätzlichen Geräteprotokolle (Daly-/JBD-BMS, Alpicool-
Kühlbox, WattCycle, Victron), die dort zwischenzeitlich entstanden.

Die Xcode-Projektdatei wurde von Hand um die neue Widget-Extension samt
SharedActivity-Gruppe erweitert (Datei-synchronisierte Gruppen, kein
App-Group-Entitlement nötig). Build für App, Watch und Widget-Extension
geprüft (Debug und Release).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fototeddy
2026-09-06 21:00:14 +02:00
co-authored by Claude Sonnet 5
parent 812874baef
commit e9b9c5bcd5
42 changed files with 2592 additions and 214 deletions
+65 -4
View File
@@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable {
var looksLikeSupported: Bool
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
var isLevelSensor = false
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
var isVotronicSolarESPSensor = false
var isVictron: Bool { victronRecordType != nil }
@@ -30,6 +32,7 @@ struct Discovery: Identifiable, Hashable {
return "Victron · " + Self.victronRecordName(type)
}
if isLevelSensor { return "VanAlign Neigungsmesser" }
if isVotronicSolarESPSensor { return "VotronicSolarESP" }
if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" }
return "Bluetooth-Gerät"
}
@@ -149,9 +152,15 @@ final class BluetoothManager: NSObject {
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var levelStates: [UUID: LevelState] = [:]
private(set) var votronicSolarESPStates: [UUID: VotronicSolarESPState] = [:]
private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
/// Verbindet die Live Activity des Neigungsmessers mit dem Funkgeschehen
/// gesetzt von `CamperMonitorApp`, damit diese Schicht nichts über
/// ActivityKit wissen muss, wenn niemand zuhört.
var activityManager: LevelActivityManager?
/// Solange true, werden alle gefundenen Peripherals gesammelt.
var isDiscovering = false {
didSet {
@@ -185,6 +194,7 @@ final class BluetoothManager: NSObject {
private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:]
private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer?
@@ -309,6 +319,7 @@ final class BluetoothManager: NSObject {
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
fridgeStates = fridgeStates.filter { known.contains($0.key) }
levelStates = levelStates.filter { known.contains($0.key) }
votronicSolarESPStates = votronicSolarESPStates.filter { known.contains($0.key) }
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
// ihren zuletzt gestellten Stand.
@@ -516,6 +527,11 @@ final class BluetoothManager: NSObject {
levelSessions[peripheralID] = nil
disconnect(peripheralID)
}
for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) {
session.stop()
votronicSolarESPSessions[peripheralID] = nil
disconnect(peripheralID)
}
// Einstellungen an bestehende Sitzungen weiterreichen.
for device in devices {
bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
@@ -561,9 +577,11 @@ final class BluetoothManager: NSObject {
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
for (_, session) in bmsSessions { session.stop() }
for (_, session) in levelSessions { session.stop() }
for (_, session) in votronicSolarESPSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
bmsSessions.removeAll()
levelSessions.removeAll()
votronicSolarESPSessions.removeAll()
connectedPeripherals.removeAll()
connectedSince.removeAll()
pendingControls.removeAll()
@@ -755,7 +773,8 @@ final class BluetoothManager: NSObject {
}
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor
entry.isVotronicSolarESPSensor = services.contains(VotronicSolarESPSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor || entry.isVotronicSolarESPSensor
|| Self.looksLikeSupported(name: entry.name)
pendingDiscoveries[peripheral.identifier] = entry
}
@@ -842,10 +861,23 @@ extension BluetoothManager: CBCentralManagerDelegate {
queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state }
self?.publish {
self?.linkStates[device.id] = state
if state == .live {
// Verbindung wieder da: ein anstehendes Ende
// verwerfen oder eine zwischenzeitlich doch schon
// beendete Live Activity wieder aufnehmen.
self?.activityManager?.handleReconnect(
deviceID: device.id,
state: self?.levelStates[device.id] ?? LevelState())
}
}
},
onLevelState: { [weak self] state in
self?.publish { self?.levelStates[device.id] = state }
self?.publish {
self?.levelStates[device.id] = state
self?.activityManager?.update(deviceID: device.id, state: state)
}
},
onDeviceOrientation: { [weak self] orientation in
// Im Gerät steht, wie der Sensor eingebaut ist für alle
@@ -859,6 +891,27 @@ extension BluetoothManager: CBCentralManagerDelegate {
return
}
if device.role == .solar {
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
// nicht in der Live Activity/CarPlay auftauchen, die ist dem
// Neigungsmesser vorbehalten.
let session = VotronicSolarESPSession(
deviceID: device.id,
peripheral: peripheral,
queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state }
},
onVotronicSolarESPState: { [weak self] state in
self?.publish { self?.votronicSolarESPStates[device.id] = state }
}
)
votronicSolarESPSessions[peripheral.identifier] = session
session.start()
return
}
let session = BMSSession(
deviceID: device.id,
peripheral: peripheral,
@@ -906,6 +959,8 @@ extension BluetoothManager: CBCentralManagerDelegate {
bmsSessions[peripheral.identifier] = nil
levelSessions[peripheral.identifier]?.handleDisconnect()
levelSessions[peripheral.identifier] = nil
votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect()
votronicSolarESPSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
@@ -920,7 +975,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
self.showLastKnownFridgeSettings(for: deviceID)
}
} else if let device = managed[peripheral.identifier] {
publish { self.linkStates[device.id] = .searching }
let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
publish {
self.linkStates[device.id] = .searching
// Erst nach einer Gnadenfrist wirklich beenden sonst
// flackerte die Live Activity bei jedem kurzen Funkloch.
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
}
if lifetime >= stableConnection {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das