forked from fritob/Camper-Monitor
Kühlbox wieder bedienbar, Einbaulage richtig messen
Zwei Fehler aus dem letzten Stand, beide von mir eingebaut. Die Kühlbox liess sich nicht mehr bedienen. Die Wartesperre gegen den Verbindungssturm hat nicht unterschieden, warum eine Verbindung endete: ein Abbruch einer Verbindung, die stand, wurde genauso lange gesperrt wie ein Fehlschlag. Im Fahrzeug ist so ein Abbruch der Normalfall, und die Box war danach bis zu einer Minute nicht erreichbar. Ein Tippen ging in dieser Zeit lautlos verloren, weil es ohne Sitzung ins Leere fiel. Jetzt zählt die Dauer: was mindestens drei Sekunden stand, gilt als zustandegekommen und wird nach zwei Sekunden wieder aufgenommen. Nur was sofort wieder abbricht, zählt als Fehlschlag und wird gestaffelt gesperrt - gegen das Wechselspiel, wenn eine andere App das Gerät belegt. Dazu gehen Stellbefehle nicht mehr verloren. Fehlt die Verbindung, wird der Befehl aufgehoben, die Sperre für dieses Gerät aufgehoben und sofort ein Versuch angestossen; sobald der Kanal steht, geht er raus. Ein Befehl, der älter als eine halbe Minute ist, verfällt - ein verspätetes Ausschalten würde nur überraschen. Die Ansicht sagt es, statt den Schalter stumm zurückspringen zu lassen. Zweitens der Assistent für die Einbaulage. Er hat die zweite Bewegung von der gekippten Lage aus gemessen statt von der Ruhelage. Damit steckte in jeder zweiten Messung das Zurückkippen aus der ersten, und es schlugen immer beide Achsen aus. Die Ruhelage ist jetzt der Bezug für beide Bewegungen, und ein eigener Schritt dazwischen nimmt sie neu auf. Auch die Auswertung war zu streng: sie hat jede Bewegung für sich beurteilt und abgelehnt, sobald die andere Achse mitlief. Von Hand gekippt läuft sie aber immer mit. Beurteilt werden jetzt beide Bewegungen gemeinsam - es gewinnt die Zuordnung, die zusammen am meisten erklärt. Zweimal um dieselbe Achse gekippt wird weiterhin erkannt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,12 @@ final class BMSSession: NSObject {
|
|||||||
private var lastResponse: Data?
|
private var lastResponse: Data?
|
||||||
private var lastCommand: Data?
|
private var lastCommand: Data?
|
||||||
private var lastCommandAt: Date?
|
private var lastCommandAt: Date?
|
||||||
|
/// Stellbefehle, die kamen, bevor der Kanal stand. Sie jetzt schon zu
|
||||||
|
/// senden hiesse, sie an einen womöglich falschen Kandidaten zu schicken;
|
||||||
|
/// sie fallen zu lassen hiesse, ein Tippen zu verschlucken.
|
||||||
|
private var waitingControls: [(packet: Data, queuedAt: Date)] = []
|
||||||
|
/// So lange darf ein Befehl warten, bevor er verfällt.
|
||||||
|
private let controlLifetime: TimeInterval = 30
|
||||||
private var receivedByteCount = 0
|
private var receivedByteCount = 0
|
||||||
private var sentFrameCount = 0
|
private var sentFrameCount = 0
|
||||||
private var gattSummary: [String] = []
|
private var gattSummary: [String] = []
|
||||||
@@ -291,6 +297,7 @@ final class BMSSession: NSObject {
|
|||||||
|
|
||||||
private func beginPolling() {
|
private func beginPolling() {
|
||||||
pollTimer?.cancel()
|
pollTimer?.cancel()
|
||||||
|
flushWaitingControls()
|
||||||
poll()
|
poll()
|
||||||
let interval = dialect == .unknown ? searchInterval : pollInterval
|
let interval = dialect == .unknown ? searchInterval : pollInterval
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
@@ -419,6 +426,18 @@ final class BMSSession: NSObject {
|
|||||||
func sendControl(_ packet: Data) {
|
func sendControl(_ packet: Data) {
|
||||||
lastCommand = packet
|
lastCommand = packet
|
||||||
lastCommandAt = Date()
|
lastCommandAt = Date()
|
||||||
|
|
||||||
|
// Erst wenn der Dialekt steht, ist auch der richtige Kanal bekannt.
|
||||||
|
guard dialect == .alpicool, currentEndpoint != nil,
|
||||||
|
peripheral.state == .connected else {
|
||||||
|
waitingControls.append((packet, Date()))
|
||||||
|
if waitingControls.count > 4 {
|
||||||
|
waitingControls.removeFirst(waitingControls.count - 4)
|
||||||
|
}
|
||||||
|
publishDiagnostics()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
send(packet)
|
send(packet)
|
||||||
// Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist.
|
// Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist.
|
||||||
queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||||
@@ -427,6 +446,20 @@ final class BMSSession: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Schickt raus, was während des Verbindungsaufbaus aufgelaufen ist.
|
||||||
|
private func flushWaitingControls() {
|
||||||
|
guard dialect == .alpicool, !waitingControls.isEmpty else { return }
|
||||||
|
let due = waitingControls.filter {
|
||||||
|
Date().timeIntervalSince($0.queuedAt) < controlLifetime
|
||||||
|
}
|
||||||
|
waitingControls.removeAll()
|
||||||
|
for (index, entry) in due.enumerated() {
|
||||||
|
queue.asyncAfter(deadline: .now() + Double(index) * 0.4) { [weak self] in
|
||||||
|
self?.sendControl(entry.packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Auswertung
|
// MARK: - Auswertung
|
||||||
private func consume(_ data: Data) {
|
private func consume(_ data: Data) {
|
||||||
lastResponse = data
|
lastResponse = data
|
||||||
|
|||||||
@@ -182,6 +182,24 @@ final class BluetoothManager: NSObject {
|
|||||||
private var connectFailures: [UUID: Int] = [:]
|
private var connectFailures: [UUID: Int] = [:]
|
||||||
private let firstRetryDelay: TimeInterval = 5
|
private let firstRetryDelay: TimeInterval = 5
|
||||||
private let longestRetryDelay: TimeInterval = 60
|
private let longestRetryDelay: TimeInterval = 60
|
||||||
|
/// Nach dem Abbruch einer Verbindung, die stand, wird zügig nachgefasst -
|
||||||
|
/// das ist der Normalfall im Fahrzeug und kein Fehler.
|
||||||
|
private let quickRetryDelay: TimeInterval = 2
|
||||||
|
/// Ab dieser Dauer gilt eine Verbindung als zustandegekommen. Was sofort
|
||||||
|
/// wieder abbricht, zählt als Fehlschlag.
|
||||||
|
private let stableConnection: TimeInterval = 3
|
||||||
|
private var connectedSince: [UUID: Date] = [:]
|
||||||
|
|
||||||
|
/// Stellbefehle, die kamen, während die Verbindung weg war. Ohne das
|
||||||
|
/// verschwindet ein Tippen lautlos: die Sitzung ist dann nicht da, und der
|
||||||
|
/// Befehl fällt ins Leere.
|
||||||
|
private var pendingControls: [UUID: [(packet: Data, queuedAt: Date)]] = [:]
|
||||||
|
/// So lange darf ein aufgehobener Befehl warten. Was älter ist, wird
|
||||||
|
/// verworfen - ein verspätetes Ausschalten würde nur überraschen.
|
||||||
|
private let controlLifetime: TimeInterval = 30
|
||||||
|
/// Letzter bekannter Kühlbox-Zustand, auch über eine Trennung hinweg. Der
|
||||||
|
/// Einstellungsblock wird daraus gebaut.
|
||||||
|
private var fridgeStateCache: [UUID: AlpicoolState] = [:]
|
||||||
private var discoveryFlushTimer: DispatchSourceTimer?
|
private var discoveryFlushTimer: DispatchSourceTimer?
|
||||||
private var discovering = false
|
private var discovering = false
|
||||||
|
|
||||||
@@ -293,10 +311,7 @@ final class BluetoothManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) {
|
func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) {
|
||||||
queue.async {
|
sendFridgeSettings(for: deviceID) { _ in AlpicoolState.setTarget(zone: zone, to: celsius) }
|
||||||
self.session(for: deviceID)?
|
|
||||||
.sendControl(AlpicoolState.setTarget(zone: zone, to: celsius))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setFridgePower(_ on: Bool, for deviceID: UUID) {
|
func setFridgePower(_ on: Bool, for deviceID: UUID) {
|
||||||
@@ -313,12 +328,44 @@ final class BluetoothManager: NSObject {
|
|||||||
|
|
||||||
/// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut –
|
/// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut –
|
||||||
/// und zwar auf der Funk-Queue, wo dieser Zustand lebt.
|
/// und zwar auf der Funk-Queue, wo dieser Zustand lebt.
|
||||||
|
///
|
||||||
|
/// Fehlt die Verbindung gerade, wird der Befehl aufgehoben und ein Versuch
|
||||||
|
/// angestossen. Ein Tippen darf nicht daran scheitern, dass die Box sich
|
||||||
|
/// zwei Sekunden vorher abgemeldet hat.
|
||||||
private func sendFridgeSettings(for deviceID: UUID,
|
private func sendFridgeSettings(for deviceID: UUID,
|
||||||
build: @escaping (AlpicoolState) -> Data?) {
|
build: @escaping (AlpicoolState) -> Data?) {
|
||||||
queue.async {
|
queue.async {
|
||||||
guard let session = self.session(for: deviceID),
|
guard let device = self.managed.values.first(where: { $0.id == deviceID })
|
||||||
let packet = build(session.alpicoolState) else { return }
|
else { return }
|
||||||
session.sendControl(packet)
|
|
||||||
|
if let session = self.bmsSessions[device.peripheralID] {
|
||||||
|
if let packet = build(session.alpicoolState) { session.sendControl(packet) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let known = self.fridgeStateCache[device.peripheralID],
|
||||||
|
let packet = build(known) else { return }
|
||||||
|
var waiting = self.pendingControls[device.peripheralID] ?? []
|
||||||
|
waiting.append((packet, Date()))
|
||||||
|
// Mehr als eine Handvoll ergibt keinen Sinn – wer mehrfach tippt,
|
||||||
|
// meint den letzten Stand.
|
||||||
|
if waiting.count > 4 { waiting.removeFirst(waiting.count - 4) }
|
||||||
|
self.pendingControls[device.peripheralID] = waiting
|
||||||
|
|
||||||
|
// Der Wunsch des Benutzers hebt die Wartesperre auf.
|
||||||
|
self.clearBackOff(device.peripheralID)
|
||||||
|
self.connectIfNeeded(device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gibt weiter, was während der Trennung aufgelaufen ist.
|
||||||
|
private func flushPendingControls(_ peripheralID: UUID, to session: BMSSession) {
|
||||||
|
guard let waiting = pendingControls.removeValue(forKey: peripheralID) else { return }
|
||||||
|
let fresh = waiting.filter { Date().timeIntervalSince($0.queuedAt) < controlLifetime }
|
||||||
|
for (index, entry) in fresh.enumerated() {
|
||||||
|
queue.asyncAfter(deadline: .now() + Double(index) * 0.4) { [weak session] in
|
||||||
|
session?.sendControl(entry.packet)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +424,7 @@ final class BluetoothManager: NSObject {
|
|||||||
lastDiagnosticsUpdate.removeAll()
|
lastDiagnosticsUpdate.removeAll()
|
||||||
nextConnectAttempt.removeAll()
|
nextConnectAttempt.removeAll()
|
||||||
connectFailures.removeAll()
|
connectFailures.removeAll()
|
||||||
|
connectedSince.removeAll()
|
||||||
|
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
@@ -416,6 +464,8 @@ final class BluetoothManager: NSObject {
|
|||||||
bmsSessions.removeAll()
|
bmsSessions.removeAll()
|
||||||
levelSessions.removeAll()
|
levelSessions.removeAll()
|
||||||
connectedPeripherals.removeAll()
|
connectedPeripherals.removeAll()
|
||||||
|
connectedSince.removeAll()
|
||||||
|
pendingControls.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restartScan() {
|
private func restartScan() {
|
||||||
@@ -668,7 +718,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
guard let device = managed[peripheral.identifier] else { return }
|
guard let device = managed[peripheral.identifier] else { return }
|
||||||
connectedPeripherals[peripheral.identifier] = peripheral
|
connectedPeripherals[peripheral.identifier] = peripheral
|
||||||
clearBackOff(peripheral.identifier)
|
connectedSince[peripheral.identifier] = Date()
|
||||||
|
|
||||||
if device.role == .leveling {
|
if device.role == .leveling {
|
||||||
let session = LevelSession(
|
let session = LevelSession(
|
||||||
@@ -702,11 +752,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
session.onFridgeState = { [weak self] state in
|
session.onFridgeState = { [weak self] state in
|
||||||
|
self?.fridgeStateCache[peripheral.identifier] = state
|
||||||
self?.publish { self?.fridgeStates[device.id] = state }
|
self?.publish { self?.fridgeStates[device.id] = state }
|
||||||
}
|
}
|
||||||
session.fridgeZoneMode = device.fridgeZoneMode
|
session.fridgeZoneMode = device.fridgeZoneMode
|
||||||
bmsSessions[peripheral.identifier] = session
|
bmsSessions[peripheral.identifier] = session
|
||||||
session.start()
|
session.start()
|
||||||
|
flushPendingControls(peripheral.identifier, to: session)
|
||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager,
|
func centralManager(_ central: CBCentralManager,
|
||||||
@@ -729,12 +781,24 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
levelSessions[peripheral.identifier] = nil
|
levelSessions[peripheral.identifier] = nil
|
||||||
connectedPeripherals[peripheral.identifier] = nil
|
connectedPeripherals[peripheral.identifier] = nil
|
||||||
|
|
||||||
|
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
||||||
|
.map { Date().timeIntervalSince($0) } ?? 0
|
||||||
|
|
||||||
if let device = managed[peripheral.identifier] {
|
if let device = managed[peripheral.identifier] {
|
||||||
publish { self.linkStates[device.id] = .searching }
|
publish { self.linkStates[device.id] = .searching }
|
||||||
// Nicht sofort wieder anklopfen. Trennt ein Gerät von sich aus -
|
if lifetime >= stableConnection {
|
||||||
// etwa weil eine andere App verbunden ist -, entstünde sonst ein
|
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||||||
// Wechselspiel aus Verbinden und Trennen.
|
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
|
||||||
|
// Gerät minutenlang nicht bedienbar.
|
||||||
|
clearBackOff(peripheral.identifier)
|
||||||
|
nextConnectAttempt[peripheral.identifier] =
|
||||||
|
Date().addingTimeInterval(quickRetryDelay)
|
||||||
|
} else {
|
||||||
|
// Sofort wieder abgebrochen: das zählt wie ein Fehlschlag. So
|
||||||
|
// entsteht kein Wechselspiel aus Verbinden und Trennen, wenn
|
||||||
|
// etwa eine andere App das Gerät belegt.
|
||||||
backOff(peripheral.identifier)
|
backOff(peripheral.identifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ enum OrientationDetection {
|
|||||||
/// Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde.
|
/// Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde.
|
||||||
static let minimumTilt = 5.0
|
static let minimumTilt = 5.0
|
||||||
|
|
||||||
|
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
|
||||||
|
static let ambiguityMargin = 1.3
|
||||||
|
|
||||||
struct Reading: Equatable {
|
struct Reading: Equatable {
|
||||||
let pitch: Double
|
let pitch: Double
|
||||||
let roll: Double
|
let roll: Double
|
||||||
@@ -78,51 +81,55 @@ enum OrientationDetection {
|
|||||||
return "Zu wenig Bewegung. Deutlicher kippen, mindestens eine "
|
return "Zu wenig Bewegung. Deutlicher kippen, mindestens eine "
|
||||||
+ "Handbreit."
|
+ "Handbreit."
|
||||||
case .ambiguous:
|
case .ambiguous:
|
||||||
return "Es wurde in beide Richtungen gleichzeitig gekippt. "
|
return "Beide Schritte haben dieselbe Achse bewegt. Im ersten "
|
||||||
+ "Möglichst nur um die eine Achse kippen."
|
+ "Schritt nach vorne kippen, im zweiten zur Seite."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ermittelt aus beiden Kippbewegungen die Einbaulage.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - nose: Änderung beim Kippen der Front nach unten.
|
/// - nose: Änderung beim Kippen der Front nach unten.
|
||||||
/// - side: Änderung beim Kippen der linken Seite nach unten.
|
/// - side: Änderung beim Kippen der linken Seite nach unten.
|
||||||
static func orientation(nose: Reading,
|
static func orientation(nose: Reading,
|
||||||
side: Reading) -> Result<SensorOrientation, Failure> {
|
side: Reading) -> Result<SensorOrientation, Failure> {
|
||||||
// Welche Achse reagiert auf das Kippen nach vorne?
|
guard max(abs(nose.pitch), abs(nose.roll)) >= minimumTilt,
|
||||||
guard let longitudinal = dominantAxis(nose) else {
|
max(abs(side.pitch), abs(side.roll)) >= minimumTilt else {
|
||||||
return .failure(max(abs(nose.pitch), abs(nose.roll)) < minimumTilt
|
return .failure(.tooLittleMovement)
|
||||||
? .tooLittleMovement : .ambiguous)
|
|
||||||
}
|
}
|
||||||
guard let lateral = dominantAxis(side) else {
|
|
||||||
return .failure(max(abs(side.pitch), abs(side.roll)) < minimumTilt
|
// Zwei mögliche Deutungen, bewertet danach, wieviel Bewegung sie
|
||||||
? .tooLittleMovement : .ambiguous)
|
// erklären: die erste Achse längs und die zweite quer – oder umgekehrt.
|
||||||
}
|
let pitchIsLongitudinal = abs(nose.pitch) + abs(side.roll)
|
||||||
// Beide Bewegungen dürfen nicht auf dieselbe Achse zeigen – dann wurde
|
let rollIsLongitudinal = abs(nose.roll) + abs(side.pitch)
|
||||||
// zweimal dasselbe gekippt.
|
let winner = max(pitchIsLongitudinal, rollIsLongitudinal)
|
||||||
guard longitudinal.source != lateral.source else { return .failure(.ambiguous) }
|
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()
|
var orientation = SensorOrientation()
|
||||||
orientation.longitudinalSource = longitudinal.source
|
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
|
// Front nach unten heisst: das Heck steht höher, die Längsneigung des
|
||||||
// Fahrzeugs ist also positiv.
|
// Fahrzeugs ist also positiv.
|
||||||
orientation.invertLongitudinal = longitudinal.change < 0
|
orientation.invertLongitudinal = longitudinal < 0
|
||||||
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
|
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
|
||||||
// ist positiv.
|
// ist positiv.
|
||||||
orientation.invertLateral = lateral.change < 0
|
orientation.invertLateral = lateral < 0
|
||||||
return .success(orientation)
|
return .success(orientation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Achse, die sich deutlich stärker bewegt hat.
|
|
||||||
private static func dominantAxis(_ change: Reading)
|
|
||||||
-> (source: SensorOrientation.Source, change: Double)? {
|
|
||||||
let byPitch = abs(change.pitch), byRoll = abs(change.roll)
|
|
||||||
guard max(byPitch, byRoll) >= minimumTilt else { return nil }
|
|
||||||
// Die schwächere Achse muss deutlich zurückstehen, sonst wurde schräg
|
|
||||||
// gekippt und die Zuordnung wäre Zufall.
|
|
||||||
guard max(byPitch, byRoll) >= 2 * min(byPitch, byRoll) else { return nil }
|
|
||||||
return byPitch > byRoll ? (.pitch, change.pitch) : (.roll, change.roll)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,24 @@ struct FridgeControls: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Steuerung")
|
Text("Steuerung")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Änderungen gehen direkt an die Box. Der angezeigte Stand kommt "
|
Text(footerText)
|
||||||
+ "aus ihrer Antwort, nicht aus der Eingabe.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob die Box gerade erreichbar ist. Ist sie es nicht, wird ein Befehl
|
||||||
|
/// aufgehoben statt verworfen – das gehört gesagt, sonst sieht es aus, als
|
||||||
|
/// hätte das Tippen nichts bewirkt.
|
||||||
|
private var isLinked: Bool { bluetooth.linkStates[device.id] == .live }
|
||||||
|
|
||||||
|
private var footerText: String {
|
||||||
|
guard isLinked else {
|
||||||
|
return "Die Box ist gerade nicht verbunden. Die Änderung wird "
|
||||||
|
+ "gemerkt und geht raus, sobald sie wieder erreichbar ist."
|
||||||
|
}
|
||||||
|
return "Änderungen gehen direkt an die Box. Der angezeigte Stand kommt "
|
||||||
|
+ "aus ihrer Antwort, nicht aus der Eingabe."
|
||||||
|
}
|
||||||
|
|
||||||
private func targetStepper(zone: AlpicoolState.Zone, title: String, value: Int?) -> some View {
|
private func targetStepper(zone: AlpicoolState.Zone, title: String, value: Int?) -> some View {
|
||||||
let range = state.targetRange
|
let range = state.targetRange
|
||||||
return Stepper(value: Binding(
|
return Stepper(value: Binding(
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ struct SensorSetupView: View {
|
|||||||
private enum Step {
|
private enum Step {
|
||||||
case intro
|
case intro
|
||||||
case tiltNose
|
case tiltNose
|
||||||
|
case settle
|
||||||
case tiltSide
|
case tiltSide
|
||||||
case done(SensorOrientation)
|
case done(SensorOrientation)
|
||||||
case failed(OrientationDetection.Failure)
|
case failed(OrientationDetection.Failure)
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var step: Step = .intro
|
@State private var step: Step = .intro
|
||||||
/// Ruhelage vor der jeweiligen Kippbewegung.
|
/// Ruhelage, auf die beide Kippbewegungen bezogen werden.
|
||||||
@State private var reference = OrientationDetection.Reading(pitch: 0, roll: 0)
|
@State private var reference = OrientationDetection.Reading(pitch: 0, roll: 0)
|
||||||
@State private var noseChange: OrientationDetection.Reading?
|
@State private var noseChange: OrientationDetection.Reading?
|
||||||
|
|
||||||
@@ -83,15 +84,29 @@ struct SensorSetupView: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(live == nil)
|
.disabled(live == nil)
|
||||||
|
|
||||||
|
case .settle:
|
||||||
|
card(icon: "equal.circle",
|
||||||
|
title: "Zurück in die Ruhelage",
|
||||||
|
text: "Stelle den Sensor wieder so hin wie am Anfang und lass "
|
||||||
|
+ "ihn kurz ruhen.\n\nVon hier aus wird die zweite Bewegung "
|
||||||
|
+ "gemessen.")
|
||||||
|
liveReadout
|
||||||
|
Button("Weiter") {
|
||||||
|
reference = live ?? reference
|
||||||
|
step = .tiltSide
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(live == nil)
|
||||||
|
|
||||||
case .tiltSide:
|
case .tiltSide:
|
||||||
card(icon: "arrow.down.left",
|
card(icon: "arrow.down.left",
|
||||||
title: "Schritt 2 von 2: nach links kippen",
|
title: "Schritt 2 von 2: nach links kippen",
|
||||||
text: "Zurück in die Ausgangslage, dann den Sensor so kippen, als "
|
text: "Kippe den Sensor jetzt so, als würde das Fahrzeug **nach "
|
||||||
+ "würde das Fahrzeug **nach links** hängen – die linke Seite "
|
+ "links** hängen – die linke Seite also nach unten.\n\n"
|
||||||
+ "also nach unten.\n\nWieder deutlich kippen und halten.")
|
+ "Wieder deutlich kippen und in dieser Lage halten.")
|
||||||
liveReadout
|
liveReadout
|
||||||
HStack {
|
HStack {
|
||||||
Button("Zurück") { step = .tiltNose }
|
Button("Zurück") { step = .settle }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
Button("Fertig") { finishSideStep() }
|
Button("Fertig") { finishSideStep() }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
@@ -161,13 +176,14 @@ struct SensorSetupView: View {
|
|||||||
private func finishNoseStep() {
|
private func finishNoseStep() {
|
||||||
guard let live else { return }
|
guard let live else { return }
|
||||||
noseChange = live - reference
|
noseChange = live - reference
|
||||||
reference = live
|
// Der Bezug bleibt die Ruhelage. Von der gekippten Lage aus zu messen
|
||||||
step = .tiltSide
|
// wäre falsch: die zweite Messung enthielte dann das Zurückkippen aus
|
||||||
|
// der ersten, und beide Achsen schlügen aus.
|
||||||
|
step = .settle
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishSideStep() {
|
private func finishSideStep() {
|
||||||
guard let live, let nose = noseChange else { return }
|
guard let live, let nose = noseChange else { return }
|
||||||
// Der Bezug ist die Lage aus Schritt 1, deshalb wird von dort gemessen.
|
|
||||||
let side = live - reference
|
let side = live - reference
|
||||||
switch OrientationDetection.orientation(nose: nose, side: side) {
|
switch OrientationDetection.orientation(nose: nose, side: side) {
|
||||||
case .success(let orientation): step = .done(orientation)
|
case .success(let orientation): step = .done(orientation)
|
||||||
|
|||||||
+20
-5
@@ -794,12 +794,27 @@ if case .failure(let failure) = detect(nose: Reading(pitch: 2, roll: 0),
|
|||||||
check("zu wenig Bewegung wird abgefangen", false)
|
check("zu wenig Bewegung wird abgefangen", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schräg gekippt: beide Achsen ähnlich stark, keine Zuordnung möglich.
|
// Von Hand gekippt geht die andere Achse ein Stück mit. Einzeln betrachtet
|
||||||
if case .failure(let failure) = detect(nose: Reading(pitch: 9, roll: 8),
|
// wäre das nicht zuzuordnen, im Paar schon.
|
||||||
side: Reading(pitch: 0, roll: 9)) {
|
if case .success(let sloppy) = detect(nose: Reading(pitch: 14, roll: 6),
|
||||||
checkEqual("schräges Kippen wird gemeldet", failure, .ambiguous)
|
side: Reading(pitch: -7, roll: 16)) {
|
||||||
|
checkEqual("mitlaufende Achse stört die Zuordnung nicht",
|
||||||
|
sloppy.longitudinalSource, .pitch)
|
||||||
|
checkEqual("mitlaufend: längs nicht umgekehrt", sloppy.invertLongitudinal, false)
|
||||||
|
checkEqual("mitlaufend: quer nicht umgekehrt", sloppy.invertLateral, false)
|
||||||
} else {
|
} else {
|
||||||
check("schräges Kippen wird abgefangen", false)
|
check("mitlaufende Achse wird verkraftet", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dasselbe bei quer eingebautem Sensor.
|
||||||
|
if case .success(let sloppySideways) = detect(nose: Reading(pitch: 6, roll: 14),
|
||||||
|
side: Reading(pitch: -16, roll: 7)) {
|
||||||
|
checkEqual("mitlaufend und quer: Achsen getauscht",
|
||||||
|
sloppySideways.longitudinalSource, .roll)
|
||||||
|
checkEqual("mitlaufend und quer: quer umgekehrt",
|
||||||
|
sloppySideways.invertLateral, true)
|
||||||
|
} else {
|
||||||
|
check("mitlaufende Achse bei quer eingebautem Sensor", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zweimal um dieselbe Achse gekippt ergibt keine Zuordnung.
|
// Zweimal um dieselbe Achse gekippt ergibt keine Zuordnung.
|
||||||
|
|||||||
Reference in New Issue
Block a user