Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d58ea6e49 | ||
|
|
141e321a6f | ||
|
|
35f4e6fbcb | ||
|
|
8e5ee6927e | ||
|
|
eed8f33e30 | ||
|
|
b956324234 | ||
|
|
ed026ce580 | ||
|
|
59076076e8 | ||
|
|
003222bf30 | ||
|
|
303a9735d0 | ||
|
|
83ea85f3b8 | ||
|
|
e9b9c5bcd5 | ||
|
|
812874baef | ||
|
|
5fec4a55dd | ||
|
|
8aa8658b5c | ||
|
|
82df240ebc | ||
|
|
0e05ca22d7 | ||
|
|
f0df433421 | ||
|
|
0761706d98 | ||
|
|
a9a44b49d6 | ||
|
|
4936a767a3 | ||
|
|
cee9129f66 | ||
|
|
396e9ed004 | ||
|
|
e47ef25188 | ||
|
|
6612b07fd5 |
@@ -59,7 +59,7 @@ jobs:
|
||||
-PversionName="$VERSION" \
|
||||
-PversionCode="$CODE" --no-daemon
|
||||
mv app/build/outputs/apk/release/app-release.apk \
|
||||
"../CamperMonitor-$VERSION.apk"
|
||||
"../VanControlPro-$VERSION.apk"
|
||||
|
||||
- name: An das Release hängen
|
||||
env:
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
fi
|
||||
|
||||
curl -sf -X POST -H "$AUTH" \
|
||||
-F "attachment=@CamperMonitor-$VERSION.apk" \
|
||||
"$API/$ID/assets?name=CamperMonitor-$VERSION.apk" > /dev/null
|
||||
-F "attachment=@VanControlPro-$VERSION.apk" \
|
||||
"$API/$ID/assets?name=VanControlPro-$VERSION.apk" > /dev/null
|
||||
|
||||
echo "CamperMonitor-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
|
||||
echo "VanControlPro-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
|
||||
|
||||
@@ -372,6 +372,11 @@ class BluetoothManager(
|
||||
onUpdate = { record(it) },
|
||||
onStateChange = { handleLinkState(device, it) },
|
||||
onLevelState = { state -> publish { levelStates[device.id] = state } },
|
||||
onDeviceOrientation = { orientation ->
|
||||
// Im Gerät steht, wie der Sensor eingebaut ist – für alle
|
||||
// Clients dasselbe. Also übernehmen statt überschreiben.
|
||||
adoptOrientation(orientation, device.id)
|
||||
},
|
||||
)
|
||||
session.orientation = device.sensorOrientation
|
||||
levelSessions[device.address] = session
|
||||
@@ -642,8 +647,32 @@ class BluetoothManager(
|
||||
|
||||
// MARK: - Neigungsmesser
|
||||
|
||||
/**
|
||||
* Nach einer Änderung der Einbaulage aufrufen.
|
||||
*
|
||||
* Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie
|
||||
* beschreibt den Einbau, und iPhone wie Uhr finden sie dann vor, ohne dass
|
||||
* jemand sie ein zweites Mal bestimmen muss.
|
||||
*/
|
||||
fun updateSensorOrientation(device: ConfiguredDevice) {
|
||||
handler.post { levelSessions[device.address]?.orientation = device.sensorOrientation }
|
||||
handler.post {
|
||||
val session = levelSessions[device.address] ?: return@post
|
||||
session.orientation = device.sensorOrientation
|
||||
session.storeOrientation(device.sensorOrientation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die Einbaulage, die das Gerät meldet.
|
||||
*
|
||||
* Steht dort noch nichts, meldet das Gerät nichts – dann bleibt es bei der
|
||||
* örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen
|
||||
* hinaufgeschrieben.
|
||||
*/
|
||||
private fun adoptOrientation(orientation: SensorOrientation, deviceID: UUID) {
|
||||
val device = store.devices.firstOrNull { it.id == deviceID } ?: return
|
||||
if (device.sensorOrientation == orientation) return
|
||||
store.update(device.copy(sensorOrientation = orientation))
|
||||
}
|
||||
|
||||
fun calibrateLevel(deviceID: UUID) {
|
||||
|
||||
@@ -30,6 +30,12 @@ class LevelSession(
|
||||
private val onUpdate: (DeviceSnapshot) -> Unit,
|
||||
private val onStateChange: (DeviceLinkState) -> Unit,
|
||||
private val onLevelState: (LevelState) -> Unit,
|
||||
/**
|
||||
* Meldet die Einbaulage, die im Gerät steht. Sie gilt vor der örtlich
|
||||
* gespeicherten: dort steht, wie der Sensor eingebaut ist, und das ist für
|
||||
* alle Clients dasselbe.
|
||||
*/
|
||||
private val onDeviceOrientation: (SensorOrientation) -> Unit = {},
|
||||
) {
|
||||
private companion object {
|
||||
val SERVICE: UUID = UUID.fromString(VanAlignProtocol.SERVICE_UUID)
|
||||
@@ -37,6 +43,7 @@ class LevelSession(
|
||||
val ROLL: UUID = UUID.fromString(VanAlignProtocol.ROLL_UUID)
|
||||
val OFFSETS: UUID = UUID.fromString(VanAlignProtocol.OFFSETS_UUID)
|
||||
val CALIBRATE: UUID = UUID.fromString(VanAlignProtocol.CALIBRATE_UUID)
|
||||
val ORIENTATION: UUID = UUID.fromString(VanAlignProtocol.ORIENTATION_UUID)
|
||||
val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
|
||||
const val POLL_INTERVAL_MS = 1_000L
|
||||
}
|
||||
@@ -53,6 +60,7 @@ class LevelSession(
|
||||
private var rollCharacteristic: BluetoothGattCharacteristic? = null
|
||||
private var offsetsCharacteristic: BluetoothGattCharacteristic? = null
|
||||
private var calibrateCharacteristic: BluetoothGattCharacteristic? = null
|
||||
private var orientationCharacteristic: BluetoothGattCharacteristic? = null
|
||||
|
||||
/** Ob die Firmware die Werte von sich aus meldet. */
|
||||
private var needsPolling = true
|
||||
@@ -84,6 +92,7 @@ class LevelSession(
|
||||
rollCharacteristic = null
|
||||
offsetsCharacteristic = null
|
||||
calibrateCharacteristic = null
|
||||
orientationCharacteristic = null
|
||||
}
|
||||
|
||||
/** Setzt die aktuelle Lage als neue Null. */
|
||||
@@ -107,6 +116,22 @@ class LevelSession(
|
||||
handler.postDelayed({ readOffsets() }, 400)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Einbaulage ins Gerät, damit alle Clients dieselbe sehen.
|
||||
*
|
||||
* Ältere Firmware hat die Charakteristik nicht; dann bleibt es bei der
|
||||
* örtlich gespeicherten Fassung, und es passiert schlicht nichts.
|
||||
*/
|
||||
fun storeOrientation(value: SensorOrientation) {
|
||||
val characteristic = orientationCharacteristic ?: return
|
||||
val g = gatt ?: return
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
characteristic.value = VanAlignProtocol.encoded(value)
|
||||
g.writeCharacteristic(characteristic)
|
||||
// Zurücklesen, damit gilt, was wirklich im Gerät steht.
|
||||
handler.postDelayed({ gatt?.readCharacteristic(characteristic) }, 300)
|
||||
}
|
||||
|
||||
private fun readOffsets() {
|
||||
val characteristic = offsetsCharacteristic ?: return
|
||||
gatt?.readCharacteristic(characteristic)
|
||||
@@ -169,6 +194,7 @@ class LevelSession(
|
||||
rollCharacteristic = service.getCharacteristic(ROLL)
|
||||
offsetsCharacteristic = service.getCharacteristic(OFFSETS)
|
||||
calibrateCharacteristic = service.getCharacteristic(CALIBRATE)
|
||||
orientationCharacteristic = service.getCharacteristic(ORIENTATION)
|
||||
|
||||
val canNotify = pitchCharacteristic?.let {
|
||||
it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
|
||||
@@ -267,6 +293,26 @@ class LevelSession(
|
||||
state = state.copy(pitchOffset = pitch, rollOffset = roll)
|
||||
}
|
||||
publish()
|
||||
// Android lässt nur eine Abfrage gleichzeitig zu, deshalb der
|
||||
// Reihe nach: erst die Offsets, dann die Einbaulage, dann der
|
||||
// Takt. Fehlt die Charakteristik – ältere Firmware –, geht es
|
||||
// sofort weiter.
|
||||
val characteristic = orientationCharacteristic
|
||||
if (characteristic != null) {
|
||||
gatt?.readCharacteristic(characteristic)
|
||||
} else {
|
||||
startPollingIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
ORIENTATION -> {
|
||||
// Was im Gerät steht, gilt. Steht dort nichts (Version 0),
|
||||
// bleibt es bei der örtlichen Fassung, und der Aufrufer
|
||||
// schreibt sie hinauf.
|
||||
VanAlignProtocol.orientation(value)?.let { stored ->
|
||||
if (stored != orientation) orientation = stored
|
||||
onDeviceOrientation(stored)
|
||||
}
|
||||
startPollingIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,7 @@ class DeviceStore(context: Context) {
|
||||
put("orientationSource", device.sensorOrientation.longitudinalSource.name)
|
||||
put("invertLongitudinal", device.sensorOrientation.invertLongitudinal)
|
||||
put("invertLateral", device.sensorOrientation.invertLateral)
|
||||
put("twist", device.sensorOrientation.twist)
|
||||
}
|
||||
|
||||
private fun deviceFrom(json: JSONObject): ConfiguredDevice? {
|
||||
@@ -227,6 +228,9 @@ class DeviceStore(context: Context) {
|
||||
}.getOrDefault(SensorOrientation.Source.PITCH),
|
||||
invertLongitudinal = json.optBoolean("invertLongitudinal", false),
|
||||
invertLateral = json.optBoolean("invertLateral", false),
|
||||
// Fehlt der Wert, ist er null – so überleben Einrichtungen aus
|
||||
// der Zeit vor der Verdrehung.
|
||||
twist = json.optDouble("twist", 0.0),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ fun WithBluetoothPermission(content: @Composable () -> Unit) {
|
||||
if (asked) {
|
||||
Text(
|
||||
"Die Freigabe wurde abgelehnt. Sie lässt sich in den " +
|
||||
"Android-Einstellungen unter „Apps → Camper Monitor → " +
|
||||
"Android-Einstellungen unter „Apps → VanControl Pro → " +
|
||||
"Berechtigungen“ nachholen.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
|
||||
@@ -65,7 +65,7 @@ fun SettingsScreen(store: DeviceStore, onBack: () -> Unit) {
|
||||
)
|
||||
|
||||
Text(
|
||||
"Camper Monitor liest Victron-Geräte über ihr Advertisement mit " +
|
||||
"VanControl Pro liest Victron-Geräte über ihr Advertisement mit " +
|
||||
"(„Instant Readout“) und spricht Batterie, Kühlbox und " +
|
||||
"Neigungsmesser direkt über Bluetooth an.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Camper Monitor</string>
|
||||
<string name="app_name">VanControl Pro</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package de.fritob.campermonitor.protocol
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Wie der Neigungsmesser im Fahrzeug sitzt.
|
||||
@@ -21,18 +24,42 @@ data class SensorOrientation(
|
||||
val longitudinalSource: Source = Source.PITCH,
|
||||
val invertLongitudinal: Boolean = false,
|
||||
val invertLateral: Boolean = false,
|
||||
/**
|
||||
* Verdrehung des Sensors um die Hochachse, in Grad – der Rest, den der
|
||||
* Achsentausch nicht abdeckt.
|
||||
*
|
||||
* Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
|
||||
* Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
|
||||
* Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
|
||||
* helfen dagegen nicht, die springen in 90°-Schritten.
|
||||
*/
|
||||
val twist: Double = 0.0,
|
||||
) {
|
||||
/** Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert. */
|
||||
enum class Source { PITCH, ROLL }
|
||||
|
||||
val isIdentity: Boolean get() = this == IDENTITY
|
||||
|
||||
/** Rechnet Sensorwerte in Fahrzeugwerte um. */
|
||||
/**
|
||||
* Rechnet Sensorwerte in Fahrzeugwerte um.
|
||||
*
|
||||
* Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
|
||||
* Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
|
||||
* Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene – genau
|
||||
* deshalb lässt sich die Verdrehung überhaupt herausrechnen.
|
||||
*/
|
||||
fun apply(pitch: Double?, roll: Double?): Pair<Double?, Double?> {
|
||||
val longitudinal = if (longitudinalSource == Source.PITCH) pitch else roll
|
||||
val lateral = if (longitudinalSource == Source.PITCH) roll else pitch
|
||||
return longitudinal?.let { if (invertLongitudinal) -it else it } to
|
||||
lateral?.let { if (invertLateral) -it else it }
|
||||
val mappedLongitudinal = longitudinal?.let { if (invertLongitudinal) -it else it }
|
||||
val mappedLateral = lateral?.let { if (invertLateral) -it else it }
|
||||
|
||||
if (twist == 0.0 || mappedLongitudinal == null || mappedLateral == null) {
|
||||
return mappedLongitudinal to mappedLateral
|
||||
}
|
||||
val angle = Math.toRadians(twist)
|
||||
return (mappedLongitudinal * cos(angle) + mappedLateral * sin(angle)) to
|
||||
(-mappedLongitudinal * sin(angle) + mappedLateral * cos(angle))
|
||||
}
|
||||
|
||||
val summary: String
|
||||
@@ -42,6 +69,7 @@ data class SensorOrientation(
|
||||
if (longitudinalSource == Source.ROLL) parts.add("Achsen getauscht")
|
||||
if (invertLongitudinal) parts.add("längs umgekehrt")
|
||||
if (invertLateral) parts.add("quer umgekehrt")
|
||||
if (twist != 0.0) parts.add("um %.0f° verdreht".format(twist))
|
||||
return parts.joinToString(", ")
|
||||
}
|
||||
|
||||
@@ -68,6 +96,9 @@ object OrientationDetection {
|
||||
/** Soviel deutlicher muss die gewinnende Deutung sein als die andere. */
|
||||
const val AMBIGUITY_MARGIN = 1.3
|
||||
|
||||
/** Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand. */
|
||||
const val MINIMUM_TWIST = 2.0
|
||||
|
||||
data class Reading(val pitch: Double, val roll: Double) {
|
||||
operator fun minus(other: Reading) = Reading(pitch - other.pitch, roll - other.roll)
|
||||
}
|
||||
@@ -131,16 +162,31 @@ object OrientationDetection {
|
||||
if (abs(longitudinal) < MINIMUM_TILT || abs(lateral) < MINIMUM_TILT) {
|
||||
return Result.Error(Failure.TOO_LITTLE_MOVEMENT)
|
||||
}
|
||||
val coarse = SensorOrientation(
|
||||
longitudinalSource = source,
|
||||
// Front nach unten heisst: das Heck steht höher, die
|
||||
// Längsneigung des Fahrzeugs ist also positiv.
|
||||
invertLongitudinal = longitudinal < 0,
|
||||
// Linke Seite nach unten heisst: rechts steht höher, die
|
||||
// Querneigung ist positiv.
|
||||
invertLateral = lateral < 0,
|
||||
)
|
||||
|
||||
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
|
||||
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
|
||||
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
|
||||
// schräg – und zwar um genau diesen Winkel.
|
||||
val (correctedLong, correctedLat) = coarse.apply(nose.pitch, nose.roll)
|
||||
val residual = if (correctedLong != null && correctedLat != null) {
|
||||
Math.toDegrees(atan2(correctedLat, correctedLong))
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
|
||||
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
|
||||
// als keine.
|
||||
return Result.Success(
|
||||
SensorOrientation(
|
||||
longitudinalSource = source,
|
||||
// Front nach unten heisst: das Heck steht höher, die
|
||||
// Längsneigung des Fahrzeugs ist also positiv.
|
||||
invertLongitudinal = longitudinal < 0,
|
||||
// Linke Seite nach unten heisst: rechts steht höher, die
|
||||
// Querneigung ist positiv.
|
||||
invertLateral = lateral < 0,
|
||||
)
|
||||
coarse.copy(twist = if (abs(residual) >= MINIMUM_TWIST) residual else 0.0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ object VanAlignProtocol {
|
||||
/** Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage. */
|
||||
const val CALIBRATE_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
|
||||
|
||||
/** Acht Byte: die Einbaulage, lesbar und schreibbar. Siehe [orientation]. */
|
||||
const val ORIENTATION_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233428"
|
||||
|
||||
val calibrateCommand = byteArrayOf(0x01)
|
||||
val resetCommand = byteArrayOf(0x00)
|
||||
|
||||
@@ -50,6 +53,55 @@ object VanAlignProtocol {
|
||||
return value.toDouble()
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Einbaulage, wie sie im Gerät liegt.
|
||||
*
|
||||
* Acht Byte:
|
||||
*
|
||||
* 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
|
||||
* 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
|
||||
* 2 längs umgekehrt (0/1)
|
||||
* 3 quer umgekehrt (0/1)
|
||||
* 4..7 Verdrehung um die Hochachse, float32, Grad
|
||||
*
|
||||
* Sie gehört ins Gerät, weil sie den Einbau beschreibt und nicht das
|
||||
* Telefon: iPhone, Uhr und Android sollen dieselbe sehen, ohne sie je
|
||||
* einzeln zu bestimmen. Gerechnet wird trotzdem in den Apps – das Gerät
|
||||
* verwahrt sie nur, sonst rechnete ein älterer Client die Korrektur ein
|
||||
* zweites Mal ein.
|
||||
*
|
||||
* Version 0 heisst „hier stand noch nie etwas" und ergibt null; dann gilt,
|
||||
* was die App örtlich gespeichert hat, und sie schreibt es hinauf.
|
||||
*/
|
||||
fun orientation(data: ByteArray): SensorOrientation? {
|
||||
if (data.size < 8 || data.u(0) != 1) return null
|
||||
val twist = angle(data, 4) ?: return null
|
||||
return SensorOrientation(
|
||||
longitudinalSource = if (data.u(1) == 1) {
|
||||
SensorOrientation.Source.ROLL
|
||||
} else {
|
||||
SensorOrientation.Source.PITCH
|
||||
},
|
||||
invertLongitudinal = data.u(2) != 0,
|
||||
invertLateral = data.u(3) != 0,
|
||||
twist = twist,
|
||||
)
|
||||
}
|
||||
|
||||
/** Dieselben acht Byte in die andere Richtung. */
|
||||
fun encoded(orientation: SensorOrientation): ByteArray {
|
||||
val bytes = ByteArray(8)
|
||||
bytes[0] = 1
|
||||
bytes[1] = if (orientation.longitudinalSource == SensorOrientation.Source.ROLL) 1 else 0
|
||||
bytes[2] = if (orientation.invertLongitudinal) 1 else 0
|
||||
bytes[3] = if (orientation.invertLateral) 1 else 0
|
||||
val raw = orientation.twist.toFloat().toRawBits()
|
||||
for (index in 0 until 4) {
|
||||
bytes[4 + index] = ((raw shr (8 * index)) and 0xFF).toByte()
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/** Die beiden gespeicherten Offsets. */
|
||||
fun offsets(data: ByteArray): Pair<Double, Double>? {
|
||||
if (data.size < 8) return null
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
AA0000000000000000000002 /* CamperMonitor */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = CamperMonitor;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
AA0000000000000000000003 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
AA0000000000000000000004 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000002 /* CamperMonitor */,
|
||||
AA0000000000000000000005 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000005 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000001 /* CamperMonitor.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
AA0000000000000000000006 /* CamperMonitor */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "CamperMonitor" */;
|
||||
buildPhases = (
|
||||
AA0000000000000000000007 /* Sources */,
|
||||
AA0000000000000000000003 /* Frameworks */,
|
||||
AA0000000000000000000008 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000002 /* CamperMonitor */,
|
||||
);
|
||||
name = CamperMonitor;
|
||||
productName = CamperMonitor;
|
||||
productReference = AA0000000000000000000001 /* CamperMonitor.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
AA0000000000000000000009 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2660;
|
||||
LastUpgradeCheck = 2660;
|
||||
TargetAttributes = {
|
||||
AA0000000000000000000006 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
de,
|
||||
);
|
||||
mainGroup = AA0000000000000000000004;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = AA0000000000000000000005 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
AA0000000000000000000006 /* CamperMonitor */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
AA0000000000000000000008 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
AA0000000000000000000007 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
AA0000000000000000000012 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA0000000000000000000013 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_VERSION = 5.0;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
AA0000000000000000000014 /* 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_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA0000000000000000000015 /* 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_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0000000000000000000012 /* Debug */,
|
||||
AA0000000000000000000013 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "CamperMonitor" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0000000000000000000014 /* Debug */,
|
||||
AA0000000000000000000015 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = AA0000000000000000000009 /* Project object */;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CamperMonitorApp: App {
|
||||
@State private var store: DeviceStore
|
||||
@State private var bluetooth: BluetoothManager
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
let store = DeviceStore()
|
||||
_store = State(initialValue: store)
|
||||
_bluetooth = State(initialValue: BluetoothManager(store: store))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
DashboardView()
|
||||
.environment(store)
|
||||
.environment(bluetooth)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
|
||||
// werden, also Funk sparen und beim Zurückkommen neu starten.
|
||||
switch phase {
|
||||
case .active: bluetooth.start()
|
||||
case .background: bluetooth.stop()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten
|
||||
/// Nebenwerte und der Verbindungszustand.
|
||||
struct DeviceCard: View {
|
||||
let device: ConfiguredDevice
|
||||
let snapshot: DeviceSnapshot?
|
||||
let linkState: DeviceLinkState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
|
||||
if let snapshot, let primary = snapshot.primaryMetric {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(primary.formatted)
|
||||
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
||||
.contentTransition(.numericText())
|
||||
Text(primary.unit)
|
||||
.font(.title3.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
|
||||
|
||||
secondaryValues(for: snapshot)
|
||||
} else {
|
||||
Text(placeholderText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
footer
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: device.role.symbol)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(device.name)
|
||||
.font(.headline)
|
||||
Text(device.role.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
|
||||
}
|
||||
}
|
||||
|
||||
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
|
||||
let others = snapshot.metrics
|
||||
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
|
||||
.prefix(3)
|
||||
return HStack(spacing: 16) {
|
||||
ForEach(Array(others)) { metric in
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.monospacedDigit()
|
||||
Text(metric.label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footer: some View {
|
||||
if let fault = snapshot?.fault {
|
||||
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(2)
|
||||
} else if let state = snapshot?.state {
|
||||
// Bei "Aus" ist erst der Grund die eigentliche Information.
|
||||
let reason = snapshot?.offReasons.first
|
||||
Text(reason.map { "\(state) · \($0)" } ?? state)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if case .failed(let message) = linkState {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholderText: String {
|
||||
switch linkState {
|
||||
case .needsKey: return "Verschlüsselungsschlüssel fehlt – im Detail eintragen."
|
||||
case .failed(let message): return message
|
||||
default: return "Warte auf Daten…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst.
|
||||
struct StatusDot: View {
|
||||
let linkState: DeviceLinkState
|
||||
let isStale: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch linkState {
|
||||
case .live: return isStale ? .orange : .green
|
||||
case .needsKey: return .orange
|
||||
case .failed: return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
if case .live = linkState, isStale { return "Veraltet" }
|
||||
return linkState.label
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Zeigt die Neigung am Fahrzeug selbst, statt an einer abstrakten Blase.
|
||||
///
|
||||
/// Zwei Ansichten, jede um ihre Achse gekippt:
|
||||
///
|
||||
/// * **Seitenansicht** für die Längsneigung. Die Front zeigt nach links, das
|
||||
/// Heck nach rechts.
|
||||
/// * **Heckansicht** für die Querneigung. Sie teilt die Blickrichtung des
|
||||
/// Fahrers, links im Bild ist also links am Fahrzeug – bei einer
|
||||
/// Frontansicht wäre es seitenverkehrt.
|
||||
///
|
||||
/// In beiden Fällen wird gegen den mathematischen Drehsinn gekippt: Steht das
|
||||
/// Heck höher, muss die rechte Bildseite nach oben.
|
||||
struct VehicleTiltView: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
|
||||
/// Kleine Neigungen sind am Fahrzeug sonst kaum zu erkennen – zwei Grad
|
||||
/// wären ein knappes Grad Bildneigung. Die Überhöhung wird angeschrieben,
|
||||
/// damit niemand den Winkel für bare Münze nimmt.
|
||||
static let exaggeration: Double = 3
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
tiltPanel(image: "VehicleSide",
|
||||
angle: pitch,
|
||||
title: "Längs",
|
||||
lowerLabel: "Front",
|
||||
upperLabel: "Heck",
|
||||
aspect: 925.0 / 600.0)
|
||||
|
||||
tiltPanel(image: "VehicleRear",
|
||||
angle: roll,
|
||||
title: "Quer",
|
||||
lowerLabel: "links",
|
||||
upperLabel: "rechts",
|
||||
aspect: 1)
|
||||
|
||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
||||
Self.exaggeration))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func tiltPanel(image: String,
|
||||
angle: Double?,
|
||||
title: String,
|
||||
lowerLabel: String,
|
||||
upperLabel: String,
|
||||
aspect: Double) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.subheadline.weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(colour(for: angle))
|
||||
}
|
||||
|
||||
ZStack {
|
||||
// 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(colour(for: angle))
|
||||
.aspectRatio(aspect, contentMode: .fit)
|
||||
.rotationEffect(.degrees(-(angle ?? 0) * Self.exaggeration))
|
||||
.animation(.spring(duration: 0.4), value: angle)
|
||||
.opacity(angle == nil ? 0.3 : 1)
|
||||
}
|
||||
.frame(height: 110)
|
||||
|
||||
HStack {
|
||||
Text(lowerLabel)
|
||||
Spacer()
|
||||
Text(upperLabel)
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
enum LevelDisplayStyle: String, CaseIterable, Identifiable {
|
||||
case bubble
|
||||
case vehicle
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .bubble: return "Libelle"
|
||||
case .vehicle: return "Fahrzeug"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,4 +1,4 @@
|
||||
# Camper Monitor
|
||||
# VanControl Pro
|
||||
|
||||
iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
|
||||
|
||||
@@ -9,24 +9,59 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
|
||||
| Batterie-BMS (Bulltron/Daly, WattCycle) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status |
|
||||
| Kompressor-Kühlbox (Alpicool und Baugleiche) | GATT-Verbindung, lesen **und** stellen | Ist- und Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bordspannung, Batterieanzeige |
|
||||
| Neigungsmesser (VanAlign Pro) | GATT-Verbindung | Längs- und Querneigung als Libelle, Kalibrierung aus der App |
|
||||
| Votronic-Solarladeregler (über eigene ESP32-Bridge) | GATT-Verbindung, im Takt abgefragt | PV-Leistung, PV-Spannung/-Strom, Batteriespannung, Reglertemperatur, Lade-/Reglerstatus |
|
||||
|
||||
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
|
||||
|
||||
Victron- und Votronic-Solarladeregler sind zwei getrennte Geräterollen, absichtlich
|
||||
nicht zusammengelegt: Der Victron-Regler meldet sich passiv und verschlüsselt im
|
||||
Advertisement, der Votronic-Regler hängt über eine eigene ESP32-Bridge (siehe
|
||||
unten) an einer GATT-Verbindung – grundverschiedene Übertragungswege für
|
||||
grundverschiedene Hardware. In Code und Einstellungen tragen beide deshalb
|
||||
konsequent `victron`/`votronic` im Namen.
|
||||
|
||||
Der Neigungsmesser zeigt sich zusätzlich als **Live Activity** auf Sperrbildschirm,
|
||||
Dynamic Island und – seit iOS 26 automatisch – im CarPlay-Dashboard, siehe
|
||||
[Live Activity](#live-activity).
|
||||
|
||||
Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt
|
||||
Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am
|
||||
Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt
|
||||
über das iPhone – warum diese Aufteilung, steht unter
|
||||
[Apple Watch](#apple-watch).
|
||||
|
||||
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe
|
||||
[firmware/vanalign](firmware/vanalign/README.md).
|
||||
[firmware/vanalign](firmware/vanalign/README.md). Der Votronic-Solarregler hängt
|
||||
an einer zweiten, unabhängigen ESP32-Bridge – Firmware und BLE-Dienst dafür
|
||||
liegen als `firmware/vanalign/esp32_ble_solar.yaml` im selben Ordner.
|
||||
|
||||
## Bauen und installieren
|
||||
|
||||
```bash
|
||||
open /Users/fritob/GIT/Camper-Management/CamperMonitor.xcodeproj
|
||||
open VanControl.xcodeproj
|
||||
```
|
||||
|
||||
Dann in Xcode:
|
||||
|
||||
1. Target `CamperMonitor` → **Signing & Capabilities** → dein Apple-Team auswählen.
|
||||
Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist.
|
||||
1. Bei allen vier Targets – `VanControl`, `VanControlWatch`,
|
||||
`VanControlComplication`, `VanControlLiveActivityExtension` – unter
|
||||
**Signing & Capabilities** dein Apple-Team auswählen. Die Bundle-IDs ggf.
|
||||
anpassen, falls sie schon vergeben sind; die der Watch-App muss die des
|
||||
iPhones mit angehängtem `.watchkitapp` bleiben, die der Komplikation
|
||||
zusätzlich `.levelwidget`.
|
||||
2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R.
|
||||
|
||||
Das Schema `VanControl` baut die Watch-App mit und bettet sie ein. Dafür
|
||||
muss die watchOS-Plattform in Xcode installiert sein – sonst bricht schon das
|
||||
Übersetzen des Symbolkatalogs ab:
|
||||
|
||||
```bash
|
||||
xcodebuild -downloadPlatform watchOS
|
||||
```
|
||||
|
||||
Auf die Uhr kommt die App danach von selbst: Sie steckt in der iPhone-App und
|
||||
taucht in der Watch-App des iPhones unter *Verfügbare Apps* auf.
|
||||
|
||||
Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber
|
||||
nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone.
|
||||
|
||||
@@ -101,7 +136,7 @@ Protokoll erkannt wurde, steht in der Detailansicht unter **Diagnose** –
|
||||
zusammen mit der letzten Rohantwort.
|
||||
|
||||
Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die
|
||||
Hersteller-App offen ist, bekommt Camper Monitor keine Verbindung.
|
||||
Hersteller-App offen ist, bekommt VanControl Pro keine Verbindung.
|
||||
|
||||
### Kühlbox
|
||||
|
||||
@@ -113,6 +148,23 @@ Beim Einrichten Art auf **Kühlbox** stellen. Die App meldet sich beim Verbinden
|
||||
selbst an; steht **APP** im Display der Box, verlangt sie dabei einen
|
||||
Tastendruck am Gerät.
|
||||
|
||||
**Verbunden wird nur, während die Kühlbox geöffnet ist.** Jede Verbindung
|
||||
meldet sich an ihrem Display an und stört, wer gerade davorsteht; und ihre Werte
|
||||
ändern sich langsam. Auf der Übersicht steht deshalb nicht die gemessene
|
||||
Innentemperatur, sondern der **zuletzt gestellte Stand** – Sollwert, Ein/Aus,
|
||||
Eco oder Max, dazu wann er gestellt wurde. Der bleibt richtig, auch wenn er von
|
||||
gestern ist: Ein Sollwert ändert sich nur, wenn jemand ihn ändert. Eine
|
||||
Innentemperatur von gestern sähe dagegen aus wie eine von jetzt.
|
||||
|
||||
Sobald du die Box öffnest, verbindet die App sich und zeigt alles live; beim
|
||||
Verlassen der Ansicht trennt sie wieder. Dasselbe gilt für die Uhr: Auch von
|
||||
dort wird die Verbindung nur angefordert, solange die Kühlbox-Ansicht offen ist.
|
||||
Ein Stellbefehl geht ebenfalls immer durch – ist die Box gerade nicht verbunden,
|
||||
wird er gemerkt und geht raus, sobald sie antwortet.
|
||||
|
||||
BMS und Neigungsmesser bleiben dagegen dauerhaft verbunden. Sie stören dabei
|
||||
nicht, und ihre Werte will man laufend sehen.
|
||||
|
||||
Neben den Messwerten gibt es hier als einzigem Gerät auch Bedienelemente:
|
||||
Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle
|
||||
Schalter zeigen den Stand, den die Box zurückmeldet – nach jedem Stellbefehl
|
||||
@@ -125,6 +177,21 @@ sie mit, was einen Quervergleich zu Batterie und Solarregler erlaubt.
|
||||
Protokoll und Feldbelegung stammen aus
|
||||
[Gruni22/alpicool_ha_ble](https://github.com/Gruni22/alpicool_ha_ble).
|
||||
|
||||
### Votronic-Solarladeregler
|
||||
|
||||
Anders als der Victron-Regler hängt der Votronic-Regler nicht selbst am
|
||||
BLE-Advertising – er wird über eine eigene, unabhängige ESP32-Bridge
|
||||
ausgelesen (siehe `firmware/vanalign/esp32_ble_solar.yaml`). Die Bridge
|
||||
bewirbt ihren eigenen Dienst, wird beim Einrichten also automatisch erkannt
|
||||
und die Art vorbelegt, genau wie beim Neigungsmesser.
|
||||
|
||||
Die Bridge liest den Regler über dessen Displaylink-Port (UART) mit der
|
||||
externen ESPHome-Komponente
|
||||
[syssi/esphome-votronic](https://github.com/syssi/esphome-votronic) aus und
|
||||
stellt PV-Leistung, PV-Spannung/-Strom, Batteriespannung, Reglertemperatur
|
||||
sowie Lade- und Reglerstatus über eigene Charakteristiken bereit – reine
|
||||
Lesewerte, im Takt abgefragt wie beim Neigungsmesser.
|
||||
|
||||
### Nivellierung
|
||||
|
||||
Der Neigungsmesser [VanAlign Pro](https://github.com/) ist ein ESP32 mit
|
||||
@@ -145,10 +212,17 @@ hinweg gemerkt:
|
||||
In beiden Fällen: grün heißt eben (bis 0,5°), orange bis zwei Grad, darüber
|
||||
rot. Dazu steht in Worten, welche Seite höher steht.
|
||||
|
||||
Die Fahrzeugzeichnungen stammen aus dem Ursprungsprojekt VanAlign Pro. Sie
|
||||
liegen als Schablonen im Asset-Katalog und werden je nach Abweichung
|
||||
eingefärbt; die Helligkeit des Originals wurde dafür in Deckkraft übersetzt,
|
||||
damit Fenster und Konturen beim Einfärben erhalten bleiben.
|
||||
Die Fahrzeugansicht kennt mehrere Grafikstile (aktuell Vanster und
|
||||
California), einstellbar je Fahrzeugprofil. Die Zeichnungen stammen aus dem
|
||||
Ursprungsprojekt VanAlign Pro. Sie liegen als Schablonen im Asset-Katalog und
|
||||
werden je nach Abweichung eingefärbt; die Helligkeit des Originals wurde
|
||||
dafür in Deckkraft übersetzt, damit Fenster und Konturen beim Einfärben
|
||||
erhalten bleiben.
|
||||
|
||||
Im Querformat rücken Libelle bzw. Fahrzeugansicht und die übrigen Angaben
|
||||
nebeneinander statt untereinander – gedacht fürs Handy in der Halterung beim
|
||||
Rangieren, wo man beiläufig hinschaut statt zu scrollen. Das gilt auch für
|
||||
den Ausrichtungs-Assistenten weiter unten.
|
||||
|
||||
**Vor der ersten Nutzung die Einbaulage bestimmen.** Sitzt der Sensor quer,
|
||||
gedreht oder kopfüber, meldet er längs und quer vertauscht oder mit falschem
|
||||
@@ -156,6 +230,46 @@ Vorzeichen. Der Assistent unter *Sensor → Einbaulage* klärt das durch zwei
|
||||
Kippbewegungen: einmal die Front nach unten, einmal die linke Seite. Aus der
|
||||
Reaktion ergibt sich die Zuordnung – geraten wird nichts.
|
||||
|
||||
Dabei wird auch eine **Verdrehung um die Hochachse** mitgemessen, also der
|
||||
Fall, dass der Sensor schräg statt längs im Fahrzeug klebt. Ohne diese
|
||||
Korrektur verteilt sich eine reine Querneigung auf beide Achsen: Das Fahrzeug
|
||||
kippt zur Seite, und die Längsanzeige kippt sichtbar mit – bei 20° Verdrehung
|
||||
mit gut einem Drittel des Werts. Der Winkel steht im Ergebnis des Assistenten
|
||||
(„um 20° verdreht") und wird von da an herausgerechnet.
|
||||
|
||||
Beim **Kalibrieren** liesse sich das nicht ermitteln, und zwar grundsätzlich
|
||||
nicht: Es misst eine einzige Lage und zieht sie als Nullpunkt ab. Eine Drehung
|
||||
um die Hochachse steckt darin nicht – eben sieht in jeder Verdrehung gleich
|
||||
aus. Dafür braucht es zwei Kippbewegungen in bekannte Richtungen, und genau die
|
||||
macht der Einbaulage-Assistent.
|
||||
|
||||
### Beides liegt im Sensor, nicht in der App
|
||||
|
||||
Sowohl die **Kalibrierung** als auch die **Einbaulage** speichert der ESP32
|
||||
selbst und gibt sie an jeden aus, der fragt. Das ist der Punkt, sobald mehr als
|
||||
ein Gerät im Spiel ist: iPhone, Apple Watch und Android-App zeigen dasselbe,
|
||||
und bestimmt werden muss beides nur ein einziges Mal, von welchem Gerät aus
|
||||
auch immer.
|
||||
|
||||
| | wo |
|
||||
|---|---|
|
||||
| Nullpunkt der Kalibrierung | im ESP (`pitch_offset`, `roll_offset`), wird dort auch schon abgezogen |
|
||||
| Einbaulage samt Verdrehung | im ESP, Charakteristik `…3428`, lesbar und schreibbar |
|
||||
| Spurweite und Radstand | in der jeweiligen App, im Fahrzeugprofil |
|
||||
|
||||
Beim Verbinden liest jede App die Einbaulage aus dem Gerät und übernimmt sie.
|
||||
Wer sie neu bestimmt, schreibt sie hinauf. Steht dort noch nichts – ältere
|
||||
Firmware oder nie bestimmt –, gilt weiter, was die App örtlich gespeichert hat;
|
||||
kaputtgehen kann dabei nichts.
|
||||
|
||||
Angewandt wird sie trotzdem in den Apps und nicht im Sensor. Der Sensor
|
||||
verwahrt sie nur: Würde er die Winkel schon umgerechnet melden, rechnete jede
|
||||
ältere App die Korrektur ein zweites Mal ein.
|
||||
|
||||
Die acht Byte der Charakteristik sind in `VanAlignProtocol` beschrieben und in
|
||||
`run-tests.sh` byteweise festgenagelt – daran hängen drei Apps und die
|
||||
Firmware.
|
||||
|
||||
**Danach kalibrieren:** Fahrzeug eben stellen, dann *Auf
|
||||
aktuelle Lage kalibrieren*. Ohne das zeigt die Anzeige die Lage des Sensors,
|
||||
nicht die des Fahrzeugs – je nachdem, wie schief er eingebaut ist.
|
||||
@@ -186,10 +300,112 @@ Das steckt vollständig im zeitlichen Verlauf der Neigung – ohne jede Annahme
|
||||
über das Gelände.
|
||||
|
||||
Sind beim Fahrzeug **Spurweite und Radstand** hinterlegt, rechnet der Assistent
|
||||
zusätzlich die nötige Höhe der Auffahrkeile aus. Das ist reine Geometrie und
|
||||
damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die
|
||||
tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das
|
||||
ⓘ in der Fahrzeugliste.
|
||||
zusätzlich die nötige Höhe der Auffahrkeile aus – **je Rad**, als Draufsicht auf
|
||||
das Fahrzeug. Getrennte Angaben für quer und längs wären irreführend: „rechts
|
||||
8 cm" und „vorne 4 cm" beschreiben dasselbe Fahrzeug, und unter beiden liegt
|
||||
teils dasselbe Rad. Es steht auf vier Punkten, also gehören vier Zahlen hin.
|
||||
|
||||
Das ist reine Geometrie und damit exakt: 2,0° Querneigung bei 2,00 m Spurweite
|
||||
ergeben 7,0 cm unter beide Räder der tieferstehenden Seite; kommen 1,5°
|
||||
Längsneigung bei 3,50 m Radstand dazu, braucht die tiefste Ecke 16,1 cm, ihre
|
||||
Nachbarn 9,2 und 7,0 cm, und das höchststehende Rad bleibt liegen. Die Maße
|
||||
stehen im Fahrzeugprofil, erreichbar über das ⓘ in der Fahrzeugliste.
|
||||
|
||||
### Live Activity
|
||||
|
||||
In der Nivellierungs-Ansicht lässt sich eine Live Activity einschalten, die
|
||||
die Neigung auch bei gesperrtem Bildschirm zeigt: Sperrbildschirm, Dynamic
|
||||
Island und – seit iOS 26, ohne eigenes CarPlay-Ziel nötig – automatisch im
|
||||
CarPlay-Dashboard. Der Schalter ist deaktiviert, solange keine Messwerte
|
||||
anliegen, sonst startete die Anzeige gleich mit einem veralteten Stand.
|
||||
|
||||
Die Aktualisierung ist bewusst auf höchstens einmal je Sekunde gedrosselt.
|
||||
Der Neigungsmesser liefert deutlich öfter, und stiesse jede Messung sofort
|
||||
eine Aktualisierung an, drosselt iOS das von sich aus zunehmend stärker –
|
||||
sichtbar vor allem in CarPlay, dessen Dashboard ohnehin zurückhaltender
|
||||
aktualisiert als Sperrbildschirm oder Dynamic Island. Innerhalb der
|
||||
Sperrfrist eingehende Messwerte werden nicht verworfen, sondern der
|
||||
jeweils neueste für ihr Ende vorgemerkt.
|
||||
|
||||
Ein kurzer Verbindungsabbruch beendet die Aktivität nicht sofort – erst nach
|
||||
zwölf Sekunden ohne Verbindung, und meldet sich das Gerät vorher zurück,
|
||||
läuft sie unverändert weiter. War sie wegen einer längeren Trennung wirklich
|
||||
beendet, startet sie bei der nächsten Verbindung automatisch neu.
|
||||
|
||||
## Apple Watch
|
||||
|
||||
Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man
|
||||
den Arm hebt:
|
||||
|
||||
* **Übersicht** – je Gerät der Hauptwert und ein Punkt für den Zustand der
|
||||
Verbindung. Ganz unten steht, wie alt der Stand ist.
|
||||
* **Nivellierung** – drei Seiten zum Wischen, weil auf diesem Bildschirm ein
|
||||
Wisch besser zu treffen ist als eine Scrollposition:
|
||||
|
||||
1. *Neigung* – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
|
||||
Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über
|
||||
Starts hinweg gemerkt. Dazu, was zu tun ist, und woher die Werte kommen.
|
||||
2. *Keile* – die Höhe je Rad als Draufsicht. Fehlen Spurweite und Radstand im
|
||||
Fahrzeugprofil, steht das dort statt einer leeren Seite.
|
||||
3. *Ausrichten* – der Assistent fürs Rangieren.
|
||||
|
||||
Kalibriert wird nur am iPhone: Das gehört einmalig auf ebenen Boden, und ein
|
||||
Knopf dafür an der Uhr wäre vor allem eine Gelegenheit, die Nullage aus
|
||||
Versehen zu verstellen.
|
||||
* **Ausrichten** – derselbe Assistent wie am iPhone: Tendenz, Rat und der
|
||||
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
|
||||
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
|
||||
gegenüber dem iPhone – beim Rangieren schaut niemand aufs Display, aber die
|
||||
Vibration am Handgelenk kommt an.
|
||||
* **Kühlbox** – ein/aus, Eco oder Max und die Solltemperatur, über die Krone
|
||||
gestellt. Verbunden wird die Box erst beim Öffnen dieser Ansicht, davor steht
|
||||
dort der zuletzt gestellte Stand.
|
||||
|
||||
### Wer mit wem funkt
|
||||
|
||||
Die Aufteilung folgt dem, was jedes Gerät hergibt.
|
||||
|
||||
**Den Neigungsmesser funkt die Uhr selbst an.** Er bewirbt seinen Dienst, ist
|
||||
also ohne jede Einrichtung auffindbar, und er ist unverschlüsselt – es gibt
|
||||
keinen Schlüssel, der auf der Uhr ein zweites Mal lagern müsste. Damit steht
|
||||
die Nivellierung am Handgelenk **ohne iPhone**: kein geöffnetes Telefon, keine
|
||||
Reichweite dorthin. Genau dafür hebt man beim Rangieren den Arm.
|
||||
|
||||
Die Einbaulage des Sensors reist einmal vom iPhone herüber und bleibt auf der
|
||||
Uhr gespeichert. Ohne sie stünden längs und quer je nach Einbau vertauscht oder
|
||||
mit falschem Vorzeichen – eingestellt wird sie weiterhin nur am iPhone, im
|
||||
Assistenten dort.
|
||||
|
||||
**Alles Übrige kommt über das iPhone.** Zwei Gründe, beide hart:
|
||||
|
||||
* BMS und Kühlbox lassen jeweils nur **eine** Verbindung zu. Eine mitlesende
|
||||
Uhr nähme dem iPhone die Verbindung weg, statt sie zu ergänzen.
|
||||
* Die Victron-Schlüssel liegen in der Keychain des iPhones. Sie auf die Uhr zu
|
||||
kopieren hiesse, sie ein zweites Mal aufzubewahren, ohne dass der zweite Ort
|
||||
irgendetwas brächte.
|
||||
|
||||
Was die Uhr stellt, stellt also in Wahrheit das iPhone – und angezeigt wird
|
||||
auch dort nur, was das Gerät zurückmeldet, nicht der Tastendruck.
|
||||
|
||||
### Was das für den Betrieb heisst
|
||||
|
||||
Für Batterie, Solar und Kühlbox muss VanControl Pro auf dem iPhone laufen.
|
||||
Solange die Uhr meldet, dass jemand hinschaut, hält die App das Funkgerät auch
|
||||
im Hintergrund am Leben; die Victron-Werbedaten stehen dabei still, weil iOS im
|
||||
Hintergrund kein ungefiltertes Suchen erlaubt.
|
||||
|
||||
Für die Nivellierung gilt das alles nicht – die läuft an der Uhr allein.
|
||||
Gefunkt wird dort nur im Vordergrund: watchOS lässt eine App im Hintergrund
|
||||
ohnehin kaum scannen, und beim Ausrichten schaut man auf die Uhr.
|
||||
|
||||
Gesendet wird in zwei Takten: ein halber Sekundentakt, solange die Watch-App im
|
||||
Vordergrund ist, sonst alle zwei Sekunden und nur bei Änderungen. Der schnelle
|
||||
Takt läuft über eine Frist, die die Uhr regelmässig erneuert – schläft sie ein,
|
||||
hört das iPhone von selbst wieder auf.
|
||||
|
||||
Was die Uhr zeigt, ist immer Weitergereichtes. Deshalb steht auf jedem
|
||||
Bildschirm das Alter des Standes: Eine abgerissene Strecke zum iPhone sähe sonst
|
||||
genauso aus wie ein Fahrzeug, an dem sich nichts tut.
|
||||
|
||||
## Diagnose
|
||||
|
||||
@@ -213,10 +429,33 @@ eingerichtet wurden, wandern beim Update automatisch ins erste Profil.
|
||||
|
||||
## Ohne Fahrzeug ansehen
|
||||
|
||||
Ein Demo-Modus füllt die App mit erfundenen Werten, damit sich die Ansichten
|
||||
ohne Bluetooth prüfen lassen. In Xcode unter *Product → Scheme → Edit Scheme →
|
||||
Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift
|
||||
nur in Debug-Builds.
|
||||
Ein Demo-Modus füllt die App mit erfundenen Fahrzeugen und Messwerten, damit
|
||||
sich die Ansichten ohne Bluetooth prüfen lassen – auch ohne die Hardware zur
|
||||
Hand zu haben. Auf zwei Wegen einzuschalten:
|
||||
|
||||
* **Schalter in den Einstellungen** (*Einstellungen → Entwicklung →
|
||||
Demo-Modus*) – in jeder Build-Konfiguration verfügbar, auch in TestFlight-
|
||||
und App-Store-Builds. Der Weg für jeden lokal installierten Build ohne
|
||||
Xcode-Verbindung. Wirkt erst nach einem Neustart der App (im
|
||||
App-Umschalter nach oben wischen, dann neu öffnen), weil `DeviceStore` und
|
||||
`BluetoothManager` den Stand nur beim Start lesen.
|
||||
* **Umgebungsvariable** `CAMPER_DEMO=1` – nur in Debug-Builds, in Xcode unter
|
||||
*Product → Scheme → Edit Scheme → Run → Arguments*, oder im Simulator
|
||||
direkt:
|
||||
|
||||
```bash
|
||||
xcrun simctl launch --terminate-running-process booted <deine-bundle-id>
|
||||
# mit SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
||||
```
|
||||
|
||||
Solange der Demo-Modus läuft, schreibt die App nichts in die echte
|
||||
Geräteliste – Änderungen an den erfundenen Fahrzeugen/Geräten verschwinden
|
||||
beim Ausschalten wieder.
|
||||
|
||||
Die Watch-App kennt nur den Umgebungsvariablen-Weg und braucht dann kein
|
||||
iPhone: Im Schema `VanControlWatch` dieselbe Variable setzen. Die Neigung
|
||||
wandert dort langsam hin und her, sonst hätte der Ausrichtungs-Assistent
|
||||
nichts zu zeigen.
|
||||
|
||||
## Protokolle prüfen
|
||||
|
||||
@@ -231,18 +470,36 @@ und die Prüfsummen beider Daly-Dialekte.
|
||||
## Aufbau
|
||||
|
||||
```
|
||||
firmware/vanalign/ Firmware des Neigungsmessers (ESPHome)
|
||||
firmware/vanalign/ Firmware der beiden ESP32 im Fahrzeug (ESPHome)
|
||||
├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle
|
||||
├── esp32_ble_solar.yaml Zweiter ESP32: Votronic-Solarregler über BLE
|
||||
└── experimente/ Nicht für den Betrieb nötig
|
||||
|
||||
CamperMonitor/
|
||||
Shared/ In iPhone- und Watch-App übersetzt
|
||||
├── Models/
|
||||
│ ├── Profile.swift Fahrzeug samt Maßen
|
||||
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung
|
||||
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilhöhe je Rad
|
||||
│ ├── SensorOrientation.swift Einbaulage und ihre Erkennung
|
||||
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
|
||||
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
|
||||
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
|
||||
│ ├── LevelState.swift Neigung, Toleranz und Klartext dazu
|
||||
│ ├── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
|
||||
│ └── VehicleGraphicStyle.swift Grafikstile der Fahrzeugansicht (Vanster/California)
|
||||
├── Bluetooth/
|
||||
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
|
||||
│ ├── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr)
|
||||
│ ├── VotronicSolarESPProtocol.swift Votronic-Solarregler: Charakteristiken, Statusbits
|
||||
│ └── VotronicSolarESPSession.swift GATT-Verbindung zur Solar-Bridge, im Takt abgefragt
|
||||
├── 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
|
||||
|
||||
SharedActivity/ In Haupt-App und Live-Activity-Extension übersetzt
|
||||
├── LevelActivityAttributes.swift Inhalt der Live Activity (ActivityKit)
|
||||
└── LevelDirectionFormatting.swift "H 1.8°" statt Vorzeichen, für App und Extension gleich
|
||||
|
||||
VanControl/
|
||||
├── Bluetooth/
|
||||
│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf
|
||||
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
|
||||
@@ -253,31 +510,76 @@ CamperMonitor/
|
||||
│ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung
|
||||
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
|
||||
│ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle
|
||||
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
|
||||
│ ├── LevelSession.swift Verbindung zum Neigungsmesser
|
||||
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
|
||||
├── LiveActivity/
|
||||
│ └── LevelActivityManager.swift Startet/aktualisiert/beendet die Live Activity, drosselt auf 1 Hz
|
||||
├── Store/
|
||||
│ ├── DeviceStore.swift Geräteliste, Persistenz
|
||||
│ ├── DemoData.swift Erfundene Werte für den Demo-Modus
|
||||
│ └── KeychainStore.swift Victron-Schlüssel
|
||||
├── Watch/
|
||||
│ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an
|
||||
└── Views/
|
||||
├── DashboardView.swift Kachelübersicht
|
||||
├── DeviceCard.swift Eine Kachel
|
||||
├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose
|
||||
├── ProfilesView.swift Fahrzeuge anlegen und verwalten
|
||||
├── FridgeControls.swift Bedienelemente der Kühlbox
|
||||
├── LevelView.swift Libelle und Kalibrierung
|
||||
├── LevelView.swift Libelle, Kalibrierung, Live-Activity-Schalter
|
||||
├── LevelSetupView.swift Einbaulage und Nullpunkt
|
||||
├── AlignmentAssistantView.swift Ausrichtungs-Assistent fürs Rangieren
|
||||
├── VehicleTiltView.swift Neigung am Fahrzeug dargestellt
|
||||
├── VictronKeyView.swift Verschlüsselungsschlüssel eintragen
|
||||
├── SettingsView.swift App-Einstellungen
|
||||
├── SensorSetupView.swift Assistent für die Einbaulage
|
||||
└── AddDeviceView.swift Scannen und Einrichten
|
||||
|
||||
VanControlWatch/ watchOS-App
|
||||
├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen
|
||||
├── PhoneLink.swift Gegenstelle zum iPhone
|
||||
├── WatchLevelRadio.swift Eigene Bluetooth-Verbindung zum Neigungsmesser
|
||||
├── WatchDemo.swift Erfundene Werte für den Simulator
|
||||
└── Views/
|
||||
├── WatchRootView.swift Übersicht und Altersangabe
|
||||
├── WatchBubble.swift Libelle für den kleinen Bildschirm
|
||||
├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse
|
||||
├── WatchLevelView.swift Die drei Seiten: Neigung, Keile, Ausrichten
|
||||
├── WatchAlignView.swift Ausrichten mit Vibration
|
||||
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
|
||||
└── WatchFridgeControls.swift Kühlbox stellen
|
||||
|
||||
VanControlComplication/ Zifferblatt-Komplikation (watchOS)
|
||||
└── LevelComplication.swift Tippt auf die Nivellierung, ohne eigenen Messwert
|
||||
|
||||
VanControlLiveActivity/ Live-Activity-Extension (WidgetKit)
|
||||
├── VanControlLiveActivityBundle.swift Einstieg der Extension
|
||||
└── VanControlLiveActivity.swift Ansicht für Sperrbildschirm, Dynamic Island, CarPlay
|
||||
|
||||
Config/
|
||||
├── VanControl-Info.plist Nur der Hintergrundbetrieb für die Uhr
|
||||
└── VanControlComplication-Info.plist Anzeigename der Komplikation
|
||||
```
|
||||
|
||||
## Bekannte Grenzen
|
||||
|
||||
* **Kein Hintergrundbetrieb.** iOS erlaubt das ungefilterte Scannen nach
|
||||
Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den
|
||||
Hintergrund geht, und nimmt beim Zurückkommen wieder auf.
|
||||
* **Im Hintergrund nur die verbundenen Geräte.** iOS erlaubt das ungefilterte
|
||||
Scannen nach Advertisements nur im Vordergrund; die Victron-Werte stehen im
|
||||
Hintergrund also still. Verbundene Geräte – Neigungsmesser, BMS, Kühlbox –
|
||||
laufen weiter, aber nur solange die Uhr meldet, dass jemand hinschaut. Ohne
|
||||
Uhr pausiert die App im Hintergrund vollständig und nimmt beim Zurückkommen
|
||||
wieder auf.
|
||||
* **Die Uhr braucht das iPhone – ausser für die Nivellierung.** Batterie, Solar
|
||||
und Kühlbox zeigt sie nur, was von dort kommt; ist das iPhone ausser
|
||||
Reichweite, steht dort, wie alt der letzte Stand ist. Den Neigungsmesser
|
||||
erreicht sie selbst.
|
||||
* **Neigungsmesser und Uhr:** Der ESP32 nimmt mehrere Verbindungen an, iPhone
|
||||
und Uhr können also gleichzeitig mitlesen. Weist er die zweite ab, hilft es,
|
||||
die iPhone-App zu schliessen – dann gehört der Sensor der Uhr.
|
||||
* **Auf der Uhr muss beim ersten Start Bluetooth erlaubt werden**, sonst bleibt
|
||||
die Nivellierung dort leer.
|
||||
* **Die Live Activity aktualisiert höchstens einmal je Sekunde.** Öfter
|
||||
drosselt iOS lokale Aktualisierungen ohnehin von sich aus, zunehmend
|
||||
stärker – sichtbar vor allem in CarPlay. Siehe [Live Activity](#live-activity).
|
||||
* **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
|
||||
Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind
|
||||
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel
|
||||
|
||||
@@ -18,6 +18,7 @@ final class LevelSession: NSObject {
|
||||
private static let rollUUID = CBUUID(string: VanAlignProtocol.rollUUID)
|
||||
private static let offsetsUUID = CBUUID(string: VanAlignProtocol.offsetsUUID)
|
||||
private static let calibrateUUID = CBUUID(string: VanAlignProtocol.calibrateUUID)
|
||||
private static let orientationUUID = CBUUID(string: VanAlignProtocol.orientationUUID)
|
||||
|
||||
let deviceID: UUID
|
||||
private let queue: DispatchQueue
|
||||
@@ -25,11 +26,16 @@ final class LevelSession: NSObject {
|
||||
private let onUpdate: (DeviceSnapshot) -> Void
|
||||
private let onStateChange: (DeviceLinkState) -> Void
|
||||
private let onLevelState: (LevelState) -> Void
|
||||
/// Meldet die Einbaulage, die im Gerät steht. Sie gilt vor der örtlich
|
||||
/// gespeicherten: dort steht, wie der Sensor eingebaut ist, und das ist für
|
||||
/// alle Clients dasselbe.
|
||||
private let onDeviceOrientation: ((SensorOrientation) -> Void)?
|
||||
|
||||
private var pitchCharacteristic: CBCharacteristic?
|
||||
private var rollCharacteristic: CBCharacteristic?
|
||||
private var offsetsCharacteristic: CBCharacteristic?
|
||||
private var calibrateCharacteristic: CBCharacteristic?
|
||||
private var orientationCharacteristic: CBCharacteristic?
|
||||
|
||||
private var state = LevelState()
|
||||
/// Aus den Geräteeinstellungen; rechnet Sensor- in Fahrzeugachsen um.
|
||||
@@ -49,7 +55,9 @@ final class LevelSession: NSObject {
|
||||
queue: DispatchQueue,
|
||||
onUpdate: @escaping (DeviceSnapshot) -> Void,
|
||||
onStateChange: @escaping (DeviceLinkState) -> Void,
|
||||
onLevelState: @escaping (LevelState) -> Void) {
|
||||
onLevelState: @escaping (LevelState) -> Void,
|
||||
onDeviceOrientation: ((SensorOrientation) -> Void)? = nil) {
|
||||
self.onDeviceOrientation = onDeviceOrientation
|
||||
self.deviceID = deviceID
|
||||
self.queue = queue
|
||||
self.peripheral = peripheral
|
||||
@@ -89,6 +97,28 @@ final class LevelSession: NSObject {
|
||||
rollCharacteristic = nil
|
||||
offsetsCharacteristic = nil
|
||||
calibrateCharacteristic = nil
|
||||
orientationCharacteristic = nil
|
||||
}
|
||||
|
||||
// MARK: - Einbaulage im Gerät
|
||||
|
||||
/// Schreibt die Einbaulage ins Gerät, damit alle Clients dieselbe sehen.
|
||||
///
|
||||
/// Ältere Firmware hat die Charakteristik nicht; dann bleibt es bei der
|
||||
/// örtlich gespeicherten Fassung, und es passiert schlicht nichts.
|
||||
func storeOrientation(_ orientation: SensorOrientation) {
|
||||
guard let characteristic = orientationCharacteristic,
|
||||
peripheral.state == .connected else { return }
|
||||
let type: CBCharacteristicWriteType =
|
||||
characteristic.properties.contains(.write) ? .withResponse : .withoutResponse
|
||||
peripheral.writeValue(VanAlignProtocol.encoded(orientation),
|
||||
for: characteristic, type: type)
|
||||
// Zurücklesen, damit angezeigt wird, was wirklich im Gerät steht.
|
||||
queue.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self, let characteristic = self.orientationCharacteristic,
|
||||
self.peripheral.state == .connected else { return }
|
||||
self.peripheral.readValue(for: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Kalibrieren
|
||||
@@ -169,7 +199,8 @@ extension LevelSession: CBPeripheralDelegate {
|
||||
return
|
||||
}
|
||||
peripheral.discoverCharacteristics(
|
||||
[Self.pitchUUID, Self.rollUUID, Self.offsetsUUID, Self.calibrateUUID],
|
||||
[Self.pitchUUID, Self.rollUUID, Self.offsetsUUID,
|
||||
Self.calibrateUUID, Self.orientationUUID],
|
||||
for: service
|
||||
)
|
||||
}
|
||||
@@ -188,6 +219,7 @@ extension LevelSession: CBPeripheralDelegate {
|
||||
case Self.rollUUID: rollCharacteristic = characteristic
|
||||
case Self.offsetsUUID: offsetsCharacteristic = characteristic
|
||||
case Self.calibrateUUID: calibrateCharacteristic = characteristic
|
||||
case Self.orientationUUID: orientationCharacteristic = characteristic
|
||||
default: break
|
||||
}
|
||||
}
|
||||
@@ -210,7 +242,8 @@ extension LevelSession: CBPeripheralDelegate {
|
||||
startPollingIfNeeded()
|
||||
|
||||
// Einmal alles lesen, damit sofort etwas dasteht.
|
||||
for characteristic in [pitchCharacteristic, rollCharacteristic, offsetsCharacteristic] {
|
||||
for characteristic in [pitchCharacteristic, rollCharacteristic,
|
||||
offsetsCharacteristic, orientationCharacteristic] {
|
||||
guard let characteristic, characteristic.properties.contains(.read) else { continue }
|
||||
peripheral.readValue(for: characteristic)
|
||||
}
|
||||
@@ -233,6 +266,16 @@ extension LevelSession: CBPeripheralDelegate {
|
||||
state.pitchOffset = offsets.pitch
|
||||
state.rollOffset = offsets.roll
|
||||
}
|
||||
case Self.orientationUUID:
|
||||
// Was im Gerät steht, gilt: es beschreibt den Einbau, nicht das
|
||||
// Telefon. Steht dort nichts (Version 0), bleibt es bei der
|
||||
// örtlichen Fassung – der Aufrufer schreibt sie dann hinauf.
|
||||
guard let stored = VanAlignProtocol.orientation(from: value) else { return }
|
||||
if stored != orientation {
|
||||
orientation = stored
|
||||
}
|
||||
onDeviceOrientation?(stored)
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
/// VotronicSolarESP – zweiter ESP32 im Fahrzeug, liest einen
|
||||
/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
|
||||
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
|
||||
///
|
||||
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
|
||||
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
|
||||
/// sie im Takt ab.
|
||||
enum VotronicSolarESPProtocol {
|
||||
|
||||
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
|
||||
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
|
||||
|
||||
static let batteryVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A101"
|
||||
static let pvVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A102"
|
||||
static let pvCurrentUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A103"
|
||||
static let pvPowerUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A104"
|
||||
static let controllerTempUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A105"
|
||||
/// Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
|
||||
/// Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
|
||||
static let statusFlagsUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A106"
|
||||
|
||||
/// Liest einen Messwert aus vier Bytes, little-endian – wie beim
|
||||
/// Neigungsmesser legt die Firmware den Float per `memcpy` ab.
|
||||
static func float(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)
|
||||
guard value.isFinite else { return nil }
|
||||
return Double(value)
|
||||
}
|
||||
|
||||
struct StatusFlags {
|
||||
var isBatteryCharging = false
|
||||
var isBatteryDischarging = false
|
||||
var isControllerActive = false
|
||||
var isCurrentLimited = false
|
||||
var isAESActive = false
|
||||
}
|
||||
|
||||
static func statusFlags(from data: Data) -> StatusFlags? {
|
||||
guard let byte = data.first else { return nil }
|
||||
var flags = StatusFlags()
|
||||
flags.isBatteryCharging = byte & (1 << 0) != 0
|
||||
flags.isBatteryDischarging = byte & (1 << 1) != 0
|
||||
flags.isControllerActive = byte & (1 << 2) != 0
|
||||
flags.isCurrentLimited = byte & (1 << 3) != 0
|
||||
flags.isAESActive = byte & (1 << 4) != 0
|
||||
return flags
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
/// Hält die Verbindung zum Solarladeregler.
|
||||
///
|
||||
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
|
||||
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
|
||||
/// immer im Takt abgefragt statt abonniert.
|
||||
final class VotronicSolarESPSession: NSObject {
|
||||
|
||||
static let serviceUUID = CBUUID(string: VotronicSolarESPProtocol.serviceUUID)
|
||||
private static let batteryVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.batteryVoltageUUID)
|
||||
private static let pvVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.pvVoltageUUID)
|
||||
private static let pvCurrentUUID = CBUUID(string: VotronicSolarESPProtocol.pvCurrentUUID)
|
||||
private static let pvPowerUUID = CBUUID(string: VotronicSolarESPProtocol.pvPowerUUID)
|
||||
private static let controllerTempUUID = CBUUID(string: VotronicSolarESPProtocol.controllerTempUUID)
|
||||
private static let statusFlagsUUID = CBUUID(string: VotronicSolarESPProtocol.statusFlagsUUID)
|
||||
|
||||
let deviceID: UUID
|
||||
private let queue: DispatchQueue
|
||||
private let peripheral: CBPeripheral
|
||||
private let onUpdate: (DeviceSnapshot) -> Void
|
||||
private let onStateChange: (DeviceLinkState) -> Void
|
||||
private let onVotronicSolarESPState: (VotronicSolarESPState) -> Void
|
||||
|
||||
private var characteristics: [CBUUID: CBCharacteristic] = [:]
|
||||
private var state = VotronicSolarESPState()
|
||||
private var pollTimer: DispatchSourceTimer?
|
||||
|
||||
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern –
|
||||
/// deutlich seltener als beim Ausrichten mit dem Neigungsmesser.
|
||||
var pollInterval: TimeInterval = 5
|
||||
|
||||
init(deviceID: UUID,
|
||||
peripheral: CBPeripheral,
|
||||
queue: DispatchQueue,
|
||||
onUpdate: @escaping (DeviceSnapshot) -> Void,
|
||||
onStateChange: @escaping (DeviceLinkState) -> Void,
|
||||
onVotronicSolarESPState: @escaping (VotronicSolarESPState) -> Void) {
|
||||
self.deviceID = deviceID
|
||||
self.queue = queue
|
||||
self.peripheral = peripheral
|
||||
self.onUpdate = onUpdate
|
||||
self.onStateChange = onStateChange
|
||||
self.onVotronicSolarESPState = onVotronicSolarESPState
|
||||
super.init()
|
||||
peripheral.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Lebenszyklus
|
||||
|
||||
func start() {
|
||||
onStateChange(.connecting)
|
||||
peripheral.discoverServices([Self.serviceUUID])
|
||||
}
|
||||
|
||||
func stop() {
|
||||
pollTimer?.cancel()
|
||||
pollTimer = nil
|
||||
characteristics.removeAll()
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
pollTimer?.cancel()
|
||||
pollTimer = nil
|
||||
characteristics.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Abfrage
|
||||
|
||||
private func startPolling() {
|
||||
guard pollTimer == nil else { return }
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now(), repeating: pollInterval)
|
||||
timer.setEventHandler { [weak self] in self?.readAll() }
|
||||
timer.resume()
|
||||
pollTimer = timer
|
||||
}
|
||||
|
||||
private func readAll() {
|
||||
guard peripheral.state == .connected else { return }
|
||||
for characteristic in characteristics.values where characteristic.properties.contains(.read) {
|
||||
peripheral.readValue(for: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
guard state.hasReading else { return }
|
||||
onStateChange(.live)
|
||||
onVotronicSolarESPState(state)
|
||||
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension VotronicSolarESPSession: CBPeripheralDelegate {
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
if let error {
|
||||
onStateChange(.failed(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
|
||||
onStateChange(.failed("Solarladeregler-Dienst nicht gefunden"))
|
||||
return
|
||||
}
|
||||
peripheral.discoverCharacteristics(
|
||||
[Self.batteryVoltageUUID, Self.pvVoltageUUID, Self.pvCurrentUUID,
|
||||
Self.pvPowerUUID, Self.controllerTempUUID, Self.statusFlagsUUID],
|
||||
for: service
|
||||
)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didDiscoverCharacteristicsFor service: CBService,
|
||||
error: Error?) {
|
||||
guard error == nil, let found = service.characteristics else {
|
||||
onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden"))
|
||||
return
|
||||
}
|
||||
for characteristic in found {
|
||||
characteristics[characteristic.uuid] = characteristic
|
||||
}
|
||||
guard !characteristics.isEmpty else {
|
||||
onStateChange(.failed("Solarwerte nicht gefunden"))
|
||||
return
|
||||
}
|
||||
startPolling()
|
||||
readAll()
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didUpdateValueFor characteristic: CBCharacteristic,
|
||||
error: Error?) {
|
||||
guard error == nil, let value = characteristic.value else { return }
|
||||
|
||||
switch characteristic.uuid {
|
||||
case Self.batteryVoltageUUID:
|
||||
state.batteryVoltage = VotronicSolarESPProtocol.float(from: value)
|
||||
case Self.pvVoltageUUID:
|
||||
state.pvVoltage = VotronicSolarESPProtocol.float(from: value)
|
||||
case Self.pvCurrentUUID:
|
||||
state.pvCurrent = VotronicSolarESPProtocol.float(from: value)
|
||||
case Self.pvPowerUUID:
|
||||
state.pvPower = VotronicSolarESPProtocol.float(from: value)
|
||||
case Self.controllerTempUUID:
|
||||
state.controllerTemperature = VotronicSolarESPProtocol.float(from: value)
|
||||
case Self.statusFlagsUUID:
|
||||
guard let flags = VotronicSolarESPProtocol.statusFlags(from: value) else { return }
|
||||
state.isBatteryCharging = flags.isBatteryCharging
|
||||
state.isBatteryDischarging = flags.isBatteryDischarging
|
||||
state.isControllerActive = flags.isControllerActive
|
||||
state.isCurrentLimited = flags.isCurrentLimited
|
||||
default:
|
||||
return
|
||||
}
|
||||
publish()
|
||||
}
|
||||
}
|
||||
@@ -129,55 +129,85 @@ struct AlignmentAssistant {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen.
|
||||
/// Wie hoch jedes einzelne Rad unterlegt werden muss.
|
||||
///
|
||||
/// Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen
|
||||
/// beziehungsweise der Räder.
|
||||
struct LevelingWedge: Equatable {
|
||||
/// Wo der Keil hin muss.
|
||||
let side: Side
|
||||
/// Höhe in Metern.
|
||||
let height: Double
|
||||
/// Der zugrundeliegende Winkel in Grad.
|
||||
let angle: Double
|
||||
/// Getrennte Angaben wie „rechts 8 cm" und „vorne 4 cm" beschreiben dasselbe
|
||||
/// Fahrzeug und lassen sich nicht getrennt ausführen: Unter „rechts" liegen
|
||||
/// zwei Räder, unter „vorne" auch, und zwei davon sind dieselben. Gefragt ist
|
||||
/// deshalb die Höhe **je Rad**.
|
||||
///
|
||||
/// Das ist reine Geometrie und damit exakt. Das Fahrzeug steht auf vier
|
||||
/// Punkten; deren Höhen ergeben sich aus Spurweite, Radstand und den beiden
|
||||
/// Winkeln. Angehoben wird auf die Höhe des höchsten Rades – das bleibt
|
||||
/// liegen, die anderen bekommen die Differenz.
|
||||
struct LevelingLift: Equatable {
|
||||
|
||||
enum Side: Equatable {
|
||||
case front, rear, left, right
|
||||
enum Wheel: CaseIterable, Hashable {
|
||||
case frontLeft, frontRight, rearLeft, rearRight
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .front: return "vorne"
|
||||
case .rear: return "hinten"
|
||||
case .left: return "links"
|
||||
case .right: return "rechts"
|
||||
case .frontLeft: return "vorne links"
|
||||
case .frontRight: return "vorne rechts"
|
||||
case .rearLeft: return "hinten links"
|
||||
case .rearRight: return "hinten rechts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var heightInCentimetres: Double { height * 100 }
|
||||
/// Höhen in Metern, je Rad. Das höchste Rad steht auf 0.
|
||||
let frontLeft: Double
|
||||
let frontRight: Double
|
||||
let rearLeft: Double
|
||||
let rearRight: Double
|
||||
|
||||
/// Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll
|
||||
/// heisst, dass rechts höher steht – der Keil gehört also nach links.
|
||||
static func across(roll: Double, trackWidth: Double) -> LevelingWedge? {
|
||||
wedge(angle: roll, distance: trackWidth,
|
||||
whenPositive: .left, whenNegative: .right)
|
||||
func height(_ wheel: Wheel) -> Double {
|
||||
switch wheel {
|
||||
case .frontLeft: return frontLeft
|
||||
case .frontRight: return frontRight
|
||||
case .rearLeft: return rearLeft
|
||||
case .rearRight: return rearRight
|
||||
}
|
||||
}
|
||||
|
||||
/// Längs: positiver Pitch heisst, dass das Heck höher steht – der Keil
|
||||
/// gehört unter die Vorderräder.
|
||||
static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? {
|
||||
wedge(angle: pitch, distance: wheelbase,
|
||||
whenPositive: .front, whenNegative: .rear)
|
||||
func centimetres(_ wheel: Wheel) -> Double { height(wheel) * 100 }
|
||||
|
||||
/// Das am tiefsten stehende Rad – dort liegt die grösste Höhe.
|
||||
var deepest: Wheel {
|
||||
Wheel.allCases.max { height($0) < height($1) } ?? .frontLeft
|
||||
}
|
||||
|
||||
private static func wedge(angle: Double,
|
||||
distance: Double,
|
||||
whenPositive: Side,
|
||||
whenNegative: Side) -> LevelingWedge? {
|
||||
guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil }
|
||||
let height = tan(abs(angle) * .pi / 180) * distance
|
||||
return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative,
|
||||
height: height,
|
||||
angle: abs(angle))
|
||||
var maximumCentimetres: Double { centimetres(deepest) }
|
||||
|
||||
/// Unter einem Zentimeter lohnt kein Keil; so genau steht kein Fahrzeug,
|
||||
/// und so genau misst der Sensor auch nicht.
|
||||
var isNegligible: Bool { maximumCentimetres < 1 }
|
||||
|
||||
/// `pitch` positiv heisst, das Heck steht höher; `roll` positiv, die rechte
|
||||
/// Seite steht höher. Ohne Fahrzeugmasse gibt es nichts zu rechnen.
|
||||
static func compute(pitch: Double,
|
||||
roll: Double,
|
||||
trackWidth: Double,
|
||||
wheelbase: Double) -> LevelingLift? {
|
||||
guard trackWidth > 0, wheelbase > 0 else { return nil }
|
||||
|
||||
// Wie viel höher die rechte Seite und wie viel höher das Heck steht.
|
||||
// Vorzeichen inbegriffen: tan(-2°) ist negativ, dann steht links höher.
|
||||
let rightRise = tan(roll * .pi / 180) * trackWidth
|
||||
let rearRise = tan(pitch * .pi / 180) * wheelbase
|
||||
|
||||
// Höhen der vier Aufstandspunkte, bezogen auf vorne links.
|
||||
let heights: [Wheel: Double] = [
|
||||
.frontLeft: 0,
|
||||
.frontRight: rightRise,
|
||||
.rearLeft: rearRise,
|
||||
.rearRight: rightRise + rearRise,
|
||||
]
|
||||
let top = heights.values.max() ?? 0
|
||||
|
||||
return LevelingLift(frontLeft: top - heights[.frontLeft]!,
|
||||
frontRight: top - heights[.frontRight]!,
|
||||
rearLeft: top - heights[.rearLeft]!,
|
||||
rearRight: top - heights[.rearRight]!)
|
||||
}
|
||||
}
|
||||
@@ -4,41 +4,66 @@ import Foundation
|
||||
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
|
||||
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case chargeBooster
|
||||
case solarCharger
|
||||
/// Victron-Solarladeregler (SmartSolar/BlueSolar MPPT), passiv über das
|
||||
/// verschlüsselte BLE-Advertisement gelesen.
|
||||
///
|
||||
/// Der rawValue bleibt `"solarCharger"`, damit bereits gespeicherte
|
||||
/// Geräte beim Decodieren nicht auf einen unbekannten Rollen-Wert
|
||||
/// treffen – siehe `ConfiguredDevice.init(from:)`.
|
||||
case victronSolarCharger = "solarCharger"
|
||||
case batteryMonitor
|
||||
case bms
|
||||
case fridge
|
||||
case leveling
|
||||
/// Votronic-Solarladeregler, über eine ESP32-Bridge per GATT-Verbindung
|
||||
/// angesprochen (siehe `VotronicSolarESPSession`).
|
||||
///
|
||||
/// Der rawValue bleibt `"solar"`, siehe `victronSolarCharger` oben.
|
||||
case votronicSolar = "solar"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .chargeBooster: return "Ladebooster"
|
||||
case .solarCharger: return "Solarladeregler"
|
||||
case .batteryMonitor: return "Batteriemonitor"
|
||||
case .bms: return "Batterie / BMS"
|
||||
case .fridge: return "Kühlbox"
|
||||
case .leveling: return "Nivellierung"
|
||||
case .chargeBooster: return "Ladebooster"
|
||||
case .victronSolarCharger: return "Solarladeregler"
|
||||
case .batteryMonitor: return "Batteriemonitor"
|
||||
case .bms: return "Batterie / BMS"
|
||||
case .fridge: return "Kühlbox"
|
||||
case .leveling: return "Nivellierung"
|
||||
case .votronicSolar: return "VotronicSolarESP"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .chargeBooster: return "bolt.car"
|
||||
case .solarCharger: return "sun.max"
|
||||
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
||||
case .bms: return "battery.100percent.bolt"
|
||||
case .fridge: return "refrigerator"
|
||||
case .leveling: return "level"
|
||||
case .chargeBooster: return "bolt.car"
|
||||
case .victronSolarCharger: return "sun.max"
|
||||
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
||||
case .bms: return "battery.100percent.bolt"
|
||||
case .fridge: return "refrigerator"
|
||||
case .leveling: return "level"
|
||||
case .votronicSolar: return "sun.max"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob nur verbunden wird, während die Ansicht des Geräts offen ist.
|
||||
///
|
||||
/// Die Kühlbox meldet jede Verbindung an ihrem Display an und verlangt je
|
||||
/// nach Modell sogar einen Tastendruck; dauerhaft verbunden zu sein ist
|
||||
/// dort also nicht unsichtbar, sondern lästig. Ihre Werte ändern sich
|
||||
/// ausserdem langsam, und was auf der Übersicht zählt – der Sollwert –
|
||||
/// ändert sich überhaupt nur, wenn jemand ihn ändert.
|
||||
///
|
||||
/// BMS und Neigungsmesser bleiben dagegen verbunden: Sie stören nicht, und
|
||||
/// ihre Werte will man laufend sehen.
|
||||
var connectsOnDemand: Bool { self == .fridge }
|
||||
|
||||
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
||||
/// braucht eine echte GATT-Verbindung.
|
||||
var transport: DeviceTransport {
|
||||
switch self {
|
||||
case .bms, .fridge, .leveling: return .connect
|
||||
case .bms, .fridge, .leveling, .votronicSolar: return .connect
|
||||
default: return .advertisement
|
||||
}
|
||||
}
|
||||
@@ -86,6 +111,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
var fridgeZoneMode: FridgeZoneMode = .automatic
|
||||
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
|
||||
var sensorOrientation = SensorOrientation.identity
|
||||
/// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt.
|
||||
var vehicleGraphicStyle = VehicleGraphicStyle.vanster
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
@@ -116,5 +143,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
?? .automatic
|
||||
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
|
||||
forKey: .sensorOrientation) ?? .identity
|
||||
vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self,
|
||||
forKey: .vehicleGraphicStyle) ?? .vanster
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
||||
struct Metric: Identifiable, Hashable, Sendable {
|
||||
struct Metric: Identifiable, Hashable, Codable, Sendable {
|
||||
let key: String
|
||||
let label: String
|
||||
let value: Double?
|
||||
@@ -39,7 +39,7 @@ struct Metric: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
|
||||
struct DeviceSnapshot: Identifiable, Sendable {
|
||||
struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
|
||||
var id: UUID { deviceID }
|
||||
var deviceID: UUID
|
||||
var timestamp: Date
|
||||
@@ -58,7 +58,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
|
||||
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
||||
var info: [InfoItem] = []
|
||||
|
||||
struct InfoItem: Identifiable, Hashable, Sendable {
|
||||
struct InfoItem: Identifiable, Hashable, Codable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
var id: String { label }
|
||||
@@ -76,7 +76,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
|
||||
}
|
||||
|
||||
/// Verbindungszustand für die UI.
|
||||
enum DeviceLinkState: Equatable, Sendable {
|
||||
enum DeviceLinkState: Equatable, Codable, Sendable {
|
||||
case idle
|
||||
case searching
|
||||
case connecting
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
/// Der zuletzt bekannte Stand einer Kühlbox – ohne Messwerte.
|
||||
///
|
||||
/// Die Box wird nur noch verbunden, während man ihre Ansicht offen hat: Jede
|
||||
/// Verbindung meldet sich an ihrem Display an und stört, wer sie gerade
|
||||
/// bedient. Auf der Übersicht steht deshalb, was zuletzt **eingestellt** war.
|
||||
///
|
||||
/// Messwerte gehören bewusst nicht dazu. Eine Innentemperatur von vorgestern
|
||||
/// sähe aus wie eine von jetzt, und man würde ihr glauben – eine Solltemperatur
|
||||
/// dagegen ändert sich nur, wenn jemand sie ändert.
|
||||
struct FridgeSettings: Codable, Equatable, Sendable {
|
||||
var isPoweredOn: Bool
|
||||
var isEco: Bool
|
||||
var isLocked: Bool
|
||||
var isDualZone: Bool
|
||||
var usesFahrenheit: Bool
|
||||
var leftTarget: Int?
|
||||
var rightTarget: Int?
|
||||
/// Wann dieser Stand von der Box kam.
|
||||
var updated: Date
|
||||
|
||||
var unitSymbol: String { usesFahrenheit ? "°F" : "°C" }
|
||||
|
||||
/// In Worten, wie es auf der Kachel steht.
|
||||
var stateText: String {
|
||||
guard isPoweredOn else { return "Aus" }
|
||||
return isEco ? "Eco" : "Max"
|
||||
}
|
||||
|
||||
/// Was die Übersicht zeigt: die Sollwerte, sonst nichts.
|
||||
func snapshot(deviceID: UUID) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: updated, rssi: nil)
|
||||
var metrics = [
|
||||
Metric("target_left", isDualZone ? "Soll links" : "Solltemperatur",
|
||||
leftTarget.map(Double.init), unit: unitSymbol, precision: 0, primary: true),
|
||||
]
|
||||
if isDualZone {
|
||||
metrics.append(Metric("target_right", "Soll rechts",
|
||||
rightTarget.map(Double.init), unit: unitSymbol, precision: 0))
|
||||
}
|
||||
snapshot.metrics = metrics
|
||||
snapshot.state = stateText
|
||||
if isLocked { snapshot.offReasons = ["Bedienfeld gesperrt"] }
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Neigungsmesser „VanAlign Pro“ – ein ESP32 mit MPU6050, der Längs- und
|
||||
/// Querneigung des Fahrzeugs über Bluetooth bereitstellt.
|
||||
///
|
||||
/// Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
|
||||
/// Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
|
||||
enum VanAlignProtocol {
|
||||
|
||||
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
|
||||
static let serviceUUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
|
||||
|
||||
static let pitchUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
|
||||
static let rollUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
|
||||
/// Zwei Floats: die gespeicherten Kalibrier-Offsets.
|
||||
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
|
||||
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
|
||||
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
|
||||
|
||||
static let calibrateCommand = Data([0x01])
|
||||
static let resetCommand = Data([0x00])
|
||||
|
||||
/// Liest einen Winkel aus vier Bytes, little-endian.
|
||||
///
|
||||
/// Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
|
||||
/// little-endian – die Reihenfolge steht also fest. Die Web-Oberfläche des
|
||||
/// Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
|
||||
/// die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
|
||||
/// schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
|
||||
/// wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
|
||||
/// unbemerkt.
|
||||
static func angle(from data: Data) -> Double? {
|
||||
guard data.count >= 4 else { return nil }
|
||||
var raw: UInt32 = 0
|
||||
for (index, byte) in data.prefix(4).enumerated() {
|
||||
raw |= UInt32(byte) << UInt32(8 * index)
|
||||
}
|
||||
let value = Float(bitPattern: raw)
|
||||
// NAN meldet die Firmware, solange der Sensor nichts liefert.
|
||||
guard value.isFinite, abs(value) <= 180 else { return nil }
|
||||
return Double(value)
|
||||
}
|
||||
|
||||
/// Die beiden gespeicherten Offsets.
|
||||
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
|
||||
guard data.count >= 8,
|
||||
let pitch = angle(from: data.prefix(4)),
|
||||
let roll = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
|
||||
return (pitch, roll)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zustand des Neigungsmessers.
|
||||
struct LevelState: Equatable {
|
||||
struct LevelState: Equatable, Codable, Sendable {
|
||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
||||
var pitch: Double?
|
||||
@@ -15,7 +15,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
symbol: String = "box.truck",
|
||||
symbol: String = "suv.side",
|
||||
trackWidth: Double? = nil,
|
||||
wheelbase: Double? = nil) {
|
||||
self.id = id
|
||||
@@ -47,7 +47,6 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
|
||||
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft – ein nicht
|
||||
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
|
||||
static let symbols = [
|
||||
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
|
||||
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
|
||||
"car", "car.side", "suv.side", "truck.pickup.side", "box.truck", "bus",
|
||||
]
|
||||
}
|
||||
@@ -23,16 +23,62 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
|
||||
var invertLongitudinal = false
|
||||
var invertLateral = false
|
||||
|
||||
/// Verdrehung des Sensors um die Hochachse, in Grad – der Rest, den der
|
||||
/// Achsentausch nicht abdeckt.
|
||||
///
|
||||
/// Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
|
||||
/// Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
|
||||
/// Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
|
||||
/// helfen dagegen nicht, die springen in 90°-Schritten.
|
||||
///
|
||||
/// Der Wert wird beim Bestimmen der Einbaulage mitgemessen und kostet
|
||||
/// keinen zusätzlichen Handgriff. Beim **Kalibrieren** liesse er sich
|
||||
/// nicht ermitteln: Das misst eine einzige Lage und zieht sie als Nullpunkt
|
||||
/// ab – eine Drehung um die Hochachse ist darin nicht enthalten, denn eben
|
||||
/// sieht in jeder Verdrehung gleich aus.
|
||||
var twist: Double = 0
|
||||
|
||||
static let identity = SensorOrientation()
|
||||
|
||||
init() {}
|
||||
|
||||
/// Von Hand geschrieben, nicht von Swift erzeugt – und das ist der Punkt.
|
||||
///
|
||||
/// Die erzeugte Fassung verlangt beim Lesen jedes Feld; Standardwerte im
|
||||
/// Code zählen dabei nicht. Eine Einbaulage, die vor `twist` gespeichert
|
||||
/// wurde, liesse sich damit nicht mehr lesen, das Gerät dazu ebenso wenig,
|
||||
/// und die Geräteliste bliebe leer. Jedes neue Feld gehört deshalb hier
|
||||
/// hinein, mit `decodeIfPresent` und einem Rückfallwert.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
longitudinalSource = try container.decodeIfPresent(Source.self,
|
||||
forKey: .longitudinalSource) ?? .pitch
|
||||
invertLongitudinal = try container.decodeIfPresent(Bool.self,
|
||||
forKey: .invertLongitudinal) ?? false
|
||||
invertLateral = try container.decodeIfPresent(Bool.self, forKey: .invertLateral) ?? false
|
||||
twist = try container.decodeIfPresent(Double.self, forKey: .twist) ?? 0
|
||||
}
|
||||
|
||||
var isIdentity: Bool { self == .identity }
|
||||
|
||||
/// Rechnet Sensorwerte in Fahrzeugwerte um.
|
||||
///
|
||||
/// Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
|
||||
/// Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
|
||||
/// Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene – genau
|
||||
/// deshalb lässt sich die Verdrehung überhaupt herausrechnen.
|
||||
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
|
||||
let longitudinal = longitudinalSource == .pitch ? pitch : roll
|
||||
let lateral = longitudinalSource == .pitch ? roll : pitch
|
||||
return (longitudinal.map { invertLongitudinal ? -$0 : $0 },
|
||||
lateral.map { invertLateral ? -$0 : $0 })
|
||||
let mappedLongitudinal = longitudinal.map { invertLongitudinal ? -$0 : $0 }
|
||||
let mappedLateral = lateral.map { invertLateral ? -$0 : $0 }
|
||||
|
||||
guard twist != 0, let long = mappedLongitudinal, let lat = mappedLateral else {
|
||||
return (mappedLongitudinal, mappedLateral)
|
||||
}
|
||||
let angle = twist * .pi / 180
|
||||
return (long * cos(angle) + lat * sin(angle),
|
||||
-long * sin(angle) + lat * cos(angle))
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
@@ -41,6 +87,7 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
|
||||
if longitudinalSource == .roll { parts.append("Achsen getauscht") }
|
||||
if invertLongitudinal { parts.append("längs umgekehrt") }
|
||||
if invertLateral { parts.append("quer umgekehrt") }
|
||||
if twist != 0 { parts.append(String(format: "um %.0f° verdreht", twist)) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
@@ -59,6 +106,9 @@ enum OrientationDetection {
|
||||
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
|
||||
static let ambiguityMargin = 1.3
|
||||
|
||||
/// Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand.
|
||||
static let minimumTwist = 2.0
|
||||
|
||||
struct Reading: Equatable {
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
@@ -130,6 +180,24 @@ enum OrientationDetection {
|
||||
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
|
||||
// ist positiv.
|
||||
orientation.invertLateral = lateral < 0
|
||||
|
||||
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
|
||||
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
|
||||
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
|
||||
// schräg – und zwar um genau diesen Winkel.
|
||||
//
|
||||
// Gemessen wird an der Frontbewegung, nicht an der Seitenbewegung: Die
|
||||
// Front lässt sich am Fahrzeug genauer treffen, und beide Schritte
|
||||
// gemeinsam auszuwerten brächte hier nichts, weil eine Verdrehung auf
|
||||
// beide gleich wirkt.
|
||||
let corrected = orientation.apply(pitch: nose.pitch, roll: nose.roll)
|
||||
if let long = corrected.pitch, let lat = corrected.roll {
|
||||
let residual = atan2(lat, long) * 180 / .pi
|
||||
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
|
||||
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
|
||||
// als keine.
|
||||
orientation.twist = abs(residual) >= minimumTwist ? residual : 0
|
||||
}
|
||||
return .success(orientation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt.
|
||||
///
|
||||
/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und
|
||||
/// Heckansicht in Assets.xcassets, beide als Vorlage ("template")
|
||||
/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird
|
||||
/// nur die Seitenansicht gezeigt.
|
||||
enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case vanster
|
||||
case california
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .vanster: return "Vanster"
|
||||
case .california: return "California"
|
||||
}
|
||||
}
|
||||
|
||||
var sideImageName: String {
|
||||
switch self {
|
||||
case .vanster: return "VansterSide"
|
||||
case .california: return "CaliforniaSide"
|
||||
}
|
||||
}
|
||||
|
||||
/// Für California liegt noch keine eigene Heckansicht vor, deshalb
|
||||
/// vorerst die von Vanster.
|
||||
var rearImageName: String {
|
||||
switch self {
|
||||
case .vanster, .california: return "VansterRear"
|
||||
}
|
||||
}
|
||||
|
||||
/// Seitenverhältnis der jeweiligen Bilder – ohne das würde eine Grafik
|
||||
/// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder
|
||||
/// ungewollt verkleinert erscheinen.
|
||||
var sideAspect: Double {
|
||||
switch self {
|
||||
case .vanster: return VehicleTilt.sideAspect
|
||||
case .california: return 1
|
||||
}
|
||||
}
|
||||
|
||||
var rearAspect: Double {
|
||||
switch self {
|
||||
case .vanster, .california: return VehicleTilt.rearAspect
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// Zustand des Solarladereglers.
|
||||
struct VotronicSolarESPState: Equatable, Codable, Sendable {
|
||||
var batteryVoltage: Double?
|
||||
var pvVoltage: Double?
|
||||
var pvCurrent: Double?
|
||||
var pvPower: Double?
|
||||
var controllerTemperature: Double?
|
||||
|
||||
var isBatteryCharging: Bool?
|
||||
var isBatteryDischarging: Bool?
|
||||
var isControllerActive: Bool?
|
||||
var isCurrentLimited: Bool?
|
||||
|
||||
var hasReading: Bool {
|
||||
batteryVoltage != nil || pvVoltage != nil || pvCurrent != nil || pvPower != nil
|
||||
}
|
||||
|
||||
/// Kurzer Klartext, wie bei den übrigen Geräten als "Zustand" angezeigt.
|
||||
var stateText: String? {
|
||||
guard isControllerActive != nil else { return nil }
|
||||
if isBatteryCharging == true { return "Lädt" }
|
||||
if isControllerActive == true { return "Aktiv" }
|
||||
return "Inaktiv"
|
||||
}
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
snapshot.metrics = [
|
||||
Metric("pv_power", "Solarleistung", pvPower, unit: "W", precision: 0, primary: true),
|
||||
Metric("pv_voltage", "PV-Spannung", pvVoltage, unit: "V", precision: 1),
|
||||
Metric("pv_current", "PV-Strom", pvCurrent, unit: "A", precision: 1),
|
||||
Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2),
|
||||
]
|
||||
if let controllerTemperature {
|
||||
snapshot.metrics.append(
|
||||
Metric("controller_temperature", "Reglertemperatur", controllerTemperature,
|
||||
unit: "°C", precision: 0)
|
||||
)
|
||||
}
|
||||
snapshot.state = stateText
|
||||
if isCurrentLimited == true {
|
||||
snapshot.offReasons = ["PV-Strombegrenzung aktiv"]
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -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,19 @@
|
||||
import Foundation
|
||||
|
||||
/// Die Adressen, über die etwas in der Watch-App geöffnet wird.
|
||||
///
|
||||
/// Sie stehen hier, weil beide Seiten sie brauchen und sie sonst zweimal
|
||||
/// dastünden: Die Komplikation hängt sie an ihre Anzeige, die App wertet sie
|
||||
/// beim Öffnen aus. Ein Tippfehler auf einer Seite wäre stumm – der Tipp aufs
|
||||
/// Zifferblatt öffnete dann einfach die Geräteliste.
|
||||
enum WatchDeepLink {
|
||||
private static let scheme = "campermonitor"
|
||||
|
||||
/// Führt direkt auf die Nivellierung.
|
||||
static let level = URL(string: "\(scheme)://nivellierung")!
|
||||
|
||||
/// Ob diese Adresse zur Nivellierung führt.
|
||||
static func isLevel(_ url: URL) -> Bool {
|
||||
url.scheme == scheme && (url.host() == "nivellierung")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
|
||||
/// Was zwischen iPhone und Uhr hin- und hergeht.
|
||||
///
|
||||
/// Die Aufteilung folgt dem, was jedes Gerät hergibt:
|
||||
///
|
||||
/// * **Den Neigungsmesser funkt die Uhr selbst an** (`WatchLevelRadio`). Er
|
||||
/// bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar, und er ist
|
||||
/// unverschlüsselt. Damit steht die Nivellierung am Handgelenk auch dann,
|
||||
/// wenn das iPhone in der Tasche schläft – und das ist der Fall, für den man
|
||||
/// die Uhr überhaupt anschaut.
|
||||
/// * **Alles andere kommt über das iPhone.** BMS und Kühlbox lassen nur eine
|
||||
/// Verbindung zu; eine mitlesende Uhr nähme dem iPhone die Verbindung weg,
|
||||
/// statt sie zu ergänzen. Und die Victron-Schlüssel liegen in der Keychain
|
||||
/// des iPhones – sie auf die Uhr zu kopieren hiesse, sie ein zweites Mal
|
||||
/// aufzubewahren, ohne dass der zweite Ort etwas brächte.
|
||||
///
|
||||
/// Übertragen werden deshalb die Messwerte der übrigen Geräte, dazu Fahrzeug
|
||||
/// und Einbaulage des Sensors: Beides braucht die Uhr, um aus den Rohwinkeln
|
||||
/// des Neigungsmessers dasselbe zu machen wie das iPhone.
|
||||
enum WatchLink {
|
||||
|
||||
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
|
||||
/// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind
|
||||
/// damit die einzige Stelle, an der das Format steht.
|
||||
static let payloadKey = "payload"
|
||||
static let commandKey = "command"
|
||||
|
||||
/// Wie lange ein „ich schaue gerade hin“ der Uhr gilt, ohne erneuert zu
|
||||
/// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den
|
||||
/// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu
|
||||
/// senden.
|
||||
static let liveLease: TimeInterval = 12
|
||||
|
||||
/// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je
|
||||
/// Sekunde abgefragt – schneller zu senden brächte nichts.
|
||||
static let liveInterval: TimeInterval = 0.5
|
||||
|
||||
/// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann
|
||||
/// noch zustellt, wenn die App auf der Uhr gerade nicht läuft.
|
||||
static let idleInterval: TimeInterval = 2
|
||||
}
|
||||
|
||||
/// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt.
|
||||
struct WatchPayload: Codable, Equatable, Sendable {
|
||||
/// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt
|
||||
/// sonst Werte, die sie falsch versteht – lieber sagt sie „App aktualisieren“.
|
||||
static let currentVersion = 1
|
||||
|
||||
var version = WatchPayload.currentVersion
|
||||
var generatedAt: Date
|
||||
/// Das am iPhone gewählte Fahrzeug.
|
||||
var profile: Profile
|
||||
/// Ob das iPhone gerade überhaupt funken kann.
|
||||
var isRadioReady: Bool
|
||||
/// Klartext dazu, falls nicht.
|
||||
var radioStatus: String
|
||||
var devices: [WatchDevice]
|
||||
|
||||
/// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein
|
||||
/// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen
|
||||
/// Platz – sonst rutschte er beim Verbindungsabriss in die Geräteliste.
|
||||
var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } }
|
||||
var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } }
|
||||
|
||||
/// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei
|
||||
/// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund
|
||||
/// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht.
|
||||
func hasSameContent(as other: WatchPayload) -> Bool {
|
||||
var mine = self, theirs = other
|
||||
mine.generatedAt = .distantPast
|
||||
theirs.generatedAt = .distantPast
|
||||
return mine == theirs
|
||||
}
|
||||
|
||||
/// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt:
|
||||
/// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum
|
||||
/// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug.
|
||||
func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) }
|
||||
}
|
||||
|
||||
/// Ein Gerät mitsamt seinem letzten Messwertsatz.
|
||||
struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var role: DeviceRole
|
||||
var link: DeviceLinkState
|
||||
var snapshot: DeviceSnapshot?
|
||||
/// Nur beim Neigungsmesser belegt.
|
||||
var level: LevelState?
|
||||
/// Ebenfalls nur dort: wie der Sensor im Fahrzeug sitzt. Die Uhr funkt
|
||||
/// den Neigungsmesser selbst an und braucht die Zuordnung deshalb auch –
|
||||
/// eingestellt wird sie weiterhin nur am iPhone.
|
||||
var orientation: SensorOrientation?
|
||||
/// Nur bei der Kühlbox belegt.
|
||||
var fridge: WatchFridge?
|
||||
|
||||
var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) }
|
||||
}
|
||||
|
||||
/// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht.
|
||||
///
|
||||
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
|
||||
/// drin, den auf die Uhr zu schicken nichts brächte.
|
||||
struct WatchFridge: Codable, Equatable, Sendable {
|
||||
/// Ob die Box gerade wirklich verbunden ist.
|
||||
///
|
||||
/// Sie wird nur verbunden, während jemand ihre Ansicht offen hat – jede
|
||||
/// Verbindung meldet sich an ihrem Display an. Sonst stehen hier die
|
||||
/// zuletzt gestellten Werte, und die Messwerte fehlen.
|
||||
var isLive: Bool
|
||||
/// Wann der Stand zuletzt von der Box kam.
|
||||
var updated: Date
|
||||
var isPoweredOn: Bool
|
||||
var isEco: Bool
|
||||
var isLocked: Bool
|
||||
var isDualZone: Bool
|
||||
var unitSymbol: String
|
||||
var leftTarget: Int?
|
||||
var leftCurrent: Int?
|
||||
var rightTarget: Int?
|
||||
var rightCurrent: Int?
|
||||
var minTarget: Int
|
||||
var maxTarget: Int
|
||||
var batteryVolts: Double?
|
||||
|
||||
var targetRange: ClosedRange<Int> {
|
||||
minTarget < maxTarget ? minTarget...maxTarget : -30...20
|
||||
}
|
||||
}
|
||||
|
||||
/// Was die Uhr das iPhone tun lässt.
|
||||
///
|
||||
/// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt
|
||||
/// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus
|
||||
/// dem nächsten Datensatz – bestätigt wird also das Gerät, nicht der Tastendruck.
|
||||
enum WatchCommand: Codable, Equatable, Sendable {
|
||||
/// Die Uhr schaut gerade hin und möchte den schnellen Takt.
|
||||
case hello(live: Bool)
|
||||
case fridgePower(device: UUID, on: Bool)
|
||||
case fridgeEco(device: UUID, eco: Bool)
|
||||
case fridgeLock(device: UUID, locked: Bool)
|
||||
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
|
||||
/// Die Kühlbox-Ansicht auf der Uhr wurde geöffnet oder geschlossen. Das
|
||||
/// iPhone verbindet die Box daraufhin – oder gibt sie wieder frei.
|
||||
case fridgeSession(device: UUID, wanted: Bool)
|
||||
|
||||
/// Ob der Befehl den schnellen Sendetakt verlängert.
|
||||
var wantsLiveUpdates: Bool {
|
||||
if case .hello(let live) = self { return live }
|
||||
// Wer stellt, schaut auch hin.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen.
|
||||
enum WatchFridgeZone: String, Codable, Equatable, Sendable {
|
||||
case left, right
|
||||
}
|
||||
|
||||
// MARK: - Verpacken
|
||||
|
||||
extension WatchFridge {
|
||||
/// Aus dem zuletzt gestellten Stand, ohne Messwerte.
|
||||
init(_ settings: FridgeSettings) {
|
||||
self.init(isLive: false,
|
||||
updated: settings.updated,
|
||||
isPoweredOn: settings.isPoweredOn,
|
||||
isEco: settings.isEco,
|
||||
isLocked: settings.isLocked,
|
||||
isDualZone: settings.isDualZone,
|
||||
unitSymbol: settings.unitSymbol,
|
||||
leftTarget: settings.leftTarget,
|
||||
leftCurrent: nil,
|
||||
rightTarget: settings.rightTarget,
|
||||
rightCurrent: nil,
|
||||
minTarget: settings.usesFahrenheit ? -22 : -30,
|
||||
maxTarget: settings.usesFahrenheit ? 68 : 20,
|
||||
batteryVolts: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchPayload {
|
||||
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
|
||||
|
||||
/// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue
|
||||
/// Datensätze ergeben nil statt eines halb gefüllten Zustands.
|
||||
static func decode(from message: [String: Any]) -> WatchPayload? {
|
||||
guard let data = message[WatchLink.payloadKey] as? Data,
|
||||
let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data),
|
||||
payload.version == WatchPayload.currentVersion else { return nil }
|
||||
return payload
|
||||
}
|
||||
|
||||
func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] }
|
||||
}
|
||||
|
||||
extension WatchCommand {
|
||||
func message() throws -> [String: Any] {
|
||||
[WatchLink.commandKey: try WatchCoder.encoder.encode(self)]
|
||||
}
|
||||
|
||||
static func decode(from message: [String: Any]) -> WatchCommand? {
|
||||
guard let data = message[WatchLink.commandKey] as? Data else { return nil }
|
||||
return try? WatchCoder.decoder.decode(WatchCommand.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der
|
||||
/// einen Seite anders geschrieben als auf der anderen gelesen.
|
||||
enum WatchCoder {
|
||||
static let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
return encoder
|
||||
}()
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
@@ -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: ", ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
/// Inhalt der Neigungsmesser-Live-Activity.
|
||||
///
|
||||
/// Die App und die Widget-Extension kompilieren diese Datei je für sich in
|
||||
/// ihr eigenes Modul – sie darf deshalb nicht von `Shared/` abhängen, sonst
|
||||
/// müsste die Extension auch Bluetooth- und WatchConnectivity-Code mitbauen.
|
||||
/// Werte wie `isLevel` und `instruction` kommen darum schon fertig berechnet
|
||||
/// aus `LevelState` an, die Extension zeigt nur an.
|
||||
struct LevelActivityAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
var pitch: Double?
|
||||
var roll: Double?
|
||||
var isLevel: Bool
|
||||
var instruction: String?
|
||||
var isCalibrated: Bool
|
||||
var updatedAt: Date
|
||||
}
|
||||
|
||||
/// Name des Neigungsmessers, wie in der App vergeben.
|
||||
var deviceName: String
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// Formatiert Neigungswerte mit vorangestelltem Richtungsbuchstaben statt
|
||||
/// Vorzeichen – "H 1.8°" statt "1.8°", "L 0.9°" statt "-0.9°".
|
||||
///
|
||||
/// Gemeinsam für App und Live-Activity-Widget, deshalb hier statt in
|
||||
/// `Shared/`, das die Extension nicht mitkompiliert (siehe
|
||||
/// `LevelActivityAttributes.swift`).
|
||||
enum LevelDirectionFormatting {
|
||||
/// "1.8°" – Betrag ohne Vorzeichen, für Stellen, die die Richtung schon
|
||||
/// im Wort oder Buchstaben ausdrücken.
|
||||
static func magnitude(_ value: Double?) -> String {
|
||||
guard let value else { return "–" }
|
||||
return String(format: "%.1f°", abs(value))
|
||||
}
|
||||
|
||||
/// Längsneigung als Kachel: positiv (Heck höher) → "H", negativ (Front
|
||||
/// höher) → "F". Muss zum Vorzeichen von `LevelState.pitch` passen.
|
||||
static func pitchTile(_ value: Double?) -> String {
|
||||
guard let value else { return "–" }
|
||||
return "\(value >= 0 ? "H" : "F") \(magnitude(value))"
|
||||
}
|
||||
|
||||
/// Querneigung als Kachel: positiv (rechts höher) → "R", negativ (links
|
||||
/// höher) → "L". Muss zum Vorzeichen von `LevelState.roll` passen.
|
||||
static func rollTile(_ value: Double?) -> String {
|
||||
guard let value else { return "–" }
|
||||
return "\(value >= 0 ? "R" : "L") \(magnitude(value))"
|
||||
}
|
||||
}
|
||||
@@ -76,22 +76,22 @@ var advertisement: [UInt8] = [0xE1, 0x02, 0x10, 0x00, 0x4C, 0xA0, 0x01,
|
||||
UInt8(nonce & 0xFF), UInt8(nonce >> 8), deviceKey[0]]
|
||||
advertisement += encrypted
|
||||
|
||||
let solar = try? VictronAdvertisement.decode(manufacturerData: Data(advertisement),
|
||||
let victronSolar = try? VictronAdvertisement.decode(manufacturerData: Data(advertisement),
|
||||
key: deviceKey,
|
||||
deviceID: UUID(),
|
||||
rssi: -55)
|
||||
func value(_ snapshot: DeviceSnapshot?, _ key: String) -> Double? {
|
||||
snapshot?.metrics.first { $0.key == key }?.value
|
||||
}
|
||||
check("Advertisement wird dekodiert", solar != nil)
|
||||
checkEqual("Zustand als Klartext", solar?.state, "Konstantstrom (Bulk)")
|
||||
checkEqual("kein Fehler gemeldet", solar?.fault, nil)
|
||||
checkEqual("Batteriespannung", value(solar, "battery_voltage").map(round2), 13.45)
|
||||
checkEqual("Ladestrom", value(solar, "battery_current").map { ($0 * 10).rounded() / 10 }, 15.2)
|
||||
checkEqual("Tagesertrag", value(solar, "yield_today"), 2.34)
|
||||
checkEqual("PV-Leistung", value(solar, "pv_power"), 210)
|
||||
checkEqual("Laststrom bleibt leer (NA)", value(solar, "load_current"), nil)
|
||||
checkEqual("PV-Leistung ist der Hauptwert", solar?.primaryMetric?.key, "pv_power")
|
||||
check("Advertisement wird dekodiert", victronSolar != nil)
|
||||
checkEqual("Zustand als Klartext", victronSolar?.state, "Konstantstrom (Bulk)")
|
||||
checkEqual("kein Fehler gemeldet", victronSolar?.fault, nil)
|
||||
checkEqual("Batteriespannung", value(victronSolar, "battery_voltage").map(round2), 13.45)
|
||||
checkEqual("Ladestrom", value(victronSolar, "battery_current").map { ($0 * 10).rounded() / 10 }, 15.2)
|
||||
checkEqual("Tagesertrag", value(victronSolar, "yield_today"), 2.34)
|
||||
checkEqual("PV-Leistung", value(victronSolar, "pv_power"), 210)
|
||||
checkEqual("Laststrom bleibt leer (NA)", value(victronSolar, "load_current"), nil)
|
||||
checkEqual("PV-Leistung ist der Hauptwert", victronSolar?.primaryMetric?.key, "pv_power")
|
||||
|
||||
var wrongKey = deviceKey; wrongKey[0] = 0x00
|
||||
do {
|
||||
@@ -698,6 +698,44 @@ checkEqual("falsche Prüfsumme wird verworfen",
|
||||
checkEqual("angefangenes Paket wird aufgehoben",
|
||||
AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0)
|
||||
|
||||
// MARK: 9b – Kühlbox: der Stand, der die Verbindung überdauert
|
||||
|
||||
print("\nKühlbox – zuletzt gestellter Stand")
|
||||
|
||||
// Derselbe Datensatz wie oben, nur diesmal auf das eingedampft, was ohne
|
||||
// Verbindung noch gilt.
|
||||
var boxState = AlpicoolState()
|
||||
boxState.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [
|
||||
0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07,
|
||||
]))
|
||||
|
||||
guard let boxSettings = boxState.settings else {
|
||||
check("Stand lässt sich aus dem Datensatz ableiten", false)
|
||||
exit(1)
|
||||
}
|
||||
checkEqual("eingeschaltet", boxSettings.isPoweredOn, true)
|
||||
checkEqual("Eco", boxSettings.isEco, true)
|
||||
checkEqual("Solltemperatur", boxSettings.leftTarget, 4)
|
||||
checkEqual("Einheit", boxSettings.unitSymbol, "°C")
|
||||
checkEqual("in Worten", boxSettings.stateText, "Eco")
|
||||
|
||||
// Die Übersicht zeigt den Sollwert – und ausdrücklich keine Innentemperatur.
|
||||
let boxSnapshot = boxSettings.snapshot(deviceID: UUID())
|
||||
checkEqual("Hauptwert ist der Sollwert", boxSnapshot.primaryMetric?.key, "target_left")
|
||||
checkEqual("Sollwert steht drin", value(boxSnapshot, "target_left"), 4)
|
||||
checkEqual("keine gemessene Temperatur", boxSnapshot.metrics.contains { $0.key == "temp_left" }, false)
|
||||
checkEqual("keine Bordspannung", boxSnapshot.metrics.contains { $0.key == "supply_voltage" }, false)
|
||||
|
||||
// Ohne Antwort der Box gibt es auch nichts aufzuheben.
|
||||
checkEqual("leerer Zustand ergibt keinen Stand", AlpicoolState().settings == nil, true)
|
||||
|
||||
// Auf der Uhr dasselbe Bild: Sollwerte ja, Messwerte nein.
|
||||
let watchBox = WatchFridge(boxSettings)
|
||||
checkEqual("auf der Uhr als nicht live gekennzeichnet", watchBox.isLive, false)
|
||||
checkEqual("Sollwert kommt mit", watchBox.leftTarget, 4)
|
||||
checkEqual("Messwert bleibt leer", watchBox.leftCurrent == nil, true)
|
||||
|
||||
// MARK: 10 – VanAlign Neigungsmesser
|
||||
print("\nVanAlign-Neigungsmesser")
|
||||
|
||||
@@ -826,28 +864,53 @@ ageing.add(pitch: 1, roll: 0, at: start.addingTimeInterval(AlignmentAssistant.me
|
||||
checkEqual("alte Messwerte fallen heraus", ageing.samples.count, 1)
|
||||
|
||||
// MARK: Auffahrkeile
|
||||
print("\nAuffahrkeile")
|
||||
|
||||
// tan(2°) x 2,00 m = 6,99 cm
|
||||
let acrossWedge = LevelingWedge.across(roll: 2.0, trackWidth: 2.0)
|
||||
checkEqual("Keilhöhe quer",
|
||||
acrossWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 7.0)
|
||||
checkEqual("rechts höher heisst Keil nach links", acrossWedge?.side, .left)
|
||||
checkEqual("links höher heisst Keil nach rechts",
|
||||
LevelingWedge.across(roll: -2.0, trackWidth: 2.0)?.side, .right)
|
||||
print("\nAuffahrkeile – Höhe je Rad")
|
||||
|
||||
// tan(1,5°) x 3,50 m = 9,16 cm
|
||||
let alongWedge = LevelingWedge.along(pitch: 1.5, wheelbase: 3.5)
|
||||
checkEqual("Keilhöhe längs",
|
||||
alongWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 9.2)
|
||||
checkEqual("Heck höher heisst Keil nach vorne", alongWedge?.side, .front)
|
||||
checkEqual("Front höher heisst Keil nach hinten",
|
||||
LevelingWedge.along(pitch: -1.5, wheelbase: 3.5)?.side, .rear)
|
||||
// Reine Querneigung: tan(2°) x 2,00 m = 6,99 cm unter beide Räder der
|
||||
// tieferstehenden Seite. Rechts steht höher, also muss links hoch.
|
||||
let acrossOnly = LevelingLift.compute(pitch: 0, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5)
|
||||
checkEqual("quer: vorne links",
|
||||
acrossOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 7.0)
|
||||
checkEqual("quer: hinten links",
|
||||
acrossOnly.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0)
|
||||
checkEqual("quer: rechts bleibt liegen",
|
||||
acrossOnly.map { $0.centimetres(.frontRight) + $0.centimetres(.rearRight) }, 0)
|
||||
|
||||
checkEqual("innerhalb der Toleranz kein Keil",
|
||||
LevelingWedge.across(roll: 0.3, trackWidth: 2.0) == nil, true)
|
||||
checkEqual("ohne Maß kein Keil",
|
||||
LevelingWedge.across(roll: 3.0, trackWidth: 0) == nil, true)
|
||||
// Reine Längsneigung: tan(1,5°) x 3,50 m = 9,16 cm unter beide Vorderräder,
|
||||
// wenn das Heck höher steht.
|
||||
let alongOnly = LevelingLift.compute(pitch: 1.5, roll: 0, trackWidth: 2.0, wheelbase: 3.5)
|
||||
checkEqual("längs: vorne links",
|
||||
alongOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 9.2)
|
||||
checkEqual("längs: vorne rechts",
|
||||
alongOnly.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2)
|
||||
checkEqual("längs: hinten bleibt liegen",
|
||||
alongOnly.map { $0.centimetres(.rearLeft) + $0.centimetres(.rearRight) }, 0)
|
||||
|
||||
// Beides zusammen: Das tiefste Rad braucht die Summe, das höchste nichts, und
|
||||
// die beiden dazwischen je einen Anteil. Genau das lässt sich mit getrennten
|
||||
// Angaben für "rechts" und "vorne" nicht ausdrücken.
|
||||
let liftBoth = LevelingLift.compute(pitch: 1.5, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5)
|
||||
checkEqual("kombiniert: tiefstes Rad ist vorne links", liftBoth?.deepest, .frontLeft)
|
||||
checkEqual("kombiniert: vorne links trägt beide Anteile",
|
||||
liftBoth.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 16.1)
|
||||
checkEqual("kombiniert: vorne rechts nur den Längsanteil",
|
||||
liftBoth.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2)
|
||||
checkEqual("kombiniert: hinten links nur den Queranteil",
|
||||
liftBoth.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0)
|
||||
checkEqual("kombiniert: hinten rechts steht am höchsten und bleibt liegen",
|
||||
liftBoth.map { $0.centimetres(.rearRight) }, 0)
|
||||
|
||||
// Vorzeichen: andersherum geneigt, andere Ecke.
|
||||
let mirrored = LevelingLift.compute(pitch: -1.5, roll: -2.0, trackWidth: 2.0, wheelbase: 3.5)
|
||||
checkEqual("umgekehrt geneigt: tiefstes Rad ist hinten rechts",
|
||||
mirrored?.deepest, .rearRight)
|
||||
|
||||
checkEqual("fast eben: kein Keil nötig",
|
||||
LevelingLift.compute(pitch: 0.1, roll: 0.1,
|
||||
trackWidth: 2.0, wheelbase: 3.5)?.isNegligible, true)
|
||||
checkEqual("ohne Fahrzeugmasse keine Rechnung",
|
||||
LevelingLift.compute(pitch: 2, roll: 2, trackWidth: 0, wheelbase: 3.5) == nil, true)
|
||||
|
||||
// MARK: 12 – Einbaulage des Neigungsmessers
|
||||
print("\nEinbaulage")
|
||||
@@ -945,9 +1008,285 @@ let untouched = SensorOrientation.identity.apply(pitch: 1.5, roll: -0.5)
|
||||
checkEqual("unveränderte Lage lässt längs stehen", untouched.pitch, 1.5)
|
||||
checkEqual("unveränderte Lage lässt quer stehen", untouched.roll, -0.5)
|
||||
|
||||
// Die Einbaulage liegt im Gerät, damit iPhone, Uhr und Android dieselbe sehen.
|
||||
// Acht Byte, hin und zurück.
|
||||
print("\nEinbaulage im Gerät")
|
||||
|
||||
var stored = SensorOrientation()
|
||||
stored.longitudinalSource = .roll
|
||||
stored.invertLongitudinal = true
|
||||
stored.invertLateral = false
|
||||
stored.twist = -17.5
|
||||
|
||||
let storedBytes = VanAlignProtocol.encoded(stored)
|
||||
checkEqual("acht Byte lang", storedBytes.count, 8)
|
||||
checkEqual("Version steht vorne", storedBytes.first, 1)
|
||||
checkEqual("gelesen kommt dasselbe zurück", VanAlignProtocol.orientation(from: storedBytes), stored)
|
||||
|
||||
// Ein Gerät, in dem noch nie etwas stand, meldet Version 0. Das ist kein
|
||||
// Fehler, sondern heisst "hier gilt, was die App hat" – und die schreibt sie
|
||||
// dann hinauf.
|
||||
checkEqual("Version 0 gilt als leer",
|
||||
VanAlignProtocol.orientation(from: Data(repeating: 0, count: 8)) == nil, true)
|
||||
checkEqual("zu kurze Antwort ergibt nichts",
|
||||
VanAlignProtocol.orientation(from: Data([1, 0, 0, 0])) == nil, true)
|
||||
|
||||
// Die Feldbelegung byteweise festgenagelt: daran hängen drei Apps und die
|
||||
// Firmware, das darf nicht unbemerkt verrutschen.
|
||||
var plain = SensorOrientation()
|
||||
plain.twist = 1
|
||||
checkEqual("Feldbelegung", [UInt8](VanAlignProtocol.encoded(plain)),
|
||||
[1, 0, 0, 0, 0x00, 0x00, 0x80, 0x3F]) // 1.0f little endian
|
||||
|
||||
var mounted = SensorOrientation()
|
||||
mounted.longitudinalSource = .roll
|
||||
mounted.invertLateral = true
|
||||
checkEqual("Achsentausch und Vorzeichen an ihrem Platz",
|
||||
[UInt8](VanAlignProtocol.encoded(mounted)).prefix(4).map { $0 }, [1, 1, 0, 1])
|
||||
|
||||
// Gespeicherte Einrichtungen müssen ältere Fassungen überleben. Ein neues Feld
|
||||
// darf das Lesen nicht scheitern lassen – sonst ist die Geräteliste leer, und
|
||||
// das nächste Speichern schreibt diese Leere über den Bestand.
|
||||
print("\nGespeicherte Geräte lesen")
|
||||
|
||||
func decodeDevice(_ json: String) -> ConfiguredDevice? {
|
||||
try? JSONDecoder().decode(ConfiguredDevice.self, from: Data(json.utf8))
|
||||
}
|
||||
|
||||
let deviceBeforeTwist = """
|
||||
{"role":"leveling","profileID":"00000000-0000-0000-0000-00000000C001",
|
||||
"name":"Nivellierung","id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B5",
|
||||
"advertisedName":"vanalign","fridgeZoneMode":"automatic",
|
||||
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DA",
|
||||
"sensorOrientation":{"invertLongitudinal":false,"invertLateral":true,
|
||||
"longitudinalSource":"roll"}}
|
||||
"""
|
||||
let restoredDevice = decodeDevice(deviceBeforeTwist)
|
||||
checkEqual("Gerät ohne twist lässt sich weiterhin lesen", restoredDevice?.name, "Nivellierung")
|
||||
checkEqual("die bekannten Felder der Einbaulage bleiben erhalten",
|
||||
restoredDevice?.sensorOrientation.longitudinalSource, .roll)
|
||||
checkEqual("das neue Feld fällt auf null zurück", restoredDevice?.sensorOrientation.twist, 0)
|
||||
|
||||
let deviceWithoutOrientation = """
|
||||
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Bulltron",
|
||||
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B6",
|
||||
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DB"}
|
||||
"""
|
||||
checkEqual("Gerät ganz ohne Einbaulage ebenso",
|
||||
decodeDevice(deviceWithoutOrientation)?.sensorOrientation, .identity)
|
||||
|
||||
checkEqual("eine unbrauchbare Einbaulage bleibt ein Fehler",
|
||||
decodeDevice("""
|
||||
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Kaputt",
|
||||
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B7",
|
||||
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DC",
|
||||
"sensorOrientation":{"longitudinalSource":"quer"}}
|
||||
""") == nil, true)
|
||||
|
||||
// Schräg eingebauter Sensor: um 20 Grad um die Hochachse verdreht. Eine reine
|
||||
// Kippbewegung nach vorne verteilt sich dann auf beide Sensorachsen.
|
||||
let twistAngle = 20.0
|
||||
func twisted(_ longitudinal: Double, _ lateral: Double) -> OrientationDetection.Reading {
|
||||
// Umgekehrter Weg zu SensorOrientation.apply: aus Fahrzeugwerten wird das,
|
||||
// was ein verdrehter Sensor melden würde.
|
||||
let a = twistAngle * .pi / 180
|
||||
return OrientationDetection.Reading(pitch: longitudinal * cos(a) - lateral * sin(a),
|
||||
roll: longitudinal * sin(a) + lateral * cos(a))
|
||||
}
|
||||
|
||||
let noseTwisted = twisted(10, 0) // Front nach unten, sauber um die Querachse
|
||||
let sideTwisted = twisted(0, 10) // linke Seite nach unten
|
||||
|
||||
if case .success(let skew) = detect(nose: noseTwisted, side: sideTwisted) {
|
||||
checkEqual("Verdrehung wird erkannt", (skew.twist * 10).rounded() / 10, 20.0)
|
||||
checkEqual("Achsen bleiben dabei zugeordnet", skew.longitudinalSource, .pitch)
|
||||
|
||||
// Die Probe: zurückgerechnet muss aus der Frontbewegung reine Längsneigung
|
||||
// werden und aus der Seitenbewegung reine Querneigung.
|
||||
let front = skew.apply(pitch: noseTwisted.pitch, roll: noseTwisted.roll)
|
||||
checkEqual("verdreht gemessene Frontbewegung wird reine Längsneigung",
|
||||
front.pitch.map { ($0 * 10).rounded() / 10 }, 10.0)
|
||||
checkEqual("und hat keine Querneigung mehr",
|
||||
front.roll.map { abs($0) < 0.001 }, true)
|
||||
|
||||
let side = skew.apply(pitch: sideTwisted.pitch, roll: sideTwisted.roll)
|
||||
checkEqual("verdreht gemessene Seitenbewegung wird reine Querneigung",
|
||||
side.roll.map { ($0 * 10).rounded() / 10 }, 10.0)
|
||||
checkEqual("und hat keine Längsneigung mehr",
|
||||
side.pitch.map { abs($0) < 0.001 }, true)
|
||||
} else {
|
||||
check("verdrehter Einbau wird ausgewertet", false)
|
||||
}
|
||||
|
||||
// Genau das, was vorher fehlte: ohne Korrektur schlägt eine reine Querneigung
|
||||
// auf die Längsanzeige durch – bei 20 Grad Verdrehung mit gut einem Drittel.
|
||||
let uncorrected = SensorOrientation.identity.apply(pitch: sideTwisted.pitch,
|
||||
roll: sideTwisted.roll)
|
||||
checkEqual("ohne Korrektur kippt die Längsanzeige mit",
|
||||
uncorrected.pitch.map { ($0 * 10).rounded() / 10 }, -3.4)
|
||||
|
||||
// Ein bisschen Wackeln ist keine Verdrehung.
|
||||
if case .success(let steady) = detect(nose: Reading(pitch: 10, roll: 0.2),
|
||||
side: Reading(pitch: 0.2, roll: 10)) {
|
||||
checkEqual("kleiner Rest gilt als Rauschen, nicht als Verdrehung", steady.twist, 0)
|
||||
} else {
|
||||
check("saubere Kippbewegung wird ausgewertet", false)
|
||||
}
|
||||
|
||||
let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2)
|
||||
checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true)
|
||||
checkEqual("vorhandene Werte kommen durch", missing.roll, 2)
|
||||
|
||||
// MARK: 13 – Strecke zur Apple Watch
|
||||
|
||||
print("\nApple Watch – Datensatz und Befehle")
|
||||
|
||||
let watchProfile = Profile(name: "Kastenwagen", symbol: "box.truck",
|
||||
trackWidth: 1.85, wheelbase: 3.50)
|
||||
let watchLevelID = UUID()
|
||||
let watchFridgeID = UUID()
|
||||
let watchBoosterID = UUID()
|
||||
|
||||
var watchLevel = LevelState()
|
||||
watchLevel.pitch = 1.8
|
||||
watchLevel.roll = -0.9
|
||||
watchLevel.pitchOffset = 0.4
|
||||
watchLevel.rollOffset = -0.2
|
||||
|
||||
// Feste Zeitstempel, damit der Vergleich unten wirklich den Inhalt prüft: über
|
||||
// die Strecke bleibt die Millisekunde stehen, ein `Date()` wäre feiner.
|
||||
let watchStamp = Date(timeIntervalSince1970: 1_699_999_999)
|
||||
|
||||
var watchLevelSnapshot = watchLevel.snapshot(deviceID: watchLevelID, rssi: -70)
|
||||
watchLevelSnapshot.timestamp = watchStamp
|
||||
|
||||
var boosterSnapshot = DeviceSnapshot(deviceID: watchBoosterID, timestamp: watchStamp, rssi: -71)
|
||||
boosterSnapshot.state = "Aus"
|
||||
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
|
||||
boosterSnapshot.metrics = [
|
||||
Metric("output_voltage", "Ausgang", 14.09, unit: "V", precision: 2, primary: true),
|
||||
Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2),
|
||||
]
|
||||
|
||||
// Quer eingebauter Sensor: Die Uhr rechnet damit selbst, also muss die
|
||||
// Zuordnung mit über die Strecke.
|
||||
var watchOrientation = SensorOrientation()
|
||||
watchOrientation.longitudinalSource = .roll
|
||||
watchOrientation.invertLateral = true
|
||||
|
||||
let watchFridge = WatchFridge(isLive: true,
|
||||
updated: Date(timeIntervalSince1970: 1_699_999_000),
|
||||
isPoweredOn: true, isEco: true, isLocked: false,
|
||||
isDualZone: false, unitSymbol: "°C",
|
||||
leftTarget: 4, leftCurrent: 6,
|
||||
rightTarget: nil, rightCurrent: nil,
|
||||
minTarget: -20, maxTarget: 20, batteryVolts: 12.7)
|
||||
|
||||
let watchPayload = WatchPayload(
|
||||
generatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
profile: watchProfile,
|
||||
isRadioReady: true,
|
||||
radioStatus: "Bereit",
|
||||
devices: [
|
||||
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
|
||||
link: .live, snapshot: watchLevelSnapshot,
|
||||
level: watchLevel, orientation: watchOrientation, fridge: nil),
|
||||
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
|
||||
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
|
||||
level: nil, orientation: nil, fridge: nil),
|
||||
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
|
||||
link: .live, snapshot: nil, level: nil,
|
||||
orientation: nil, fridge: watchFridge),
|
||||
])
|
||||
|
||||
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
|
||||
if let message = try? watchPayload.message(),
|
||||
let restored = WatchPayload.decode(from: message) {
|
||||
checkEqual("Datensatz kommt unverändert an", restored, watchPayload)
|
||||
checkEqual("Zeitstempel überlebt das Verpacken",
|
||||
restored.generatedAt.timeIntervalSince1970, 1_700_000_000)
|
||||
checkEqual("Fehlerklartext bleibt am Gerät",
|
||||
restored.devices[1].link, .failed("Nicht gefunden"))
|
||||
checkEqual("Neigung wird gefunden", restored.levelDevice?.id, watchLevelID)
|
||||
checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID)
|
||||
checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4)
|
||||
checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85)
|
||||
checkEqual("Einbaulage kommt mit – die Uhr misst selbst",
|
||||
restored.levelDevice?.orientation?.longitudinalSource, .roll)
|
||||
checkEqual("Umkehrung der Querachse ebenfalls",
|
||||
restored.levelDevice?.orientation?.invertLateral, true)
|
||||
} else {
|
||||
check("Datensatz lässt sich verpacken und wieder lesen", false)
|
||||
}
|
||||
|
||||
// Eine Uhr mit älterer App darf einen neueren Datensatz nicht halb verstehen.
|
||||
var futurePayload = watchPayload
|
||||
futurePayload.version = WatchPayload.currentVersion + 1
|
||||
if let message = try? futurePayload.message() {
|
||||
checkEqual("fremde Formatversion wird abgelehnt",
|
||||
WatchPayload.decode(from: message) == nil, true)
|
||||
} else {
|
||||
check("fremde Formatversion lässt sich verpacken", false)
|
||||
}
|
||||
|
||||
// Zeitpunkte reisen als Millisekunden seit 1970 in einer Fliesskommazahl. Auf
|
||||
// die Millisekunde genau kommen sie zurück, aufs letzte Bit nicht – deshalb
|
||||
// vergleicht der Test oben feste Zeitstempel und nicht ein `Date()`.
|
||||
var preciseSnapshot = boosterSnapshot
|
||||
preciseSnapshot.timestamp = Date(timeIntervalSince1970: 1_699_999_999.123_456)
|
||||
var precisePayload = watchPayload
|
||||
precisePayload.devices[1].snapshot = preciseSnapshot
|
||||
if let message = try? precisePayload.message(),
|
||||
let restored = WatchPayload.decode(from: message)?.devices[1].snapshot?.timestamp {
|
||||
checkEqual("feiner Zeitstempel kommt auf die Millisekunde genau zurück",
|
||||
abs(restored.timeIntervalSince1970 - 1_699_999_999.123_456) < 0.001, true)
|
||||
} else {
|
||||
check("Datensatz mit feinem Zeitstempel lässt sich verpacken", false)
|
||||
}
|
||||
|
||||
checkEqual("leeres Wörterbuch ergibt keinen Datensatz",
|
||||
WatchPayload.decode(from: [:]) == nil, true)
|
||||
checkEqual("Unsinn im Nutzdatenfeld ergibt keinen Datensatz",
|
||||
WatchPayload.decode(from: [WatchLink.payloadKey: Data([0x00, 0x01])]) == nil, true)
|
||||
|
||||
// Der Vergleich entscheidet, ob überhaupt gefunkt wird: der Zeitstempel allein
|
||||
// darf keinen Funkverkehr auslösen, ein geänderter Messwert schon.
|
||||
var laterPayload = watchPayload
|
||||
laterPayload.generatedAt = watchPayload.generatedAt.addingTimeInterval(30)
|
||||
checkEqual("gleicher Inhalt, neuer Zeitstempel gilt als unverändert",
|
||||
watchPayload.hasSameContent(as: laterPayload), true)
|
||||
|
||||
var changedPayload = laterPayload
|
||||
changedPayload.devices[2].fridge?.leftTarget = 5
|
||||
checkEqual("geänderter Sollwert gilt als Änderung",
|
||||
watchPayload.hasSameContent(as: changedPayload), false)
|
||||
|
||||
// Befehle in die Gegenrichtung.
|
||||
let commands: [WatchCommand] = [
|
||||
.hello(live: true),
|
||||
.hello(live: false),
|
||||
.fridgePower(device: watchFridgeID, on: false),
|
||||
.fridgeEco(device: watchFridgeID, eco: true),
|
||||
.fridgeLock(device: watchFridgeID, locked: true),
|
||||
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
|
||||
.fridgeSession(device: watchFridgeID, wanted: true),
|
||||
.fridgeSession(device: watchFridgeID, wanted: false),
|
||||
]
|
||||
for command in commands {
|
||||
guard let message = try? command.message(),
|
||||
let restored = WatchCommand.decode(from: message) else {
|
||||
check("Befehl \(command) lässt sich verpacken", false)
|
||||
continue
|
||||
}
|
||||
checkEqual("Befehl kommt unverändert an", restored, command)
|
||||
}
|
||||
|
||||
checkEqual("Anmeldung ohne Hinschauen verlängert den schnellen Takt nicht",
|
||||
WatchCommand.hello(live: false).wantsLiveUpdates, false)
|
||||
checkEqual("wer stellt, schaut auch hin",
|
||||
WatchCommand.fridgePower(device: watchFridgeID, on: true).wantsLiveUpdates, true)
|
||||
checkEqual("ein Datensatz ist kein Befehl",
|
||||
WatchCommand.decode(from: (try? watchPayload.message()) ?? [:]) == nil, true)
|
||||
|
||||
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||||
exit(failures == 0 ? 0 : 1)
|
||||
|
||||
@@ -13,7 +13,7 @@ Anschließend skaliert es auf 1024×1024 ohne Alphakanal.
|
||||
|
||||
```bash
|
||||
swiftc -O -o /tmp/make-app-icon Tools/make-app-icon.swift
|
||||
/tmp/make-app-icon logo.png CamperMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
|
||||
/tmp/make-app-icon logo.png VanControl/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
|
||||
```
|
||||
|
||||
Nur nötig, wenn `logo.png` sich ändert – das erzeugte Icon liegt im Repo.
|
||||
@@ -32,5 +32,11 @@ gefüllte Form, in der die Details erhalten bleiben.
|
||||
```bash
|
||||
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
|
||||
sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png
|
||||
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png
|
||||
/tmp/make-vehicle-art /tmp/rear.png VanControl/Assets.xcassets/VansterRear.imageset/VansterRear.png
|
||||
```
|
||||
|
||||
Jedes Fahrzeug-Grafikset (siehe `VehicleGraphicStyle`) hat sein eigenes
|
||||
Bildpaar `<Name>Side`/`<Name>Rear`. Hat die Vorlage schon einen
|
||||
transparenten Hintergrund, geht die Quelldatei direkt ins Skript. Liegt sie
|
||||
auf weißem Grund, muss der Rand vorher weg – sonst wird die ganze Fläche
|
||||
mitgefärbt.
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
AA0000000000000000000027 /* VanControlWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* VanControlWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
AA0000000000000000000042 /* VanControlComplication.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* VanControlComplication.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
AA0000000000000000000046 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000053 /* ActivityKit.framework */; };
|
||||
AA0000000000000000000047 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000052 /* SwiftUI.framework */; };
|
||||
AA0000000000000000000048 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000051 /* WidgetKit.framework */; };
|
||||
AA0000000000000000000049 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000053 /* ActivityKit.framework */; };
|
||||
AA0000000000000000000050 /* VanControlLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000054 /* VanControlLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
AA0000000000000000000028 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA0000000000000000000009 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = AA0000000000000000000022;
|
||||
remoteInfo = VanControlWatch;
|
||||
};
|
||||
AA0000000000000000000043 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA0000000000000000000009 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = AA0000000000000000000037;
|
||||
remoteInfo = VanControlComplication;
|
||||
};
|
||||
AA0000000000000000000063 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA0000000000000000000009 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = AA0000000000000000000062;
|
||||
remoteInfo = VanControlLiveActivityExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
AA0000000000000000000026 /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
AA0000000000000000000027 /* VanControlWatch.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000041 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
AA0000000000000000000042 /* VanControlComplication.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000065 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
AA0000000000000000000050 /* VanControlLiveActivityExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AA0000000000000000000001 /* VanControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AA0000000000000000000016 /* VanControlWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanControlWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AA0000000000000000000030 /* VanControl-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "VanControl-Info.plist"; sourceTree = "<group>"; };
|
||||
AA0000000000000000000032 /* VanControlComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanControlComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AA0000000000000000000045 /* VanControlComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "VanControlComplication-Info.plist"; sourceTree = "<group>"; };
|
||||
AA0000000000000000000051 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
AA0000000000000000000052 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
AA0000000000000000000053 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
|
||||
AA0000000000000000000054 /* VanControlLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanControlLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
AA0000000000000000000055 /* Exceptions for "VanControlLiveActivity" folder in "VanControlLiveActivityExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = AA0000000000000000000062 /* VanControlLiveActivityExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
AA0000000000000000000002 /* VanControl */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = VanControl;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000017 /* VanControlWatch */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = VanControlWatch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000018 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000033 /* VanControlComplication */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = VanControlComplication;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000056 /* VanControlLiveActivity */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
AA0000000000000000000055 /* Exceptions for "VanControlLiveActivity" folder in "VanControlLiveActivityExtension" target */,
|
||||
);
|
||||
path = VanControlLiveActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000057 /* SharedActivity */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = SharedActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
AA0000000000000000000003 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0000000000000000000049 /* ActivityKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000020 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000035 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000058 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AA0000000000000000000047 /* SwiftUI.framework in Frameworks */,
|
||||
AA0000000000000000000048 /* WidgetKit.framework in Frameworks */,
|
||||
AA0000000000000000000046 /* ActivityKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
AA0000000000000000000004 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000002 /* VanControl */,
|
||||
AA0000000000000000000017 /* VanControlWatch */,
|
||||
AA0000000000000000000033 /* VanControlComplication */,
|
||||
AA0000000000000000000018 /* Shared */,
|
||||
AA0000000000000000000057 /* SharedActivity */,
|
||||
AA0000000000000000000031 /* Config */,
|
||||
AA0000000000000000000056 /* VanControlLiveActivity */,
|
||||
AA0000000000000000000059 /* Frameworks */,
|
||||
AA0000000000000000000005 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000005 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000001 /* VanControl.app */,
|
||||
AA0000000000000000000016 /* VanControlWatch.app */,
|
||||
AA0000000000000000000032 /* VanControlComplication.appex */,
|
||||
AA0000000000000000000054 /* VanControlLiveActivityExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000031 /* Config */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000030 /* VanControl-Info.plist */,
|
||||
AA0000000000000000000045 /* VanControlComplication-Info.plist */,
|
||||
);
|
||||
path = Config;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000059 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000051 /* WidgetKit.framework */,
|
||||
AA0000000000000000000052 /* SwiftUI.framework */,
|
||||
AA0000000000000000000053 /* ActivityKit.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
AA0000000000000000000006 /* VanControl */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "VanControl" */;
|
||||
buildPhases = (
|
||||
AA0000000000000000000007 /* Sources */,
|
||||
AA0000000000000000000003 /* Frameworks */,
|
||||
AA0000000000000000000008 /* Resources */,
|
||||
AA0000000000000000000026 /* Embed Watch Content */,
|
||||
AA0000000000000000000065 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
AA0000000000000000000029 /* PBXTargetDependency */,
|
||||
AA0000000000000000000064 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000002 /* VanControl */,
|
||||
AA0000000000000000000018 /* Shared */,
|
||||
AA0000000000000000000057 /* SharedActivity */,
|
||||
);
|
||||
name = VanControl;
|
||||
productName = VanControl;
|
||||
productReference = AA0000000000000000000001 /* VanControl.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
AA0000000000000000000022 /* VanControlWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "VanControlWatch" */;
|
||||
buildPhases = (
|
||||
AA0000000000000000000019 /* Sources */,
|
||||
AA0000000000000000000020 /* Frameworks */,
|
||||
AA0000000000000000000021 /* Resources */,
|
||||
AA0000000000000000000041 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
AA0000000000000000000044 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000017 /* VanControlWatch */,
|
||||
AA0000000000000000000018 /* Shared */,
|
||||
);
|
||||
name = VanControlWatch;
|
||||
productName = VanControlWatch;
|
||||
productReference = AA0000000000000000000016 /* VanControlWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
AA0000000000000000000037 /* VanControlComplication */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "VanControlComplication" */;
|
||||
buildPhases = (
|
||||
AA0000000000000000000034 /* Sources */,
|
||||
AA0000000000000000000035 /* Frameworks */,
|
||||
AA0000000000000000000036 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000018 /* Shared */,
|
||||
AA0000000000000000000033 /* VanControlComplication */,
|
||||
);
|
||||
name = VanControlComplication;
|
||||
productName = VanControlComplication;
|
||||
productReference = AA0000000000000000000032 /* VanControlComplication.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
AA0000000000000000000062 /* VanControlLiveActivityExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AA0000000000000000000068 /* Build configuration list for PBXNativeTarget "VanControlLiveActivityExtension" */;
|
||||
buildPhases = (
|
||||
AA0000000000000000000060 /* Sources */,
|
||||
AA0000000000000000000058 /* Frameworks */,
|
||||
AA0000000000000000000061 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000056 /* VanControlLiveActivity */,
|
||||
AA0000000000000000000057 /* SharedActivity */,
|
||||
);
|
||||
name = VanControlLiveActivityExtension;
|
||||
productName = VanControlLiveActivityExtension;
|
||||
productReference = AA0000000000000000000054 /* VanControlLiveActivityExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
AA0000000000000000000009 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2660;
|
||||
LastUpgradeCheck = 2660;
|
||||
TargetAttributes = {
|
||||
AA0000000000000000000006 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
AA0000000000000000000022 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
AA0000000000000000000037 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
AA0000000000000000000062 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "VanControl" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
de,
|
||||
);
|
||||
mainGroup = AA0000000000000000000004;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = AA0000000000000000000005 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
AA0000000000000000000006 /* VanControl */,
|
||||
AA0000000000000000000022 /* VanControlWatch */,
|
||||
AA0000000000000000000037 /* VanControlComplication */,
|
||||
AA0000000000000000000062 /* VanControlLiveActivityExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
AA0000000000000000000008 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000021 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000036 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000061 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
AA0000000000000000000007 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000019 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000034 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000060 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
AA0000000000000000000029 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
platformFilter = ios;
|
||||
target = AA0000000000000000000022 /* VanControlWatch */;
|
||||
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
|
||||
};
|
||||
AA0000000000000000000044 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AA0000000000000000000037 /* VanControlComplication */;
|
||||
targetProxy = AA0000000000000000000043 /* PBXContainerItemProxy */;
|
||||
};
|
||||
AA0000000000000000000064 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AA0000000000000000000062 /* VanControlLiveActivityExtension */;
|
||||
targetProxy = AA0000000000000000000063 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
AA0000000000000000000012 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA0000000000000000000013 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_VERSION = 5.0;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
AA0000000000000000000014 /* 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_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/VanControl-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA0000000000000000000015 /* 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_ASSET_PATHS = "";
|
||||
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/VanControl-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
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 = V5C6Q86XJR;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanControl;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.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 = V5C6Q86XJR;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanControl;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
AA0000000000000000000038 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/VanControlComplication-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp.levelwidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA0000000000000000000039 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/VanControlComplication-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp.levelwidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
AA0000000000000000000066 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = VanControlLiveActivity/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Live Activity";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.VanControlLiveActivity;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA0000000000000000000067 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = VanControlLiveActivity/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Live Activity";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.VanControlLiveActivity;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
AA0000000000000000000010 /* Build configuration list for PBXProject "VanControl" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0000000000000000000012 /* Debug */,
|
||||
AA0000000000000000000013 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "VanControl" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0000000000000000000014 /* Debug */,
|
||||
AA0000000000000000000015 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "VanControlWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0000000000000000000023 /* Debug */,
|
||||
AA0000000000000000000024 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "VanControlComplication" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0000000000000000000038 /* Debug */,
|
||||
AA0000000000000000000039 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AA0000000000000000000068 /* Build configuration list for PBXNativeTarget "VanControlLiveActivityExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0000000000000000000066 /* Debug */,
|
||||
AA0000000000000000000067 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = AA0000000000000000000009 /* Project object */;
|
||||
}
|
||||
@@ -6,9 +6,9 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000006"
|
||||
BuildableName = "CamperMonitor.app"
|
||||
BlueprintName = "CamperMonitor"
|
||||
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||
BuildableName = "VanControl.app"
|
||||
BlueprintName = "VanControl"
|
||||
ReferencedContainer = "container:VanControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
@@ -21,9 +21,9 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000006"
|
||||
BuildableName = "CamperMonitor.app"
|
||||
BlueprintName = "CamperMonitor"
|
||||
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||
BuildableName = "VanControl.app"
|
||||
BlueprintName = "VanControl"
|
||||
ReferencedContainer = "container:VanControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
@@ -32,9 +32,9 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000006"
|
||||
BuildableName = "CamperMonitor.app"
|
||||
BlueprintName = "CamperMonitor"
|
||||
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||
BuildableName = "VanControl.app"
|
||||
BlueprintName = "VanControl"
|
||||
ReferencedContainer = "container:VanControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
@@ -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 = "VanControlWatch.app"
|
||||
BlueprintName = "VanControlWatch"
|
||||
ReferencedContainer = "container:VanControl.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 = "VanControlWatch.app"
|
||||
BlueprintName = "VanControlWatch"
|
||||
ReferencedContainer = "container:VanControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000022"
|
||||
BuildableName = "VanControlWatch.app"
|
||||
BlueprintName = "VanControlWatch"
|
||||
ReferencedContainer = "container:VanControl.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 341 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CaliforniaSide.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleRear.png",
|
||||
"filename" : "VansterRear.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 130 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VehicleSide.png",
|
||||
"filename" : "VansterSide.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 253 KiB |
@@ -232,6 +232,19 @@ struct AlpicoolState: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Stand, der die Verbindung überdauert – ohne Messwerte.
|
||||
var settings: FridgeSettings? {
|
||||
guard hasStatus else { return nil }
|
||||
return FridgeSettings(isPoweredOn: isPoweredOn,
|
||||
isEco: isEco,
|
||||
isLocked: isLocked,
|
||||
isDualZone: isDualZone,
|
||||
usesFahrenheit: usesFahrenheit,
|
||||
leftTarget: leftTarget,
|
||||
rightTarget: isDualZone ? rightTarget : nil,
|
||||
updated: Date())
|
||||
}
|
||||
|
||||
/// Die Bytes, die ein Stellbefehl ändert.
|
||||
///
|
||||
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
|
||||
@@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable {
|
||||
var looksLikeSupported: Bool
|
||||
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
|
||||
var isLevelSensor = false
|
||||
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
|
||||
var isVotronicSolarESPSensor = false
|
||||
|
||||
var isVictron: Bool { victronRecordType != nil }
|
||||
|
||||
@@ -30,6 +32,7 @@ struct Discovery: Identifiable, Hashable {
|
||||
return "Victron · " + Self.victronRecordName(type)
|
||||
}
|
||||
if isLevelSensor { return "VanAlign Neigungsmesser" }
|
||||
if isVotronicSolarESPSensor { return "VotronicSolarESP" }
|
||||
if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" }
|
||||
return "Bluetooth-Gerät"
|
||||
}
|
||||
@@ -149,9 +152,15 @@ final class BluetoothManager: NSObject {
|
||||
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
|
||||
private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
|
||||
private(set) var levelStates: [UUID: LevelState] = [:]
|
||||
private(set) var votronicSolarESPStates: [UUID: VotronicSolarESPState] = [:]
|
||||
private(set) var isBluetoothReady = false
|
||||
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
|
||||
|
||||
/// Verbindet die Live Activity des Neigungsmessers mit dem Funkgeschehen –
|
||||
/// gesetzt von `VanControlApp`, damit diese Schicht nichts über
|
||||
/// ActivityKit wissen muss, wenn niemand zuhört.
|
||||
var activityManager: LevelActivityManager?
|
||||
|
||||
/// Solange true, werden alle gefundenen Peripherals gesammelt.
|
||||
var isDiscovering = false {
|
||||
didSet {
|
||||
@@ -162,7 +171,7 @@ final class BluetoothManager: NSObject {
|
||||
|
||||
// MARK: - Nur auf `queue`
|
||||
|
||||
private let queue = DispatchQueue(label: "de.fritob.CamperMonitor.bluetooth")
|
||||
private let queue = DispatchQueue(label: "de.fritob.VanControl.bluetooth")
|
||||
private var central: CBCentralManager?
|
||||
|
||||
/// Momentaufnahme der eingerichteten Geräte, damit die Funk-Queue nicht in
|
||||
@@ -178,8 +187,14 @@ final class BluetoothManager: NSObject {
|
||||
}
|
||||
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
|
||||
|
||||
/// Geräte, die nur verbunden werden, solange ihre Ansicht offen ist.
|
||||
/// Schlüssel ist die Gerätekennung, gezählt wird nicht – eine Ansicht ist
|
||||
/// zu einer Zeit offen.
|
||||
private var requestedSessions: Set<UUID> = []
|
||||
|
||||
private var bmsSessions: [UUID: BMSSession] = [:]
|
||||
private var levelSessions: [UUID: LevelSession] = [:]
|
||||
private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:]
|
||||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||||
private var reconnectTimer: DispatchSourceTimer?
|
||||
|
||||
@@ -257,6 +272,13 @@ final class BluetoothManager: NSObject {
|
||||
for snapshot in DemoData.snapshots() {
|
||||
snapshots[snapshot.deviceID] = snapshot
|
||||
linkStates[snapshot.deviceID] = .live
|
||||
// Die Kühlbox wird nur beim Öffnen verbunden – im Vorführbetrieb
|
||||
// steht sie deshalb wie im Alltag auf „zuletzt gestellt“.
|
||||
if snapshot.deviceID == DemoData.fridge.id,
|
||||
let settings = DemoData.fridgeState.settings {
|
||||
linkStates[snapshot.deviceID] = .idle
|
||||
snapshots[snapshot.deviceID] = settings.snapshot(deviceID: snapshot.deviceID)
|
||||
}
|
||||
if let value = snapshot.primaryMetric?.value {
|
||||
history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value)
|
||||
}
|
||||
@@ -297,6 +319,15 @@ final class BluetoothManager: NSObject {
|
||||
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
||||
fridgeStates = fridgeStates.filter { known.contains($0.key) }
|
||||
levelStates = levelStates.filter { known.contains($0.key) }
|
||||
votronicSolarESPStates = votronicSolarESPStates.filter { known.contains($0.key) }
|
||||
|
||||
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
|
||||
// ihren zuletzt gestellten Stand.
|
||||
for device in store.activeDevices where device.role.connectsOnDemand {
|
||||
guard linkStates[device.id] != .live else { continue }
|
||||
linkStates[device.id] = .idle
|
||||
showLastKnownFridgeSettings(for: device.id)
|
||||
}
|
||||
|
||||
queue.async { self.applyConfiguration(devices) }
|
||||
}
|
||||
@@ -309,9 +340,64 @@ final class BluetoothManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verbindung auf Anforderung (vom Hauptthread aufgerufen)
|
||||
|
||||
/// Verbindet ein Gerät, das sonst unverbunden bleibt – solange seine
|
||||
/// Ansicht offen ist.
|
||||
///
|
||||
/// Für die Kühlbox: Jede Verbindung meldet sich an ihrem Display an, manche
|
||||
/// Modelle verlangen dabei sogar einen Tastendruck. Dauerhaft verbunden zu
|
||||
/// sein ist dort also nicht unsichtbar, sondern lästig.
|
||||
func beginSession(for device: ConfiguredDevice) {
|
||||
guard device.role.connectsOnDemand else { return }
|
||||
let deviceID = device.id
|
||||
queue.async {
|
||||
self.requestedSessions.insert(deviceID)
|
||||
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
|
||||
// Der Wunsch des Benutzers hebt die Wartesperre auf.
|
||||
self.clearBackOff(entry.peripheralID)
|
||||
self.connectIfNeeded(entry)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gibt das Gerät wieder frei. Der zuletzt gemeldete Stand bleibt in der
|
||||
/// Übersicht stehen, damit dort nicht plötzlich eine Lücke klafft.
|
||||
func endSession(for device: ConfiguredDevice) {
|
||||
guard device.role.connectsOnDemand else { return }
|
||||
let deviceID = device.id
|
||||
queue.async {
|
||||
self.requestedSessions.remove(deviceID)
|
||||
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
|
||||
self.bmsSessions[entry.peripheralID]?.stop()
|
||||
self.bmsSessions[entry.peripheralID] = nil
|
||||
self.disconnect(entry.peripheralID)
|
||||
self.publish {
|
||||
self.linkStates[deviceID] = .idle
|
||||
self.showLastKnownFridgeSettings(for: deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Setzt an die Stelle der Live-Werte den zuletzt gestellten Stand.
|
||||
///
|
||||
/// Nur auf dem Hauptthread aufrufen: greift auf den Speicher zu.
|
||||
private func showLastKnownFridgeSettings(for deviceID: UUID) {
|
||||
guard let settings = store.lastFridgeSettings(for: deviceID) else {
|
||||
snapshots[deviceID] = nil
|
||||
return
|
||||
}
|
||||
snapshots[deviceID] = settings.snapshot(deviceID: deviceID)
|
||||
// Der Zustand bleibt stehen, damit sich die Box beim nächsten Öffnen
|
||||
// sofort bedienen lässt – die Schalter zeigen dann den letzten Stand.
|
||||
}
|
||||
|
||||
// MARK: - Kühlbox steuern (vom Hauptthread aufgerufen)
|
||||
|
||||
func updateFridgeZoneMode(for device: ConfiguredDevice) {
|
||||
guard !isDemo else {
|
||||
applyDemoFridgeChange(for: device.id) { $0.zoneMode = device.fridgeZoneMode }
|
||||
return
|
||||
}
|
||||
let deviceID = device.id, mode = device.fridgeZoneMode
|
||||
queue.async {
|
||||
guard let session = self.session(for: deviceID) else { return }
|
||||
@@ -322,21 +408,51 @@ final class BluetoothManager: NSObject {
|
||||
}
|
||||
|
||||
func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) {
|
||||
guard !isDemo else {
|
||||
applyDemoFridgeChange(for: deviceID) { state in
|
||||
switch zone {
|
||||
case .left: state.leftTarget = celsius
|
||||
case .right: state.rightTarget = celsius
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
sendFridgeSettings(for: deviceID) { _ in AlpicoolState.setTarget(zone: zone, to: celsius) }
|
||||
}
|
||||
|
||||
func setFridgePower(_ on: Bool, for deviceID: UUID) {
|
||||
guard !isDemo else {
|
||||
applyDemoFridgeChange(for: deviceID) { $0.isPoweredOn = on }
|
||||
return
|
||||
}
|
||||
sendFridgeSettings(for: deviceID) { $0.settingsCommand(poweredOn: on) }
|
||||
}
|
||||
|
||||
func setFridgeEco(_ eco: Bool, for deviceID: UUID) {
|
||||
guard !isDemo else {
|
||||
applyDemoFridgeChange(for: deviceID) { $0.runMode = eco ? 1 : 0 }
|
||||
return
|
||||
}
|
||||
sendFridgeSettings(for: deviceID) { $0.settingsCommand(eco: eco) }
|
||||
}
|
||||
|
||||
func setFridgeLock(_ locked: Bool, for deviceID: UUID) {
|
||||
guard !isDemo else {
|
||||
applyDemoFridgeChange(for: deviceID) { $0.isLocked = locked }
|
||||
return
|
||||
}
|
||||
sendFridgeSettings(for: deviceID) { $0.settingsCommand(locked: locked) }
|
||||
}
|
||||
|
||||
/// Im Demo-Modus gibt es keine Box, die einen Stellbefehl bestätigt – die
|
||||
/// Änderung wird direkt im angezeigten Zustand nachgezogen, auf dem
|
||||
/// Hauptthread, wo `fridgeStates` lebt.
|
||||
private func applyDemoFridgeChange(for deviceID: UUID, _ change: (inout AlpicoolState) -> Void) {
|
||||
var state = fridgeStates[deviceID] ?? DemoData.fridgeState
|
||||
change(&state)
|
||||
fridgeStates[deviceID] = state
|
||||
}
|
||||
|
||||
/// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut –
|
||||
/// und zwar auf der Funk-Queue, wo dieser Zustand lebt.
|
||||
///
|
||||
@@ -381,14 +497,31 @@ final class BluetoothManager: NSObject {
|
||||
}
|
||||
|
||||
/// Nach einer Änderung der Einbaulage aufrufen.
|
||||
///
|
||||
/// Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie
|
||||
/// beschreibt den Einbau, und Uhr wie Android-App finden sie dann vor,
|
||||
/// ohne dass jemand sie ein zweites Mal bestimmen muss.
|
||||
func updateSensorOrientation(for device: ConfiguredDevice) {
|
||||
let deviceID = device.id, orientation = device.sensorOrientation
|
||||
queue.async {
|
||||
guard let session = self.levelSession(for: deviceID) else { return }
|
||||
session.orientation = orientation
|
||||
session.storeOrientation(orientation)
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt die Einbaulage, die das Gerät meldet.
|
||||
///
|
||||
/// Steht dort noch nichts, meldet das Gerät nichts – dann bleibt es bei der
|
||||
/// örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen
|
||||
/// hinaufgeschrieben.
|
||||
private func adoptOrientation(_ orientation: SensorOrientation, for deviceID: UUID) {
|
||||
guard var device = store.devices.first(where: { $0.id == deviceID }),
|
||||
device.sensorOrientation != orientation else { return }
|
||||
device.sensorOrientation = orientation
|
||||
store.update(device)
|
||||
}
|
||||
|
||||
/// Setzt die aktuelle Lage des Fahrzeugs als neue Null.
|
||||
func calibrateLevel(for deviceID: UUID) {
|
||||
queue.async { self.levelSession(for: deviceID)?.calibrate() }
|
||||
@@ -414,8 +547,10 @@ final class BluetoothManager: NSObject {
|
||||
private func applyConfiguration(_ devices: [ManagedDevice]) {
|
||||
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
|
||||
|
||||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
|
||||
let wanted = Set(devices.filter { $0.transport == .connect }.map(\.peripheralID))
|
||||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören – und zu
|
||||
// denen, die nur auf Anforderung verbunden werden und gerade niemand
|
||||
// anschaut.
|
||||
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
|
||||
for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) {
|
||||
session.stop()
|
||||
bmsSessions[peripheralID] = nil
|
||||
@@ -426,6 +561,11 @@ final class BluetoothManager: NSObject {
|
||||
levelSessions[peripheralID] = nil
|
||||
disconnect(peripheralID)
|
||||
}
|
||||
for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) {
|
||||
session.stop()
|
||||
votronicSolarESPSessions[peripheralID] = nil
|
||||
disconnect(peripheralID)
|
||||
}
|
||||
// Einstellungen an bestehende Sitzungen weiterreichen.
|
||||
for device in devices {
|
||||
bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
|
||||
@@ -471,9 +611,11 @@ final class BluetoothManager: NSObject {
|
||||
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
|
||||
for (_, session) in bmsSessions { session.stop() }
|
||||
for (_, session) in levelSessions { session.stop() }
|
||||
for (_, session) in votronicSolarESPSessions { session.stop() }
|
||||
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
|
||||
bmsSessions.removeAll()
|
||||
levelSessions.removeAll()
|
||||
votronicSolarESPSessions.removeAll()
|
||||
connectedPeripherals.removeAll()
|
||||
connectedSince.removeAll()
|
||||
pendingControls.removeAll()
|
||||
@@ -492,11 +634,22 @@ final class BluetoothManager: NSObject {
|
||||
|
||||
private func connectManagedPeripherals() {
|
||||
guard central?.state == .poweredOn else { return }
|
||||
for device in managed.values where device.transport == .connect {
|
||||
for device in managed.values where shouldStayConnected(device) {
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob dieses Gerät von sich aus verbunden gehalten wird.
|
||||
///
|
||||
/// Geräte auf Anforderung – die Kühlbox – nur dann, wenn ihre Ansicht
|
||||
/// gerade offen ist oder ein Stellbefehl darauf wartet.
|
||||
private func shouldStayConnected(_ device: ManagedDevice) -> Bool {
|
||||
guard device.transport == .connect else { return false }
|
||||
guard device.role.connectsOnDemand else { return true }
|
||||
return requestedSessions.contains(device.id)
|
||||
|| pendingControls[device.peripheralID]?.isEmpty == false
|
||||
}
|
||||
|
||||
private func connectIfNeeded(_ device: ManagedDevice) {
|
||||
if let existing = connectedPeripherals[device.peripheralID],
|
||||
existing.state == .connected || existing.state == .connecting {
|
||||
@@ -654,7 +807,8 @@ final class BluetoothManager: NSObject {
|
||||
}
|
||||
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
||||
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
|
||||
entry.looksLikeSupported = entry.isLevelSensor
|
||||
entry.isVotronicSolarESPSensor = services.contains(VotronicSolarESPSession.serviceUUID)
|
||||
entry.looksLikeSupported = entry.isLevelSensor || entry.isVotronicSolarESPSensor
|
||||
|| Self.looksLikeSupported(name: entry.name)
|
||||
pendingDiscoveries[peripheral.identifier] = entry
|
||||
}
|
||||
@@ -719,8 +873,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
|
||||
|
||||
case .connect:
|
||||
// Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen.
|
||||
if connectedPeripherals[identifier] == nil {
|
||||
// Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt
|
||||
// aufbauen. Ausser bei Geräten auf Anforderung: Die Kühlbox piept
|
||||
// bei jeder Verbindung, und dass sie in Reichweite ist, ist kein
|
||||
// Grund, sie anzufassen.
|
||||
if connectedPeripherals[identifier] == nil, shouldStayConnected(device) {
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
@@ -738,10 +895,28 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
queue: queue,
|
||||
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
||||
onStateChange: { [weak self] state in
|
||||
self?.publish { self?.linkStates[device.id] = state }
|
||||
self?.publish {
|
||||
self?.linkStates[device.id] = state
|
||||
if state == .live {
|
||||
// Verbindung wieder da: ein anstehendes Ende
|
||||
// verwerfen oder eine zwischenzeitlich doch schon
|
||||
// beendete Live Activity wieder aufnehmen.
|
||||
self?.activityManager?.handleReconnect(
|
||||
deviceID: device.id,
|
||||
state: self?.levelStates[device.id] ?? LevelState())
|
||||
}
|
||||
}
|
||||
},
|
||||
onLevelState: { [weak self] state in
|
||||
self?.publish { self?.levelStates[device.id] = state }
|
||||
self?.publish {
|
||||
self?.levelStates[device.id] = state
|
||||
self?.activityManager?.update(deviceID: device.id, state: 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
|
||||
@@ -750,6 +925,27 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
if device.role == .votronicSolar {
|
||||
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
|
||||
// nicht in der Live Activity/CarPlay auftauchen, die ist dem
|
||||
// Neigungsmesser vorbehalten.
|
||||
let session = VotronicSolarESPSession(
|
||||
deviceID: device.id,
|
||||
peripheral: peripheral,
|
||||
queue: queue,
|
||||
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
||||
onStateChange: { [weak self] state in
|
||||
self?.publish { self?.linkStates[device.id] = state }
|
||||
},
|
||||
onVotronicSolarESPState: { [weak self] state in
|
||||
self?.publish { self?.votronicSolarESPStates[device.id] = state }
|
||||
}
|
||||
)
|
||||
votronicSolarESPSessions[peripheral.identifier] = session
|
||||
session.start()
|
||||
return
|
||||
}
|
||||
|
||||
let session = BMSSession(
|
||||
deviceID: device.id,
|
||||
peripheral: peripheral,
|
||||
@@ -764,7 +960,14 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
)
|
||||
session.onFridgeState = { [weak self] state in
|
||||
self?.fridgeStateCache[peripheral.identifier] = state
|
||||
self?.publish { self?.fridgeStates[device.id] = state }
|
||||
self?.publish {
|
||||
self?.fridgeStates[device.id] = state
|
||||
// Für die Übersicht aufheben: Sie zeigt den Stand auch dann,
|
||||
// wenn längst niemand mehr verbunden ist.
|
||||
if let settings = state.settings {
|
||||
self?.store.setLastFridgeSettings(settings, for: device.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
session.fridgeZoneMode = device.fridgeZoneMode
|
||||
bmsSessions[peripheral.identifier] = session
|
||||
@@ -790,13 +993,29 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
bmsSessions[peripheral.identifier] = nil
|
||||
levelSessions[peripheral.identifier]?.handleDisconnect()
|
||||
levelSessions[peripheral.identifier] = nil
|
||||
votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect()
|
||||
votronicSolarESPSessions[peripheral.identifier] = nil
|
||||
connectedPeripherals[peripheral.identifier] = nil
|
||||
|
||||
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
||||
.map { Date().timeIntervalSince($0) } ?? 0
|
||||
|
||||
if let device = managed[peripheral.identifier] {
|
||||
publish { self.linkStates[device.id] = .searching }
|
||||
if let device = managed[peripheral.identifier], !shouldStayConnected(device) {
|
||||
// Auf Anforderung verbunden und niemand schaut mehr hin: nicht
|
||||
// wieder anklopfen, sondern den letzten Stand stehenlassen.
|
||||
let deviceID = device.id
|
||||
publish {
|
||||
self.linkStates[deviceID] = .idle
|
||||
self.showLastKnownFridgeSettings(for: deviceID)
|
||||
}
|
||||
} else if let device = managed[peripheral.identifier] {
|
||||
let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
|
||||
publish {
|
||||
self.linkStates[device.id] = .searching
|
||||
// Erst nach einer Gnadenfrist wirklich beenden – sonst
|
||||
// flackerte die Live Activity bei jedem kurzen Funkloch.
|
||||
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
|
||||
}
|
||||
if lifetime >= stableConnection {
|
||||
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||||
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
|
||||
@@ -0,0 +1,167 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers.
|
||||
///
|
||||
/// Es läuft höchstens eine Aktivität gleichzeitig – mehr als einen
|
||||
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 26 zeigt
|
||||
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
|
||||
/// eigenes CarPlay-App-Target braucht es dafür nicht – die Inhalte kommen
|
||||
/// aber nicht von der Sperrbildschirm-Ansicht, sondern von der
|
||||
/// `.small`-Aktivitätsfamilie (siehe `VanControlLiveActivity`).
|
||||
@Observable
|
||||
final class LevelActivityManager {
|
||||
private(set) var trackedDeviceID: UUID?
|
||||
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
|
||||
|
||||
/// Wie lange nach einem Verbindungsabbruch gewartet wird, bevor die
|
||||
/// Aktivität wirklich endet. Verbindungen fallen im Fahrzeug regelmässig
|
||||
/// kurz weg – ohne diese Gnadenfrist flackerte die Anzeige bei jedem
|
||||
/// kurzen Funkloch aus und wieder ein.
|
||||
static let disconnectGrace: TimeInterval = 12
|
||||
|
||||
/// Wie oft die Live Activity höchstens tatsächlich aktualisiert wird.
|
||||
///
|
||||
/// Der Neigungsmesser liefert Werte deutlich öfter (abonniert bei jeder
|
||||
/// Änderung, sonst alle 0,5 s abgefragt). Stösst man ActivityKit im
|
||||
/// selben Takt an, drosselt iOS nach kurzer Zeit selbst immer stärker –
|
||||
/// die Anzeige hinkt dann sichtbar hinterher, in CarPlay besonders
|
||||
/// deutlich, weil das Dashboard ohnehin zurückhaltender aktualisiert als
|
||||
/// Sperrbildschirm oder Dynamic Island. Ein fester Mindestabstand hält
|
||||
/// die tatsächlichen Aktualisierungen unter dem, was das System duldet.
|
||||
static let minimumUpdateInterval: TimeInterval = 1
|
||||
|
||||
@ObservationIgnored private var lastPushedAt: Date?
|
||||
/// Ein während der Sperrfrist aufgelaufener Messwert, der nachgeholt
|
||||
/// wird, sobald die Frist um ist – sonst bliebe die Anzeige bei
|
||||
/// schnellen Änderungen auf einem Zwischenstand stehen.
|
||||
@ObservationIgnored private var pendingUpdate: Task<Void, Never>?
|
||||
|
||||
@ObservationIgnored private var pendingEnd: Task<Void, Never>?
|
||||
/// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung
|
||||
/// tatsächlich beendet wurde – bei der nächsten Wiederverbindung wird sie
|
||||
/// automatisch neu gestartet, damit das kein bewusster Nutzer-Stop war.
|
||||
@ObservationIgnored private var deviceToResume: (id: UUID, name: String)?
|
||||
|
||||
/// Ob gerade irgendeine Aktivität läuft – die App muss dafür im
|
||||
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
|
||||
/// Anzeige beim ersten Sperren des Bildschirms ein.
|
||||
var isActive: Bool { trackedDeviceID != nil }
|
||||
|
||||
func isActive(for deviceID: UUID) -> Bool {
|
||||
trackedDeviceID == deviceID && activity != nil
|
||||
}
|
||||
|
||||
func start(deviceID: UUID, deviceName: String, state: LevelState) {
|
||||
pendingEnd?.cancel()
|
||||
pendingEnd = nil
|
||||
deviceToResume = nil
|
||||
pendingUpdate?.cancel()
|
||||
pendingUpdate = nil
|
||||
lastPushedAt = nil
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
endActivity()
|
||||
let attributes = LevelActivityAttributes(deviceName: deviceName)
|
||||
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
||||
do {
|
||||
activity = try Activity.request(attributes: attributes, content: content)
|
||||
trackedDeviceID = deviceID
|
||||
lastPushedAt = Date()
|
||||
} catch {
|
||||
// Kann z.B. an fehlender Nutzerfreigabe liegen – dann bleibt es
|
||||
// einfach bei der Anzeige in der App, es gibt sonst nichts zu tun.
|
||||
}
|
||||
}
|
||||
|
||||
/// Wird bei jeder neuen Messung aufgerufen, unabhängig davon, ob gerade
|
||||
/// eine Aktivität läuft – kein Aufwand ohne aktive Anzeige.
|
||||
///
|
||||
/// Innerhalb der Sperrfrist wird nicht einfach verworfen, sondern der
|
||||
/// neueste Stand für ihr Ende vorgemerkt – ein älterer vorgemerkter
|
||||
/// Aufruf verfällt dabei. So kommt bei schnellen Änderungen immer der
|
||||
/// zuletzt gemessene Wert an, nur eben gesammelt statt einzeln.
|
||||
func update(deviceID: UUID, state: LevelState) {
|
||||
guard trackedDeviceID == deviceID, activity != nil else { return }
|
||||
pendingUpdate?.cancel()
|
||||
pendingUpdate = nil
|
||||
|
||||
let elapsed = lastPushedAt.map { Date().timeIntervalSince($0) } ?? .infinity
|
||||
guard elapsed < Self.minimumUpdateInterval else {
|
||||
push(state)
|
||||
return
|
||||
}
|
||||
let delay = Self.minimumUpdateInterval - elapsed
|
||||
pendingUpdate = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
|
||||
self.pendingUpdate = nil
|
||||
self.push(state)
|
||||
}
|
||||
}
|
||||
|
||||
private func push(_ state: LevelState) {
|
||||
guard let activity else { return }
|
||||
lastPushedAt = Date()
|
||||
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
||||
Task { await activity.update(content) }
|
||||
}
|
||||
|
||||
/// Bewusstes Beenden durch den Nutzer – anders als bei einem
|
||||
/// Verbindungsabbruch soll das bei der nächsten Verbindung nicht
|
||||
/// automatisch wieder aufleben.
|
||||
func end() {
|
||||
pendingEnd?.cancel()
|
||||
pendingEnd = nil
|
||||
deviceToResume = nil
|
||||
endActivity()
|
||||
}
|
||||
|
||||
/// BLE-Verbindung weg: nicht sofort beenden, sondern erst nach einer
|
||||
/// kurzen Gnadenfrist – meldet sich das Gerät vorher zurück
|
||||
/// (`handleReconnect`), passiert gar nichts.
|
||||
func handleDisconnect(deviceID: UUID, deviceName: String) {
|
||||
guard trackedDeviceID == deviceID, activity != nil, pendingEnd == nil else { return }
|
||||
pendingEnd = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(Self.disconnectGrace))
|
||||
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
|
||||
self.deviceToResume = (deviceID, deviceName)
|
||||
self.endActivity()
|
||||
self.pendingEnd = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// BLE-Verbindung wieder da: ein noch anstehendes Ende verwerfen, oder –
|
||||
/// falls die Gnadenfrist schon abgelaufen und die Aktivität wirklich
|
||||
/// beendet war – sie mit dem letzten bekannten Stand neu starten.
|
||||
func handleReconnect(deviceID: UUID, state: LevelState) {
|
||||
if pendingEnd != nil, trackedDeviceID == deviceID {
|
||||
pendingEnd?.cancel()
|
||||
pendingEnd = nil
|
||||
return
|
||||
}
|
||||
guard let resume = deviceToResume, resume.id == deviceID else { return }
|
||||
deviceToResume = nil
|
||||
start(deviceID: deviceID, deviceName: resume.name, state: state)
|
||||
}
|
||||
|
||||
private func endActivity() {
|
||||
pendingUpdate?.cancel()
|
||||
pendingUpdate = nil
|
||||
guard let activity else { return }
|
||||
trackedDeviceID = nil
|
||||
self.activity = nil
|
||||
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
||||
}
|
||||
|
||||
private static func contentState(from state: LevelState) -> LevelActivityAttributes.ContentState {
|
||||
LevelActivityAttributes.ContentState(
|
||||
pitch: state.pitch,
|
||||
roll: state.roll,
|
||||
isLevel: state.isLevel,
|
||||
instruction: state.instruction,
|
||||
isCalibrated: state.isCalibrated,
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,29 @@ import Foundation
|
||||
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
|
||||
/// Fahrzeug und ohne Bluetooth prüfen lassen.
|
||||
///
|
||||
/// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist:
|
||||
/// Auf zwei Wegen einzuschalten:
|
||||
///
|
||||
/// xcrun simctl launch --terminate-running-process \
|
||||
/// booted de.fritob.CamperMonitor
|
||||
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
||||
/// * Schalter in den Einstellungen (`SettingsView`) – in jeder
|
||||
/// Build-Konfiguration verfügbar, auch in TestFlight- und
|
||||
/// App-Store-Builds. Der Weg für jeden lokal installierten Build ohne
|
||||
/// Xcode-Verbindung. Wirkt erst nach einem Neustart der App, weil
|
||||
/// `DeviceStore` und `BluetoothManager` den Stand nur beim Start lesen.
|
||||
/// * Umgebungsvariable `CAMPER_DEMO=1` beim Start – nur in Debug-Builds,
|
||||
/// praktisch im Simulator:
|
||||
///
|
||||
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
|
||||
/// xcrun simctl launch --terminate-running-process \
|
||||
/// booted de.fritob.VanControl
|
||||
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
||||
enum DemoData {
|
||||
|
||||
/// Schlüssel für den Einstellungen-Schalter, siehe `SettingsView`.
|
||||
static let enabledKey = "demoModeEnabled"
|
||||
|
||||
static var isEnabled: Bool {
|
||||
#if DEBUG
|
||||
return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
|
||||
#else
|
||||
return false
|
||||
if ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" { return true }
|
||||
#endif
|
||||
return UserDefaults.standard.bool(forKey: enabledKey)
|
||||
}
|
||||
|
||||
static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen",
|
||||
@@ -34,9 +42,9 @@ enum DemoData {
|
||||
name: "Ladebooster", role: .chargeBooster, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000B1")!)
|
||||
|
||||
static let solar = ConfiguredDevice(
|
||||
static let victronSolar = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-000000000050")!,
|
||||
name: "Solar Dach", role: .solarCharger, profileID: Profile.defaultID,
|
||||
name: "Solar Dach", role: .victronSolarCharger, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-000000000051")!)
|
||||
|
||||
static let battery = ConfiguredDevice(
|
||||
@@ -44,9 +52,9 @@ enum DemoData {
|
||||
name: "Bulltron 200 Ah", role: .bms, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!)
|
||||
|
||||
static let caravanSolar = ConfiguredDevice(
|
||||
static let caravanVictronSolar = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C3")!,
|
||||
name: "Solar Wohnwagen", role: .solarCharger, profileID: secondProfile.id,
|
||||
name: "Solar Wohnwagen", role: .victronSolarCharger, profileID: secondProfile.id,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!)
|
||||
|
||||
static let fridge = ConfiguredDevice(
|
||||
@@ -59,8 +67,13 @@ enum DemoData {
|
||||
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!)
|
||||
|
||||
static let votronicSolar = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000D0")!,
|
||||
name: "VotronicSolarESP", role: .votronicSolar, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!)
|
||||
|
||||
static var devices: [ConfiguredDevice] {
|
||||
[solar, booster, battery, fridge, level, caravanSolar]
|
||||
[victronSolar, booster, battery, fridge, level, votronicSolar, caravanVictronSolar]
|
||||
}
|
||||
|
||||
/// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
|
||||
@@ -83,10 +96,24 @@ enum DemoData {
|
||||
return state
|
||||
}
|
||||
|
||||
/// Mittags, gute Sonne.
|
||||
static var votronicSolarESPState: VotronicSolarESPState {
|
||||
var state = VotronicSolarESPState()
|
||||
state.batteryVoltage = 13.9
|
||||
state.pvVoltage = 19.4
|
||||
state.pvCurrent = 6.2
|
||||
state.pvPower = 120
|
||||
state.controllerTemperature = 34
|
||||
state.isBatteryCharging = true
|
||||
state.isControllerActive = true
|
||||
state.isCurrentLimited = false
|
||||
return state
|
||||
}
|
||||
|
||||
static func snapshots() -> [DeviceSnapshot] {
|
||||
var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58)
|
||||
solarSnapshot.state = "Konstantspannung (Absorption)"
|
||||
solarSnapshot.metrics = [
|
||||
var victronSolarSnapshot = DeviceSnapshot(deviceID: victronSolar.id, timestamp: Date(), rssi: -58)
|
||||
victronSolarSnapshot.state = "Konstantspannung (Absorption)"
|
||||
victronSolarSnapshot.metrics = [
|
||||
Metric("pv_power", "PV-Leistung", 284, unit: "W", precision: 0, primary: true),
|
||||
Metric("battery_power", "Ladeleistung", 262, unit: "W", precision: 0),
|
||||
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
|
||||
@@ -120,7 +147,7 @@ enum DemoData {
|
||||
Metric("cycles", "Ladezyklen", 143, unit: "", precision: 0),
|
||||
]
|
||||
|
||||
var caravanSnapshot = DeviceSnapshot(deviceID: caravanSolar.id, timestamp: Date(), rssi: -77)
|
||||
var caravanSnapshot = DeviceSnapshot(deviceID: caravanVictronSolar.id, timestamp: Date(), rssi: -77)
|
||||
caravanSnapshot.state = "Erhaltung (Float)"
|
||||
caravanSnapshot.metrics = [
|
||||
Metric("pv_power", "PV-Leistung", 62, unit: "W", precision: 0, primary: true),
|
||||
@@ -128,9 +155,10 @@ enum DemoData {
|
||||
Metric("battery_current", "Ladestrom", 4.4, unit: "A", precision: 1),
|
||||
]
|
||||
|
||||
return [solarSnapshot, boosterSnapshot, batterySnapshot,
|
||||
return [victronSolarSnapshot, boosterSnapshot, batterySnapshot,
|
||||
fridgeState.snapshot(deviceID: fridge.id, rssi: -66),
|
||||
levelState.snapshot(deviceID: level.id, rssi: -70),
|
||||
votronicSolarESPState.snapshot(deviceID: votronicSolar.id, rssi: -58),
|
||||
caravanSnapshot]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ final class DeviceStore {
|
||||
private static let devicesKey = "configuredDevices"
|
||||
private static let profilesKey = "profiles"
|
||||
private static let activeProfileKey = "activeProfileID"
|
||||
/// Hierhin wird gerettet, was sich nicht lesen liess – siehe `loadDevices`.
|
||||
private static let unreadableDevicesKey = "configuredDevices.unreadable"
|
||||
private static let fridgeSettingsKey = "lastFridgeSettings"
|
||||
|
||||
/// Alle Geräte über alle Profile hinweg.
|
||||
private(set) var devices: [ConfiguredDevice] = []
|
||||
@@ -16,6 +19,11 @@ final class DeviceStore {
|
||||
|
||||
private(set) var activeProfileID: UUID = Profile.defaultID
|
||||
|
||||
/// Ob beim Start Geräte im Speicher lagen, die sich nicht lesen liessen.
|
||||
/// Ihre Rohdaten sind aufgehoben, statt beim nächsten Speichern
|
||||
/// überschrieben zu werden.
|
||||
private(set) var hasUnreadableDevices = false
|
||||
|
||||
/// Zwischenspeicher, damit nicht bei jedem Advertisement die Keychain
|
||||
/// befragt wird – das passiert bis zu mehrmals pro Sekunde.
|
||||
@ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:]
|
||||
@@ -137,6 +145,36 @@ final class DeviceStore {
|
||||
return bytes
|
||||
}
|
||||
|
||||
// MARK: - Zuletzt bekannter Stand der Kühlbox
|
||||
|
||||
/// Die Kühlbox wird nur verbunden, während ihre Ansicht offen ist. Damit
|
||||
/// die Übersicht trotzdem etwas zeigt, wird der zuletzt gemeldete Stand
|
||||
/// aufgehoben – die Einstellungen, nicht die Messwerte.
|
||||
func lastFridgeSettings(for deviceID: UUID) -> FridgeSettings? {
|
||||
allFridgeSettings()[deviceID.uuidString]
|
||||
}
|
||||
|
||||
/// Der Zeitstempel bedeutet „zuletzt **geändert**“, nicht „zuletzt
|
||||
/// gesehen“: Sonst wanderte er im Sekundentakt und die Übersicht behauptete
|
||||
/// Frische, wo sich nichts getan hat.
|
||||
func setLastFridgeSettings(_ settings: FridgeSettings, for deviceID: UUID) {
|
||||
var all = allFridgeSettings()
|
||||
if var existing = all[deviceID.uuidString] {
|
||||
existing.updated = settings.updated
|
||||
guard existing != settings else { return }
|
||||
}
|
||||
all[deviceID.uuidString] = settings
|
||||
guard let data = try? JSONEncoder().encode(all) else { return }
|
||||
UserDefaults.standard.set(data, forKey: Self.fridgeSettingsKey)
|
||||
}
|
||||
|
||||
private func allFridgeSettings() -> [String: FridgeSettings] {
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.fridgeSettingsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: FridgeSettings].self, from: data)
|
||||
else { return [:] }
|
||||
return decoded
|
||||
}
|
||||
|
||||
// MARK: - Persistenz
|
||||
|
||||
private func load() {
|
||||
@@ -151,9 +189,8 @@ final class DeviceStore {
|
||||
profiles = [.initial]
|
||||
}
|
||||
|
||||
if let data = defaults.data(forKey: Self.devicesKey),
|
||||
let decoded = try? JSONDecoder().decode([ConfiguredDevice].self, from: data) {
|
||||
devices = decoded
|
||||
if let data = defaults.data(forKey: Self.devicesKey) {
|
||||
devices = loadDevices(from: data)
|
||||
}
|
||||
|
||||
if let raw = defaults.string(forKey: Self.activeProfileKey),
|
||||
@@ -171,7 +208,48 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Im Demo-Modus wird nichts geschrieben – sonst überlebten die erfundenen
|
||||
/// Fahrzeuge und Geräte das Ausschalten des Demo-Modus, weil z.B. das
|
||||
/// Ändern der Fahrzeuggrafik oder der Einbaulage ganz normal `save()`
|
||||
/// aufruft.
|
||||
private func save() {
|
||||
guard !DemoData.isEnabled else { return }
|
||||
let defaults = UserDefaults.standard
|
||||
if let data = try? JSONEncoder().encode(devices) {
|
||||
defaults.set(data, forKey: Self.devicesKey)
|
||||
@@ -5,7 +5,7 @@ import Security
|
||||
/// UserDefaults, deshalb Keychain.
|
||||
enum KeychainStore {
|
||||
|
||||
private static let service = "de.fritob.CamperMonitor.victronKeys"
|
||||
private static let service = "de.fritob.VanControl.victronKeys"
|
||||
|
||||
static func setKey(_ hex: String?, for deviceID: UUID) {
|
||||
let account = deviceID.uuidString
|
||||
@@ -0,0 +1,56 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VanControlApp: App {
|
||||
@State private var store: DeviceStore
|
||||
@State private var bluetooth: BluetoothManager
|
||||
@State private var watch: PhoneWatchLink
|
||||
@State private var levelActivity: LevelActivityManager
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
let store = DeviceStore()
|
||||
let bluetooth = BluetoothManager(store: store)
|
||||
let levelActivity = LevelActivityManager()
|
||||
bluetooth.activityManager = levelActivity
|
||||
_store = State(initialValue: store)
|
||||
_bluetooth = State(initialValue: bluetooth)
|
||||
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
|
||||
_levelActivity = State(initialValue: levelActivity)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
DashboardView()
|
||||
.environment(store)
|
||||
.environment(bluetooth)
|
||||
.environment(watch)
|
||||
.environment(levelActivity)
|
||||
.task { watch.activate() }
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
|
||||
// werden, also Funk sparen und beim Zurückkommen neu starten.
|
||||
//
|
||||
// Ausnahme: Solange jemand auf die Uhr schaut, läuft es weiter. Beim
|
||||
// Rangieren liegt das iPhone in der Halterung mit dunklem Bildschirm
|
||||
// – hörte das Funkgerät dann auf, wäre die Anzeige am Handgelenk
|
||||
// genau dann tot, wenn sie gebraucht wird. Die verbundenen Geräte
|
||||
// (Neigungsmesser, BMS, Kühlbox) liefern im Hintergrund weiter; die
|
||||
// Victron-Werbedaten nicht, dafür müsste ohne Filter gesucht werden.
|
||||
watch.isAppInBackground = phase != .active
|
||||
switch phase {
|
||||
case .active:
|
||||
bluetooth.start()
|
||||
case .background:
|
||||
// Läuft gerade eine Live Activity, muss der Neigungsmesser
|
||||
// auch mit dunklem Bildschirm weiter Werte liefern – sonst
|
||||
// friert die Anzeige auf Sperrbildschirm und CarPlay beim
|
||||
// ersten Wegdrücken der App ein.
|
||||
if !watch.wantsLiveUpdates && !levelActivity.isActive { bluetooth.stop() }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ private struct ConfigureDeviceView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name: String = ""
|
||||
@State private var role: DeviceRole = .solarCharger
|
||||
@State private var role: DeviceRole = .victronSolarCharger
|
||||
@State private var key: String = ""
|
||||
|
||||
private var needsKey: Bool { role.transport == .advertisement }
|
||||
@@ -188,13 +188,15 @@ private struct ConfigureDeviceView: View {
|
||||
private func prefill() {
|
||||
if let recordType = discovery.victronRecordType {
|
||||
switch VictronAdvertisement.RecordType(rawValue: recordType) {
|
||||
case .solarCharger: role = .solarCharger
|
||||
case .solarCharger: role = .victronSolarCharger
|
||||
case .dcdcConverter, .orionXS: role = .chargeBooster
|
||||
case .batteryMonitor: role = .batteryMonitor
|
||||
default: role = .solarCharger
|
||||
default: role = .victronSolarCharger
|
||||
}
|
||||
} else if discovery.isLevelSensor {
|
||||
role = .leveling
|
||||
} else if discovery.isVotronicSolarESPSensor {
|
||||
role = .votronicSolar
|
||||
} else if let name = discovery.name?.lowercased(),
|
||||
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
||||
role = .fridge
|
||||
@@ -206,6 +208,8 @@ private struct ConfigureDeviceView: View {
|
||||
// ohnehin darüber unter "Gefunden als".
|
||||
if discovery.isLevelSensor {
|
||||
name = "Nivellierung"
|
||||
} else if discovery.isVotronicSolarESPSensor {
|
||||
name = role.title
|
||||
} else if let advertised = discovery.name, advertised.count <= 20,
|
||||
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
||||
name = advertised
|
||||
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@State private var assistant = AlignmentAssistant()
|
||||
@State private var didAnnounceTarget = false
|
||||
@@ -18,48 +19,18 @@ struct AlignmentAssistantView: View {
|
||||
|
||||
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
||||
|
||||
/// iPhone im Querformat meldet eine kompakte Höhe – das ist das
|
||||
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
|
||||
private var isLandscape: Bool { verticalSizeClass == .compact }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.top, 8)
|
||||
|
||||
switch displayStyle {
|
||||
case .bubble:
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxWidth: 320)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||
}
|
||||
|
||||
if !isLive { disconnectedBanner }
|
||||
|
||||
adviceBanner
|
||||
|
||||
readings
|
||||
|
||||
if let best = assistant.best, let gain = assistant.improvementAtBest,
|
||||
let seconds = assistant.timeSinceBest {
|
||||
bestPointCard(best: best, gain: gain, seconds: seconds)
|
||||
}
|
||||
|
||||
wedgeSection
|
||||
|
||||
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
|
||||
assistant.reset()
|
||||
didAnnounceTarget = false
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.bottom, 24)
|
||||
Group {
|
||||
if isLandscape {
|
||||
landscapeLayout
|
||||
} else {
|
||||
portraitLayout
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Ausrichtungs-Assistent")
|
||||
@@ -95,10 +66,94 @@ struct AlignmentAssistantView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layouts
|
||||
|
||||
private var portraitLayout: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
picker.padding(.top, 8)
|
||||
|
||||
display
|
||||
|
||||
if !isLive { disconnectedBanner }
|
||||
|
||||
adviceBanner
|
||||
|
||||
readings
|
||||
|
||||
if let best = assistant.best, let gain = assistant.improvementAtBest,
|
||||
let seconds = assistant.timeSinceBest {
|
||||
bestPointCard(best: best, gain: gain, seconds: seconds)
|
||||
}
|
||||
|
||||
wedgeSection
|
||||
|
||||
resetButton
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// Anzeige links, alles zum Rangieren Nötige rechts – ohne Scrollen, denn
|
||||
/// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die
|
||||
/// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür
|
||||
/// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar.
|
||||
private var landscapeLayout: some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
VStack(spacing: 8) {
|
||||
picker
|
||||
display
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if !isLive { compactDisconnectedBanner }
|
||||
adviceBanner
|
||||
readings
|
||||
wedgeSection
|
||||
resetButton
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
// MARK: - Bausteine
|
||||
|
||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||
|
||||
private var picker: some View {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var display: some View {
|
||||
switch displayStyle {
|
||||
case .bubble:
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxWidth: isLandscape ? 220 : 320, maxHeight: isLandscape ? 160 : .infinity)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
||||
style: device.vehicleGraphicStyle, compact: isLandscape)
|
||||
}
|
||||
}
|
||||
|
||||
private var resetButton: some View {
|
||||
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
|
||||
assistant.reset()
|
||||
didAnnounceTarget = false
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
|
||||
/// Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem
|
||||
/// Wert, der längst nicht mehr gilt.
|
||||
@@ -120,40 +175,54 @@ struct AlignmentAssistantView: View {
|
||||
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
/// Kurzform für Querformat: derselbe Hinweis in einer Zeile.
|
||||
private var compactDisconnectedBanner: some View {
|
||||
Label("Nicht verbunden – Anzeige steht still", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var adviceBanner: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: assistant.hasReachedTarget
|
||||
? "checkmark.circle.fill" : assistant.trend.symbol)
|
||||
.font(.title)
|
||||
.font(isLandscape ? .title2 : .title)
|
||||
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
|
||||
Text(assistant.advice)
|
||||
.font(.title3.weight(.medium))
|
||||
.font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
.padding(isLandscape ? 10 : 16)
|
||||
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
|
||||
: Color(.secondarySystemGroupedBackground),
|
||||
in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var readings: some View {
|
||||
HStack(spacing: 12) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
reading("Gesamt", assistant.current?.deviation)
|
||||
HStack(spacing: isLandscape ? 8 : 12) {
|
||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||
if !isLandscape {
|
||||
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
private func reading(_ title: String, _ text: String) -> some View {
|
||||
VStack(spacing: isLandscape ? 1 : 4) {
|
||||
Text(text)
|
||||
.font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, isLandscape ? 6 : 12)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@@ -176,42 +245,44 @@ struct AlignmentAssistantView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var wedgeSection: some View {
|
||||
let across = profile?.trackWidth.flatMap { width in
|
||||
state.roll.flatMap { LevelingWedge.across(roll: $0, trackWidth: width) }
|
||||
}
|
||||
let along = profile?.wheelbase.flatMap { base in
|
||||
state.pitch.flatMap { LevelingWedge.along(pitch: $0, wheelbase: base) }
|
||||
}
|
||||
// Gerechnet wird über alle vier Räder auf einmal. Getrennte Angaben für
|
||||
// quer und längs beschreiben dasselbe Fahrzeug und lassen sich nicht
|
||||
// getrennt ausführen: Unter „rechts" liegen zwei Räder, unter „vorne"
|
||||
// auch, und eines davon ist dasselbe.
|
||||
let lift: LevelingLift? = {
|
||||
guard let width = profile?.trackWidth, let base = profile?.wheelbase,
|
||||
let pitch = state.pitch, let roll = state.roll else { return nil }
|
||||
return LevelingLift.compute(pitch: pitch, roll: roll,
|
||||
trackWidth: width, wheelbase: base)
|
||||
}()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
|
||||
Label("Auffahrkeile", systemImage: "triangle.fill")
|
||||
.font(.headline)
|
||||
.font(isLandscape ? .subheadline.weight(.semibold) : .headline)
|
||||
|
||||
if profile?.trackWidth == nil && profile?.wheelbase == nil {
|
||||
if profile?.trackWidth == nil || profile?.wheelbase == nil {
|
||||
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
|
||||
+ "sich beim Fahrzeug hinterlegen.")
|
||||
.font(.callout)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if across == nil && along == nil {
|
||||
} else if let lift, !lift.isNegligible {
|
||||
WheelLiftPlan(lift: lift, isCompact: isLandscape)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
if !isLandscape {
|
||||
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
||||
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Keine Keile nötig.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach([across, along].compactMap { $0 }, id: \.side) { wedge in
|
||||
HStack {
|
||||
Text(wedge.side.text.capitalized)
|
||||
Spacer()
|
||||
Text(String(format: "%.0f cm", wedge.heightInCentimetres))
|
||||
.font(.title3.weight(.semibold).monospacedDigit())
|
||||
}
|
||||
}
|
||||
Text("Höhe unter die tieferstehende Seite, damit das Fahrzeug eben steht.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.padding(isLandscape ? 10 : 16)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
@@ -229,5 +300,3 @@ struct AlignmentAssistantView: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension LevelingWedge.Side: Hashable {}
|
||||
@@ -0,0 +1,241 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten
|
||||
/// Nebenwerte und der Verbindungszustand.
|
||||
struct DeviceCard: View {
|
||||
let device: ConfiguredDevice
|
||||
let snapshot: DeviceSnapshot?
|
||||
let linkState: DeviceLinkState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
|
||||
if isShowingLastSettings, let snapshot {
|
||||
lastSettings(for: snapshot)
|
||||
} else if let snapshot, snapshot.primaryMetric != nil, device.role == .leveling {
|
||||
levelReadout(for: snapshot)
|
||||
} else if let snapshot, let primary = snapshot.primaryMetric {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(primary.formatted)
|
||||
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
||||
.contentTransition(.numericText())
|
||||
Text(primary.unit)
|
||||
.font(.title3.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
|
||||
|
||||
secondaryValues(for: snapshot)
|
||||
} else {
|
||||
Text(placeholderText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
footer
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: device.role.symbol)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(device.name)
|
||||
.font(.headline)
|
||||
Text(device.role.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob hier der zuletzt gestellte Stand steht statt Messwerten.
|
||||
private var isShowingLastSettings: Bool {
|
||||
device.role.connectsOnDemand && linkState != .live
|
||||
}
|
||||
|
||||
/// Die Kachel der Kühlbox, solange sie nicht verbunden ist.
|
||||
///
|
||||
/// Hier steht kein Messwert, sondern was zuletzt eingestellt war – also
|
||||
/// gehört „Soll“ dazu. Ohne das läse man die Zahl als Innentemperatur, und
|
||||
/// genau dieser Irrtum wäre teuer.
|
||||
private func lastSettings(for snapshot: DeviceSnapshot) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(snapshot.metrics.count > 1 ? "Soll links" : "Soll")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(snapshot.primaryMetric?.formatted ?? "–")
|
||||
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
||||
Text(snapshot.primaryMetric?.unit ?? "")
|
||||
.font(.title3.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let right = snapshot.metrics.first(where: { $0.key == "target_right" }),
|
||||
right.value != nil {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Soll rechts")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(right.formattedWithUnit)
|
||||
.font(.title2.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
modeBadge(snapshot.state)
|
||||
}
|
||||
}
|
||||
|
||||
/// Betriebsart als Schild: Auf der Kachel ist Platz, und ob die Box läuft
|
||||
/// oder aus ist, ist die zweite Frage nach dem Sollwert.
|
||||
@ViewBuilder
|
||||
private func modeBadge(_ state: String?) -> some View {
|
||||
if let state {
|
||||
let isOff = state == "Aus"
|
||||
Text(state)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(isOff ? Color.secondary : Color.accentColor)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(isOff ? Color.secondary.opacity(0.15)
|
||||
: Color.accentColor.opacity(0.15),
|
||||
in: .rect(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
/// Für die Nivellierung: Front/Heck und links/rechts nebeneinander, groß,
|
||||
/// ohne "Längsneigung"/"Querneigung"-Beschriftung – die Richtung steht ja
|
||||
/// schon im vorangestellten Buchstaben.
|
||||
private func levelReadout(for snapshot: DeviceSnapshot) -> some View {
|
||||
let pitch = snapshot.metrics.first(where: { $0.key == "pitch" })?.value
|
||||
let roll = snapshot.metrics.first(where: { $0.key == "roll" })?.value
|
||||
return HStack(spacing: 24) {
|
||||
Text(LevelDirectionFormatting.pitchTile(pitch))
|
||||
Text(LevelDirectionFormatting.rollTile(roll))
|
||||
}
|
||||
.font(.system(size: 36, weight: .semibold, design: .rounded))
|
||||
.contentTransition(.numericText())
|
||||
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
|
||||
}
|
||||
|
||||
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
|
||||
let others = snapshot.metrics
|
||||
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
|
||||
.prefix(3)
|
||||
return HStack(spacing: 16) {
|
||||
ForEach(Array(others)) { metric in
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.monospacedDigit()
|
||||
Text(metric.label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footer: some View {
|
||||
if isShowingLastSettings, let snapshot {
|
||||
// Kein Messwert, sondern der zuletzt gestellte Stand. Das gehört
|
||||
// dazugeschrieben, sonst liest man ihn als aktuelle Temperatur.
|
||||
Text(lastSetText(snapshot))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if let fault = snapshot?.fault {
|
||||
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(2)
|
||||
} else if let state = snapshot?.state {
|
||||
// Bei "Aus" ist erst der Grund die eigentliche Information.
|
||||
let reason = snapshot?.offReasons.first
|
||||
Text(reason.map { "\(state) · \($0)" } ?? state)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if case .failed(let message) = linkState {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was unter einem Gerät steht, das nur beim Öffnen verbunden wird.
|
||||
///
|
||||
/// Der Hinweis aufs Verbinden steht vorn: Er erklärt, warum hier kein
|
||||
/// Messwert steht, und das ist die Frage, die sich zuerst stellt.
|
||||
private func lastSetText(_ snapshot: DeviceSnapshot) -> String {
|
||||
"Verbindet erst beim Öffnen · zuletzt gestellt \(relativeUpdate(snapshot.timestamp))"
|
||||
}
|
||||
|
||||
/// „vor 3 Minuten“ statt einer Uhrzeit – auf der Kachel zählt das Alter.
|
||||
private func relativeUpdate(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.locale = Locale(identifier: "de_DE")
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
private var placeholderText: String {
|
||||
switch linkState {
|
||||
case .needsKey: return "Verschlüsselungsschlüssel fehlt – im Detail eintragen."
|
||||
case .failed(let message): return message
|
||||
default:
|
||||
return device.role.connectsOnDemand
|
||||
? "Verbindet erst beim Öffnen."
|
||||
: "Warte auf Daten…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst.
|
||||
struct StatusDot: View {
|
||||
let linkState: DeviceLinkState
|
||||
let isStale: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch linkState {
|
||||
case .live: return isStale ? .orange : .green
|
||||
case .needsKey: return .orange
|
||||
case .failed: return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
if case .live = linkState, isStale { return "Veraltet" }
|
||||
return linkState.label
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@State private var editedName = ""
|
||||
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
|
||||
@@ -37,98 +38,126 @@ struct DeviceDetailView: View {
|
||||
|
||||
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
||||
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||||
|
||||
/// iPhone im Querformat meldet eine kompakte Höhe – das ist das
|
||||
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
|
||||
private var isLandscape: Bool { verticalSizeClass == .compact }
|
||||
|
||||
/// Nur der Neigungsmesser bekommt die Querformat-Sonderbehandlung
|
||||
/// (Verbindungsstatus ans Ende, Anzeige auf Bildschirmhöhe) – andere
|
||||
/// Sensoren behalten ihre bisherige Reihenfolge und Grösse.
|
||||
private var showsCompactLevelLayout: Bool { isLandscape && currentDevice.role == .leveling }
|
||||
|
||||
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
statusSection
|
||||
if needsKeyAttention { keyPrompt }
|
||||
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
|
||||
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
|
||||
// festen Wert – deshalb misst ein GeometryReader die Liste von aussen.
|
||||
GeometryReader { geometry in
|
||||
List {
|
||||
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
|
||||
// sichtbar sein, ohne erst am Verbindungsstatus
|
||||
// vorbeizuscrollen – der rutscht dort ganz ans Ende. Gilt
|
||||
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`.
|
||||
if !showsCompactLevelLayout { statusSection }
|
||||
if needsKeyAttention { keyPrompt }
|
||||
|
||||
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||||
FridgeControls(device: currentDevice, state: fridge)
|
||||
}
|
||||
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||||
FridgeControls(device: currentDevice, state: fridge)
|
||||
}
|
||||
|
||||
if currentDevice.role == .leveling {
|
||||
LevelControls(device: currentDevice,
|
||||
state: bluetooth.levelStates[device.id] ?? LevelState())
|
||||
}
|
||||
if currentDevice.role == .leveling {
|
||||
LevelControls(device: currentDevice,
|
||||
state: bluetooth.levelStates[device.id] ?? LevelState(),
|
||||
availableHeight: geometry.size.height)
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.metrics.isEmpty {
|
||||
Section("Messwerte") {
|
||||
ForEach(snapshot.metrics) { metric in
|
||||
LabeledContent(metric.label) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
||||
if let snapshot, !snapshot.metrics.isEmpty {
|
||||
Section("Messwerte") {
|
||||
ForEach(snapshot.metrics) { metric in
|
||||
LabeledContent(metric.label) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
||||
Section("Verlauf – \(primary.label)") {
|
||||
Chart(samples) { sample in
|
||||
AreaMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint.opacity(0.15))
|
||||
LineMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint)
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
.chartYAxisLabel(primary.unit)
|
||||
.frame(height: 180)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
||||
cellSection(snapshot.cellVoltages)
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.info.isEmpty {
|
||||
Section("Gerät") {
|
||||
ForEach(snapshot.info) { item in
|
||||
LabeledContent(item.label, value: item.value)
|
||||
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
||||
Section("Verlauf – \(primary.label)") {
|
||||
Chart(samples) { sample in
|
||||
AreaMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint.opacity(0.15))
|
||||
LineMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
.foregroundStyle(.tint)
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
.chartYAxisLabel(primary.unit)
|
||||
.frame(height: 180)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let snapshot, snapshot.temperatures.count > 1 {
|
||||
Section("Temperaturen") {
|
||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||
LabeledContent("Fühler \(index + 1)") {
|
||||
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
||||
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
||||
cellSection(snapshot.cellVoltages)
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.info.isEmpty {
|
||||
Section("Gerät") {
|
||||
ForEach(snapshot.info) { item in
|
||||
LabeledContent(item.label, value: item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if currentDevice.role.transport == .advertisement {
|
||||
if showsTechnicalDetails { diagnosticsSection }
|
||||
} else if showsTechnicalDetails {
|
||||
bmsDiagnosticsSection
|
||||
}
|
||||
if showsCompactLevelLayout { statusSection }
|
||||
|
||||
settingsSection
|
||||
}
|
||||
.navigationTitle(currentDevice.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onDisappear(perform: saveName)
|
||||
.onAppear {
|
||||
editedName = currentDevice.name
|
||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||
}
|
||||
.confirmationDialog("Gerät entfernen?",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible) {
|
||||
Button("Entfernen", role: .destructive) {
|
||||
store.remove(device)
|
||||
bluetooth.refreshConfiguration()
|
||||
dismiss()
|
||||
if let snapshot, snapshot.temperatures.count > 1 {
|
||||
Section("Temperaturen") {
|
||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||
LabeledContent("Fühler \(index + 1)") {
|
||||
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if currentDevice.role.transport == .advertisement {
|
||||
if showsTechnicalDetails { diagnosticsSection }
|
||||
} else if showsTechnicalDetails {
|
||||
bmsDiagnosticsSection
|
||||
}
|
||||
|
||||
settingsSection
|
||||
}
|
||||
.navigationTitle(currentDevice.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onDisappear {
|
||||
saveName()
|
||||
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
||||
// Verbindung meldet sich an ihrem Display an.
|
||||
bluetooth.endSession(for: currentDevice)
|
||||
}
|
||||
.onAppear {
|
||||
editedName = currentDevice.name
|
||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||
bluetooth.beginSession(for: currentDevice)
|
||||
}
|
||||
.confirmationDialog("Gerät entfernen?",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible) {
|
||||
Button("Entfernen", role: .destructive) {
|
||||
store.remove(device)
|
||||
bluetooth.refreshConfiguration()
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||||
}
|
||||
} message: {
|
||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ struct LevelSetupView: View {
|
||||
store.devices.first { $0.id == device.id } ?? device
|
||||
}
|
||||
|
||||
private let graphicColumns = [GridItem(.adaptive(minimum: 76), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
@@ -80,6 +82,44 @@ struct LevelSetupView: View {
|
||||
} footer: {
|
||||
Text(calibrationHint)
|
||||
}
|
||||
|
||||
Section {
|
||||
LazyVGrid(columns: graphicColumns, spacing: 12) {
|
||||
ForEach(VehicleGraphicStyle.allCases) { style in
|
||||
Button {
|
||||
var updated = currentDevice
|
||||
updated.vehicleGraphicStyle = style
|
||||
store.update(updated)
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(style.sideImageName)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.padding(10)
|
||||
.frame(width: 60, height: 60)
|
||||
.background(currentDevice.vehicleGraphicStyle == style
|
||||
? Color.accentColor.opacity(0.18)
|
||||
: Color.secondary.opacity(0.08),
|
||||
in: .rect(cornerRadius: 12))
|
||||
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
|
||||
? Color.accentColor : Color.primary)
|
||||
Text(style.title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
|
||||
? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(style.title)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} header: {
|
||||
Text("Fahrzeuggrafik")
|
||||
} footer: {
|
||||
Text("Wird in der Fahrzeug-Darstellung des Neigungsmessers verwendet. "
|
||||
+ "Weitere Grafiken folgen.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Neigungsmesser einrichten")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -104,45 +104,76 @@ struct LevelBubble: View {
|
||||
struct LevelControls: View {
|
||||
let device: ConfiguredDevice
|
||||
let state: LevelState
|
||||
/// Höhe der umgebenden Liste, von `DeviceDetailView` per `GeometryReader`
|
||||
/// gemessen. Nur im Querformat gebraucht, um die Anzeige auf
|
||||
/// Bildschirmhöhe zu bringen statt sie auf einen festen Wert zu kappen.
|
||||
var availableHeight: CGFloat?
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(LevelActivityManager.self) private var levelActivity
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@State private var showAssistant = false
|
||||
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||||
|
||||
/// iPhone im Querformat meldet eine kompakte Höhe – das ist das
|
||||
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
|
||||
private var isLandscape: Bool { verticalSizeClass == .compact }
|
||||
|
||||
/// Verfügbare Höhe abzüglich grober Reserve für Listenränder und die
|
||||
/// eigene vertikale Auffüllung – genug, um praktisch den ganzen
|
||||
/// Bildschirm zu nutzen, ohne über den unteren Rand hinauszuschiessen.
|
||||
private var landscapeContentHeight: CGFloat {
|
||||
max(150, (availableHeight ?? 350) - 56)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
if isLandscape {
|
||||
HStack(alignment: .center, spacing: 20) {
|
||||
display
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
picker
|
||||
if let instruction = state.instruction {
|
||||
Label(instruction,
|
||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(height: landscapeContentHeight)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
picker
|
||||
|
||||
display
|
||||
|
||||
if let instruction = state.instruction {
|
||||
Label(instruction,
|
||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
HStack(spacing: 24) {
|
||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
switch displayStyle {
|
||||
case .bubble:
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxHeight: 220)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||
}
|
||||
|
||||
if let instruction = state.instruction {
|
||||
Label(instruction,
|
||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
HStack(spacing: 24) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -177,6 +208,29 @@ struct LevelControls: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(isOn: liveActivityBinding) {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Live Activity")
|
||||
Text("Sperrbildschirm, Dynamic Island und CarPlay")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: "widget.small")
|
||||
}
|
||||
}
|
||||
// Ohne laufende Messwerte gäbe es nichts anzuzeigen – und beim
|
||||
// Umschalten hätte die Activity sofort einen veralteten Stand.
|
||||
.disabled(!isLive || !state.hasReading)
|
||||
} footer: {
|
||||
Text(!isLive
|
||||
? "Braucht laufende Messwerte. Der Neigungsmesser ist gerade nicht verbunden."
|
||||
: "Zeigt die Neigung, solange sie läuft – auch bei gesperrtem Bildschirm. "
|
||||
+ "Ist das iPhone mit CarPlay verbunden, erscheint sie automatisch auch dort.")
|
||||
}
|
||||
|
||||
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
|
||||
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
|
||||
Section {
|
||||
@@ -194,17 +248,52 @@ struct LevelControls: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var picker: some View {
|
||||
Picker("Darstellung", selection: $displayStyle) {
|
||||
ForEach(LevelDisplayStyle.allCases) { style in
|
||||
Text(style.title).tag(style)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var display: some View {
|
||||
switch displayStyle {
|
||||
case .bubble:
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxHeight: isLandscape ? landscapeContentHeight : 220)
|
||||
case .vehicle:
|
||||
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
||||
style: device.vehicleGraphicStyle, compact: isLandscape,
|
||||
compactPanelHeight: max(60, landscapeContentHeight - 40))
|
||||
}
|
||||
}
|
||||
|
||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||
|
||||
private var liveActivityBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { levelActivity.isActive(for: device.id) },
|
||||
set: { isOn in
|
||||
if isOn {
|
||||
levelActivity.start(deviceID: device.id, deviceName: device.name, state: state)
|
||||
} else {
|
||||
levelActivity.end()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Was auf der Einrichtungsseite zu holen ist – der Nullpunkt zuerst, denn
|
||||
/// ohne ihn stimmt die Anzeige nicht.
|
||||
private var setupSummary: String {
|
||||
state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
private func reading(_ title: String, _ text: String) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
Text(text)
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
@@ -145,7 +145,7 @@ private struct ProfileEditView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var symbol = "box.truck"
|
||||
@State private var symbol = "suv.side"
|
||||
@State private var trackWidth = ""
|
||||
@State private var wheelbase = ""
|
||||
|
||||
@@ -4,11 +4,24 @@ import SwiftUI
|
||||
/// betrifft, steht bei diesem Gerät.
|
||||
struct SettingsView: View {
|
||||
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
||||
@AppStorage(DemoData.enabledKey) private var demoModeEnabled = false
|
||||
@Environment(PhoneWatchLink.self) private var watch
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Toggle("Demo-Modus", isOn: $demoModeEnabled)
|
||||
} header: {
|
||||
Text("Entwicklung")
|
||||
} footer: {
|
||||
Text("Füllt die App mit erfundenen Fahrzeugen und Messwerten, um "
|
||||
+ "Ansichten ohne echte Sensoren zu prüfen. Wirkt erst nach "
|
||||
+ "einem Neustart der App – einmal beenden (im App-Umschalter "
|
||||
+ "nach oben wischen) und wieder öffnen.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
|
||||
} header: {
|
||||
@@ -21,6 +34,16 @@ struct SettingsView: View {
|
||||
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
|
||||
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.")
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Apple Watch", value: watch.statusText)
|
||||
} footer: {
|
||||
Text("Die Uhr zeigt die Werte dieses iPhones und steuert die "
|
||||
+ "Kühlbox darüber – sie funkt nicht selbst zu den Geräten. "
|
||||
+ "Dafür muss diese App laufen; im Hintergrund liefern "
|
||||
+ "Neigungsmesser, BMS und Kühlbox weiter, die "
|
||||
+ "Victron-Werbedaten erst wieder im Vordergrund.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Einstellungen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Zeigt die Neigung am Fahrzeug selbst, statt an einer abstrakten Blase.
|
||||
///
|
||||
/// Zwei Ansichten, jede um ihre Achse gekippt:
|
||||
///
|
||||
/// * **Seitenansicht** für die Längsneigung. Die Front zeigt nach links, das
|
||||
/// Heck nach rechts.
|
||||
/// * **Heckansicht** für die Querneigung. Sie teilt die Blickrichtung des
|
||||
/// Fahrers, links im Bild ist also links am Fahrzeug – bei einer
|
||||
/// Frontansicht wäre es seitenverkehrt.
|
||||
///
|
||||
/// In beiden Fällen wird gegen den mathematischen Drehsinn gekippt: Steht das
|
||||
/// Heck höher, muss die rechte Bildseite nach oben.
|
||||
struct VehicleTiltView: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
var style: VehicleGraphicStyle = .vanster
|
||||
/// Für Querformat: beide Ansichten nebeneinander statt untereinander,
|
||||
/// kleinere Schrift, ohne Überhöhungs-Hinweis – muss ohne Scrollen in die
|
||||
/// Bildschirmhöhe passen.
|
||||
var compact = false
|
||||
/// Bildhöhe je Panel im Querformat – vom Aufrufer an die tatsächlich
|
||||
/// verfügbare Bildschirmhöhe angepasst, statt fest verdrahtet.
|
||||
var compactPanelHeight: CGFloat = 62
|
||||
|
||||
var body: some View {
|
||||
if compact {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
|
||||
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
|
||||
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
|
||||
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 20) {
|
||||
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
|
||||
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
|
||||
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
|
||||
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
|
||||
|
||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
||||
VehicleTilt.exaggeration))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func tiltPanel(image: String,
|
||||
angle: Double?,
|
||||
title: String,
|
||||
lowerLabel: String,
|
||||
upperLabel: String,
|
||||
aspect: Double) -> some View {
|
||||
VStack(spacing: compact ? 4 : 8) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit())
|
||||
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||
}
|
||||
|
||||
ZStack {
|
||||
// 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: compact ? compactPanelHeight : 110)
|
||||
|
||||
HStack {
|
||||
Text(lowerLabel)
|
||||
Spacer()
|
||||
Text(upperLabel)
|
||||
}
|
||||
.font(compact ? .system(size: 9) : .caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Umschalter zwischen den beiden Darstellungen, gemerkt über Starts hinweg.
|
||||
enum LevelDisplayStyle: String, CaseIterable, Identifiable {
|
||||
case bubble
|
||||
case vehicle
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .bubble: return "Libelle"
|
||||
case .vehicle: return "Fahrzeug"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Die Gegenstelle zur Apple Watch auf dem iPhone.
|
||||
///
|
||||
/// Das iPhone bleibt das Funkgerät (warum, steht bei `WatchLink`) und reicht
|
||||
/// den fertigen Stand weiter. Zwei Wege, je nachdem, ob die Uhr gerade
|
||||
/// hinschaut:
|
||||
///
|
||||
/// * **Anwendungskontext** – ein Datensatz, den watchOS aufhebt und auch dann
|
||||
/// zustellt, wenn die App auf der Uhr gerade nicht läuft. Damit steht beim
|
||||
/// Aufwecken sofort etwas da statt eines leeren Bildschirms.
|
||||
/// * **Nachricht** – nur solange die Uhr erreichbar ist und gemeldet hat, dass
|
||||
/// sie hinschaut. Dafür im halben Sekundentakt, was beim Ausrichten zählt.
|
||||
///
|
||||
/// Ohne gekoppelte Uhr läuft hier gar nichts: kein Timer, keine Sitzung.
|
||||
@Observable
|
||||
final class PhoneWatchLink: NSObject {
|
||||
|
||||
/// Ob die Uhr gerade Werte sehen will. Solange das gilt, hält die App das
|
||||
/// Bluetooth auch im Hintergrund am Leben.
|
||||
private(set) var wantsLiveUpdates = false
|
||||
/// Nur für die Anzeige in den Einstellungen.
|
||||
private(set) var statusText = "Keine Uhr gekoppelt"
|
||||
|
||||
private let store: DeviceStore
|
||||
private let bluetooth: BluetoothManager
|
||||
|
||||
private var session: WCSession?
|
||||
private var timer: Timer?
|
||||
/// Bis wann der Wunsch der Uhr nach schnellen Werten gilt.
|
||||
private var liveUntil = Date.distantPast
|
||||
private var lastSentContent: WatchPayload?
|
||||
private var lastContextSent = Date.distantPast
|
||||
|
||||
/// Setzt die App, damit beim Ablauf des Wunsches im Hintergrund das Funken
|
||||
/// wieder eingestellt wird.
|
||||
var isAppInBackground = false
|
||||
|
||||
init(store: DeviceStore, bluetooth: BluetoothManager) {
|
||||
self.store = store
|
||||
self.bluetooth = bluetooth
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else {
|
||||
statusText = "Dieses Gerät kann keine Uhr koppeln"
|
||||
return
|
||||
}
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
self.session = session
|
||||
session.activate()
|
||||
}
|
||||
|
||||
// MARK: - Takt
|
||||
|
||||
private func updateTimer() {
|
||||
let wanted = session?.activationState == .activated && session?.isPaired == true
|
||||
if wanted && timer == nil {
|
||||
// Der schnelle Takt ist der Grundtakt; ob wirklich gesendet wird,
|
||||
// entscheidet `tick` – so muss der Timer nie umgestellt werden.
|
||||
let timer = Timer(timeInterval: WatchLink.liveInterval, repeats: true) { [weak self] _ in
|
||||
self?.tick()
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.timer = timer
|
||||
} else if !wanted {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let live = Date() < liveUntil
|
||||
if live != wantsLiveUpdates {
|
||||
wantsLiveUpdates = live
|
||||
// Der Wunsch ist gerade abgelaufen und niemand schaut mehr hin:
|
||||
// dann darf das Funkgerät im Hintergrund auch wieder ruhen.
|
||||
if !live && isAppInBackground { bluetooth.stop() }
|
||||
}
|
||||
|
||||
// Den Stand erst zusammenstellen, wenn er auch rausgeht: im Ruhetakt
|
||||
// ist das nur jeder vierte Aufruf, und zusammengestellt wird auf dem
|
||||
// Hauptthread.
|
||||
let isContextDue = Date().timeIntervalSince(lastContextSent) >= WatchLink.idleInterval
|
||||
guard let session, live || isContextDue else { return }
|
||||
guard let payload = buildPayload() else { return }
|
||||
|
||||
if live && session.isReachable {
|
||||
send(payload, over: session)
|
||||
return
|
||||
}
|
||||
|
||||
guard isContextDue,
|
||||
lastSentContent?.hasSameContent(as: payload) != true,
|
||||
session.isPaired, session.isWatchAppInstalled else { return }
|
||||
do {
|
||||
try session.updateApplicationContext(payload.message())
|
||||
lastSentContent = payload
|
||||
lastContextSent = Date()
|
||||
} catch {
|
||||
statusText = "Uhr: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Nachrichten werden ohne Antwort verschickt: Ein verlorener Datensatz ist
|
||||
/// in einer halben Sekunde ohnehin überholt, ein Wiederholungsversuch käme
|
||||
/// zu spät und stünde nur dem nächsten im Weg.
|
||||
private func send(_ payload: WatchPayload, over session: WCSession) {
|
||||
guard let message = try? payload.message() else { return }
|
||||
session.sendMessage(message, replyHandler: nil, errorHandler: nil)
|
||||
lastSentContent = payload
|
||||
}
|
||||
|
||||
/// Nach Änderungen an Geräten oder Profilen aufrufen, damit die Uhr nicht
|
||||
/// bis zum nächsten Takt einen überholten Fahrzeugnamen zeigt.
|
||||
func sendNow() {
|
||||
lastSentContent = nil
|
||||
lastContextSent = .distantPast
|
||||
tick()
|
||||
}
|
||||
|
||||
// MARK: - Stand zusammenstellen
|
||||
|
||||
private func buildPayload() -> WatchPayload? {
|
||||
guard let profile = store.activeProfile else { return nil }
|
||||
let devices = store.activeDevices.map { device in
|
||||
WatchDevice(id: device.id,
|
||||
name: device.name,
|
||||
role: device.role,
|
||||
link: bluetooth.linkStates[device.id] ?? .searching,
|
||||
snapshot: bluetooth.snapshots[device.id],
|
||||
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
|
||||
orientation: device.role == .leveling ? device.sensorOrientation : nil,
|
||||
fridge: device.role == .fridge ? fridgeState(for: device) : nil)
|
||||
}
|
||||
return WatchPayload(generatedAt: Date(),
|
||||
profile: profile,
|
||||
isRadioReady: bluetooth.isBluetoothReady,
|
||||
radioStatus: bluetooth.bluetoothStatusText,
|
||||
devices: devices)
|
||||
}
|
||||
|
||||
/// Live, solange die Box verbunden ist – sonst der zuletzt gestellte Stand
|
||||
/// aus dem Speicher, ohne Messwerte.
|
||||
private func fridgeState(for device: ConfiguredDevice) -> WatchFridge? {
|
||||
if linkStateIsLive(device), let state = bluetooth.fridgeStates[device.id] {
|
||||
return WatchFridge(state)
|
||||
}
|
||||
return store.lastFridgeSettings(for: device.id).map(WatchFridge.init)
|
||||
}
|
||||
|
||||
private func linkStateIsLive(_ device: ConfiguredDevice) -> Bool {
|
||||
bluetooth.linkStates[device.id] == .live
|
||||
}
|
||||
|
||||
// MARK: - Befehle von der Uhr
|
||||
|
||||
private func handle(_ command: WatchCommand) {
|
||||
if command.wantsLiveUpdates {
|
||||
liveUntil = Date().addingTimeInterval(WatchLink.liveLease)
|
||||
wantsLiveUpdates = true
|
||||
// Die Uhr kann das iPhone aus dem Hintergrund wecken. Dann steht
|
||||
// das Funkgerät und muss erst wieder anlaufen, sonst geht der
|
||||
// Stellbefehl ins Leere.
|
||||
if isAppInBackground { bluetooth.start() }
|
||||
}
|
||||
|
||||
switch command {
|
||||
case .hello:
|
||||
break
|
||||
case .fridgePower(let device, let on):
|
||||
bluetooth.setFridgePower(on, for: device)
|
||||
case .fridgeEco(let device, let eco):
|
||||
bluetooth.setFridgeEco(eco, for: device)
|
||||
case .fridgeLock(let device, let locked):
|
||||
bluetooth.setFridgeLock(locked, for: device)
|
||||
case .fridgeTarget(let device, let zone, let value):
|
||||
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
|
||||
case .fridgeSession(let deviceID, let wanted):
|
||||
guard let device = store.devices.first(where: { $0.id == deviceID }) else { break }
|
||||
if wanted {
|
||||
bluetooth.beginSession(for: device)
|
||||
} else {
|
||||
bluetooth.endSession(for: device)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension PhoneWatchLink: WCSessionDelegate {
|
||||
|
||||
func session(_ session: WCSession,
|
||||
activationDidCompleteWith state: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
DispatchQueue.main.async {
|
||||
if let error {
|
||||
self.statusText = "Uhr: \(error.localizedDescription)"
|
||||
} else {
|
||||
self.updateStatus(session)
|
||||
}
|
||||
self.updateTimer()
|
||||
self.sendNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStatus(_ session: WCSession) {
|
||||
if !session.isPaired {
|
||||
statusText = "Keine Uhr gekoppelt"
|
||||
} else if !session.isWatchAppInstalled {
|
||||
statusText = "Uhr gekoppelt, App dort nicht installiert"
|
||||
} else {
|
||||
statusText = "Uhr bereit"
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let command = WatchCommand.decode(from: message) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.handle(command)
|
||||
// Auf einen Stellbefehl gleich den frischen Stand hinterherschicken.
|
||||
// Der zeigt zwar noch den alten Wert – die Box antwortet erst –,
|
||||
// aber die Uhr weiss dadurch sofort, dass der Befehl ankam.
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mit Antwort, wenn die Uhr auf eine wartet: das ist der Weg, auf dem sie
|
||||
/// beim Öffnen ohne Verzögerung an einen Stand kommt.
|
||||
func session(_ session: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
if let command = WatchCommand.decode(from: message) { self.handle(command) }
|
||||
replyHandler((try? self.buildPayload()?.message()) ?? [:])
|
||||
}
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async { self.sendNow() }
|
||||
}
|
||||
|
||||
/// Die Uhr wurde gekoppelt, gewechselt oder die App dort installiert.
|
||||
func sessionWatchStateDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.updateStatus(session)
|
||||
self.updateTimer()
|
||||
self.sendNow()
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Wechsel auf eine andere Uhr muss die Sitzung neu aktiviert werden.
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Umformen
|
||||
|
||||
extension WatchFridge {
|
||||
/// Aus dem Zustand der Box wird nur übernommen, was die Uhr anzeigt oder
|
||||
/// stellt – der Rohdatensatz bleibt auf dem iPhone.
|
||||
init(_ state: AlpicoolState) {
|
||||
let range = state.targetRange
|
||||
self.init(isLive: true,
|
||||
updated: Date(),
|
||||
isPoweredOn: state.isPoweredOn,
|
||||
isEco: state.isEco,
|
||||
isLocked: state.isLocked,
|
||||
isDualZone: state.isDualZone,
|
||||
unitSymbol: state.unitSymbol,
|
||||
leftTarget: state.leftTarget,
|
||||
leftCurrent: state.leftCurrent,
|
||||
rightTarget: state.isDualZone ? state.rightTarget : nil,
|
||||
rightCurrent: state.isDualZone ? state.rightCurrent : nil,
|
||||
minTarget: range.lowerBound,
|
||||
maxTarget: range.upperBound,
|
||||
batteryVolts: state.batteryVolts)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// Komplikation fürs Zifferblatt: ein Tipp, und die Nivellierung steht da.
|
||||
///
|
||||
/// Bewusst **ohne Messwert**. Eine Komplikation läuft in einem eigenen Prozess
|
||||
/// und käme an die Werte nur über eine App-Gruppe – die verlangt eine bezahlte
|
||||
/// Entwicklermitgliedschaft. Ein Wert, der beim Blick aufs Zifferblatt
|
||||
/// womöglich Stunden alt ist, wäre ausserdem schlimmer als keiner: Man würde
|
||||
/// ihm glauben.
|
||||
///
|
||||
/// Die Komplikation tut deshalb genau eine Sache, und die zuverlässig: Sie
|
||||
/// öffnet die App auf der Nivellierung. Von dort sind die Werte live, weil die
|
||||
/// Uhr den Sensor selbst anfunkt.
|
||||
struct LevelComplicationEntry: TimelineEntry {
|
||||
let date: Date
|
||||
}
|
||||
|
||||
/// Nichts zu planen: Die Anzeige ändert sich nie, also ein einziger Eintrag
|
||||
/// ohne Nachschub. Das kostet die Uhr auch nichts.
|
||||
struct LevelComplicationProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> LevelComplicationEntry {
|
||||
LevelComplicationEntry(date: .now)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context,
|
||||
completion: @escaping (LevelComplicationEntry) -> Void) {
|
||||
completion(LevelComplicationEntry(date: .now))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context,
|
||||
completion: @escaping (Timeline<LevelComplicationEntry>) -> Void) {
|
||||
completion(Timeline(entries: [LevelComplicationEntry(date: .now)], policy: .never))
|
||||
}
|
||||
}
|
||||
|
||||
struct LevelComplicationView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryInline:
|
||||
// Eine Zeile Text, mehr lässt diese Familie nicht zu.
|
||||
Label("Nivellierung", systemImage: "level")
|
||||
|
||||
case .accessoryCorner:
|
||||
Image(systemName: "level")
|
||||
.font(.title2)
|
||||
.widgetLabel("Nivellieren")
|
||||
|
||||
case .accessoryRectangular:
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "level")
|
||||
.font(.title3)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Nivellierung")
|
||||
.font(.headline)
|
||||
Text("Antippen zum Ausrichten")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Rund – auf dem Zifferblatt der knappste Platz, also nur das
|
||||
// Symbol, gross genug zum Treffen.
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
Image(systemName: "level")
|
||||
.font(.title2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct LevelComplication: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "de.fritob.VanControl.complication.level",
|
||||
provider: LevelComplicationProvider()) { _ in
|
||||
LevelComplicationView()
|
||||
// Der Tipp landet nicht irgendwo in der App, sondern auf der
|
||||
// Nivellierung – dafür wertet die App diese Adresse aus.
|
||||
.widgetURL(WatchDeepLink.level)
|
||||
.containerBackground(.clear, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("Nivellierung")
|
||||
.description("Öffnet die Nivellierung direkt vom Zifferblatt.")
|
||||
.supportedFamilies([.accessoryCircular, .accessoryCorner,
|
||||
.accessoryInline, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,288 @@
|
||||
//
|
||||
// VanControlLiveActivity.swift
|
||||
// VanControlLiveActivity
|
||||
//
|
||||
// Created by Christopher Helberg on 02.09.26.
|
||||
//
|
||||
|
||||
import ActivityKit
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
private struct LevelIcon: View {
|
||||
let isLevel: Bool
|
||||
let hasReading: Bool
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: hasReading ? (isLevel ? "checkmark.circle.fill" : "level") : "level")
|
||||
.foregroundStyle(hasReading ? (isLevel ? .green : .orange) : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
|
||||
private let levelTolerance: Double = 0.5
|
||||
|
||||
/// Grün bis Toleranz, orange bis 2°, sonst rot – dieselbe Skala für Blase
|
||||
/// und Text, wie in der App (`VehicleTilt.colour`). Hier dupliziert, weil
|
||||
/// die Extension `Shared/` nicht mitkompiliert (siehe
|
||||
/// `LevelActivityAttributes.swift`).
|
||||
private func levelColour(forDeviation deviation: Double?) -> Color {
|
||||
guard let deviation else { return .white.opacity(0.4) }
|
||||
if deviation <= levelTolerance { return .green }
|
||||
if deviation <= 2 { return .orange }
|
||||
return .red
|
||||
}
|
||||
|
||||
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
|
||||
/// Dynamic Island – dieselbe Optik wie `LevelBubble` in der App, aber ohne
|
||||
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
|
||||
/// Extension nicht mitkompiliert wird.
|
||||
private struct LevelBubbleGlyph: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
let isLevel: Bool
|
||||
var diameter: CGFloat = 54
|
||||
|
||||
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
|
||||
private let range: Double = 6
|
||||
|
||||
private var hasReading: Bool { pitch != nil || roll != nil }
|
||||
|
||||
private var bubbleColor: Color {
|
||||
guard let pitch, let roll else { return .white.opacity(0.4) }
|
||||
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let radius = diameter / 2
|
||||
let bubble = diameter * 0.22
|
||||
let travel = radius - bubble / 2 - 3
|
||||
let offsetX = clamped(roll) / range * travel
|
||||
let offsetY = clamped(pitch) / range * travel
|
||||
let toleranceRadius = max(levelTolerance / range * travel, bubble * 0.55)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.1))
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.3), lineWidth: 1)
|
||||
Circle()
|
||||
.strokeBorder(isLevel ? Color.green : Color.white.opacity(0.25),
|
||||
lineWidth: isLevel ? 2 : 1)
|
||||
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
|
||||
Circle()
|
||||
.fill(bubbleColor)
|
||||
.frame(width: bubble, height: bubble)
|
||||
// Positiver Pitch heisst: Heck steht höher, die Blase wandert
|
||||
// also nach unten – wie bei der echten Wasserwaage in der App.
|
||||
.offset(x: offsetX, y: offsetY)
|
||||
.opacity(hasReading ? 1 : 0.3)
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
}
|
||||
|
||||
private func clamped(_ value: Double?) -> Double {
|
||||
guard let value else { return 0 }
|
||||
return min(max(value, -range), range)
|
||||
}
|
||||
}
|
||||
|
||||
struct VanControlLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: LevelActivityAttributes.self) { context in
|
||||
LevelActivityContent(attributes: context.attributes, state: context.state)
|
||||
.activityBackgroundTint(Color(white: 0.08))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
|
||||
} dynamicIsland: { context in
|
||||
// Die Dynamic Island bleibt bewusst so klein wie möglich – Libelle
|
||||
// und Zahlenwerte gehören auf Sperrbildschirm/CarPlay, hier reicht
|
||||
// ein Blick auf Icon und, aufgeklappt, die Kurzanweisung.
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||||
.font(.title3)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
Text(context.state.instruction ?? context.attributes.deviceName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(context.state.isLevel ? .green : .primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
} compactLeading: {
|
||||
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||||
} compactTrailing: {
|
||||
EmptyView()
|
||||
} minimal: {
|
||||
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||||
}
|
||||
.keylineTint(context.state.isLevel ? .green : .orange)
|
||||
}
|
||||
.supplementalActivityFamilies([.small])
|
||||
}
|
||||
}
|
||||
|
||||
/// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und
|
||||
/// Kompaktansicht.
|
||||
///
|
||||
/// Ohne `.small`-Familie fällt CarPlay (wie die Smart Stack der Uhr) auf
|
||||
/// `compactLeading`/`compactTrailing` der Dynamic Island zurück – dort steht
|
||||
/// nur das generische Symbol und, weil `compactTrailing` leer bleibt, gar
|
||||
/// kein Text. Erst die `.small`-Familie liefert CarPlay Libelle und Text.
|
||||
private struct LevelActivityContent: View {
|
||||
let attributes: LevelActivityAttributes
|
||||
let state: LevelActivityAttributes.ContentState
|
||||
|
||||
@Environment(\.activityFamily) private var activityFamily
|
||||
|
||||
var body: some View {
|
||||
switch activityFamily {
|
||||
case .small:
|
||||
CompactLevelView(attributes: attributes, state: state)
|
||||
default:
|
||||
LockScreenLevelView(attributes: attributes, state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
|
||||
/// ein Blick-Symbol statt eines ganzen Satzes – zum Lesen im Vorbeifahren.
|
||||
///
|
||||
/// Gezeigt wird nur, welche Seite höher steht: Buchstabe (Längsneigung),
|
||||
/// Pfeil, Buchstabe (Querneigung) – z.B. "H ↑ L" für "Heck und links stehen
|
||||
/// höher". Ein einzelner Pfeil in der Mitte reicht, weil ohnehin nur die
|
||||
/// höher stehende Seite benannt wird ("höher" ist implizit "nach oben").
|
||||
private struct CompactLevelView: View {
|
||||
let attributes: LevelActivityAttributes
|
||||
let state: LevelActivityAttributes.ContentState
|
||||
|
||||
private var hasReading: Bool { state.pitch != nil || state.roll != nil }
|
||||
|
||||
/// "H"/"F", nur wenn ausserhalb der Toleranz.
|
||||
private var pitchLetter: String? {
|
||||
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
|
||||
return pitch >= 0 ? "H" : "F"
|
||||
}
|
||||
|
||||
/// "R"/"L", nur wenn ausserhalb der Toleranz.
|
||||
private var rollLetter: String? {
|
||||
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
|
||||
return roll >= 0 ? "R" : "L"
|
||||
}
|
||||
|
||||
/// Dieselbe Skala wie die Blase, über die grössere der beiden Neigungen.
|
||||
private var statusColor: Color {
|
||||
guard let pitch = state.pitch, let roll = state.roll else { return .white.opacity(0.4) }
|
||||
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
|
||||
|
||||
Group {
|
||||
if !hasReading {
|
||||
Text("–")
|
||||
} else if state.isLevel {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
if let pitchLetter { Text(pitchLetter) }
|
||||
Image(systemName: "arrow.up")
|
||||
if let rollLetter { Text(rollLetter) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skalierbarer Textstil statt fester Punktgrösse: CarPlay-
|
||||
// Bildschirme unterscheiden sich in Grösse, die exakten Masse
|
||||
// eines Displays sind aber nicht abfragbar. `minimumScaleFactor`
|
||||
// lässt die Schrift so gross wie möglich, aber so klein wie
|
||||
// nötig werden, um in den vom System zugeteilten Platz zu passen.
|
||||
.font(.title2.weight(.bold))
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(statusColor)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sperrbildschirm- und Banner-Ansicht.
|
||||
private struct LockScreenLevelView: View {
|
||||
let attributes: LevelActivityAttributes
|
||||
let state: LevelActivityAttributes.ContentState
|
||||
|
||||
/// Längszeile, z.B. "Heck steht höher 1.8°" – nur wenn ausserhalb der
|
||||
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
|
||||
private var pitchLine: String? {
|
||||
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
|
||||
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
|
||||
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
|
||||
}
|
||||
|
||||
/// Querzeile, z.B. "links steht höher 0.9°".
|
||||
private var rollLine: String? {
|
||||
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
|
||||
let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
|
||||
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 64)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(attributes.deviceName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if pitchLine == nil && rollLine == nil {
|
||||
Text(state.pitch == nil && state.roll == nil ? "Keine Messwerte" : "Steht eben")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(state.isLevel ? .green : .white)
|
||||
} else {
|
||||
if let pitchLine {
|
||||
Text(pitchLine).font(.title3.weight(.semibold))
|
||||
}
|
||||
if let rollLine {
|
||||
Text(rollLine).font(.title3.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(16)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
extension LevelActivityAttributes {
|
||||
fileprivate static var preview: LevelActivityAttributes {
|
||||
LevelActivityAttributes(deviceName: "Nivellierung")
|
||||
}
|
||||
}
|
||||
|
||||
extension LevelActivityAttributes.ContentState {
|
||||
fileprivate static var level: LevelActivityAttributes.ContentState {
|
||||
LevelActivityAttributes.ContentState(pitch: 0.1, roll: -0.2, isLevel: true,
|
||||
instruction: "Steht eben", isCalibrated: true,
|
||||
updatedAt: .now)
|
||||
}
|
||||
|
||||
fileprivate static var tilted: LevelActivityAttributes.ContentState {
|
||||
LevelActivityAttributes.ContentState(pitch: 2.4, roll: -1.1, isLevel: false,
|
||||
instruction: "Heck steht höher, links steht höher",
|
||||
isCalibrated: true, updatedAt: .now)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Notification", as: .content, using: LevelActivityAttributes.preview) {
|
||||
VanControlLiveActivity()
|
||||
} contentStates: {
|
||||
LevelActivityAttributes.ContentState.level
|
||||
LevelActivityAttributes.ContentState.tilted
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// VanControlLiveActivityBundle.swift
|
||||
// VanControlLiveActivity
|
||||
//
|
||||
// Created by Christopher Helberg on 02.09.26.
|
||||
//
|
||||
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VanControlLiveActivityBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
VanControlLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
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" : "VansterRear.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VansterSide.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// VanControl Pro am Handgelenk.
|
||||
///
|
||||
/// Zwei Quellen, jede dort, wo sie hingehört:
|
||||
///
|
||||
/// * Der **Neigungsmesser** hängt direkt an der Uhr (`WatchLevelRadio`). Die
|
||||
/// Nivellierung braucht damit kein geöffnetes iPhone – und genau dafür hebt
|
||||
/// man beim Rangieren den Arm.
|
||||
/// * **Batterie, Solar und Kühlbox** kommen über das iPhone (`PhoneLink`),
|
||||
/// weil dort die Schlüssel liegen und die Geräte nur eine Verbindung
|
||||
/// zulassen.
|
||||
@main
|
||||
struct CamperWatchApp: App {
|
||||
@State private var link = PhoneLink()
|
||||
@State private var radio = WatchLevelRadio()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchRootView()
|
||||
.environment(link)
|
||||
.environment(radio)
|
||||
.task {
|
||||
link.activate()
|
||||
radio.start()
|
||||
// Beim Start meldet `onChange` nichts: Die Bildschirmphase
|
||||
// steht schon auf „aktiv“ und ändert sich nicht mehr. Ohne
|
||||
// diese Zeile bliebe der schnelle Takt des iPhones aus, bis
|
||||
// die App einmal im Hintergrund war.
|
||||
link.setLive(true)
|
||||
}
|
||||
// Die Einbaulage kommt bevorzugt aus dem Sensor selbst. Nur
|
||||
// falls dort noch nichts steht – ältere Firmware, oder nie
|
||||
// bestimmt –, gilt, was das iPhone meldet.
|
||||
.onChange(of: link.payload?.levelDevice?.orientation) { _, orientation in
|
||||
guard let orientation, !radio.hasDeviceOrientation else { return }
|
||||
radio.orientation = orientation
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
// Beides kostet Strom und lohnt nur, solange jemand hinschaut.
|
||||
// Genau das sagt die Bildschirmphase aus.
|
||||
link.setLive(phase == .active)
|
||||
if phase == .active { radio.start() } else { radio.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Die Gegenstelle zum iPhone auf der Uhr.
|
||||
///
|
||||
/// Hält den zuletzt empfangenen Stand und schickt Stellbefehle zurück. Gefunkt
|
||||
/// wird nur zum iPhone – zu den Geräten im Fahrzeug nie, siehe `WatchLink`.
|
||||
///
|
||||
/// Zwei Quellen laufen zusammen:
|
||||
///
|
||||
/// * Der **Anwendungskontext** liegt schon da, wenn die App startet. Deshalb
|
||||
/// steht sofort etwas auf dem Bildschirm, auch wenn das iPhone in der Tasche
|
||||
/// steckt und gerade nicht antwortet – mit Altersangabe, versteht sich.
|
||||
/// * **Nachrichten** kommen im halben Sekundentakt, solange wir gemeldet haben,
|
||||
/// dass jemand hinschaut. Das Melden wird regelmässig erneuert: Schläft die
|
||||
/// Uhr ein, bleibt die Erneuerung aus und das iPhone hört von selbst auf.
|
||||
@Observable
|
||||
final class PhoneLink: NSObject {
|
||||
|
||||
private(set) var payload: WatchPayload?
|
||||
/// Wann der Stand hier ankam – nicht, wann er entstand. Beides zusammen
|
||||
/// unterscheidet „iPhone weg“ von „Gerät im Fahrzeug antwortet nicht“.
|
||||
private(set) var receivedAt: Date?
|
||||
private(set) var isReachable = false
|
||||
private(set) var isActivated = false
|
||||
|
||||
private var session: WCSession?
|
||||
/// Erneuert die Meldung „hier schaut jemand hin“ – im Vorführbetrieb
|
||||
/// stattdessen der Taktgeber der erfundenen Werte.
|
||||
private var timer: Timer?
|
||||
private var isLive = false
|
||||
|
||||
/// Ohne iPhone lässt sich auf der Uhr sonst nichts vorführen.
|
||||
private let isDemo = ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
|
||||
|
||||
func activate() {
|
||||
if isDemo {
|
||||
startDemo()
|
||||
return
|
||||
}
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
self.session = session
|
||||
session.activate()
|
||||
}
|
||||
|
||||
/// Meldet dem iPhone, ob gerade jemand hinschaut. Beim Ausrichten zählt
|
||||
/// jeder halbe Sekundentakt, beim Nachsehen des Ladezustands nicht.
|
||||
func setLive(_ live: Bool) {
|
||||
guard !isDemo else { return }
|
||||
isLive = live
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
guard live else {
|
||||
send(.hello(live: false))
|
||||
return
|
||||
}
|
||||
send(.hello(live: true))
|
||||
// Vor Ablauf der Frist erneuern, damit zwischendurch keine Lücke
|
||||
// entsteht. Bleibt die Erneuerung aus, endet der schnelle Takt von
|
||||
// selbst – niemand muss ihn abbestellen.
|
||||
let renew = Timer(timeInterval: WatchLink.liveLease / 3, repeats: true) { [weak self] _ in
|
||||
self?.send(.hello(live: true))
|
||||
}
|
||||
RunLoop.main.add(renew, forMode: .common)
|
||||
timer = renew
|
||||
}
|
||||
|
||||
/// Schickt einen Befehl und übernimmt die Antwort, falls eine kommt.
|
||||
///
|
||||
/// Die Antwort ist der Stand *vor* der Wirkung – die Kühlbox antwortet dem
|
||||
/// iPhone erst ein paar Zehntel später. Sie zeigt also nicht, dass der
|
||||
/// Befehl gewirkt hat, sondern nur, dass er angekommen ist. Der gestellte
|
||||
/// Wert erscheint, wenn das Gerät ihn zurückmeldet, so wie am iPhone auch.
|
||||
func send(_ command: WatchCommand) {
|
||||
guard !isDemo, let session, session.activationState == .activated,
|
||||
let message = try? command.message() else { return }
|
||||
guard session.isReachable else { return }
|
||||
session.sendMessage(message, replyHandler: { [weak self] reply in
|
||||
guard let payload = WatchPayload.decode(from: reply) else { return }
|
||||
DispatchQueue.main.async { self?.accept(payload) }
|
||||
}, errorHandler: nil)
|
||||
}
|
||||
|
||||
private func accept(_ payload: WatchPayload) {
|
||||
// Ein Nachzügler darf einen neueren Stand nicht überschreiben.
|
||||
if let current = self.payload, current.generatedAt > payload.generatedAt { return }
|
||||
self.payload = payload
|
||||
receivedAt = Date()
|
||||
}
|
||||
|
||||
// MARK: - Vorführbetrieb
|
||||
|
||||
private func startDemo() {
|
||||
payload = WatchDemo.payload(at: 0)
|
||||
receivedAt = Date()
|
||||
isReachable = true
|
||||
isActivated = true
|
||||
var step = 0.0
|
||||
let tick = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
step += 0.5
|
||||
self?.payload = WatchDemo.payload(at: step)
|
||||
self?.receivedAt = Date()
|
||||
}
|
||||
RunLoop.main.add(tick, forMode: .common)
|
||||
timer = tick
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension PhoneLink: WCSessionDelegate {
|
||||
|
||||
func session(_ session: WCSession,
|
||||
activationDidCompleteWith state: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
DispatchQueue.main.async {
|
||||
self.isActivated = state == .activated
|
||||
self.isReachable = session.isReachable
|
||||
// Was schon dalag, gilt sofort – sonst bliebe der Bildschirm leer,
|
||||
// bis das iPhone das nächste Mal von sich aus sendet.
|
||||
if let payload = WatchPayload.decode(from: session.receivedApplicationContext) {
|
||||
self.accept(payload)
|
||||
}
|
||||
if self.isLive { self.send(.hello(live: true)) }
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let payload = WatchPayload.decode(from: message) else { return }
|
||||
DispatchQueue.main.async { self.accept(payload) }
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
|
||||
guard let payload = WatchPayload.decode(from: context) else { return }
|
||||
DispatchQueue.main.async { self.accept(payload) }
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.isReachable = session.isReachable
|
||||
if session.isReachable, self.isLive { self.send(.hello(live: true)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
/// Der Ausrichtungs-Assistent am Handgelenk – der eigentliche Grund für die
|
||||
/// Uhr: Beim Rangieren hat man das iPhone nicht in der Hand, aber den Arm am
|
||||
/// Lenkrad. Ein Blick nach unten genügt, und die Rückmeldung „steht eben“ kommt
|
||||
/// zusätzlich als Vibration, ganz ohne Blick.
|
||||
///
|
||||
/// Gerechnet wird mit demselben `AlignmentAssistant` wie am iPhone: kein
|
||||
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
|
||||
/// schlechter wird, steckt vollständig darin.
|
||||
struct WatchAlignView: View {
|
||||
@Environment(PhoneLink.self) private var link
|
||||
@Environment(WatchLevelRadio.self) private var radio
|
||||
@State private var assistant = AlignmentAssistant()
|
||||
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
|
||||
@State private var hasAnnouncedTarget = false
|
||||
|
||||
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
|
||||
private var state: LevelState? { reading.hasReading ? reading.state : nil }
|
||||
|
||||
var body: some View {
|
||||
// Ohne ScrollView: Beim Rangieren schaut man kurz hin, da darf nichts
|
||||
// ausserhalb des Bildes liegen. Alles ist deshalb so bemessen, dass es
|
||||
// auch auf der kleinsten Uhr auf einen Blick passt.
|
||||
VStack(spacing: 3) {
|
||||
if let current = assistant.current {
|
||||
Text(String(format: "%.1f°", current.deviation))
|
||||
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
ProgressView()
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(trendColor)
|
||||
|
||||
Text(assistant.advice)
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
if let state {
|
||||
HStack(spacing: 8) {
|
||||
WatchBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(width: 40, height: 40)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
|
||||
Text(String(format: "Quer %.1f°", state.roll ?? 0))
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
assistant.reset()
|
||||
hasAnnouncedTarget = false
|
||||
} label: {
|
||||
Label("Von vorn", systemImage: "arrow.counterclockwise")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, 2)
|
||||
.navigationTitle("Ausrichten")
|
||||
.onChange(of: tilt, initial: true) { _, tilt in
|
||||
guard let tilt else { return }
|
||||
assistant.add(pitch: tilt.pitch, roll: tilt.roll)
|
||||
announceIfLevel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Wertepaar als Auslöser: gerechnet wird nur, wenn sich die Neigung
|
||||
/// wirklich ändert – nicht bei jeder Aktualisierung der Anzeige.
|
||||
private var tilt: Tilt? {
|
||||
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
|
||||
return Tilt(pitch: pitch, roll: roll)
|
||||
}
|
||||
|
||||
private struct Tilt: Equatable {
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
}
|
||||
|
||||
private var trendColor: Color {
|
||||
switch assistant.trend {
|
||||
case .improving: return .green
|
||||
case .worsening: return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Vibration ist der Punkt: Sie sagt „anhalten“, ohne dass jemand
|
||||
/// hinschauen muss. Erneut gemeldet wird erst, wenn das Fahrzeug die
|
||||
/// Toleranz zwischendurch wieder verlassen hat.
|
||||
private func announceIfLevel() {
|
||||
if assistant.hasReachedTarget {
|
||||
guard !hasAnnouncedTarget else { return }
|
||||
hasAnnouncedTarget = true
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
} else {
|
||||
hasAnnouncedTarget = false
|
||||
}
|
||||
}
|
||||
}
|
||||