ironOS native ios app
2
fork

Configure Feed

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

fix: improve code quality, performance, and reliability

- Fix memory leaks by replacing [self] with [weak self] in all closures
- Add thread safety with operationQueue for dictionary access
- Implement 5-second timeout mechanism for BLE operations to prevent hanging
- Optimize CircularBuffer with caching to reduce array allocations
- Add data validation for BLE bulk data (size and temperature range checks)
- Consolidate settings loading to reduce redundant BLE reads
- Simplify temperature control logic by removing duplicate onChange handler
- Enable native refresh rate for smooth graph scrolling animation
- Clean up all pending operations and timeouts on disconnect

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

dunkirk.sh c6f6a53f 677f3e8c

verified
+232 -135
+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 = 3; 255 + CURRENT_PROJECT_VERSION = 4; 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 = 3; 291 + CURRENT_PROJECT_VERSION = 4; 292 292 DEVELOPMENT_TEAM = M67B42LX8D; 293 293 ENABLE_PREVIEWS = YES; 294 294 GENERATE_INFOPLIST_FILE = YES;
+171 -98
ios/PinecilTime/BLEManager.swift
··· 39 39 private var scanTimer: Timer? 40 40 private let bleQueue = DispatchQueue(label: "com.pineciltime.ble", qos: .userInitiated) 41 41 private let timerQueue = DispatchQueue.main 42 + private let operationQueue = DispatchQueue(label: "com.pineciltime.operations", qos: .userInitiated) 42 43 private var pendingWrites: [CBUUID: UInt16] = [:] 43 44 private var settingReadCompletions: [CBUUID: (UInt16?) -> Void] = [:] 45 + private var operationTimeouts: [CBUUID: DispatchWorkItem] = [:] 44 46 45 47 // MARK: - Init 46 48 ··· 84 86 isScanning = true 85 87 86 88 // Timeout after 10 seconds 87 - scanTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { [weak self] _ in 88 - self?.stopScanning() 89 + timerQueue.async { [weak self] in 90 + guard let self else { return } 91 + self.scanTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { [weak self] _ in 92 + self?.stopScanning() 93 + } 94 + if let timer = self.scanTimer { 95 + RunLoop.main.add(timer, forMode: .common) 96 + } 89 97 } 90 - RunLoop.main.add(scanTimer!, forMode: .common) 91 98 } 92 99 93 100 func stopScanning() { ··· 115 122 centralManager.cancelPeripheralConnection(peripheral) 116 123 } 117 124 125 + // Clean up all state 126 + operationQueue.sync { 127 + // Cancel all pending operations 128 + for timeout in operationTimeouts.values { 129 + timeout.cancel() 130 + } 131 + operationTimeouts.removeAll() 132 + pendingWrites.removeAll() 133 + settingReadCompletions.removeAll() 134 + } 135 + 118 136 connectionState = .disconnected 119 137 connectedPeripheral = nil 120 138 discoveredCharacteristics.removeAll() ··· 134 152 lastError = .notConnected 135 153 return 136 154 } 137 - 155 + 138 156 let uuid = IronOSUUIDs.settingUUID(index: index) 139 - 140 - // If we have the characteristic cached, use it 141 - if let characteristic = discoveredCharacteristics[uuid] { 142 - peripheral.writeValue(value.data, for: characteristic, type: .withResponse) 143 - settingsCache.set(value, for: index) 144 - } else { 145 - // Otherwise discover it first 146 - if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { 147 - peripheral.discoverCharacteristics([uuid], for: settingsService) 148 - // Store for later write after discovery 149 - pendingWrites[uuid] = value 157 + 158 + operationQueue.sync { 159 + // If we have the characteristic cached, use it 160 + if let characteristic = discoveredCharacteristics[uuid] { 161 + peripheral.writeValue(value.data, for: characteristic, type: .withResponse) 162 + settingsCache.set(value, for: index) 163 + scheduleOperationTimeout(for: uuid, type: "write") 150 164 } else { 151 - lastError = .characteristicNotFound(uuid) 165 + // Otherwise discover it first 166 + if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { 167 + peripheral.discoverCharacteristics([uuid], for: settingsService) 168 + // Store for later write after discovery 169 + pendingWrites[uuid] = value 170 + scheduleOperationTimeout(for: uuid, type: "discover-write") 171 + } else { 172 + lastError = .characteristicNotFound(uuid) 173 + } 152 174 } 153 175 } 154 176 } ··· 160 182 completion(cached) 161 183 return 162 184 } 163 - 185 + 164 186 guard connectionState == .connected, 165 187 let peripheral = connectedPeripheral else { 166 188 lastError = .notConnected 167 189 completion(nil) 168 190 return 169 191 } 170 - 192 + 171 193 let uuid = IronOSUUIDs.settingUUID(index: index) 172 - 173 - // Store completion handler 174 - settingReadCompletions[uuid] = completion 175 - 176 - if let characteristic = discoveredCharacteristics[uuid] { 177 - peripheral.readValue(for: characteristic) 178 - } else { 179 - // Discover it first 180 - if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { 181 - peripheral.discoverCharacteristics([uuid], for: settingsService) 194 + 195 + operationQueue.sync { 196 + // Store completion handler 197 + settingReadCompletions[uuid] = completion 198 + 199 + if let characteristic = discoveredCharacteristics[uuid] { 200 + peripheral.readValue(for: characteristic) 201 + scheduleOperationTimeout(for: uuid, type: "read") 182 202 } else { 183 - lastError = .characteristicNotFound(uuid) 184 - completion(nil) 185 - settingReadCompletions.removeValue(forKey: uuid) 203 + // Discover it first 204 + if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { 205 + peripheral.discoverCharacteristics([uuid], for: settingsService) 206 + scheduleOperationTimeout(for: uuid, type: "discover-read") 207 + } else { 208 + lastError = .characteristicNotFound(uuid) 209 + completion(nil) 210 + settingReadCompletions.removeValue(forKey: uuid) 211 + } 186 212 } 187 213 } 188 214 } ··· 201 227 } 202 228 203 229 func setSlowPolling() { 204 - pollTimer?.invalidate() 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) 214 - } 230 + updatePollingInterval(0.2) 215 231 } 216 232 217 233 func setFastPolling() { 218 234 guard connectionState == .connected else { return } 219 - pollTimer?.invalidate() 220 - pollTimer = Timer(timeInterval: 0.1, repeats: true) { [weak self] _ in 235 + updatePollingInterval(0.1) 236 + } 237 + 238 + private func updatePollingInterval(_ interval: TimeInterval) { 239 + timerQueue.async { [weak self] in 221 240 guard let self else { return } 222 - Task { @MainActor in 223 - self.readBulkData() 241 + self.pollTimer?.invalidate() 242 + self.pollTimer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in 243 + guard let self else { return } 244 + Task { @MainActor in 245 + self.readBulkData() 246 + } 247 + } 248 + if let timer = self.pollTimer { 249 + RunLoop.main.add(timer, forMode: .common) 224 250 } 225 251 } 226 - timerQueue.async { [weak self] in 227 - guard let timer = self?.pollTimer else { return } 228 - RunLoop.main.add(timer, forMode: .common) 229 - } 230 252 } 231 253 232 254 // MARK: - Polling 233 255 234 256 private func startPolling() { 235 257 stopPolling() 258 + updatePollingInterval(0.1) 236 259 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 - } 260 + Task { @MainActor in 261 + readBulkData() 242 262 } 263 + } 264 + 265 + private func stopPolling() { 243 266 timerQueue.async { [weak self] in 244 - guard let timer = self?.pollTimer else { return } 245 - RunLoop.main.add(timer, forMode: .common) 267 + self?.pollTimer?.invalidate() 268 + self?.pollTimer = nil 246 269 } 247 - 248 - Task { @MainActor in 249 - readBulkData() 270 + } 271 + 272 + // MARK: - Timeout Management 273 + 274 + private func scheduleOperationTimeout(for uuid: CBUUID, type: String) { 275 + // Cancel any existing timeout 276 + operationQueue.sync { 277 + operationTimeouts[uuid]?.cancel() 278 + 279 + let timeoutWork = DispatchWorkItem { [weak self] in 280 + guard let self else { return } 281 + Task { @MainActor in 282 + self.handleOperationTimeout(uuid: uuid, type: type) 283 + } 284 + } 285 + 286 + operationTimeouts[uuid] = timeoutWork 287 + operationQueue.asyncAfter(deadline: .now() + 5.0, execute: timeoutWork) 250 288 } 251 289 } 252 290 253 - private func stopPolling() { 254 - pollTimer?.invalidate() 255 - pollTimer = nil 291 + private func cancelOperationTimeout(for uuid: CBUUID) { 292 + operationQueue.sync { 293 + operationTimeouts[uuid]?.cancel() 294 + operationTimeouts.removeValue(forKey: uuid) 295 + } 296 + } 297 + 298 + @MainActor 299 + private func handleOperationTimeout(uuid: CBUUID, type: String) { 300 + operationQueue.sync { 301 + // Clean up any pending operations 302 + if let completion = settingReadCompletions.removeValue(forKey: uuid) { 303 + lastError = .timeout 304 + completion(nil) 305 + } 306 + pendingWrites.removeValue(forKey: uuid) 307 + operationTimeouts.removeValue(forKey: uuid) 308 + } 256 309 } 257 310 258 311 @MainActor ··· 322 375 extension BLEManager: CBCentralManagerDelegate { 323 376 324 377 func centralManagerDidUpdateState(_ central: CBCentralManager) { 325 - DispatchQueue.main.async { [self] in 378 + DispatchQueue.main.async { [weak self] in 379 + guard let self else { return } 326 380 switch central.state { 327 381 case .poweredOn: 328 - startScanning() 382 + self.startScanning() 329 383 case .poweredOff: 330 - connectionState = .error("Bluetooth is off") 384 + self.connectionState = .error("Bluetooth is off") 331 385 case .unauthorized: 332 - connectionState = .error("Bluetooth access denied") 386 + self.connectionState = .error("Bluetooth access denied") 333 387 case .unsupported: 334 - connectionState = .error("Bluetooth not supported") 388 + self.connectionState = .error("Bluetooth not supported") 335 389 default: 336 390 break 337 391 } ··· 342 396 didDiscover peripheral: CBPeripheral, 343 397 advertisementData: [String: Any], 344 398 rssi RSSI: NSNumber) { 345 - DispatchQueue.main.async { [self] in 399 + DispatchQueue.main.async { [weak self] in 400 + guard let self else { return } 346 401 // Auto-connect to first discovered Pinecil 347 - if connectedPeripheral == nil { 402 + if self.connectedPeripheral == nil { 348 403 // Match either Pinecil-* or by the advertised service UUID 349 404 if peripheral.name?.hasPrefix("Pinecil-") == true || 350 405 peripheral.name?.hasPrefix("PrattlePin-") == true || 351 406 (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?.contains(IronOSUUIDs.bulkDataService) == true { 352 - connect(to: peripheral) 407 + self.connect(to: peripheral) 353 408 return 354 409 } 355 410 } 356 411 357 - if !discoveredDevices.contains(where: { $0.identifier == peripheral.identifier }) { 358 - discoveredDevices.append(peripheral) 412 + if !self.discoveredDevices.contains(where: { $0.identifier == peripheral.identifier }) { 413 + self.discoveredDevices.append(peripheral) 359 414 } 360 415 } 361 416 } 362 417 363 418 func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 364 - DispatchQueue.main.async { [self] in 365 - connectionState = .connected 366 - deviceName = peripheral.name ?? "Pinecil" 419 + DispatchQueue.main.async { [weak self] in 420 + guard let self else { return } 421 + self.connectionState = .connected 422 + self.deviceName = peripheral.name ?? "Pinecil" 367 423 } 368 424 peripheral.discoverServices(nil) 369 425 } ··· 371 427 func centralManager(_ central: CBCentralManager, 372 428 didFailToConnect peripheral: CBPeripheral, 373 429 error: Error?) { 374 - DispatchQueue.main.async { [self] in 375 - connectionState = .error(error?.localizedDescription ?? "Connection failed") 376 - connectedPeripheral = nil 430 + DispatchQueue.main.async { [weak self] in 431 + guard let self else { return } 432 + self.connectionState = .error(error?.localizedDescription ?? "Connection failed") 433 + self.connectedPeripheral = nil 377 434 } 378 435 } 379 436 380 437 func centralManager(_ central: CBCentralManager, 381 438 didDisconnectPeripheral peripheral: CBPeripheral, 382 439 error: Error?) { 383 - DispatchQueue.main.async { [self] in 384 - stopPolling() 385 - connectionState = .disconnected 386 - connectedPeripheral = nil 387 - discoveredCharacteristics.removeAll() 440 + DispatchQueue.main.async { [weak self] in 441 + guard let self else { return } 442 + self.stopPolling() 443 + self.connectionState = .disconnected 444 + self.connectedPeripheral = nil 445 + self.discoveredCharacteristics.removeAll() 388 446 } 389 447 } 390 448 } ··· 422 480 } 423 481 424 482 // Handle pending writes for dynamically discovered settings 425 - if let pendingValue = pendingWrites[characteristic.uuid] { 426 - peripheral.writeValue(pendingValue.data, for: characteristic, type: .withResponse) 427 - pendingWrites.removeValue(forKey: characteristic.uuid) 428 - } 429 - 430 - // Handle pending reads for dynamically discovered settings 431 - if settingReadCompletions[characteristic.uuid] != nil { 432 - peripheral.readValue(for: characteristic) 483 + operationQueue.sync { 484 + if let pendingValue = pendingWrites[characteristic.uuid] { 485 + peripheral.writeValue(pendingValue.data, for: characteristic, type: .withResponse) 486 + pendingWrites.removeValue(forKey: characteristic.uuid) 487 + scheduleOperationTimeout(for: characteristic.uuid, type: "write") 488 + } 489 + 490 + // Handle pending reads for dynamically discovered settings 491 + if settingReadCompletions[characteristic.uuid] != nil { 492 + peripheral.readValue(for: characteristic) 493 + scheduleOperationTimeout(for: characteristic.uuid, type: "read") 494 + } 433 495 } 434 496 } 435 497 436 498 // Start polling once we have the live data service 437 499 if service.uuid == IronOSUUIDs.liveDataService || service.uuid == IronOSUUIDs.bulkDataService { 438 - DispatchQueue.main.async { [self] in 439 - startPolling() 500 + DispatchQueue.main.async { [weak self] in 501 + self?.startPolling() 440 502 } 441 503 } 442 504 } ··· 445 507 didUpdateValueFor characteristic: CBCharacteristic, 446 508 error: Error?) { 447 509 guard error == nil else { 510 + cancelOperationTimeout(for: characteristic.uuid) 448 511 DispatchQueue.main.async { [weak self] in 449 512 self?.lastError = .readFailed(error?.localizedDescription ?? "Unknown error") 450 513 } 451 514 return 452 515 } 453 - 516 + 517 + // Cancel timeout for successful read 518 + cancelOperationTimeout(for: characteristic.uuid) 519 + 454 520 // Check if this is a setting read completion 455 - if let completion = settingReadCompletions[characteristic.uuid] { 521 + let completion = operationQueue.sync { () -> ((UInt16?) -> Void)? in 522 + settingReadCompletions.removeValue(forKey: characteristic.uuid) 523 + } 524 + 525 + if let completion = completion { 456 526 let value = characteristic.value?.withUnsafeBytes { $0.load(as: UInt16.self) } 457 527 if let value = value, let index = IronOSUUIDs.settingIndex(from: characteristic.uuid) { 458 528 DispatchQueue.main.async { [weak self] in ··· 464 534 completion(value) 465 535 } 466 536 } 467 - settingReadCompletions.removeValue(forKey: characteristic.uuid) 468 537 return 469 538 } 470 - 539 + 471 540 Task { @MainActor in 472 541 handleCharacteristicValue(characteristic) 473 542 } ··· 477 546 didWriteValueFor characteristic: CBCharacteristic, 478 547 error: Error?) { 479 548 if let error = error { 549 + cancelOperationTimeout(for: characteristic.uuid) 480 550 DispatchQueue.main.async { [weak self] in 481 551 self?.lastError = .writeFailed(error.localizedDescription) 482 552 } 553 + } else { 554 + // Cancel timeout on successful write 555 + cancelOperationTimeout(for: characteristic.uuid) 483 556 } 484 557 } 485 558 }
+5 -11
ios/PinecilTime/ContentView.swift
··· 238 238 if editing { 239 239 bleManager.setSlowPolling() 240 240 } else { 241 - bleManager.setTemperature(UInt32(targetTemp)) 242 - lastSentTemp = targetTemp 241 + // Only send if value changed 242 + if abs(targetTemp - lastSentTemp) >= 5 { 243 + bleManager.setTemperature(UInt32(targetTemp)) 244 + lastSentTemp = targetTemp 245 + } 243 246 bleManager.setFastPolling() 244 247 } 245 248 } ··· 247 250 .tint(colorForTemp(targetTemp, maxTemp: 450)) 248 251 .accessibilityLabel("Target temperature") 249 252 .accessibilityValue("\(Int(targetTemp)) degrees") 250 - .onChange(of: targetTemp) { _, newValue in 251 - guard isEditingSlider else { return } 252 - let now = Date() 253 - if now.timeIntervalSince(lastSendTime) > 0.2 && abs(newValue - lastSentTemp) >= 5 { 254 - bleManager.setTemperature(UInt32(newValue)) 255 - lastSentTemp = newValue 256 - lastSendTime = now 257 - } 258 - } 259 253 } 260 254 .padding(.horizontal, 20) 261 255 .padding(.vertical, 14)
+34 -13
ios/PinecilTime/Models.swift
··· 80 80 private var buffer: [T] 81 81 private var writeIndex = 0 82 82 private(set) var isFull = false 83 + private var cachedElements: [T]? 84 + private var cacheInvalidated = false 83 85 let capacity: Int 84 - 86 + 85 87 var elements: [T] { 86 - if isFull { 87 - return Array(buffer[writeIndex...]) + Array(buffer[..<writeIndex]) 88 - } else { 89 - return Array(buffer[..<writeIndex]) 88 + if cacheInvalidated { 89 + cachedElements = computeElements() 90 + cacheInvalidated = false 90 91 } 92 + return cachedElements ?? computeElements() 91 93 } 92 - 94 + 93 95 var count: Int { 94 96 isFull ? capacity : writeIndex 95 97 } 96 - 98 + 97 99 init(capacity: Int) { 98 100 self.capacity = capacity 99 101 self.buffer = [] 100 102 self.buffer.reserveCapacity(capacity) 101 103 } 102 - 104 + 103 105 func append(_ element: T) { 104 106 if buffer.count < capacity { 105 107 buffer.append(element) ··· 113 115 writeIndex = (writeIndex + 1) % capacity 114 116 isFull = true 115 117 } 118 + cacheInvalidated = true 116 119 } 117 - 120 + 118 121 func clear() { 119 122 buffer.removeAll(keepingCapacity: true) 120 123 writeIndex = 0 121 124 isFull = false 125 + cachedElements = nil 126 + cacheInvalidated = false 127 + } 128 + 129 + private func computeElements() -> [T] { 130 + if isFull { 131 + return Array(buffer[writeIndex...]) + Array(buffer[..<writeIndex]) 132 + } else { 133 + return Array(buffer[..<writeIndex]) 134 + } 122 135 } 123 136 } 124 137 ··· 167 180 } 168 181 169 182 func updateFromBulkData(_ data: Data) { 170 - guard data.count >= 56 else { return } 183 + // Validate data size (14 UInt32 values = 56 bytes) 184 + guard data.count == 56 else { return } 171 185 172 186 let values = data.withUnsafeBytes { buffer -> [UInt32] in 173 187 guard let baseAddress = buffer.baseAddress else { return [] } ··· 178 192 179 193 guard values.count == 14 else { return } 180 194 181 - liveTemp = values[0] 195 + // Basic validation of values 196 + let temp = values[0] 197 + let maxTempValue = values[9] 198 + 199 + // Sanity check temperatures (0-600°C range) 200 + guard temp <= 600, maxTempValue <= 600 else { return } 201 + 202 + liveTemp = temp 182 203 setpoint = values[1] 183 204 dcInput = values[2] 184 205 handleTemp = values[3] 185 - powerLevel = values[4] 206 + powerLevel = min(values[4], 255) // Power level should be 0-255 186 207 powerSource = values[5] 187 208 tipResistance = values[6] 188 209 uptime = values[7] 189 210 lastMovement = values[8] 190 - maxTemp = values[9] 211 + maxTemp = maxTempValue 191 212 rawTip = values[10] 192 213 hallSensor = values[11] 193 214 operatingMode = values[12]
+19 -10
ios/PinecilTime/SettingsView.swift
··· 258 258 259 259 Section { 260 260 Button { 261 - saveInProgress = true 262 - bleManager.saveSettings() 263 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 264 - saveInProgress = false 265 - } 261 + saveSettings() 266 262 } label: { 267 263 HStack { 268 264 Spacer() ··· 287 283 } 288 284 } 289 285 .task { 290 - await loadSettings() 291 - } 292 - .onAppear { 293 - // Pre-populate from cache 286 + // Pre-populate from cache first for instant display 294 287 let settingsToLoad: [UInt16] = [0, 1, 2, 6, 7, 11, 13, 14, 17, 22, 24, 25, 26, 27, 28, 33, 34] 295 288 for index in settingsToLoad { 296 289 if let cached = bleManager.settingsCache.get(index) { 297 290 settings[Int(index)] = cached 298 291 } 299 292 } 293 + 294 + // Then load from device in background 295 + await loadSettings() 300 296 } 301 297 } 302 - 298 + 299 + private func saveSettings() { 300 + saveInProgress = true 301 + bleManager.saveSettings() 302 + 303 + // Wait for a reasonable time for the write operation 304 + Task { 305 + try? await Task.sleep(for: .milliseconds(500)) 306 + await MainActor.run { 307 + saveInProgress = false 308 + } 309 + } 310 + } 311 + 303 312 private func loadSettings() async { 304 313 // Load commonly used settings (will use cache if available) 305 314 let settingsToLoad: [UInt16] = [0, 1, 2, 6, 7, 11, 13, 14, 17, 22, 24, 25, 26, 27, 28, 33, 34]
+1 -1
ios/PinecilTime/TemperatureGraph.swift
··· 87 87 } 88 88 89 89 var body: some View { 90 - TimelineView(.animation(minimumInterval: 0.1, paused: false)) { timeline in 90 + TimelineView(.animation(paused: false)) { timeline in 91 91 let now = timeline.date 92 92 let windowSeconds: TimeInterval = 6 93 93 let xDomain = now.addingTimeInterval(-windowSeconds)...now.addingTimeInterval(1)