forked from fritob/Camper-Monitor
init
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
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.looksLikeDaly }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.isVictron != rhs.isVictron { return lhs.isVictron }
|
||||
if lhs.looksLikeDaly != rhs.looksLikeDaly { return lhs.looksLikeDaly }
|
||||
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-…“ – findest du es 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.looksLikeDaly ? "battery.100percent" : "dot.radiowaves.left.and.right")
|
||||
.font(.title3)
|
||||
.foregroundStyle(discovery.isVictron || discovery.looksLikeDaly ? 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 discovery.looksLikeDaly {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
|
||||
@State private var isAddingDevice = false
|
||||
@State private var isManagingProfiles = false
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 300), spacing: 16)]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
if !bluetooth.isBluetoothReady {
|
||||
statusBanner
|
||||
}
|
||||
|
||||
if store.activeDevices.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(store.activeDevices) { device in
|
||||
NavigationLink {
|
||||
DeviceDetailView(device: device)
|
||||
} label: {
|
||||
DeviceCard(
|
||||
device: device,
|
||||
snapshot: bluetooth.snapshots[device.id],
|
||||
linkState: bluetooth.linkStates[device.id] ?? .searching
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle(store.activeProfile?.name ?? "Camper")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
profileMenu
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Gerät hinzufügen", systemImage: "plus") {
|
||||
isAddingDevice = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isAddingDevice) {
|
||||
AddDeviceView()
|
||||
}
|
||||
.sheet(isPresented: $isManagingProfiles) {
|
||||
ProfilesView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Umschalter zwischen den Fahrzeugen.
|
||||
private var profileMenu: some View {
|
||||
Menu {
|
||||
Picker("Fahrzeug", selection: Binding(
|
||||
get: { store.activeProfileID },
|
||||
set: { id in
|
||||
guard let profile = store.profiles.first(where: { $0.id == id }) else { return }
|
||||
store.selectProfile(profile)
|
||||
bluetooth.refreshConfiguration()
|
||||
}
|
||||
)) {
|
||||
ForEach(store.profiles) { profile in
|
||||
Label(profile.name, systemImage: profile.symbol).tag(profile.id)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Fahrzeuge verwalten…", systemImage: "gearshape") {
|
||||
isManagingProfiles = true
|
||||
}
|
||||
} label: {
|
||||
Label(store.activeProfile?.name ?? "Fahrzeug",
|
||||
systemImage: store.activeProfile?.symbol ?? "box.truck")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusBanner: some View {
|
||||
Label(bluetooth.bluetoothStatusText, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.callout)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(.orange.opacity(0.15), in: .rect(cornerRadius: 12))
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right")
|
||||
} description: {
|
||||
Text("Füge \(store.activeProfile.map { "„\($0.name)“" } ?? "diesem Fahrzeug") "
|
||||
+ "den Ladebooster, den Solarladeregler und das BMS hinzu.")
|
||||
} actions: {
|
||||
Button("Gerät suchen") { isAddingDevice = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.top, 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten
|
||||
/// Nebenwerte und der Verbindungszustand.
|
||||
struct DeviceCard: View {
|
||||
let device: ConfiguredDevice
|
||||
let snapshot: DeviceSnapshot?
|
||||
let linkState: DeviceLinkState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
|
||||
if let snapshot, let primary = snapshot.primaryMetric {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(primary.formatted)
|
||||
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
||||
.contentTransition(.numericText())
|
||||
Text(primary.unit)
|
||||
.font(.title3.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
|
||||
|
||||
secondaryValues(for: snapshot)
|
||||
} else {
|
||||
Text(placeholderText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
footer
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: device.role.symbol)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(device.name)
|
||||
.font(.headline)
|
||||
Text(device.role.title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
|
||||
}
|
||||
}
|
||||
|
||||
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
|
||||
let others = snapshot.metrics
|
||||
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
|
||||
.prefix(3)
|
||||
return HStack(spacing: 16) {
|
||||
ForEach(Array(others)) { metric in
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.monospacedDigit()
|
||||
Text(metric.label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footer: some View {
|
||||
if let fault = snapshot?.fault {
|
||||
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(2)
|
||||
} else if let state = snapshot?.state {
|
||||
// Bei "Aus" ist erst der Grund die eigentliche Information.
|
||||
let reason = snapshot?.offReasons.first
|
||||
Text(reason.map { "\(state) · \($0)" } ?? state)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if case .failed(let message) = linkState {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholderText: String {
|
||||
switch linkState {
|
||||
case .needsKey: return "Verschlüsselungsschlüssel fehlt – im Detail eintragen."
|
||||
case .failed(let message): return message
|
||||
default: return "Warte auf Daten…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst.
|
||||
struct StatusDot: View {
|
||||
let linkState: DeviceLinkState
|
||||
let isStale: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch linkState {
|
||||
case .live: return isStale ? .orange : .green
|
||||
case .needsKey: return .orange
|
||||
case .failed: return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
if case .live = linkState, isStale { return "Veraltet" }
|
||||
return linkState.label
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
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.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 service = info.serviceUUID {
|
||||
LabeledContent("Dienst") {
|
||||
Text(service).font(.caption.monospaced()).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 Daly (klassisch und Modbus) sowie JBD/Xiaoxiang "
|
||||
+ "durch und übernimmt, was antwortet. Bleibt es bei „wird ermittelt“, "
|
||||
+ "spricht das BMS ein anderes Protokoll – dann hilft die Rohantwort weiter.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Fahrzeuge anlegen, umbenennen und entfernen.
|
||||
struct ProfilesView: View {
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var editing: Profile?
|
||||
@State private var pendingDeletion: Profile?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(store.profiles) { profile in
|
||||
row(for: profile)
|
||||
}
|
||||
} footer: {
|
||||
Text("Jedes Fahrzeug hat seine eigenen Geräte. Die App liest immer "
|
||||
+ "nur die des gewählten Fahrzeugs aus.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Fahrzeug hinzufügen", systemImage: "plus") {
|
||||
let profile = store.addProfile(named: "Camper \(store.profiles.count + 1)")
|
||||
editing = profile
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Fahrzeuge")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Fertig") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(item: $editing) { profile in
|
||||
ProfileEditView(profile: profile)
|
||||
}
|
||||
.confirmationDialog("Fahrzeug entfernen?",
|
||||
isPresented: .init(get: { pendingDeletion != nil },
|
||||
set: { if !$0 { pendingDeletion = nil } }),
|
||||
titleVisibility: .visible) {
|
||||
Button("Entfernen", role: .destructive) {
|
||||
if let pendingDeletion {
|
||||
store.removeProfile(pendingDeletion)
|
||||
bluetooth.refreshConfiguration()
|
||||
}
|
||||
pendingDeletion = nil
|
||||
}
|
||||
} message: {
|
||||
if let pendingDeletion {
|
||||
Text("„\(pendingDeletion.name)“ und die \(store.deviceCount(in: pendingDeletion)) "
|
||||
+ "darin eingerichteten Geräte werden gelöscht.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for profile: Profile) -> some View {
|
||||
HStack {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(profile.name)
|
||||
Text(deviceSummary(for: profile))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} icon: {
|
||||
Image(systemName: profile.symbol)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if profile.id == store.activeProfileID {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.tint)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
.onTapGesture {
|
||||
store.selectProfile(profile)
|
||||
bluetooth.refreshConfiguration()
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
// Das letzte Fahrzeug muss bleiben, sonst hätten Geräte keinen Ort.
|
||||
if store.hasMultipleProfiles {
|
||||
Button("Entfernen", systemImage: "trash", role: .destructive) {
|
||||
pendingDeletion = profile
|
||||
}
|
||||
}
|
||||
Button("Bearbeiten", systemImage: "pencil") {
|
||||
editing = profile
|
||||
}
|
||||
.tint(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
private func deviceSummary(for profile: Profile) -> String {
|
||||
let count = store.deviceCount(in: profile)
|
||||
return count == 1 ? "1 Gerät" : "\(count) Geräte"
|
||||
}
|
||||
}
|
||||
|
||||
/// Name und Symbol eines Fahrzeugs ändern.
|
||||
private struct ProfileEditView: View {
|
||||
let profile: Profile
|
||||
|
||||
@Environment(DeviceStore.self) private var store
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var name = ""
|
||||
@State private var symbol = "box.truck"
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Name") {
|
||||
TextField("Name", text: $name)
|
||||
}
|
||||
Section("Symbol") {
|
||||
LazyVGrid(columns: columns, spacing: 12) {
|
||||
ForEach(Profile.symbols, id: \.self) { candidate in
|
||||
Button {
|
||||
symbol = candidate
|
||||
} label: {
|
||||
Image(systemName: candidate)
|
||||
.font(.title2)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(symbol == candidate ? Color.accentColor.opacity(0.18)
|
||||
: Color.secondary.opacity(0.08),
|
||||
in: .rect(cornerRadius: 12))
|
||||
.foregroundStyle(symbol == candidate ? Color.accentColor : Color.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Fahrzeug")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Sichern") {
|
||||
var updated = profile
|
||||
updated.name = name.trimmingCharacters(in: .whitespaces)
|
||||
updated.symbol = symbol
|
||||
store.update(updated)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
name = profile.name
|
||||
symbol = profile.symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user