import CoreBluetooth import Foundation import Observation /// Die eigene Funkverbindung der Uhr zum Neigungsmesser. /// /// Für dieses eine Gerät geht die Uhr direkt hin, statt über das iPhone. Das /// ist genau dort möglich, wo es beim Rest nicht geht: /// /// * Der Neigungsmesser **bewirbt seinen Dienst**. Die Uhr findet ihn also von /// selbst und muss nichts eingerichtet bekommen – anders als BMS und /// Kühlbox, die über die Kennung des Peripheriegeräts gesucht werden. /// * Er ist **unverschlüsselt**. Es braucht keinen Victron-Schlüssel, der auf /// der Uhr ein zweites Mal lagern müsste. /// /// Damit ist die Nivellierung auf der Uhr eigenständig: kein geöffnetes iPhone, /// keine Reichweite zum Telefon. Für alles andere bleibt der Weg über das /// iPhone, siehe `PhoneLink`. /// /// Gefunkt wird nur im Vordergrund. watchOS lässt eine App im Hintergrund /// ohnehin kaum scannen, und beim Ausrichten schaut man auf die Uhr. @Observable final class WatchLevelRadio: NSObject { private(set) var state = LevelState() private(set) var link: DeviceLinkState = .idle /// Wann zuletzt ein Wert vom Sensor kam. private(set) var updatedAt: Date? private(set) var statusText = "Bluetooth wird gestartet…" /// Ob die Einbaulage aus dem Gerät selbst stammt. Dann hat sie Vorrang vor /// dem, was das iPhone meldet – im Gerät steht, wie der Sensor eingebaut /// ist, und das ist für alle Clients dasselbe. private(set) var hasDeviceOrientation = false /// Die Einbaulage des Sensors. Kommt vom Gerät oder vom iPhone und wird /// hier behalten, /// damit die Uhr auch ohne Telefon richtig rechnet – ohne sie stünden /// längs und quer je nach Einbau vertauscht oder mit falschem Vorzeichen. var orientation = SensorOrientation.identity { didSet { guard orientation != oldValue else { return } Self.store(orientation) let value = orientation queue.async { self.session?.orientation = value } } } private let queue = DispatchQueue(label: "de.fritob.CamperMonitor.watch.ble") private var central: CBCentralManager? private var peripheral: CBPeripheral? private var session: LevelSession? private var isRunning = false /// Wie lange nach einem Fehlschlag gewartet wird, bevor neu gesucht wird. /// /// Ohne diese Bremse dreht es sich im Kreis: Ist der Sensor gerade nicht zu /// haben – etwa weil er schon mit dem iPhone spricht –, folgt auf jeden /// Verbindungsversuch sofort der Abbruch, auf den Abbruch sofort die /// nächste Suche, und das so schnell, wie das Funkmodul es zulässt. Die /// Uhr ist dann mit Verbindungsaufbau beschäftigt statt mit Anzeigen, und /// watchOS schiesst die App irgendwann wegen Speicher ab. private var retryDelay: TimeInterval = 1 private static let maximumRetryDelay: TimeInterval = 8 private var isRetryScheduled = false /// Anzeigewerte höchstens fünfmal je Sekunde weiterreichen. Der Sensor /// meldet je nach Firmware deutlich öfter; jede Meldung ist sonst ein /// Neuzeichnen der Oberfläche, und dafür ist die Uhr zu klein. private static let publishInterval: TimeInterval = 0.2 private var lastPublished = Date.distantPast /// Kennung für die Momentaufnahmen dieser Sitzung. Auf der Uhr gibt es /// keine Geräteliste, in der sie nachgeschlagen würde – sie muss nur /// innerhalb eines Laufs gleich bleiben. private let deviceID = UUID() override init() { super.init() orientation = Self.storedOrientation() } // MARK: - Steuerung func start() { isRunning = true if central == nil { central = CBCentralManager(delegate: self, queue: queue) } else { queue.async { self.scanIfPossible() } } } func stop() { isRunning = false queue.async { self.central?.stopScan() self.session?.stop() if let peripheral = self.peripheral, peripheral.state != .disconnected { self.central?.cancelPeripheralConnection(peripheral) } } publish { self.link = .idle } } private func scanIfPossible() { guard isRunning, let central, central.state == .poweredOn else { return } if let peripheral, peripheral.state == .connected { return } central.scanForPeripherals(withServices: [LevelSession.serviceUUID]) publish { self.link = .searching self.statusText = "Suche Neigungsmesser…" } } /// Nach einem Fehlschlag: erst warten, dann wieder suchen. Die Wartezeit /// wächst, bis der Sensor wieder antwortet. private func scanAfterBackOff() { guard isRunning, !isRetryScheduled else { return } isRetryScheduled = true let delay = retryDelay retryDelay = min(retryDelay * 2, Self.maximumRetryDelay) queue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.isRetryScheduled = false self?.scanIfPossible() } } private func publish(_ work: @escaping () -> Void) { if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } } // MARK: - Einbaulage merken private static let orientationKey = "sensorOrientation" private static func storedOrientation() -> SensorOrientation { guard let data = UserDefaults.standard.data(forKey: orientationKey), let value = try? JSONDecoder().decode(SensorOrientation.self, from: data) else { return .identity } return value } private static func store(_ orientation: SensorOrientation) { guard let data = try? JSONEncoder().encode(orientation) else { return } UserDefaults.standard.set(data, forKey: orientationKey) } } // MARK: - CBCentralManagerDelegate extension WatchLevelRadio: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { switch central.state { case .poweredOn: scanIfPossible() case .unauthorized: publish { self.link = .failed("Bluetooth nicht erlaubt") self.statusText = "Bluetooth für die App erlauben" } case .poweredOff: publish { self.link = .failed("Bluetooth aus") self.statusText = "Bluetooth ist ausgeschaltet" } default: publish { self.statusText = "Bluetooth nicht verfügbar" } } } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { // Nach dem Abschalten der Suche können noch Meldungen nachkommen. Ein // zweiter Verbindungsversuch auf dasselbe Gerät bringt nichts und // erzeugt nur eine weitere Trennung, auf die wieder eine Suche folgt. if let existing = self.peripheral, existing.state != .disconnected { return } // Gesucht wird nach dem Dienst, nicht nach dem Namen: der lässt sich in // der Firmware ändern, die Dienstkennung nicht. central.stopScan() self.peripheral = peripheral central.connect(peripheral) publish { self.link = .connecting self.statusText = "Verbinde…" } } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { let session = LevelSession( deviceID: deviceID, peripheral: peripheral, queue: queue, onUpdate: { _ in }, onStateChange: { [weak self] state in self?.publish { self?.link = state } }, onLevelState: { [weak self] state in guard let self else { return } // Es kommen Werte: Der Sensor ist zu haben, die Wartezeit darf // wieder von vorn beginnen. self.retryDelay = 1 let now = Date() guard now.timeIntervalSince(self.lastPublished) >= Self.publishInterval else { return } self.lastPublished = now self.publish { self.state = state self.updatedAt = now self.statusText = "Direkt am Sensor" } }, onDeviceOrientation: { [weak self] orientation in guard let self else { return } Self.store(orientation) self.publish { self.hasDeviceOrientation = true self.orientation = orientation } }) session.orientation = orientation self.session = session session.start() } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { publish { self.link = .failed(error?.localizedDescription ?? "Verbindung gescheitert") } scanAfterBackOff() } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { session?.handleDisconnect() session = nil publish { // Die zuletzt gemessene Lage bleibt stehen; wie alt sie ist, steht // daneben. Sie wegzuwerfen hiesse, beim Rangieren mitten in der // Bewegung einen leeren Bildschirm zu zeigen. self.link = .searching self.statusText = "Verbindung verloren – suche erneut" } scanAfterBackOff() } }