forked from fritob/Camper-Monitor
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
83ea85f3b8
commit
303a9735d0
@@ -0,0 +1,259 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user