ironOS native ios app
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: performance and bug review

+267 -41
+2 -2
ios/PinecilTime.xcodeproj/project.pbxproj
··· 252 252 ASSETCATALOG_COMPILER_APPICON_NAME = icon; 253 253 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 254 254 CODE_SIGN_STYLE = Automatic; 255 - CURRENT_PROJECT_VERSION = 2; 255 + CURRENT_PROJECT_VERSION = 3; 256 256 DEVELOPMENT_TEAM = M67B42LX8D; 257 257 ENABLE_PREVIEWS = YES; 258 258 GENERATE_INFOPLIST_FILE = YES; ··· 288 288 ASSETCATALOG_COMPILER_APPICON_NAME = icon; 289 289 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 290 290 CODE_SIGN_STYLE = Automatic; 291 - CURRENT_PROJECT_VERSION = 2; 291 + CURRENT_PROJECT_VERSION = 3; 292 292 DEVELOPMENT_TEAM = M67B42LX8D; 293 293 ENABLE_PREVIEWS = YES; 294 294 GENERATE_INFOPLIST_FILE = YES;
+91 -25
ios/PinecilTime/BLEManager.swift
··· 21 21 var buildID: String = "" 22 22 var deviceSerial: String = "" 23 23 24 - // Temperature history for graph 25 - var temperatureHistory: [TemperaturePoint] = [] 26 - private let maxHistoryPoints = 60 24 + // Temperature history for graph (circular buffer) 25 + var temperatureHistory = CircularBuffer<TemperaturePoint>(capacity: 60) 26 + var temperatureHistoryArray: [TemperaturePoint] { temperatureHistory.elements } 27 + 28 + // Settings cache 29 + var settingsCache = SettingsCache() 30 + 31 + // Error state 32 + var lastError: BLEError? 27 33 28 34 // MARK: - Private 29 35 ··· 32 38 private var pollTimer: Timer? 33 39 private var scanTimer: Timer? 34 40 private let bleQueue = DispatchQueue(label: "com.pineciltime.ble", qos: .userInitiated) 41 + private let timerQueue = DispatchQueue.main 35 42 private var pendingWrites: [CBUUID: UInt16] = [:] 36 43 private var settingReadCompletions: [CBUUID: (UInt16?) -> Void] = [:] 37 44 ··· 80 87 scanTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { [weak self] _ in 81 88 self?.stopScanning() 82 89 } 90 + RunLoop.main.add(scanTimer!, forMode: .common) 83 91 } 84 92 85 93 func stopScanning() { ··· 110 118 connectionState = .disconnected 111 119 connectedPeripheral = nil 112 120 discoveredCharacteristics.removeAll() 113 - temperatureHistory.removeAll() 121 + temperatureHistory.clear() 122 + lastError = nil 114 123 } 115 124 125 + @MainActor 116 126 func setTemperature(_ temp: UInt32) { 117 127 writeSetting(index: 0, value: UInt16(temp)) 118 128 } 119 129 130 + @MainActor 120 131 func writeSetting(index: UInt16, value: UInt16) { 121 132 guard connectionState == .connected, 122 133 let peripheral = connectedPeripheral else { 134 + lastError = .notConnected 123 135 return 124 136 } 125 137 ··· 128 140 // If we have the characteristic cached, use it 129 141 if let characteristic = discoveredCharacteristics[uuid] { 130 142 peripheral.writeValue(value.data, for: characteristic, type: .withResponse) 143 + settingsCache.set(value, for: index) 131 144 } else { 132 145 // Otherwise discover it first 133 146 if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { 134 147 peripheral.discoverCharacteristics([uuid], for: settingsService) 135 148 // Store for later write after discovery 136 149 pendingWrites[uuid] = value 150 + } else { 151 + lastError = .characteristicNotFound(uuid) 137 152 } 138 153 } 139 154 } 140 155 156 + @MainActor 141 157 func readSetting(index: UInt16, completion: @escaping (UInt16?) -> Void) { 158 + // Check cache first 159 + if let cached = settingsCache.get(index) { 160 + completion(cached) 161 + return 162 + } 163 + 142 164 guard connectionState == .connected, 143 165 let peripheral = connectedPeripheral else { 166 + lastError = .notConnected 144 167 completion(nil) 145 168 return 146 169 } ··· 156 179 // Discover it first 157 180 if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { 158 181 peripheral.discoverCharacteristics([uuid], for: settingsService) 182 + } else { 183 + lastError = .characteristicNotFound(uuid) 184 + completion(nil) 185 + settingReadCompletions.removeValue(forKey: uuid) 159 186 } 160 187 } 161 188 } 162 189 190 + @MainActor 163 191 func saveSettings() { 164 192 guard connectionState == .connected, 165 193 let peripheral = connectedPeripheral, 166 194 let characteristic = discoveredCharacteristics[IronOSUUIDs.saveSettings] else { 195 + lastError = .notConnected 167 196 return 168 197 } 169 198 ··· 173 202 174 203 func setSlowPolling() { 175 204 pollTimer?.invalidate() 176 - pollTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in 177 - self?.readBulkData() 205 + pollTimer = Timer(timeInterval: 0.2, repeats: true) { [weak self] _ in 206 + guard let self else { return } 207 + Task { @MainActor in 208 + self.readBulkData() 209 + } 210 + } 211 + timerQueue.async { [weak self] in 212 + guard let timer = self?.pollTimer else { return } 213 + RunLoop.main.add(timer, forMode: .common) 178 214 } 179 215 } 180 216 181 217 func setFastPolling() { 182 218 guard connectionState == .connected else { return } 183 219 pollTimer?.invalidate() 184 - pollTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in 185 - self?.readBulkData() 220 + pollTimer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in 221 + guard let self else { return } 222 + Task { @MainActor in 223 + self.readBulkData() 224 + } 225 + } 226 + timerQueue.async { [weak self] in 227 + guard let timer = self?.pollTimer else { return } 228 + RunLoop.main.add(timer, forMode: .common) 186 229 } 187 230 } 188 231 ··· 191 234 private func startPolling() { 192 235 stopPolling() 193 236 194 - pollTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in 195 - self?.readBulkData() 237 + pollTimer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in 238 + guard let self else { return } 239 + Task { @MainActor in 240 + self.readBulkData() 241 + } 242 + } 243 + timerQueue.async { [weak self] in 244 + guard let timer = self?.pollTimer else { return } 245 + RunLoop.main.add(timer, forMode: .common) 246 + } 247 + 248 + Task { @MainActor in 249 + readBulkData() 196 250 } 197 - 198 - readBulkData() 199 251 } 200 252 201 253 private func stopPolling() { ··· 203 255 pollTimer = nil 204 256 } 205 257 258 + @MainActor 206 259 private func readBulkData() { 207 260 guard let characteristic = discoveredCharacteristics[IronOSUUIDs.bulkLiveData], 208 261 let peripheral = connectedPeripheral else { return } ··· 218 271 ) 219 272 220 273 temperatureHistory.append(point) 221 - 222 - // Keep only last N points 223 - if temperatureHistory.count > maxHistoryPoints { 224 - temperatureHistory.removeFirst() 225 - } 226 274 } 227 275 276 + @MainActor 228 277 private func handleCharacteristicValue(_ characteristic: CBCharacteristic) { 229 278 guard let value = characteristic.value else { return } 230 - 231 - DispatchQueue.main.async { [self] in 232 - switch characteristic.uuid { 279 + 280 + switch characteristic.uuid { 233 281 case IronOSUUIDs.bulkLiveData: 234 282 liveData.updateFromBulkData(value) 235 283 recordTemperature() ··· 265 313 266 314 default: 267 315 break 268 - } 269 316 } 270 317 } 271 318 } ··· 397 444 func peripheral(_ peripheral: CBPeripheral, 398 445 didUpdateValueFor characteristic: CBCharacteristic, 399 446 error: Error?) { 400 - if error != nil { return } 447 + guard error == nil else { 448 + DispatchQueue.main.async { [weak self] in 449 + self?.lastError = .readFailed(error?.localizedDescription ?? "Unknown error") 450 + } 451 + return 452 + } 401 453 402 454 // Check if this is a setting read completion 403 455 if let completion = settingReadCompletions[characteristic.uuid] { 404 456 let value = characteristic.value?.withUnsafeBytes { $0.load(as: UInt16.self) } 405 - DispatchQueue.main.async { 406 - completion(value) 457 + if let value = value, let index = IronOSUUIDs.settingIndex(from: characteristic.uuid) { 458 + DispatchQueue.main.async { [weak self] in 459 + self?.settingsCache.set(value, for: index) 460 + completion(value) 461 + } 462 + } else { 463 + DispatchQueue.main.async { 464 + completion(value) 465 + } 407 466 } 408 467 settingReadCompletions.removeValue(forKey: characteristic.uuid) 409 468 return 410 469 } 411 470 412 - handleCharacteristicValue(characteristic) 471 + Task { @MainActor in 472 + handleCharacteristicValue(characteristic) 473 + } 413 474 } 414 475 415 476 func peripheral(_ peripheral: CBPeripheral, 416 477 didWriteValueFor characteristic: CBCharacteristic, 417 478 error: Error?) { 479 + if let error = error { 480 + DispatchQueue.main.async { [weak self] in 481 + self?.lastError = .writeFailed(error.localizedDescription) 482 + } 483 + } 418 484 } 419 485 }
+20 -5
ios/PinecilTime/ContentView.swift
··· 13 13 @State private var lastSendTime: Date = .distantPast 14 14 @State private var isTopBarExpanded = false 15 15 @State private var showingSettings = false 16 + @State private var showingError = false 16 17 17 18 private var isHeating: Bool { 18 - bleManager.liveData.mode?.isActive ?? false 19 + bleManager.liveData.mode?.isActive == true 19 20 } 20 21 21 22 var body: some View { 22 23 ZStack { 23 24 // Background graph 24 - if !bleManager.temperatureHistory.isEmpty { 25 + if bleManager.temperatureHistory.count > 0 { 25 26 TemperatureGraph( 26 - history: bleManager.temperatureHistory, 27 + history: bleManager.temperatureHistoryArray, 27 28 currentSetpoint: Int(targetTemp) 28 29 ) 29 30 .padding(.horizontal, 20) ··· 43 44 targetTemp = Double(newValue) 44 45 } 45 46 } 47 + .onChange(of: bleManager.lastError) { _, error in 48 + if error != nil { 49 + showingError = true 50 + } 51 + } 52 + .alert("Bluetooth Error", isPresented: $showingError, presenting: bleManager.lastError) { _ in 53 + Button("OK") { 54 + bleManager.lastError = nil 55 + } 56 + } message: { error in 57 + Text(error.localizedDescription) 58 + } 46 59 } 47 60 48 61 // MARK: - Connected View ··· 135 148 HStack { 136 149 detailItem(label: "Handle", value: String(format: "%.1f°C", bleManager.liveData.handleTempC)) 137 150 Spacer() 138 - detailItem(label: "Tip Resist", value: String(format: "%.2f Ω", bleManager.liveData.resistance)) 151 + detailItem(label: "Tip Resistance", value: String(format: "%.2f Ω", bleManager.liveData.resistance)) 139 152 } 140 153 141 154 HStack { ··· 231 244 } 232 245 ) 233 246 .tint(colorForTemp(targetTemp, maxTemp: 450)) 247 + .accessibilityLabel("Target temperature") 248 + .accessibilityValue("\(Int(targetTemp)) degrees") 234 249 .onChange(of: targetTemp) { _, newValue in 235 250 guard isEditingSlider else { return } 236 251 let now = Date() ··· 271 286 } 272 287 273 288 private func colorForTemp(_ temp: Double, maxTemp: Double) -> Color { 274 - let progress = Swift.min(Swift.max(temp / maxTemp, 0), 1) 289 + let progress = min(max(temp / maxTemp, 0), 1) 275 290 276 291 if progress < 0.33 { 277 292 let t = progress / 0.33
+10
ios/PinecilTime/IronOSUUIDs.swift
··· 41 41 let hexString = String(format: "F6D7%04X-5A10-4EBA-AA55-33E27F9BC533", index) 42 42 return CBUUID(string: hexString) 43 43 } 44 + 45 + // Helper to extract setting index from UUID 46 + static func settingIndex(from uuid: CBUUID) -> UInt16? { 47 + let uuidString = uuid.uuidString.uppercased() 48 + guard uuidString.hasPrefix("F6D7") && uuidString.hasSuffix("-5A10-4EBA-AA55-33E27F9BC533") else { 49 + return nil 50 + } 51 + let hexIndex = String(uuidString.prefix(8).suffix(4)) 52 + return UInt16(hexIndex, radix: 16) 53 + } 44 54 }
+117 -2
ios/PinecilTime/Models.swift
··· 3 3 // PinecilTime 4 4 // 5 5 6 + import CoreBluetooth 6 7 import Foundation 7 8 import SwiftUI 8 9 ··· 72 73 let setpoint: UInt32 73 74 } 74 75 76 + // MARK: - Circular Buffer 77 + 78 + @Observable 79 + class CircularBuffer<T> { 80 + private var buffer: [T] 81 + private var writeIndex = 0 82 + private(set) var isFull = false 83 + let capacity: Int 84 + 85 + var elements: [T] { 86 + if isFull { 87 + return Array(buffer[writeIndex...]) + Array(buffer[..<writeIndex]) 88 + } else { 89 + return Array(buffer[..<writeIndex]) 90 + } 91 + } 92 + 93 + var count: Int { 94 + isFull ? capacity : writeIndex 95 + } 96 + 97 + init(capacity: Int) { 98 + self.capacity = capacity 99 + self.buffer = [] 100 + self.buffer.reserveCapacity(capacity) 101 + } 102 + 103 + func append(_ element: T) { 104 + if buffer.count < capacity { 105 + buffer.append(element) 106 + writeIndex = buffer.count 107 + if writeIndex == capacity { 108 + isFull = true 109 + writeIndex = 0 110 + } 111 + } else { 112 + buffer[writeIndex] = element 113 + writeIndex = (writeIndex + 1) % capacity 114 + isFull = true 115 + } 116 + } 117 + 118 + func clear() { 119 + buffer.removeAll(keepingCapacity: true) 120 + writeIndex = 0 121 + isFull = false 122 + } 123 + } 124 + 75 125 // MARK: - Live Data 76 126 77 127 @Observable ··· 149 199 150 200 extension Data { 151 201 func toUInt32() -> UInt32? { 152 - guard count >= 4 else { return nil } 202 + guard count >= MemoryLayout<UInt32>.size else { return nil } 153 203 return withUnsafeBytes { $0.load(as: UInt32.self) } 154 204 } 155 205 156 206 func toUInt64() -> UInt64? { 157 - guard count >= 8 else { return nil } 207 + guard count >= MemoryLayout<UInt64>.size else { return nil } 158 208 return withUnsafeBytes { $0.load(as: UInt64.self) } 159 209 } 160 210 ··· 168 218 withUnsafeBytes(of: self) { Data($0) } 169 219 } 170 220 } 221 + 222 + // MARK: - Settings Cache 223 + 224 + @Observable 225 + class SettingsCache { 226 + private(set) var cache: [UInt16: UInt16] = [:] 227 + private let userDefaults = UserDefaults.standard 228 + private let cacheKey = "pinecilSettingsCache" 229 + 230 + init() { 231 + loadFromDisk() 232 + } 233 + 234 + func set(_ value: UInt16, for index: UInt16) { 235 + cache[index] = value 236 + saveToDisk() 237 + } 238 + 239 + func get(_ index: UInt16) -> UInt16? { 240 + cache[index] 241 + } 242 + 243 + func clear() { 244 + cache.removeAll() 245 + userDefaults.removeObject(forKey: cacheKey) 246 + } 247 + 248 + private func saveToDisk() { 249 + let data = cache.map { ["index": $0.key, "value": $0.value] } 250 + userDefaults.set(data, forKey: cacheKey) 251 + } 252 + 253 + private func loadFromDisk() { 254 + guard let data = userDefaults.array(forKey: cacheKey) as? [[String: UInt16]] else { return } 255 + cache = Dictionary(uniqueKeysWithValues: data.compactMap { dict in 256 + guard let index = dict["index"], let value = dict["value"] else { return nil } 257 + return (index, value) 258 + }) 259 + } 260 + } 261 + 262 + // MARK: - BLE Error 263 + 264 + enum BLEError: LocalizedError, Equatable { 265 + case notConnected 266 + case characteristicNotFound(CBUUID) 267 + case readFailed(String) 268 + case writeFailed(String) 269 + case timeout 270 + 271 + var errorDescription: String? { 272 + switch self { 273 + case .notConnected: 274 + return "Device not connected" 275 + case .characteristicNotFound(let uuid): 276 + return "Characteristic not found: \(uuid.uuidString)" 277 + case .readFailed(let reason): 278 + return "Read failed: \(reason)" 279 + case .writeFailed(let reason): 280 + return "Write failed: \(reason)" 281 + case .timeout: 282 + return "Operation timed out" 283 + } 284 + } 285 + }
+26 -6
ios/PinecilTime/SettingsView.swift
··· 43 43 struct ConfigurationView: View { 44 44 let bleManager: BLEManager 45 45 @State private var settings: [Int: UInt16] = [:] 46 - @State private var isLoading = true 46 + @State private var isLoading = false 47 47 @State private var saveInProgress = false 48 48 49 49 var body: some View { ··· 289 289 .task { 290 290 await loadSettings() 291 291 } 292 + .onAppear { 293 + // Pre-populate from cache 294 + let settingsToLoad: [UInt16] = [0, 1, 2, 6, 7, 11, 13, 14, 17, 22, 24, 25, 26, 27, 28, 33, 34] 295 + for index in settingsToLoad { 296 + if let cached = bleManager.settingsCache.get(index) { 297 + settings[Int(index)] = cached 298 + } 299 + } 300 + } 292 301 } 293 302 294 303 private func loadSettings() async { 295 - // Load commonly used settings 304 + // Load commonly used settings (will use cache if available) 296 305 let settingsToLoad: [UInt16] = [0, 1, 2, 6, 7, 11, 13, 14, 17, 22, 24, 25, 26, 27, 28, 33, 34] 297 306 298 - for index in settingsToLoad { 299 - bleManager.readSetting(index: index) { value in 307 + isLoading = true 308 + 309 + await withTaskGroup(of: (Int, UInt16?).self) { group in 310 + for index in settingsToLoad { 311 + group.addTask { @MainActor in 312 + await withCheckedContinuation { (continuation: CheckedContinuation<(Int, UInt16?), Never>) in 313 + bleManager.readSetting(index: index) { value in 314 + continuation.resume(returning: (Int(index), value)) 315 + } 316 + } 317 + } 318 + } 319 + 320 + for await (index, value) in group { 300 321 if let value = value { 301 - settings[Int(index)] = value 322 + settings[index] = value 302 323 } 303 324 } 304 - try? await Task.sleep(nanoseconds: 50_000_000) // 50ms between reads 305 325 } 306 326 307 327 isLoading = false
+1 -1
ios/PinecilTime/TemperatureGraph.swift
··· 89 89 } 90 90 91 91 var body: some View { 92 - TimelineView(.animation) { timeline in 92 + TimelineView(.animation(minimumInterval: 0.1, paused: false)) { timeline in 93 93 let now = timeline.date 94 94 let windowSeconds: TimeInterval = 6 95 95 let xDomain = now.addingTimeInterval(-windowSeconds)...now