Files
Camper-Monitor/VanControl/Views/AddDeviceView.swift
T
fototeddyandClaude Sonnet 5 303a9735d0 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>
2026-09-06 21:25:28 +02:00

260 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}