diff --git a/CamperMonitor.xcodeproj/project.pbxproj b/CamperMonitor.xcodeproj/project.pbxproj index 5c8c860..004c516 100644 --- a/CamperMonitor.xcodeproj/project.pbxproj +++ b/CamperMonitor.xcodeproj/project.pbxproj @@ -362,6 +362,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone."; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -378,7 +379,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 11.6; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; }; @@ -393,6 +394,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone."; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -409,7 +411,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 11.6; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; diff --git a/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitorWatch.xcscheme b/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitorWatch.xcscheme index 85fb1da..768033a 100644 --- a/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitorWatch.xcscheme +++ b/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitorWatch.xcscheme @@ -1,8 +1,17 @@ - - + + - + - - + + + - - + + - - + + - - + + + + diff --git a/CamperMonitor/Views/VehicleTiltView.swift b/CamperMonitor/Views/VehicleTiltView.swift index 3ef9c6d..f979ce7 100644 --- a/CamperMonitor/Views/VehicleTiltView.swift +++ b/CamperMonitor/Views/VehicleTiltView.swift @@ -16,11 +16,6 @@ struct VehicleTiltView: View { let pitch: Double? let roll: Double? - /// Kleine Neigungen sind am Fahrzeug sonst kaum zu erkennen – zwei Grad - /// wären ein knappes Grad Bildneigung. Die Überhöhung wird angeschrieben, - /// damit niemand den Winkel für bare Münze nimmt. - static let exaggeration: Double = 3 - var body: some View { VStack(spacing: 20) { tiltPanel(image: "VehicleSide", @@ -28,19 +23,19 @@ struct VehicleTiltView: View { title: "Längs", lowerLabel: "Front", upperLabel: "Heck", - aspect: 925.0 / 600.0) + aspect: VehicleTilt.sideAspect) tiltPanel(image: "VehicleRear", angle: roll, title: "Quer", lowerLabel: "links", upperLabel: "rechts", - aspect: 1) + aspect: VehicleTilt.rearAspect) // Ohne diesen Hinweis nähme man den Bildwinkel für den echten. Text(String(format: "Neigung %.0f-fach überhöht dargestellt – " + "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.", - Self.exaggeration)) + VehicleTilt.exaggeration)) .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -61,7 +56,7 @@ struct VehicleTiltView: View { Spacer() Text(angle.map { String(format: "%.1f°", $0) } ?? "–") .font(.subheadline.weight(.semibold).monospacedDigit()) - .foregroundStyle(colour(for: angle)) + .foregroundStyle(VehicleTilt.colour(for: angle)) } ZStack { @@ -74,9 +69,9 @@ struct VehicleTiltView: View { Image(image) .resizable() .scaledToFit() - .foregroundStyle(colour(for: angle)) + .foregroundStyle(VehicleTilt.colour(for: angle)) .aspectRatio(aspect, contentMode: .fit) - .rotationEffect(.degrees(-(angle ?? 0) * Self.exaggeration)) + .rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration)) .animation(.spring(duration: 0.4), value: angle) .opacity(angle == nil ? 0.3 : 1) } @@ -92,12 +87,6 @@ struct VehicleTiltView: View { } } - private func colour(for angle: Double?) -> Color { - guard let angle else { return .secondary } - if abs(angle) <= LevelState.levelTolerance { return .green } - if abs(angle) <= 2 { return .orange } - return .red - } } /// Umschalter zwischen den beiden Darstellungen, gemerkt über Starts hinweg. diff --git a/CamperMonitor/Watch/PhoneWatchLink.swift b/CamperMonitor/Watch/PhoneWatchLink.swift index ae2c29c..a178ff8 100644 --- a/CamperMonitor/Watch/PhoneWatchLink.swift +++ b/CamperMonitor/Watch/PhoneWatchLink.swift @@ -134,6 +134,7 @@ final class PhoneWatchLink: NSObject { link: bluetooth.linkStates[device.id] ?? .searching, snapshot: bluetooth.snapshots[device.id], level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil, + orientation: device.role == .leveling ? device.sensorOrientation : nil, fridge: device.role == .fridge ? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil) } @@ -167,8 +168,6 @@ final class PhoneWatchLink: NSObject { bluetooth.setFridgeLock(locked, for: device) case .fridgeTarget(let device, let zone, let value): bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device) - case .calibrateLevel(let device): - bluetooth.calibrateLevel(for: device) } } } diff --git a/CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/Contents.json b/CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/Contents.json new file mode 100644 index 0000000..a0f8834 --- /dev/null +++ b/CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { + "filename" : "VehicleRear.png", + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "template-rendering-intent" : "template" } +} diff --git a/CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/VehicleRear.png b/CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/VehicleRear.png new file mode 100644 index 0000000..a71ab5e Binary files /dev/null and b/CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/VehicleRear.png differ diff --git a/CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/Contents.json b/CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/Contents.json new file mode 100644 index 0000000..7e18a2d --- /dev/null +++ b/CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { + "filename" : "VehicleSide.png", + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "template-rendering-intent" : "template" } +} diff --git a/CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/VehicleSide.png b/CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/VehicleSide.png new file mode 100644 index 0000000..9f1c333 Binary files /dev/null and b/CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/VehicleSide.png differ diff --git a/CamperMonitorWatch/CamperWatchApp.swift b/CamperMonitorWatch/CamperWatchApp.swift index 240b0c0..73bba06 100644 --- a/CamperMonitorWatch/CamperWatchApp.swift +++ b/CamperMonitorWatch/CamperWatchApp.swift @@ -2,25 +2,45 @@ import SwiftUI /// Camper Monitor am Handgelenk. /// -/// Die Uhr ist Anzeige und Fernbedienung; gefunkt wird über das iPhone. Was -/// hier steht, kam von dort – deshalb steht auf jedem Bildschirm auch, wie alt -/// es ist. +/// 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) - .task { link.activate() } + .environment(radio) + .task { + link.activate() + radio.start() + // Beim Start meldet `onChange` nichts: Die Bildschirmphase + // steht schon auf „aktiv“ und ändert sich nicht mehr. Ohne + // diese Zeile bliebe der schnelle Takt des iPhones aus, bis + // die App einmal im Hintergrund war. + link.setLive(true) + } + // Die Einbaulage wird am iPhone bestimmt; die Uhr übernimmt sie + // und behält sie, damit sie auch allein richtig rechnet. + .onChange(of: link.payload?.levelDevice?.orientation) { _, orientation in + if let orientation { radio.orientation = orientation } + } } .onChange(of: scenePhase) { _, phase in - // Der schnelle Takt kostet auf beiden Seiten Strom und lohnt nur, - // solange jemand hinschaut. Genau das sagt die Bildschirmphase aus: - // im Vordergrund schaut jemand hin, sonst nicht. + // 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() } } } } diff --git a/CamperMonitorWatch/Views/WatchAlignView.swift b/CamperMonitorWatch/Views/WatchAlignView.swift index 1ecd1d9..e502146 100644 --- a/CamperMonitorWatch/Views/WatchAlignView.swift +++ b/CamperMonitorWatch/Views/WatchAlignView.swift @@ -10,16 +10,14 @@ import WatchKit /// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was /// schlechter wird, steckt vollständig darin. struct WatchAlignView: View { - let deviceID: UUID - @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 state: LevelState? { - link.payload?.devices.first { $0.id == deviceID }?.level - } + private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) } + private var state: LevelState? { reading.hasReading ? reading.state : nil } var body: some View { ScrollView { @@ -69,21 +67,21 @@ struct WatchAlignView: View { .padding(.horizontal, 4) } .navigationTitle("Ausrichten") - .onChange(of: reading, initial: true) { _, reading in - guard let reading else { return } - assistant.add(pitch: reading.pitch, roll: reading.roll) + .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: nur wenn sich wirklich eine Neigung ändert, - /// wird gerechnet – nicht bei jedem Datensatz des iPhones. - private var reading: Reading? { + /// 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 Reading(pitch: pitch, roll: roll) + return Tilt(pitch: pitch, roll: roll) } - private struct Reading: Equatable { + private struct Tilt: Equatable { let pitch: Double let roll: Double } diff --git a/CamperMonitorWatch/Views/WatchLevelView.swift b/CamperMonitorWatch/Views/WatchLevelView.swift index 8572d23..13ffd2a 100644 --- a/CamperMonitorWatch/Views/WatchLevelView.swift +++ b/CamperMonitorWatch/Views/WatchLevelView.swift @@ -1,32 +1,40 @@ import SwiftUI -/// Nivellierung auf der Uhr: Libelle, Zahlen, und was zu tun ist. +/// Nivellierung auf der Uhr. +/// +/// Die Werte kommen bevorzugt **direkt vom Sensor** (`WatchLevelRadio`); nur +/// wenn der gerade nicht erreichbar ist, tritt der zuletzt über das iPhone +/// gemeldete Stand an seine Stelle. Woher sie stammen, steht unten – ein alter +/// Wert sieht sonst aus wie ein stillstehendes Fahrzeug. +/// +/// Kalibriert wird hier nichts. Das gehört einmalig ans iPhone, mit ebenem +/// Fahrzeug und Ruhe dafür; auf der Uhr wäre der Knopf nur eine Gelegenheit, +/// die Nullage aus Versehen zu verstellen. struct WatchLevelView: View { - /// Nur für den Einstieg – angezeigt wird immer der Stand aus dem letzten - /// Datensatz, sonst bliebe die Ansicht auf dem Wert vom Öffnen stehen. - let device: WatchDevice - @Environment(PhoneLink.self) private var link - @State private var isCalibrating = false + @Environment(WatchLevelRadio.self) private var radio + @AppStorage("levelDisplayStyle") private var showsVehicle = true - private var current: WatchDevice { link.payload?.levelDevice ?? device } - private var state: LevelState { current.level ?? LevelState() } + private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) } var body: some View { ScrollView { - VStack(spacing: 10) { - WatchBubble(pitch: state.pitch, roll: state.roll) - .frame(maxWidth: 110) - - HStack(spacing: 14) { - angle("Längs", state.pitch) - angle("Quer", state.roll) + VStack(spacing: 8) { + if showsVehicle { + WatchVehicleView(pitch: reading.state.pitch, roll: reading.state.roll) + } else { + WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll) + .frame(maxWidth: 110) + HStack(spacing: 14) { + angle("Längs", reading.state.pitch) + angle("Quer", reading.state.roll) + } } - Text(state.instruction ?? "Keine Werte") + Text(reading.state.instruction ?? "Keine Werte") .font(.footnote.weight(.medium)) .multilineTextAlignment(.center) - .foregroundStyle(state.isLevel ? .green : .primary) + .foregroundStyle(reading.state.isLevel ? .green : .primary) if let wedges = wedgeText { Text(wedges) @@ -36,33 +44,25 @@ struct WatchLevelView: View { } NavigationLink { - WatchAlignView(deviceID: current.id) + WatchAlignView() } label: { - Label("Ausrichten", systemImage: "car.side.rear.and.collision.and.car.side.front") + Label("Ausrichten", systemImage: "arrow.left.and.right") } .buttonStyle(.borderedProminent) - Button("Kalibrieren", systemImage: "scope") { isCalibrating = true } - .font(.footnote) - - if state.isKnownUncalibrated { - Text("Noch nicht kalibriert – die Anzeige zeigt die Lage des " - + "Sensors, nicht die des Fahrzeugs.") - .font(.caption2) - .foregroundStyle(.orange) - .multilineTextAlignment(.center) + Button { + showsVehicle.toggle() + } label: { + Label(showsVehicle ? "Libelle" : "Fahrzeug", + systemImage: showsVehicle ? "circle.circle" : "box.truck") } + .font(.caption2) + + SourceNote(reading: reading) } .padding(.horizontal, 4) } .navigationTitle("Nivellierung") - .confirmationDialog("Auf aktuelle Lage kalibrieren?", - isPresented: $isCalibrating, titleVisibility: .visible) { - Button("Kalibrieren") { link.send(.calibrateLevel(device: current.id)) } - Button("Abbrechen", role: .cancel) {} - } message: { - Text("Nur sinnvoll, wenn das Fahrzeug jetzt eben steht.") - } } private func angle(_ title: String, _ value: Double?) -> some View { @@ -81,14 +81,97 @@ struct WatchLevelView: View { private var wedgeText: String? { guard let profile = link.payload?.profile else { return nil } var parts: [String] = [] - if let roll = state.roll, let width = profile.trackWidth, + if let roll = reading.state.roll, let width = profile.trackWidth, let wedge = LevelingWedge.across(roll: roll, trackWidth: width) { parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text)) } - if let pitch = state.pitch, let base = profile.wheelbase, + if let pitch = reading.state.pitch, let base = profile.wheelbase, let wedge = LevelingWedge.along(pitch: pitch, wheelbase: base) { parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text)) } return parts.isEmpty ? nil : "Keile: " + parts.joined(separator: ", ") } } + +/// Woher die angezeigte Neigung kommt und wie alt sie ist. +struct SourceNote: View { + let reading: WatchLevelReading + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + let age = reading.age(now: context.date) + Label { + Text(text(age: age)) + } icon: { + Image(systemName: reading.isDirect + ? "antenna.radiowaves.left.and.right" : "iphone.gen3") + } + .font(.system(size: 10)) + .foregroundStyle(age > 5 ? .orange : .secondary) + } + } + + private func text(age: TimeInterval) -> String { + guard reading.hasReading else { return reading.statusText } + let source = reading.isDirect ? "Sensor" : "iPhone" + if age < 3 { return "\(source), eben gemessen" } + // Steht der Wert, gehört der Grund dazu und nicht nur das Alter. + if age < 90 { return String(format: "%@, vor %.0f s · %@", source, age, reading.statusText) } + return String(format: "%@, vor %.0f min · %@", source, age / 60, reading.statusText) + } +} + +/// Die angezeigte Neigung samt Herkunft. +/// +/// Die eigene Verbindung hat Vorrang, solange sie **frisch** ist. Das „solange“ +/// ist der Punkt: Bleibt der Sensor stehen, ohne die Verbindung zu trennen, +/// würde ein einmal gelesener Wert sonst für immer stehenbleiben – und eine +/// eingefrorene Anzeige ist beim Ausrichten schlimmer als gar keine. Dann tritt +/// der Stand des iPhones an seine Stelle, und wenn auch der fehlt, bleibt der +/// alte Wert stehen, aber mit seinem Alter daneben. +struct WatchLevelReading { + let state: LevelState + let isDirect: Bool + let measuredAt: Date? + let statusText: String + + /// Ab hier gilt ein direkt gemessener Wert als überholt. Gefragt wird + /// zweimal je Sekunde ab; drei Sekunden Stille sind schon viel. + static let directLifetime: TimeInterval = 3 + + init(radio: WatchLevelRadio, phone: PhoneLink, now: Date = Date()) { + let directAge = radio.updatedAt.map { now.timeIntervalSince($0) } + let hasFreshDirect = radio.state.hasReading + && (directAge ?? .infinity) <= Self.directLifetime + let relayed = phone.payload?.levelDevice + + if hasFreshDirect { + state = radio.state + isDirect = true + measuredAt = radio.updatedAt + statusText = radio.statusText + } else if let relayed, let level = relayed.level { + state = level + isDirect = false + measuredAt = relayed.snapshot?.timestamp ?? phone.payload?.generatedAt + statusText = relayed.link.label + } else if radio.state.hasReading { + state = radio.state + isDirect = true + measuredAt = radio.updatedAt + statusText = radio.statusText + } else { + state = LevelState() + isDirect = false + measuredAt = nil + statusText = radio.statusText + } + } + + var hasReading: Bool { state.hasReading } + + func age(now: Date = Date()) -> TimeInterval { + guard let measuredAt else { return 0 } + return now.timeIntervalSince(measuredAt) + } +} diff --git a/CamperMonitorWatch/Views/WatchRootView.swift b/CamperMonitorWatch/Views/WatchRootView.swift index daa065c..1bf0df3 100644 --- a/CamperMonitorWatch/Views/WatchRootView.swift +++ b/CamperMonitorWatch/Views/WatchRootView.swift @@ -4,62 +4,62 @@ import SwiftUI /// im Wohnmobil danach schaut. struct WatchRootView: View { @Environment(PhoneLink.self) private var link + @Environment(WatchLevelRadio.self) private var radio var body: some View { NavigationStack { - Group { - if let payload = link.payload { - content(for: payload) - } else { - waiting - } - } - .navigationTitle(link.payload?.profile.name ?? "Camper") + content + .navigationTitle(link.payload?.profile.name ?? "Camper") } } - private func content(for payload: WatchPayload) -> some View { + private var content: some View { List { - if let level = payload.levelDevice { + // Die Nivellierung steht immer oben und immer da: Sie kommt vom + // Sensor selbst und braucht das iPhone nicht. + Section { + NavigationLink { + WatchLevelView() + } label: { + LevelSummaryRow(reading: WatchLevelReading(radio: radio, phone: link)) + } + } + + if let payload = link.payload { Section { - NavigationLink { - WatchLevelView(device: level) - } label: { - LevelSummaryRow(state: level.level, link: level.link) + ForEach(payload.devices.filter { $0.role != .leveling }) { device in + NavigationLink { + WatchDeviceDetailView(deviceID: device.id) + } label: { + DeviceRow(device: device) + } } } - } - Section { - ForEach(payload.devices.filter { $0.role != .leveling }) { device in - NavigationLink { - WatchDeviceDetailView(deviceID: device.id) - } label: { - DeviceRow(device: device) - } + Section { + FreshnessNote(payload: payload, receivedAt: link.receivedAt) + } + } else { + Section { + waiting + } footer: { + Text("Batterie, Solar und Kühlbox kommen vom iPhone. Die " + + "Nivellierung oben nicht – die geht direkt.") + .font(.system(size: 10)) } - } - - Section { - FreshnessNote(payload: payload, receivedAt: link.receivedAt) } } } private var waiting: some View { - VStack(spacing: 8) { - Image(systemName: "iphone.gen3.radiowaves.left.and.right") - .font(.title2) - .foregroundStyle(.tint) - Text("Warte auf das iPhone") - .font(.headline) - Text("Die Werte kommen vom iPhone. Dort muss Camper Monitor " - + "geöffnet sein.") + VStack(spacing: 4) { + Label("Warte auf das iPhone", systemImage: "iphone.gen3.radiowaves.left.and.right") + .font(.footnote.weight(.medium)) + Text("Dort muss Camper Monitor geöffnet sein.") .font(.caption2) .foregroundStyle(.secondary) - .multilineTextAlignment(.center) } - .padding(.horizontal, 4) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -100,12 +100,11 @@ struct DeviceRow: View { /// Die Nivellierung bekommt eine eigene, grössere Zeile: sie ist der Grund, /// aus dem man die Uhr im Fahrzeug überhaupt anschaut. struct LevelSummaryRow: View { - let state: LevelState? - let link: DeviceLinkState + let reading: WatchLevelReading var body: some View { HStack(spacing: 10) { - WatchBubble(pitch: state?.pitch, roll: state?.roll) + WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll) .frame(width: 44, height: 44) VStack(alignment: .leading, spacing: 2) { Text("Nivellierung") @@ -113,7 +112,9 @@ struct LevelSummaryRow: View { .foregroundStyle(.secondary) // Ohne Messwerte steht hier, woran es liegt, statt eines // nichtssagenden Strichs. - Text(state?.instruction ?? link.label) + Text(reading.hasReading + ? (reading.state.instruction ?? "Keine Werte") + : reading.statusText) .font(.footnote.weight(.medium)) .lineLimit(2) } diff --git a/CamperMonitorWatch/Views/WatchVehicleView.swift b/CamperMonitorWatch/Views/WatchVehicleView.swift new file mode 100644 index 0000000..f0c4c4b --- /dev/null +++ b/CamperMonitorWatch/Views/WatchVehicleView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// Die Neigung am Fahrzeug selbst, wie am iPhone – nur auf Uhrgrösse gebracht. +/// +/// Zwei Ansichten, jede um ihre Achse gekippt: die Seitenansicht für längs +/// (Front links, Heck rechts) und die Heckansicht für quer. Die Heckansicht +/// teilt die Blickrichtung des Fahrers, links im Bild ist also links am +/// Fahrzeug. +struct WatchVehicleView: View { + let pitch: Double? + let roll: Double? + + /// Wie am iPhone dreifach überhöht – zwei Grad wären sonst kaum zu sehen. + /// Die angeschriebenen Gradzahlen bleiben echt. + var body: some View { + VStack(spacing: 8) { + panel(image: "VehicleSide", angle: pitch, title: "Längs", + lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect) + panel(image: "VehicleRear", angle: roll, title: "Quer", + lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect) + } + } + + private func panel(image: String, + angle: Double?, + title: String, + lower: String, + upper: String, + aspect: Double) -> some View { + VStack(spacing: 2) { + HStack { + Text(title) + Spacer() + Text(angle.map { String(format: "%.1f°", $0) } ?? "–") + .monospacedDigit() + .foregroundStyle(VehicleTilt.colour(for: angle)) + } + .font(.caption2) + + ZStack { + // Die Waagerechte als Bezug – ohne sie ist eine kleine Neigung + // nicht einzuschätzen. + Rectangle() + .fill(Color.secondary.opacity(0.35)) + .frame(height: 1) + + Image(image) + .resizable() + .scaledToFit() + .foregroundStyle(VehicleTilt.colour(for: angle)) + .aspectRatio(aspect, contentMode: .fit) + .rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration)) + .animation(.spring(duration: 0.4), value: angle) + .opacity(angle == nil ? 0.3 : 1) + } + .frame(height: 52) + + HStack { + Text(lower) + Spacer() + Text(upper) + } + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + } +} diff --git a/CamperMonitorWatch/WatchDemo.swift b/CamperMonitorWatch/WatchDemo.swift index 8b254ad..124354e 100644 --- a/CamperMonitorWatch/WatchDemo.swift +++ b/CamperMonitorWatch/WatchDemo.swift @@ -63,13 +63,15 @@ enum WatchDemo { devices: [ WatchDevice(id: levelID, name: "Nivellierung", role: .leveling, link: .live, snapshot: level.snapshot(deviceID: levelID, rssi: -70), - level: level, fridge: nil), + level: level, orientation: .identity, fridge: nil), WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms, - link: .live, snapshot: battery, level: nil, fridge: nil), + link: .live, snapshot: battery, level: nil, + orientation: nil, fridge: nil), WatchDevice(id: solarID, name: "Solar Dach", role: .solarCharger, - link: .live, snapshot: solar, level: nil, fridge: nil), + link: .live, snapshot: solar, level: nil, + orientation: nil, fridge: nil), WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge, - link: .live, snapshot: fridge, level: nil, + link: .live, snapshot: fridge, level: nil, orientation: nil, fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false, isDualZone: false, unitSymbol: "°C", leftTarget: 4, leftCurrent: 6, diff --git a/CamperMonitorWatch/WatchLevelRadio.swift b/CamperMonitorWatch/WatchLevelRadio.swift new file mode 100644 index 0000000..3e7b190 --- /dev/null +++ b/CamperMonitorWatch/WatchLevelRadio.swift @@ -0,0 +1,193 @@ +import CoreBluetooth +import Foundation +import Observation + +/// Die eigene Funkverbindung der Uhr zum Neigungsmesser. +/// +/// Für dieses eine Gerät geht die Uhr direkt hin, statt über das iPhone. Das +/// ist genau dort möglich, wo es beim Rest nicht geht: +/// +/// * Der Neigungsmesser **bewirbt seinen Dienst**. Die Uhr findet ihn also von +/// selbst und muss nichts eingerichtet bekommen – anders als BMS und +/// Kühlbox, die über die Kennung des Peripheriegeräts gesucht werden. +/// * Er ist **unverschlüsselt**. Es braucht keinen Victron-Schlüssel, der auf +/// der Uhr ein zweites Mal lagern müsste. +/// +/// Damit ist die Nivellierung auf der Uhr eigenständig: kein geöffnetes iPhone, +/// keine Reichweite zum Telefon. Für alles andere bleibt der Weg über das +/// iPhone, siehe `PhoneLink`. +/// +/// Gefunkt wird nur im Vordergrund. watchOS lässt eine App im Hintergrund +/// ohnehin kaum scannen, und beim Ausrichten schaut man auf die Uhr. +@Observable +final class WatchLevelRadio: NSObject { + + private(set) var state = LevelState() + private(set) var link: DeviceLinkState = .idle + /// Wann zuletzt ein Wert vom Sensor kam. + private(set) var updatedAt: Date? + private(set) var statusText = "Bluetooth wird gestartet…" + + /// Die Einbaulage des Sensors. Kommt vom iPhone und wird hier behalten, + /// damit die Uhr auch ohne Telefon richtig rechnet – ohne sie stünden + /// längs und quer je nach Einbau vertauscht oder mit falschem Vorzeichen. + var orientation = SensorOrientation.identity { + didSet { + guard orientation != oldValue else { return } + Self.store(orientation) + let value = orientation + queue.async { self.session?.orientation = value } + } + } + + private let queue = DispatchQueue(label: "de.fritob.CamperMonitor.watch.ble") + private var central: CBCentralManager? + private var peripheral: CBPeripheral? + private var session: LevelSession? + private var isRunning = false + + /// Kennung für die Momentaufnahmen dieser Sitzung. Auf der Uhr gibt es + /// keine Geräteliste, in der sie nachgeschlagen würde – sie muss nur + /// innerhalb eines Laufs gleich bleiben. + private let deviceID = UUID() + + override init() { + super.init() + orientation = Self.storedOrientation() + } + + // MARK: - Steuerung + + func start() { + isRunning = true + if central == nil { + central = CBCentralManager(delegate: self, queue: queue) + } else { + queue.async { self.scanIfPossible() } + } + } + + func stop() { + isRunning = false + queue.async { + self.central?.stopScan() + self.session?.stop() + if let peripheral = self.peripheral, peripheral.state != .disconnected { + self.central?.cancelPeripheralConnection(peripheral) + } + } + publish { self.link = .idle } + } + + private func scanIfPossible() { + guard isRunning, let central, central.state == .poweredOn else { return } + if let peripheral, peripheral.state == .connected { return } + central.scanForPeripherals(withServices: [LevelSession.serviceUUID]) + publish { + self.link = .searching + self.statusText = "Suche Neigungsmesser…" + } + } + + private func publish(_ work: @escaping () -> Void) { + if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } + } + + // MARK: - Einbaulage merken + + private static let orientationKey = "sensorOrientation" + + private static func storedOrientation() -> SensorOrientation { + guard let data = UserDefaults.standard.data(forKey: orientationKey), + let value = try? JSONDecoder().decode(SensorOrientation.self, from: data) + else { return .identity } + return value + } + + private static func store(_ orientation: SensorOrientation) { + guard let data = try? JSONEncoder().encode(orientation) else { return } + UserDefaults.standard.set(data, forKey: orientationKey) + } +} + +// MARK: - CBCentralManagerDelegate + +extension WatchLevelRadio: CBCentralManagerDelegate { + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOn: + scanIfPossible() + case .unauthorized: + publish { + self.link = .failed("Bluetooth nicht erlaubt") + self.statusText = "Bluetooth für die App erlauben" + } + case .poweredOff: + publish { + self.link = .failed("Bluetooth aus") + self.statusText = "Bluetooth ist ausgeschaltet" + } + default: + publish { self.statusText = "Bluetooth nicht verfügbar" } + } + } + + func centralManager(_ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber) { + // Gesucht wird nach dem Dienst, nicht nach dem Namen: der lässt sich in + // der Firmware ändern, die Dienstkennung nicht. + central.stopScan() + self.peripheral = peripheral + central.connect(peripheral) + publish { + self.link = .connecting + self.statusText = "Verbinde…" + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + let session = LevelSession( + deviceID: deviceID, + peripheral: peripheral, + queue: queue, + onUpdate: { _ in }, + onStateChange: { [weak self] state in + self?.publish { self?.link = state } + }, + onLevelState: { [weak self] state in + self?.publish { + self?.state = state + self?.updatedAt = Date() + self?.statusText = "Direkt am Sensor" + } + }) + session.orientation = orientation + self.session = session + session.start() + } + + func centralManager(_ central: CBCentralManager, + didFailToConnect peripheral: CBPeripheral, + error: Error?) { + publish { self.link = .failed(error?.localizedDescription ?? "Verbindung gescheitert") } + scanIfPossible() + } + + func centralManager(_ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + error: Error?) { + session?.handleDisconnect() + session = nil + publish { + // Die zuletzt gemessene Lage bleibt stehen; wie alt sie ist, steht + // daneben. Sie wegzuwerfen hiesse, beim Rangieren mitten in der + // Bewegung einen leeren Bildschirm zu zeigen. + self.link = .searching + self.statusText = "Verbindung verloren – suche erneut" + } + scanIfPossible() + } +} diff --git a/Config/CamperMonitor-Info.plist b/Config/CamperMonitor-Info.plist index d7bc36e..e999718 100644 --- a/Config/CamperMonitor-Info.plist +++ b/Config/CamperMonitor-Info.plist @@ -2,15 +2,6 @@ - UIBackgroundModes bluetooth-central diff --git a/README.md b/README.md index 7309635..0575446 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazu Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am -Handgelenk. Sie funkt nicht selbst, sondern hängt am iPhone – warum, steht -unter [Apple Watch](#apple-watch). +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). @@ -216,8 +217,12 @@ 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** – Libelle, beide Winkel, was zu tun ist, und die nötige - Keilhöhe, falls im Fahrzeugprofil Spurweite und Radstand stehen. +* **Nivellierung** – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und + Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über Starts + hinweg gemerkt. Dazu, was zu tun ist, und die nötige Keilhöhe, falls im + Fahrzeugprofil Spurweite und Radstand stehen. Kalibriert wird nur am iPhone: + Das gehört einmalig auf ebenen Boden, und ein Knopf dafür an der Uhr wäre vor + allem eine Gelegenheit, die Nullage aus Versehen zu verstellen. * **Ausrichten** – derselbe Assistent wie am iPhone: Tendenz, Rat und der Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn @@ -226,30 +231,42 @@ den Arm hebt: * **Kühlbox** – ein/aus, Eco oder Max und die Solltemperatur, über die Krone gestellt. -### Warum die Uhr nicht selbst funkt +### Wer mit wem funkt -watchOS könnte Bluetooth, und trotzdem geht alles über das iPhone. Zwei Gründe, -beide hart: +Die Aufteilung folgt dem, was jedes Gerät hergibt. -* BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu. - Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie zu - ergänzen. +**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. -Das iPhone bleibt also das Funkgerät und reicht fertige Messwerte weiter. Was -die Uhr stellt, stellt in Wahrheit das iPhone – und angezeigt wird auch dort -nur, was das Gerät zurückmeldet, nicht der Tastendruck. +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 -Auf dem iPhone muss Camper Monitor laufen. Solange die Uhr meldet, dass jemand -hinschaut, hält die App das Funkgerät auch im Hintergrund am Leben – sonst wäre -die Anzeige am Handgelenk beim Rangieren tot, denn dann liegt das iPhone mit -dunklem Bildschirm in der Halterung. Die verbundenen Geräte liefern dabei -weiter; die Victron-Werbedaten nicht, weil iOS im Hintergrund kein ungefiltertes -Suchen erlaubt. +Für Batterie, Solar und Kühlbox muss Camper Monitor auf dem iPhone laufen. +Solange die Uhr meldet, dass jemand hinschaut, hält die App das Funkgerät auch +im Hintergrund am Leben; die Victron-Werbedaten stehen dabei still, weil iOS im +Hintergrund kein ungefiltertes Suchen erlaubt. + +Für die Nivellierung gilt das alles nicht – die läuft an der Uhr allein. +Gefunkt wird dort nur im Vordergrund: watchOS lässt eine App im Hintergrund +ohnehin kaum scannen, und beim Ausrichten schaut man auf die Uhr. Gesendet wird in zwei Takten: ein halber Sekundentakt, solange die Watch-App im Vordergrund ist, sonst alle zwei Sekunden und nur bei Änderungen. Der schnelle @@ -317,6 +334,10 @@ Shared/ In iPhone- und Watch-App übersetzt │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform │ ├── LevelState.swift Neigung, Toleranz und Klartext dazu │ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes +├── Bluetooth/ +│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung +│ └── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr) +├── VehicleTilt.swift Überhöhung und Farben der Fahrzeugansicht └── WatchLink/ └── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr @@ -331,8 +352,6 @@ CamperMonitor/ │ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung │ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung │ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle -│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung -│ ├── LevelSession.swift Verbindung zum Neigungsmesser │ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage ├── Store/ │ ├── DeviceStore.swift Geräteliste, Persistenz @@ -355,11 +374,13 @@ CamperMonitor/ CamperMonitorWatch/ watchOS-App ├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen ├── PhoneLink.swift Gegenstelle zum iPhone +├── WatchLevelRadio.swift Eigene Bluetooth-Verbindung zum Neigungsmesser ├── WatchDemo.swift Erfundene Werte für den Simulator └── Views/ ├── WatchRootView.swift Übersicht und Altersangabe ├── WatchBubble.swift Libelle für den kleinen Bildschirm - ├── WatchLevelView.swift Neigung, Keilhöhe, Kalibrierung + ├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse + ├── WatchLevelView.swift Neigung, Keilhöhe, Herkunft der Werte ├── WatchAlignView.swift Ausrichten mit Vibration ├── WatchDeviceDetailView.swift Alle Werte eines Geräts └── WatchFridgeControls.swift Kühlbox stellen @@ -376,9 +397,15 @@ Config/ 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.** Sie zeigt nur, was von dort kommt. Steckt das - iPhone ausser Reichweite, steht auf der Uhr, wie alt der letzte Stand ist – - aber kein neuer. +* **Die Uhr braucht das iPhone – ausser für die Nivellierung.** Batterie, Solar + und Kühlbox zeigt sie nur, was von dort kommt; ist das iPhone ausser + Reichweite, steht dort, wie alt der letzte Stand ist. Den Neigungsmesser + erreicht sie selbst. +* **Neigungsmesser und Uhr:** Der ESP32 nimmt mehrere Verbindungen an, iPhone + und Uhr können also gleichzeitig mitlesen. Weist er die zweite ab, hilft es, + die iPhone-App zu schliessen – dann gehört der Sensor der Uhr. +* **Auf der Uhr muss beim ersten Start Bluetooth erlaubt werden**, sonst bleibt + die Nivellierung dort leer. * **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel diff --git a/CamperMonitor/Bluetooth/LevelSession.swift b/Shared/Bluetooth/LevelSession.swift similarity index 100% rename from CamperMonitor/Bluetooth/LevelSession.swift rename to Shared/Bluetooth/LevelSession.swift diff --git a/CamperMonitor/Bluetooth/VanAlignProtocol.swift b/Shared/Bluetooth/VanAlignProtocol.swift similarity index 100% rename from CamperMonitor/Bluetooth/VanAlignProtocol.swift rename to Shared/Bluetooth/VanAlignProtocol.swift diff --git a/Shared/VehicleTilt.swift b/Shared/VehicleTilt.swift new file mode 100644 index 0000000..bccaad7 --- /dev/null +++ b/Shared/VehicleTilt.swift @@ -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 + } +} diff --git a/Shared/WatchLink/WatchLink.swift b/Shared/WatchLink/WatchLink.swift index 5b5fd86..58f7001 100644 --- a/Shared/WatchLink/WatchLink.swift +++ b/Shared/WatchLink/WatchLink.swift @@ -2,18 +2,22 @@ import Foundation /// Was zwischen iPhone und Uhr hin- und hergeht. /// -/// Die Uhr funkt **nicht** selbst zu den Geräten im Fahrzeug, obwohl watchOS -/// das könnte. Zwei Gründe sprechen dagegen, und beide sind hart: +/// Die Aufteilung folgt dem, was jedes Gerät hergibt: /// -/// * BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu. -/// Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie -/// zu ergänzen. -/// * Die Victron-Schlüssel liegen in der Keychain des iPhones. Die Uhr hat ihre -/// eigene; die Schlüssel dorthin zu kopieren hiesse, sie ein zweites Mal -/// aufzubewahren, ohne dass ein zweiter Aufbewahrungsort etwas brächte. +/// * **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. /// -/// Das iPhone bleibt also das Funkgerät und schickt fertige Messwerte weiter, -/// die Uhr ist Anzeige und Fernbedienung. +/// Ü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 @@ -84,6 +88,10 @@ struct WatchDevice: Codable, Equatable, Identifiable, Sendable { 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? @@ -125,7 +133,6 @@ enum WatchCommand: Codable, Equatable, Sendable { case fridgeEco(device: UUID, eco: Bool) case fridgeLock(device: UUID, locked: Bool) case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int) - case calibrateLevel(device: UUID) /// Ob der Befehl den schnellen Sendetakt verlängert. var wantsLiveUpdates: Bool { diff --git a/Tests/main.swift b/Tests/main.swift index a05cb44..e0c54bb 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -980,6 +980,12 @@ boosterSnapshot.metrics = [ Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2), ] +// Quer eingebauter Sensor: Die Uhr rechnet damit selbst, also muss die +// Zuordnung mit über die Strecke. +var watchOrientation = SensorOrientation() +watchOrientation.longitudinalSource = .roll +watchOrientation.invertLateral = true + let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false, isDualZone: false, unitSymbol: "°C", leftTarget: 4, leftCurrent: 6, @@ -994,12 +1000,13 @@ let watchPayload = WatchPayload( devices: [ WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling, link: .live, snapshot: watchLevelSnapshot, - level: watchLevel, fridge: nil), + level: watchLevel, orientation: watchOrientation, fridge: nil), WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster, link: .failed("Nicht gefunden"), snapshot: boosterSnapshot, - level: nil, fridge: nil), + level: nil, orientation: nil, fridge: nil), WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge, - link: .live, snapshot: nil, level: nil, fridge: watchFridge), + link: .live, snapshot: nil, level: nil, + orientation: nil, fridge: watchFridge), ]) // Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen. @@ -1014,6 +1021,10 @@ if let message = try? watchPayload.message(), 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) } @@ -1068,7 +1079,6 @@ let commands: [WatchCommand] = [ .fridgeEco(device: watchFridgeID, eco: true), .fridgeLock(device: watchFridgeID, locked: true), .fridgeTarget(device: watchFridgeID, zone: .right, value: -3), - .calibrateLevel(device: watchLevelID), ] for command in commands { guard let message = try? command.message(), diff --git a/run-tests.sh b/run-tests.sh index 0bf8401..4452217 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -13,7 +13,7 @@ swiftc -O -o "$OUT/tests" \ CamperMonitor/Bluetooth/JBDProtocol.swift \ CamperMonitor/Bluetooth/WattCycleProtocol.swift \ CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ - CamperMonitor/Bluetooth/VanAlignProtocol.swift \ + Shared/Bluetooth/VanAlignProtocol.swift \ Shared/Models/DeviceSnapshot.swift \ Shared/Models/ConfiguredDevice.swift \ Shared/Models/LevelState.swift \