forked from fritob/Camper-Monitor
Das ESPHome-Projekt VanAlign Pro misst über einen MPU6050 die Längs- und Querneigung des Fahrzeugs und stellt sie über Bluetooth bereit. Damit lässt sich der Camper beim Parken ausrichten. Der Neigungsmesser bewirbt seinen Dienst, wird beim Einrichten also sicher erkannt und die Geräteart vorbelegt. Anders als bei den übrigen verbundenen Geräten gibt es hier kein Rahmenprotokoll: jede Messgrösse liegt in einer eigenen Charakteristik als Float. LevelSession ist deshalb eine eigene, deutlich einfachere Sitzungsart neben BMSSession - ohne Protokollerkennung und ohne Kandidatensuche. Bevorzugt werden die Werte abonniert; bietet das Gerät das nicht an, wird im Takt abgefragt. Damit läuft die App auch mit der alten Firmware 1.0, die nur Lesen kennt. Die Detailansicht zeigt eine Libelle: die Blase wandert dorthin, wo das Fahrzeug höher steht. Grün bis 0,5 Grad, orange bis zwei, darüber rot - die Akzentfarbe der App ist selbst grün, "schief" hätte sonst genauso ausgesehen wie "eben". Die Ringe sind ein echter Massstab (Toleranz und zwei Grad), sonst sagt die Blasenlage nichts über die verbleibende Abweichung. Dazu die Ansage in Worten, welche Seite höher steht. Die Winkel werden fest little-endian gelesen. Die Web-Oberfläche des Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls die erste unplausibel wirkt. Das ist nicht nur unnötig, sondern schädlich: ein vertauschter Float von 4,25 Grad liest sich als etwa 0,0 und wirkt damit völlig plausibel - der Fehler bliebe unbemerkt. Ein Test hält das fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
9.9 KiB
Swift
256 lines
9.9 KiB
Swift
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 = .solarCharger
|
||
@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 = .solarCharger
|
||
case .dcdcConverter, .orionXS: role = .chargeBooster
|
||
case .batteryMonitor: role = .batteryMonitor
|
||
default: role = .solarCharger
|
||
}
|
||
} else if discovery.isLevelSensor {
|
||
role = .leveling
|
||
} 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 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")
|
||
}
|
||
}
|