forked from fritob/Camper-Monitor
Apple Watch: Übersicht, Ausrichten mit Vibration, Kühlbox am Handgelenk
Neues Target CamperMonitorWatch, eingebettet in die iPhone-App. Die Uhr zeigt je Gerät den Hauptwert, die Nivellierung mit Libelle und Keilhöhen, den Ausrichtungs-Assistenten und stellt die Kühlbox. Die Uhr funkt nicht selbst, obwohl watchOS das könnte. BMS, Kühlbox und Neigungsmesser lassen jeweils nur eine Verbindung zu – eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie zu ergänzen. Und die Victron-Schlüssel liegen in der Keychain des iPhones; sie ein zweites Mal auf der Uhr aufzubewahren brächte nichts. Das iPhone bleibt also das Funkgerät und reicht fertige Messwerte über WatchConnectivity weiter. Gesendet wird in zwei Takten: ein halber Sekundentakt als Nachricht, solange die Watch-App im Vordergrund ist und das über eine Frist meldet, sonst alle zwei Sekunden als Anwendungskontext und nur bei Änderungen. Läuft die Frist ab, hört das iPhone von selbst wieder auf. Solange die Uhr hinschaut, hält die App das Funkgerät auch im Hintergrund am Leben (UIBackgroundModes in Config/CamperMonitor-Info.plist) – beim Rangieren liegt das iPhone sonst mit dunklem Bildschirm in der Halterung und die Anzeige am Handgelenk wäre genau dann tot. Die verbundenen Geräte liefern dabei weiter, die Victron-Werbedaten nicht: ungefiltertes Suchen lässt iOS im Hintergrund nicht zu. Der plattformneutrale Modellcode wandert nach Shared/ und wird in beide Targets übersetzt; LevelState ist dafür aus VanAlignProtocol.swift herausgelöst. Datensatz und Befehle stehen in Shared/WatchLink und sind in run-tests.sh abgesichert: Rundlauf, Schutz gegen fremde Formatversion, Vergleich ohne Zeitstempel und die Auflösung der Zeitangaben. Geprüft sind der Bau beider Targets samt Einbetten und der Testlauf; auf echter Hardware noch nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
|
||||
/// Hilft beim Ausrichten des Fahrzeugs während des Rangierens.
|
||||
///
|
||||
/// Bewusst **ohne** Positionsbestimmung: Aus einem MEMS-Beschleunigungssensor
|
||||
/// lässt sich keine brauchbare Strecke ableiten, weil der Fehler beim
|
||||
/// zweifachen Integrieren quadratisch mit der Zeit wächst. Beim Rangieren im
|
||||
/// Schritttempo gehen die tatsächlichen Beschleunigungen ohnehin im Rauschen
|
||||
/// unter.
|
||||
///
|
||||
/// Gebraucht wird das auch gar nicht. Die Frage beim Einparken lautet nie „wo
|
||||
/// stehe ich“, sondern „wird es besser oder schlechter, und wo war es am
|
||||
/// besten“. Beides steckt bereits im zeitlichen Verlauf der Neigung – ganz
|
||||
/// ohne Annahmen über das Gelände.
|
||||
struct AlignmentAssistant {
|
||||
|
||||
struct Sample: Equatable {
|
||||
let time: Date
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
|
||||
/// Gesamtabweichung von der Waagerechten.
|
||||
var deviation: Double { (pitch * pitch + roll * roll).squareRoot() }
|
||||
}
|
||||
|
||||
/// Wie lange zurückgeschaut wird.
|
||||
static let memory: TimeInterval = 90
|
||||
/// Ab dieser Verbesserung lohnt der Hinweis auf einen früheren Punkt.
|
||||
static let worthGoingBack = 0.2
|
||||
/// Unterhalb dieser Änderung gilt die Lage als unverändert.
|
||||
static let trendThreshold = 0.08
|
||||
|
||||
private(set) var samples: [Sample] = []
|
||||
|
||||
enum Trend: Equatable {
|
||||
case improving
|
||||
case worsening
|
||||
case steady
|
||||
case unknown
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .improving: return "Wird besser"
|
||||
case .worsening: return "Wird schlechter"
|
||||
case .steady: return "Bleibt gleich"
|
||||
case .unknown: return "Messe…"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .improving: return "arrow.down.right.circle.fill"
|
||||
case .worsening: return "arrow.up.right.circle.fill"
|
||||
case .steady: return "equal.circle.fill"
|
||||
case .unknown: return "hourglass.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutating func add(pitch: Double, roll: Double, at time: Date = Date()) {
|
||||
samples.append(Sample(time: time, pitch: pitch, roll: roll))
|
||||
let cutoff = time.addingTimeInterval(-Self.memory)
|
||||
samples.removeAll { $0.time < cutoff }
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
samples.removeAll()
|
||||
}
|
||||
|
||||
var current: Sample? { samples.last }
|
||||
|
||||
/// Der flachste Punkt, den wir gesehen haben.
|
||||
var best: Sample? {
|
||||
samples.min { $0.deviation < $1.deviation }
|
||||
}
|
||||
|
||||
/// Ob und wie stark sich die Lage gerade ändert.
|
||||
///
|
||||
/// Verglichen wird das jüngste Drittel mit dem davorliegenden. Einzelne
|
||||
/// Messwerte wären zu unruhig; das Fahrzeug wippt beim Rangieren.
|
||||
var trend: Trend {
|
||||
guard samples.count >= 6 else { return .unknown }
|
||||
let recent = samples.suffix(3)
|
||||
let previous = samples.dropLast(3).suffix(3)
|
||||
guard !previous.isEmpty else { return .unknown }
|
||||
|
||||
let now = recent.map(\.deviation).reduce(0, +) / Double(recent.count)
|
||||
let before = previous.map(\.deviation).reduce(0, +) / Double(previous.count)
|
||||
let change = now - before
|
||||
|
||||
if abs(change) < Self.trendThreshold { return .steady }
|
||||
return change < 0 ? .improving : .worsening
|
||||
}
|
||||
|
||||
/// Wieviel besser der beste Punkt gegenüber jetzt war – nil, wenn es sich
|
||||
/// nicht lohnt oder wir gerade selbst am besten Punkt stehen.
|
||||
var improvementAtBest: Double? {
|
||||
guard let current, let best, best.time < current.time else { return nil }
|
||||
let gain = current.deviation - best.deviation
|
||||
return gain >= Self.worthGoingBack ? gain : nil
|
||||
}
|
||||
|
||||
/// Wie lange der beste Punkt zurückliegt.
|
||||
var timeSinceBest: TimeInterval? {
|
||||
guard improvementAtBest != nil, let current, let best else { return nil }
|
||||
return current.time.timeIntervalSince(best.time)
|
||||
}
|
||||
|
||||
/// Was der Fahrer jetzt tun soll.
|
||||
var advice: String {
|
||||
guard let current else { return "Warte auf Messwerte…" }
|
||||
if current.deviation <= LevelState.levelTolerance {
|
||||
return "Steht eben – anhalten"
|
||||
}
|
||||
if let seconds = timeSinceBest {
|
||||
return String(format: "Vor %.0f s stand es besser – ein Stück zurück",
|
||||
seconds.rounded())
|
||||
}
|
||||
switch trend {
|
||||
case .improving: return "Wird besser – weiter so"
|
||||
case .worsening: return "Wird schlechter – andere Richtung"
|
||||
case .steady: return "Ändert sich kaum – andere Richtung versuchen"
|
||||
case .unknown: return "Langsam weiterfahren"
|
||||
}
|
||||
}
|
||||
|
||||
var hasReachedTarget: Bool {
|
||||
(current?.deviation ?? .infinity) <= LevelState.levelTolerance
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen.
|
||||
///
|
||||
/// Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen
|
||||
/// beziehungsweise der Räder.
|
||||
struct LevelingWedge: Equatable {
|
||||
/// Wo der Keil hin muss.
|
||||
let side: Side
|
||||
/// Höhe in Metern.
|
||||
let height: Double
|
||||
/// Der zugrundeliegende Winkel in Grad.
|
||||
let angle: Double
|
||||
|
||||
enum Side: Equatable {
|
||||
case front, rear, left, right
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .front: return "vorne"
|
||||
case .rear: return "hinten"
|
||||
case .left: return "links"
|
||||
case .right: return "rechts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var heightInCentimetres: Double { height * 100 }
|
||||
|
||||
/// Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll
|
||||
/// heisst, dass rechts höher steht – der Keil gehört also nach links.
|
||||
static func across(roll: Double, trackWidth: Double) -> LevelingWedge? {
|
||||
wedge(angle: roll, distance: trackWidth,
|
||||
whenPositive: .left, whenNegative: .right)
|
||||
}
|
||||
|
||||
/// Längs: positiver Pitch heisst, dass das Heck höher steht – der Keil
|
||||
/// gehört unter die Vorderräder.
|
||||
static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? {
|
||||
wedge(angle: pitch, distance: wheelbase,
|
||||
whenPositive: .front, whenNegative: .rear)
|
||||
}
|
||||
|
||||
private static func wedge(angle: Double,
|
||||
distance: Double,
|
||||
whenPositive: Side,
|
||||
whenNegative: Side) -> LevelingWedge? {
|
||||
guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil }
|
||||
let height = tan(abs(angle) * .pi / 180) * distance
|
||||
return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative,
|
||||
height: height,
|
||||
angle: abs(angle))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
|
||||
/// Welche Rolle ein Gerät im Camper spielt. Bestimmt Icon, Sortierung und
|
||||
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
|
||||
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case chargeBooster
|
||||
case solarCharger
|
||||
case batteryMonitor
|
||||
case bms
|
||||
case fridge
|
||||
case leveling
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .chargeBooster: return "Ladebooster"
|
||||
case .solarCharger: return "Solarladeregler"
|
||||
case .batteryMonitor: return "Batteriemonitor"
|
||||
case .bms: return "Batterie / BMS"
|
||||
case .fridge: return "Kühlbox"
|
||||
case .leveling: return "Nivellierung"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .chargeBooster: return "bolt.car"
|
||||
case .solarCharger: return "sun.max"
|
||||
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
||||
case .bms: return "battery.100percent.bolt"
|
||||
case .fridge: return "refrigerator"
|
||||
case .leveling: return "level"
|
||||
}
|
||||
}
|
||||
|
||||
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
||||
/// braucht eine echte GATT-Verbindung.
|
||||
var transport: DeviceTransport {
|
||||
switch self {
|
||||
case .bms, .fridge, .leveling: return .connect
|
||||
default: return .advertisement
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DeviceTransport: Sendable {
|
||||
/// Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout).
|
||||
case advertisement
|
||||
/// Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen (Daly).
|
||||
case connect
|
||||
}
|
||||
|
||||
/// Wie viele Kühlzonen eine Box hat. Manche Einzonen-Boxen senden den langen
|
||||
/// Datensatz einer Doppelzonen-Box mit, weshalb die Erkennung übersteuerbar ist.
|
||||
enum FridgeZoneMode: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case automatic
|
||||
case single
|
||||
case dual
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .automatic: return "Automatisch"
|
||||
case .single: return "Eine Zone"
|
||||
case .dual: return "Zwei Zonen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein vom Nutzer eingerichtetes Gerät. Der Victron-Schlüssel liegt nicht hier,
|
||||
/// sondern in der Keychain (siehe `KeychainStore`).
|
||||
struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var role: DeviceRole
|
||||
/// Zu welchem Fahrzeug das Gerät gehört.
|
||||
var profileID: UUID
|
||||
/// CoreBluetooth-Identifier des Peripherals. Pro iPhone stabil, aber nicht
|
||||
/// geräteübergreifend – deshalb wird beim Einrichten neu gescannt.
|
||||
var peripheralID: UUID
|
||||
/// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI.
|
||||
var advertisedName: String?
|
||||
/// Nur für Kühlboxen.
|
||||
var fridgeZoneMode: FridgeZoneMode = .automatic
|
||||
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
|
||||
var sensorOrientation = SensorOrientation.identity
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
role: DeviceRole,
|
||||
profileID: UUID,
|
||||
peripheralID: UUID,
|
||||
advertisedName: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.role = role
|
||||
self.profileID = profileID
|
||||
self.peripheralID = peripheralID
|
||||
self.advertisedName = advertisedName
|
||||
}
|
||||
|
||||
/// Geräte, die vor der Profilverwaltung angelegt wurden, haben noch keine
|
||||
/// Zuordnung – die landen im Standardprofil.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
role = try container.decode(DeviceRole.self, forKey: .role)
|
||||
profileID = try container.decodeIfPresent(UUID.self, forKey: .profileID)
|
||||
?? Profile.defaultID
|
||||
peripheralID = try container.decode(UUID.self, forKey: .peripheralID)
|
||||
advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName)
|
||||
fridgeZoneMode = try container.decodeIfPresent(FridgeZoneMode.self, forKey: .fridgeZoneMode)
|
||||
?? .automatic
|
||||
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
|
||||
forKey: .sensorOrientation) ?? .identity
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
||||
struct Metric: Identifiable, Hashable, Codable, Sendable {
|
||||
let key: String
|
||||
let label: String
|
||||
let value: Double?
|
||||
let unit: String
|
||||
/// Nachkommastellen für die Anzeige.
|
||||
let precision: Int
|
||||
/// Wird auf der Kachel gross dargestellt.
|
||||
let isPrimary: Bool
|
||||
|
||||
var id: String { key }
|
||||
|
||||
init(_ key: String,
|
||||
_ label: String,
|
||||
_ value: Double?,
|
||||
unit: String,
|
||||
precision: Int = 2,
|
||||
primary: Bool = false) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.value = value
|
||||
self.unit = unit
|
||||
self.precision = precision
|
||||
self.isPrimary = primary
|
||||
}
|
||||
|
||||
var formatted: String {
|
||||
guard let value else { return "–" }
|
||||
return String(format: "%.\(precision)f", value)
|
||||
}
|
||||
|
||||
var formattedWithUnit: String {
|
||||
guard value != nil else { return "–" }
|
||||
return unit.isEmpty ? formatted : "\(formatted) \(unit)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
|
||||
struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
|
||||
var id: UUID { deviceID }
|
||||
var deviceID: UUID
|
||||
var timestamp: Date
|
||||
var metrics: [Metric] = []
|
||||
/// z.B. "Bulk", "Float", "Aus"
|
||||
var state: String?
|
||||
/// Klartext einer aktiven Störung, sonst nil.
|
||||
var fault: String?
|
||||
/// Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason).
|
||||
var offReasons: [String] = []
|
||||
var rssi: Int?
|
||||
/// Einzelzellspannungen in Volt (nur BMS).
|
||||
var cellVoltages: [Double] = []
|
||||
/// Temperaturfühler in °C (nur BMS).
|
||||
var temperatures: [Double] = []
|
||||
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
||||
var info: [InfoItem] = []
|
||||
|
||||
struct InfoItem: Identifiable, Hashable, Codable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
var id: String { label }
|
||||
}
|
||||
|
||||
var primaryMetric: Metric? {
|
||||
metrics.first(where: \.isPrimary) ?? metrics.first
|
||||
}
|
||||
|
||||
var age: TimeInterval { Date().timeIntervalSince(timestamp) }
|
||||
|
||||
/// Werte älter als eine Minute gelten als veraltet – Victron sendet etwa
|
||||
/// jede Sekunde, das Daly wird alle paar Sekunden gepollt.
|
||||
var isStale: Bool { age > 60 }
|
||||
}
|
||||
|
||||
/// Verbindungszustand für die UI.
|
||||
enum DeviceLinkState: Equatable, Codable, Sendable {
|
||||
case idle
|
||||
case searching
|
||||
case connecting
|
||||
case live
|
||||
case needsKey
|
||||
case failed(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .idle: return "Inaktiv"
|
||||
case .searching: return "Suche…"
|
||||
case .connecting: return "Verbinde…"
|
||||
case .live: return "Live"
|
||||
case .needsKey: return "Schlüssel fehlt"
|
||||
case .failed(let m): return m
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Zustand des Neigungsmessers.
|
||||
struct LevelState: Equatable, Codable, Sendable {
|
||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
||||
var pitch: Double?
|
||||
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
|
||||
var roll: Double?
|
||||
/// Wie der Sensor selbst meldet – nötig, um die Einbaulage zu bestimmen.
|
||||
var rawPitch: Double?
|
||||
var rawRoll: Double?
|
||||
var pitchOffset: Double?
|
||||
var rollOffset: Double?
|
||||
|
||||
var hasReading: Bool { pitch != nil || roll != nil }
|
||||
|
||||
/// Ob kalibriert wurde – oder nil, wenn das Gerät es nicht verrät.
|
||||
///
|
||||
/// Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das
|
||||
/// „unbekannt“ und nicht „nicht kalibriert“: eine Warnung, die sich nie
|
||||
/// abstellen lässt, ist schlimmer als keine.
|
||||
var calibrationState: Bool? {
|
||||
guard let pitchOffset, let rollOffset else { return nil }
|
||||
return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001
|
||||
}
|
||||
|
||||
/// Nur wahr, wenn es auch belegt ist.
|
||||
var isCalibrated: Bool { calibrationState == true }
|
||||
|
||||
/// Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet.
|
||||
var isKnownUncalibrated: Bool { calibrationState == false }
|
||||
|
||||
/// Bis hierhin gilt das Fahrzeug als eben genug.
|
||||
static let levelTolerance = 0.5
|
||||
|
||||
var isLevel: Bool {
|
||||
guard let pitch, let roll else { return false }
|
||||
return abs(pitch) <= Self.levelTolerance && abs(roll) <= Self.levelTolerance
|
||||
}
|
||||
|
||||
/// Die grössere der beiden Abweichungen – das ist die, die man zuerst
|
||||
/// ausgleichen will.
|
||||
var largestDeviation: Double? {
|
||||
switch (pitch, roll) {
|
||||
case let (p?, r?): return max(abs(p), abs(r))
|
||||
case let (p?, nil): return abs(p)
|
||||
case let (nil, r?): return abs(r)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy,
|
||||
/// nicht auf Vorzeichen.
|
||||
var instruction: String? {
|
||||
guard let pitch, let roll else { return nil }
|
||||
if isLevel { return "Steht eben" }
|
||||
var parts: [String] = []
|
||||
if abs(pitch) > Self.levelTolerance {
|
||||
parts.append(pitch > 0 ? "Heck steht höher" : "Front steht höher")
|
||||
}
|
||||
if abs(roll) > Self.levelTolerance {
|
||||
parts.append(roll > 0 ? "rechts steht höher" : "links steht höher")
|
||||
}
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
snapshot.metrics = [
|
||||
Metric("pitch", "Längsneigung", pitch, unit: "°", precision: 1, primary: true),
|
||||
Metric("roll", "Querneigung", roll, unit: "°", precision: 1),
|
||||
]
|
||||
snapshot.state = instruction
|
||||
if isKnownUncalibrated {
|
||||
snapshot.offReasons = ["Nicht kalibriert"]
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und
|
||||
/// funkt immer nur für das gerade gewählte.
|
||||
struct Profile: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
/// SF-Symbol für die Auswahl im Dashboard.
|
||||
var symbol: String
|
||||
|
||||
/// Spurweite in Metern – für die Berechnung der Auffahrkeile quer.
|
||||
var trackWidth: Double?
|
||||
/// Radstand in Metern – dasselbe längs.
|
||||
var wheelbase: Double?
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
symbol: String = "box.truck",
|
||||
trackWidth: Double? = nil,
|
||||
wheelbase: Double? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.symbol = symbol
|
||||
self.trackWidth = trackWidth
|
||||
self.wheelbase = wheelbase
|
||||
}
|
||||
|
||||
/// Profile aus der Zeit vor den Fahrzeugmassen haben noch keine.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
symbol = try container.decode(String.self, forKey: .symbol)
|
||||
trackWidth = try container.decodeIfPresent(Double.self, forKey: .trackWidth)
|
||||
wheelbase = try container.decodeIfPresent(Double.self, forKey: .wheelbase)
|
||||
}
|
||||
|
||||
/// Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
|
||||
/// werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt.
|
||||
static let defaultID = UUID(uuidString: "00000000-0000-0000-0000-00000000C001")!
|
||||
|
||||
static var initial: Profile {
|
||||
Profile(id: defaultID, name: "Mein Camper")
|
||||
}
|
||||
|
||||
/// Auswahl für die Profilbearbeitung.
|
||||
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft – ein nicht
|
||||
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
|
||||
static let symbols = [
|
||||
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
|
||||
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// Wie der Neigungsmesser im Fahrzeug sitzt.
|
||||
///
|
||||
/// Der Sensor kann quer eingebaut, gedreht oder kopfüber montiert sein. Dann
|
||||
/// stimmen seine Achsen nicht mit denen des Fahrzeugs überein: was er als
|
||||
/// Längsneigung meldet, ist womöglich die Querneigung, und Vorzeichen können
|
||||
/// vertauscht sein.
|
||||
///
|
||||
/// Statt das raten zu lassen, ermittelt der Einrichtungsassistent die Lage
|
||||
/// durch zwei definierte Kippbewegungen.
|
||||
struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
|
||||
|
||||
/// Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert.
|
||||
enum Source: String, Codable, Hashable, Sendable {
|
||||
case pitch
|
||||
case roll
|
||||
}
|
||||
|
||||
/// Woher die Längsneigung kommt. Die Querneigung kommt aus der jeweils
|
||||
/// anderen Achse.
|
||||
var longitudinalSource: Source = .pitch
|
||||
var invertLongitudinal = false
|
||||
var invertLateral = false
|
||||
|
||||
static let identity = SensorOrientation()
|
||||
|
||||
var isIdentity: Bool { self == .identity }
|
||||
|
||||
/// Rechnet Sensorwerte in Fahrzeugwerte um.
|
||||
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
|
||||
let longitudinal = longitudinalSource == .pitch ? pitch : roll
|
||||
let lateral = longitudinalSource == .pitch ? roll : pitch
|
||||
return (longitudinal.map { invertLongitudinal ? -$0 : $0 },
|
||||
lateral.map { invertLateral ? -$0 : $0 })
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
if isIdentity { return "Achsen unverändert" }
|
||||
var parts: [String] = []
|
||||
if longitudinalSource == .roll { parts.append("Achsen getauscht") }
|
||||
if invertLongitudinal { parts.append("längs umgekehrt") }
|
||||
if invertLateral { parts.append("quer umgekehrt") }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Wertet die Kippbewegungen des Einrichtungsassistenten aus.
|
||||
///
|
||||
/// Zweimal wird gekippt: einmal die Front nach unten, einmal die linke Seite
|
||||
/// nach unten. Welche Sensorachse sich dabei jeweils bewegt und in welche
|
||||
/// Richtung, ergibt die Einbaulage.
|
||||
enum OrientationDetection {
|
||||
|
||||
/// So weit muss gekippt werden, damit die Bewegung eindeutig ist.
|
||||
/// Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde.
|
||||
static let minimumTilt = 5.0
|
||||
|
||||
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
|
||||
static let ambiguityMargin = 1.3
|
||||
|
||||
struct Reading: Equatable {
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
|
||||
static func - (lhs: Reading, rhs: Reading) -> Reading {
|
||||
Reading(pitch: lhs.pitch - rhs.pitch, roll: lhs.roll - rhs.roll)
|
||||
}
|
||||
}
|
||||
|
||||
enum Failure: Error, Equatable {
|
||||
/// Es wurde zu wenig oder gar nicht gekippt.
|
||||
case tooLittleMovement
|
||||
/// Beide Achsen haben sich ähnlich stark bewegt – dann lässt sich
|
||||
/// nicht sagen, welche gemeint war.
|
||||
case ambiguous
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .tooLittleMovement:
|
||||
return "Zu wenig Bewegung. Deutlicher kippen, mindestens eine "
|
||||
+ "Handbreit."
|
||||
case .ambiguous:
|
||||
return "Beide Schritte haben dieselbe Achse bewegt. Im ersten "
|
||||
+ "Schritt nach vorne kippen, im zweiten zur Seite."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt aus beiden Kippbewegungen die Einbaulage.
|
||||
///
|
||||
/// Beide Bewegungen werden gemeinsam beurteilt. Kippt man von Hand zur
|
||||
/// Seite, geht die andere Achse fast immer ein Stück mit – jede Bewegung
|
||||
/// für sich betrachtet sähe das nach schrägem Kippen aus. Im Paar ist die
|
||||
/// Zuordnung trotzdem eindeutig: es gewinnt die Deutung, die beide
|
||||
/// Bewegungen zusammen am besten erklärt.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - nose: Änderung beim Kippen der Front nach unten.
|
||||
/// - side: Änderung beim Kippen der linken Seite nach unten.
|
||||
static func orientation(nose: Reading,
|
||||
side: Reading) -> Result<SensorOrientation, Failure> {
|
||||
guard max(abs(nose.pitch), abs(nose.roll)) >= minimumTilt,
|
||||
max(abs(side.pitch), abs(side.roll)) >= minimumTilt else {
|
||||
return .failure(.tooLittleMovement)
|
||||
}
|
||||
|
||||
// Zwei mögliche Deutungen, bewertet danach, wieviel Bewegung sie
|
||||
// erklären: die erste Achse längs und die zweite quer – oder umgekehrt.
|
||||
let pitchIsLongitudinal = abs(nose.pitch) + abs(side.roll)
|
||||
let rollIsLongitudinal = abs(nose.roll) + abs(side.pitch)
|
||||
let winner = max(pitchIsLongitudinal, rollIsLongitudinal)
|
||||
let loser = min(pitchIsLongitudinal, rollIsLongitudinal)
|
||||
// Liegen beide Deutungen dicht beieinander, ist die Zuordnung wirklich
|
||||
// nicht zu treffen – etwa wenn zweimal um dieselbe Achse gekippt wurde.
|
||||
guard winner >= ambiguityMargin * loser else { return .failure(.ambiguous) }
|
||||
|
||||
var orientation = SensorOrientation()
|
||||
orientation.longitudinalSource = pitchIsLongitudinal > rollIsLongitudinal ? .pitch : .roll
|
||||
let longitudinal = orientation.longitudinalSource == .pitch ? nose.pitch : nose.roll
|
||||
let lateral = orientation.longitudinalSource == .pitch ? side.roll : side.pitch
|
||||
// Die zugeordnete Achse muss in ihrem Schritt auch deutlich gewandert
|
||||
// sein, sonst stünde das Vorzeichen auf wackligem Grund.
|
||||
guard abs(longitudinal) >= minimumTilt, abs(lateral) >= minimumTilt else {
|
||||
return .failure(.tooLittleMovement)
|
||||
}
|
||||
// Front nach unten heisst: das Heck steht höher, die Längsneigung des
|
||||
// Fahrzeugs ist also positiv.
|
||||
orientation.invertLongitudinal = longitudinal < 0
|
||||
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
|
||||
// ist positiv.
|
||||
orientation.invertLateral = lateral < 0
|
||||
return .success(orientation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
/// Klartexte für die Zustands- und Fehlercodes aus den Victron-Werbedaten.
|
||||
enum VictronCodes {
|
||||
|
||||
/// VE.Reg 0x0201 – Betriebszustand des Laders.
|
||||
static func deviceState(_ code: UInt32?) -> String? {
|
||||
guard let code else { return nil }
|
||||
switch code {
|
||||
case 0: return "Aus"
|
||||
case 1: return "Stromsparmodus"
|
||||
case 2: return "Störung"
|
||||
case 3: return "Konstantstrom (Bulk)"
|
||||
case 4: return "Konstantspannung (Absorption)"
|
||||
case 5: return "Erhaltung (Float)"
|
||||
case 6: return "Lagerung"
|
||||
case 7: return "Ausgleichsladung"
|
||||
case 9: return "Wechselrichten"
|
||||
case 11: return "Netzteilbetrieb"
|
||||
case 245: return "Startet"
|
||||
case 246: return "Wiederholte Absorption"
|
||||
case 247: return "Auto-Ausgleich"
|
||||
case 248: return "Battery Safe"
|
||||
case 252: return "Externe Steuerung"
|
||||
default: return "Zustand \(code)"
|
||||
}
|
||||
}
|
||||
|
||||
/// VE.Reg 0xEDDA – Ladefehler. 0 bedeutet "kein Fehler".
|
||||
static func chargerError(_ code: UInt32?) -> String? {
|
||||
guard let code, code != 0 else { return nil }
|
||||
switch code {
|
||||
case 1: return "Batterietemperatur zu hoch"
|
||||
case 2: return "Batteriespannung zu hoch"
|
||||
case 3: return "Temperatursensor defekt"
|
||||
case 4: return "Temperatursensor Kurzschluss"
|
||||
case 5: return "Temperatursensor unplausibel"
|
||||
case 6: return "Spannungsmessung defekt"
|
||||
case 7: return "Spannungsmessung Kurzschluss"
|
||||
case 8: return "Spannungsmessung unplausibel"
|
||||
case 11: return "Zu hohe Restwelligkeit"
|
||||
case 14: return "Batterietemperatur zu niedrig"
|
||||
case 17: return "Lader überhitzt"
|
||||
case 18: return "Lader Überstrom"
|
||||
case 19: return "Stromrichtung verkehrt"
|
||||
case 20: return "Bulk-Zeit überschritten"
|
||||
case 21: return "Stromsensor defekt"
|
||||
case 22: return "Interner Temperatursensor defekt"
|
||||
case 26: return "Anschlussklemme überhitzt"
|
||||
case 27: return "Kurzschluss im Lader"
|
||||
case 28: return "Endstufenfehler"
|
||||
case 29: return "Überladeschutz"
|
||||
case 33: return "Eingangsspannung zu hoch (PV)"
|
||||
case 34: return "Eingangsstrom zu hoch (PV)"
|
||||
case 38: return "Eingang abgeschaltet (Batteriespannung)"
|
||||
case 39: return "Eingang abgeschaltet (Stromfluss)"
|
||||
case 65: return "Kommunikation verloren"
|
||||
case 66: return "Konfiguration synchronisierter Lader fehlerhaft"
|
||||
case 67: return "BMS-Verbindung verloren"
|
||||
case 68: return "Netzwerk fehlkonfiguriert"
|
||||
case 116: return "Kalibrierdaten verloren"
|
||||
case 117: return "Inkompatible Firmware"
|
||||
case 119: return "Einstellungen ungültig"
|
||||
default: return "Fehler \(code)"
|
||||
}
|
||||
}
|
||||
|
||||
/// VE.Reg 0x0207 – Bitmaske, warum das Gerät gerade nicht arbeitet.
|
||||
static func offReasons(_ mask: UInt32?) -> [String] {
|
||||
guard let mask, mask != 0 else { return [] }
|
||||
let table: [(UInt32, String)] = [
|
||||
(0x0000_0001, "Keine Eingangsspannung"),
|
||||
(0x0000_0002, "Per Schalter ausgeschaltet"),
|
||||
(0x0000_0004, "Per Einstellung ausgeschaltet"),
|
||||
(0x0000_0008, "Remote-Eingang"),
|
||||
(0x0000_0010, "Schutzfunktion aktiv"),
|
||||
(0x0000_0020, "Paygo"),
|
||||
(0x0000_0040, "BMS"),
|
||||
(0x0000_0080, "Motor-Abschalterkennung"),
|
||||
(0x0000_0100, "Eingangsspannung wird geprüft"),
|
||||
]
|
||||
return table.filter { mask & $0.0 != 0 }.map(\.1)
|
||||
}
|
||||
|
||||
/// VE.Reg 0xEEB8 – Alarmgründe des Batteriewächters.
|
||||
static func alarmReasons(_ mask: UInt32?) -> [String] {
|
||||
guard let mask, mask != 0 else { return [] }
|
||||
let table: [(UInt32, String)] = [
|
||||
(0x0001, "Unterspannung"),
|
||||
(0x0002, "Überspannung"),
|
||||
(0x0004, "Niedriger Ladezustand"),
|
||||
(0x0008, "Starterbatterie Unterspannung"),
|
||||
(0x0010, "Starterbatterie Überspannung"),
|
||||
(0x0020, "Temperatur zu niedrig"),
|
||||
(0x0040, "Temperatur zu hoch"),
|
||||
(0x0080, "Mittenspannung"),
|
||||
(0x0100, "Ladung überfällig"),
|
||||
]
|
||||
return table.filter { mask & $0.0 != 0 }.map(\.1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
|
||||
/// Was zwischen iPhone und Uhr hin- und hergeht.
|
||||
///
|
||||
/// Die Uhr funkt **nicht** selbst zu den Geräten im Fahrzeug, obwohl watchOS
|
||||
/// das könnte. Zwei Gründe sprechen dagegen, und beide sind hart:
|
||||
///
|
||||
/// * BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu.
|
||||
/// Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie
|
||||
/// zu ergänzen.
|
||||
/// * Die Victron-Schlüssel liegen in der Keychain des iPhones. Die Uhr hat ihre
|
||||
/// eigene; die Schlüssel dorthin zu kopieren hiesse, sie ein zweites Mal
|
||||
/// aufzubewahren, ohne dass ein zweiter Aufbewahrungsort etwas brächte.
|
||||
///
|
||||
/// Das iPhone bleibt also das Funkgerät und schickt fertige Messwerte weiter,
|
||||
/// die Uhr ist Anzeige und Fernbedienung.
|
||||
enum WatchLink {
|
||||
|
||||
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
|
||||
/// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind
|
||||
/// damit die einzige Stelle, an der das Format steht.
|
||||
static let payloadKey = "payload"
|
||||
static let commandKey = "command"
|
||||
|
||||
/// Wie lange ein „ich schaue gerade hin“ der Uhr gilt, ohne erneuert zu
|
||||
/// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den
|
||||
/// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu
|
||||
/// senden.
|
||||
static let liveLease: TimeInterval = 12
|
||||
|
||||
/// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je
|
||||
/// Sekunde abgefragt – schneller zu senden brächte nichts.
|
||||
static let liveInterval: TimeInterval = 0.5
|
||||
|
||||
/// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann
|
||||
/// noch zustellt, wenn die App auf der Uhr gerade nicht läuft.
|
||||
static let idleInterval: TimeInterval = 2
|
||||
}
|
||||
|
||||
/// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt.
|
||||
struct WatchPayload: Codable, Equatable, Sendable {
|
||||
/// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt
|
||||
/// sonst Werte, die sie falsch versteht – lieber sagt sie „App aktualisieren“.
|
||||
static let currentVersion = 1
|
||||
|
||||
var version = WatchPayload.currentVersion
|
||||
var generatedAt: Date
|
||||
/// Das am iPhone gewählte Fahrzeug.
|
||||
var profile: Profile
|
||||
/// Ob das iPhone gerade überhaupt funken kann.
|
||||
var isRadioReady: Bool
|
||||
/// Klartext dazu, falls nicht.
|
||||
var radioStatus: String
|
||||
var devices: [WatchDevice]
|
||||
|
||||
/// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein
|
||||
/// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen
|
||||
/// Platz – sonst rutschte er beim Verbindungsabriss in die Geräteliste.
|
||||
var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } }
|
||||
var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } }
|
||||
|
||||
/// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei
|
||||
/// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund
|
||||
/// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht.
|
||||
func hasSameContent(as other: WatchPayload) -> Bool {
|
||||
var mine = self, theirs = other
|
||||
mine.generatedAt = .distantPast
|
||||
theirs.generatedAt = .distantPast
|
||||
return mine == theirs
|
||||
}
|
||||
|
||||
/// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt:
|
||||
/// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum
|
||||
/// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug.
|
||||
func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) }
|
||||
}
|
||||
|
||||
/// Ein Gerät mitsamt seinem letzten Messwertsatz.
|
||||
struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var role: DeviceRole
|
||||
var link: DeviceLinkState
|
||||
var snapshot: DeviceSnapshot?
|
||||
/// Nur beim Neigungsmesser belegt.
|
||||
var level: LevelState?
|
||||
/// Nur bei der Kühlbox belegt.
|
||||
var fridge: WatchFridge?
|
||||
|
||||
var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) }
|
||||
}
|
||||
|
||||
/// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht.
|
||||
///
|
||||
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
|
||||
/// drin, den auf die Uhr zu schicken nichts brächte.
|
||||
struct WatchFridge: Codable, Equatable, Sendable {
|
||||
var isPoweredOn: Bool
|
||||
var isEco: Bool
|
||||
var isLocked: Bool
|
||||
var isDualZone: Bool
|
||||
var unitSymbol: String
|
||||
var leftTarget: Int?
|
||||
var leftCurrent: Int?
|
||||
var rightTarget: Int?
|
||||
var rightCurrent: Int?
|
||||
var minTarget: Int
|
||||
var maxTarget: Int
|
||||
var batteryVolts: Double?
|
||||
|
||||
var targetRange: ClosedRange<Int> {
|
||||
minTarget < maxTarget ? minTarget...maxTarget : -30...20
|
||||
}
|
||||
}
|
||||
|
||||
/// Was die Uhr das iPhone tun lässt.
|
||||
///
|
||||
/// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt
|
||||
/// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus
|
||||
/// dem nächsten Datensatz – bestätigt wird also das Gerät, nicht der Tastendruck.
|
||||
enum WatchCommand: Codable, Equatable, Sendable {
|
||||
/// Die Uhr schaut gerade hin und möchte den schnellen Takt.
|
||||
case hello(live: Bool)
|
||||
case fridgePower(device: UUID, on: Bool)
|
||||
case fridgeEco(device: UUID, eco: Bool)
|
||||
case fridgeLock(device: UUID, locked: Bool)
|
||||
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
|
||||
case calibrateLevel(device: UUID)
|
||||
|
||||
/// Ob der Befehl den schnellen Sendetakt verlängert.
|
||||
var wantsLiveUpdates: Bool {
|
||||
if case .hello(let live) = self { return live }
|
||||
// Wer stellt, schaut auch hin.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen.
|
||||
enum WatchFridgeZone: String, Codable, Equatable, Sendable {
|
||||
case left, right
|
||||
}
|
||||
|
||||
// MARK: - Verpacken
|
||||
|
||||
extension WatchPayload {
|
||||
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
|
||||
|
||||
/// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue
|
||||
/// Datensätze ergeben nil statt eines halb gefüllten Zustands.
|
||||
static func decode(from message: [String: Any]) -> WatchPayload? {
|
||||
guard let data = message[WatchLink.payloadKey] as? Data,
|
||||
let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data),
|
||||
payload.version == WatchPayload.currentVersion else { return nil }
|
||||
return payload
|
||||
}
|
||||
|
||||
func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] }
|
||||
}
|
||||
|
||||
extension WatchCommand {
|
||||
func message() throws -> [String: Any] {
|
||||
[WatchLink.commandKey: try WatchCoder.encoder.encode(self)]
|
||||
}
|
||||
|
||||
static func decode(from message: [String: Any]) -> WatchCommand? {
|
||||
guard let data = message[WatchLink.commandKey] as? Data else { return nil }
|
||||
return try? WatchCoder.decoder.decode(WatchCommand.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der
|
||||
/// einen Seite anders geschrieben als auf der anderen gelesen.
|
||||
enum WatchCoder {
|
||||
static let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
return encoder
|
||||
}()
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user