Umbenennen war zwar möglich, aber praktisch unauffindbar: das Eingabe- feld trug nur einen Platzhalter, und übernommen wurde erst über einen zusätzlichen Knopf. Jetzt steht "Name" als Beschriftung davor, der Knopf ist weg, gesichert wird beim Abschluss der Eingabe und beim Verlassen der Ansicht. Der Funkname steht darunter als "Gefunden als", damit das Gerät weiter zuzuordnen ist. Beim Einrichten wird nicht mehr der Funkname vorgeschlagen - Namen wie "WTaEaAA25342229" taugen nicht als Anzeigename -, sondern die Art des Geräts, sofern der Funkname kryptisch wirkt. Die Detailansicht arbeitete auf einer Momentaufnahme des Geräts. Nach dem Umbenennen oder nach dem Umstellen der Kühlzonen zeigte sie deshalb weiter die alten Werte; sie liest den Stand jetzt aus dem Speicher. Gegen die träge Oberfläche: CoreBluetooth meldet jedes Advertisement einzeln und auf dem Hauptthread, und gescannt wird mit Duplikaten über alle Geräte in Reichweite. Victron sendet mehrmals je Sekunde, dazu kommt alles andere in Funkreichweite. Ausgewertet wird jetzt höchstens einmal je Sekunde und Gerät, die Geräteliste beim Einrichten alle zwei Sekunden, die Rohdaten der Diagnose alle drei. Ein Advertisement mehr ändert die Anzeige ohnehin nicht, kostet aber Entschlüsselung und eine Neuzeichnung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
9.8 KiB
Swift
252 lines
9.8 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 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 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")
|
||
}
|
||
}
|