forked from fritob/Camper-Monitor
.solarCharger und .solar sahen im Code zu ähnlich aus, um auf einen Blick zu erkennen, welches der Victron-Solarladeregler (passives BLE-Advertisement) und welches der Votronic-Solarladeregler (aktive GATT-Verbindung über die ESP32-Bridge) ist. Jetzt .victronSolarCharger und .votronicSolar, samt allen abgeleiteten Bezeichnern in DemoData, WatchDemo und Tests. Die rawValues bleiben "solarCharger"/"solar", damit bereits gespeicherte Geräte beim Decodieren nicht auf einen unbekannten Rollen-Wert treffen. VictronAdvertisement.RecordType.solarCharger (das rohe Protokollfeld) bleibt unverändert – schon durch den Typnamen eindeutig, und passend zur offiziellen Victron-Bezeichnung des Datensatztyps. run-tests.sh fehlten VehicleGraphicStyle.swift und VehicleTilt.swift, seit ConfiguredDevice sie referenziert – ergänzt, Testlauf grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
260 lines
10 KiB
Swift
260 lines
10 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 = .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")
|
||
}
|
||
}
|