diff --git a/apps/client/assets/images/devices/Echo-translation.png b/apps/client/assets/images/devices/Echo-translation.png new file mode 100644 index 00000000..1b536f7b Binary files /dev/null and b/apps/client/assets/images/devices/Echo-translation.png differ diff --git a/apps/client/assets/images/devices/Echo-translation_icon.png b/apps/client/assets/images/devices/Echo-translation_icon.png new file mode 100644 index 00000000..9a8797ea Binary files /dev/null and b/apps/client/assets/images/devices/Echo-translation_icon.png differ diff --git a/apps/client/lib/data/models/device_category.dart b/apps/client/lib/data/models/device_category.dart index 9d493df3..d66df8d5 100644 --- a/apps/client/lib/data/models/device_category.dart +++ b/apps/client/lib/data/models/device_category.dart @@ -23,6 +23,8 @@ class DeviceCategory { this.imageAsset, this.deviceTypes = const [], this.nameKeywords = const [], + this.productLabel, + this.bluetoothName, }); final String id; @@ -45,6 +47,12 @@ class DeviceCategory { /// 命中的 devicename 关键字(大小写不敏感,包含即算) final List nameKeywords; + /// 选择设备页上的产品名。有值时原样显示,不再把 [title] 当文案键翻译。 + final String? productLabel; + + /// 点进连接页时只认这个蓝牙名。空表示该品类下的名字都认。 + final String? bluetoothName; + /// 该品类是否已经配置了筛选条件 bool get hasFilter => deviceTypes.isNotEmpty || nameKeywords.isNotEmpty; } @@ -72,7 +80,7 @@ class DeviceCategories { // 2026-09-08 废弃,下发的值恒为 0,再也匹配不到 1/2/3。留着字段只为不动 // 老数据结构,**新品类一律用 nameKeywords 圈定**。圈不到时 productsOf 返回空。 // - // 「选择设备」页当前上架两条:AI耳机(恒玄)、AI车载香薰(EaiCar)。 + // 「选择设备」页当前上架:AI耳机(Echo-one)、Echo-translation、AI车载香薰(EaiCar)。 // AI手机支架(EaiRec)2026-09-26 暂时下架,定义在 [hidden] 里。 // 下架某条 = 把它挪回 [hidden]。 static const List all = [ @@ -84,7 +92,20 @@ class DeviceCategories { gradient: [Color(0xFFAB47BC), Color(0xFF7B1FA2)], imageAsset: '${DeviceImages.dir}Echo-one.png', // deviceTypes 已失效(见上),耳机改由名字圈定 - nameKeywords: ['echo-one', 'deepvoice', 'deepsound', 'buds', 'ai80', 'a80'], + nameKeywords: ['echo-one', 'echo-translation', 'deepvoice', 'deepsound', 'buds', 'ai80', 'a80'], + ), + // 另一颜色的同协议耳机。选择页单独一张卡,名字和图片都是这款产品自己的。 + // id 仍是 earphone:连接、校验、默认功能都走恒玄耳机那条,不另开品类。 + DeviceCategory( + id: earphone, + title: 'catEarphone', + subtitle: 'catEarphoneDesc', + icon: Icons.headphones, + gradient: [Color(0xFF616161), Color(0xFF212121)], + imageAsset: '${DeviceImages.dir}Echo-translation.png', + productLabel: 'Echo-translation', + bluetoothName: 'Echo-translation', + nameKeywords: ['echo-translation'], ), // AI车载香薰 = EaiCar(广播名,旧名 Smartcar 已于 2026-09-15 停止接纳)。 DeviceCategory( diff --git a/apps/client/lib/modules/devices/views/bes_scan_view.dart b/apps/client/lib/modules/devices/views/bes_scan_view.dart index 72e119d4..24c8e748 100644 --- a/apps/client/lib/modules/devices/views/bes_scan_view.dart +++ b/apps/client/lib/modules/devices/views/bes_scan_view.dart @@ -28,13 +28,20 @@ class BesScanView extends StatefulWidget { } class _BesScanViewState extends State { + /// 从「选择设备」点进来时带的蓝牙名。空则该品类下的名字都认。 + late final String? _bluetoothName; + /// 展示用的设备名。当前固件的广播名。 - static const String _targetName = 'Echo-one'; + String get _targetName => _bluetoothName ?? 'Echo-one'; /// 认哪些广播名:设备端 2026-08-31 把广播名从 `DEEPVOICE` 改成 `Echo-one`, /// 但 Android 的配对列表给的是系统缓存的配对名,改名前配过对的手机在重新配对 /// 之前仍然报旧名。旧名可在存量设备全部换代后删除。 - static const List _acceptedNames = ['Echo-one', 'DEEPVOICE']; + /// `Echo-translation` 是另一颜色的同协议耳机,从它自己的卡片进来时只认这个名字。 + static const List _allAcceptedNames = ['Echo-one', 'Echo-translation', 'DEEPVOICE']; + + List get _acceptedNames => + _bluetoothName == null ? _allAcceptedNames : [_bluetoothName!]; DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.bes)!; final RxList _devices = [].obs; @@ -46,6 +53,8 @@ class _BesScanViewState extends State { @override void initState() { super.initState(); + final arg = Get.arguments; + _bluetoothName = arg is String && arg.trim().isNotEmpty ? arg.trim() : null; // 主动连接失败后耳机才连上手机的场景:原生靠 ACL 广播自动补 RFCOMM, // 一旦上线,这页也要把绑定存下来并跳回设备页。 // `_connecting` 期间不开火:候选循环自己会收尾。 @@ -194,7 +203,7 @@ class _BesScanViewState extends State { appBar: AppBar( backgroundColor: AppPalette.background(isDark), elevation: 0, - title: const Text('添加 AI 耳机($_targetName)'), + title: Text('添加 AI 耳机($_targetName)'), actions: [ IconButton(icon: const Icon(Icons.refresh), onPressed: _load), ], diff --git a/apps/client/lib/modules/devices/views/device_category_view.dart b/apps/client/lib/modules/devices/views/device_category_view.dart index 8d13620f..31eac9cb 100644 --- a/apps/client/lib/modules/devices/views/device_category_view.dart +++ b/apps/client/lib/modules/devices/views/device_category_view.dart @@ -72,7 +72,7 @@ class DeviceCategoryView extends StatelessWidget { } switch (category.id) { case DeviceCategories.earphone: - Get.toNamed(Routes.besScan); + Get.toNamed(Routes.besScan, arguments: category.bluetoothName); break; case DeviceCategories.carAroma: // EaiCar 车载香薰,走 lib/devices/eaicar 那条 BLE 链路(C2 私有协议)。 @@ -127,7 +127,7 @@ class DeviceCategoryView extends StatelessWidget { Padding( padding: EdgeInsets.fromLTRB(10.w, 0, 10.w, 12.h), child: Text( - category.title.tr, + category.productLabel ?? category.title.tr, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt index 53831ce7..2962d95f 100644 --- a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt +++ b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt @@ -27,7 +27,7 @@ import java.util.concurrent.RejectedExecutionException * Echo-one 面对面的 **BLE 双耳链路**(协议见 docs/face_to_face_ble_protocol.md v0.4)。 * * 时序(App → 耳机全是 AA 帧头): - * ① SPP 发一帧 `AA 19 06 02 01 01` 切 BLE 链路 → 左右耳各自发 BLE 广播(**`Echo-one-L` / `Echo-one-R`**); + * ① SPP 发一帧 `AA 19 06 02 01 01` 切 BLE 链路 → 左右耳各自发 BLE 广播(`Echo-one-L/R` 或 `Echo-translation-L/R`); * ② App 扫到两只、各建一条 GATT(MTU + 开通知); * ③ 两耳都就绪后经 BLE 给两耳发 `AA 66` 启动面对面; * ④ 左耳收音 → 译文播右耳,右耳收音 → 译文播左耳; @@ -35,7 +35,7 @@ import java.util.concurrent.RejectedExecutionException * Dart 仍只发 `AA 66` / `AA 67`,由 BluetoothManager.writeBytes 转给 [start] / [requestStop] 编排。 * * 细节: - * - **集齐一左一右再连**:扫描 [SCAN_TIMEOUT_MS] 内要扫到 [LEFT_NAME]、[RIGHT_NAME] 各一个广播,只扫到一侧/一个都没有 + * - **集齐一左一右再连**:扫描 [SCAN_TIMEOUT_MS] 内要扫到左耳名、右耳名各一个广播,只扫到一侧/一个都没有 * 判失败回滚;同侧的第二个广播(附近别的 Echo-one)忽略;名字写死,`-L` = 左耳 = leg A,其它名字一律不认; * - 两条 GATT 同时连,[CONNECT_TIMEOUT_MS] 内两耳都要就绪(MTU + 开通知),否则失败回滚; * - 服务 00001100-d102-…,可写特征 / 通知特征按 properties 挑(真机 1101 可写、1102 notify); @@ -79,9 +79,9 @@ object F2fBleLink { private const val UPLINK_SILENT_MS = 1_500L private const val SILENCE_CHECK_MS = 500L - /** 固件在面对面里的左右耳广播名(写死,只认这两个) */ - private const val LEFT_NAME = "Echo-one-L" - private const val RIGHT_NAME = "Echo-one-R" + /** 面对面左右耳广播名。Echo-one 与同协议的 Echo-translation 都认,`-L` 左耳 / `-R` 右耳。 */ + private val LEFT_NAMES = listOf("Echo-one-L", "Echo-translation-L") + private val RIGHT_NAMES = listOf("Echo-one-R", "Echo-translation-R") private const val REQUEST_MTU = 247 /** 建链阶段单耳 GATT 断开的重试次数 */ private const val GATT_RETRIES = 3 @@ -655,7 +655,7 @@ object F2fBleLink { if (candidates.containsKey(addr)) return val side = sideOf(name) if (side == null) { - if (firstSeen) Log.w(TAG, "广播 $addr '$name' 不是 $LEFT_NAME / $RIGHT_NAME,忽略") + if (firstSeen) Log.w(TAG, "广播 $addr '$name' 不是 Echo-one / Echo-translation 的左右耳名,忽略") return } // 已经有同侧的了:多半是旁边另一副 Echo-one,别凑成两只左耳 @@ -708,12 +708,12 @@ object F2fBleLink { handler.postDelayed(connectDeadline, CONNECT_TIMEOUT_MS) } - /** 广播名 → 左右:只认 [LEFT_NAME] / [RIGHT_NAME](忽略大小写和首尾空白),其它返回 null */ + /** 广播名 → 左右:只认 Echo-one / Echo-translation 的 `-L` / `-R`(忽略大小写和首尾空白)。 */ private fun sideOf(name: String): String? { val n = name.trim { it.isWhitespace() || it == '\u0000' } return when { - n.equals(LEFT_NAME, ignoreCase = true) -> "L" - n.equals(RIGHT_NAME, ignoreCase = true) -> "R" + LEFT_NAMES.any { n.equals(it, ignoreCase = true) } -> "L" + RIGHT_NAMES.any { n.equals(it, ignoreCase = true) } -> "R" else -> null } } diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift index 0898e198..b08ae00f 100644 --- a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift +++ b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift @@ -5,7 +5,7 @@ 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。 +/// 左右耳 `Echo-one-L/R` 或 `Echo-translation-L/R` 另起一个不带状态恢复的 LE central,避免和经典蓝牙抢同一个 central。 /// Dart 仍只发 `AA 66` / `AA 67`。 public final class F2fBleLink: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { @@ -47,8 +47,8 @@ public final class F2fBleLink: NSObject, CBCentralManagerDelegate, CBPeripheralD private let writeQueueCap = 8 private let gattRetries = 3 - private let leftName = "Echo-one-L" - private let rightName = "Echo-one-R" + private let leftNames = ["Echo-one-L", "Echo-translation-L"] + private let rightNames = ["Echo-one-R", "Echo-translation-R"] private let prefsExit = "f2f.exitPending" @@ -395,7 +395,7 @@ public final class F2fBleLink: NSObject, CBCentralManagerDelegate, CBPeripheralD 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) } + if first { os_log("广播 %{public}@ '%{public}@' 不是 Echo-one / Echo-translation 的左右耳名,忽略", log: Self.log, type: .default, id.uuidString, name) } return } if let same = candidates.first(where: { sideOf($0.value.1) == side }) { @@ -436,7 +436,7 @@ public final class F2fBleLink: NSObject, CBCentralManagerDelegate, CBPeripheralD 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)") + fail(n == 1 ? "oneEar" : "noEar", "15s 内只扫到 \(n) 个 Echo 耳机广播(共见 \(seen.count) 个):\(desc)") } private func onBothFound() { @@ -467,8 +467,8 @@ public final class F2fBleLink: NSObject, CBCentralManagerDelegate, CBPeripheralD 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" } + if leftNames.contains(where: { n.caseInsensitiveCompare($0) == .orderedSame }) { return "L" } + if rightNames.contains(where: { n.caseInsensitiveCompare($0) == .orderedSame }) { return "R" } return nil }