From 19791f95358d2f7af5c336c482b0dba18a484f64 Mon Sep 17 00:00:00 2001 From: Ahmed Harmouche Date: Mon, 14 Sep 2026 11:50:05 +0200 Subject: [PATCH] [ios] Sport kick-down, supress pings during OTA, refresh fw version after OTA --- .../BLE/DashKitFirmwareVersion.swift | 12 ++- .../dashpilot/BLE/DashKitOtaUpdate.swift | 27 +++++-- .../dashpilot/BLE/VehicleControl.swift | 16 ++++ .../DataSource/DashKitBleManager.swift | 13 ++- .../dashpilot/UI/AutomationsView.swift | 81 ++++++++++++++++--- .../ViewModel/ConnectionViewModel.swift | 4 + 6 files changed, 131 insertions(+), 22 deletions(-) diff --git a/dashpilot-ios/dashpilot/BLE/DashKitFirmwareVersion.swift b/dashpilot-ios/dashpilot/BLE/DashKitFirmwareVersion.swift index cf8091d3..1e701b03 100644 --- a/dashpilot-ios/dashpilot/BLE/DashKitFirmwareVersion.swift +++ b/dashpilot-ios/dashpilot/BLE/DashKitFirmwareVersion.swift @@ -11,17 +11,26 @@ final class DashKitFirmwareVersion: DashKitGattListener { private(set) var version: String? private let manager: DashKitBleManager + private var registered = false init(manager: DashKitBleManager) { self.manager = manager } /// Register for callbacks; if already connected the manager immediately - /// replays onServicesReady, which triggers the read. + /// replays onServicesReady, which triggers the read. The listener stays + /// registered so every reconnect (e.g. the reboot after an OTA) re-reads. func read() { + guard !registered else { return } + registered = true manager.addGattListener(self) } + // The link is gone, so the last value no longer describes what is running. + func onDisconnected() { + DispatchQueue.main.async { self.version = nil } + } + func onServicesReady(_ peripheral: CBPeripheral) { guard let characteristic = peripheral.services? .first(where: { $0.uuid == DashKitGatt.canService })? @@ -59,5 +68,6 @@ final class DashKitFirmwareVersion: DashKitGattListener { func dispose() { manager.removeGattListener(self) + registered = false } } diff --git a/dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift b/dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift index 846ecfb0..1b7a96bb 100644 --- a/dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift +++ b/dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift @@ -41,6 +41,10 @@ final class DashKitOtaUpdate: DashKitGattListener { private var canChar: CBCharacteristic? private var canWasNotifying = false + // Set synchronously on the BLE queue when the device reports completion; + // `state` is published on main so it can lag the disconnect that follows. + private var rebooting = false + init(manager: DashKitBleManager) { self.manager = manager } @@ -52,6 +56,8 @@ final class DashKitOtaUpdate: DashKitGattListener { } firmware = fw firmwareOffset = 0 + rebooting = false + manager.suppressPings = true setState(.connecting) // If already connected the manager replays onServicesReady right away; // otherwise kick off a connection. @@ -61,6 +67,8 @@ final class DashKitOtaUpdate: DashKitGattListener { func cancel() { manager.removeGattListener(self) + manager.suppressPings = false + rebooting = false resumeCanNotifications() firmware = nil peripheral = nil @@ -85,6 +93,13 @@ final class DashKitOtaUpdate: DashKitGattListener { // MARK: - DashKitGattListener (called on the manager's BLE queue) func onServicesReady(_ peripheral: CBPeripheral) { + if rebooting { + // The DashKit came back on the new firmware: the update is done. + rebooting = false + manager.removeGattListener(self) + setState(.idle) + return + } guard let service = peripheral.services?.first(where: { $0.uuid == DashKitGatt.otaService }) else { setState(.error("OTA service not found on device")) return @@ -137,12 +152,12 @@ final class DashKitOtaUpdate: DashKitGattListener { } func onDisconnected() { - switch state { - case .rebooting, .idle: - break - default: + // Rebooting expects this drop; the listener stays registered so the + // reconnect's onServicesReady can clear the completed state. + if !rebooting, state != .idle { setState(.error("Disconnected unexpectedly")) } + manager.suppressPings = false firmware = nil peripheral = nil ctrlChar = nil @@ -198,14 +213,16 @@ final class DashKitOtaUpdate: DashKitGattListener { } case 0x02: print("[DashKitOta] OTA complete, device rebooting") + rebooting = true setState(.rebooting) - manager.removeGattListener(self) + manager.suppressPings = false firmware = nil case 0xFF: let errCode = value.count > 1 ? value[1] : 0 print("[DashKitOta] OTA error from device: 0x\(String(errCode, radix: 16))") setState(.error("Device reported error (0x\(String(errCode, radix: 16)))")) manager.removeGattListener(self) + manager.suppressPings = false resumeCanNotifications() firmware = nil default: diff --git a/dashpilot-ios/dashpilot/BLE/VehicleControl.swift b/dashpilot-ios/dashpilot/BLE/VehicleControl.swift index e575d394..ec5fa1d6 100644 --- a/dashpilot-ios/dashpilot/BLE/VehicleControl.swift +++ b/dashpilot-ios/dashpilot/BLE/VehicleControl.swift @@ -77,6 +77,12 @@ enum VehicleControl { // car is in reverse; the override drops when it leaves reverse. Value ignored. static let cmdMirrorDipToggle = 0x48 + // --- Sport kick-down (UI_powertrainControl 0x334) --- + // 1=enable, 0=disable. Persisted in NVS by the firmware. + static let cmdSportKickdownEnable = 0x49 + // Trigger pedal percent, clamped to 10..95. Persisted in NVS by the firmware. + static let cmdSportKickdownThreshold = 0x4A + /// Bind (or clear, with actionValue 0) an N-finger tap to a control action. @discardableResult static func sendFingerAction(_ manager: DashKitBleManager, fingers: Int, actionValue: Int) -> Bool { @@ -136,6 +142,16 @@ enum VehicleControl { send(manager, opcode: cmdClimateKeepDuration, value: minutes) } + @discardableResult + static func sendSportKickdown(_ manager: DashKitBleManager, enabled: Bool) -> Bool { + send(manager, opcode: cmdSportKickdownEnable, value: enabled ? 1 : 0) + } + + @discardableResult + static func sendSportKickdownThreshold(_ manager: DashKitBleManager, percent: Int) -> Bool { + send(manager, opcode: cmdSportKickdownThreshold, value: percent) + } + /// Write a control command to the DashKit. Returns true if the write was /// dispatched (not necessarily acknowledged). No-op returning false when /// the link is down or the control characteristic is unavailable. diff --git a/dashpilot-ios/dashpilot/DataSource/DashKitBleManager.swift b/dashpilot-ios/dashpilot/DataSource/DashKitBleManager.swift index edecabfd..ae9ef41e 100644 --- a/dashpilot-ios/dashpilot/DataSource/DashKitBleManager.swift +++ b/dashpilot-ios/dashpilot/DataSource/DashKitBleManager.swift @@ -130,6 +130,11 @@ final class DashKitBleManager: NSObject { private var retryTask: DispatchWorkItem? private var pingTask: DispatchWorkItem? + // Set by the OTA uploader for the duration of a transfer; the firmware + // exempts the updating phone from its keepalive cull. The loop keeps + // rescheduling so pings resume as soon as the flag clears. + var suppressPings = false + /// Keepalive foreground gate. Suspension alone stops pings too late (and /// never under the debugger), so it's explicit. Accessed on `queue`. private var appInForeground = true @@ -257,9 +262,11 @@ final class DashKitBleManager: NSObject { // `queue` because VehicleControl.send uses queue.sync. private func schedulePing() { pingTask?.cancel() - DispatchQueue.global().async { [weak self] in - guard let self else { return } - VehicleControl.sendPing(self) + if !suppressPings { + DispatchQueue.global().async { [weak self] in + guard let self else { return } + VehicleControl.sendPing(self) + } } pingTask = schedule(after: Self.pingInterval) { [weak self] in guard let self, self.state == .connected else { return } diff --git a/dashpilot-ios/dashpilot/UI/AutomationsView.swift b/dashpilot-ios/dashpilot/UI/AutomationsView.swift index d6aeeaa9..b9c065fe 100644 --- a/dashpilot-ios/dashpilot/UI/AutomationsView.swift +++ b/dashpilot-ios/dashpilot/UI/AutomationsView.swift @@ -10,6 +10,9 @@ private let fingerActionsKey = "finger_actions" /// Minutes the keep-climate-on window can run (matches the firmware clamp). private let climateKeepMinuteRange = 1...60 +// Matches the firmware clamp. +private let sportKickdownPercentOptions = Array(stride(from: 10, through: 95, by: 5)) + /// A single multi-finger tap binding: `fingerCount` fingers -> vehicle /// control `controlId`. struct FingerAction: Identifiable, Equatable { @@ -33,9 +36,13 @@ struct AutomationsView: View { @AppStorage("wiper_off_automation") private var wiperOff: Bool = false @AppStorage("climate_keep_automation") private var climateKeep: Bool = false @AppStorage("climate_keep_minutes") private var climateKeepMinutes: Int = 5 + @AppStorage("sport_kickdown_automation") private var sportKickdown: Bool = false + @AppStorage("sport_kickdown_percent") private var sportKickdownPercent: Int = 80 @State private var fingerActions: [FingerAction] = [] @State private var minutesPushTask: Task? @State private var minutesWheelExpanded = false + @State private var percentPushTask: Task? + @State private var percentWheelExpanded = false var body: some View { ZStack { @@ -68,8 +75,11 @@ struct AutomationsView: View { isOn: $climateKeep ) { if climateKeep { - ClimateKeepDurationFooter( - minutes: $climateKeepMinutes, + ValuePickerFooter( + label: "Stop after", + unit: "min", + options: Array(climateKeepMinuteRange), + value: $climateKeepMinutes, expanded: $minutesWheelExpanded ) } @@ -77,6 +87,27 @@ struct AutomationsView: View { Spacer().frame(height: 28) + SectionLabel("Driving") + Spacer().frame(height: 8) + AutomationRow( + icon: "gauge.with.needle", + title: "Sport kick-down", + subtitle: "Switch from Chill to Sport pedal response while the accelerator is pressed past the threshold. Reverts when you ease off.", + isOn: $sportKickdown + ) { + if sportKickdown { + ValuePickerFooter( + label: "Pedal threshold", + unit: "%", + options: sportKickdownPercentOptions, + value: $sportKickdownPercent, + expanded: $percentWheelExpanded + ) + } + } + + Spacer().frame(height: 28) + SectionLabel("Multi-touch infotainment trigger") Spacer().frame(height: 4) Text("Bind 3-, 4-, or 5-finger infotainment taps to a control") @@ -107,7 +138,10 @@ struct AutomationsView: View { // empty space: tapping outside the minutes wheel collapses it. .contentShape(Rectangle()) .onTapGesture { - withAnimation { minutesWheelExpanded = false } + withAnimation { + minutesWheelExpanded = false + percentWheelExpanded = false + } } .navigationBarHidden(true) .onAppear(perform: loadFingerActions) @@ -136,6 +170,24 @@ struct AutomationsView: View { } } } + .onChange(of: sportKickdown) { _, newValue in + if !newValue { + percentWheelExpanded = false + } + if let manager = connectionVM.bleManager { + VehicleControl.sendSportKickdown(manager, enabled: newValue) + } + } + .onChange(of: sportKickdownPercent) { _, newValue in + percentPushTask?.cancel() + percentPushTask = Task { + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + if let manager = connectionVM.bleManager { + VehicleControl.sendSportKickdownThreshold(manager, percent: newValue) + } + } + } .onChange(of: fingerActions) { oldValue, newValue in saveFingerActions() pushChangedBindings(from: oldValue, to: newValue) @@ -313,18 +365,21 @@ extension AutomationRow where Footer == EmptyView { } } -// MARK: - Climate keep duration footer +// MARK: - Value picker footer -/// "Stop after N min" line inside the keep-climate-on card; tapping the value -/// expands a minutes wheel (Android `ClimateKeepDurationFooter`). -private struct ClimateKeepDurationFooter: View { - @Binding var minutes: Int +/// "Label N unit" line inside an automation card; tapping the value expands a +/// wheel over `options` (Android `NumberPickerFooter`). +private struct ValuePickerFooter: View { + let label: String + let unit: String + let options: [Int] + @Binding var value: Int @Binding var expanded: Bool var body: some View { VStack(spacing: 0) { HStack { - Text("Stop after") + Text(label) .foregroundColor(.dashTextMuted) .font(.system(size: 14)) .frame(maxWidth: .infinity, alignment: .leading) @@ -333,7 +388,7 @@ private struct ClimateKeepDurationFooter: View { withAnimation { expanded.toggle() } } label: { HStack(spacing: 2) { - Text("\(minutes) min") + Text("\(value) \(unit)") .foregroundColor(.white) .font(.system(size: 15)) Image(systemName: "chevron.down") @@ -351,9 +406,9 @@ private struct ClimateKeepDurationFooter: View { .padding(.top, 10) if expanded { - Picker("", selection: $minutes) { - ForEach(climateKeepMinuteRange, id: \.self) { value in - Text("\(value) min").tag(value) + Picker("", selection: $value) { + ForEach(options, id: \.self) { option in + Text("\(option) \(unit)").tag(option) } } .pickerStyle(.wheel) diff --git a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift index 568bcdc2..dacf3efb 100644 --- a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift +++ b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift @@ -163,6 +163,10 @@ final class ConnectionViewModel { VehicleControl.sendClimateKeep(manager, enabled: climateKeep) let climateKeepMinutes = UserDefaults.standard.integer(forKey: "climate_keep_minutes") VehicleControl.sendClimateKeepDuration(manager, minutes: climateKeepMinutes > 0 ? climateKeepMinutes : 5) + let sportKickdown = UserDefaults.standard.bool(forKey: "sport_kickdown_automation") + VehicleControl.sendSportKickdown(manager, enabled: sportKickdown) + let sportKickdownPercent = UserDefaults.standard.integer(forKey: "sport_kickdown_percent") + VehicleControl.sendSportKickdownThreshold(manager, percent: sportKickdownPercent > 0 ? sportKickdownPercent : 80) } // MARK: - Comma (WiFi)