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>
350 lines
14 KiB
Swift
350 lines
14 KiB
Swift
import Charts
|
||
import SwiftUI
|
||
|
||
struct DeviceDetailView: View {
|
||
let device: ConfiguredDevice
|
||
|
||
@Environment(DeviceStore.self) private var store
|
||
@Environment(BluetoothManager.self) private var bluetooth
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var editedName = ""
|
||
@State private var keyInput = ""
|
||
@State private var showDeleteConfirmation = false
|
||
|
||
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
||
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
|
||
|
||
var body: some View {
|
||
List {
|
||
statusSection
|
||
|
||
if device.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||
FridgeControls(device: device, state: fridge)
|
||
}
|
||
|
||
if let snapshot, !snapshot.metrics.isEmpty {
|
||
Section("Messwerte") {
|
||
ForEach(snapshot.metrics) { metric in
|
||
LabeledContent(metric.label) {
|
||
Text(metric.formattedWithUnit)
|
||
.monospacedDigit()
|
||
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
||
Section("Verlauf – \(primary.label)") {
|
||
Chart(samples) { sample in
|
||
AreaMark(x: .value("Zeit", sample.time),
|
||
y: .value(primary.label, sample.value))
|
||
.foregroundStyle(.tint.opacity(0.15))
|
||
LineMark(x: .value("Zeit", sample.time),
|
||
y: .value(primary.label, sample.value))
|
||
.foregroundStyle(.tint)
|
||
.interpolationMethod(.monotone)
|
||
}
|
||
.chartYAxisLabel(primary.unit)
|
||
.frame(height: 180)
|
||
.padding(.vertical, 8)
|
||
}
|
||
}
|
||
|
||
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
||
cellSection(snapshot.cellVoltages)
|
||
}
|
||
|
||
if let snapshot, !snapshot.info.isEmpty {
|
||
Section("Gerät") {
|
||
ForEach(snapshot.info) { item in
|
||
LabeledContent(item.label, value: item.value)
|
||
}
|
||
}
|
||
}
|
||
|
||
if let snapshot, snapshot.temperatures.count > 1 {
|
||
Section("Temperaturen") {
|
||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||
LabeledContent("Fühler \(index + 1)") {
|
||
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if device.role.transport == .advertisement {
|
||
keySection
|
||
diagnosticsSection
|
||
} else {
|
||
bmsDiagnosticsSection
|
||
}
|
||
|
||
settingsSection
|
||
}
|
||
.navigationTitle(device.name)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.onAppear {
|
||
editedName = device.name
|
||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||
}
|
||
.confirmationDialog("Gerät entfernen?",
|
||
isPresented: $showDeleteConfirmation,
|
||
titleVisibility: .visible) {
|
||
Button("Entfernen", role: .destructive) {
|
||
store.remove(device)
|
||
bluetooth.refreshConfiguration()
|
||
dismiss()
|
||
}
|
||
} message: {
|
||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||
}
|
||
}
|
||
|
||
// MARK: - Abschnitte
|
||
|
||
private var statusSection: some View {
|
||
Section {
|
||
LabeledContent("Verbindung") {
|
||
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
|
||
}
|
||
if let state = snapshot?.state {
|
||
LabeledContent("Zustand", value: state)
|
||
}
|
||
if let fault = snapshot?.fault {
|
||
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(.red)
|
||
}
|
||
ForEach(snapshot?.offReasons ?? [], id: \.self) { reason in
|
||
Label(reason, systemImage: "pause.circle")
|
||
.foregroundStyle(.orange)
|
||
}
|
||
if let snapshot {
|
||
LabeledContent("Aktualisiert vor") {
|
||
Text(snapshot.timestamp, style: .relative)
|
||
}
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
if let rssi = snapshot?.rssi {
|
||
LabeledContent("Signal", value: "\(rssi) dBm")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func cellSection(_ voltages: [Double]) -> some View {
|
||
let minimum = voltages.min() ?? 0
|
||
let maximum = voltages.max() ?? 0
|
||
return Section("Zellspannungen") {
|
||
Chart(Array(voltages.enumerated()), id: \.offset) { index, voltage in
|
||
// Kategoriale x-Achse: sonst stehen die Balken zwischen den
|
||
// Beschriftungen statt darüber.
|
||
BarMark(
|
||
x: .value("Zelle", "\(index + 1)"),
|
||
y: .value("Spannung", voltage)
|
||
)
|
||
.foregroundStyle(voltage == maximum ? Color.orange
|
||
: voltage == minimum ? Color.blue : Color.accentColor)
|
||
// Die Zellnummer als Beschriftung am Balken statt über die
|
||
// x-Achse – die blendet Swift Charts in der Liste aus.
|
||
// Ab neun Zellen wird es zu eng, dann ordnet die Liste zu.
|
||
.annotation(position: .bottom, alignment: .center) {
|
||
if voltages.count <= 8 {
|
||
Text("\(index + 1)")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.chartXAxis(.hidden)
|
||
// Der interessante Bereich sind die letzten Millivolt, nicht die
|
||
// absolute Spannung – deshalb eng um die Messwerte zoomen.
|
||
.chartYScale(domain: (minimum - 0.05)...(maximum + 0.05))
|
||
.chartYAxisLabel("V")
|
||
.frame(height: 160)
|
||
.padding(.vertical, 8)
|
||
|
||
ForEach(Array(voltages.enumerated()), id: \.offset) { index, voltage in
|
||
LabeledContent("Zelle \(index + 1)") {
|
||
Text(String(format: "%.3f V", voltage)).monospacedDigit()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var keySection: some View {
|
||
Section {
|
||
TextField("32 Hex-Zeichen", text: $keyInput)
|
||
.font(.system(.body, design: .monospaced))
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled()
|
||
.onSubmit(saveKey)
|
||
Button("Schlüssel speichern", action: saveKey)
|
||
.disabled(keyInput.hexBytes?.count != 16)
|
||
} header: {
|
||
Text("Verschlüsselungsschlüssel")
|
||
} footer: {
|
||
Text("In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → "
|
||
+ "„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. "
|
||
+ "Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).")
|
||
}
|
||
}
|
||
|
||
/// Zeigt, was das Gerät unverschlüsselt sendet. Wichtigster Wert ist das
|
||
/// erste Schlüsselbyte: stimmt es nicht mit der Eingabe überein, gehört der
|
||
/// Schlüssel zu einem anderen Victron-Gerät.
|
||
@ViewBuilder
|
||
private var diagnosticsSection: some View {
|
||
if let info = bluetooth.diagnostics[device.id] {
|
||
Section {
|
||
LabeledContent("Gerät sendet als erstes Schlüsselbyte") {
|
||
Text(info.expectedKeyText)
|
||
.font(.body.monospaced())
|
||
.foregroundStyle(keyBytesAgree == false ? .red : .primary)
|
||
}
|
||
LabeledContent("Eingetragener Schlüssel beginnt mit") {
|
||
Text(enteredKeyText)
|
||
.font(.body.monospaced())
|
||
.foregroundStyle(keyBytesAgree == false ? .red : .secondary)
|
||
}
|
||
LabeledContent("Datensatz", value: info.recordName)
|
||
LabeledContent("Produkt-ID") {
|
||
Text(info.productIDText).font(.body.monospaced())
|
||
}
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Rohdaten")
|
||
Text(info.rawHex)
|
||
.font(.caption.monospaced())
|
||
.foregroundStyle(.secondary)
|
||
.textSelection(.enabled)
|
||
}
|
||
} header: {
|
||
Text("Diagnose")
|
||
} footer: {
|
||
if keyBytesAgree == false {
|
||
Text("Die beiden Bytes müssen übereinstimmen. Tun sie das nicht, "
|
||
+ "stammt der Schlüssel von einem anderen Victron-Gerät – in "
|
||
+ "VictronConnect prüfen, ob wirklich dieses Gerät geöffnet war.")
|
||
} else {
|
||
Text("Diese Werte sendet das Gerät unverschlüsselt mit.")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// nil, solange kein vollständiger Schlüssel eingetragen ist.
|
||
private var keyBytesAgree: Bool? {
|
||
guard let expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte,
|
||
let entered = keyInput.hexBytes?.first else { return nil }
|
||
return expected == entered
|
||
}
|
||
|
||
private var enteredKeyText: String {
|
||
guard let byte = keyInput.hexBytes?.first else { return "–" }
|
||
return String(format: "0x%02X", byte)
|
||
}
|
||
|
||
/// Welches Protokoll das BMS spricht und was zuletzt ankam.
|
||
@ViewBuilder
|
||
private var bmsDiagnosticsSection: some View {
|
||
if let info = bluetooth.bmsDiagnostics[device.id] {
|
||
Section {
|
||
LabeledContent("Erkanntes Protokoll", value: info.dialect)
|
||
if let position = info.endpointPosition {
|
||
LabeledContent("Verbindungsweg",
|
||
value: "\(position.index) von \(position.total)")
|
||
}
|
||
if let endpoint = info.endpointLabel {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Aktueller Weg")
|
||
Text(endpoint)
|
||
.font(.caption.monospaced())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
LabeledContent("Verbunden") {
|
||
Label(info.isConnected ? "ja" : "nein",
|
||
systemImage: info.isConnected ? "checkmark.circle" : "xmark.circle")
|
||
.foregroundStyle(info.isConnected ? .green : .red)
|
||
}
|
||
LabeledContent("Empfang abonniert") {
|
||
Label(info.isNotifyActive ? "ja" : "nein",
|
||
systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle")
|
||
.foregroundStyle(info.isNotifyActive ? .green : .orange)
|
||
}
|
||
LabeledContent("Gesendet / empfangen",
|
||
value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte")
|
||
if let lastSendAt = info.lastSendAt {
|
||
LabeledContent("Zuletzt gesendet vor") {
|
||
Text(lastSendAt, style: .relative)
|
||
}
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
if let hex = info.lastResponseHex {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Letzte Antwort")
|
||
Text(hex)
|
||
.font(.caption.monospaced())
|
||
.foregroundStyle(.secondary)
|
||
.textSelection(.enabled)
|
||
}
|
||
}
|
||
} header: {
|
||
Text("Diagnose")
|
||
} footer: {
|
||
Text("Die App probiert alle Schreib-/Empfangs-Kombinationen des Geräts "
|
||
+ "durch und fragt auf jeder Daly (klassisch und Modbus) sowie "
|
||
+ "JBD/Xiaoxiang an. Der „Verbindungsweg“ zählt dabei hoch. "
|
||
+ "Bleibt „empfangen“ am Ende bei 0 Byte, nimmt das BMS auf keinem "
|
||
+ "Weg Kommandos an; kommen Bytes an, ohne dass ein Protokoll "
|
||
+ "erkannt wird, spricht es ein noch unbekanntes.")
|
||
}
|
||
|
||
if !info.gattSummary.isEmpty {
|
||
Section {
|
||
Text(info.gattSummary.joined(separator: "\n"))
|
||
.font(.caption2.monospaced())
|
||
.foregroundStyle(.secondary)
|
||
.textSelection(.enabled)
|
||
} header: {
|
||
Text("Bluetooth-Merkmale des Geräts")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var settingsSection: some View {
|
||
Section("Einstellungen") {
|
||
TextField("Name", text: $editedName)
|
||
.onSubmit(saveName)
|
||
Button("Namen übernehmen", action: saveName)
|
||
.disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty
|
||
|| editedName == device.name)
|
||
LabeledContent("Typ", value: device.role.title)
|
||
LabeledContent("Bluetooth-ID") {
|
||
Text(device.peripheralID.uuidString.prefix(8) + "…")
|
||
.font(.caption.monospaced())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Button("Gerät entfernen", role: .destructive) {
|
||
showDeleteConfirmation = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Aktionen
|
||
|
||
private func saveKey() {
|
||
store.setVictronKey(keyInput, for: device.id)
|
||
bluetooth.refreshConfiguration()
|
||
}
|
||
|
||
private func saveName() {
|
||
var updated = device
|
||
updated.name = editedName.trimmingCharacters(in: .whitespaces)
|
||
store.update(updated)
|
||
}
|
||
}
|