13 Commits
Author SHA1 Message Date
BiasFandClaude Opus 5 812874baef Kühlbox-Kachel: "Soll" dazuschreiben, Betriebsart gross, Hinweis nach vorn
Auf der Kachel stand eine nackte Zahl, und das ist genau die Zahl, die
man für die Innentemperatur hält. Jetzt steht "Soll" darüber – bei zwei
Zonen "Soll links", mit "Soll rechts" gleich daneben statt in der
Nebenwertzeile.

Die Betriebsart bekommt ein eigenes Schild in Titelgrösse, rechts auf
Höhe des Sollwerts: Eco oder Max in der Akzentfarbe hinterlegt, Aus grau.
Ob die Box überhaupt läuft, ist die zweite Frage nach dem Sollwert, und
Platz ist auf der Kachel reichlich.

In der Fusszeile steht der Hinweis aufs Verbinden jetzt vorn: "Verbindet
erst beim Öffnen · zuletzt gestellt vor 12 Minuten". Er erklärt, warum
hier kein Messwert steht – das ist die Frage, die sich zuerst stellt, das
Alter kommt danach.

Im Vorführbetrieb steht die Kühlbox ebenfalls auf "zuletzt gestellt"
statt live. Sonst zeigt er ein Verhalten, das es nicht mehr gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:21:09 +02:00
BiasFandClaude Opus 5 5fec4a55dd 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>
2026-08-31 22:00:10 +02:00
BiasFandClaude Opus 5 8aa8658b5c Uhr: Nivellierung auf drei Seiten, Assistent passt ohne Scrollen
Alles auf einem Bildschirm hiess scrollen, und auf dieser Grösse ist das
der falsche Handgriff: Einen Wisch trifft man beim Rangieren auch ohne
hinzusehen, eine Scrollposition nicht. Was gerade zählt, hängt ausserdem
vom Arbeitsschritt ab.

Jetzt drei Seiten zum Wischen:

* Neigung – Fahrzeugansicht oder Libelle, was zu tun ist, Herkunft der
  Werte
* Keile – die Höhe je Rad. Fehlen Spurweite und Radstand, steht das dort,
  statt dass die Seite leer wirkt.
* Ausrichten – der Assistent, vorher hinter einem Knopf

Der Assistent ist dabei enger gesetzt: kleinere Zahl, zwei Zeilen Rat mit
Skalierung, kleinere Libelle, und kein ScrollView mehr. Beim Rangieren
schaut man kurz hin, da darf nichts ausserhalb des Bildes liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:59:52 +02:00
BiasFandClaude Opus 5 82df240ebc Komplikation: andere Bundle-Kennung, die alte war bei Apple vergeben
Beim Registrieren lehnte Apple `…watchkitapp.complication` ab: „cannot be
registered to your development team because it is not available." Das
heisst dort immer, die Kennung gehört bereits jemandem – vergebene
Kennungen gibt Apple nie wieder frei, auch nicht nach dem Löschen.

Jetzt `…watchkitapp.levelwidget`. An der Erweiterung hing noch nichts,
das kostet also nichts. Der Anfang muss die Kennung der Watch-App
bleiben, das verlangt iOS für App-Erweiterungen; frei wählbar ist nur
der letzte Teil.

Geprüft: Bau läuft durch, die Erweiterung trägt die neue Kennung und den
Erweiterungspunkt com.apple.widgetkit-extension. Auf der Uhr liegt sie
samt Watch-App und startet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:34:35 +02:00
BiasFandClaude Opus 5 0e05ca22d7 Komplikation fürs Zifferblatt, und die App heisst jetzt VanControl Pro
Zwei Dinge in einem Commit, weil sie sich dieselben Dateien teilen:
project.pbxproj und WatchRootView.swift tragen beides.

**Komplikation.** Neues Target CamperMonitorComplication, auf watchOS
sind Komplikationen WidgetKit-Widgets. Es wird in die Watch-App
eingebettet (PlugIns) und bietet die vier Zubehör-Familien an. Ein Tipp
öffnet die App direkt auf der Nivellierung statt in der Geräteliste:
Die Komplikation hängt eine Adresse an, die Watch-App führt dafür jetzt
einen Navigationsweg, den sie von aussen setzen kann. Die Adresse steht
in Shared/WatchDeepLink.swift, weil beide Seiten sie brauchen – ein
Tippfehler auf einer Seite bliebe sonst stumm.

Bewusst ohne Messwert: Eine Komplikation läuft in einem eigenen Prozess
und käme an die Werte nur über eine App-Gruppe, und die verlangt eine
bezahlte Mitgliedschaft. Ein Wert, der beim Blick aufs Zifferblatt
Stunden alt sein kann, wäre ohnehin schlimmer als keiner – man glaubt
ihm. Die Komplikation tut deshalb genau eine Sache, und die zuverlässig.

**Rebranding.** Der sichtbare Name ist überall VanControl Pro:
CFBundleDisplayName beider Apple-Targets, Android-Label, die Texte in
beiden Apps, beide READMEs und der Name der APK im Gitea-Ablauf.

Die Kennungen bleiben, wie sie sind – Bundle-IDs, Android-Paket,
Target-, Ordner- und Schemanamen. Eine geänderte Bundle-ID ist für iOS
eine neue App: Geräte, Fahrzeugprofile und Victron-Schlüssel wären weg,
dazu neue Profile und eine neu zu koppelnde Watch-App. Sichtbar ist
davon nichts, also gibt es dafür auch keinen Grund.

Geprüft: alle drei Apple-Targets bauen, die Erweiterung liegt im
Watch-Bundle unter PlugIns, die Anzeigenamen stimmen im gebauten
Bundle, Tests grün. Die neue Bundle-ID der Erweiterung braucht beim
ersten Lauf aus Xcode ein Profil, das Xcode selbst anlegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:10:54 +02:00
BiasFandClaude Opus 5 f0df433421 Kalibrierung und Einbaulage gehören in den Sensor, nicht in die Apps
Die Kalibrier-Nullpunkte lagen schon immer im ESP und wurden dort auch
abgezogen – nur verriet die Firmware sie niemandem. Die Einbaulage lag
dagegen in jeder App einzeln, und damit stand sie bei drei Endgeräten
dreimal da, im Zweifel dreimal verschieden. Sie beschreibt aber den
Einbau und nicht das Telefon.

Zwei neue Charakteristiken:

    ...3426  Nullpunkte, zwei Floats, nur lesen
    ...3428  Einbaulage, acht Byte, lesen und schreiben

        0      Version, 1 = gültig gesetzt, 0 = nie geschrieben
        1      Längsachse: 0 = Pitch des Sensors, 1 = Roll
        2      längs umgekehrt (0/1)
        3      quer umgekehrt (0/1)
        4..7   Verdrehung um die Hochachse, float32, Grad

Das Versionsbyte löst die Umstellung: Steht dort 0, hat nie jemand
geschrieben – dann gilt weiter, was die App örtlich gespeichert hat, und
beim nächsten Bestimmen wandert sie hinauf. Mit älterer Firmware ohne die
Charakteristik passiert schlicht nichts.

Angewandt wird die Lage weiterhin in den Apps. Der Sensor verwahrt sie
nur: Meldete er die Winkel bereits umgerechnet, rechnete jede ältere App
die Korrektur ein zweites Mal ein.

Beim Verbinden liest jede App die Lage und übernimmt sie; nach dem
Einbaulage-Assistenten schreibt sie sie hinauf. Die Uhr braucht das
iPhone dafür nicht mehr – sie holt sie sich selbst. Die Android-App
bekommt dabei auch die Verdrehung, die ihrer Fassung bisher ganz fehlte:
Modell, Erkennung und Persistenz nachgezogen.

Geprüft: Firmware übersetzt, im erzeugten Code stehen beide
Charakteristiken mit den richtigen Rechten und der Schreib-Handler;
iPhone- und Watch-Ziel bauen; sieben neue Prüfungen in run-tests.sh
nageln das Byte-Format fest, samt Rundlauf und der Bedeutung von
Version 0. Nicht geprüft ist die Android-Fassung: Auf dieser Maschine
liegt nur Java 8, Gradle verlangt mindestens 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:50:23 +02:00
BiasF 0761706d98 Revert "Neigungsmesser: BLE-Wert bei jeder Messung setzen statt einmal beim Start"
This reverts commit cee9129f66.
2026-08-31 20:29:32 +02:00
BiasFandClaude Opus 5 a9a44b49d6 Einbaulage: Verdrehung um die Hochachse messen – und Geräte dabei nicht verlieren
Sitzt der Sensor schräg statt längs im Fahrzeug, verteilt sich eine reine
Querneigung auf beide Achsen: Das Fahrzeug kippt zur Seite, und die
Längsanzeige kippt sichtbar mit – bei 20° Verdrehung mit gut einem
Drittel des Werts. Achsentausch und Vorzeichen halfen dagegen nicht, die
springen in 90°-Schritten.

SensorOrientation bekommt deshalb "twist", einen stufenlosen Winkel. Der
Einbaulage-Assistent misst ihn ohne zusätzlichen Handgriff mit: Beim
Kippen der Front nach unten dürfte sich nur die Längsneigung ändern;
wandert die Querneigung mit, ist das der gesuchte Winkel. Unter zwei Grad
gilt es als Wackeln der Hand. Angezeigt wird er in der Zusammenfassung
("um 20° verdreht"), herausgerechnet bei jeder Anzeige.

Beim Kalibrieren liesse sich das grundsätzlich nicht ermitteln: Es misst
eine einzige Lage und zieht sie als Nullpunkt ab. Eine Drehung um die
Hochachse steckt darin nicht – eben sieht in jeder Verdrehung gleich aus.

Das neue Feld hat beim Aufspielen sämtliche eingerichteten Geräte
gekostet, und der Grund gehört hierher: Swifts erzeugte Codable-Umsetzung
verlangt beim Lesen jedes Feld, Standardwerte im Code zählen nicht.
Gespeicherte Einbaulagen hatten kein "twist", also scheiterte das Lesen
der Einbaulage, damit des Geräts, damit der ganzen Liste – und das
nächste Speichern schrieb die leere Liste über den Bestand.

Zwei Vorkehrungen dagegen:

* SensorOrientation liest jetzt von Hand und nachsichtig, mit
  decodeIfPresent und Rückfallwerten, wie Profile und ConfiguredDevice es
  längst tun. Jedes künftige Feld gehört dort hinein.
* DeviceStore liest die Liste zweistufig: scheitert sie als Ganzes, wird
  jedes Gerät einzeln versucht und behalten, was lesbar ist. Die
  Rohdaten wandern zusätzlich in einen eigenen Schlüssel, statt beim
  nächsten Speichern überschrieben zu werden.

Beides in run-tests.sh abgesichert: die Erkennung und Rückrechnung der
Verdrehung samt Gegenprobe ohne Korrektur, und das Lesen gespeicherter
Geräte ohne "twist", ganz ohne Einbaulage und mit unbrauchbarer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:40:45 +02:00
BiasFandClaude Opus 5 4936a767a3 Uhr: Verbindungsversuche bremsen, Messwerte drosseln, Bilder verkleinern
Die Uhr hatte Hänger und wurde von watchOS wegen Speicherverbrauch
abgeschossen. Ursache war eine Schleife ohne Bremse: Ist der Sensor
gerade nicht zu haben, folgte auf jeden Verbindungsversuch sofort der
Abbruch, auf den Abbruch sofort die nächste Suche – so schnell, wie das
Funkmodul es zulässt. Jeder Durchlauf schiebt Arbeit auf den
Hauptthread; die Uhr war dann mit Verbindungsaufbau beschäftigt statt
mit Anzeigen.

Drei Änderungen:

* Nach einem Fehlschlag wird gewartet, beginnend bei einer Sekunde und
  verdoppelnd bis acht. Kommen wieder Werte, beginnt die Wartezeit von
  vorn.
* Fundmeldungen, die nach dem Abschalten der Suche noch nachkommen,
  werden verworfen. Sie lösten je einen weiteren Verbindungsversuch aus.
* Messwerte gehen höchstens fünfmal je Sekunde an die Oberfläche. Der
  Sensor meldet zehnmal je Sekunde und für zwei Achsen getrennt, das
  waren bis zu zwanzig Neuzeichnungen pro Sekunde.

Dazu die Fahrzeugbilder der Watch-App von iPhone-Auflösung (925x600 und
600x600) auf Bildschirmgrösse gebracht. Auf einem 40-mm-Display wurden
sie in voller Grösse entpackt, ohne dass davon etwas zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:40:25 +02:00
BiasFandClaude Opus 5 cee9129f66 Neigungsmesser: BLE-Wert bei jeder Messung setzen statt einmal beim Start
Seit dem Neubau mit ESPHome 2026.6.5 lieferte der Sensor bei jeder Abfrage
denselben Wert. Der Grund steht im erzeugten Code: Ein getemplatetes
"value:" an einer Charakteristik wird von der neuen Fassung in eine
Aktion übersetzt, die genau einmal in setup() abläuft.

    void setup() {
      ...
      esp32_ble_server_..._setvalueaction_id->play();    // Pitch
      esp32_ble_server_..._setvalueaction_id_2->play();  // Roll

Danach stand in der Charakteristik für immer der Wert vom Start – beim
Hochfahren ist das NAN oder die erste Messung. Die Apps lasen also
gewissenhaft eine Zahl, die sich nie mehr änderte; in der Einbaulage-
Bestimmung sah das aus, als merke sich die App sofort einen Wert.

Jetzt setzt jeder Sensor seinen Wert selbst, über on_value. Das
getemplatete "value:" an den Charakteristiken entfällt – ESPHome lehnt
beides nebeneinander ohnehin ab. Im erzeugten Code steht set_value
dadurch nicht mehr in setup(), sondern an den Auslösern der beiden
Sensoren.

Notify bleibt bewusst aus: Die Apps fragen im Takt ab, das funktioniert,
und eine frühere Fassung mit notify liess das Gerät verstummen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:40:10 +02:00
BiasFandClaude Opus 5 396e9ed004 Auffahrkeile: Höhe je Rad statt getrennt für quer und längs
Bisher standen dort zwei Angaben nebeneinander, etwa "rechts 8 cm" und
"vorne 4 cm". Das liest sich wie zwei Aufgaben, ist aber eine: Unter
"rechts" liegen zwei Räder, unter "vorne" auch, und eines davon ist
dasselbe. Ausführen lässt sich das so nicht.

Gerechnet wird jetzt über alle vier Aufstandspunkte auf einmal. Aus
Spurweite, Radstand und den beiden Winkeln ergeben sich deren Höhen;
angehoben wird auf das höchststehende Rad, das liegen bleibt. Aus dem
Beispiel oben werden damit 12 cm vorne rechts, 8 cm hinten rechts, 4 cm
vorne links und nichts hinten links – dieselbe Geometrie, nur richtig
zusammengesetzt.

Angezeigt wird das als Draufsicht auf das Fahrzeug (WheelLiftPlan), auf
iPhone und Uhr dieselbe Ansicht, auf der Uhr kompakt. LevelingWedge
entfällt.

Abgesichert in run-tests.sh: reine Querneigung, reine Längsneigung,
beides zusammen (16,1 / 9,2 / 7,0 / 0 cm), gespiegelte Vorzeichen und
die Fälle ohne Fahrzeugmasse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:06:11 +02:00
BiasFandClaude Opus 5 e47ef25188 Uhr: Neigungsmesser direkt, Fahrzeugansicht, kein Kalibrieren
Die Uhr funkt den Neigungsmesser jetzt selbst an, statt die Werte vom
iPhone weiterreichen zu lassen. Bei diesem einen Gerät geht das, und nur
bei ihm: Es bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar,
und es ist unverschlüsselt – es gibt keinen Schlüssel, der auf der Uhr
ein zweites Mal lagern müsste. Damit steht die Nivellierung am Handgelenk
ohne geöffnetes iPhone, und genau dafür hebt man beim Rangieren den Arm.

Batterie, Solar und Kühlbox bleiben beim Weg über das iPhone: Sie lassen
nur eine Verbindung zu, und die Victron-Schlüssel liegen in dessen
Keychain. Die Einbaulage des Sensors reist einmal mit dem Datensatz
herüber und bleibt auf der Uhr gespeichert – ohne sie stünden längs und
quer je nach Einbau vertauscht.

LevelSession und VanAlignProtocol wandern nach Shared/Bluetooth; beide
sind reines CoreBluetooth und laufen auf watchOS unverändert. Die
Fahrzeugansicht gibt es jetzt auch auf der Uhr, umschaltbar zur Libelle;
Überhöhung und Farbregeln stehen in Shared/VehicleTilt.swift, damit
iPhone und Uhr nicht auseinanderlaufen.

Kalibrieren fällt auf der Uhr weg, samt Befehl. Das gehört einmalig ans
iPhone mit ebenem Fahrzeug; ein Knopf dafür am Handgelenk wäre vor allem
eine Gelegenheit, die Nullage aus Versehen zu verstellen.

Zwei Fehler dabei behoben:

* Beim Start meldete die Uhr dem iPhone nie, dass jemand hinschaut –
  onChange(of: scenePhase) feuert beim ersten Erscheinen nicht. Der
  schnelle Sendetakt blieb aus, bis die App einmal im Hintergrund war.
* Ein einmal direkt gemessener Wert hatte für immer Vorrang. Blieb der
  Sensor stehen, ohne die Verbindung zu trennen, klebte die Anzeige
  daran. Jetzt gilt er drei Sekunden als frisch, danach übernimmt der
  Stand des iPhones – mit Alter und Grund daneben.

Ausserdem WATCHOS_DEPLOYMENT_TARGET auf 10.0 (Xcode hatte 11.6 gesetzt)
und die Bluetooth-Begründung für das Watch-Target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:51:38 +02:00
BiasFandClaude Opus 5 6612b07fd5 Apple Watch: Übersicht, Ausrichten mit Vibration, Kühlbox am Handgelenk
Neues Target CamperMonitorWatch, eingebettet in die iPhone-App. Die Uhr
zeigt je Gerät den Hauptwert, die Nivellierung mit Libelle und Keilhöhen,
den Ausrichtungs-Assistenten und stellt die Kühlbox.

Die Uhr funkt nicht selbst, obwohl watchOS das könnte. BMS, Kühlbox und
Neigungsmesser lassen jeweils nur eine Verbindung zu – eine mitlesende Uhr
würde dem iPhone die Verbindung wegnehmen, statt sie zu ergänzen. Und die
Victron-Schlüssel liegen in der Keychain des iPhones; sie ein zweites Mal
auf der Uhr aufzubewahren brächte nichts. Das iPhone bleibt also das
Funkgerät und reicht fertige Messwerte über WatchConnectivity weiter.

Gesendet wird in zwei Takten: ein halber Sekundentakt als Nachricht,
solange die Watch-App im Vordergrund ist und das über eine Frist meldet,
sonst alle zwei Sekunden als Anwendungskontext und nur bei Änderungen.
Läuft die Frist ab, hört das iPhone von selbst wieder auf.

Solange die Uhr hinschaut, hält die App das Funkgerät auch im Hintergrund
am Leben (UIBackgroundModes in Config/CamperMonitor-Info.plist) – beim
Rangieren liegt das iPhone sonst mit dunklem Bildschirm in der Halterung
und die Anzeige am Handgelenk wäre genau dann tot. Die verbundenen Geräte
liefern dabei weiter, die Victron-Werbedaten nicht: ungefiltertes Suchen
lässt iOS im Hintergrund nicht zu.

Der plattformneutrale Modellcode wandert nach Shared/ und wird in beide
Targets übersetzt; LevelState ist dafür aus VanAlignProtocol.swift
herausgelöst. Datensatz und Befehle stehen in Shared/WatchLink und sind
in run-tests.sh abgesichert: Rundlauf, Schutz gegen fremde Formatversion,
Vergleich ohne Zeitstempel und die Auflösung der Zeitangaben.

Geprüft sind der Bau beider Targets samt Einbetten und der Testlauf; auf
echter Hardware noch nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:20:16 +02:00
62 changed files with 4120 additions and 221 deletions
+4 -4
View File
@@ -59,7 +59,7 @@ jobs:
-PversionName="$VERSION" \
-PversionCode="$CODE" --no-daemon
mv app/build/outputs/apk/release/app-release.apk \
"../CamperMonitor-$VERSION.apk"
"../VanControlPro-$VERSION.apk"
- name: An das Release hängen
env:
@@ -88,7 +88,7 @@ jobs:
fi
curl -sf -X POST -H "$AUTH" \
-F "attachment=@CamperMonitor-$VERSION.apk" \
"$API/$ID/assets?name=CamperMonitor-$VERSION.apk" > /dev/null
-F "attachment=@VanControlPro-$VERSION.apk" \
"$API/$ID/assets?name=VanControlPro-$VERSION.apk" > /dev/null
echo "CamperMonitor-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
echo "VanControlPro-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
@@ -372,6 +372,11 @@ class BluetoothManager(
onUpdate = { record(it) },
onStateChange = { handleLinkState(device, it) },
onLevelState = { state -> publish { levelStates[device.id] = state } },
onDeviceOrientation = { orientation ->
// Im Gerät steht, wie der Sensor eingebaut ist für alle
// Clients dasselbe. Also übernehmen statt überschreiben.
adoptOrientation(orientation, device.id)
},
)
session.orientation = device.sensorOrientation
levelSessions[device.address] = session
@@ -642,8 +647,32 @@ class BluetoothManager(
// MARK: - Neigungsmesser
/**
* Nach einer Änderung der Einbaulage aufrufen.
*
* Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie
* beschreibt den Einbau, und iPhone wie Uhr finden sie dann vor, ohne dass
* jemand sie ein zweites Mal bestimmen muss.
*/
fun updateSensorOrientation(device: ConfiguredDevice) {
handler.post { levelSessions[device.address]?.orientation = device.sensorOrientation }
handler.post {
val session = levelSessions[device.address] ?: return@post
session.orientation = device.sensorOrientation
session.storeOrientation(device.sensorOrientation)
}
}
/**
* Übernimmt die Einbaulage, die das Gerät meldet.
*
* Steht dort noch nichts, meldet das Gerät nichts dann bleibt es bei der
* örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen
* hinaufgeschrieben.
*/
private fun adoptOrientation(orientation: SensorOrientation, deviceID: UUID) {
val device = store.devices.firstOrNull { it.id == deviceID } ?: return
if (device.sensorOrientation == orientation) return
store.update(device.copy(sensorOrientation = orientation))
}
fun calibrateLevel(deviceID: UUID) {
@@ -30,6 +30,12 @@ class LevelSession(
private val onUpdate: (DeviceSnapshot) -> Unit,
private val onStateChange: (DeviceLinkState) -> Unit,
private val onLevelState: (LevelState) -> Unit,
/**
* Meldet die Einbaulage, die im Gerät steht. Sie gilt vor der örtlich
* gespeicherten: dort steht, wie der Sensor eingebaut ist, und das ist für
* alle Clients dasselbe.
*/
private val onDeviceOrientation: (SensorOrientation) -> Unit = {},
) {
private companion object {
val SERVICE: UUID = UUID.fromString(VanAlignProtocol.SERVICE_UUID)
@@ -37,6 +43,7 @@ class LevelSession(
val ROLL: UUID = UUID.fromString(VanAlignProtocol.ROLL_UUID)
val OFFSETS: UUID = UUID.fromString(VanAlignProtocol.OFFSETS_UUID)
val CALIBRATE: UUID = UUID.fromString(VanAlignProtocol.CALIBRATE_UUID)
val ORIENTATION: UUID = UUID.fromString(VanAlignProtocol.ORIENTATION_UUID)
val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
const val POLL_INTERVAL_MS = 1_000L
}
@@ -53,6 +60,7 @@ class LevelSession(
private var rollCharacteristic: BluetoothGattCharacteristic? = null
private var offsetsCharacteristic: BluetoothGattCharacteristic? = null
private var calibrateCharacteristic: BluetoothGattCharacteristic? = null
private var orientationCharacteristic: BluetoothGattCharacteristic? = null
/** Ob die Firmware die Werte von sich aus meldet. */
private var needsPolling = true
@@ -84,6 +92,7 @@ class LevelSession(
rollCharacteristic = null
offsetsCharacteristic = null
calibrateCharacteristic = null
orientationCharacteristic = null
}
/** Setzt die aktuelle Lage als neue Null. */
@@ -107,6 +116,22 @@ class LevelSession(
handler.postDelayed({ readOffsets() }, 400)
}
/**
* Schreibt die Einbaulage ins Gerät, damit alle Clients dieselbe sehen.
*
* Ältere Firmware hat die Charakteristik nicht; dann bleibt es bei der
* örtlich gespeicherten Fassung, und es passiert schlicht nichts.
*/
fun storeOrientation(value: SensorOrientation) {
val characteristic = orientationCharacteristic ?: return
val g = gatt ?: return
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = VanAlignProtocol.encoded(value)
g.writeCharacteristic(characteristic)
// Zurücklesen, damit gilt, was wirklich im Gerät steht.
handler.postDelayed({ gatt?.readCharacteristic(characteristic) }, 300)
}
private fun readOffsets() {
val characteristic = offsetsCharacteristic ?: return
gatt?.readCharacteristic(characteristic)
@@ -169,6 +194,7 @@ class LevelSession(
rollCharacteristic = service.getCharacteristic(ROLL)
offsetsCharacteristic = service.getCharacteristic(OFFSETS)
calibrateCharacteristic = service.getCharacteristic(CALIBRATE)
orientationCharacteristic = service.getCharacteristic(ORIENTATION)
val canNotify = pitchCharacteristic?.let {
it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
@@ -267,6 +293,26 @@ class LevelSession(
state = state.copy(pitchOffset = pitch, rollOffset = roll)
}
publish()
// Android lässt nur eine Abfrage gleichzeitig zu, deshalb der
// Reihe nach: erst die Offsets, dann die Einbaulage, dann der
// Takt. Fehlt die Charakteristik ältere Firmware , geht es
// sofort weiter.
val characteristic = orientationCharacteristic
if (characteristic != null) {
gatt?.readCharacteristic(characteristic)
} else {
startPollingIfNeeded()
}
}
ORIENTATION -> {
// Was im Gerät steht, gilt. Steht dort nichts (Version 0),
// bleibt es bei der örtlichen Fassung, und der Aufrufer
// schreibt sie hinauf.
VanAlignProtocol.orientation(value)?.let { stored ->
if (stored != orientation) orientation = stored
onDeviceOrientation(stored)
}
startPollingIfNeeded()
}
}
@@ -206,6 +206,7 @@ class DeviceStore(context: Context) {
put("orientationSource", device.sensorOrientation.longitudinalSource.name)
put("invertLongitudinal", device.sensorOrientation.invertLongitudinal)
put("invertLateral", device.sensorOrientation.invertLateral)
put("twist", device.sensorOrientation.twist)
}
private fun deviceFrom(json: JSONObject): ConfiguredDevice? {
@@ -227,6 +228,9 @@ class DeviceStore(context: Context) {
}.getOrDefault(SensorOrientation.Source.PITCH),
invertLongitudinal = json.optBoolean("invertLongitudinal", false),
invertLateral = json.optBoolean("invertLateral", false),
// Fehlt der Wert, ist er null so überleben Einrichtungen aus
// der Zeit vor der Verdrehung.
twist = json.optDouble("twist", 0.0),
),
)
}
@@ -89,7 +89,7 @@ fun WithBluetoothPermission(content: @Composable () -> Unit) {
if (asked) {
Text(
"Die Freigabe wurde abgelehnt. Sie lässt sich in den " +
"Android-Einstellungen unter „Apps → Camper Monitor" +
"Android-Einstellungen unter „Apps → VanControl Pro" +
"Berechtigungen“ nachholen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
@@ -65,7 +65,7 @@ fun SettingsScreen(store: DeviceStore, onBack: () -> Unit) {
)
Text(
"Camper Monitor liest Victron-Geräte über ihr Advertisement mit " +
"VanControl Pro liest Victron-Geräte über ihr Advertisement mit " +
"(„Instant Readout“) und spricht Batterie, Kühlbox und " +
"Neigungsmesser direkt über Bluetooth an.",
style = MaterialTheme.typography.bodySmall,
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Camper Monitor</string>
<string name="app_name">VanControl Pro</string>
</resources>
@@ -1,6 +1,9 @@
package de.fritob.campermonitor.protocol
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
/**
* Wie der Neigungsmesser im Fahrzeug sitzt.
@@ -21,18 +24,42 @@ data class SensorOrientation(
val longitudinalSource: Source = Source.PITCH,
val invertLongitudinal: Boolean = false,
val invertLateral: Boolean = false,
/**
* Verdrehung des Sensors um die Hochachse, in Grad der Rest, den der
* Achsentausch nicht abdeckt.
*
* Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
* Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
* Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
* helfen dagegen nicht, die springen in 90°-Schritten.
*/
val twist: Double = 0.0,
) {
/** Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert. */
enum class Source { PITCH, ROLL }
val isIdentity: Boolean get() = this == IDENTITY
/** Rechnet Sensorwerte in Fahrzeugwerte um. */
/**
* Rechnet Sensorwerte in Fahrzeugwerte um.
*
* Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
* Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
* Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene genau
* deshalb lässt sich die Verdrehung überhaupt herausrechnen.
*/
fun apply(pitch: Double?, roll: Double?): Pair<Double?, Double?> {
val longitudinal = if (longitudinalSource == Source.PITCH) pitch else roll
val lateral = if (longitudinalSource == Source.PITCH) roll else pitch
return longitudinal?.let { if (invertLongitudinal) -it else it } to
lateral?.let { if (invertLateral) -it else it }
val mappedLongitudinal = longitudinal?.let { if (invertLongitudinal) -it else it }
val mappedLateral = lateral?.let { if (invertLateral) -it else it }
if (twist == 0.0 || mappedLongitudinal == null || mappedLateral == null) {
return mappedLongitudinal to mappedLateral
}
val angle = Math.toRadians(twist)
return (mappedLongitudinal * cos(angle) + mappedLateral * sin(angle)) to
(-mappedLongitudinal * sin(angle) + mappedLateral * cos(angle))
}
val summary: String
@@ -42,6 +69,7 @@ data class SensorOrientation(
if (longitudinalSource == Source.ROLL) parts.add("Achsen getauscht")
if (invertLongitudinal) parts.add("längs umgekehrt")
if (invertLateral) parts.add("quer umgekehrt")
if (twist != 0.0) parts.add("um %.0f° verdreht".format(twist))
return parts.joinToString(", ")
}
@@ -68,6 +96,9 @@ object OrientationDetection {
/** Soviel deutlicher muss die gewinnende Deutung sein als die andere. */
const val AMBIGUITY_MARGIN = 1.3
/** Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand. */
const val MINIMUM_TWIST = 2.0
data class Reading(val pitch: Double, val roll: Double) {
operator fun minus(other: Reading) = Reading(pitch - other.pitch, roll - other.roll)
}
@@ -131,16 +162,31 @@ object OrientationDetection {
if (abs(longitudinal) < MINIMUM_TILT || abs(lateral) < MINIMUM_TILT) {
return Result.Error(Failure.TOO_LITTLE_MOVEMENT)
}
val coarse = SensorOrientation(
longitudinalSource = source,
// Front nach unten heisst: das Heck steht höher, die
// Längsneigung des Fahrzeugs ist also positiv.
invertLongitudinal = longitudinal < 0,
// Linke Seite nach unten heisst: rechts steht höher, die
// Querneigung ist positiv.
invertLateral = lateral < 0,
)
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
// schräg und zwar um genau diesen Winkel.
val (correctedLong, correctedLat) = coarse.apply(nose.pitch, nose.roll)
val residual = if (correctedLong != null && correctedLat != null) {
Math.toDegrees(atan2(correctedLat, correctedLong))
} else {
0.0
}
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
// als keine.
return Result.Success(
SensorOrientation(
longitudinalSource = source,
// Front nach unten heisst: das Heck steht höher, die
// Längsneigung des Fahrzeugs ist also positiv.
invertLongitudinal = longitudinal < 0,
// Linke Seite nach unten heisst: rechts steht höher, die
// Querneigung ist positiv.
invertLateral = lateral < 0,
)
coarse.copy(twist = if (abs(residual) >= MINIMUM_TWIST) residual else 0.0)
)
}
}
@@ -24,6 +24,9 @@ object VanAlignProtocol {
/** Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage. */
const val CALIBRATE_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
/** Acht Byte: die Einbaulage, lesbar und schreibbar. Siehe [orientation]. */
const val ORIENTATION_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233428"
val calibrateCommand = byteArrayOf(0x01)
val resetCommand = byteArrayOf(0x00)
@@ -50,6 +53,55 @@ object VanAlignProtocol {
return value.toDouble()
}
/**
* Die Einbaulage, wie sie im Gerät liegt.
*
* Acht Byte:
*
* 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
* 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
* 2 längs umgekehrt (0/1)
* 3 quer umgekehrt (0/1)
* 4..7 Verdrehung um die Hochachse, float32, Grad
*
* Sie gehört ins Gerät, weil sie den Einbau beschreibt und nicht das
* Telefon: iPhone, Uhr und Android sollen dieselbe sehen, ohne sie je
* einzeln zu bestimmen. Gerechnet wird trotzdem in den Apps das Gerät
* verwahrt sie nur, sonst rechnete ein älterer Client die Korrektur ein
* zweites Mal ein.
*
* Version 0 heisst „hier stand noch nie etwas" und ergibt null; dann gilt,
* was die App örtlich gespeichert hat, und sie schreibt es hinauf.
*/
fun orientation(data: ByteArray): SensorOrientation? {
if (data.size < 8 || data.u(0) != 1) return null
val twist = angle(data, 4) ?: return null
return SensorOrientation(
longitudinalSource = if (data.u(1) == 1) {
SensorOrientation.Source.ROLL
} else {
SensorOrientation.Source.PITCH
},
invertLongitudinal = data.u(2) != 0,
invertLateral = data.u(3) != 0,
twist = twist,
)
}
/** Dieselben acht Byte in die andere Richtung. */
fun encoded(orientation: SensorOrientation): ByteArray {
val bytes = ByteArray(8)
bytes[0] = 1
bytes[1] = if (orientation.longitudinalSource == SensorOrientation.Source.ROLL) 1 else 0
bytes[2] = if (orientation.invertLongitudinal) 1 else 0
bytes[3] = if (orientation.invertLateral) 1 else 0
val raw = orientation.twist.toFloat().toRawBits()
for (index in 0 until 4) {
bytes[4 + index] = ((raw shr (8 * index)) and 0xFF).toByte()
}
return bytes
}
/** Die beiden gespeicherten Offsets. */
fun offsets(data: ByteArray): Pair<Double, Double>? {
if (data.size < 8) return null
+341 -7
View File
@@ -6,8 +6,59 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* CamperMonitorComplication.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
AA0000000000000000000028 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000022;
remoteInfo = CamperMonitorWatch;
};
AA0000000000000000000043 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000037;
remoteInfo = CamperMonitorComplication;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
AA0000000000000000000026 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000041 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
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; };
AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = "<group>"; };
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 */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -16,6 +67,21 @@
path = CamperMonitor;
sourceTree = "<group>";
};
AA0000000000000000000017 /* CamperMonitorWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitorWatch;
sourceTree = "<group>";
};
AA0000000000000000000018 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
AA0000000000000000000033 /* CamperMonitorComplication */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitorComplication;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -26,6 +92,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000020 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000035 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -33,6 +113,10 @@
isa = PBXGroup;
children = (
AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000017 /* CamperMonitorWatch */,
AA0000000000000000000033 /* CamperMonitorComplication */,
AA0000000000000000000018 /* Shared */,
AA0000000000000000000031 /* Config */,
AA0000000000000000000005 /* Products */,
);
sourceTree = "<group>";
@@ -41,10 +125,21 @@
isa = PBXGroup;
children = (
AA0000000000000000000001 /* CamperMonitor.app */,
AA0000000000000000000016 /* CamperMonitorWatch.app */,
AA0000000000000000000032 /* CamperMonitorComplication.appex */,
);
name = Products;
sourceTree = "<group>";
};
AA0000000000000000000031 /* Config */ = {
isa = PBXGroup;
children = (
AA0000000000000000000030 /* CamperMonitor-Info.plist */,
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */,
);
path = Config;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -55,18 +150,65 @@
AA0000000000000000000007 /* Sources */,
AA0000000000000000000003 /* Frameworks */,
AA0000000000000000000008 /* Resources */,
AA0000000000000000000026 /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
AA0000000000000000000029 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000018 /* Shared */,
);
name = CamperMonitor;
productName = CamperMonitor;
productReference = AA0000000000000000000001 /* CamperMonitor.app */;
productType = "com.apple.product-type.application";
};
AA0000000000000000000022 /* CamperMonitorWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */;
buildPhases = (
AA0000000000000000000019 /* Sources */,
AA0000000000000000000020 /* Frameworks */,
AA0000000000000000000021 /* Resources */,
AA0000000000000000000041 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
AA0000000000000000000044 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA0000000000000000000017 /* CamperMonitorWatch */,
AA0000000000000000000018 /* Shared */,
);
name = CamperMonitorWatch;
productName = CamperMonitorWatch;
productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */;
productType = "com.apple.product-type.application";
};
AA0000000000000000000037 /* CamperMonitorComplication */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */;
buildPhases = (
AA0000000000000000000034 /* Sources */,
AA0000000000000000000035 /* Frameworks */,
AA0000000000000000000036 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000018 /* Shared */,
AA0000000000000000000033 /* CamperMonitorComplication */,
);
name = CamperMonitor;
productName = CamperMonitor;
productReference = AA0000000000000000000001 /* CamperMonitor.app */;
productType = "com.apple.product-type.application";
name = CamperMonitorComplication;
productName = CamperMonitorComplication;
productReference = AA0000000000000000000032 /* CamperMonitorComplication.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
@@ -81,6 +223,12 @@
AA0000000000000000000006 = {
CreatedOnToolsVersion = 26.6;
};
AA0000000000000000000022 = {
CreatedOnToolsVersion = 26.6;
};
AA0000000000000000000037 = {
CreatedOnToolsVersion = 26.6;
};
};
};
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */;
@@ -99,6 +247,8 @@
projectRoot = "";
targets = (
AA0000000000000000000006 /* CamperMonitor */,
AA0000000000000000000022 /* CamperMonitorWatch */,
AA0000000000000000000037 /* CamperMonitorComplication */,
);
};
/* End PBXProject section */
@@ -111,6 +261,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000021 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000036 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -121,8 +285,36 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000019 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000034 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
AA0000000000000000000029 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilter = ios;
target = AA0000000000000000000022 /* CamperMonitorWatch */;
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
};
AA0000000000000000000044 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0000000000000000000037 /* CamperMonitorComplication */;
targetProxy = AA0000000000000000000043 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
AA0000000000000000000012 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -190,7 +382,8 @@
DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -220,7 +413,8 @@
DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -239,6 +433,128 @@
};
name = Release;
};
AA0000000000000000000023 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Debug;
};
AA0000000000000000000024 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Release;
};
AA0000000000000000000038 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Debug;
};
AA0000000000000000000039 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -260,6 +576,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000023 /* Debug */,
AA0000000000000000000024 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000038 /* Debug */,
AA0000000000000000000039 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AA0000000000000000000009 /* Project object */;
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app"
BlueprintName = "CamperMonitorWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app"
BlueprintName = "CamperMonitorWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app"
BlueprintName = "CamperMonitorWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -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.
///
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
+131 -7
View File
@@ -178,6 +178,11 @@ final class BluetoothManager: NSObject {
}
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 levelSessions: [UUID: LevelSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
@@ -257,6 +262,13 @@ final class BluetoothManager: NSObject {
for snapshot in DemoData.snapshots() {
snapshots[snapshot.deviceID] = snapshot
linkStates[snapshot.deviceID] = .live
// Die Kühlbox wird nur beim Öffnen verbunden im Vorführbetrieb
// steht sie deshalb wie im Alltag auf zuletzt gestellt.
if snapshot.deviceID == DemoData.fridge.id,
let settings = DemoData.fridgeState.settings {
linkStates[snapshot.deviceID] = .idle
snapshots[snapshot.deviceID] = settings.snapshot(deviceID: snapshot.deviceID)
}
if let value = snapshot.primaryMetric?.value {
history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value)
}
@@ -298,6 +310,14 @@ final class BluetoothManager: NSObject {
fridgeStates = fridgeStates.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) }
}
@@ -309,6 +329,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)
func updateFridgeZoneMode(for device: ConfiguredDevice) {
@@ -381,14 +452,31 @@ final class BluetoothManager: NSObject {
}
/// Nach einer Änderung der Einbaulage aufrufen.
///
/// Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie
/// beschreibt den Einbau, und Uhr wie Android-App finden sie dann vor,
/// ohne dass jemand sie ein zweites Mal bestimmen muss.
func updateSensorOrientation(for device: ConfiguredDevice) {
let deviceID = device.id, orientation = device.sensorOrientation
queue.async {
guard let session = self.levelSession(for: deviceID) else { return }
session.orientation = orientation
session.storeOrientation(orientation)
}
}
/// Übernimmt die Einbaulage, die das Gerät meldet.
///
/// Steht dort noch nichts, meldet das Gerät nichts dann bleibt es bei der
/// örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen
/// hinaufgeschrieben.
private func adoptOrientation(_ orientation: SensorOrientation, for deviceID: UUID) {
guard var device = store.devices.first(where: { $0.id == deviceID }),
device.sensorOrientation != orientation else { return }
device.sensorOrientation = orientation
store.update(device)
}
/// Setzt die aktuelle Lage des Fahrzeugs als neue Null.
func calibrateLevel(for deviceID: UUID) {
queue.async { self.levelSession(for: deviceID)?.calibrate() }
@@ -414,8 +502,10 @@ final class BluetoothManager: NSObject {
private func applyConfiguration(_ devices: [ManagedDevice]) {
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
let wanted = Set(devices.filter { $0.transport == .connect }.map(\.peripheralID))
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören und zu
// 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) {
session.stop()
bmsSessions[peripheralID] = nil
@@ -492,11 +582,22 @@ final class BluetoothManager: NSObject {
private func connectManagedPeripherals() {
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)
}
}
/// 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) {
if let existing = connectedPeripherals[device.peripheralID],
existing.state == .connected || existing.state == .connecting {
@@ -719,8 +820,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
case .connect:
// Das Gerät wurde gesehen falls die Verbindung fehlt, jetzt aufbauen.
if connectedPeripherals[identifier] == nil {
// Das Gerät wurde gesehen falls die Verbindung fehlt, jetzt
// 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)
}
}
@@ -742,6 +846,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
},
onLevelState: { [weak self] state in
self?.publish { self?.levelStates[device.id] = state }
},
onDeviceOrientation: { [weak self] orientation in
// Im Gerät steht, wie der Sensor eingebaut ist für alle
// Clients dasselbe. Also übernehmen statt überschreiben.
self?.publish { self?.adoptOrientation(orientation, for: device.id) }
}
)
session.orientation = device.sensorOrientation
@@ -764,7 +873,14 @@ extension BluetoothManager: CBCentralManagerDelegate {
)
session.onFridgeState = { [weak self] state in
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
bmsSessions[peripheral.identifier] = session
@@ -795,7 +911,15 @@ extension BluetoothManager: CBCentralManagerDelegate {
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
.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 }
if lifetime >= stableConnection {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
+20 -4
View File
@@ -4,12 +4,15 @@ import SwiftUI
struct CamperMonitorApp: App {
@State private var store: DeviceStore
@State private var bluetooth: BluetoothManager
@State private var watch: PhoneWatchLink
@Environment(\.scenePhase) private var scenePhase
init() {
let store = DeviceStore()
let bluetooth = BluetoothManager(store: store)
_store = State(initialValue: store)
_bluetooth = State(initialValue: BluetoothManager(store: store))
_bluetooth = State(initialValue: bluetooth)
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
}
var body: some Scene {
@@ -17,14 +20,27 @@ struct CamperMonitorApp: App {
DashboardView()
.environment(store)
.environment(bluetooth)
.environment(watch)
.task { watch.activate() }
}
.onChange(of: scenePhase) { _, phase in
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
// werden, also Funk sparen und beim Zurückkommen neu starten.
//
// Ausnahme: Solange jemand auf die Uhr schaut, läuft es weiter. Beim
// Rangieren liegt das iPhone in der Halterung mit dunklem Bildschirm
// hörte das Funkgerät dann auf, wäre die Anzeige am Handgelenk
// genau dann tot, wenn sie gebraucht wird. Die verbundenen Geräte
// (Neigungsmesser, BMS, Kühlbox) liefern im Hintergrund weiter; die
// Victron-Werbedaten nicht, dafür müsste ohne Filter gesucht werden.
watch.isAppInBackground = phase != .active
switch phase {
case .active: bluetooth.start()
case .background: bluetooth.stop()
default: break
case .active:
bluetooth.start()
case .background:
if !watch.wantsLiveUpdates { bluetooth.stop() }
default:
break
}
}
}
+76 -3
View File
@@ -9,6 +9,9 @@ final class DeviceStore {
private static let devicesKey = "configuredDevices"
private static let profilesKey = "profiles"
private static let activeProfileKey = "activeProfileID"
/// Hierhin wird gerettet, was sich nicht lesen liess siehe `loadDevices`.
private static let unreadableDevicesKey = "configuredDevices.unreadable"
private static let fridgeSettingsKey = "lastFridgeSettings"
/// Alle Geräte über alle Profile hinweg.
private(set) var devices: [ConfiguredDevice] = []
@@ -16,6 +19,11 @@ final class DeviceStore {
private(set) var activeProfileID: UUID = Profile.defaultID
/// Ob beim Start Geräte im Speicher lagen, die sich nicht lesen liessen.
/// Ihre Rohdaten sind aufgehoben, statt beim nächsten Speichern
/// überschrieben zu werden.
private(set) var hasUnreadableDevices = false
/// Zwischenspeicher, damit nicht bei jedem Advertisement die Keychain
/// befragt wird das passiert bis zu mehrmals pro Sekunde.
@ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:]
@@ -137,6 +145,36 @@ final class DeviceStore {
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
private func load() {
@@ -151,9 +189,8 @@ final class DeviceStore {
profiles = [.initial]
}
if let data = defaults.data(forKey: Self.devicesKey),
let decoded = try? JSONDecoder().decode([ConfiguredDevice].self, from: data) {
devices = decoded
if let data = defaults.data(forKey: Self.devicesKey) {
devices = loadDevices(from: data)
}
if let raw = defaults.string(forKey: Self.activeProfileKey),
@@ -171,6 +208,42 @@ final class DeviceStore {
}
}
/// Liest die Geräteliste so verlustarm wie möglich.
///
/// Der einfache Weg die ganze Liste auf einmal scheitert vollständig,
/// sobald ein einziges Gerät nicht lesbar ist: etwa weil eine neuere
/// Fassung der App ein Feld hinzugefügt hat, das die ältere nicht kennt,
/// oder umgekehrt. Die Liste wäre dann leer, und das nächste Speichern
/// schriebe diese Leere über den Bestand. Genau so gehen Einrichtungen
/// verloren, ohne dass jemand etwas löscht.
///
/// Deshalb zweistufig: erst die ganze Liste, und wenn das misslingt, jedes
/// Gerät für sich. Was dabei übrig bleibt, wird behalten; die Rohdaten
/// wandern zusätzlich in einen eigenen Schlüssel, damit sich der Bestand
/// später noch untersuchen lässt.
private func loadDevices(from data: Data) -> [ConfiguredDevice] {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode([ConfiguredDevice].self, from: data) {
return decoded
}
let salvaged = (try? decoder.decode([Salvage].self, from: data))?
.compactMap(\.device) ?? []
UserDefaults.standard.set(data, forKey: Self.unreadableDevicesKey)
hasUnreadableDevices = true
return salvaged
}
/// Hülle, die ein einzelnes unlesbares Gerät verschluckt, statt die ganze
/// Liste scheitern zu lassen.
private struct Salvage: Decodable {
let device: ConfiguredDevice?
init(from decoder: Decoder) throws {
device = try? ConfiguredDevice(from: decoder)
}
}
private func save() {
let defaults = UserDefaults.standard
if let data = try? JSONEncoder().encode(devices) {
@@ -176,37 +176,37 @@ struct AlignmentAssistantView: View {
@ViewBuilder
private var wedgeSection: some View {
let across = profile?.trackWidth.flatMap { width in
state.roll.flatMap { LevelingWedge.across(roll: $0, trackWidth: width) }
}
let along = profile?.wheelbase.flatMap { base in
state.pitch.flatMap { LevelingWedge.along(pitch: $0, wheelbase: base) }
}
// Gerechnet wird über alle vier Räder auf einmal. Getrennte Angaben für
// quer und längs beschreiben dasselbe Fahrzeug und lassen sich nicht
// getrennt ausführen: Unter rechts" liegen zwei Räder, unter vorne"
// auch, und eines davon ist dasselbe.
let lift: LevelingLift? = {
guard let width = profile?.trackWidth, let base = profile?.wheelbase,
let pitch = state.pitch, let roll = state.roll else { return nil }
return LevelingLift.compute(pitch: pitch, roll: roll,
trackWidth: width, wheelbase: base)
}()
VStack(alignment: .leading, spacing: 10) {
Label("Auffahrkeile", systemImage: "triangle.fill")
.font(.headline)
if profile?.trackWidth == nil && profile?.wheelbase == nil {
if profile?.trackWidth == nil || profile?.wheelbase == nil {
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
+ "sich beim Fahrzeug hinterlegen.")
.font(.callout)
.foregroundStyle(.secondary)
} else if across == nil && along == nil {
Text("Keine Keile nötig.")
.font(.callout)
} else if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift)
.frame(maxWidth: .infinity)
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach([across, along].compactMap { $0 }, id: \.side) { wedge in
HStack {
Text(wedge.side.text.capitalized)
Spacer()
Text(String(format: "%.0f cm", wedge.heightInCentimetres))
.font(.title3.weight(.semibold).monospacedDigit())
}
}
Text("Höhe unter die tieferstehende Seite, damit das Fahrzeug eben steht.")
.font(.caption)
Text("Keine Keile nötig.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
@@ -230,4 +230,3 @@ struct AlignmentAssistantView: View {
}
}
extension LevelingWedge.Side: Hashable {}
+90 -3
View File
@@ -11,7 +11,9 @@ struct DeviceCard: View {
VStack(alignment: .leading, spacing: 12) {
header
if let snapshot, let primary = snapshot.primaryMetric {
if isShowingLastSettings, let snapshot {
lastSettings(for: snapshot)
} else if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted)
.font(.system(size: 44, weight: .semibold, design: .rounded))
@@ -54,6 +56,65 @@ struct DeviceCard: View {
}
}
/// Ob hier der zuletzt gestellte Stand steht statt Messwerten.
private var isShowingLastSettings: Bool {
device.role.connectsOnDemand && linkState != .live
}
/// Die Kachel der Kühlbox, solange sie nicht verbunden ist.
///
/// Hier steht kein Messwert, sondern was zuletzt eingestellt war also
/// gehört Soll dazu. Ohne das läse man die Zahl als Innentemperatur, und
/// genau dieser Irrtum wäre teuer.
private func lastSettings(for snapshot: DeviceSnapshot) -> some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(snapshot.metrics.count > 1 ? "Soll links" : "Soll")
.font(.caption)
.foregroundStyle(.secondary)
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(snapshot.primaryMetric?.formatted ?? "")
.font(.system(size: 44, weight: .semibold, design: .rounded))
Text(snapshot.primaryMetric?.unit ?? "")
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}
}
if let right = snapshot.metrics.first(where: { $0.key == "target_right" }),
right.value != nil {
VStack(alignment: .leading, spacing: 2) {
Text("Soll rechts")
.font(.caption)
.foregroundStyle(.secondary)
Text(right.formattedWithUnit)
.font(.title2.weight(.semibold))
.monospacedDigit()
}
}
Spacer(minLength: 0)
modeBadge(snapshot.state)
}
}
/// Betriebsart als Schild: Auf der Kachel ist Platz, und ob die Box läuft
/// oder aus ist, ist die zweite Frage nach dem Sollwert.
@ViewBuilder
private func modeBadge(_ state: String?) -> some View {
if let state {
let isOff = state == "Aus"
Text(state)
.font(.title3.weight(.semibold))
.foregroundStyle(isOff ? Color.secondary : Color.accentColor)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isOff ? Color.secondary.opacity(0.15)
: Color.accentColor.opacity(0.15),
in: .rect(cornerRadius: 12))
}
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
@@ -76,7 +137,14 @@ struct DeviceCard: View {
@ViewBuilder
private var footer: some View {
if let fault = snapshot?.fault {
if isShowingLastSettings, 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")
.font(.caption)
.foregroundStyle(.red)
@@ -96,11 +164,30 @@ struct DeviceCard: View {
}
}
/// Was unter einem Gerät steht, das nur beim Öffnen verbunden wird.
///
/// Der Hinweis aufs Verbinden steht vorn: Er erklärt, warum hier kein
/// Messwert steht, und das ist die Frage, die sich zuerst stellt.
private func lastSetText(_ snapshot: DeviceSnapshot) -> String {
"Verbindet erst beim Öffnen · zuletzt gestellt \(relativeUpdate(snapshot.timestamp))"
}
/// 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 {
switch linkState {
case .needsKey: return "Verschlüsselungsschlüssel fehlt im Detail eintragen."
case .failed(let message): return message
default: return "Warte auf Daten…"
default:
return device.role.connectsOnDemand
? "Verbindet erst beim Öffnen."
: "Warte auf Daten…"
}
}
}
+7 -1
View File
@@ -114,10 +114,16 @@ struct DeviceDetailView: View {
}
.navigationTitle(currentDevice.name)
.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 {
editedName = currentDevice.name
keyInput = store.victronKeyText(for: device.id) ?? ""
bluetooth.beginSession(for: currentDevice)
}
.confirmationDialog("Gerät entfernen?",
isPresented: $showDeleteConfirmation,
+11
View File
@@ -4,6 +4,7 @@ import SwiftUI
/// betrifft, steht bei diesem Gerät.
struct SettingsView: View {
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
@Environment(PhoneWatchLink.self) private var watch
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -21,6 +22,16 @@ struct SettingsView: View {
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.")
}
Section {
LabeledContent("Apple Watch", value: watch.statusText)
} footer: {
Text("Die Uhr zeigt die Werte dieses iPhones und steuert die "
+ "Kühlbox darüber sie funkt nicht selbst zu den Geräten. "
+ "Dafür muss diese App laufen; im Hintergrund liefern "
+ "Neigungsmesser, BMS und Kühlbox weiter, die "
+ "Victron-Werbedaten erst wieder im Vordergrund.")
}
}
.navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.inline)
+6 -17
View File
@@ -16,11 +16,6 @@ struct VehicleTiltView: View {
let pitch: Double?
let roll: Double?
/// Kleine Neigungen sind am Fahrzeug sonst kaum zu erkennen zwei Grad
/// wären ein knappes Grad Bildneigung. Die Überhöhung wird angeschrieben,
/// damit niemand den Winkel für bare Münze nimmt.
static let exaggeration: Double = 3
var body: some View {
VStack(spacing: 20) {
tiltPanel(image: "VehicleSide",
@@ -28,19 +23,19 @@ struct VehicleTiltView: View {
title: "Längs",
lowerLabel: "Front",
upperLabel: "Heck",
aspect: 925.0 / 600.0)
aspect: VehicleTilt.sideAspect)
tiltPanel(image: "VehicleRear",
angle: roll,
title: "Quer",
lowerLabel: "links",
upperLabel: "rechts",
aspect: 1)
aspect: VehicleTilt.rearAspect)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
Self.exaggeration))
VehicleTilt.exaggeration))
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -61,7 +56,7 @@ struct VehicleTiltView: View {
Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font(.subheadline.weight(.semibold).monospacedDigit())
.foregroundStyle(colour(for: angle))
.foregroundStyle(VehicleTilt.colour(for: angle))
}
ZStack {
@@ -74,9 +69,9 @@ struct VehicleTiltView: View {
Image(image)
.resizable()
.scaledToFit()
.foregroundStyle(colour(for: angle))
.foregroundStyle(VehicleTilt.colour(for: angle))
.aspectRatio(aspect, contentMode: .fit)
.rotationEffect(.degrees(-(angle ?? 0) * Self.exaggeration))
.rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration))
.animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1)
}
@@ -92,12 +87,6 @@ struct VehicleTiltView: View {
}
}
private func colour(for angle: Double?) -> Color {
guard let angle else { return .secondary }
if abs(angle) <= LevelState.levelTolerance { return .green }
if abs(angle) <= 2 { return .orange }
return .red
}
}
/// Umschalter zwischen den beiden Darstellungen, gemerkt über Starts hinweg.
+287
View File
@@ -0,0 +1,287 @@
import Foundation
import Observation
import WatchConnectivity
/// Die Gegenstelle zur Apple Watch auf dem iPhone.
///
/// Das iPhone bleibt das Funkgerät (warum, steht bei `WatchLink`) und reicht
/// den fertigen Stand weiter. Zwei Wege, je nachdem, ob die Uhr gerade
/// hinschaut:
///
/// * **Anwendungskontext** ein Datensatz, den watchOS aufhebt und auch dann
/// zustellt, wenn die App auf der Uhr gerade nicht läuft. Damit steht beim
/// Aufwecken sofort etwas da statt eines leeren Bildschirms.
/// * **Nachricht** nur solange die Uhr erreichbar ist und gemeldet hat, dass
/// sie hinschaut. Dafür im halben Sekundentakt, was beim Ausrichten zählt.
///
/// Ohne gekoppelte Uhr läuft hier gar nichts: kein Timer, keine Sitzung.
@Observable
final class PhoneWatchLink: NSObject {
/// Ob die Uhr gerade Werte sehen will. Solange das gilt, hält die App das
/// Bluetooth auch im Hintergrund am Leben.
private(set) var wantsLiveUpdates = false
/// Nur für die Anzeige in den Einstellungen.
private(set) var statusText = "Keine Uhr gekoppelt"
private let store: DeviceStore
private let bluetooth: BluetoothManager
private var session: WCSession?
private var timer: Timer?
/// Bis wann der Wunsch der Uhr nach schnellen Werten gilt.
private var liveUntil = Date.distantPast
private var lastSentContent: WatchPayload?
private var lastContextSent = Date.distantPast
/// Setzt die App, damit beim Ablauf des Wunsches im Hintergrund das Funken
/// wieder eingestellt wird.
var isAppInBackground = false
init(store: DeviceStore, bluetooth: BluetoothManager) {
self.store = store
self.bluetooth = bluetooth
super.init()
}
func activate() {
guard WCSession.isSupported() else {
statusText = "Dieses Gerät kann keine Uhr koppeln"
return
}
let session = WCSession.default
session.delegate = self
self.session = session
session.activate()
}
// MARK: - Takt
private func updateTimer() {
let wanted = session?.activationState == .activated && session?.isPaired == true
if wanted && timer == nil {
// Der schnelle Takt ist der Grundtakt; ob wirklich gesendet wird,
// entscheidet `tick` so muss der Timer nie umgestellt werden.
let timer = Timer(timeInterval: WatchLink.liveInterval, repeats: true) { [weak self] _ in
self?.tick()
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
} else if !wanted {
timer?.invalidate()
timer = nil
}
}
private func tick() {
let live = Date() < liveUntil
if live != wantsLiveUpdates {
wantsLiveUpdates = live
// Der Wunsch ist gerade abgelaufen und niemand schaut mehr hin:
// dann darf das Funkgerät im Hintergrund auch wieder ruhen.
if !live && isAppInBackground { bluetooth.stop() }
}
// Den Stand erst zusammenstellen, wenn er auch rausgeht: im Ruhetakt
// ist das nur jeder vierte Aufruf, und zusammengestellt wird auf dem
// Hauptthread.
let isContextDue = Date().timeIntervalSince(lastContextSent) >= WatchLink.idleInterval
guard let session, live || isContextDue else { return }
guard let payload = buildPayload() else { return }
if live && session.isReachable {
send(payload, over: session)
return
}
guard isContextDue,
lastSentContent?.hasSameContent(as: payload) != true,
session.isPaired, session.isWatchAppInstalled else { return }
do {
try session.updateApplicationContext(payload.message())
lastSentContent = payload
lastContextSent = Date()
} catch {
statusText = "Uhr: \(error.localizedDescription)"
}
}
/// Nachrichten werden ohne Antwort verschickt: Ein verlorener Datensatz ist
/// in einer halben Sekunde ohnehin überholt, ein Wiederholungsversuch käme
/// zu spät und stünde nur dem nächsten im Weg.
private func send(_ payload: WatchPayload, over session: WCSession) {
guard let message = try? payload.message() else { return }
session.sendMessage(message, replyHandler: nil, errorHandler: nil)
lastSentContent = payload
}
/// Nach Änderungen an Geräten oder Profilen aufrufen, damit die Uhr nicht
/// bis zum nächsten Takt einen überholten Fahrzeugnamen zeigt.
func sendNow() {
lastSentContent = nil
lastContextSent = .distantPast
tick()
}
// MARK: - Stand zusammenstellen
private func buildPayload() -> WatchPayload? {
guard let profile = store.activeProfile else { return nil }
let devices = store.activeDevices.map { device in
WatchDevice(id: device.id,
name: device.name,
role: device.role,
link: bluetooth.linkStates[device.id] ?? .searching,
snapshot: bluetooth.snapshots[device.id],
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
orientation: device.role == .leveling ? device.sensorOrientation : nil,
fridge: device.role == .fridge ? fridgeState(for: device) : nil)
}
return WatchPayload(generatedAt: Date(),
profile: profile,
isRadioReady: bluetooth.isBluetoothReady,
radioStatus: bluetooth.bluetoothStatusText,
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
private func handle(_ command: WatchCommand) {
if command.wantsLiveUpdates {
liveUntil = Date().addingTimeInterval(WatchLink.liveLease)
wantsLiveUpdates = true
// Die Uhr kann das iPhone aus dem Hintergrund wecken. Dann steht
// das Funkgerät und muss erst wieder anlaufen, sonst geht der
// Stellbefehl ins Leere.
if isAppInBackground { bluetooth.start() }
}
switch command {
case .hello:
break
case .fridgePower(let device, let on):
bluetooth.setFridgePower(on, for: device)
case .fridgeEco(let device, let eco):
bluetooth.setFridgeEco(eco, for: device)
case .fridgeLock(let device, let locked):
bluetooth.setFridgeLock(locked, for: device)
case .fridgeTarget(let device, let zone, let value):
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)
}
}
}
}
// MARK: - WCSessionDelegate
extension PhoneWatchLink: WCSessionDelegate {
func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
DispatchQueue.main.async {
if let error {
self.statusText = "Uhr: \(error.localizedDescription)"
} else {
self.updateStatus(session)
}
self.updateTimer()
self.sendNow()
}
}
private func updateStatus(_ session: WCSession) {
if !session.isPaired {
statusText = "Keine Uhr gekoppelt"
} else if !session.isWatchAppInstalled {
statusText = "Uhr gekoppelt, App dort nicht installiert"
} else {
statusText = "Uhr bereit"
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let command = WatchCommand.decode(from: message) else { return }
DispatchQueue.main.async {
self.handle(command)
// Auf einen Stellbefehl gleich den frischen Stand hinterherschicken.
// Der zeigt zwar noch den alten Wert die Box antwortet erst ,
// aber die Uhr weiss dadurch sofort, dass der Befehl ankam.
self.tick()
}
}
/// Mit Antwort, wenn die Uhr auf eine wartet: das ist der Weg, auf dem sie
/// beim Öffnen ohne Verzögerung an einen Stand kommt.
func session(_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
DispatchQueue.main.async {
if let command = WatchCommand.decode(from: message) { self.handle(command) }
replyHandler((try? self.buildPayload()?.message()) ?? [:])
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async { self.sendNow() }
}
/// Die Uhr wurde gekoppelt, gewechselt oder die App dort installiert.
func sessionWatchStateDidChange(_ session: WCSession) {
DispatchQueue.main.async {
self.updateStatus(session)
self.updateTimer()
self.sendNow()
}
}
// Beim Wechsel auf eine andere Uhr muss die Sitzung neu aktiviert werden.
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
}
// MARK: - Umformen
extension WatchFridge {
/// Aus dem Zustand der Box wird nur übernommen, was die Uhr anzeigt oder
/// stellt der Rohdatensatz bleibt auf dem iPhone.
init(_ state: AlpicoolState) {
let range = state.targetRange
self.init(isLive: true,
updated: Date(),
isPoweredOn: state.isPoweredOn,
isEco: state.isEco,
isLocked: state.isLocked,
isDualZone: state.isDualZone,
unitSymbol: state.unitSymbol,
leftTarget: state.leftTarget,
leftCurrent: state.leftCurrent,
rightTarget: state.isDualZone ? state.rightTarget : nil,
rightCurrent: state.isDualZone ? state.rightCurrent : nil,
minTarget: range.lowerBound,
maxTarget: range.upperBound,
batteryVolts: state.batteryVolts)
}
}
@@ -0,0 +1,92 @@
import SwiftUI
import WidgetKit
/// Komplikation fürs Zifferblatt: ein Tipp, und die Nivellierung steht da.
///
/// Bewusst **ohne Messwert**. Eine Komplikation läuft in einem eigenen Prozess
/// und käme an die Werte nur über eine App-Gruppe die verlangt eine bezahlte
/// Entwicklermitgliedschaft. Ein Wert, der beim Blick aufs Zifferblatt
/// womöglich Stunden alt ist, wäre ausserdem schlimmer als keiner: Man würde
/// ihm glauben.
///
/// Die Komplikation tut deshalb genau eine Sache, und die zuverlässig: Sie
/// öffnet die App auf der Nivellierung. Von dort sind die Werte live, weil die
/// Uhr den Sensor selbst anfunkt.
struct LevelComplicationEntry: TimelineEntry {
let date: Date
}
/// Nichts zu planen: Die Anzeige ändert sich nie, also ein einziger Eintrag
/// ohne Nachschub. Das kostet die Uhr auch nichts.
struct LevelComplicationProvider: TimelineProvider {
func placeholder(in context: Context) -> LevelComplicationEntry {
LevelComplicationEntry(date: .now)
}
func getSnapshot(in context: Context,
completion: @escaping (LevelComplicationEntry) -> Void) {
completion(LevelComplicationEntry(date: .now))
}
func getTimeline(in context: Context,
completion: @escaping (Timeline<LevelComplicationEntry>) -> Void) {
completion(Timeline(entries: [LevelComplicationEntry(date: .now)], policy: .never))
}
}
struct LevelComplicationView: View {
@Environment(\.widgetFamily) private var family
var body: some View {
switch family {
case .accessoryInline:
// Eine Zeile Text, mehr lässt diese Familie nicht zu.
Label("Nivellierung", systemImage: "level")
case .accessoryCorner:
Image(systemName: "level")
.font(.title2)
.widgetLabel("Nivellieren")
case .accessoryRectangular:
HStack(spacing: 6) {
Image(systemName: "level")
.font(.title3)
VStack(alignment: .leading, spacing: 0) {
Text("Nivellierung")
.font(.headline)
Text("Antippen zum Ausrichten")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
default:
// Rund auf dem Zifferblatt der knappste Platz, also nur das
// Symbol, gross genug zum Treffen.
ZStack {
AccessoryWidgetBackground()
Image(systemName: "level")
.font(.title2)
}
}
}
}
@main
struct LevelComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "de.fritob.CamperMonitor.complication.level",
provider: LevelComplicationProvider()) { _ in
LevelComplicationView()
// Der Tipp landet nicht irgendwo in der App, sondern auf der
// Nivellierung dafür wertet die App diese Adresse aus.
.widgetURL(WatchDeepLink.level)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName("Nivellierung")
.description("Öffnet die Nivellierung direkt vom Zifferblatt.")
.supportedFamilies([.accessoryCircular, .accessoryCorner,
.accessoryInline, .accessoryRectangular])
}
}
@@ -0,0 +1,4 @@
{
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.400", "green" : "0.620", "red" : "0.110" } }, "idiom" : "universal" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,11 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "VehicleRear.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "VehicleSide.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+48
View File
@@ -0,0 +1,48 @@
import SwiftUI
/// VanControl Pro am Handgelenk.
///
/// Zwei Quellen, jede dort, wo sie hingehört:
///
/// * Der **Neigungsmesser** hängt direkt an der Uhr (`WatchLevelRadio`). Die
/// Nivellierung braucht damit kein geöffnetes iPhone und genau dafür hebt
/// man beim Rangieren den Arm.
/// * **Batterie, Solar und Kühlbox** kommen über das iPhone (`PhoneLink`),
/// weil dort die Schlüssel liegen und die Geräte nur eine Verbindung
/// zulassen.
@main
struct CamperWatchApp: App {
@State private var link = PhoneLink()
@State private var radio = WatchLevelRadio()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
WatchRootView()
.environment(link)
.environment(radio)
.task {
link.activate()
radio.start()
// Beim Start meldet `onChange` nichts: Die Bildschirmphase
// steht schon auf aktiv und ändert sich nicht mehr. Ohne
// diese Zeile bliebe der schnelle Takt des iPhones aus, bis
// die App einmal im Hintergrund war.
link.setLive(true)
}
// Die Einbaulage kommt bevorzugt aus dem Sensor selbst. Nur
// falls dort noch nichts steht ältere Firmware, oder nie
// bestimmt , gilt, was das iPhone meldet.
.onChange(of: link.payload?.levelDevice?.orientation) { _, orientation in
guard let orientation, !radio.hasDeviceOrientation else { return }
radio.orientation = orientation
}
}
.onChange(of: scenePhase) { _, phase in
// Beides kostet Strom und lohnt nur, solange jemand hinschaut.
// Genau das sagt die Bildschirmphase aus.
link.setLive(phase == .active)
if phase == .active { radio.start() } else { radio.stop() }
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import Foundation
import Observation
import WatchConnectivity
/// Die Gegenstelle zum iPhone auf der Uhr.
///
/// Hält den zuletzt empfangenen Stand und schickt Stellbefehle zurück. Gefunkt
/// wird nur zum iPhone zu den Geräten im Fahrzeug nie, siehe `WatchLink`.
///
/// Zwei Quellen laufen zusammen:
///
/// * Der **Anwendungskontext** liegt schon da, wenn die App startet. Deshalb
/// steht sofort etwas auf dem Bildschirm, auch wenn das iPhone in der Tasche
/// steckt und gerade nicht antwortet mit Altersangabe, versteht sich.
/// * **Nachrichten** kommen im halben Sekundentakt, solange wir gemeldet haben,
/// dass jemand hinschaut. Das Melden wird regelmässig erneuert: Schläft die
/// Uhr ein, bleibt die Erneuerung aus und das iPhone hört von selbst auf.
@Observable
final class PhoneLink: NSObject {
private(set) var payload: WatchPayload?
/// Wann der Stand hier ankam nicht, wann er entstand. Beides zusammen
/// unterscheidet iPhone weg von Gerät im Fahrzeug antwortet nicht.
private(set) var receivedAt: Date?
private(set) var isReachable = false
private(set) var isActivated = false
private var session: WCSession?
/// Erneuert die Meldung hier schaut jemand hin im Vorführbetrieb
/// stattdessen der Taktgeber der erfundenen Werte.
private var timer: Timer?
private var isLive = false
/// Ohne iPhone lässt sich auf der Uhr sonst nichts vorführen.
private let isDemo = ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
func activate() {
if isDemo {
startDemo()
return
}
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
self.session = session
session.activate()
}
/// Meldet dem iPhone, ob gerade jemand hinschaut. Beim Ausrichten zählt
/// jeder halbe Sekundentakt, beim Nachsehen des Ladezustands nicht.
func setLive(_ live: Bool) {
guard !isDemo else { return }
isLive = live
timer?.invalidate()
timer = nil
guard live else {
send(.hello(live: false))
return
}
send(.hello(live: true))
// Vor Ablauf der Frist erneuern, damit zwischendurch keine Lücke
// entsteht. Bleibt die Erneuerung aus, endet der schnelle Takt von
// selbst niemand muss ihn abbestellen.
let renew = Timer(timeInterval: WatchLink.liveLease / 3, repeats: true) { [weak self] _ in
self?.send(.hello(live: true))
}
RunLoop.main.add(renew, forMode: .common)
timer = renew
}
/// Schickt einen Befehl und übernimmt die Antwort, falls eine kommt.
///
/// Die Antwort ist der Stand *vor* der Wirkung die Kühlbox antwortet dem
/// iPhone erst ein paar Zehntel später. Sie zeigt also nicht, dass der
/// Befehl gewirkt hat, sondern nur, dass er angekommen ist. Der gestellte
/// Wert erscheint, wenn das Gerät ihn zurückmeldet, so wie am iPhone auch.
func send(_ command: WatchCommand) {
guard !isDemo, let session, session.activationState == .activated,
let message = try? command.message() else { return }
guard session.isReachable else { return }
session.sendMessage(message, replyHandler: { [weak self] reply in
guard let payload = WatchPayload.decode(from: reply) else { return }
DispatchQueue.main.async { self?.accept(payload) }
}, errorHandler: nil)
}
private func accept(_ payload: WatchPayload) {
// Ein Nachzügler darf einen neueren Stand nicht überschreiben.
if let current = self.payload, current.generatedAt > payload.generatedAt { return }
self.payload = payload
receivedAt = Date()
}
// MARK: - Vorführbetrieb
private func startDemo() {
payload = WatchDemo.payload(at: 0)
receivedAt = Date()
isReachable = true
isActivated = true
var step = 0.0
let tick = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in
step += 0.5
self?.payload = WatchDemo.payload(at: step)
self?.receivedAt = Date()
}
RunLoop.main.add(tick, forMode: .common)
timer = tick
}
}
// MARK: - WCSessionDelegate
extension PhoneLink: WCSessionDelegate {
func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
DispatchQueue.main.async {
self.isActivated = state == .activated
self.isReachable = session.isReachable
// Was schon dalag, gilt sofort sonst bliebe der Bildschirm leer,
// bis das iPhone das nächste Mal von sich aus sendet.
if let payload = WatchPayload.decode(from: session.receivedApplicationContext) {
self.accept(payload)
}
if self.isLive { self.send(.hello(live: true)) }
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let payload = WatchPayload.decode(from: message) else { return }
DispatchQueue.main.async { self.accept(payload) }
}
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
guard let payload = WatchPayload.decode(from: context) else { return }
DispatchQueue.main.async { self.accept(payload) }
}
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async {
self.isReachable = session.isReachable
if session.isReachable, self.isLive { self.send(.hello(live: true)) }
}
}
}
@@ -0,0 +1,115 @@
import SwiftUI
import WatchKit
/// Der Ausrichtungs-Assistent am Handgelenk der eigentliche Grund für die
/// Uhr: Beim Rangieren hat man das iPhone nicht in der Hand, aber den Arm am
/// Lenkrad. Ein Blick nach unten genügt, und die Rückmeldung steht eben kommt
/// zusätzlich als Vibration, ganz ohne Blick.
///
/// Gerechnet wird mit demselben `AlignmentAssistant` wie am iPhone: kein
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
/// schlechter wird, steckt vollständig darin.
struct WatchAlignView: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
@State private var assistant = AlignmentAssistant()
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
@State private var hasAnnouncedTarget = false
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
private var state: LevelState? { reading.hasReading ? reading.state : nil }
var body: some View {
// Ohne ScrollView: Beim Rangieren schaut man kurz hin, da darf nichts
// ausserhalb des Bildes liegen. Alles ist deshalb so bemessen, dass es
// auch auf der kleinsten Uhr auf einen Blick passt.
VStack(spacing: 3) {
if let current = assistant.current {
Text(String(format: "%.1f°", current.deviation))
.font(.system(size: 30, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
.contentTransition(.numericText())
} else {
ProgressView()
.padding(.vertical, 6)
}
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
.font(.caption2.weight(.medium))
.foregroundStyle(trendColor)
Text(assistant.advice)
.font(.system(size: 11))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.lineLimit(2)
.minimumScaleFactor(0.8)
if let state {
HStack(spacing: 8) {
WatchBubble(pitch: state.pitch, roll: state.roll)
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 1) {
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
Text(String(format: "Quer %.1f°", state.roll ?? 0))
}
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
Button {
assistant.reset()
hasAnnouncedTarget = false
} label: {
Label("Von vorn", systemImage: "arrow.counterclockwise")
.font(.system(size: 10))
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 2)
.navigationTitle("Ausrichten")
.onChange(of: tilt, initial: true) { _, tilt in
guard let tilt else { return }
assistant.add(pitch: tilt.pitch, roll: tilt.roll)
announceIfLevel()
}
}
/// Ein Wertepaar als Auslöser: gerechnet wird nur, wenn sich die Neigung
/// wirklich ändert nicht bei jeder Aktualisierung der Anzeige.
private var tilt: Tilt? {
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
return Tilt(pitch: pitch, roll: roll)
}
private struct Tilt: Equatable {
let pitch: Double
let roll: Double
}
private var trendColor: Color {
switch assistant.trend {
case .improving: return .green
case .worsening: return .orange
default: return .secondary
}
}
/// Die Vibration ist der Punkt: Sie sagt anhalten, ohne dass jemand
/// hinschauen muss. Erneut gemeldet wird erst, wenn das Fahrzeug die
/// Toleranz zwischendurch wieder verlassen hat.
private func announceIfLevel() {
if assistant.hasReachedTarget {
guard !hasAnnouncedTarget else { return }
hasAnnouncedTarget = true
WKInterfaceDevice.current().play(.success)
} else {
hasAnnouncedTarget = false
}
}
}
@@ -0,0 +1,77 @@
import SwiftUI
/// Libelle für die Uhr: eine Blase, die dorthin wandert, wo das Fahrzeug
/// **höher** steht wie bei einer echten Wasserwaage.
///
/// Bewusst schlichter als am iPhone: Auf 40 mm Bildschirm ist der innere Ring
/// (die Toleranz) die einzige Hilfslinie, die sich noch ablesen lässt.
struct WatchBubble: View {
let pitch: Double?
let roll: Double?
/// Bis zu welcher Neigung die Anzeige ausschlägt.
var range: Double = 4
var body: some View {
GeometryReader { geometry in
let side = min(geometry.size.width, geometry.size.height)
let radius = side / 2
let bubble = side * 0.22
let travel = radius - bubble / 2 - 2
let tolerance = max(LevelState.levelTolerance / range * travel, bubble * 0.55)
ZStack {
Circle().fill(Color.gray.opacity(0.25))
Circle().strokeBorder(Color.gray.opacity(0.5), lineWidth: 1)
Circle()
.strokeBorder(isLevel ? Color.green : Color.gray.opacity(0.6),
lineWidth: isLevel ? 2 : 1)
.frame(width: tolerance * 2, height: tolerance * 2)
Circle()
.fill(color)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert nach oben in der Ansicht also nach hinten.
.offset(x: clamped(roll) / range * travel,
y: -clamped(pitch) / range * travel)
.animation(.spring(duration: 0.3), value: pitch)
.animation(.spring(duration: 0.3), value: roll)
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
}
.frame(width: side, height: side)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.aspectRatio(1, contentMode: .fit)
.accessibilityLabel(accessibilityText)
}
/// Hält die Blase im Ring, auch wenn das Fahrzeug stark schräg steht.
private func clamped(_ value: Double?) -> Double {
guard let value else { return 0 }
return min(max(value, -range), range)
}
private var deviation: Double? {
switch (pitch, roll) {
case let (p?, r?): return max(abs(p), abs(r))
case let (p?, nil): return abs(p)
case let (nil, r?): return abs(r)
default: return nil
}
}
private var isLevel: Bool { (deviation ?? .infinity) <= LevelState.levelTolerance }
/// Grün nur, wenn es wirklich eben ist die Akzentfarbe der App ist selbst
/// grün, sonst sähe schief aus wie eben.
private var color: Color {
guard let deviation else { return .gray }
if deviation <= LevelState.levelTolerance { return .green }
return deviation <= 2 ? .orange : .red
}
private var accessibilityText: String {
guard let pitch, let roll else { return "Keine Messwerte" }
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
}
}
@@ -0,0 +1,69 @@
import SwiftUI
/// Alle Werte eines Geräts. Am iPhone stehen hier zusätzlich Verlauf und
/// Diagnose auf der Uhr wäre beides unlesbar und ist deshalb weggelassen.
struct WatchDeviceDetailView: View {
let deviceID: UUID
@Environment(PhoneLink.self) private var link
private var device: WatchDevice? {
link.payload?.devices.first { $0.id == deviceID }
}
var body: some View {
List {
if let device {
if let fridge = device.fridge {
WatchFridgeControls(deviceID: device.id, fridge: fridge)
}
Section {
ForEach(device.snapshot?.metrics.filter { $0.value != nil } ?? []) { metric in
LabeledContent(metric.label) {
Text(metric.formattedWithUnit)
.monospacedDigit()
}
.font(.footnote)
}
} header: {
Text("Messwerte")
}
Section {
if let fault = device.snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(.red)
}
if let state = device.snapshot?.state {
Text(state).font(.caption2)
}
ForEach(device.snapshot?.offReasons ?? [], id: \.self) { reason in
Text(reason)
.font(.caption2)
.foregroundStyle(.secondary)
}
Label(device.link.label, systemImage: "antenna.radiowaves.left.and.right")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Text("Gerät ist nicht mehr im Fahrzeugprofil.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.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))
}
}
}
@@ -0,0 +1,69 @@
import SwiftUI
/// Die Kühlbox von der Uhr aus stellen das einzige Gerät, das sich stellen
/// lässt.
///
/// Wie am iPhone folgen die Schalter dem Gerät, nicht der Vermutung: Getippt
/// wird ein Befehl, angezeigt wird, was die Box zurückmeldet. Über die Uhr
/// dauert das einen Wimpernschlag länger der Weg geht über das iPhone ,
/// dafür kann kein Schalter etwas behaupten, das die Box nie bestätigt hat.
struct WatchFridgeControls: View {
let deviceID: UUID
let fridge: WatchFridge
@Environment(PhoneLink.self) private var link
var body: some View {
Section {
Toggle("Ein", isOn: Binding(
get: { fridge.isPoweredOn },
set: { link.send(.fridgePower(device: deviceID, on: $0)) }
))
Toggle("Eco", isOn: Binding(
get: { fridge.isEco },
set: { link.send(.fridgeEco(device: deviceID, eco: $0)) }
))
.disabled(!fridge.isPoweredOn)
target(zone: .left,
title: fridge.isDualZone ? "Soll links" : "Soll",
value: fridge.leftTarget)
if fridge.isDualZone {
target(zone: .right, title: "Soll rechts", value: fridge.rightTarget)
}
} header: {
Text("Kühlbox")
} footer: {
if !link.isReachable {
Text("iPhone gerade nicht erreichbar Stellbefehle gehen erst "
+ "wieder durch, wenn es antwortet.")
.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)
}
}
}
private func target(zone: WatchFridgeZone, title: String, value: Int?) -> some View {
Stepper(value: Binding(
get: { value ?? fridge.targetRange.lowerBound },
set: { link.send(.fridgeTarget(device: deviceID, zone: zone, value: $0)) }
), in: fridge.targetRange) {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value.map { "\($0) \(fridge.unitSymbol)" } ?? "")
.font(.headline)
.monospacedDigit()
}
}
.disabled(!fridge.isPoweredOn || value == nil)
}
}
@@ -0,0 +1,230 @@
import SwiftUI
/// Nivellierung auf der Uhr, aufgeteilt auf drei Seiten zum Wischen.
///
/// Auf einem Bildschirm dieser Grösse ist Wischen besser als Scrollen: Beim
/// Rangieren trifft man einen Wisch auch ohne hinzusehen, eine Scrollposition
/// dagegen nicht und was gerade wichtig ist, hängt vom Arbeitsschritt ab.
///
/// 1. **Neigung** wie das Fahrzeug steht, als Fahrzeugansicht oder Libelle.
/// 2. **Keile** wie hoch jedes Rad unterlegt werden muss.
/// 3. **Ausrichten** der Assistent fürs Rangieren, mit Vibration.
///
/// Die Werte kommen bevorzugt **direkt vom Sensor** (`WatchLevelRadio`); nur
/// wenn der gerade nicht erreichbar ist, tritt der zuletzt über das iPhone
/// gemeldete Stand an seine Stelle. Woher sie stammen, steht auf der ersten
/// Seite ein alter Wert sieht sonst aus wie ein stillstehendes Fahrzeug.
///
/// Kalibriert wird hier nichts. Das gehört einmalig ans iPhone, mit ebenem
/// Fahrzeug und Ruhe dafür; auf der Uhr wäre der Knopf nur eine Gelegenheit,
/// die Nullage aus Versehen zu verstellen.
struct WatchLevelView: View {
@State private var page = Page.reading
enum Page: Hashable {
case reading, wedges, align
}
var body: some View {
TabView(selection: $page) {
LevelReadingPage()
.tag(Page.reading)
WedgePage()
.tag(Page.wedges)
WatchAlignView()
.tag(Page.align)
}
.tabViewStyle(.page)
}
}
/// Erste Seite: wie das Fahrzeug steht.
struct LevelReadingPage: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
@AppStorage("levelDisplayStyle") private var showsVehicle = true
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
var body: some View {
ScrollView {
VStack(spacing: 8) {
if showsVehicle {
WatchVehicleView(pitch: reading.state.pitch, roll: reading.state.roll)
} else {
WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll)
.frame(maxWidth: 110)
HStack(spacing: 14) {
angle("Längs", reading.state.pitch)
angle("Quer", reading.state.roll)
}
}
Text(reading.state.instruction ?? "Keine Werte")
.font(.footnote.weight(.medium))
.multilineTextAlignment(.center)
.foregroundStyle(reading.state.isLevel ? .green : .primary)
Button {
showsVehicle.toggle()
} label: {
Label(showsVehicle ? "Libelle" : "Fahrzeug",
systemImage: showsVehicle ? "circle.circle" : "box.truck")
}
.font(.caption2)
SourceNote(reading: reading)
}
.padding(.horizontal, 4)
}
.navigationTitle("Neigung")
}
private func angle(_ title: String, _ value: Double?) -> some View {
VStack(spacing: 0) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
.font(.title3.weight(.semibold))
.monospacedDigit()
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
/// Zweite Seite: die Keilhöhen, als Draufsicht auf die vier Räder.
struct WedgePage: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
var body: some View {
ScrollView {
VStack(spacing: 8) {
if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift, isCompact: true)
Text("Zentimeter unter das jeweilige Rad. Das höchste bleibt liegen.")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else if hasMeasurements {
Label("Keine Keile nötig", systemImage: "checkmark.circle.fill")
.font(.footnote)
.foregroundStyle(.green)
.padding(.top, 20)
} else {
// Ohne Fahrzeugmasse lässt sich nichts rechnen. Das gehört
// gesagt, sonst wirkt die Seite kaputt.
Label("Fahrzeugmasse fehlen", systemImage: "ruler")
.font(.footnote.weight(.medium))
Text("Spurweite und Radstand am iPhone im Fahrzeugprofil "
+ "hinterlegen, dann steht hier die Keilhöhe je Rad.")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Keile")
}
private var hasMeasurements: Bool {
link.payload?.profile.trackWidth != nil && link.payload?.profile.wheelbase != nil
}
/// Wie hoch jedes Rad unterlegt werden muss reine Geometrie, und nur zu
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
private var lift: LevelingLift? {
guard let profile = link.payload?.profile,
let width = profile.trackWidth, let base = profile.wheelbase,
let pitch = reading.state.pitch, let roll = reading.state.roll
else { return nil }
return LevelingLift.compute(pitch: pitch, roll: roll,
trackWidth: width, wheelbase: base)
}
}
/// Woher die angezeigte Neigung kommt und wie alt sie ist.
struct SourceNote: View {
let reading: WatchLevelReading
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
let age = reading.age(now: context.date)
Label {
Text(text(age: age))
} icon: {
Image(systemName: reading.isDirect
? "antenna.radiowaves.left.and.right" : "iphone.gen3")
}
.font(.system(size: 10))
.foregroundStyle(age > 5 ? .orange : .secondary)
}
}
private func text(age: TimeInterval) -> String {
guard reading.hasReading else { return reading.statusText }
let source = reading.isDirect ? "Sensor" : "iPhone"
if age < 3 { return "\(source), eben gemessen" }
// Steht der Wert, gehört der Grund dazu und nicht nur das Alter.
if age < 90 { return String(format: "%@, vor %.0f s · %@", source, age, reading.statusText) }
return String(format: "%@, vor %.0f min · %@", source, age / 60, reading.statusText)
}
}
/// Die angezeigte Neigung samt Herkunft.
///
/// Die eigene Verbindung hat Vorrang, solange sie **frisch** ist. Das solange
/// ist der Punkt: Bleibt der Sensor stehen, ohne die Verbindung zu trennen,
/// würde ein einmal gelesener Wert sonst für immer stehenbleiben und eine
/// eingefrorene Anzeige ist beim Ausrichten schlimmer als gar keine. Dann tritt
/// der Stand des iPhones an seine Stelle, und wenn auch der fehlt, bleibt der
/// alte Wert stehen, aber mit seinem Alter daneben.
struct WatchLevelReading {
let state: LevelState
let isDirect: Bool
let measuredAt: Date?
let statusText: String
/// Ab hier gilt ein direkt gemessener Wert als überholt. Gefragt wird
/// zweimal je Sekunde ab; drei Sekunden Stille sind schon viel.
static let directLifetime: TimeInterval = 3
init(radio: WatchLevelRadio, phone: PhoneLink, now: Date = Date()) {
let directAge = radio.updatedAt.map { now.timeIntervalSince($0) }
let hasFreshDirect = radio.state.hasReading
&& (directAge ?? .infinity) <= Self.directLifetime
let relayed = phone.payload?.levelDevice
if hasFreshDirect {
state = radio.state
isDirect = true
measuredAt = radio.updatedAt
statusText = radio.statusText
} else if let relayed, let level = relayed.level {
state = level
isDirect = false
measuredAt = relayed.snapshot?.timestamp ?? phone.payload?.generatedAt
statusText = relayed.link.label
} else if radio.state.hasReading {
state = radio.state
isDirect = true
measuredAt = radio.updatedAt
statusText = radio.statusText
} else {
state = LevelState()
isDirect = false
measuredAt = nil
statusText = radio.statusText
}
}
var hasReading: Bool { state.hasReading }
func age(now: Date = Date()) -> TimeInterval {
guard let measuredAt else { return 0 }
return now.timeIntervalSince(measuredAt)
}
}
@@ -0,0 +1,185 @@
import SwiftUI
/// Der Einstieg: das Wichtigste untereinander, in der Reihenfolge, in der man
/// im Wohnmobil danach schaut.
struct WatchRootView: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
/// Der Weg durch die Ansichten liegt hier, damit ihn auch die Komplikation
/// setzen kann: Ein Tipp aufs Zifferblatt springt direkt zur Nivellierung,
/// statt in der Geräteliste zu landen.
@State private var path: [WatchRoute] = []
var body: some View {
NavigationStack(path: $path) {
content
.navigationTitle(link.payload?.profile.name ?? "Camper")
.navigationDestination(for: WatchRoute.self) { route in
switch route {
case .level: WatchLevelView()
case .device(let id): WatchDeviceDetailView(deviceID: id)
}
}
}
.onOpenURL { url in
guard WatchDeepLink.isLevel(url) else { return }
// Nicht anhängen, sondern ersetzen: Wer vom Zifferblatt kommt, will
// die Nivellierung sehen und nicht einen Stapel von vorhin.
path = [.level]
}
}
private var content: some View {
List {
// Die Nivellierung steht immer oben und immer da: Sie kommt vom
// Sensor selbst und braucht das iPhone nicht.
Section {
NavigationLink(value: WatchRoute.level) {
LevelSummaryRow(reading: WatchLevelReading(radio: radio, phone: link))
}
}
if let payload = link.payload {
Section {
ForEach(payload.devices.filter { $0.role != .leveling }) { device in
NavigationLink(value: WatchRoute.device(device.id)) {
DeviceRow(device: device)
}
}
}
Section {
FreshnessNote(payload: payload, receivedAt: link.receivedAt)
}
} else {
Section {
waiting
} footer: {
Text("Batterie, Solar und Kühlbox kommen vom iPhone. Die "
+ "Nivellierung oben nicht die geht direkt.")
.font(.system(size: 10))
}
}
}
}
private var waiting: some View {
VStack(spacing: 4) {
Label("Warte auf das iPhone", systemImage: "iphone.gen3.radiowaves.left.and.right")
.font(.footnote.weight(.medium))
Text("Dort muss VanControl Pro geöffnet sein.")
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Wohin die Uhr springen kann. Als eigener Typ, weil der Weg von aussen
/// gesetzt wird von der Komplikation.
enum WatchRoute: Hashable {
case level
case device(UUID)
}
/// Eine Gerätezeile: Name klein, Hauptwert gross auf dem kleinen Bildschirm
/// zählt nur der eine Wert, wegen dem man den Arm hebt.
struct DeviceRow: View {
let device: WatchDevice
var body: some View {
HStack(spacing: 8) {
Image(systemName: device.role.symbol)
.foregroundStyle(.tint)
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(device.name)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
if let metric = device.snapshot?.primaryMetric, metric.value != nil {
Text(metric.formattedWithUnit)
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(device.isLive ? .primary : .secondary)
} else {
Text(device.link.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
Circle()
.fill(device.statusColor)
.frame(width: 7, height: 7)
}
}
}
/// Die Nivellierung bekommt eine eigene, grössere Zeile: sie ist der Grund,
/// aus dem man die Uhr im Fahrzeug überhaupt anschaut.
struct LevelSummaryRow: View {
let reading: WatchLevelReading
var body: some View {
HStack(spacing: 10) {
WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Nivellierung")
.font(.caption2)
.foregroundStyle(.secondary)
// Ohne Messwerte steht hier, woran es liegt, statt eines
// nichtssagenden Strichs.
Text(reading.hasReading
? (reading.state.instruction ?? "Keine Werte")
: reading.statusText)
.font(.footnote.weight(.medium))
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
}
/// Wie alt der Stand ist auf der Uhr die wichtigste Nebenangabe. Alles hier
/// ist weitergereicht; eine abgerissene Strecke zum iPhone sieht sonst genauso
/// aus wie ein Fahrzeug, an dem sich nichts tut.
struct FreshnessNote: View {
let payload: WatchPayload
let receivedAt: Date?
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
let age = context.date.timeIntervalSince(receivedAt ?? payload.generatedAt)
Label {
Text(text(age: age))
.font(.caption2)
} icon: {
Image(systemName: age > 10 ? "exclamationmark.triangle.fill" : "iphone.gen3")
}
.foregroundStyle(age > 10 ? .orange : .secondary)
}
}
private func text(age: TimeInterval) -> String {
if !payload.isRadioReady { return payload.radioStatus }
if age < 3 { return "Stand: eben" }
if age < 90 { return String(format: "Stand: vor %.0f s", age) }
return String(format: "Stand: vor %.0f min iPhone erreichbar?", age / 60)
}
}
extension WatchDevice {
/// Grün heisst: der Wert ist frisch und kommt vom Gerät selbst.
var statusColor: Color {
switch link {
case .live: return (snapshot?.isStale ?? true) ? .orange : .green
case .needsKey: return .orange
case .failed: return .red
default: return .gray
}
}
}
@@ -0,0 +1,67 @@
import SwiftUI
/// Die Neigung am Fahrzeug selbst, wie am iPhone nur auf Uhrgrösse gebracht.
///
/// Zwei Ansichten, jede um ihre Achse gekippt: die Seitenansicht für längs
/// (Front links, Heck rechts) und die Heckansicht für quer. Die Heckansicht
/// teilt die Blickrichtung des Fahrers, links im Bild ist also links am
/// Fahrzeug.
struct WatchVehicleView: View {
let pitch: Double?
let roll: Double?
/// Wie am iPhone dreifach überhöht zwei Grad wären sonst kaum zu sehen.
/// Die angeschriebenen Gradzahlen bleiben echt.
var body: some View {
VStack(spacing: 8) {
panel(image: "VehicleSide", angle: pitch, title: "Längs",
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
panel(image: "VehicleRear", angle: roll, title: "Quer",
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
}
}
private func panel(image: String,
angle: Double?,
title: String,
lower: String,
upper: String,
aspect: Double) -> some View {
VStack(spacing: 2) {
HStack {
Text(title)
Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.monospacedDigit()
.foregroundStyle(VehicleTilt.colour(for: angle))
}
.font(.caption2)
ZStack {
// Die Waagerechte als Bezug ohne sie ist eine kleine Neigung
// nicht einzuschätzen.
Rectangle()
.fill(Color.secondary.opacity(0.35))
.frame(height: 1)
Image(image)
.resizable()
.scaledToFit()
.foregroundStyle(VehicleTilt.colour(for: angle))
.aspectRatio(aspect, contentMode: .fit)
.rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration))
.animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1)
}
.frame(height: 52)
HStack {
Text(lower)
Spacer()
Text(upper)
}
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
}
}
+84
View File
@@ -0,0 +1,84 @@
import Foundation
/// Erfundene Werte für den Simulator, damit sich die Ansichten ohne iPhone und
/// ohne Fahrzeug prüfen lassen. Eingeschaltet über `CAMPER_DEMO=1`:
///
/// xcrun simctl launch --terminate-running-process booted \
/// de.fritob.CamperMonitor.watchkitapp
/// # mit SIMCTL_CHILD_CAMPER_DEMO=1 davor
///
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
enum WatchDemo {
private static let profile = Profile(name: "Kastenwagen", symbol: "box.truck",
trackWidth: 1.85, wheelbase: 3.50)
private static let solarID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")!
private static let batteryID = UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!
private static let fridgeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!
private static let levelID = UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!
/// `seconds` läuft weiter, damit sich die Neigung bewegt sonst zeigt der
/// Ausrichtungs-Assistent nie einen Trend.
static func payload(at seconds: Double) -> WatchPayload {
let now = Date()
let swing = sin(seconds / 8)
var level = LevelState()
level.pitch = 1.4 * swing + 0.3
level.roll = -0.9 * cos(seconds / 11)
level.pitchOffset = 0.4
level.rollOffset = -0.2
var solar = DeviceSnapshot(deviceID: solarID, timestamp: now, rssi: -58)
solar.state = "Konstantspannung (Absorption)"
solar.metrics = [
Metric("pv_power", "PV-Leistung", 284 + 40 * swing, unit: "W", precision: 0, primary: true),
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1),
]
var battery = DeviceSnapshot(deviceID: batteryID, timestamp: now, rssi: -64)
battery.state = "Lädt"
battery.metrics = [
Metric("soc", "Ladezustand", 78.4, unit: "%", precision: 1, primary: true),
Metric("voltage", "Spannung", 13.66, unit: "V", precision: 2),
Metric("current", "Strom", 18.6, unit: "A", precision: 1),
Metric("capacity", "Restkapazität", 156.8, unit: "Ah", precision: 1),
]
var fridge = DeviceSnapshot(deviceID: fridgeID, timestamp: now, rssi: -66)
fridge.state = "Kompressor läuft"
fridge.metrics = [
Metric("temperature", "Innentemperatur", 6, unit: "°C", precision: 0, primary: true),
Metric("target", "Solltemperatur", 4, unit: "°C", precision: 0),
Metric("battery_voltage", "Bordspannung", 12.7, unit: "V", precision: 1),
]
return WatchPayload(
generatedAt: now,
profile: profile,
isRadioReady: true,
radioStatus: "Demo-Modus",
devices: [
WatchDevice(id: levelID, name: "Nivellierung", role: .leveling,
link: .live, snapshot: level.snapshot(deviceID: levelID, rssi: -70),
level: level, orientation: .identity, fridge: nil),
WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms,
link: .live, snapshot: battery, level: nil,
orientation: nil, fridge: nil),
WatchDevice(id: solarID, name: "Solar Dach", role: .solarCharger,
link: .live, snapshot: solar, level: nil,
orientation: nil, fridge: nil),
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: fridge, level: nil, orientation: nil,
fridge: WatchFridge(isLive: true, updated: now,
isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6,
rightTarget: nil, rightCurrent: nil,
minTarget: -20, maxTarget: 20,
batteryVolts: 12.7)),
])
}
}
+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.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()
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
</dict>
</plist>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+213 -13
View File
@@ -1,4 +1,4 @@
# Camper Monitor
# VanControl Pro
iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
@@ -12,6 +12,12 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt
Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am
Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt
über das iPhone warum diese Aufteilung, steht unter
[Apple Watch](#apple-watch).
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe
[firmware/vanalign](firmware/vanalign/README.md).
@@ -25,8 +31,21 @@ Dann in Xcode:
1. Target `CamperMonitor`**Signing & Capabilities** → dein Apple-Team auswählen.
Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist.
Dasselbe beim Target `CamperMonitorWatch`; dessen Bundle-ID muss die des
iPhones mit angehängtem `.watchkitapp` bleiben.
2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R.
Das Schema `CamperMonitor` baut die Watch-App mit und bettet sie ein. Dafür
muss die watchOS-Plattform in Xcode installiert sein sonst bricht schon das
Übersetzen des Symbolkatalogs ab:
```bash
xcodebuild -downloadPlatform watchOS
```
Auf die Uhr kommt die App danach von selbst: Sie steckt in der iPhone-App und
taucht in der Watch-App des iPhones unter *Verfügbare Apps* auf.
Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber
nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone.
@@ -101,7 +120,7 @@ Protokoll erkannt wurde, steht in der Detailansicht unter **Diagnose**
zusammen mit der letzten Rohantwort.
Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die
Hersteller-App offen ist, bekommt Camper Monitor keine Verbindung.
Hersteller-App offen ist, bekommt VanControl Pro keine Verbindung.
### Kühlbox
@@ -113,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
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:
Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle
Schalter zeigen den Stand, den die Box zurückmeldet nach jedem Stellbefehl
@@ -156,6 +192,46 @@ Vorzeichen. Der Assistent unter *Sensor → Einbaulage* klärt das durch zwei
Kippbewegungen: einmal die Front nach unten, einmal die linke Seite. Aus der
Reaktion ergibt sich die Zuordnung geraten wird nichts.
Dabei wird auch eine **Verdrehung um die Hochachse** mitgemessen, also der
Fall, dass der Sensor schräg statt längs im Fahrzeug klebt. Ohne diese
Korrektur verteilt sich eine reine Querneigung auf beide Achsen: Das Fahrzeug
kippt zur Seite, und die Längsanzeige kippt sichtbar mit bei 20° Verdrehung
mit gut einem Drittel des Werts. Der Winkel steht im Ergebnis des Assistenten
(„um 20° verdreht") und wird von da an herausgerechnet.
Beim **Kalibrieren** liesse sich das nicht ermitteln, und zwar grundsätzlich
nicht: Es misst eine einzige Lage und zieht sie als Nullpunkt ab. Eine Drehung
um die Hochachse steckt darin nicht eben sieht in jeder Verdrehung gleich
aus. Dafür braucht es zwei Kippbewegungen in bekannte Richtungen, und genau die
macht der Einbaulage-Assistent.
### Beides liegt im Sensor, nicht in der App
Sowohl die **Kalibrierung** als auch die **Einbaulage** speichert der ESP32
selbst und gibt sie an jeden aus, der fragt. Das ist der Punkt, sobald mehr als
ein Gerät im Spiel ist: iPhone, Apple Watch und Android-App zeigen dasselbe,
und bestimmt werden muss beides nur ein einziges Mal, von welchem Gerät aus
auch immer.
| | wo |
|---|---|
| Nullpunkt der Kalibrierung | im ESP (`pitch_offset`, `roll_offset`), wird dort auch schon abgezogen |
| Einbaulage samt Verdrehung | im ESP, Charakteristik `…3428`, lesbar und schreibbar |
| Spurweite und Radstand | in der jeweiligen App, im Fahrzeugprofil |
Beim Verbinden liest jede App die Einbaulage aus dem Gerät und übernimmt sie.
Wer sie neu bestimmt, schreibt sie hinauf. Steht dort noch nichts ältere
Firmware oder nie bestimmt , gilt weiter, was die App örtlich gespeichert hat;
kaputtgehen kann dabei nichts.
Angewandt wird sie trotzdem in den Apps und nicht im Sensor. Der Sensor
verwahrt sie nur: Würde er die Winkel schon umgerechnet melden, rechnete jede
ältere App die Korrektur ein zweites Mal ein.
Die acht Byte der Charakteristik sind in `VanAlignProtocol` beschrieben und in
`run-tests.sh` byteweise festgenagelt daran hängen drei Apps und die
Firmware.
**Danach kalibrieren:** Fahrzeug eben stellen, dann *Auf
aktuelle Lage kalibrieren*. Ohne das zeigt die Anzeige die Lage des Sensors,
nicht die des Fahrzeugs je nachdem, wie schief er eingebaut ist.
@@ -186,10 +262,91 @@ Das steckt vollständig im zeitlichen Verlauf der Neigung ohne jede Annahme
über das Gelände.
Sind beim Fahrzeug **Spurweite und Radstand** hinterlegt, rechnet der Assistent
zusätzlich die nötige Höhe der Auffahrkeile aus. Das ist reine Geometrie und
damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die
tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das
ⓘ in der Fahrzeugliste.
zusätzlich die nötige Höhe der Auffahrkeile aus **je Rad**, als Draufsicht auf
das Fahrzeug. Getrennte Angaben für quer und längs wären irreführend: „rechts
8 cm" und „vorne 4 cm" beschreiben dasselbe Fahrzeug, und unter beiden liegt
teils dasselbe Rad. Es steht auf vier Punkten, also gehören vier Zahlen hin.
Das ist reine Geometrie und damit exakt: 2,0° Querneigung bei 2,00 m Spurweite
ergeben 7,0 cm unter beide Räder der tieferstehenden Seite; kommen 1,5°
Längsneigung bei 3,50 m Radstand dazu, braucht die tiefste Ecke 16,1 cm, ihre
Nachbarn 9,2 und 7,0 cm, und das höchststehende Rad bleibt liegen. Die Maße
stehen im Fahrzeugprofil, erreichbar über das ⓘ in der Fahrzeugliste.
## Apple Watch
Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man
den Arm hebt:
* **Übersicht** je Gerät der Hauptwert und ein Punkt für den Zustand der
Verbindung. Ganz unten steht, wie alt der Stand ist.
* **Nivellierung** drei Seiten zum Wischen, weil auf diesem Bildschirm ein
Wisch besser zu treffen ist als eine Scrollposition:
1. *Neigung* wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über
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
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
gegenüber dem iPhone beim Rangieren schaut niemand aufs Display, aber die
Vibration am Handgelenk kommt an.
* **Kühlbox** ein/aus, Eco oder Max und die Solltemperatur, über die Krone
gestellt. Verbunden wird die Box erst beim Öffnen dieser Ansicht, davor steht
dort der zuletzt gestellte Stand.
### Wer mit wem funkt
Die Aufteilung folgt dem, was jedes Gerät hergibt.
**Den Neigungsmesser funkt die Uhr selbst an.** Er bewirbt seinen Dienst, ist
also ohne jede Einrichtung auffindbar, und er ist unverschlüsselt es gibt
keinen Schlüssel, der auf der Uhr ein zweites Mal lagern müsste. Damit steht
die Nivellierung am Handgelenk **ohne iPhone**: kein geöffnetes Telefon, keine
Reichweite dorthin. Genau dafür hebt man beim Rangieren den Arm.
Die Einbaulage des Sensors reist einmal vom iPhone herüber und bleibt auf der
Uhr gespeichert. Ohne sie stünden längs und quer je nach Einbau vertauscht oder
mit falschem Vorzeichen eingestellt wird sie weiterhin nur am iPhone, im
Assistenten dort.
**Alles Übrige kommt über das iPhone.** Zwei Gründe, beide hart:
* BMS und Kühlbox lassen jeweils nur **eine** Verbindung zu. Eine mitlesende
Uhr nähme dem iPhone die Verbindung weg, statt sie zu ergänzen.
* Die Victron-Schlüssel liegen in der Keychain des iPhones. Sie auf die Uhr zu
kopieren hiesse, sie ein zweites Mal aufzubewahren, ohne dass der zweite Ort
irgendetwas brächte.
Was die Uhr stellt, stellt also in Wahrheit das iPhone und angezeigt wird
auch dort nur, was das Gerät zurückmeldet, nicht der Tastendruck.
### Was das für den Betrieb heisst
Für Batterie, Solar und Kühlbox muss VanControl Pro auf dem iPhone laufen.
Solange die Uhr meldet, dass jemand hinschaut, hält die App das Funkgerät auch
im Hintergrund am Leben; die Victron-Werbedaten stehen dabei still, weil iOS im
Hintergrund kein ungefiltertes Suchen erlaubt.
Für die Nivellierung gilt das alles nicht die läuft an der Uhr allein.
Gefunkt wird dort nur im Vordergrund: watchOS lässt eine App im Hintergrund
ohnehin kaum scannen, und beim Ausrichten schaut man auf die Uhr.
Gesendet wird in zwei Takten: ein halber Sekundentakt, solange die Watch-App im
Vordergrund ist, sonst alle zwei Sekunden und nur bei Änderungen. Der schnelle
Takt läuft über eine Frist, die die Uhr regelmässig erneuert schläft sie ein,
hört das iPhone von selbst wieder auf.
Was die Uhr zeigt, ist immer Weitergereichtes. Deshalb steht auf jedem
Bildschirm das Alter des Standes: Eine abgerissene Strecke zum iPhone sähe sonst
genauso aus wie ein Fahrzeug, an dem sich nichts tut.
## Diagnose
@@ -218,6 +375,10 @@ ohne Bluetooth prüfen lassen. In Xcode unter *Product → Scheme → Edit Schem
Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift
nur in Debug-Builds.
Die Watch-App kennt denselben Schalter und braucht dann kein iPhone: Im Schema
`CamperMonitorWatch` dieselbe Variable setzen. Die Neigung wandert dort
langsam hin und her, sonst hätte der Ausrichtungs-Assistent nichts zu zeigen.
## Protokolle prüfen
```bash
@@ -235,15 +396,25 @@ firmware/vanalign/ Firmware des Neigungsmessers (ESPHome)
├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle
└── experimente/ Nicht für den Betrieb nötig
CamperMonitor/
Shared/ In iPhone- und Watch-App übersetzt
├── Models/
│ ├── Profile.swift Fahrzeug samt Maßen
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilhöhe je Rad
│ ├── SensorOrientation.swift Einbaulage und ihre Erkennung
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
│ ├── LevelState.swift Neigung, Toleranz und Klartext dazu
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
├── Bluetooth/
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
│ └── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr)
├── VehicleTilt.swift Überhöhung und Farben der Fahrzeugansicht
├── WheelLiftPlan.swift Keilhöhen als Draufsicht auf die vier Räder
└── WatchLink/
└── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr
CamperMonitor/
├── Bluetooth/
│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
│ ├── AESCounterMode.swift AES-128-CTR (CommonCrypto)
@@ -253,12 +424,12 @@ CamperMonitor/
│ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
│ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
│ ├── LevelSession.swift Verbindung zum Neigungsmesser
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
├── Store/
│ ├── DeviceStore.swift Geräteliste, Persistenz
│ └── KeychainStore.swift Victron-Schlüssel
├── Watch/
│ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an
└── Views/
├── DashboardView.swift Kachelübersicht
├── DeviceCard.swift Eine Kachel
@@ -271,13 +442,42 @@ CamperMonitor/
├── SettingsView.swift App-Einstellungen
├── SensorSetupView.swift Assistent für die Einbaulage
└── AddDeviceView.swift Scannen und Einrichten
CamperMonitorWatch/ watchOS-App
├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen
├── PhoneLink.swift Gegenstelle zum iPhone
├── WatchLevelRadio.swift Eigene Bluetooth-Verbindung zum Neigungsmesser
├── WatchDemo.swift Erfundene Werte für den Simulator
└── Views/
├── WatchRootView.swift Übersicht und Altersangabe
├── WatchBubble.swift Libelle für den kleinen Bildschirm
├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse
├── WatchLevelView.swift Die drei Seiten: Neigung, Keile, Ausrichten
├── WatchAlignView.swift Ausrichten mit Vibration
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
└── WatchFridgeControls.swift Kühlbox stellen
Config/
└── CamperMonitor-Info.plist Nur der Hintergrundbetrieb für die Uhr
```
## Bekannte Grenzen
* **Kein Hintergrundbetrieb.** iOS erlaubt das ungefilterte Scannen nach
Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den
Hintergrund geht, und nimmt beim Zurückkommen wieder auf.
* **Im Hintergrund nur die verbundenen Geräte.** iOS erlaubt das ungefilterte
Scannen nach Advertisements nur im Vordergrund; die Victron-Werte stehen im
Hintergrund also still. Verbundene Geräte Neigungsmesser, BMS, Kühlbox
laufen weiter, aber nur solange die Uhr meldet, dass jemand hinschaut. Ohne
Uhr pausiert die App im Hintergrund vollständig und nimmt beim Zurückkommen
wieder auf.
* **Die Uhr braucht das iPhone ausser für die Nivellierung.** Batterie, Solar
und Kühlbox zeigt sie nur, was von dort kommt; ist das iPhone ausser
Reichweite, steht dort, wie alt der letzte Stand ist. Den Neigungsmesser
erreicht sie selbst.
* **Neigungsmesser und Uhr:** Der ESP32 nimmt mehrere Verbindungen an, iPhone
und Uhr können also gleichzeitig mitlesen. Weist er die zweite ab, hilft es,
die iPhone-App zu schliessen dann gehört der Sensor der Uhr.
* **Auf der Uhr muss beim ersten Start Bluetooth erlaubt werden**, sonst bleibt
die Nivellierung dort leer.
* **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel
@@ -18,6 +18,7 @@ final class LevelSession: NSObject {
private static let rollUUID = CBUUID(string: VanAlignProtocol.rollUUID)
private static let offsetsUUID = CBUUID(string: VanAlignProtocol.offsetsUUID)
private static let calibrateUUID = CBUUID(string: VanAlignProtocol.calibrateUUID)
private static let orientationUUID = CBUUID(string: VanAlignProtocol.orientationUUID)
let deviceID: UUID
private let queue: DispatchQueue
@@ -25,11 +26,16 @@ final class LevelSession: NSObject {
private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void
private let onLevelState: (LevelState) -> Void
/// Meldet die Einbaulage, die im Gerät steht. Sie gilt vor der örtlich
/// gespeicherten: dort steht, wie der Sensor eingebaut ist, und das ist für
/// alle Clients dasselbe.
private let onDeviceOrientation: ((SensorOrientation) -> Void)?
private var pitchCharacteristic: CBCharacteristic?
private var rollCharacteristic: CBCharacteristic?
private var offsetsCharacteristic: CBCharacteristic?
private var calibrateCharacteristic: CBCharacteristic?
private var orientationCharacteristic: CBCharacteristic?
private var state = LevelState()
/// Aus den Geräteeinstellungen; rechnet Sensor- in Fahrzeugachsen um.
@@ -49,7 +55,9 @@ final class LevelSession: NSObject {
queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void,
onLevelState: @escaping (LevelState) -> Void) {
onLevelState: @escaping (LevelState) -> Void,
onDeviceOrientation: ((SensorOrientation) -> Void)? = nil) {
self.onDeviceOrientation = onDeviceOrientation
self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral
@@ -89,6 +97,28 @@ final class LevelSession: NSObject {
rollCharacteristic = nil
offsetsCharacteristic = nil
calibrateCharacteristic = nil
orientationCharacteristic = nil
}
// MARK: - Einbaulage im Gerät
/// Schreibt die Einbaulage ins Gerät, damit alle Clients dieselbe sehen.
///
/// Ältere Firmware hat die Charakteristik nicht; dann bleibt es bei der
/// örtlich gespeicherten Fassung, und es passiert schlicht nichts.
func storeOrientation(_ orientation: SensorOrientation) {
guard let characteristic = orientationCharacteristic,
peripheral.state == .connected else { return }
let type: CBCharacteristicWriteType =
characteristic.properties.contains(.write) ? .withResponse : .withoutResponse
peripheral.writeValue(VanAlignProtocol.encoded(orientation),
for: characteristic, type: type)
// Zurücklesen, damit angezeigt wird, was wirklich im Gerät steht.
queue.asyncAfter(deadline: .now() + 0.3) { [weak self] in
guard let self, let characteristic = self.orientationCharacteristic,
self.peripheral.state == .connected else { return }
self.peripheral.readValue(for: characteristic)
}
}
// MARK: - Kalibrieren
@@ -169,7 +199,8 @@ extension LevelSession: CBPeripheralDelegate {
return
}
peripheral.discoverCharacteristics(
[Self.pitchUUID, Self.rollUUID, Self.offsetsUUID, Self.calibrateUUID],
[Self.pitchUUID, Self.rollUUID, Self.offsetsUUID,
Self.calibrateUUID, Self.orientationUUID],
for: service
)
}
@@ -188,6 +219,7 @@ extension LevelSession: CBPeripheralDelegate {
case Self.rollUUID: rollCharacteristic = characteristic
case Self.offsetsUUID: offsetsCharacteristic = characteristic
case Self.calibrateUUID: calibrateCharacteristic = characteristic
case Self.orientationUUID: orientationCharacteristic = characteristic
default: break
}
}
@@ -210,7 +242,8 @@ extension LevelSession: CBPeripheralDelegate {
startPollingIfNeeded()
// Einmal alles lesen, damit sofort etwas dasteht.
for characteristic in [pitchCharacteristic, rollCharacteristic, offsetsCharacteristic] {
for characteristic in [pitchCharacteristic, rollCharacteristic,
offsetsCharacteristic, orientationCharacteristic] {
guard let characteristic, characteristic.properties.contains(.read) else { continue }
peripheral.readValue(for: characteristic)
}
@@ -233,6 +266,16 @@ extension LevelSession: CBPeripheralDelegate {
state.pitchOffset = offsets.pitch
state.rollOffset = offsets.roll
}
case Self.orientationUUID:
// Was im Gerät steht, gilt: es beschreibt den Einbau, nicht das
// Telefon. Steht dort nichts (Version 0), bleibt es bei der
// örtlichen Fassung der Aufrufer schreibt sie dann hinauf.
guard let stored = VanAlignProtocol.orientation(from: value) else { return }
if stored != orientation {
orientation = stored
}
onDeviceOrientation?(stored)
return
default:
return
}
+97
View File
@@ -0,0 +1,97 @@
import Foundation
/// Neigungsmesser VanAlign Pro ein ESP32 mit MPU6050, der Längs- und
/// Querneigung des Fahrzeugs über Bluetooth bereitstellt.
///
/// Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
/// Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
enum VanAlignProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
static let pitchUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
static let rollUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
/// Zwei Floats: die gespeicherten Kalibrier-Offsets.
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
/// Acht Byte: die Einbaulage, lesbar und schreibbar. Siehe `orientation`.
static let orientationUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233428"
static let calibrateCommand = Data([0x01])
static let resetCommand = Data([0x00])
/// Liest einen Winkel aus vier Bytes, little-endian.
///
/// Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
/// little-endian die Reihenfolge steht also fest. Die Web-Oberfläche des
/// Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
/// die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
/// schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
/// wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
/// unbemerkt.
static func angle(from data: Data) -> Double? {
guard data.count >= 4 else { return nil }
var raw: UInt32 = 0
for (index, byte) in data.prefix(4).enumerated() {
raw |= UInt32(byte) << UInt32(8 * index)
}
let value = Float(bitPattern: raw)
// NAN meldet die Firmware, solange der Sensor nichts liefert.
guard value.isFinite, abs(value) <= 180 else { return nil }
return Double(value)
}
/// Die Einbaulage, wie sie im Gerät liegt.
///
/// Acht Byte:
///
/// 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
/// 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
/// 2 längs umgekehrt (0/1)
/// 3 quer umgekehrt (0/1)
/// 4..7 Verdrehung um die Hochachse, float32, Grad
///
/// Sie gehört ins Gerät, weil sie den Einbau beschreibt und nicht das
/// Telefon: iPhone, Uhr und Android sollen dieselbe sehen, ohne sie je
/// einzeln zu bestimmen. Gerechnet wird trotzdem in den Apps das Gerät
/// verwahrt sie nur, sonst rechnete ein älterer Client die Korrektur ein
/// zweites Mal ein.
///
/// Version 0 heisst hier stand noch nie etwas und ergibt nil; dann gilt,
/// was die App örtlich gespeichert hat, und sie schreibt es hinauf.
static func orientation(from data: Data) -> SensorOrientation? {
let bytes = [UInt8](data)
guard bytes.count >= 8, bytes[0] == 1 else { return nil }
var orientation = SensorOrientation()
orientation.longitudinalSource = bytes[1] == 1 ? .roll : .pitch
orientation.invertLongitudinal = bytes[2] != 0
orientation.invertLateral = bytes[3] != 0
guard let twist = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
orientation.twist = twist
return orientation
}
/// Dieselben acht Byte in die andere Richtung.
static func encoded(_ orientation: SensorOrientation) -> Data {
var bytes = [UInt8](repeating: 0, count: 8)
bytes[0] = 1
bytes[1] = orientation.longitudinalSource == .roll ? 1 : 0
bytes[2] = orientation.invertLongitudinal ? 1 : 0
bytes[3] = orientation.invertLateral ? 1 : 0
let raw = Float(orientation.twist).bitPattern
for index in 0..<4 {
bytes[4 + index] = UInt8((raw >> UInt32(8 * index)) & 0xFF)
}
return Data(bytes)
}
/// Die beiden gespeicherten Offsets.
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
guard data.count >= 8,
let pitch = angle(from: data.prefix(4)),
let roll = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
return (pitch, roll)
}
}
@@ -129,55 +129,85 @@ struct AlignmentAssistant {
}
}
/// Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen.
/// Wie hoch jedes einzelne Rad unterlegt werden muss.
///
/// Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen
/// beziehungsweise der Räder.
struct LevelingWedge: Equatable {
/// Wo der Keil hin muss.
let side: Side
/// Höhe in Metern.
let height: Double
/// Der zugrundeliegende Winkel in Grad.
let angle: Double
/// Getrennte Angaben wie rechts 8 cm" und vorne 4 cm" beschreiben dasselbe
/// Fahrzeug und lassen sich nicht getrennt ausführen: Unter rechts" liegen
/// zwei Räder, unter vorne" auch, und zwei davon sind dieselben. Gefragt ist
/// deshalb die Höhe **je Rad**.
///
/// Das ist reine Geometrie und damit exakt. Das Fahrzeug steht auf vier
/// Punkten; deren Höhen ergeben sich aus Spurweite, Radstand und den beiden
/// Winkeln. Angehoben wird auf die Höhe des höchsten Rades das bleibt
/// liegen, die anderen bekommen die Differenz.
struct LevelingLift: Equatable {
enum Side: Equatable {
case front, rear, left, right
enum Wheel: CaseIterable, Hashable {
case frontLeft, frontRight, rearLeft, rearRight
var text: String {
switch self {
case .front: return "vorne"
case .rear: return "hinten"
case .left: return "links"
case .right: return "rechts"
case .frontLeft: return "vorne links"
case .frontRight: return "vorne rechts"
case .rearLeft: return "hinten links"
case .rearRight: return "hinten rechts"
}
}
}
var heightInCentimetres: Double { height * 100 }
/// Höhen in Metern, je Rad. Das höchste Rad steht auf 0.
let frontLeft: Double
let frontRight: Double
let rearLeft: Double
let rearRight: Double
/// Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll
/// heisst, dass rechts höher steht der Keil gehört also nach links.
static func across(roll: Double, trackWidth: Double) -> LevelingWedge? {
wedge(angle: roll, distance: trackWidth,
whenPositive: .left, whenNegative: .right)
func height(_ wheel: Wheel) -> Double {
switch wheel {
case .frontLeft: return frontLeft
case .frontRight: return frontRight
case .rearLeft: return rearLeft
case .rearRight: return rearRight
}
}
/// Längs: positiver Pitch heisst, dass das Heck höher steht der Keil
/// gehört unter die Vorderräder.
static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? {
wedge(angle: pitch, distance: wheelbase,
whenPositive: .front, whenNegative: .rear)
func centimetres(_ wheel: Wheel) -> Double { height(wheel) * 100 }
/// Das am tiefsten stehende Rad dort liegt die grösste Höhe.
var deepest: Wheel {
Wheel.allCases.max { height($0) < height($1) } ?? .frontLeft
}
private static func wedge(angle: Double,
distance: Double,
whenPositive: Side,
whenNegative: Side) -> LevelingWedge? {
guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil }
let height = tan(abs(angle) * .pi / 180) * distance
return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative,
height: height,
angle: abs(angle))
var maximumCentimetres: Double { centimetres(deepest) }
/// Unter einem Zentimeter lohnt kein Keil; so genau steht kein Fahrzeug,
/// und so genau misst der Sensor auch nicht.
var isNegligible: Bool { maximumCentimetres < 1 }
/// `pitch` positiv heisst, das Heck steht höher; `roll` positiv, die rechte
/// Seite steht höher. Ohne Fahrzeugmasse gibt es nichts zu rechnen.
static func compute(pitch: Double,
roll: Double,
trackWidth: Double,
wheelbase: Double) -> LevelingLift? {
guard trackWidth > 0, wheelbase > 0 else { return nil }
// Wie viel höher die rechte Seite und wie viel höher das Heck steht.
// Vorzeichen inbegriffen: tan(-2°) ist negativ, dann steht links höher.
let rightRise = tan(roll * .pi / 180) * trackWidth
let rearRise = tan(pitch * .pi / 180) * wheelbase
// Höhen der vier Aufstandspunkte, bezogen auf vorne links.
let heights: [Wheel: Double] = [
.frontLeft: 0,
.frontRight: rightRise,
.rearLeft: rearRise,
.rearRight: rightRise + rearRise,
]
let top = heights.values.max() ?? 0
return LevelingLift(frontLeft: top - heights[.frontLeft]!,
frontRight: top - heights[.frontRight]!,
rearLeft: top - heights[.rearLeft]!,
rearRight: top - heights[.rearRight]!)
}
}
@@ -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
/// braucht eine echte GATT-Verbindung.
var transport: DeviceTransport {
@@ -1,7 +1,7 @@
import Foundation
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
struct Metric: Identifiable, Hashable, Sendable {
struct Metric: Identifiable, Hashable, Codable, Sendable {
let key: String
let label: String
let value: Double?
@@ -39,7 +39,7 @@ struct Metric: Identifiable, Hashable, Sendable {
}
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
struct DeviceSnapshot: Identifiable, Sendable {
struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
var id: UUID { deviceID }
var deviceID: UUID
var timestamp: Date
@@ -58,7 +58,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
var info: [InfoItem] = []
struct InfoItem: Identifiable, Hashable, Sendable {
struct InfoItem: Identifiable, Hashable, Codable, Sendable {
let label: String
let value: String
var id: String { label }
@@ -76,7 +76,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
}
/// Verbindungszustand für die UI.
enum DeviceLinkState: Equatable, Sendable {
enum DeviceLinkState: Equatable, Codable, Sendable {
case idle
case searching
case connecting
+47
View File
@@ -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
}
}
@@ -1,57 +1,7 @@
import Foundation
/// Neigungsmesser VanAlign Pro ein ESP32 mit MPU6050, der Längs- und
/// Querneigung des Fahrzeugs über Bluetooth bereitstellt.
///
/// Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
/// Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
enum VanAlignProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
static let pitchUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
static let rollUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
/// Zwei Floats: die gespeicherten Kalibrier-Offsets.
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
static let calibrateCommand = Data([0x01])
static let resetCommand = Data([0x00])
/// Liest einen Winkel aus vier Bytes, little-endian.
///
/// Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
/// little-endian die Reihenfolge steht also fest. Die Web-Oberfläche des
/// Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
/// die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
/// schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
/// wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
/// unbemerkt.
static func angle(from data: Data) -> Double? {
guard data.count >= 4 else { return nil }
var raw: UInt32 = 0
for (index, byte) in data.prefix(4).enumerated() {
raw |= UInt32(byte) << UInt32(8 * index)
}
let value = Float(bitPattern: raw)
// NAN meldet die Firmware, solange der Sensor nichts liefert.
guard value.isFinite, abs(value) <= 180 else { return nil }
return Double(value)
}
/// Die beiden gespeicherten Offsets.
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
guard data.count >= 8,
let pitch = angle(from: data.prefix(4)),
let roll = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
return (pitch, roll)
}
}
/// Zustand des Neigungsmessers.
struct LevelState: Equatable {
struct LevelState: Equatable, Codable, Sendable {
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
/// Bereits auf die Einbaulage des Sensors umgerechnet.
var pitch: Double?
@@ -23,16 +23,62 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
var invertLongitudinal = false
var invertLateral = false
/// Verdrehung des Sensors um die Hochachse, in Grad der Rest, den der
/// Achsentausch nicht abdeckt.
///
/// Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
/// Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
/// Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
/// helfen dagegen nicht, die springen in 90°-Schritten.
///
/// Der Wert wird beim Bestimmen der Einbaulage mitgemessen und kostet
/// keinen zusätzlichen Handgriff. Beim **Kalibrieren** liesse er sich
/// nicht ermitteln: Das misst eine einzige Lage und zieht sie als Nullpunkt
/// ab eine Drehung um die Hochachse ist darin nicht enthalten, denn eben
/// sieht in jeder Verdrehung gleich aus.
var twist: Double = 0
static let identity = SensorOrientation()
init() {}
/// Von Hand geschrieben, nicht von Swift erzeugt und das ist der Punkt.
///
/// Die erzeugte Fassung verlangt beim Lesen jedes Feld; Standardwerte im
/// Code zählen dabei nicht. Eine Einbaulage, die vor `twist` gespeichert
/// wurde, liesse sich damit nicht mehr lesen, das Gerät dazu ebenso wenig,
/// und die Geräteliste bliebe leer. Jedes neue Feld gehört deshalb hier
/// hinein, mit `decodeIfPresent` und einem Rückfallwert.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
longitudinalSource = try container.decodeIfPresent(Source.self,
forKey: .longitudinalSource) ?? .pitch
invertLongitudinal = try container.decodeIfPresent(Bool.self,
forKey: .invertLongitudinal) ?? false
invertLateral = try container.decodeIfPresent(Bool.self, forKey: .invertLateral) ?? false
twist = try container.decodeIfPresent(Double.self, forKey: .twist) ?? 0
}
var isIdentity: Bool { self == .identity }
/// Rechnet Sensorwerte in Fahrzeugwerte um.
///
/// Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
/// Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
/// Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene genau
/// deshalb lässt sich die Verdrehung überhaupt herausrechnen.
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
let longitudinal = longitudinalSource == .pitch ? pitch : roll
let lateral = longitudinalSource == .pitch ? roll : pitch
return (longitudinal.map { invertLongitudinal ? -$0 : $0 },
lateral.map { invertLateral ? -$0 : $0 })
let mappedLongitudinal = longitudinal.map { invertLongitudinal ? -$0 : $0 }
let mappedLateral = lateral.map { invertLateral ? -$0 : $0 }
guard twist != 0, let long = mappedLongitudinal, let lat = mappedLateral else {
return (mappedLongitudinal, mappedLateral)
}
let angle = twist * .pi / 180
return (long * cos(angle) + lat * sin(angle),
-long * sin(angle) + lat * cos(angle))
}
var summary: String {
@@ -41,6 +87,7 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
if longitudinalSource == .roll { parts.append("Achsen getauscht") }
if invertLongitudinal { parts.append("längs umgekehrt") }
if invertLateral { parts.append("quer umgekehrt") }
if twist != 0 { parts.append(String(format: "um %.0f° verdreht", twist)) }
return parts.joined(separator: ", ")
}
}
@@ -59,6 +106,9 @@ enum OrientationDetection {
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
static let ambiguityMargin = 1.3
/// Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand.
static let minimumTwist = 2.0
struct Reading: Equatable {
let pitch: Double
let roll: Double
@@ -130,6 +180,24 @@ enum OrientationDetection {
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
// ist positiv.
orientation.invertLateral = lateral < 0
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
// schräg und zwar um genau diesen Winkel.
//
// Gemessen wird an der Frontbewegung, nicht an der Seitenbewegung: Die
// Front lässt sich am Fahrzeug genauer treffen, und beide Schritte
// gemeinsam auszuwerten brächte hier nichts, weil eine Verdrehung auf
// beide gleich wirkt.
let corrected = orientation.apply(pitch: nose.pitch, roll: nose.roll)
if let long = corrected.pitch, let lat = corrected.roll {
let residual = atan2(lat, long) * 180 / .pi
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
// als keine.
orientation.twist = abs(residual) >= minimumTwist ? residual : 0
}
return .success(orientation)
}
}
+26
View File
@@ -0,0 +1,26 @@
import SwiftUI
/// Was sich die Fahrzeugansichten auf iPhone und Uhr teilen.
///
/// Beide zeichnen dieselben Schablonen, nur in anderer Grösse die Regeln,
/// nach denen gekippt und eingefärbt wird, dürfen deshalb nicht zweimal
/// dastehen und auseinanderlaufen.
enum VehicleTilt {
/// Kleine Neigungen sind am Fahrzeug sonst kaum zu erkennen zwei Grad
/// wären ein knappes Grad Bildneigung. Die Überhöhung wird angeschrieben,
/// damit niemand den Bildwinkel für den echten nimmt.
static let exaggeration: Double = 3
/// Seitenansicht: Front links, Heck rechts.
static let sideAspect = 925.0 / 600.0
/// Heckansicht, quadratisch.
static let rearAspect = 1.0
/// Grün heisst eben, orange bis zwei Grad, darüber rot.
static func colour(for angle: Double?) -> Color {
guard let angle else { return .secondary }
if abs(angle) <= LevelState.levelTolerance { return .green }
return abs(angle) <= 2 ? .orange : .red
}
}
+19
View File
@@ -0,0 +1,19 @@
import Foundation
/// Die Adressen, über die etwas in der Watch-App geöffnet wird.
///
/// Sie stehen hier, weil beide Seiten sie brauchen und sie sonst zweimal
/// dastünden: Die Komplikation hängt sie an ihre Anzeige, die App wertet sie
/// beim Öffnen aus. Ein Tippfehler auf einer Seite wäre stumm der Tipp aufs
/// Zifferblatt öffnete dann einfach die Geräteliste.
enum WatchDeepLink {
private static let scheme = "campermonitor"
/// Führt direkt auf die Nivellierung.
static let level = URL(string: "\(scheme)://nivellierung")!
/// Ob diese Adresse zur Nivellierung führt.
static func isLevel(_ url: URL) -> Bool {
url.scheme == scheme && (url.host() == "nivellierung")
}
}
+223
View File
@@ -0,0 +1,223 @@
import Foundation
/// Was zwischen iPhone und Uhr hin- und hergeht.
///
/// Die Aufteilung folgt dem, was jedes Gerät hergibt:
///
/// * **Den Neigungsmesser funkt die Uhr selbst an** (`WatchLevelRadio`). Er
/// bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar, und er ist
/// unverschlüsselt. Damit steht die Nivellierung am Handgelenk auch dann,
/// wenn das iPhone in der Tasche schläft und das ist der Fall, für den man
/// die Uhr überhaupt anschaut.
/// * **Alles andere kommt über das iPhone.** BMS und Kühlbox lassen nur eine
/// Verbindung zu; eine mitlesende Uhr nähme dem iPhone die Verbindung weg,
/// statt sie zu ergänzen. Und die Victron-Schlüssel liegen in der Keychain
/// des iPhones sie auf die Uhr zu kopieren hiesse, sie ein zweites Mal
/// aufzubewahren, ohne dass der zweite Ort etwas brächte.
///
/// Übertragen werden deshalb die Messwerte der übrigen Geräte, dazu Fahrzeug
/// und Einbaulage des Sensors: Beides braucht die Uhr, um aus den Rohwinkeln
/// des Neigungsmessers dasselbe zu machen wie das iPhone.
enum WatchLink {
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
/// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind
/// damit die einzige Stelle, an der das Format steht.
static let payloadKey = "payload"
static let commandKey = "command"
/// Wie lange ein ich schaue gerade hin der Uhr gilt, ohne erneuert zu
/// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den
/// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu
/// senden.
static let liveLease: TimeInterval = 12
/// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je
/// Sekunde abgefragt schneller zu senden brächte nichts.
static let liveInterval: TimeInterval = 0.5
/// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann
/// noch zustellt, wenn die App auf der Uhr gerade nicht läuft.
static let idleInterval: TimeInterval = 2
}
/// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt.
struct WatchPayload: Codable, Equatable, Sendable {
/// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt
/// sonst Werte, die sie falsch versteht lieber sagt sie App aktualisieren.
static let currentVersion = 1
var version = WatchPayload.currentVersion
var generatedAt: Date
/// Das am iPhone gewählte Fahrzeug.
var profile: Profile
/// Ob das iPhone gerade überhaupt funken kann.
var isRadioReady: Bool
/// Klartext dazu, falls nicht.
var radioStatus: String
var devices: [WatchDevice]
/// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein
/// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen
/// Platz sonst rutschte er beim Verbindungsabriss in die Geräteliste.
var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } }
var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } }
/// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei
/// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund
/// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht.
func hasSameContent(as other: WatchPayload) -> Bool {
var mine = self, theirs = other
mine.generatedAt = .distantPast
theirs.generatedAt = .distantPast
return mine == theirs
}
/// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt:
/// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum
/// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug.
func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) }
}
/// Ein Gerät mitsamt seinem letzten Messwertsatz.
struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
var id: UUID
var name: String
var role: DeviceRole
var link: DeviceLinkState
var snapshot: DeviceSnapshot?
/// Nur beim Neigungsmesser belegt.
var level: LevelState?
/// Ebenfalls nur dort: wie der Sensor im Fahrzeug sitzt. Die Uhr funkt
/// den Neigungsmesser selbst an und braucht die Zuordnung deshalb auch
/// eingestellt wird sie weiterhin nur am iPhone.
var orientation: SensorOrientation?
/// Nur bei der Kühlbox belegt.
var fridge: WatchFridge?
var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) }
}
/// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht.
///
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
/// drin, den auf die Uhr zu schicken nichts brächte.
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 isEco: Bool
var isLocked: Bool
var isDualZone: Bool
var unitSymbol: String
var leftTarget: Int?
var leftCurrent: Int?
var rightTarget: Int?
var rightCurrent: Int?
var minTarget: Int
var maxTarget: Int
var batteryVolts: Double?
var targetRange: ClosedRange<Int> {
minTarget < maxTarget ? minTarget...maxTarget : -30...20
}
}
/// Was die Uhr das iPhone tun lässt.
///
/// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt
/// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus
/// dem nächsten Datensatz bestätigt wird also das Gerät, nicht der Tastendruck.
enum WatchCommand: Codable, Equatable, Sendable {
/// Die Uhr schaut gerade hin und möchte den schnellen Takt.
case hello(live: Bool)
case fridgePower(device: UUID, on: Bool)
case fridgeEco(device: UUID, eco: Bool)
case fridgeLock(device: UUID, locked: Bool)
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.
var wantsLiveUpdates: Bool {
if case .hello(let live) = self { return live }
// Wer stellt, schaut auch hin.
return true
}
}
/// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen.
enum WatchFridgeZone: String, Codable, Equatable, Sendable {
case left, right
}
// 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 {
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
/// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue
/// Datensätze ergeben nil statt eines halb gefüllten Zustands.
static func decode(from message: [String: Any]) -> WatchPayload? {
guard let data = message[WatchLink.payloadKey] as? Data,
let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data),
payload.version == WatchPayload.currentVersion else { return nil }
return payload
}
func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] }
}
extension WatchCommand {
func message() throws -> [String: Any] {
[WatchLink.commandKey: try WatchCoder.encoder.encode(self)]
}
static func decode(from message: [String: Any]) -> WatchCommand? {
guard let data = message[WatchLink.commandKey] as? Data else { return nil }
return try? WatchCoder.decoder.decode(WatchCommand.self, from: data)
}
}
/// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der
/// einen Seite anders geschrieben als auf der anderen gelesen.
enum WatchCoder {
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
return encoder
}()
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
return decoder
}()
}
+74
View File
@@ -0,0 +1,74 @@
import SwiftUI
/// Die Keilhöhen als Draufsicht auf das Fahrzeug.
///
/// Vier Zahlen an vier Rädern, statt zweier Angaben rechts" und vorne", die
/// sich nicht getrennt ausführen lassen. Oben ist vorne; links im Bild ist
/// links am Fahrzeug, aus Sicht des Fahrers.
///
/// Das höchststehende Rad bekommt nichts es bleibt liegen, die anderen
/// werden auf seine Höhe gebracht.
struct WheelLiftPlan: View {
let lift: LevelingLift
/// Für die Uhr: kleinere Schrift, engerer Aufbau.
var isCompact = false
var body: some View {
VStack(spacing: isCompact ? 2 : 6) {
Text("vorne")
.font(labelFont)
.foregroundStyle(.secondary)
HStack(spacing: isCompact ? 6 : 12) {
VStack(spacing: isCompact ? 6 : 14) {
badge(.frontLeft)
badge(.rearLeft)
}
body_
VStack(spacing: isCompact ? 6 : 14) {
badge(.frontRight)
badge(.rearRight)
}
}
Text("hinten")
.font(labelFont)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityText)
}
/// Der Umriss dazwischen macht aus vier Zahlen ein Fahrzeug.
private var body_: some View {
RoundedRectangle(cornerRadius: isCompact ? 4 : 8)
.strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1)
.frame(width: isCompact ? 26 : 48, height: isCompact ? 44 : 84)
}
private func badge(_ wheel: LevelingLift.Wheel) -> some View {
let value = lift.centimetres(wheel)
let needed = value >= 1
return Text(needed ? String(format: "%.0f", value) : "")
.font(valueFont)
.monospacedDigit()
.foregroundStyle(needed ? .primary : .secondary)
.frame(width: isCompact ? 26 : 42, height: isCompact ? 20 : 30)
.background(needed ? Color.orange.opacity(0.25) : Color.secondary.opacity(0.12),
in: .rect(cornerRadius: isCompact ? 4 : 6))
}
private var labelFont: Font { isCompact ? .system(size: 9) : .caption2 }
private var valueFont: Font {
isCompact ? .system(size: 12, weight: .semibold) : .callout.weight(.semibold)
}
private var accessibilityText: String {
let parts = LevelingLift.Wheel.allCases.compactMap { wheel -> String? in
let value = lift.centimetres(wheel)
guard value >= 1 else { return nil }
return String(format: "%@ %.0f Zentimeter", wheel.text, value)
}
return parts.isEmpty ? "Keine Keile nötig" : "Unterlegen: " + parts.joined(separator: ", ")
}
}
+358 -19
View File
@@ -698,6 +698,44 @@ checkEqual("falsche Prüfsumme wird verworfen",
checkEqual("angefangenes Paket wird aufgehoben",
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
print("\nVanAlign-Neigungsmesser")
@@ -826,28 +864,53 @@ ageing.add(pitch: 1, roll: 0, at: start.addingTimeInterval(AlignmentAssistant.me
checkEqual("alte Messwerte fallen heraus", ageing.samples.count, 1)
// MARK: Auffahrkeile
print("\nAuffahrkeile")
// tan(2°) x 2,00 m = 6,99 cm
let acrossWedge = LevelingWedge.across(roll: 2.0, trackWidth: 2.0)
checkEqual("Keilhöhe quer",
acrossWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 7.0)
checkEqual("rechts höher heisst Keil nach links", acrossWedge?.side, .left)
checkEqual("links höher heisst Keil nach rechts",
LevelingWedge.across(roll: -2.0, trackWidth: 2.0)?.side, .right)
print("\nAuffahrkeile Höhe je Rad")
// tan(1,5°) x 3,50 m = 9,16 cm
let alongWedge = LevelingWedge.along(pitch: 1.5, wheelbase: 3.5)
checkEqual("Keilhöhe längs",
alongWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 9.2)
checkEqual("Heck höher heisst Keil nach vorne", alongWedge?.side, .front)
checkEqual("Front höher heisst Keil nach hinten",
LevelingWedge.along(pitch: -1.5, wheelbase: 3.5)?.side, .rear)
// Reine Querneigung: tan(2°) x 2,00 m = 6,99 cm unter beide Räder der
// tieferstehenden Seite. Rechts steht höher, also muss links hoch.
let acrossOnly = LevelingLift.compute(pitch: 0, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5)
checkEqual("quer: vorne links",
acrossOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 7.0)
checkEqual("quer: hinten links",
acrossOnly.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0)
checkEqual("quer: rechts bleibt liegen",
acrossOnly.map { $0.centimetres(.frontRight) + $0.centimetres(.rearRight) }, 0)
checkEqual("innerhalb der Toleranz kein Keil",
LevelingWedge.across(roll: 0.3, trackWidth: 2.0) == nil, true)
checkEqual("ohne Maß kein Keil",
LevelingWedge.across(roll: 3.0, trackWidth: 0) == nil, true)
// Reine Längsneigung: tan(1,5°) x 3,50 m = 9,16 cm unter beide Vorderräder,
// wenn das Heck höher steht.
let alongOnly = LevelingLift.compute(pitch: 1.5, roll: 0, trackWidth: 2.0, wheelbase: 3.5)
checkEqual("längs: vorne links",
alongOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 9.2)
checkEqual("längs: vorne rechts",
alongOnly.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2)
checkEqual("längs: hinten bleibt liegen",
alongOnly.map { $0.centimetres(.rearLeft) + $0.centimetres(.rearRight) }, 0)
// Beides zusammen: Das tiefste Rad braucht die Summe, das höchste nichts, und
// die beiden dazwischen je einen Anteil. Genau das lässt sich mit getrennten
// Angaben für "rechts" und "vorne" nicht ausdrücken.
let liftBoth = LevelingLift.compute(pitch: 1.5, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5)
checkEqual("kombiniert: tiefstes Rad ist vorne links", liftBoth?.deepest, .frontLeft)
checkEqual("kombiniert: vorne links trägt beide Anteile",
liftBoth.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 16.1)
checkEqual("kombiniert: vorne rechts nur den Längsanteil",
liftBoth.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2)
checkEqual("kombiniert: hinten links nur den Queranteil",
liftBoth.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0)
checkEqual("kombiniert: hinten rechts steht am höchsten und bleibt liegen",
liftBoth.map { $0.centimetres(.rearRight) }, 0)
// Vorzeichen: andersherum geneigt, andere Ecke.
let mirrored = LevelingLift.compute(pitch: -1.5, roll: -2.0, trackWidth: 2.0, wheelbase: 3.5)
checkEqual("umgekehrt geneigt: tiefstes Rad ist hinten rechts",
mirrored?.deepest, .rearRight)
checkEqual("fast eben: kein Keil nötig",
LevelingLift.compute(pitch: 0.1, roll: 0.1,
trackWidth: 2.0, wheelbase: 3.5)?.isNegligible, true)
checkEqual("ohne Fahrzeugmasse keine Rechnung",
LevelingLift.compute(pitch: 2, roll: 2, trackWidth: 0, wheelbase: 3.5) == nil, true)
// MARK: 12 Einbaulage des Neigungsmessers
print("\nEinbaulage")
@@ -945,9 +1008,285 @@ let untouched = SensorOrientation.identity.apply(pitch: 1.5, roll: -0.5)
checkEqual("unveränderte Lage lässt längs stehen", untouched.pitch, 1.5)
checkEqual("unveränderte Lage lässt quer stehen", untouched.roll, -0.5)
// Die Einbaulage liegt im Gerät, damit iPhone, Uhr und Android dieselbe sehen.
// Acht Byte, hin und zurück.
print("\nEinbaulage im Gerät")
var stored = SensorOrientation()
stored.longitudinalSource = .roll
stored.invertLongitudinal = true
stored.invertLateral = false
stored.twist = -17.5
let storedBytes = VanAlignProtocol.encoded(stored)
checkEqual("acht Byte lang", storedBytes.count, 8)
checkEqual("Version steht vorne", storedBytes.first, 1)
checkEqual("gelesen kommt dasselbe zurück", VanAlignProtocol.orientation(from: storedBytes), stored)
// Ein Gerät, in dem noch nie etwas stand, meldet Version 0. Das ist kein
// Fehler, sondern heisst "hier gilt, was die App hat" und die schreibt sie
// dann hinauf.
checkEqual("Version 0 gilt als leer",
VanAlignProtocol.orientation(from: Data(repeating: 0, count: 8)) == nil, true)
checkEqual("zu kurze Antwort ergibt nichts",
VanAlignProtocol.orientation(from: Data([1, 0, 0, 0])) == nil, true)
// Die Feldbelegung byteweise festgenagelt: daran hängen drei Apps und die
// Firmware, das darf nicht unbemerkt verrutschen.
var plain = SensorOrientation()
plain.twist = 1
checkEqual("Feldbelegung", [UInt8](VanAlignProtocol.encoded(plain)),
[1, 0, 0, 0, 0x00, 0x00, 0x80, 0x3F]) // 1.0f little endian
var mounted = SensorOrientation()
mounted.longitudinalSource = .roll
mounted.invertLateral = true
checkEqual("Achsentausch und Vorzeichen an ihrem Platz",
[UInt8](VanAlignProtocol.encoded(mounted)).prefix(4).map { $0 }, [1, 1, 0, 1])
// Gespeicherte Einrichtungen müssen ältere Fassungen überleben. Ein neues Feld
// darf das Lesen nicht scheitern lassen sonst ist die Geräteliste leer, und
// das nächste Speichern schreibt diese Leere über den Bestand.
print("\nGespeicherte Geräte lesen")
func decodeDevice(_ json: String) -> ConfiguredDevice? {
try? JSONDecoder().decode(ConfiguredDevice.self, from: Data(json.utf8))
}
let deviceBeforeTwist = """
{"role":"leveling","profileID":"00000000-0000-0000-0000-00000000C001",
"name":"Nivellierung","id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B5",
"advertisedName":"vanalign","fridgeZoneMode":"automatic",
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DA",
"sensorOrientation":{"invertLongitudinal":false,"invertLateral":true,
"longitudinalSource":"roll"}}
"""
let restoredDevice = decodeDevice(deviceBeforeTwist)
checkEqual("Gerät ohne twist lässt sich weiterhin lesen", restoredDevice?.name, "Nivellierung")
checkEqual("die bekannten Felder der Einbaulage bleiben erhalten",
restoredDevice?.sensorOrientation.longitudinalSource, .roll)
checkEqual("das neue Feld fällt auf null zurück", restoredDevice?.sensorOrientation.twist, 0)
let deviceWithoutOrientation = """
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Bulltron",
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B6",
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DB"}
"""
checkEqual("Gerät ganz ohne Einbaulage ebenso",
decodeDevice(deviceWithoutOrientation)?.sensorOrientation, .identity)
checkEqual("eine unbrauchbare Einbaulage bleibt ein Fehler",
decodeDevice("""
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Kaputt",
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B7",
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DC",
"sensorOrientation":{"longitudinalSource":"quer"}}
""") == nil, true)
// Schräg eingebauter Sensor: um 20 Grad um die Hochachse verdreht. Eine reine
// Kippbewegung nach vorne verteilt sich dann auf beide Sensorachsen.
let twistAngle = 20.0
func twisted(_ longitudinal: Double, _ lateral: Double) -> OrientationDetection.Reading {
// Umgekehrter Weg zu SensorOrientation.apply: aus Fahrzeugwerten wird das,
// was ein verdrehter Sensor melden würde.
let a = twistAngle * .pi / 180
return OrientationDetection.Reading(pitch: longitudinal * cos(a) - lateral * sin(a),
roll: longitudinal * sin(a) + lateral * cos(a))
}
let noseTwisted = twisted(10, 0) // Front nach unten, sauber um die Querachse
let sideTwisted = twisted(0, 10) // linke Seite nach unten
if case .success(let skew) = detect(nose: noseTwisted, side: sideTwisted) {
checkEqual("Verdrehung wird erkannt", (skew.twist * 10).rounded() / 10, 20.0)
checkEqual("Achsen bleiben dabei zugeordnet", skew.longitudinalSource, .pitch)
// Die Probe: zurückgerechnet muss aus der Frontbewegung reine Längsneigung
// werden und aus der Seitenbewegung reine Querneigung.
let front = skew.apply(pitch: noseTwisted.pitch, roll: noseTwisted.roll)
checkEqual("verdreht gemessene Frontbewegung wird reine Längsneigung",
front.pitch.map { ($0 * 10).rounded() / 10 }, 10.0)
checkEqual("und hat keine Querneigung mehr",
front.roll.map { abs($0) < 0.001 }, true)
let side = skew.apply(pitch: sideTwisted.pitch, roll: sideTwisted.roll)
checkEqual("verdreht gemessene Seitenbewegung wird reine Querneigung",
side.roll.map { ($0 * 10).rounded() / 10 }, 10.0)
checkEqual("und hat keine Längsneigung mehr",
side.pitch.map { abs($0) < 0.001 }, true)
} else {
check("verdrehter Einbau wird ausgewertet", false)
}
// Genau das, was vorher fehlte: ohne Korrektur schlägt eine reine Querneigung
// auf die Längsanzeige durch bei 20 Grad Verdrehung mit gut einem Drittel.
let uncorrected = SensorOrientation.identity.apply(pitch: sideTwisted.pitch,
roll: sideTwisted.roll)
checkEqual("ohne Korrektur kippt die Längsanzeige mit",
uncorrected.pitch.map { ($0 * 10).rounded() / 10 }, -3.4)
// Ein bisschen Wackeln ist keine Verdrehung.
if case .success(let steady) = detect(nose: Reading(pitch: 10, roll: 0.2),
side: Reading(pitch: 0.2, roll: 10)) {
checkEqual("kleiner Rest gilt als Rauschen, nicht als Verdrehung", steady.twist, 0)
} else {
check("saubere Kippbewegung wird ausgewertet", false)
}
let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2)
checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true)
checkEqual("vorhandene Werte kommen durch", missing.roll, 2)
// MARK: 13 Strecke zur Apple Watch
print("\nApple Watch Datensatz und Befehle")
let watchProfile = Profile(name: "Kastenwagen", symbol: "box.truck",
trackWidth: 1.85, wheelbase: 3.50)
let watchLevelID = UUID()
let watchFridgeID = UUID()
let watchBoosterID = UUID()
var watchLevel = LevelState()
watchLevel.pitch = 1.8
watchLevel.roll = -0.9
watchLevel.pitchOffset = 0.4
watchLevel.rollOffset = -0.2
// Feste Zeitstempel, damit der Vergleich unten wirklich den Inhalt prüft: über
// die Strecke bleibt die Millisekunde stehen, ein `Date()` wäre feiner.
let watchStamp = Date(timeIntervalSince1970: 1_699_999_999)
var watchLevelSnapshot = watchLevel.snapshot(deviceID: watchLevelID, rssi: -70)
watchLevelSnapshot.timestamp = watchStamp
var boosterSnapshot = DeviceSnapshot(deviceID: watchBoosterID, timestamp: watchStamp, rssi: -71)
boosterSnapshot.state = "Aus"
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
boosterSnapshot.metrics = [
Metric("output_voltage", "Ausgang", 14.09, unit: "V", precision: 2, primary: true),
Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2),
]
// Quer eingebauter Sensor: Die Uhr rechnet damit selbst, also muss die
// Zuordnung mit über die Strecke.
var watchOrientation = SensorOrientation()
watchOrientation.longitudinalSource = .roll
watchOrientation.invertLateral = true
let watchFridge = WatchFridge(isLive: true,
updated: Date(timeIntervalSince1970: 1_699_999_000),
isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6,
rightTarget: nil, rightCurrent: nil,
minTarget: -20, maxTarget: 20, batteryVolts: 12.7)
let watchPayload = WatchPayload(
generatedAt: Date(timeIntervalSince1970: 1_700_000_000),
profile: watchProfile,
isRadioReady: true,
radioStatus: "Bereit",
devices: [
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
link: .live, snapshot: watchLevelSnapshot,
level: watchLevel, orientation: watchOrientation, fridge: nil),
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
level: nil, orientation: nil, fridge: nil),
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: nil, level: nil,
orientation: nil, fridge: watchFridge),
])
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
if let message = try? watchPayload.message(),
let restored = WatchPayload.decode(from: message) {
checkEqual("Datensatz kommt unverändert an", restored, watchPayload)
checkEqual("Zeitstempel überlebt das Verpacken",
restored.generatedAt.timeIntervalSince1970, 1_700_000_000)
checkEqual("Fehlerklartext bleibt am Gerät",
restored.devices[1].link, .failed("Nicht gefunden"))
checkEqual("Neigung wird gefunden", restored.levelDevice?.id, watchLevelID)
checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID)
checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4)
checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85)
checkEqual("Einbaulage kommt mit die Uhr misst selbst",
restored.levelDevice?.orientation?.longitudinalSource, .roll)
checkEqual("Umkehrung der Querachse ebenfalls",
restored.levelDevice?.orientation?.invertLateral, true)
} else {
check("Datensatz lässt sich verpacken und wieder lesen", false)
}
// Eine Uhr mit älterer App darf einen neueren Datensatz nicht halb verstehen.
var futurePayload = watchPayload
futurePayload.version = WatchPayload.currentVersion + 1
if let message = try? futurePayload.message() {
checkEqual("fremde Formatversion wird abgelehnt",
WatchPayload.decode(from: message) == nil, true)
} else {
check("fremde Formatversion lässt sich verpacken", false)
}
// Zeitpunkte reisen als Millisekunden seit 1970 in einer Fliesskommazahl. Auf
// die Millisekunde genau kommen sie zurück, aufs letzte Bit nicht deshalb
// vergleicht der Test oben feste Zeitstempel und nicht ein `Date()`.
var preciseSnapshot = boosterSnapshot
preciseSnapshot.timestamp = Date(timeIntervalSince1970: 1_699_999_999.123_456)
var precisePayload = watchPayload
precisePayload.devices[1].snapshot = preciseSnapshot
if let message = try? precisePayload.message(),
let restored = WatchPayload.decode(from: message)?.devices[1].snapshot?.timestamp {
checkEqual("feiner Zeitstempel kommt auf die Millisekunde genau zurück",
abs(restored.timeIntervalSince1970 - 1_699_999_999.123_456) < 0.001, true)
} else {
check("Datensatz mit feinem Zeitstempel lässt sich verpacken", false)
}
checkEqual("leeres Wörterbuch ergibt keinen Datensatz",
WatchPayload.decode(from: [:]) == nil, true)
checkEqual("Unsinn im Nutzdatenfeld ergibt keinen Datensatz",
WatchPayload.decode(from: [WatchLink.payloadKey: Data([0x00, 0x01])]) == nil, true)
// Der Vergleich entscheidet, ob überhaupt gefunkt wird: der Zeitstempel allein
// darf keinen Funkverkehr auslösen, ein geänderter Messwert schon.
var laterPayload = watchPayload
laterPayload.generatedAt = watchPayload.generatedAt.addingTimeInterval(30)
checkEqual("gleicher Inhalt, neuer Zeitstempel gilt als unverändert",
watchPayload.hasSameContent(as: laterPayload), true)
var changedPayload = laterPayload
changedPayload.devices[2].fridge?.leftTarget = 5
checkEqual("geänderter Sollwert gilt als Änderung",
watchPayload.hasSameContent(as: changedPayload), false)
// Befehle in die Gegenrichtung.
let commands: [WatchCommand] = [
.hello(live: true),
.hello(live: false),
.fridgePower(device: watchFridgeID, on: false),
.fridgeEco(device: watchFridgeID, eco: true),
.fridgeLock(device: watchFridgeID, locked: true),
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
.fridgeSession(device: watchFridgeID, wanted: true),
.fridgeSession(device: watchFridgeID, wanted: false),
]
for command in commands {
guard let message = try? command.message(),
let restored = WatchCommand.decode(from: message) else {
check("Befehl \(command) lässt sich verpacken", false)
continue
}
checkEqual("Befehl kommt unverändert an", restored, command)
}
checkEqual("Anmeldung ohne Hinschauen verlängert den schnellen Takt nicht",
WatchCommand.hello(live: false).wantsLiveUpdates, false)
checkEqual("wer stellt, schaut auch hin",
WatchCommand.fridgePower(device: watchFridgeID, on: true).wantsLiveUpdates, true)
checkEqual("ein Datensatz ist kein Befehl",
WatchCommand.decode(from: (try? watchPayload.message()) ?? [:]) == nil, true)
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
exit(failures == 0 ? 0 : 1)
+3 -3
View File
@@ -26,7 +26,7 @@ darüber auffindbar).
| Roll | `…3425` | lesen | Querneigung, Float32 little-endian, Grad |
| Kalibrieren | `…3427` | schreiben | ein Byte: `0` setzt zurück, alles andere kalibriert |
Der Client fragt Pitch und Roll im Takt ab; Camper Monitor tut das zweimal je
Der Client fragt Pitch und Roll im Takt ab; VanControl Pro tut das zweimal je
Sekunde.
Positiver Pitch heißt: das Heck steht höher. Positiver Roll: die rechte Seite
@@ -35,7 +35,7 @@ steht höher.
### Kalibrierung
Fahrzeug eben stellen, dann ein beliebiges Byte ungleich `0` auf `…3427`
schreiben oder in Camper Monitor auf *Auf aktuelle Lage kalibrieren* tippen.
schreiben oder in VanControl Pro auf *Auf aktuelle Lage kalibrieren* tippen.
Die Offsets werden dauerhaft gespeichert und überstehen einen Neustart.
## Was sich gegenüber 1.0 geändert hat
@@ -70,7 +70,7 @@ Die UUIDs sind unverändert, die vorhandene WebApp läuft weiter.
## Clients
* **Camper Monitor** (iOS) die App in diesem Projekt. Geräteart
* **VanControl Pro** (iOS) die App in diesem Projekt. Geräteart
*Nivellierung*: grafische Libelle, Kalibrierung aus der App, zusammen mit
den übrigen Geräten im Fahrzeug.
* Die ursprüngliche WebBLE-Oberfläche liegt weiterhin im Projekt
+83
View File
@@ -105,6 +105,32 @@ globals:
type: float
restore_value: yes
initial_value: '0.0'
# Die Einbaulage liegt im Gerät, nicht in den Apps: Sie beschreibt, wie der
# Sensor im Fahrzeug sitzt eine Eigenschaft des Einbaus, nicht des Telefons.
# Damit sehen iPhone, Uhr und Android dasselbe, ohne sie je einzeln zu
# bestimmen. Angewandt wird sie weiterhin in den Apps; das Gerät verwahrt sie
# nur, sonst rechneten ältere Clients die Korrektur ein zweites Mal.
- id: orientation_version
type: uint8_t
restore_value: yes
initial_value: '0'
- id: orientation_source
type: uint8_t
restore_value: yes
initial_value: '0'
- id: orientation_invert_long
type: bool
restore_value: yes
initial_value: 'false'
- id: orientation_invert_lat
type: bool
restore_value: yes
initial_value: 'false'
- id: orientation_twist
type: float
restore_value: yes
initial_value: '0.0'
- id: enable_captive
type: bool
restore_value: yes
@@ -133,6 +159,63 @@ esp32_ble_server:
float val = id(roll).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Die gespeicherten Nullpunkte, zwei Floats. Daran erkennen die Apps,
# ob überhaupt schon kalibriert wurde.
- id: offsets_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233426
description: "Kalibrier-Offsets"
read: true
value: !lambda |-
std::vector<unsigned char> v(2 * sizeof(float));
float p = id(pitch_offset);
float r = id(roll_offset);
memcpy(v.data(), &p, sizeof(float));
memcpy(v.data() + sizeof(float), &r, sizeof(float));
return v;
# Die Einbaulage, acht Byte:
#
# 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
# 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
# 2 längs umgekehrt (0/1)
# 3 quer umgekehrt (0/1)
# 4..7 Verdrehung um die Hochachse, float32, Grad
- id: orientation_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233428
description: "Einbaulage"
read: true
write: true
value: !lambda |-
std::vector<unsigned char> v(8, 0);
v[0] = id(orientation_version);
v[1] = id(orientation_source);
v[2] = id(orientation_invert_long) ? 1 : 0;
v[3] = id(orientation_invert_lat) ? 1 : 0;
float t = id(orientation_twist);
memcpy(v.data() + 4, &t, sizeof(float));
return v;
on_write:
then:
- lambda: |-
if (x.size() < 8 || x[0] != 1) {
ESP_LOGW("vanalign", "Einbaulage verworfen: %d Byte, Version %d",
(int) x.size(), x.empty() ? -1 : (int) x[0]);
return;
}
float t;
memcpy(&t, x.data() + 4, sizeof(float));
if (!std::isfinite(t) || fabsf(t) > 180.0f) {
ESP_LOGW("vanalign", "Einbaulage verworfen: Verdrehung %.1f", t);
return;
}
id(orientation_version) = 1;
id(orientation_source) = x[1];
id(orientation_invert_long) = x[2] != 0;
id(orientation_invert_lat) = x[3] != 0;
id(orientation_twist) = t;
ESP_LOGI("vanalign", "Einbaulage gespeichert: Quelle=%d laengs=%d quer=%d verdreht=%.1f",
(int) x[1], (int) x[2], (int) x[3], t);
- id: calib_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233427
description: "Kalibriere Neigung"
+10 -7
View File
@@ -13,13 +13,16 @@ swiftc -O -o "$OUT/tests" \
CamperMonitor/Bluetooth/JBDProtocol.swift \
CamperMonitor/Bluetooth/WattCycleProtocol.swift \
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
CamperMonitor/Bluetooth/VanAlignProtocol.swift \
CamperMonitor/Models/DeviceSnapshot.swift \
CamperMonitor/Models/ConfiguredDevice.swift \
CamperMonitor/Models/Profile.swift \
CamperMonitor/Models/AlignmentAssistant.swift \
CamperMonitor/Models/SensorOrientation.swift \
CamperMonitor/Models/VictronCodes.swift \
Shared/Bluetooth/VanAlignProtocol.swift \
Shared/Models/DeviceSnapshot.swift \
Shared/Models/FridgeSettings.swift \
Shared/Models/ConfiguredDevice.swift \
Shared/Models/LevelState.swift \
Shared/Models/Profile.swift \
Shared/Models/AlignmentAssistant.swift \
Shared/Models/SensorOrientation.swift \
Shared/Models/VictronCodes.swift \
Shared/WatchLink/WatchLink.swift \
CamperMonitor/Store/KeychainStore.swift \
Tests/main.swift
"$OUT/tests"