Ver Fonte

feat: add Spark BLE recording support

bob.yuxinyang há 1 mês atrás
pai
commit
bb7b5735cf

+ 31 - 23
CelestiaTrace.xcodeproj/project.pbxproj

@@ -36,7 +36,11 @@
 		9BDA680497BF5DD72A7BA8F3 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6305E60488EEABAFE2E1D6D8 /* HomeView.swift */; };
 		9C5A0C9CDFCB8CFB10FF0B06 /* DeviceScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7063E6702335B02B65775F4 /* DeviceScanView.swift */; };
 		9E4554980E7FF484BF400458 /* MultiTrackTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC9982D9E53E1A77CDB0470 /* MultiTrackTimeline.swift */; };
+		A1B2C3D4E5F6012345678901 /* AudioPathHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* AudioPathHelper.swift */; };
 		A3F43A5200A626CBFB70395E /* CelestiaTimelineEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DAA0FBAEC0BF91F45D15B1E /* CelestiaTimelineEvent.swift */; };
+		A9B8C7D6E5F4000000000001 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000002 /* SplashView.swift */; };
+		A9B8C7D6E5F4000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000004 /* Assets.xcassets */; };
+		A9B8C7D6E5F4000000000007 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */; };
 		AF09E026C9AB15874D870E1F /* PulsingRecordButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D23B83EDDD5489004E42DC /* PulsingRecordButton.swift */; };
 		B262A6AF17A835DB666B2FAB /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EBB5FC2AC0B9D31C7A04715 /* SessionListView.swift */; };
 		BE12EF8734ABF645B2B28BBB /* PlaybackViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E528009D607763F9E68EFBAD /* PlaybackViewModel.swift */; };
@@ -49,10 +53,6 @@
 		F3CB51CC262E0F4929EB129B /* RemoteAuthServiceStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E13AE9CB7E8C9E3BACFC679 /* RemoteAuthServiceStub.swift */; };
 		F5035B942DE1FEC10515D9D6 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADF67536C0CF9AE7F405C74 /* ImagePicker.swift */; };
 		FD30DBB57E19055AB8F0A956 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4883D42730E6133FCC3C81C2 /* SettingsView.swift */; };
-		A1B2C3D4E5F6012345678901 /* AudioPathHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6012345678902 /* AudioPathHelper.swift */; };
-		A9B8C7D6E5F4000000000001 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000002 /* SplashView.swift */; };
-		A9B8C7D6E5F4000000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000004 /* Assets.xcassets */; };
-		A9B8C7D6E5F4000000000007 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -100,6 +100,7 @@
 		A9B8C7D6E5F4000000000002 /* SplashView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
 		A9B8C7D6E5F4000000000004 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
 		A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
+		A9B8C7D6E5F4000000000009 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		AA13A08133A50094CBFFDDF9 /* EditProfileModalView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditProfileModalView.swift; sourceTree = "<group>"; };
 		AEED3C75367F66D6D6CE745D /* SilenceDetector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SilenceDetector.swift; sourceTree = "<group>"; };
 		B7B9693BD9924C08A1F2ECD1 /* ActiveRecordingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ActiveRecordingView.swift; sourceTree = "<group>"; };
@@ -245,6 +246,24 @@
 			path = History;
 			sourceTree = "<group>";
 		};
+		A9B8C7D6E5F4000000000005 /* Splash */ = {
+			isa = PBXGroup;
+			children = (
+				A9B8C7D6E5F4000000000002 /* SplashView.swift */,
+			);
+			path = Splash;
+			sourceTree = "<group>";
+		};
+		A9B8C7D6E5F4000000000006 /* Resources */ = {
+			isa = PBXGroup;
+			children = (
+				A9B8C7D6E5F4000000000004 /* Assets.xcassets */,
+				A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */,
+				A9B8C7D6E5F4000000000009 /* Info.plist */,
+			);
+			path = Resources;
+			sourceTree = "<group>";
+		};
 		AA9F20B44A68E171B39266B0 /* iOS */ = {
 			isa = PBXGroup;
 			children = (
@@ -360,23 +379,6 @@
 			path = Devices;
 			sourceTree = "<group>";
 		};
-		A9B8C7D6E5F4000000000005 /* Splash */ = {
-			isa = PBXGroup;
-			children = (
-				A9B8C7D6E5F4000000000002 /* SplashView.swift */,
-			);
-			path = Splash;
-			sourceTree = "<group>";
-		};
-		A9B8C7D6E5F4000000000006 /* Resources */ = {
-			isa = PBXGroup;
-			children = (
-				A9B8C7D6E5F4000000000004 /* Assets.xcassets */,
-				A9B8C7D6E5F4000000000008 /* LaunchScreen.storyboard */,
-			);
-			path = Resources;
-			sourceTree = "<group>";
-		};
 		FD4D76A3358AAF1519C279F1 /* Audio */ = {
 			isa = PBXGroup;
 			children = (
@@ -617,9 +619,11 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CLANG_ENABLE_OBJC_WEAK = NO;
 				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_ASSET_PATHS = "\"\"";
 				DEVELOPMENT_TEAM = 5ERVQEJ497;
-				GENERATE_INFOPLIST_FILE = YES;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = CelestiaTrace/Resources/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "星痕";
 				INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "需要蓝牙权限以扫描和绑定 BLE 录音设备";
 				INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "需要蓝牙权限以与录音设备传输数据";
@@ -630,6 +634,7 @@
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
 				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = com.celestia.trace;
 				SDKROOT = iphoneos;
 				SWIFT_VERSION = 5.9;
@@ -644,9 +649,11 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CLANG_ENABLE_OBJC_WEAK = NO;
 				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
 				DEVELOPMENT_ASSET_PATHS = "\"\"";
 				DEVELOPMENT_TEAM = 5ERVQEJ497;
-				GENERATE_INFOPLIST_FILE = YES;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = CelestiaTrace/Resources/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = "星痕";
 				INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "需要蓝牙权限以扫描和绑定 BLE 录音设备";
 				INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "需要蓝牙权限以与录音设备传输数据";
@@ -657,6 +664,7 @@
 				INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
 				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+				MARKETING_VERSION = 1.0.0;
 				PRODUCT_BUNDLE_IDENTIFIER = com.celestia.trace;
 				SDKROOT = iphoneos;
 				SWIFT_VERSION = 5.9;

+ 15 - 3
CelestiaTrace/Models/BoundDevice.swift

@@ -2,14 +2,18 @@ import Foundation
 
 /// Represents a physical recording device bound to a user account via BLE.
 struct BoundDevice: Codable, Identifiable, Equatable {
-    let id: String           // Unique device hardware ID / MAC / Serial
-    var name: String         // Display name of the device
+    let id: String           // CoreBluetooth peripheral identifier
+    var name: String         // User-defined display name
     var peripheralUUID: String // CoreBluetooth Peripheral UUID
     var boundAt: Date        // Date when bound to user account
     var isConnected: Bool    // Connection status (runtime state)
     var batteryLevel: Int?   // Battery percentage (0 - 100)
     var firmwareVersion: String? // Firmware version
     var boundUserId: String  // User account ID owning this device
+    var advertisedName: String? // Name reported in the BLE advertisement
+    var hardwareMAC: String? // Hardware identity returned by PQ_BLE&MAC
+    var freeStorageMB: Int?
+    var totalStorageMB: Int?
     
     init(
         id: String = UUID().uuidString,
@@ -19,7 +23,11 @@ struct BoundDevice: Codable, Identifiable, Equatable {
         isConnected: Bool = false,
         batteryLevel: Int? = 85,
         firmwareVersion: String? = "v1.2.0",
-        boundUserId: String
+        boundUserId: String,
+        advertisedName: String? = nil,
+        hardwareMAC: String? = nil,
+        freeStorageMB: Int? = nil,
+        totalStorageMB: Int? = nil
     ) {
         self.id = id
         self.name = name
@@ -29,6 +37,10 @@ struct BoundDevice: Codable, Identifiable, Equatable {
         self.batteryLevel = batteryLevel
         self.firmwareVersion = firmwareVersion
         self.boundUserId = boundUserId
+        self.advertisedName = advertisedName
+        self.hardwareMAC = hardwareMAC
+        self.freeStorageMB = freeStorageMB
+        self.totalStorageMB = totalStorageMB
     }
 }
 

+ 46 - 0
CelestiaTrace/Resources/Info.plist

@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>CFBundleDisplayName</key>
+	<string>星痕</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+	<key>CFBundleShortVersionString</key>
+	<string>$(MARKETING_VERSION)</string>
+	<key>CFBundleVersion</key>
+	<string>$(CURRENT_PROJECT_VERSION)</string>
+	<key>LSRequiresIPhoneOS</key>
+	<true/>
+	<key>NSBluetoothAlwaysUsageDescription</key>
+	<string>需要蓝牙权限以搜索、绑定并使用微光(Spark)录音设备</string>
+	<key>NSBluetoothPeripheralUsageDescription</key>
+	<string>需要蓝牙权限以与微光(Spark)传输录音数据</string>
+	<key>NSCameraUsageDescription</key>
+	<string>需要相机权限以在现场录制中拍摄照片记录</string>
+	<key>NSMicrophoneUsageDescription</key>
+	<string>需要麦克风权限以使用 iPhone 进行现场录音</string>
+	<key>NSPhotoLibraryUsageDescription</key>
+	<string>需要相册权限以选择照片记录</string>
+	<key>UIBackgroundModes</key>
+	<array>
+		<string>bluetooth-central</string>
+	</array>
+	<key>UILaunchStoryboardName</key>
+	<string>LaunchScreen</string>
+	<key>UISupportedInterfaceOrientations</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+	</array>
+</dict>
+</plist>

+ 61 - 0
CelestiaTrace/Services/Audio/AudioRecorderProtocol.swift

@@ -1,6 +1,49 @@
 import Foundation
 import Combine
 
+/// The physical source selected for one recording segment.
+enum RecordingSourceChoice: Identifiable, Equatable {
+    case iPhone
+    case spark(deviceID: String, displayName: String)
+
+    var id: String {
+        switch self {
+        case .iPhone:
+            return "iphone"
+        case .spark(let deviceID, _):
+            return "spark-\(deviceID)"
+        }
+    }
+
+    var displayName: String {
+        switch self {
+        case .iPhone:
+            return "iPhone 麦克风"
+        case .spark(_, let displayName):
+            return displayName
+        }
+    }
+
+    var systemImage: String {
+        switch self {
+        case .iPhone: return "iphone"
+        case .spark: return "wave.3.right.circle.fill"
+        }
+    }
+}
+
+enum AudioRecorderError: LocalizedError {
+    case failedToStart(String)
+    case failedToStop(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .failedToStart(let message), .failedToStop(let message):
+            return message
+        }
+    }
+}
+
 /// Protocol abstracting audio recording capabilities.
 /// Allows swapping between Mock and Real (AVAudioEngine) implementations.
 protocol AudioRecorderProtocol: ObservableObject {
@@ -16,9 +59,27 @@ protocol AudioRecorderProtocol: ObservableObject {
     var outputFileURL: URL? { get }
     /// Whether the active recording is paused
     var isPaused: Bool { get }
+    /// User-facing name of the active recording source.
+    var sourceDisplayName: String { get }
+    /// Current source status, such as connecting or recording.
+    var statusMessage: String { get }
+    /// A recoverable error that should be shown in the recording UI.
+    var errorMessage: String? { get }
     
     func startRecording()
     func stopRecording()
+    func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void)
     func pauseRecording()
     func resumeRecording()
 }
+
+extension AudioRecorderProtocol {
+    var sourceDisplayName: String { "iPhone 麦克风" }
+    var statusMessage: String { isRecording ? "正在录音" : "正在准备" }
+    var errorMessage: String? { nil }
+
+    func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
+        stopRecording()
+        completion(.success(outputFileURL))
+    }
+}

+ 946 - 82
CelestiaTrace/Services/Bluetooth/BLEManager.swift

@@ -1,127 +1,991 @@
 import Foundation
 import CoreBluetooth
 import Combine
+import Security
 
-/// Manages Bluetooth Low Energy (BLE) scanning, device connection, and binding to user accounts.
+enum SparkConnectionPhase: Equatable {
+    case disconnected
+    case connecting
+    case discovering
+    case authenticating
+    case ready
+    case recording
+    case reconnecting
+    case failed(String)
+}
+
+enum SparkRecorderConnectionEvent {
+    case recording(fileName: String)
+    case reconnecting
+    case ready
+    case failed(String)
+}
+
+enum SparkBLEError: LocalizedError {
+    case bluetoothUnavailable
+    case deviceUnavailable
+    case incompatibleDevice
+    case connectionFailed(String)
+    case commandUnavailable
+    case commandTimedOut(String)
+    case pairingFailed
+    case deviceReported(String)
+
+    var errorDescription: String? {
+        switch self {
+        case .bluetoothUnavailable: return "蓝牙当前不可用,请确认系统蓝牙已开启。"
+        case .deviceUnavailable: return "找不到该微光设备,请让设备靠近 iPhone 后重试。"
+        case .incompatibleDevice: return "该设备没有提供微光录音服务。"
+        case .connectionFailed(let reason): return "连接微光失败:\(reason)"
+        case .commandUnavailable: return "微光指令通道尚未就绪。"
+        case .commandTimedOut(let command): return "设备未及时响应指令 \(command)。"
+        case .pairingFailed: return "微光密钥配对失败,请重置设备后重试。"
+        case .deviceReported(let message): return message
+        }
+    }
+}
+
+/// Owns the CoreBluetooth central role and implements the MR20/Spark command protocol.
 final class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
     static let shared = BLEManager()
 
+    static let serviceUUID = CBUUID(string: "001120a0-2233-4455-6677-88995a5b5c5d")
+    static let audioNotifyUUID = CBUUID(string: "001120a1-2233-4455-6677-88995a5b5c5d")
+    static let writeUUID = CBUUID(string: "001120a2-2233-4455-6677-88995a5b5c5d")
+    static let commandNotifyUUID = CBUUID(string: "001120a3-2233-4455-6677-88995a5b5c5d")
+
     @Published private(set) var state: CBManagerState = .unknown
-    @Published var isScanning: Bool = false
-    @Published var discoveredDevices: [DiscoveredBLEDevice] = []
-    @Published var boundDevices: [BoundDevice] = []
-    
+    @Published private(set) var isScanning: Bool = false
+    @Published private(set) var discoveredDevices: [DiscoveredBLEDevice] = []
+    @Published private(set) var boundDevices: [BoundDevice] = []
+    @Published private(set) var connectionPhases: [String: SparkConnectionPhase] = [:]
+    @Published private(set) var recordingDeviceID: String?
+    @Published private(set) var lastErrorMessage: String?
+
+    private struct SparkCharacteristics {
+        var audioNotify: CBCharacteristic?
+        var commandNotify: CBCharacteristic?
+        var write: CBCharacteristic?
+
+        var isComplete: Bool {
+            audioNotify != nil && commandNotify != nil && write != nil
+        }
+    }
+
+    private struct PendingBinding {
+        let discoveredDevice: DiscoveredBLEDevice
+        let userID: String
+        let password: String
+        let completion: (Result<BoundDevice, Error>) -> Void
+    }
+
+    private struct PendingCommand {
+        let token: UUID
+        let completion: (Result<String, Error>) -> Void
+    }
+
     private var centralManager: CBCentralManager!
     private let boundDevicesKey = "com.celestia.trace.bound_devices"
-    
+    private let restorationIdentifier = "com.celestia.trace.spark.central"
+    private let keychainService = "com.celestia.trace.spark.pairing"
+
+    private var peripherals: [String: CBPeripheral] = [:]
+    private var characteristics: [String: SparkCharacteristics] = [:]
+    private var pendingBindings: [String: PendingBinding] = [:]
+    private var authenticatingDeviceIDs: Set<String> = []
+    private var pendingStarts: [String: PendingCommand] = [:]
+    private var pendingStops: [String: PendingCommand] = [:]
+    private var audioConsumers: [String: (Data) -> Void] = [:]
+    private var recorderStateConsumers: [String: (SparkRecorderConnectionEvent) -> Void] = [:]
+    private var scanRequested = false
+
     override private init() {
         super.init()
-        self.centralManager = CBCentralManager(delegate: self, queue: nil)
         loadBoundDevices()
+        centralManager = CBCentralManager(
+            delegate: self,
+            queue: nil,
+            options: [CBCentralManagerOptionRestoreIdentifierKey: restorationIdentifier]
+        )
     }
-    
-    // MARK: - CentralManager Delegate
-    
-    func centralManagerDidUpdateState(_ central: CBCentralManager) {
-        DispatchQueue.main.async {
-            self.state = central.state
-        }
+
+    // MARK: - Public device access
+
+    func devices(forUserId userId: String) -> [BoundDevice] {
+        boundDevices.filter { $0.boundUserId == userId }
     }
-    
-    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
-        let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "现场录音设备"
-        let device = DiscoveredBLEDevice(
-            id: peripheral.identifier.uuidString,
-            name: name,
-            rssi: RSSI.intValue,
-            peripheralUUID: peripheral.identifier.uuidString
-        )
-        
-        DispatchQueue.main.async {
-            if !self.discoveredDevices.contains(where: { $0.id == device.id }) {
-                self.discoveredDevices.append(device)
+
+    func connectedDevices(forUserId userId: String) -> [BoundDevice] {
+        devices(forUserId: userId).filter { $0.isConnected }
+    }
+
+    func phase(for deviceID: String) -> SparkConnectionPhase {
+        connectionPhases[deviceID] ?? .disconnected
+    }
+
+    func renameDevice(id: String, to proposedName: String) {
+        let trimmed = proposedName.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty, let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
+        boundDevices[index].name = String(trimmed.prefix(30))
+        saveBoundDevices()
+    }
+
+    func unbindDevice(id: String) {
+        var peripheralToDisconnect: CBPeripheral?
+        if boundDevices.first(where: { $0.id == id })?.isConnected == true {
+            try? writeCommand("PQ_BLE&BLE&OFF", to: id)
+            peripheralToDisconnect = peripherals[id]
+        }
+        deletePairingPassword(for: id)
+        boundDevices.removeAll(where: { $0.id == id })
+        connectionPhases[id] = .disconnected
+        if let peripheralToDisconnect {
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self, weak peripheralToDisconnect] in
+                guard let self, let peripheralToDisconnect else { return }
+                self.centralManager.cancelPeripheralConnection(peripheralToDisconnect)
             }
+        } else if let peripheral = peripherals[id] {
+            centralManager.cancelPeripheralConnection(peripheral)
         }
+        saveBoundDevices()
     }
-    
-    // MARK: - Scanning & Mocking
-    
+
+    // MARK: - Scanning and binding
+
     func startScanning() {
         discoveredDevices.removeAll()
+        lastErrorMessage = nil
+        scanRequested = true
+
+        guard centralManager.state == .poweredOn else {
+            isScanning = true
+            return
+        }
+
         isScanning = true
-        
-        if centralManager.state == .poweredOn {
-            centralManager.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
-        }
-        
-        // Populate mock devices for simulator or testing environment
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
-            guard let self = self, self.isScanning else { return }
-            let mockDevices = [
-                DiscoveredBLEDevice(id: "MOCK-BLE-01", name: "星痕专业录音麦克风 01", rssi: -58, peripheralUUID: "0000180A-0000-1000-8000-00805F9B34FB"),
-                DiscoveredBLEDevice(id: "MOCK-BLE-02", name: "星痕无线音频集线器", rssi: -72, peripheralUUID: "0000180F-0000-1000-8000-00805F9B34FB"),
-                DiscoveredBLEDevice(id: "MOCK-BLE-03", name: "智能胸卡录音器 A2", rssi: -85, peripheralUUID: "0000181A-0000-1000-8000-00805F9B34FB")
+        // The vendor sheet doesn't guarantee that the service UUID is advertised,
+        // so discovery is broad and results are filtered by service/name hints.
+        centralManager.scanForPeripherals(
+            withServices: nil,
+            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
+        )
+
+#if targetEnvironment(simulator)
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
+            guard let self, self.isScanning else { return }
+            self.discoveredDevices = [
+                DiscoveredBLEDevice(
+                    id: "MOCK-SPARK-01",
+                    name: "MR20 Spark Demo",
+                    rssi: -55,
+                    peripheralUUID: "MOCK-SPARK-01"
+                )
             ]
-            for device in mockDevices {
-                if !self.discoveredDevices.contains(where: { $0.id == device.id }) {
-                    self.discoveredDevices.append(device)
-                }
-            }
         }
+#endif
     }
-    
+
     func stopScanning() {
+        scanRequested = false
         if centralManager.state == .poweredOn {
             centralManager.stopScan()
         }
         isScanning = false
     }
-    
-    // MARK: - Binding Management
-    
-    /// Binds a discovered BLE device to the specified user account ID.
-    func bindDevice(_ device: DiscoveredBLEDevice, userId: String) -> BoundDevice {
-        let boundDevice = BoundDevice(
-            id: device.id,
-            name: device.name,
-            peripheralUUID: device.peripheralUUID,
-            boundAt: Date(),
-            isConnected: true,
-            batteryLevel: Int.random(in: 75...99),
-            firmwareVersion: "v1.4.2",
-            boundUserId: userId
+
+    func connectAndBind(
+        _ device: DiscoveredBLEDevice,
+        userId: String,
+        completion: @escaping (Result<BoundDevice, Error>) -> Void
+    ) {
+#if targetEnvironment(simulator)
+        if device.id.hasPrefix("MOCK-") {
+            let bound = BoundDevice(
+                id: device.id,
+                name: "微光(Spark)",
+                peripheralUUID: device.peripheralUUID,
+                isConnected: true,
+                batteryLevel: 86,
+                firmwareVersion: "Demo",
+                boundUserId: userId,
+                advertisedName: device.name,
+                freeStorageMB: 1_024,
+                totalStorageMB: 2_048
+            )
+            upsertBoundDevice(bound)
+            connectionPhases[device.id] = .ready
+            completion(.success(bound))
+            return
+        }
+#endif
+
+        guard centralManager.state == .poweredOn else {
+            completion(.failure(SparkBLEError.bluetoothUnavailable))
+            return
+        }
+        guard let peripheral = peripherals[device.id] else {
+            completion(.failure(SparkBLEError.deviceUnavailable))
+            return
+        }
+
+        let password = generatePairingPassword()
+        pendingBindings[device.id] = PendingBinding(
+            discoveredDevice: device,
+            userID: userId,
+            password: password,
+            completion: completion
         )
-        
-        // Unbind any existing duplicate
-        boundDevices.removeAll(where: { $0.id == boundDevice.id })
-        boundDevices.append(boundDevice)
+        connectionPhases[device.id] = .connecting
+        peripheral.delegate = self
+        stopScanning()
+        centralManager.connect(peripheral, options: nil)
+    }
+
+    // MARK: - Spark recording control
+
+    func startSparkRecording(
+        deviceID: String,
+        onAudioData: @escaping (Data) -> Void,
+        onStateChange: @escaping (SparkRecorderConnectionEvent) -> Void,
+        completion: @escaping (Result<String, Error>) -> Void
+    ) {
+        guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else {
+            completion(.failure(SparkBLEError.deviceUnavailable))
+            return
+        }
+
+        audioConsumers[deviceID] = onAudioData
+        recorderStateConsumers[deviceID] = onStateChange
+        let token = UUID()
+        pendingStarts[deviceID] = PendingCommand(token: token, completion: completion)
+
+        do {
+            try writeCommand("PQ_BLE&STA", to: deviceID)
+            scheduleTimeout(for: deviceID, command: "STA", token: token, isStart: true)
+        } catch {
+            pendingStarts.removeValue(forKey: deviceID)
+            completion(.failure(error))
+        }
+    }
+
+    func stopSparkRecording(
+        deviceID: String,
+        completion: @escaping (Result<String, Error>) -> Void
+    ) {
+        guard boundDevices.first(where: { $0.id == deviceID })?.isConnected == true else {
+            completion(.failure(SparkBLEError.deviceUnavailable))
+            return
+        }
+
+        let token = UUID()
+        pendingStops[deviceID] = PendingCommand(token: token, completion: completion)
+        do {
+            try writeCommand("PQ_BLE&STO", to: deviceID)
+            scheduleTimeout(for: deviceID, command: "STO", token: token, isStart: false)
+        } catch {
+            pendingStops.removeValue(forKey: deviceID)
+            completion(.failure(error))
+        }
+    }
+
+    func detachRecorder(deviceID: String) {
+        audioConsumers.removeValue(forKey: deviceID)
+        recorderStateConsumers.removeValue(forKey: deviceID)
+    }
+
+    // MARK: - CBCentralManagerDelegate
+
+    func centralManagerDidUpdateState(_ central: CBCentralManager) {
+        state = central.state
+        guard central.state == .poweredOn else {
+            markAllDevicesDisconnected()
+            return
+        }
+        reconnectBoundDevices()
+        if scanRequested {
+            isScanning = true
+            central.scanForPeripherals(
+                withServices: nil,
+                options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
+            )
+        }
+    }
+
+    func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
+        let restored = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? []
+        for peripheral in restored {
+            let id = peripheral.identifier.uuidString
+            peripherals[id] = peripheral
+            peripheral.delegate = self
+            connectionPhases[id] = peripheral.state == .connected ? .discovering : .reconnecting
+            if peripheral.state == .connected {
+                peripheral.discoverServices([Self.serviceUUID])
+            } else {
+                central.connect(peripheral, options: nil)
+            }
+        }
+    }
+
+    func centralManager(
+        _ central: CBCentralManager,
+        didDiscover peripheral: CBPeripheral,
+        advertisementData: [String: Any],
+        rssi RSSI: NSNumber
+    ) {
+        let id = peripheral.identifier.uuidString
+        let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
+            ?? peripheral.name
+            ?? "微光录音设备"
+        let advertisedServices = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
+        let normalizedName = advertisedName.lowercased()
+        let matchesProduct = advertisedServices.contains(Self.serviceUUID)
+            || normalizedName.contains("spark")
+            || normalizedName.contains("mr20")
+            || normalizedName.contains("微光")
+        guard matchesProduct else { return }
+
+        peripherals[id] = peripheral
+        peripheral.delegate = self
+        let device = DiscoveredBLEDevice(
+            id: id,
+            name: advertisedName,
+            rssi: RSSI.intValue,
+            peripheralUUID: id
+        )
+        if let index = discoveredDevices.firstIndex(where: { $0.id == id }) {
+            discoveredDevices[index] = device
+        } else {
+            discoveredDevices.append(device)
+        }
+    }
+
+    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
+        let id = peripheral.identifier.uuidString
+        peripherals[id] = peripheral
+        peripheral.delegate = self
+        connectionPhases[id] = .discovering
+        peripheral.discoverServices([Self.serviceUUID])
+    }
+
+    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        let failure = SparkBLEError.connectionFailed(error?.localizedDescription ?? "未知错误")
+        connectionPhases[id] = .failed(failure.localizedDescription)
+        finishPendingBinding(deviceID: id, result: .failure(failure))
+    }
+
+    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        updateBoundDevice(id: id) { $0.isConnected = false }
+        characteristics.removeValue(forKey: id)
+        authenticatingDeviceIDs.remove(id)
+
+        if recordingDeviceID == id {
+            connectionPhases[id] = .reconnecting
+            recorderStateConsumers[id]?(.reconnecting)
+        } else {
+            connectionPhases[id] = .disconnected
+        }
+
+        if let pending = pendingStarts.removeValue(forKey: id) {
+            pending.completion(.failure(SparkBLEError.connectionFailed("设备已断开")))
+        }
+        if let pending = pendingStops.removeValue(forKey: id) {
+            pending.completion(.failure(SparkBLEError.connectionFailed("停止录音前设备已断开")))
+        }
+        finishPendingBinding(deviceID: id, result: .failure(SparkBLEError.connectionFailed("设备已断开")))
+
+        guard boundDevices.contains(where: { $0.id == id }) else { return }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self, weak peripheral] in
+            guard let self, let peripheral, self.centralManager.state == .poweredOn else { return }
+            self.connectionPhases[id] = .reconnecting
+            self.centralManager.connect(peripheral, options: nil)
+        }
+    }
+
+    // MARK: - CBPeripheralDelegate
+
+    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
+        let id = peripheral.identifier.uuidString
+        guard error == nil,
+              let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
+            let failure = SparkBLEError.incompatibleDevice
+            connectionPhases[id] = .failed(failure.localizedDescription)
+            finishPendingBinding(deviceID: id, result: .failure(failure))
+            centralManager.cancelPeripheralConnection(peripheral)
+            return
+        }
+        peripheral.discoverCharacteristics(
+            [Self.audioNotifyUUID, Self.commandNotifyUUID, Self.writeUUID],
+            for: service
+        )
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        guard error == nil else {
+            failTransport(deviceID: id, message: error?.localizedDescription ?? "发现特征失败")
+            return
+        }
+
+        var set = SparkCharacteristics()
+        for characteristic in service.characteristics ?? [] {
+            switch characteristic.uuid {
+            case Self.audioNotifyUUID: set.audioNotify = characteristic
+            case Self.commandNotifyUUID: set.commandNotify = characteristic
+            case Self.writeUUID: set.write = characteristic
+            default: break
+            }
+        }
+        guard set.isComplete, let audio = set.audioNotify, let command = set.commandNotify else {
+            failTransport(deviceID: id, message: "设备缺少必要的录音特征")
+            return
+        }
+        characteristics[id] = set
+        peripheral.setNotifyValue(true, for: command)
+        peripheral.setNotifyValue(true, for: audio)
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        guard error == nil else {
+            failTransport(deviceID: id, message: error?.localizedDescription ?? "订阅设备通知失败")
+            return
+        }
+        guard let set = characteristics[id],
+              set.audioNotify?.isNotifying == true,
+              set.commandNotify?.isNotifying == true else { return }
+        transportDidBecomeReady(deviceID: id)
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
+        let id = peripheral.identifier.uuidString
+        guard error == nil, let data = characteristic.value else { return }
+
+        if characteristic.uuid == Self.audioNotifyUUID {
+            audioConsumers[id]?(data)
+        } else if characteristic.uuid == Self.commandNotifyUUID {
+            for message in decodeCommandMessages(data) {
+                handleCommand(message, deviceID: id)
+            }
+        }
+    }
+
+    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
+        guard let error else { return }
+        let id = peripheral.identifier.uuidString
+        lastErrorMessage = "发送设备指令失败:\(error.localizedDescription)"
+        recorderStateConsumers[id]?(.failed(lastErrorMessage ?? "发送设备指令失败"))
+    }
+
+    // MARK: - Protocol handling
+
+    private func transportDidBecomeReady(deviceID: String) {
+        if let pending = pendingBindings[deviceID] {
+            connectionPhases[deviceID] = .authenticating
+            do {
+                try writeCommand("PQ_BLE&SK&\(pending.password)", to: deviceID)
+            } catch {
+                finishPendingBinding(deviceID: deviceID, result: .failure(error))
+            }
+            return
+        }
+
+        if let password = pairingPassword(for: deviceID) {
+            authenticatingDeviceIDs.insert(deviceID)
+            connectionPhases[deviceID] = .authenticating
+            do {
+                try writeCommand("PQ_BLE&SK&\(password)", to: deviceID)
+            } catch {
+                authenticatingDeviceIDs.remove(deviceID)
+                markDeviceReady(deviceID)
+            }
+        } else {
+            // Existing pre-Spark installations have no key in Keychain. Keep them
+            // usable and let the first explicit rebind establish one.
+            markDeviceReady(deviceID)
+        }
+    }
+
+    private func handleCommand(_ message: String, deviceID: String) {
+        let fields = message.components(separatedBy: "&")
+        guard fields.count >= 2 else { return }
+
+        if message.hasPrefix("PQ_DEV&SK&OK") {
+            if let pending = pendingBindings[deviceID] {
+                savePairingPassword(pending.password, for: deviceID)
+                var bound = BoundDevice(
+                    id: deviceID,
+                    name: "微光(Spark)",
+                    peripheralUUID: pending.discoveredDevice.peripheralUUID,
+                    boundAt: Date(),
+                    isConnected: true,
+                    batteryLevel: nil,
+                    firmwareVersion: nil,
+                    boundUserId: pending.userID,
+                    advertisedName: pending.discoveredDevice.name
+                )
+                if let existing = boundDevices.first(where: { $0.id == deviceID }) {
+                    bound.name = existing.name
+                }
+                upsertBoundDevice(bound)
+                connectionPhases[deviceID] = .ready
+                pending.completion(.success(bound))
+                pendingBindings.removeValue(forKey: deviceID)
+                queryDeviceStatus(deviceID)
+            } else if authenticatingDeviceIDs.remove(deviceID) != nil {
+                markDeviceReady(deviceID)
+            }
+            return
+        }
+
+        if message.hasPrefix("PQ_DEV&SK&ERR") {
+            authenticatingDeviceIDs.remove(deviceID)
+            finishPendingBinding(deviceID: deviceID, result: .failure(SparkBLEError.pairingFailed))
+            connectionPhases[deviceID] = .failed(SparkBLEError.pairingFailed.localizedDescription)
+            return
+        }
+
+        if message.hasPrefix("PQ_DEV&STA&") {
+            let fileName = fields.dropFirst(2).joined(separator: "&")
+            recordingDeviceID = deviceID
+            connectionPhases[deviceID] = .recording
+            recorderStateConsumers[deviceID]?(.recording(fileName: fileName))
+            pendingStarts.removeValue(forKey: deviceID)?.completion(.success(fileName))
+            return
+        }
+
+        if message == "PQ_DEV&STO" || message.hasPrefix("PQ_DEV&STO&") {
+            recordingDeviceID = nil
+            connectionPhases[deviceID] = .ready
+            pendingStops.removeValue(forKey: deviceID)?.completion(.success(message))
+            recorderStateConsumers[deviceID]?(.ready)
+            return
+        }
+
+        if message.hasPrefix("PQ_DEV&RT&") {
+            recordingDeviceID = deviceID
+            connectionPhases[deviceID] = .recording
+            if fields.count >= 4 {
+                recorderStateConsumers[deviceID]?(.recording(fileName: fields[2]))
+            }
+            return
+        }
+
+        if message.hasPrefix("PQ_DEV&BAT&"), let value = fields.last.flatMap(Int.init) {
+            updateBoundDevice(id: deviceID) { $0.batteryLevel = min(100, max(0, value)) }
+        } else if message.hasPrefix("PQ_DEV&STE&"), let value = fields.last.flatMap(Int.init) {
+            if value == 1 {
+                recordingDeviceID = deviceID
+                connectionPhases[deviceID] = .recording
+            } else if recordingDeviceID == deviceID {
+                recordingDeviceID = nil
+                connectionPhases[deviceID] = .ready
+            }
+        } else if message.hasPrefix("PQ_DEV&FW&"), fields.count >= 3 {
+            updateBoundDevice(id: deviceID) { $0.firmwareVersion = fields.dropFirst(2).joined(separator: "&") }
+        } else if message.hasPrefix("PQ_DEV&SPA&"), fields.count >= 4 {
+            updateBoundDevice(id: deviceID) {
+                $0.freeStorageMB = Int(fields[2])
+                $0.totalStorageMB = Int(fields[3])
+            }
+        } else if message.hasPrefix("PQ_DEV&MAC&"), fields.count >= 3 {
+            updateBoundDevice(id: deviceID) { $0.hardwareMAC = fields[2] }
+        } else if message == "PQ_EV&REC&ERR" || message == "PQ_DEV&REC&ERR" {
+            reportRecordingError(deviceID: deviceID, message: "微光报告录音失败,请检查设备后重试。")
+        } else if message == "PQ_DEV&DISK&ERR" {
+            reportRecordingError(deviceID: deviceID, message: "微光存储空间已满,录音无法继续。")
+        }
+    }
+
+    private func decodeCommandMessages(_ data: Data) -> [String] {
+        guard var text = String(data: data, encoding: .utf8) else { return [] }
+        text = text.replacingOccurrences(of: "\0", with: "")
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !text.isEmpty else { return [] }
+
+        let lineMessages = text.components(separatedBy: .newlines)
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+        if lineMessages.count > 1 { return lineMessages }
+
+        // Also tolerate multiple prefix-delimited messages in one notification.
+        let pattern = "(?=PQ_(?:DEV|EV)&)"
+        if let regex = try? NSRegularExpression(pattern: pattern) {
+            let range = NSRange(text.startIndex..., in: text)
+            let matches = regex.matches(in: text, range: range)
+            if matches.count > 1 {
+                return matches.enumerated().compactMap { index, match in
+                    let start = match.range.location
+                    let end = index + 1 < matches.count ? matches[index + 1].range.location : range.length
+                    guard let swiftRange = Range(NSRange(location: start, length: end - start), in: text) else { return nil }
+                    return String(text[swiftRange])
+                }
+            }
+        }
+        return [text]
+    }
+
+    private func writeCommand(_ command: String, to deviceID: String) throws {
+        guard let peripheral = peripherals[deviceID],
+              peripheral.state == .connected,
+              let characteristic = characteristics[deviceID]?.write else {
+            throw SparkBLEError.commandUnavailable
+        }
+        let data = Data(command.utf8)
+        let writeType: CBCharacteristicWriteType
+        if characteristic.properties.contains(.write) {
+            writeType = .withResponse
+        } else if characteristic.properties.contains(.writeWithoutResponse) {
+            writeType = .withoutResponse
+        } else {
+            throw SparkBLEError.commandUnavailable
+        }
+        guard data.count <= peripheral.maximumWriteValueLength(for: writeType) else {
+            throw SparkBLEError.deviceReported("设备指令超过单帧写入长度。")
+        }
+        peripheral.writeValue(data, for: characteristic, type: writeType)
+    }
+
+    private func queryDeviceStatus(_ deviceID: String) {
+        let commands = ["PQ_BLE&STE", "PQ_BLE&BAT", "PQ_BLE&SPACE", "PQ_BLE&FW", "PQ_BLE&MAC", currentTimeCommand()]
+        for (index, command) in commands.enumerated() {
+            DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.12) { [weak self] in
+                try? self?.writeCommand(command, to: deviceID)
+            }
+        }
+    }
+
+    private func currentTimeCommand() -> String {
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "en_US_POSIX")
+        formatter.dateFormat = "yyyyMMddHHmmss"
+        return "PQ_BLE&T&\(formatter.string(from: Date()))"
+    }
+
+    // MARK: - State and persistence
+
+    private func markDeviceReady(_ deviceID: String) {
+        updateBoundDevice(id: deviceID) { $0.isConnected = true }
+        connectionPhases[deviceID] = recordingDeviceID == deviceID ? .recording : .ready
+        recorderStateConsumers[deviceID]?(.ready)
+        queryDeviceStatus(deviceID)
+    }
+
+    private func reconnectBoundDevices() {
+        let identifiers = boundDevices.compactMap { UUID(uuidString: $0.peripheralUUID) }
+        guard !identifiers.isEmpty else { return }
+        for peripheral in centralManager.retrievePeripherals(withIdentifiers: identifiers) {
+            let id = peripheral.identifier.uuidString
+            peripherals[id] = peripheral
+            peripheral.delegate = self
+            connectionPhases[id] = .reconnecting
+            centralManager.connect(peripheral, options: nil)
+        }
+    }
+
+    private func markAllDevicesDisconnected() {
+        for index in boundDevices.indices {
+            boundDevices[index].isConnected = false
+            connectionPhases[boundDevices[index].id] = .disconnected
+        }
         saveBoundDevices()
-        return boundDevice
     }
-    
-    /// Unbinds a device by its ID.
-    func unbindDevice(id: String) {
-        boundDevices.removeAll(where: { $0.id == id })
+
+    private func updateBoundDevice(id: String, mutation: (inout BoundDevice) -> Void) {
+        guard let index = boundDevices.firstIndex(where: { $0.id == id }) else { return }
+        mutation(&boundDevices[index])
         saveBoundDevices()
     }
-    
-    /// Returns devices bound to the given user account.
-    func devices(forUserId userId: String) -> [BoundDevice] {
-        return boundDevices.filter { $0.boundUserId == userId }
+
+    private func upsertBoundDevice(_ device: BoundDevice) {
+        boundDevices.removeAll(where: { $0.id == device.id })
+        boundDevices.append(device)
+        saveBoundDevices()
+    }
+
+    private func finishPendingBinding(deviceID: String, result: Result<BoundDevice, Error>) {
+        guard let pending = pendingBindings.removeValue(forKey: deviceID) else { return }
+        pending.completion(result)
+    }
+
+    private func failTransport(deviceID: String, message: String) {
+        let error = SparkBLEError.connectionFailed(message)
+        connectionPhases[deviceID] = .failed(error.localizedDescription)
+        finishPendingBinding(deviceID: deviceID, result: .failure(error))
+        recorderStateConsumers[deviceID]?(.failed(error.localizedDescription))
+    }
+
+    private func reportRecordingError(deviceID: String, message: String) {
+        lastErrorMessage = message
+        recorderStateConsumers[deviceID]?(.failed(message))
+        pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.deviceReported(message)))
     }
-    
-    // MARK: - Local Persistence
-    
+
+    private func scheduleTimeout(for deviceID: String, command: String, token: UUID, isStart: Bool) {
+        DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
+            guard let self else { return }
+            if isStart, self.pendingStarts[deviceID]?.token == token {
+                self.pendingStarts.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command)))
+            } else if !isStart, self.pendingStops[deviceID]?.token == token {
+                self.pendingStops.removeValue(forKey: deviceID)?.completion(.failure(SparkBLEError.commandTimedOut(command)))
+            }
+        }
+    }
+
     private func saveBoundDevices() {
         if let data = try? JSONEncoder().encode(boundDevices) {
             UserDefaults.standard.set(data, forKey: boundDevicesKey)
         }
     }
-    
+
     private func loadBoundDevices() {
-        if let data = UserDefaults.standard.data(forKey: boundDevicesKey),
-           let devices = try? JSONDecoder().decode([BoundDevice].self, from: data) {
-            self.boundDevices = devices
+        guard let data = UserDefaults.standard.data(forKey: boundDevicesKey),
+              var devices = try? JSONDecoder().decode([BoundDevice].self, from: data) else { return }
+        for index in devices.indices {
+            devices[index].isConnected = false
+        }
+        boundDevices = devices
+    }
+
+    // MARK: - Pairing secret
+
+    private func generatePairingPassword() -> String {
+        let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789")
+        var bytes = [UInt8](repeating: 0, count: 16)
+        let result = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+        if result == errSecSuccess {
+            return String(bytes.map { alphabet[Int($0) % alphabet.count] })
+        }
+        return String(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(16))
+    }
+
+    private func savePairingPassword(_ password: String, for deviceID: String) {
+        deletePairingPassword(for: deviceID)
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: keychainService,
+            kSecAttrAccount as String: deviceID,
+            kSecValueData as String: Data(password.utf8),
+            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+        ]
+        SecItemAdd(query as CFDictionary, nil)
+    }
+
+    private func pairingPassword(for deviceID: String) -> String? {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: keychainService,
+            kSecAttrAccount as String: deviceID,
+            kSecReturnData as String: true,
+            kSecMatchLimit as String: kSecMatchLimitOne
+        ]
+        var result: CFTypeRef?
+        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
+              let data = result as? Data else { return nil }
+        return String(data: data, encoding: .utf8)
+    }
+
+    private func deletePairingPassword(for deviceID: String) {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: keychainService,
+            kSecAttrAccount as String: deviceID
+        ]
+        SecItemDelete(query as CFDictionary)
+    }
+}
+
+// MARK: - Spark audio recorder
+
+/// Records the MP3 notification stream produced by a bound Spark device.
+final class SparkAudioRecorder: AudioRecorderProtocol {
+    @Published var isRecording: Bool = false
+    @Published var elapsedTime: TimeInterval = 0
+    @Published var currentAmplitude: Float = 0
+    @Published var waveformSamples: [Float] = []
+    @Published var outputFileURL: URL?
+    @Published var isPaused: Bool = false
+    @Published var statusMessage: String = "正在连接设备"
+    @Published var errorMessage: String?
+
+    let sourceDisplayName: String
+
+    private let deviceID: String
+    private let manager: BLEManager
+    private let ioQueue = DispatchQueue(label: "com.celestia.trace.spark.audio-io")
+    private var fileHandle: FileHandle?
+    private var timer: Timer?
+    private var recordingStartedAt: Date?
+    private var stopCompletion: ((Result<URL?, Error>) -> Void)?
+
+    init(deviceID: String, displayName: String, manager: BLEManager = .shared) {
+        self.deviceID = deviceID
+        self.sourceDisplayName = displayName
+        self.manager = manager
+    }
+
+    func startRecording() {
+        guard !isRecording, fileHandle == nil else { return }
+        errorMessage = nil
+        statusMessage = "正在启动 \(sourceDisplayName)"
+        elapsedTime = 0
+        waveformSamples = []
+        currentAmplitude = 0
+
+        do {
+            let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+            let url = documents.appendingPathComponent("spark_\(UUID().uuidString).mp3")
+            FileManager.default.createFile(atPath: url.path, contents: nil)
+            fileHandle = try FileHandle(forWritingTo: url)
+            outputFileURL = url
+        } catch {
+            errorMessage = "无法创建微光录音文件:\(error.localizedDescription)"
+            statusMessage = "启动失败"
+            return
+        }
+
+        manager.startSparkRecording(
+            deviceID: deviceID,
+            onAudioData: { [weak self] data in self?.appendAudioData(data) },
+            onStateChange: { [weak self] event in self?.handleStateEvent(event) }
+        ) { [weak self] result in
+            guard let self else { return }
+            DispatchQueue.main.async {
+                switch result {
+                case .success:
+                    self.isRecording = true
+                    self.statusMessage = "正在录音"
+                    self.recordingStartedAt = Date()
+                    self.startTimer()
+                case .failure(let error):
+                    self.errorMessage = error.localizedDescription
+                    self.statusMessage = "启动失败"
+                    self.finishFile { _ in }
+                }
+            }
+        }
+    }
+
+    func stopRecording() {
+        stopRecording { _ in }
+    }
+
+    func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
+        guard fileHandle != nil else {
+            completion(.success(outputFileURL))
+            return
+        }
+        guard isRecording else {
+            finishFile(completion: completion)
+            return
+        }
+
+        statusMessage = "正在保存设备录音"
+        stopCompletion = completion
+        manager.stopSparkRecording(deviceID: deviceID) { [weak self] result in
+            guard let self else { return }
+            DispatchQueue.main.async {
+                switch result {
+                case .success:
+                    self.isRecording = false
+                    self.invalidateTimer()
+                    self.finishFile { result in
+                        self.manager.detachRecorder(deviceID: self.deviceID)
+                        self.stopCompletion?(result)
+                        self.stopCompletion = nil
+                    }
+                case .failure(let error):
+                    self.errorMessage = error.localizedDescription
+                    self.statusMessage = "设备尚未确认停止"
+                    self.stopCompletion?(.failure(error))
+                    self.stopCompletion = nil
+                }
+            }
+        }
+    }
+
+    func pauseRecording() {
+        errorMessage = "微光暂不支持暂停,请结束当前录音。"
+    }
+
+    func resumeRecording() {}
+
+    private func appendAudioData(_ data: Data) {
+        guard !data.isEmpty else { return }
+        ioQueue.async { [weak self] in
+            guard let self else { return }
+            do {
+                try self.fileHandle?.write(contentsOf: data)
+            } catch {
+                DispatchQueue.main.async {
+                    self.errorMessage = "保存微光音频失败:\(error.localizedDescription)"
+                }
+            }
+        }
+
+        // This represents stream activity until incremental MP3 PCM metering is available.
+        let activity = min(1, max(0.08, Float(data.count) / 244.0))
+        DispatchQueue.main.async { [weak self] in
+            guard let self else { return }
+            self.currentAmplitude = activity
+            self.waveformSamples.append(activity)
+            if self.waveformSamples.count > 200 {
+                self.waveformSamples.removeFirst(self.waveformSamples.count - 200)
+            }
+        }
+    }
+
+    private func handleStateEvent(_ event: SparkRecorderConnectionEvent) {
+        DispatchQueue.main.async { [weak self] in
+            guard let self else { return }
+            switch event {
+            case .recording:
+                if self.isRecording { self.statusMessage = "正在录音" }
+            case .reconnecting:
+                self.statusMessage = "连接中断,正在重连"
+            case .ready:
+                if self.isRecording { self.statusMessage = "正在录音" }
+            case .failed(let message):
+                self.errorMessage = message
+                self.statusMessage = "设备异常"
+            }
+        }
+    }
+
+    private func startTimer() {
+        invalidateTimer()
+        timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
+            guard let self, let startedAt = self.recordingStartedAt else { return }
+            self.elapsedTime = Date().timeIntervalSince(startedAt)
         }
+        if let timer { RunLoop.main.add(timer, forMode: .common) }
+    }
+
+    private func invalidateTimer() {
+        timer?.invalidate()
+        timer = nil
+    }
+
+    private func finishFile(completion: @escaping (Result<URL?, Error>) -> Void) {
+        let url = outputFileURL
+        ioQueue.async { [weak self] in
+            guard let self else { return }
+            do {
+                try self.fileHandle?.synchronize()
+                try self.fileHandle?.close()
+                self.fileHandle = nil
+                DispatchQueue.main.async { completion(.success(url)) }
+            } catch {
+                self.fileHandle = nil
+                DispatchQueue.main.async { completion(.failure(error)) }
+            }
+        }
+    }
+
+    deinit {
+        invalidateTimer()
+        try? fileHandle?.close()
+        manager.detachRecorder(deviceID: deviceID)
     }
 }

+ 31 - 1
CelestiaTrace/ViewModels/RecordingViewModel.swift

@@ -17,6 +17,9 @@ final class RecordingViewModel {
     private(set) var currentAmplitude: Float = 0
     private(set) var waveformSamples: [Float] = []
     private(set) var isPaused: Bool = false
+    private(set) var sourceDisplayName: String
+    private(set) var statusMessage: String = "正在准备"
+    private(set) var errorMessage: String?
 
     // MARK: - Computed Properties
 
@@ -46,10 +49,22 @@ final class RecordingViewModel {
 
     init() {
         self.recorder = RealAudioRecorder()
+        self.sourceDisplayName = "iPhone 麦克风"
+    }
+
+    init(source: RecordingSourceChoice) {
+        switch source {
+        case .iPhone:
+            self.recorder = RealAudioRecorder()
+        case .spark(let deviceID, let displayName):
+            self.recorder = SparkAudioRecorder(deviceID: deviceID, displayName: displayName)
+        }
+        self.sourceDisplayName = source.displayName
     }
 
     init(recorder: any AudioRecorderProtocol) {
         self.recorder = recorder
+        self.sourceDisplayName = recorder.sourceDisplayName
     }
 
     deinit {
@@ -64,7 +79,7 @@ final class RecordingViewModel {
         self.initialDuration = initialDuration
         self.elapsedTime = initialDuration
         recorder.startRecording()
-        isRecording = true
+        syncState()
         startSyncTimer()
     }
 
@@ -76,6 +91,18 @@ final class RecordingViewModel {
         syncState() // Final sync
     }
 
+    /// Stops the active source and reports whether the source confirmed that it
+    /// is safe to finalize the session. Spark uses this to await PQ_DEV&STO.
+    func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
+        recorder.stopRecording { [weak self] result in
+            guard let self else { return }
+            self.isRecording = false
+            self.stopSyncTimer()
+            self.syncState()
+            completion(result)
+        }
+    }
+
     /// Pauses the current recording session.
     func pauseRecording() {
         recorder.pauseRecording()
@@ -156,5 +183,8 @@ final class RecordingViewModel {
         currentAmplitude = recorder.currentAmplitude
         waveformSamples = recorder.waveformSamples
         isPaused = recorder.isPaused
+        sourceDisplayName = recorder.sourceDisplayName
+        statusMessage = recorder.statusMessage
+        errorMessage = recorder.errorMessage
     }
 }

+ 40 - 9
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -8,6 +8,8 @@ import SwiftData
 /// Redesigned to use a minimalist wireframe business style.
 struct SessionDetailView: View {
     @Environment(\.modelContext) private var modelContext
+    @ObservedObject private var bleManager: BLEManager = .shared
+    @ObservedObject private var authManager: AuthManager = .shared
 
     let session: CelestiaSession
 
@@ -16,6 +18,8 @@ struct SessionDetailView: View {
     @State private var detector = RecordingEnvironmentDetector()
     @State private var showPrepAlert = false
     @State private var activeWarningMsg = ""
+    @State private var showRecordingSourcePicker = false
+    @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
 
     var body: some View {
         ZStack {
@@ -53,13 +57,7 @@ struct SessionDetailView: View {
             }
             ToolbarItem(placement: .topBarTrailing) {
                 Button {
-                    detector.checkEnvironment()
-                    if let firstWarning = detector.activeWarnings.first {
-                        activeWarningMsg = firstWarning
-                        showPrepAlert = true
-                    } else {
-                        navigateToRecording = true
-                    }
+                    prepareContinuation()
                 } label: {
                     HStack(spacing: 4) {
                         Image(systemName: "plus")
@@ -77,7 +75,7 @@ struct SessionDetailView: View {
         }
         .alert("录音环境提醒", isPresented: $showPrepAlert) {
             Button("继续录制", role: .none) {
-                navigateToRecording = true
+                chooseSourceAndContinue()
             }
             Button("取消", role: .cancel) {}
         } message: {
@@ -86,13 +84,21 @@ struct SessionDetailView: View {
         .fullScreenCover(isPresented: $navigateToRecording) {
             ActiveRecordingView(
                 session: session,
-                initialDuration: Double(session.durationMs) / 1000.0
+                initialDuration: Double(session.durationMs) / 1000.0,
+                recordingSource: selectedRecordingSource
             ) {
                 playbackVM.totalDurationMs = Double(session.durationMs)
                 playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
                 playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
             }
         }
+        .sheet(isPresented: $showRecordingSourcePicker) {
+            RecordingSourcePickerView(devices: connectedSparkDevices) { source in
+                selectedRecordingSource = source
+                showRecordingSourcePicker = false
+                navigateToRecording = true
+            }
+        }
         .onAppear {
             playbackVM.totalDurationMs = Double(session.durationMs)
             playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
@@ -100,6 +106,31 @@ struct SessionDetailView: View {
         }
     }
 
+    private func prepareContinuation() {
+        detector.checkEnvironment()
+        if let firstWarning = detector.activeWarnings.first {
+            activeWarningMsg = firstWarning
+            showPrepAlert = true
+        } else {
+            chooseSourceAndContinue()
+        }
+    }
+
+    private func chooseSourceAndContinue() {
+        if connectedSparkDevices.isEmpty {
+            selectedRecordingSource = .iPhone
+            navigateToRecording = true
+        } else {
+            showRecordingSourcePicker = true
+        }
+    }
+
+    private var connectedSparkDevices: [BoundDevice] {
+        guard let userID = authManager.currentUser?.id else { return [] }
+        return bleManager.connectedDevices(forUserId: userID)
+            .sorted { $0.boundAt < $1.boundAt }
+    }
+
     // MARK: - Session Info Card
 
     private var sessionInfoCard: some View {

+ 160 - 3
CelestiaTrace/Views/Home/HomeView.swift

@@ -8,12 +8,16 @@ import SwiftData
 struct HomeView: View {
     @Environment(\.modelContext) private var modelContext
     @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession]
+    @ObservedObject private var bleManager: BLEManager = .shared
+    @ObservedObject private var authManager: AuthManager = .shared
 
     @State private var navigateToRecording = false
     @State private var activeSession: CelestiaSession?
     @State private var selectedSessionForDetail: CelestiaSession?
     @State private var detector = RecordingEnvironmentDetector()
     @State private var dismissedWarnings: Set<String> = []
+    @State private var showRecordingSourcePicker = false
+    @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
 
     /// Callback to switch to the History tab from the parent TabView.
     var onSwitchToHistory: (() -> Void)?
@@ -62,11 +66,17 @@ struct HomeView: View {
             }
             .fullScreenCover(isPresented: $navigateToRecording) {
                 if let session = activeSession {
-                    ActiveRecordingView(session: session) {
+                    ActiveRecordingView(session: session, recordingSource: selectedRecordingSource) {
                         selectedSessionForDetail = session
                     }
                 }
             }
+            .sheet(isPresented: $showRecordingSourcePicker) {
+                RecordingSourcePickerView(devices: connectedSparkDevices) { source in
+                    showRecordingSourcePicker = false
+                    createSessionAndNavigate(source: source)
+                }
+            }
             .navigationDestination(item: $selectedSessionForDetail) { session in
                 SessionDetailView(session: session)
             }
@@ -109,7 +119,7 @@ struct HomeView: View {
     private var recordSection: some View {
         VStack(spacing: 24) {
             PulsingRecordButton {
-                createSessionAndNavigate()
+                beginNewRecording()
             }
 
             VStack(spacing: 6) {
@@ -224,7 +234,15 @@ struct HomeView: View {
 
     // MARK: - Actions
 
-    private func createSessionAndNavigate() {
+    private func beginNewRecording() {
+        if connectedSparkDevices.isEmpty {
+            createSessionAndNavigate(source: .iPhone)
+        } else {
+            showRecordingSourcePicker = true
+        }
+    }
+
+    private func createSessionAndNavigate(source: RecordingSourceChoice) {
         let formatter = DateFormatter()
         formatter.dateFormat = "yyyy.MM.dd HH:mm"
         let title = "现场记录 · \(formatter.string(from: Date()))"
@@ -234,10 +252,17 @@ struct HomeView: View {
         try? modelContext.save()
 
         activeSession = session
+        selectedRecordingSource = source
         HapticManager.trigger(.recordStart)
         navigateToRecording = true
     }
 
+    private var connectedSparkDevices: [BoundDevice] {
+        guard let userID = authManager.currentUser?.id else { return [] }
+        return bleManager.connectedDevices(forUserId: userID)
+            .sorted { $0.boundAt < $1.boundAt }
+    }
+
     // MARK: - Helpers
 
     private var currentDateFormatted: String {
@@ -248,6 +273,138 @@ struct HomeView: View {
     }
 }
 
+// MARK: - Recording source picker
+
+/// Shared by new recordings and continuation recordings. When at least one
+/// connected Spark exists, the first Spark is selected by default.
+struct RecordingSourcePickerView: View {
+    @Environment(\.dismiss) private var dismiss
+
+    let devices: [BoundDevice]
+    let onConfirm: (RecordingSourceChoice) -> Void
+    @State private var selection: RecordingSourceChoice
+
+    init(devices: [BoundDevice], onConfirm: @escaping (RecordingSourceChoice) -> Void) {
+        self.devices = devices
+        self.onConfirm = onConfirm
+        if let first = devices.first {
+            _selection = State(initialValue: .spark(deviceID: first.id, displayName: first.name))
+        } else {
+            _selection = State(initialValue: .iPhone)
+        }
+    }
+
+    var body: some View {
+        NavigationStack {
+            ZStack {
+                Color.spaceBlack.ignoresSafeArea()
+                VStack(alignment: .leading, spacing: 14) {
+                    Text("选择本次现场记录使用的录音设备")
+                        .font(.system(size: 13))
+                        .foregroundStyle(Color.secondary)
+
+                    ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in
+                        sourceRow(
+                            source: .spark(deviceID: device.id, displayName: device.name),
+                            title: device.name,
+                            subtitle: sparkSubtitle(device),
+                            badge: index == 0 ? "默认" : nil
+                        )
+                    }
+
+                    sourceRow(
+                        source: .iPhone,
+                        title: "iPhone 麦克风",
+                        subtitle: "使用手机内置或当前系统音频输入",
+                        badge: devices.isEmpty ? "默认" : nil
+                    )
+
+                    Button {
+                        onConfirm(selection)
+                    } label: {
+                        Text("开始现场记录")
+                            .font(.system(size: 14, weight: .semibold))
+                            .foregroundStyle(Color.spaceBlack)
+                            .frame(maxWidth: .infinity)
+                            .padding(.vertical, 12)
+                            .background(Color.primary)
+                            .cornerRadius(8)
+                    }
+                    .padding(.top, 8)
+
+                    Spacer()
+                }
+                .padding(20)
+            }
+            .navigationTitle("录音设备")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("取消") { dismiss() }
+                        .foregroundStyle(Color.secondary)
+                }
+            }
+        }
+        .presentationDetents([.medium, .large])
+        .presentationBackground(Color.spaceBlack)
+    }
+
+    private func sourceRow(
+        source: RecordingSourceChoice,
+        title: String,
+        subtitle: String,
+        badge: String?
+    ) -> some View {
+        Button {
+            selection = source
+        } label: {
+            HStack(spacing: 12) {
+                Image(systemName: source.systemImage)
+                    .font(.system(size: 19, weight: .light))
+                    .foregroundStyle(Color.primary)
+                    .frame(width: 34)
+
+                VStack(alignment: .leading, spacing: 4) {
+                    HStack(spacing: 7) {
+                        Text(title)
+                            .font(.system(size: 14, weight: .semibold))
+                            .foregroundStyle(Color.primary)
+                        if let badge {
+                            Text(badge)
+                                .font(.system(size: 9, weight: .bold))
+                                .foregroundStyle(Color.spaceBlack)
+                                .padding(.horizontal, 6)
+                                .padding(.vertical, 2)
+                                .background(Color.primary)
+                                .cornerRadius(4)
+                        }
+                    }
+                    Text(subtitle)
+                        .font(.system(size: 11))
+                        .foregroundStyle(Color.secondary)
+                }
+
+                Spacer()
+
+                Image(systemName: selection == source ? "checkmark.circle.fill" : "circle")
+                    .font(.system(size: 18))
+                    .foregroundStyle(selection == source ? Color.primary : Color.secondary)
+            }
+            .padding(14)
+            .background(Color.cardBackground.opacity(selection == source ? 0.55 : 0.25))
+            .businessBorder(cornerRadius: 10)
+        }
+        .buttonStyle(.plain)
+    }
+
+    private func sparkSubtitle(_ device: BoundDevice) -> String {
+        var parts = ["已连接"]
+        if let battery = device.batteryLevel { parts.append("电量 \(battery)%") }
+        if let free = device.freeStorageMB { parts.append("剩余 \(free) MB") }
+        return parts.joined(separator: " · ")
+    }
+}
+
 // MARK: - Recent Session Card
 
 private struct RecentSessionCard: View {

+ 51 - 13
CelestiaTrace/Views/Profile/Devices/DeviceListView.swift

@@ -7,6 +7,8 @@ struct DeviceListView: View {
     
     var onAddDeviceClicked: () -> Void
     @State private var deviceToUnbind: BoundDevice?
+    @State private var deviceToRename: BoundDevice?
+    @State private var proposedDeviceName = ""
     
     private var userBoundDevices: [BoundDevice] {
         guard let userId = authManager.currentUser?.id else { return [] }
@@ -74,6 +76,26 @@ struct DeviceListView: View {
         } message: {
             Text("解绑后该设备将从你的账号名下移除。")
         }
+        .alert(
+            "给微光取个名字",
+            isPresented: Binding(
+                get: { deviceToRename != nil },
+                set: { if !$0 { deviceToRename = nil } }
+            )
+        ) {
+            TextField("设备名称", text: $proposedDeviceName)
+            Button("保存") {
+                if let device = deviceToRename {
+                    bleManager.renameDevice(id: device.id, to: proposedDeviceName)
+                }
+                deviceToRename = nil
+            }
+            Button("取消", role: .cancel) {
+                deviceToRename = nil
+            }
+        } message: {
+            Text("这个名称只保存在你的 App 中,不会修改设备的蓝牙广播名。")
+        }
     }
     
     // MARK: - Empty Device Card
@@ -175,25 +197,41 @@ struct DeviceListView: View {
                             .font(.system(size: 11, design: .monospaced))
                             .foregroundStyle(Color.secondary)
                     }
+
+                    if let freeStorage = device.freeStorageMB {
+                        Text("剩余: \(freeStorage) MB")
+                            .font(.system(size: 11, design: .monospaced))
+                            .foregroundStyle(Color.secondary)
+                    }
                 }
             }
             
             Spacer()
             
-            // Unbind Button
-            Button {
-                deviceToUnbind = device
-            } label: {
-                Text("解绑")
-                    .font(.system(size: 12, weight: .regular))
-                    .foregroundStyle(Color.recordingRed)
-                    .padding(.horizontal, 10)
-                    .padding(.vertical, 5)
-                    .overlay(
-                        RoundedRectangle(cornerRadius: 6)
-                            .stroke(Color.recordingRed.opacity(0.3), lineWidth: 1)
-                    )
+            VStack(spacing: 8) {
+                Button {
+                    proposedDeviceName = device.name
+                    deviceToRename = device
+                } label: {
+                    Text("重命名")
+                        .font(.system(size: 12, weight: .regular))
+                        .foregroundStyle(Color.primary)
+                }
+
+                Button {
+                    deviceToUnbind = device
+                } label: {
+                    Text("解绑")
+                        .font(.system(size: 12, weight: .regular))
+                        .foregroundStyle(Color.recordingRed)
+                }
             }
+            .padding(.horizontal, 10)
+            .padding(.vertical, 7)
+            .overlay(
+                RoundedRectangle(cornerRadius: 6)
+                    .stroke(Color.primary.opacity(0.2), lineWidth: 1)
+            )
         }
         .padding(14)
         .background(Color.cardBackground.opacity(0.35))

+ 45 - 14
CelestiaTrace/Views/Profile/Devices/DeviceScanView.swift

@@ -7,6 +7,8 @@ struct DeviceScanView: View {
     @ObservedObject var authManager: AuthManager = .shared
     
     @State private var boundSuccessMessage: String?
+    @State private var bindingDeviceID: String?
+    @State private var bindingErrorMessage: String?
     
     var body: some View {
         ZStack {
@@ -127,6 +129,14 @@ struct DeviceScanView: View {
         .onDisappear {
             bleManager.stopScanning()
         }
+        .alert("无法绑定微光", isPresented: Binding(
+            get: { bindingErrorMessage != nil },
+            set: { if !$0 { bindingErrorMessage = nil } }
+        )) {
+            Button("知道了", role: .cancel) { bindingErrorMessage = nil }
+        } message: {
+            Text(bindingErrorMessage ?? "请稍后重试。")
+        }
     }
     
     private func discoveredDeviceRow(_ device: DiscoveredBLEDevice) -> some View {
@@ -144,7 +154,7 @@ struct DeviceScanView: View {
             }
             
             VStack(alignment: .leading, spacing: 4) {
-                Text(device.name)
+                Text("微光(Spark)")
                     .font(.system(size: 14, weight: .medium))
                     .foregroundStyle(Color.primary)
                 
@@ -161,6 +171,10 @@ struct DeviceScanView: View {
                         .font(.system(size: 11, design: .monospaced))
                         .foregroundStyle(Color.secondary)
                 }
+
+                Text("广播名称: \(device.name)")
+                    .font(.system(size: 10))
+                    .foregroundStyle(Color.secondary.opacity(0.8))
             }
             
             Spacer()
@@ -174,22 +188,39 @@ struct DeviceScanView: View {
             } else {
                 Button {
                     guard let userId = authManager.currentUser?.id else { return }
-                    let bound = bleManager.bindDevice(device, userId: userId)
-                    withAnimation {
-                        boundSuccessMessage = "已成功绑定设备「\(bound.name)」"
-                    }
-                    DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
-                        dismiss()
+                    bindingDeviceID = device.id
+                    bleManager.connectAndBind(device, userId: userId) { result in
+                        bindingDeviceID = nil
+                        switch result {
+                        case .success(let bound):
+                            withAnimation {
+                                boundSuccessMessage = "已成功绑定设备「\(bound.name)」"
+                            }
+                            DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
+                                dismiss()
+                            }
+                        case .failure(let error):
+                            bindingErrorMessage = error.localizedDescription
+                        }
                     }
                 } label: {
-                    Text("绑定设备")
-                        .font(.system(size: 13, weight: .semibold))
-                        .foregroundStyle(Color.spaceBlack)
-                        .padding(.horizontal, 14)
-                        .padding(.vertical, 7)
-                        .background(Color.primary)
-                        .cornerRadius(6)
+                    Group {
+                        if bindingDeviceID == device.id {
+                            ProgressView()
+                                .tint(Color.spaceBlack)
+                        } else {
+                            Text("绑定设备")
+                                .font(.system(size: 13, weight: .semibold))
+                        }
+                    }
+                    .foregroundStyle(Color.spaceBlack)
+                    .frame(minWidth: 72)
+                    .padding(.horizontal, 14)
+                    .padding(.vertical, 7)
+                    .background(Color.primary)
+                    .cornerRadius(6)
                 }
+                .disabled(bindingDeviceID != nil)
             }
         }
         .padding(14)

+ 84 - 25
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -11,14 +11,30 @@ struct ActiveRecordingView: View {
 
     let session: CelestiaSession
     var initialDuration: TimeInterval = 0
+    let recordingSource: RecordingSourceChoice
     var onFinishRecording: (() -> Void)? = nil
 
-    @State private var recordingVM = RecordingViewModel()
+    @State private var recordingVM: RecordingViewModel
     @State private var showNoteSheet = false
     @State private var showCamera = false
     @State private var noteText = ""
     @State private var latestEvent: CelestiaTimelineEvent?
     @State private var latestEventVisible = false
+    @State private var isEndingRecording = false
+    @State private var stopErrorMessage: String?
+
+    init(
+        session: CelestiaSession,
+        initialDuration: TimeInterval = 0,
+        recordingSource: RecordingSourceChoice = .iPhone,
+        onFinishRecording: (() -> Void)? = nil
+    ) {
+        self.session = session
+        self.initialDuration = initialDuration
+        self.recordingSource = recordingSource
+        self.onFinishRecording = onFinishRecording
+        _recordingVM = State(initialValue: RecordingViewModel(source: recordingSource))
+    }
 
     var body: some View {
         ZStack {
@@ -72,13 +88,24 @@ struct ActiveRecordingView: View {
             }
         }
         .onAppear {
+            UIApplication.shared.isIdleTimerDisabled = true
             recordingVM.startRecording(initialDuration: initialDuration)
         }
         .onDisappear {
+            UIApplication.shared.isIdleTimerDisabled = false
             if recordingVM.isRecording {
                 recordingVM.stopRecording()
             }
         }
+        .alert("无法结束设备录音", isPresented: Binding(
+            get: { stopErrorMessage != nil },
+            set: { if !$0 { stopErrorMessage = nil } }
+        )) {
+            Button("重试") { endRecording() }
+            Button("继续录音", role: .cancel) { stopErrorMessage = nil }
+        } message: {
+            Text(stopErrorMessage ?? "请确认微光仍在附近并保持连接。")
+        }
     }
 
     // MARK: - Timecode
@@ -95,30 +122,47 @@ struct ActiveRecordingView: View {
     // MARK: - Recording Indicator
 
     private var recordingIndicator: some View {
-        HStack(spacing: 8) {
-            TimelineView(.periodic(from: .now, by: 1.0)) { context in
-                let isEven = Int(context.date.timeIntervalSince1970) % 2 == 0
-                Circle()
-                    .fill(recordingVM.isPaused ? Color.secondary : Color.recordingRed)
-                    .frame(width: 8, height: 8)
-                    .opacity(recordingVM.isPaused ? 0.6 : (isEven ? 1.0 : 0.3))
-                    .animation(.easeInOut(duration: 0.5), value: isEven)
+        VStack(spacing: 8) {
+            HStack(spacing: 8) {
+                TimelineView(.periodic(from: .now, by: 1.0)) { context in
+                    let isEven = Int(context.date.timeIntervalSince1970) % 2 == 0
+                    Circle()
+                        .fill(recordingVM.isRecording ? Color.recordingRed : Color.secondary)
+                        .frame(width: 8, height: 8)
+                        .opacity(recordingVM.isRecording ? (isEven ? 1.0 : 0.3) : 0.6)
+                        .animation(.easeInOut(duration: 0.5), value: isEven)
+                }
+                .frame(width: 10, height: 10)
+
+                Text(recordingVM.isPaused ? "已暂停" : recordingVM.statusMessage)
+                    .font(.system(size: 11, weight: .semibold, design: .monospaced))
+                    .foregroundStyle(recordingVM.isRecording ? Color.recordingRed : Color.secondary)
+                    .tracking(1.2)
+
+                Text("· \(session.title)")
+                    .font(.system(size: 11, weight: .regular))
+                    .foregroundStyle(Color.secondary.opacity(0.7))
+                    .lineLimit(1)
+            }
+
+            HStack(spacing: 6) {
+                Image(systemName: recordingSource.systemImage)
+                    .font(.system(size: 11, weight: .medium))
+                Text("当前录音设备:\(recordingVM.sourceDisplayName)")
+                    .font(.system(size: 11, weight: .medium))
+            }
+            .foregroundStyle(Color.primary.opacity(0.85))
+
+            if let error = recordingVM.errorMessage {
+                Text(error)
+                    .font(.system(size: 10))
+                    .foregroundStyle(Color.recordingRed)
+                    .multilineTextAlignment(.center)
+                    .lineLimit(2)
             }
-            .frame(width: 10, height: 10)
-
-            Text(recordingVM.isPaused ? "已暂停" : "正在录音")
-                .font(.system(size: 11, weight: .semibold, design: .monospaced))
-                .foregroundStyle(recordingVM.isPaused ? Color.secondary : Color.recordingRed)
-                .tracking(2)
-
-            // Session title
-            Text("· \(session.title)")
-                .font(.system(size: 11, weight: .regular))
-                .foregroundStyle(Color.secondary.opacity(0.7))
-                .lineLimit(1)
         }
         .padding(.horizontal, 10)
-        .padding(.vertical, 4)
+        .padding(.vertical, 8)
         .businessBorder(cornerRadius: 6)
     }
 
@@ -268,6 +312,7 @@ struct ActiveRecordingView: View {
             }
         )
         .buttonStyle(.plain)
+        .disabled(isEndingRecording)
     }
 
     // MARK: - Note Input Sheet
@@ -356,9 +401,21 @@ struct ActiveRecordingView: View {
     }
 
     private func endRecording() {
-        recordingVM.stopRecording()
-        
-        let newRecordedURL = recordingVM.outputFileURL
+        guard !isEndingRecording else { return }
+        isEndingRecording = true
+
+        recordingVM.stopRecording { result in
+            switch result {
+            case .success(let confirmedURL):
+                finalizeRecording(newRecordedURL: confirmedURL ?? recordingVM.outputFileURL)
+            case .failure(let error):
+                isEndingRecording = false
+                stopErrorMessage = error.localizedDescription
+            }
+        }
+    }
+
+    private func finalizeRecording(newRecordedURL: URL?) {
         let existingPath = session.localAudioPath
         let existingURL = AudioPathHelper.resolveURL(for: existingPath)
         let totalRecordedSeconds = recordingVM.elapsedTime
@@ -371,6 +428,7 @@ struct ActiveRecordingView: View {
                 try? modelContext.save()
                 
                 HapticManager.trigger(.recordStop)
+                isEndingRecording = false
                 dismiss()
                 onFinishRecording?()
             }
@@ -383,6 +441,7 @@ struct ActiveRecordingView: View {
                 try? modelContext.save()
             }
             HapticManager.trigger(.recordStop)
+            isEndingRecording = false
             dismiss()
             onFinishRecording?()
         }

+ 5 - 1
generate_project.rb

@@ -42,7 +42,8 @@ app_target.build_configurations.each do |config|
   config.build_settings['TARGETED_DEVICE_FAMILY'] = '1' # 1 for iPhone, 2 for iPad, 1,2 for both
   config.build_settings['SDKROOT'] = 'iphoneos'
   config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.celestia.trace'
-  config.build_settings['GENERATE_INFOPLIST_FILE'] = 'YES'
+  config.build_settings['GENERATE_INFOPLIST_FILE'] = 'NO'
+  config.build_settings['INFOPLIST_FILE'] = 'CelestiaTrace/Resources/Info.plist'
   config.build_settings['INFOPLIST_KEY_UILaunchStoryboardName'] = 'LaunchScreen'
   config.build_settings['INFOPLIST_KEY_CFBundleDisplayName'] = '星痕'
   config.build_settings['INFOPLIST_KEY_UISupportedInterfaceOrientations'] = 'UIInterfaceOrientationPortrait'
@@ -51,6 +52,8 @@ app_target.build_configurations.each do |config|
   config.build_settings['INFOPLIST_KEY_NSPhotoLibraryUsageDescription'] = '需要相册权限以选择照片记录'
   config.build_settings['INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription'] = '需要蓝牙权限以扫描和绑定 BLE 录音设备'
   config.build_settings['INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription'] = '需要蓝牙权限以与录音设备传输数据'
+  config.build_settings['MARKETING_VERSION'] = '1.0.0'
+  config.build_settings['CURRENT_PROJECT_VERSION'] = '1'
   config.build_settings['ASSETCATALOG_COMPILER_APPICON_NAME'] = 'AppIcon'
   config.build_settings['CODE_SIGN_STYLE'] = 'Automatic'
   # Enable SwiftData & SwiftUI previews support
@@ -81,6 +84,7 @@ assets_ref = app_group.new_file('Resources/Assets.xcassets')
 app_target.resources_build_phase.add_file_reference(assets_ref)
 launch_screen_ref = app_group.new_file('Resources/LaunchScreen.storyboard')
 app_target.resources_build_phase.add_file_reference(launch_screen_ref)
+app_group.new_file('Resources/Info.plist')
 
 # Save project
 project.save