App-Namen im iOS-Projekt auf VanControl vereinheitlichen

CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten
solar-integration-Branch) liefen unter zwei verschiedenen internen
Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro"
heisst. Jetzt durchgängig VanControl:

- Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/,
  VanAligneiOSWidget/ → VanControl/, VanControlWatch/,
  VanControlComplication/, VanControlWidget/
- Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle
  Targets/Schemes/Produktnamen entsprechend umbenannt
- Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl*
  (App noch nicht veröffentlicht); dabei auch die
  WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte
  de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes
  trugen
- Swift-Dateien/Typen: CamperMonitorApp → VanControlApp,
  VanAligneiOSWidget* → VanControlWidget*
- Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh
  auf die neuen Pfade angepasst

Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign"
als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein
App-Name) sowie der komplette Android/-Ordner.

Build (App, Watch, Debug) und Protokoll-Testlauf grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fototeddy
2026-09-06 21:25:28 +02:00
co-authored by Claude Sonnet 5
parent 83ea85f3b8
commit 303a9735d0
79 changed files with 190 additions and 190 deletions
+251
View File
@@ -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.VanControl.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()
}
}