Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
644c4c7767 | ||
|
|
7fc9442aeb | ||
|
|
8d975d2c4d |
@@ -18,7 +18,7 @@ struct Discovery: Identifiable, Hashable {
|
|||||||
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
|
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
|
||||||
var isLevelSensor = false
|
var isLevelSensor = false
|
||||||
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
|
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
|
||||||
var isSolarSensor = false
|
var isVotronicSolarESPSensor = false
|
||||||
|
|
||||||
//var isVictron: Bool { victronRecordType != nil }
|
//var isVictron: Bool { victronRecordType != nil }
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ struct Discovery: Identifiable, Hashable {
|
|||||||
|
|
||||||
var subtitle: String {
|
var subtitle: String {
|
||||||
if isLevelSensor { return "VanAlign Neigungsmesser" }
|
if isLevelSensor { return "VanAlign Neigungsmesser" }
|
||||||
if isSolarSensor { return "VanAlign Solarladeregler" }
|
if isVotronicSolarESPSensor { return "VotronicSolarESP" }
|
||||||
return "Bluetooth-Gerät"
|
return "Bluetooth-Gerät"
|
||||||
}
|
}
|
||||||
/*var subtitle: String {
|
/*var subtitle: String {
|
||||||
@@ -164,7 +164,7 @@ final class BluetoothManager: NSObject {
|
|||||||
//private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
|
//private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
|
||||||
//private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
|
//private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
|
||||||
private(set) var levelStates: [UUID: LevelState] = [:]
|
private(set) var levelStates: [UUID: LevelState] = [:]
|
||||||
private(set) var solarStates: [UUID: SolarState] = [:]
|
private(set) var votronicSolarESPStates: [UUID: VotronicSolarESPState] = [:]
|
||||||
private(set) var isBluetoothReady = false
|
private(set) var isBluetoothReady = false
|
||||||
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
|
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ final class BluetoothManager: NSObject {
|
|||||||
|
|
||||||
//private var bmsSessions: [UUID: BMSSession] = [:]
|
//private var bmsSessions: [UUID: BMSSession] = [:]
|
||||||
private var levelSessions: [UUID: LevelSession] = [:]
|
private var levelSessions: [UUID: LevelSession] = [:]
|
||||||
private var solarSessions: [UUID: SolarSession] = [:]
|
private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:]
|
||||||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||||||
private var reconnectTimer: DispatchSourceTimer?
|
private var reconnectTimer: DispatchSourceTimer?
|
||||||
|
|
||||||
@@ -280,11 +280,15 @@ final class BluetoothManager: NSObject {
|
|||||||
bluetoothStatusText = "Demo-Modus"
|
bluetoothStatusText = "Demo-Modus"
|
||||||
//fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
|
//fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
|
||||||
levelStates[DemoData.level.id] = DemoData.levelState
|
levelStates[DemoData.level.id] = DemoData.levelState
|
||||||
|
// Ohne "live" bleibt der Ausrichtungs-Assistent (und die Live
|
||||||
|
// Activity) im Demo-Modus deaktiviert, weil beide echte Messwerte
|
||||||
|
// voraussetzen.
|
||||||
|
linkStates[DemoData.level.id] = .live
|
||||||
|
|
||||||
solarStates[DemoData.solar.id] = DemoData.solarState
|
votronicSolarESPStates[DemoData.solar.id] = DemoData.votronicSolarESPState
|
||||||
linkStates[DemoData.solar.id] = .live
|
linkStates[DemoData.solar.id] = .live
|
||||||
record(DemoData.solarState.snapshot(deviceID: DemoData.solar.id, rssi: -58))
|
record(DemoData.votronicSolarESPState.snapshot(deviceID: DemoData.solar.id, rssi: -58))
|
||||||
history[DemoData.solar.id] = DemoData.solarHistory()
|
history[DemoData.solar.id] = DemoData.votronicSolarESPHistory()
|
||||||
/* for snapshot in DemoData.snapshots() {
|
/* for snapshot in DemoData.snapshots() {
|
||||||
snapshots[snapshot.deviceID] = snapshot
|
snapshots[snapshot.deviceID] = snapshot
|
||||||
linkStates[snapshot.deviceID] = .live
|
linkStates[snapshot.deviceID] = .live
|
||||||
@@ -342,7 +346,7 @@ final class BluetoothManager: NSObject {
|
|||||||
//bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
//bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
||||||
//fridgeStates = fridgeStates.filter { known.contains($0.key) }
|
//fridgeStates = fridgeStates.filter { known.contains($0.key) }
|
||||||
levelStates = levelStates.filter { known.contains($0.key) }
|
levelStates = levelStates.filter { known.contains($0.key) }
|
||||||
solarStates = solarStates.filter { known.contains($0.key) }
|
votronicSolarESPStates = votronicSolarESPStates.filter { known.contains($0.key) }
|
||||||
|
|
||||||
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
|
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
|
||||||
// ihren zuletzt gestellten Stand.
|
// ihren zuletzt gestellten Stand.
|
||||||
@@ -552,9 +556,9 @@ final class BluetoothManager: NSObject {
|
|||||||
levelSessions[peripheralID] = nil
|
levelSessions[peripheralID] = nil
|
||||||
disconnect(peripheralID)
|
disconnect(peripheralID)
|
||||||
}
|
}
|
||||||
for (peripheralID, session) in solarSessions where !wanted.contains(peripheralID) {
|
for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) {
|
||||||
session.stop()
|
session.stop()
|
||||||
solarSessions[peripheralID] = nil
|
votronicSolarESPSessions[peripheralID] = nil
|
||||||
disconnect(peripheralID)
|
disconnect(peripheralID)
|
||||||
}
|
}
|
||||||
// Einstellungen an bestehende Sitzungen weiterreichen.
|
// Einstellungen an bestehende Sitzungen weiterreichen.
|
||||||
@@ -602,11 +606,11 @@ final class BluetoothManager: NSObject {
|
|||||||
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
|
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
|
||||||
//for (_, session) in bmsSessions { session.stop() }
|
//for (_, session) in bmsSessions { session.stop() }
|
||||||
for (_, session) in levelSessions { session.stop() }
|
for (_, session) in levelSessions { session.stop() }
|
||||||
for (_, session) in solarSessions { session.stop() }
|
for (_, session) in votronicSolarESPSessions { session.stop() }
|
||||||
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
|
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
|
||||||
//bmsSessions.removeAll()
|
//bmsSessions.removeAll()
|
||||||
levelSessions.removeAll()
|
levelSessions.removeAll()
|
||||||
solarSessions.removeAll()
|
votronicSolarESPSessions.removeAll()
|
||||||
connectedPeripherals.removeAll()
|
connectedPeripherals.removeAll()
|
||||||
connectedSince.removeAll()
|
connectedSince.removeAll()
|
||||||
//pendingControls.removeAll()
|
//pendingControls.removeAll()
|
||||||
@@ -802,8 +806,8 @@ final class BluetoothManager: NSObject {
|
|||||||
if let name, !name.isEmpty { entry.name = name }
|
if let name, !name.isEmpty { entry.name = name }
|
||||||
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
||||||
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
|
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
|
||||||
entry.isSolarSensor = services.contains(SolarSession.serviceUUID)
|
entry.isVotronicSolarESPSensor = services.contains(VotronicSolarESPSession.serviceUUID)
|
||||||
entry.looksLikeSupported = entry.isLevelSensor || entry.isSolarSensor
|
entry.looksLikeSupported = entry.isLevelSensor || entry.isVotronicSolarESPSensor
|
||||||
pendingDiscoveries[peripheral.identifier] = entry
|
pendingDiscoveries[peripheral.identifier] = entry
|
||||||
}
|
}
|
||||||
/*private func updateDiscovery(peripheral: CBPeripheral,
|
/*private func updateDiscovery(peripheral: CBPeripheral,
|
||||||
@@ -961,7 +965,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
|
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
|
||||||
// nicht in der Live Activity/CarPlay auftauchen, die ist dem
|
// nicht in der Live Activity/CarPlay auftauchen, die ist dem
|
||||||
// Neigungsmesser vorbehalten.
|
// Neigungsmesser vorbehalten.
|
||||||
let session = SolarSession(
|
let session = VotronicSolarESPSession(
|
||||||
deviceID: device.id,
|
deviceID: device.id,
|
||||||
peripheral: peripheral,
|
peripheral: peripheral,
|
||||||
queue: queue,
|
queue: queue,
|
||||||
@@ -969,11 +973,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
onStateChange: { [weak self] state in
|
onStateChange: { [weak self] state in
|
||||||
self?.publish { self?.linkStates[device.id] = state }
|
self?.publish { self?.linkStates[device.id] = state }
|
||||||
},
|
},
|
||||||
onSolarState: { [weak self] state in
|
onVotronicSolarESPState: { [weak self] state in
|
||||||
self?.publish { self?.solarStates[device.id] = state }
|
self?.publish { self?.votronicSolarESPStates[device.id] = state }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
solarSessions[peripheral.identifier] = session
|
votronicSolarESPSessions[peripheral.identifier] = session
|
||||||
session.start()
|
session.start()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1025,8 +1029,8 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
//bmsSessions[peripheral.identifier] = nil
|
//bmsSessions[peripheral.identifier] = nil
|
||||||
levelSessions[peripheral.identifier]?.handleDisconnect()
|
levelSessions[peripheral.identifier]?.handleDisconnect()
|
||||||
levelSessions[peripheral.identifier] = nil
|
levelSessions[peripheral.identifier] = nil
|
||||||
solarSessions[peripheral.identifier]?.handleDisconnect()
|
votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect()
|
||||||
solarSessions[peripheral.identifier] = nil
|
votronicSolarESPSessions[peripheral.identifier] = nil
|
||||||
connectedPeripherals[peripheral.identifier] = nil
|
connectedPeripherals[peripheral.identifier] = nil
|
||||||
|
|
||||||
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
||||||
|
|||||||
@@ -3,18 +3,30 @@ import Foundation
|
|||||||
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
|
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
|
||||||
/// Fahrzeug und ohne Bluetooth prüfen lassen.
|
/// Fahrzeug und ohne Bluetooth prüfen lassen.
|
||||||
///
|
///
|
||||||
/// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist:
|
/// Nur in Debug-Builds, auf zwei Wegen einzuschalten:
|
||||||
///
|
///
|
||||||
/// xcrun simctl launch --terminate-running-process \
|
/// * Umgebungsvariable `CAMPER_DEMO=1` beim Start – praktisch im Simulator:
|
||||||
/// booted de.fritob.CamperMonitor
|
///
|
||||||
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
/// xcrun simctl launch --terminate-running-process \
|
||||||
|
/// booted de.fritob.CamperMonitor
|
||||||
|
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
||||||
|
///
|
||||||
|
/// * Schalter in den Einstellungen (`SettingsView`) – der einzige Weg auf
|
||||||
|
/// einem lokal installierten Build ohne Xcode-Verbindung, da sich dort
|
||||||
|
/// keine Umgebungsvariable setzen lässt. Wirkt erst nach einem Neustart der
|
||||||
|
/// App, weil `DeviceStore` und `BluetoothManager` den Stand nur beim Start
|
||||||
|
/// lesen.
|
||||||
///
|
///
|
||||||
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
|
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
|
||||||
enum DemoData {
|
enum DemoData {
|
||||||
|
|
||||||
|
/// Schlüssel für den Einstellungen-Schalter, siehe `SettingsView`.
|
||||||
|
static let enabledKey = "demoModeEnabled"
|
||||||
|
|
||||||
static var isEnabled: Bool {
|
static var isEnabled: Bool {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
|
if ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" { return true }
|
||||||
|
return UserDefaults.standard.bool(forKey: enabledKey)
|
||||||
#else
|
#else
|
||||||
return false
|
return false
|
||||||
#endif
|
#endif
|
||||||
@@ -69,8 +81,8 @@ enum DemoData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mittags, gute Sonne.
|
/// Mittags, gute Sonne.
|
||||||
static var solarState: SolarState {
|
static var votronicSolarESPState: VotronicSolarESPState {
|
||||||
var state = SolarState()
|
var state = VotronicSolarESPState()
|
||||||
state.batteryVoltage = 13.9
|
state.batteryVoltage = 13.9
|
||||||
state.pvVoltage = 19.4
|
state.pvVoltage = 19.4
|
||||||
state.pvCurrent = 6.2
|
state.pvCurrent = 6.2
|
||||||
@@ -84,7 +96,7 @@ enum DemoData {
|
|||||||
|
|
||||||
/// Ein paar Stunden Verlauf je Kanal, damit die Grafik im Demo-Modus
|
/// Ein paar Stunden Verlauf je Kanal, damit die Grafik im Demo-Modus
|
||||||
/// sofort etwas zeigt statt erst nach ein paar Minuten Laufzeit.
|
/// sofort etwas zeigt statt erst nach ein paar Minuten Laufzeit.
|
||||||
static func solarHistory() -> DeviceHistory {
|
static func votronicSolarESPHistory() -> DeviceHistory {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
func series(around value: Double, noise: Double) -> [HistorySample] {
|
func series(around value: Double, noise: Double) -> [HistorySample] {
|
||||||
(0..<120).reversed().map { step in
|
(0..<120).reversed().map { step in
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ private struct ConfigureDeviceView: View {
|
|||||||
}
|
}
|
||||||
} else*/ if discovery.isLevelSensor {
|
} else*/ if discovery.isLevelSensor {
|
||||||
role = .leveling
|
role = .leveling
|
||||||
} else if discovery.isSolarSensor {
|
} else if discovery.isVotronicSolarESPSensor {
|
||||||
role = .solar
|
role = .solar
|
||||||
} /*else if let name = discovery.name?.lowercased(),
|
} /*else if let name = discovery.name?.lowercased(),
|
||||||
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
||||||
@@ -208,8 +208,8 @@ private struct ConfigureDeviceView: View {
|
|||||||
// ohnehin darüber unter "Gefunden als".
|
// ohnehin darüber unter "Gefunden als".
|
||||||
if discovery.isLevelSensor {
|
if discovery.isLevelSensor {
|
||||||
name = "Nivellierung"
|
name = "Nivellierung"
|
||||||
} else if discovery.isSolarSensor {
|
} else if discovery.isVotronicSolarESPSensor {
|
||||||
name = "Solarladeregler"
|
name = role.title
|
||||||
} else if let advertised = discovery.name, advertised.count <= 20,
|
} else if let advertised = discovery.name, advertised.count <= 20,
|
||||||
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
||||||
name = advertised
|
name = advertised
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
|
|||||||
|
|
||||||
@Environment(BluetoothManager.self) private var bluetooth
|
@Environment(BluetoothManager.self) private var bluetooth
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
|
|
||||||
@State private var assistant = AlignmentAssistant()
|
@State private var assistant = AlignmentAssistant()
|
||||||
@State private var didAnnounceTarget = false
|
@State private var didAnnounceTarget = false
|
||||||
@@ -18,49 +19,18 @@ struct AlignmentAssistantView: View {
|
|||||||
|
|
||||||
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
||||||
|
|
||||||
|
/// iPhone im Querformat meldet eine kompakte Höhe – das ist das
|
||||||
|
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
|
||||||
|
private var isLandscape: Bool { verticalSizeClass == .compact }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
Group {
|
||||||
VStack(spacing: 24) {
|
if isLandscape {
|
||||||
Picker("Darstellung", selection: $displayStyle) {
|
landscapeLayout
|
||||||
ForEach(LevelDisplayStyle.allCases) { style in
|
} else {
|
||||||
Text(style.title).tag(style)
|
portraitLayout
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
switch displayStyle {
|
|
||||||
case .bubble:
|
|
||||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
|
||||||
.frame(maxWidth: 320)
|
|
||||||
case .vehicle:
|
|
||||||
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
|
||||||
style: device.vehicleGraphicStyle)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isLive { disconnectedBanner }
|
|
||||||
|
|
||||||
adviceBanner
|
|
||||||
|
|
||||||
readings
|
|
||||||
|
|
||||||
if let best = assistant.best, let gain = assistant.improvementAtBest,
|
|
||||||
let seconds = assistant.timeSinceBest {
|
|
||||||
bestPointCard(best: best, gain: gain, seconds: seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
wedgeSection
|
|
||||||
|
|
||||||
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
|
|
||||||
assistant.reset()
|
|
||||||
didAnnounceTarget = false
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
.navigationTitle("Ausrichtungs-Assistent")
|
.navigationTitle("Ausrichtungs-Assistent")
|
||||||
@@ -96,10 +66,94 @@ struct AlignmentAssistantView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Layouts
|
||||||
|
|
||||||
|
private var portraitLayout: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
picker.padding(.top, 8)
|
||||||
|
|
||||||
|
display
|
||||||
|
|
||||||
|
if !isLive { disconnectedBanner }
|
||||||
|
|
||||||
|
adviceBanner
|
||||||
|
|
||||||
|
readings
|
||||||
|
|
||||||
|
if let best = assistant.best, let gain = assistant.improvementAtBest,
|
||||||
|
let seconds = assistant.timeSinceBest {
|
||||||
|
bestPointCard(best: best, gain: gain, seconds: seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
wedgeSection
|
||||||
|
|
||||||
|
resetButton
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anzeige links, alles zum Rangieren Nötige rechts – ohne Scrollen, denn
|
||||||
|
/// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die
|
||||||
|
/// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür
|
||||||
|
/// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar.
|
||||||
|
private var landscapeLayout: some View {
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
picker
|
||||||
|
display
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if !isLive { compactDisconnectedBanner }
|
||||||
|
adviceBanner
|
||||||
|
readings
|
||||||
|
wedgeSection
|
||||||
|
resetButton
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Bausteine
|
// MARK: - Bausteine
|
||||||
|
|
||||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||||
|
|
||||||
|
private var picker: some View {
|
||||||
|
Picker("Darstellung", selection: $displayStyle) {
|
||||||
|
ForEach(LevelDisplayStyle.allCases) { style in
|
||||||
|
Text(style.title).tag(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var display: some View {
|
||||||
|
switch displayStyle {
|
||||||
|
case .bubble:
|
||||||
|
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||||
|
.frame(maxWidth: isLandscape ? 220 : 320, maxHeight: isLandscape ? 160 : .infinity)
|
||||||
|
case .vehicle:
|
||||||
|
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
||||||
|
style: device.vehicleGraphicStyle, compact: isLandscape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resetButton: some View {
|
||||||
|
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
|
||||||
|
assistant.reset()
|
||||||
|
didAnnounceTarget = false
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
|
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
|
||||||
/// Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem
|
/// Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem
|
||||||
/// Wert, der längst nicht mehr gilt.
|
/// Wert, der längst nicht mehr gilt.
|
||||||
@@ -121,40 +175,54 @@ struct AlignmentAssistantView: View {
|
|||||||
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
|
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kurzform für Querformat: derselbe Hinweis in einer Zeile.
|
||||||
|
private var compactDisconnectedBanner: some View {
|
||||||
|
Label("Nicht verbunden – Anzeige steht still", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
private var adviceBanner: some View {
|
private var adviceBanner: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: assistant.hasReachedTarget
|
Image(systemName: assistant.hasReachedTarget
|
||||||
? "checkmark.circle.fill" : assistant.trend.symbol)
|
? "checkmark.circle.fill" : assistant.trend.symbol)
|
||||||
.font(.title)
|
.font(isLandscape ? .title2 : .title)
|
||||||
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
|
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
|
||||||
Text(assistant.advice)
|
Text(assistant.advice)
|
||||||
.font(.title3.weight(.medium))
|
.font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(isLandscape ? 10 : 16)
|
||||||
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
|
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
|
||||||
: Color(.secondarySystemGroupedBackground),
|
: Color(.secondarySystemGroupedBackground),
|
||||||
in: .rect(cornerRadius: 16))
|
in: .rect(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var readings: some View {
|
private var readings: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: isLandscape ? 8 : 12) {
|
||||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||||
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
|
if !isLandscape {
|
||||||
|
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reading(_ title: String, _ text: String) -> some View {
|
private func reading(_ title: String, _ text: String) -> some View {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: isLandscape ? 1 : 4) {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.title2.weight(.semibold).monospacedDigit())
|
.font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.caption)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, isLandscape ? 6 : 12)
|
||||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,23 +256,25 @@ struct AlignmentAssistantView: View {
|
|||||||
trackWidth: width, wheelbase: base)
|
trackWidth: width, wheelbase: base)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
|
||||||
Label("Auffahrkeile", systemImage: "triangle.fill")
|
Label("Auffahrkeile", systemImage: "triangle.fill")
|
||||||
.font(.headline)
|
.font(isLandscape ? .subheadline.weight(.semibold) : .headline)
|
||||||
|
|
||||||
if profile?.trackWidth == nil || profile?.wheelbase == nil {
|
if profile?.trackWidth == nil || profile?.wheelbase == nil {
|
||||||
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
|
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
|
||||||
+ "sich beim Fahrzeug hinterlegen.")
|
+ "sich beim Fahrzeug hinterlegen.")
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else if let lift, !lift.isNegligible {
|
|
||||||
WheelLiftPlan(lift: lift)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
|
||||||
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
} else if let lift, !lift.isNegligible {
|
||||||
|
WheelLiftPlan(lift: lift, isCompact: isLandscape)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
if !isLandscape {
|
||||||
|
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
||||||
|
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Keine Keile nötig.")
|
Text("Keine Keile nötig.")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@@ -212,7 +282,7 @@ struct AlignmentAssistantView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding()
|
.padding(isLandscape ? 10 : 16)
|
||||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,4 +300,3 @@ struct AlignmentAssistantView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ struct DashboardView: View {
|
|||||||
Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right")
|
Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right")
|
||||||
} description: {
|
} description: {
|
||||||
Text("Füge \(store.activeProfile.map { "„\($0.name)“" } ?? "diesem Fahrzeug") "
|
Text("Füge \(store.activeProfile.map { "„\($0.name)“" } ?? "diesem Fahrzeug") "
|
||||||
+ "den Ladebooster, den Solarladeregler und das BMS hinzu.")
|
+ "den Ladebooster, den VotronicSolarESP und das BMS hinzu.")
|
||||||
} actions: {
|
} actions: {
|
||||||
Button("Gerät suchen") { isAddingDevice = true }
|
Button("Gerät suchen") { isAddingDevice = true }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
|
|||||||
@Environment(DeviceStore.self) private var store
|
@Environment(DeviceStore.self) private var store
|
||||||
@Environment(BluetoothManager.self) private var bluetooth
|
@Environment(BluetoothManager.self) private var bluetooth
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
|
|
||||||
@State private var editedName = ""
|
@State private var editedName = ""
|
||||||
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
|
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
|
||||||
@@ -38,6 +39,15 @@ struct DeviceDetailView: View {
|
|||||||
/// Nur solange die App läuft: siehe `DeviceHistory`.
|
/// Nur solange die App läuft: siehe `DeviceHistory`.
|
||||||
@State private var selectedHistoryMetricKey: String?
|
@State private var selectedHistoryMetricKey: String?
|
||||||
|
|
||||||
|
/// iPhone im Querformat meldet eine kompakte Höhe – das ist das
|
||||||
|
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
|
||||||
|
private var isLandscape: Bool { verticalSizeClass == .compact }
|
||||||
|
|
||||||
|
/// Nur der Neigungsmesser bekommt die Querformat-Sonderbehandlung
|
||||||
|
/// (Verbindungsstatus ans Ende, Anzeige auf Bildschirmhöhe) – andere
|
||||||
|
/// Sensoren behalten ihre bisherige Reihenfolge und Grösse.
|
||||||
|
private var showsCompactLevelLayout: Bool { isLandscape && currentDevice.role == .leveling }
|
||||||
|
|
||||||
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
||||||
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||||||
private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
|
private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
|
||||||
@@ -62,119 +72,131 @@ struct DeviceDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
|
||||||
statusSection
|
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
|
||||||
// if needsKeyAttention { keyPrompt }
|
// festen Wert – deshalb misst ein GeometryReader die Liste von aussen.
|
||||||
|
GeometryReader { geometry in
|
||||||
|
List {
|
||||||
|
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
|
||||||
|
// sichtbar sein, ohne erst am Verbindungsstatus
|
||||||
|
// vorbeizuscrollen – der rutscht dort ganz ans Ende. Gilt
|
||||||
|
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`.
|
||||||
|
if !showsCompactLevelLayout { statusSection }
|
||||||
|
// if needsKeyAttention { keyPrompt }
|
||||||
|
|
||||||
// if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
// if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||||||
// FridgeControls(device: currentDevice, state: fridge)
|
// FridgeControls(device: currentDevice, state: fridge)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
if currentDevice.role == .leveling {
|
if currentDevice.role == .leveling {
|
||||||
LevelControls(device: currentDevice,
|
LevelControls(device: currentDevice,
|
||||||
state: bluetooth.levelStates[device.id] ?? LevelState())
|
state: bluetooth.levelStates[device.id] ?? LevelState(),
|
||||||
}
|
availableHeight: geometry.size.height)
|
||||||
|
|
||||||
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 let metric = selectedMetric, samples.count > 1 {
|
if let snapshot, !snapshot.metrics.isEmpty {
|
||||||
Section {
|
Section("Messwerte") {
|
||||||
if chartableMetrics.count > 1 {
|
ForEach(snapshot.metrics) { metric in
|
||||||
Picker("Messgrösse", selection: Binding(
|
LabeledContent(metric.label) {
|
||||||
get: { metric.key },
|
Text(metric.formattedWithUnit)
|
||||||
set: { selectedHistoryMetricKey = $0 }
|
.monospacedDigit()
|
||||||
)) {
|
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
||||||
ForEach(chartableMetrics) { candidate in
|
|
||||||
Text(candidate.label).tag(candidate.key)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.listRowInsets(EdgeInsets())
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
Chart(samples) { sample in
|
|
||||||
AreaMark(x: .value("Zeit", sample.time),
|
|
||||||
y: .value(metric.label, sample.value))
|
|
||||||
.foregroundStyle(.tint.opacity(0.15))
|
|
||||||
LineMark(x: .value("Zeit", sample.time),
|
|
||||||
y: .value(metric.label, sample.value))
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
.interpolationMethod(.monotone)
|
|
||||||
}
|
|
||||||
.chartYAxisLabel(metric.unit)
|
|
||||||
.frame(height: 180)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
} header: {
|
|
||||||
Text("Verlauf – \(metric.label)")
|
|
||||||
} footer: {
|
|
||||||
Text("Nur für die laufende Sitzung – wird beim Neustart der App verworfen.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if let metric = selectedMetric, samples.count > 1 {
|
||||||
Section("Temperaturen") {
|
Section {
|
||||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
if chartableMetrics.count > 1 {
|
||||||
LabeledContent("Fühler \(index + 1)") {
|
Picker("Messgrösse", selection: Binding(
|
||||||
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
get: { metric.key },
|
||||||
|
set: { selectedHistoryMetricKey = $0 }
|
||||||
|
)) {
|
||||||
|
ForEach(chartableMetrics) { candidate in
|
||||||
|
Text(candidate.label).tag(candidate.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
Chart(samples) { sample in
|
||||||
|
AreaMark(x: .value("Zeit", sample.time),
|
||||||
|
y: .value(metric.label, sample.value))
|
||||||
|
.foregroundStyle(.tint.opacity(0.15))
|
||||||
|
LineMark(x: .value("Zeit", sample.time),
|
||||||
|
y: .value(metric.label, sample.value))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.interpolationMethod(.monotone)
|
||||||
|
}
|
||||||
|
.chartYAxisLabel(metric.unit)
|
||||||
|
.frame(height: 180)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
} header: {
|
||||||
|
Text("Verlauf – \(metric.label)")
|
||||||
|
} footer: {
|
||||||
|
Text("Nur für die laufende Sitzung – wird beim Neustart der App verworfen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 currentDevice.role.transport == .advertisement {
|
if showsCompactLevelLayout { statusSection }
|
||||||
if showsTechnicalDetails { diagnosticsSection }
|
|
||||||
} else if showsTechnicalDetails {
|
|
||||||
bmsDiagnosticsSection
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsSection*/
|
/* if let snapshot, snapshot.temperatures.count > 1 {
|
||||||
}
|
Section("Temperaturen") {
|
||||||
.navigationTitle(currentDevice.name)
|
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
LabeledContent("Fühler \(index + 1)") {
|
||||||
.onDisappear {
|
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
||||||
saveName()
|
}
|
||||||
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
}
|
||||||
// Verbindung meldet sich an ihrem Display an.
|
}
|
||||||
//bluetooth.endSession(for: currentDevice)
|
}
|
||||||
}
|
|
||||||
.onAppear {
|
if currentDevice.role.transport == .advertisement {
|
||||||
editedName = currentDevice.name
|
if showsTechnicalDetails { diagnosticsSection }
|
||||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
} else if showsTechnicalDetails {
|
||||||
//bluetooth.beginSession(for: currentDevice)
|
bmsDiagnosticsSection
|
||||||
}
|
}
|
||||||
.confirmationDialog("Gerät entfernen?",
|
|
||||||
isPresented: $showDeleteConfirmation,
|
settingsSection*/
|
||||||
titleVisibility: .visible) {
|
}
|
||||||
Button("Entfernen", role: .destructive) {
|
.navigationTitle(currentDevice.name)
|
||||||
store.remove(device)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
bluetooth.refreshConfiguration()
|
.onDisappear {
|
||||||
dismiss()
|
saveName()
|
||||||
|
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
||||||
|
// Verbindung meldet sich an ihrem Display an.
|
||||||
|
//bluetooth.endSession(for: currentDevice)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
editedName = currentDevice.name
|
||||||
|
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||||
|
//bluetooth.beginSession(for: currentDevice)
|
||||||
|
}
|
||||||
|
.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.")
|
||||||
}
|
}
|
||||||
} message: {
|
|
||||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,47 +105,76 @@ struct LevelBubble: View {
|
|||||||
struct LevelControls: View {
|
struct LevelControls: View {
|
||||||
let device: ConfiguredDevice
|
let device: ConfiguredDevice
|
||||||
let state: LevelState
|
let state: LevelState
|
||||||
|
/// Höhe der umgebenden Liste, von `DeviceDetailView` per `GeometryReader`
|
||||||
|
/// gemessen. Nur im Querformat gebraucht, um die Anzeige auf
|
||||||
|
/// Bildschirmhöhe zu bringen statt sie auf einen festen Wert zu kappen.
|
||||||
|
var availableHeight: CGFloat?
|
||||||
|
|
||||||
@Environment(BluetoothManager.self) private var bluetooth
|
@Environment(BluetoothManager.self) private var bluetooth
|
||||||
@Environment(DeviceStore.self) private var store
|
@Environment(DeviceStore.self) private var store
|
||||||
@Environment(LevelActivityManager.self) private var levelActivity
|
@Environment(LevelActivityManager.self) private var levelActivity
|
||||||
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
@State private var showAssistant = false
|
@State private var showAssistant = false
|
||||||
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||||||
|
|
||||||
|
/// iPhone im Querformat meldet eine kompakte Höhe – das ist das
|
||||||
|
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
|
||||||
|
private var isLandscape: Bool { verticalSizeClass == .compact }
|
||||||
|
|
||||||
|
/// Verfügbare Höhe abzüglich grober Reserve für Listenränder und die
|
||||||
|
/// eigene vertikale Auffüllung – genug, um praktisch den ganzen
|
||||||
|
/// Bildschirm zu nutzen, ohne über den unteren Rand hinauszuschiessen.
|
||||||
|
private var landscapeContentHeight: CGFloat {
|
||||||
|
max(150, (availableHeight ?? 350) - 56)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
VStack(spacing: 16) {
|
if isLandscape {
|
||||||
Picker("Darstellung", selection: $displayStyle) {
|
HStack(alignment: .center, spacing: 20) {
|
||||||
ForEach(LevelDisplayStyle.allCases) { style in
|
display
|
||||||
Text(style.title).tag(style)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
picker
|
||||||
|
if let instruction = state.instruction {
|
||||||
|
Label(instruction,
|
||||||
|
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||||
|
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.frame(height: landscapeContentHeight)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
picker
|
||||||
|
|
||||||
|
display
|
||||||
|
|
||||||
|
if let instruction = state.instruction {
|
||||||
|
Label(instruction,
|
||||||
|
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||||
|
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 8)
|
||||||
switch displayStyle {
|
|
||||||
case .bubble:
|
|
||||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
|
||||||
.frame(maxHeight: 220)
|
|
||||||
case .vehicle:
|
|
||||||
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
|
||||||
style: device.vehicleGraphicStyle)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let instruction = state.instruction {
|
|
||||||
Label(instruction,
|
|
||||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 24) {
|
|
||||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
|
||||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -220,6 +249,28 @@ struct LevelControls: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var picker: some View {
|
||||||
|
Picker("Darstellung", selection: $displayStyle) {
|
||||||
|
ForEach(LevelDisplayStyle.allCases) { style in
|
||||||
|
Text(style.title).tag(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var display: some View {
|
||||||
|
switch displayStyle {
|
||||||
|
case .bubble:
|
||||||
|
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||||
|
.frame(maxHeight: isLandscape ? landscapeContentHeight : 220)
|
||||||
|
case .vehicle:
|
||||||
|
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
||||||
|
style: device.vehicleGraphicStyle, compact: isLandscape,
|
||||||
|
compactPanelHeight: max(60, landscapeContentHeight - 40))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||||
|
|
||||||
private var liveActivityBinding: Binding<Bool> {
|
private var liveActivityBinding: Binding<Bool> {
|
||||||
|
|||||||
@@ -4,12 +4,28 @@ import SwiftUI
|
|||||||
/// betrifft, steht bei diesem Gerät.
|
/// betrifft, steht bei diesem Gerät.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
||||||
|
#if DEBUG
|
||||||
|
@AppStorage(DemoData.enabledKey) private var demoModeEnabled = false
|
||||||
|
#endif
|
||||||
@Environment(PhoneWatchLink.self) private var watch
|
@Environment(PhoneWatchLink.self) private var watch
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
|
#if DEBUG
|
||||||
|
Section {
|
||||||
|
Toggle("Demo-Modus", isOn: $demoModeEnabled)
|
||||||
|
} header: {
|
||||||
|
Text("Entwicklung")
|
||||||
|
} footer: {
|
||||||
|
Text("Füllt die App mit erfundenen Fahrzeugen und Messwerten, um "
|
||||||
|
+ "Ansichten ohne echte Sensoren zu prüfen. Wirkt erst nach "
|
||||||
|
+ "einem Neustart der App – einmal beenden (im App-Umschalter "
|
||||||
|
+ "nach oben wischen) und wieder öffnen.")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
|
Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
|
||||||
} header: {
|
} header: {
|
||||||
|
|||||||
@@ -16,31 +16,38 @@ struct VehicleTiltView: View {
|
|||||||
let pitch: Double?
|
let pitch: Double?
|
||||||
let roll: Double?
|
let roll: Double?
|
||||||
var style: VehicleGraphicStyle = .vanster
|
var style: VehicleGraphicStyle = .vanster
|
||||||
|
/// Für Querformat: beide Ansichten nebeneinander statt untereinander,
|
||||||
|
/// kleinere Schrift, ohne Überhöhungs-Hinweis – muss ohne Scrollen in die
|
||||||
|
/// Bildschirmhöhe passen.
|
||||||
|
var compact = false
|
||||||
|
/// Bildhöhe je Panel im Querformat – vom Aufrufer an die tatsächlich
|
||||||
|
/// verfügbare Bildschirmhöhe angepasst, statt fest verdrahtet.
|
||||||
|
var compactPanelHeight: CGFloat = 62
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
if compact {
|
||||||
tiltPanel(image: style.sideImageName,
|
HStack(alignment: .top, spacing: 16) {
|
||||||
angle: pitch,
|
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
|
||||||
title: "Längs",
|
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
|
||||||
lowerLabel: "Front",
|
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
|
||||||
upperLabel: "Heck",
|
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
|
||||||
aspect: style.sideAspect)
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
|
||||||
|
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
|
||||||
|
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
|
||||||
|
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
|
||||||
|
|
||||||
tiltPanel(image: style.rearImageName,
|
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||||
angle: roll,
|
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||||
title: "Quer",
|
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
||||||
lowerLabel: "links",
|
VehicleTilt.exaggeration))
|
||||||
upperLabel: "rechts",
|
.font(.caption2)
|
||||||
aspect: style.rearAspect)
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
.frame(maxWidth: .infinity)
|
||||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
}
|
||||||
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
|
||||||
VehicleTilt.exaggeration))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,13 +57,13 @@ struct VehicleTiltView: View {
|
|||||||
lowerLabel: String,
|
lowerLabel: String,
|
||||||
upperLabel: String,
|
upperLabel: String,
|
||||||
aspect: Double) -> some View {
|
aspect: Double) -> some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: compact ? 4 : 8) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.subheadline.weight(.medium))
|
.font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
||||||
.font(.subheadline.weight(.semibold).monospacedDigit())
|
.font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit())
|
||||||
.foregroundStyle(VehicleTilt.colour(for: angle))
|
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,14 +83,14 @@ struct VehicleTiltView: View {
|
|||||||
.animation(.spring(duration: 0.4), value: angle)
|
.animation(.spring(duration: 0.4), value: angle)
|
||||||
.opacity(angle == nil ? 0.3 : 1)
|
.opacity(angle == nil ? 0.3 : 1)
|
||||||
}
|
}
|
||||||
.frame(height: 110)
|
.frame(height: compact ? compactPanelHeight : 110)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(lowerLabel)
|
Text(lowerLabel)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(upperLabel)
|
Text(upperLabel)
|
||||||
}
|
}
|
||||||
.font(.caption2)
|
.font(compact ? .system(size: 9) : .caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Solarladeregler „VanAlign Solar" – zweiter ESP32 im Fahrzeug, liest einen
|
/// VotronicSolarESP – zweiter ESP32 im Fahrzeug, liest einen
|
||||||
/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
|
/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
|
||||||
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
|
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
|
||||||
///
|
///
|
||||||
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
|
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
|
||||||
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
|
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
|
||||||
/// sie im Takt ab.
|
/// sie im Takt ab.
|
||||||
enum VanAlignSolarProtocol {
|
enum VotronicSolarESPProtocol {
|
||||||
|
|
||||||
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
|
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
|
||||||
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
|
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
|
||||||
+20
-20
@@ -6,25 +6,25 @@ import Foundation
|
|||||||
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
|
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
|
||||||
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
|
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
|
||||||
/// immer im Takt abgefragt statt abonniert.
|
/// immer im Takt abgefragt statt abonniert.
|
||||||
final class SolarSession: NSObject {
|
final class VotronicSolarESPSession: NSObject {
|
||||||
|
|
||||||
static let serviceUUID = CBUUID(string: VanAlignSolarProtocol.serviceUUID)
|
static let serviceUUID = CBUUID(string: VotronicSolarESPProtocol.serviceUUID)
|
||||||
private static let batteryVoltageUUID = CBUUID(string: VanAlignSolarProtocol.batteryVoltageUUID)
|
private static let batteryVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.batteryVoltageUUID)
|
||||||
private static let pvVoltageUUID = CBUUID(string: VanAlignSolarProtocol.pvVoltageUUID)
|
private static let pvVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.pvVoltageUUID)
|
||||||
private static let pvCurrentUUID = CBUUID(string: VanAlignSolarProtocol.pvCurrentUUID)
|
private static let pvCurrentUUID = CBUUID(string: VotronicSolarESPProtocol.pvCurrentUUID)
|
||||||
private static let pvPowerUUID = CBUUID(string: VanAlignSolarProtocol.pvPowerUUID)
|
private static let pvPowerUUID = CBUUID(string: VotronicSolarESPProtocol.pvPowerUUID)
|
||||||
private static let controllerTempUUID = CBUUID(string: VanAlignSolarProtocol.controllerTempUUID)
|
private static let controllerTempUUID = CBUUID(string: VotronicSolarESPProtocol.controllerTempUUID)
|
||||||
private static let statusFlagsUUID = CBUUID(string: VanAlignSolarProtocol.statusFlagsUUID)
|
private static let statusFlagsUUID = CBUUID(string: VotronicSolarESPProtocol.statusFlagsUUID)
|
||||||
|
|
||||||
let deviceID: UUID
|
let deviceID: UUID
|
||||||
private let queue: DispatchQueue
|
private let queue: DispatchQueue
|
||||||
private let peripheral: CBPeripheral
|
private let peripheral: CBPeripheral
|
||||||
private let onUpdate: (DeviceSnapshot) -> Void
|
private let onUpdate: (DeviceSnapshot) -> Void
|
||||||
private let onStateChange: (DeviceLinkState) -> Void
|
private let onStateChange: (DeviceLinkState) -> Void
|
||||||
private let onSolarState: (SolarState) -> Void
|
private let onVotronicSolarESPState: (VotronicSolarESPState) -> Void
|
||||||
|
|
||||||
private var characteristics: [CBUUID: CBCharacteristic] = [:]
|
private var characteristics: [CBUUID: CBCharacteristic] = [:]
|
||||||
private var state = SolarState()
|
private var state = VotronicSolarESPState()
|
||||||
private var pollTimer: DispatchSourceTimer?
|
private var pollTimer: DispatchSourceTimer?
|
||||||
|
|
||||||
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern –
|
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern –
|
||||||
@@ -36,13 +36,13 @@ final class SolarSession: NSObject {
|
|||||||
queue: DispatchQueue,
|
queue: DispatchQueue,
|
||||||
onUpdate: @escaping (DeviceSnapshot) -> Void,
|
onUpdate: @escaping (DeviceSnapshot) -> Void,
|
||||||
onStateChange: @escaping (DeviceLinkState) -> Void,
|
onStateChange: @escaping (DeviceLinkState) -> Void,
|
||||||
onSolarState: @escaping (SolarState) -> Void) {
|
onVotronicSolarESPState: @escaping (VotronicSolarESPState) -> Void) {
|
||||||
self.deviceID = deviceID
|
self.deviceID = deviceID
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
self.peripheral = peripheral
|
self.peripheral = peripheral
|
||||||
self.onUpdate = onUpdate
|
self.onUpdate = onUpdate
|
||||||
self.onStateChange = onStateChange
|
self.onStateChange = onStateChange
|
||||||
self.onSolarState = onSolarState
|
self.onVotronicSolarESPState = onVotronicSolarESPState
|
||||||
super.init()
|
super.init()
|
||||||
peripheral.delegate = self
|
peripheral.delegate = self
|
||||||
}
|
}
|
||||||
@@ -87,14 +87,14 @@ final class SolarSession: NSObject {
|
|||||||
private func publish() {
|
private func publish() {
|
||||||
guard state.hasReading else { return }
|
guard state.hasReading else { return }
|
||||||
onStateChange(.live)
|
onStateChange(.live)
|
||||||
onSolarState(state)
|
onVotronicSolarESPState(state)
|
||||||
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
|
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CBPeripheralDelegate
|
// MARK: - CBPeripheralDelegate
|
||||||
|
|
||||||
extension SolarSession: CBPeripheralDelegate {
|
extension VotronicSolarESPSession: CBPeripheralDelegate {
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||||
if let error {
|
if let error {
|
||||||
@@ -137,17 +137,17 @@ extension SolarSession: CBPeripheralDelegate {
|
|||||||
|
|
||||||
switch characteristic.uuid {
|
switch characteristic.uuid {
|
||||||
case Self.batteryVoltageUUID:
|
case Self.batteryVoltageUUID:
|
||||||
state.batteryVoltage = VanAlignSolarProtocol.float(from: value)
|
state.batteryVoltage = VotronicSolarESPProtocol.float(from: value)
|
||||||
case Self.pvVoltageUUID:
|
case Self.pvVoltageUUID:
|
||||||
state.pvVoltage = VanAlignSolarProtocol.float(from: value)
|
state.pvVoltage = VotronicSolarESPProtocol.float(from: value)
|
||||||
case Self.pvCurrentUUID:
|
case Self.pvCurrentUUID:
|
||||||
state.pvCurrent = VanAlignSolarProtocol.float(from: value)
|
state.pvCurrent = VotronicSolarESPProtocol.float(from: value)
|
||||||
case Self.pvPowerUUID:
|
case Self.pvPowerUUID:
|
||||||
state.pvPower = VanAlignSolarProtocol.float(from: value)
|
state.pvPower = VotronicSolarESPProtocol.float(from: value)
|
||||||
case Self.controllerTempUUID:
|
case Self.controllerTempUUID:
|
||||||
state.controllerTemperature = VanAlignSolarProtocol.float(from: value)
|
state.controllerTemperature = VotronicSolarESPProtocol.float(from: value)
|
||||||
case Self.statusFlagsUUID:
|
case Self.statusFlagsUUID:
|
||||||
guard let flags = VanAlignSolarProtocol.statusFlags(from: value) else { return }
|
guard let flags = VotronicSolarESPProtocol.statusFlags(from: value) else { return }
|
||||||
state.isBatteryCharging = flags.isBatteryCharging
|
state.isBatteryCharging = flags.isBatteryCharging
|
||||||
state.isBatteryDischarging = flags.isBatteryDischarging
|
state.isBatteryDischarging = flags.isBatteryDischarging
|
||||||
state.isControllerActive = flags.isControllerActive
|
state.isControllerActive = flags.isControllerActive
|
||||||
@@ -19,7 +19,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
|||||||
//case .bms: return "Batterie / BMS"
|
//case .bms: return "Batterie / BMS"
|
||||||
//case .fridge: return "Kühlbox"
|
//case .fridge: return "Kühlbox"
|
||||||
case .leveling: return "Nivellierung"
|
case .leveling: return "Nivellierung"
|
||||||
case .solar: return "Solarladeregler"
|
case .solar: return "VotronicSolarESP"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Zustand des Solarladereglers.
|
/// Zustand des Solarladereglers.
|
||||||
struct SolarState: Equatable, Codable, Sendable {
|
struct VotronicSolarESPState: Equatable, Codable, Sendable {
|
||||||
var batteryVoltage: Double?
|
var batteryVoltage: Double?
|
||||||
var pvVoltage: Double?
|
var pvVoltage: Double?
|
||||||
var pvCurrent: Double?
|
var pvCurrent: Double?
|
||||||
Reference in New Issue
Block a user