69 changed files with 4262 additions and 500 deletions
@ -1,5 +1,18 @@ |
|||
<?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/> |
|||
<dict> |
|||
<key>com.apple.developer.applesignin</key> |
|||
<array> |
|||
<string>Default</string> |
|||
</array> |
|||
<key>com.apple.developer.associated-domains</key> |
|||
<array> |
|||
<string>applinks:yimai.games</string> |
|||
</array> |
|||
<key>com.apple.developer.networking.wifi-info</key> |
|||
<true/> |
|||
<key>com.apple.external-accessory.wireless-configuration</key> |
|||
<true/> |
|||
</dict> |
|||
</plist> |
|||
|
|||
@ -0,0 +1,6 @@ |
|||
import Flutter |
|||
import UIKit |
|||
|
|||
/// iOS 26 起没有 UIScene 的 App 会在创建窗口时被系统直接掐掉 |
|||
/// (`UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption`)。 |
|||
class SceneDelegate: FlutterSceneDelegate {} |
|||
File diff suppressed because it is too large
@ -0,0 +1,965 @@ |
|||
import Foundation |
|||
import CoreBluetooth |
|||
import os.log |
|||
|
|||
/// Echo-one 面对面的 BLE 双耳链路,对齐 Android `F2fBleLink.kt`(协议 v0.4.1)。 |
|||
/// |
|||
/// iOS 上现有耳机链路是 GATT over BR/EDR,不是 SPP。`AA 19` 走这条经典 GATT 写特征; |
|||
/// 左右耳 `Echo-one-L` / `Echo-one-R` 另起一个不带状态恢复的 LE central,避免和经典蓝牙抢同一个 central。 |
|||
/// Dart 仍只发 `AA 66` / `AA 67`。 |
|||
public final class F2fBleLink: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { |
|||
|
|||
/// 只有进过面对面才有实例。普通连接、电量查询、断开都读 [existing], |
|||
/// 避免一碰 `.shared` 就把第二个 CBCentralManager 建出来,把经典 GATT 挤掉。 |
|||
private static var _live: F2fBleLink? |
|||
public static var existing: F2fBleLink? { _live } |
|||
public static var shared: F2fBleLink { |
|||
if let live = _live { return live } |
|||
let created = F2fBleLink() |
|||
_live = created |
|||
return created |
|||
} |
|||
|
|||
private static let log = OSLog(subsystem: "com.eaimar.bluetooth", category: "BesF2fBle") |
|||
|
|||
private let serviceUUID = CBUUID(string: "00001100-d102-11e1-9b23-00025b00afae") |
|||
private let writeUUID = CBUUID(string: "00001101-d102-11e1-9b23-00025b00afae") |
|||
private let notifyUUID = CBUUID(string: "00001102-d102-11e1-9b23-00025b00afae") |
|||
|
|||
private let scanTimeout: TimeInterval = 15 |
|||
/// `AA 19` 写出后等 `BB 99` 的上限。到点还没回应,不开扫、不连。 |
|||
private let linkAckTimeout: TimeInterval = 3 |
|||
/// 收到 `BB 99` 之后再等这么久才开扫、再连左右耳。 |
|||
private let linkScanDelay: TimeInterval = 1 |
|||
/// 等于当前 sessionToken 时,startScan 先不开。`BB 99` 之后的 1 秒里也按住。 |
|||
private var scanBlockedToken = 0 |
|||
private let connectTimeout: TimeInterval = 10 |
|||
private let earRecover: TimeInterval = 10 |
|||
private let failStop: TimeInterval = 3 |
|||
private let statsInterval: TimeInterval = 5 |
|||
private let uplinkSilent: TimeInterval = 1.5 |
|||
private let startAckWait: TimeInterval = 1.5 |
|||
private let linkSwitchGap: TimeInterval = 0.3 |
|||
private let gattCloseDelay: TimeInterval = 0.4 |
|||
private let classicReconnectDelay: TimeInterval = 1.5 |
|||
private let recoverCooldown: TimeInterval = 2 |
|||
private let recoverCap: TimeInterval = 30 |
|||
private let writeQueueCap = 8 |
|||
private let gattRetries = 3 |
|||
|
|||
private let leftName = "Echo-one-L" |
|||
private let rightName = "Echo-one-R" |
|||
|
|||
private let prefsExit = "f2f.exitPending" |
|||
|
|||
private let leQueue = DispatchQueue(label: "f2f.ble", qos: .userInitiated) |
|||
private let leQueueKey = DispatchSpecificKey<Bool>() |
|||
private let flagLock = NSLock() |
|||
|
|||
private var leCentral: CBCentralManager! |
|||
private var _sessionActive = false |
|||
private var _recovering = false |
|||
private var _classicLost = false |
|||
private var _pendingClassicReconnect = false |
|||
|
|||
public var sessionActive: Bool { flagLock.lock(); defer { flagLock.unlock() }; return _sessionActive } |
|||
public var recovering: Bool { flagLock.lock(); defer { flagLock.unlock() }; return _recovering } |
|||
public var classicLost: Bool { |
|||
get { flagLock.lock(); defer { flagLock.unlock() }; return _classicLost } |
|||
set { flagLock.lock(); _classicLost = newValue; flagLock.unlock() } |
|||
} |
|||
public var pendingClassicReconnect: Bool { |
|||
get { flagLock.lock(); defer { flagLock.unlock() }; return _pendingClassicReconnect } |
|||
set { flagLock.lock(); _pendingClassicReconnect = newValue; flagLock.unlock() } |
|||
} |
|||
public var suppressClassicDown: Bool { sessionActive || pendingClassicReconnect } |
|||
|
|||
private enum Phase { case scan, connect, live } |
|||
private var phase = Phase.scan |
|||
private var sessionToken = 0 |
|||
private var failed = false |
|||
private var stopping = false |
|||
private var recoverToken = 0 |
|||
private var sessionStart = CFAbsoluteTimeGetCurrent() |
|||
private var scanning = false |
|||
private var seen = Set<UUID>() |
|||
private var candidates: [UUID: (CBPeripheral, String, Bool)] = [:] |
|||
private var left: Ear? |
|||
private var right: Ear? |
|||
private var scanTimer: DispatchWorkItem? |
|||
private var linkAckTimer: DispatchWorkItem? |
|||
private var scanDelayTimer: DispatchWorkItem? |
|||
private var connectTimer: DispatchWorkItem? |
|||
private var statsTimer: DispatchWorkItem? |
|||
private var silenceTimer: DispatchWorkItem? |
|||
private var recoverTimer: DispatchWorkItem? |
|||
|
|||
private final class Ear { |
|||
let side: String |
|||
let peripheral: CBPeripheral |
|||
let name: String |
|||
let type: String |
|||
let label: String |
|||
let exec = DispatchQueue(label: "f2f.ble.ear") |
|||
var tx: CBCharacteristic? |
|||
var rx: CBCharacteristic? |
|||
var useWithResponse = false |
|||
var ready = false |
|||
var closed = false |
|||
var retries = 0 |
|||
var recovering = false |
|||
var startAcked = false |
|||
var lastConnect = CFAbsoluteTimeGetCurrent() |
|||
var state = "" |
|||
var writeQ: [Data] = [] |
|||
var writing = false |
|||
var stash = Data() |
|||
var codec: G722Codec? |
|||
var rawMode = false |
|||
var notifies: Int64 = 0 |
|||
var lastNotify = CFAbsoluteTimeGetCurrent() |
|||
var frames: Int64 = 0 |
|||
var pcmFrames: Int64 = 0 |
|||
var unknown: Int64 = 0 |
|||
var sent: Int64 = 0 |
|||
var dropped: Int64 = 0 |
|||
var writeFail: Int64 = 0 |
|||
var lost = 0 |
|||
init(side: String, peripheral: CBPeripheral, name: String) { |
|||
self.side = side |
|||
self.peripheral = peripheral |
|||
self.name = name |
|||
self.type = side == "L" ? "micData" : "spkData" |
|||
self.label = side == "L" ? "左耳" : "右耳" |
|||
} |
|||
func summary() -> String { |
|||
"\(label) \(peripheral.identifier.uuidString) 通知=\(notifies) 上行帧=\(frames) PCM=\(pcmFrames) 下发=\(sent) 丢=\(dropped) 写失败=\(writeFail) 掉线=\(lost)" |
|||
} |
|||
} |
|||
|
|||
private override init() { |
|||
super.init() |
|||
leQueue.setSpecific(key: leQueueKey, value: true) |
|||
leCentral = CBCentralManager(delegate: self, queue: leQueue) |
|||
} |
|||
|
|||
// MARK: - 经典蓝牙写入拦截(BluetoothManager.write 调用) |
|||
|
|||
/// 返回 true = 这条经典蓝牙写已经被面对面吃掉。 |
|||
public func interceptClassicWrite(_ data: Data) -> Bool { |
|||
let b = [UInt8](data) |
|||
let cmd: UInt8? = (b.count == 2 && b[0] == 0xAA && (b[1] == 0x66 || b[1] == 0x67)) ? b[1] : nil |
|||
if cmd == 0x66 { |
|||
_ = start("App 下发 AA 66") |
|||
return true |
|||
} |
|||
if sessionActive { |
|||
if cmd == 0x67 { |
|||
requestStop("App 下发 AA 67") |
|||
return true |
|||
} |
|||
if b.count >= 2 && b[0] == 0xAA && (b[1] == 0x51 || b[1] == 0x59 || b[1] == 0x6A) { |
|||
os_log("面对面进行中,拦下模式命令 AA %02X", log: Self.log, type: .default, b[1]) |
|||
return true |
|||
} |
|||
if classicLost { |
|||
if cmd == nil { |
|||
let head = b.prefix(4).map { String(format: "%02X", $0) }.joined(separator: " ") |
|||
os_log("经典蓝牙已断,控制命令丢弃 %{public}@", log: Self.log, type: .default, head) |
|||
} |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
@discardableResult |
|||
public func start(_ reason: String) -> Bool { |
|||
flagLock.lock() |
|||
if _sessionActive { flagLock.unlock(); return true } |
|||
if _recovering { |
|||
flagLock.unlock() |
|||
os_log("上一场面对面还在恢复中,拒绝进入(%{public}@)", log: Self.log, type: .default, reason) |
|||
publishOverall("failed", extra: ["reason": "recovering"]) |
|||
return false |
|||
} |
|||
_sessionActive = true |
|||
_classicLost = false |
|||
_pendingClassicReconnect = false |
|||
flagLock.unlock() |
|||
onLe { |
|||
self.failed = false |
|||
self.stopping = false |
|||
self.phase = .scan |
|||
self.sessionToken += 1 |
|||
self.sessionStart = CFAbsoluteTimeGetCurrent() |
|||
self.left = nil |
|||
self.right = nil |
|||
self.seen.removeAll() |
|||
self.candidates.removeAll() |
|||
UserDefaults.standard.set(true, forKey: self.prefsExit) |
|||
os_log("面对面 BLE 会话开始(%{public}@),经经典蓝牙发一帧 AA 19 06 02 01 01 切 BLE", log: Self.log, type: .default, reason) |
|||
self.publishSide("L", "scanning") |
|||
self.publishSide("R", "scanning") |
|||
let token = self.sessionToken |
|||
let frame = self.linkFrame(value: 1) |
|||
if BluetoothManager.sharedInstance?.writeClassicCtrl(frame, label: "AA 19 切 BLE(\(self.hex(frame, 8)))") != true { |
|||
self.fail("noSpp", "经典蓝牙未连接,AA 19 发不出去") |
|||
return |
|||
} |
|||
self.scanBlockedToken = token |
|||
self.armLinkAck(token) |
|||
self.diag("AA 19 已写出,等设备 BB 99;收到后再等 1 秒扫描并连接左右耳") |
|||
} |
|||
return true |
|||
} |
|||
|
|||
public func requestStop(_ reason: String) { |
|||
flagLock.lock() |
|||
let active = _sessionActive |
|||
flagLock.unlock() |
|||
guard active else { return } |
|||
onLe { |
|||
self.linkAckTimer?.cancel() |
|||
self.linkAckTimer = nil |
|||
self.scanDelayTimer?.cancel() |
|||
self.scanDelayTimer = nil |
|||
if self.stopping { return } |
|||
self.stopping = true |
|||
self.left?.writeQ.removeAll() |
|||
self.right?.writeQ.removeAll() |
|||
let n = self.writeCtrl(Data([0xAA, 0x67])) |
|||
if n > 0 { |
|||
os_log("AA 67 经 BLE 发给 %d 只耳机,300ms 后发 AA 19 00(%{public}@)", log: Self.log, type: .default, n, reason) |
|||
self.onLe(after: self.linkSwitchGap) { |
|||
let f = self.linkFrame(value: 0) |
|||
let m = self.writeCtrl(f, front: false) |
|||
os_log("AA 19 00 经 BLE 发给 %d 只耳机 %{public}@", log: Self.log, type: .default, m, self.hex(f, 8)) |
|||
self.onLe(after: self.gattCloseDelay) { self.stop(reason, reconnect: true, exitDelivered: m > 0) } |
|||
} |
|||
return |
|||
} |
|||
let back = self.linkFrame(value: 0) |
|||
let via = !self.classicLost |
|||
&& BluetoothManager.sharedInstance?.writeClassicCtrl(Data([0xAA, 0x67]), label: "AA 67") == true |
|||
&& BluetoothManager.sharedInstance?.writeClassicCtrl(back, label: "AA 19 00") == true |
|||
os_log("没有就绪的 BLE 耳机,AA 67 + AA 19 00 %{public}@(%{public}@)", |
|||
log: Self.log, type: .default, via ? "改经经典蓝牙发" : "经典蓝牙也不通,发不出去", reason) |
|||
self.onLe(after: self.gattCloseDelay) { self.stop(reason, reconnect: true, exitDelivered: via) } |
|||
} |
|||
} |
|||
|
|||
public func stop(_ reason: String, reconnect: Bool = true, exitDelivered: Bool = true) { |
|||
onLe { self.stopOnLe(reason, reconnect: reconnect, exitDelivered: exitDelivered) } |
|||
} |
|||
|
|||
public func onLinkAck(_ data: Data) { |
|||
onLe { |
|||
let status = self.linkAckStatus(data) |
|||
let statusText = status.map { String(format: "%02X", $0) } ?? "无" |
|||
self.diag("收到 BB 99 \(self.hex(data, 16)) status=\(statusText)") |
|||
let token = self.scanBlockedToken |
|||
guard token != 0, token == self.sessionToken, self.sessionActive, !self.stopping, !self.failed else { return } |
|||
guard self.scanDelayTimer == nil else { return } |
|||
self.linkAckTimer?.cancel() |
|||
self.linkAckTimer = nil |
|||
// 通道切换应答已经拿到,旧的 GATT over BR/EDR 让开。立刻记成已断开, |
|||
// 结束时恢复期才会回连;断连回调晚到也没关系。 |
|||
self.noteClassicLost() |
|||
BluetoothManager.sharedInstance?.suspendClassicForFaceToFace() |
|||
self.diag("已关闭经典 GATT over BR/EDR,\(Int(self.linkScanDelay * 1000)) 毫秒后只扫左右耳 BLE") |
|||
let work = DispatchWorkItem { [weak self] in |
|||
self?.beginScan(token: token) |
|||
} |
|||
self.scanDelayTimer = work |
|||
self.leQueue.asyncAfter(deadline: .now() + self.linkScanDelay, execute: work) |
|||
} |
|||
} |
|||
|
|||
/// `BB 99 04 <status>`。`00` 才是切到 BLE 成功。 |
|||
private func linkAckStatus(_ data: Data) -> UInt8? { |
|||
let b = [UInt8](data) |
|||
guard b.count >= 4, b[0] == 0xBB, b[1] == 0x99 else { return nil } |
|||
return b[3] |
|||
} |
|||
|
|||
public func onClassicConnected() { |
|||
onLe { |
|||
if self.sessionActive { return } |
|||
guard UserDefaults.standard.bool(forKey: self.prefsExit) else { return } |
|||
UserDefaults.standard.set(false, forKey: self.prefsExit) |
|||
os_log("上一场面对面异常退出(AA 19 00 未送达),经典蓝牙已连上,不补发", log: Self.log, type: .default) |
|||
} |
|||
} |
|||
|
|||
public func noteClassicLost() { |
|||
if !classicLost { |
|||
classicLost = true |
|||
os_log("面对面 BLE 期间经典蓝牙断开,按预期忽略(不上报断连)", log: Self.log, type: .default) |
|||
} |
|||
} |
|||
|
|||
@discardableResult |
|||
public func writeEarAudio(isLeft: Bool, pkt: Data) -> Bool { |
|||
var ok = false |
|||
onLeSync { |
|||
let ear = isLeft ? self.left : self.right |
|||
let other = isLeft ? self.right : self.left |
|||
guard let ear, ear.ready, other?.ready != false else { return } |
|||
ok = self.enqueue(ear, pkt, front: false) |
|||
} |
|||
return ok |
|||
} |
|||
|
|||
// MARK: - AA 19 |
|||
|
|||
/// `AA 19 06 02 01 <value>`。切 BLE 时 value=1,切回经典时 value=0。只发这一帧。 |
|||
private func linkFrame(value: UInt8) -> Data { |
|||
Data([0xAA, 0x19, 0x06, 0x02, 0x01, value]) |
|||
} |
|||
|
|||
private func startFrame() -> Data { Data([0xAA, 0x66, 0x06, 0x02, 0x01, 0x01]) } |
|||
|
|||
// MARK: - 扫描 / 连接 |
|||
|
|||
public func centralManagerDidUpdateState(_ central: CBCentralManager) { |
|||
guard sessionActive, phase == .scan, !scanning, central.state == .poweredOn else { |
|||
if sessionActive && central.state != .poweredOn && central.state != .unknown && central.state != .resetting { |
|||
fail("bluetoothOff", "蓝牙不可用,无法扫描 BLE") |
|||
} |
|||
return |
|||
} |
|||
startScan() |
|||
} |
|||
|
|||
/// `BB 99` 之后的 1 秒到了:开扫,扫到左右耳再连 GATT。 |
|||
private func beginScan(token: Int) { |
|||
scanDelayTimer = nil |
|||
linkAckTimer?.cancel() |
|||
linkAckTimer = nil |
|||
guard sessionToken == token, sessionActive, !stopping, !failed else { |
|||
diag("等待结束时这场已停,不再扫描") |
|||
return |
|||
} |
|||
if scanBlockedToken == token { scanBlockedToken = 0 } |
|||
diag("开始扫描 BLE central.state=\(leCentral.state.rawValue)(5=开)") |
|||
startScan() |
|||
if !scanning { |
|||
diag("扫描没有启动 central.state=\(leCentral.state.rawValue)") |
|||
} |
|||
arm(after: scanTimeout, token: token) { [weak self] in self?.onScanTimeout() } |
|||
armStats(token) |
|||
armSilence(token) |
|||
} |
|||
|
|||
private func armLinkAck(_ token: Int) { |
|||
linkAckTimer?.cancel() |
|||
let work = DispatchWorkItem { [weak self] in |
|||
guard let self, self.scanBlockedToken == token, self.sessionToken == token, |
|||
self.sessionActive, !self.stopping, !self.failed else { return } |
|||
self.fail("linkAck", "AA 19 发出后 \(Int(self.linkAckTimeout)) 秒没有收到 BB 99,不扫描 BLE") |
|||
} |
|||
linkAckTimer = work |
|||
leQueue.asyncAfter(deadline: .now() + linkAckTimeout, execute: work) |
|||
} |
|||
|
|||
private func startScan() { |
|||
guard sessionActive, !scanning else { return } |
|||
guard scanBlockedToken != sessionToken else { return } |
|||
guard leCentral.state == .poweredOn else { return } |
|||
leCentral.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) |
|||
scanning = true |
|||
} |
|||
|
|||
private func stopScan() { |
|||
guard scanning else { return } |
|||
scanning = false |
|||
if leCentral.state == .poweredOn { leCentral.stopScan() } |
|||
} |
|||
|
|||
public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, |
|||
advertisementData: [String: Any], rssi RSSI: NSNumber) { |
|||
guard sessionActive else { return } |
|||
let id = peripheral.identifier |
|||
let advName = advertisementData[CBAdvertisementDataLocalNameKey] as? String |
|||
let fromCache = advName == nil || advName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true |
|||
let name = (fromCache ? peripheral.name : advName) ?? "" |
|||
let first = seen.insert(id).inserted |
|||
let isEcho = name.lowercased().contains("echo") |
|||
if first && (isEcho || (!name.isEmpty && seen.count <= 40)) { |
|||
os_log("广播 %{public}@ name='%{public}@'%{public}@ rssi=%d", log: Self.log, type: .default, |
|||
id.uuidString, name, fromCache ? "(广播无名字,系统缓存名)" : "", RSSI.intValue) |
|||
} |
|||
guard isEcho else { return } |
|||
switch phase { |
|||
case .scan: |
|||
if candidates[id] != nil { return } |
|||
guard let side = sideOf(name) else { |
|||
if first { os_log("广播 %{public}@ '%{public}@' 不是 Echo-one-L / Echo-one-R,忽略", log: Self.log, type: .default, id.uuidString, name) } |
|||
return |
|||
} |
|||
if let same = candidates.first(where: { sideOf($0.value.1) == side }) { |
|||
if same.value.2 && !fromCache { |
|||
os_log("%{public}@耳广播 %{public}@ 带名字 '%{public}@',替换缓存名备胎", log: Self.log, type: .default, |
|||
side == "L" ? "左" : "右", id.uuidString, name) |
|||
candidates.removeValue(forKey: same.key) |
|||
} else { |
|||
if first { |
|||
os_log("又扫到一个%{public}@耳广播,已有同侧,忽略", log: Self.log, type: .default, side == "L" ? "左" : "右") |
|||
} |
|||
return |
|||
} |
|||
} |
|||
candidates[id] = (peripheral, name, fromCache) |
|||
let line = "扫到\(side == "L" ? "左" : "右")耳 \(id.uuidString) '\(name)'\(fromCache ? "(缓存名)" : "") rssi=\(RSSI.intValue)" |
|||
diag(line) |
|||
os_log("扫到%{public}@耳 %{public}@ '%{public}@'", log: Self.log, type: .default, |
|||
side == "L" ? "左" : "右", id.uuidString, name) |
|||
if candidates.count >= 2 && candidates.values.allSatisfy({ !$0.2 }) { onBothFound() } |
|||
default: |
|||
guard let ear = [left, right].compactMap({ $0 }).first(where: { $0.peripheral.identifier == id && $0.recovering }) else { return } |
|||
let now = CFAbsoluteTimeGetCurrent() |
|||
if ear.peripheral.state == .connected || ear.peripheral.state == .connecting || now - ear.lastConnect < 1 { return } |
|||
os_log("重新扫到%{public}@,重连 GATT", log: Self.log, type: .default, ear.label) |
|||
setState(ear, "connecting") |
|||
connectGatt(ear) |
|||
} |
|||
} |
|||
|
|||
private func onScanTimeout() { |
|||
guard sessionActive, phase == .scan else { return } |
|||
if candidates.count >= 2 { |
|||
os_log("扫描到时:一左一右都有(含缓存名备胎),照样开连", log: Self.log, type: .default) |
|||
onBothFound() |
|||
return |
|||
} |
|||
stopScan() |
|||
let n = candidates.count |
|||
let desc = candidates.map { "\($0.key.uuidString) '\($0.value.1)'" }.joined(separator: ", ") |
|||
fail(n == 1 ? "oneEar" : "noEar", "15s 内只扫到 \(n) 个 Echo-one 广播(共见 \(seen.count) 个):\(desc)") |
|||
} |
|||
|
|||
private func onBothFound() { |
|||
scanTimer?.cancel() |
|||
stopScan() |
|||
guard let l = candidates.values.first(where: { sideOf($0.1) == "L" }), |
|||
let r = candidates.values.first(where: { sideOf($0.1) == "R" }) else { return } |
|||
diag("左右耳都已扫到,发起 GATT:左=\(l.0.identifier.uuidString) 右=\(r.0.identifier.uuidString)") |
|||
os_log("左右耳都已扫到:左耳=%{public}@ 右耳=%{public}@,同时发起 GATT 连接", |
|||
log: Self.log, type: .default, l.0.identifier.uuidString, r.0.identifier.uuidString) |
|||
phase = .connect |
|||
let le = Ear(side: "L", peripheral: l.0, name: l.1) |
|||
let re = Ear(side: "R", peripheral: r.0, name: r.1) |
|||
left = le |
|||
right = re |
|||
setState(le, "connecting") |
|||
setState(re, "connecting") |
|||
connectGatt(le) |
|||
connectGatt(re) |
|||
arm(after: connectTimeout, token: sessionToken) { [weak self] in self?.onConnectTimeout() } |
|||
} |
|||
|
|||
private func onConnectTimeout() { |
|||
guard sessionActive, phase == .connect else { return } |
|||
if left?.ready == true && right?.ready == true { return } |
|||
fail("connectTimeout", "集齐后 10s 内两耳未都就绪:左耳=\(left?.ready == true ? "就绪" : left?.state ?? "无") 右耳=\(right?.ready == true ? "就绪" : right?.state ?? "无")") |
|||
} |
|||
|
|||
private func sideOf(_ name: String) -> String? { |
|||
let n = name.trimmingCharacters(in: .whitespacesAndNewlines) |
|||
if n.caseInsensitiveCompare(leftName) == .orderedSame { return "L" } |
|||
if n.caseInsensitiveCompare(rightName) == .orderedSame { return "R" } |
|||
return nil |
|||
} |
|||
|
|||
private func connectGatt(_ ear: Ear) { |
|||
guard !ear.closed, sessionActive else { return } |
|||
ear.lastConnect = CFAbsoluteTimeGetCurrent() |
|||
ear.peripheral.delegate = self |
|||
diag("\(ear.label) 发起 GATT 连接 \(ear.peripheral.identifier.uuidString)") |
|||
leCentral.connect(ear.peripheral, options: nil) |
|||
} |
|||
|
|||
public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { |
|||
guard let ear = earOf(peripheral) else { return } |
|||
diag("\(ear.label) GATT 已连接,开始发现服务") |
|||
os_log("%{public}@ GATT 已连接,发现服务", log: Self.log, type: .default, ear.label) |
|||
peripheral.discoverServices([serviceUUID]) |
|||
} |
|||
|
|||
public func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { |
|||
handleGattDown(peripheral, status: gattErrorText(error, fallback: "fail")) |
|||
} |
|||
|
|||
public func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { |
|||
handleGattDown(peripheral, status: gattErrorText(error, fallback: "disconnect")) |
|||
} |
|||
|
|||
private func gattErrorText(_ error: Error?, fallback: String) -> String { |
|||
guard let error = error as NSError? else { return fallback } |
|||
return "\(fallback) \(error.domain) \(error.code) \(error.localizedDescription)" |
|||
} |
|||
|
|||
private func handleGattDown(_ peripheral: CBPeripheral, status: String) { |
|||
guard let ear = earOf(peripheral), !ear.closed else { return } |
|||
let wasReady = ear.ready |
|||
ear.ready = false |
|||
ear.writeQ.removeAll() |
|||
ear.writing = false |
|||
diag("\(ear.label) GATT \(status) 曾就绪=\(wasReady) 重试=\(ear.retries)/\(gattRetries)") |
|||
os_log("%{public}@ GATT 断开 %{public}@ 曾就绪=%{public}@ 重试=%d/%d", |
|||
log: Self.log, type: .default, ear.label, status, wasReady ? "是" : "否", ear.retries, gattRetries) |
|||
guard sessionActive, !stopping else { return } |
|||
if phase == .live { |
|||
onEarLost(ear) |
|||
} else if ear.retries < gattRetries { |
|||
ear.retries += 1 |
|||
setState(ear, "connecting") |
|||
onLe(after: 0.8) { self.connectGatt(ear) } |
|||
} else { |
|||
fail("connectFailed", "\(ear.label) GATT 重试 \(gattRetries) 次仍断开") |
|||
} |
|||
} |
|||
|
|||
public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { |
|||
guard let ear = earOf(peripheral), error == nil, |
|||
let svc = peripheral.services?.first(where: { $0.uuid == serviceUUID }) else { |
|||
if let ear = earOf(peripheral) { |
|||
fail("noService", "\(ear.label) 没有私有服务") |
|||
} |
|||
return |
|||
} |
|||
peripheral.discoverCharacteristics(nil, for: svc) |
|||
} |
|||
|
|||
public func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { |
|||
guard let ear = earOf(peripheral), error == nil, let chars = service.characteristics else { return } |
|||
let notifyMask: CBCharacteristicProperties = [.notify, .indicate] |
|||
let writeMask: CBCharacteristicProperties = [.write, .writeWithoutResponse] |
|||
ear.rx = chars.first { $0.uuid == notifyUUID && !$0.properties.intersection(notifyMask).isEmpty } |
|||
?? chars.first { !$0.properties.intersection(notifyMask).isEmpty } |
|||
ear.tx = chars.first { $0.uuid == writeUUID && !$0.properties.intersection(writeMask).isEmpty } |
|||
?? chars.first { !$0.properties.intersection(writeMask).isEmpty } |
|||
if let tx = ear.tx { |
|||
ear.useWithResponse = !tx.properties.contains(.writeWithoutResponse) |
|||
} |
|||
let maxLen = peripheral.maximumWriteValueLength(for: ear.useWithResponse ? .withResponse : .withoutResponse) |
|||
os_log("%{public}@ 特征 tx=%{public}@ rx=%{public}@ 写=%{public}@ maxLen=%d", |
|||
log: Self.log, type: .default, ear.label, |
|||
ear.tx?.uuid.uuidString ?? "无", ear.rx?.uuid.uuidString ?? "无", |
|||
ear.useWithResponse ? "带响应" : "无响应", maxLen) |
|||
guard let rx = ear.rx else { |
|||
os_log("%{public}@ 没有可通知的特征,收不到上行", log: Self.log, type: .default, ear.label) |
|||
onReady(ear) |
|||
return |
|||
} |
|||
peripheral.setNotifyValue(true, for: rx) |
|||
} |
|||
|
|||
public func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { |
|||
guard let ear = earOf(peripheral) else { return } |
|||
os_log("%{public}@ 通知已开 %{public}@ err=%{public}@", log: Self.log, type: .default, |
|||
ear.label, characteristic.uuid.uuidString, error?.localizedDescription ?? "无") |
|||
if characteristic.uuid == ear.rx?.uuid { onReady(ear) } |
|||
} |
|||
|
|||
public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { |
|||
guard let ear = earOf(peripheral), error == nil, let value = characteristic.value, !value.isEmpty else { return } |
|||
guard characteristic.uuid == ear.rx?.uuid else { return } |
|||
onNotify(ear, value) |
|||
} |
|||
|
|||
public func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { |
|||
guard let ear = earOf(peripheral) else { return } |
|||
ear.writing = false |
|||
if error != nil { |
|||
ear.writeFail += 1 |
|||
} |
|||
pump(ear) |
|||
} |
|||
|
|||
public func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { |
|||
guard let ear = earOf(peripheral) else { return } |
|||
pump(ear) |
|||
} |
|||
|
|||
private func onReady(_ ear: Ear) { |
|||
guard !ear.ready else { return } |
|||
ear.ready = true |
|||
let ms = Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000) |
|||
os_log("%{public}@ 就绪(%dms)", log: Self.log, type: .default, ear.label, ms) |
|||
setState(ear, ear.pcmFrames > 0 ? "streaming" : "ready") |
|||
if ear.recovering { |
|||
ear.recovering = false |
|||
ear.startAcked = false |
|||
let ok = enqueue(ear, startFrame(), front: true) |
|||
os_log("%{public}@ 已重连就绪,经 BLE 补发 AA 66 %{public}@", log: Self.log, type: .default, ear.label, ok ? "" : "失败") |
|||
let token = sessionToken |
|||
onLe(after: startAckWait) { self.checkStartAck(ear, token) } |
|||
} |
|||
if left?.ready == true && right?.ready == true { |
|||
if phase == .connect { |
|||
phase = .live |
|||
connectTimer?.cancel() |
|||
left?.startAcked = false |
|||
right?.startAcked = false |
|||
let n = writeCtrl(startFrame()) |
|||
os_log("左右耳都已就绪(%dms),AA 66 06 02 01 01 经 BLE 发给 %d 只耳机,面对面正式开始", |
|||
log: Self.log, type: .default, ms, n) |
|||
let token = sessionToken |
|||
onLe(after: startAckWait) { |
|||
if let l = self.left { self.checkStartAck(l, token) } |
|||
if let r = self.right { self.checkStartAck(r, token) } |
|||
} |
|||
} |
|||
stopScan() |
|||
} |
|||
} |
|||
|
|||
private func checkStartAck(_ ear: Ear, _ token: Int) { |
|||
guard sessionActive, token == sessionToken, !stopping, !ear.closed, ear.ready else { return } |
|||
if ear.startAcked || ear.frames > 0 { return } |
|||
if !ear.useWithResponse, ear.tx?.properties.contains(.write) == true { |
|||
ear.useWithResponse = true |
|||
let ok = enqueue(ear, startFrame(), front: true) |
|||
os_log("%{public}@ AA 66 发出 1.5s 没有 BB E6 也没上行,改带响应写重发%{public}@", |
|||
log: Self.log, type: .default, ear.label, ok ? "" : "失败") |
|||
} else { |
|||
os_log("%{public}@ AA 66 发出 1.5s 仍没有 BB E6 也没上行", log: Self.log, type: .default, ear.label) |
|||
} |
|||
} |
|||
|
|||
private func onEarLost(_ ear: Ear) { |
|||
if ear.recovering { return } |
|||
ear.recovering = true |
|||
ear.lost += 1 |
|||
os_log("%{public}@ 会话中掉线(第 %d 次),10s 内重扫重连,期间两耳下行停发", log: Self.log, type: .default, ear.label, ear.lost) |
|||
setState(ear, "reconnecting") |
|||
onLe(after: earRecover) { |
|||
guard self.sessionActive, !ear.closed, ear.recovering else { return } |
|||
self.fail("earLost", "\(ear.label) 掉线后 10s 内没重连上") |
|||
} |
|||
startScan() |
|||
} |
|||
|
|||
// MARK: - 上行 |
|||
|
|||
private func onNotify(_ ear: Ear, _ v: Data) { |
|||
guard !ear.closed else { return } |
|||
ear.notifies += 1 |
|||
ear.lastNotify = CFAbsoluteTimeGetCurrent() |
|||
if ear.notifies <= 8 || ear.notifies % 1000 == 0 { |
|||
os_log("%{public}@ 通知#%d len=%d %{public}@", log: Self.log, type: .default, ear.label, ear.notifies, v.count, hex(v, 24)) |
|||
} |
|||
let bytes = v |
|||
ear.exec.async { self.parse(ear, bytes) } |
|||
} |
|||
|
|||
private func parse(_ ear: Ear, _ v: Data) { |
|||
if ear.closed { return } |
|||
let b0 = v.first ?? 0 |
|||
if ear.stash.isEmpty && b0 != 0xBB && b0 != 0xCC && v.count % 40 == 0 { |
|||
if !ear.rawMode { |
|||
ear.rawMode = true |
|||
os_log("%{public}@ 通知不是 BB/CC 帧且长度为 40 的倍数,按裸 G.722 解", log: Self.log, type: .default, ear.label) |
|||
} |
|||
decodeChunks(ear, v, start: 0) |
|||
return |
|||
} |
|||
ear.stash.append(v) |
|||
if ear.stash.count > 65536 { ear.stash.removeAll(); return } |
|||
let buf = [UInt8](ear.stash) |
|||
var cursor = 0 |
|||
while ear.stash.count - cursor >= 3 { |
|||
while cursor < buf.count && buf[cursor] != 0xBB && buf[cursor] != 0xCC { cursor += 1 } |
|||
if buf.count - cursor < 3 { break } |
|||
var len = Int(buf[cursor + 2]) |
|||
if buf[cursor] == 0xCC { len += 256 } |
|||
if len < 2 { cursor += 1; continue } |
|||
if buf.count - cursor < len { break } |
|||
handleFrame(ear, Data(buf[cursor..<(cursor + len)])) |
|||
cursor += len |
|||
} |
|||
if cursor > 0 { ear.stash = Data(buf.dropFirst(cursor)) } |
|||
} |
|||
|
|||
private func handleFrame(_ ear: Ear, _ f: Data) { |
|||
let b = [UInt8](f) |
|||
guard b.count >= 2 else { return } |
|||
switch b[1] { |
|||
case 0x68, 0x55, 0x60: |
|||
ear.frames += 1 |
|||
if ear.frames <= 5 || ear.frames % 500 == 0 { |
|||
os_log("%{public}@ 上行#%d len=%d 头=%{public}@ 累计PCM=%d", |
|||
log: Self.log, type: .default, ear.label, ear.frames, f.count, hex(f, 8), ear.pcmFrames) |
|||
} |
|||
decodeChunks(ear, f, start: 4) |
|||
case 0x66: |
|||
os_log("%{public}@ 收到启动 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16)) |
|||
publish(["type": "startFaceToFace", "data": f]) |
|||
case 0x67: |
|||
os_log("%{public}@ 收到结束 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16)) |
|||
publish(["type": "stopFaceToFace", "data": f]) |
|||
let token = sessionToken |
|||
onLe(after: 3) { if token == self.sessionToken { self.requestStop("耳机经 BLE 上报 BB 67") } } |
|||
case 0xE6: |
|||
ear.startAcked = true |
|||
os_log("%{public}@ 收到启动应答 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16)) |
|||
case 0xE7: |
|||
os_log("%{public}@ 收到结束应答 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16)) |
|||
case 0x99: |
|||
os_log("%{public}@ 收到 AA 19 应答 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16)) |
|||
default: |
|||
ear.unknown += 1 |
|||
if ear.unknown <= 20 { |
|||
os_log("%{public}@ 未识别帧 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16)) |
|||
} |
|||
} |
|||
} |
|||
|
|||
private func decodeChunks(_ ear: Ear, _ data: Data, start: Int) { |
|||
if ear.codec == nil { ear.codec = G722Codec(sampleRate: 16000) } |
|||
var pos = start |
|||
let bytes = [UInt8](data) |
|||
while pos + 40 <= bytes.count { |
|||
let chunk = Data(bytes[pos..<(pos + 40)]) |
|||
if let pcm = ear.codec?.decode(chunk) { |
|||
ear.pcmFrames += 1 |
|||
if ear.pcmFrames == 1 || ear.state == "ready" { setState(ear, "streaming") } |
|||
publish(["type": ear.type, "data": pcm]) |
|||
} |
|||
pos += 40 |
|||
} |
|||
} |
|||
|
|||
// MARK: - 写 |
|||
|
|||
@discardableResult |
|||
private func writeCtrl(_ msg: Data, front: Bool = true) -> Int { |
|||
var n = 0 |
|||
for ear in [left, right].compactMap({ $0 }) { |
|||
if enqueue(ear, msg, front: front) { n += 1 } |
|||
} |
|||
return n |
|||
} |
|||
|
|||
@discardableResult |
|||
private func enqueue(_ ear: Ear, _ bytes: Data, front: Bool) -> Bool { |
|||
guard ear.ready, ear.tx != nil else { return false } |
|||
if front { ear.writeQ.insert(bytes, at: 0) } else { ear.writeQ.append(bytes) } |
|||
if ear.writeQ.count > writeQueueCap { |
|||
if !front { ear.writeQ.removeFirst() } else if ear.writeQ.count > 1 { ear.writeQ.remove(at: 1) } |
|||
ear.dropped += 1 |
|||
} |
|||
pump(ear) |
|||
return true |
|||
} |
|||
|
|||
private func pump(_ ear: Ear) { |
|||
guard ear.ready, let tx = ear.tx, !ear.writing else { return } |
|||
let p = ear.peripheral |
|||
let noResp = !ear.useWithResponse && tx.properties.contains(.writeWithoutResponse) |
|||
if noResp && !p.canSendWriteWithoutResponse && ear.sent > 0 { return } |
|||
guard !ear.writeQ.isEmpty else { return } |
|||
let bytes = ear.writeQ.removeFirst() |
|||
let type: CBCharacteristicWriteType = noResp ? .withoutResponse : .withResponse |
|||
p.writeValue(bytes, for: tx, type: type) |
|||
if !noResp { ear.writing = true } |
|||
ear.sent += 1 |
|||
if ear.sent <= 6 || ear.sent % 500 == 0 { |
|||
os_log("%{public}@ 下发#%d len=%d %{public}@", log: Self.log, type: .default, ear.label, ear.sent, bytes.count, hex(bytes, 8)) |
|||
} |
|||
if noResp { pump(ear) } |
|||
} |
|||
|
|||
// MARK: - 结束 / 恢复 |
|||
|
|||
private func stopOnLe(_ reason: String, reconnect: Bool, exitDelivered: Bool) { |
|||
flagLock.lock() |
|||
guard _sessionActive else { flagLock.unlock(); return } |
|||
_sessionActive = false |
|||
let lost = _classicLost |
|||
_classicLost = false |
|||
_pendingClassicReconnect = reconnect && lost |
|||
flagLock.unlock() |
|||
let ears = [left, right].compactMap { $0 } |
|||
left = nil |
|||
right = nil |
|||
sessionToken += 1 |
|||
if exitDelivered { UserDefaults.standard.set(false, forKey: prefsExit) } |
|||
scanTimer?.cancel(); connectTimer?.cancel(); statsTimer?.cancel(); silenceTimer?.cancel() |
|||
stopScan() |
|||
os_log("面对面 BLE 会话结束(%{public}@)经典蓝牙%{public}@ 切回指令%{public}@", |
|||
log: Self.log, type: .default, reason, lost ? "已断" : "未断", exitDelivered ? "已发" : "未送达") |
|||
for e in ears { |
|||
e.closed = true |
|||
e.ready = false |
|||
e.writeQ.removeAll() |
|||
if e.peripheral.state == .connected || e.peripheral.state == .connecting { |
|||
leCentral.cancelPeripheralConnection(e.peripheral) |
|||
} |
|||
e.codec = nil |
|||
} |
|||
guard reconnect else { |
|||
publishSide("L", "idle") |
|||
publishSide("R", "idle") |
|||
return |
|||
} |
|||
beginRecovery(lost) |
|||
} |
|||
|
|||
private func beginRecovery(_ classicWasLost: Bool) { |
|||
flagLock.lock() |
|||
_recovering = true |
|||
recoverToken += 1 |
|||
let token = recoverToken |
|||
flagLock.unlock() |
|||
publishSide("L", "restoring") |
|||
publishSide("R", "restoring") |
|||
os_log("进入恢复期(%{public}@)", log: Self.log, type: .default, classicWasLost ? "回连经典蓝牙" : "经典蓝牙未断,冷却 2s") |
|||
recoverTimer?.cancel() |
|||
let cap = DispatchWorkItem { [weak self] in self?.endRecovery(token, false, "恢复超时 30s") } |
|||
recoverTimer = cap |
|||
leQueue.asyncAfter(deadline: .now() + recoverCap, execute: cap) |
|||
if classicWasLost { |
|||
onLe(after: classicReconnectDelay) { |
|||
BluetoothManager.sharedInstance?.reconnectClassicAfterF2f { ok in |
|||
self.onLe { self.endRecovery(token, ok, ok ? "经典蓝牙回连成功" : "经典蓝牙回连失败") } |
|||
} |
|||
} |
|||
} else { |
|||
onLe(after: recoverCooldown) { self.endRecovery(token, true, "冷却结束") } |
|||
} |
|||
} |
|||
|
|||
private func endRecovery(_ token: Int, _ ok: Bool, _ why: String) { |
|||
flagLock.lock() |
|||
guard _recovering, token == recoverToken else { flagLock.unlock(); return } |
|||
_recovering = false |
|||
_pendingClassicReconnect = false |
|||
flagLock.unlock() |
|||
recoverTimer?.cancel() |
|||
os_log("恢复期结束:%{public}@", log: Self.log, type: .default, why) |
|||
publishSide("L", "idle") |
|||
publishSide("R", "idle") |
|||
publishOverall("recovered", extra: ["ok": ok]) |
|||
} |
|||
|
|||
private func fail(_ code: String, _ detail: String) { |
|||
guard sessionActive, !failed, !stopping else { return } |
|||
failed = true |
|||
linkAckTimer?.cancel() |
|||
linkAckTimer = nil |
|||
scanDelayTimer?.cancel() |
|||
scanDelayTimer = nil |
|||
diag("失败[\(code)] \(detail)") |
|||
publishOverall("failed", extra: ["reason": code]) |
|||
let token = sessionToken |
|||
onLe(after: failStop) { |
|||
if self.sessionActive && token == self.sessionToken { self.requestStop("失败[\(code)]后 Dart 未收尾") } |
|||
} |
|||
} |
|||
|
|||
// MARK: - 辅助 |
|||
|
|||
private func earOf(_ p: CBPeripheral) -> Ear? { |
|||
if left?.peripheral.identifier == p.identifier { return left } |
|||
if right?.peripheral.identifier == p.identifier { return right } |
|||
return nil |
|||
} |
|||
|
|||
private func setState(_ ear: Ear, _ state: String) { |
|||
if ear.closed || ear.state == state { return } |
|||
ear.state = state |
|||
os_log("%{public}@ 状态 → %{public}@", log: Self.log, type: .default, ear.label, state) |
|||
publishSide(ear.side, state) |
|||
} |
|||
|
|||
private func publishSide(_ side: String, _ state: String) { |
|||
publish(["type": "f2fLink", "side": side, "state": state]) |
|||
} |
|||
|
|||
private func publishOverall(_ state: String, extra: [String: Any]) { |
|||
var ev: [String: Any] = ["type": "f2fLink", "side": "", "state": state] |
|||
extra.forEach { ev[$0.key] = $0.value } |
|||
publish(ev) |
|||
} |
|||
|
|||
private func publish(_ event: [String: Any]) { |
|||
BluetoothManager.publishF2f(event) |
|||
} |
|||
|
|||
/// 扫连过程写进 App 文件日志,真机上不用 syslog 也能看到卡在哪一步。 |
|||
private func diag(_ message: String) { |
|||
os_log("F2F-BLE %{public}@", log: Self.log, type: .default, message) |
|||
publish(["type": "f2fLog", "msg": message]) |
|||
} |
|||
|
|||
private func checkSilence(_ token: Int) { |
|||
guard sessionActive, token == sessionToken else { return } |
|||
let now = CFAbsoluteTimeGetCurrent() |
|||
for e in [left, right].compactMap({ $0 }) { |
|||
if e.ready && e.state == "streaming" && now - e.lastNotify > uplinkSilent { |
|||
os_log("%{public}@ 上行已 %.0fms 没数据,标签退回 ready", log: Self.log, type: .default, e.label, (now - e.lastNotify) * 1000) |
|||
setState(e, "ready") |
|||
} |
|||
} |
|||
armSilence(token) |
|||
} |
|||
|
|||
private func logStats(_ token: Int) { |
|||
guard sessionActive, token == sessionToken else { return } |
|||
let ears = [left, right].compactMap { $0 } |
|||
let sec = Int(CFAbsoluteTimeGetCurrent() - sessionStart) |
|||
if ears.isEmpty { |
|||
os_log("会话统计(%ds 阶段=scan):已扫到 %d 只", log: Self.log, type: .default, sec, candidates.count) |
|||
} else { |
|||
os_log("会话统计(%ds):%{public}@", log: Self.log, type: .default, sec, ears.map { "\($0.summary()) 就绪=\($0.ready)" }.joined(separator: ";")) |
|||
} |
|||
armStats(token) |
|||
} |
|||
|
|||
private func armStats(_ token: Int) { |
|||
statsTimer?.cancel() |
|||
let work = DispatchWorkItem { [weak self] in self?.logStats(token) } |
|||
statsTimer = work |
|||
leQueue.asyncAfter(deadline: .now() + statsInterval, execute: work) |
|||
} |
|||
|
|||
private func armSilence(_ token: Int) { |
|||
silenceTimer?.cancel() |
|||
let work = DispatchWorkItem { [weak self] in self?.checkSilence(token) } |
|||
silenceTimer = work |
|||
leQueue.asyncAfter(deadline: .now() + 0.5, execute: work) |
|||
} |
|||
|
|||
private func arm(after: TimeInterval, token: Int, _ body: @escaping () -> Void) { |
|||
let work = DispatchWorkItem { [weak self] in |
|||
guard let self, self.sessionToken == token else { return } |
|||
body() |
|||
} |
|||
if after == scanTimeout { scanTimer?.cancel(); scanTimer = work } |
|||
if after == connectTimeout { connectTimer?.cancel(); connectTimer = work } |
|||
leQueue.asyncAfter(deadline: .now() + after, execute: work) |
|||
} |
|||
|
|||
private func onLe(_ body: @escaping () -> Void) { |
|||
if DispatchQueue.getSpecific(key: leQueueKey) == true { body() } |
|||
else { leQueue.async(execute: body) } |
|||
} |
|||
|
|||
private func onLeSync(_ body: () -> Void) { |
|||
if DispatchQueue.getSpecific(key: leQueueKey) == true { body() } |
|||
else { leQueue.sync(execute: body) } |
|||
} |
|||
|
|||
private func onLe(after: TimeInterval, _ body: @escaping () -> Void) { |
|||
leQueue.asyncAfter(deadline: .now() + after, execute: body) |
|||
} |
|||
|
|||
private func hex(_ data: Data, _ max: Int) -> String { |
|||
let n = min(max, data.count) |
|||
let h = data.prefix(n).map { String(format: "%02X", $0) }.joined(separator: " ") |
|||
return data.count > max ? "\(h) …(\(data.count))" : h |
|||
} |
|||
} |
|||
Loading…
Reference in new issue