ironOS native ios app

feat: graph overhaul and async everwhere

dunkirk.sh 4211fd6e b7d8ce30

verified
+190 -97
ios/PinecilTime.xcodeproj/project.xcworkspace/xcuserdata/kierank.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+91 -57
ios/PinecilTime/BLEManager.swift
··· 29 29 private var discoveredCharacteristics: [CBUUID: CBCharacteristic] = [:] 30 30 private var pollTimer: Timer? 31 31 private var scanTimer: Timer? 32 + private let bleQueue = DispatchQueue(label: "com.pineciltime.ble", qos: .userInitiated) 32 33 33 34 // MARK: - Init 34 35 35 36 override init() { 36 37 super.init() 37 - centralManager = CBCentralManager(delegate: self, queue: .main) 38 + centralManager = CBCentralManager(delegate: self, queue: bleQueue) 38 39 } 39 40 40 41 // MARK: - Connection State ··· 48 49 49 50 var isConnected: Bool { 50 51 self == .connected 52 + } 53 + 54 + var isConnecting: Bool { 55 + self == .connecting 51 56 } 52 57 } 53 58 ··· 112 117 } 113 118 114 119 let value = UInt16(temp).data 115 - peripheral.writeValue(value, for: characteristic, type: .withResponse) 120 + peripheral.writeValue(value, for: characteristic, type: .withoutResponse) 121 + } 122 + 123 + func setSlowPolling() { 124 + pollTimer?.invalidate() 125 + pollTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in 126 + self?.readBulkData() 127 + } 128 + } 129 + 130 + func setFastPolling() { 131 + guard connectionState == .connected else { return } 132 + pollTimer?.invalidate() 133 + pollTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in 134 + self?.readBulkData() 135 + } 116 136 } 117 137 118 138 // MARK: - Polling ··· 157 177 private func handleCharacteristicValue(_ characteristic: CBCharacteristic) { 158 178 guard let value = characteristic.value else { return } 159 179 160 - switch characteristic.uuid { 161 - case IronOSUUIDs.bulkLiveData: 162 - liveData.updateFromBulkData(value) 163 - recordTemperature() 180 + DispatchQueue.main.async { [self] in 181 + switch characteristic.uuid { 182 + case IronOSUUIDs.bulkLiveData: 183 + liveData.updateFromBulkData(value) 184 + recordTemperature() 164 185 165 - case IronOSUUIDs.liveTemp: 166 - liveData.liveTemp = value.toUInt32() ?? 0 167 - case IronOSUUIDs.setpointRead: 168 - liveData.setpoint = value.toUInt32() ?? 0 169 - case IronOSUUIDs.dcInput: 170 - liveData.dcInput = value.toUInt32() ?? 0 171 - case IronOSUUIDs.handleTemp: 172 - liveData.handleTemp = value.toUInt32() ?? 0 173 - case IronOSUUIDs.powerLevel: 174 - liveData.powerLevel = value.toUInt32() ?? 0 175 - case IronOSUUIDs.powerSource: 176 - liveData.powerSource = value.toUInt32() ?? 0 177 - case IronOSUUIDs.operatingMode: 178 - liveData.operatingMode = value.toUInt32() ?? 0 179 - case IronOSUUIDs.estimatedWatts: 180 - liveData.estimatedWatts = value.toUInt32() ?? 0 181 - case IronOSUUIDs.maxTemp: 182 - liveData.maxTemp = value.toUInt32() ?? 450 186 + case IronOSUUIDs.liveTemp: 187 + liveData.liveTemp = value.toUInt32() ?? 0 188 + case IronOSUUIDs.setpointRead: 189 + liveData.setpoint = value.toUInt32() ?? 0 190 + case IronOSUUIDs.dcInput: 191 + liveData.dcInput = value.toUInt32() ?? 0 192 + case IronOSUUIDs.handleTemp: 193 + liveData.handleTemp = value.toUInt32() ?? 0 194 + case IronOSUUIDs.powerLevel: 195 + liveData.powerLevel = value.toUInt32() ?? 0 196 + case IronOSUUIDs.powerSource: 197 + liveData.powerSource = value.toUInt32() ?? 0 198 + case IronOSUUIDs.operatingMode: 199 + liveData.operatingMode = value.toUInt32() ?? 0 200 + case IronOSUUIDs.estimatedWatts: 201 + liveData.estimatedWatts = value.toUInt32() ?? 0 202 + case IronOSUUIDs.maxTemp: 203 + liveData.maxTemp = value.toUInt32() ?? 450 183 204 184 - case IronOSUUIDs.firmwareVersion: 185 - firmwareVersion = value.toString() ?? "" 205 + case IronOSUUIDs.firmwareVersion: 206 + firmwareVersion = value.toString() ?? "" 186 207 187 - default: 188 - break 208 + default: 209 + break 210 + } 189 211 } 190 212 } 191 213 } ··· 195 217 extension BLEManager: CBCentralManagerDelegate { 196 218 197 219 func centralManagerDidUpdateState(_ central: CBCentralManager) { 198 - switch central.state { 199 - case .poweredOn: 200 - startScanning() 201 - case .poweredOff: 202 - connectionState = .error("Bluetooth is off") 203 - case .unauthorized: 204 - connectionState = .error("Bluetooth access denied") 205 - case .unsupported: 206 - connectionState = .error("Bluetooth not supported") 207 - default: 208 - break 220 + DispatchQueue.main.async { [self] in 221 + switch central.state { 222 + case .poweredOn: 223 + startScanning() 224 + case .poweredOff: 225 + connectionState = .error("Bluetooth is off") 226 + case .unauthorized: 227 + connectionState = .error("Bluetooth access denied") 228 + case .unsupported: 229 + connectionState = .error("Bluetooth not supported") 230 + default: 231 + break 232 + } 209 233 } 210 234 } 211 235 ··· 213 237 didDiscover peripheral: CBPeripheral, 214 238 advertisementData: [String: Any], 215 239 rssi RSSI: NSNumber) { 216 - // Auto-connect to first discovered Pinecil 217 - if connectedPeripheral == nil { 218 - if peripheral.name?.hasPrefix("PrattlePin-") == true || 219 - advertisementData[CBAdvertisementDataServiceUUIDsKey] != nil { 220 - connect(to: peripheral) 221 - return 240 + DispatchQueue.main.async { [self] in 241 + // Auto-connect to first discovered Pinecil 242 + if connectedPeripheral == nil { 243 + if peripheral.name?.hasPrefix("PrattlePin-") == true || 244 + advertisementData[CBAdvertisementDataServiceUUIDsKey] != nil { 245 + connect(to: peripheral) 246 + return 247 + } 222 248 } 223 - } 224 249 225 - if !discoveredDevices.contains(where: { $0.identifier == peripheral.identifier }) { 226 - discoveredDevices.append(peripheral) 250 + if !discoveredDevices.contains(where: { $0.identifier == peripheral.identifier }) { 251 + discoveredDevices.append(peripheral) 252 + } 227 253 } 228 254 } 229 255 230 256 func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 231 - connectionState = .connected 232 - deviceName = peripheral.name ?? "Pinecil" 257 + DispatchQueue.main.async { [self] in 258 + connectionState = .connected 259 + deviceName = peripheral.name ?? "Pinecil" 260 + } 233 261 peripheral.discoverServices(nil) 234 262 } 235 263 236 264 func centralManager(_ central: CBCentralManager, 237 265 didFailToConnect peripheral: CBPeripheral, 238 266 error: Error?) { 239 - connectionState = .error(error?.localizedDescription ?? "Connection failed") 240 - connectedPeripheral = nil 267 + DispatchQueue.main.async { [self] in 268 + connectionState = .error(error?.localizedDescription ?? "Connection failed") 269 + connectedPeripheral = nil 270 + } 241 271 } 242 272 243 273 func centralManager(_ central: CBCentralManager, 244 274 didDisconnectPeripheral peripheral: CBPeripheral, 245 275 error: Error?) { 246 - stopPolling() 247 - connectionState = .disconnected 248 - connectedPeripheral = nil 249 - discoveredCharacteristics.removeAll() 276 + DispatchQueue.main.async { [self] in 277 + stopPolling() 278 + connectionState = .disconnected 279 + connectedPeripheral = nil 280 + discoveredCharacteristics.removeAll() 281 + } 250 282 } 251 283 } 252 284 ··· 284 316 285 317 // Start polling once we have the live data service 286 318 if service.uuid == IronOSUUIDs.liveDataService || service.uuid == IronOSUUIDs.bulkDataService { 287 - startPolling() 319 + DispatchQueue.main.async { [self] in 320 + startPolling() 321 + } 288 322 } 289 323 } 290 324
+8 -5
ios/PinecilTime/ContentView.swift
··· 22 22 if !bleManager.temperatureHistory.isEmpty { 23 23 TemperatureGraph( 24 24 history: bleManager.temperatureHistory, 25 - maxTemp: bleManager.liveData.maxTemp 25 + currentSetpoint: Int(targetTemp) 26 26 ) 27 27 .padding(.horizontal, 20) 28 28 .padding(.vertical, 120) ··· 150 150 step: 5, 151 151 onEditingChanged: { editing in 152 152 isEditingSlider = editing 153 - if !editing { 153 + if editing { 154 + bleManager.setSlowPolling() 155 + } else { 154 156 bleManager.setTemperature(UInt32(targetTemp)) 155 157 lastSentTemp = targetTemp 158 + bleManager.setFastPolling() 156 159 } 157 160 } 158 161 ) ··· 160 163 .onChange(of: targetTemp) { _, newValue in 161 164 guard isEditingSlider else { return } 162 165 let now = Date() 163 - if now.timeIntervalSince(lastSendTime) > 0.15 && abs(newValue - lastSentTemp) >= 5 { 166 + if now.timeIntervalSince(lastSendTime) > 0.2 && abs(newValue - lastSentTemp) >= 5 { 164 167 bleManager.setTemperature(UInt32(newValue)) 165 168 lastSentTemp = newValue 166 169 lastSendTime = now ··· 220 223 .ignoresSafeArea() 221 224 222 225 VStack(spacing: 20) { 223 - if bleManager.isScanning || bleManager.connectionState == BLEManager.ConnectionState.connecting { 226 + if bleManager.isScanning || bleManager.connectionState.isConnecting { 224 227 ProgressView() 225 228 .scaleEffect(1.2) 226 229 .padding(.bottom, 4) 227 230 228 - Text(bleManager.connectionState == BLEManager.ConnectionState.connecting ? "Connecting..." : "Scanning...") 231 + Text(bleManager.connectionState.isConnecting ? "Connecting..." : "Scanning...") 229 232 .font(.headline) 230 233 231 234 Text("Looking for your Pinecil")
+91 -35
ios/PinecilTime/TemperatureGraph.swift
··· 6 6 import Charts 7 7 import SwiftUI 8 8 9 + private struct ChartDataPoint: Identifiable { 10 + let id: String 11 + let timestamp: Date 12 + let value: Int 13 + let series: String 14 + } 15 + 9 16 struct TemperatureGraph: View { 10 17 let history: [TemperaturePoint] 11 - let maxTemp: UInt32 18 + let currentSetpoint: Int 12 19 13 - var body: some View { 14 - Chart { 15 - setpointLine 16 - actualTempLine 20 + private var chartData: [ChartDataPoint] { 21 + var data: [ChartDataPoint] = [] 22 + for point in history { 23 + data.append(ChartDataPoint( 24 + id: "\(point.id)-setpoint", 25 + timestamp: point.timestamp, 26 + value: Int(point.setpoint), 27 + series: "Setpoint" 28 + )) 29 + data.append(ChartDataPoint( 30 + id: "\(point.id)-temp", 31 + timestamp: point.timestamp, 32 + value: Int(point.actualTemp), 33 + series: "Temp" 34 + )) 17 35 } 18 - .chartXAxis(.hidden) 19 - .chartYAxis(.hidden) 20 - .chartLegend(.hidden) 21 - .chartYScale(domain: 0...500) 22 - } 23 - 24 - @ChartContentBuilder 25 - private var setpointLine: some ChartContent { 26 - ForEach(history) { point in 27 - LineMark( 28 - x: .value("T", point.timestamp), 29 - y: .value("S", Int(point.setpoint)), 30 - series: .value("L", "S") 31 - ) 32 - .foregroundStyle(Color.gray.opacity(0.4)) 33 - .lineStyle(StrokeStyle(lineWidth: 1.5)) 34 - } 35 - } 36 - 37 - @ChartContentBuilder 38 - private var actualTempLine: some ChartContent { 39 - ForEach(history) { point in 40 - LineMark( 41 - x: .value("T", point.timestamp), 42 - y: .value("A", Int(point.actualTemp)), 43 - series: .value("L", "A") 44 - ) 45 - .foregroundStyle(lineColor) 46 - .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round)) 47 - } 36 + return data 48 37 } 49 38 50 39 private var lineColor: Color { ··· 53 42 if temp < 150 { return .blue } 54 43 if temp < 300 { return .orange } 55 44 return .red 45 + } 46 + 47 + private func chartDataWithEdge(now: Date, windowSeconds: TimeInterval) -> [ChartDataPoint] { 48 + var data: [ChartDataPoint] = [] 49 + 50 + // Add point slightly before first data point (only if data has scrolled to left edge) 51 + if let first = history.first { 52 + let windowStart = now.addingTimeInterval(-windowSeconds) 53 + if first.timestamp <= windowStart { 54 + let leftEdge = windowStart.addingTimeInterval(-1) 55 + data.append(ChartDataPoint( 56 + id: "left-setpoint", 57 + timestamp: leftEdge, 58 + value: Int(first.setpoint), 59 + series: "Setpoint" 60 + )) 61 + data.append(ChartDataPoint( 62 + id: "left-temp", 63 + timestamp: leftEdge, 64 + value: Int(first.actualTemp), 65 + series: "Temp" 66 + )) 67 + } 68 + } 69 + 70 + data.append(contentsOf: chartData) 71 + 72 + // Add point at right edge 73 + if let last = history.last { 74 + let rightEdge = now.addingTimeInterval(1) 75 + data.append(ChartDataPoint( 76 + id: "edge-setpoint", 77 + timestamp: rightEdge, 78 + value: currentSetpoint, 79 + series: "Setpoint" 80 + )) 81 + data.append(ChartDataPoint( 82 + id: "edge-temp", 83 + timestamp: rightEdge, 84 + value: Int(last.actualTemp), 85 + series: "Temp" 86 + )) 87 + } 88 + return data 89 + } 90 + 91 + var body: some View { 92 + TimelineView(.animation) { timeline in 93 + let now = timeline.date 94 + let windowSeconds: TimeInterval = 6 95 + let xDomain = now.addingTimeInterval(-windowSeconds)...now 96 + 97 + Chart(chartDataWithEdge(now: now, windowSeconds: windowSeconds)) { point in 98 + LineMark( 99 + x: .value("Time", point.timestamp), 100 + y: .value("Value", point.value), 101 + series: .value("Series", point.series) 102 + ) 103 + .foregroundStyle(point.series == "Setpoint" ? Color.gray.opacity(0.4) : lineColor) 104 + .lineStyle(StrokeStyle(lineWidth: point.series == "Setpoint" ? 1.5 : 2.5, lineCap: .round)) 105 + } 106 + .chartXAxis(.hidden) 107 + .chartYAxis(.hidden) 108 + .chartLegend(.hidden) 109 + .chartYScale(domain: 0...500) 110 + .chartXScale(domain: xDomain) 111 + } 56 112 } 57 113 }