first commit
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user