forked from fritob/Camper-Monitor
Die IceCube-Boxen von Plug-in Festivals sind umgelabelte Alpicool-Boxen – der Hersteller verweist selbst auf die App "Alpicool T-Series". Bestätigt am Gerät: die Charakteristiken 00001235 und 00001236 tauchen im GATT-Baum auf. Dasselbe Protokoll sprechen BrassMonkey und Ocean Comfort. AlpicoolProtocol baut und liest die Rahmen (FE FE, Länge, Kommando, Daten, Bytesummen-Prüfsumme) und wertet die Statusantwort aus: Ist- und Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bedienfeldsperre, Bordspannung und Batterieanzeige. Zweizonen-Boxen werden an der Nutzlastlänge erkannt. Auf Stellbefehle antwortet die Box mit Echo und Status in einer einzigen Benachrichtigung, weshalb der Rahmenleser weitersucht statt nach dem ersten Treffer abzubrechen; das Echo läuft mangels Statuslänge ins Leere. Als bisher einziges Gerät ist die Box auch stellbar: Ein/Aus, Eco/Max, Solltemperatur je Zone und Bedienfeldsperre. Nach jedem Stellbefehl wird der Zustand neu abgefragt, sodass die Schalter dem Gerät folgen und nicht der Eingabe. Ausschalten fragt einmal nach, weil dabei die Kühlung stoppt. Ein/Aus und Betriebsart brauchen den kompletten Einstellungsblock: er wird aus dem letzten Status neu aufgebaut und nur im betroffenen Byte geändert, sonst überschriebe die Box eigene Einstellungen mit Nullen. Ohne bekannten Status entsteht gar kein Stellbefehl. Die Namensprüfung in der Geräteliste hiess bisher looksLikeDaly und hätte Kühlboxen aus der Vorauswahl geworfen; sie deckt jetzt alle unterstützten Geräte ab und belegt die Art beim Einrichten vor. Protokoll und Feldbelegung nach Gruni22/alpicool_ha_ble. Die festen Anmelde- und Abfragepakete der Referenz sind byteweise abgesichert und belegen zugleich, dass die allgemeine Prüfsummenregel auch für sie gilt. 139 Prüfungen laufen durch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
9.4 KiB
Swift
244 lines
9.4 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
|
||
}
|
||
name = discovery.name?.isEmpty == false ? discovery.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")
|
||
}
|
||
}
|