forked from fritob/Camper-Monitor
Kühlbox nur verbinden, während man sie ansieht
Jede Verbindung meldet sich am Display der Box an – sie piept, und wer gerade davorsteht, wird gestört. Dauerhaft verbunden zu sein ist dort also nicht unsichtbar wie beim BMS, sondern lästig. Die Kühlbox wird deshalb nur noch verbunden, solange ihre Ansicht offen ist: am iPhone über die Detailansicht, an der Uhr über einen eigenen Befehl, den sie beim Öffnen und Schliessen schickt. Ein Stellbefehl geht weiterhin immer durch – ist die Box nicht verbunden, wird er gemerkt und löst den Verbindungsaufbau aus. Abgeriegelt sind alle drei Wege, über die bisher verbunden wurde: beim Start, im Takt des Wiederverbindens, und – das war das eigentliche Loch – sobald das Gerät in den Werbedaten auftaucht. Da die App dauerhaft scannt, hätte allein dieser Weg die Box weiter angefunkt. Dass sie in Reichweite ist, ist kein Grund, sie anzufassen. Auf der Übersicht steht dafür der zuletzt gestellte Stand statt der Messwerte: Sollwert, Ein/Aus, Eco oder Max, dazu wann er gestellt wurde. Das bleibt richtig, auch wenn es von gestern ist – ein Sollwert ändert sich nur, wenn jemand ihn ändert. Eine Innentemperatur von gestern sähe dagegen aus wie eine von jetzt, und man würde ihr glauben. Genau das sichern die neuen Prüfungen ab: Hauptwert ist der Sollwert, gemessene Temperatur und Bordspannung kommen nicht vor. Der Stand liegt in den Einstellungen (FridgeSettings) mit einem Zeitstempel, der „zuletzt geändert" bedeutet und nicht „zuletzt gesehen" – sonst behauptete die Kachel Frische, wo sich nichts getan hat. Die Uhr bekommt dasselbe Bild: WatchFridge trägt jetzt ein Kennzeichen isLive. BMS und Neigungsmesser bleiben dauerhaft verbunden. Sie stören nicht, und ihre Werte will man laufend sehen. Nicht nachgezogen ist die Android-App – dort verbindet der BluetoothManager weiterhin dauerhaft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,8 +57,8 @@
|
|||||||
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AA0000000000000000000016 /* CamperMonitorWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitorWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000016 /* CamperMonitorWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitorWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = "<group>"; };
|
AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = "<group>"; };
|
||||||
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitorComplication-Info.plist"; sourceTree = "<group>"; };
|
|
||||||
AA0000000000000000000032 /* CamperMonitorComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CamperMonitorComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000032 /* CamperMonitorComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CamperMonitorComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitorComplication-Info.plist"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -72,16 +72,16 @@
|
|||||||
path = CamperMonitorWatch;
|
path = CamperMonitorWatch;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
AA0000000000000000000033 /* CamperMonitorComplication */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = CamperMonitorComplication;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
AA0000000000000000000018 /* Shared */ = {
|
AA0000000000000000000018 /* Shared */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = Shared;
|
path = Shared;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000033 /* CamperMonitorComplication */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = CamperMonitorComplication;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -202,8 +202,8 @@
|
|||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
AA0000000000000000000033 /* CamperMonitorComplication */,
|
|
||||||
AA0000000000000000000018 /* Shared */,
|
AA0000000000000000000018 /* Shared */,
|
||||||
|
AA0000000000000000000033 /* CamperMonitorComplication */,
|
||||||
);
|
);
|
||||||
name = CamperMonitorComplication;
|
name = CamperMonitorComplication;
|
||||||
productName = CamperMonitorComplication;
|
productName = CamperMonitorComplication;
|
||||||
@@ -497,7 +497,7 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
AA0000000000000000000038 = {
|
AA0000000000000000000038 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -505,7 +505,7 @@
|
|||||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Nivellierung";
|
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -517,16 +517,16 @@
|
|||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
AA0000000000000000000039 = {
|
AA0000000000000000000039 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -534,7 +534,7 @@
|
|||||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Nivellierung";
|
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -546,12 +546,12 @@
|
|||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 4;
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -232,6 +232,19 @@ struct AlpicoolState: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Stand, der die Verbindung überdauert – ohne Messwerte.
|
||||||
|
var settings: FridgeSettings? {
|
||||||
|
guard hasStatus else { return nil }
|
||||||
|
return FridgeSettings(isPoweredOn: isPoweredOn,
|
||||||
|
isEco: isEco,
|
||||||
|
isLocked: isLocked,
|
||||||
|
isDualZone: isDualZone,
|
||||||
|
usesFahrenheit: usesFahrenheit,
|
||||||
|
leftTarget: leftTarget,
|
||||||
|
rightTarget: isDualZone ? rightTarget : nil,
|
||||||
|
updated: Date())
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Bytes, die ein Stellbefehl ändert.
|
/// Die Bytes, die ein Stellbefehl ändert.
|
||||||
///
|
///
|
||||||
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
|
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
|
||||||
|
|||||||
@@ -178,6 +178,11 @@ final class BluetoothManager: NSObject {
|
|||||||
}
|
}
|
||||||
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
|
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 bmsSessions: [UUID: BMSSession] = [:]
|
||||||
private var levelSessions: [UUID: LevelSession] = [:]
|
private var levelSessions: [UUID: LevelSession] = [:]
|
||||||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||||||
@@ -298,6 +303,14 @@ final class BluetoothManager: NSObject {
|
|||||||
fridgeStates = fridgeStates.filter { known.contains($0.key) }
|
fridgeStates = fridgeStates.filter { known.contains($0.key) }
|
||||||
levelStates = levelStates.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) }
|
queue.async { self.applyConfiguration(devices) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +322,57 @@ final class BluetoothManager: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// MARK: - Kühlbox steuern (vom Hauptthread aufgerufen)
|
||||||
|
|
||||||
func updateFridgeZoneMode(for device: ConfiguredDevice) {
|
func updateFridgeZoneMode(for device: ConfiguredDevice) {
|
||||||
@@ -431,8 +495,10 @@ final class BluetoothManager: NSObject {
|
|||||||
private func applyConfiguration(_ devices: [ManagedDevice]) {
|
private func applyConfiguration(_ devices: [ManagedDevice]) {
|
||||||
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
|
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
|
||||||
|
|
||||||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
|
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören – und zu
|
||||||
let wanted = Set(devices.filter { $0.transport == .connect }.map(\.peripheralID))
|
// denen, die nur auf Anforderung verbunden werden und gerade niemand
|
||||||
|
// anschaut.
|
||||||
|
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
|
||||||
for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) {
|
for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) {
|
||||||
session.stop()
|
session.stop()
|
||||||
bmsSessions[peripheralID] = nil
|
bmsSessions[peripheralID] = nil
|
||||||
@@ -509,11 +575,22 @@ final class BluetoothManager: NSObject {
|
|||||||
|
|
||||||
private func connectManagedPeripherals() {
|
private func connectManagedPeripherals() {
|
||||||
guard central?.state == .poweredOn else { return }
|
guard central?.state == .poweredOn else { return }
|
||||||
for device in managed.values where device.transport == .connect {
|
for device in managed.values where shouldStayConnected(device) {
|
||||||
connectIfNeeded(device)
|
connectIfNeeded(device)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
private func connectIfNeeded(_ device: ManagedDevice) {
|
||||||
if let existing = connectedPeripherals[device.peripheralID],
|
if let existing = connectedPeripherals[device.peripheralID],
|
||||||
existing.state == .connected || existing.state == .connecting {
|
existing.state == .connected || existing.state == .connecting {
|
||||||
@@ -736,8 +813,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
|
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
|
||||||
|
|
||||||
case .connect:
|
case .connect:
|
||||||
// Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen.
|
// Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt
|
||||||
if connectedPeripherals[identifier] == nil {
|
// 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)
|
connectIfNeeded(device)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -786,7 +866,14 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
)
|
)
|
||||||
session.onFridgeState = { [weak self] state in
|
session.onFridgeState = { [weak self] state in
|
||||||
self?.fridgeStateCache[peripheral.identifier] = state
|
self?.fridgeStateCache[peripheral.identifier] = state
|
||||||
self?.publish { self?.fridgeStates[device.id] = 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
|
session.fridgeZoneMode = device.fridgeZoneMode
|
||||||
bmsSessions[peripheral.identifier] = session
|
bmsSessions[peripheral.identifier] = session
|
||||||
@@ -817,7 +904,15 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
||||||
.map { Date().timeIntervalSince($0) } ?? 0
|
.map { Date().timeIntervalSince($0) } ?? 0
|
||||||
|
|
||||||
if let device = managed[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 }
|
publish { self.linkStates[device.id] = .searching }
|
||||||
if lifetime >= stableConnection {
|
if lifetime >= stableConnection {
|
||||||
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ final class DeviceStore {
|
|||||||
private static let activeProfileKey = "activeProfileID"
|
private static let activeProfileKey = "activeProfileID"
|
||||||
/// Hierhin wird gerettet, was sich nicht lesen liess – siehe `loadDevices`.
|
/// Hierhin wird gerettet, was sich nicht lesen liess – siehe `loadDevices`.
|
||||||
private static let unreadableDevicesKey = "configuredDevices.unreadable"
|
private static let unreadableDevicesKey = "configuredDevices.unreadable"
|
||||||
|
private static let fridgeSettingsKey = "lastFridgeSettings"
|
||||||
|
|
||||||
/// Alle Geräte über alle Profile hinweg.
|
/// Alle Geräte über alle Profile hinweg.
|
||||||
private(set) var devices: [ConfiguredDevice] = []
|
private(set) var devices: [ConfiguredDevice] = []
|
||||||
@@ -144,6 +145,36 @@ final class DeviceStore {
|
|||||||
return 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
|
// MARK: - Persistenz
|
||||||
|
|
||||||
private func load() {
|
private func load() {
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ struct DeviceCard: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var footer: some View {
|
private var footer: some View {
|
||||||
if let fault = snapshot?.fault {
|
if device.role.connectsOnDemand, linkState != .live, 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")
|
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
@@ -96,11 +103,29 @@ struct DeviceCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was unter einem Gerät steht, das nur beim Öffnen verbunden wird.
|
||||||
|
private func lastSetText(_ snapshot: DeviceSnapshot) -> String {
|
||||||
|
let age = relativeUpdate(snapshot.timestamp)
|
||||||
|
guard let state = snapshot.state else { return "Zuletzt gestellt \(age)" }
|
||||||
|
return "\(state) · zuletzt gestellt \(age)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „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 {
|
private var placeholderText: String {
|
||||||
switch linkState {
|
switch linkState {
|
||||||
case .needsKey: return "Verschlüsselungsschlüssel fehlt – im Detail eintragen."
|
case .needsKey: return "Verschlüsselungsschlüssel fehlt – im Detail eintragen."
|
||||||
case .failed(let message): return message
|
case .failed(let message): return message
|
||||||
default: return "Warte auf Daten…"
|
default:
|
||||||
|
return device.role.connectsOnDemand
|
||||||
|
? "Verbindet erst beim Öffnen."
|
||||||
|
: "Warte auf Daten…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,10 +114,16 @@ struct DeviceDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(currentDevice.name)
|
.navigationTitle(currentDevice.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.onDisappear(perform: saveName)
|
.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 {
|
.onAppear {
|
||||||
editedName = currentDevice.name
|
editedName = currentDevice.name
|
||||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||||
|
bluetooth.beginSession(for: currentDevice)
|
||||||
}
|
}
|
||||||
.confirmationDialog("Gerät entfernen?",
|
.confirmationDialog("Gerät entfernen?",
|
||||||
isPresented: $showDeleteConfirmation,
|
isPresented: $showDeleteConfirmation,
|
||||||
|
|||||||
@@ -135,8 +135,7 @@ final class PhoneWatchLink: NSObject {
|
|||||||
snapshot: bluetooth.snapshots[device.id],
|
snapshot: bluetooth.snapshots[device.id],
|
||||||
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
|
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
|
||||||
orientation: device.role == .leveling ? device.sensorOrientation : nil,
|
orientation: device.role == .leveling ? device.sensorOrientation : nil,
|
||||||
fridge: device.role == .fridge
|
fridge: device.role == .fridge ? fridgeState(for: device) : nil)
|
||||||
? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil)
|
|
||||||
}
|
}
|
||||||
return WatchPayload(generatedAt: Date(),
|
return WatchPayload(generatedAt: Date(),
|
||||||
profile: profile,
|
profile: profile,
|
||||||
@@ -145,6 +144,19 @@ final class PhoneWatchLink: NSObject {
|
|||||||
devices: devices)
|
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
|
// MARK: - Befehle von der Uhr
|
||||||
|
|
||||||
private func handle(_ command: WatchCommand) {
|
private func handle(_ command: WatchCommand) {
|
||||||
@@ -168,6 +180,13 @@ final class PhoneWatchLink: NSObject {
|
|||||||
bluetooth.setFridgeLock(locked, for: device)
|
bluetooth.setFridgeLock(locked, for: device)
|
||||||
case .fridgeTarget(let device, let zone, let value):
|
case .fridgeTarget(let device, let zone, let value):
|
||||||
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +269,9 @@ extension WatchFridge {
|
|||||||
/// stellt – der Rohdatensatz bleibt auf dem iPhone.
|
/// stellt – der Rohdatensatz bleibt auf dem iPhone.
|
||||||
init(_ state: AlpicoolState) {
|
init(_ state: AlpicoolState) {
|
||||||
let range = state.targetRange
|
let range = state.targetRange
|
||||||
self.init(isPoweredOn: state.isPoweredOn,
|
self.init(isLive: true,
|
||||||
|
updated: Date(),
|
||||||
|
isPoweredOn: state.isPoweredOn,
|
||||||
isEco: state.isEco,
|
isEco: state.isEco,
|
||||||
isLocked: state.isLocked,
|
isLocked: state.isLocked,
|
||||||
isDualZone: state.isDualZone,
|
isDualZone: state.isDualZone,
|
||||||
|
|||||||
@@ -55,5 +55,15 @@ struct WatchDeviceDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(device?.name ?? "Gerät")
|
.navigationTitle(device?.name ?? "Gerät")
|
||||||
|
// Die Kühlbox wird nur verbunden, solange jemand sie ansieht. Das gilt
|
||||||
|
// auch von hier aus – das iPhone verbindet auf Zuruf.
|
||||||
|
.onAppear {
|
||||||
|
guard device?.fridge != nil else { return }
|
||||||
|
link.send(.fridgeSession(device: deviceID, wanted: true))
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
guard device?.fridge != nil else { return }
|
||||||
|
link.send(.fridgeSession(device: deviceID, wanted: false))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ struct WatchFridgeControls: View {
|
|||||||
Text("iPhone gerade nicht erreichbar – Stellbefehle gehen erst "
|
Text("iPhone gerade nicht erreichbar – Stellbefehle gehen erst "
|
||||||
+ "wieder durch, wenn es antwortet.")
|
+ "wieder durch, wenn es antwortet.")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
|
} else if !fridge.isLive {
|
||||||
|
// Kein Messwert, sondern der zuletzt gestellte Stand: Die Box
|
||||||
|
// wird erst verbunden, während man diese Ansicht offen hat.
|
||||||
|
Text("Zuletzt gestellter Stand. Die Box verbindet sich gerade – "
|
||||||
|
+ "die Werte kommen gleich.")
|
||||||
|
.font(.caption2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ enum WatchDemo {
|
|||||||
orientation: nil, fridge: nil),
|
orientation: nil, fridge: nil),
|
||||||
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
|
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
|
||||||
link: .live, snapshot: fridge, level: nil, orientation: nil,
|
link: .live, snapshot: fridge, level: nil, orientation: nil,
|
||||||
fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
|
fridge: WatchFridge(isLive: true, updated: now,
|
||||||
|
isPoweredOn: true, isEco: true, isLocked: false,
|
||||||
isDualZone: false, unitSymbol: "°C",
|
isDualZone: false, unitSymbol: "°C",
|
||||||
leftTarget: 4, leftCurrent: 6,
|
leftTarget: 4, leftCurrent: 6,
|
||||||
rightTarget: nil, rightCurrent: nil,
|
rightTarget: nil, rightCurrent: nil,
|
||||||
|
|||||||
@@ -132,6 +132,23 @@ Beim Einrichten Art auf **Kühlbox** stellen. Die App meldet sich beim Verbinden
|
|||||||
selbst an; steht **APP** im Display der Box, verlangt sie dabei einen
|
selbst an; steht **APP** im Display der Box, verlangt sie dabei einen
|
||||||
Tastendruck am Gerät.
|
Tastendruck am Gerät.
|
||||||
|
|
||||||
|
**Verbunden wird nur, während die Kühlbox geöffnet ist.** Jede Verbindung
|
||||||
|
meldet sich an ihrem Display an und stört, wer gerade davorsteht; und ihre Werte
|
||||||
|
ändern sich langsam. Auf der Übersicht steht deshalb nicht die gemessene
|
||||||
|
Innentemperatur, sondern der **zuletzt gestellte Stand** – Sollwert, Ein/Aus,
|
||||||
|
Eco oder Max, dazu wann er gestellt wurde. Der bleibt richtig, auch wenn er von
|
||||||
|
gestern ist: Ein Sollwert ändert sich nur, wenn jemand ihn ändert. Eine
|
||||||
|
Innentemperatur von gestern sähe dagegen aus wie eine von jetzt.
|
||||||
|
|
||||||
|
Sobald du die Box öffnest, verbindet die App sich und zeigt alles live; beim
|
||||||
|
Verlassen der Ansicht trennt sie wieder. Dasselbe gilt für die Uhr: Auch von
|
||||||
|
dort wird die Verbindung nur angefordert, solange die Kühlbox-Ansicht offen ist.
|
||||||
|
Ein Stellbefehl geht ebenfalls immer durch – ist die Box gerade nicht verbunden,
|
||||||
|
wird er gemerkt und geht raus, sobald sie antwortet.
|
||||||
|
|
||||||
|
BMS und Neigungsmesser bleiben dagegen dauerhaft verbunden. Sie stören dabei
|
||||||
|
nicht, und ihre Werte will man laufend sehen.
|
||||||
|
|
||||||
Neben den Messwerten gibt es hier als einzigem Gerät auch Bedienelemente:
|
Neben den Messwerten gibt es hier als einzigem Gerät auch Bedienelemente:
|
||||||
Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle
|
Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle
|
||||||
Schalter zeigen den Stand, den die Box zurückmeldet – nach jedem Stellbefehl
|
Schalter zeigen den Stand, den die Box zurückmeldet – nach jedem Stellbefehl
|
||||||
@@ -263,19 +280,27 @@ den Arm hebt:
|
|||||||
|
|
||||||
* **Übersicht** – je Gerät der Hauptwert und ein Punkt für den Zustand der
|
* **Übersicht** – je Gerät der Hauptwert und ein Punkt für den Zustand der
|
||||||
Verbindung. Ganz unten steht, wie alt der Stand ist.
|
Verbindung. Ganz unten steht, wie alt der Stand ist.
|
||||||
* **Nivellierung** – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
|
* **Nivellierung** – drei Seiten zum Wischen, weil auf diesem Bildschirm ein
|
||||||
Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über Starts
|
Wisch besser zu treffen ist als eine Scrollposition:
|
||||||
hinweg gemerkt. Dazu, was zu tun ist, und die Keilhöhe je Rad, falls im
|
|
||||||
Fahrzeugprofil Spurweite und Radstand stehen. Kalibriert wird nur am iPhone:
|
1. *Neigung* – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
|
||||||
Das gehört einmalig auf ebenen Boden, und ein Knopf dafür an der Uhr wäre vor
|
Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über
|
||||||
allem eine Gelegenheit, die Nullage aus Versehen zu verstellen.
|
Starts hinweg gemerkt. Dazu, was zu tun ist, und woher die Werte kommen.
|
||||||
|
2. *Keile* – die Höhe je Rad als Draufsicht. Fehlen Spurweite und Radstand im
|
||||||
|
Fahrzeugprofil, steht das dort statt einer leeren Seite.
|
||||||
|
3. *Ausrichten* – der Assistent fürs Rangieren.
|
||||||
|
|
||||||
|
Kalibriert wird nur am iPhone: Das gehört einmalig auf ebenen Boden, und ein
|
||||||
|
Knopf dafür an der Uhr wäre vor allem eine Gelegenheit, die Nullage aus
|
||||||
|
Versehen zu verstellen.
|
||||||
* **Ausrichten** – derselbe Assistent wie am iPhone: Tendenz, Rat und der
|
* **Ausrichten** – derselbe Assistent wie am iPhone: Tendenz, Rat und der
|
||||||
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
|
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
|
||||||
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
|
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
|
||||||
gegenüber dem iPhone – beim Rangieren schaut niemand aufs Display, aber die
|
gegenüber dem iPhone – beim Rangieren schaut niemand aufs Display, aber die
|
||||||
Vibration am Handgelenk kommt an.
|
Vibration am Handgelenk kommt an.
|
||||||
* **Kühlbox** – ein/aus, Eco oder Max und die Solltemperatur, über die Krone
|
* **Kühlbox** – ein/aus, Eco oder Max und die Solltemperatur, über die Krone
|
||||||
gestellt.
|
gestellt. Verbunden wird die Box erst beim Öffnen dieser Ansicht, davor steht
|
||||||
|
dort der zuletzt gestellte Stand.
|
||||||
|
|
||||||
### Wer mit wem funkt
|
### Wer mit wem funkt
|
||||||
|
|
||||||
@@ -427,7 +452,7 @@ CamperMonitorWatch/ watchOS-App
|
|||||||
├── WatchRootView.swift Übersicht und Altersangabe
|
├── WatchRootView.swift Übersicht und Altersangabe
|
||||||
├── WatchBubble.swift Libelle für den kleinen Bildschirm
|
├── WatchBubble.swift Libelle für den kleinen Bildschirm
|
||||||
├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse
|
├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse
|
||||||
├── WatchLevelView.swift Neigung, Keilhöhe, Herkunft der Werte
|
├── WatchLevelView.swift Die drei Seiten: Neigung, Keile, Ausrichten
|
||||||
├── WatchAlignView.swift Ausrichten mit Vibration
|
├── WatchAlignView.swift Ausrichten mit Vibration
|
||||||
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
|
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
|
||||||
└── WatchFridgeControls.swift Kühlbox stellen
|
└── WatchFridgeControls.swift Kühlbox stellen
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob nur verbunden wird, während die Ansicht des Geräts offen ist.
|
||||||
|
///
|
||||||
|
/// Die Kühlbox meldet jede Verbindung an ihrem Display an und verlangt je
|
||||||
|
/// nach Modell sogar einen Tastendruck; dauerhaft verbunden zu sein ist
|
||||||
|
/// dort also nicht unsichtbar, sondern lästig. Ihre Werte ändern sich
|
||||||
|
/// ausserdem langsam, und was auf der Übersicht zählt – der Sollwert –
|
||||||
|
/// ändert sich überhaupt nur, wenn jemand ihn ändert.
|
||||||
|
///
|
||||||
|
/// BMS und Neigungsmesser bleiben dagegen verbunden: Sie stören nicht, und
|
||||||
|
/// ihre Werte will man laufend sehen.
|
||||||
|
var connectsOnDemand: Bool { self == .fridge }
|
||||||
|
|
||||||
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
||||||
/// braucht eine echte GATT-Verbindung.
|
/// braucht eine echte GATT-Verbindung.
|
||||||
var transport: DeviceTransport {
|
var transport: DeviceTransport {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Der zuletzt bekannte Stand einer Kühlbox – ohne Messwerte.
|
||||||
|
///
|
||||||
|
/// Die Box wird nur noch verbunden, während man ihre Ansicht offen hat: Jede
|
||||||
|
/// Verbindung meldet sich an ihrem Display an und stört, wer sie gerade
|
||||||
|
/// bedient. Auf der Übersicht steht deshalb, was zuletzt **eingestellt** war.
|
||||||
|
///
|
||||||
|
/// Messwerte gehören bewusst nicht dazu. Eine Innentemperatur von vorgestern
|
||||||
|
/// sähe aus wie eine von jetzt, und man würde ihr glauben – eine Solltemperatur
|
||||||
|
/// dagegen ändert sich nur, wenn jemand sie ändert.
|
||||||
|
struct FridgeSettings: Codable, Equatable, Sendable {
|
||||||
|
var isPoweredOn: Bool
|
||||||
|
var isEco: Bool
|
||||||
|
var isLocked: Bool
|
||||||
|
var isDualZone: Bool
|
||||||
|
var usesFahrenheit: Bool
|
||||||
|
var leftTarget: Int?
|
||||||
|
var rightTarget: Int?
|
||||||
|
/// Wann dieser Stand von der Box kam.
|
||||||
|
var updated: Date
|
||||||
|
|
||||||
|
var unitSymbol: String { usesFahrenheit ? "°F" : "°C" }
|
||||||
|
|
||||||
|
/// In Worten, wie es auf der Kachel steht.
|
||||||
|
var stateText: String {
|
||||||
|
guard isPoweredOn else { return "Aus" }
|
||||||
|
return isEco ? "Eco" : "Max"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was die Übersicht zeigt: die Sollwerte, sonst nichts.
|
||||||
|
func snapshot(deviceID: UUID) -> DeviceSnapshot {
|
||||||
|
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: updated, rssi: nil)
|
||||||
|
var metrics = [
|
||||||
|
Metric("target_left", isDualZone ? "Soll links" : "Solltemperatur",
|
||||||
|
leftTarget.map(Double.init), unit: unitSymbol, precision: 0, primary: true),
|
||||||
|
]
|
||||||
|
if isDualZone {
|
||||||
|
metrics.append(Metric("target_right", "Soll rechts",
|
||||||
|
rightTarget.map(Double.init), unit: unitSymbol, precision: 0))
|
||||||
|
}
|
||||||
|
snapshot.metrics = metrics
|
||||||
|
snapshot.state = stateText
|
||||||
|
if isLocked { snapshot.offReasons = ["Bedienfeld gesperrt"] }
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,14 @@ struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
|
|||||||
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
|
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
|
||||||
/// drin, den auf die Uhr zu schicken nichts brächte.
|
/// drin, den auf die Uhr zu schicken nichts brächte.
|
||||||
struct WatchFridge: Codable, Equatable, Sendable {
|
struct WatchFridge: Codable, Equatable, Sendable {
|
||||||
|
/// Ob die Box gerade wirklich verbunden ist.
|
||||||
|
///
|
||||||
|
/// Sie wird nur verbunden, während jemand ihre Ansicht offen hat – jede
|
||||||
|
/// Verbindung meldet sich an ihrem Display an. Sonst stehen hier die
|
||||||
|
/// zuletzt gestellten Werte, und die Messwerte fehlen.
|
||||||
|
var isLive: Bool
|
||||||
|
/// Wann der Stand zuletzt von der Box kam.
|
||||||
|
var updated: Date
|
||||||
var isPoweredOn: Bool
|
var isPoweredOn: Bool
|
||||||
var isEco: Bool
|
var isEco: Bool
|
||||||
var isLocked: Bool
|
var isLocked: Bool
|
||||||
@@ -133,6 +141,9 @@ enum WatchCommand: Codable, Equatable, Sendable {
|
|||||||
case fridgeEco(device: UUID, eco: Bool)
|
case fridgeEco(device: UUID, eco: Bool)
|
||||||
case fridgeLock(device: UUID, locked: Bool)
|
case fridgeLock(device: UUID, locked: Bool)
|
||||||
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
|
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
|
||||||
|
/// Die Kühlbox-Ansicht auf der Uhr wurde geöffnet oder geschlossen. Das
|
||||||
|
/// iPhone verbindet die Box daraufhin – oder gibt sie wieder frei.
|
||||||
|
case fridgeSession(device: UUID, wanted: Bool)
|
||||||
|
|
||||||
/// Ob der Befehl den schnellen Sendetakt verlängert.
|
/// Ob der Befehl den schnellen Sendetakt verlängert.
|
||||||
var wantsLiveUpdates: Bool {
|
var wantsLiveUpdates: Bool {
|
||||||
@@ -149,6 +160,26 @@ enum WatchFridgeZone: String, Codable, Equatable, Sendable {
|
|||||||
|
|
||||||
// MARK: - Verpacken
|
// MARK: - Verpacken
|
||||||
|
|
||||||
|
extension WatchFridge {
|
||||||
|
/// Aus dem zuletzt gestellten Stand, ohne Messwerte.
|
||||||
|
init(_ settings: FridgeSettings) {
|
||||||
|
self.init(isLive: false,
|
||||||
|
updated: settings.updated,
|
||||||
|
isPoweredOn: settings.isPoweredOn,
|
||||||
|
isEco: settings.isEco,
|
||||||
|
isLocked: settings.isLocked,
|
||||||
|
isDualZone: settings.isDualZone,
|
||||||
|
unitSymbol: settings.unitSymbol,
|
||||||
|
leftTarget: settings.leftTarget,
|
||||||
|
leftCurrent: nil,
|
||||||
|
rightTarget: settings.rightTarget,
|
||||||
|
rightCurrent: nil,
|
||||||
|
minTarget: settings.usesFahrenheit ? -22 : -30,
|
||||||
|
maxTarget: settings.usesFahrenheit ? 68 : 20,
|
||||||
|
batteryVolts: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension WatchPayload {
|
extension WatchPayload {
|
||||||
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
|
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
|
||||||
|
|
||||||
|
|||||||
+43
-1
@@ -698,6 +698,44 @@ checkEqual("falsche Prüfsumme wird verworfen",
|
|||||||
checkEqual("angefangenes Paket wird aufgehoben",
|
checkEqual("angefangenes Paket wird aufgehoben",
|
||||||
AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0)
|
AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0)
|
||||||
|
|
||||||
|
// MARK: 9b – Kühlbox: der Stand, der die Verbindung überdauert
|
||||||
|
|
||||||
|
print("\nKühlbox – zuletzt gestellter Stand")
|
||||||
|
|
||||||
|
// Derselbe Datensatz wie oben, nur diesmal auf das eingedampft, was ohne
|
||||||
|
// Verbindung noch gilt.
|
||||||
|
var boxState = AlpicoolState()
|
||||||
|
boxState.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [
|
||||||
|
0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07,
|
||||||
|
]))
|
||||||
|
|
||||||
|
guard let boxSettings = boxState.settings else {
|
||||||
|
check("Stand lässt sich aus dem Datensatz ableiten", false)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
checkEqual("eingeschaltet", boxSettings.isPoweredOn, true)
|
||||||
|
checkEqual("Eco", boxSettings.isEco, true)
|
||||||
|
checkEqual("Solltemperatur", boxSettings.leftTarget, 4)
|
||||||
|
checkEqual("Einheit", boxSettings.unitSymbol, "°C")
|
||||||
|
checkEqual("in Worten", boxSettings.stateText, "Eco")
|
||||||
|
|
||||||
|
// Die Übersicht zeigt den Sollwert – und ausdrücklich keine Innentemperatur.
|
||||||
|
let boxSnapshot = boxSettings.snapshot(deviceID: UUID())
|
||||||
|
checkEqual("Hauptwert ist der Sollwert", boxSnapshot.primaryMetric?.key, "target_left")
|
||||||
|
checkEqual("Sollwert steht drin", value(boxSnapshot, "target_left"), 4)
|
||||||
|
checkEqual("keine gemessene Temperatur", boxSnapshot.metrics.contains { $0.key == "temp_left" }, false)
|
||||||
|
checkEqual("keine Bordspannung", boxSnapshot.metrics.contains { $0.key == "supply_voltage" }, false)
|
||||||
|
|
||||||
|
// Ohne Antwort der Box gibt es auch nichts aufzuheben.
|
||||||
|
checkEqual("leerer Zustand ergibt keinen Stand", AlpicoolState().settings == nil, true)
|
||||||
|
|
||||||
|
// Auf der Uhr dasselbe Bild: Sollwerte ja, Messwerte nein.
|
||||||
|
let watchBox = WatchFridge(boxSettings)
|
||||||
|
checkEqual("auf der Uhr als nicht live gekennzeichnet", watchBox.isLive, false)
|
||||||
|
checkEqual("Sollwert kommt mit", watchBox.leftTarget, 4)
|
||||||
|
checkEqual("Messwert bleibt leer", watchBox.leftCurrent == nil, true)
|
||||||
|
|
||||||
// MARK: 10 – VanAlign Neigungsmesser
|
// MARK: 10 – VanAlign Neigungsmesser
|
||||||
print("\nVanAlign-Neigungsmesser")
|
print("\nVanAlign-Neigungsmesser")
|
||||||
|
|
||||||
@@ -1136,7 +1174,9 @@ var watchOrientation = SensorOrientation()
|
|||||||
watchOrientation.longitudinalSource = .roll
|
watchOrientation.longitudinalSource = .roll
|
||||||
watchOrientation.invertLateral = true
|
watchOrientation.invertLateral = true
|
||||||
|
|
||||||
let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
|
let watchFridge = WatchFridge(isLive: true,
|
||||||
|
updated: Date(timeIntervalSince1970: 1_699_999_000),
|
||||||
|
isPoweredOn: true, isEco: true, isLocked: false,
|
||||||
isDualZone: false, unitSymbol: "°C",
|
isDualZone: false, unitSymbol: "°C",
|
||||||
leftTarget: 4, leftCurrent: 6,
|
leftTarget: 4, leftCurrent: 6,
|
||||||
rightTarget: nil, rightCurrent: nil,
|
rightTarget: nil, rightCurrent: nil,
|
||||||
@@ -1229,6 +1269,8 @@ let commands: [WatchCommand] = [
|
|||||||
.fridgeEco(device: watchFridgeID, eco: true),
|
.fridgeEco(device: watchFridgeID, eco: true),
|
||||||
.fridgeLock(device: watchFridgeID, locked: true),
|
.fridgeLock(device: watchFridgeID, locked: true),
|
||||||
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
|
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
|
||||||
|
.fridgeSession(device: watchFridgeID, wanted: true),
|
||||||
|
.fridgeSession(device: watchFridgeID, wanted: false),
|
||||||
]
|
]
|
||||||
for command in commands {
|
for command in commands {
|
||||||
guard let message = try? command.message(),
|
guard let message = try? command.message(),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ swiftc -O -o "$OUT/tests" \
|
|||||||
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
|
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
|
||||||
Shared/Bluetooth/VanAlignProtocol.swift \
|
Shared/Bluetooth/VanAlignProtocol.swift \
|
||||||
Shared/Models/DeviceSnapshot.swift \
|
Shared/Models/DeviceSnapshot.swift \
|
||||||
|
Shared/Models/FridgeSettings.swift \
|
||||||
Shared/Models/ConfiguredDevice.swift \
|
Shared/Models/ConfiguredDevice.swift \
|
||||||
Shared/Models/LevelState.swift \
|
Shared/Models/LevelState.swift \
|
||||||
Shared/Models/Profile.swift \
|
Shared/Models/Profile.swift \
|
||||||
|
|||||||
Reference in New Issue
Block a user