7 Commits
Author SHA1 Message Date
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
35 changed files with 1605 additions and 301 deletions
@@ -372,6 +372,11 @@ class BluetoothManager(
onUpdate = { record(it) }, onUpdate = { record(it) },
onStateChange = { handleLinkState(device, it) }, onStateChange = { handleLinkState(device, it) },
onLevelState = { state -> publish { levelStates[device.id] = state } }, 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 session.orientation = device.sensorOrientation
levelSessions[device.address] = session levelSessions[device.address] = session
@@ -642,8 +647,32 @@ class BluetoothManager(
// MARK: - Neigungsmesser // 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) { 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) { fun calibrateLevel(deviceID: UUID) {
@@ -30,6 +30,12 @@ class LevelSession(
private val onUpdate: (DeviceSnapshot) -> Unit, private val onUpdate: (DeviceSnapshot) -> Unit,
private val onStateChange: (DeviceLinkState) -> Unit, private val onStateChange: (DeviceLinkState) -> Unit,
private val onLevelState: (LevelState) -> 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 { private companion object {
val SERVICE: UUID = UUID.fromString(VanAlignProtocol.SERVICE_UUID) val SERVICE: UUID = UUID.fromString(VanAlignProtocol.SERVICE_UUID)
@@ -37,6 +43,7 @@ class LevelSession(
val ROLL: UUID = UUID.fromString(VanAlignProtocol.ROLL_UUID) val ROLL: UUID = UUID.fromString(VanAlignProtocol.ROLL_UUID)
val OFFSETS: UUID = UUID.fromString(VanAlignProtocol.OFFSETS_UUID) val OFFSETS: UUID = UUID.fromString(VanAlignProtocol.OFFSETS_UUID)
val CALIBRATE: UUID = UUID.fromString(VanAlignProtocol.CALIBRATE_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") val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
const val POLL_INTERVAL_MS = 1_000L const val POLL_INTERVAL_MS = 1_000L
} }
@@ -53,6 +60,7 @@ class LevelSession(
private var rollCharacteristic: BluetoothGattCharacteristic? = null private var rollCharacteristic: BluetoothGattCharacteristic? = null
private var offsetsCharacteristic: BluetoothGattCharacteristic? = null private var offsetsCharacteristic: BluetoothGattCharacteristic? = null
private var calibrateCharacteristic: BluetoothGattCharacteristic? = null private var calibrateCharacteristic: BluetoothGattCharacteristic? = null
private var orientationCharacteristic: BluetoothGattCharacteristic? = null
/** Ob die Firmware die Werte von sich aus meldet. */ /** Ob die Firmware die Werte von sich aus meldet. */
private var needsPolling = true private var needsPolling = true
@@ -84,6 +92,7 @@ class LevelSession(
rollCharacteristic = null rollCharacteristic = null
offsetsCharacteristic = null offsetsCharacteristic = null
calibrateCharacteristic = null calibrateCharacteristic = null
orientationCharacteristic = null
} }
/** Setzt die aktuelle Lage als neue Null. */ /** Setzt die aktuelle Lage als neue Null. */
@@ -107,6 +116,22 @@ class LevelSession(
handler.postDelayed({ readOffsets() }, 400) 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() { private fun readOffsets() {
val characteristic = offsetsCharacteristic ?: return val characteristic = offsetsCharacteristic ?: return
gatt?.readCharacteristic(characteristic) gatt?.readCharacteristic(characteristic)
@@ -169,6 +194,7 @@ class LevelSession(
rollCharacteristic = service.getCharacteristic(ROLL) rollCharacteristic = service.getCharacteristic(ROLL)
offsetsCharacteristic = service.getCharacteristic(OFFSETS) offsetsCharacteristic = service.getCharacteristic(OFFSETS)
calibrateCharacteristic = service.getCharacteristic(CALIBRATE) calibrateCharacteristic = service.getCharacteristic(CALIBRATE)
orientationCharacteristic = service.getCharacteristic(ORIENTATION)
val canNotify = pitchCharacteristic?.let { val canNotify = pitchCharacteristic?.let {
it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0 it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
@@ -267,6 +293,26 @@ class LevelSession(
state = state.copy(pitchOffset = pitch, rollOffset = roll) state = state.copy(pitchOffset = pitch, rollOffset = roll)
} }
publish() 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() startPollingIfNeeded()
} }
} }
@@ -206,6 +206,7 @@ class DeviceStore(context: Context) {
put("orientationSource", device.sensorOrientation.longitudinalSource.name) put("orientationSource", device.sensorOrientation.longitudinalSource.name)
put("invertLongitudinal", device.sensorOrientation.invertLongitudinal) put("invertLongitudinal", device.sensorOrientation.invertLongitudinal)
put("invertLateral", device.sensorOrientation.invertLateral) put("invertLateral", device.sensorOrientation.invertLateral)
put("twist", device.sensorOrientation.twist)
} }
private fun deviceFrom(json: JSONObject): ConfiguredDevice? { private fun deviceFrom(json: JSONObject): ConfiguredDevice? {
@@ -227,6 +228,9 @@ class DeviceStore(context: Context) {
}.getOrDefault(SensorOrientation.Source.PITCH), }.getOrDefault(SensorOrientation.Source.PITCH),
invertLongitudinal = json.optBoolean("invertLongitudinal", false), invertLongitudinal = json.optBoolean("invertLongitudinal", false),
invertLateral = json.optBoolean("invertLateral", 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),
), ),
) )
} }
@@ -1,6 +1,9 @@
package de.fritob.campermonitor.protocol package de.fritob.campermonitor.protocol
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
/** /**
* Wie der Neigungsmesser im Fahrzeug sitzt. * Wie der Neigungsmesser im Fahrzeug sitzt.
@@ -21,18 +24,42 @@ data class SensorOrientation(
val longitudinalSource: Source = Source.PITCH, val longitudinalSource: Source = Source.PITCH,
val invertLongitudinal: Boolean = false, val invertLongitudinal: Boolean = false,
val invertLateral: 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. */ /** Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert. */
enum class Source { PITCH, ROLL } enum class Source { PITCH, ROLL }
val isIdentity: Boolean get() = this == IDENTITY 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?> { fun apply(pitch: Double?, roll: Double?): Pair<Double?, Double?> {
val longitudinal = if (longitudinalSource == Source.PITCH) pitch else roll val longitudinal = if (longitudinalSource == Source.PITCH) pitch else roll
val lateral = if (longitudinalSource == Source.PITCH) roll else pitch val lateral = if (longitudinalSource == Source.PITCH) roll else pitch
return longitudinal?.let { if (invertLongitudinal) -it else it } to val mappedLongitudinal = longitudinal?.let { if (invertLongitudinal) -it else it }
lateral?.let { if (invertLateral) -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 val summary: String
@@ -42,6 +69,7 @@ data class SensorOrientation(
if (longitudinalSource == Source.ROLL) parts.add("Achsen getauscht") if (longitudinalSource == Source.ROLL) parts.add("Achsen getauscht")
if (invertLongitudinal) parts.add("längs umgekehrt") if (invertLongitudinal) parts.add("längs umgekehrt")
if (invertLateral) parts.add("quer umgekehrt") if (invertLateral) parts.add("quer umgekehrt")
if (twist != 0.0) parts.add("um %.0f° verdreht".format(twist))
return parts.joinToString(", ") return parts.joinToString(", ")
} }
@@ -68,6 +96,9 @@ object OrientationDetection {
/** Soviel deutlicher muss die gewinnende Deutung sein als die andere. */ /** Soviel deutlicher muss die gewinnende Deutung sein als die andere. */
const val AMBIGUITY_MARGIN = 1.3 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) { data class Reading(val pitch: Double, val roll: Double) {
operator fun minus(other: Reading) = Reading(pitch - other.pitch, roll - other.roll) 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) { if (abs(longitudinal) < MINIMUM_TILT || abs(lateral) < MINIMUM_TILT) {
return Result.Error(Failure.TOO_LITTLE_MOVEMENT) 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( return Result.Success(
SensorOrientation( coarse.copy(twist = if (abs(residual) >= MINIMUM_TWIST) residual else 0.0)
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,
)
) )
} }
} }
@@ -24,6 +24,9 @@ object VanAlignProtocol {
/** Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage. */ /** Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage. */
const val CALIBRATE_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427" 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 calibrateCommand = byteArrayOf(0x01)
val resetCommand = byteArrayOf(0x00) val resetCommand = byteArrayOf(0x00)
@@ -50,6 +53,55 @@ object VanAlignProtocol {
return value.toDouble() 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. */ /** Die beiden gespeicherten Offsets. */
fun offsets(data: ByteArray): Pair<Double, Double>? { fun offsets(data: ByteArray): Pair<Double, Double>? {
if (data.size < 8) return null if (data.size < 8) return null
+4 -2
View File
@@ -362,6 +362,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -378,7 +379,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.6; WATCHOS_DEPLOYMENT_TARGET = 10.0;
}; };
name = Debug; name = Debug;
}; };
@@ -393,6 +394,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -409,7 +411,7 @@
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4; TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.6; WATCHOS_DEPLOYMENT_TARGET = 10.0;
}; };
name = Release; name = Release;
}; };
@@ -1,8 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion = "2660" version = "1.7"> <Scheme
<BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES"> LastUpgradeVersion = "2660"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES"> <BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022" BlueprintIdentifier = "AA0000000000000000000022"
@@ -13,11 +22,26 @@
</BuildActionEntry> </BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> <TestAction
<Testables></Testables> buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction> </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"> <LaunchAction
<BuildableProductRunnable runnableDebuggingMode = "0"> 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 <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022" BlueprintIdentifier = "AA0000000000000000000022"
@@ -27,8 +51,14 @@
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</LaunchAction> </LaunchAction>
<ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> <ProfileAction
<BuildableProductRunnable runnableDebuggingMode = "0"> buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022" BlueprintIdentifier = "AA0000000000000000000022"
@@ -38,6 +68,11 @@
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</ProfileAction> </ProfileAction>
<AnalyzeAction buildConfiguration = "Debug"></AnalyzeAction> <AnalyzeAction
<ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"></ArchiveAction> buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme> </Scheme>
@@ -381,14 +381,31 @@ final class BluetoothManager: NSObject {
} }
/// Nach einer Änderung der Einbaulage aufrufen. /// 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) { func updateSensorOrientation(for device: ConfiguredDevice) {
let deviceID = device.id, orientation = device.sensorOrientation let deviceID = device.id, orientation = device.sensorOrientation
queue.async { queue.async {
guard let session = self.levelSession(for: deviceID) else { return } guard let session = self.levelSession(for: deviceID) else { return }
session.orientation = orientation 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. /// Setzt die aktuelle Lage des Fahrzeugs als neue Null.
func calibrateLevel(for deviceID: UUID) { func calibrateLevel(for deviceID: UUID) {
queue.async { self.levelSession(for: deviceID)?.calibrate() } queue.async { self.levelSession(for: deviceID)?.calibrate() }
@@ -742,6 +759,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
}, },
onLevelState: { [weak self] state in onLevelState: { [weak self] state in
self?.publish { self?.levelStates[device.id] = state } 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 session.orientation = device.sensorOrientation
+45 -3
View File
@@ -9,6 +9,8 @@ final class DeviceStore {
private static let devicesKey = "configuredDevices" private static let devicesKey = "configuredDevices"
private static let profilesKey = "profiles" private static let profilesKey = "profiles"
private static let activeProfileKey = "activeProfileID" private static let activeProfileKey = "activeProfileID"
/// Hierhin wird gerettet, was sich nicht lesen liess siehe `loadDevices`.
private static let unreadableDevicesKey = "configuredDevices.unreadable"
/// Alle Geräte über alle Profile hinweg. /// Alle Geräte über alle Profile hinweg.
private(set) var devices: [ConfiguredDevice] = [] private(set) var devices: [ConfiguredDevice] = []
@@ -16,6 +18,11 @@ final class DeviceStore {
private(set) var activeProfileID: UUID = Profile.defaultID 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 /// Zwischenspeicher, damit nicht bei jedem Advertisement die Keychain
/// befragt wird das passiert bis zu mehrmals pro Sekunde. /// befragt wird das passiert bis zu mehrmals pro Sekunde.
@ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:] @ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:]
@@ -151,9 +158,8 @@ final class DeviceStore {
profiles = [.initial] profiles = [.initial]
} }
if let data = defaults.data(forKey: Self.devicesKey), if let data = defaults.data(forKey: Self.devicesKey) {
let decoded = try? JSONDecoder().decode([ConfiguredDevice].self, from: data) { devices = loadDevices(from: data)
devices = decoded
} }
if let raw = defaults.string(forKey: Self.activeProfileKey), if let raw = defaults.string(forKey: Self.activeProfileKey),
@@ -171,6 +177,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() { private func save() {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
if let data = try? JSONEncoder().encode(devices) { if let data = try? JSONEncoder().encode(devices) {
@@ -176,37 +176,37 @@ struct AlignmentAssistantView: View {
@ViewBuilder @ViewBuilder
private var wedgeSection: some View { private var wedgeSection: some View {
let across = profile?.trackWidth.flatMap { width in // Gerechnet wird über alle vier Räder auf einmal. Getrennte Angaben für
state.roll.flatMap { LevelingWedge.across(roll: $0, trackWidth: width) } // quer und längs beschreiben dasselbe Fahrzeug und lassen sich nicht
} // getrennt ausführen: Unter rechts" liegen zwei Räder, unter vorne"
let along = profile?.wheelbase.flatMap { base in // auch, und eines davon ist dasselbe.
state.pitch.flatMap { LevelingWedge.along(pitch: $0, wheelbase: base) } 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) { VStack(alignment: .leading, spacing: 10) {
Label("Auffahrkeile", systemImage: "triangle.fill") Label("Auffahrkeile", systemImage: "triangle.fill")
.font(.headline) .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 " Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
+ "sich beim Fahrzeug hinterlegen.") + "sich beim Fahrzeug hinterlegen.")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if across == nil && along == nil { } else if let lift, !lift.isNegligible {
Text("Keine Keile nötig.") WheelLiftPlan(lift: lift)
.font(.callout) .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) .foregroundStyle(.secondary)
} else { } else {
ForEach([across, along].compactMap { $0 }, id: \.side) { wedge in Text("Keine Keile nötig.")
HStack { .font(.callout)
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)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -230,4 +230,3 @@ struct AlignmentAssistantView: View {
} }
} }
extension LevelingWedge.Side: Hashable {}
+6 -17
View File
@@ -16,11 +16,6 @@ struct VehicleTiltView: View {
let pitch: Double? let pitch: Double?
let roll: 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 { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
tiltPanel(image: "VehicleSide", tiltPanel(image: "VehicleSide",
@@ -28,19 +23,19 @@ struct VehicleTiltView: View {
title: "Längs", title: "Längs",
lowerLabel: "Front", lowerLabel: "Front",
upperLabel: "Heck", upperLabel: "Heck",
aspect: 925.0 / 600.0) aspect: VehicleTilt.sideAspect)
tiltPanel(image: "VehicleRear", tiltPanel(image: "VehicleRear",
angle: roll, angle: roll,
title: "Quer", title: "Quer",
lowerLabel: "links", lowerLabel: "links",
upperLabel: "rechts", upperLabel: "rechts",
aspect: 1) aspect: VehicleTilt.rearAspect)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten. // Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
Text(String(format: "Neigung %.0f-fach überhöht dargestellt " Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.", + "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
Self.exaggeration)) VehicleTilt.exaggeration))
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -61,7 +56,7 @@ struct VehicleTiltView: View {
Spacer() Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "") Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font(.subheadline.weight(.semibold).monospacedDigit()) .font(.subheadline.weight(.semibold).monospacedDigit())
.foregroundStyle(colour(for: angle)) .foregroundStyle(VehicleTilt.colour(for: angle))
} }
ZStack { ZStack {
@@ -74,9 +69,9 @@ struct VehicleTiltView: View {
Image(image) Image(image)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.foregroundStyle(colour(for: angle)) .foregroundStyle(VehicleTilt.colour(for: angle))
.aspectRatio(aspect, contentMode: .fit) .aspectRatio(aspect, contentMode: .fit)
.rotationEffect(.degrees(-(angle ?? 0) * Self.exaggeration)) .rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration))
.animation(.spring(duration: 0.4), value: angle) .animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1) .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. /// Umschalter zwischen den beiden Darstellungen, gemerkt über Starts hinweg.
+1 -2
View File
@@ -134,6 +134,7 @@ final class PhoneWatchLink: NSObject {
link: bluetooth.linkStates[device.id] ?? .searching, link: bluetooth.linkStates[device.id] ?? .searching,
snapshot: bluetooth.snapshots[device.id], snapshot: bluetooth.snapshots[device.id],
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil, level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
orientation: device.role == .leveling ? device.sensorOrientation : nil,
fridge: device.role == .fridge fridge: device.role == .fridge
? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil) ? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil)
} }
@@ -167,8 +168,6 @@ final class PhoneWatchLink: NSObject {
bluetooth.setFridgeLock(locked, for: device) bluetooth.setFridgeLock(locked, for: device)
case .fridgeTarget(let device, let zone, let value): case .fridgeTarget(let device, let zone, let value):
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device) bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
case .calibrateLevel(let device):
bluetooth.calibrateLevel(for: device)
} }
} }
} }
@@ -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

+29 -7
View File
@@ -2,25 +2,47 @@ import SwiftUI
/// Camper Monitor am Handgelenk. /// Camper Monitor am Handgelenk.
/// ///
/// Die Uhr ist Anzeige und Fernbedienung; gefunkt wird über das iPhone. Was /// Zwei Quellen, jede dort, wo sie hingehört:
/// hier steht, kam von dort deshalb steht auf jedem Bildschirm auch, wie alt ///
/// es ist. /// * 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 @main
struct CamperWatchApp: App { struct CamperWatchApp: App {
@State private var link = PhoneLink() @State private var link = PhoneLink()
@State private var radio = WatchLevelRadio()
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
WatchRootView() WatchRootView()
.environment(link) .environment(link)
.task { link.activate() } .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 .onChange(of: scenePhase) { _, phase in
// Der schnelle Takt kostet auf beiden Seiten Strom und lohnt nur, // Beides kostet Strom und lohnt nur, solange jemand hinschaut.
// solange jemand hinschaut. Genau das sagt die Bildschirmphase aus: // Genau das sagt die Bildschirmphase aus.
// im Vordergrund schaut jemand hin, sonst nicht.
link.setLive(phase == .active) link.setLive(phase == .active)
if phase == .active { radio.start() } else { radio.stop() }
} }
} }
} }
+11 -13
View File
@@ -10,16 +10,14 @@ import WatchKit
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was /// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
/// schlechter wird, steckt vollständig darin. /// schlechter wird, steckt vollständig darin.
struct WatchAlignView: View { struct WatchAlignView: View {
let deviceID: UUID
@Environment(PhoneLink.self) private var link @Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
@State private var assistant = AlignmentAssistant() @State private var assistant = AlignmentAssistant()
/// Damit die Vibration einmal kommt und nicht im Sekundentakt. /// Damit die Vibration einmal kommt und nicht im Sekundentakt.
@State private var hasAnnouncedTarget = false @State private var hasAnnouncedTarget = false
private var state: LevelState? { private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
link.payload?.devices.first { $0.id == deviceID }?.level private var state: LevelState? { reading.hasReading ? reading.state : nil }
}
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -69,21 +67,21 @@ struct WatchAlignView: View {
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
.navigationTitle("Ausrichten") .navigationTitle("Ausrichten")
.onChange(of: reading, initial: true) { _, reading in .onChange(of: tilt, initial: true) { _, tilt in
guard let reading else { return } guard let tilt else { return }
assistant.add(pitch: reading.pitch, roll: reading.roll) assistant.add(pitch: tilt.pitch, roll: tilt.roll)
announceIfLevel() announceIfLevel()
} }
} }
/// Ein Wertepaar als Auslöser: nur wenn sich wirklich eine Neigung ändert, /// Ein Wertepaar als Auslöser: gerechnet wird nur, wenn sich die Neigung
/// wird gerechnet nicht bei jedem Datensatz des iPhones. /// wirklich ändert nicht bei jeder Aktualisierung der Anzeige.
private var reading: Reading? { private var tilt: Tilt? {
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil } guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
return Reading(pitch: pitch, roll: roll) return Tilt(pitch: pitch, roll: roll)
} }
private struct Reading: Equatable { private struct Tilt: Equatable {
let pitch: Double let pitch: Double
let roll: Double let roll: Double
} }
+129 -53
View File
@@ -1,68 +1,66 @@
import SwiftUI import SwiftUI
/// Nivellierung auf der Uhr: Libelle, Zahlen, und was zu tun ist. /// Nivellierung auf der Uhr.
///
/// 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 unten 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 { struct WatchLevelView: View {
/// Nur für den Einstieg angezeigt wird immer der Stand aus dem letzten
/// Datensatz, sonst bliebe die Ansicht auf dem Wert vom Öffnen stehen.
let device: WatchDevice
@Environment(PhoneLink.self) private var link @Environment(PhoneLink.self) private var link
@State private var isCalibrating = false @Environment(WatchLevelRadio.self) private var radio
@AppStorage("levelDisplayStyle") private var showsVehicle = true
private var current: WatchDevice { link.payload?.levelDevice ?? device } private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
private var state: LevelState { current.level ?? LevelState() }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 10) { VStack(spacing: 8) {
WatchBubble(pitch: state.pitch, roll: state.roll) if showsVehicle {
.frame(maxWidth: 110) WatchVehicleView(pitch: reading.state.pitch, roll: reading.state.roll)
} else {
HStack(spacing: 14) { WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll)
angle("Längs", state.pitch) .frame(maxWidth: 110)
angle("Quer", state.roll) HStack(spacing: 14) {
angle("Längs", reading.state.pitch)
angle("Quer", reading.state.roll)
}
} }
Text(state.instruction ?? "Keine Werte") Text(reading.state.instruction ?? "Keine Werte")
.font(.footnote.weight(.medium)) .font(.footnote.weight(.medium))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(state.isLevel ? .green : .primary) .foregroundStyle(reading.state.isLevel ? .green : .primary)
if let wedges = wedgeText { if let lift, !lift.isNegligible {
Text(wedges) WheelLiftPlan(lift: lift, isCompact: true)
.font(.caption2) .padding(.top, 2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} }
NavigationLink { NavigationLink {
WatchAlignView(deviceID: current.id) WatchAlignView()
} label: { } label: {
Label("Ausrichten", systemImage: "car.side.rear.and.collision.and.car.side.front") Label("Ausrichten", systemImage: "arrow.left.and.right")
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
Button("Kalibrieren", systemImage: "scope") { isCalibrating = true } Button {
.font(.footnote) showsVehicle.toggle()
} label: {
if state.isKnownUncalibrated { Label(showsVehicle ? "Libelle" : "Fahrzeug",
Text("Noch nicht kalibriert die Anzeige zeigt die Lage des " systemImage: showsVehicle ? "circle.circle" : "box.truck")
+ "Sensors, nicht die des Fahrzeugs.")
.font(.caption2)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
} }
.font(.caption2)
SourceNote(reading: reading)
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
.navigationTitle("Nivellierung") .navigationTitle("Nivellierung")
.confirmationDialog("Auf aktuelle Lage kalibrieren?",
isPresented: $isCalibrating, titleVisibility: .visible) {
Button("Kalibrieren") { link.send(.calibrateLevel(device: current.id)) }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Nur sinnvoll, wenn das Fahrzeug jetzt eben steht.")
}
} }
private func angle(_ title: String, _ value: Double?) -> some View { private func angle(_ title: String, _ value: Double?) -> some View {
@@ -76,19 +74,97 @@ struct WatchLevelView: View {
} }
} }
/// Wie hoch die Auffahrkeile sein müssen reine Geometrie, und nur zu /// Wie hoch jedes Rad unterlegt werden muss reine Geometrie, und nur zu
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen. /// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
private var wedgeText: String? { private var lift: LevelingLift? {
guard let profile = link.payload?.profile else { return nil } guard let profile = link.payload?.profile,
var parts: [String] = [] let width = profile.trackWidth, let base = profile.wheelbase,
if let roll = state.roll, let width = profile.trackWidth, let pitch = reading.state.pitch, let roll = reading.state.roll
let wedge = LevelingWedge.across(roll: roll, trackWidth: width) { else { return nil }
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text)) return LevelingLift.compute(pitch: pitch, roll: roll,
} trackWidth: width, wheelbase: base)
if let pitch = state.pitch, let base = profile.wheelbase, }
let wedge = LevelingWedge.along(pitch: pitch, wheelbase: base) { }
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text))
} /// Woher die angezeigte Neigung kommt und wie alt sie ist.
return parts.isEmpty ? nil : "Keile: " + parts.joined(separator: ", ") 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)
} }
} }
+41 -40
View File
@@ -4,62 +4,62 @@ import SwiftUI
/// im Wohnmobil danach schaut. /// im Wohnmobil danach schaut.
struct WatchRootView: View { struct WatchRootView: View {
@Environment(PhoneLink.self) private var link @Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { content
if let payload = link.payload { .navigationTitle(link.payload?.profile.name ?? "Camper")
content(for: payload)
} else {
waiting
}
}
.navigationTitle(link.payload?.profile.name ?? "Camper")
} }
} }
private func content(for payload: WatchPayload) -> some View { private var content: some View {
List { List {
if let level = payload.levelDevice { // Die Nivellierung steht immer oben und immer da: Sie kommt vom
// Sensor selbst und braucht das iPhone nicht.
Section {
NavigationLink {
WatchLevelView()
} label: {
LevelSummaryRow(reading: WatchLevelReading(radio: radio, phone: link))
}
}
if let payload = link.payload {
Section { Section {
NavigationLink { ForEach(payload.devices.filter { $0.role != .leveling }) { device in
WatchLevelView(device: level) NavigationLink {
} label: { WatchDeviceDetailView(deviceID: device.id)
LevelSummaryRow(state: level.level, link: level.link) } label: {
DeviceRow(device: device)
}
} }
} }
}
Section { Section {
ForEach(payload.devices.filter { $0.role != .leveling }) { device in FreshnessNote(payload: payload, receivedAt: link.receivedAt)
NavigationLink { }
WatchDeviceDetailView(deviceID: device.id) } else {
} label: { Section {
DeviceRow(device: device) waiting
} } footer: {
Text("Batterie, Solar und Kühlbox kommen vom iPhone. Die "
+ "Nivellierung oben nicht die geht direkt.")
.font(.system(size: 10))
} }
}
Section {
FreshnessNote(payload: payload, receivedAt: link.receivedAt)
} }
} }
} }
private var waiting: some View { private var waiting: some View {
VStack(spacing: 8) { VStack(spacing: 4) {
Image(systemName: "iphone.gen3.radiowaves.left.and.right") Label("Warte auf das iPhone", systemImage: "iphone.gen3.radiowaves.left.and.right")
.font(.title2) .font(.footnote.weight(.medium))
.foregroundStyle(.tint) Text("Dort muss Camper Monitor geöffnet sein.")
Text("Warte auf das iPhone")
.font(.headline)
Text("Die Werte kommen vom iPhone. Dort muss Camper Monitor "
+ "geöffnet sein.")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} }
.padding(.horizontal, 4) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
@@ -100,12 +100,11 @@ struct DeviceRow: View {
/// Die Nivellierung bekommt eine eigene, grössere Zeile: sie ist der Grund, /// Die Nivellierung bekommt eine eigene, grössere Zeile: sie ist der Grund,
/// aus dem man die Uhr im Fahrzeug überhaupt anschaut. /// aus dem man die Uhr im Fahrzeug überhaupt anschaut.
struct LevelSummaryRow: View { struct LevelSummaryRow: View {
let state: LevelState? let reading: WatchLevelReading
let link: DeviceLinkState
var body: some View { var body: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
WatchBubble(pitch: state?.pitch, roll: state?.roll) WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Nivellierung") Text("Nivellierung")
@@ -113,7 +112,9 @@ struct LevelSummaryRow: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
// Ohne Messwerte steht hier, woran es liegt, statt eines // Ohne Messwerte steht hier, woran es liegt, statt eines
// nichtssagenden Strichs. // nichtssagenden Strichs.
Text(state?.instruction ?? link.label) Text(reading.hasReading
? (reading.state.instruction ?? "Keine Werte")
: reading.statusText)
.font(.footnote.weight(.medium)) .font(.footnote.weight(.medium))
.lineLimit(2) .lineLimit(2)
} }
@@ -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)
}
}
}
+6 -4
View File
@@ -63,13 +63,15 @@ enum WatchDemo {
devices: [ devices: [
WatchDevice(id: levelID, name: "Nivellierung", role: .leveling, WatchDevice(id: levelID, name: "Nivellierung", role: .leveling,
link: .live, snapshot: level.snapshot(deviceID: levelID, rssi: -70), link: .live, snapshot: level.snapshot(deviceID: levelID, rssi: -70),
level: level, fridge: nil), level: level, orientation: .identity, fridge: nil),
WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms, WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms,
link: .live, snapshot: battery, level: nil, fridge: nil), link: .live, snapshot: battery, level: nil,
orientation: nil, fridge: nil),
WatchDevice(id: solarID, name: "Solar Dach", role: .solarCharger, WatchDevice(id: solarID, name: "Solar Dach", role: .solarCharger,
link: .live, snapshot: solar, level: nil, fridge: nil), link: .live, snapshot: solar, level: nil,
orientation: nil, fridge: nil),
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge, WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: fridge, level: nil, link: .live, snapshot: fridge, level: nil, orientation: nil,
fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false, fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C", isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6, leftTarget: 4, leftCurrent: 6,
+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()
}
}
-9
View File
@@ -2,15 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<!-- Alles Übrige erzeugt Xcode aus den INFOPLIST_KEY_-Einstellungen des
Targets. Hier steht nur, was sich dort nicht ausdrücken lässt.
Der Hintergrundbetrieb ist für die Apple Watch da: Beim Rangieren liegt
das iPhone in der Halterung mit dunklem Bildschirm, und die Anzeige am
Handgelenk wäre sonst genau dann tot, wenn sie gebraucht wird.
Verbundene Geräte (Neigungsmesser, BMS, Kühlbox) liefern damit weiter.
Die Victron-Werbedaten nicht: dafür müsste ohne Dienstfilter gesucht
werden, und das lässt iOS im Hintergrund grundsätzlich nicht zu. -->
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
+104 -30
View File
@@ -14,8 +14,9 @@ Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazu
Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt
Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am
Handgelenk. Sie funkt nicht selbst, sondern hängt am iPhone warum, steht Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt
unter [Apple Watch](#apple-watch). über das iPhone warum diese Aufteilung, steht unter
[Apple Watch](#apple-watch).
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe Die Firmware des Neigungsmessers liegt mit im Projekt, siehe
[firmware/vanalign](firmware/vanalign/README.md). [firmware/vanalign](firmware/vanalign/README.md).
@@ -174,6 +175,46 @@ Vorzeichen. Der Assistent unter *Sensor → Einbaulage* klärt das durch zwei
Kippbewegungen: einmal die Front nach unten, einmal die linke Seite. Aus der Kippbewegungen: einmal die Front nach unten, einmal die linke Seite. Aus der
Reaktion ergibt sich die Zuordnung geraten wird nichts. 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 **Danach kalibrieren:** Fahrzeug eben stellen, dann *Auf
aktuelle Lage kalibrieren*. Ohne das zeigt die Anzeige die Lage des Sensors, aktuelle Lage kalibrieren*. Ohne das zeigt die Anzeige die Lage des Sensors,
nicht die des Fahrzeugs je nachdem, wie schief er eingebaut ist. nicht die des Fahrzeugs je nachdem, wie schief er eingebaut ist.
@@ -204,10 +245,16 @@ Das steckt vollständig im zeitlichen Verlauf der Neigung ohne jede Annahme
über das Gelände. über das Gelände.
Sind beim Fahrzeug **Spurweite und Radstand** hinterlegt, rechnet der Assistent 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 zusätzlich die nötige Höhe der Auffahrkeile aus **je Rad**, als Draufsicht auf
damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die das Fahrzeug. Getrennte Angaben für quer und längs wären irreführend: „rechts
tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das 8 cm" und „vorne 4 cm" beschreiben dasselbe Fahrzeug, und unter beiden liegt
ⓘ in der Fahrzeugliste. 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 ## Apple Watch
@@ -216,8 +263,12 @@ den Arm hebt:
* **Übersicht** je Gerät der Hauptwert und ein Punkt für den Zustand der * **Übersicht** je Gerät der Hauptwert und ein Punkt für den Zustand der
Verbindung. Ganz unten steht, wie alt der Stand ist. Verbindung. Ganz unten steht, wie alt der Stand ist.
* **Nivellierung** Libelle, beide Winkel, was zu tun ist, und die nötige * **Nivellierung** wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
Keilhöhe, falls im Fahrzeugprofil Spurweite und Radstand stehen. Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über Starts
hinweg gemerkt. Dazu, was zu tun ist, und die Keilhöhe je Rad, falls im
Fahrzeugprofil Spurweite und Radstand stehen. 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 * **Ausrichten** derselbe Assistent wie am iPhone: Tendenz, Rat und der
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
@@ -226,30 +277,42 @@ den Arm hebt:
* **Kühlbox** ein/aus, Eco oder Max und die Solltemperatur, über die Krone * **Kühlbox** ein/aus, Eco oder Max und die Solltemperatur, über die Krone
gestellt. gestellt.
### Warum die Uhr nicht selbst funkt ### Wer mit wem funkt
watchOS könnte Bluetooth, und trotzdem geht alles über das iPhone. Zwei Gründe, Die Aufteilung folgt dem, was jedes Gerät hergibt.
beide hart:
* BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu. **Den Neigungsmesser funkt die Uhr selbst an.** Er bewirbt seinen Dienst, ist
Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie zu also ohne jede Einrichtung auffindbar, und er ist unverschlüsselt es gibt
ergänzen. 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 * 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 kopieren hiesse, sie ein zweites Mal aufzubewahren, ohne dass der zweite Ort
irgendetwas brächte. irgendetwas brächte.
Das iPhone bleibt also das Funkgerät und reicht fertige Messwerte weiter. Was Was die Uhr stellt, stellt also in Wahrheit das iPhone und angezeigt wird
die Uhr stellt, stellt in Wahrheit das iPhone und angezeigt wird auch dort auch dort nur, was das Gerät zurückmeldet, nicht der Tastendruck.
nur, was das Gerät zurückmeldet, nicht der Tastendruck.
### Was das für den Betrieb heisst ### Was das für den Betrieb heisst
Auf dem iPhone muss Camper Monitor laufen. Solange die Uhr meldet, dass jemand Für Batterie, Solar und Kühlbox muss Camper Monitor auf dem iPhone laufen.
hinschaut, hält die App das Funkgerät auch im Hintergrund am Leben sonst wäre Solange die Uhr meldet, dass jemand hinschaut, hält die App das Funkgerät auch
die Anzeige am Handgelenk beim Rangieren tot, denn dann liegt das iPhone mit im Hintergrund am Leben; die Victron-Werbedaten stehen dabei still, weil iOS im
dunklem Bildschirm in der Halterung. Die verbundenen Geräte liefern dabei Hintergrund kein ungefiltertes Suchen erlaubt.
weiter; die Victron-Werbedaten nicht, 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 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 Vordergrund ist, sonst alle zwei Sekunden und nur bei Änderungen. Der schnelle
@@ -311,12 +374,17 @@ firmware/vanalign/ Firmware des Neigungsmessers (ESPHome)
Shared/ In iPhone- und Watch-App übersetzt Shared/ In iPhone- und Watch-App übersetzt
├── Models/ ├── Models/
│ ├── Profile.swift Fahrzeug samt Maßen │ ├── 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 │ ├── SensorOrientation.swift Einbaulage und ihre Erkennung
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
│ ├── LevelState.swift Neigung, Toleranz und Klartext dazu │ ├── LevelState.swift Neigung, Toleranz und Klartext dazu
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes │ └── 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/
└── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr └── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr
@@ -331,8 +399,6 @@ CamperMonitor/
│ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung │ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung │ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
│ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle │ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
│ ├── LevelSession.swift Verbindung zum Neigungsmesser
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage │ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
├── Store/ ├── Store/
│ ├── DeviceStore.swift Geräteliste, Persistenz │ ├── DeviceStore.swift Geräteliste, Persistenz
@@ -355,11 +421,13 @@ CamperMonitor/
CamperMonitorWatch/ watchOS-App CamperMonitorWatch/ watchOS-App
├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen ├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen
├── PhoneLink.swift Gegenstelle zum iPhone ├── PhoneLink.swift Gegenstelle zum iPhone
├── WatchLevelRadio.swift Eigene Bluetooth-Verbindung zum Neigungsmesser
├── WatchDemo.swift Erfundene Werte für den Simulator ├── WatchDemo.swift Erfundene Werte für den Simulator
└── Views/ └── Views/
├── WatchRootView.swift Übersicht und Altersangabe ├── WatchRootView.swift Übersicht und Altersangabe
├── WatchBubble.swift Libelle für den kleinen Bildschirm ├── WatchBubble.swift Libelle für den kleinen Bildschirm
├── WatchLevelView.swift Neigung, Keilhöhe, Kalibrierung ├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse
├── WatchLevelView.swift Neigung, Keilhöhe, Herkunft der Werte
├── WatchAlignView.swift Ausrichten mit Vibration ├── WatchAlignView.swift Ausrichten mit Vibration
├── WatchDeviceDetailView.swift Alle Werte eines Geräts ├── WatchDeviceDetailView.swift Alle Werte eines Geräts
└── WatchFridgeControls.swift Kühlbox stellen └── WatchFridgeControls.swift Kühlbox stellen
@@ -376,9 +444,15 @@ Config/
laufen weiter, aber nur solange die Uhr meldet, dass jemand hinschaut. Ohne 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 Uhr pausiert die App im Hintergrund vollständig und nimmt beim Zurückkommen
wieder auf. wieder auf.
* **Die Uhr braucht das iPhone.** Sie zeigt nur, was von dort kommt. Steckt das * **Die Uhr braucht das iPhone ausser für die Nivellierung.** Batterie, Solar
iPhone ausser Reichweite, steht auf der Uhr, wie alt der letzte Stand ist und Kühlbox zeigt sie nur, was von dort kommt; ist das iPhone ausser
aber kein neuer. 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 * **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel 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 rollUUID = CBUUID(string: VanAlignProtocol.rollUUID)
private static let offsetsUUID = CBUUID(string: VanAlignProtocol.offsetsUUID) private static let offsetsUUID = CBUUID(string: VanAlignProtocol.offsetsUUID)
private static let calibrateUUID = CBUUID(string: VanAlignProtocol.calibrateUUID) private static let calibrateUUID = CBUUID(string: VanAlignProtocol.calibrateUUID)
private static let orientationUUID = CBUUID(string: VanAlignProtocol.orientationUUID)
let deviceID: UUID let deviceID: UUID
private let queue: DispatchQueue private let queue: DispatchQueue
@@ -25,11 +26,16 @@ final class LevelSession: NSObject {
private let onUpdate: (DeviceSnapshot) -> Void private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void private let onStateChange: (DeviceLinkState) -> Void
private let onLevelState: (LevelState) -> 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 pitchCharacteristic: CBCharacteristic?
private var rollCharacteristic: CBCharacteristic? private var rollCharacteristic: CBCharacteristic?
private var offsetsCharacteristic: CBCharacteristic? private var offsetsCharacteristic: CBCharacteristic?
private var calibrateCharacteristic: CBCharacteristic? private var calibrateCharacteristic: CBCharacteristic?
private var orientationCharacteristic: CBCharacteristic?
private var state = LevelState() private var state = LevelState()
/// Aus den Geräteeinstellungen; rechnet Sensor- in Fahrzeugachsen um. /// Aus den Geräteeinstellungen; rechnet Sensor- in Fahrzeugachsen um.
@@ -49,7 +55,9 @@ final class LevelSession: NSObject {
queue: DispatchQueue, queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void, onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void, onStateChange: @escaping (DeviceLinkState) -> Void,
onLevelState: @escaping (LevelState) -> Void) { onLevelState: @escaping (LevelState) -> Void,
onDeviceOrientation: ((SensorOrientation) -> Void)? = nil) {
self.onDeviceOrientation = onDeviceOrientation
self.deviceID = deviceID self.deviceID = deviceID
self.queue = queue self.queue = queue
self.peripheral = peripheral self.peripheral = peripheral
@@ -89,6 +97,28 @@ final class LevelSession: NSObject {
rollCharacteristic = nil rollCharacteristic = nil
offsetsCharacteristic = nil offsetsCharacteristic = nil
calibrateCharacteristic = 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 // MARK: - Kalibrieren
@@ -169,7 +199,8 @@ extension LevelSession: CBPeripheralDelegate {
return return
} }
peripheral.discoverCharacteristics( peripheral.discoverCharacteristics(
[Self.pitchUUID, Self.rollUUID, Self.offsetsUUID, Self.calibrateUUID], [Self.pitchUUID, Self.rollUUID, Self.offsetsUUID,
Self.calibrateUUID, Self.orientationUUID],
for: service for: service
) )
} }
@@ -188,6 +219,7 @@ extension LevelSession: CBPeripheralDelegate {
case Self.rollUUID: rollCharacteristic = characteristic case Self.rollUUID: rollCharacteristic = characteristic
case Self.offsetsUUID: offsetsCharacteristic = characteristic case Self.offsetsUUID: offsetsCharacteristic = characteristic
case Self.calibrateUUID: calibrateCharacteristic = characteristic case Self.calibrateUUID: calibrateCharacteristic = characteristic
case Self.orientationUUID: orientationCharacteristic = characteristic
default: break default: break
} }
} }
@@ -210,7 +242,8 @@ extension LevelSession: CBPeripheralDelegate {
startPollingIfNeeded() startPollingIfNeeded()
// Einmal alles lesen, damit sofort etwas dasteht. // 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 } guard let characteristic, characteristic.properties.contains(.read) else { continue }
peripheral.readValue(for: characteristic) peripheral.readValue(for: characteristic)
} }
@@ -233,6 +266,16 @@ extension LevelSession: CBPeripheralDelegate {
state.pitchOffset = offsets.pitch state.pitchOffset = offsets.pitch
state.rollOffset = offsets.roll 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: default:
return return
} }
@@ -16,6 +16,8 @@ enum VanAlignProtocol {
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426" static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage. /// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427" 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 calibrateCommand = Data([0x01])
static let resetCommand = Data([0x00]) static let resetCommand = Data([0x00])
@@ -41,6 +43,50 @@ enum VanAlignProtocol {
return Double(value) 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. /// Die beiden gespeicherten Offsets.
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? { static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
guard data.count >= 8, guard data.count >= 8,
+66 -36
View File
@@ -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 /// Getrennte Angaben wie rechts 8 cm" und vorne 4 cm" beschreiben dasselbe
/// beziehungsweise der Räder. /// Fahrzeug und lassen sich nicht getrennt ausführen: Unter rechts" liegen
struct LevelingWedge: Equatable { /// zwei Räder, unter vorne" auch, und zwei davon sind dieselben. Gefragt ist
/// Wo der Keil hin muss. /// deshalb die Höhe **je Rad**.
let side: Side ///
/// Höhe in Metern. /// Das ist reine Geometrie und damit exakt. Das Fahrzeug steht auf vier
let height: Double /// Punkten; deren Höhen ergeben sich aus Spurweite, Radstand und den beiden
/// Der zugrundeliegende Winkel in Grad. /// Winkeln. Angehoben wird auf die Höhe des höchsten Rades das bleibt
let angle: Double /// liegen, die anderen bekommen die Differenz.
struct LevelingLift: Equatable {
enum Side: Equatable { enum Wheel: CaseIterable, Hashable {
case front, rear, left, right case frontLeft, frontRight, rearLeft, rearRight
var text: String { var text: String {
switch self { switch self {
case .front: return "vorne" case .frontLeft: return "vorne links"
case .rear: return "hinten" case .frontRight: return "vorne rechts"
case .left: return "links" case .rearLeft: return "hinten links"
case .right: return "rechts" 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 func height(_ wheel: Wheel) -> Double {
/// heisst, dass rechts höher steht der Keil gehört also nach links. switch wheel {
static func across(roll: Double, trackWidth: Double) -> LevelingWedge? { case .frontLeft: return frontLeft
wedge(angle: roll, distance: trackWidth, case .frontRight: return frontRight
whenPositive: .left, whenNegative: .right) case .rearLeft: return rearLeft
case .rearRight: return rearRight
}
} }
/// Längs: positiver Pitch heisst, dass das Heck höher steht der Keil func centimetres(_ wheel: Wheel) -> Double { height(wheel) * 100 }
/// gehört unter die Vorderräder.
static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? { /// Das am tiefsten stehende Rad dort liegt die grösste Höhe.
wedge(angle: pitch, distance: wheelbase, var deepest: Wheel {
whenPositive: .front, whenNegative: .rear) Wheel.allCases.max { height($0) < height($1) } ?? .frontLeft
} }
private static func wedge(angle: Double, var maximumCentimetres: Double { centimetres(deepest) }
distance: Double,
whenPositive: Side, /// Unter einem Zentimeter lohnt kein Keil; so genau steht kein Fahrzeug,
whenNegative: Side) -> LevelingWedge? { /// und so genau misst der Sensor auch nicht.
guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil } var isNegligible: Bool { maximumCentimetres < 1 }
let height = tan(abs(angle) * .pi / 180) * distance
return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative, /// `pitch` positiv heisst, das Heck steht höher; `roll` positiv, die rechte
height: height, /// Seite steht höher. Ohne Fahrzeugmasse gibt es nichts zu rechnen.
angle: abs(angle)) 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]!)
} }
} }
+70 -2
View File
@@ -23,16 +23,62 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
var invertLongitudinal = false var invertLongitudinal = false
var invertLateral = 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() 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 } var isIdentity: Bool { self == .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.
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) { func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
let longitudinal = longitudinalSource == .pitch ? pitch : roll let longitudinal = longitudinalSource == .pitch ? pitch : roll
let lateral = longitudinalSource == .pitch ? roll : pitch let lateral = longitudinalSource == .pitch ? roll : pitch
return (longitudinal.map { invertLongitudinal ? -$0 : $0 }, let mappedLongitudinal = longitudinal.map { invertLongitudinal ? -$0 : $0 }
lateral.map { invertLateral ? -$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 { var summary: String {
@@ -41,6 +87,7 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
if longitudinalSource == .roll { parts.append("Achsen getauscht") } if longitudinalSource == .roll { parts.append("Achsen getauscht") }
if invertLongitudinal { parts.append("längs umgekehrt") } if invertLongitudinal { parts.append("längs umgekehrt") }
if invertLateral { parts.append("quer umgekehrt") } if invertLateral { parts.append("quer umgekehrt") }
if twist != 0 { parts.append(String(format: "um %.0f° verdreht", twist)) }
return parts.joined(separator: ", ") return parts.joined(separator: ", ")
} }
} }
@@ -59,6 +106,9 @@ enum OrientationDetection {
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere. /// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
static let ambiguityMargin = 1.3 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 { struct Reading: Equatable {
let pitch: Double let pitch: Double
let roll: Double let roll: Double
@@ -130,6 +180,24 @@ enum OrientationDetection {
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung // Linke Seite nach unten heisst: rechts steht höher, die Querneigung
// ist positiv. // ist positiv.
orientation.invertLateral = lateral < 0 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) 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
}
}
+18 -11
View File
@@ -2,18 +2,22 @@ import Foundation
/// Was zwischen iPhone und Uhr hin- und hergeht. /// Was zwischen iPhone und Uhr hin- und hergeht.
/// ///
/// Die Uhr funkt **nicht** selbst zu den Geräten im Fahrzeug, obwohl watchOS /// Die Aufteilung folgt dem, was jedes Gerät hergibt:
/// das könnte. Zwei Gründe sprechen dagegen, und beide sind hart:
/// ///
/// * BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu. /// * **Den Neigungsmesser funkt die Uhr selbst an** (`WatchLevelRadio`). Er
/// Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie /// bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar, und er ist
/// zu ergänzen. /// unverschlüsselt. Damit steht die Nivellierung am Handgelenk auch dann,
/// * Die Victron-Schlüssel liegen in der Keychain des iPhones. Die Uhr hat ihre /// wenn das iPhone in der Tasche schläft und das ist der Fall, für den man
/// eigene; die Schlüssel dorthin zu kopieren hiesse, sie ein zweites Mal /// die Uhr überhaupt anschaut.
/// aufzubewahren, ohne dass ein zweiter Aufbewahrungsort etwas brächte. /// * **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.
/// ///
/// Das iPhone bleibt also das Funkgerät und schickt fertige Messwerte weiter, /// Übertragen werden deshalb die Messwerte der übrigen Geräte, dazu Fahrzeug
/// die Uhr ist Anzeige und Fernbedienung. /// und Einbaulage des Sensors: Beides braucht die Uhr, um aus den Rohwinkeln
/// des Neigungsmessers dasselbe zu machen wie das iPhone.
enum WatchLink { enum WatchLink {
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein /// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
@@ -84,6 +88,10 @@ struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
var snapshot: DeviceSnapshot? var snapshot: DeviceSnapshot?
/// Nur beim Neigungsmesser belegt. /// Nur beim Neigungsmesser belegt.
var level: LevelState? 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. /// Nur bei der Kühlbox belegt.
var fridge: WatchFridge? var fridge: WatchFridge?
@@ -125,7 +133,6 @@ enum WatchCommand: Codable, Equatable, Sendable {
case fridgeEco(device: UUID, eco: Bool) case fridgeEco(device: UUID, eco: Bool)
case fridgeLock(device: UUID, locked: Bool) case fridgeLock(device: UUID, locked: Bool)
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int) case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
case calibrateLevel(device: UUID)
/// Ob der Befehl den schnellen Sendetakt verlängert. /// Ob der Befehl den schnellen Sendetakt verlängert.
var wantsLiveUpdates: Bool { var wantsLiveUpdates: Bool {
+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: ", ")
}
}
+183 -23
View File
@@ -826,28 +826,53 @@ ageing.add(pitch: 1, roll: 0, at: start.addingTimeInterval(AlignmentAssistant.me
checkEqual("alte Messwerte fallen heraus", ageing.samples.count, 1) checkEqual("alte Messwerte fallen heraus", ageing.samples.count, 1)
// MARK: Auffahrkeile // MARK: Auffahrkeile
print("\nAuffahrkeile")
// tan(2°) x 2,00 m = 6,99 cm print("\nAuffahrkeile Höhe je Rad")
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)
// tan(1,5°) x 3,50 m = 9,16 cm // Reine Querneigung: tan(2°) x 2,00 m = 6,99 cm unter beide Räder der
let alongWedge = LevelingWedge.along(pitch: 1.5, wheelbase: 3.5) // tieferstehenden Seite. Rechts steht höher, also muss links hoch.
checkEqual("Keilhöhe längs", let acrossOnly = LevelingLift.compute(pitch: 0, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5)
alongWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 9.2) checkEqual("quer: vorne links",
checkEqual("Heck höher heisst Keil nach vorne", alongWedge?.side, .front) acrossOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 7.0)
checkEqual("Front höher heisst Keil nach hinten", checkEqual("quer: hinten links",
LevelingWedge.along(pitch: -1.5, wheelbase: 3.5)?.side, .rear) 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", // Reine Längsneigung: tan(1,5°) x 3,50 m = 9,16 cm unter beide Vorderräder,
LevelingWedge.across(roll: 0.3, trackWidth: 2.0) == nil, true) // wenn das Heck höher steht.
checkEqual("ohne Maß kein Keil", let alongOnly = LevelingLift.compute(pitch: 1.5, roll: 0, trackWidth: 2.0, wheelbase: 3.5)
LevelingWedge.across(roll: 3.0, trackWidth: 0) == nil, true) 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 // MARK: 12 Einbaulage des Neigungsmessers
print("\nEinbaulage") print("\nEinbaulage")
@@ -945,6 +970,131 @@ 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 längs stehen", untouched.pitch, 1.5)
checkEqual("unveränderte Lage lässt quer stehen", untouched.roll, -0.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) let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2)
checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true) checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true)
checkEqual("vorhandene Werte kommen durch", missing.roll, 2) checkEqual("vorhandene Werte kommen durch", missing.roll, 2)
@@ -980,6 +1130,12 @@ boosterSnapshot.metrics = [
Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2), 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(isPoweredOn: true, isEco: true, isLocked: false, let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C", isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6, leftTarget: 4, leftCurrent: 6,
@@ -994,12 +1150,13 @@ let watchPayload = WatchPayload(
devices: [ devices: [
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling, WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
link: .live, snapshot: watchLevelSnapshot, link: .live, snapshot: watchLevelSnapshot,
level: watchLevel, fridge: nil), level: watchLevel, orientation: watchOrientation, fridge: nil),
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster, WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot, link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
level: nil, fridge: nil), level: nil, orientation: nil, fridge: nil),
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge, WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: nil, level: nil, fridge: watchFridge), link: .live, snapshot: nil, level: nil,
orientation: nil, fridge: watchFridge),
]) ])
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen. // Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
@@ -1014,6 +1171,10 @@ if let message = try? watchPayload.message(),
checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID) checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID)
checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4) checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4)
checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85) 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 { } else {
check("Datensatz lässt sich verpacken und wieder lesen", false) check("Datensatz lässt sich verpacken und wieder lesen", false)
} }
@@ -1068,7 +1229,6 @@ let commands: [WatchCommand] = [
.fridgeEco(device: watchFridgeID, eco: true), .fridgeEco(device: watchFridgeID, eco: true),
.fridgeLock(device: watchFridgeID, locked: true), .fridgeLock(device: watchFridgeID, locked: true),
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3), .fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
.calibrateLevel(device: watchLevelID),
] ]
for command in commands { for command in commands {
guard let message = try? command.message(), guard let message = try? command.message(),
+83
View File
@@ -105,6 +105,32 @@ globals:
type: float type: float
restore_value: yes restore_value: yes
initial_value: '0.0' 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 - id: enable_captive
type: bool type: bool
restore_value: yes restore_value: yes
@@ -133,6 +159,63 @@ esp32_ble_server:
float val = id(roll).state; float val = id(roll).state;
memcpy(v.data(), &val, sizeof(float)); memcpy(v.data(), &val, sizeof(float));
return v; 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 - id: calib_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233427 uuid: cad48e28-7fbe-41cf-bae9-d77a6c233427
description: "Kalibriere Neigung" description: "Kalibriere Neigung"
+1 -1
View File
@@ -13,7 +13,7 @@ swiftc -O -o "$OUT/tests" \
CamperMonitor/Bluetooth/JBDProtocol.swift \ CamperMonitor/Bluetooth/JBDProtocol.swift \
CamperMonitor/Bluetooth/WattCycleProtocol.swift \ CamperMonitor/Bluetooth/WattCycleProtocol.swift \
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
CamperMonitor/Bluetooth/VanAlignProtocol.swift \ Shared/Bluetooth/VanAlignProtocol.swift \
Shared/Models/DeviceSnapshot.swift \ Shared/Models/DeviceSnapshot.swift \
Shared/Models/ConfiguredDevice.swift \ Shared/Models/ConfiguredDevice.swift \
Shared/Models/LevelState.swift \ Shared/Models/LevelState.swift \