Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion dashpilot-ios/dashpilot/BLE/DashKitFirmwareVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })?
Expand Down Expand Up @@ -59,5 +68,6 @@ final class DashKitFirmwareVersion: DashKitGattListener {

func dispose() {
manager.removeGattListener(self)
registered = false
}
}
27 changes: 22 additions & 5 deletions dashpilot-ios/dashpilot/BLE/DashKitOtaUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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.
Expand All @@ -61,6 +67,8 @@ final class DashKitOtaUpdate: DashKitGattListener {

func cancel() {
manager.removeGattListener(self)
manager.suppressPings = false
rebooting = false
resumeCanNotifications()
firmware = nil
peripheral = nil
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions dashpilot-ios/dashpilot/BLE/VehicleControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions dashpilot-ios/dashpilot/DataSource/DashKitBleManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
81 changes: 68 additions & 13 deletions dashpilot-ios/dashpilot/UI/AutomationsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Void, Never>?
@State private var minutesWheelExpanded = false
@State private var percentPushTask: Task<Void, Never>?
@State private var percentWheelExpanded = false

var body: some View {
ZStack {
Expand Down Expand Up @@ -68,15 +75,39 @@ struct AutomationsView: View {
isOn: $climateKeep
) {
if climateKeep {
ClimateKeepDurationFooter(
minutes: $climateKeepMinutes,
ValuePickerFooter(
label: "Stop after",
unit: "min",
options: Array(climateKeepMinuteRange),
value: $climateKeepMinutes,
expanded: $minutesWheelExpanded
)
}
}

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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading