first commit
This commit is contained in:
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.400", "green" : "0.620", "red" : "0.110" } }, "idiom" : "universal" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleRear.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleSide.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 253 KiB |
@@ -0,0 +1,996 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Ein während des Einrichtens gefundenes Gerät.
|
||||
struct Discovery: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var name: String?
|
||||
/// Geglättet, damit die Signalanzeige nicht flackert.
|
||||
var rssi: Int
|
||||
/// Sortierschlüssel: die Reihenfolge des Auftauchens ist stabil,
|
||||
/// die Signalstärke wäre es nicht.
|
||||
var firstSeen: Date
|
||||
var lastSeen: Date
|
||||
//var victronRecordType: UInt8?
|
||||
//var victronProductID: UInt16?
|
||||
var looksLikeSupported: Bool
|
||||
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
|
||||
var isLevelSensor = false
|
||||
|
||||
//var isVictron: Bool { victronRecordType != nil }
|
||||
|
||||
var displayName: String {
|
||||
if let name, !name.isEmpty { return name }
|
||||
return "Unbenanntes Gerät"
|
||||
}
|
||||
/*var displayName: String {
|
||||
if let name, !name.isEmpty { return name }
|
||||
return isVictron ? "Victron-Gerät" : "Unbenanntes Gerät"
|
||||
}*/
|
||||
|
||||
var subtitle: String {
|
||||
if isLevelSensor { return "VanAlign Neigungsmesser" }
|
||||
return "Bluetooth-Gerät"
|
||||
}
|
||||
/*var subtitle: String {
|
||||
if let type = victronRecordType {
|
||||
return "Victron · " + Self.victronRecordName(type)
|
||||
}
|
||||
if isLevelSensor { return "VanAlign Neigungsmesser" }
|
||||
if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" }
|
||||
return "Bluetooth-Gerät"
|
||||
}*/
|
||||
|
||||
/*static func victronRecordName(_ type: UInt8) -> String {
|
||||
switch VictronAdvertisement.RecordType(rawValue: type) {
|
||||
case .solarCharger: return "Solarladeregler"
|
||||
case .batteryMonitor: return "Batteriewächter"
|
||||
case .inverter: return "Wechselrichter"
|
||||
case .dcdcConverter: return "DC/DC-Lader (Orion-TR)"
|
||||
case .orionXS: return "Orion XS"
|
||||
case .acCharger: return "AC-Ladegerät"
|
||||
case .smartLithium: return "Smart Lithium"
|
||||
case .smartBatteryProtect: return "Battery Protect"
|
||||
case .lynxSmartBMS: return "Lynx Smart BMS"
|
||||
case .dcEnergyMeter: return "DC-Energiezähler"
|
||||
default: return String(format: "Typ 0x%02X", type)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
/*/// Was ein Victron-Gerät unverschlüsselt mitsendet. Wird unabhängig vom
|
||||
/// Schlüssel gefüllt und hilft, eine falsche Eingabe einzugrenzen.
|
||||
struct VictronDiagnostics: Hashable {
|
||||
var productID: UInt16
|
||||
var recordType: UInt8
|
||||
var expectedKeyFirstByte: UInt8
|
||||
var nonce: UInt16
|
||||
var payloadLength: Int
|
||||
var rawHex: String
|
||||
var updated: Date
|
||||
|
||||
var recordName: String { Discovery.victronRecordName(recordType) }
|
||||
var productIDText: String { String(format: "0x%04X", productID) }
|
||||
var expectedKeyText: String { String(format: "0x%02X", expectedKeyFirstByte) }
|
||||
}*/
|
||||
|
||||
/*/// Was bei der BMS-Verbindung erkannt wurde. Zeigt, ob und welches Protokoll
|
||||
/// greift, und gibt die letzte Rohantwort zum Nachsehen aus.
|
||||
struct BMSDiagnostics: Hashable {
|
||||
var dialect: String
|
||||
/// Welche Schreib-/Notify-Kombination gerade versucht wird.
|
||||
var endpointLabel: String?
|
||||
/// Der wievielte von wie vielen Kandidaten das ist.
|
||||
var endpointPosition: Pair?
|
||||
var serviceUUID: String?
|
||||
var isConnected: Bool
|
||||
/// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat.
|
||||
var isNotifyActive: Bool
|
||||
/// Nur bei Kühlboxen: ob die Box die Anmeldung beantwortet hat.
|
||||
var isBound: Bool?
|
||||
/// Wieviele Schreibvorgänge das Gerät bestätigt hat. Zählt nur mit, wenn
|
||||
/// mit Bestätigung geschrieben wird.
|
||||
var confirmedWrites: Int
|
||||
/// Fehler des letzten bestätigten Schreibvorgangs.
|
||||
var lastWriteError: String?
|
||||
/// Nur bei Kühlboxen: die vollständige Nutzlast der letzten Statusantwort.
|
||||
/// Daran hängt die Länge des Stellbefehls, deshalb gehört sie in die
|
||||
/// Diagnose.
|
||||
var fridgePayloadHex: String?
|
||||
/// Vollständiger Dienst-/Merkmalsbaum des Geräts.
|
||||
var gattSummary: [String]
|
||||
var sentFrames: Int
|
||||
var receivedBytes: Int
|
||||
var lastSendAt: Date?
|
||||
var lastResponseHex: String?
|
||||
/// Zuletzt abgeschickter Stellbefehl, damit sich prüfen lässt, ob er das
|
||||
/// Gerät überhaupt erreicht hat.
|
||||
var lastCommandHex: String?
|
||||
var lastCommandAt: Date?
|
||||
var updated: Date
|
||||
|
||||
struct Pair: Hashable {
|
||||
var index: Int
|
||||
var total: Int
|
||||
init(_ index: Int, _ total: Int) { self.index = index; self.total = total }
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
/// Ein Messpunkt für die Verlaufsgrafik.
|
||||
struct HistorySample: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let time: Date
|
||||
let value: Double
|
||||
}
|
||||
|
||||
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Geräten und hält
|
||||
/// parallel die Verbindung zum Neigungsmesser.
|
||||
///
|
||||
/// **Threading.** CoreBluetooth arbeitet auf einer eigenen seriellen Queue,
|
||||
/// nicht auf dem Hauptthread. Advertisements treffen einzeln ein – bei
|
||||
/// aktivem Duplikat-Scan über alle Geräte in Reichweite sind das leicht
|
||||
/// hunderte je Sekunde, die sonst mit dem Zeichnen der Oberfläche um
|
||||
/// dieselbe Zeit konkurrieren.
|
||||
///
|
||||
/// Daraus folgt eine strikte Trennung:
|
||||
///
|
||||
/// * Alles unter „nur auf `queue`“ wird ausschließlich dort angefasst –
|
||||
/// Verbindungen, Puffer, Zeitgeber.
|
||||
/// * Die veröffentlichten Werte werden ausschließlich auf dem Hauptthread
|
||||
/// geschrieben, über `publish`.
|
||||
/// * Die Geräteliste wird nicht aus der Funk-Queue heraus im Speicher
|
||||
/// nachgeschlagen, sondern als Momentaufnahme hinübergereicht.
|
||||
@Observable
|
||||
final class BluetoothManager: NSObject {
|
||||
|
||||
// MARK: - Veröffentlicht (nur auf dem Hauptthread schreiben)
|
||||
|
||||
private(set) var snapshots: [UUID: DeviceSnapshot] = [:]
|
||||
private(set) var linkStates: [UUID: DeviceLinkState] = [:]
|
||||
private(set) var discoveries: [UUID: Discovery] = [:]
|
||||
private(set) var history: [UUID: [HistorySample]] = [:]
|
||||
//private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
|
||||
//private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
|
||||
//private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
|
||||
private(set) var levelStates: [UUID: LevelState] = [:]
|
||||
private(set) var isBluetoothReady = false
|
||||
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
|
||||
|
||||
/// Solange true, werden alle gefundenen Peripherals gesammelt.
|
||||
var isDiscovering = false {
|
||||
didSet {
|
||||
let wanted = isDiscovering
|
||||
queue.async { self.setDiscovering(wanted) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nur auf `queue`
|
||||
|
||||
private let queue = DispatchQueue(label: "fototeddy.VanAlignePro.bluetooth")
|
||||
private var central: CBCentralManager?
|
||||
|
||||
/// Momentaufnahme der eingerichteten Geräte, damit die Funk-Queue nicht in
|
||||
/// den Speicher greifen muss.
|
||||
private struct ManagedDevice {
|
||||
let id: UUID
|
||||
let peripheralID: UUID
|
||||
let transport: DeviceTransport
|
||||
let role: DeviceRole
|
||||
//let fridgeZoneMode: FridgeZoneMode
|
||||
let sensorOrientation: SensorOrientation
|
||||
//let victronKey: [UInt8]?
|
||||
}
|
||||
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
|
||||
|
||||
/*/// Geräte, die nur verbunden werden, solange ihre Ansicht offen ist.
|
||||
/// Schlüssel ist die Gerätekennung, gezählt wird nicht – eine Ansicht ist
|
||||
/// zu einer Zeit offen.
|
||||
private var requestedSessions: Set<UUID> = []*/
|
||||
|
||||
//private var bmsSessions: [UUID: BMSSession] = [:]
|
||||
private var levelSessions: [UUID: LevelSession] = [:]
|
||||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||||
private var reconnectTimer: DispatchSourceTimer?
|
||||
|
||||
/// Frühester Zeitpunkt für den nächsten Verbindungsversuch je Gerät.
|
||||
///
|
||||
/// Ohne diese Sperre entsteht ein Verbindungssturm: schlägt ein Versuch
|
||||
/// fehl, ist der Eintrag wieder frei, und das nächste Advertisement löst
|
||||
/// sofort den nächsten aus - bei aktivem Scan bis zu einmal je Sekunde.
|
||||
/// Geräte quittieren jeden Versuch, Kühlboxen etwa mit einem Piepton.
|
||||
private var nextConnectAttempt: [UUID: Date] = [:]
|
||||
private var connectFailures: [UUID: Int] = [:]
|
||||
private let firstRetryDelay: TimeInterval = 5
|
||||
private let longestRetryDelay: TimeInterval = 60
|
||||
/// Nach dem Abbruch einer Verbindung, die stand, wird zügig nachgefasst -
|
||||
/// das ist der Normalfall im Fahrzeug und kein Fehler.
|
||||
private let quickRetryDelay: TimeInterval = 2
|
||||
/// Ab dieser Dauer gilt eine Verbindung als zustandegekommen. Was sofort
|
||||
/// wieder abbricht, zählt als Fehlschlag.
|
||||
private let stableConnection: TimeInterval = 3
|
||||
private var connectedSince: [UUID: Date] = [:]
|
||||
|
||||
/// Stellbefehle, die kamen, während die Verbindung weg war. Ohne das
|
||||
/// verschwindet ein Tippen lautlos: die Sitzung ist dann nicht da, und der
|
||||
/// Befehl fällt ins Leere.
|
||||
//private var pendingControls: [UUID: [(packet: Data, queuedAt: Date)]] = [:]
|
||||
/// So lange darf ein aufgehobener Befehl warten. Was älter ist, wird
|
||||
/// verworfen - ein verspätetes Ausschalten würde nur überraschen.
|
||||
//private let controlLifetime: TimeInterval = 30
|
||||
/// Letzter bekannter Kühlbox-Zustand, auch über eine Trennung hinweg. Der
|
||||
/// Einstellungsblock wird daraus gebaut.
|
||||
//private var fridgeStateCache: [UUID: AlpicoolState] = [:]
|
||||
private var discoveryFlushTimer: DispatchSourceTimer?
|
||||
private var discovering = false
|
||||
|
||||
/// Advertisements treffen mehrmals je Sekunde und Gerät ein. Öfter als
|
||||
/// hier festgelegt wird nichts ausgewertet – für die Anzeige ändert ein
|
||||
/// Advertisement mehr nichts, es kostet aber Entschlüsselung und eine
|
||||
/// Neuzeichnung.
|
||||
private var pendingDiscoveries: [UUID: Discovery] = [:]
|
||||
private var lastHandledAdvertisement: [UUID: Date] = [:]
|
||||
private var lastDiscoveryUpdate: [UUID: Date] = [:]
|
||||
//private var lastDiagnosticsUpdate: [UUID: Date] = [:]
|
||||
private let minimumAdvertisementInterval: TimeInterval = 0.9
|
||||
private let minimumDiscoveryInterval: TimeInterval = 2.0
|
||||
//private let minimumDiagnosticsInterval: TimeInterval = 3.0
|
||||
|
||||
// MARK: - Sonstiges
|
||||
|
||||
private let store: DeviceStore
|
||||
private let isDemo = DemoData.isEnabled
|
||||
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
|
||||
private let historyLimit = 720
|
||||
|
||||
init(store: DeviceStore) {
|
||||
self.store = store
|
||||
super.init()
|
||||
if isDemo {
|
||||
loadDemoData()
|
||||
return
|
||||
}
|
||||
central = CBCentralManager(delegate: self, queue: queue)
|
||||
refreshConfiguration()
|
||||
}
|
||||
|
||||
/// Führt Änderungen an den veröffentlichten Werten auf dem Hauptthread aus.
|
||||
private func publish(_ work: @escaping () -> Void) {
|
||||
if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
|
||||
}
|
||||
|
||||
private func loadDemoData() {
|
||||
isBluetoothReady = true
|
||||
bluetoothStatusText = "Demo-Modus"
|
||||
//fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
|
||||
levelStates[DemoData.level.id] = DemoData.levelState
|
||||
/* for snapshot in DemoData.snapshots() {
|
||||
snapshots[snapshot.deviceID] = snapshot
|
||||
linkStates[snapshot.deviceID] = .live
|
||||
// Die Kühlbox wird nur beim Öffnen verbunden – im Vorführbetrieb
|
||||
// steht sie deshalb wie im Alltag auf „zuletzt gestellt“.
|
||||
/*if snapshot.deviceID == DemoData.fridge.id,
|
||||
let settings = DemoData.fridgeState.settings {
|
||||
linkStates[snapshot.deviceID] = .idle
|
||||
snapshots[snapshot.deviceID] = settings.snapshot(deviceID: snapshot.deviceID)
|
||||
}*/
|
||||
if let value = snapshot.primaryMetric?.value {
|
||||
history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
// MARK: - Steuerung (vom Hauptthread aufgerufen)
|
||||
|
||||
func start() {
|
||||
guard !isDemo else { return }
|
||||
queue.async { self.startScanning() }
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard !isDemo else { return }
|
||||
queue.async { self.stopEverything() }
|
||||
}
|
||||
|
||||
/// Nach Änderungen an der Geräteliste oder den Schlüsseln aufrufen.
|
||||
func refreshConfiguration() {
|
||||
guard !isDemo else { return }
|
||||
|
||||
// Momentaufnahme auf dem Hauptthread ziehen, wo der Speicher lebt.
|
||||
let devices = store.activeDevices.map { device in
|
||||
ManagedDevice(id: device.id,
|
||||
peripheralID: device.peripheralID,
|
||||
transport: device.role.transport,
|
||||
role: device.role,
|
||||
sensorOrientation: device.sensorOrientation)
|
||||
}
|
||||
/*let devices = store.activeDevices.map { device in
|
||||
ManagedDevice(id: device.id,
|
||||
peripheralID: device.peripheralID,
|
||||
transport: device.role.transport,
|
||||
role: device.role,
|
||||
fridgeZoneMode: device.fridgeZoneMode,
|
||||
sensorOrientation: device.sensorOrientation,
|
||||
victronKey: store.victronKeyBytes(for: device.id))
|
||||
}*/
|
||||
let known = Set(devices.map(\.id))
|
||||
snapshots = snapshots.filter { known.contains($0.key) }
|
||||
linkStates = linkStates.filter { known.contains($0.key) }
|
||||
history = history.filter { known.contains($0.key) }
|
||||
//diagnostics = diagnostics.filter { known.contains($0.key) }
|
||||
//bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
||||
//fridgeStates = fridgeStates.filter { known.contains($0.key) }
|
||||
levelStates = levelStates.filter { known.contains($0.key) }
|
||||
|
||||
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
|
||||
// ihren zuletzt gestellten Stand.
|
||||
/*for device in store.activeDevices where device.role.connectsOnDemand {
|
||||
guard linkStates[device.id] != .live else { continue }
|
||||
linkStates[device.id] = .idle
|
||||
showLastKnownFridgeSettings(for: device.id)
|
||||
}*/
|
||||
|
||||
queue.async { self.applyConfiguration(devices) }
|
||||
}
|
||||
|
||||
func clearDiscoveries() {
|
||||
discoveries.removeAll()
|
||||
queue.async {
|
||||
self.pendingDiscoveries.removeAll()
|
||||
self.lastDiscoveryUpdate.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verbindung auf Anforderung (vom Hauptthread aufgerufen)
|
||||
|
||||
/*/// Verbindet ein Gerät, das sonst unverbunden bleibt – solange seine
|
||||
/// Ansicht offen ist.
|
||||
///
|
||||
/// Für die Kühlbox: Jede Verbindung meldet sich an ihrem Display an, manche
|
||||
/// Modelle verlangen dabei sogar einen Tastendruck. Dauerhaft verbunden zu
|
||||
/// sein ist dort also nicht unsichtbar, sondern lästig.
|
||||
func beginSession(for device: ConfiguredDevice) {
|
||||
guard device.role.connectsOnDemand else { return }
|
||||
let deviceID = device.id
|
||||
queue.async {
|
||||
self.requestedSessions.insert(deviceID)
|
||||
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
|
||||
// Der Wunsch des Benutzers hebt die Wartesperre auf.
|
||||
self.clearBackOff(entry.peripheralID)
|
||||
self.connectIfNeeded(entry)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gibt das Gerät wieder frei. Der zuletzt gemeldete Stand bleibt in der
|
||||
/// Übersicht stehen, damit dort nicht plötzlich eine Lücke klafft.
|
||||
func endSession(for device: ConfiguredDevice) {
|
||||
guard device.role.connectsOnDemand else { return }
|
||||
let deviceID = device.id
|
||||
queue.async {
|
||||
self.requestedSessions.remove(deviceID)
|
||||
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
|
||||
self.bmsSessions[entry.peripheralID]?.stop()
|
||||
self.bmsSessions[entry.peripheralID] = nil
|
||||
self.disconnect(entry.peripheralID)
|
||||
self.publish {
|
||||
self.linkStates[deviceID] = .idle
|
||||
self.showLastKnownFridgeSettings(for: deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt an die Stelle der Live-Werte den zuletzt gestellten Stand.
|
||||
///
|
||||
/// Nur auf dem Hauptthread aufrufen: greift auf den Speicher zu.
|
||||
private func showLastKnownFridgeSettings(for deviceID: UUID) {
|
||||
guard let settings = store.lastFridgeSettings(for: deviceID) else {
|
||||
snapshots[deviceID] = nil
|
||||
return
|
||||
}
|
||||
snapshots[deviceID] = settings.snapshot(deviceID: deviceID)
|
||||
// Der Zustand bleibt stehen, damit sich die Box beim nächsten Öffnen
|
||||
// sofort bedienen lässt – die Schalter zeigen dann den letzten Stand.
|
||||
}*/
|
||||
|
||||
// MARK: - Kühlbox steuern (vom Hauptthread aufgerufen)
|
||||
|
||||
/*func updateFridgeZoneMode(for device: ConfiguredDevice) {
|
||||
let deviceID = device.id, mode = device.fridgeZoneMode
|
||||
queue.async {
|
||||
guard let session = self.session(for: deviceID) else { return }
|
||||
session.fridgeZoneMode = mode
|
||||
let state = session.alpicoolState
|
||||
self.publish { self.fridgeStates[deviceID] = state }
|
||||
}
|
||||
}
|
||||
|
||||
func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) {
|
||||
sendFridgeSettings(for: deviceID) { _ in AlpicoolState.setTarget(zone: zone, to: celsius) }
|
||||
}
|
||||
|
||||
func setFridgePower(_ on: Bool, for deviceID: UUID) {
|
||||
sendFridgeSettings(for: deviceID) { $0.settingsCommand(poweredOn: on) }
|
||||
}
|
||||
|
||||
func setFridgeEco(_ eco: Bool, for deviceID: UUID) {
|
||||
sendFridgeSettings(for: deviceID) { $0.settingsCommand(eco: eco) }
|
||||
}
|
||||
|
||||
func setFridgeLock(_ locked: Bool, for deviceID: UUID) {
|
||||
sendFridgeSettings(for: deviceID) { $0.settingsCommand(locked: locked) }
|
||||
}
|
||||
|
||||
/// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut –
|
||||
/// und zwar auf der Funk-Queue, wo dieser Zustand lebt.
|
||||
///
|
||||
/// Fehlt die Verbindung gerade, wird der Befehl aufgehoben und ein Versuch
|
||||
/// angestossen. Ein Tippen darf nicht daran scheitern, dass die Box sich
|
||||
/// zwei Sekunden vorher abgemeldet hat.
|
||||
private func sendFridgeSettings(for deviceID: UUID,
|
||||
build: @escaping (AlpicoolState) -> Data?) {
|
||||
queue.async {
|
||||
guard let device = self.managed.values.first(where: { $0.id == deviceID })
|
||||
else { return }
|
||||
|
||||
if let session = self.bmsSessions[device.peripheralID] {
|
||||
if let packet = build(session.alpicoolState) { session.sendControl(packet) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let known = self.fridgeStateCache[device.peripheralID],
|
||||
let packet = build(known) else { return }
|
||||
var waiting = self.pendingControls[device.peripheralID] ?? []
|
||||
waiting.append((packet, Date()))
|
||||
// Mehr als eine Handvoll ergibt keinen Sinn – wer mehrfach tippt,
|
||||
// meint den letzten Stand.
|
||||
if waiting.count > 4 { waiting.removeFirst(waiting.count - 4) }
|
||||
self.pendingControls[device.peripheralID] = waiting
|
||||
|
||||
// Der Wunsch des Benutzers hebt die Wartesperre auf.
|
||||
self.clearBackOff(device.peripheralID)
|
||||
self.connectIfNeeded(device)
|
||||
}
|
||||
}*/
|
||||
|
||||
/*/// Gibt weiter, was während der Trennung aufgelaufen ist.
|
||||
private func flushPendingControls(_ peripheralID: UUID, to session: BMSSession) {
|
||||
guard let waiting = pendingControls.removeValue(forKey: peripheralID) else { return }
|
||||
let fresh = waiting.filter { Date().timeIntervalSince($0.queuedAt) < controlLifetime }
|
||||
for (index, entry) in fresh.enumerated() {
|
||||
queue.asyncAfter(deadline: .now() + Double(index) * 0.4) { [weak session] in
|
||||
session?.sendControl(entry.packet)
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
/// Nach einer Änderung der Einbaulage aufrufen.
|
||||
///
|
||||
/// Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie
|
||||
/// beschreibt den Einbau, und Uhr wie Android-App finden sie dann vor,
|
||||
/// ohne dass jemand sie ein zweites Mal bestimmen muss.
|
||||
func updateSensorOrientation(for device: ConfiguredDevice) {
|
||||
let deviceID = device.id, orientation = device.sensorOrientation
|
||||
queue.async {
|
||||
guard let session = self.levelSession(for: deviceID) else { return }
|
||||
session.orientation = orientation
|
||||
session.storeOrientation(orientation)
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt die Einbaulage, die das Gerät meldet.
|
||||
///
|
||||
/// Steht dort noch nichts, meldet das Gerät nichts – dann bleibt es bei der
|
||||
/// örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen
|
||||
/// hinaufgeschrieben.
|
||||
private func adoptOrientation(_ orientation: SensorOrientation, for deviceID: UUID) {
|
||||
guard var device = store.devices.first(where: { $0.id == deviceID }),
|
||||
device.sensorOrientation != orientation else { return }
|
||||
device.sensorOrientation = orientation
|
||||
store.update(device)
|
||||
}
|
||||
|
||||
/// Setzt die aktuelle Lage des Fahrzeugs als neue Null.
|
||||
func calibrateLevel(for deviceID: UUID) {
|
||||
queue.async { self.levelSession(for: deviceID)?.calibrate() }
|
||||
}
|
||||
|
||||
/// Verwirft die Kalibrierung.
|
||||
func resetLevelCalibration(for deviceID: UUID) {
|
||||
queue.async { self.levelSession(for: deviceID)?.resetCalibration() }
|
||||
}
|
||||
|
||||
private func levelSession(for deviceID: UUID) -> LevelSession? {
|
||||
guard let entry = managed.values.first(where: { $0.id == deviceID }) else { return nil }
|
||||
return levelSessions[entry.peripheralID]
|
||||
}
|
||||
|
||||
/*private func session(for deviceID: UUID) -> BMSSession? {
|
||||
guard let entry = managed.values.first(where: { $0.id == deviceID }) else { return nil }
|
||||
return bmsSessions[entry.peripheralID]
|
||||
}*/
|
||||
|
||||
// MARK: - Ablauf auf der Funk-Queue
|
||||
|
||||
private func applyConfiguration(_ devices: [ManagedDevice]) {
|
||||
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
|
||||
|
||||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
|
||||
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
|
||||
//for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) {
|
||||
// session.stop()
|
||||
// bmsSessions[peripheralID] = nil
|
||||
// disconnect(peripheralID)
|
||||
//}
|
||||
for (peripheralID, session) in levelSessions where !wanted.contains(peripheralID) {
|
||||
session.stop()
|
||||
levelSessions[peripheralID] = nil
|
||||
disconnect(peripheralID)
|
||||
}
|
||||
// Einstellungen an bestehende Sitzungen weiterreichen.
|
||||
for device in devices {
|
||||
//bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
|
||||
levelSessions[device.peripheralID]?.orientation = device.sensorOrientation
|
||||
}
|
||||
lastHandledAdvertisement.removeAll()
|
||||
//lastDiagnosticsUpdate.removeAll()
|
||||
nextConnectAttempt.removeAll()
|
||||
connectFailures.removeAll()
|
||||
connectedSince.removeAll()
|
||||
|
||||
startScanning()
|
||||
}
|
||||
|
||||
private func disconnect(_ peripheralID: UUID) {
|
||||
guard let peripheral = connectedPeripherals[peripheralID] else { return }
|
||||
central?.cancelPeripheralConnection(peripheral)
|
||||
connectedPeripherals[peripheralID] = nil
|
||||
}
|
||||
|
||||
private func setDiscovering(_ wanted: Bool) {
|
||||
guard discovering != wanted else { return }
|
||||
discovering = wanted
|
||||
if wanted {
|
||||
startDiscoveryFlush()
|
||||
} else {
|
||||
discoveryFlushTimer?.cancel()
|
||||
discoveryFlushTimer = nil
|
||||
}
|
||||
restartScan()
|
||||
}
|
||||
|
||||
private func startScanning() {
|
||||
guard central?.state == .poweredOn else { return }
|
||||
restartScan()
|
||||
connectManagedPeripherals()
|
||||
scheduleReconnects()
|
||||
}
|
||||
|
||||
private func stopEverything() {
|
||||
central?.stopScan()
|
||||
reconnectTimer?.cancel(); reconnectTimer = nil
|
||||
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
|
||||
//for (_, session) in bmsSessions { session.stop() }
|
||||
for (_, session) in levelSessions { session.stop() }
|
||||
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
|
||||
//bmsSessions.removeAll()
|
||||
levelSessions.removeAll()
|
||||
connectedPeripherals.removeAll()
|
||||
connectedSince.removeAll()
|
||||
//pendingControls.removeAll()
|
||||
}
|
||||
|
||||
private func restartScan() {
|
||||
guard central?.state == .poweredOn else { return }
|
||||
central?.stopScan()
|
||||
// Der Neigungsmesser bewirbt seinen Dienst dauerhaft, deshalb reichen
|
||||
// auch hier Duplikate durch.
|
||||
central?.scanForPeripherals(
|
||||
withServices: nil,
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
||||
)
|
||||
}
|
||||
|
||||
private func connectManagedPeripherals() {
|
||||
guard central?.state == .poweredOn else { return }
|
||||
for device in managed.values where shouldStayConnected(device) {
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob dieses Gerät von sich aus verbunden gehalten wird. Es gibt keine
|
||||
/// Geräte mehr, die nur auf Anforderung verbunden werden – der
|
||||
/// Neigungsmesser wird immer verbunden gehalten, sobald er per GATT
|
||||
/// ansprechbar ist.
|
||||
private func shouldStayConnected(_ device: ManagedDevice) -> Bool {
|
||||
return device.transport == .connect
|
||||
}
|
||||
/*/// Ob dieses Gerät von sich aus verbunden gehalten wird.
|
||||
///
|
||||
/// Geräte auf Anforderung – die Kühlbox – nur dann, wenn ihre Ansicht
|
||||
/// gerade offen ist oder ein Stellbefehl darauf wartet.
|
||||
private func shouldStayConnected(_ device: ManagedDevice) -> Bool {
|
||||
guard device.transport == .connect else { return false }
|
||||
guard device.role.connectsOnDemand else { return true }
|
||||
return requestedSessions.contains(device.id)
|
||||
|| pendingControls[device.peripheralID]?.isEmpty == false
|
||||
}*/
|
||||
|
||||
private func connectIfNeeded(_ device: ManagedDevice) {
|
||||
if let existing = connectedPeripherals[device.peripheralID],
|
||||
existing.state == .connected || existing.state == .connecting {
|
||||
return
|
||||
}
|
||||
// Nach einem Fehlschlag eine Weile Ruhe geben, sonst wird das Gerät
|
||||
// im Sekundentakt angeklopft.
|
||||
if let earliest = nextConnectAttempt[device.peripheralID], Date() < earliest {
|
||||
return
|
||||
}
|
||||
guard let peripheral = central?
|
||||
.retrievePeripherals(withIdentifiers: [device.peripheralID]).first else {
|
||||
publish { self.linkStates[device.id] = .searching }
|
||||
return
|
||||
}
|
||||
connectedPeripherals[device.peripheralID] = peripheral
|
||||
publish { self.linkStates[device.id] = .connecting }
|
||||
central?.connect(peripheral, options: nil)
|
||||
}
|
||||
|
||||
/// Sperrt weitere Versuche für eine Weile. Der Abstand wächst mit jedem
|
||||
/// Fehlschlag, damit ein dauerhaft unerreichbares Gerät nicht endlos
|
||||
/// angeklopft wird.
|
||||
private func backOff(_ peripheralID: UUID) {
|
||||
let failures = (connectFailures[peripheralID] ?? 0) + 1
|
||||
connectFailures[peripheralID] = failures
|
||||
let delay = min(firstRetryDelay * pow(2, Double(failures - 1)), longestRetryDelay)
|
||||
nextConnectAttempt[peripheralID] = Date().addingTimeInterval(delay)
|
||||
}
|
||||
|
||||
private func clearBackOff(_ peripheralID: UUID) {
|
||||
connectFailures[peripheralID] = nil
|
||||
nextConnectAttempt[peripheralID] = nil
|
||||
}
|
||||
|
||||
/// Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig
|
||||
/// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren.
|
||||
private func scheduleReconnects() {
|
||||
reconnectTimer?.cancel()
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 15, repeating: 15)
|
||||
timer.setEventHandler { [weak self] in self?.connectManagedPeripherals() }
|
||||
timer.resume()
|
||||
reconnectTimer = timer
|
||||
}
|
||||
|
||||
private func startDiscoveryFlush() {
|
||||
discoveryFlushTimer?.cancel()
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + 1, repeating: 1)
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
// Verschwundene Geräte erst nach einer Weile fallen lassen, damit
|
||||
// ein Eintrag nicht wegen eines verpassten Pakets verschwindet.
|
||||
let cutoff = Date().addingTimeInterval(-20)
|
||||
self.pendingDiscoveries = self.pendingDiscoveries.filter { $0.value.lastSeen > cutoff }
|
||||
let current = self.pendingDiscoveries
|
||||
self.publish { self.discoveries = current }
|
||||
}
|
||||
timer.resume()
|
||||
discoveryFlushTimer = timer
|
||||
}
|
||||
|
||||
// MARK: - Auswertung (auf der Funk-Queue)
|
||||
|
||||
private func record(_ snapshot: DeviceSnapshot) {
|
||||
publish {
|
||||
self.snapshots[snapshot.deviceID] = snapshot
|
||||
guard let primary = snapshot.primaryMetric, let value = primary.value else { return }
|
||||
var samples = self.history[snapshot.deviceID] ?? []
|
||||
// Höchstens alle fünf Sekunden einen Punkt aufnehmen.
|
||||
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return }
|
||||
samples.append(HistorySample(time: snapshot.timestamp, value: value))
|
||||
if samples.count > self.historyLimit {
|
||||
samples.removeFirst(samples.count - self.historyLimit)
|
||||
}
|
||||
self.history[snapshot.deviceID] = samples
|
||||
}
|
||||
}
|
||||
|
||||
/*private func handleVictronAdvertisement(_ manufacturerData: Data,
|
||||
device: ManagedDevice,
|
||||
rssi: Int) {
|
||||
// Den unverschlüsselten Rahmen festhalten – gerade wenn der Schlüssel
|
||||
// nicht passt, ist das die einzige verwertbare Information. Der
|
||||
// Hex-String kostet mehr als die Auswertung selbst, deshalb nur
|
||||
// gelegentlich, und immer wenn noch gar nichts angezeigt werden kann.
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastDiagnosticsUpdate[device.id] ?? .distantPast)
|
||||
>= minimumDiagnosticsInterval,
|
||||
let envelope = VictronAdvertisement.envelope(from: manufacturerData) {
|
||||
lastDiagnosticsUpdate[device.id] = now
|
||||
let info = VictronDiagnostics(
|
||||
productID: envelope.productID,
|
||||
recordType: envelope.recordType,
|
||||
expectedKeyFirstByte: envelope.keyCheckByte,
|
||||
nonce: envelope.nonce,
|
||||
payloadLength: envelope.ciphertext.count,
|
||||
rawHex: manufacturerData.map { String(format: "%02X", $0) }.joined(separator: " "),
|
||||
updated: now
|
||||
)
|
||||
publish { self.diagnostics[device.id] = info }
|
||||
}
|
||||
|
||||
guard let key = device.victronKey else {
|
||||
publish { self.linkStates[device.id] = .needsKey }
|
||||
return
|
||||
}
|
||||
do {
|
||||
let snapshot = try VictronAdvertisement.decode(manufacturerData: manufacturerData,
|
||||
key: key,
|
||||
deviceID: device.id,
|
||||
rssi: rssi)
|
||||
publish { self.linkStates[device.id] = .live }
|
||||
record(snapshot)
|
||||
} catch let error as VictronAdvertisement.DecodeError {
|
||||
switch error {
|
||||
case .notVictron:
|
||||
break // Fremdes Advertisement desselben Peripherals, ignorieren.
|
||||
default:
|
||||
let message = error.localizedDescription
|
||||
publish { self.linkStates[device.id] = .failed(message) }
|
||||
}
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
publish { self.linkStates[device.id] = .failed(message) }
|
||||
}
|
||||
}*/
|
||||
|
||||
private func updateDiscovery(peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi: Int) {
|
||||
let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name
|
||||
|
||||
let existing = pendingDiscoveries[peripheral.identifier]
|
||||
var entry = existing ?? Discovery(
|
||||
id: peripheral.identifier,
|
||||
name: name,
|
||||
rssi: rssi,
|
||||
firstSeen: Date(),
|
||||
lastSeen: Date(),
|
||||
looksLikeSupported: false
|
||||
)
|
||||
// Gleitender Mittelwert über die letzten Messungen.
|
||||
entry.rssi = existing.map { ($0.rssi * 3 + rssi) / 4 } ?? rssi
|
||||
entry.lastSeen = Date()
|
||||
if let name, !name.isEmpty { entry.name = name }
|
||||
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
||||
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
|
||||
entry.looksLikeSupported = entry.isLevelSensor
|
||||
pendingDiscoveries[peripheral.identifier] = entry
|
||||
}
|
||||
/*private func updateDiscovery(peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi: Int) {
|
||||
let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
||||
let envelope = manufacturerData.flatMap { VictronAdvertisement.envelope(from: $0) }
|
||||
let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name
|
||||
|
||||
let existing = pendingDiscoveries[peripheral.identifier]
|
||||
var entry = existing ?? Discovery(
|
||||
id: peripheral.identifier,
|
||||
name: name,
|
||||
rssi: rssi,
|
||||
firstSeen: Date(),
|
||||
lastSeen: Date(),
|
||||
victronRecordType: nil,
|
||||
victronProductID: nil,
|
||||
looksLikeSupported: false
|
||||
)
|
||||
// Gleitender Mittelwert über die letzten Messungen.
|
||||
entry.rssi = existing.map { ($0.rssi * 3 + rssi) / 4 } ?? rssi
|
||||
entry.lastSeen = Date()
|
||||
if let name, !name.isEmpty { entry.name = name }
|
||||
if let envelope {
|
||||
entry.victronRecordType = envelope.recordType
|
||||
entry.victronProductID = envelope.productID
|
||||
}
|
||||
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
||||
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
|
||||
entry.looksLikeSupported = entry.isLevelSensor
|
||||
|| Self.looksLikeSupported(name: entry.name)
|
||||
pendingDiscoveries[peripheral.identifier] = entry
|
||||
}*/
|
||||
|
||||
/*/// Grobe Namensprüfung für die Vorauswahl in der Geräteliste. Nur ein
|
||||
/// Hinweis – auswählbar ist über "Alle" weiterhin jedes Gerät.
|
||||
private static func looksLikeSupported(name: String?) -> Bool {
|
||||
guard let name = name?.lowercased() else { return false }
|
||||
let needles = [
|
||||
"daly", "bms", "bulltron", "wattcycle", // Batterien
|
||||
"alpicool", "icecube", "ice cube", "fridge", "cool", // Kühlboxen
|
||||
]
|
||||
return name.hasPrefix("dl-") || name.hasPrefix("wt")
|
||||
|| needles.contains { name.contains($0) }
|
||||
}*/
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate (alles auf der Funk-Queue)
|
||||
|
||||
extension BluetoothManager: CBCentralManagerDelegate {
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
let ready: Bool, text: String
|
||||
switch central.state {
|
||||
case .poweredOn: ready = true; text = "Bereit"
|
||||
case .poweredOff: ready = false; text = "Bluetooth ist ausgeschaltet"
|
||||
case .unauthorized: ready = false; text = "Bluetooth-Zugriff wurde nicht erlaubt"
|
||||
case .unsupported: ready = false; text = "Dieses Gerät unterstützt kein Bluetooth LE"
|
||||
default: ready = false; text = "Bluetooth wird gestartet…"
|
||||
}
|
||||
publish {
|
||||
self.isBluetoothReady = ready
|
||||
self.bluetoothStatusText = text
|
||||
}
|
||||
if central.state == .poweredOn { startScanning() }
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager,
|
||||
didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi RSSI: NSNumber) {
|
||||
let rssi = RSSI.intValue
|
||||
let now = Date()
|
||||
let identifier = peripheral.identifier
|
||||
|
||||
if discovering,
|
||||
now.timeIntervalSince(lastDiscoveryUpdate[identifier] ?? .distantPast)
|
||||
>= minimumDiscoveryInterval {
|
||||
lastDiscoveryUpdate[identifier] = now
|
||||
updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi)
|
||||
}
|
||||
|
||||
guard let device = managed[identifier] else { return }
|
||||
guard now.timeIntervalSince(lastHandledAdvertisement[identifier] ?? .distantPast)
|
||||
>= minimumAdvertisementInterval else { return }
|
||||
lastHandledAdvertisement[identifier] = now
|
||||
|
||||
// Der Neigungsmesser wird per Verbindung angesprochen: gesehen, aber
|
||||
// noch nicht verbunden -> jetzt verbinden.
|
||||
if device.transport == .connect, connectedPeripherals[identifier] == nil,
|
||||
shouldStayConnected(device) {
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
/*switch device.transport {
|
||||
case .advertisement:
|
||||
guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
||||
else { return }
|
||||
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
|
||||
|
||||
case .connect:
|
||||
// Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt
|
||||
// aufbauen. Ausser bei Geräten auf Anforderung: Die Kühlbox piept
|
||||
// bei jeder Verbindung, und dass sie in Reichweite ist, ist kein
|
||||
// Grund, sie anzufassen.
|
||||
if connectedPeripherals[identifier] == nil, shouldStayConnected(device) {
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
guard let device = managed[peripheral.identifier] else { return }
|
||||
connectedPeripherals[peripheral.identifier] = peripheral
|
||||
connectedSince[peripheral.identifier] = Date()
|
||||
|
||||
if device.role == .leveling {
|
||||
let session = LevelSession(
|
||||
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 }
|
||||
},
|
||||
onLevelState: { [weak self] state in
|
||||
self?.publish { self?.levelStates[device.id] = state }
|
||||
},
|
||||
onDeviceOrientation: { [weak self] orientation in
|
||||
// Im Gerät steht, wie der Sensor eingebaut ist – für alle
|
||||
// Clients dasselbe. Also übernehmen statt überschreiben.
|
||||
self?.publish { self?.adoptOrientation(orientation, for: device.id) }
|
||||
}
|
||||
)
|
||||
session.orientation = device.sensorOrientation
|
||||
levelSessions[peripheral.identifier] = session
|
||||
session.start()
|
||||
return
|
||||
}
|
||||
|
||||
/*let session = BMSSession(
|
||||
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 }
|
||||
},
|
||||
onDiagnostics: { [weak self] info in
|
||||
self?.publish { self?.bmsDiagnostics[device.id] = info }
|
||||
}
|
||||
)
|
||||
session.onFridgeState = { [weak self] state in
|
||||
self?.fridgeStateCache[peripheral.identifier] = state
|
||||
self?.publish {
|
||||
self?.fridgeStates[device.id] = state
|
||||
// Für die Übersicht aufheben: Sie zeigt den Stand auch dann,
|
||||
// wenn längst niemand mehr verbunden ist.
|
||||
if let settings = state.settings {
|
||||
self?.store.setLastFridgeSettings(settings, for: device.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
session.fridgeZoneMode = device.fridgeZoneMode
|
||||
bmsSessions[peripheral.identifier] = session
|
||||
session.start()
|
||||
flushPendingControls(peripheral.identifier, to: session)*/
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager,
|
||||
didFailToConnect peripheral: CBPeripheral,
|
||||
error: Error?) {
|
||||
if let device = managed[peripheral.identifier] {
|
||||
let message = error?.localizedDescription ?? "Verbindung fehlgeschlagen"
|
||||
publish { self.linkStates[device.id] = .failed(message) }
|
||||
}
|
||||
connectedPeripherals[peripheral.identifier] = nil
|
||||
backOff(peripheral.identifier)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager,
|
||||
didDisconnectPeripheral peripheral: CBPeripheral,
|
||||
error: Error?) {
|
||||
//bmsSessions[peripheral.identifier]?.handleDisconnect()
|
||||
//bmsSessions[peripheral.identifier] = nil
|
||||
levelSessions[peripheral.identifier]?.handleDisconnect()
|
||||
levelSessions[peripheral.identifier] = nil
|
||||
connectedPeripherals[peripheral.identifier] = nil
|
||||
|
||||
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
||||
.map { Date().timeIntervalSince($0) } ?? 0
|
||||
|
||||
if let device = managed[peripheral.identifier] {
|
||||
publish { self.linkStates[device.id] = .searching }
|
||||
if lifetime >= stableConnection {
|
||||
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||||
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
|
||||
// Gerät minutenlang nicht bedienbar.
|
||||
clearBackOff(peripheral.identifier)
|
||||
nextConnectAttempt[peripheral.identifier] =
|
||||
Date().addingTimeInterval(quickRetryDelay)
|
||||
} else {
|
||||
// Sofort wieder abgebrochen: das zählt wie ein Fehlschlag. So
|
||||
// entsteht kein Wechselspiel aus Verbinden und Trennen, wenn
|
||||
// etwa eine andere App das Gerät belegt.
|
||||
backOff(peripheral.identifier)
|
||||
}
|
||||
}
|
||||
/*if let device = managed[peripheral.identifier], !shouldStayConnected(device) {
|
||||
// Auf Anforderung verbunden und niemand schaut mehr hin: nicht
|
||||
// wieder anklopfen, sondern den letzten Stand stehenlassen.
|
||||
let deviceID = device.id
|
||||
publish {
|
||||
self.linkStates[deviceID] = .idle
|
||||
self.showLastKnownFridgeSettings(for: deviceID)
|
||||
}
|
||||
} else if let device = managed[peripheral.identifier] {
|
||||
publish { self.linkStates[device.id] = .searching }
|
||||
if lifetime >= stableConnection {
|
||||
clearBackOff(peripheral.identifier)
|
||||
nextConnectAttempt[peripheral.identifier] =
|
||||
Date().addingTimeInterval(quickRetryDelay)
|
||||
} else {
|
||||
backOff(peripheral.identifier)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CamperMonitorApp: App {
|
||||
@State private var store: DeviceStore
|
||||
@State private var bluetooth: BluetoothManager
|
||||
@State private var watch: PhoneWatchLink
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
let store = DeviceStore()
|
||||
let bluetooth = BluetoothManager(store: store)
|
||||
_store = State(initialValue: store)
|
||||
_bluetooth = State(initialValue: bluetooth)
|
||||
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
DashboardView()
|
||||
.environment(store)
|
||||
.environment(bluetooth)
|
||||
.environment(watch)
|
||||
.task { watch.activate() }
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
|
||||
// werden, also Funk sparen und beim Zurückkommen neu starten.
|
||||
//
|
||||
// Ausnahme: Solange jemand auf die Uhr schaut, läuft es weiter. Beim
|
||||
// Rangieren liegt das iPhone in der Halterung mit dunklem Bildschirm
|
||||
// – hörte das Funkgerät dann auf, wäre die Anzeige am Handgelenk
|
||||
// genau dann tot, wenn sie gebraucht wird. Die verbundenen Geräte
|
||||
// (Neigungsmesser, BMS, Kühlbox) liefern im Hintergrund weiter; die
|
||||
// Victron-Werbedaten nicht, dafür müsste ohne Filter gesucht werden.
|
||||
watch.isAppInBackground = phase != .active
|
||||
switch phase {
|
||||
case .active:
|
||||
bluetooth.start()
|
||||
case .background:
|
||||
if !watch.wantsLiveUpdates { bluetooth.stop() }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
|
||||
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
|
||||
/// Fahrzeug und ohne Bluetooth prüfen lassen.
|
||||
///
|
||||
/// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist:
|
||||
///
|
||||
/// xcrun simctl launch --terminate-running-process \
|
||||
/// booted de.fritob.CamperMonitor
|
||||
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
||||
///
|
||||
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
|
||||
enum DemoData {
|
||||
|
||||
static var isEnabled: Bool {
|
||||
#if DEBUG
|
||||
return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen",
|
||||
symbol: "box.truck",
|
||||
trackWidth: 1.85, wheelbase: 3.50)
|
||||
static let secondProfile = Profile(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!,
|
||||
name: "Wohnwagen", symbol: "car.side")
|
||||
|
||||
static var profiles: [Profile] { [mainProfile, secondProfile] }
|
||||
|
||||
/* static let booster = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000B0")!,
|
||||
name: "Ladebooster", role: .chargeBooster, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000B1")!)
|
||||
|
||||
static let solar = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-000000000050")!,
|
||||
name: "Solar Dach", role: .solarCharger, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-000000000051")!)
|
||||
|
||||
static let battery = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!,
|
||||
name: "Bulltron 200 Ah", role: .bms, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!)
|
||||
|
||||
static let caravanSolar = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C3")!,
|
||||
name: "Solar Wohnwagen", role: .solarCharger, profileID: secondProfile.id,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!)
|
||||
|
||||
static let fridge = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!,
|
||||
name: "Kühlbox", role: .fridge, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000F1")!)
|
||||
*/
|
||||
static let level = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!,
|
||||
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!)
|
||||
|
||||
static var devices: [ConfiguredDevice] {
|
||||
[/*solar, booster, battery, fridge, */level/*, caravanSolar*/]
|
||||
}
|
||||
|
||||
/// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
|
||||
static var levelState: LevelState {
|
||||
var state = LevelState()
|
||||
state.pitch = 1.8
|
||||
state.roll = -0.9
|
||||
state.pitchOffset = 0.4
|
||||
state.rollOffset = -0.2
|
||||
return state
|
||||
}
|
||||
|
||||
/* /// Zustand der Demo-Kühlbox: eingeschaltet, Eco, Soll 4 °C, ist 6 °C.
|
||||
static var fridgeState: AlpicoolState {
|
||||
var state = AlpicoolState()
|
||||
state.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [
|
||||
0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07,
|
||||
]))
|
||||
return state
|
||||
}
|
||||
|
||||
static func snapshots() -> [DeviceSnapshot] {
|
||||
var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58)
|
||||
solarSnapshot.state = "Konstantspannung (Absorption)"
|
||||
solarSnapshot.metrics = [
|
||||
Metric("pv_power", "PV-Leistung", 284, unit: "W", precision: 0, primary: true),
|
||||
Metric("battery_power", "Ladeleistung", 262, unit: "W", precision: 0),
|
||||
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
|
||||
Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1),
|
||||
Metric("yield_today", "Ertrag heute", 1.34, unit: "kWh", precision: 2),
|
||||
]
|
||||
|
||||
var boosterSnapshot = DeviceSnapshot(deviceID: booster.id, timestamp: Date(), rssi: -71)
|
||||
boosterSnapshot.state = "Aus"
|
||||
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
|
||||
boosterSnapshot.metrics = [
|
||||
Metric("output_voltage", "Ausgang (Aufbaubatterie)", 14.09, unit: "V", precision: 2, primary: true),
|
||||
Metric("input_voltage", "Eingang (Starterbatterie)", 12.42, unit: "V", precision: 2),
|
||||
]
|
||||
|
||||
let cells = [3.412, 3.418, 3.409, 3.421]
|
||||
var batterySnapshot = DeviceSnapshot(deviceID: battery.id, timestamp: Date(), rssi: -64)
|
||||
batterySnapshot.state = "Lädt"
|
||||
batterySnapshot.cellVoltages = cells
|
||||
batterySnapshot.temperatures = [21, 22]
|
||||
batterySnapshot.metrics = [
|
||||
Metric("soc", "Ladezustand", 78.4, unit: "%", precision: 1, primary: true),
|
||||
Metric("voltage", "Spannung", 13.66, unit: "V", precision: 2),
|
||||
Metric("current", "Strom", 18.6, unit: "A", precision: 1),
|
||||
Metric("power", "Leistung", 254, unit: "W", precision: 0),
|
||||
Metric("capacity", "Restkapazität", 156.8, unit: "Ah", precision: 1),
|
||||
Metric("cell_delta", "Zell-Differenz", 12, unit: "mV", precision: 0),
|
||||
Metric("cell_max", "Höchste Zelle (Zelle 4)", 3.421, unit: "V", precision: 3),
|
||||
Metric("cell_min", "Niedrigste Zelle (Zelle 3)", 3.409, unit: "V", precision: 3),
|
||||
Metric("temp_max", "Temperatur", 22, unit: "°C", precision: 0),
|
||||
Metric("cycles", "Ladezyklen", 143, unit: "", precision: 0),
|
||||
]
|
||||
|
||||
var caravanSnapshot = DeviceSnapshot(deviceID: caravanSolar.id, timestamp: Date(), rssi: -77)
|
||||
caravanSnapshot.state = "Erhaltung (Float)"
|
||||
caravanSnapshot.metrics = [
|
||||
Metric("pv_power", "PV-Leistung", 62, unit: "W", precision: 0, primary: true),
|
||||
Metric("battery_voltage", "Batteriespannung", 13.62, unit: "V", precision: 2),
|
||||
Metric("battery_current", "Ladestrom", 4.4, unit: "A", precision: 1),
|
||||
]
|
||||
|
||||
return [solarSnapshot, boosterSnapshot, batterySnapshot,
|
||||
fridgeState.snapshot(deviceID: fridge.id, rssi: -66),
|
||||
levelState.snapshot(deviceID: level.id, rssi: -70),
|
||||
caravanSnapshot]
|
||||
}
|
||||
|
||||
/// Ein paar Stunden Verlauf, damit die Grafik etwas zeigt.
|
||||
static func history(for deviceID: UUID, around value: Double) -> [HistorySample] {
|
||||
let now = Date()
|
||||
return (0..<80).reversed().map { step in
|
||||
let t = Double(step)
|
||||
let wave = sin(t / 9) * value * 0.18 + cos(t / 23) * value * 0.07
|
||||
return HistorySample(time: now.addingTimeInterval(-t * 60),
|
||||
value: max(0, value + wave))
|
||||
}
|
||||
}*/
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Verwaltet die Fahrzeugprofile, die darin eingerichteten Geräte und deren
|
||||
/// Schlüssel.
|
||||
@Observable
|
||||
final class DeviceStore {
|
||||
|
||||
private static let devicesKey = "configuredDevices"
|
||||
private static let profilesKey = "profiles"
|
||||
private static let activeProfileKey = "activeProfileID"
|
||||
/// Hierhin wird gerettet, was sich nicht lesen liess – siehe `loadDevices`.
|
||||
private static let unreadableDevicesKey = "configuredDevices.unreadable"
|
||||
private static let fridgeSettingsKey = "lastFridgeSettings"
|
||||
|
||||
/// Alle Geräte über alle Profile hinweg.
|
||||
private(set) var devices: [ConfiguredDevice] = []
|
||||
private(set) var profiles: [Profile] = []
|
||||
|
||||
private(set) var activeProfileID: UUID = Profile.defaultID
|
||||
|
||||
/// Ob beim Start Geräte im Speicher lagen, die sich nicht lesen liessen.
|
||||
/// Ihre Rohdaten sind aufgehoben, statt beim nächsten Speichern
|
||||
/// überschrieben zu werden.
|
||||
private(set) var hasUnreadableDevices = false
|
||||
|
||||
/// Zwischenspeicher, damit nicht bei jedem Advertisement die Keychain
|
||||
/// befragt wird – das passiert bis zu mehrmals pro Sekunde.
|
||||
@ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:]
|
||||
|
||||
init() {
|
||||
if DemoData.isEnabled {
|
||||
profiles = DemoData.profiles
|
||||
devices = DemoData.devices
|
||||
activeProfileID = DemoData.profiles[0].id
|
||||
return
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Profile
|
||||
|
||||
var activeProfile: Profile? {
|
||||
profiles.first { $0.id == activeProfileID }
|
||||
}
|
||||
|
||||
/// Die Geräte des gewählten Fahrzeugs.
|
||||
var activeDevices: [ConfiguredDevice] {
|
||||
devices.filter { $0.profileID == activeProfileID }
|
||||
}
|
||||
|
||||
var hasMultipleProfiles: Bool { profiles.count > 1 }
|
||||
|
||||
func selectProfile(_ profile: Profile) {
|
||||
guard profile.id != activeProfileID else { return }
|
||||
activeProfileID = profile.id
|
||||
save()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addProfile(named name: String, symbol: String = "box.truck") -> Profile {
|
||||
let profile = Profile(name: name, symbol: symbol)
|
||||
profiles.append(profile)
|
||||
save()
|
||||
return profile
|
||||
}
|
||||
|
||||
func update(_ profile: Profile) {
|
||||
guard let index = profiles.firstIndex(where: { $0.id == profile.id }) else { return }
|
||||
profiles[index] = profile
|
||||
save()
|
||||
}
|
||||
|
||||
/// Entfernt ein Profil samt seiner Geräte und Schlüssel. Das letzte Profil
|
||||
/// bleibt bestehen – ohne Profil hätte die App keinen Ort für Geräte.
|
||||
func removeProfile(_ profile: Profile) {
|
||||
guard profiles.count > 1 else { return }
|
||||
for device in devices where device.profileID == profile.id {
|
||||
KeychainStore.setKey(nil, for: device.id)
|
||||
keyCache[device.id] = nil
|
||||
}
|
||||
devices.removeAll { $0.profileID == profile.id }
|
||||
profiles.removeAll { $0.id == profile.id }
|
||||
if activeProfileID == profile.id, let first = profiles.first {
|
||||
activeProfileID = first.id
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
func deviceCount(in profile: Profile) -> Int {
|
||||
devices.count { $0.profileID == profile.id }
|
||||
}
|
||||
|
||||
// MARK: - Geräte
|
||||
|
||||
/// Legt ein Gerät im gerade gewählten Profil an.
|
||||
func add(_ device: ConfiguredDevice, victronKey: String? = nil) {
|
||||
devices.append(device)
|
||||
if let victronKey {
|
||||
setVictronKey(victronKey, for: device.id)
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
func update(_ device: ConfiguredDevice) {
|
||||
guard let index = devices.firstIndex(where: { $0.id == device.id }) else { return }
|
||||
devices[index] = device
|
||||
save()
|
||||
}
|
||||
|
||||
func remove(_ device: ConfiguredDevice) {
|
||||
devices.removeAll { $0.id == device.id }
|
||||
KeychainStore.setKey(nil, for: device.id)
|
||||
keyCache[device.id] = nil
|
||||
save()
|
||||
}
|
||||
|
||||
/// Nur im aktiven Profil suchen: die Geräte des anderen Fahrzeugs sollen
|
||||
/// weder gelesen noch verbunden werden.
|
||||
func device(withPeripheralID id: UUID) -> ConfiguredDevice? {
|
||||
activeDevices.first { $0.peripheralID == id }
|
||||
}
|
||||
|
||||
// MARK: - Victron-Schlüssel
|
||||
|
||||
func victronKeyText(for deviceID: UUID) -> String? {
|
||||
KeychainStore.key(for: deviceID)
|
||||
}
|
||||
|
||||
func setVictronKey(_ hex: String?, for deviceID: UUID) {
|
||||
let trimmed = hex?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = (trimmed?.isEmpty ?? true) ? nil : trimmed
|
||||
KeychainStore.setKey(value, for: deviceID)
|
||||
keyCache[deviceID] = value?.hexBytes
|
||||
}
|
||||
|
||||
/// Der Schlüssel als Bytes, oder nil wenn keiner hinterlegt bzw. der
|
||||
/// hinterlegte keine 16 Byte lang ist.
|
||||
func victronKeyBytes(for deviceID: UUID) -> [UInt8]? {
|
||||
if let cached = keyCache[deviceID] { return cached }
|
||||
guard let bytes = KeychainStore.key(for: deviceID)?.hexBytes, bytes.count == 16 else {
|
||||
return nil
|
||||
}
|
||||
keyCache[deviceID] = bytes
|
||||
return bytes
|
||||
}
|
||||
|
||||
// MARK: - Zuletzt bekannter Stand der Kühlbox
|
||||
/*
|
||||
/// Die Kühlbox wird nur verbunden, während ihre Ansicht offen ist. Damit
|
||||
/// die Übersicht trotzdem etwas zeigt, wird der zuletzt gemeldete Stand
|
||||
/// aufgehoben – die Einstellungen, nicht die Messwerte.
|
||||
func lastFridgeSettings(for deviceID: UUID) -> FridgeSettings? {
|
||||
allFridgeSettings()[deviceID.uuidString]
|
||||
}
|
||||
|
||||
/// Der Zeitstempel bedeutet „zuletzt **geändert**“, nicht „zuletzt
|
||||
/// gesehen“: Sonst wanderte er im Sekundentakt und die Übersicht behauptete
|
||||
/// Frische, wo sich nichts getan hat.
|
||||
func setLastFridgeSettings(_ settings: FridgeSettings, for deviceID: UUID) {
|
||||
var all = allFridgeSettings()
|
||||
if var existing = all[deviceID.uuidString] {
|
||||
existing.updated = settings.updated
|
||||
guard existing != settings else { return }
|
||||
}
|
||||
all[deviceID.uuidString] = settings
|
||||
guard let data = try? JSONEncoder().encode(all) else { return }
|
||||
UserDefaults.standard.set(data, forKey: Self.fridgeSettingsKey)
|
||||
}
|
||||
|
||||
private func allFridgeSettings() -> [String: FridgeSettings] {
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.fridgeSettingsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: FridgeSettings].self, from: data)
|
||||
else { return [:] }
|
||||
return decoded
|
||||
}*/
|
||||
|
||||
// MARK: - Persistenz
|
||||
|
||||
private func load() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let data = defaults.data(forKey: Self.profilesKey),
|
||||
let decoded = try? JSONDecoder().decode([Profile].self, from: data),
|
||||
!decoded.isEmpty {
|
||||
profiles = decoded
|
||||
} else {
|
||||
// Erster Start oder Update von einer Version ohne Profile.
|
||||
profiles = [.initial]
|
||||
}
|
||||
|
||||
if let data = defaults.data(forKey: Self.devicesKey) {
|
||||
devices = loadDevices(from: data)
|
||||
}
|
||||
|
||||
if let raw = defaults.string(forKey: Self.activeProfileKey),
|
||||
let id = UUID(uuidString: raw),
|
||||
profiles.contains(where: { $0.id == id }) {
|
||||
activeProfileID = id
|
||||
} else {
|
||||
activeProfileID = profiles[0].id
|
||||
}
|
||||
|
||||
// Geräte, deren Profil nicht mehr existiert, wären sonst unerreichbar.
|
||||
let known = Set(profiles.map(\.id))
|
||||
for index in devices.indices where !known.contains(devices[index].profileID) {
|
||||
devices[index].profileID = profiles[0].id
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest die Geräteliste so verlustarm wie möglich.
|
||||
///
|
||||
/// Der einfache Weg – die ganze Liste auf einmal – scheitert vollständig,
|
||||
/// sobald ein einziges Gerät nicht lesbar ist: etwa weil eine neuere
|
||||
/// Fassung der App ein Feld hinzugefügt hat, das die ältere nicht kennt,
|
||||
/// oder umgekehrt. Die Liste wäre dann leer, und das nächste Speichern
|
||||
/// schriebe diese Leere über den Bestand. Genau so gehen Einrichtungen
|
||||
/// verloren, ohne dass jemand etwas löscht.
|
||||
///
|
||||
/// Deshalb zweistufig: erst die ganze Liste, und wenn das misslingt, jedes
|
||||
/// Gerät für sich. Was dabei übrig bleibt, wird behalten; die Rohdaten
|
||||
/// wandern zusätzlich in einen eigenen Schlüssel, damit sich der Bestand
|
||||
/// später noch untersuchen lässt.
|
||||
private func loadDevices(from data: Data) -> [ConfiguredDevice] {
|
||||
let decoder = JSONDecoder()
|
||||
if let decoded = try? decoder.decode([ConfiguredDevice].self, from: data) {
|
||||
return decoded
|
||||
}
|
||||
|
||||
let salvaged = (try? decoder.decode([Salvage].self, from: data))?
|
||||
.compactMap(\.device) ?? []
|
||||
UserDefaults.standard.set(data, forKey: Self.unreadableDevicesKey)
|
||||
hasUnreadableDevices = true
|
||||
return salvaged
|
||||
}
|
||||
|
||||
/// Hülle, die ein einzelnes unlesbares Gerät verschluckt, statt die ganze
|
||||
/// Liste scheitern zu lassen.
|
||||
private struct Salvage: Decodable {
|
||||
let device: ConfiguredDevice?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
device = try? ConfiguredDevice(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let defaults = UserDefaults.standard
|
||||
if let data = try? JSONEncoder().encode(devices) {
|
||||
defaults.set(data, forKey: Self.devicesKey)
|
||||
}
|
||||
if let data = try? JSONEncoder().encode(profiles) {
|
||||
defaults.set(data, forKey: Self.profilesKey)
|
||||
}
|
||||
defaults.set(activeProfileID.uuidString, forKey: Self.activeProfileKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Ablage für die Victron-Verschlüsselungsschlüssel. Die gehören nicht in die
|
||||
/// UserDefaults, deshalb Keychain.
|
||||
enum KeychainStore {
|
||||
|
||||
private static let service = "de.fritob.CamperMonitor.victronKeys"
|
||||
|
||||
static func setKey(_ hex: String?, for deviceID: UUID) {
|
||||
let account = deviceID.uuidString
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
guard let hex, let data = hex.data(using: .utf8) else { return }
|
||||
query[kSecValueData as String] = data
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func key(for deviceID: UUID) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: deviceID.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// Wandelt einen Hex-String in Bytes. Leerzeichen, Doppelpunkte und ein
|
||||
/// führendes "0x" werden ignoriert, damit sich der Schlüssel aus
|
||||
/// VictronConnect einfach einfügen lässt.
|
||||
var hexBytes: [UInt8]? {
|
||||
var cleaned = self.lowercased()
|
||||
.replacingOccurrences(of: "0x", with: "")
|
||||
.filter { $0.isHexDigit }
|
||||
guard !cleaned.isEmpty, cleaned.count % 2 == 0 else { return nil }
|
||||
var bytes: [UInt8] = []
|
||||
bytes.reserveCapacity(cleaned.count / 2)
|
||||
while !cleaned.isEmpty {
|
||||
let pair = String(cleaned.prefix(2))
|
||||
cleaned.removeFirst(2)
|
||||
guard let byte = UInt8(pair, radix: 16) else { return nil }
|
||||
bytes.append(byte)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Einrichten eines neuen Geräts: scannen, auswählen, benennen.
|
||||
struct AddDeviceView: View {
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selected: Discovery?
|
||||
/// Auf einem Stellplatz sind dutzende fremde Geräte in Reichweite.
|
||||
@State private var showsAllDevices = false
|
||||
|
||||
/// Nur Geräte anzeigen, die in den letzten Sekunden zu hören waren –
|
||||
/// sonst füllt sich die Liste in Wohnmobilparks endlos.
|
||||
private var visibleDiscoveries: [Discovery] {
|
||||
// Bewusst nicht nach Signalstärke sortieren: die schwankt im
|
||||
// Sekundentakt und die Liste würde unter dem Finger springen.
|
||||
bluetooth.discoveries.values
|
||||
.filter { showsAllDevices || $0.looksLikeSupported }
|
||||
.sorted { lhs, rhs in
|
||||
//if lhs.isVictron != rhs.isVictron { return lhs.isVictron }
|
||||
if lhs.looksLikeSupported != rhs.looksLikeSupported { return lhs.looksLikeSupported }
|
||||
return lhs.firstSeen < rhs.firstSeen
|
||||
}
|
||||
}
|
||||
|
||||
private var alreadyAdded: Set<UUID> {
|
||||
Set(store.activeDevices.map(\.peripheralID))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Picker("Anzeige", selection: $showsAllDevices) {
|
||||
Text("Passende Geräte").tag(false)
|
||||
Text("Alle").tag(true)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
Section {
|
||||
if visibleDiscoveries.isEmpty {
|
||||
Label(showsAllDevices ? "Suche…" : "Noch nichts Passendes gefunden",
|
||||
systemImage: "antenna.radiowaves.left.and.right")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(visibleDiscoveries) { discovery in
|
||||
Button {
|
||||
selected = discovery
|
||||
} label: {
|
||||
row(for: discovery)
|
||||
}
|
||||
.disabled(alreadyAdded.contains(discovery.id))
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Gefundene Geräte")
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
} footer: {
|
||||
Text("Victron-Geräte werden automatisch erkannt. Damit sie hier "
|
||||
+ "auftauchen, muss „Instant Readout“ in VictronConnect aktiv sein. "
|
||||
+ "Das BMS meldet sich meist als „DL-…“. Kühlboxen tragen oft "
|
||||
+ "einen kryptischen Namen – findest du dein Gerät nicht, auf "
|
||||
+ "„Alle“ umschalten.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Gerät für \(store.activeProfile?.name ?? "Camper")")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(item: $selected) { discovery in
|
||||
ConfigureDeviceView(discovery: discovery) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
bluetooth.clearDiscoveries()
|
||||
bluetooth.isDiscovering = true
|
||||
}
|
||||
.onDisappear {
|
||||
bluetooth.isDiscovering = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for discovery: Discovery) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName/*: discovery.isVictron ? "bolt.circle.fill"*/
|
||||
: discovery.looksLikeSupported ? "battery.100percent" : "dot.radiowaves.left.and.right")
|
||||
.font(.title3)
|
||||
.foregroundStyle(/*discovery.isVictron || */discovery.looksLikeSupported ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(discovery.displayName)
|
||||
.foregroundStyle(.primary)
|
||||
Text(discovery.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if alreadyAdded.contains(discovery.id) {
|
||||
Text("Hinzugefügt")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
SignalBars(rssi: discovery.rssi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formular für ein neu gewähltes Gerät.
|
||||
private struct ConfigureDeviceView: View {
|
||||
let discovery: Discovery
|
||||
let onSaved: () -> Void
|
||||
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name: String = ""
|
||||
@State private var role: DeviceRole = .leveling
|
||||
@State private var key: String = ""
|
||||
|
||||
//private var needsKey: Bool { role.transport == .advertisement }
|
||||
//private var keyIsValid: Bool { key.hexBytes?.count == 16 }
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty /*&& (!needsKey || key.isEmpty || keyIsValid)*/
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Gerät") {
|
||||
LabeledContent("Gefunden als", value: discovery.displayName)
|
||||
TextField("Name", text: $name)
|
||||
Picker("Art", selection: $role) {
|
||||
ForEach(DeviceRole.allCases) { role in
|
||||
Text(role.title).tag(role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*if needsKey {
|
||||
Section {
|
||||
TextField("32 Hex-Zeichen", text: $key)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
if !key.isEmpty && !keyIsValid {
|
||||
Label("Der Schlüssel muss 16 Byte (32 Hex-Zeichen) haben.",
|
||||
systemImage: "exclamationmark.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
} header: {
|
||||
Text("Verschlüsselungsschlüssel")
|
||||
} footer: {
|
||||
Text("VictronConnect → Gerät → Zahnrad → ⋮ → Produkt-Info → "
|
||||
+ "„Instant Readout“ aktivieren → Verschlüsselungsdaten anzeigen. "
|
||||
+ "Kann auch später nachgetragen werden.")
|
||||
}
|
||||
}*/
|
||||
}
|
||||
.navigationTitle("Einrichten")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Sichern", action: save).disabled(!canSave)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: prefill)
|
||||
}
|
||||
}
|
||||
|
||||
/// Aus dem Advertisement lässt sich Art und Name oft schon erraten.
|
||||
private func prefill() {
|
||||
/*if let recordType = discovery.victronRecordType {
|
||||
switch VictronAdvertisement.RecordType(rawValue: recordType) {
|
||||
case .solarCharger: role = .solarCharger
|
||||
case .dcdcConverter, .orionXS: role = .chargeBooster
|
||||
case .batteryMonitor: role = .batteryMonitor
|
||||
default: role = .solarCharger
|
||||
}
|
||||
} else*/ if discovery.isLevelSensor {
|
||||
role = .leveling
|
||||
} /*else if let name = discovery.name?.lowercased(),
|
||||
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
||||
role = .fridge
|
||||
} else if discovery.looksLikeSupported {
|
||||
role = .bms
|
||||
}*/
|
||||
// Funknamen wie "WTaEaAA25342229" taugen nicht als Anzeigename. Die
|
||||
// Art des Geräts ist der bessere Vorschlag; der Funkname steht
|
||||
// ohnehin darüber unter "Gefunden als".
|
||||
if discovery.isLevelSensor {
|
||||
name = "Nivellierung"
|
||||
} else if let advertised = discovery.name, advertised.count <= 20,
|
||||
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
||||
name = advertised
|
||||
} else {
|
||||
name = role.title
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let device = ConfiguredDevice(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
role: role,
|
||||
profileID: store.activeProfileID,
|
||||
peripheralID: discovery.id,
|
||||
advertisedName: discovery.name
|
||||
)
|
||||
store.add(device/*, victronKey: needsKey && keyIsValid ? key : nil*/)
|
||||
bluetooth.refreshConfiguration()
|
||||
dismiss()
|
||||
onSaved()
|
||||
}
|
||||
}
|
||||
|
||||
/// Signalstärke als drei Balken.
|
||||
struct SignalBars: View {
|
||||
let rssi: Int
|
||||
|
||||
private var level: Int {
|
||||
switch rssi {
|
||||
case (-60)...: return 3
|
||||
case (-75)..<(-60): return 2
|
||||
case (-90)..<(-75): return 1
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 2) {
|
||||
ForEach(1...3, id: \.self) { bar in
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(bar <= level ? Color.accentColor : Color.secondary.opacity(0.25))
|
||||
.frame(width: 3, height: CGFloat(bar) * 4 + 2)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Signalstärke \(level) von 3")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Der Ausrichtungs-Assistent: begleitet das Rangieren und sagt, ob es besser
|
||||
/// oder schlechter wird und wo es am besten stand.
|
||||
struct AlignmentAssistantView: View {
|
||||
let device: ConfiguredDevice
|
||||
let profile: Profile?
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var assistant = AlignmentAssistant()
|
||||
@State private var didAnnounceTarget = false
|
||||
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||||
|
||||
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.top, 8)
|
||||
|
||||
switch displayStyle {
|
||||
case .bubble:
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxWidth: 320)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||
}
|
||||
|
||||
if !isLive { disconnectedBanner }
|
||||
|
||||
adviceBanner
|
||||
|
||||
readings
|
||||
|
||||
if let best = assistant.best, let gain = assistant.improvementAtBest,
|
||||
let seconds = assistant.timeSinceBest {
|
||||
bestPointCard(best: best, gain: gain, seconds: seconds)
|
||||
}
|
||||
|
||||
wedgeSection
|
||||
|
||||
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
|
||||
assistant.reset()
|
||||
didAnnounceTarget = false
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Ausrichtungs-Assistent")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: state) { _, new in
|
||||
guard let pitch = new.pitch, let roll = new.roll else { return }
|
||||
assistant.add(pitch: pitch, roll: roll)
|
||||
announceIfReached()
|
||||
}
|
||||
.onAppear {
|
||||
// Den anliegenden Wert gleich übernehmen. Sonst stünde bis zur
|
||||
// nächsten Messung "Warte auf Messwerte", obwohl längst welche da
|
||||
// sind – die Ansicht wirkt dann tot.
|
||||
if let pitch = state.pitch, let roll = state.roll {
|
||||
assistant.add(pitch: pitch, roll: roll)
|
||||
}
|
||||
// Beim Rangieren schaut man immer wieder aufs Display; es darf
|
||||
// dabei nicht dunkel werden.
|
||||
#if canImport(UIKit)
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
#endif
|
||||
}
|
||||
.onDisappear {
|
||||
#if canImport(UIKit)
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||
|
||||
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
|
||||
/// Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem
|
||||
/// Wert, der längst nicht mehr gilt.
|
||||
private var disconnectedBanner: some View {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Nicht verbunden").font(.headline)
|
||||
Text("Die Anzeige steht still, bis der Neigungsmesser wieder da ist.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||
.font(.title)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var adviceBanner: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: assistant.hasReachedTarget
|
||||
? "checkmark.circle.fill" : assistant.trend.symbol)
|
||||
.font(.title)
|
||||
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
|
||||
Text(assistant.advice)
|
||||
.font(.title3.weight(.medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
|
||||
: Color(.secondarySystemGroupedBackground),
|
||||
in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var readings: some View {
|
||||
HStack(spacing: 12) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
reading("Gesamt", assistant.current?.deviation)
|
||||
}
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func bestPointCard(best: AlignmentAssistant.Sample,
|
||||
gain: Double,
|
||||
seconds: TimeInterval) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Bester Punkt", systemImage: "flag.checkered")
|
||||
.font(.headline)
|
||||
Text(String(format: "Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°).",
|
||||
seconds.rounded(), gain, best.deviation,
|
||||
assistant.current?.deviation ?? 0))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var wedgeSection: some View {
|
||||
// Gerechnet wird über alle vier Räder auf einmal. Getrennte Angaben für
|
||||
// quer und längs beschreiben dasselbe Fahrzeug und lassen sich nicht
|
||||
// getrennt ausführen: Unter „rechts" liegen zwei Räder, unter „vorne"
|
||||
// auch, und eines davon ist dasselbe.
|
||||
let lift: LevelingLift? = {
|
||||
guard let width = profile?.trackWidth, let base = profile?.wheelbase,
|
||||
let pitch = state.pitch, let roll = state.roll else { return nil }
|
||||
return LevelingLift.compute(pitch: pitch, roll: roll,
|
||||
trackWidth: width, wheelbase: base)
|
||||
}()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("Auffahrkeile", systemImage: "triangle.fill")
|
||||
.font(.headline)
|
||||
|
||||
if profile?.trackWidth == nil || profile?.wheelbase == nil {
|
||||
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
|
||||
+ "sich beim Fahrzeug hinterlegen.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let lift, !lift.isNegligible {
|
||||
WheelLiftPlan(lift: lift)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
||||
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Keine Keile nötig.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
/// Einmal spürbar melden, wenn die Waage erreicht ist – man schaut beim
|
||||
/// Rangieren nicht dauernd aufs Display.
|
||||
private func announceIfReached() {
|
||||
guard assistant.hasReachedTarget else {
|
||||
didAnnounceTarget = false
|
||||
return
|
||||
}
|
||||
guard !didAnnounceTarget else { return }
|
||||
didAnnounceTarget = true
|
||||
#if canImport(UIKit)
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
|
||||
@State private var isAddingDevice = false
|
||||
@State private var isManagingProfiles = false
|
||||
@State private var isShowingSettings = false
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 300), spacing: 16)]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
if !bluetooth.isBluetoothReady {
|
||||
statusBanner
|
||||
}
|
||||
|
||||
if store.activeDevices.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(store.activeDevices) { device in
|
||||
NavigationLink {
|
||||
DeviceDetailView(device: device)
|
||||
} label: {
|
||||
DeviceCard(
|
||||
device: device,
|
||||
snapshot: bluetooth.snapshots[device.id],
|
||||
linkState: bluetooth.linkStates[device.id] ?? .searching
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle(store.activeProfile?.name ?? "Camper")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
profileMenu
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Gerät hinzufügen", systemImage: "plus") {
|
||||
isAddingDevice = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isAddingDevice) {
|
||||
AddDeviceView()
|
||||
}
|
||||
.sheet(isPresented: $isManagingProfiles) {
|
||||
ProfilesView()
|
||||
}
|
||||
.sheet(isPresented: $isShowingSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Umschalter zwischen den Fahrzeugen.
|
||||
private var profileMenu: some View {
|
||||
Menu {
|
||||
Picker("Fahrzeug", selection: Binding(
|
||||
get: { store.activeProfileID },
|
||||
set: { id in
|
||||
guard let profile = store.profiles.first(where: { $0.id == id }) else { return }
|
||||
store.selectProfile(profile)
|
||||
bluetooth.refreshConfiguration()
|
||||
}
|
||||
)) {
|
||||
ForEach(store.profiles) { profile in
|
||||
Label(profile.name, systemImage: profile.symbol).tag(profile.id)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Fahrzeuge verwalten…", systemImage: "car.2") {
|
||||
isManagingProfiles = true
|
||||
}
|
||||
Button("Einstellungen…", systemImage: "gearshape") {
|
||||
isShowingSettings = true
|
||||
}
|
||||
} label: {
|
||||
Label(store.activeProfile?.name ?? "Fahrzeug",
|
||||
systemImage: store.activeProfile?.symbol ?? "box.truck")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusBanner: some View {
|
||||
Label(bluetooth.bluetoothStatusText, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.orange.opacity(0.15), in: .rect(cornerRadius: 12))
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right")
|
||||
} description: {
|
||||
Text("Füge \(store.activeProfile.map { "„\($0.name)“" } ?? "diesem Fahrzeug") "
|
||||
+ "den Ladebooster, den Solarladeregler und das BMS hinzu.")
|
||||
} actions: {
|
||||
Button("Gerät suchen") { isAddingDevice = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten
|
||||
/// Nebenwerte und der Verbindungszustand.
|
||||
struct DeviceCard: View {
|
||||
let device: ConfiguredDevice
|
||||
let snapshot: DeviceSnapshot?
|
||||
let linkState: DeviceLinkState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
|
||||
if isShowingLastSettings, let snapshot {
|
||||
lastSettings(for: snapshot)
|
||||
} else if let snapshot, let primary = snapshot.primaryMetric {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(primary.formatted)
|
||||
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
||||
.contentTransition(.numericText())
|
||||
Text(primary.unit)
|
||||
.font(.title3.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
|
||||
|
||||
secondaryValues(for: snapshot)
|
||||
} else {
|
||||
Text(placeholderText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
footer
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: device.role.symbol)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(device.name)
|
||||
.font(.headline)
|
||||
Text(device.role.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob hier der zuletzt gestellte Stand steht statt Messwerten.
|
||||
private var isShowingLastSettings: Bool {
|
||||
device.role.connectsOnDemand && linkState != .live
|
||||
}
|
||||
|
||||
/// Die Kachel der Kühlbox, solange sie nicht verbunden ist.
|
||||
///
|
||||
/// Hier steht kein Messwert, sondern was zuletzt eingestellt war – also
|
||||
/// gehört „Soll“ dazu. Ohne das läse man die Zahl als Innentemperatur, und
|
||||
/// genau dieser Irrtum wäre teuer.
|
||||
private func lastSettings(for snapshot: DeviceSnapshot) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(snapshot.metrics.count > 1 ? "Soll links" : "Soll")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(snapshot.primaryMetric?.formatted ?? "–")
|
||||
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
||||
Text(snapshot.primaryMetric?.unit ?? "")
|
||||
.font(.title3.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let right = snapshot.metrics.first(where: { $0.key == "target_right" }),
|
||||
right.value != nil {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Soll rechts")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(right.formattedWithUnit)
|
||||
.font(.title2.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
modeBadge(snapshot.state)
|
||||
}
|
||||
}
|
||||
|
||||
/// Betriebsart als Schild: Auf der Kachel ist Platz, und ob die Box läuft
|
||||
/// oder aus ist, ist die zweite Frage nach dem Sollwert.
|
||||
@ViewBuilder
|
||||
private func modeBadge(_ state: String?) -> some View {
|
||||
if let state {
|
||||
let isOff = state == "Aus"
|
||||
Text(state)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(isOff ? Color.secondary : Color.accentColor)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(isOff ? Color.secondary.opacity(0.15)
|
||||
: Color.accentColor.opacity(0.15),
|
||||
in: .rect(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
|
||||
let others = snapshot.metrics
|
||||
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
|
||||
.prefix(3)
|
||||
return HStack(spacing: 16) {
|
||||
ForEach(Array(others)) { metric in
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.monospacedDigit()
|
||||
Text(metric.label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footer: some View {
|
||||
if isShowingLastSettings, let snapshot {
|
||||
// Kein Messwert, sondern der zuletzt gestellte Stand. Das gehört
|
||||
// dazugeschrieben, sonst liest man ihn als aktuelle Temperatur.
|
||||
Text(lastSetText(snapshot))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if let fault = snapshot?.fault {
|
||||
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(2)
|
||||
} else if let state = snapshot?.state {
|
||||
// Bei "Aus" ist erst der Grund die eigentliche Information.
|
||||
let reason = snapshot?.offReasons.first
|
||||
Text(reason.map { "\(state) · \($0)" } ?? state)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if case .failed(let message) = linkState {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was unter einem Gerät steht, das nur beim Öffnen verbunden wird.
|
||||
///
|
||||
/// Der Hinweis aufs Verbinden steht vorn: Er erklärt, warum hier kein
|
||||
/// Messwert steht, und das ist die Frage, die sich zuerst stellt.
|
||||
private func lastSetText(_ snapshot: DeviceSnapshot) -> String {
|
||||
"Verbindet erst beim Öffnen · zuletzt gestellt \(relativeUpdate(snapshot.timestamp))"
|
||||
}
|
||||
|
||||
/// „vor 3 Minuten“ statt einer Uhrzeit – auf der Kachel zählt das Alter.
|
||||
private func relativeUpdate(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.locale = Locale(identifier: "de_DE")
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
private var placeholderText: String {
|
||||
switch linkState {
|
||||
case .needsKey: return "Verschlüsselungsschlüssel fehlt – im Detail eintragen."
|
||||
case .failed(let message): return message
|
||||
default:
|
||||
return device.role.connectsOnDemand
|
||||
? "Verbindet erst beim Öffnen."
|
||||
: "Warte auf Daten…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst.
|
||||
struct StatusDot: View {
|
||||
let linkState: DeviceLinkState
|
||||
let isStale: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch linkState {
|
||||
case .live: return isStale ? .orange : .green
|
||||
case .needsKey: return .orange
|
||||
case .failed: return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
if case .live = linkState, isStale { return "Veraltet" }
|
||||
return linkState.label
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
|
||||
struct DeviceDetailView: View {
|
||||
let device: ConfiguredDevice
|
||||
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var editedName = ""
|
||||
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
|
||||
/// Schlüssel auf einer eigenen Seite.
|
||||
@State private var keyInput = ""
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var didCopyReport = false
|
||||
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
||||
|
||||
/// Ob die technischen Angaben eingeblendet werden.
|
||||
///
|
||||
/// Im Alltag stören sie nur. Meldet ein Gerät aber einen Fehler oder fehlt
|
||||
/// der Schlüssel, sind sie genau das, was weiterhilft – dann werden sie
|
||||
/// unabhängig von der Einstellung gezeigt.
|
||||
private var showsTechnicalDetails: Bool {
|
||||
if showDiagnostics { return true }
|
||||
switch linkState {
|
||||
case .failed, .needsKey: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Immer der aktuelle Stand aus dem Speicher – `device` ist die
|
||||
/// Momentaufnahme beim Öffnen und veraltet nach jeder Änderung.
|
||||
private var currentDevice: ConfiguredDevice {
|
||||
store.devices.first { $0.id == device.id } ?? device
|
||||
}
|
||||
|
||||
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
||||
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||||
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
statusSection
|
||||
// if needsKeyAttention { keyPrompt }
|
||||
|
||||
// if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||||
// FridgeControls(device: currentDevice, state: fridge)
|
||||
// }
|
||||
|
||||
if currentDevice.role == .leveling {
|
||||
LevelControls(device: currentDevice,
|
||||
state: bluetooth.levelStates[device.id] ?? LevelState())
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.metrics.isEmpty {
|
||||
Section("Messwerte") {
|
||||
ForEach(snapshot.metrics) { metric in
|
||||
LabeledContent(metric.label) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
||||
Section("Verlauf – \(primary.label)") {
|
||||
Chart(samples) { sample in
|
||||
AreaMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint.opacity(0.15))
|
||||
LineMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint)
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
.chartYAxisLabel(primary.unit)
|
||||
.frame(height: 180)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
||||
cellSection(snapshot.cellVoltages)
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.info.isEmpty {
|
||||
Section("Gerät") {
|
||||
ForEach(snapshot.info) { item in
|
||||
LabeledContent(item.label, value: item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* if let snapshot, snapshot.temperatures.count > 1 {
|
||||
Section("Temperaturen") {
|
||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||
LabeledContent("Fühler \(index + 1)") {
|
||||
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if currentDevice.role.transport == .advertisement {
|
||||
if showsTechnicalDetails { diagnosticsSection }
|
||||
} else if showsTechnicalDetails {
|
||||
bmsDiagnosticsSection
|
||||
}
|
||||
|
||||
settingsSection*/
|
||||
}
|
||||
.navigationTitle(currentDevice.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onDisappear {
|
||||
saveName()
|
||||
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
||||
// Verbindung meldet sich an ihrem Display an.
|
||||
//bluetooth.endSession(for: currentDevice)
|
||||
}
|
||||
.onAppear {
|
||||
editedName = currentDevice.name
|
||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||
//bluetooth.beginSession(for: currentDevice)
|
||||
}
|
||||
.confirmationDialog("Gerät entfernen?",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible) {
|
||||
Button("Entfernen", role: .destructive) {
|
||||
store.remove(device)
|
||||
bluetooth.refreshConfiguration()
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Abschnitte
|
||||
|
||||
private var statusSection: some View {
|
||||
Section {
|
||||
LabeledContent("Verbindung") {
|
||||
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
|
||||
}
|
||||
if let state = snapshot?.state {
|
||||
LabeledContent("Zustand", value: state)
|
||||
}
|
||||
if let fault = snapshot?.fault {
|
||||
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
ForEach(snapshot?.offReasons ?? [], id: \.self) { reason in
|
||||
Label(reason, systemImage: "pause.circle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
// Solange die Werte frisch sind, sagt das Alter nichts, was der
|
||||
// Verbindungspunkt nicht schon zeigt. Erst wenn sie stehenbleiben,
|
||||
// ist es die eigentliche Nachricht.
|
||||
if let snapshot, snapshot.isStale || showsTechnicalDetails {
|
||||
LabeledContent("Aktualisiert vor") {
|
||||
Text(snapshot.timestamp, style: .relative)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// Der Empfangspegel hilft beim Suchen eines Geräts, im Alltag
|
||||
// sagt er nichts – deshalb nur bei eingeblendeter Diagnose.
|
||||
if showsTechnicalDetails, let rssi = snapshot?.rssi {
|
||||
LabeledContent("Signal", value: "\(rssi) dBm")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cellSection(_ voltages: [Double]) -> some View {
|
||||
let minimum = voltages.min() ?? 0
|
||||
let maximum = voltages.max() ?? 0
|
||||
return Section("Zellspannungen") {
|
||||
Chart(Array(voltages.enumerated()), id: \.offset) { index, voltage in
|
||||
// Kategoriale x-Achse: sonst stehen die Balken zwischen den
|
||||
// Beschriftungen statt darüber.
|
||||
BarMark(
|
||||
x: .value("Zelle", "\(index + 1)"),
|
||||
y: .value("Spannung", voltage)
|
||||
)
|
||||
.foregroundStyle(voltage == maximum ? Color.orange
|
||||
: voltage == minimum ? Color.blue : Color.accentColor)
|
||||
// Die Zellnummer als Beschriftung am Balken statt über die
|
||||
// x-Achse – die blendet Swift Charts in der Liste aus.
|
||||
// Ab neun Zellen wird es zu eng, dann ordnet die Liste zu.
|
||||
.annotation(position: .bottom, alignment: .center) {
|
||||
if voltages.count <= 8 {
|
||||
Text("\(index + 1)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis(.hidden)
|
||||
// Der interessante Bereich sind die letzten Millivolt, nicht die
|
||||
// absolute Spannung – deshalb eng um die Messwerte zoomen.
|
||||
.chartYScale(domain: (minimum - 0.05)...(maximum + 0.05))
|
||||
.chartYAxisLabel("V")
|
||||
.frame(height: 160)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
ForEach(Array(voltages.enumerated()), id: \.offset) { index, voltage in
|
||||
LabeledContent("Zelle \(index + 1)") {
|
||||
Text(String(format: "%.3f V", voltage)).monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ohne passenden Schlüssel bleibt das Gerät stumm – das ist dann keine
|
||||
/// Nebensache, sondern das Einzige, was zu tun ist.
|
||||
/*private var needsKeyAttention: Bool {
|
||||
guard currentDevice.role.transport == .advertisement else { return false }
|
||||
if keyBytesAgree == false { return true }
|
||||
// Kommen Werte an, passt der Schlüssel offensichtlich – dann ist hier
|
||||
// nichts zu tun und nichts zu melden.
|
||||
if snapshot != nil, linkState == .live { return false }
|
||||
return store.victronKeyText(for: device.id)?.hexBytes?.count != 16
|
||||
}
|
||||
|
||||
private var keyPrompt: some View {
|
||||
Section {
|
||||
NavigationLink {
|
||||
//VictronKeyView(device: currentDevice)
|
||||
} label: {
|
||||
Label(keyBytesAgree == false
|
||||
? "Schlüssel passt nicht zum Gerät"
|
||||
: "Verschlüsselungsschlüssel eintragen",
|
||||
systemImage: "key.horizontal.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
} footer: {
|
||||
Text(keyBytesAgree == false
|
||||
? "Der hinterlegte Schlüssel stammt von einem anderen Victron-Gerät."
|
||||
: "Victron-Geräte senden ihre Werte verschlüsselt. Ohne den "
|
||||
+ "Schlüssel aus VictronConnect bleibt die Anzeige leer.")
|
||||
}
|
||||
}*/
|
||||
|
||||
/// Zeigt, was das Gerät unverschlüsselt sendet. Wichtigster Wert ist das
|
||||
/// erste Schlüsselbyte: stimmt es nicht mit der Eingabe überein, gehört der
|
||||
/// Schlüssel zu einem anderen Victron-Gerät.
|
||||
/*@ViewBuilder
|
||||
private var diagnosticsSection: some View {
|
||||
if let info = bluetooth.diagnostics[device.id] {
|
||||
Section {
|
||||
LabeledContent("Datensatz", value: info.recordName)
|
||||
LabeledContent("Produkt-ID") {
|
||||
Text(info.productIDText).font(.body.monospaced())
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Rohdaten")
|
||||
Text(info.rawHex)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} header: {
|
||||
Text("Diagnose")
|
||||
} footer: {
|
||||
Text("Diese Werte sendet das Gerät unverschlüsselt mit.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// nil, solange kein vollständiger Schlüssel eingetragen ist.
|
||||
private var keyBytesAgree: Bool? {
|
||||
guard let expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte,
|
||||
let entered = keyInput.hexBytes?.first else { return nil }
|
||||
return expected == entered
|
||||
}
|
||||
|
||||
private var keyStatusText: String {
|
||||
if store.victronKeyText(for: device.id)?.hexBytes?.count != 16 { return "fehlt" }
|
||||
return keyBytesAgree == false ? "passt nicht" : "hinterlegt"
|
||||
}
|
||||
|
||||
/// Alles auf einmal, zum Weitergeben. Einzeln abzutippen ist zuviel
|
||||
/// verlangt, und gerade der Merkmalsbaum ist zu lang dafür.
|
||||
private func report(_ info: BMSDiagnostics) -> String {
|
||||
var lines = [
|
||||
"Gerät: \(currentDevice.name) (\(currentDevice.role.title))",
|
||||
"Protokoll: \(info.dialect)",
|
||||
"Verbunden: \(info.isConnected ? "ja" : "nein")",
|
||||
"Empfang abonniert: \(info.isNotifyActive ? "ja" : "nein")",
|
||||
]
|
||||
if let position = info.endpointPosition {
|
||||
lines.append("Weg: \(position.index) von \(position.total)")
|
||||
}
|
||||
if let endpoint = info.endpointLabel { lines.append("Merkmal: \(endpoint)") }
|
||||
if let isBound = info.isBound { lines.append("Angemeldet: \(isBound ? "ja" : "nein")") }
|
||||
lines.append("Gesendet: \(info.sentFrames) · empfangen: \(info.receivedBytes) Byte"
|
||||
+ " · bestätigt: \(info.confirmedWrites)")
|
||||
if let writeError = info.lastWriteError { lines.append("Schreibfehler: \(writeError)") }
|
||||
if let command = info.lastCommandHex { lines.append("Letzter Stellbefehl: \(command)") }
|
||||
if let hex = info.lastResponseHex { lines.append("Letzte Antwort: \(hex)") }
|
||||
if let payload = info.fridgePayloadHex { lines.append("Statusdaten: \(payload)") }
|
||||
if !info.gattSummary.isEmpty {
|
||||
lines.append("Merkmale:")
|
||||
lines.append(contentsOf: info.gattSummary)
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Welches Protokoll das BMS spricht und was zuletzt ankam.
|
||||
@ViewBuilder
|
||||
private var bmsDiagnosticsSection: some View {
|
||||
if let info = bluetooth.bmsDiagnostics[device.id] {
|
||||
Section {
|
||||
LabeledContent("Erkanntes Protokoll", value: info.dialect)
|
||||
if let position = info.endpointPosition {
|
||||
LabeledContent("Verbindungsweg",
|
||||
value: "\(position.index) von \(position.total)")
|
||||
}
|
||||
if let endpoint = info.endpointLabel {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Aktueller Weg")
|
||||
Text(endpoint)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent("Verbunden") {
|
||||
Label(info.isConnected ? "ja" : "nein",
|
||||
systemImage: info.isConnected ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundStyle(info.isConnected ? .green : .red)
|
||||
}
|
||||
LabeledContent("Empfang abonniert") {
|
||||
Label(info.isNotifyActive ? "ja" : "nein",
|
||||
systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundStyle(info.isNotifyActive ? .green : .orange)
|
||||
}
|
||||
if let isBound = info.isBound {
|
||||
LabeledContent("Angemeldet") {
|
||||
Label(isBound ? "ja" : "nein",
|
||||
systemImage: isBound ? "checkmark.circle" : "xmark.circle")
|
||||
.foregroundStyle(isBound ? .green : .orange)
|
||||
}
|
||||
}
|
||||
if info.confirmedWrites > 0 {
|
||||
LabeledContent("Schreibvorgänge bestätigt",
|
||||
value: "\(info.confirmedWrites)")
|
||||
}
|
||||
if let writeError = info.lastWriteError {
|
||||
LabeledContent("Letzter Schreibfehler") {
|
||||
Text(writeError)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
LabeledContent("Gesendet / empfangen",
|
||||
value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte")
|
||||
if let lastSendAt = info.lastSendAt {
|
||||
LabeledContent("Zuletzt gesendet vor") {
|
||||
Text(lastSendAt, style: .relative)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let command = info.lastCommandHex {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Letzter Stellbefehl")
|
||||
Spacer()
|
||||
if let at = info.lastCommandAt {
|
||||
Text(at, style: .relative).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(command)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
if let hex = info.lastResponseHex {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Letzte Antwort")
|
||||
Text(hex)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
if let payload = info.fridgePayloadHex {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Statusdaten der Box (\(payload.split(separator: " ").count) Byte)")
|
||||
Text(payload)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
UIPasteboard.general.string = report(info)
|
||||
didCopyReport = true
|
||||
} label: {
|
||||
Label(didCopyReport ? "Diagnose kopiert" : "Diagnose kopieren",
|
||||
systemImage: didCopyReport ? "checkmark" : "doc.on.doc")
|
||||
}
|
||||
} header: {
|
||||
Text("Diagnose")
|
||||
} footer: {
|
||||
Text("Die App probiert alle Schreib-/Empfangs-Kombinationen des Geräts "
|
||||
+ "durch und fragt auf jeder Daly (klassisch und Modbus) sowie "
|
||||
+ "JBD/Xiaoxiang an. Der „Verbindungsweg“ zählt dabei hoch. "
|
||||
+ "Bleibt „empfangen“ am Ende bei 0 Byte, nimmt das BMS auf keinem "
|
||||
+ "Weg Kommandos an; kommen Bytes an, ohne dass ein Protokoll "
|
||||
+ "erkannt wird, spricht es ein noch unbekanntes.")
|
||||
}
|
||||
|
||||
if !info.gattSummary.isEmpty {
|
||||
Section {
|
||||
Text(info.gattSummary.joined(separator: "\n"))
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
} header: {
|
||||
Text("Bluetooth-Merkmale des Geräts")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsSection: some View {
|
||||
Section("Einstellungen") {
|
||||
HStack {
|
||||
Text("Name")
|
||||
Spacer()
|
||||
// Beim Abschluss der Eingabe und beim Verlassen der Ansicht
|
||||
// gesichert – bei jedem Tastendruck zu speichern hiesse, die
|
||||
// ganze Geräteliste je Zeichen neu zu schreiben.
|
||||
TextField("Gerätename", text: $editedName)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.submitLabel(.done)
|
||||
.onSubmit(saveName)
|
||||
}
|
||||
if let advertised = currentDevice.advertisedName, !advertised.isEmpty {
|
||||
LabeledContent("Gefunden als") {
|
||||
Text(advertised).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent("Typ", value: currentDevice.role.title)
|
||||
if currentDevice.role == .fridge {
|
||||
Picker("Kühlzonen", selection: Binding(
|
||||
get: { currentDevice.fridgeZoneMode },
|
||||
set: { mode in
|
||||
var updated = currentDevice
|
||||
updated.fridgeZoneMode = mode
|
||||
store.update(updated)
|
||||
bluetooth.updateFridgeZoneMode(for: updated)
|
||||
}
|
||||
)) {
|
||||
ForEach(FridgeZoneMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
if currentDevice.role.transport == .advertisement {
|
||||
NavigationLink {
|
||||
VictronKeyView(device: currentDevice)
|
||||
} label: {
|
||||
LabeledContent("Verschlüsselung", value: keyStatusText)
|
||||
}
|
||||
}
|
||||
LabeledContent("Bluetooth-ID") {
|
||||
Text(currentDevice.peripheralID.uuidString.prefix(8) + "…")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Button("Gerät entfernen", role: .destructive) {
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
// MARK: - Aktionen
|
||||
|
||||
private func saveName() {
|
||||
let trimmed = editedName.trimmingCharacters(in: .whitespaces)
|
||||
// Ein leeres Feld beim Tippen darf den Namen nicht löschen.
|
||||
guard !trimmed.isEmpty, trimmed != currentDevice.name else { return }
|
||||
var updated = currentDevice
|
||||
updated.name = trimmed
|
||||
store.update(updated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Einrichtung des Neigungsmessers: Einbaulage und Nullpunkt.
|
||||
///
|
||||
/// Beides wird einmal eingestellt und danach kaum wieder angefasst. In der
|
||||
/// Geräteansicht standen die Knöpfe direkt unter der Libelle – ein Fehlgriff
|
||||
/// beim Ablesen verstellte dort den Nullpunkt. Deshalb liegen sie hier.
|
||||
///
|
||||
/// Die Reihenfolge ist nicht beliebig: erst muss klar sein, welche Achse des
|
||||
/// Sensors welche des Fahrzeugs ist, sonst wird der Nullpunkt auf die falsche
|
||||
/// Achse gelegt.
|
||||
struct LevelSetupView: View {
|
||||
let device: ConfiguredDevice
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@State private var showSensorSetup = false
|
||||
@State private var showResetConfirmation = false
|
||||
|
||||
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
||||
|
||||
/// Immer der aktuelle Stand – `device` veraltet, sobald der Assistent
|
||||
/// eine neue Einbaulage gespeichert hat.
|
||||
@Environment(DeviceStore.self) private var store
|
||||
private var currentDevice: ConfiguredDevice {
|
||||
store.devices.first { $0.id == device.id } ?? device
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
HStack(spacing: 24) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showSensorSetup = true
|
||||
} label: {
|
||||
LabeledContent {
|
||||
Text(currentDevice.sensorOrientation.summary)
|
||||
.font(.caption)
|
||||
} label: {
|
||||
Label("Einbaulage bestimmen",
|
||||
systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
.disabled(!state.hasReading)
|
||||
} header: {
|
||||
Text("Schritt 1 – Einbaulage")
|
||||
} footer: {
|
||||
Text("Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet er längs "
|
||||
+ "und quer vertauscht. Der Assistent klärt das durch zweimaliges "
|
||||
+ "Kippen. Danach den Nullpunkt setzen.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
bluetooth.calibrateLevel(for: device.id)
|
||||
} label: {
|
||||
Label("Aktuelle Lage als eben übernehmen", systemImage: "scope")
|
||||
}
|
||||
.disabled(!state.hasReading)
|
||||
|
||||
if !state.isKnownUncalibrated {
|
||||
Button(role: .destructive) {
|
||||
showResetConfirmation = true
|
||||
} label: {
|
||||
// Ohne das bliebe das Symbol in der Akzentfarbe, während
|
||||
// die Beschriftung rot ist.
|
||||
Label("Nullpunkt verwerfen", systemImage: "arrow.uturn.backward")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Schritt 2 – Nullpunkt")
|
||||
} footer: {
|
||||
Text(calibrationHint)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Neigungsmesser einrichten")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showSensorSetup) {
|
||||
SensorSetupView(device: currentDevice)
|
||||
}
|
||||
.confirmationDialog("Nullpunkt verwerfen?",
|
||||
isPresented: $showResetConfirmation,
|
||||
titleVisibility: .visible) {
|
||||
Button("Verwerfen", role: .destructive) {
|
||||
bluetooth.resetLevelCalibration(for: device.id)
|
||||
}
|
||||
} message: {
|
||||
Text("Die Anzeige zeigt danach wieder die Lage des Sensors.")
|
||||
}
|
||||
}
|
||||
|
||||
private var calibrationHint: String {
|
||||
if state.isCalibrated, let pitchOffset = state.pitchOffset,
|
||||
let rollOffset = state.rollOffset {
|
||||
return String(format: "Der Nullpunkt liegt bei %.1f° längs und %.1f° quer. "
|
||||
+ "Zum Neusetzen das Fahrzeug eben stellen und dann tippen.",
|
||||
pitchOffset, rollOffset)
|
||||
}
|
||||
if state.isKnownUncalibrated {
|
||||
return "Noch kein Nullpunkt gesetzt – die Anzeige zeigt die Lage des "
|
||||
+ "Sensors, nicht die des Fahrzeugs. Fahrzeug eben stellen, dann tippen."
|
||||
}
|
||||
// Ältere Firmware gibt die Offsets nicht heraus.
|
||||
return "Zum Setzen das Fahrzeug eben stellen und dann tippen. Ob schon ein "
|
||||
+ "Nullpunkt gesetzt wurde, meldet dieses Gerät nicht zurück."
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt.
|
||||
///
|
||||
/// Die Blase wandert dorthin, wo das Fahrzeug **höher** steht – so, wie sich
|
||||
/// eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die
|
||||
/// Gegenseite anheben.
|
||||
struct LevelBubble: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||||
var range: Double = 6
|
||||
|
||||
private var isLevel: Bool {
|
||||
guard let pitch, let roll else { return false }
|
||||
return abs(pitch) <= LevelState.levelTolerance
|
||||
&& abs(roll) <= LevelState.levelTolerance
|
||||
}
|
||||
|
||||
/// Grün nur, wenn es wirklich eben ist. Die Akzentfarbe der App ist selbst
|
||||
/// grün – damit sähe "schief" genauso aus wie "eben".
|
||||
private var bubbleColor: Color {
|
||||
guard let deviation = maxDeviation else { return .secondary }
|
||||
if deviation <= LevelState.levelTolerance { return .green }
|
||||
if deviation <= 2 { return .orange }
|
||||
return .red
|
||||
}
|
||||
|
||||
private var maxDeviation: Double? {
|
||||
switch (pitch, roll) {
|
||||
case let (p?, r?): return max(abs(p), abs(r))
|
||||
case let (p?, nil): return abs(p)
|
||||
case let (nil, r?): return abs(r)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let side = min(geometry.size.width, geometry.size.height)
|
||||
let radius = side / 2
|
||||
let bubble = side * 0.16
|
||||
// Die Blase darf den Rand nicht verlassen, auch bei starker Neigung.
|
||||
let travel = radius - bubble / 2 - 4
|
||||
|
||||
let offsetX = clamped(roll) / range * travel
|
||||
let offsetY = clamped(pitch) / range * travel
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.secondarySystemFill))
|
||||
Circle()
|
||||
.strokeBorder(Color.secondary.opacity(0.35), lineWidth: 1)
|
||||
|
||||
// Ringe als echter Maßstab: der innere markiert die Toleranz,
|
||||
// der mittlere zwei Grad. Ohne Maßstab sagt die Blasenlage
|
||||
// nichts darüber, wie weit es noch ist.
|
||||
let toleranceRadius = max(LevelState.levelTolerance / range * travel, bubble * 0.6)
|
||||
Circle()
|
||||
.strokeBorder(isLevel ? Color.green : Color.secondary.opacity(0.5),
|
||||
lineWidth: isLevel ? 2 : 1)
|
||||
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
|
||||
Circle()
|
||||
.strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
.frame(width: 2.0 / range * travel * 2, height: 2.0 / range * travel * 2)
|
||||
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: radius - travel, y: radius))
|
||||
path.addLine(to: CGPoint(x: radius + travel, y: radius))
|
||||
path.move(to: CGPoint(x: radius, y: radius - travel))
|
||||
path.addLine(to: CGPoint(x: radius, y: radius + travel))
|
||||
}
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
|
||||
Circle()
|
||||
.fill(bubbleColor)
|
||||
.frame(width: bubble, height: bubble)
|
||||
// Positiver Pitch heisst: das Heck steht höher, die Blase
|
||||
// wandert also nach oben – in der Ansicht nach hinten.
|
||||
.offset(x: offsetX, y: -offsetY)
|
||||
.animation(.spring(duration: 0.35), value: offsetX)
|
||||
.animation(.spring(duration: 0.35), value: offsetY)
|
||||
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
private func clamped(_ value: Double?) -> Double {
|
||||
guard let value else { return 0 }
|
||||
return min(max(value, -range), range)
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
guard let pitch, let roll else { return "Keine Messwerte" }
|
||||
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
|
||||
}
|
||||
}
|
||||
|
||||
/// Anzeige und Kalibrierung des Neigungsmessers.
|
||||
struct LevelControls: View {
|
||||
let device: ConfiguredDevice
|
||||
let state: LevelState
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@State private var showAssistant = false
|
||||
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
switch displayStyle {
|
||||
case .bubble:
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxHeight: 220)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||
}
|
||||
|
||||
if let instruction = state.instruction {
|
||||
Label(instruction,
|
||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
HStack(spacing: 24) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showAssistant = true
|
||||
} label: {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Ausrichtungs-Assistent")
|
||||
Text("Begleitet das Rangieren")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "car.side.arrowtriangle.up.arrowtriangle.down")
|
||||
}
|
||||
}
|
||||
// Der Assistent lebt von laufenden Messwerten. Ein einmal
|
||||
// empfangener Wert genügt nicht: nach einem Verbindungsabbruch
|
||||
// bliebe er stehen und die Ansicht sähe eingefroren aus.
|
||||
.disabled(!isLive || !state.hasReading)
|
||||
// Der Vollbildschirm gehört an den Knopf, nicht an den Abschnitt.
|
||||
// SwiftUI zeigt Blätter und Vollbildschirme an einer `Section`
|
||||
// nicht zuverlässig an – dasselbe hatte die Kühlbox schon.
|
||||
.fullScreenCover(isPresented: $showAssistant) {
|
||||
AlignmentAssistantView(device: device, profile: store.activeProfile)
|
||||
}
|
||||
} footer: {
|
||||
if !isLive {
|
||||
Text("Der Assistent braucht laufende Messwerte. Der Neigungsmesser "
|
||||
+ "ist gerade nicht verbunden.")
|
||||
}
|
||||
}
|
||||
|
||||
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
|
||||
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
|
||||
Section {
|
||||
NavigationLink {
|
||||
LevelSetupView(device: device)
|
||||
} label: {
|
||||
LabeledContent {
|
||||
Text(setupSummary).font(.caption)
|
||||
} label: {
|
||||
Label("Neigungsmesser einrichten", systemImage: "slider.horizontal.3")
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Einbaulage des Sensors und Nullpunkt der Anzeige.")
|
||||
}
|
||||
}
|
||||
|
||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||
|
||||
/// Was auf der Einrichtungsseite zu holen ist – der Nullpunkt zuerst, denn
|
||||
/// ohne ihn stimmt die Anzeige nicht.
|
||||
private var setupSummary: String {
|
||||
state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Fahrzeuge anlegen, umbenennen und entfernen.
|
||||
struct ProfilesView: View {
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var editing: Profile?
|
||||
@State private var pendingDeletion: Profile?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(store.profiles) { profile in
|
||||
row(for: profile)
|
||||
}
|
||||
} footer: {
|
||||
Text("Antippen wählt das Fahrzeug aus, das ⓘ öffnet Name, Symbol "
|
||||
+ "und Maße. Jedes Fahrzeug hat seine eigenen Geräte, und die "
|
||||
+ "App liest immer nur die des gewählten aus.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Fahrzeug hinzufügen", systemImage: "plus") {
|
||||
let profile = store.addProfile(named: "Camper \(store.profiles.count + 1)")
|
||||
editing = profile
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Fahrzeuge")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(item: $editing) { profile in
|
||||
ProfileEditView(profile: profile)
|
||||
}
|
||||
.confirmationDialog("Fahrzeug entfernen?",
|
||||
isPresented: .init(get: { pendingDeletion != nil },
|
||||
set: { if !$0 { pendingDeletion = nil } }),
|
||||
titleVisibility: .visible) {
|
||||
Button("Entfernen", role: .destructive) {
|
||||
if let pendingDeletion {
|
||||
store.removeProfile(pendingDeletion)
|
||||
bluetooth.refreshConfiguration()
|
||||
}
|
||||
pendingDeletion = nil
|
||||
}
|
||||
} message: {
|
||||
if let pendingDeletion {
|
||||
Text("„\(pendingDeletion.name)“ und die \(store.deviceCount(in: pendingDeletion)) "
|
||||
+ "darin eingerichteten Geräte werden gelöscht.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for profile: Profile) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(profile.name)
|
||||
Text(summary(for: profile))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: profile.symbol)
|
||||
}
|
||||
// Nur dieser Bereich wählt aus; der Knopf rechts bleibt frei.
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
store.selectProfile(profile)
|
||||
bluetooth.refreshConfiguration()
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if profile.id == store.activeProfileID {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
// Zum Bearbeiten reichte bisher nur das Wischen - das findet
|
||||
// niemand, erst recht nicht für die Fahrzeugmaße.
|
||||
Button {
|
||||
editing = profile
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.title3)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel("\(profile.name) bearbeiten")
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
// Das letzte Fahrzeug muss bleiben, sonst hätten Geräte keinen Ort.
|
||||
if store.hasMultipleProfiles {
|
||||
Button("Entfernen", systemImage: "trash", role: .destructive) {
|
||||
pendingDeletion = profile
|
||||
}
|
||||
}
|
||||
Button("Bearbeiten", systemImage: "pencil") {
|
||||
editing = profile
|
||||
}
|
||||
.tint(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
private func summary(for profile: Profile) -> String {
|
||||
let count = store.deviceCount(in: profile)
|
||||
var parts = [count == 1 ? "1 Gerät" : "\(count) Geräte"]
|
||||
if profile.trackWidth != nil || profile.wheelbase != nil {
|
||||
parts.append("Maße hinterlegt")
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Name, Symbol und Maße eines Fahrzeugs ändern.
|
||||
private struct ProfileEditView: View {
|
||||
|
||||
/// Nimmt Komma wie Punkt an – auf einer deutschen Tastatur liegt das Komma
|
||||
/// näher. Unplausible Werte werden verworfen, damit die Keilhöhe nicht
|
||||
/// stillschweigend Unsinn ergibt.
|
||||
static func metres(from text: String) -> Double? {
|
||||
let cleaned = text.replacingOccurrences(of: ",", with: ".")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard let value = Double(cleaned), value > 0.5, value < 12 else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
static func text(from value: Double?) -> String {
|
||||
guard let value else { return "" }
|
||||
return String(format: "%.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||
}
|
||||
|
||||
let profile: Profile
|
||||
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var symbol = "box.truck"
|
||||
@State private var trackWidth = ""
|
||||
@State private var wheelbase = ""
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Name") {
|
||||
TextField("Name", text: $name)
|
||||
}
|
||||
Section {
|
||||
LabeledContent("Spurweite") {
|
||||
HStack {
|
||||
TextField("z. B. 2,00", text: $trackWidth)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
Text("m").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent("Radstand") {
|
||||
HStack {
|
||||
TextField("z. B. 3,50", text: $wheelbase)
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
Text("m").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Fahrzeugmaße")
|
||||
} footer: {
|
||||
Text("Nur für den Ausrichtungs-Assistenten: Aus Neigung und "
|
||||
+ "Abstand errechnet er, wie hoch der Auffahrkeil sein muss. "
|
||||
+ "Ohne Angabe entfällt diese Anzeige.")
|
||||
}
|
||||
|
||||
Section("Symbol") {
|
||||
LazyVGrid(columns: columns, spacing: 12) {
|
||||
ForEach(Profile.symbols, id: \.self) { candidate in
|
||||
Button {
|
||||
symbol = candidate
|
||||
} label: {
|
||||
Image(systemName: candidate)
|
||||
.font(.title2)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(symbol == candidate ? Color.accentColor.opacity(0.18)
|
||||
: Color.secondary.opacity(0.08),
|
||||
in: .rect(cornerRadius: 12))
|
||||
.foregroundStyle(symbol == candidate ? Color.accentColor : Color.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Fahrzeug")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Sichern") {
|
||||
var updated = profile
|
||||
updated.name = name.trimmingCharacters(in: .whitespaces)
|
||||
updated.symbol = symbol
|
||||
updated.trackWidth = Self.metres(from: trackWidth)
|
||||
updated.wheelbase = Self.metres(from: wheelbase)
|
||||
store.update(updated)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
name = profile.name
|
||||
symbol = profile.symbol
|
||||
trackWidth = Self.text(from: profile.trackWidth)
|
||||
wheelbase = Self.text(from: profile.wheelbase)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Führt durch die Einrichtung der Einbaulage des Neigungsmessers.
|
||||
///
|
||||
/// Der Sensor kann quer, gedreht oder kopfüber sitzen. Statt die Lage aus einer
|
||||
/// Liste raten zu lassen, wird sie gemessen: zweimal kippen, einmal um jede
|
||||
/// Achse, und aus der Reaktion ergibt sich die Zuordnung.
|
||||
struct SensorSetupView: View {
|
||||
let device: ConfiguredDevice
|
||||
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private enum Step {
|
||||
case intro
|
||||
case tiltNose
|
||||
case settle
|
||||
case tiltSide
|
||||
case done(SensorOrientation)
|
||||
case failed(OrientationDetection.Failure)
|
||||
}
|
||||
|
||||
@State private var step: Step = .intro
|
||||
/// Ruhelage, auf die beide Kippbewegungen bezogen werden.
|
||||
@State private var reference = OrientationDetection.Reading(pitch: 0, roll: 0)
|
||||
@State private var noseChange: OrientationDetection.Reading?
|
||||
|
||||
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
||||
|
||||
private var live: OrientationDetection.Reading? {
|
||||
guard let pitch = state.rawPitch, let roll = state.rawRoll else { return nil }
|
||||
return OrientationDetection.Reading(pitch: pitch, roll: roll)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
content
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Einbaulage")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch step {
|
||||
case .intro:
|
||||
card(icon: "arrow.triangle.2.circlepath",
|
||||
title: "Einbaulage bestimmen",
|
||||
text: "Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet er "
|
||||
+ "die Neigung vertauscht. Um das zu klären, wird er gleich "
|
||||
+ "zweimal gekippt.\n\nBaue ihn dazu so ein oder halte ihn so, "
|
||||
+ "wie er später sitzen soll. Er muss nicht angeschraubt sein – "
|
||||
+ "nur die Ausrichtung muss stimmen.")
|
||||
liveReadout
|
||||
Button("Los geht’s") {
|
||||
reference = live ?? .init(pitch: 0, roll: 0)
|
||||
step = .tiltNose
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(live == nil)
|
||||
|
||||
case .tiltNose:
|
||||
card(icon: "arrow.down.forward",
|
||||
title: "Schritt 1 von 2: nach vorne kippen",
|
||||
text: "Kippe den Sensor so, als würde das Fahrzeug **vorne "
|
||||
+ "abwärts** stehen – die Front also nach unten.\n\n"
|
||||
+ "Deutlich kippen, etwa eine Handbreit, und in dieser Lage "
|
||||
+ "halten. Dann weiter.")
|
||||
liveReadout
|
||||
Button("Weiter") { finishNoseStep() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(live == nil)
|
||||
|
||||
case .settle:
|
||||
card(icon: "equal.circle",
|
||||
title: "Zurück in die Ruhelage",
|
||||
text: "Stelle den Sensor wieder so hin wie am Anfang und lass "
|
||||
+ "ihn kurz ruhen.\n\nVon hier aus wird die zweite Bewegung "
|
||||
+ "gemessen.")
|
||||
liveReadout
|
||||
Button("Weiter") {
|
||||
reference = live ?? reference
|
||||
step = .tiltSide
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(live == nil)
|
||||
|
||||
case .tiltSide:
|
||||
card(icon: "arrow.down.left",
|
||||
title: "Schritt 2 von 2: nach links kippen",
|
||||
text: "Kippe den Sensor jetzt so, als würde das Fahrzeug **nach "
|
||||
+ "links** hängen – die linke Seite also nach unten.\n\n"
|
||||
+ "Wieder deutlich kippen und in dieser Lage halten.")
|
||||
liveReadout
|
||||
HStack {
|
||||
Button("Zurück") { step = .settle }
|
||||
.buttonStyle(.bordered)
|
||||
Button("Fertig") { finishSideStep() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(live == nil)
|
||||
}
|
||||
|
||||
case .done(let orientation):
|
||||
card(icon: "checkmark.circle.fill",
|
||||
title: "Einbaulage erkannt",
|
||||
text: "Ergebnis: **\(orientation.summary)**.\n\nDie Anzeige rechnet "
|
||||
+ "die Werte des Sensors ab jetzt auf die Achsen des Fahrzeugs "
|
||||
+ "um. Vergiss nicht, anschliessend im ebenen Stand zu "
|
||||
+ "kalibrieren.")
|
||||
Button("Übernehmen") { save(orientation) }
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
case .failed(let failure):
|
||||
card(icon: "exclamationmark.triangle.fill",
|
||||
title: "Das hat nicht geklappt",
|
||||
text: failure.message)
|
||||
Button("Nochmal versuchen") { step = .intro }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
|
||||
private func card(icon: String, title: String, text: String) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(.init(text))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
/// Was der Sensor gerade meldet – ohne Umrechnung, denn die wird hier ja
|
||||
/// erst bestimmt.
|
||||
private var liveReadout: some View {
|
||||
HStack(spacing: 16) {
|
||||
value("Achse A", state.rawPitch)
|
||||
value("Achse B", state.rawRoll)
|
||||
}
|
||||
}
|
||||
|
||||
private func value(_ title: String, _ reading: Double?) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(reading.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title3.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
||||
}
|
||||
|
||||
// MARK: - Ablauf
|
||||
|
||||
private func finishNoseStep() {
|
||||
guard let live else { return }
|
||||
noseChange = live - reference
|
||||
// Der Bezug bleibt die Ruhelage. Von der gekippten Lage aus zu messen
|
||||
// wäre falsch: die zweite Messung enthielte dann das Zurückkippen aus
|
||||
// der ersten, und beide Achsen schlügen aus.
|
||||
step = .settle
|
||||
}
|
||||
|
||||
private func finishSideStep() {
|
||||
guard let live, let nose = noseChange else { return }
|
||||
let side = live - reference
|
||||
switch OrientationDetection.orientation(nose: nose, side: side) {
|
||||
case .success(let orientation): step = .done(orientation)
|
||||
case .failure(let failure): step = .failed(failure)
|
||||
}
|
||||
}
|
||||
|
||||
private func save(_ orientation: SensorOrientation) {
|
||||
var updated = device
|
||||
updated.sensorOrientation = orientation
|
||||
store.update(updated)
|
||||
bluetooth.updateSensorOrientation(for: updated)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
/// App-weite Einstellungen. Bewusst knapp gehalten – was ein einzelnes Gerät
|
||||
/// betrifft, steht bei diesem Gerät.
|
||||
struct SettingsView: View {
|
||||
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
||||
@Environment(PhoneWatchLink.self) private var watch
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
|
||||
} header: {
|
||||
Text("Fehlersuche")
|
||||
} footer: {
|
||||
Text("Zeigt bei jedem Gerät die technischen Angaben: erkanntes "
|
||||
+ "Protokoll, Bluetooth-Merkmale, gesendete Befehle und die "
|
||||
+ "Rohdaten der letzten Antwort. Für den Alltag nicht nötig – "
|
||||
+ "hilfreich, wenn ein Gerät sich nicht wie erwartet verhält.\n\n"
|
||||
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
|
||||
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.")
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Apple Watch", value: watch.statusText)
|
||||
} footer: {
|
||||
Text("Die Uhr zeigt die Werte dieses iPhones und steuert die "
|
||||
+ "Kühlbox darüber – sie funkt nicht selbst zu den Geräten. "
|
||||
+ "Dafür muss diese App laufen; im Hintergrund liefern "
|
||||
+ "Neigungsmesser, BMS und Kühlbox weiter, die "
|
||||
+ "Victron-Werbedaten erst wieder im Vordergrund.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Einstellungen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppSettings {
|
||||
static let showDiagnosticsKey = "showDiagnostics"
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Zeigt die Neigung am Fahrzeug selbst, statt an einer abstrakten Blase.
|
||||
///
|
||||
/// Zwei Ansichten, jede um ihre Achse gekippt:
|
||||
///
|
||||
/// * **Seitenansicht** für die Längsneigung. Die Front zeigt nach links, das
|
||||
/// Heck nach rechts.
|
||||
/// * **Heckansicht** für die Querneigung. Sie teilt die Blickrichtung des
|
||||
/// Fahrers, links im Bild ist also links am Fahrzeug – bei einer
|
||||
/// Frontansicht wäre es seitenverkehrt.
|
||||
///
|
||||
/// In beiden Fällen wird gegen den mathematischen Drehsinn gekippt: Steht das
|
||||
/// Heck höher, muss die rechte Bildseite nach oben.
|
||||
struct VehicleTiltView: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
tiltPanel(image: "VehicleSide",
|
||||
angle: pitch,
|
||||
title: "Längs",
|
||||
lowerLabel: "Front",
|
||||
upperLabel: "Heck",
|
||||
aspect: VehicleTilt.sideAspect)
|
||||
|
||||
tiltPanel(image: "VehicleRear",
|
||||
angle: roll,
|
||||
title: "Quer",
|
||||
lowerLabel: "links",
|
||||
upperLabel: "rechts",
|
||||
aspect: VehicleTilt.rearAspect)
|
||||
|
||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
||||
VehicleTilt.exaggeration))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func tiltPanel(image: String,
|
||||
angle: Double?,
|
||||
title: String,
|
||||
lowerLabel: String,
|
||||
upperLabel: String,
|
||||
aspect: Double) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.subheadline.weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||
}
|
||||
|
||||
ZStack {
|
||||
// Waagerechte als Bezug – ohne sie ist eine kleine Neigung
|
||||
// nicht einzuschätzen.
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.35))
|
||||
.frame(height: 1)
|
||||
|
||||
Image(image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||
.aspectRatio(aspect, contentMode: .fit)
|
||||
.rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration))
|
||||
.animation(.spring(duration: 0.4), value: angle)
|
||||
.opacity(angle == nil ? 0.3 : 1)
|
||||
}
|
||||
.frame(height: 110)
|
||||
|
||||
HStack {
|
||||
Text(lowerLabel)
|
||||
Spacer()
|
||||
Text(upperLabel)
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Umschalter zwischen den beiden Darstellungen, gemerkt über Starts hinweg.
|
||||
enum LevelDisplayStyle: String, CaseIterable, Identifiable {
|
||||
case bubble
|
||||
case vehicle
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .bubble: return "Libelle"
|
||||
case .vehicle: return "Fahrzeug"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Die Gegenstelle zur Apple Watch auf dem iPhone.
|
||||
///
|
||||
/// Das iPhone bleibt das Funkgerät (warum, steht bei `WatchLink`) und reicht
|
||||
/// den fertigen Stand weiter. Zwei Wege, je nachdem, ob die Uhr gerade
|
||||
/// hinschaut:
|
||||
///
|
||||
/// * **Anwendungskontext** – ein Datensatz, den watchOS aufhebt und auch dann
|
||||
/// zustellt, wenn die App auf der Uhr gerade nicht läuft. Damit steht beim
|
||||
/// Aufwecken sofort etwas da statt eines leeren Bildschirms.
|
||||
/// * **Nachricht** – nur solange die Uhr erreichbar ist und gemeldet hat, dass
|
||||
/// sie hinschaut. Dafür im halben Sekundentakt, was beim Ausrichten zählt.
|
||||
///
|
||||
/// Ohne gekoppelte Uhr läuft hier gar nichts: kein Timer, keine Sitzung.
|
||||
@Observable
|
||||
final class PhoneWatchLink: NSObject {
|
||||
|
||||
/// Ob die Uhr gerade Werte sehen will. Solange das gilt, hält die App das
|
||||
/// Bluetooth auch im Hintergrund am Leben.
|
||||
private(set) var wantsLiveUpdates = false
|
||||
/// Nur für die Anzeige in den Einstellungen.
|
||||
private(set) var statusText = "Keine Uhr gekoppelt"
|
||||
|
||||
private let store: DeviceStore
|
||||
private let bluetooth: BluetoothManager
|
||||
|
||||
private var session: WCSession?
|
||||
private var timer: Timer?
|
||||
/// Bis wann der Wunsch der Uhr nach schnellen Werten gilt.
|
||||
private var liveUntil = Date.distantPast
|
||||
private var lastSentContent: WatchPayload?
|
||||
private var lastContextSent = Date.distantPast
|
||||
|
||||
/// Setzt die App, damit beim Ablauf des Wunsches im Hintergrund das Funken
|
||||
/// wieder eingestellt wird.
|
||||
var isAppInBackground = false
|
||||
|
||||
init(store: DeviceStore, bluetooth: BluetoothManager) {
|
||||
self.store = store
|
||||
self.bluetooth = bluetooth
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else {
|
||||
statusText = "Dieses Gerät kann keine Uhr koppeln"
|
||||
return
|
||||
}
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
self.session = session
|
||||
session.activate()
|
||||
}
|
||||
|
||||
// MARK: - Takt
|
||||
|
||||
private func updateTimer() {
|
||||
let wanted = session?.activationState == .activated && session?.isPaired == true
|
||||
if wanted && timer == nil {
|
||||
// Der schnelle Takt ist der Grundtakt; ob wirklich gesendet wird,
|
||||
// entscheidet `tick` – so muss der Timer nie umgestellt werden.
|
||||
let timer = Timer(timeInterval: WatchLink.liveInterval, repeats: true) { [weak self] _ in
|
||||
self?.tick()
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.timer = timer
|
||||
} else if !wanted {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let live = Date() < liveUntil
|
||||
if live != wantsLiveUpdates {
|
||||
wantsLiveUpdates = live
|
||||
// Der Wunsch ist gerade abgelaufen und niemand schaut mehr hin:
|
||||
// dann darf das Funkgerät im Hintergrund auch wieder ruhen.
|
||||
if !live && isAppInBackground { bluetooth.stop() }
|
||||
}
|
||||
|
||||
// Den Stand erst zusammenstellen, wenn er auch rausgeht: im Ruhetakt
|
||||
// ist das nur jeder vierte Aufruf, und zusammengestellt wird auf dem
|
||||
// Hauptthread.
|
||||
let isContextDue = Date().timeIntervalSince(lastContextSent) >= WatchLink.idleInterval
|
||||
guard let session, live || isContextDue else { return }
|
||||
guard let payload = buildPayload() else { return }
|
||||
|
||||
if live && session.isReachable {
|
||||
send(payload, over: session)
|
||||
return
|
||||
}
|
||||
|
||||
guard isContextDue,
|
||||
lastSentContent?.hasSameContent(as: payload) != true,
|
||||
session.isPaired, session.isWatchAppInstalled else { return }
|
||||
do {
|
||||
try session.updateApplicationContext(payload.message())
|
||||
lastSentContent = payload
|
||||
lastContextSent = Date()
|
||||
} catch {
|
||||
statusText = "Uhr: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Nachrichten werden ohne Antwort verschickt: Ein verlorener Datensatz ist
|
||||
/// in einer halben Sekunde ohnehin überholt, ein Wiederholungsversuch käme
|
||||
/// zu spät und stünde nur dem nächsten im Weg.
|
||||
private func send(_ payload: WatchPayload, over session: WCSession) {
|
||||
guard let message = try? payload.message() else { return }
|
||||
session.sendMessage(message, replyHandler: nil, errorHandler: nil)
|
||||
lastSentContent = payload
|
||||
}
|
||||
|
||||
/// Nach Änderungen an Geräten oder Profilen aufrufen, damit die Uhr nicht
|
||||
/// bis zum nächsten Takt einen überholten Fahrzeugnamen zeigt.
|
||||
func sendNow() {
|
||||
lastSentContent = nil
|
||||
lastContextSent = .distantPast
|
||||
tick()
|
||||
}
|
||||
|
||||
// MARK: - Stand zusammenstellen
|
||||
|
||||
private func buildPayload() -> WatchPayload? {
|
||||
guard let profile = store.activeProfile else { return nil }
|
||||
let devices = store.activeDevices.map { device in
|
||||
WatchDevice(id: device.id,
|
||||
name: device.name,
|
||||
role: device.role,
|
||||
link: bluetooth.linkStates[device.id] ?? .searching,
|
||||
snapshot: bluetooth.snapshots[device.id],
|
||||
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
|
||||
orientation: device.role == .leveling ? device.sensorOrientation : nil)
|
||||
//fridge: device.role == .fridge ? fridgeState(for: device) : nil)
|
||||
}
|
||||
return WatchPayload(generatedAt: Date(),
|
||||
profile: profile,
|
||||
isRadioReady: bluetooth.isBluetoothReady,
|
||||
radioStatus: bluetooth.bluetoothStatusText,
|
||||
devices: devices)
|
||||
}
|
||||
|
||||
/// Live, solange die Box verbunden ist – sonst der zuletzt gestellte Stand
|
||||
/// aus dem Speicher, ohne Messwerte.
|
||||
/*private func fridgeState(for device: ConfiguredDevice) -> WatchFridge? {
|
||||
if linkStateIsLive(device), let state = bluetooth.fridgeStates[device.id] {
|
||||
return WatchFridge(state)
|
||||
}
|
||||
return store.lastFridgeSettings(for: device.id).map(WatchFridge.init)
|
||||
}*/
|
||||
|
||||
private func linkStateIsLive(_ device: ConfiguredDevice) -> Bool {
|
||||
bluetooth.linkStates[device.id] == .live
|
||||
}
|
||||
|
||||
// MARK: - Befehle von der Uhr
|
||||
|
||||
private func handle(_ command: WatchCommand) {
|
||||
if command.wantsLiveUpdates {
|
||||
liveUntil = Date().addingTimeInterval(WatchLink.liveLease)
|
||||
wantsLiveUpdates = true
|
||||
// Die Uhr kann das iPhone aus dem Hintergrund wecken. Dann steht
|
||||
// das Funkgerät und muss erst wieder anlaufen, sonst geht der
|
||||
// Stellbefehl ins Leere.
|
||||
if isAppInBackground { bluetooth.start() }
|
||||
}
|
||||
|
||||
switch command {
|
||||
case .hello:
|
||||
break
|
||||
/*case .fridgePower(let device, let on):
|
||||
bluetooth.setFridgePower(on, for: device)
|
||||
case .fridgeEco(let device, let eco):
|
||||
bluetooth.setFridgeEco(eco, for: device)
|
||||
case .fridgeLock(let device, let locked):
|
||||
bluetooth.setFridgeLock(locked, for: device)
|
||||
case .fridgeTarget(let device, let zone, let value):
|
||||
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
|
||||
case .fridgeSession(let deviceID, let wanted):
|
||||
guard let device = store.devices.first(where: { $0.id == deviceID }) else { break }
|
||||
if wanted {
|
||||
bluetooth.beginSession(for: device)
|
||||
} else {
|
||||
bluetooth.endSession(for: device)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension PhoneWatchLink: WCSessionDelegate {
|
||||
|
||||
func session(_ session: WCSession,
|
||||
activationDidCompleteWith state: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
DispatchQueue.main.async {
|
||||
if let error {
|
||||
self.statusText = "Uhr: \(error.localizedDescription)"
|
||||
} else {
|
||||
self.updateStatus(session)
|
||||
}
|
||||
self.updateTimer()
|
||||
self.sendNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStatus(_ session: WCSession) {
|
||||
if !session.isPaired {
|
||||
statusText = "Keine Uhr gekoppelt"
|
||||
} else if !session.isWatchAppInstalled {
|
||||
statusText = "Uhr gekoppelt, App dort nicht installiert"
|
||||
} else {
|
||||
statusText = "Uhr bereit"
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let command = WatchCommand.decode(from: message) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.handle(command)
|
||||
// Auf einen Stellbefehl gleich den frischen Stand hinterherschicken.
|
||||
// Der zeigt zwar noch den alten Wert – die Box antwortet erst –,
|
||||
// aber die Uhr weiss dadurch sofort, dass der Befehl ankam.
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mit Antwort, wenn die Uhr auf eine wartet: das ist der Weg, auf dem sie
|
||||
/// beim Öffnen ohne Verzögerung an einen Stand kommt.
|
||||
func session(_ session: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
if let command = WatchCommand.decode(from: message) { self.handle(command) }
|
||||
replyHandler((try? self.buildPayload()?.message()) ?? [:])
|
||||
}
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async { self.sendNow() }
|
||||
}
|
||||
|
||||
/// Die Uhr wurde gekoppelt, gewechselt oder die App dort installiert.
|
||||
func sessionWatchStateDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.updateStatus(session)
|
||||
self.updateTimer()
|
||||
self.sendNow()
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Wechsel auf eine andere Uhr muss die Sitzung neu aktiviert werden.
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Umformen
|
||||
|
||||
/*extension WatchFridge {
|
||||
/// Aus dem Zustand der Box wird nur übernommen, was die Uhr anzeigt oder
|
||||
/// stellt – der Rohdatensatz bleibt auf dem iPhone.
|
||||
init(_ state: AlpicoolState) {
|
||||
let range = state.targetRange
|
||||
self.init(isLive: true,
|
||||
updated: Date(),
|
||||
isPoweredOn: state.isPoweredOn,
|
||||
isEco: state.isEco,
|
||||
isLocked: state.isLocked,
|
||||
isDualZone: state.isDualZone,
|
||||
unitSymbol: state.unitSymbol,
|
||||
leftTarget: state.leftTarget,
|
||||
leftCurrent: state.leftCurrent,
|
||||
rightTarget: state.isDualZone ? state.rightTarget : nil,
|
||||
rightCurrent: state.isDualZone ? state.rightCurrent : nil,
|
||||
minTarget: range.lowerBound,
|
||||
maxTarget: range.upperBound,
|
||||
batteryVolts: state.batteryVolts)
|
||||
}
|
||||
}*/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Reference in New Issue
Block a user