App-Namen im iOS-Projekt auf VanControl vereinheitlichen

CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten
solar-integration-Branch) liefen unter zwei verschiedenen internen
Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro"
heisst. Jetzt durchgängig VanControl:

- Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/,
  VanAligneiOSWidget/ → VanControl/, VanControlWatch/,
  VanControlComplication/, VanControlWidget/
- Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle
  Targets/Schemes/Produktnamen entsprechend umbenannt
- Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl*
  (App noch nicht veröffentlicht); dabei auch die
  WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte
  de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes
  trugen
- Swift-Dateien/Typen: CamperMonitorApp → VanControlApp,
  VanAligneiOSWidget* → VanControlWidget*
- Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh
  auf die neuen Pfade angepasst

Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign"
als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein
App-Name) sowie der komplette Android/-Ordner.

Build (App, Watch, Debug) und Protokoll-Testlauf grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fototeddy
2026-09-06 21:25:28 +02:00
co-authored by Claude Sonnet 5
parent 83ea85f3b8
commit 303a9735d0
79 changed files with 190 additions and 190 deletions
+259
View File
@@ -0,0 +1,259 @@
import SwiftUI
/// Einrichten eines neuen Geräts: scannen, auswählen, benennen.
struct AddDeviceView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var selected: Discovery?
/// Auf einem Stellplatz sind dutzende fremde Geräte in Reichweite.
@State private var showsAllDevices = false
/// Nur Geräte anzeigen, die in den letzten Sekunden zu hören waren
/// sonst füllt sich die Liste in Wohnmobilparks endlos.
private var visibleDiscoveries: [Discovery] {
// Bewusst nicht nach Signalstärke sortieren: die schwankt im
// Sekundentakt und die Liste würde unter dem Finger springen.
bluetooth.discoveries.values
.filter { showsAllDevices || $0.isVictron || $0.looksLikeSupported }
.sorted { lhs, rhs in
if lhs.isVictron != rhs.isVictron { return lhs.isVictron }
if lhs.looksLikeSupported != rhs.looksLikeSupported { return lhs.looksLikeSupported }
return lhs.firstSeen < rhs.firstSeen
}
}
private var alreadyAdded: Set<UUID> {
Set(store.activeDevices.map(\.peripheralID))
}
var body: some View {
NavigationStack {
List {
Picker("Anzeige", selection: $showsAllDevices) {
Text("Passende Geräte").tag(false)
Text("Alle").tag(true)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
Section {
if visibleDiscoveries.isEmpty {
Label(showsAllDevices ? "Suche…" : "Noch nichts Passendes gefunden",
systemImage: "antenna.radiowaves.left.and.right")
.foregroundStyle(.secondary)
}
ForEach(visibleDiscoveries) { discovery in
Button {
selected = discovery
} label: {
row(for: discovery)
}
.disabled(alreadyAdded.contains(discovery.id))
}
} header: {
HStack {
Text("Gefundene Geräte")
Spacer()
ProgressView().controlSize(.small)
}
} footer: {
Text("Victron-Geräte werden automatisch erkannt. Damit sie hier "
+ "auftauchen, muss „Instant Readout“ in VictronConnect aktiv sein. "
+ "Das BMS meldet sich meist als „DL-…“. Kühlboxen tragen oft "
+ "einen kryptischen Namen findest du dein Gerät nicht, auf "
+ "„Alle“ umschalten.")
}
}
.navigationTitle("Gerät für \(store.activeProfile?.name ?? "Camper")")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fertig") { dismiss() }
}
}
.sheet(item: $selected) { discovery in
ConfigureDeviceView(discovery: discovery) {
dismiss()
}
}
.onAppear {
bluetooth.clearDiscoveries()
bluetooth.isDiscovering = true
}
.onDisappear {
bluetooth.isDiscovering = false
}
}
}
private func row(for discovery: Discovery) -> some View {
HStack(spacing: 12) {
Image(systemName: discovery.isVictron ? "bolt.circle.fill"
: discovery.looksLikeSupported ? "battery.100percent" : "dot.radiowaves.left.and.right")
.font(.title3)
.foregroundStyle(discovery.isVictron || discovery.looksLikeSupported ? Color.accentColor : Color.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(discovery.displayName)
.foregroundStyle(.primary)
Text(discovery.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if alreadyAdded.contains(discovery.id) {
Text("Hinzugefügt")
.font(.caption)
.foregroundStyle(.secondary)
} else {
SignalBars(rssi: discovery.rssi)
}
}
}
}
/// Formular für ein neu gewähltes Gerät.
private struct ConfigureDeviceView: View {
let discovery: Discovery
let onSaved: () -> Void
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@State private var role: DeviceRole = .victronSolarCharger
@State private var key: String = ""
private var needsKey: Bool { role.transport == .advertisement }
private var keyIsValid: Bool { key.hexBytes?.count == 16 }
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty && (!needsKey || key.isEmpty || keyIsValid)
}
var body: some View {
NavigationStack {
Form {
Section("Gerät") {
LabeledContent("Gefunden als", value: discovery.displayName)
TextField("Name", text: $name)
Picker("Art", selection: $role) {
ForEach(DeviceRole.allCases) { role in
Text(role.title).tag(role)
}
}
}
if needsKey {
Section {
TextField("32 Hex-Zeichen", text: $key)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !key.isEmpty && !keyIsValid {
Label("Der Schlüssel muss 16 Byte (32 Hex-Zeichen) haben.",
systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(.orange)
}
} header: {
Text("Verschlüsselungsschlüssel")
} footer: {
Text("VictronConnect → Gerät → Zahnrad → ⋮ → Produkt-Info → "
+ "„Instant Readout“ aktivieren → Verschlüsselungsdaten anzeigen. "
+ "Kann auch später nachgetragen werden.")
}
}
}
.navigationTitle("Einrichten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Sichern", action: save).disabled(!canSave)
}
}
.onAppear(perform: prefill)
}
}
/// Aus dem Advertisement lässt sich Art und Name oft schon erraten.
private func prefill() {
if let recordType = discovery.victronRecordType {
switch VictronAdvertisement.RecordType(rawValue: recordType) {
case .solarCharger: role = .victronSolarCharger
case .dcdcConverter, .orionXS: role = .chargeBooster
case .batteryMonitor: role = .batteryMonitor
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
} else if discovery.looksLikeSupported {
role = .bms
}
// Funknamen wie "WTaEaAA25342229" taugen nicht als Anzeigename. Die
// Art des Geräts ist der bessere Vorschlag; der Funkname steht
// 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
} else {
name = role.title
}
}
private func save() {
let device = ConfiguredDevice(
name: name.trimmingCharacters(in: .whitespaces),
role: role,
profileID: store.activeProfileID,
peripheralID: discovery.id,
advertisedName: discovery.name
)
store.add(device, victronKey: needsKey && keyIsValid ? key : nil)
bluetooth.refreshConfiguration()
dismiss()
onSaved()
}
}
/// Signalstärke als drei Balken.
struct SignalBars: View {
let rssi: Int
private var level: Int {
switch rssi {
case (-60)...: return 3
case (-75)..<(-60): return 2
case (-90)..<(-75): return 1
default: return 0
}
}
var body: some View {
HStack(alignment: .bottom, spacing: 2) {
ForEach(1...3, id: \.self) { bar in
RoundedRectangle(cornerRadius: 1)
.fill(bar <= level ? Color.accentColor : Color.secondary.opacity(0.25))
.frame(width: 3, height: CGFloat(bar) * 4 + 2)
}
}
.accessibilityLabel("Signalstärke \(level) von 3")
}
}
@@ -0,0 +1,302 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
/// Der Ausrichtungs-Assistent: begleitet das Rangieren und sagt, ob es besser
/// oder schlechter wird und wo es am besten stand.
struct AlignmentAssistantView: View {
let device: ConfiguredDevice
let profile: Profile?
@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
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
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 {
Group {
if isLandscape {
landscapeLayout
} else {
portraitLayout
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Ausrichtungs-Assistent")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { dismiss() }
}
}
}
.onChange(of: state) { _, new in
guard let pitch = new.pitch, let roll = new.roll else { return }
assistant.add(pitch: pitch, roll: roll)
announceIfReached()
}
.onAppear {
// Den anliegenden Wert gleich übernehmen. Sonst stünde bis zur
// nächsten Messung "Warte auf Messwerte", obwohl längst welche da
// sind die Ansicht wirkt dann tot.
if let pitch = state.pitch, let roll = state.roll {
assistant.add(pitch: pitch, roll: roll)
}
// Beim Rangieren schaut man immer wieder aufs Display; es darf
// dabei nicht dunkel werden.
#if canImport(UIKit)
UIApplication.shared.isIdleTimerDisabled = true
#endif
}
.onDisappear {
#if canImport(UIKit)
UIApplication.shared.isIdleTimerDisabled = false
#endif
}
}
// 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.
private var disconnectedBanner: some View {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Nicht verbunden").font(.headline)
Text("Die Anzeige steht still, bis der Neigungsmesser wieder da ist.")
.font(.callout)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "antenna.radiowaves.left.and.right.slash")
.font(.title)
.foregroundStyle(.orange)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.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(isLandscape ? .title2 : .title)
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
Text(assistant.advice)
.font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
.frame(maxWidth: .infinity, alignment: .leading)
}
.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: 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, _ text: String) -> some View {
VStack(spacing: isLandscape ? 1 : 4) {
Text(text)
.font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, isLandscape ? 6 : 12)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
}
private func bestPointCard(best: AlignmentAssistant.Sample,
gain: Double,
seconds: TimeInterval) -> some View {
VStack(alignment: .leading, spacing: 6) {
Label("Bester Punkt", systemImage: "flag.checkered")
.font(.headline)
Text(String(format: "Vor %.0f Sekunden stand das Fahrzeug %.1f° flacher (%.1f° statt %.1f°).",
seconds.rounded(), gain, best.deviation,
assistant.current?.deviation ?? 0))
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
@ViewBuilder
private var wedgeSection: some View {
// 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: isLandscape ? 6 : 10) {
Label("Auffahrkeile", systemImage: "triangle.fill")
.font(isLandscape ? .subheadline.weight(.semibold) : .headline)
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(.caption)
.foregroundStyle(.secondary)
} 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)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(isLandscape ? 10 : 16)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
/// Einmal spürbar melden, wenn die Waage erreicht ist man schaut beim
/// Rangieren nicht dauernd aufs Display.
private func announceIfReached() {
guard assistant.hasReachedTarget else {
didAnnounceTarget = false
return
}
guard !didAnnounceTarget else { return }
didAnnounceTarget = true
#if canImport(UIKit)
UINotificationFeedbackGenerator().notificationOccurred(.success)
#endif
}
}
+116
View File
@@ -0,0 +1,116 @@
import SwiftUI
struct DashboardView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@State private var isAddingDevice = false
@State private var isManagingProfiles = false
@State private var isShowingSettings = false
private let columns = [GridItem(.adaptive(minimum: 300), spacing: 16)]
var body: some View {
NavigationStack {
ScrollView {
if !bluetooth.isBluetoothReady {
statusBanner
}
if store.activeDevices.isEmpty {
emptyState
} else {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(store.activeDevices) { device in
NavigationLink {
DeviceDetailView(device: device)
} label: {
DeviceCard(
device: device,
snapshot: bluetooth.snapshots[device.id],
linkState: bluetooth.linkStates[device.id] ?? .searching
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.top, 8)
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle(store.activeProfile?.name ?? "Camper")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
profileMenu
}
ToolbarItem(placement: .primaryAction) {
Button("Gerät hinzufügen", systemImage: "plus") {
isAddingDevice = true
}
}
}
.sheet(isPresented: $isAddingDevice) {
AddDeviceView()
}
.sheet(isPresented: $isManagingProfiles) {
ProfilesView()
}
.sheet(isPresented: $isShowingSettings) {
SettingsView()
}
}
}
/// Umschalter zwischen den Fahrzeugen.
private var profileMenu: some View {
Menu {
Picker("Fahrzeug", selection: Binding(
get: { store.activeProfileID },
set: { id in
guard let profile = store.profiles.first(where: { $0.id == id }) else { return }
store.selectProfile(profile)
bluetooth.refreshConfiguration()
}
)) {
ForEach(store.profiles) { profile in
Label(profile.name, systemImage: profile.symbol).tag(profile.id)
}
}
Divider()
Button("Fahrzeuge verwalten…", systemImage: "car.2") {
isManagingProfiles = true
}
Button("Einstellungen…", systemImage: "gearshape") {
isShowingSettings = true
}
} label: {
Label(store.activeProfile?.name ?? "Fahrzeug",
systemImage: store.activeProfile?.symbol ?? "box.truck")
.labelStyle(.iconOnly)
}
}
private var statusBanner: some View {
Label(bluetooth.bluetoothStatusText, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.orange.opacity(0.15), in: .rect(cornerRadius: 12))
.padding(.horizontal)
.padding(.top, 8)
}
private var emptyState: some View {
ContentUnavailableView {
Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right")
} description: {
Text("Füge \(store.activeProfile.map { "\($0.name)" } ?? "diesem Fahrzeug") "
+ "den Ladebooster, den Solarladeregler und das BMS hinzu.")
} actions: {
Button("Gerät suchen") { isAddingDevice = true }
.buttonStyle(.borderedProminent)
}
.padding(.top, 60)
}
}
+224
View File
@@ -0,0 +1,224 @@
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, 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))
}
}
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
}
}
+515
View File
@@ -0,0 +1,515 @@
import Charts
import SwiftUI
struct DeviceDetailView: View {
let device: ConfiguredDevice
@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
/// Schlüssel auf einer eigenen Seite.
@State private var keyInput = ""
@State private var showDeleteConfirmation = false
@State private var didCopyReport = false
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
/// Ob die technischen Angaben eingeblendet werden.
///
/// Im Alltag stören sie nur. Meldet ein Gerät aber einen Fehler oder fehlt
/// der Schlüssel, sind sie genau das, was weiterhilft dann werden sie
/// unabhängig von der Einstellung gezeigt.
private var showsTechnicalDetails: Bool {
if showDiagnostics { return true }
switch linkState {
case .failed, .needsKey: return true
default: return false
}
}
/// Immer der aktuelle Stand aus dem Speicher `device` ist die
/// Momentaufnahme beim Öffnen und veraltet nach jeder Änderung.
private var currentDevice: ConfiguredDevice {
store.devices.first { $0.id == device.id } ?? device
}
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 {
// 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 == .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 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 showsCompactLevelLayout { statusSection }
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.")
}
}
}
// MARK: - Abschnitte
private var statusSection: some View {
Section {
LabeledContent("Verbindung") {
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
}
if let state = snapshot?.state {
LabeledContent("Zustand", value: state)
}
if let fault = snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
ForEach(snapshot?.offReasons ?? [], id: \.self) { reason in
Label(reason, systemImage: "pause.circle")
.foregroundStyle(.orange)
}
// Solange die Werte frisch sind, sagt das Alter nichts, was der
// Verbindungspunkt nicht schon zeigt. Erst wenn sie stehenbleiben,
// ist es die eigentliche Nachricht.
if let snapshot, snapshot.isStale || showsTechnicalDetails {
LabeledContent("Aktualisiert vor") {
Text(snapshot.timestamp, style: .relative)
}
.foregroundStyle(.secondary)
}
// Der Empfangspegel hilft beim Suchen eines Geräts, im Alltag
// sagt er nichts deshalb nur bei eingeblendeter Diagnose.
if showsTechnicalDetails, let rssi = snapshot?.rssi {
LabeledContent("Signal", value: "\(rssi) dBm")
.foregroundStyle(.secondary)
}
}
}
private func cellSection(_ voltages: [Double]) -> some View {
let minimum = voltages.min() ?? 0
let maximum = voltages.max() ?? 0
return Section("Zellspannungen") {
Chart(Array(voltages.enumerated()), id: \.offset) { index, voltage in
// Kategoriale x-Achse: sonst stehen die Balken zwischen den
// Beschriftungen statt darüber.
BarMark(
x: .value("Zelle", "\(index + 1)"),
y: .value("Spannung", voltage)
)
.foregroundStyle(voltage == maximum ? Color.orange
: voltage == minimum ? Color.blue : Color.accentColor)
// Die Zellnummer als Beschriftung am Balken statt über die
// x-Achse die blendet Swift Charts in der Liste aus.
// Ab neun Zellen wird es zu eng, dann ordnet die Liste zu.
.annotation(position: .bottom, alignment: .center) {
if voltages.count <= 8 {
Text("\(index + 1)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.chartXAxis(.hidden)
// Der interessante Bereich sind die letzten Millivolt, nicht die
// absolute Spannung deshalb eng um die Messwerte zoomen.
.chartYScale(domain: (minimum - 0.05)...(maximum + 0.05))
.chartYAxisLabel("V")
.frame(height: 160)
.padding(.vertical, 8)
ForEach(Array(voltages.enumerated()), id: \.offset) { index, voltage in
LabeledContent("Zelle \(index + 1)") {
Text(String(format: "%.3f V", voltage)).monospacedDigit()
}
}
}
}
/// Ohne passenden Schlüssel bleibt das Gerät stumm das ist dann keine
/// Nebensache, sondern das Einzige, was zu tun ist.
private var needsKeyAttention: Bool {
guard currentDevice.role.transport == .advertisement else { return false }
if keyBytesAgree == false { return true }
// Kommen Werte an, passt der Schlüssel offensichtlich dann ist hier
// nichts zu tun und nichts zu melden.
if snapshot != nil, linkState == .live { return false }
return store.victronKeyText(for: device.id)?.hexBytes?.count != 16
}
private var keyPrompt: some View {
Section {
NavigationLink {
VictronKeyView(device: currentDevice)
} label: {
Label(keyBytesAgree == false
? "Schlüssel passt nicht zum Gerät"
: "Verschlüsselungsschlüssel eintragen",
systemImage: "key.horizontal.fill")
.foregroundStyle(.orange)
}
} footer: {
Text(keyBytesAgree == false
? "Der hinterlegte Schlüssel stammt von einem anderen Victron-Gerät."
: "Victron-Geräte senden ihre Werte verschlüsselt. Ohne den "
+ "Schlüssel aus VictronConnect bleibt die Anzeige leer.")
}
}
/// Zeigt, was das Gerät unverschlüsselt sendet. Wichtigster Wert ist das
/// erste Schlüsselbyte: stimmt es nicht mit der Eingabe überein, gehört der
/// Schlüssel zu einem anderen Victron-Gerät.
@ViewBuilder
private var diagnosticsSection: some View {
if let info = bluetooth.diagnostics[device.id] {
Section {
LabeledContent("Datensatz", value: info.recordName)
LabeledContent("Produkt-ID") {
Text(info.productIDText).font(.body.monospaced())
}
VStack(alignment: .leading, spacing: 4) {
Text("Rohdaten")
Text(info.rawHex)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
} header: {
Text("Diagnose")
} footer: {
Text("Diese Werte sendet das Gerät unverschlüsselt mit.")
}
}
}
/// nil, solange kein vollständiger Schlüssel eingetragen ist.
private var keyBytesAgree: Bool? {
guard let expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte,
let entered = keyInput.hexBytes?.first else { return nil }
return expected == entered
}
private var keyStatusText: String {
if store.victronKeyText(for: device.id)?.hexBytes?.count != 16 { return "fehlt" }
return keyBytesAgree == false ? "passt nicht" : "hinterlegt"
}
/// Alles auf einmal, zum Weitergeben. Einzeln abzutippen ist zuviel
/// verlangt, und gerade der Merkmalsbaum ist zu lang dafür.
private func report(_ info: BMSDiagnostics) -> String {
var lines = [
"Gerät: \(currentDevice.name) (\(currentDevice.role.title))",
"Protokoll: \(info.dialect)",
"Verbunden: \(info.isConnected ? "ja" : "nein")",
"Empfang abonniert: \(info.isNotifyActive ? "ja" : "nein")",
]
if let position = info.endpointPosition {
lines.append("Weg: \(position.index) von \(position.total)")
}
if let endpoint = info.endpointLabel { lines.append("Merkmal: \(endpoint)") }
if let isBound = info.isBound { lines.append("Angemeldet: \(isBound ? "ja" : "nein")") }
lines.append("Gesendet: \(info.sentFrames) · empfangen: \(info.receivedBytes) Byte"
+ " · bestätigt: \(info.confirmedWrites)")
if let writeError = info.lastWriteError { lines.append("Schreibfehler: \(writeError)") }
if let command = info.lastCommandHex { lines.append("Letzter Stellbefehl: \(command)") }
if let hex = info.lastResponseHex { lines.append("Letzte Antwort: \(hex)") }
if let payload = info.fridgePayloadHex { lines.append("Statusdaten: \(payload)") }
if !info.gattSummary.isEmpty {
lines.append("Merkmale:")
lines.append(contentsOf: info.gattSummary)
}
return lines.joined(separator: "\n")
}
/// Welches Protokoll das BMS spricht und was zuletzt ankam.
@ViewBuilder
private var bmsDiagnosticsSection: some View {
if let info = bluetooth.bmsDiagnostics[device.id] {
Section {
LabeledContent("Erkanntes Protokoll", value: info.dialect)
if let position = info.endpointPosition {
LabeledContent("Verbindungsweg",
value: "\(position.index) von \(position.total)")
}
if let endpoint = info.endpointLabel {
VStack(alignment: .leading, spacing: 4) {
Text("Aktueller Weg")
Text(endpoint)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
LabeledContent("Verbunden") {
Label(info.isConnected ? "ja" : "nein",
systemImage: info.isConnected ? "checkmark.circle" : "xmark.circle")
.foregroundStyle(info.isConnected ? .green : .red)
}
LabeledContent("Empfang abonniert") {
Label(info.isNotifyActive ? "ja" : "nein",
systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle")
.foregroundStyle(info.isNotifyActive ? .green : .orange)
}
if let isBound = info.isBound {
LabeledContent("Angemeldet") {
Label(isBound ? "ja" : "nein",
systemImage: isBound ? "checkmark.circle" : "xmark.circle")
.foregroundStyle(isBound ? .green : .orange)
}
}
if info.confirmedWrites > 0 {
LabeledContent("Schreibvorgänge bestätigt",
value: "\(info.confirmedWrites)")
}
if let writeError = info.lastWriteError {
LabeledContent("Letzter Schreibfehler") {
Text(writeError)
.font(.caption)
.foregroundStyle(.red)
}
}
LabeledContent("Gesendet / empfangen",
value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte")
if let lastSendAt = info.lastSendAt {
LabeledContent("Zuletzt gesendet vor") {
Text(lastSendAt, style: .relative)
}
.foregroundStyle(.secondary)
}
if let command = info.lastCommandHex {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Letzter Stellbefehl")
Spacer()
if let at = info.lastCommandAt {
Text(at, style: .relative).foregroundStyle(.secondary)
}
}
Text(command)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if let hex = info.lastResponseHex {
VStack(alignment: .leading, spacing: 4) {
Text("Letzte Antwort")
Text(hex)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if let payload = info.fridgePayloadHex {
VStack(alignment: .leading, spacing: 4) {
Text("Statusdaten der Box (\(payload.split(separator: " ").count) Byte)")
Text(payload)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
Button {
UIPasteboard.general.string = report(info)
didCopyReport = true
} label: {
Label(didCopyReport ? "Diagnose kopiert" : "Diagnose kopieren",
systemImage: didCopyReport ? "checkmark" : "doc.on.doc")
}
} header: {
Text("Diagnose")
} footer: {
Text("Die App probiert alle Schreib-/Empfangs-Kombinationen des Geräts "
+ "durch und fragt auf jeder Daly (klassisch und Modbus) sowie "
+ "JBD/Xiaoxiang an. Der „Verbindungsweg“ zählt dabei hoch. "
+ "Bleibt „empfangen“ am Ende bei 0 Byte, nimmt das BMS auf keinem "
+ "Weg Kommandos an; kommen Bytes an, ohne dass ein Protokoll "
+ "erkannt wird, spricht es ein noch unbekanntes.")
}
if !info.gattSummary.isEmpty {
Section {
Text(info.gattSummary.joined(separator: "\n"))
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
} header: {
Text("Bluetooth-Merkmale des Geräts")
}
}
}
}
private var settingsSection: some View {
Section("Einstellungen") {
HStack {
Text("Name")
Spacer()
// Beim Abschluss der Eingabe und beim Verlassen der Ansicht
// gesichert bei jedem Tastendruck zu speichern hiesse, die
// ganze Geräteliste je Zeichen neu zu schreiben.
TextField("Gerätename", text: $editedName)
.multilineTextAlignment(.trailing)
.submitLabel(.done)
.onSubmit(saveName)
}
if let advertised = currentDevice.advertisedName, !advertised.isEmpty {
LabeledContent("Gefunden als") {
Text(advertised).font(.caption).foregroundStyle(.secondary)
}
}
LabeledContent("Typ", value: currentDevice.role.title)
if currentDevice.role == .fridge {
Picker("Kühlzonen", selection: Binding(
get: { currentDevice.fridgeZoneMode },
set: { mode in
var updated = currentDevice
updated.fridgeZoneMode = mode
store.update(updated)
bluetooth.updateFridgeZoneMode(for: updated)
}
)) {
ForEach(FridgeZoneMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
}
if currentDevice.role.transport == .advertisement {
NavigationLink {
VictronKeyView(device: currentDevice)
} label: {
LabeledContent("Verschlüsselung", value: keyStatusText)
}
}
LabeledContent("Bluetooth-ID") {
Text(currentDevice.peripheralID.uuidString.prefix(8) + "")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Button("Gerät entfernen", role: .destructive) {
showDeleteConfirmation = true
}
}
}
// MARK: - Aktionen
private func saveName() {
let trimmed = editedName.trimmingCharacters(in: .whitespaces)
// Ein leeres Feld beim Tippen darf den Namen nicht löschen.
guard !trimmed.isEmpty, trimmed != currentDevice.name else { return }
var updated = currentDevice
updated.name = trimmed
store.update(updated)
}
}
+79
View File
@@ -0,0 +1,79 @@
import SwiftUI
/// Bedienelemente einer Alpicool-Kühlbox.
///
/// Alle Schalter folgen dem Gerät, nicht der Vermutung: nach jedem Stellbefehl
/// fragt die Sitzung den Zustand neu ab, und die Ansicht zeigt, was zurückkam.
struct FridgeControls: View {
let device: ConfiguredDevice
let state: AlpicoolState
@Environment(BluetoothManager.self) private var bluetooth
var body: some View {
Section {
// Ohne Rückfrage: ein Bestätigungsdialog an einer Section wird von
// SwiftUI nicht zuverlässig angezeigt, und der Schalter blieb dann
// wirkungslos. Der Weg ist jetzt für Ein und Aus derselbe.
Toggle("Eingeschaltet", isOn: Binding(
get: { state.isPoweredOn },
set: { bluetooth.setFridgePower($0, for: device.id) }
))
Picker("Betriebsart", selection: Binding(
get: { state.isEco },
set: { bluetooth.setFridgeEco($0, for: device.id) }
)) {
Text("Max").tag(false)
Text("Eco").tag(true)
}
.pickerStyle(.segmented)
.disabled(!state.isPoweredOn)
targetStepper(zone: .left,
title: state.isDualZone ? "Soll links" : "Solltemperatur",
value: state.leftTarget)
if state.isDualZone {
targetStepper(zone: .right, title: "Soll rechts", value: state.rightTarget)
}
Toggle("Bedienfeld gesperrt", isOn: Binding(
get: { state.isLocked },
set: { bluetooth.setFridgeLock($0, for: device.id) }
))
} header: {
Text("Steuerung")
} footer: {
Text(footerText)
}
}
/// Ob die Box gerade erreichbar ist. Ist sie es nicht, wird ein Befehl
/// aufgehoben statt verworfen das gehört gesagt, sonst sieht es aus, als
/// hätte das Tippen nichts bewirkt.
private var isLinked: Bool { bluetooth.linkStates[device.id] == .live }
private var footerText: String {
guard isLinked else {
return "Die Box ist gerade nicht verbunden. Die Änderung wird "
+ "gemerkt und geht raus, sobald sie wieder erreichbar ist."
}
return "Änderungen gehen direkt an die Box. Der angezeigte Stand kommt "
+ "aus ihrer Antwort, nicht aus der Eingabe."
}
private func targetStepper(zone: AlpicoolState.Zone, title: String, value: Int?) -> some View {
let range = state.targetRange
return Stepper(value: Binding(
get: { value ?? range.lowerBound },
set: { bluetooth.setFridgeTarget($0, zone: zone, for: device.id) }
), in: range) {
LabeledContent(title) {
Text(value.map { "\($0) \(state.unitSymbol)" } ?? "")
.monospacedDigit()
}
}
.disabled(!state.isPoweredOn || value == nil)
}
}
+125
View File
@@ -0,0 +1,125 @@
import SwiftUI
/// Einrichtung des Neigungsmessers: Einbaulage und Nullpunkt.
///
/// Beides wird einmal eingestellt und danach kaum wieder angefasst. In der
/// Geräteansicht standen die Knöpfe direkt unter der Libelle ein Fehlgriff
/// beim Ablesen verstellte dort den Nullpunkt. Deshalb liegen sie hier.
///
/// Die Reihenfolge ist nicht beliebig: erst muss klar sein, welche Achse des
/// Sensors welche des Fahrzeugs ist, sonst wird der Nullpunkt auf die falsche
/// Achse gelegt.
struct LevelSetupView: View {
let device: ConfiguredDevice
@Environment(BluetoothManager.self) private var bluetooth
@State private var showSensorSetup = false
@State private var showResetConfirmation = false
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
/// Immer der aktuelle Stand `device` veraltet, sobald der Assistent
/// eine neue Einbaulage gespeichert hat.
@Environment(DeviceStore.self) private var store
private var currentDevice: ConfiguredDevice {
store.devices.first { $0.id == device.id } ?? device
}
var body: some View {
List {
Section {
HStack(spacing: 24) {
reading("Längs", state.pitch)
reading("Quer", state.roll)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
Section {
Button {
showSensorSetup = true
} label: {
LabeledContent {
Text(currentDevice.sensorOrientation.summary)
.font(.caption)
} label: {
Label("Einbaulage bestimmen",
systemImage: "arrow.triangle.2.circlepath")
}
}
.disabled(!state.hasReading)
} header: {
Text("Schritt 1 Einbaulage")
} footer: {
Text("Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet er längs "
+ "und quer vertauscht. Der Assistent klärt das durch zweimaliges "
+ "Kippen. Danach den Nullpunkt setzen.")
}
Section {
Button {
bluetooth.calibrateLevel(for: device.id)
} label: {
Label("Aktuelle Lage als eben übernehmen", systemImage: "scope")
}
.disabled(!state.hasReading)
if !state.isKnownUncalibrated {
Button(role: .destructive) {
showResetConfirmation = true
} label: {
// Ohne das bliebe das Symbol in der Akzentfarbe, während
// die Beschriftung rot ist.
Label("Nullpunkt verwerfen", systemImage: "arrow.uturn.backward")
.foregroundStyle(.red)
}
}
} header: {
Text("Schritt 2 Nullpunkt")
} footer: {
Text(calibrationHint)
}
}
.navigationTitle("Neigungsmesser einrichten")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showSensorSetup) {
SensorSetupView(device: currentDevice)
}
.confirmationDialog("Nullpunkt verwerfen?",
isPresented: $showResetConfirmation,
titleVisibility: .visible) {
Button("Verwerfen", role: .destructive) {
bluetooth.resetLevelCalibration(for: device.id)
}
} message: {
Text("Die Anzeige zeigt danach wieder die Lage des Sensors.")
}
}
private var calibrationHint: String {
if state.isCalibrated, let pitchOffset = state.pitchOffset,
let rollOffset = state.rollOffset {
return String(format: "Der Nullpunkt liegt bei %.1f° längs und %.1f° quer. "
+ "Zum Neusetzen das Fahrzeug eben stellen und dann tippen.",
pitchOffset, rollOffset)
}
if state.isKnownUncalibrated {
return "Noch kein Nullpunkt gesetzt die Anzeige zeigt die Lage des "
+ "Sensors, nicht die des Fahrzeugs. Fahrzeug eben stellen, dann tippen."
}
// Ältere Firmware gibt die Offsets nicht heraus.
return "Zum Setzen das Fahrzeug eben stellen und dann tippen. Ob schon ein "
+ "Nullpunkt gesetzt wurde, meldet dieses Gerät nicht zurück."
}
private func reading(_ title: String, _ value: Double?) -> some View {
VStack(spacing: 2) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
.font(.title2.weight(.semibold).monospacedDigit())
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
+303
View File
@@ -0,0 +1,303 @@
import SwiftUI
/// Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt.
///
/// Die Blase wandert dorthin, wo das Fahrzeug **höher** steht so, wie sich
/// eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die
/// Gegenseite anheben.
struct LevelBubble: View {
let pitch: Double?
let roll: Double?
/// Bis zu welcher Neigung die Anzeige ausschlägt.
var range: Double = 6
private var isLevel: Bool {
guard let pitch, let roll else { return false }
return abs(pitch) <= LevelState.levelTolerance
&& abs(roll) <= LevelState.levelTolerance
}
/// Grün nur, wenn es wirklich eben ist. Die Akzentfarbe der App ist selbst
/// grün damit sähe "schief" genauso aus wie "eben".
private var bubbleColor: Color {
guard let deviation = maxDeviation else { return .secondary }
if deviation <= LevelState.levelTolerance { return .green }
if deviation <= 2 { return .orange }
return .red
}
private var maxDeviation: Double? {
switch (pitch, roll) {
case let (p?, r?): return max(abs(p), abs(r))
case let (p?, nil): return abs(p)
case let (nil, r?): return abs(r)
default: return nil
}
}
var body: some View {
GeometryReader { geometry in
let side = min(geometry.size.width, geometry.size.height)
let radius = side / 2
let bubble = side * 0.16
// Die Blase darf den Rand nicht verlassen, auch bei starker Neigung.
let travel = radius - bubble / 2 - 4
let offsetX = clamped(roll) / range * travel
let offsetY = clamped(pitch) / range * travel
ZStack {
Circle()
.fill(Color(.secondarySystemFill))
Circle()
.strokeBorder(Color.secondary.opacity(0.35), lineWidth: 1)
// Ringe als echter Maßstab: der innere markiert die Toleranz,
// der mittlere zwei Grad. Ohne Maßstab sagt die Blasenlage
// nichts darüber, wie weit es noch ist.
let toleranceRadius = max(LevelState.levelTolerance / range * travel, bubble * 0.6)
Circle()
.strokeBorder(isLevel ? Color.green : Color.secondary.opacity(0.5),
lineWidth: isLevel ? 2 : 1)
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
Circle()
.strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1)
.frame(width: 2.0 / range * travel * 2, height: 2.0 / range * travel * 2)
Path { path in
path.move(to: CGPoint(x: radius - travel, y: radius))
path.addLine(to: CGPoint(x: radius + travel, y: radius))
path.move(to: CGPoint(x: radius, y: radius - travel))
path.addLine(to: CGPoint(x: radius, y: radius + travel))
}
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
Circle()
.fill(bubbleColor)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert also nach oben in der Ansicht nach hinten.
.offset(x: offsetX, y: -offsetY)
.animation(.spring(duration: 0.35), value: offsetX)
.animation(.spring(duration: 0.35), value: offsetY)
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
}
.frame(width: side, height: side)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.aspectRatio(1, contentMode: .fit)
.accessibilityLabel(accessibilityText)
}
private func clamped(_ value: Double?) -> Double {
guard let value else { return 0 }
return min(max(value, -range), range)
}
private var accessibilityText: String {
guard let pitch, let roll else { return "Keine Messwerte" }
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
}
}
/// Anzeige und Kalibrierung des Neigungsmessers.
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 {
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))
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
Section {
Button {
showAssistant = true
} label: {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Ausrichtungs-Assistent")
Text("Begleitet das Rangieren")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "car.side.arrowtriangle.up.arrowtriangle.down")
}
}
// Der Assistent lebt von laufenden Messwerten. Ein einmal
// empfangener Wert genügt nicht: nach einem Verbindungsabbruch
// bliebe er stehen und die Ansicht sähe eingefroren aus.
.disabled(!isLive || !state.hasReading)
// Der Vollbildschirm gehört an den Knopf, nicht an den Abschnitt.
// SwiftUI zeigt Blätter und Vollbildschirme an einer `Section`
// nicht zuverlässig an dasselbe hatte die Kühlbox schon.
.fullScreenCover(isPresented: $showAssistant) {
AlignmentAssistantView(device: device, profile: store.activeProfile)
}
} footer: {
if !isLive {
Text("Der Assistent braucht laufende Messwerte. Der Neigungsmesser "
+ "ist gerade nicht verbunden.")
}
}
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 {
NavigationLink {
LevelSetupView(device: device)
} label: {
LabeledContent {
Text(setupSummary).font(.caption)
} label: {
Label("Neigungsmesser einrichten", systemImage: "slider.horizontal.3")
}
}
} footer: {
Text("Einbaulage des Sensors und Nullpunkt der Anzeige.")
}
}
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, _ text: String) -> some View {
VStack(spacing: 2) {
Text(text)
.font(.title2.weight(.semibold).monospacedDigit())
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
+232
View File
@@ -0,0 +1,232 @@
import SwiftUI
/// Fahrzeuge anlegen, umbenennen und entfernen.
struct ProfilesView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var editing: Profile?
@State private var pendingDeletion: Profile?
var body: some View {
NavigationStack {
List {
Section {
ForEach(store.profiles) { profile in
row(for: profile)
}
} footer: {
Text("Antippen wählt das Fahrzeug aus, das ⓘ öffnet Name, Symbol "
+ "und Maße. Jedes Fahrzeug hat seine eigenen Geräte, und die "
+ "App liest immer nur die des gewählten aus.")
}
Section {
Button("Fahrzeug hinzufügen", systemImage: "plus") {
let profile = store.addProfile(named: "Camper \(store.profiles.count + 1)")
editing = profile
}
}
}
.navigationTitle("Fahrzeuge")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { dismiss() }
}
}
.sheet(item: $editing) { profile in
ProfileEditView(profile: profile)
}
.confirmationDialog("Fahrzeug entfernen?",
isPresented: .init(get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }),
titleVisibility: .visible) {
Button("Entfernen", role: .destructive) {
if let pendingDeletion {
store.removeProfile(pendingDeletion)
bluetooth.refreshConfiguration()
}
pendingDeletion = nil
}
} message: {
if let pendingDeletion {
Text("\(pendingDeletion.name)“ und die \(store.deviceCount(in: pendingDeletion)) "
+ "darin eingerichteten Geräte werden gelöscht.")
}
}
}
}
private func row(for profile: Profile) -> some View {
HStack(spacing: 12) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(profile.name)
Text(summary(for: profile))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: profile.symbol)
}
// Nur dieser Bereich wählt aus; der Knopf rechts bleibt frei.
.contentShape(.rect)
.onTapGesture {
store.selectProfile(profile)
bluetooth.refreshConfiguration()
}
Spacer(minLength: 0)
if profile.id == store.activeProfileID {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
.fontWeight(.semibold)
}
// Zum Bearbeiten reichte bisher nur das Wischen - das findet
// niemand, erst recht nicht für die Fahrzeugmaße.
Button {
editing = profile
} label: {
Image(systemName: "info.circle")
.font(.title3)
}
.buttonStyle(.borderless)
.accessibilityLabel("\(profile.name) bearbeiten")
}
.swipeActions(edge: .trailing) {
// Das letzte Fahrzeug muss bleiben, sonst hätten Geräte keinen Ort.
if store.hasMultipleProfiles {
Button("Entfernen", systemImage: "trash", role: .destructive) {
pendingDeletion = profile
}
}
Button("Bearbeiten", systemImage: "pencil") {
editing = profile
}
.tint(.gray)
}
}
private func summary(for profile: Profile) -> String {
let count = store.deviceCount(in: profile)
var parts = [count == 1 ? "1 Gerät" : "\(count) Geräte"]
if profile.trackWidth != nil || profile.wheelbase != nil {
parts.append("Maße hinterlegt")
}
return parts.joined(separator: " · ")
}
}
/// Name, Symbol und Maße eines Fahrzeugs ändern.
private struct ProfileEditView: View {
/// Nimmt Komma wie Punkt an auf einer deutschen Tastatur liegt das Komma
/// näher. Unplausible Werte werden verworfen, damit die Keilhöhe nicht
/// stillschweigend Unsinn ergibt.
static func metres(from text: String) -> Double? {
let cleaned = text.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard let value = Double(cleaned), value > 0.5, value < 12 else { return nil }
return value
}
static func text(from value: Double?) -> String {
guard let value else { return "" }
return String(format: "%.2f", value).replacingOccurrences(of: ".", with: ",")
}
let profile: Profile
@Environment(DeviceStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var symbol = "box.truck"
@State private var trackWidth = ""
@State private var wheelbase = ""
private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)]
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Name", text: $name)
}
Section {
LabeledContent("Spurweite") {
HStack {
TextField("z. B. 2,00", text: $trackWidth)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
Text("m").foregroundStyle(.secondary)
}
}
LabeledContent("Radstand") {
HStack {
TextField("z. B. 3,50", text: $wheelbase)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
Text("m").foregroundStyle(.secondary)
}
}
} header: {
Text("Fahrzeugmaße")
} footer: {
Text("Nur für den Ausrichtungs-Assistenten: Aus Neigung und "
+ "Abstand errechnet er, wie hoch der Auffahrkeil sein muss. "
+ "Ohne Angabe entfällt diese Anzeige.")
}
Section("Symbol") {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(Profile.symbols, id: \.self) { candidate in
Button {
symbol = candidate
} label: {
Image(systemName: candidate)
.font(.title2)
.frame(width: 52, height: 52)
.background(symbol == candidate ? Color.accentColor.opacity(0.18)
: Color.secondary.opacity(0.08),
in: .rect(cornerRadius: 12))
.foregroundStyle(symbol == candidate ? Color.accentColor : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
.navigationTitle("Fahrzeug")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Sichern") {
var updated = profile
updated.name = name.trimmingCharacters(in: .whitespaces)
updated.symbol = symbol
updated.trackWidth = Self.metres(from: trackWidth)
updated.wheelbase = Self.metres(from: wheelbase)
store.update(updated)
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear {
name = profile.name
symbol = profile.symbol
trackWidth = Self.text(from: profile.trackWidth)
wheelbase = Self.text(from: profile.wheelbase)
}
}
}
}
+201
View File
@@ -0,0 +1,201 @@
import SwiftUI
/// Führt durch die Einrichtung der Einbaulage des Neigungsmessers.
///
/// Der Sensor kann quer, gedreht oder kopfüber sitzen. Statt die Lage aus einer
/// Liste raten zu lassen, wird sie gemessen: zweimal kippen, einmal um jede
/// Achse, und aus der Reaktion ergibt sich die Zuordnung.
struct SensorSetupView: View {
let device: ConfiguredDevice
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
private enum Step {
case intro
case tiltNose
case settle
case tiltSide
case done(SensorOrientation)
case failed(OrientationDetection.Failure)
}
@State private var step: Step = .intro
/// Ruhelage, auf die beide Kippbewegungen bezogen werden.
@State private var reference = OrientationDetection.Reading(pitch: 0, roll: 0)
@State private var noseChange: OrientationDetection.Reading?
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
private var live: OrientationDetection.Reading? {
guard let pitch = state.rawPitch, let roll = state.rawRoll else { return nil }
return OrientationDetection.Reading(pitch: pitch, roll: roll)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
content
}
.padding()
.frame(maxWidth: .infinity)
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Einbaulage")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
}
}
}
@ViewBuilder
private var content: some View {
switch step {
case .intro:
card(icon: "arrow.triangle.2.circlepath",
title: "Einbaulage bestimmen",
text: "Sitzt der Sensor quer oder verdreht im Fahrzeug, meldet er "
+ "die Neigung vertauscht. Um das zu klären, wird er gleich "
+ "zweimal gekippt.\n\nBaue ihn dazu so ein oder halte ihn so, "
+ "wie er später sitzen soll. Er muss nicht angeschraubt sein "
+ "nur die Ausrichtung muss stimmen.")
liveReadout
Button("Los gehts") {
reference = live ?? .init(pitch: 0, roll: 0)
step = .tiltNose
}
.buttonStyle(.borderedProminent)
.disabled(live == nil)
case .tiltNose:
card(icon: "arrow.down.forward",
title: "Schritt 1 von 2: nach vorne kippen",
text: "Kippe den Sensor so, als würde das Fahrzeug **vorne "
+ "abwärts** stehen die Front also nach unten.\n\n"
+ "Deutlich kippen, etwa eine Handbreit, und in dieser Lage "
+ "halten. Dann weiter.")
liveReadout
Button("Weiter") { finishNoseStep() }
.buttonStyle(.borderedProminent)
.disabled(live == nil)
case .settle:
card(icon: "equal.circle",
title: "Zurück in die Ruhelage",
text: "Stelle den Sensor wieder so hin wie am Anfang und lass "
+ "ihn kurz ruhen.\n\nVon hier aus wird die zweite Bewegung "
+ "gemessen.")
liveReadout
Button("Weiter") {
reference = live ?? reference
step = .tiltSide
}
.buttonStyle(.borderedProminent)
.disabled(live == nil)
case .tiltSide:
card(icon: "arrow.down.left",
title: "Schritt 2 von 2: nach links kippen",
text: "Kippe den Sensor jetzt so, als würde das Fahrzeug **nach "
+ "links** hängen die linke Seite also nach unten.\n\n"
+ "Wieder deutlich kippen und in dieser Lage halten.")
liveReadout
HStack {
Button("Zurück") { step = .settle }
.buttonStyle(.bordered)
Button("Fertig") { finishSideStep() }
.buttonStyle(.borderedProminent)
.disabled(live == nil)
}
case .done(let orientation):
card(icon: "checkmark.circle.fill",
title: "Einbaulage erkannt",
text: "Ergebnis: **\(orientation.summary)**.\n\nDie Anzeige rechnet "
+ "die Werte des Sensors ab jetzt auf die Achsen des Fahrzeugs "
+ "um. Vergiss nicht, anschliessend im ebenen Stand zu "
+ "kalibrieren.")
Button("Übernehmen") { save(orientation) }
.buttonStyle(.borderedProminent)
case .failed(let failure):
card(icon: "exclamationmark.triangle.fill",
title: "Das hat nicht geklappt",
text: failure.message)
Button("Nochmal versuchen") { step = .intro }
.buttonStyle(.borderedProminent)
}
}
private func card(icon: String, title: String, text: String) -> some View {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 44))
.foregroundStyle(.tint)
Text(title)
.font(.title3.weight(.semibold))
.multilineTextAlignment(.center)
Text(.init(text))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
/// Was der Sensor gerade meldet ohne Umrechnung, denn die wird hier ja
/// erst bestimmt.
private var liveReadout: some View {
HStack(spacing: 16) {
value("Achse A", state.rawPitch)
value("Achse B", state.rawRoll)
}
}
private func value(_ title: String, _ reading: Double?) -> some View {
VStack(spacing: 4) {
Text(reading.map { String(format: "%.1f°", $0) } ?? "")
.font(.title3.weight(.semibold).monospacedDigit())
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
}
// MARK: - Ablauf
private func finishNoseStep() {
guard let live else { return }
noseChange = live - reference
// Der Bezug bleibt die Ruhelage. Von der gekippten Lage aus zu messen
// wäre falsch: die zweite Messung enthielte dann das Zurückkippen aus
// der ersten, und beide Achsen schlügen aus.
step = .settle
}
private func finishSideStep() {
guard let live, let nose = noseChange else { return }
let side = live - reference
switch OrientationDetection.orientation(nose: nose, side: side) {
case .success(let orientation): step = .done(orientation)
case .failure(let failure): step = .failed(failure)
}
}
private func save(_ orientation: SensorOrientation) {
var updated = device
updated.sensorOrientation = orientation
store.update(updated)
bluetooth.updateSensorOrientation(for: updated)
dismiss()
}
}
+49
View File
@@ -0,0 +1,49 @@
import SwiftUI
/// App-weite Einstellungen. Bewusst knapp gehalten was ein einzelnes Gerät
/// betrifft, steht bei diesem Gerät.
struct SettingsView: View {
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
@Environment(PhoneWatchLink.self) private var watch
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
} header: {
Text("Fehlersuche")
} footer: {
Text("Zeigt bei jedem Gerät die technischen Angaben: erkanntes "
+ "Protokoll, Bluetooth-Merkmale, gesendete Befehle und die "
+ "Rohdaten der letzten Antwort. Für den Alltag nicht nötig "
+ "hilfreich, wenn ein Gerät sich nicht wie erwartet verhält.\n\n"
+ "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)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { dismiss() }
}
}
}
}
}
enum AppSettings {
static let showDiagnosticsKey = "showDiagnostics"
}
+113
View File
@@ -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"
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import SwiftUI
/// Eingabe des Victron-Verschlüsselungsschlüssels.
///
/// Der Schlüssel wird einmal eingetragen und danach nie wieder angefasst
/// deshalb steht er hier und nicht in der Geräteübersicht. Nur solange er
/// fehlt oder nicht passt, weist die Übersicht darauf hin.
struct VictronKeyView: View {
let device: ConfiguredDevice
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@State private var keyInput = ""
var body: some View {
List {
Section {
TextField("32 Hex-Zeichen", text: $keyInput)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit(save)
Button("Schlüssel speichern", action: save)
.disabled(keyInput.hexBytes?.count != 16)
} footer: {
Text("In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → "
+ "„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. "
+ "Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).")
}
// Das erste Byte sendet das Gerät unverschlüsselt mit. Stimmt es
// nicht mit dem eingetragenen überein, gehört der Schlüssel zu
// einem anderen Victron-Gerät der häufigste Fehler überhaupt.
if let expected = bluetooth.diagnostics[device.id]?.expectedKeyText {
Section {
LabeledContent("Gerät sendet") {
Text(expected)
.font(.body.monospaced())
.foregroundStyle(bytesAgree == false ? .red : .primary)
}
LabeledContent("Eingetragen") {
Text(enteredKeyText)
.font(.body.monospaced())
.foregroundStyle(bytesAgree == false ? .red : .secondary)
}
} header: {
Text("Erstes Schlüsselbyte")
} footer: {
if bytesAgree == false {
Text("Die beiden Bytes müssen übereinstimmen. Tun sie das "
+ "nicht, stammt der Schlüssel von einem anderen "
+ "Victron-Gerät in VictronConnect prüfen, ob wirklich "
+ "dieses Gerät geöffnet war.")
} else {
Text("Zum Vergleichen: dieses Byte sendet das Gerät "
+ "unverschlüsselt mit.")
}
}
}
}
.navigationTitle("Verschlüsselung")
.navigationBarTitleDisplayMode(.inline)
.onAppear { keyInput = store.victronKeyText(for: device.id) ?? "" }
}
/// nil, solange kein vollständiger Schlüssel eingetragen ist.
private var bytesAgree: Bool? {
guard let expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte,
let entered = keyInput.hexBytes?.first else { return nil }
return expected == entered
}
private var enteredKeyText: String {
guard let byte = keyInput.hexBytes?.first else { return "" }
return String(format: "0x%02X", byte)
}
private func save() {
store.setVictronKey(keyInput, for: device.id)
bluetooth.refreshConfiguration()
}
}