forked from fritob/Camper-Monitor
solar-integration (aus dem separaten VanAligneiOS-Repo) und dev_watch haben unabhängige Git-Historien, decken aber überlappende und sich ergänzende Funktionen ab. Übernommen aus solar-integration: Votronic- Solar-ESP-Anbindung samt Geräterolle, die Live-Activity/Widget-Extension fürs Sperrbildschirm/Dynamic-Island/CarPlay, das Querformat-Layout für Libelle/Fahrzeug-Ansicht und Ausrichtungs-Assistent, sowie die mehreren Fahrzeuggrafik-Stile (Vanster/California). Beibehalten aus dev_watch: alle zusätzlichen Geräteprotokolle (Daly-/JBD-BMS, Alpicool- Kühlbox, WattCycle, Victron), die dort zwischenzeitlich entstanden. Die Xcode-Projektdatei wurde von Hand um die neue Widget-Extension samt SharedActivity-Gruppe erweitert (Datei-synchronisierte Gruppen, kein App-Group-Entitlement nötig). Build für App, Watch und Widget-Extension geprüft (Debug und Release). 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 = .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 discovery.isVotronicSolarESPSensor {
|
||
role = .solar
|
||
} 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")
|
||
}
|
||
}
|