From f6a5e9f256aa99112043faaab00cac480dbf4f85 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Fri, 6 Feb 2026 19:59:01 -0500 Subject: [PATCH] feat: performance and bug review --- ios/PinecilTime.xcodeproj/project.pbxproj | 4 +- ios/PinecilTime/BLEManager.swift | 116 ++++++++++++++++----- ios/PinecilTime/ContentView.swift | 25 ++++- ios/PinecilTime/IronOSUUIDs.swift | 10 ++ ios/PinecilTime/Models.swift | 119 +++++++++++++++++++++- ios/PinecilTime/SettingsView.swift | 32 ++++-- ios/PinecilTime/TemperatureGraph.swift | 2 +- 7 files changed, 267 insertions(+), 41 deletions(-) diff --git a/ios/PinecilTime.xcodeproj/project.pbxproj b/ios/PinecilTime.xcodeproj/project.pbxproj index dc70098..4f372a7 100644 --- a/ios/PinecilTime.xcodeproj/project.pbxproj +++ b/ios/PinecilTime.xcodeproj/project.pbxproj @@ -252,7 +252,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = icon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = M67B42LX8D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -288,7 +288,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = icon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = M67B42LX8D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/ios/PinecilTime/BLEManager.swift b/ios/PinecilTime/BLEManager.swift index f64655b..c5e448f 100644 --- a/ios/PinecilTime/BLEManager.swift +++ b/ios/PinecilTime/BLEManager.swift @@ -21,9 +21,15 @@ class BLEManager: NSObject { var buildID: String = "" var deviceSerial: String = "" - // Temperature history for graph - var temperatureHistory: [TemperaturePoint] = [] - private let maxHistoryPoints = 60 + // Temperature history for graph (circular buffer) + var temperatureHistory = CircularBuffer(capacity: 60) + var temperatureHistoryArray: [TemperaturePoint] { temperatureHistory.elements } + + // Settings cache + var settingsCache = SettingsCache() + + // Error state + var lastError: BLEError? // MARK: - Private @@ -32,6 +38,7 @@ class BLEManager: NSObject { private var pollTimer: Timer? private var scanTimer: Timer? private let bleQueue = DispatchQueue(label: "com.pineciltime.ble", qos: .userInitiated) + private let timerQueue = DispatchQueue.main private var pendingWrites: [CBUUID: UInt16] = [:] private var settingReadCompletions: [CBUUID: (UInt16?) -> Void] = [:] @@ -80,6 +87,7 @@ class BLEManager: NSObject { scanTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { [weak self] _ in self?.stopScanning() } + RunLoop.main.add(scanTimer!, forMode: .common) } func stopScanning() { @@ -110,16 +118,20 @@ class BLEManager: NSObject { connectionState = .disconnected connectedPeripheral = nil discoveredCharacteristics.removeAll() - temperatureHistory.removeAll() + temperatureHistory.clear() + lastError = nil } + @MainActor func setTemperature(_ temp: UInt32) { writeSetting(index: 0, value: UInt16(temp)) } + @MainActor func writeSetting(index: UInt16, value: UInt16) { guard connectionState == .connected, let peripheral = connectedPeripheral else { + lastError = .notConnected return } @@ -128,19 +140,30 @@ class BLEManager: NSObject { // If we have the characteristic cached, use it if let characteristic = discoveredCharacteristics[uuid] { peripheral.writeValue(value.data, for: characteristic, type: .withResponse) + settingsCache.set(value, for: index) } else { // Otherwise discover it first if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { peripheral.discoverCharacteristics([uuid], for: settingsService) // Store for later write after discovery pendingWrites[uuid] = value + } else { + lastError = .characteristicNotFound(uuid) } } } + @MainActor func readSetting(index: UInt16, completion: @escaping (UInt16?) -> Void) { + // Check cache first + if let cached = settingsCache.get(index) { + completion(cached) + return + } + guard connectionState == .connected, let peripheral = connectedPeripheral else { + lastError = .notConnected completion(nil) return } @@ -156,14 +179,20 @@ class BLEManager: NSObject { // Discover it first if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { peripheral.discoverCharacteristics([uuid], for: settingsService) + } else { + lastError = .characteristicNotFound(uuid) + completion(nil) + settingReadCompletions.removeValue(forKey: uuid) } } } + @MainActor func saveSettings() { guard connectionState == .connected, let peripheral = connectedPeripheral, let characteristic = discoveredCharacteristics[IronOSUUIDs.saveSettings] else { + lastError = .notConnected return } @@ -173,16 +202,30 @@ class BLEManager: NSObject { func setSlowPolling() { pollTimer?.invalidate() - pollTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in - self?.readBulkData() + pollTimer = Timer(timeInterval: 0.2, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.readBulkData() + } + } + timerQueue.async { [weak self] in + guard let timer = self?.pollTimer else { return } + RunLoop.main.add(timer, forMode: .common) } } func setFastPolling() { guard connectionState == .connected else { return } pollTimer?.invalidate() - pollTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - self?.readBulkData() + pollTimer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.readBulkData() + } + } + timerQueue.async { [weak self] in + guard let timer = self?.pollTimer else { return } + RunLoop.main.add(timer, forMode: .common) } } @@ -191,11 +234,20 @@ class BLEManager: NSObject { private func startPolling() { stopPolling() - pollTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in - self?.readBulkData() + pollTimer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.readBulkData() + } + } + timerQueue.async { [weak self] in + guard let timer = self?.pollTimer else { return } + RunLoop.main.add(timer, forMode: .common) + } + + Task { @MainActor in + readBulkData() } - - readBulkData() } private func stopPolling() { @@ -203,6 +255,7 @@ class BLEManager: NSObject { pollTimer = nil } + @MainActor private func readBulkData() { guard let characteristic = discoveredCharacteristics[IronOSUUIDs.bulkLiveData], let peripheral = connectedPeripheral else { return } @@ -218,18 +271,13 @@ class BLEManager: NSObject { ) temperatureHistory.append(point) - - // Keep only last N points - if temperatureHistory.count > maxHistoryPoints { - temperatureHistory.removeFirst() - } } + @MainActor private func handleCharacteristicValue(_ characteristic: CBCharacteristic) { guard let value = characteristic.value else { return } - - DispatchQueue.main.async { [self] in - switch characteristic.uuid { + + switch characteristic.uuid { case IronOSUUIDs.bulkLiveData: liveData.updateFromBulkData(value) recordTemperature() @@ -265,7 +313,6 @@ class BLEManager: NSObject { default: break - } } } } @@ -397,23 +444,42 @@ extension BLEManager: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - if error != nil { return } + guard error == nil else { + DispatchQueue.main.async { [weak self] in + self?.lastError = .readFailed(error?.localizedDescription ?? "Unknown error") + } + return + } // Check if this is a setting read completion if let completion = settingReadCompletions[characteristic.uuid] { let value = characteristic.value?.withUnsafeBytes { $0.load(as: UInt16.self) } - DispatchQueue.main.async { - completion(value) + if let value = value, let index = IronOSUUIDs.settingIndex(from: characteristic.uuid) { + DispatchQueue.main.async { [weak self] in + self?.settingsCache.set(value, for: index) + completion(value) + } + } else { + DispatchQueue.main.async { + completion(value) + } } settingReadCompletions.removeValue(forKey: characteristic.uuid) return } - handleCharacteristicValue(characteristic) + Task { @MainActor in + handleCharacteristicValue(characteristic) + } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + if let error = error { + DispatchQueue.main.async { [weak self] in + self?.lastError = .writeFailed(error.localizedDescription) + } + } } } diff --git a/ios/PinecilTime/ContentView.swift b/ios/PinecilTime/ContentView.swift index f428bf7..d14c44f 100644 --- a/ios/PinecilTime/ContentView.swift +++ b/ios/PinecilTime/ContentView.swift @@ -13,17 +13,18 @@ struct ContentView: View { @State private var lastSendTime: Date = .distantPast @State private var isTopBarExpanded = false @State private var showingSettings = false + @State private var showingError = false private var isHeating: Bool { - bleManager.liveData.mode?.isActive ?? false + bleManager.liveData.mode?.isActive == true } var body: some View { ZStack { // Background graph - if !bleManager.temperatureHistory.isEmpty { + if bleManager.temperatureHistory.count > 0 { TemperatureGraph( - history: bleManager.temperatureHistory, + history: bleManager.temperatureHistoryArray, currentSetpoint: Int(targetTemp) ) .padding(.horizontal, 20) @@ -43,6 +44,18 @@ struct ContentView: View { targetTemp = Double(newValue) } } + .onChange(of: bleManager.lastError) { _, error in + if error != nil { + showingError = true + } + } + .alert("Bluetooth Error", isPresented: $showingError, presenting: bleManager.lastError) { _ in + Button("OK") { + bleManager.lastError = nil + } + } message: { error in + Text(error.localizedDescription) + } } // MARK: - Connected View @@ -135,7 +148,7 @@ struct ContentView: View { HStack { detailItem(label: "Handle", value: String(format: "%.1f°C", bleManager.liveData.handleTempC)) Spacer() - detailItem(label: "Tip Resist", value: String(format: "%.2f Ω", bleManager.liveData.resistance)) + detailItem(label: "Tip Resistance", value: String(format: "%.2f Ω", bleManager.liveData.resistance)) } HStack { @@ -231,6 +244,8 @@ struct ContentView: View { } ) .tint(colorForTemp(targetTemp, maxTemp: 450)) + .accessibilityLabel("Target temperature") + .accessibilityValue("\(Int(targetTemp)) degrees") .onChange(of: targetTemp) { _, newValue in guard isEditingSlider else { return } let now = Date() @@ -271,7 +286,7 @@ struct ContentView: View { } private func colorForTemp(_ temp: Double, maxTemp: Double) -> Color { - let progress = Swift.min(Swift.max(temp / maxTemp, 0), 1) + let progress = min(max(temp / maxTemp, 0), 1) if progress < 0.33 { let t = progress / 0.33 diff --git a/ios/PinecilTime/IronOSUUIDs.swift b/ios/PinecilTime/IronOSUUIDs.swift index 99f25cd..1331048 100644 --- a/ios/PinecilTime/IronOSUUIDs.swift +++ b/ios/PinecilTime/IronOSUUIDs.swift @@ -41,4 +41,14 @@ enum IronOSUUIDs { let hexString = String(format: "F6D7%04X-5A10-4EBA-AA55-33E27F9BC533", index) return CBUUID(string: hexString) } + + // Helper to extract setting index from UUID + static func settingIndex(from uuid: CBUUID) -> UInt16? { + let uuidString = uuid.uuidString.uppercased() + guard uuidString.hasPrefix("F6D7") && uuidString.hasSuffix("-5A10-4EBA-AA55-33E27F9BC533") else { + return nil + } + let hexIndex = String(uuidString.prefix(8).suffix(4)) + return UInt16(hexIndex, radix: 16) + } } diff --git a/ios/PinecilTime/Models.swift b/ios/PinecilTime/Models.swift index f252e18..110edcb 100644 --- a/ios/PinecilTime/Models.swift +++ b/ios/PinecilTime/Models.swift @@ -3,6 +3,7 @@ // PinecilTime // +import CoreBluetooth import Foundation import SwiftUI @@ -72,6 +73,55 @@ struct TemperaturePoint: Identifiable { let setpoint: UInt32 } +// MARK: - Circular Buffer + +@Observable +class CircularBuffer { + private var buffer: [T] + private var writeIndex = 0 + private(set) var isFull = false + let capacity: Int + + var elements: [T] { + if isFull { + return Array(buffer[writeIndex...]) + Array(buffer[.. UInt32? { - guard count >= 4 else { return nil } + guard count >= MemoryLayout.size else { return nil } return withUnsafeBytes { $0.load(as: UInt32.self) } } func toUInt64() -> UInt64? { - guard count >= 8 else { return nil } + guard count >= MemoryLayout.size else { return nil } return withUnsafeBytes { $0.load(as: UInt64.self) } } @@ -168,3 +218,68 @@ extension UInt16 { withUnsafeBytes(of: self) { Data($0) } } } + +// MARK: - Settings Cache + +@Observable +class SettingsCache { + private(set) var cache: [UInt16: UInt16] = [:] + private let userDefaults = UserDefaults.standard + private let cacheKey = "pinecilSettingsCache" + + init() { + loadFromDisk() + } + + func set(_ value: UInt16, for index: UInt16) { + cache[index] = value + saveToDisk() + } + + func get(_ index: UInt16) -> UInt16? { + cache[index] + } + + func clear() { + cache.removeAll() + userDefaults.removeObject(forKey: cacheKey) + } + + private func saveToDisk() { + let data = cache.map { ["index": $0.key, "value": $0.value] } + userDefaults.set(data, forKey: cacheKey) + } + + private func loadFromDisk() { + guard let data = userDefaults.array(forKey: cacheKey) as? [[String: UInt16]] else { return } + cache = Dictionary(uniqueKeysWithValues: data.compactMap { dict in + guard let index = dict["index"], let value = dict["value"] else { return nil } + return (index, value) + }) + } +} + +// MARK: - BLE Error + +enum BLEError: LocalizedError, Equatable { + case notConnected + case characteristicNotFound(CBUUID) + case readFailed(String) + case writeFailed(String) + case timeout + + var errorDescription: String? { + switch self { + case .notConnected: + return "Device not connected" + case .characteristicNotFound(let uuid): + return "Characteristic not found: \(uuid.uuidString)" + case .readFailed(let reason): + return "Read failed: \(reason)" + case .writeFailed(let reason): + return "Write failed: \(reason)" + case .timeout: + return "Operation timed out" + } + } +} diff --git a/ios/PinecilTime/SettingsView.swift b/ios/PinecilTime/SettingsView.swift index 2de88c0..66ba7b7 100644 --- a/ios/PinecilTime/SettingsView.swift +++ b/ios/PinecilTime/SettingsView.swift @@ -43,7 +43,7 @@ struct SettingsView: View { struct ConfigurationView: View { let bleManager: BLEManager @State private var settings: [Int: UInt16] = [:] - @State private var isLoading = true + @State private var isLoading = false @State private var saveInProgress = false var body: some View { @@ -289,19 +289,39 @@ struct ConfigurationView: View { .task { await loadSettings() } + .onAppear { + // Pre-populate from cache + let settingsToLoad: [UInt16] = [0, 1, 2, 6, 7, 11, 13, 14, 17, 22, 24, 25, 26, 27, 28, 33, 34] + for index in settingsToLoad { + if let cached = bleManager.settingsCache.get(index) { + settings[Int(index)] = cached + } + } + } } private func loadSettings() async { - // Load commonly used settings + // Load commonly used settings (will use cache if available) let settingsToLoad: [UInt16] = [0, 1, 2, 6, 7, 11, 13, 14, 17, 22, 24, 25, 26, 27, 28, 33, 34] - for index in settingsToLoad { - bleManager.readSetting(index: index) { value in + isLoading = true + + await withTaskGroup(of: (Int, UInt16?).self) { group in + for index in settingsToLoad { + group.addTask { @MainActor in + await withCheckedContinuation { (continuation: CheckedContinuation<(Int, UInt16?), Never>) in + bleManager.readSetting(index: index) { value in + continuation.resume(returning: (Int(index), value)) + } + } + } + } + + for await (index, value) in group { if let value = value { - settings[Int(index)] = value + settings[index] = value } } - try? await Task.sleep(nanoseconds: 50_000_000) // 50ms between reads } isLoading = false diff --git a/ios/PinecilTime/TemperatureGraph.swift b/ios/PinecilTime/TemperatureGraph.swift index 5c88b32..8393121 100644 --- a/ios/PinecilTime/TemperatureGraph.swift +++ b/ios/PinecilTime/TemperatureGraph.swift @@ -89,7 +89,7 @@ struct TemperatureGraph: View { } var body: some View { - TimelineView(.animation) { timeline in + TimelineView(.animation(minimumInterval: 0.1, paused: false)) { timeline in let now = timeline.date let windowSeconds: TimeInterval = 6 let xDomain = now.addingTimeInterval(-windowSeconds)...now -- 2.51.2