ironOS native ios app
2
fork

Configure Feed

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

feat: add info and settings screens

+683 -34
+92 -9
ios/PinecilTime/BLEManager.swift
··· 18 18 var liveData = IronOSLiveData() 19 19 var deviceName: String = "" 20 20 var firmwareVersion: String = "" 21 + var buildID: String = "" 22 + var deviceSerial: String = "" 21 23 22 24 // Temperature history for graph 23 25 var temperatureHistory: [TemperaturePoint] = [] ··· 30 32 private var pollTimer: Timer? 31 33 private var scanTimer: Timer? 32 34 private let bleQueue = DispatchQueue(label: "com.pineciltime.ble", qos: .userInitiated) 35 + private var pendingWrites: [CBUUID: UInt16] = [:] 36 + private var settingReadCompletions: [CBUUID: (UInt16?) -> Void] = [:] 33 37 34 38 // MARK: - Init 35 39 ··· 110 114 } 111 115 112 116 func setTemperature(_ temp: UInt32) { 117 + writeSetting(index: 0, value: UInt16(temp)) 118 + } 119 + 120 + func writeSetting(index: UInt16, value: UInt16) { 121 + guard connectionState == .connected, 122 + let peripheral = connectedPeripheral else { 123 + return 124 + } 125 + 126 + let uuid = IronOSUUIDs.settingUUID(index: index) 127 + 128 + // If we have the characteristic cached, use it 129 + if let characteristic = discoveredCharacteristics[uuid] { 130 + peripheral.writeValue(value.data, for: characteristic, type: .withResponse) 131 + } else { 132 + // Otherwise discover it first 133 + if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { 134 + peripheral.discoverCharacteristics([uuid], for: settingsService) 135 + // Store for later write after discovery 136 + pendingWrites[uuid] = value 137 + } 138 + } 139 + } 140 + 141 + func readSetting(index: UInt16, completion: @escaping (UInt16?) -> Void) { 142 + guard connectionState == .connected, 143 + let peripheral = connectedPeripheral else { 144 + completion(nil) 145 + return 146 + } 147 + 148 + let uuid = IronOSUUIDs.settingUUID(index: index) 149 + 150 + // Store completion handler 151 + settingReadCompletions[uuid] = completion 152 + 153 + if let characteristic = discoveredCharacteristics[uuid] { 154 + peripheral.readValue(for: characteristic) 155 + } else { 156 + // Discover it first 157 + if let settingsService = peripheral.services?.first(where: { $0.uuid == IronOSUUIDs.settingsService }) { 158 + peripheral.discoverCharacteristics([uuid], for: settingsService) 159 + } 160 + } 161 + } 162 + 163 + func saveSettings() { 113 164 guard connectionState == .connected, 114 165 let peripheral = connectedPeripheral, 115 - let characteristic = discoveredCharacteristics[IronOSUUIDs.setpointSetting] else { 166 + let characteristic = discoveredCharacteristics[IronOSUUIDs.saveSettings] else { 116 167 return 117 168 } 118 - 119 - let value = UInt16(temp).data 120 - peripheral.writeValue(value, for: characteristic, type: .withoutResponse) 169 + 170 + let value = UInt16(1).data 171 + peripheral.writeValue(value, for: characteristic, type: .withResponse) 121 172 } 122 173 123 174 func setSlowPolling() { ··· 202 253 case IronOSUUIDs.maxTemp: 203 254 liveData.maxTemp = value.toUInt32() ?? 450 204 255 205 - case IronOSUUIDs.firmwareVersion: 256 + case IronOSUUIDs.buildID: 257 + // Build version is the actual firmware version string 206 258 firmwareVersion = value.toString() ?? "" 259 + buildID = value.toString() ?? "" 260 + 261 + case IronOSUUIDs.deviceSerial: 262 + if let serial = value.toUInt64() { 263 + deviceSerial = String(format: "%016llX", serial) 264 + } 207 265 208 266 default: 209 267 break ··· 240 298 DispatchQueue.main.async { [self] in 241 299 // Auto-connect to first discovered Pinecil 242 300 if connectedPeripheral == nil { 243 - if peripheral.name?.hasPrefix("PrattlePin-") == true || 244 - advertisementData[CBAdvertisementDataServiceUUIDsKey] != nil { 301 + // Match either Pinecil-* or by the advertised service UUID 302 + if peripheral.name?.hasPrefix("Pinecil-") == true || 303 + peripheral.name?.hasPrefix("PrattlePin-") == true || 304 + (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?.contains(IronOSUUIDs.bulkDataService) == true { 245 305 connect(to: peripheral) 246 306 return 247 307 } ··· 302 362 for characteristic in characteristics { 303 363 discoveredCharacteristics[characteristic.uuid] = characteristic 304 364 305 - // Read bulk data and firmware version on connect 365 + // Read bulk data and device info on connect 306 366 if characteristic.uuid == IronOSUUIDs.bulkLiveData || 307 - characteristic.uuid == IronOSUUIDs.firmwareVersion { 367 + characteristic.uuid == IronOSUUIDs.buildID || 368 + characteristic.uuid == IronOSUUIDs.deviceSerial { 308 369 peripheral.readValue(for: characteristic) 309 370 } 310 371 ··· 312 373 if characteristic.uuid == IronOSUUIDs.operatingMode { 313 374 peripheral.setNotifyValue(true, for: characteristic) 314 375 } 376 + 377 + // Handle pending writes for dynamically discovered settings 378 + if let pendingValue = pendingWrites[characteristic.uuid] { 379 + peripheral.writeValue(pendingValue.data, for: characteristic, type: .withResponse) 380 + pendingWrites.removeValue(forKey: characteristic.uuid) 381 + } 382 + 383 + // Handle pending reads for dynamically discovered settings 384 + if settingReadCompletions[characteristic.uuid] != nil { 385 + peripheral.readValue(for: characteristic) 386 + } 315 387 } 316 388 317 389 // Start polling once we have the live data service ··· 326 398 didUpdateValueFor characteristic: CBCharacteristic, 327 399 error: Error?) { 328 400 if error != nil { return } 401 + 402 + // Check if this is a setting read completion 403 + if let completion = settingReadCompletions[characteristic.uuid] { 404 + let value = characteristic.value?.withUnsafeBytes { $0.load(as: UInt16.self) } 405 + DispatchQueue.main.async { 406 + completion(value) 407 + } 408 + settingReadCompletions.removeValue(forKey: characteristic.uuid) 409 + return 410 + } 411 + 329 412 handleCharacteristicValue(characteristic) 330 413 } 331 414
+103 -21
ios/PinecilTime/ContentView.swift
··· 11 11 @State private var isEditingSlider = false 12 12 @State private var lastSentTemp: Double = 0 13 13 @State private var lastSendTime: Date = .distantPast 14 + @State private var isTopBarExpanded = false 15 + @State private var showingSettings = false 14 16 15 17 private var isHeating: Bool { 16 18 bleManager.liveData.mode?.isActive ?? false ··· 80 82 // MARK: - Top Bar 81 83 82 84 private var topBar: some View { 83 - HStack(spacing: 16) { 84 - // Device name 85 - Text(bleManager.deviceName) 86 - .font(.subheadline.bold()) 87 - .lineLimit(1) 88 - .truncationMode(.tail) 85 + VStack(spacing: 0) { 86 + // Main top bar (always visible) 87 + Button { 88 + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { 89 + isTopBarExpanded.toggle() 90 + } 91 + } label: { 92 + HStack(spacing: 16) { 93 + // Device name 94 + Text(bleManager.deviceName) 95 + .font(.subheadline.bold()) 96 + .lineLimit(1) 97 + .truncationMode(.tail) 89 98 90 - Spacer(minLength: 8) 99 + Spacer(minLength: 8) 91 100 92 - // Stats 93 - HStack(spacing: 12) { 94 - statItem(value: String(format: "%.1f", bleManager.liveData.watts), unit: "W") 95 - statItem(value: String(format: "%.1f", bleManager.liveData.voltage), unit: "V") 96 - statItem(value: "\(bleManager.liveData.powerPercent)", unit: "%") 97 - } 98 - .layoutPriority(1) 101 + // Stats 102 + HStack(spacing: 12) { 103 + statItem(value: String(format: "%.1f", bleManager.liveData.watts), unit: "W") 104 + statItem(value: String(format: "%.1f", bleManager.liveData.voltage), unit: "V") 105 + statItem(value: "\(bleManager.liveData.powerPercent)", unit: "%") 106 + } 107 + .layoutPriority(1) 99 108 100 - // Mode indicator 101 - if let mode = bleManager.liveData.mode { 102 - Image(systemName: mode.icon) 103 - .font(.caption) 104 - .foregroundStyle(mode.isActive ? .orange : .secondary) 109 + // Mode indicator 110 + if let mode = bleManager.liveData.mode { 111 + Image(systemName: mode.icon) 112 + .font(.caption) 113 + .foregroundStyle(mode.isActive ? .orange : .secondary) 114 + } 115 + 116 + // Expand chevron 117 + Image(systemName: "chevron.down") 118 + .font(.caption2) 119 + .fontWeight(.semibold) 120 + .foregroundStyle(.secondary) 121 + .rotationEffect(.degrees(isTopBarExpanded ? 180 : 0)) 122 + } 123 + .padding(.horizontal, 20) 124 + .padding(.vertical, 12) 125 + } 126 + .buttonStyle(.plain) 127 + 128 + // Expanded info section 129 + if isTopBarExpanded { 130 + VStack(spacing: 12) { 131 + Divider() 132 + .padding(.horizontal, 20) 133 + 134 + VStack(spacing: 8) { 135 + HStack { 136 + detailItem(label: "Handle", value: String(format: "%.1f°C", bleManager.liveData.handleTempC)) 137 + Spacer() 138 + detailItem(label: "Tip Resist", value: String(format: "%.2f Ω", bleManager.liveData.resistance)) 139 + } 140 + 141 + HStack { 142 + detailItem(label: "Mode", value: bleManager.liveData.mode?.displayName ?? "Unknown") 143 + Spacer() 144 + detailItem(label: "Power", value: bleManager.liveData.power?.displayName ?? "Unknown") 145 + } 146 + 147 + if !bleManager.firmwareVersion.isEmpty { 148 + HStack { 149 + detailItem(label: "Firmware", value: bleManager.firmwareVersion) 150 + Spacer() 151 + } 152 + } 153 + } 154 + .padding(.horizontal, 20) 155 + .padding(.bottom, 8) 156 + 157 + // Settings button 158 + Button { 159 + showingSettings = true 160 + } label: { 161 + HStack { 162 + Image(systemName: "gear") 163 + Text("Settings & Info") 164 + } 165 + .font(.subheadline.weight(.medium)) 166 + .frame(maxWidth: .infinity) 167 + .padding(.vertical, 10) 168 + .background(Color.accentColor.opacity(0.1), in: RoundedRectangle(cornerRadius: 10)) 169 + } 170 + .buttonStyle(.plain) 171 + .padding(.horizontal, 20) 172 + .padding(.bottom, 12) 173 + } 174 + .transition(.opacity.combined(with: .move(edge: .top))) 105 175 } 106 176 } 107 - .padding(.horizontal, 20) 108 - .padding(.vertical, 12) 109 177 .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) 110 178 .padding(.horizontal, 16) 179 + .sheet(isPresented: $showingSettings) { 180 + SettingsView(bleManager: bleManager) 181 + } 111 182 } 112 183 113 184 // MARK: - Temperature Display ··· 186 257 .foregroundStyle(.secondary) 187 258 } 188 259 .fixedSize() 260 + } 261 + 262 + private func detailItem(label: String, value: String) -> some View { 263 + VStack(alignment: .leading, spacing: 2) { 264 + Text(label) 265 + .font(.caption2) 266 + .foregroundStyle(.secondary) 267 + Text(value) 268 + .font(.subheadline) 269 + .fontWeight(.medium) 270 + } 189 271 } 190 272 191 273 private func colorForTemp(_ temp: Double, maxTemp: Double) -> Color {
+8 -4
ios/PinecilTime/IronOSUUIDs.swift
··· 11 11 // MARK: - Bulk Data Service (for discovery) 12 12 static let bulkDataService = CBUUID(string: "9EAE1000-9D0D-48C5-AA55-33E27F9BC533") 13 13 static let bulkLiveData = CBUUID(string: "9EAE1001-9D0D-48C5-AA55-33E27F9BC533") 14 - static let firmwareVersion = CBUUID(string: "9EAE1003-9D0D-48C5-AA55-33E27F9BC533") 15 - static let deviceSerial = CBUUID(string: "9EAE1004-9D0D-48C5-AA55-33E27F9BC533") 14 + static let buildID = CBUUID(string: "9EAE1003-9D0D-48C5-AA55-33E27F9BC533") // Firmware build version 15 + static let deviceSerial = CBUUID(string: "9EAE1004-9D0D-48C5-AA55-33E27F9BC533") // MAC address 16 16 17 17 // MARK: - Live Data Service 18 18 static let liveDataService = CBUUID(string: "D85EF000-168E-4A71-AA55-33E27F9BC533") ··· 33 33 34 34 // MARK: - Settings Service 35 35 static let settingsService = CBUUID(string: "F6D80000-5A10-4EBA-AA55-33E27F9BC533") 36 - // Setting 0 = Setpoint temperature (writable) 37 - static let setpointSetting = CBUUID(string: "F6D70000-5A10-4EBA-AA55-33E27F9BC533") 38 36 static let saveSettings = CBUUID(string: "F6D7FFFF-5A10-4EBA-AA55-33E27F9BC533") 39 37 static let resetSettings = CBUUID(string: "F6D7FFFE-5A10-4EBA-AA55-33E27F9BC533") 38 + 39 + // Helper to generate setting UUID from index 40 + static func settingUUID(index: UInt16) -> CBUUID { 41 + let hexString = String(format: "F6D7%04X-5A10-4EBA-AA55-33E27F9BC533", index) 42 + return CBUUID(string: hexString) 43 + } 40 44 }
+480
ios/PinecilTime/SettingsView.swift
··· 1 + // 2 + // SettingsView.swift 3 + // PinecilTime 4 + // 5 + 6 + import SwiftUI 7 + 8 + struct SettingsView: View { 9 + @Environment(\.dismiss) var dismiss 10 + let bleManager: BLEManager 11 + @State private var selectedTab = 0 12 + 13 + var body: some View { 14 + NavigationStack { 15 + TabView(selection: $selectedTab) { 16 + ConfigurationView(bleManager: bleManager) 17 + .tabItem { 18 + Label("Settings", systemImage: "slider.horizontal.3") 19 + } 20 + .tag(0) 21 + 22 + DiagnosticsView(bleManager: bleManager) 23 + .tabItem { 24 + Label("Info", systemImage: "info.circle") 25 + } 26 + .tag(1) 27 + } 28 + .navigationTitle(selectedTab == 0 ? "Settings" : "Device Info") 29 + .navigationBarTitleDisplayMode(.inline) 30 + .toolbar { 31 + ToolbarItem(placement: .topBarTrailing) { 32 + Button("Done") { 33 + dismiss() 34 + } 35 + } 36 + } 37 + } 38 + } 39 + } 40 + 41 + // MARK: - Configuration View 42 + 43 + struct ConfigurationView: View { 44 + let bleManager: BLEManager 45 + @State private var settings: [Int: UInt16] = [:] 46 + @State private var isLoading = true 47 + @State private var saveInProgress = false 48 + 49 + var body: some View { 50 + List { 51 + Section("Temperature") { 52 + SettingRow( 53 + label: "Soldering Temp", 54 + value: Binding( 55 + get: { settings[0] ?? 320 }, 56 + set: { settings[0] = $0 } 57 + ), 58 + range: 10...450, 59 + step: 5, 60 + unit: "°C", 61 + onChange: { bleManager.writeSetting(index: 0, value: $0) } 62 + ) 63 + 64 + SettingRow( 65 + label: "Sleep Temp", 66 + value: Binding( 67 + get: { settings[1] ?? 150 }, 68 + set: { settings[1] = $0 } 69 + ), 70 + range: 10...450, 71 + step: 5, 72 + unit: "°C", 73 + onChange: { bleManager.writeSetting(index: 1, value: $0) } 74 + ) 75 + 76 + SettingRow( 77 + label: "Boost Temp", 78 + value: Binding( 79 + get: { settings[22] ?? 420 }, 80 + set: { settings[22] = $0 } 81 + ), 82 + range: 10...450, 83 + step: 10, 84 + unit: "°C", 85 + onChange: { bleManager.writeSetting(index: 22, value: $0) } 86 + ) 87 + } 88 + 89 + Section("Timers") { 90 + SettingRow( 91 + label: "Sleep Time", 92 + value: Binding( 93 + get: { settings[2] ?? 1 }, 94 + set: { settings[2] = $0 } 95 + ), 96 + range: 0...15, 97 + step: 1, 98 + unit: "min", 99 + onChange: { bleManager.writeSetting(index: 2, value: $0) } 100 + ) 101 + 102 + SettingRow( 103 + label: "Shutdown Time", 104 + value: Binding( 105 + get: { settings[11] ?? 10 }, 106 + set: { settings[11] = $0 } 107 + ), 108 + range: 0...60, 109 + step: 1, 110 + unit: "min", 111 + onChange: { bleManager.writeSetting(index: 11, value: $0) } 112 + ) 113 + } 114 + 115 + Section("Power") { 116 + SettingRow( 117 + label: "Power Limit", 118 + value: Binding( 119 + get: { settings[24] ?? 65 }, 120 + set: { settings[24] = $0 } 121 + ), 122 + range: 0...180, 123 + step: 5, 124 + unit: "W", 125 + onChange: { bleManager.writeSetting(index: 24, value: $0) } 126 + ) 127 + } 128 + 129 + Section("Display") { 130 + PickerSettingRow( 131 + label: "Orientation", 132 + value: Binding( 133 + get: { settings[6] ?? 2 }, 134 + set: { settings[6] = $0 } 135 + ), 136 + options: [ 137 + (0, "Right"), 138 + (1, "Left"), 139 + (2, "Auto") 140 + ], 141 + onChange: { bleManager.writeSetting(index: 6, value: $0) } 142 + ) 143 + 144 + SettingRow( 145 + label: "Brightness", 146 + value: Binding( 147 + get: { settings[34] ?? 51 }, 148 + set: { settings[34] = $0 } 149 + ), 150 + range: 1...101, 151 + step: 25, 152 + unit: "%", 153 + onChange: { bleManager.writeSetting(index: 34, value: $0) } 154 + ) 155 + 156 + ToggleSettingRow( 157 + label: "Invert Display", 158 + value: Binding( 159 + get: { settings[33] == 1 }, 160 + set: { settings[33] = $0 ? 1 : 0 } 161 + ), 162 + onChange: { bleManager.writeSetting(index: 33, value: $0 ? 1 : 0) } 163 + ) 164 + 165 + ToggleSettingRow( 166 + label: "Detailed Idle", 167 + value: Binding( 168 + get: { settings[13] == 1 }, 169 + set: { settings[13] = $0 ? 1 : 0 } 170 + ), 171 + onChange: { bleManager.writeSetting(index: 13, value: $0 ? 1 : 0) } 172 + ) 173 + 174 + ToggleSettingRow( 175 + label: "Detailed Soldering", 176 + value: Binding( 177 + get: { settings[14] == 1 }, 178 + set: { settings[14] = $0 ? 1 : 0 } 179 + ), 180 + onChange: { bleManager.writeSetting(index: 14, value: $0 ? 1 : 0) } 181 + ) 182 + } 183 + 184 + Section("Sensors") { 185 + SettingRow( 186 + label: "Motion Sensitivity", 187 + value: Binding( 188 + get: { settings[7] ?? 6 }, 189 + set: { settings[7] = $0 } 190 + ), 191 + range: 0...9, 192 + step: 1, 193 + unit: "", 194 + onChange: { bleManager.writeSetting(index: 7, value: $0) } 195 + ) 196 + 197 + SettingRow( 198 + label: "Hall Sensitivity", 199 + value: Binding( 200 + get: { settings[28] ?? 7 }, 201 + set: { settings[28] = $0 } 202 + ), 203 + range: 0...9, 204 + step: 1, 205 + unit: "", 206 + onChange: { bleManager.writeSetting(index: 28, value: $0) } 207 + ) 208 + } 209 + 210 + Section("Controls") { 211 + PickerSettingRow( 212 + label: "Locking Mode", 213 + value: Binding( 214 + get: { settings[17] ?? 0 }, 215 + set: { settings[17] = $0 } 216 + ), 217 + options: [ 218 + (0, "Off"), 219 + (1, "Boost Only"), 220 + (2, "Full") 221 + ], 222 + onChange: { bleManager.writeSetting(index: 17, value: $0) } 223 + ) 224 + 225 + ToggleSettingRow( 226 + label: "Reverse +/- Buttons", 227 + value: Binding( 228 + get: { settings[25] == 1 }, 229 + set: { settings[25] = $0 ? 1 : 0 } 230 + ), 231 + onChange: { bleManager.writeSetting(index: 25, value: $0 ? 1 : 0) } 232 + ) 233 + 234 + SettingRow( 235 + label: "Short Press Step", 236 + value: Binding( 237 + get: { settings[27] ?? 1 }, 238 + set: { settings[27] = $0 } 239 + ), 240 + range: 1...25, 241 + step: 1, 242 + unit: "°C", 243 + onChange: { bleManager.writeSetting(index: 27, value: $0) } 244 + ) 245 + 246 + SettingRow( 247 + label: "Long Press Step", 248 + value: Binding( 249 + get: { settings[26] ?? 10 }, 250 + set: { settings[26] = $0 } 251 + ), 252 + range: 5...50, 253 + step: 5, 254 + unit: "°C", 255 + onChange: { bleManager.writeSetting(index: 26, value: $0) } 256 + ) 257 + } 258 + 259 + Section { 260 + Button { 261 + saveInProgress = true 262 + bleManager.saveSettings() 263 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 264 + saveInProgress = false 265 + } 266 + } label: { 267 + HStack { 268 + Spacer() 269 + if saveInProgress { 270 + ProgressView() 271 + .padding(.trailing, 8) 272 + } 273 + Text("Save to Device") 274 + Spacer() 275 + } 276 + } 277 + .disabled(saveInProgress) 278 + } footer: { 279 + Text("Changes are written immediately but must be saved to persist across restarts.") 280 + } 281 + } 282 + .overlay { 283 + if isLoading { 284 + ProgressView("Loading settings...") 285 + .padding() 286 + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) 287 + } 288 + } 289 + .task { 290 + await loadSettings() 291 + } 292 + } 293 + 294 + private func loadSettings() async { 295 + // Load commonly used settings 296 + let settingsToLoad: [UInt16] = [0, 1, 2, 6, 7, 11, 13, 14, 17, 22, 24, 25, 26, 27, 28, 33, 34] 297 + 298 + for index in settingsToLoad { 299 + bleManager.readSetting(index: index) { value in 300 + if let value = value { 301 + settings[Int(index)] = value 302 + } 303 + } 304 + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms between reads 305 + } 306 + 307 + isLoading = false 308 + } 309 + } 310 + 311 + // MARK: - Diagnostics View 312 + 313 + struct DiagnosticsView: View { 314 + let bleManager: BLEManager 315 + 316 + var body: some View { 317 + List { 318 + Section("Device Information") { 319 + InfoRow(label: "Device Name", value: bleManager.deviceName) 320 + InfoRow(label: "Firmware", value: bleManager.firmwareVersion.isEmpty ? "Unknown" : bleManager.firmwareVersion) 321 + InfoRow(label: "Build ID", value: bleManager.buildID.isEmpty ? "Unknown" : bleManager.buildID) 322 + InfoRow(label: "Serial Number", value: bleManager.deviceSerial.isEmpty ? "Unknown" : bleManager.deviceSerial) 323 + } 324 + 325 + Section("Current Status") { 326 + InfoRow(label: "Temperature", value: "\(bleManager.liveData.liveTemp)°C") 327 + InfoRow(label: "Setpoint", value: "\(bleManager.liveData.setpoint)°C") 328 + InfoRow(label: "Max Temperature", value: "\(bleManager.liveData.maxTemp)°C") 329 + InfoRow(label: "Operating Mode", value: bleManager.liveData.mode?.displayName ?? "Unknown") 330 + } 331 + 332 + Section("Power") { 333 + InfoRow(label: "Voltage", value: String(format: "%.1f V", bleManager.liveData.voltage)) 334 + InfoRow(label: "Wattage", value: String(format: "%.1f W", bleManager.liveData.watts)) 335 + InfoRow(label: "Power Level", value: "\(bleManager.liveData.powerPercent)%") 336 + InfoRow(label: "Power Source", value: bleManager.liveData.power?.displayName ?? "Unknown") 337 + } 338 + 339 + Section("Diagnostics") { 340 + InfoRow(label: "Handle Temp", value: String(format: "%.1f°C", bleManager.liveData.handleTempC)) 341 + InfoRow(label: "Tip Resistance", value: String(format: "%.2f Ω", bleManager.liveData.resistance)) 342 + InfoRow(label: "Raw Tip", value: "\(bleManager.liveData.rawTip) μV") 343 + InfoRow(label: "Hall Sensor", value: "\(bleManager.liveData.hallSensor)") 344 + InfoRow(label: "Uptime", value: formatUptime(bleManager.liveData.uptime)) 345 + InfoRow(label: "Last Movement", value: formatTimeAgo(bleManager.liveData.uptime, lastMovement: bleManager.liveData.lastMovement)) 346 + } 347 + 348 + Section { 349 + Button(role: .destructive) { 350 + bleManager.disconnect() 351 + } label: { 352 + HStack { 353 + Spacer() 354 + Text("Disconnect") 355 + Spacer() 356 + } 357 + } 358 + } 359 + } 360 + } 361 + 362 + private func formatUptime(_ deciseconds: UInt32) -> String { 363 + let totalSeconds = deciseconds / 10 364 + let hours = totalSeconds / 3600 365 + let minutes = (totalSeconds % 3600) / 60 366 + let secs = totalSeconds % 60 367 + return String(format: "%02d:%02d:%02d", hours, minutes, secs) 368 + } 369 + 370 + private func formatTimeAgo(_ uptime: UInt32, lastMovement: UInt32) -> String { 371 + let uptimeSeconds = uptime / 10 372 + let lastMovementSeconds = lastMovement / 10 373 + 374 + if uptimeSeconds < lastMovementSeconds { 375 + return "just now" 376 + } 377 + 378 + let secondsAgo = uptimeSeconds - lastMovementSeconds 379 + if secondsAgo < 60 { 380 + return "\(secondsAgo)s ago" 381 + } else if secondsAgo < 3600 { 382 + let minutes = secondsAgo / 60 383 + return "\(minutes)m ago" 384 + } else { 385 + let hours = secondsAgo / 3600 386 + let minutes = (secondsAgo % 3600) / 60 387 + return "\(hours)h \(minutes)m ago" 388 + } 389 + } 390 + } 391 + 392 + // MARK: - Setting Row Components 393 + 394 + struct SettingRow: View { 395 + let label: String 396 + @Binding var value: UInt16 397 + let range: ClosedRange<UInt16> 398 + let step: UInt16 399 + let unit: String 400 + let onChange: (UInt16) -> Void 401 + 402 + var body: some View { 403 + VStack(alignment: .leading, spacing: 8) { 404 + HStack { 405 + Text(label) 406 + Spacer() 407 + Text("\(value) \(unit)") 408 + .foregroundStyle(.secondary) 409 + .monospacedDigit() 410 + } 411 + 412 + Slider( 413 + value: Binding( 414 + get: { Double(value) }, 415 + set: { value = UInt16($0) } 416 + ), 417 + in: Double(range.lowerBound)...Double(range.upperBound), 418 + step: Double(step), 419 + onEditingChanged: { editing in 420 + if !editing { 421 + onChange(value) 422 + } 423 + } 424 + ) 425 + } 426 + } 427 + } 428 + 429 + struct ToggleSettingRow: View { 430 + let label: String 431 + @Binding var value: Bool 432 + let onChange: (Bool) -> Void 433 + 434 + var body: some View { 435 + Toggle(label, isOn: Binding( 436 + get: { value }, 437 + set: { newValue in 438 + value = newValue 439 + onChange(newValue) 440 + } 441 + )) 442 + } 443 + } 444 + 445 + struct PickerSettingRow: View { 446 + let label: String 447 + @Binding var value: UInt16 448 + let options: [(UInt16, String)] 449 + let onChange: (UInt16) -> Void 450 + 451 + var body: some View { 452 + Picker(label, selection: Binding( 453 + get: { value }, 454 + set: { newValue in 455 + value = newValue 456 + onChange(newValue) 457 + } 458 + )) { 459 + ForEach(options, id: \.0) { option in 460 + Text(option.1).tag(option.0) 461 + } 462 + } 463 + } 464 + } 465 + 466 + struct InfoRow: View { 467 + let label: String 468 + let value: String 469 + 470 + var body: some View { 471 + HStack { 472 + Text(label) 473 + .foregroundStyle(.secondary) 474 + Spacer() 475 + Text(value) 476 + .foregroundStyle(.primary) 477 + .multilineTextAlignment(.trailing) 478 + } 479 + } 480 + }