252 lines
9.7 KiB
Swift
252 lines
9.7 KiB
Swift
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()
|
||
}
|
||
}
|