Browse Source

client:新增同协议耳机 Echo-translation,选择设备页单独展示,连接和面对面左右耳都认这个蓝牙名。

Co-authored-by: Cursor <cursoragent@cursor.com>
along
Rodger-Wang 12 hours ago
parent
commit
3635322ac5
  1. BIN
      apps/client/assets/images/devices/Echo-translation.png
  2. BIN
      apps/client/assets/images/devices/Echo-translation_icon.png
  3. 25
      apps/client/lib/data/models/device_category.dart
  4. 15
      apps/client/lib/modules/devices/views/bes_scan_view.dart
  5. 4
      apps/client/lib/modules/devices/views/device_category_view.dart
  6. 18
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt
  7. 14
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift

BIN
apps/client/assets/images/devices/Echo-translation.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

BIN
apps/client/assets/images/devices/Echo-translation_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

25
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<String> 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<DeviceCategory> 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(

15
apps/client/lib/modules/devices/views/bes_scan_view.dart

@ -28,13 +28,20 @@ class BesScanView extends StatefulWidget {
}
class _BesScanViewState extends State<BesScanView> {
/// 从「选择设备」点进来时带的蓝牙名。空则该品类下的名字都认。
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<String> _acceptedNames = ['Echo-one', 'DEEPVOICE'];
/// `Echo-translation` 是另一颜色的同协议耳机,从它自己的卡片进来时只认这个名字。
static const List<String> _allAcceptedNames = ['Echo-one', 'Echo-translation', 'DEEPVOICE'];
List<String> get _acceptedNames =>
_bluetoothName == null ? _allAcceptedNames : [_bluetoothName!];
DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.bes)!;
final RxList<DiscoveredDevice> _devices = <DiscoveredDevice>[].obs;
@ -46,6 +53,8 @@ class _BesScanViewState extends State<BesScanView> {
@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<BesScanView> {
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),
],

4
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,

18
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
}
}

14
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
}

Loading…
Cancel
Save