forked from fritob/Camper-Monitor
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:
co-authored by
Claude Sonnet 5
parent
812874baef
commit
e9b9c5bcd5
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// VotronicSolarESP – zweiter ESP32 im Fahrzeug, liest einen
|
||||
/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
|
||||
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
|
||||
///
|
||||
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
|
||||
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
|
||||
/// sie im Takt ab.
|
||||
enum VotronicSolarESPProtocol {
|
||||
|
||||
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
|
||||
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
|
||||
|
||||
static let batteryVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A101"
|
||||
static let pvVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A102"
|
||||
static let pvCurrentUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A103"
|
||||
static let pvPowerUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A104"
|
||||
static let controllerTempUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A105"
|
||||
/// Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
|
||||
/// Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
|
||||
static let statusFlagsUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A106"
|
||||
|
||||
/// Liest einen Messwert aus vier Bytes, little-endian – wie beim
|
||||
/// Neigungsmesser legt die Firmware den Float per `memcpy` ab.
|
||||
static func float(from data: Data) -> Double? {
|
||||
guard data.count >= 4 else { return nil }
|
||||
var raw: UInt32 = 0
|
||||
for (index, byte) in data.prefix(4).enumerated() {
|
||||
raw |= UInt32(byte) << UInt32(8 * index)
|
||||
}
|
||||
let value = Float(bitPattern: raw)
|
||||
guard value.isFinite else { return nil }
|
||||
return Double(value)
|
||||
}
|
||||
|
||||
struct StatusFlags {
|
||||
var isBatteryCharging = false
|
||||
var isBatteryDischarging = false
|
||||
var isControllerActive = false
|
||||
var isCurrentLimited = false
|
||||
var isAESActive = false
|
||||
}
|
||||
|
||||
static func statusFlags(from data: Data) -> StatusFlags? {
|
||||
guard let byte = data.first else { return nil }
|
||||
var flags = StatusFlags()
|
||||
flags.isBatteryCharging = byte & (1 << 0) != 0
|
||||
flags.isBatteryDischarging = byte & (1 << 1) != 0
|
||||
flags.isControllerActive = byte & (1 << 2) != 0
|
||||
flags.isCurrentLimited = byte & (1 << 3) != 0
|
||||
flags.isAESActive = byte & (1 << 4) != 0
|
||||
return flags
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case bms
|
||||
case fridge
|
||||
case leveling
|
||||
case solar
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -20,6 +21,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case .bms: return "Batterie / BMS"
|
||||
case .fridge: return "Kühlbox"
|
||||
case .leveling: return "Nivellierung"
|
||||
case .solar: return "VotronicSolarESP"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case .bms: return "battery.100percent.bolt"
|
||||
case .fridge: return "refrigerator"
|
||||
case .leveling: return "level"
|
||||
case .solar: return "sun.max"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +53,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
/// braucht eine echte GATT-Verbindung.
|
||||
var transport: DeviceTransport {
|
||||
switch self {
|
||||
case .bms, .fridge, .leveling: return .connect
|
||||
case .bms, .fridge, .leveling, .solar: return .connect
|
||||
default: return .advertisement
|
||||
}
|
||||
}
|
||||
@@ -98,6 +101,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
var fridgeZoneMode: FridgeZoneMode = .automatic
|
||||
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
|
||||
var sensorOrientation = SensorOrientation.identity
|
||||
/// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt.
|
||||
var vehicleGraphicStyle = VehicleGraphicStyle.vanster
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
@@ -128,5 +133,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
?? .automatic
|
||||
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
|
||||
forKey: .sensorOrientation) ?? .identity
|
||||
vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self,
|
||||
forKey: .vehicleGraphicStyle) ?? .vanster
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt.
|
||||
///
|
||||
/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und
|
||||
/// Heckansicht in Assets.xcassets, beide als Vorlage ("template")
|
||||
/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird
|
||||
/// nur die Seitenansicht gezeigt.
|
||||
enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case vanster
|
||||
case california
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .vanster: return "Vanster"
|
||||
case .california: return "California"
|
||||
}
|
||||
}
|
||||
|
||||
var sideImageName: String {
|
||||
switch self {
|
||||
case .vanster: return "VansterSide"
|
||||
case .california: return "CaliforniaSide"
|
||||
}
|
||||
}
|
||||
|
||||
/// Für California liegt noch keine eigene Heckansicht vor, deshalb
|
||||
/// vorerst die von Vanster.
|
||||
var rearImageName: String {
|
||||
switch self {
|
||||
case .vanster, .california: return "VansterRear"
|
||||
}
|
||||
}
|
||||
|
||||
/// Seitenverhältnis der jeweiligen Bilder – ohne das würde eine Grafik
|
||||
/// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder
|
||||
/// ungewollt verkleinert erscheinen.
|
||||
var sideAspect: Double {
|
||||
switch self {
|
||||
case .vanster: return VehicleTilt.sideAspect
|
||||
case .california: return 1
|
||||
}
|
||||
}
|
||||
|
||||
var rearAspect: Double {
|
||||
switch self {
|
||||
case .vanster, .california: return VehicleTilt.rearAspect
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// Zustand des Solarladereglers.
|
||||
struct VotronicSolarESPState: Equatable, Codable, Sendable {
|
||||
var batteryVoltage: Double?
|
||||
var pvVoltage: Double?
|
||||
var pvCurrent: Double?
|
||||
var pvPower: Double?
|
||||
var controllerTemperature: Double?
|
||||
|
||||
var isBatteryCharging: Bool?
|
||||
var isBatteryDischarging: Bool?
|
||||
var isControllerActive: Bool?
|
||||
var isCurrentLimited: Bool?
|
||||
|
||||
var hasReading: Bool {
|
||||
batteryVoltage != nil || pvVoltage != nil || pvCurrent != nil || pvPower != nil
|
||||
}
|
||||
|
||||
/// Kurzer Klartext, wie bei den übrigen Geräten als "Zustand" angezeigt.
|
||||
var stateText: String? {
|
||||
guard isControllerActive != nil else { return nil }
|
||||
if isBatteryCharging == true { return "Lädt" }
|
||||
if isControllerActive == true { return "Aktiv" }
|
||||
return "Inaktiv"
|
||||
}
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
snapshot.metrics = [
|
||||
Metric("pv_power", "Solarleistung", pvPower, unit: "W", precision: 0, primary: true),
|
||||
Metric("pv_voltage", "PV-Spannung", pvVoltage, unit: "V", precision: 1),
|
||||
Metric("pv_current", "PV-Strom", pvCurrent, unit: "A", precision: 1),
|
||||
Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2),
|
||||
]
|
||||
if let controllerTemperature {
|
||||
snapshot.metrics.append(
|
||||
Metric("controller_temperature", "Reglertemperatur", controllerTemperature,
|
||||
unit: "°C", precision: 0)
|
||||
)
|
||||
}
|
||||
snapshot.state = stateText
|
||||
if isCurrentLimited == true {
|
||||
snapshot.offReasons = ["PV-Strombegrenzung aktiv"]
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user