Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0df433421 | ||
|
|
0761706d98 | ||
|
|
a9a44b49d6 | ||
|
|
4936a767a3 | ||
|
|
cee9129f66 | ||
|
|
396e9ed004 | ||
|
|
e47ef25188 | ||
|
|
6612b07fd5 |
@@ -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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-12
@@ -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
|
||||||
|
|||||||
@@ -6,8 +6,38 @@
|
|||||||
objectVersion = 77;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
AA0000000000000000000028 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = AA0000000000000000000009 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = AA0000000000000000000022;
|
||||||
|
remoteInfo = CamperMonitorWatch;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
AA0000000000000000000026 /* Embed Watch Content */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||||
|
dstSubfolderSpec = 16;
|
||||||
|
files = (
|
||||||
|
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */,
|
||||||
|
);
|
||||||
|
name = "Embed Watch Content";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
AA0000000000000000000016 /* CamperMonitorWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitorWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -16,6 +46,16 @@
|
|||||||
path = CamperMonitor;
|
path = CamperMonitor;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000017 /* CamperMonitorWatch */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = CamperMonitorWatch;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AA0000000000000000000018 /* Shared */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -26,6 +66,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000020 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -33,6 +80,9 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AA0000000000000000000002 /* CamperMonitor */,
|
AA0000000000000000000002 /* CamperMonitor */,
|
||||||
|
AA0000000000000000000017 /* CamperMonitorWatch */,
|
||||||
|
AA0000000000000000000018 /* Shared */,
|
||||||
|
AA0000000000000000000031 /* Config */,
|
||||||
AA0000000000000000000005 /* Products */,
|
AA0000000000000000000005 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -41,10 +91,19 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AA0000000000000000000001 /* CamperMonitor.app */,
|
AA0000000000000000000001 /* CamperMonitor.app */,
|
||||||
|
AA0000000000000000000016 /* CamperMonitorWatch.app */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000031 /* Config */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AA0000000000000000000030 /* CamperMonitor-Info.plist */,
|
||||||
|
);
|
||||||
|
path = Config;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -55,17 +114,41 @@
|
|||||||
AA0000000000000000000007 /* Sources */,
|
AA0000000000000000000007 /* Sources */,
|
||||||
AA0000000000000000000003 /* Frameworks */,
|
AA0000000000000000000003 /* Frameworks */,
|
||||||
AA0000000000000000000008 /* Resources */,
|
AA0000000000000000000008 /* Resources */,
|
||||||
|
AA0000000000000000000026 /* Embed Watch Content */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
AA0000000000000000000029 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
AA0000000000000000000002 /* CamperMonitor */,
|
||||||
|
AA0000000000000000000018 /* Shared */,
|
||||||
|
);
|
||||||
|
name = CamperMonitor;
|
||||||
|
productName = CamperMonitor;
|
||||||
|
productReference = AA0000000000000000000001 /* CamperMonitor.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
AA0000000000000000000022 /* CamperMonitorWatch */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */;
|
||||||
|
buildPhases = (
|
||||||
|
AA0000000000000000000019 /* Sources */,
|
||||||
|
AA0000000000000000000020 /* Frameworks */,
|
||||||
|
AA0000000000000000000021 /* Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
AA0000000000000000000002 /* CamperMonitor */,
|
AA0000000000000000000017 /* CamperMonitorWatch */,
|
||||||
|
AA0000000000000000000018 /* Shared */,
|
||||||
);
|
);
|
||||||
name = CamperMonitor;
|
name = CamperMonitorWatch;
|
||||||
productName = CamperMonitor;
|
productName = CamperMonitorWatch;
|
||||||
productReference = AA0000000000000000000001 /* CamperMonitor.app */;
|
productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
@@ -81,6 +164,9 @@
|
|||||||
AA0000000000000000000006 = {
|
AA0000000000000000000006 = {
|
||||||
CreatedOnToolsVersion = 26.6;
|
CreatedOnToolsVersion = 26.6;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000022 = {
|
||||||
|
CreatedOnToolsVersion = 26.6;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */;
|
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */;
|
||||||
@@ -99,6 +185,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
AA0000000000000000000006 /* CamperMonitor */,
|
AA0000000000000000000006 /* CamperMonitor */,
|
||||||
|
AA0000000000000000000022 /* CamperMonitorWatch */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -111,6 +198,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000021 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -121,8 +215,24 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000019 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
AA0000000000000000000029 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
platformFilter = ios;
|
||||||
|
target = AA0000000000000000000022 /* CamperMonitorWatch */;
|
||||||
|
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
AA0000000000000000000012 /* Debug */ = {
|
AA0000000000000000000012 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
@@ -190,6 +300,7 @@
|
|||||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -220,6 +331,7 @@
|
|||||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
@@ -239,6 +351,70 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000023 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
||||||
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
|
||||||
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
AA0000000000000000000024 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
||||||
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
|
||||||
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = watchos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 4;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -260,6 +436,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
AA0000000000000000000023 /* Debug */,
|
||||||
|
AA0000000000000000000024 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = AA0000000000000000000009 /* Project object */;
|
rootObject = AA0000000000000000000009 /* Project object */;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2660"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AA0000000000000000000022"
|
||||||
|
BuildableName = "CamperMonitorWatch.app"
|
||||||
|
BlueprintName = "CamperMonitorWatch"
|
||||||
|
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AA0000000000000000000022"
|
||||||
|
BuildableName = "CamperMonitorWatch.app"
|
||||||
|
BlueprintName = "CamperMonitorWatch"
|
||||||
|
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AA0000000000000000000022"
|
||||||
|
BuildableName = "CamperMonitorWatch.app"
|
||||||
|
BlueprintName = "CamperMonitorWatch"
|
||||||
|
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import SwiftUI
|
|||||||
struct CamperMonitorApp: App {
|
struct CamperMonitorApp: App {
|
||||||
@State private var store: DeviceStore
|
@State private var store: DeviceStore
|
||||||
@State private var bluetooth: BluetoothManager
|
@State private var bluetooth: BluetoothManager
|
||||||
|
@State private var watch: PhoneWatchLink
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let store = DeviceStore()
|
let store = DeviceStore()
|
||||||
|
let bluetooth = BluetoothManager(store: store)
|
||||||
_store = State(initialValue: store)
|
_store = State(initialValue: store)
|
||||||
_bluetooth = State(initialValue: BluetoothManager(store: store))
|
_bluetooth = State(initialValue: bluetooth)
|
||||||
|
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -17,14 +20,27 @@ struct CamperMonitorApp: App {
|
|||||||
DashboardView()
|
DashboardView()
|
||||||
.environment(store)
|
.environment(store)
|
||||||
.environment(bluetooth)
|
.environment(bluetooth)
|
||||||
|
.environment(watch)
|
||||||
|
.task { watch.activate() }
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
|
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
|
||||||
// werden, also Funk sparen und beim Zurückkommen neu starten.
|
// werden, also Funk sparen und beim Zurückkommen neu starten.
|
||||||
|
//
|
||||||
|
// Ausnahme: Solange jemand auf die Uhr schaut, läuft es weiter. Beim
|
||||||
|
// Rangieren liegt das iPhone in der Halterung mit dunklem Bildschirm
|
||||||
|
// – hörte das Funkgerät dann auf, wäre die Anzeige am Handgelenk
|
||||||
|
// genau dann tot, wenn sie gebraucht wird. Die verbundenen Geräte
|
||||||
|
// (Neigungsmesser, BMS, Kühlbox) liefern im Hintergrund weiter; die
|
||||||
|
// Victron-Werbedaten nicht, dafür müsste ohne Filter gesucht werden.
|
||||||
|
watch.isAppInBackground = phase != .active
|
||||||
switch phase {
|
switch phase {
|
||||||
case .active: bluetooth.start()
|
case .active:
|
||||||
case .background: bluetooth.stop()
|
bluetooth.start()
|
||||||
default: break
|
case .background:
|
||||||
|
if !watch.wantsLiveUpdates { bluetooth.stop() }
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SwiftUI
|
|||||||
/// betrifft, steht bei diesem Gerät.
|
/// betrifft, steht bei diesem Gerät.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
||||||
|
@Environment(PhoneWatchLink.self) private var watch
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -21,6 +22,16 @@ struct SettingsView: View {
|
|||||||
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
|
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
|
||||||
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.")
|
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
LabeledContent("Apple Watch", value: watch.statusText)
|
||||||
|
} footer: {
|
||||||
|
Text("Die Uhr zeigt die Werte dieses iPhones und steuert die "
|
||||||
|
+ "Kühlbox darüber – sie funkt nicht selbst zu den Geräten. "
|
||||||
|
+ "Dafür muss diese App laufen; im Hintergrund liefern "
|
||||||
|
+ "Neigungsmesser, BMS und Kühlbox weiter, die "
|
||||||
|
+ "Victron-Werbedaten erst wieder im Vordergrund.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Einstellungen")
|
.navigationTitle("Einstellungen")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
/// Die Gegenstelle zur Apple Watch auf dem iPhone.
|
||||||
|
///
|
||||||
|
/// Das iPhone bleibt das Funkgerät (warum, steht bei `WatchLink`) und reicht
|
||||||
|
/// den fertigen Stand weiter. Zwei Wege, je nachdem, ob die Uhr gerade
|
||||||
|
/// hinschaut:
|
||||||
|
///
|
||||||
|
/// * **Anwendungskontext** – ein Datensatz, den watchOS aufhebt und auch dann
|
||||||
|
/// zustellt, wenn die App auf der Uhr gerade nicht läuft. Damit steht beim
|
||||||
|
/// Aufwecken sofort etwas da statt eines leeren Bildschirms.
|
||||||
|
/// * **Nachricht** – nur solange die Uhr erreichbar ist und gemeldet hat, dass
|
||||||
|
/// sie hinschaut. Dafür im halben Sekundentakt, was beim Ausrichten zählt.
|
||||||
|
///
|
||||||
|
/// Ohne gekoppelte Uhr läuft hier gar nichts: kein Timer, keine Sitzung.
|
||||||
|
@Observable
|
||||||
|
final class PhoneWatchLink: NSObject {
|
||||||
|
|
||||||
|
/// Ob die Uhr gerade Werte sehen will. Solange das gilt, hält die App das
|
||||||
|
/// Bluetooth auch im Hintergrund am Leben.
|
||||||
|
private(set) var wantsLiveUpdates = false
|
||||||
|
/// Nur für die Anzeige in den Einstellungen.
|
||||||
|
private(set) var statusText = "Keine Uhr gekoppelt"
|
||||||
|
|
||||||
|
private let store: DeviceStore
|
||||||
|
private let bluetooth: BluetoothManager
|
||||||
|
|
||||||
|
private var session: WCSession?
|
||||||
|
private var timer: Timer?
|
||||||
|
/// Bis wann der Wunsch der Uhr nach schnellen Werten gilt.
|
||||||
|
private var liveUntil = Date.distantPast
|
||||||
|
private var lastSentContent: WatchPayload?
|
||||||
|
private var lastContextSent = Date.distantPast
|
||||||
|
|
||||||
|
/// Setzt die App, damit beim Ablauf des Wunsches im Hintergrund das Funken
|
||||||
|
/// wieder eingestellt wird.
|
||||||
|
var isAppInBackground = false
|
||||||
|
|
||||||
|
init(store: DeviceStore, bluetooth: BluetoothManager) {
|
||||||
|
self.store = store
|
||||||
|
self.bluetooth = bluetooth
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
guard WCSession.isSupported() else {
|
||||||
|
statusText = "Dieses Gerät kann keine Uhr koppeln"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let session = WCSession.default
|
||||||
|
session.delegate = self
|
||||||
|
self.session = session
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Takt
|
||||||
|
|
||||||
|
private func updateTimer() {
|
||||||
|
let wanted = session?.activationState == .activated && session?.isPaired == true
|
||||||
|
if wanted && timer == nil {
|
||||||
|
// Der schnelle Takt ist der Grundtakt; ob wirklich gesendet wird,
|
||||||
|
// entscheidet `tick` – so muss der Timer nie umgestellt werden.
|
||||||
|
let timer = Timer(timeInterval: WatchLink.liveInterval, repeats: true) { [weak self] _ in
|
||||||
|
self?.tick()
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
self.timer = timer
|
||||||
|
} else if !wanted {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tick() {
|
||||||
|
let live = Date() < liveUntil
|
||||||
|
if live != wantsLiveUpdates {
|
||||||
|
wantsLiveUpdates = live
|
||||||
|
// Der Wunsch ist gerade abgelaufen und niemand schaut mehr hin:
|
||||||
|
// dann darf das Funkgerät im Hintergrund auch wieder ruhen.
|
||||||
|
if !live && isAppInBackground { bluetooth.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Den Stand erst zusammenstellen, wenn er auch rausgeht: im Ruhetakt
|
||||||
|
// ist das nur jeder vierte Aufruf, und zusammengestellt wird auf dem
|
||||||
|
// Hauptthread.
|
||||||
|
let isContextDue = Date().timeIntervalSince(lastContextSent) >= WatchLink.idleInterval
|
||||||
|
guard let session, live || isContextDue else { return }
|
||||||
|
guard let payload = buildPayload() else { return }
|
||||||
|
|
||||||
|
if live && session.isReachable {
|
||||||
|
send(payload, over: session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard isContextDue,
|
||||||
|
lastSentContent?.hasSameContent(as: payload) != true,
|
||||||
|
session.isPaired, session.isWatchAppInstalled else { return }
|
||||||
|
do {
|
||||||
|
try session.updateApplicationContext(payload.message())
|
||||||
|
lastSentContent = payload
|
||||||
|
lastContextSent = Date()
|
||||||
|
} catch {
|
||||||
|
statusText = "Uhr: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nachrichten werden ohne Antwort verschickt: Ein verlorener Datensatz ist
|
||||||
|
/// in einer halben Sekunde ohnehin überholt, ein Wiederholungsversuch käme
|
||||||
|
/// zu spät und stünde nur dem nächsten im Weg.
|
||||||
|
private func send(_ payload: WatchPayload, over session: WCSession) {
|
||||||
|
guard let message = try? payload.message() else { return }
|
||||||
|
session.sendMessage(message, replyHandler: nil, errorHandler: nil)
|
||||||
|
lastSentContent = payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach Änderungen an Geräten oder Profilen aufrufen, damit die Uhr nicht
|
||||||
|
/// bis zum nächsten Takt einen überholten Fahrzeugnamen zeigt.
|
||||||
|
func sendNow() {
|
||||||
|
lastSentContent = nil
|
||||||
|
lastContextSent = .distantPast
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stand zusammenstellen
|
||||||
|
|
||||||
|
private func buildPayload() -> WatchPayload? {
|
||||||
|
guard let profile = store.activeProfile else { return nil }
|
||||||
|
let devices = store.activeDevices.map { device in
|
||||||
|
WatchDevice(id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
role: device.role,
|
||||||
|
link: bluetooth.linkStates[device.id] ?? .searching,
|
||||||
|
snapshot: bluetooth.snapshots[device.id],
|
||||||
|
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
|
||||||
|
orientation: device.role == .leveling ? device.sensorOrientation : nil,
|
||||||
|
fridge: device.role == .fridge
|
||||||
|
? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil)
|
||||||
|
}
|
||||||
|
return WatchPayload(generatedAt: Date(),
|
||||||
|
profile: profile,
|
||||||
|
isRadioReady: bluetooth.isBluetoothReady,
|
||||||
|
radioStatus: bluetooth.bluetoothStatusText,
|
||||||
|
devices: devices)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Befehle von der Uhr
|
||||||
|
|
||||||
|
private func handle(_ command: WatchCommand) {
|
||||||
|
if command.wantsLiveUpdates {
|
||||||
|
liveUntil = Date().addingTimeInterval(WatchLink.liveLease)
|
||||||
|
wantsLiveUpdates = true
|
||||||
|
// Die Uhr kann das iPhone aus dem Hintergrund wecken. Dann steht
|
||||||
|
// das Funkgerät und muss erst wieder anlaufen, sonst geht der
|
||||||
|
// Stellbefehl ins Leere.
|
||||||
|
if isAppInBackground { bluetooth.start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
switch command {
|
||||||
|
case .hello:
|
||||||
|
break
|
||||||
|
case .fridgePower(let device, let on):
|
||||||
|
bluetooth.setFridgePower(on, for: device)
|
||||||
|
case .fridgeEco(let device, let eco):
|
||||||
|
bluetooth.setFridgeEco(eco, for: device)
|
||||||
|
case .fridgeLock(let device, let locked):
|
||||||
|
bluetooth.setFridgeLock(locked, for: device)
|
||||||
|
case .fridgeTarget(let device, let zone, let value):
|
||||||
|
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
|
extension PhoneWatchLink: WCSessionDelegate {
|
||||||
|
|
||||||
|
func session(_ session: WCSession,
|
||||||
|
activationDidCompleteWith state: WCSessionActivationState,
|
||||||
|
error: Error?) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let error {
|
||||||
|
self.statusText = "Uhr: \(error.localizedDescription)"
|
||||||
|
} else {
|
||||||
|
self.updateStatus(session)
|
||||||
|
}
|
||||||
|
self.updateTimer()
|
||||||
|
self.sendNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateStatus(_ session: WCSession) {
|
||||||
|
if !session.isPaired {
|
||||||
|
statusText = "Keine Uhr gekoppelt"
|
||||||
|
} else if !session.isWatchAppInstalled {
|
||||||
|
statusText = "Uhr gekoppelt, App dort nicht installiert"
|
||||||
|
} else {
|
||||||
|
statusText = "Uhr bereit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
guard let command = WatchCommand.decode(from: message) else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.handle(command)
|
||||||
|
// Auf einen Stellbefehl gleich den frischen Stand hinterherschicken.
|
||||||
|
// Der zeigt zwar noch den alten Wert – die Box antwortet erst –,
|
||||||
|
// aber die Uhr weiss dadurch sofort, dass der Befehl ankam.
|
||||||
|
self.tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mit Antwort, wenn die Uhr auf eine wartet: das ist der Weg, auf dem sie
|
||||||
|
/// beim Öffnen ohne Verzögerung an einen Stand kommt.
|
||||||
|
func session(_ session: WCSession,
|
||||||
|
didReceiveMessage message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let command = WatchCommand.decode(from: message) { self.handle(command) }
|
||||||
|
replyHandler((try? self.buildPayload()?.message()) ?? [:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||||
|
DispatchQueue.main.async { self.sendNow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Uhr wurde gekoppelt, gewechselt oder die App dort installiert.
|
||||||
|
func sessionWatchStateDidChange(_ session: WCSession) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.updateStatus(session)
|
||||||
|
self.updateTimer()
|
||||||
|
self.sendNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Wechsel auf eine andere Uhr muss die Sitzung neu aktiviert werden.
|
||||||
|
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||||
|
|
||||||
|
func sessionDidDeactivate(_ session: WCSession) {
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Umformen
|
||||||
|
|
||||||
|
extension WatchFridge {
|
||||||
|
/// Aus dem Zustand der Box wird nur übernommen, was die Uhr anzeigt oder
|
||||||
|
/// stellt – der Rohdatensatz bleibt auf dem iPhone.
|
||||||
|
init(_ state: AlpicoolState) {
|
||||||
|
let range = state.targetRange
|
||||||
|
self.init(isPoweredOn: state.isPoweredOn,
|
||||||
|
isEco: state.isEco,
|
||||||
|
isLocked: state.isLocked,
|
||||||
|
isDualZone: state.isDualZone,
|
||||||
|
unitSymbol: state.unitSymbol,
|
||||||
|
leftTarget: state.leftTarget,
|
||||||
|
leftCurrent: state.leftCurrent,
|
||||||
|
rightTarget: state.isDualZone ? state.rightTarget : nil,
|
||||||
|
rightCurrent: state.isDualZone ? state.rightCurrent : nil,
|
||||||
|
minTarget: range.lowerBound,
|
||||||
|
maxTarget: range.upperBound,
|
||||||
|
batteryVolts: state.batteryVolts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.400", "green" : "0.620", "red" : "0.110" } }, "idiom" : "universal" } ],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "watchos",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "VehicleRear.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "template-rendering-intent" : "template" }
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "VehicleSide.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 },
|
||||||
|
"properties" : { "template-rendering-intent" : "template" }
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Camper Monitor am Handgelenk.
|
||||||
|
///
|
||||||
|
/// Zwei Quellen, jede dort, wo sie hingehört:
|
||||||
|
///
|
||||||
|
/// * Der **Neigungsmesser** hängt direkt an der Uhr (`WatchLevelRadio`). Die
|
||||||
|
/// Nivellierung braucht damit kein geöffnetes iPhone – und genau dafür hebt
|
||||||
|
/// man beim Rangieren den Arm.
|
||||||
|
/// * **Batterie, Solar und Kühlbox** kommen über das iPhone (`PhoneLink`),
|
||||||
|
/// weil dort die Schlüssel liegen und die Geräte nur eine Verbindung
|
||||||
|
/// zulassen.
|
||||||
|
@main
|
||||||
|
struct CamperWatchApp: App {
|
||||||
|
@State private var link = PhoneLink()
|
||||||
|
@State private var radio = WatchLevelRadio()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
WatchRootView()
|
||||||
|
.environment(link)
|
||||||
|
.environment(radio)
|
||||||
|
.task {
|
||||||
|
link.activate()
|
||||||
|
radio.start()
|
||||||
|
// Beim Start meldet `onChange` nichts: Die Bildschirmphase
|
||||||
|
// steht schon auf „aktiv“ und ändert sich nicht mehr. Ohne
|
||||||
|
// diese Zeile bliebe der schnelle Takt des iPhones aus, bis
|
||||||
|
// die App einmal im Hintergrund war.
|
||||||
|
link.setLive(true)
|
||||||
|
}
|
||||||
|
// Die Einbaulage kommt bevorzugt aus dem Sensor selbst. Nur
|
||||||
|
// falls dort noch nichts steht – ältere Firmware, oder nie
|
||||||
|
// bestimmt –, gilt, was das iPhone meldet.
|
||||||
|
.onChange(of: link.payload?.levelDevice?.orientation) { _, orientation in
|
||||||
|
guard let orientation, !radio.hasDeviceOrientation else { return }
|
||||||
|
radio.orientation = orientation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
// Beides kostet Strom und lohnt nur, solange jemand hinschaut.
|
||||||
|
// Genau das sagt die Bildschirmphase aus.
|
||||||
|
link.setLive(phase == .active)
|
||||||
|
if phase == .active { radio.start() } else { radio.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
/// Die Gegenstelle zum iPhone auf der Uhr.
|
||||||
|
///
|
||||||
|
/// Hält den zuletzt empfangenen Stand und schickt Stellbefehle zurück. Gefunkt
|
||||||
|
/// wird nur zum iPhone – zu den Geräten im Fahrzeug nie, siehe `WatchLink`.
|
||||||
|
///
|
||||||
|
/// Zwei Quellen laufen zusammen:
|
||||||
|
///
|
||||||
|
/// * Der **Anwendungskontext** liegt schon da, wenn die App startet. Deshalb
|
||||||
|
/// steht sofort etwas auf dem Bildschirm, auch wenn das iPhone in der Tasche
|
||||||
|
/// steckt und gerade nicht antwortet – mit Altersangabe, versteht sich.
|
||||||
|
/// * **Nachrichten** kommen im halben Sekundentakt, solange wir gemeldet haben,
|
||||||
|
/// dass jemand hinschaut. Das Melden wird regelmässig erneuert: Schläft die
|
||||||
|
/// Uhr ein, bleibt die Erneuerung aus und das iPhone hört von selbst auf.
|
||||||
|
@Observable
|
||||||
|
final class PhoneLink: NSObject {
|
||||||
|
|
||||||
|
private(set) var payload: WatchPayload?
|
||||||
|
/// Wann der Stand hier ankam – nicht, wann er entstand. Beides zusammen
|
||||||
|
/// unterscheidet „iPhone weg“ von „Gerät im Fahrzeug antwortet nicht“.
|
||||||
|
private(set) var receivedAt: Date?
|
||||||
|
private(set) var isReachable = false
|
||||||
|
private(set) var isActivated = false
|
||||||
|
|
||||||
|
private var session: WCSession?
|
||||||
|
/// Erneuert die Meldung „hier schaut jemand hin“ – im Vorführbetrieb
|
||||||
|
/// stattdessen der Taktgeber der erfundenen Werte.
|
||||||
|
private var timer: Timer?
|
||||||
|
private var isLive = false
|
||||||
|
|
||||||
|
/// Ohne iPhone lässt sich auf der Uhr sonst nichts vorführen.
|
||||||
|
private let isDemo = ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
|
||||||
|
|
||||||
|
func activate() {
|
||||||
|
if isDemo {
|
||||||
|
startDemo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard WCSession.isSupported() else { return }
|
||||||
|
let session = WCSession.default
|
||||||
|
session.delegate = self
|
||||||
|
self.session = session
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet dem iPhone, ob gerade jemand hinschaut. Beim Ausrichten zählt
|
||||||
|
/// jeder halbe Sekundentakt, beim Nachsehen des Ladezustands nicht.
|
||||||
|
func setLive(_ live: Bool) {
|
||||||
|
guard !isDemo else { return }
|
||||||
|
isLive = live
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
guard live else {
|
||||||
|
send(.hello(live: false))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send(.hello(live: true))
|
||||||
|
// Vor Ablauf der Frist erneuern, damit zwischendurch keine Lücke
|
||||||
|
// entsteht. Bleibt die Erneuerung aus, endet der schnelle Takt von
|
||||||
|
// selbst – niemand muss ihn abbestellen.
|
||||||
|
let renew = Timer(timeInterval: WatchLink.liveLease / 3, repeats: true) { [weak self] _ in
|
||||||
|
self?.send(.hello(live: true))
|
||||||
|
}
|
||||||
|
RunLoop.main.add(renew, forMode: .common)
|
||||||
|
timer = renew
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schickt einen Befehl und übernimmt die Antwort, falls eine kommt.
|
||||||
|
///
|
||||||
|
/// Die Antwort ist der Stand *vor* der Wirkung – die Kühlbox antwortet dem
|
||||||
|
/// iPhone erst ein paar Zehntel später. Sie zeigt also nicht, dass der
|
||||||
|
/// Befehl gewirkt hat, sondern nur, dass er angekommen ist. Der gestellte
|
||||||
|
/// Wert erscheint, wenn das Gerät ihn zurückmeldet, so wie am iPhone auch.
|
||||||
|
func send(_ command: WatchCommand) {
|
||||||
|
guard !isDemo, let session, session.activationState == .activated,
|
||||||
|
let message = try? command.message() else { return }
|
||||||
|
guard session.isReachable else { return }
|
||||||
|
session.sendMessage(message, replyHandler: { [weak self] reply in
|
||||||
|
guard let payload = WatchPayload.decode(from: reply) else { return }
|
||||||
|
DispatchQueue.main.async { self?.accept(payload) }
|
||||||
|
}, errorHandler: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func accept(_ payload: WatchPayload) {
|
||||||
|
// Ein Nachzügler darf einen neueren Stand nicht überschreiben.
|
||||||
|
if let current = self.payload, current.generatedAt > payload.generatedAt { return }
|
||||||
|
self.payload = payload
|
||||||
|
receivedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Vorführbetrieb
|
||||||
|
|
||||||
|
private func startDemo() {
|
||||||
|
payload = WatchDemo.payload(at: 0)
|
||||||
|
receivedAt = Date()
|
||||||
|
isReachable = true
|
||||||
|
isActivated = true
|
||||||
|
var step = 0.0
|
||||||
|
let tick = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||||
|
step += 0.5
|
||||||
|
self?.payload = WatchDemo.payload(at: step)
|
||||||
|
self?.receivedAt = Date()
|
||||||
|
}
|
||||||
|
RunLoop.main.add(tick, forMode: .common)
|
||||||
|
timer = tick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
|
extension PhoneLink: WCSessionDelegate {
|
||||||
|
|
||||||
|
func session(_ session: WCSession,
|
||||||
|
activationDidCompleteWith state: WCSessionActivationState,
|
||||||
|
error: Error?) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isActivated = state == .activated
|
||||||
|
self.isReachable = session.isReachable
|
||||||
|
// Was schon dalag, gilt sofort – sonst bliebe der Bildschirm leer,
|
||||||
|
// bis das iPhone das nächste Mal von sich aus sendet.
|
||||||
|
if let payload = WatchPayload.decode(from: session.receivedApplicationContext) {
|
||||||
|
self.accept(payload)
|
||||||
|
}
|
||||||
|
if self.isLive { self.send(.hello(live: true)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
guard let payload = WatchPayload.decode(from: message) else { return }
|
||||||
|
DispatchQueue.main.async { self.accept(payload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
|
||||||
|
guard let payload = WatchPayload.decode(from: context) else { return }
|
||||||
|
DispatchQueue.main.async { self.accept(payload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isReachable = session.isReachable
|
||||||
|
if session.isReachable, self.isLive { self.send(.hello(live: true)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
/// Der Ausrichtungs-Assistent am Handgelenk – der eigentliche Grund für die
|
||||||
|
/// Uhr: Beim Rangieren hat man das iPhone nicht in der Hand, aber den Arm am
|
||||||
|
/// Lenkrad. Ein Blick nach unten genügt, und die Rückmeldung „steht eben“ kommt
|
||||||
|
/// zusätzlich als Vibration, ganz ohne Blick.
|
||||||
|
///
|
||||||
|
/// Gerechnet wird mit demselben `AlignmentAssistant` wie am iPhone: kein
|
||||||
|
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
|
||||||
|
/// schlechter wird, steckt vollständig darin.
|
||||||
|
struct WatchAlignView: View {
|
||||||
|
@Environment(PhoneLink.self) private var link
|
||||||
|
@Environment(WatchLevelRadio.self) private var radio
|
||||||
|
@State private var assistant = AlignmentAssistant()
|
||||||
|
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
|
||||||
|
@State private var hasAnnouncedTarget = false
|
||||||
|
|
||||||
|
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
|
||||||
|
private var state: LevelState? { reading.hasReading ? reading.state : nil }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if let current = assistant.current {
|
||||||
|
Text(String(format: "%.1f°", current.deviation))
|
||||||
|
.font(.system(size: 40, weight: .semibold, design: .rounded))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
.foregroundStyle(trendColor)
|
||||||
|
|
||||||
|
Text(assistant.advice)
|
||||||
|
.font(.caption)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let state {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
WatchBubble(pitch: state.pitch, roll: state.roll)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
|
||||||
|
Text(String(format: "Quer %.1f°", state.roll ?? 0))
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Von vorn messen", systemImage: "arrow.counterclockwise") {
|
||||||
|
assistant.reset()
|
||||||
|
hasAnnouncedTarget = false
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.navigationTitle("Ausrichten")
|
||||||
|
.onChange(of: tilt, initial: true) { _, tilt in
|
||||||
|
guard let tilt else { return }
|
||||||
|
assistant.add(pitch: tilt.pitch, roll: tilt.roll)
|
||||||
|
announceIfLevel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Wertepaar als Auslöser: gerechnet wird nur, wenn sich die Neigung
|
||||||
|
/// wirklich ändert – nicht bei jeder Aktualisierung der Anzeige.
|
||||||
|
private var tilt: Tilt? {
|
||||||
|
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
|
||||||
|
return Tilt(pitch: pitch, roll: roll)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Tilt: Equatable {
|
||||||
|
let pitch: Double
|
||||||
|
let roll: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trendColor: Color {
|
||||||
|
switch assistant.trend {
|
||||||
|
case .improving: return .green
|
||||||
|
case .worsening: return .orange
|
||||||
|
default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Vibration ist der Punkt: Sie sagt „anhalten“, ohne dass jemand
|
||||||
|
/// hinschauen muss. Erneut gemeldet wird erst, wenn das Fahrzeug die
|
||||||
|
/// Toleranz zwischendurch wieder verlassen hat.
|
||||||
|
private func announceIfLevel() {
|
||||||
|
if assistant.hasReachedTarget {
|
||||||
|
guard !hasAnnouncedTarget else { return }
|
||||||
|
hasAnnouncedTarget = true
|
||||||
|
WKInterfaceDevice.current().play(.success)
|
||||||
|
} else {
|
||||||
|
hasAnnouncedTarget = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Libelle für die Uhr: eine Blase, die dorthin wandert, wo das Fahrzeug
|
||||||
|
/// **höher** steht – wie bei einer echten Wasserwaage.
|
||||||
|
///
|
||||||
|
/// Bewusst schlichter als am iPhone: Auf 40 mm Bildschirm ist der innere Ring
|
||||||
|
/// (die Toleranz) die einzige Hilfslinie, die sich noch ablesen lässt.
|
||||||
|
struct WatchBubble: View {
|
||||||
|
let pitch: Double?
|
||||||
|
let roll: Double?
|
||||||
|
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||||||
|
var range: Double = 4
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let side = min(geometry.size.width, geometry.size.height)
|
||||||
|
let radius = side / 2
|
||||||
|
let bubble = side * 0.22
|
||||||
|
let travel = radius - bubble / 2 - 2
|
||||||
|
let tolerance = max(LevelState.levelTolerance / range * travel, bubble * 0.55)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Color.gray.opacity(0.25))
|
||||||
|
Circle().strokeBorder(Color.gray.opacity(0.5), lineWidth: 1)
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(isLevel ? Color.green : Color.gray.opacity(0.6),
|
||||||
|
lineWidth: isLevel ? 2 : 1)
|
||||||
|
.frame(width: tolerance * 2, height: tolerance * 2)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: bubble, height: bubble)
|
||||||
|
// Positiver Pitch heisst: das Heck steht höher, die Blase
|
||||||
|
// wandert nach oben – in der Ansicht also nach hinten.
|
||||||
|
.offset(x: clamped(roll) / range * travel,
|
||||||
|
y: -clamped(pitch) / range * travel)
|
||||||
|
.animation(.spring(duration: 0.3), value: pitch)
|
||||||
|
.animation(.spring(duration: 0.3), value: roll)
|
||||||
|
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||||||
|
}
|
||||||
|
.frame(width: side, height: side)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.aspectRatio(1, contentMode: .fit)
|
||||||
|
.accessibilityLabel(accessibilityText)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hält die Blase im Ring, auch wenn das Fahrzeug stark schräg steht.
|
||||||
|
private func clamped(_ value: Double?) -> Double {
|
||||||
|
guard let value else { return 0 }
|
||||||
|
return min(max(value, -range), range)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deviation: Double? {
|
||||||
|
switch (pitch, roll) {
|
||||||
|
case let (p?, r?): return max(abs(p), abs(r))
|
||||||
|
case let (p?, nil): return abs(p)
|
||||||
|
case let (nil, r?): return abs(r)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isLevel: Bool { (deviation ?? .infinity) <= LevelState.levelTolerance }
|
||||||
|
|
||||||
|
/// Grün nur, wenn es wirklich eben ist – die Akzentfarbe der App ist selbst
|
||||||
|
/// grün, sonst sähe schief aus wie eben.
|
||||||
|
private var color: Color {
|
||||||
|
guard let deviation else { return .gray }
|
||||||
|
if deviation <= LevelState.levelTolerance { return .green }
|
||||||
|
return deviation <= 2 ? .orange : .red
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accessibilityText: String {
|
||||||
|
guard let pitch, let roll else { return "Keine Messwerte" }
|
||||||
|
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Alle Werte eines Geräts. Am iPhone stehen hier zusätzlich Verlauf und
|
||||||
|
/// Diagnose – auf der Uhr wäre beides unlesbar und ist deshalb weggelassen.
|
||||||
|
struct WatchDeviceDetailView: View {
|
||||||
|
let deviceID: UUID
|
||||||
|
|
||||||
|
@Environment(PhoneLink.self) private var link
|
||||||
|
|
||||||
|
private var device: WatchDevice? {
|
||||||
|
link.payload?.devices.first { $0.id == deviceID }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if let device {
|
||||||
|
if let fridge = device.fridge {
|
||||||
|
WatchFridgeControls(deviceID: device.id, fridge: fridge)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(device.snapshot?.metrics.filter { $0.value != nil } ?? []) { metric in
|
||||||
|
LabeledContent(metric.label) {
|
||||||
|
Text(metric.formattedWithUnit)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Messwerte")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
if let fault = device.snapshot?.fault {
|
||||||
|
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
if let state = device.snapshot?.state {
|
||||||
|
Text(state).font(.caption2)
|
||||||
|
}
|
||||||
|
ForEach(device.snapshot?.offReasons ?? [], id: \.self) { reason in
|
||||||
|
Text(reason)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Label(device.link.label, systemImage: "antenna.radiowaves.left.and.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Gerät ist nicht mehr im Fahrzeugprofil.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(device?.name ?? "Gerät")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Die Kühlbox von der Uhr aus stellen – das einzige Gerät, das sich stellen
|
||||||
|
/// lässt.
|
||||||
|
///
|
||||||
|
/// Wie am iPhone folgen die Schalter dem Gerät, nicht der Vermutung: Getippt
|
||||||
|
/// wird ein Befehl, angezeigt wird, was die Box zurückmeldet. Über die Uhr
|
||||||
|
/// dauert das einen Wimpernschlag länger – der Weg geht über das iPhone –,
|
||||||
|
/// dafür kann kein Schalter etwas behaupten, das die Box nie bestätigt hat.
|
||||||
|
struct WatchFridgeControls: View {
|
||||||
|
let deviceID: UUID
|
||||||
|
let fridge: WatchFridge
|
||||||
|
|
||||||
|
@Environment(PhoneLink.self) private var link
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Toggle("Ein", isOn: Binding(
|
||||||
|
get: { fridge.isPoweredOn },
|
||||||
|
set: { link.send(.fridgePower(device: deviceID, on: $0)) }
|
||||||
|
))
|
||||||
|
|
||||||
|
Toggle("Eco", isOn: Binding(
|
||||||
|
get: { fridge.isEco },
|
||||||
|
set: { link.send(.fridgeEco(device: deviceID, eco: $0)) }
|
||||||
|
))
|
||||||
|
.disabled(!fridge.isPoweredOn)
|
||||||
|
|
||||||
|
target(zone: .left,
|
||||||
|
title: fridge.isDualZone ? "Soll links" : "Soll",
|
||||||
|
value: fridge.leftTarget)
|
||||||
|
|
||||||
|
if fridge.isDualZone {
|
||||||
|
target(zone: .right, title: "Soll rechts", value: fridge.rightTarget)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Kühlbox")
|
||||||
|
} footer: {
|
||||||
|
if !link.isReachable {
|
||||||
|
Text("iPhone gerade nicht erreichbar – Stellbefehle gehen erst "
|
||||||
|
+ "wieder durch, wenn es antwortet.")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func target(zone: WatchFridgeZone, title: String, value: Int?) -> some View {
|
||||||
|
Stepper(value: Binding(
|
||||||
|
get: { value ?? fridge.targetRange.lowerBound },
|
||||||
|
set: { link.send(.fridgeTarget(device: deviceID, zone: zone, value: $0)) }
|
||||||
|
), in: fridge.targetRange) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value.map { "\($0) \(fridge.unitSymbol)" } ?? "–")
|
||||||
|
.font(.headline)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!fridge.isPoweredOn || value == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
@Environment(PhoneLink.self) private var link
|
||||||
|
@Environment(WatchLevelRadio.self) private var radio
|
||||||
|
@AppStorage("levelDisplayStyle") private var showsVehicle = true
|
||||||
|
|
||||||
|
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if showsVehicle {
|
||||||
|
WatchVehicleView(pitch: reading.state.pitch, roll: reading.state.roll)
|
||||||
|
} else {
|
||||||
|
WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll)
|
||||||
|
.frame(maxWidth: 110)
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
angle("Längs", reading.state.pitch)
|
||||||
|
angle("Quer", reading.state.roll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(reading.state.instruction ?? "Keine Werte")
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(reading.state.isLevel ? .green : .primary)
|
||||||
|
|
||||||
|
if let lift, !lift.isNegligible {
|
||||||
|
WheelLiftPlan(lift: lift, isCompact: true)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
WatchAlignView()
|
||||||
|
} label: {
|
||||||
|
Label("Ausrichten", systemImage: "arrow.left.and.right")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showsVehicle.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(showsVehicle ? "Libelle" : "Fahrzeug",
|
||||||
|
systemImage: showsVehicle ? "circle.circle" : "box.truck")
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
|
||||||
|
SourceNote(reading: reading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.navigationTitle("Nivellierung")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func angle(_ title: String, _ value: Double?) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.monospacedDigit()
|
||||||
|
Text(title)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie hoch jedes Rad unterlegt werden muss – reine Geometrie, und nur zu
|
||||||
|
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
|
||||||
|
private var lift: LevelingLift? {
|
||||||
|
guard let profile = link.payload?.profile,
|
||||||
|
let width = profile.trackWidth, let base = profile.wheelbase,
|
||||||
|
let pitch = reading.state.pitch, let roll = reading.state.roll
|
||||||
|
else { return nil }
|
||||||
|
return LevelingLift.compute(pitch: pitch, roll: roll,
|
||||||
|
trackWidth: width, wheelbase: base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Woher die angezeigte Neigung kommt und wie alt sie ist.
|
||||||
|
struct SourceNote: View {
|
||||||
|
let reading: WatchLevelReading
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||||
|
let age = reading.age(now: context.date)
|
||||||
|
Label {
|
||||||
|
Text(text(age: age))
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: reading.isDirect
|
||||||
|
? "antenna.radiowaves.left.and.right" : "iphone.gen3")
|
||||||
|
}
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(age > 5 ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func text(age: TimeInterval) -> String {
|
||||||
|
guard reading.hasReading else { return reading.statusText }
|
||||||
|
let source = reading.isDirect ? "Sensor" : "iPhone"
|
||||||
|
if age < 3 { return "\(source), eben gemessen" }
|
||||||
|
// Steht der Wert, gehört der Grund dazu und nicht nur das Alter.
|
||||||
|
if age < 90 { return String(format: "%@, vor %.0f s · %@", source, age, reading.statusText) }
|
||||||
|
return String(format: "%@, vor %.0f min · %@", source, age / 60, reading.statusText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die angezeigte Neigung samt Herkunft.
|
||||||
|
///
|
||||||
|
/// Die eigene Verbindung hat Vorrang, solange sie **frisch** ist. Das „solange“
|
||||||
|
/// ist der Punkt: Bleibt der Sensor stehen, ohne die Verbindung zu trennen,
|
||||||
|
/// würde ein einmal gelesener Wert sonst für immer stehenbleiben – und eine
|
||||||
|
/// eingefrorene Anzeige ist beim Ausrichten schlimmer als gar keine. Dann tritt
|
||||||
|
/// der Stand des iPhones an seine Stelle, und wenn auch der fehlt, bleibt der
|
||||||
|
/// alte Wert stehen, aber mit seinem Alter daneben.
|
||||||
|
struct WatchLevelReading {
|
||||||
|
let state: LevelState
|
||||||
|
let isDirect: Bool
|
||||||
|
let measuredAt: Date?
|
||||||
|
let statusText: String
|
||||||
|
|
||||||
|
/// Ab hier gilt ein direkt gemessener Wert als überholt. Gefragt wird
|
||||||
|
/// zweimal je Sekunde ab; drei Sekunden Stille sind schon viel.
|
||||||
|
static let directLifetime: TimeInterval = 3
|
||||||
|
|
||||||
|
init(radio: WatchLevelRadio, phone: PhoneLink, now: Date = Date()) {
|
||||||
|
let directAge = radio.updatedAt.map { now.timeIntervalSince($0) }
|
||||||
|
let hasFreshDirect = radio.state.hasReading
|
||||||
|
&& (directAge ?? .infinity) <= Self.directLifetime
|
||||||
|
let relayed = phone.payload?.levelDevice
|
||||||
|
|
||||||
|
if hasFreshDirect {
|
||||||
|
state = radio.state
|
||||||
|
isDirect = true
|
||||||
|
measuredAt = radio.updatedAt
|
||||||
|
statusText = radio.statusText
|
||||||
|
} else if let relayed, let level = relayed.level {
|
||||||
|
state = level
|
||||||
|
isDirect = false
|
||||||
|
measuredAt = relayed.snapshot?.timestamp ?? phone.payload?.generatedAt
|
||||||
|
statusText = relayed.link.label
|
||||||
|
} else if radio.state.hasReading {
|
||||||
|
state = radio.state
|
||||||
|
isDirect = true
|
||||||
|
measuredAt = radio.updatedAt
|
||||||
|
statusText = radio.statusText
|
||||||
|
} else {
|
||||||
|
state = LevelState()
|
||||||
|
isDirect = false
|
||||||
|
measuredAt = nil
|
||||||
|
statusText = radio.statusText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasReading: Bool { state.hasReading }
|
||||||
|
|
||||||
|
func age(now: Date = Date()) -> TimeInterval {
|
||||||
|
guard let measuredAt else { return 0 }
|
||||||
|
return now.timeIntervalSince(measuredAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Der Einstieg: das Wichtigste untereinander, in der Reihenfolge, in der man
|
||||||
|
/// im Wohnmobil danach schaut.
|
||||||
|
struct WatchRootView: View {
|
||||||
|
@Environment(PhoneLink.self) private var link
|
||||||
|
@Environment(WatchLevelRadio.self) private var radio
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
content
|
||||||
|
.navigationTitle(link.payload?.profile.name ?? "Camper")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var content: some View {
|
||||||
|
List {
|
||||||
|
// Die Nivellierung steht immer oben und immer da: Sie kommt vom
|
||||||
|
// Sensor selbst und braucht das iPhone nicht.
|
||||||
|
Section {
|
||||||
|
NavigationLink {
|
||||||
|
WatchLevelView()
|
||||||
|
} label: {
|
||||||
|
LevelSummaryRow(reading: WatchLevelReading(radio: radio, phone: link))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let payload = link.payload {
|
||||||
|
Section {
|
||||||
|
ForEach(payload.devices.filter { $0.role != .leveling }) { device in
|
||||||
|
NavigationLink {
|
||||||
|
WatchDeviceDetailView(deviceID: device.id)
|
||||||
|
} label: {
|
||||||
|
DeviceRow(device: device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
FreshnessNote(payload: payload, receivedAt: link.receivedAt)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
waiting
|
||||||
|
} footer: {
|
||||||
|
Text("Batterie, Solar und Kühlbox kommen vom iPhone. Die "
|
||||||
|
+ "Nivellierung oben nicht – die geht direkt.")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var waiting: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Label("Warte auf das iPhone", systemImage: "iphone.gen3.radiowaves.left.and.right")
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
Text("Dort muss Camper Monitor geöffnet sein.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Gerätezeile: Name klein, Hauptwert gross – auf dem kleinen Bildschirm
|
||||||
|
/// zählt nur der eine Wert, wegen dem man den Arm hebt.
|
||||||
|
struct DeviceRow: View {
|
||||||
|
let device: WatchDevice
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: device.role.symbol)
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.frame(width: 20)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(device.name)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
if let metric = device.snapshot?.primaryMetric, metric.value != nil {
|
||||||
|
Text(metric.formattedWithUnit)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(device.isLive ? .primary : .secondary)
|
||||||
|
} else {
|
||||||
|
Text(device.link.label)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Circle()
|
||||||
|
.fill(device.statusColor)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Nivellierung bekommt eine eigene, grössere Zeile: sie ist der Grund,
|
||||||
|
/// aus dem man die Uhr im Fahrzeug überhaupt anschaut.
|
||||||
|
struct LevelSummaryRow: View {
|
||||||
|
let reading: WatchLevelReading
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Nivellierung")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
// Ohne Messwerte steht hier, woran es liegt, statt eines
|
||||||
|
// nichtssagenden Strichs.
|
||||||
|
Text(reading.hasReading
|
||||||
|
? (reading.state.instruction ?? "Keine Werte")
|
||||||
|
: reading.statusText)
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie alt der Stand ist – auf der Uhr die wichtigste Nebenangabe. Alles hier
|
||||||
|
/// ist weitergereicht; eine abgerissene Strecke zum iPhone sieht sonst genauso
|
||||||
|
/// aus wie ein Fahrzeug, an dem sich nichts tut.
|
||||||
|
struct FreshnessNote: View {
|
||||||
|
let payload: WatchPayload
|
||||||
|
let receivedAt: Date?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||||
|
let age = context.date.timeIntervalSince(receivedAt ?? payload.generatedAt)
|
||||||
|
Label {
|
||||||
|
Text(text(age: age))
|
||||||
|
.font(.caption2)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: age > 10 ? "exclamationmark.triangle.fill" : "iphone.gen3")
|
||||||
|
}
|
||||||
|
.foregroundStyle(age > 10 ? .orange : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func text(age: TimeInterval) -> String {
|
||||||
|
if !payload.isRadioReady { return payload.radioStatus }
|
||||||
|
if age < 3 { return "Stand: eben" }
|
||||||
|
if age < 90 { return String(format: "Stand: vor %.0f s", age) }
|
||||||
|
return String(format: "Stand: vor %.0f min – iPhone erreichbar?", age / 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WatchDevice {
|
||||||
|
/// Grün heisst: der Wert ist frisch und kommt vom Gerät selbst.
|
||||||
|
var statusColor: Color {
|
||||||
|
switch link {
|
||||||
|
case .live: return (snapshot?.isStale ?? true) ? .orange : .green
|
||||||
|
case .needsKey: return .orange
|
||||||
|
case .failed: return .red
|
||||||
|
default: return .gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Die Neigung am Fahrzeug selbst, wie am iPhone – nur auf Uhrgrösse gebracht.
|
||||||
|
///
|
||||||
|
/// Zwei Ansichten, jede um ihre Achse gekippt: die Seitenansicht für längs
|
||||||
|
/// (Front links, Heck rechts) und die Heckansicht für quer. Die Heckansicht
|
||||||
|
/// teilt die Blickrichtung des Fahrers, links im Bild ist also links am
|
||||||
|
/// Fahrzeug.
|
||||||
|
struct WatchVehicleView: View {
|
||||||
|
let pitch: Double?
|
||||||
|
let roll: Double?
|
||||||
|
|
||||||
|
/// Wie am iPhone dreifach überhöht – zwei Grad wären sonst kaum zu sehen.
|
||||||
|
/// Die angeschriebenen Gradzahlen bleiben echt.
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
panel(image: "VehicleSide", angle: pitch, title: "Längs",
|
||||||
|
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
|
||||||
|
panel(image: "VehicleRear", angle: roll, title: "Quer",
|
||||||
|
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func panel(image: String,
|
||||||
|
angle: Double?,
|
||||||
|
title: String,
|
||||||
|
lower: String,
|
||||||
|
upper: String,
|
||||||
|
aspect: Double) -> some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
Spacer()
|
||||||
|
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// Die Waagerechte als Bezug – ohne sie ist eine kleine Neigung
|
||||||
|
// nicht einzuschätzen.
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.secondary.opacity(0.35))
|
||||||
|
.frame(height: 1)
|
||||||
|
|
||||||
|
Image(image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||||
|
.aspectRatio(aspect, contentMode: .fit)
|
||||||
|
.rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration))
|
||||||
|
.animation(.spring(duration: 0.4), value: angle)
|
||||||
|
.opacity(angle == nil ? 0.3 : 1)
|
||||||
|
}
|
||||||
|
.frame(height: 52)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text(lower)
|
||||||
|
Spacer()
|
||||||
|
Text(upper)
|
||||||
|
}
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Erfundene Werte für den Simulator, damit sich die Ansichten ohne iPhone und
|
||||||
|
/// ohne Fahrzeug prüfen lassen. Eingeschaltet über `CAMPER_DEMO=1`:
|
||||||
|
///
|
||||||
|
/// xcrun simctl launch --terminate-running-process booted \
|
||||||
|
/// de.fritob.CamperMonitor.watchkitapp
|
||||||
|
/// # mit SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
||||||
|
///
|
||||||
|
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
|
||||||
|
enum WatchDemo {
|
||||||
|
|
||||||
|
private static let profile = Profile(name: "Kastenwagen", symbol: "box.truck",
|
||||||
|
trackWidth: 1.85, wheelbase: 3.50)
|
||||||
|
|
||||||
|
private static let solarID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")!
|
||||||
|
private static let batteryID = UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!
|
||||||
|
private static let fridgeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!
|
||||||
|
private static let levelID = UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!
|
||||||
|
|
||||||
|
/// `seconds` läuft weiter, damit sich die Neigung bewegt – sonst zeigt der
|
||||||
|
/// Ausrichtungs-Assistent nie einen Trend.
|
||||||
|
static func payload(at seconds: Double) -> WatchPayload {
|
||||||
|
let now = Date()
|
||||||
|
let swing = sin(seconds / 8)
|
||||||
|
|
||||||
|
var level = LevelState()
|
||||||
|
level.pitch = 1.4 * swing + 0.3
|
||||||
|
level.roll = -0.9 * cos(seconds / 11)
|
||||||
|
level.pitchOffset = 0.4
|
||||||
|
level.rollOffset = -0.2
|
||||||
|
|
||||||
|
var solar = DeviceSnapshot(deviceID: solarID, timestamp: now, rssi: -58)
|
||||||
|
solar.state = "Konstantspannung (Absorption)"
|
||||||
|
solar.metrics = [
|
||||||
|
Metric("pv_power", "PV-Leistung", 284 + 40 * swing, unit: "W", precision: 0, primary: true),
|
||||||
|
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
|
||||||
|
Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
var battery = DeviceSnapshot(deviceID: batteryID, timestamp: now, rssi: -64)
|
||||||
|
battery.state = "Lädt"
|
||||||
|
battery.metrics = [
|
||||||
|
Metric("soc", "Ladezustand", 78.4, unit: "%", precision: 1, primary: true),
|
||||||
|
Metric("voltage", "Spannung", 13.66, unit: "V", precision: 2),
|
||||||
|
Metric("current", "Strom", 18.6, unit: "A", precision: 1),
|
||||||
|
Metric("capacity", "Restkapazität", 156.8, unit: "Ah", precision: 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
var fridge = DeviceSnapshot(deviceID: fridgeID, timestamp: now, rssi: -66)
|
||||||
|
fridge.state = "Kompressor läuft"
|
||||||
|
fridge.metrics = [
|
||||||
|
Metric("temperature", "Innentemperatur", 6, unit: "°C", precision: 0, primary: true),
|
||||||
|
Metric("target", "Solltemperatur", 4, unit: "°C", precision: 0),
|
||||||
|
Metric("battery_voltage", "Bordspannung", 12.7, unit: "V", precision: 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
return WatchPayload(
|
||||||
|
generatedAt: now,
|
||||||
|
profile: profile,
|
||||||
|
isRadioReady: true,
|
||||||
|
radioStatus: "Demo-Modus",
|
||||||
|
devices: [
|
||||||
|
WatchDevice(id: levelID, name: "Nivellierung", role: .leveling,
|
||||||
|
link: .live, snapshot: level.snapshot(deviceID: levelID, rssi: -70),
|
||||||
|
level: level, orientation: .identity, fridge: nil),
|
||||||
|
WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms,
|
||||||
|
link: .live, snapshot: battery, level: nil,
|
||||||
|
orientation: nil, fridge: nil),
|
||||||
|
WatchDevice(id: solarID, name: "Solar Dach", role: .solarCharger,
|
||||||
|
link: .live, snapshot: solar, level: nil,
|
||||||
|
orientation: nil, fridge: nil),
|
||||||
|
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
|
||||||
|
link: .live, snapshot: fridge, level: nil, orientation: nil,
|
||||||
|
fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
|
||||||
|
isDualZone: false, unitSymbol: "°C",
|
||||||
|
leftTarget: 4, leftCurrent: 6,
|
||||||
|
rightTarget: nil, rightCurrent: nil,
|
||||||
|
minTarget: -20, maxTarget: 20,
|
||||||
|
batteryVolts: 12.7)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>bluetooth-central</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -12,6 +12,12 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
|
|||||||
|
|
||||||
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
|
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
|
||||||
|
|
||||||
|
Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt
|
||||||
|
Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am
|
||||||
|
Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt
|
||||||
|
über das iPhone – warum diese Aufteilung, steht unter
|
||||||
|
[Apple Watch](#apple-watch).
|
||||||
|
|
||||||
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe
|
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe
|
||||||
[firmware/vanalign](firmware/vanalign/README.md).
|
[firmware/vanalign](firmware/vanalign/README.md).
|
||||||
|
|
||||||
@@ -25,8 +31,21 @@ Dann in Xcode:
|
|||||||
|
|
||||||
1. Target `CamperMonitor` → **Signing & Capabilities** → dein Apple-Team auswählen.
|
1. Target `CamperMonitor` → **Signing & Capabilities** → dein Apple-Team auswählen.
|
||||||
Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist.
|
Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist.
|
||||||
|
Dasselbe beim Target `CamperMonitorWatch`; dessen Bundle-ID muss die des
|
||||||
|
iPhones mit angehängtem `.watchkitapp` bleiben.
|
||||||
2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R.
|
2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R.
|
||||||
|
|
||||||
|
Das Schema `CamperMonitor` baut die Watch-App mit und bettet sie ein. Dafür
|
||||||
|
muss die watchOS-Plattform in Xcode installiert sein – sonst bricht schon das
|
||||||
|
Übersetzen des Symbolkatalogs ab:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -downloadPlatform watchOS
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf die Uhr kommt die App danach von selbst: Sie steckt in der iPhone-App und
|
||||||
|
taucht in der Watch-App des iPhones unter *Verfügbare Apps* auf.
|
||||||
|
|
||||||
Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber
|
Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber
|
||||||
nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone.
|
nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone.
|
||||||
|
|
||||||
@@ -156,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.
|
||||||
@@ -186,10 +245,83 @@ 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
|
||||||
|
|
||||||
|
Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man
|
||||||
|
den Arm hebt:
|
||||||
|
|
||||||
|
* **Übersicht** – je Gerät der Hauptwert und ein Punkt für den Zustand der
|
||||||
|
Verbindung. Ganz unten steht, wie alt der Stand ist.
|
||||||
|
* **Nivellierung** – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
|
||||||
|
Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über Starts
|
||||||
|
hinweg gemerkt. Dazu, was zu tun ist, und 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
|
||||||
|
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
|
||||||
|
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
|
||||||
|
gegenüber dem iPhone – beim Rangieren schaut niemand aufs Display, aber die
|
||||||
|
Vibration am Handgelenk kommt an.
|
||||||
|
* **Kühlbox** – ein/aus, Eco oder Max und die Solltemperatur, über die Krone
|
||||||
|
gestellt.
|
||||||
|
|
||||||
|
### Wer mit wem funkt
|
||||||
|
|
||||||
|
Die Aufteilung folgt dem, was jedes Gerät hergibt.
|
||||||
|
|
||||||
|
**Den Neigungsmesser funkt die Uhr selbst an.** Er bewirbt seinen Dienst, ist
|
||||||
|
also ohne jede Einrichtung auffindbar, und er ist unverschlüsselt – es gibt
|
||||||
|
keinen Schlüssel, der auf der Uhr ein zweites Mal lagern müsste. Damit steht
|
||||||
|
die Nivellierung am Handgelenk **ohne iPhone**: kein geöffnetes Telefon, keine
|
||||||
|
Reichweite dorthin. Genau dafür hebt man beim Rangieren den Arm.
|
||||||
|
|
||||||
|
Die Einbaulage des Sensors reist einmal vom iPhone herüber und bleibt auf der
|
||||||
|
Uhr gespeichert. Ohne sie stünden längs und quer je nach Einbau vertauscht oder
|
||||||
|
mit falschem Vorzeichen – eingestellt wird sie weiterhin nur am iPhone, im
|
||||||
|
Assistenten dort.
|
||||||
|
|
||||||
|
**Alles Übrige kommt über das iPhone.** Zwei Gründe, beide hart:
|
||||||
|
|
||||||
|
* BMS und Kühlbox lassen jeweils nur **eine** Verbindung zu. Eine mitlesende
|
||||||
|
Uhr nähme dem iPhone die Verbindung weg, statt sie zu ergänzen.
|
||||||
|
* Die Victron-Schlüssel liegen in der Keychain des iPhones. Sie auf die Uhr zu
|
||||||
|
kopieren hiesse, sie ein zweites Mal aufzubewahren, ohne dass der zweite Ort
|
||||||
|
irgendetwas brächte.
|
||||||
|
|
||||||
|
Was die Uhr stellt, stellt also in Wahrheit das iPhone – und angezeigt wird
|
||||||
|
auch dort nur, was das Gerät zurückmeldet, nicht der Tastendruck.
|
||||||
|
|
||||||
|
### Was das für den Betrieb heisst
|
||||||
|
|
||||||
|
Für Batterie, Solar und Kühlbox muss Camper Monitor auf dem iPhone laufen.
|
||||||
|
Solange die Uhr meldet, dass jemand hinschaut, hält die App das Funkgerät auch
|
||||||
|
im Hintergrund am Leben; die Victron-Werbedaten stehen dabei still, weil iOS im
|
||||||
|
Hintergrund kein ungefiltertes Suchen erlaubt.
|
||||||
|
|
||||||
|
Für die Nivellierung gilt das alles nicht – die läuft an der Uhr allein.
|
||||||
|
Gefunkt wird dort nur im Vordergrund: watchOS lässt eine App im Hintergrund
|
||||||
|
ohnehin kaum scannen, und beim Ausrichten schaut man auf die Uhr.
|
||||||
|
|
||||||
|
Gesendet wird in zwei Takten: ein halber Sekundentakt, solange die Watch-App im
|
||||||
|
Vordergrund ist, sonst alle zwei Sekunden und nur bei Änderungen. Der schnelle
|
||||||
|
Takt läuft über eine Frist, die die Uhr regelmässig erneuert – schläft sie ein,
|
||||||
|
hört das iPhone von selbst wieder auf.
|
||||||
|
|
||||||
|
Was die Uhr zeigt, ist immer Weitergereichtes. Deshalb steht auf jedem
|
||||||
|
Bildschirm das Alter des Standes: Eine abgerissene Strecke zum iPhone sähe sonst
|
||||||
|
genauso aus wie ein Fahrzeug, an dem sich nichts tut.
|
||||||
|
|
||||||
## Diagnose
|
## Diagnose
|
||||||
|
|
||||||
@@ -218,6 +350,10 @@ ohne Bluetooth prüfen lassen. In Xcode unter *Product → Scheme → Edit Schem
|
|||||||
Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift
|
Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift
|
||||||
nur in Debug-Builds.
|
nur in Debug-Builds.
|
||||||
|
|
||||||
|
Die Watch-App kennt denselben Schalter und braucht dann kein iPhone: Im Schema
|
||||||
|
`CamperMonitorWatch` dieselbe Variable setzen. Die Neigung wandert dort
|
||||||
|
langsam hin und her, sonst hätte der Ausrichtungs-Assistent nichts zu zeigen.
|
||||||
|
|
||||||
## Protokolle prüfen
|
## Protokolle prüfen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -235,15 +371,25 @@ firmware/vanalign/ Firmware des Neigungsmessers (ESPHome)
|
|||||||
├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle
|
├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle
|
||||||
└── experimente/ Nicht für den Betrieb nötig
|
└── experimente/ Nicht für den Betrieb nötig
|
||||||
|
|
||||||
CamperMonitor/
|
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
|
||||||
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
|
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
|
||||||
├── Bluetooth/
|
├── Bluetooth/
|
||||||
|
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
|
||||||
|
│ └── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr)
|
||||||
|
├── VehicleTilt.swift Überhöhung und Farben der Fahrzeugansicht
|
||||||
|
├── WheelLiftPlan.swift Keilhöhen als Draufsicht auf die vier Räder
|
||||||
|
└── WatchLink/
|
||||||
|
└── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr
|
||||||
|
|
||||||
|
CamperMonitor/
|
||||||
|
├── Bluetooth/
|
||||||
│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf
|
│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf
|
||||||
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
|
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
|
||||||
│ ├── AESCounterMode.swift AES-128-CTR (CommonCrypto)
|
│ ├── AESCounterMode.swift AES-128-CTR (CommonCrypto)
|
||||||
@@ -253,12 +399,12 @@ 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
|
||||||
│ └── KeychainStore.swift Victron-Schlüssel
|
│ └── KeychainStore.swift Victron-Schlüssel
|
||||||
|
├── Watch/
|
||||||
|
│ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an
|
||||||
└── Views/
|
└── Views/
|
||||||
├── DashboardView.swift Kachelübersicht
|
├── DashboardView.swift Kachelübersicht
|
||||||
├── DeviceCard.swift Eine Kachel
|
├── DeviceCard.swift Eine Kachel
|
||||||
@@ -271,13 +417,42 @@ CamperMonitor/
|
|||||||
├── SettingsView.swift App-Einstellungen
|
├── SettingsView.swift App-Einstellungen
|
||||||
├── SensorSetupView.swift Assistent für die Einbaulage
|
├── SensorSetupView.swift Assistent für die Einbaulage
|
||||||
└── AddDeviceView.swift Scannen und Einrichten
|
└── AddDeviceView.swift Scannen und Einrichten
|
||||||
|
|
||||||
|
CamperMonitorWatch/ watchOS-App
|
||||||
|
├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen
|
||||||
|
├── PhoneLink.swift Gegenstelle zum iPhone
|
||||||
|
├── WatchLevelRadio.swift Eigene Bluetooth-Verbindung zum Neigungsmesser
|
||||||
|
├── WatchDemo.swift Erfundene Werte für den Simulator
|
||||||
|
└── Views/
|
||||||
|
├── WatchRootView.swift Übersicht und Altersangabe
|
||||||
|
├── WatchBubble.swift Libelle für den kleinen Bildschirm
|
||||||
|
├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse
|
||||||
|
├── WatchLevelView.swift Neigung, Keilhöhe, Herkunft der Werte
|
||||||
|
├── WatchAlignView.swift Ausrichten mit Vibration
|
||||||
|
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
|
||||||
|
└── WatchFridgeControls.swift Kühlbox stellen
|
||||||
|
|
||||||
|
Config/
|
||||||
|
└── CamperMonitor-Info.plist Nur der Hintergrundbetrieb für die Uhr
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bekannte Grenzen
|
## Bekannte Grenzen
|
||||||
|
|
||||||
* **Kein Hintergrundbetrieb.** iOS erlaubt das ungefilterte Scannen nach
|
* **Im Hintergrund nur die verbundenen Geräte.** iOS erlaubt das ungefilterte
|
||||||
Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den
|
Scannen nach Advertisements nur im Vordergrund; die Victron-Werte stehen im
|
||||||
Hintergrund geht, und nimmt beim Zurückkommen wieder auf.
|
Hintergrund also still. Verbundene Geräte – Neigungsmesser, BMS, Kühlbox –
|
||||||
|
laufen weiter, aber nur solange die Uhr meldet, dass jemand hinschaut. Ohne
|
||||||
|
Uhr pausiert die App im Hintergrund vollständig und nimmt beim Zurückkommen
|
||||||
|
wieder auf.
|
||||||
|
* **Die Uhr braucht das iPhone – ausser für die Nivellierung.** Batterie, Solar
|
||||||
|
und Kühlbox zeigt sie nur, was von dort kommt; ist das iPhone ausser
|
||||||
|
Reichweite, steht dort, wie alt der letzte Stand ist. Den Neigungsmesser
|
||||||
|
erreicht sie selbst.
|
||||||
|
* **Neigungsmesser und Uhr:** Der ESP32 nimmt mehrere Verbindungen an, iPhone
|
||||||
|
und Uhr können also gleichzeitig mitlesen. Weist er die zweite ab, hilft es,
|
||||||
|
die iPhone-App zu schliessen – dann gehört der Sensor der Uhr.
|
||||||
|
* **Auf der Uhr muss beim ersten Start Bluetooth erlaubt werden**, sonst bleibt
|
||||||
|
die Nivellierung dort leer.
|
||||||
* **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
|
* **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
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Neigungsmesser „VanAlign Pro“ – ein ESP32 mit MPU6050, der Längs- und
|
||||||
|
/// Querneigung des Fahrzeugs über Bluetooth bereitstellt.
|
||||||
|
///
|
||||||
|
/// Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
|
||||||
|
/// Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
|
||||||
|
enum VanAlignProtocol {
|
||||||
|
|
||||||
|
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
|
||||||
|
static let serviceUUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
|
||||||
|
|
||||||
|
static let pitchUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
|
||||||
|
static let rollUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
|
||||||
|
/// Zwei Floats: die gespeicherten Kalibrier-Offsets.
|
||||||
|
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
|
||||||
|
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
|
||||||
|
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
|
||||||
|
/// Acht Byte: die Einbaulage, lesbar und schreibbar. Siehe `orientation`.
|
||||||
|
static let orientationUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233428"
|
||||||
|
|
||||||
|
static let calibrateCommand = Data([0x01])
|
||||||
|
static let resetCommand = Data([0x00])
|
||||||
|
|
||||||
|
/// Liest einen Winkel aus vier Bytes, little-endian.
|
||||||
|
///
|
||||||
|
/// Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
|
||||||
|
/// little-endian – die Reihenfolge steht also fest. Die Web-Oberfläche des
|
||||||
|
/// Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
|
||||||
|
/// die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
|
||||||
|
/// schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
|
||||||
|
/// wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
|
||||||
|
/// unbemerkt.
|
||||||
|
static func angle(from data: Data) -> Double? {
|
||||||
|
guard data.count >= 4 else { return nil }
|
||||||
|
var raw: UInt32 = 0
|
||||||
|
for (index, byte) in data.prefix(4).enumerated() {
|
||||||
|
raw |= UInt32(byte) << UInt32(8 * index)
|
||||||
|
}
|
||||||
|
let value = Float(bitPattern: raw)
|
||||||
|
// NAN meldet die Firmware, solange der Sensor nichts liefert.
|
||||||
|
guard value.isFinite, abs(value) <= 180 else { return nil }
|
||||||
|
return Double(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Einbaulage, wie sie im Gerät liegt.
|
||||||
|
///
|
||||||
|
/// Acht Byte:
|
||||||
|
///
|
||||||
|
/// 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
|
||||||
|
/// 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
|
||||||
|
/// 2 längs umgekehrt (0/1)
|
||||||
|
/// 3 quer umgekehrt (0/1)
|
||||||
|
/// 4..7 Verdrehung um die Hochachse, float32, Grad
|
||||||
|
///
|
||||||
|
/// Sie gehört ins Gerät, weil sie den Einbau beschreibt und nicht das
|
||||||
|
/// Telefon: iPhone, Uhr und Android sollen dieselbe sehen, ohne sie je
|
||||||
|
/// einzeln zu bestimmen. Gerechnet wird trotzdem in den Apps – das Gerät
|
||||||
|
/// verwahrt sie nur, sonst rechnete ein älterer Client die Korrektur ein
|
||||||
|
/// zweites Mal ein.
|
||||||
|
///
|
||||||
|
/// Version 0 heisst „hier stand noch nie etwas“ und ergibt nil; dann gilt,
|
||||||
|
/// was die App örtlich gespeichert hat, und sie schreibt es hinauf.
|
||||||
|
static func orientation(from data: Data) -> SensorOrientation? {
|
||||||
|
let bytes = [UInt8](data)
|
||||||
|
guard bytes.count >= 8, bytes[0] == 1 else { return nil }
|
||||||
|
var orientation = SensorOrientation()
|
||||||
|
orientation.longitudinalSource = bytes[1] == 1 ? .roll : .pitch
|
||||||
|
orientation.invertLongitudinal = bytes[2] != 0
|
||||||
|
orientation.invertLateral = bytes[3] != 0
|
||||||
|
guard let twist = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
|
||||||
|
orientation.twist = twist
|
||||||
|
return orientation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dieselben acht Byte in die andere Richtung.
|
||||||
|
static func encoded(_ orientation: SensorOrientation) -> Data {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: 8)
|
||||||
|
bytes[0] = 1
|
||||||
|
bytes[1] = orientation.longitudinalSource == .roll ? 1 : 0
|
||||||
|
bytes[2] = orientation.invertLongitudinal ? 1 : 0
|
||||||
|
bytes[3] = orientation.invertLateral ? 1 : 0
|
||||||
|
let raw = Float(orientation.twist).bitPattern
|
||||||
|
for index in 0..<4 {
|
||||||
|
bytes[4 + index] = UInt8((raw >> UInt32(8 * index)) & 0xFF)
|
||||||
|
}
|
||||||
|
return Data(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die beiden gespeicherten Offsets.
|
||||||
|
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
|
||||||
|
guard data.count >= 8,
|
||||||
|
let pitch = angle(from: data.prefix(4)),
|
||||||
|
let roll = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
|
||||||
|
return (pitch, roll)
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-36
@@ -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]!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
||||||
struct Metric: Identifiable, Hashable, Sendable {
|
struct Metric: Identifiable, Hashable, Codable, Sendable {
|
||||||
let key: String
|
let key: String
|
||||||
let label: String
|
let label: String
|
||||||
let value: Double?
|
let value: Double?
|
||||||
@@ -39,7 +39,7 @@ struct Metric: Identifiable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
|
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
|
||||||
struct DeviceSnapshot: Identifiable, Sendable {
|
struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
|
||||||
var id: UUID { deviceID }
|
var id: UUID { deviceID }
|
||||||
var deviceID: UUID
|
var deviceID: UUID
|
||||||
var timestamp: Date
|
var timestamp: Date
|
||||||
@@ -58,7 +58,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
|
|||||||
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
||||||
var info: [InfoItem] = []
|
var info: [InfoItem] = []
|
||||||
|
|
||||||
struct InfoItem: Identifiable, Hashable, Sendable {
|
struct InfoItem: Identifiable, Hashable, Codable, Sendable {
|
||||||
let label: String
|
let label: String
|
||||||
let value: String
|
let value: String
|
||||||
var id: String { label }
|
var id: String { label }
|
||||||
@@ -76,7 +76,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verbindungszustand für die UI.
|
/// Verbindungszustand für die UI.
|
||||||
enum DeviceLinkState: Equatable, Sendable {
|
enum DeviceLinkState: Equatable, Codable, Sendable {
|
||||||
case idle
|
case idle
|
||||||
case searching
|
case searching
|
||||||
case connecting
|
case connecting
|
||||||
@@ -1,57 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Neigungsmesser „VanAlign Pro“ – ein ESP32 mit MPU6050, der Längs- und
|
|
||||||
/// Querneigung des Fahrzeugs über Bluetooth bereitstellt.
|
|
||||||
///
|
|
||||||
/// Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
|
|
||||||
/// Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
|
|
||||||
enum VanAlignProtocol {
|
|
||||||
|
|
||||||
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
|
|
||||||
static let serviceUUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
|
|
||||||
|
|
||||||
static let pitchUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
|
|
||||||
static let rollUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
|
|
||||||
/// Zwei Floats: die gespeicherten Kalibrier-Offsets.
|
|
||||||
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
|
|
||||||
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
|
|
||||||
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
|
|
||||||
|
|
||||||
static let calibrateCommand = Data([0x01])
|
|
||||||
static let resetCommand = Data([0x00])
|
|
||||||
|
|
||||||
/// Liest einen Winkel aus vier Bytes, little-endian.
|
|
||||||
///
|
|
||||||
/// Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
|
|
||||||
/// little-endian – die Reihenfolge steht also fest. Die Web-Oberfläche des
|
|
||||||
/// Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
|
|
||||||
/// die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
|
|
||||||
/// schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
|
|
||||||
/// wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
|
|
||||||
/// unbemerkt.
|
|
||||||
static func angle(from data: Data) -> Double? {
|
|
||||||
guard data.count >= 4 else { return nil }
|
|
||||||
var raw: UInt32 = 0
|
|
||||||
for (index, byte) in data.prefix(4).enumerated() {
|
|
||||||
raw |= UInt32(byte) << UInt32(8 * index)
|
|
||||||
}
|
|
||||||
let value = Float(bitPattern: raw)
|
|
||||||
// NAN meldet die Firmware, solange der Sensor nichts liefert.
|
|
||||||
guard value.isFinite, abs(value) <= 180 else { return nil }
|
|
||||||
return Double(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Die beiden gespeicherten Offsets.
|
|
||||||
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
|
|
||||||
guard data.count >= 8,
|
|
||||||
let pitch = angle(from: data.prefix(4)),
|
|
||||||
let roll = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
|
|
||||||
return (pitch, roll)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zustand des Neigungsmessers.
|
/// Zustand des Neigungsmessers.
|
||||||
struct LevelState: Equatable {
|
struct LevelState: Equatable, Codable, Sendable {
|
||||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||||
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
||||||
var pitch: Double?
|
var pitch: Double?
|
||||||
+70
-2
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Was zwischen iPhone und Uhr hin- und hergeht.
|
||||||
|
///
|
||||||
|
/// Die Aufteilung folgt dem, was jedes Gerät hergibt:
|
||||||
|
///
|
||||||
|
/// * **Den Neigungsmesser funkt die Uhr selbst an** (`WatchLevelRadio`). Er
|
||||||
|
/// bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar, und er ist
|
||||||
|
/// unverschlüsselt. Damit steht die Nivellierung am Handgelenk auch dann,
|
||||||
|
/// wenn das iPhone in der Tasche schläft – und das ist der Fall, für den man
|
||||||
|
/// die Uhr überhaupt anschaut.
|
||||||
|
/// * **Alles andere kommt über das iPhone.** BMS und Kühlbox lassen nur eine
|
||||||
|
/// Verbindung zu; eine mitlesende Uhr nähme dem iPhone die Verbindung weg,
|
||||||
|
/// statt sie zu ergänzen. Und die Victron-Schlüssel liegen in der Keychain
|
||||||
|
/// des iPhones – sie auf die Uhr zu kopieren hiesse, sie ein zweites Mal
|
||||||
|
/// aufzubewahren, ohne dass der zweite Ort etwas brächte.
|
||||||
|
///
|
||||||
|
/// Übertragen werden deshalb die Messwerte der übrigen Geräte, dazu Fahrzeug
|
||||||
|
/// und Einbaulage des Sensors: Beides braucht die Uhr, um aus den Rohwinkeln
|
||||||
|
/// des Neigungsmessers dasselbe zu machen wie das iPhone.
|
||||||
|
enum WatchLink {
|
||||||
|
|
||||||
|
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
|
||||||
|
/// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind
|
||||||
|
/// damit die einzige Stelle, an der das Format steht.
|
||||||
|
static let payloadKey = "payload"
|
||||||
|
static let commandKey = "command"
|
||||||
|
|
||||||
|
/// Wie lange ein „ich schaue gerade hin“ der Uhr gilt, ohne erneuert zu
|
||||||
|
/// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den
|
||||||
|
/// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu
|
||||||
|
/// senden.
|
||||||
|
static let liveLease: TimeInterval = 12
|
||||||
|
|
||||||
|
/// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je
|
||||||
|
/// Sekunde abgefragt – schneller zu senden brächte nichts.
|
||||||
|
static let liveInterval: TimeInterval = 0.5
|
||||||
|
|
||||||
|
/// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann
|
||||||
|
/// noch zustellt, wenn die App auf der Uhr gerade nicht läuft.
|
||||||
|
static let idleInterval: TimeInterval = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt.
|
||||||
|
struct WatchPayload: Codable, Equatable, Sendable {
|
||||||
|
/// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt
|
||||||
|
/// sonst Werte, die sie falsch versteht – lieber sagt sie „App aktualisieren“.
|
||||||
|
static let currentVersion = 1
|
||||||
|
|
||||||
|
var version = WatchPayload.currentVersion
|
||||||
|
var generatedAt: Date
|
||||||
|
/// Das am iPhone gewählte Fahrzeug.
|
||||||
|
var profile: Profile
|
||||||
|
/// Ob das iPhone gerade überhaupt funken kann.
|
||||||
|
var isRadioReady: Bool
|
||||||
|
/// Klartext dazu, falls nicht.
|
||||||
|
var radioStatus: String
|
||||||
|
var devices: [WatchDevice]
|
||||||
|
|
||||||
|
/// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein
|
||||||
|
/// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen
|
||||||
|
/// Platz – sonst rutschte er beim Verbindungsabriss in die Geräteliste.
|
||||||
|
var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } }
|
||||||
|
var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } }
|
||||||
|
|
||||||
|
/// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei
|
||||||
|
/// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund
|
||||||
|
/// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht.
|
||||||
|
func hasSameContent(as other: WatchPayload) -> Bool {
|
||||||
|
var mine = self, theirs = other
|
||||||
|
mine.generatedAt = .distantPast
|
||||||
|
theirs.generatedAt = .distantPast
|
||||||
|
return mine == theirs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt:
|
||||||
|
/// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum
|
||||||
|
/// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug.
|
||||||
|
func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Gerät mitsamt seinem letzten Messwertsatz.
|
||||||
|
struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
var id: UUID
|
||||||
|
var name: String
|
||||||
|
var role: DeviceRole
|
||||||
|
var link: DeviceLinkState
|
||||||
|
var snapshot: DeviceSnapshot?
|
||||||
|
/// Nur beim Neigungsmesser belegt.
|
||||||
|
var level: LevelState?
|
||||||
|
/// Ebenfalls nur dort: wie der Sensor im Fahrzeug sitzt. Die Uhr funkt
|
||||||
|
/// den Neigungsmesser selbst an und braucht die Zuordnung deshalb auch –
|
||||||
|
/// eingestellt wird sie weiterhin nur am iPhone.
|
||||||
|
var orientation: SensorOrientation?
|
||||||
|
/// Nur bei der Kühlbox belegt.
|
||||||
|
var fridge: WatchFridge?
|
||||||
|
|
||||||
|
var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht.
|
||||||
|
///
|
||||||
|
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
|
||||||
|
/// drin, den auf die Uhr zu schicken nichts brächte.
|
||||||
|
struct WatchFridge: Codable, Equatable, Sendable {
|
||||||
|
var isPoweredOn: Bool
|
||||||
|
var isEco: Bool
|
||||||
|
var isLocked: Bool
|
||||||
|
var isDualZone: Bool
|
||||||
|
var unitSymbol: String
|
||||||
|
var leftTarget: Int?
|
||||||
|
var leftCurrent: Int?
|
||||||
|
var rightTarget: Int?
|
||||||
|
var rightCurrent: Int?
|
||||||
|
var minTarget: Int
|
||||||
|
var maxTarget: Int
|
||||||
|
var batteryVolts: Double?
|
||||||
|
|
||||||
|
var targetRange: ClosedRange<Int> {
|
||||||
|
minTarget < maxTarget ? minTarget...maxTarget : -30...20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was die Uhr das iPhone tun lässt.
|
||||||
|
///
|
||||||
|
/// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt
|
||||||
|
/// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus
|
||||||
|
/// dem nächsten Datensatz – bestätigt wird also das Gerät, nicht der Tastendruck.
|
||||||
|
enum WatchCommand: Codable, Equatable, Sendable {
|
||||||
|
/// Die Uhr schaut gerade hin und möchte den schnellen Takt.
|
||||||
|
case hello(live: Bool)
|
||||||
|
case fridgePower(device: UUID, on: Bool)
|
||||||
|
case fridgeEco(device: UUID, eco: Bool)
|
||||||
|
case fridgeLock(device: UUID, locked: Bool)
|
||||||
|
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
|
||||||
|
|
||||||
|
/// Ob der Befehl den schnellen Sendetakt verlängert.
|
||||||
|
var wantsLiveUpdates: Bool {
|
||||||
|
if case .hello(let live) = self { return live }
|
||||||
|
// Wer stellt, schaut auch hin.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen.
|
||||||
|
enum WatchFridgeZone: String, Codable, Equatable, Sendable {
|
||||||
|
case left, right
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Verpacken
|
||||||
|
|
||||||
|
extension WatchPayload {
|
||||||
|
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
|
||||||
|
|
||||||
|
/// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue
|
||||||
|
/// Datensätze ergeben nil statt eines halb gefüllten Zustands.
|
||||||
|
static func decode(from message: [String: Any]) -> WatchPayload? {
|
||||||
|
guard let data = message[WatchLink.payloadKey] as? Data,
|
||||||
|
let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data),
|
||||||
|
payload.version == WatchPayload.currentVersion else { return nil }
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WatchCommand {
|
||||||
|
func message() throws -> [String: Any] {
|
||||||
|
[WatchLink.commandKey: try WatchCoder.encoder.encode(self)]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(from message: [String: Any]) -> WatchCommand? {
|
||||||
|
guard let data = message[WatchLink.commandKey] as? Data else { return nil }
|
||||||
|
return try? WatchCoder.decoder.decode(WatchCommand.self, from: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der
|
||||||
|
/// einen Seite anders geschrieben als auf der anderen gelesen.
|
||||||
|
enum WatchCoder {
|
||||||
|
static let encoder: JSONEncoder = {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||||
|
return encoder
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let decoder: JSONDecoder = {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||||
|
return decoder
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
+316
-19
@@ -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,9 +970,281 @@ 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)
|
||||||
|
|
||||||
|
// MARK: 13 – Strecke zur Apple Watch
|
||||||
|
|
||||||
|
print("\nApple Watch – Datensatz und Befehle")
|
||||||
|
|
||||||
|
let watchProfile = Profile(name: "Kastenwagen", symbol: "box.truck",
|
||||||
|
trackWidth: 1.85, wheelbase: 3.50)
|
||||||
|
let watchLevelID = UUID()
|
||||||
|
let watchFridgeID = UUID()
|
||||||
|
let watchBoosterID = UUID()
|
||||||
|
|
||||||
|
var watchLevel = LevelState()
|
||||||
|
watchLevel.pitch = 1.8
|
||||||
|
watchLevel.roll = -0.9
|
||||||
|
watchLevel.pitchOffset = 0.4
|
||||||
|
watchLevel.rollOffset = -0.2
|
||||||
|
|
||||||
|
// Feste Zeitstempel, damit der Vergleich unten wirklich den Inhalt prüft: über
|
||||||
|
// die Strecke bleibt die Millisekunde stehen, ein `Date()` wäre feiner.
|
||||||
|
let watchStamp = Date(timeIntervalSince1970: 1_699_999_999)
|
||||||
|
|
||||||
|
var watchLevelSnapshot = watchLevel.snapshot(deviceID: watchLevelID, rssi: -70)
|
||||||
|
watchLevelSnapshot.timestamp = watchStamp
|
||||||
|
|
||||||
|
var boosterSnapshot = DeviceSnapshot(deviceID: watchBoosterID, timestamp: watchStamp, rssi: -71)
|
||||||
|
boosterSnapshot.state = "Aus"
|
||||||
|
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
|
||||||
|
boosterSnapshot.metrics = [
|
||||||
|
Metric("output_voltage", "Ausgang", 14.09, unit: "V", precision: 2, primary: true),
|
||||||
|
Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Quer eingebauter Sensor: Die Uhr rechnet damit selbst, also muss die
|
||||||
|
// Zuordnung mit über die Strecke.
|
||||||
|
var watchOrientation = SensorOrientation()
|
||||||
|
watchOrientation.longitudinalSource = .roll
|
||||||
|
watchOrientation.invertLateral = true
|
||||||
|
|
||||||
|
let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
|
||||||
|
isDualZone: false, unitSymbol: "°C",
|
||||||
|
leftTarget: 4, leftCurrent: 6,
|
||||||
|
rightTarget: nil, rightCurrent: nil,
|
||||||
|
minTarget: -20, maxTarget: 20, batteryVolts: 12.7)
|
||||||
|
|
||||||
|
let watchPayload = WatchPayload(
|
||||||
|
generatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
profile: watchProfile,
|
||||||
|
isRadioReady: true,
|
||||||
|
radioStatus: "Bereit",
|
||||||
|
devices: [
|
||||||
|
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
|
||||||
|
link: .live, snapshot: watchLevelSnapshot,
|
||||||
|
level: watchLevel, orientation: watchOrientation, fridge: nil),
|
||||||
|
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
|
||||||
|
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
|
||||||
|
level: nil, orientation: nil, fridge: nil),
|
||||||
|
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
|
||||||
|
link: .live, snapshot: nil, level: nil,
|
||||||
|
orientation: nil, fridge: watchFridge),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
|
||||||
|
if let message = try? watchPayload.message(),
|
||||||
|
let restored = WatchPayload.decode(from: message) {
|
||||||
|
checkEqual("Datensatz kommt unverändert an", restored, watchPayload)
|
||||||
|
checkEqual("Zeitstempel überlebt das Verpacken",
|
||||||
|
restored.generatedAt.timeIntervalSince1970, 1_700_000_000)
|
||||||
|
checkEqual("Fehlerklartext bleibt am Gerät",
|
||||||
|
restored.devices[1].link, .failed("Nicht gefunden"))
|
||||||
|
checkEqual("Neigung wird gefunden", restored.levelDevice?.id, watchLevelID)
|
||||||
|
checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID)
|
||||||
|
checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4)
|
||||||
|
checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85)
|
||||||
|
checkEqual("Einbaulage kommt mit – die Uhr misst selbst",
|
||||||
|
restored.levelDevice?.orientation?.longitudinalSource, .roll)
|
||||||
|
checkEqual("Umkehrung der Querachse ebenfalls",
|
||||||
|
restored.levelDevice?.orientation?.invertLateral, true)
|
||||||
|
} else {
|
||||||
|
check("Datensatz lässt sich verpacken und wieder lesen", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Uhr mit älterer App darf einen neueren Datensatz nicht halb verstehen.
|
||||||
|
var futurePayload = watchPayload
|
||||||
|
futurePayload.version = WatchPayload.currentVersion + 1
|
||||||
|
if let message = try? futurePayload.message() {
|
||||||
|
checkEqual("fremde Formatversion wird abgelehnt",
|
||||||
|
WatchPayload.decode(from: message) == nil, true)
|
||||||
|
} else {
|
||||||
|
check("fremde Formatversion lässt sich verpacken", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeitpunkte reisen als Millisekunden seit 1970 in einer Fliesskommazahl. Auf
|
||||||
|
// die Millisekunde genau kommen sie zurück, aufs letzte Bit nicht – deshalb
|
||||||
|
// vergleicht der Test oben feste Zeitstempel und nicht ein `Date()`.
|
||||||
|
var preciseSnapshot = boosterSnapshot
|
||||||
|
preciseSnapshot.timestamp = Date(timeIntervalSince1970: 1_699_999_999.123_456)
|
||||||
|
var precisePayload = watchPayload
|
||||||
|
precisePayload.devices[1].snapshot = preciseSnapshot
|
||||||
|
if let message = try? precisePayload.message(),
|
||||||
|
let restored = WatchPayload.decode(from: message)?.devices[1].snapshot?.timestamp {
|
||||||
|
checkEqual("feiner Zeitstempel kommt auf die Millisekunde genau zurück",
|
||||||
|
abs(restored.timeIntervalSince1970 - 1_699_999_999.123_456) < 0.001, true)
|
||||||
|
} else {
|
||||||
|
check("Datensatz mit feinem Zeitstempel lässt sich verpacken", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkEqual("leeres Wörterbuch ergibt keinen Datensatz",
|
||||||
|
WatchPayload.decode(from: [:]) == nil, true)
|
||||||
|
checkEqual("Unsinn im Nutzdatenfeld ergibt keinen Datensatz",
|
||||||
|
WatchPayload.decode(from: [WatchLink.payloadKey: Data([0x00, 0x01])]) == nil, true)
|
||||||
|
|
||||||
|
// Der Vergleich entscheidet, ob überhaupt gefunkt wird: der Zeitstempel allein
|
||||||
|
// darf keinen Funkverkehr auslösen, ein geänderter Messwert schon.
|
||||||
|
var laterPayload = watchPayload
|
||||||
|
laterPayload.generatedAt = watchPayload.generatedAt.addingTimeInterval(30)
|
||||||
|
checkEqual("gleicher Inhalt, neuer Zeitstempel gilt als unverändert",
|
||||||
|
watchPayload.hasSameContent(as: laterPayload), true)
|
||||||
|
|
||||||
|
var changedPayload = laterPayload
|
||||||
|
changedPayload.devices[2].fridge?.leftTarget = 5
|
||||||
|
checkEqual("geänderter Sollwert gilt als Änderung",
|
||||||
|
watchPayload.hasSameContent(as: changedPayload), false)
|
||||||
|
|
||||||
|
// Befehle in die Gegenrichtung.
|
||||||
|
let commands: [WatchCommand] = [
|
||||||
|
.hello(live: true),
|
||||||
|
.hello(live: false),
|
||||||
|
.fridgePower(device: watchFridgeID, on: false),
|
||||||
|
.fridgeEco(device: watchFridgeID, eco: true),
|
||||||
|
.fridgeLock(device: watchFridgeID, locked: true),
|
||||||
|
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
|
||||||
|
]
|
||||||
|
for command in commands {
|
||||||
|
guard let message = try? command.message(),
|
||||||
|
let restored = WatchCommand.decode(from: message) else {
|
||||||
|
check("Befehl \(command) lässt sich verpacken", false)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checkEqual("Befehl kommt unverändert an", restored, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkEqual("Anmeldung ohne Hinschauen verlängert den schnellen Takt nicht",
|
||||||
|
WatchCommand.hello(live: false).wantsLiveUpdates, false)
|
||||||
|
checkEqual("wer stellt, schaut auch hin",
|
||||||
|
WatchCommand.fridgePower(device: watchFridgeID, on: true).wantsLiveUpdates, true)
|
||||||
|
checkEqual("ein Datensatz ist kein Befehl",
|
||||||
|
WatchCommand.decode(from: (try? watchPayload.message()) ?? [:]) == nil, true)
|
||||||
|
|
||||||
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||||||
exit(failures == 0 ? 0 : 1)
|
exit(failures == 0 ? 0 : 1)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+9
-7
@@ -13,13 +13,15 @@ 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 \
|
||||||
CamperMonitor/Models/DeviceSnapshot.swift \
|
Shared/Models/DeviceSnapshot.swift \
|
||||||
CamperMonitor/Models/ConfiguredDevice.swift \
|
Shared/Models/ConfiguredDevice.swift \
|
||||||
CamperMonitor/Models/Profile.swift \
|
Shared/Models/LevelState.swift \
|
||||||
CamperMonitor/Models/AlignmentAssistant.swift \
|
Shared/Models/Profile.swift \
|
||||||
CamperMonitor/Models/SensorOrientation.swift \
|
Shared/Models/AlignmentAssistant.swift \
|
||||||
CamperMonitor/Models/VictronCodes.swift \
|
Shared/Models/SensorOrientation.swift \
|
||||||
|
Shared/Models/VictronCodes.swift \
|
||||||
|
Shared/WatchLink/WatchLink.swift \
|
||||||
CamperMonitor/Store/KeychainStore.swift \
|
CamperMonitor/Store/KeychainStore.swift \
|
||||||
Tests/main.swift
|
Tests/main.swift
|
||||||
"$OUT/tests"
|
"$OUT/tests"
|
||||||
|
|||||||
Reference in New Issue
Block a user