forked from fritob/Camper-Monitor
WattCycle spricht weder Daly noch JBD, sondern ein eigenes Modbus-artiges Protokoll – und verlangt vor der ersten Abfrage eine Freischaltung: der Text "HiLink" muss auf die Charakteristik FFFA geschrieben werden, sonst bleibt der Akku auf alles stumm. Genau daran scheiterte die Erkennung; die Charakteristik war im GATT-Baum sichtbar, wurde aber nur als weiterer Schreibkandidat behandelt. Neu ist WattCycleProtocol mit Rahmenbau (1E … 0D für Anfragen, 7E … 0D für Antworten), Prüfsummen und der Auswertung des Messwert-Datensatzes 0x008C. Der ist selbstbeschreibend: Zellenanzahl, Zellspannungen, Fühleranzahl, MOSFET- und Platinentemperatur, Zellfühler, dann Strom, Spannung, Kapazitäten, Zyklen und Ladezustand. Der Strom hat ein eigenes Format, bei dem Bit 15 das Vorzeichen und Bit 14 die Nachkommastelle angibt. Aus Datenpunkt 0x0092 kommen Modell, Hersteller und Seriennummer, die einmalig gelesen und in der Detailansicht gezeigt werden. BMSSession kennt die Freischaltung jetzt als Teil eines Kandidaten: liegt im selben Dienst eine FFFA-Charakteristik, wird nach dem bestätigten Abo kurz gewartet, freigeschaltet, nochmal gewartet und dann erst abgefragt. Für die anderen Protokolle ist das unschädlich. Protokoll und Feldbelegung stammen aus frabnet/esphome-wattcycle-ble. Die dortige Tabellen-Prüfsumme ist gegen den klassischen Modbus-CRC nachgerechnet (identisch über 3063 Testfälle), sodass die vorhandene CRC-Funktion genügt und die 512 Byte Tabellen entfallen. Die Anfragerahmen sind byteweise abgesichert, die Auswertung an einem vollständigen Datensatz – 107 Prüfungen laufen durch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
346 lines
14 KiB
Swift
346 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 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)
|
||
}
|
||
}
|