Browse Source

上传一个稳定版本

weicu
liwei1dao 5 months ago
parent
commit
8c3c9c4506
  1. 13
      lib/data/services/ast_service.dart
  2. 93
      lib/data/services/ble_manager.dart
  3. 6
      lib/data/services/speech_impl/azure_ast_service.dart
  4. 160
      lib/modules/agent/controllers/agent_controller.dart
  5. 847
      lib/modules/agent/views/agent_view.dart
  6. 6
      lib/modules/devices/controllers/devices_controller.dart
  7. 63
      lib/modules/meeting/controllers/meeting_record_controller.dart
  8. 277
      lib/modules/meeting/utils/jieli_call_recorder.dart
  9. 6
      lib/modules/translation/controllers/translation_controller.dart
  10. 17
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  11. 19
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  12. 22
      local_plugins/agent_service/lib/agent_service.dart
  13. 22
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt
  14. 19
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  15. 242
      local_plugins/device_jieli/JIELI_SDK_COMMANDS.md
  16. 4
      local_plugins/device_jieli/android/build.gradle
  17. 22
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt
  18. 5
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt
  19. 45
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt
  20. 68
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt
  21. 65
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt
  22. 6
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt
  23. 8
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt
  24. 297
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt
  25. 143
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/AssistantBridge.kt
  26. 276
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/JieliAssistantPort.kt
  27. 141
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/DeviceRecordFeature.kt
  28. 151
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt
  29. 12
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt
  30. 113
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt
  31. 102
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt
  32. 46
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt
  33. 62
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt
  34. 56
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt
  35. 280
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliOtaPort.kt
  36. 268
      local_plugins/device_jieli/lib/device_jieli.dart
  37. 48
      local_plugins/device_jieli/lib/src/jieli_device_plugin.dart
  38. 68
      local_plugins/device_jieli/lib/src/jieli_device_session.dart
  39. 149
      local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Assistant.kt
  40. 16
      local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt
  41. 11
      local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt
  42. 170
      local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Ota.kt
  43. 1
      local_plugins/device_plugin_interface/lib/device_plugin_interface.dart
  44. 7
      local_plugins/device_plugin_interface/lib/src/device_exception.dart
  45. 13
      local_plugins/device_plugin_interface/lib/src/device_info.dart
  46. 172
      local_plugins/device_plugin_interface/lib/src/device_ota.dart
  47. 10
      local_plugins/device_plugin_interface/lib/src/device_session.dart

13
lib/data/services/ast_service.dart

@ -54,7 +54,18 @@ class AstTtsFrame {
/// 'A' = 己方→对端 译文(让对端听);'B' = 对端→己方 译文(让本机听)
final String leg;
final Uint8List pcm;
const AstTtsFrame({required this.leg, required this.pcm});
/// 是否为当前 utterance 的最后一帧。
///
/// 仅当原生 AST 服务(azure / iflytek / volcano / alibaba 任一)确实发出段尾事件时为 true。
/// 段尾帧通常 pcm 为空字节,仅作信号传递,由下游(杰理 RCSP runtime)触发立即整段下发。
final bool isFinal;
const AstTtsFrame({
required this.leg,
required this.pcm,
this.isFinal = false,
});
}
// 识别事件类型

93
lib/data/services/ble_manager.dart

@ -220,7 +220,9 @@ class BleManager extends GetxService {
int channels = 1,
bool isFinal = false,
}) async {
if (pcm.isEmpty) return true;
// 段尾帧(pcm 为空 + isFinal=true)必须放行,让 native 触发立即整段下发;
// 仅对"普通空帧(非段尾)"短路。
if (pcm.isEmpty && !isFinal) return true;
final upper = leg.toUpperCase();
final streamId = (upper == 'A' || upper == 'UPLINK')
? 'out.uplink'
@ -271,12 +273,10 @@ class BleManager extends GetxService {
} else if (event is DeviceFoundEvent) {
final found = event.device;
final exists = scannedDevices.any((d) => d.address == found.address);
// 每次广播都用最新数据覆盖(电量/充电状态会变)
// 每次广播都用最新数据覆盖(后续若 SDK 扩展了广播字段,仍可从这里拿到)
_latestAdvByAddress[found.address] = found;
print('[BLE] [EVT] deviceFound name="${found.name}" addr=${found.address} '
'rssi=${found.rssi} L=${found.leftBattery}${found.leftCharging == true ? "+" : ""} '
'R=${found.rightBattery}${found.rightCharging == true ? "+" : ""} '
'Case=${found.caseBattery}${found.caseCharging == true ? "+" : ""} '
'rssi=${found.rssi} '
'(已存在=$exists, 当前列表 size=${scannedDevices.length})');
if (!exists) {
scannedDevices.add(found);
@ -289,6 +289,15 @@ class BleManager extends GetxService {
if (event.success) {
// RCSP init 完成后 SDK 才开始往缓存里填电量;这里启动轮询直到拿到非 0
_startBatteryWatchdog();
// 同时启动 AgentService(azureAsrHelper / TTS / chatApi 在这里建出来)。
// native initialize 自带幂等保护,重连重复触发是 no-op;
// 老的 PairingController 删除时这一调用丢了,不补回来 AI 助理 / PTT
// 等所有 ASR 路径都会因为 azureAsrHelper=null 静默失败。
startAgentService().then((ok) {
Logger.i('BleManager', 'startAgentService on RCSP init: ok=$ok');
}).catchError((e) {
Logger.e('BleManager', 'startAgentService on RCSP init 失败: $e');
});
}
} else if (event is BatteryEvent) {
Logger.i('BleManager',
@ -385,33 +394,20 @@ class BleManager extends GetxService {
);
}
/// 用扫到的 advertisement 数据立刻填 DeviceInfo 的双端电量/充电状态。
/// 在 connectionOk 时调用,无需等 RCSP 拉快照。
/// 杰理同步后的 `JieliDevice` 仅暴露 name / address / rssi 等基础字段,
/// 不再提供左右耳/仓的电量与充电状态。电量改由以下两路拿:
/// 1. 连接成功后 RCSP 初始化完成 → `_startBatteryWatchdog` 轮询
/// `fetchDeviceInfo` 拉 `deviceSnapshot` / `queryTargetInfo`;
/// 2. SDK 主动推送的 [BatteryEvent](见 `_handleBattery`)。
/// 所以这里不再从扫描广播里预填电量,保留空实现以兼容既有调用点。
void _fillBatteryFromAdvertisement(String address) {
final adv = _latestAdvByAddress[address];
if (adv == null) {
Logger.w('BleManager', '[BAT] 没有 $address 的 advertisement 数据可用');
return;
}
final cur = deviceInfo.value;
if (cur == null) return;
// 单耳设备:rightDeviceQuantity 通常是 0 → 把左耳的值镜像过去,避免 UI 显示 0%
final l = adv.leftBattery ?? 0;
final r = adv.rightBattery ?? 0;
final caseBat = adv.caseBattery ?? 0;
final fallbackR = (r == 0 && l > 0) ? l : r;
deviceInfo.value = cur.copyWith(
batteryLevel: l > 0 ? l : (r > 0 ? r : 0),
leftEarBattery: l,
rightEarBattery: fallbackR,
caseBattery: caseBat,
leftEarCharging: adv.leftCharging ?? false,
rightEarCharging: adv.rightCharging ?? false,
caseCharging: adv.caseCharging ?? false,
);
Logger.i('BleManager',
'[BAT] 来自广播: L=$l R=$fallbackR Case=$caseBat '
'(charging L=${adv.leftCharging} R=${adv.rightCharging} Case=${adv.caseCharging})');
'[BAT] 广播不再携带电量字段,连接后将通过 RCSP/BatteryEvent 补齐');
}
void _handleTranslationAudio(TranslationAudioEvent e) {
@ -466,10 +462,25 @@ class BleManager extends GetxService {
}
// ─── 扫描 / 连接 ────────────────────────────────────
/// 启动扫描。
///
/// 扫描的 service UUID 白名单来源:
/// 1) 调用方显式传入 [uuids](通常用于调试或定向扫描);
/// 2) 否则根据 [productIds] 从 [AppConfig.config.products] 映射出每个产品
/// 的 `scanuuid`(服务端下发的产品配置),在 Flutter 侧拼接成 uuids 列表
/// 再通过平台通道下发给底层,由 Android `ScanFilter.setServiceUuid`
/// 进行 OS 级过滤;
/// 3) 如果上面两项都没给,就默认使用 [AppConfig.allproductUuids()],等价
/// 于「下发所有已配置产品」的 uuid 白名单。
///
/// 空列表 / AppConfig 未初始化时,底层不做 uuid 过滤,全部放行。
Future<bool> startScan({
Duration timeout = const Duration(seconds: 30),
List<String>? uuids,
List<int>? productIds,
}) async {
print('[BLE] [SCAN] BleManager.startScan 入口');
print('[BLE] [SCAN] BleManager.startScan 入口 '
'productIds=$productIds uuids=$uuids');
_lastScanError = null;
try {
await ensureInitialized();
@ -479,11 +490,37 @@ class BleManager extends GetxService {
isScanning.value = false;
return false;
}
print('[BLE] [SCAN] init 已通过,清空列表并发送 native startScan');
final List<String> resolvedUuids;
if (uuids != null && uuids.isNotEmpty) {
resolvedUuids =
uuids.where((u) => u.isNotEmpty).toList(growable: false);
} else if (productIds != null &&
productIds.isNotEmpty &&
AppConfig.isInitialized()) {
final ids = productIds.toSet();
resolvedUuids = AppConfig.config.products
.where((p) => ids.contains(p.id))
.map((p) => p.scanuuid)
.where((u) => u.isNotEmpty)
.toList(growable: false);
} else if (AppConfig.isInitialized()) {
resolvedUuids = AppConfig.allproductUuids()
.where((u) => u.isNotEmpty)
.toList(growable: false);
} else {
resolvedUuids = const <String>[];
}
print('[BLE] [SCAN] init 已通过,UUID 白名单=$resolvedUuids');
scannedDevices.clear();
isScanning.value = true;
try {
await Jielihome.instance.startScan(timeout: timeout);
await Jielihome.instance.startScan(
timeout: timeout,
uuidList: resolvedUuids,
skipUnnamed: true,
);
print('[BLE] [SCAN] native startScan 返回成功,等待 deviceFound 事件');
return true;
} catch (e, s) {

6
lib/data/services/speech_impl/azure_ast_service.dart

@ -458,8 +458,12 @@ class AzureAstService extends GetxService implements AstService {
if (raw is! Map) return;
final leg = raw['leg'] as String?;
final pcm = raw['pcm'];
// 兼容旧 native:未带 final 字段时按 false 处理。
final isFinal = raw['final'] == true;
if (leg == null || pcm is! Uint8List) return;
if (!c.isClosed) c.add(AstTtsFrame(leg: leg, pcm: pcm));
if (!c.isClosed) {
c.add(AstTtsFrame(leg: leg, pcm: pcm, isFinal: isFinal));
}
},
onError: (e) => Logger.error('astTtsFrames stream error: $e'),
);

160
lib/modules/agent/controllers/agent_controller.dart

@ -22,6 +22,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:agent_service/agent_service.dart';
import 'package:chat_storage/chat_storage.dart';
import 'package:device_jieli/device_jieli.dart';
import 'package:image_picker/image_picker.dart';
import 'package:get_storage/get_storage.dart';
import 'package:permission_handler/permission_handler.dart';
@ -111,6 +112,17 @@ class AgentController extends GetxController with WidgetsBindingObserver {
// 打电话模式状态
final isPhoneCallMode = false.obs; // 是否处于打电话模式
// ─── 设备通话模式(device_jieli 耳机录音 → 外部注入 ASR)─────
// 独立于普通打电话模式,不复用 isPhoneCallMode
final isDeviceCallMode = false.obs; // 是否处于设备通话模式
final isDeviceCallMicMuted = false.obs; // 设备通话模式下是否静音(丢帧)
StreamSubscription<JieliEvent>? _deviceCallJieliSub;
bool _deviceCallPushing = false; // 是否当前允许把设备 PCM 推给 ASR
// 调试:每秒打印一次 PCM 推送统计,确认音频是否真正流到 ASR
int _deviceCallPushedFrames = 0;
int _deviceCallPushedBytes = 0;
int _deviceCallLastReportMs = 0;
// 按住说话状态
final isPushToTalkActive = false.obs; // 是否正在按住说话
@ -235,6 +247,10 @@ class AgentController extends GetxController with WidgetsBindingObserver {
@override
void onClose() {
// 若处于设备通话模式,fire-and-forget 结束
if (isDeviceCallMode.value) {
unawaited(_stopDeviceCallInternal());
}
AgentService.stopAiSteam(); //停止AI流
AgentService.stopConversation(); // 停止当前会话
AgentService.stopTts(); // 停止当前语音
@ -242,6 +258,8 @@ class AgentController extends GetxController with WidgetsBindingObserver {
scrollController.dispose();
_eventSubscription?.cancel();
_amapeventSubscription?.cancel();
_deviceCallJieliSub?.cancel();
_deviceCallJieliSub = null;
Intro.of(Get.context!).dispose();
// 取消应用生命周期监听
@ -1724,6 +1742,148 @@ class AgentController extends GetxController with WidgetsBindingObserver {
}
}
// ─── 设备通话模式(device_jieli AI 助手通路) ────────────────────
// 走 device_jieli 的 AI 助手通路(speechStart/speechStop):
// 上行帧由 SpeechAudioEvent 派发,PCM_S16LE/16k/mono/20ms,直接灌给 ASR。
// 与翻译 / 设备录音互斥;不影响已有 phone_call / push_to_talk 任何路径。
/// 启动设备通话模式
Future<void> startDeviceCall() async {
Logger.i(TAG, '🎧 开始设备通话模式(AI 助手通路)');
if (isDeviceCallMode.value) {
Logger.w(TAG, '已处于设备通话模式,忽略');
return;
}
// 必须在 Android 上且已连接杰理耳机
if (!Platform.isAndroid) {
Get.snackbar('tips'.tr, '该功能仅安卓端支持');
return;
}
final BleManager bleManager = Get.find<BleManager>();
if (!bleManager.isConnected) {
Get.snackbar('tips'.tr, '请先连接耳机');
return;
}
// 收缩 + 号
collapsePlusOptions();
isDeviceCallMode.value = true;
isDeviceCallMicMuted.value = false;
_deviceCallPushing = true;
try {
// 先停之前可能存在的会话(不同模式之间切换保底)
await AgentService.stopConversation();
// 订阅 device_jieli 事件流,按 uplink 推给 ASR
await _deviceCallJieliSub?.cancel();
_deviceCallJieliSub = Jielihome.instance.events.listen(_onDeviceCallEvent);
// ignore: avoid_print
print(
'[DEVCALL] _deviceCallJieliSub 已订阅 sub=$_deviceCallJieliSub mode=${isDeviceCallMode.value} pushing=$_deviceCallPushing',
);
// 启动 AgentService 外部音频模式 + 电话通话模式(避免 push_to_talk 静默超时)
final started = await AgentService.startConversation(
mode: 'phone_call',
isExternalActive: true,
);
if (!started) {
Logger.w(TAG, '设备通话:AgentService 启动失败');
}
// 启动 AI 助理通路(JieliAssistantPort):MODE_RECORD + DEVICE_ALWAYS_RECORDING,
// 耳机自主持续推 OPUS,由 Port 解码成 16k/16bit/mono/20ms PCM 帧
final ok = await Jielihome.instance.assistantStart();
if (!ok) {
Logger.w(TAG, '设备通话:assistantStart 启动失败');
await _stopDeviceCallInternal();
Get.snackbar('error'.tr, '设备通话启动失败');
return;
}
Logger.i(TAG, '设备通话模式已启动(JieliAssistantPort)');
} catch (e) {
Logger.e(TAG, '启动设备通话失败: $e');
await _stopDeviceCallInternal();
Get.snackbar('error'.tr, '设备通话启动失败');
}
}
/// 结束设备通话模式
Future<void> endDeviceCall() async {
Logger.i(TAG, '🎧 结束设备通话模式');
await _stopDeviceCallInternal();
}
/// 设备通话模式下切换静音(丢帧)
void toggleDeviceCallMic() {
if (!isDeviceCallMode.value) return;
isDeviceCallMicMuted.value = !isDeviceCallMicMuted.value;
_deviceCallPushing = !isDeviceCallMicMuted.value;
Logger.i(TAG, '设备通话静音切换: muted=${isDeviceCallMicMuted.value}');
}
Future<void> _stopDeviceCallInternal() async {
_deviceCallPushing = false;
try {
await _deviceCallJieliSub?.cancel();
} catch (_) {}
_deviceCallJieliSub = null;
try {
await Jielihome.instance.assistantStop();
} catch (e) {
Logger.w(TAG, 'assistantStop 失败(忽略): $e');
}
try {
await AgentService.stopConversation();
} catch (e) {
Logger.w(TAG, 'stopConversation 失败(忽略): $e');
}
isDeviceCallMicMuted.value = false;
isDeviceCallMode.value = false;
isListening.value = false;
}
void _onDeviceCallEvent(JieliEvent event) {
// 入口诊断:每秒打一次"是否进入此函数",无条件,用 print 走 I/flutter 通道
final now0 = DateTime.now().millisecondsSinceEpoch;
if (event is AssistantAudioEvent) {
_deviceCallPushedFrames += 1;
_deviceCallPushedBytes += event.pcm.length;
if (now0 - _deviceCallLastReportMs >= 1000) {
// ignore: avoid_print
print(
'[DEVCALL] _onDeviceCallEvent 入口 (last 1s): frames=$_deviceCallPushedFrames bytes=$_deviceCallPushedBytes mode=${isDeviceCallMode.value} pushing=$_deviceCallPushing seq=${event.sequence}',
);
_deviceCallPushedFrames = 0;
_deviceCallPushedBytes = 0;
_deviceCallLastReportMs = now0;
}
}
if (!isDeviceCallMode.value) return;
if (event is AssistantAudioEvent) {
if (!_deviceCallPushing) return;
if (event.encoding != 'pcm16') return;
if (event.pcm.isEmpty) return;
// fire-and-forget 推送给 ASR
AgentService.pushAudioData(event.pcm).catchError((e) {
Logger.w(TAG, 'pushAudioData 失败: $e');
return false;
});
} else if (event is AssistantStartEvent) {
Logger.i(TAG, 'AI 助理通路已启动(端侧通知)sr=${event.sampleRate}');
} else if (event is AssistantErrorEvent) {
Logger.e(TAG, 'AI 助理错误 code=${event.code} msg=${event.message ?? ''}');
} else if (event is AssistantEndEvent) {
Logger.i(TAG, 'AI 助理通路已停止(端侧通知)');
}
}
// 检查当前是否可以启动耳机通话
bool canStartHeadsetCall() {
// 如果已经在打电话模式,不允许重复启动

847
lib/modules/agent/views/agent_view.dart

@ -1,6 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_intro/flutter_intro.dart';
import 'package:get/get.dart';
@ -10,7 +8,6 @@ import '../../../data/services/version_update_service.dart';
import '../controllers/agent_controller.dart';
import '../controllers/message_action_controller.dart';
import 'message_bubble.dart';
import '../widgets/full_screen_editor.dart';
import '../widgets/sound_bar_widget.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
@ -26,9 +23,6 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
late MessageActionController messageActionController;
double _keyboardHeight = 0;
// 👇 新增:用于控制 TextField 焦点
final FocusNode _textFocusNode = FocusNode();
void _showTtsSpeedDialog() {
final primaryColor = Theme.of(context).primaryColor;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
@ -110,7 +104,6 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
@override
void dispose() {
_textFocusNode.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@ -400,12 +393,8 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
key: key,
),
),
// 主要聊天界面
Column(
children: [
// 聊天内容
Expanded(
child: GestureDetector(
// 主要聊天界面(占满整个 body,底部按钮浮在其上)
GestureDetector(
onTap: () {
// 点击聊天区域时,收起键盘并切换工具显示状态
FocusScope.of(context).unfocus();
@ -420,8 +409,13 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
controller: controller.scrollController,
shrinkWrap: true,
reverse: true,
padding: EdgeInsets.symmetric(
vertical: 16.h, horizontal: 16.w),
padding: EdgeInsets.only(
top: 16.h,
// 预留浮动通话/挂断按钮的高度,避免最后一条消息被遮挡
bottom: 140.h,
left: 16.w,
right: 16.w,
),
itemCount: controller.messages.length +
(controller.hasMoreMessages.value ? 1 : 0),
itemBuilder: (context, index) {
@ -528,682 +522,59 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
);
}),
),
),
// 输入区域
Container(
padding:
EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
decoration: BoxDecoration(
color: backgroundColor,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 8.w,
offset: const Offset(0, -1),
),
],
border: Border(
top: BorderSide(
color: Colors.grey.withValues(alpha: 0.15),
width: 1.w,
),
),
),
// 底部浮动通话按钮:悬浮在聊天界面之上
// 点击启动耳机录音 → JieliAssistantPort 通路
// (MODE_RECORD + STRATEGY_DEVICE_ALWAYS_RECORDING)
Align(
alignment: Alignment.bottomCenter,
child: Obx(() {
if (controller.isDeviceCallMode.value) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 图片预览区域 - 仅在图片输入模式下显示
Obx(
() {
if (controller.isImageInputActive.value &&
controller.selectedImagePath.value != null) {
return Column(
children: [
// 图片预览和关闭按钮
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12.w),
child: Image.file(
File(controller
.selectedImagePath.value!),
height: 150.h,
width: double.infinity,
fit: BoxFit.cover,
),
),
Positioned(
top: 8.h,
right: 8.w,
child: GestureDetector(
onTap: () =>
controller.clearImageInput(),
GestureDetector(
onTap: () => controller.startDeviceCall(),
child: Container(
padding: EdgeInsets.all(4.w),
width: 68.w,
height: 68.w,
decoration: BoxDecoration(
color: Colors.black
.withValues(alpha: 0.6),
color: const Color(0xFF10B981),
shape: BoxShape.circle,
),
child: Icon(
Icons.close,
color: Colors.white,
size: 16.sp,
),
),
),
),
],
),
// 预设提示选项
SizedBox(height: 12.h),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildPromptChip(
context: context,
label:
'summarizeImageContent'.tr, //总结图片内容
onTap: () =>
controller.summarizeImage(),
),
SizedBox(width: 8.w),
_buildPromptChip(
context: context,
label: 'extractImageText'.tr, //抽取图像文字
onTap: () =>
controller.extractTextFromImage(),
),
SizedBox(width: 8.w),
_buildPromptChip(
context: context,
label: 'translateImageText'.tr, //翻译图像文字
onTap: () =>
controller.translateImageText(),
),
],
),
),
SizedBox(height: 12.h),
],
);
} else {
// 文本输入模式下,且输入框为空
return Column(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildPromptChip(
context: context,
label: 'presetMusic'.tr, // 来点音乐
onTap: () => controller
.preSetPromptAction('Music')),
SizedBox(width: 8.w),
_buildPromptChip(
context: context,
label: 'presetNavigation'.tr, // 导航
onTap: () =>
controller.preSetPromptAction(
'Navigation')),
SizedBox(width: 8.w),
_buildPromptChip(
context: context,
label: 'presetWeather'.tr, // 天气
onTap: () => controller
.preSetPromptAction('Weather')),
SizedBox(width: 8.w),
_buildPromptChip(
context: context,
label: 'presetNews'.tr, // 新闻
onTap: () => controller
.preSetPromptAction('News')),
SizedBox(width: 8.w),
_buildPromptChip(
context: context,
label: 'presetFood'.tr, // 美食
onTap: () => controller
.preSetPromptAction('Food')),
],
)),
SizedBox(height: 12.h),
],
);
}
},
),
// 输入控件区域 - 包含输入行和展开的选项
Column(
children: [
Row(
crossAxisAlignment:
CrossAxisAlignment.end, // 所有元素底部对齐
children: [
// 键盘/语音切换按钮
Obx(() {
final isTextMode =
controller.isTextInputMode.value;
return GestureDetector(
onTap: () {
controller.toggleInputMode();
// 切换模式时,确保焦点正确
if (!isTextMode) {
// 切换到语音模式时,请求焦点
Future.delayed(Duration.zero, () {
_textFocusNode.requestFocus();
});
}
},
child: Container(
width: 44.w,
height: 44.w,
margin: EdgeInsets.symmetric(vertical: 2.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[800]
: Colors.grey[200],
borderRadius: BorderRadius.circular(22),
),
child: Icon(
isTextMode
? Icons.mic_none
: Icons.keyboard,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 22.sp,
),
),
);
}),
SizedBox(width: 8.w),
// 文本输入框或语音波形图
Expanded(
child: Obx(() {
final isTextMode =
controller.isTextInputMode.value;
if (isTextMode) {
// 文本输入模式 - 限制最大高度并只向上扩展
return IntroStepBuilder(
group: 'ai',
order: 1,
getOverlayPosition:
IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
'${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['startKey']}'
.tr,
buttonText: 'confirmInput'.tr,
onNext: () {
controller.textController.text =
'${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['inputKey']}'
.tr;
},
onTap: () {
controller.textController.text =
'${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['inputKey']}'
.tr;
params.onNext!();
},
);
},
onWidgetLoad: () {
controller.noviceGuideService
.startAIGuide();
},
builder: (BuildContext context,
GlobalKey key) =>
Container(
key: key,
constraints: BoxConstraints(
minHeight: 48.w,
),
padding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 5.w,
),
alignment: Alignment.center,
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[800]
: Colors.grey[200],
borderRadius:
BorderRadius.circular(22.w),
border: Border.all(
color: isDarkMode
? Colors.grey[800]!
: Colors.grey
.withValues(alpha: 0.1),
width: 1.w,
),
),
child: Obx(() {
// 图片模式下使用更高的输入框
final isImageActive = controller
.isImageInputActive.value;
return TextField(
focusNode: _textFocusNode, // 绑定焦点节点
controller:
controller.textController,
maxLines: isImageActive
? 3
: 5, // 设置合理的最大行数
minLines: isImageActive ? 2 : 1,
keyboardType: TextInputType
.multiline, // 启用多行键盘
textInputAction: TextInputAction
.newline, // 支持回车换行
onTap: () {
// 当点击输入框时,如果'+'选项已展开,则收缩
if (controller
.isPlusOptionsExpanded
.value) {
controller
.collapsePlusOptions();
}
},
style: TextStyle(
fontSize: 15.sp,
color: isDarkMode
? Colors.white
: Colors.grey[800],
),
decoration: InputDecoration(
hintText: isImageActive
? 'addQuestionAboutImage'
.tr // 添加对图片的问题或描述...
: 'inputMessage'
.tr, // 输入消息...
hintStyle: TextStyle(
color: Colors.grey[500],
fontSize: 15.sp,
),
filled: false,
isDense: true,
border: InputBorder.none,
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: isDarkMode
? Colors.grey[800]!
: Colors.grey[200]!,
width: 1.w,
),
),
contentPadding: EdgeInsets.zero,
),
onSubmitted: (_) =>
controller.sendMessage(),
);
}),
),
);
} else {
// 语音输入模式 - 按住说话模式
return Listener(
onPointerDown: (_) {
controller.startPushToTalk();
},
onPointerUp: (_) {
controller.endPushToTalk();
},
onPointerCancel: (_) {
controller.endPushToTalk();
},
// onLongPressStart: (_) {
// // 开始按住说话
// controller.startPushToTalk();
// },
// onLongPressEnd: (_) {
// // 结束按住说话
// controller.endPushToTalk();
// },
// onLongPressCancel: () {
// // 取消按住说话
// controller.endPushToTalk();
// },
child: Obx(() {
final isPushToTalkActive =
controller.isPushToTalkActive.value;
return Container(
height: 48.w,
padding: EdgeInsets.symmetric(
horizontal: 16.w),
decoration: BoxDecoration(
color: isPushToTalkActive
? (isDarkMode
? Colors.blue[900]
: Colors.blue[100])
: (isDarkMode
? Colors.grey[800]
: const Color.fromARGB(
255, 255, 255, 255)),
borderRadius:
BorderRadius.circular(22.w),
boxShadow: [
BoxShadow(
color: Colors.black
.withValues(alpha: 0.2),
spreadRadius: 1,
blurRadius: 2,
offset: const Offset(0, 1),
color: const Color(0xFF10B981)
.withValues(alpha: 0.35),
blurRadius: 16.w,
offset: Offset(0, 6.h),
),
],
// border: Border.all(
// color: isPushToTalkActive
// ? Colors.blue
// : (isDarkMode
// ? Colors.grey[800]!
// : const Color.fromARGB(
// 255, 255, 255, 255)
// .withOpacity(0.1)),
// width:
// isPushToTalkActive ? 2.w : 1.w,
// ),
),
child: Row(
children: [
// 语音波形图 - 只在按住时显示
if (isPushToTalkActive) ...[
const AgentSoundBarsWidget(),
//const Spacer(),
],
// 状态文字和图标
Expanded(
child: Row(
mainAxisAlignment:
isPushToTalkActive
? MainAxisAlignment
.center
: MainAxisAlignment
.center,
children: [
Icon(
isPushToTalkActive
? Icons.mic
: Icons.mic_none,
size: 16.sp,
color: isPushToTalkActive
? Colors.blue
: Colors.grey[500],
),
const SizedBox(width: 6),
Text(
isPushToTalkActive
? 'recording'
.tr // 正在录音...
: 'holdToSpeak'
.tr, // 按住说话
style: TextStyle(
fontSize: 14,
fontWeight:
FontWeight.w500,
color:
isPushToTalkActive
? Colors.blue
: Colors
.grey[600],
),
),
],
),
),
],
),
);
}),
);
}
}),
),
SizedBox(width: 8.w),
// 动态按钮区域:展开按钮 + 发送按钮 或 图片选择按钮
IntroStepBuilder(
group: 'ai',
order: 2,
getOverlayPosition:
IntroOverlayBuilder.overlayPosition,
overlayBuilder: (params) {
return IntroOverlayBuilder.aiBuilder(
params,
'${'guideClickSendPrefix'.tr}${'${controller.noviceGuideService.aiStepsList[controller.noviceGuideService.aiStep]['sendKey']}'.tr}',
alignment: false,
onTap: () async {
await controller
.noviceGuideService.aiStepsList[
controller.noviceGuideService
.aiStep]['method']();
params.onNext!();
controller.sendMessage();
},
);
},
builder:
(BuildContext context, GlobalKey key) =>
Obx(
key: key,
() {
final hasText =
controller.hasTextInput.value;
final hasImage =
controller.isImageInputActive.value;
final shouldShowSend = hasText || hasImage;
final shouldShowExpand =
controller.shouldShowExpandButton.value;
if (shouldShowSend) {
// 显示发送按钮(可能还有展开按钮)
return Column(
children: [
// 展开按钮(在发送按钮上方)
if (shouldShowExpand && hasText)
Padding(
padding:
EdgeInsets.only(bottom: 40.h),
child: GestureDetector(
onTap: () {
// 打开底部弹出编辑模式
controller
.openFullScreenEdit();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor:
Colors.transparent,
builder: (context) =>
const FullScreenEditor(),
).then((_) {
// 弹出页关闭时的回调
controller
.closeFullScreenEdit();
});
},
child: Container(
width: 32.w,
height: 32.w,
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[700]
: Colors.grey[300],
borderRadius:
BorderRadius.circular(
16.w),
),
child: Icon(
Icons.expand_less,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 20.sp,
),
),
),
),
// 发送按钮
GestureDetector(
onTap: () =>
controller.sendMessage(),
child: Container(
width: 44.w,
height: 44.w,
margin: EdgeInsets.symmetric(
vertical: 2.w),
decoration: BoxDecoration(
color: primaryColor,
borderRadius:
BorderRadius.circular(22.w),
),
child: Icon(
Icons.send,
Icons.call,
color: Colors.white,
size: 22.sp,
),
size: 30.sp,
),
),
],
);
} else {
// 显示'+'选择按钮
return Obx(() {
final isExpanded = controller
.isPlusOptionsExpanded.value;
return GestureDetector(
onTap: () {
controller.togglePlusOptions();
},
child: Container(
width: 44.w,
height: 44.w,
margin: EdgeInsets.symmetric(
vertical: 2.w),
decoration: BoxDecoration(
color: isDarkMode
? Colors.grey[800]
: Colors.grey[200],
borderRadius:
BorderRadius.circular(22.w),
),
child: Icon(
isExpanded
? Icons.close
: Icons.add,
SizedBox(height: 8.h),
Text(
'通话',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: isDarkMode
? Colors.grey[300]
: Colors.grey[700],
size: 22.sp,
),
),
);
});
}
},
),
),
],
),
// 展开的'+'选择选项
Obx(() {
final isExpanded =
controller.isPlusOptionsExpanded.value;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: isExpanded ? 100.h : 0, // 调整高度以适应按钮尺寸
child: isExpanded
? LayoutBuilder(
// 添加LayoutBuilder获取实际约束
builder: (context, constraints) {
return SingleChildScrollView(
// 确保内容可滚动
scrollDirection:
Axis.horizontal, // 水平滚动
child: Container(
margin: EdgeInsets.only(top: 16.h),
padding: EdgeInsets.symmetric(
horizontal: 16.w),
// 根据可用宽度调整容器宽度
width: max(constraints.maxWidth,
300.w), // 最小300宽
child: Wrap(
// 使用Wrap替代Row,自动换行
alignment: WrapAlignment.center,
spacing: 26.w, // 按钮水平间距
runSpacing: 8.h, // 按钮垂直间距
children: [
// 相机按钮
_buildPlusOptionButton(
context: context,
icon: Icons.camera_alt,
label: 'takePhoto'.tr, // 拍照
onTap: () async {
//controller.collapsePlusOptions();
await controller
.takePhoto();
},
isDarkMode: isDarkMode,
),
// 相册按钮
_buildPlusOptionButton(
context: context,
icon: Icons.photo_library,
label: 'photoAlbum'.tr, // 相册
onTap: () async {
//controller.collapsePlusOptions();
await controller
.pickImage();
},
isDarkMode: isDarkMode,
),
// 文件按钮(预留位置)
// _buildPlusOptionButton(
// context: context,
// icon: Icons.folder,
// label: '文件',
// onTap: () {
// //controller.collapsePlusOptions();
// // TODO: 实现文件选择功能
// },
// isDarkMode: isDarkMode,
// ),
// 通话按钮
_buildPlusOptionButton(
context: context,
icon: Icons.phone,
label: 'makeCall'.tr, // 打电话
onTap: () {
// 启动打电话模式
controller.startPhoneCall();
},
isDarkMode: isDarkMode,
),
],
),
),
);
},
)
: const SizedBox.shrink(),
);
}),
],
),
],
),
),
],
),
// 打电话模式覆盖层
@ -1213,94 +584,14 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
}
return const SizedBox.shrink();
}),
],
),
),
);
}
// 构建提示选项芯片
Widget _buildPromptChip({
required BuildContext context,
required String label,
required VoidCallback onTap,
}) {
final primaryColor = Theme.of(context).primaryColor;
return GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16.w),
border: Border.all(
color: primaryColor.withValues(alpha: 0.2),
width: 1.w,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 13.sp,
color: primaryColor,
fontWeight: FontWeight.w500,
),
),
),
);
// 设备通话模式覆盖层
Obx(() {
if (controller.isDeviceCallMode.value) {
return _buildDeviceCallOverlay(context, isDarkMode);
}
// 构建"+"号展开选项按钮
Widget _buildPlusOptionButton({
required BuildContext context,
required IconData icon,
required String label,
required VoidCallback onTap,
required bool isDarkMode,
}) {
final primaryColor = Theme.of(context).primaryColor;
return GestureDetector(
onTap: onTap,
child: SizedBox(
// 调整为一行四个按钮的宽度,考虑屏幕宽度和间距
width: (1.sw - 100.w) / 4, // 屏幕宽度减去左右边距,除以4个按钮
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 正方形图标容器 - 增大尺寸
Container(
width: 50.w, // 调整为44.w以适应布局
height: 50.w, // 调整为44.w以适应布局
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[100],
borderRadius: BorderRadius.circular(12.w),
border: Border.all(
color: isDarkMode ? Colors.grey[700]! : Colors.grey[300]!,
width: 1.w,
),
),
child: Icon(
icon,
color: primaryColor,
size: 20.sp, // 调整为18.sp
),
),
SizedBox(height: 4.h), // 调整为3.h
// 文字标签
Text(
label,
style: TextStyle(
fontSize: 10.sp, // 调整为9.sp
color: isDarkMode ? Colors.grey[300] : Colors.grey[700],
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
return const SizedBox.shrink();
}),
],
),
),
@ -1408,6 +699,56 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
);
}
// ─── 设备通话覆盖层(接听后仅一个挂断按钮,悬浮在聊天界面之上)─────────────────────
Widget _buildDeviceCallOverlay(BuildContext context, bool isDarkMode) {
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => controller.endDeviceCall(),
child: Container(
width: 68.w,
height: 68.w,
decoration: BoxDecoration(
color: const Color(0xFFEF4444),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: const Color(0xFFEF4444).withValues(alpha: 0.35),
blurRadius: 16.w,
offset: Offset(0, 6.h),
),
],
),
child: Transform.rotate(
angle: 2.356,
child: Icon(
Icons.call_end,
color: Colors.white,
size: 30.sp,
),
),
),
),
SizedBox(height: 8.h),
Text(
'挂断',
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.grey[300] : Colors.grey[700],
),
),
],
),
),
);
}
// 构建加载更多指示器
Widget _buildLoadMoreIndicator() {
return Obx(

6
lib/modules/devices/controllers/devices_controller.dart

@ -7,6 +7,7 @@ import 'package:get/get.dart';
import 'package:permission_handler/permission_handler.dart';
import '/core/utils/logger.dart';
import '/data/models/appconfig.dart';
import '/data/services/ble_manager.dart';
import '/routes/app_routes.dart';
@ -48,7 +49,10 @@ class DevicesController extends GetxController {
Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr);
return;
}
final ok = await _bleManager.startScan();
// 设备页是通用绑定入口,扫描白名单 = AppConfig 下发的所有产品 scanuuid
final uuids =
AppConfig.isInitialized() ? AppConfig.allproductUuids() : <String>[];
final ok = await _bleManager.startScan(uuids: uuids);
if (!ok) {
final err = _bleManager.lastScanError ?? 'unknown';
print('[BLE] [SCAN] 扫描启动失败: $err');

63
lib/modules/meeting/controllers/meeting_record_controller.dart

@ -20,6 +20,7 @@ import '../../../routes/app_routes.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/services/meeting/meeting_task_service.dart';
import '../utils/jieli_call_recorder.dart';
/// 会议记录状态枚举
enum TimerStatus {
@ -374,6 +375,7 @@ class MeetingRecordController extends GetxController
// 移除了未使用的变量和TabController
StreamSubscription<Uint8List>? _audioDataSubscription;
JieliCallRecorder? _jieliCallRecorder;
double _smoothedRms = 0.0;
double _latestRms = 0.0;
Timer? _waveformTimer;
@ -477,10 +479,15 @@ class MeetingRecordController extends GetxController
_audioDataSubscription = null;
titleEditingController.dispose();
titleFocusNode.dispose();
if (audioType.value != 0) {
if (audioType.value == 1) {
_bleManager.closeCodec();
}
if (isRecording.value) {
final pendingJieli = _jieliCallRecorder;
_jieliCallRecorder = null;
if (pendingJieli != null) {
// onClose 不能 await,这里发起 fire-and-forget 关闭,丢弃文件即可。
unawaited(pendingJieli.stop(save: false));
} else if (isRecording.value) {
_asrService.stopRecord(true);
}
isRecording.value = false;
@ -603,6 +610,20 @@ class MeetingRecordController extends GetxController
acceptAudioData = true;
}
if (audioType.value == 2) {
// 通话录音:走 device_jieli 设备端录音,直接在 Dart 侧合成立体声 WAV,
// 不复用 Azure 插件的 RecordFile 路径(其 setAudioConfig 为 no-op,
// 也没有干净的 Dart push 入口)。
_jieliCallRecorder = JieliCallRecorder(
onWavePcm: _processAudioData,
);
final ok = await _jieliCallRecorder!.start(filePath);
if (!ok) {
_jieliCallRecorder = null;
Get.snackbar('error'.tr, 'meetingRecordSaveFailed'.tr);
return;
}
} else {
await _asrService.enableRecord(
audioSourceType,
filePath,
@ -619,6 +640,7 @@ class MeetingRecordController extends GetxController
// 处理音频数据的逻辑
_processAudioData(audioData);
});
}
isRecording.value = true;
_overlayPermissionPromptedThisSession = false;
@ -648,15 +670,20 @@ class MeetingRecordController extends GetxController
_bleManager.openDecoder();
break;
case 2:
_asrService.setAudioConfig(sampleRate: 16000, channels: 2);
_bleManager.openCallRecordDecoder();
// 通话录音已切换到 device_jieli JieliDeviceRecordPort 通道,
// 由 JieliCallRecorder 在 Dart 层接管 PCM 合成与文件写入,
// 不再依赖 BleManager.openCallRecordDecoder(当前为空壳实现)。
break;
}
}
/// 暂停当前录音会话
Future<void> _pauseRecording() async {
if (_jieliCallRecorder != null) {
_jieliCallRecorder!.pause();
} else {
await _asrService.pauseRecord();
}
_stopWaveformTimer();
rms.value = 0.0;
pauseTimer();
@ -665,7 +692,11 @@ class MeetingRecordController extends GetxController
/// 恢复已暂停的录音会话
Future<void> _resumeRecording() async {
if (_jieliCallRecorder != null) {
_jieliCallRecorder!.resume();
} else {
await _asrService.resumeRecord();
}
_startWaveformTimer();
startTimer();
await _refreshRecordingIndicator();
@ -749,16 +780,24 @@ class MeetingRecordController extends GetxController
_stopWaveformTimer();
try {
final bool stopped = await _asrService
final bool stopped;
if (_jieliCallRecorder != null) {
stopped = await _jieliCallRecorder!
.stop(save: true)
.timeout(_stopRecordTimeout, onTimeout: () => false);
_jieliCallRecorder = null;
} else {
stopped = await _asrService
.stopRecord(true)
.timeout(_stopRecordTimeout, onTimeout: () => false);
}
if (!stopped) {
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr);
return false;
}
if (audioType.value != 0) {
if (audioType.value == 1) {
_bleManager.closeCodec();
}
@ -805,16 +844,24 @@ class MeetingRecordController extends GetxController
_stopWaveformTimer();
try {
final bool stopped = await _asrService
final bool stopped;
if (_jieliCallRecorder != null) {
stopped = await _jieliCallRecorder!
.stop(save: false)
.timeout(_stopRecordTimeout, onTimeout: () => false);
_jieliCallRecorder = null;
} else {
stopped = await _asrService
.stopRecord(false)
.timeout(_stopRecordTimeout, onTimeout: () => false);
}
if (!stopped) {
_restoreRecordingUiAfterFailedStop(prevStatus, prevElapsedMs);
Get.snackbar('error'.tr, 'meetingRecordStopTimeout'.tr);
return false;
}
if (audioType.value != 0) {
if (audioType.value == 1) {
_bleManager.closeCodec();
}

277
lib/modules/meeting/utils/jieli_call_recorder.dart

@ -0,0 +1,277 @@
// 通话录音 Dart 端 PCM 合成器
//
// 用途:把 device_jieli 上行(in.uplink)/下行(in.downlink)两路异步 PCM 帧
// 配对成 16k/16bit/双声道(左=本端、右=对端)的标准 WAV 文件,独立于
// Azure 插件的 RecordFile 路径(避开其 setAudioConfig no-op 与 push 通道的
// 参数 key 不一致 bug)。
//
// 注意:device_jieli 的设备录音没有原生 pause API,本类的暂停语义通过
// 在 Dart 层丢弃接收到的 PCM 帧实现。
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:device_jieli/device_jieli.dart';
import '/core/utils/logger.dart';
class JieliCallRecorder {
JieliCallRecorder({
this.sampleRate = 16000,
this.bitsPerSample = 16,
this.onWavePcm,
});
/// 采样率(Hz)。device_jieli DeviceRecord 默认 16000。
final int sampleRate;
/// 位深,固定 16bit。
final int bitsPerSample;
/// 写入文件后的立体声 PCM 通知,用于驱动上层波形/振幅。
final void Function(Uint8List stereoPcm)? onWavePcm;
static const String _tag = 'JieliCallRecorder';
static const int _channels = 2;
StreamSubscription<JieliEvent>? _eventSub;
RandomAccessFile? _raf;
String? _filePath;
int _dataBytes = 0;
bool _paused = false;
bool _running = false;
// 上下行 PCM 字节缓冲(s16le)
final BytesBuilder _uplink = BytesBuilder(copy: false);
final BytesBuilder _downlink = BytesBuilder(copy: false);
bool get isRunning => _running;
String? get filePath => _filePath;
/// 启动录音:写 WAV 头占位、订阅事件、调起设备录音上行。
Future<bool> start(String filePath) async {
if (_running) {
Logger.w(_tag, '重复 start 调用,已忽略');
return false;
}
try {
final file = File(filePath);
final dir = file.parent;
if (!await dir.exists()) {
await dir.create(recursive: true);
}
if (await file.exists()) {
await file.delete();
}
_raf = await file.open(mode: FileMode.write);
await _raf!.writeFrom(_makeWavHeader(0));
_dataBytes = 0;
_filePath = filePath;
_paused = false;
_uplink.clear();
_downlink.clear();
_eventSub = Jielihome.instance.events.listen(_onEvent);
try {
await Jielihome.instance.deviceRecordStart(sampleRate: sampleRate);
} catch (e) {
Logger.error('$_tag deviceRecordStart 失败: $e');
await _shutdown(saveFile: false);
return false;
}
_running = true;
Logger.info('$_tag 启动通话录音 path=$filePath sr=$sampleRate');
return true;
} catch (e) {
Logger.error('$_tag 启动失败: $e');
await _shutdown(saveFile: false);
return false;
}
}
/// 暂停:仅在 Dart 层丢帧;设备端继续推流。
void pause() {
if (!_running) return;
_paused = true;
}
/// 恢复接收设备端帧。
void resume() {
if (!_running) return;
_paused = false;
}
/// 停止录音;[save] 为 false 时回滚删除文件。
Future<bool> stop({required bool save}) async {
if (!_running) {
// 即便外部异常重复 stop,也保证幂等
await _shutdown(saveFile: save);
return true;
}
_running = false;
try {
try {
await Jielihome.instance.deviceRecordStop();
} catch (e) {
Logger.w(_tag, 'deviceRecordStop 失败(忽略): $e');
}
await _shutdown(saveFile: save);
return true;
} catch (e) {
Logger.error('$_tag 停止失败: $e');
return false;
}
}
Future<void> _shutdown({required bool saveFile}) async {
await _eventSub?.cancel();
_eventSub = null;
final raf = _raf;
final path = _filePath;
_raf = null;
_filePath = null;
_uplink.clear();
_downlink.clear();
_paused = false;
if (raf != null) {
try {
if (saveFile) {
await raf.setPosition(0);
await raf.writeFrom(_makeWavHeader(_dataBytes));
}
} catch (e) {
Logger.error('$_tag 写 WAV 头失败: $e');
} finally {
try {
await raf.flush();
} catch (_) {}
try {
await raf.close();
} catch (_) {}
}
}
if (!saveFile && path != null) {
try {
final f = File(path);
if (await f.exists()) {
await f.delete();
}
} catch (e) {
Logger.w(_tag, '删除录音文件失败: $e');
}
}
_dataBytes = 0;
}
void _onEvent(JieliEvent event) {
if (event is DeviceRecordAudioEvent) {
if (_paused) return;
if (event.streamId == DeviceRecordStreams.inUplink) {
_uplink.add(event.pcm);
} else if (event.streamId == DeviceRecordStreams.inDownlink) {
_downlink.add(event.pcm);
} else {
return;
}
_drain();
} else if (event is DeviceRecordErrorEvent) {
Logger.error(
'$_tag 设备录音错误: code=${event.code} msg=${event.message ?? ''}');
}
}
/// 按 sample 对齐配对 uplink/downlink,写入立体声 WAV 数据段。
void _drain() {
final raf = _raf;
if (raf == null) return;
final upBytes = _uplink.takeBytes();
final downBytes = _downlink.takeBytes();
final pairableBytes = (min(upBytes.length, downBytes.length) ~/ 2) * 2;
if (pairableBytes <= 0) {
// 没有可配对样本,原样放回缓冲
_uplink.add(upBytes);
_downlink.add(downBytes);
return;
}
final samples = pairableBytes ~/ 2;
final stereo = Uint8List(samples * 4);
for (int i = 0; i < samples; i++) {
final si = i * 2;
final di = i * 4;
// L = uplink(本端)
stereo[di] = upBytes[si];
stereo[di + 1] = upBytes[si + 1];
// R = downlink(对端)
stereo[di + 2] = downBytes[si];
stereo[di + 3] = downBytes[si + 1];
}
try {
raf.writeFromSync(stereo);
_dataBytes += stereo.length;
} catch (e) {
Logger.error('$_tag 写 PCM 失败: $e');
// 写失败时,把字节放回缓冲,避免丢数据无可恢复
_uplink.add(upBytes);
_downlink.add(downBytes);
return;
}
onWavePcm?.call(stereo);
if (pairableBytes < upBytes.length) {
_uplink.add(Uint8List.sublistView(upBytes, pairableBytes));
}
if (pairableBytes < downBytes.length) {
_downlink.add(Uint8List.sublistView(downBytes, pairableBytes));
}
}
/// 标准 PCM WAV 头(44 字节)。dataLength=0 用于占位,stop 时回填实际值。
Uint8List _makeWavHeader(int dataLength) {
final byteRate = sampleRate * _channels * (bitsPerSample ~/ 8);
final blockAlign = _channels * (bitsPerSample ~/ 8);
final totalLength = 36 + dataLength;
final h = ByteData(44);
// "RIFF"
h.setUint8(0, 0x52);
h.setUint8(1, 0x49);
h.setUint8(2, 0x46);
h.setUint8(3, 0x46);
h.setUint32(4, totalLength, Endian.little);
// "WAVE"
h.setUint8(8, 0x57);
h.setUint8(9, 0x41);
h.setUint8(10, 0x56);
h.setUint8(11, 0x45);
// "fmt "
h.setUint8(12, 0x66);
h.setUint8(13, 0x6d);
h.setUint8(14, 0x74);
h.setUint8(15, 0x20);
h.setUint32(16, 16, Endian.little); // fmt chunk size
h.setUint16(20, 1, Endian.little); // PCM
h.setUint16(22, _channels, Endian.little);
h.setUint32(24, sampleRate, Endian.little);
h.setUint32(28, byteRate, Endian.little);
h.setUint16(32, blockAlign, Endian.little);
h.setUint16(34, bitsPerSample, Endian.little);
// "data"
h.setUint8(36, 0x64);
h.setUint8(37, 0x61);
h.setUint8(38, 0x74);
h.setUint8(39, 0x61);
h.setUint32(40, dataLength, Endian.little);
return h.buffer.asUint8List();
}
}

6
lib/modules/translation/controllers/translation_controller.dart

@ -1259,7 +1259,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
);
_jieliCallAstTtsSub = _astService.astTtsFrames.listen(
(frame) {
bleManager.feedAstTtsPcm(leg: frame.leg, pcm: frame.pcm);
bleManager.feedAstTtsPcm(
leg: frame.leg,
pcm: frame.pcm,
isFinal: frame.isFinal,
);
},
onError: (e) => Logger.error('[CALL-BRIDGE] ast tts stream error: $e'),
);

17
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt

@ -29,6 +29,7 @@ import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.sync.Mutex
import com.deep_voice.speech.tts.TtsEvent
import com.deep_voice.speech.tts.TtsEventListener
@ -1222,7 +1223,23 @@ object AgentService : CoroutineScope {
}
}
private val pushAudioFrameCount = AtomicLong(0)
@Volatile private var pushAudioLastReportMs = 0L
fun pushAudioData(data: ByteArray) {
pushAudioFrameCount.incrementAndGet()
val now = System.currentTimeMillis()
if (now - pushAudioLastReportMs >= 1000L) {
pushAudioLastReportMs = now
val helper = azureAsrHelper
val stream = helper?.audioStream
_log(
"INFO",
"[Agent][PCM] pushAudioData 1s frames=${pushAudioFrameCount.getAndSet(0)} " +
"helperReady=${helper != null} streamSource=${stream?.audioSourceType} " +
"active=${stream?.isContinuousRecognitionActive}"
)
}
azureAsrHelper?.audioStream?.saveAudioDataTo(data)
}

19
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt

@ -156,13 +156,30 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
"startConversation" -> {
try {
val mode = call.argument<String>("mode") ?: "normal"
val isStarted = AgentService.startRecognition(false, mode)
// isExternalActive: true = 走外部 PCM 注入(由 Dart 侧通过 pushAudioData 推送)
val isExternalActive = call.argument<Boolean>("isExternalActive") ?: false
val isStarted = AgentService.startRecognition(isExternalActive, mode)
result.success(isStarted)
} catch (e: Exception) {
Log.e(TAG, "启动对话失败", e)
result.error("START_ERROR", "启动对话失败: ${e.message}", null)
}
}
"pushAudioData" -> {
try {
val data = call.argument<ByteArray>("data")
if (data == null) {
result.error("INVALID_ARGUMENTS", "音频数据不能为空", null)
return
}
AgentService.pushAudioData(data)
result.success(true)
} catch (e: Exception) {
Log.e(TAG, "推送音频数据失败", e)
result.error("PUSH_AUDIO_ERROR", "推送音频数据失败: ${e.message}", null)
}
}
"isInterrupt" -> {
try {
val isinterrupt = call.argument<Boolean>("isinterrupt")

22
local_plugins/agent_service/lib/agent_service.dart

@ -287,7 +287,10 @@ class AgentService {
static DateTime? _lastStartConversationAt;
static const Duration _conversationDebounce = Duration(milliseconds: 600);
static Future<bool> startConversation({String mode = 'normal'}) async {
static Future<bool> startConversation({
String mode = 'normal',
bool isExternalActive = false,
}) async {
// 防抖:短时间内重复点击直接忽略(冷却式防抖)
final now = DateTime.now();
if (_lastStartConversationAt != null &&
@ -306,6 +309,7 @@ class AgentService {
print('liwei--------- view startConversation');
final bool result = await _channel.invokeMethod('startConversation', {
'mode': mode,
'isExternalActive': isExternalActive,
});
return result;
} on PlatformException catch (e) {
@ -313,6 +317,22 @@ class AgentService {
}
}
/// 推送外部 PCM 音频数据到识别引擎
///
/// 仅在通过 [startConversation] 设置 `isExternalActive = true` 时有效。
/// PCM 格式要求:16bit signed, little-endian, 1 channel, 16000 Hz。
static Future<bool> pushAudioData(Uint8List data) async {
if (data.isEmpty) return false;
try {
final bool result = await _channel.invokeMethod('pushAudioData', {
'data': data,
});
return result;
} on PlatformException catch (e) {
throw AgentServiceException(e.code, e.message ?? '推送音频数据失败', e.details);
}
}
static Future<bool> isInterrupt(bool isInterrupt) async {
try {
final bool result = await _channel.invokeMethod('isInterrupt', {

22
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt

@ -11,10 +11,16 @@ fun interface AstEventSender {
}
/**
* Interface for writing audio data (PCM)
* Interface for writing audio data (PCM).
*
* 由 4 个 AST provider 的 callback 在产出 TTS PCM 时回调;
* [markEnd] 在每段 utterance(一句翻译)TTS 流结束时调用一次,
* 让下游(Dart → 杰理 RCSP runtime)感知到段尾以触发"立即整段下发"。
*/
fun interface AudioWriter {
interface AudioWriter {
fun write(data: ByteArray)
/** 当前 utterance 的最后一段 PCM 已经 [write] 过;触发下游 isFinal=true 信号。 */
fun markEnd() {}
}
/**
@ -170,6 +176,8 @@ class AzureAstCallback(
"text" to text
)
)
// Azure 段尾:触发下游 RCSP runtime 立即整段下发。
audioWriter?.markEnd()
}
override fun onSynthesisAudioGenerated(
@ -419,6 +427,8 @@ class IflytekAstCallback(
"text" to text
)
)
// 讯飞段尾:触发下游 RCSP runtime 立即整段下发。
audioWriter?.markEnd()
}
override fun onSynthesisAudioGenerated(
@ -603,6 +613,8 @@ class DoubaoAstCallback(
// if (finalAudio.isNotEmpty()) {
// audioWriter?.write(finalAudio)
// }
// 火山豆包段尾:触发下游 RCSP runtime 立即整段下发。
audioWriter?.markEnd()
}
override fun onSessionError(sessionId: String, code: Int, message: String) {
@ -616,6 +628,8 @@ class DoubaoAstCallback(
"code" to code
)
)
// 错误也要把当前 buffer 释放掉,避免残留。
audioWriter?.markEnd()
}
override fun onFinalTranslatedText(sessionId: String, finalText: String) {
@ -725,6 +739,8 @@ class AliyunAstCallback(
// if (finalAudio.isNotEmpty()) {
// audioWriter?.write(finalAudio)
// }
// 阿里百炼段尾:触发下游 RCSP runtime 立即整段下发。
audioWriter?.markEnd()
}
override fun onSessionError(sessionId: String, code: Int, message: String) {
@ -738,6 +754,8 @@ class AliyunAstCallback(
"code" to code
)
)
// 错误也要把当前 buffer 释放掉,避免残留。
audioWriter?.markEnd()
}
override fun onFinalTranslatedText(sessionId: String, finalText: String) {

19
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -80,12 +80,12 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
// 设备产品配置 broadcastpeertranslate:true=B 路同时也走外放(耳机一路总会走,外放是叠加)。
@Volatile private var broadcastPeerTranslate: Boolean = false
private fun emitAstTtsFrame(leg: String, data: ByteArray) {
private fun emitAstTtsFrame(leg: String, data: ByteArray, isFinal: Boolean = false) {
val sink = astTtsEventSink ?: return
// EventChannel 在主线程 send,确保有效;TTS 段一段一段过来,量级在 KB,主线程开销可忽略
mainHandler.post {
try {
sink.success(mapOf("leg" to leg, "pcm" to data))
sink.success(mapOf("leg" to leg, "pcm" to data, "final" to isFinal))
} catch (e: Exception) {
FileLogger.e(tag, "发送 AST TTS 事件失败 leg=$leg: ${e.message}")
}
@ -93,15 +93,26 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
}
/** A 路 TTS(己方→对端语种)→ 让对端听 = SOURCE_E_SCO_UP_LINK */
private val aPcmWriter: AudioWriter = AudioWriter { data ->
private val aPcmWriter: AudioWriter = object : AudioWriter {
override fun write(data: ByteArray) {
emitAstTtsFrame("A", data)
}
override fun markEnd() {
// 空 PCM + isFinal=true:通知下游当前 utterance 结束。
emitAstTtsFrame("A", ByteArray(0), isFinal = true)
}
}
/** B 路 TTS(对端→己方语种)→ 让本机听 = SOURCE_E_SCO_DOWN_LINK;
* 若 broadcastPeerTranslate=true 还额外通过手机外放叠加播放。 */
private val bPcmWriter: AudioWriter = AudioWriter { data ->
private val bPcmWriter: AudioWriter = object : AudioWriter {
override fun write(data: ByteArray) {
emitAstTtsFrame("B", data)
if (broadcastPeerTranslate) callBPcmPlayer.feed(data)
}
override fun markEnd() {
emitAstTtsFrame("B", ByteArray(0), isFinal = true)
}
}
// 翻译(Volcano)相关
private lateinit var translationChannel: MethodChannel

242
local_plugins/device_jieli/JIELI_SDK_COMMANDS.md

@ -0,0 +1,242 @@
# 杰理 RCSP SDK 指令 / 常量参考
> **来源**:`android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar` 中
> `com.jieli.bluetooth.constant.Command` 等 class 的 `javap -p -constants` 反编译结果。
> **生成时间**:2026-05-07
> **用途**:核对项目里硬编码的 cmd / opcode / 模式 ID 是否与 SDK 内部定义一致,避免猜测。
---
## 一、RCSP CMD opcode(`com.jieli.bluetooth.constant.Command`)
按 opcode 升序排列,**斜体** = 项目内已被 Kotlin 代码直接调用或回调命中。
| opcode (10) | opcode (16) | 常量名 | 用途速记 |
|---:|---:|---|---|
| 1 | 0x01 | CMD_DATA | 通用数据 |
| 2 | 0x02 | CMD_GET_TARGET_FEATURE_MAP | 拉取设备能力位图 |
| 3 | 0x03 | CMD_GET_TARGET_INFO | 拉取设备基础信息 |
| **4** | **0x04** | ***CMD_RECEIVE_SPEECH_START*** | **耳机语音助手按键唤醒:开始上推语音** |
| **5** | **0x05** | ***CMD_RECEIVE_SPEECH_STOP*** | **耳机语音助手按键松开:停止上推** |
| 6 | 0x06 | CMD_DISCONNECT_CLASSIC_BLUETOOTH | 断开经典蓝牙 |
| 7 | 0x07 | CMD_GET_SYS_INFO | 取系统信息 |
| 8 | 0x08 | CMD_SET_SYS_INFO | 设系统信息 |
| 9 | 0x09 | CMD_SYS_INFO_AUTO_UPDATE | 系统信息自动同步 |
| 10 | 0x0A | CMD_PHONE_CALL_REQUEST | 通话相关请求 |
| 11 | 0x0B | CMD_SWITCH_DEVICE_REQUEST | 多机切换请求 |
| 12 | 0x0C | CMD_START_FILE_BROWSE | 开始文件浏览 |
| 13 | 0x0D | CMD_STOP_FILE_BROWSE | 结束文件浏览 |
| 14 | 0x0E | CMD_FUNCTION | 功能控制(按键映射等) |
| 15 | 0x0F | CMD_LRC_GET_START | 开始拉取歌词 |
| 16 | 0x10 | CMD_LRC_GET_STOP | 停止拉取歌词 |
| 17 | 0x11 | CMD_LRC_PUSH_START_TTS | 推送 TTS 歌词 |
| 18 | 0x12 | CMD_START_PERIPHERALS_SCAN | 设备代扫外设开始 |
| 19 | 0x13 | CMD_UPDATE_PERIPHERALS_RESULT | 代扫结果上报 |
| 20 | 0x14 | CMD_STOP_PERIPHERALS_SCAN | 代扫结束 |
| 22 | 0x16 | CMD_START_FILE_TRANSFER | 文件传输开始 |
| 23 | 0x17 | CMD_STOP_FILE_TRANSFER | 文件传输结束 |
| 24 | 0x18 | CMD_NOTIFY_FILE_TRANSFER_OP | 文件传输操作通知 |
| 25 | 0x19 | CMD_SEARCH_DEVICE | 搜索设备 |
| 26 | 0x1A | CMD_EXTERNAL_FLASH_IO_CTRL | 外部 Flash IO 控制 |
| 27 | 0x1B | CMD_START_LARGE_FILE_TRANSFER | 大文件传输开始 |
| 28 | 0x1C | CMD_STOP_LARGE_FILE_TRANSFER | 大文件传输结束 |
| 29 | 0x1D | CMD_LARGE_FILE_TRANSFER_OP | 大文件传输操作 |
| 30 | 0x1E | CMD_CANCEL_LARGE_FILE_TRANSFER | 取消大文件传输 |
| 31 | 0x1F | CMD_FILE_BROWSE_DELETE | 文件浏览删除 |
| 32 | 0x20 | CMD_LARGE_FILE_TRANSFER_GET_NAME | 取大文件名 |
| 33 | 0x21 | CMD_NOTIFY_PREPARE_ENV | 准备环境通知 |
| 34 | 0x22 | CMD_FORMAT_DEVICE | 设备格式化 |
| 35 | 0x23 | CMD_DELETE_FILE_BY_NAME | 按名删文件 |
| 36 | 0x24 | CMD_READ_FILE_FROM_DEVICE | 从设备读文件 |
| 37 | 0x25 | CMD_RTC_EXPAND | RTC 扩展 |
| 38 | 0x26 | CMD_BATCH | 批量命令 |
| 39 | 0x27 | CMD_DEV_PARAM_EXTEND | 设备参数扩展 |
| 40 | 0x28 | CMD_SMALL_FILE_TRANSFER | 小文件传输 |
| 41 | 0x29 | CMD_READ_ERROR_MSG | 读错误信息 |
| 48 | 0x30 | CMD_DATA_TRANSFER | 通用数据传输 |
| 49 | 0x31 | CMD_QUERY_CONNECTED_PHONE_BT_INFO | 查询已连手机蓝牙信息 |
| 51 | 0x33 | CMD_PUBLIC_SETTINGS | 公共设置 |
| **52** | **0x34** | ***CMD_TRANSLATION_MODE*** | **翻译模式控制 + 上下行 PCM 数据通道** |
| 161 | 0xA1 | CMD_PUSH_MESSAGE_TO_DEVICE | APP → 设备消息推送 |
| 192 | 0xC0 | CMD_ADV_SETTINGS | 广告设置 |
| 193 | 0xC1 | CMD_ADV_GET_INFO | 广告信息查询 |
| 194 | 0xC2 | CMD_ADV_DEVICE_NOTIFY | 设备广告通知 |
| 195 | 0xC3 | CMD_ADV_NOTIFY_SETTINGS | 广告通知设置 |
| 196 | 0xC4 | CMD_ADV_DEV_REQUEST_OPERATION | 设备发起的请求 |
| 208 | 0xD0 | CMD_NOTIFY_DEVICE_APP_INFO | 通知设备 APP 信息 |
| 209 | 0xD1 | CMD_SETTINGS_COMMUNICATION_MTU | 协商通讯 MTU |
| **210** | **0xD2** | ***CMD_RECEIVE_SPEECH_CANCEL*** | **取消语音助手会话** |
| 212 | 0xD4 | CMD_GET_DEV_MD5 | 取设备 MD5 |
| 213 | 0xD5 | CMD_GET_LOW_LATENCY_SETTINGS | 低延时模式设置 |
| 214 | 0xD6 | CMD_GET_EXTERNAL_FLASH_MSG | 外部 Flash 信息 |
| 216 | 0xD8 | CMD_SET_DEVICE_STORAGE | 设备存储设置 |
| 217 | 0xD9 | CMD_GET_DEVICE_CONFIG_INFO | 设备配置信息 |
| 225 | 0xE1 | CMD_OTA_GET_DEVICE_UPDATE_FILE_INFO_OFFSET | OTA:取升级文件偏移 |
| 226 | 0xE2 | CMD_OTA_INQUIRE_DEVICE_IF_CAN_UPDATE | OTA:询问可否升级 |
| 227 | 0xE3 | CMD_OTA_ENTER_UPDATE_MODE | OTA:进入升级模式 |
| 228 | 0xE4 | CMD_OTA_EXIT_UPDATE_MODE | OTA:退出升级模式 |
| 229 | 0xE5 | CMD_OTA_SEND_FIRMWARE_UPDATE_BLOCK | OTA:发送固件块 |
| 230 | 0xE6 | CMD_OTA_GET_DEVICE_REFRESH_FIRMWARE_STATUS | OTA:取刷新状态 |
| 231 | 0xE7 | CMD_REBOOT_DEVICE | 重启设备 |
| 232 | 0xE8 | CMD_OTA_NOTIFY_UPDATE_CONTENT_SIZE | OTA:通知升级内容大小 |
| 240 | 0xF0 | CMD_CUSTOM | 厂商自定义 |
| 241 | 0xF1 | CMD_PHONE_NUMBER_PLAY_MODE | 手机号播报模式 |
| 255 | 0xFF | CMD_EXTRA_CUSTOM | 扩展自定义 |
---
## 二、音频通道两条独立通路(关键)
杰理 SDK 上行麦克风音频实际上有两条**互不关联**的命令通路,调用入口和回调机制完全不同:
### 通路 A:原生语音助手(cmd=4 / 5 / 210)
- **类**:`com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl`
- **回调**:`OnRecordStateCallback`(多回调可共存)
- **触发模型**:固件以"耳机硬键唤醒"为前提才会持续上推
- APP 主动调用 `startRecord()` 通常只能拿到 `RECORD_STATE_START` 回调,
`RECORD_STATE_WORKING` 帧推送依赖耳机端按键事件 / 厂商定制
- **下行**:**无**。该通路只管上行
- **不抢占 eSCO / A2DP**
### 通路 B:翻译模式(cmd=52)
- **类**:`com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl`
- **接口**:`writeAudioData()` 上下行复用
- **触发模型**:APP 调用 `enterMode()` 后固件**主动**持续上推 PCM
- `MODE_CALL_TRANSLATION (3)` + `STRATEGY_DEVICE_ALWAYS_RECORDING (1)` → 不依赖通话事件即可拿帧
- `MODE_CALL_TRANSLATION_WITH_STEREO (6)` → 依赖真实通话事件(SCO_MIX),AI 助理场景**零帧**
- **下行**:通过 `feedTranslatedAudio()` 走 RCSP 注入回耳机
- **会占用 SCO**,与 A2DP 媒体音乐互斥
> 实测结论(ColorOS + 当前耳机型号):通路 A 的 `startRecord` 仅有 `START` 回调,
> 持续帧不到货。**当前可拿上行 PCM 的唯一稳定通路是 B(cmd=52)+ MODE_CALL_TRANSLATION (3)**。
---
## 三、`TranslationMode` 模式常量
来自 `com.jieli.bluetooth.bean.translation.TranslationMode`:
| 值 | 常量 | 上行 | 下行 | AI 助理可用性 |
|---:|---|:---:|:---:|---|
| 0 | MODE_IDLE | - | - | 关闭 |
| 1 | MODE_RECORD | ✓ | ✗ | 只录音,不能放 TTS |
| 2 | MODE_RECORDING_TRANSLATION | ✓ | ✗ | 只录音 + 字幕 |
| **3** | **MODE_CALL_TRANSLATION** | ✓ | ✓ | **当前唯一全双工可用** |
| 4 | MODE_AUDIO_TRANSLATION | ✓ | ✓ | 音视频文件翻译,需喂文件 PCM |
| 5 | MODE_FACE_TO_FACE_TRANSLATION | ✓ | ✓ | 面对面翻译,依赖双麦阵列 |
| **6** | **MODE_CALL_TRANSLATION_WITH_STEREO** | **✗** | ✓ | **AI 助理场景零帧 → 必须规避** |
> ⚠️ 项目里 [TranslationModeIds](android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt) 与 SDK 编号**完全一致**,
> `MODE_CALL_TRANSLATION = 3`、`MODE_CALL_TRANSLATION_WITH_STEREO = 6`。
### RecordingStrategy(`TranslationMode` 内嵌)
| 值 | 常量 | 含义 |
|---:|---|---|
| 0 | STRATEGY_CUSTOM_RECORDING | APP 自定录音节奏 |
| **1** | **STRATEGY_DEVICE_ALWAYS_RECORDING** | **设备持续录音上推(AI 助理场景需用此项)** |
| 2 | STRATEGY_DEVICE_AUTO_RECORDING | 设备 VAD 自动启停 |
---
## 四、`AudioData.source`(cmd=52 上下行 PCM 包的来源标识)
| 值 | 常量 | 含义 |
|---:|---|---|
| -1 | SOURCE_UNKNOWN | 未知 |
| 0 | SOURCE_FILE | 文件喂入 |
| 1 | SOURCE_DEVICE_MIC | 耳机麦 |
| 2 | SOURCE_PHONE_MIC | 手机麦 |
| 3 | SOURCE_E_SCO_UP_LINK | SCO 上行(通话场景) |
| 4 | SOURCE_E_SCO_DOWN_LINK | SCO 下行(通话场景) |
| 5 | SOURCE_M_SBC | mSBC 编码源 |
| 6 | SOURCE_E_SCO_MIX | SCO 上下行混音(mode=6 stereo 用) |
> AI 助理上行帧实际取自 `SOURCE_DEVICE_MIC`(mode=3);mode=6 等待 `SOURCE_E_SCO_MIX`,
> 没有真实通话时此源不出包,所以零帧。
---
## 五、`RecordParam`(cmd=4 通路的录音参数)
来自 `com.jieli.bluetooth.bean.record.RecordParam`:
### voiceType
| 值 | 常量 |
|---:|---|
| 0 | VOICE_TYPE_PCM |
| 1 | VOICE_TYPE_SPEEX |
| **2** | **VOICE_TYPE_OPUS**(项目当前使用) |
### sampleRate(**单位是 kHz**,注意不是 Hz)
| 值 | 常量 | 实际频率 |
|---:|---|---:|
| 8 | SAMPLE_RATE_8K | 8000 Hz |
| **16** | **SAMPLE_RATE_16K** | **16000 Hz**(项目当前使用) |
### vadWay
| 值 | 常量 |
|---:|---|
| **0** | **VAD_WAY_DEVICE**(设备端 VAD,项目当前使用) |
| 1 | VAD_WAY_SDK(SDK 软 VAD) |
---
## 六、`RecordState`(cmd=4 通路的回调状态机)
| 字段 | 值 | 常量 | 说明 |
|---|---:|---|---|
| state | 0 | RECORD_STATE_IDLE | 录音结束 / 异常退出 |
| state | 1 | RECORD_STATE_START | 已应答 startRecord,**注意:不代表后续会有数据** |
| state | 2 | RECORD_STATE_WORKING | 数据持续上推中(payload 见 voiceData / voiceDataBlock) |
| reason | 0 | REASON_NORMAL | 正常 |
| reason | 1 | REASON_STOP | 主动 stop |
字段:
- `voiceDataBlock`:分块裸数据
- `voiceData`:单包数据
- `recordParam`:本次录音参数回带
- `message`:异常描述
---
## 七、音频编解码类型(`com.jieli.bluetooth.constant.Constants`)
| 值 | 常量 |
|---:|---|
| 0 | AUDIO_TYPE_PCM |
| 1 | AUDIO_TYPE_SPEEX |
| **2** | **AUDIO_TYPE_OPUS**(项目当前使用) |
| 3 | AUDIO_TYPE_M_SBC |
| 4 | AUDIO_TYPE_JLA_V2 |
---
## 八、项目现状速查表
| 功能 | 入口类 | 走的 cmd | 模式 ID | 备注 |
|---|---|---:|---:|---|
| 翻译 / 通话翻译 | TranslationFeature | 52 | 3 / 6 | 6 在 ColorOS+AI 场景零帧 |
| 录音(独立通路) | RecordOpImpl | 4 / 5 | - | 当前 AI 助理曾尝试此通路,**实测 WORKING 帧不到货** |
| AI 助理 | JieliAssistantPort | 见下文决策 | - | 上行需复用 cmd=52 + mode=3 |
> **AI 助理音频路由结论**:
> - **上行**:必须走 cmd=52 + `MODE_CALL_TRANSLATION (3)` + `STRATEGY_DEVICE_ALWAYS_RECORDING (1)`,
> 规避 mode=6
> - **下行**:APP 端 `AudioTrack` USAGE_MEDIA 直送(系统蓝牙路由 → A2DP),
> **不走** RCSP `feedTranslatedAudio`,避免 SCO/A2DP 抢通道
---
## 附:如何从 aar 自行核对
```bash
unzip jl_bluetooth_rcsp_*.aar -d /tmp/jl
unzip /tmp/jl/classes.jar -d /tmp/jl/classes
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/constant/Command.class
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/record/RecordParam.class
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/record/RecordState.class
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/translation/TranslationMode.class
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/bean/translation/AudioData.class
javap -p -constants /tmp/jl/classes/com/jieli/bluetooth/constant/Constants.class | grep AUDIO_TYPE
```

4
local_plugins/device_jieli/android/build.gradle

@ -51,8 +51,8 @@ android {
}
dependencies {
// 杰理原生 SDK:library 模块只能 compileOnly(AGP 限制:library 不能直接 bundle 本地 aar)。
// host app 需在 android/app/build.gradle.kts 里把同名 aar 用 implementation 引入作运行时依赖。
// The JieLi AARs are compile-only here. The host app must redeclare them
// as runtime dependencies in its own build.gradle (see example/android/app).
compileOnly fileTree(dir: 'libs', include: ['*.aar', '*.jar'])
// device_plugin_interface 提供 DeviceCallTranslationPort 等通用接口,
// 让 JieliCallTranslationPort 可以无 vendor 耦合地被 translate_server 消费。

22
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt

@ -94,6 +94,11 @@ abstract class JieliEventAdapter : JieliEventListener {
"speechEnd" -> onSpeechEnd(payload)
"speechError" -> onSpeechError(payload)
"deviceRecordStart" -> onDeviceRecordStart(payload)
"deviceRecordAudio" -> onDeviceRecordAudio(payload)
"deviceRecordStop" -> onDeviceRecordStop(payload)
"deviceRecordError" -> onDeviceRecordError(payload)
"otaState" -> onOtaState(payload)
"otaError" -> onOtaError(payload)
@ -144,6 +149,23 @@ abstract class JieliEventAdapter : JieliEventListener {
open fun onSpeechError(payload: Map<String, Any?>) {}
// ── 设备录音 ────────────────────────────────────────────────────────────
/** 录音上行已启动;payload: address, sampleRate, tsMs */
open fun onDeviceRecordStart(payload: Map<String, Any?>) {}
/**
* 录音音频帧;payload: address, streamId("in.uplink"/"in.downlink"),
* pcm(ByteArray), sampleRate, channels, bitsPerSample, tsMs
*/
open fun onDeviceRecordAudio(payload: Map<String, Any?>) {}
/** 录音上行已停止;payload: address, tsMs */
open fun onDeviceRecordStop(payload: Map<String, Any?>) {}
/** 录音错误;payload: address, code, message */
open fun onDeviceRecordError(payload: Map<String, Any?>) {}
/** OTA 进度/状态:state(String), sent(Long), total(Long), percent(Int) */
open fun onOtaState(payload: Map<String, Any?>) {}

5
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt

@ -10,7 +10,10 @@ import com.jieli.jl_audio_decode.opus.model.OpusOption
* 输入:从耳机收到的 OPUS 编码帧
* 输出:16kHz / mono PCM 流
*
* 立体声场景把 [channel] 设 2 + [packetSize] 设 80。
* **packetSize 是单个 OPUS 帧的实际字节大小,不是"传输块大小"**。
* 16k/16bit/mono/20ms ≈ 40B/帧,stereo ≈ 80B/帧。
* 杰理 SDK demo 里用 200/80 这类"块"值会让 OpusManager 把多帧拼包当成 1 帧解,
* 直接丢掉后面的帧(mono 200B = 5 帧 → 只解 1 帧,丢 80%)。
*/
class OpusStreamDecoder(
private val channel: Int = 1,

45
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt

@ -32,7 +32,10 @@ class MethodRouter(
"startScan" -> {
val timeout = call.argument<Int>("timeoutMs") ?: 30000
server.scanFeature.startScan(timeout)
val nameList = call.argument<List<String>>("nameList") ?: emptyList()
val uuidList = call.argument<List<String>>("uuidList") ?: emptyList()
val skipUnnamed = call.argument<Boolean>("skipUnnamed") ?: true
server.scanFeature.startScan(timeout, nameList, uuidList, skipUnnamed)
.fold({ result.success(true) },
{ result.error("SCAN_FAILED", it.message, null) })
}
@ -113,6 +116,8 @@ class MethodRouter(
?: return result.error("BAD_ARG", "modeId required", null)
@Suppress("UNCHECKED_CAST")
val args = (call.argument<Map<String, Any?>>("args") ?: emptyMap())
// 设备录音与翻译互斥:启动翻译前先停录音
if (server.deviceRecordFeature.isRecording()) server.deviceRecordFeature.stop()
server.translationFeature.start(modeId, args)
.fold({ result.success(true) },
{ result.error("TRANSLATION_ERR", it.message, null) })
@ -202,6 +207,44 @@ class MethodRouter(
)
}
// ── AI 助理通路(JieliAssistantPort:MODE_RECORD + DEVICE_ALWAYS_RECORDING)──
"assistantStart" -> {
// 翻译 / 设备录音与本通路互斥(同一 RCSP TranslationImpl 状态机)
if (server.translationFeature.isWorking()) server.translationFeature.stop()
if (server.deviceRecordFeature.isRecording()) server.deviceRecordFeature.stop()
val ok = server.assistantBridge.start()
if (ok) result.success(true)
else result.error("ASSISTANT_START_ERR", "assistant bridge start failed", null)
}
"assistantStop" -> {
server.assistantBridge.stop()
result.success(true)
}
"assistantIsRunning" ->
result.success(server.assistantBridge.isRunning())
// ── 设备录音 ──────────────────────────────────────────────────
"deviceRecordStart" -> {
@Suppress("UNCHECKED_CAST")
val args = (call.argument<Map<String, Any?>>("args") ?: emptyMap())
// 翻译与设备录音互斥:启动录音前先停翻译
if (server.translationFeature.isWorking()) server.translationFeature.stop()
server.deviceRecordFeature.start(args)
.fold({ result.success(true) },
{ result.error("DEVICE_RECORD_ERR", it.message, null) })
}
"deviceRecordStop" -> {
server.deviceRecordFeature.stop()
result.success(true)
}
"deviceRecordStatus" -> result.success(
mapOf("recording" to server.deviceRecordFeature.isRecording())
)
"otaStart" -> {
val path = call.argument<String>("firmwareFilePath")
?: return result.error("BAD_ARG", "firmwareFilePath required", null)

68
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt

@ -16,6 +16,7 @@ import com.jielihome.jielihome.feature.CustomCmdFeature
import com.jielihome.jielihome.feature.DeviceInfoFeature
import com.jielihome.jielihome.feature.ScanFeature
import com.jielihome.jielihome.feature.ota.OtaFeature
import com.jielihome.jielihome.feature.record.DeviceRecordFeature
import com.jielihome.jielihome.feature.translation.EventChannelAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationFeature
@ -58,6 +59,10 @@ class JieliHomeServer private constructor() {
private lateinit var btManager: JL_BluetoothManager
/** 模块内访问 btManager 用(如 [com.jielihome.jielihome.feature.assistant.JieliAssistantPort]
* 需要直接持有 [com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl] 单例时)。 */
internal val internalBtManager: JL_BluetoothManager get() = btManager
private lateinit var bluetoothForwarder: BluetoothEventForwarder
private lateinit var deviceInfoForwarder: DeviceInfoEventForwarder
private lateinit var mediaForwarder: MediaEventForwarder
@ -77,6 +82,8 @@ class JieliHomeServer private constructor() {
private set
lateinit var otaFeature: OtaFeature
private set
lateinit var deviceRecordFeature: DeviceRecordFeature
private set
/** 默认翻译音频桥(EventChannel 透传到 Dart);宿主 native 想完全自管时可调
* [TranslationFeature.setBridge] 替换。
@ -97,14 +104,21 @@ class JieliHomeServer private constructor() {
JL_Log.configureLog(context, enableLog, enableLog)
val option = BluetoothOption.createDefaultOption().also {
it.isUseMultiDevice = multiDevice
it.isSkipNoNameDev = skipNoNameDev
// 清空杰理协议广播过滤头:默认值会让 SDK 只回调匹配杰理私有广播头的设备,
// 导致 onDiscovery 在普通 BLE 设备 / 测试场景下完全不触发。
it.scanFilterData = ""
it.otaScanFilterData = ""
}
// 注意:扫描已不走 Jieli SDK(改为 ScanFeature 内的 BluetoothLeScanner),
// 所以 BluetoothOption 的 bleScanStrategy / scanFilterData 不再设置——
// 它们仅影响 SDK 自己的 `btManager.scan()` 路径,留默认即可。
val option = BluetoothOption.createDefaultOption()
option.setUseMultiDevice(multiDevice)
option.setSkipNoNameDev(skipNoNameDev)
// 关闭设备认证:SDK 默认 isUseDeviceAuth=true,会要求设备走 RCSP 授权流程
// (`DeviceStatusManager.isAuthBtDevice`),未授权设备会被拒绝 → 表现为
// 连上后 RCSP init 完成立刻断开。授权需要服务端发签名密钥的整套基础
// 设施,目前没接,统一关掉。
option.setUseDeviceAuth(true)
android.util.Log.d(
"JieliHome",
"init multiDev=$multiDevice skipNoName=$skipNoNameDev useAuth=false"
)
RCSPController.init(context, option)
btManager = RCSPController.getInstance().bluetoothManager
@ -117,13 +131,14 @@ class JieliHomeServer private constructor() {
customForwarder = CustomEventForwarder(btManager, dispatcher).also { it.attach() }
// 业务功能层
scanFeature = ScanFeature(btManager)
scanFeature = ScanFeature(context.applicationContext, dispatcher)
connectFeature = ConnectFeature(btManager)
deviceInfoFeature = DeviceInfoFeature(btManager)
customCmdFeature = CustomCmdFeature(btManager)
translationFeature = TranslationFeature(context.applicationContext, btManager, connectFeature)
speechFeature = SpeechFeature(btManager, connectFeature, dispatcher).also { it.attach() }
otaFeature = OtaFeature(btManager, connectFeature, dispatcher)
deviceRecordFeature = DeviceRecordFeature(context.applicationContext, btManager, connectFeature, dispatcher)
// 默认音频桥:EventChannel;injector 把 Dart 推过来的 PCM 路由回当前 ModeHandler
defaultBridge = EventChannelAudioBridge(dispatcher) { _, streamId, pcm, fmt, isFinal ->
@ -144,8 +159,9 @@ class JieliHomeServer private constructor() {
"connectionState" -> {
val state = payload["state"] as? Int ?: return
// StateCode.CONNECTION_DISCONNECT == 0
if (state == 0 && translationFeature.isWorking()) {
translationFeature.stop()
if (state == 0) {
if (translationFeature.isWorking()) translationFeature.stop()
if (deviceRecordFeature.isRecording()) deviceRecordFeature.stop()
}
}
"phoneCallStatus" -> {
@ -181,9 +197,39 @@ class JieliHomeServer private constructor() {
com.jielihome.jielihome.feature.translation.JieliCallTranslationPort(this)
}
/**
* 设备录音端口 —— 原生层直连入口。
* 收集 [deviceRecordPort.audioFrames] 获取 PCM 帧,调用 [deviceRecordPort.start]/[stop] 控制录音。
*/
val deviceRecordPort: com.jielihome.jielihome.feature.record.JieliDeviceRecordPort by lazy {
com.jielihome.jielihome.feature.record.JieliDeviceRecordPort(this)
}
/**
* AI 助理能力端口([com.aiagent.device_plugin_interface.DeviceAssistantPort]
* 的杰理实现)。延迟创建;`assistant_server` native 编排器通过
* [JieliNativeDeviceSession.assistantPort] 拿引用,与 [callTranslationPort] 语义独立。
*
* 底层同样依赖 RCSP `MODE_CALL_TRANSLATION`,但接口上已完全隐藏 leg/翻译语义。
*/
val assistantPort: com.jielihome.jielihome.feature.assistant.JieliAssistantPort by lazy {
com.jielihome.jielihome.feature.assistant.JieliAssistantPort(this)
}
/**
* Flutter ↔ [assistantPort] 的桥接。让 Dart 侧可以通过 MethodChannel
* 启停 AI 助理的耳机录音通路(`MODE_RECORD` + `STRATEGY_DEVICE_ALWAYS_RECORDING`),
* 上行 PCM 帧通过 EventDispatcher 以 `assistantAudio` 事件推给 Dart。
*/
val assistantBridge: com.jielihome.jielihome.feature.assistant.AssistantBridge by lazy {
com.jielihome.jielihome.feature.assistant.AssistantBridge(assistantPort, dispatcher)
}
fun shutdown() {
if (!initialized) return
runCatching { assistantBridge.shutdown() }
runCatching { otaFeature.cancel() }
runCatching { deviceRecordFeature.stop() }
runCatching { translationFeature.stop() }
runCatching { speechFeature.detach() }
runCatching { bluetoothForwarder.detach() }

65
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt

@ -1,20 +1,21 @@
package com.jielihome.jielihome.event
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.util.Log
import com.jieli.bluetooth.bean.BleScanMessage
import com.jieli.bluetooth.constant.BluetoothConstant
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.interfaces.bluetooth.BluetoothCallbackImpl
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspCallback
import com.jielihome.jielihome.bridge.EventDispatcher
private const val TAG = "JLBtEvtFwd"
/**
* 蓝牙基础事件:适配器开关、扫描状态、设备发现、配对、RCSP init、连接状态。
* 蓝牙基础事件:适配器开关、配对、RCSP init、连接状态。
* 与设备「业务数据」(电量、音乐等)解耦。
*
* 注意:扫描结果(`onDiscovery` / `onDiscoveryStatus`)已不再走这里——
* [com.jielihome.jielihome.feature.ScanFeature] 直接用 Android 原生
* `BluetoothLeScanner` 扫描,避免 Jieli SDK `ParseDataUtil.isFilterBleDevice`
* 的 strategy / flagContent 过滤导致看不到设备。SDK 自身若偶发回调
* `onDiscovery`(例如内部自动重连等路径),这里**忽略**,避免重复上报。
*/
class BluetoothEventForwarder(
private val btManager: JL_BluetoothManager,
@ -22,58 +23,16 @@ class BluetoothEventForwarder(
) {
private val btCallback = object : BluetoothCallbackImpl() {
override fun onAdapterStatus(bEnabled: Boolean, bHasBle: Boolean) {
Log.i(TAG, "onAdapterStatus enabled=$bEnabled hasBle=$bHasBle")
dispatcher.send(
mapOf("type" to "adapterStatus", "enabled" to bEnabled, "hasBle" to bHasBle)
)
}
override fun onDiscoveryStatus(bBle: Boolean, bStart: Boolean) {
Log.i(TAG, "onDiscoveryStatus ble=$bBle started=$bStart")
dispatcher.send(
mapOf("type" to "scanStatus", "ble" to bBle, "started" to bStart)
)
}
// SDK 内部仍会触发,但扫描状态由 ScanFeature 自行派发,这里不转发。
override fun onDiscoveryStatus(bBle: Boolean, bStart: Boolean) = Unit
@SuppressLint("MissingPermission")
override fun onDiscovery(device: BluetoothDevice?, msg: BleScanMessage?) {
Log.i(TAG, "onDiscovery raw: device=$device msg=$msg")
if (device == null) {
Log.w(TAG, "onDiscovery 跳过 device==null")
return
}
// 只放行杰理协议设备(vid == JL_VID=1494)。
// 其它厂商广播(Apple vid=76、Microsoft vid=6 等)跳过,避免列表里出来一堆不能连的设备。
if (msg == null || msg.vid != BluetoothConstant.JL_VID) {
Log.d(TAG, "onDiscovery 跳过非杰理: addr=${device.address} vid=${msg?.vid}")
return
}
val name = try { device.name } catch (_: SecurityException) { null } ?: ""
Log.i(
TAG,
"onDiscovery deliver name='$name' addr=${device.address} " +
"edr=${msg?.edrAddr} type=${msg?.deviceType} way=${msg?.connectWay} rssi=${msg?.rssi}"
)
dispatcher.send(
mapOf(
"type" to "deviceFound",
"name" to name,
"address" to device.address,
"edrAddr" to (msg?.edrAddr ?: ""),
"deviceType" to (msg?.deviceType ?: 0),
"connectWay" to (msg?.connectWay ?: 0),
"rssi" to (msg?.rssi ?: 0),
// BleScanMessage 已经包含双耳/盒子电量及充电状态——直接透传,
// 上层就不需要等 RCSP 拉快照了。0 视为「该位置无数据」(如单耳设备的 rightDeviceQuantity)
"leftBattery" to (msg?.leftDeviceQuantity ?: 0),
"rightBattery" to (msg?.rightDeviceQuantity ?: 0),
"caseBattery" to (msg?.chargingBinQuantity ?: 0),
"leftCharging" to (msg?.isLeftCharging ?: false),
"rightCharging" to (msg?.isRightCharging ?: false),
"caseCharging" to (msg?.isDeviceCharging ?: false)
)
)
}
// 同上:扫描结果由 ScanFeature 直接派发,这里忽略。
override fun onDiscovery(device: BluetoothDevice?, msg: BleScanMessage?) = Unit
override fun onBondStatus(device: BluetoothDevice?, status: Int) {
if (device == null) return
@ -101,13 +60,11 @@ class BluetoothEventForwarder(
}
fun attach() {
Log.i(TAG, "attach: 注册 btCallback / rcspCallback")
btManager.addEventListener(btCallback)
btManager.registerOnRcspCallback(rcspCallback)
}
fun detach() {
Log.i(TAG, "detach: 注销 btCallback / rcspCallback")
btManager.removeEventListener(btCallback)
btManager.unregisterOnRcspCallback(rcspCallback)
}

6
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt

@ -2,6 +2,7 @@ package com.jielihome.jielihome.event
import android.bluetooth.BluetoothDevice
import android.util.Base64
import android.util.Log
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener
import com.jielihome.jielihome.bridge.EventDispatcher
@ -17,8 +18,13 @@ class CustomEventForwarder(
private val btManager: JL_BluetoothManager,
private val dispatcher: EventDispatcher,
) {
companion object {
private const val TAG = "CustomEventForwarder"
}
private val listener = object : OnRcspEventListener() {
override fun onExpandFunction(device: BluetoothDevice?, opCode: Int, payload: ByteArray?) {
Log.i(TAG, "[SDK<-DEV] onExpandFunction addr=${device?.address} opCode=$opCode(0x${String.format("%02X", opCode)}) payloadSize=${payload?.size ?: 0}")
dispatcher.send(
mapOf(
"type" to "expandFunction",

8
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt

@ -1,6 +1,7 @@
package com.jielihome.jielihome.feature
import android.bluetooth.BluetoothDevice
import android.util.Log
import com.jieli.bluetooth.bean.base.BaseError
import com.jieli.bluetooth.bean.base.CommandBase
import com.jieli.bluetooth.bean.command.custom.CustomCmd
@ -17,6 +18,10 @@ import com.jieli.bluetooth.utils.BluetoothUtil
*/
class CustomCmdFeature(private val btManager: JL_BluetoothManager) {
companion object {
private const val TAG = "CustomCmdFeature"
}
fun send(
address: String,
opCode: Int,
@ -27,14 +32,17 @@ class CustomCmdFeature(private val btManager: JL_BluetoothManager) {
val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address)
?: return onError(-1, "remote device not found")
Log.i(TAG, "[APP->SDK] sendRcspCommand addr=$address opCode=$opCode(0x${String.format("%02X", opCode)}) payloadSize=${payload.size}")
val cmd = CustomCmd(opCode, CustomParam(payload))
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback {
override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) {
val r = resp?.response as? CustomResponse
Log.i(TAG, "[SDK<-DEV] sendRcspCommand onResponse addr=${dev?.address} opCode=$opCode dataSize=${r?.data?.size ?: 0}")
onSuccess(r?.data)
}
override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) {
Log.w(TAG, "[SDK<-DEV] sendRcspCommand onErrCode addr=${dev?.address} opCode=$opCode code=${err?.code} msg=${err?.message}")
onError(err?.code ?: -2, err?.message)
}
})

297
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt

@ -1,19 +1,298 @@
package com.jielihome.jielihome.feature
import com.jieli.bluetooth.constant.ErrorCode
import com.jieli.bluetooth.impl.JL_BluetoothManager
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import com.jieli.bluetooth.bean.BluetoothOption
import com.jieli.bluetooth.utils.ParseDataUtil
import com.jielihome.jielihome.bridge.EventDispatcher
import java.util.UUID
class ScanFeature(private val btManager: JL_BluetoothManager) {
/**
* BLE 扫描入口。
*
* 不再走 Jieli SDK 的 `btManager.scan()`(受其内置 ParseDataUtil 的
* flagContent / strategy 限制),改为直接用 Android 原生
* [BluetoothLeScanner] + [ScanFilter] 扫描,复合过滤:
*
* - [nameList]:对 `BluetoothDevice.name` 精确匹配(忽略大小写),空 = 不按名过滤
* - [uuidList]:构造 `ScanFilter.setServiceUuid(...)`,由 OS 过滤;空 = 不按 UUID 过滤
*
* 两者同时设置时按 (UUID 由 OS 过滤后) AND (名字命中) 上报。
*
* 由于不再依赖 Jieli SDK 的 `BleScanMessage`,发出的 `deviceFound` 事件里
* `edrAddr` / `deviceType` / `connectWay` 三个字段为 null —— 后续连接由
* [com.jielihome.jielihome.feature.ConnectFeature] 用默认值(deviceType=-1,
* connectWay=0)尝试,可在调用方通过 connectWay override 指定。
*/
class ScanFeature(
private val context: Context,
private val dispatcher: EventDispatcher,
) {
fun startScan(timeoutMs: Int): Result<Unit> {
val code = btManager.scan(timeoutMs)
return if (code == ErrorCode.ERR_NONE) Result.success(Unit)
else Result.failure(IllegalStateException("scan returned $code"))
private val mainHandler = Handler(Looper.getMainLooper())
/**
* 用于把 Android 原生 ScanRecord 字节交给 Jieli SDK 解析的可复用 option。
* `bleScanStrategy=3` + 空 flagContent → 跳过 SDK 的 flagContent.equals 校验,
* 任何 mode-3 可解析的 Jieli 广播都返回 BleScanMessage(含 edrAddr/pid 等)。
*/
private val parserOption: BluetoothOption by lazy {
BluetoothOption.createDefaultOption().apply {
setBleScanStrategy(3)
setScanFilterData("")
}
}
@Volatile private var scanCallback: ScanCallback? = null
@Volatile private var scanning: Boolean = false
private var stopRunnable: Runnable? = null
@Volatile private var currentNameList: List<String> = emptyList()
@Volatile private var currentSkipUnnamed: Boolean = true
@SuppressLint("MissingPermission")
fun startScan(
timeoutMs: Int,
nameList: List<String> = emptyList(),
uuidList: List<String> = emptyList(),
skipUnnamed: Boolean = true,
): Result<Unit> {
val scanner = leScanner()
?: return Result.failure(IllegalStateException("BLE scanner unavailable"))
// 已在扫描:先停旧的(替换扫描参数)
if (scanning) doStop(scanner)
currentNameList = nameList.filter { it.isNotEmpty() }
currentSkipUnnamed = skipUnnamed
val filters = mutableListOf<ScanFilter>()
uuidList.forEach { raw ->
val uuid = parseUuid(raw) ?: return@forEach
filters.add(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid(uuid))
.build()
)
Log.d(TAG, "add ScanFilter serviceUuid=$uuid")
}
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setReportDelay(0)
.build()
val cb = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
handleScanResult(result)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
for (r in results) handleScanResult(r)
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "onScanFailed errorCode=$errorCode")
scanning = false
scanCallback = null
stopRunnable?.let { mainHandler.removeCallbacks(it) }
stopRunnable = null
dispatcher.send(
mapOf("type" to "scanStatus", "ble" to true, "started" to false)
)
}
}
return try {
// filters 为空时传 null("扫描所有 BLE 广播")
scanner.startScan(filters.takeIf { it.isNotEmpty() }, settings, cb)
scanCallback = cb
scanning = true
Log.d(
TAG,
"startScan timeoutMs=$timeoutMs filters=${filters.size} " +
"nameList=$currentNameList"
)
dispatcher.send(
mapOf("type" to "scanStatus", "ble" to true, "started" to true)
)
val stop = Runnable {
Log.d(TAG, "scan timeout reached, stopping")
stopScan()
}
stopRunnable = stop
mainHandler.postDelayed(stop, timeoutMs.toLong())
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "startScan exception", e)
scanning = false
Result.failure(e)
}
}
fun stopScan() {
btManager.stopScan()
val scanner = leScanner() ?: return
doStop(scanner)
}
@SuppressLint("MissingPermission")
private fun doStop(scanner: BluetoothLeScanner) {
val cb = scanCallback
if (cb != null) {
try {
scanner.stopScan(cb)
} catch (e: Exception) {
Log.w(TAG, "stopScan exception: ${e.message}")
}
}
scanCallback = null
stopRunnable?.let { mainHandler.removeCallbacks(it) }
stopRunnable = null
scanning = false
currentNameList = emptyList()
dispatcher.send(
mapOf("type" to "scanStatus", "ble" to true, "started" to false)
)
}
fun isScanning(): Boolean = scanning
@SuppressLint("MissingPermission")
private fun handleScanResult(result: ScanResult) {
val device = result.device
val name = try {
device.name ?: result.scanRecord?.deviceName
} catch (_: SecurityException) { null }
// 未命名过滤(默认 ON):drop 掉 name 为空的环境噪声
if (currentSkipUnnamed && name.isNullOrEmpty()) return
val names = currentNameList
if (names.isNotEmpty()) {
if (name.isNullOrEmpty() ||
names.none { it.equals(name, ignoreCase = true) }
) return
}
// 用 Jieli SDK 的 ParseDataUtil 解一次原始 ScanRecord,能解出来就拿到
// edrAddr / pid / deviceType / connectWay 等私有字段;解不出来就当作普通
// BLE 设备(这些字段为 null,连接时走 BLE-only fallback)。
val rawBytes = result.scanRecord?.bytes
val msg = if (rawBytes != null) {
try { ParseDataUtil.isFilterBleDevice(parserOption, rawBytes) }
catch (e: Throwable) {
Log.w(TAG, "parseBleScanMessage failed: ${e.message}")
null
}
} else null
// 解析出给 Flutter 侧 "广播详情" 弹窗用的结构化字段。
val adRecords = if (rawBytes != null) parseAdRecords(rawBytes) else emptyList()
val advRecordsPayload = adRecords.map { r ->
mapOf(
"len" to "%02d".format(r.len),
"type" to "0x%02X".format(r.type),
"data" to "0x" + r.data.toUpperHex(),
)
}
val advFlags = adRecords.firstOrNull { it.type == 0x01 }
?.data?.takeIf { it.isNotEmpty() }
?.let { it[0].toInt() and 0xFF }
val mfrRecord = adRecords.firstOrNull { it.type == 0xFF }
val manufacturerCompanyId = mfrRecord?.data?.takeIf { it.size >= 2 }?.let {
((it[1].toInt() and 0xFF) shl 8) or (it[0].toInt() and 0xFF)
}
val manufacturerData = mfrRecord?.data?.takeIf { it.size > 2 }
?.let { it.copyOfRange(2, it.size).toUpperHex() }
val serviceUuidStrings = result.scanRecord?.serviceUuids
?.map { it.uuid.toString().uppercase() } ?: emptyList()
dispatcher.send(
mapOf(
"type" to "deviceFound",
"name" to (name ?: ""),
"address" to device.address,
"edrAddr" to msg?.edrAddr,
"deviceType" to msg?.deviceType,
"connectWay" to msg?.connectWay,
"rssi" to result.rssi,
"rawAdv" to rawBytes?.toUpperHex(),
"advRecords" to advRecordsPayload,
"advFlags" to advFlags,
"manufacturerCompanyId" to manufacturerCompanyId,
"manufacturerData" to manufacturerData,
"serviceUuids" to serviceUuidStrings,
)
)
}
fun isScanning(): Boolean = btManager.bluetoothOperation.isScanning
private fun leScanner(): BluetoothLeScanner? {
val mgr = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
?: return null
return mgr.adapter?.bluetoothLeScanner
}
/** 单个 AD (Advertising Data) 结构:len 为 "type + data" 的字节数,不含 len 字节自身。 */
private data class AdRecord(val len: Int, val type: Int, val data: ByteArray)
/**
* 按 BLE Core Spec §11(AD structure)解析 ScanRecord 字节。
* 每条记录:`[len:1][type:1][data:len-1]`;遇到 len==0 视为 padding 结束。
*/
private fun parseAdRecords(bytes: ByteArray): List<AdRecord> {
val out = mutableListOf<AdRecord>()
var i = 0
while (i < bytes.size) {
val len = bytes[i].toInt() and 0xFF
if (len == 0) break
if (i + len >= bytes.size) break // 畸形包:data 越界,丢弃尾部
val type = bytes[i + 1].toInt() and 0xFF
val data = bytes.copyOfRange(i + 2, i + 1 + len)
out.add(AdRecord(len, type, data))
i += 1 + len
}
return out
}
private fun ByteArray.toUpperHex(): String =
joinToString("") { "%02X".format(it) }
private fun parseUuid(raw: String): UUID? {
val s = raw.trim()
return try {
when {
s.length == 4 -> UUID.fromString("0000$s-0000-1000-8000-00805f9b34fb")
s.length == 8 -> UUID.fromString("$s-0000-1000-8000-00805f9b34fb")
s.length == 36 -> UUID.fromString(s)
s.length == 32 -> UUID.fromString(
"${s.substring(0, 8)}-${s.substring(8, 12)}-${s.substring(12, 16)}" +
"-${s.substring(16, 20)}-${s.substring(20, 32)}"
)
else -> {
Log.w(TAG, "invalid UUID format: $raw")
null
}
}
} catch (e: IllegalArgumentException) {
Log.e(TAG, "parseUuid failed: $raw", e)
null
}
}
private companion object {
const val TAG = "JieliScan"
}
}

143
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/AssistantBridge.kt

@ -0,0 +1,143 @@
package com.jielihome.jielihome.feature.assistant
import android.util.Log
import com.aiagent.device_plugin_interface.AssistantAudioFormat
import com.jielihome.jielihome.bridge.EventDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import java.util.concurrent.atomic.AtomicLong
/**
* Flutter ↔ [JieliAssistantPort] 的桥接。把 SharedFlow 形态的音频帧 / 错误流
* 转成 [EventDispatcher] 事件(`assistantAudio` / `assistantError`),让
* Dart 侧可以通过 EventChannel 实时收到耳机麦上行的 16 kHz PCM。
*
* `start` 内部调用 [JieliAssistantPort.enter](`MODE_RECORD` +
* `STRATEGY_DEVICE_ALWAYS_RECORDING`),耳机会持续推 OPUS,由 Port 解码成
* 16 kHz/16 bit/mono/20 ms PCM。`stop` 调用 [JieliAssistantPort.exit] 并
* 取消订阅。两者通过 `synchronized` 互斥,重复调用安全。
*/
class AssistantBridge(
private val port: JieliAssistantPort,
private val dispatcher: EventDispatcher,
) {
companion object {
private const val TAG = "AssistantBridge"
}
private val scopeJob = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + scopeJob)
@Volatile private var audioJob: Job? = null
@Volatile private var errorJob: Job? = null
@Volatile private var running = false
/** 调试:每秒打一次 PCM 转发统计,方便确认音频是否真正流到 Dart */
private val fwdCount = AtomicLong(0)
@Volatile private var lastReportMs = 0L
@Synchronized
fun start(): Boolean {
if (running) {
Log.w(TAG, "start: already running, ignored")
return true
}
// 先订阅 Flow,再 enter(),避免首帧落在订阅者建立之前被 SharedFlow 丢掉
// (JieliAssistantPort 用 replay=0 + DROP_OLDEST)
audioJob = port.audioFrames.onEach { frame ->
fwdCount.incrementAndGet()
val now = System.currentTimeMillis()
if (now - lastReportMs >= 1000L) {
Log.i(
TAG,
"fwd PCM stats (last 1s): frames=${fwdCount.getAndSet(0)} bytes/frame=${frame.bytes.size} sr=${frame.sampleRate}",
)
lastReportMs = now
}
dispatcher.send(
mapOf(
"type" to "assistantAudio",
"encoding" to "pcm16",
"sampleRate" to frame.sampleRate,
"channels" to frame.channels,
"bitsPerSample" to 16,
"sequence" to frame.sequence,
"tsMs" to (frame.timestampUs / 1000L),
"pcm" to frame.bytes,
)
)
}.launchIn(scope)
errorJob = port.errors.onEach { err ->
Log.w(TAG, "port.error code=${err.code} msg=${err.message}")
dispatcher.send(
mapOf(
"type" to "assistantError",
"code" to err.code,
"message" to err.message,
)
)
}.launchIn(scope)
try {
port.enter(AssistantAudioFormat.PCM_S16LE_16K_MONO_20MS)
} catch (t: Throwable) {
Log.e(TAG, "start: port.enter failed: ${t.message}", t)
runCatching { audioJob?.cancel() }
runCatching { errorJob?.cancel() }
audioJob = null
errorJob = null
dispatcher.send(
mapOf(
"type" to "assistantError",
"code" to "device.assistant.enter_failed",
"message" to (t.message ?: t.javaClass.simpleName),
)
)
return false
}
running = true
dispatcher.send(
mapOf(
"type" to "assistantStart",
"sampleRate" to 16000,
"tsMs" to System.currentTimeMillis(),
)
)
Log.i(TAG, "start: assistant port entered, audio bridge active")
return true
}
@Synchronized
fun stop() {
if (!running) return
running = false
runCatching { audioJob?.cancel() }
runCatching { errorJob?.cancel() }
audioJob = null
errorJob = null
runCatching { port.exit() }
dispatcher.send(
mapOf(
"type" to "assistantEnd",
"tsMs" to System.currentTimeMillis(),
)
)
Log.i(TAG, "stop: assistant port exited, audio bridge inactive")
}
fun isRunning(): Boolean = running
fun shutdown() {
stop()
runCatching { scope.cancel() }
}
}

276
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/assistant/JieliAssistantPort.kt

@ -0,0 +1,276 @@
package com.jielihome.jielihome.feature.assistant
import android.bluetooth.BluetoothDevice
import android.util.Log
import com.aiagent.device_plugin_interface.AssistantAudioCodec
import com.aiagent.device_plugin_interface.AssistantAudioFormat
import com.aiagent.device_plugin_interface.AssistantAudioFrame
import com.aiagent.device_plugin_interface.AssistantError
import com.aiagent.device_plugin_interface.AssistantPlaybackFrame
import com.aiagent.device_plugin_interface.DeviceAssistantPort
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.constant.Constants
import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback
import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback
import com.jielihome.jielihome.audio.LocalPlayer
import com.jielihome.jielihome.audio.OpusStreamDecoder
import com.jielihome.jielihome.core.JieliHomeServer
import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.util.concurrent.atomic.AtomicLong
/**
* `DeviceAssistantPort` 的杰理实现 —— **录音模式(MODE_RECORD=1)通路**版。
*
* # 设计要点
* 杰理 demo 里没有「AI 助理」这种产品形态,但其 [TranslationImpl.enterMode] +
* `MODE_RECORD`(=1) + `STRATEGY_DEVICE_ALWAYS_RECORDING`(=1) 能让耳机持续上推
* OPUS 音频帧到 [TranslationCallback.onReceiveAudioData],完全满足 AI 助理的
* 上行采集需求,我们就借这条录音模式通路:
* - 上行采集:耳机麦 → 耳机固件 OPUS 编码 → onReceiveAudioData → OpusStreamDecoder
* → 16kHz PCM → SharedFlow 抛给编排器。**mode 字段 = 1,与 demo 行为一致**。
* - 下行回灌:TTS PCM 写 [LocalPlayer](AudioTrack + USAGE_MEDIA),由 Android
* 蓝牙路由走 A2DP 通道送回耳机扬声器。不走 RCSP 下发,避免与 A2DP 媒体音频抢通道。
*
* # 与 [com.jielihome.jielihome.feature.translation.JieliCallTranslationPort] 的关系
* 两者都走 TranslationImpl,但 mode 不同、策略不同:
* - CallTranslation:mode=3/6(通话翻译),依赖真实 eSCO 通话
* - 本类:mode=1(录音),纯上行,不依赖任何通话事件
* 两者不会同时工作(enterMode 互斥)。
*
* # 格式
* 仅放开 PCM_S16LE / 16 kHz / mono / 20 ms。
* 上行 SDK 推 OPUS,由 [OpusStreamDecoder] 解码成 16 kHz PCM 推到 Flow。
* 下行 PCM 直接写 AudioTrack。
*
* # 线程安全
* `enter` / `exit` / `reportPlayback` 通过 `synchronized(this)` 互斥;
* SharedFlow.tryEmit / AudioTrack.write 自身线程安全;
* SDK 回调线程不固定,所有共享状态用 @Volatile 标记。
*/
class JieliAssistantPort(
private val server: JieliHomeServer,
) : DeviceAssistantPort {
companion object {
private const val TAG = "JieliAssistantPort"
private val PCM_16K_MONO_20MS = AssistantAudioFormat.PCM_S16LE_16K_MONO_20MS
}
private val _audioFrames = MutableSharedFlow<AssistantAudioFrame>(
replay = 0,
extraBufferCapacity = 128,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val _errors = MutableSharedFlow<AssistantError>(
replay = 0,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@Volatile private var entered = false
@Volatile private var device: BluetoothDevice? = null
@Volatile private var decoder: OpusStreamDecoder? = null
@Volatile private var player: LocalPlayer? = null
@Volatile private var translationImpl: TranslationImpl? = null
@Volatile private var translationCallback: TranslationCallback? = null
/** 调试统计:每秒打一次 SDK PCM 帧产出量 */
private val upCount = AtomicLong(0)
@Volatile private var lastReportMs = 0L
/** 首帧 flag:首次 onModeChange / onReceiveAudioData / 首次 PCM 帧发射都各打一条 INFO */
@Volatile private var rxFirstModeChangeLogged = false
@Volatile private var rxFirstAudioLogged = false
/** 上行帧自增序号 */
private val seqGen = AtomicLong(0)
override fun supportedSourceFormats(): Set<AssistantAudioFormat> = setOf(PCM_16K_MONO_20MS)
override fun supportedSinkFormats(): Set<AssistantAudioFormat> = setOf(PCM_16K_MONO_20MS)
override val audioFrames: Flow<AssistantAudioFrame> = _audioFrames.asSharedFlow()
override val errors: Flow<AssistantError> = _errors.asSharedFlow()
@Synchronized
override fun enter(sourceFormat: AssistantAudioFormat) {
require(sourceFormat == PCM_16K_MONO_20MS) {
"JieliAssistantPort only supports PCM_S16LE/16k/mono/20ms (got $sourceFormat)"
}
check(!entered) { "device.assistant.busy" }
val dev = server.connectFeature.connectedDevice()
?: throw IllegalStateException("device.assistant.no_device: no connected device")
device = dev
// 1. 启动本地 A2DP 播放器(接收 TTS PCM)
val lp = LocalPlayer(sampleRate = 16000, channels = 1).also { it.start() }
player = lp
// 2. 启动 Opus 解码器:耳机上推的录音流是 OPUS,解码后丢回 SharedFlow。
// packetSize 必须是耳机单个 OPUS 帧的真实大小(16k/16bit/20ms 约 40B),
// 不是 SDK 里那种"块大小"。配 200 时 OpusManager 把 5 个拼接帧当 1 帧解,
// 1s 只出 10 帧 PCM(应有 50),丢 80% 数据 —— 与"AI 助理 10 fps"问题完全对应。
val od = OpusStreamDecoder(
channel = 1,
packetSize = 40,
sampleRate = 16000,
onPcm = { pcm -> emitUplinkFrame(pcm) },
onError = { c, m ->
_errors.tryEmit(AssistantError(
code = "device.decoder_failed",
message = "opus: code=$c msg=${m ?: ""}",
))
},
).also { it.start() }
decoder = od
// 3. 构造 TranslationImpl + 前置校验
val impl = TranslationImpl(server.internalBtManager, NoOpAITranslationApi(), dev)
if (!impl.isInit) {
runCatching { impl.destroy() }
cleanup()
throw IllegalStateException("device.assistant.rcsp_not_init: RCSP not init for ${dev.address}")
}
if (!impl.isSupportTranslation) {
runCatching { impl.destroy() }
cleanup()
throw IllegalStateException("device.assistant.not_supported: device does not support translation")
}
// 4. 注册 TranslationCallback —— 关键回调:onReceiveAudioData 拿 OPUS 帧
val cb = object : TranslationCallback {
override fun onModeChange(d: BluetoothDevice, m: TranslationMode) {
if (!rxFirstModeChangeLogged) {
rxFirstModeChangeLogged = true
Log.i(TAG, "[SDK<-DEV] onModeChange FIRST addr=${d.address} mode=${m.mode} type=${m.audioType} ch=${m.channel} sr=${m.sampleRate} strategy=${m.recordingStrategy}")
} else {
Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} strategy=${m.recordingStrategy}")
}
if (m.mode == TranslationMode.MODE_IDLE) {
// 耳机主动退出 RECORD 模式(按键 / 超时 / 被抢占)
_errors.tryEmit(AssistantError(
code = "device.mode_exited",
message = "headset exited MODE_RECORD → MODE_IDLE",
))
}
}
override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) {
val payload = data.audioData
if (payload == null || payload.isEmpty()) {
Log.w(TAG, "[SDK<-DEV] onReceiveAudioData null/empty payload source=${data.source} type=${data.type}")
return
}
if (!rxFirstAudioLogged) {
rxFirstAudioLogged = true
Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST FRAME addr=${d.address} source=${data.source} type=${data.type} size=${payload.size}")
}
when (data.type) {
Constants.AUDIO_TYPE_PCM -> emitUplinkFrame(payload)
Constants.AUDIO_TYPE_OPUS -> decoder?.feedEncoded(payload)
else -> Log.w(TAG, "[SDK<-DEV] onReceiveAudioData unknown type=${data.type} (skip)")
}
}
override fun onError(d: BluetoothDevice, code: Int, msg: String) {
Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg")
_errors.tryEmit(AssistantError(
code = "device.translation_error",
message = "code=$code msg=$msg",
))
}
}
impl.addTranslationCallback(cb)
translationImpl = impl
translationCallback = cb
// 5. 下发 enterMode(MODE_RECORD=1, OPUS, ch=1, 16k, STRATEGY_DEVICE_ALWAYS_RECORDING)
// - mode=1:与 demo 录音模式对齐,耳机 RCSP 状态机进入 MODE_RECORD
// - STRATEGY_DEVICE_ALWAYS_RECORDING=1:耳机固件自主采集并持续上推,APP 不用手机麦
val sdkMode = TranslationMode(
TranslationMode.MODE_RECORD,
Constants.AUDIO_TYPE_OPUS,
1,
16000,
).setRecordingStrategy(TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING)
Log.i(TAG, "[APP->SDK] enterMode addr=${dev.address} mode=${sdkMode.mode}(MODE_RECORD) type=${sdkMode.audioType}(OPUS) ch=${sdkMode.channel} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(DEVICE_ALWAYS_RECORDING)")
impl.enterMode(sdkMode, cb)
entered = true
Log.d(TAG, "entered assistant mode (MODE_RECORD=1, DEVICE_ALWAYS_RECORDING, A2DP playback)")
}
private fun emitUplinkFrame(pcm: ByteArray) {
upCount.incrementAndGet()
val now = System.currentTimeMillis()
if (now - lastReportMs >= 1000L) {
Log.d(TAG, "uplink PCM stats (last 1s): frames=${upCount.getAndSet(0)} bytes/frame=${pcm.size}")
lastReportMs = now
}
val frame = AssistantAudioFrame(
codec = AssistantAudioCodec.PCM_S16LE,
sampleRate = 16000,
channels = 1,
bytes = pcm,
sequence = seqGen.incrementAndGet(),
timestampUs = now * 1000L,
)
if (!_audioFrames.tryEmit(frame)) {
Log.w(TAG, "audioFrames buffer overflow; dropped seq=${frame.sequence}")
}
}
@Synchronized
override fun reportPlayback(frame: AssistantPlaybackFrame) {
check(entered) { "device.assistant.not_active" }
require(frame.codec == AssistantAudioCodec.PCM_S16LE) {
"JieliAssistantPort only accepts PCM_S16LE for sink (got ${frame.codec})"
}
// TTS PCM 写入本地 A2DP 播放器,由系统蓝牙路由送回耳机扬声器
player?.feed(frame.bytes)
}
@Synchronized
override fun exit() {
if (!entered) return
entered = false
cleanup()
Log.d(TAG, "exited assistant mode")
}
/** 释放 enterMode / decoder / player / callback;可被 enter 失败路径调用,幂等 */
private fun cleanup() {
val impl = translationImpl
val cb = translationCallback
val addr = device?.address
if (impl != null) {
runCatching {
Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORD}")
impl.exitMode(object : OnRcspActionCallback<Int> {
override fun onSuccess(d: BluetoothDevice?, t: Int?) {
Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t")
}
override fun onError(d: BluetoothDevice?, e: com.jieli.bluetooth.bean.base.BaseError?) {
Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${e?.code} msg=${e?.message}")
}
})
}
if (cb != null) runCatching { impl.removeTranslationCallback(cb) }
runCatching { impl.destroy() }
}
runCatching { decoder?.stop() }
runCatching { player?.stop() }
translationImpl = null
translationCallback = null
decoder = null
player = null
device = null
// 复位首帧标志,下次 enter 重新打点
rxFirstModeChangeLogged = false
rxFirstAudioLogged = false
}
}

141
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/DeviceRecordFeature.kt

@ -0,0 +1,141 @@
package com.jielihome.jielihome.feature.record
import android.content.Context
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.constant.Constants
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jielihome.jielihome.bridge.EventDispatcher
import com.jielihome.jielihome.feature.ConnectFeature
import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime
import java.io.File
/**
* 设备录音功能。
*
* 通过 RCSP [TranslationMode.MODE_CALL_TRANSLATION] + STRATEGY_DEVICE_ALWAYS_RECORDING
* 让耳机持续上推双通道 PCM:
* - [AudioData.SOURCE_E_SCO_UP_LINK] → streamId = "in.uplink" (本端/耳机麦克风)
* - [AudioData.SOURCE_E_SCO_DOWN_LINK] → streamId = "in.downlink" (对端/通话对方)
*
* 事件类型:
* - `deviceRecordStart` — 上行启动成功
* - `deviceRecordAudio` — PCM 帧(含 streamId 区分上下行)
* - `deviceRecordStop` — 上行已停止
* - `deviceRecordError` — 错误
*
* 与 [com.jielihome.jielihome.feature.translation.TranslationFeature] 互斥:
* 两者都调用 SDK enterMode,同时只能有一个活跃。
* 调用方(MethodRouter)负责在启动前 stop 另一个。
*/
class DeviceRecordFeature(
private val context: Context,
private val btManager: JL_BluetoothManager,
private val connectFeature: ConnectFeature,
private val dispatcher: EventDispatcher,
) {
@Volatile private var runtime: RcspTranslationRuntime? = null
@Volatile private var working = false
@Volatile private var deviceAddress: String? = null
fun isRecording(): Boolean = working
/**
* 启动设备录音上行。
*
* @param args 可选参数:
* - `address` String 目标设备 MAC;省略则取当前已连设备
* - `sampleRate` Int 采样率(Hz),默认 16000
*/
fun start(args: Map<String, Any?>): Result<Unit> {
if (working) return Result.failure(IllegalStateException("already recording"))
val address = args["address"] as? String
val device = address?.let { connectFeature.deviceByAddress(it) }
?: connectFeature.connectedDevice()
?: return Result.failure(IllegalStateException("no connected device; pass args.address or connect first"))
val sampleRate = (args["sampleRate"] as? Int) ?: 16000
val sdkMode = TranslationMode(
TranslationMode.MODE_CALL_TRANSLATION,
Constants.AUDIO_TYPE_OPUS,
1,
sampleRate,
).setRecordingStrategy(TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING)
val rt = RcspTranslationRuntime(
btManager = btManager,
device = device,
mode = sdkMode,
tempDir = File(context.cacheDir, "jieli_device_record_tmp"),
onPcm = { source, pcm ->
val streamId = when (source) {
AudioData.SOURCE_E_SCO_UP_LINK -> "in.uplink"
AudioData.SOURCE_E_SCO_DOWN_LINK -> "in.downlink"
else -> return@RcspTranslationRuntime
}
dispatcher.send(
mapOf(
"type" to "deviceRecordAudio",
"address" to device.address,
"streamId" to streamId,
"sampleRate" to sampleRate,
"channels" to 1,
"bitsPerSample" to 16,
"tsMs" to System.currentTimeMillis(),
"pcm" to pcm,
)
)
},
onError = { code, msg ->
dispatcher.send(
mapOf(
"type" to "deviceRecordError",
"address" to device.address,
"code" to code,
"message" to msg,
)
)
},
)
return rt.start().fold(
onSuccess = {
runtime = rt
working = true
deviceAddress = device.address
dispatcher.send(
mapOf(
"type" to "deviceRecordStart",
"address" to device.address,
"sampleRate" to sampleRate,
"tsMs" to System.currentTimeMillis(),
)
)
Result.success(Unit)
},
onFailure = { err ->
rt.stop()
Result.failure(err)
},
)
}
/** 停止设备录音上行。幂等:未录音时无副作用。 */
fun stop() {
if (!working) return
working = false
val addr = deviceAddress
deviceAddress = null
runtime?.stop()
runtime = null
dispatcher.send(
mapOf(
"type" to "deviceRecordStop",
"address" to addr,
"tsMs" to System.currentTimeMillis(),
)
)
}
}

151
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/record/JieliDeviceRecordPort.kt

@ -0,0 +1,151 @@
package com.jielihome.jielihome.feature.record
import android.util.Log
import com.jielihome.jielihome.api.JieliEventAdapter
import com.jielihome.jielihome.core.JieliHomeServer
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/** 录音音频帧(已解码为 PCM_S16LE)。*/
data class DeviceRecordFrame(
val address: String,
/** [DeviceRecordFeature] 的 "in.uplink"(本端)或 "in.downlink"(对端)*/
val streamId: String,
val pcm: ByteArray,
val sampleRate: Int,
val channels: Int = 1,
val bitsPerSample: Int = 16,
val tsMs: Long,
) {
// ByteArray 默认 equals/hashCode 按内容比,这里改为引用比避免大帧逐字节比
override fun equals(other: Any?): Boolean = this === other
override fun hashCode(): Int = System.identityHashCode(this)
}
/** 录音错误。 */
data class DeviceRecordError(val address: String?, val code: Int, val message: String?)
/**
* 设备录音端口 —— 原生层编排器的直连入口。
*
* 与 [com.jielihome.jielihome.feature.translation.JieliCallTranslationPort] 对称:
* - [start] 启动耳机音频上行
* - [audioFrames] 是 Kotlin Flow,收集即可获取上行/下行 PCM 帧
* - [stop] 停止上行
*
* Flutter 路径(EventChannel)不受影响:同一帧会同时推给 native flow 和 Dart EventChannel。
*
* # 典型用法
* ```kotlin
* val port = server.deviceRecordPort
* val job = scope.launch {
* port.audioFrames.collect { frame ->
* // frame.streamId == "in.uplink" → 本端说话
* // frame.streamId == "in.downlink" → 对端说话
* writeToFile(frame.streamId, frame.pcm)
* }
* }
* port.start(address = device.address)
* // ...
* port.stop()
* job.cancel()
* ```
*
* # 生命周期
* Port 是 [JieliHomeServer] 的懒加载单例(`server.deviceRecordPort`),
* 与 server 共存亡,无需手动 [release]。若在 server 生命周期外独立使用则需调 [release]。
*/
class JieliDeviceRecordPort(
private val server: JieliHomeServer,
) {
companion object {
private const val TAG = "JieliDeviceRecordPort"
const val STREAM_UPLINK = "in.uplink"
const val STREAM_DOWNLINK = "in.downlink"
}
private val _audioFrames = MutableSharedFlow<DeviceRecordFrame>(
replay = 0,
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val _errors = MutableSharedFlow<DeviceRecordError>(
replay = 0,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** 录音 PCM 帧流。每帧包含 streamId 区分上行/下行。 */
val audioFrames: Flow<DeviceRecordFrame> = _audioFrames.asSharedFlow()
/** 错误流。 */
val errors: Flow<DeviceRecordError> = _errors.asSharedFlow()
val isRecording: Boolean get() = server.deviceRecordFeature.isRecording()
private val eventListener = object : JieliEventAdapter() {
override fun onDeviceRecordAudio(payload: Map<String, Any?>) {
val address = payload["address"] as? String ?: return
val streamId = payload["streamId"] as? String ?: return
val pcm = payload["pcm"] as? ByteArray ?: return
val sampleRate = (payload["sampleRate"] as? Number)?.toInt() ?: 16000
val channels = (payload["channels"] as? Number)?.toInt() ?: 1
val bitsPerSample = (payload["bitsPerSample"] as? Number)?.toInt() ?: 16
val tsMs = (payload["tsMs"] as? Number)?.toLong() ?: System.currentTimeMillis()
val frame = DeviceRecordFrame(
address = address,
streamId = streamId,
pcm = pcm,
sampleRate = sampleRate,
channels = channels,
bitsPerSample = bitsPerSample,
tsMs = tsMs,
)
if (!_audioFrames.tryEmit(frame)) {
Log.w(TAG, "audioFrames buffer overflow; dropped streamId=$streamId")
}
}
override fun onDeviceRecordError(payload: Map<String, Any?>) {
_errors.tryEmit(
DeviceRecordError(
address = payload["address"] as? String,
code = (payload["code"] as? Number)?.toInt() ?: 0,
message = payload["message"] as? String,
)
)
}
}
init {
server.addEventListener(eventListener)
}
/**
* 启动设备录音上行。若翻译功能正在运行,会先自动停止。
*
* @param address 目标设备 MAC;null 取当前已连设备
* @param sampleRate 采样率(Hz),默认 16000
*/
fun start(address: String? = null, sampleRate: Int = 16000): Result<Unit> {
if (server.translationFeature.isWorking()) server.translationFeature.stop()
val args = buildMap<String, Any?> {
if (address != null) put("address", address)
put("sampleRate", sampleRate)
}
return server.deviceRecordFeature.start(args)
}
/** 停止设备录音上行。幂等。 */
fun stop() {
server.deviceRecordFeature.stop()
}
/** 释放事件监听。Port 作为 server 单例使用时无需调用;独立使用时须在不再需要时调用。 */
fun release() {
server.removeEventListener(eventListener)
}
}

12
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt

@ -63,7 +63,13 @@ class TranslationFeature(
// + OPUS + 设备支持立体声方案时,自动升级到 MODE_CALL_TRANSLATION_WITH_STEREO
// (channel=2)。很多固件版本下 mono CALL_TRANSLATION 通路实际上不出帧,
// 真正出数据的是立体声方案——上下行 mix 成一路立体声 PCM 上推。
//
// 例外:AI 助理 / 纯上行录音 场景没有真实通话事件,固件不会下发 SCO_MIX,
// 升级到 stereo 反而会"零帧"。这种调用方应在 args 里传
// "bypassStereoUpgrade"=true,强制走 mono mode=5 + ALWAYS_RECORDING。
val bypassStereo = args["bypassStereoUpgrade"] == true
val effectiveModeId = if (
!bypassStereo &&
modeId == TranslationModeIds.MODE_CALL_TRANSLATION &&
(args["audioType"] == null ||
args["audioType"] == com.jieli.bluetooth.constant.Constants.AUDIO_TYPE_OPUS ||
@ -76,6 +82,12 @@ class TranslationFeature(
)
TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO
} else modeId
if (bypassStereo) {
android.util.Log.d(
"TranslationFeature",
"bypassStereoUpgrade=true → keep mono MODE_CALL_TRANSLATION (no real phone-call scenario)",
)
}
val handler = handlers[effectiveModeId]
?: return Result.failure(IllegalArgumentException("unknown modeId=$effectiveModeId"))

113
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt

@ -1,20 +1,46 @@
package com.jielihome.jielihome.feature.translation.mode
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.util.Log
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.constant.Constants
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback
import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback
import com.jielihome.jielihome.audio.PhoneMicCapture
import com.jielihome.jielihome.feature.translation.AudioFormat
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationModeIds
import com.jielihome.jielihome.feature.translation.TranslationStreams
import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi
/**
* MODE_RECORD —— 单向录音翻译。
* 输入:手机麦 → 推到外部
* 输出:speaker ← 外部回送 TTS PCM;当前不依赖耳机回送,直接交给宿主自行播放(可选)。
* MODE_RECORD —— 单向录音翻译(mode=1)。
*
* 注:本模式完全不动 RCSP,只用手机麦。耳机端不需要进入翻译模式。
* # 与 JL_HomeSdkDemo 对齐
* Demo 里 TranslationFragment 进入"录音"时会调 `TranslationImpl.enterMode(MODE_RECORD=1, ...)`,
* 耳机端 RCSP 状态机随之从 MODE_IDLE(0) 切到 MODE_RECORD(0x01),耳机可能播放提示音 / 切 LED 状态。
* 之前本类仅启动手机麦、**未发 enterMode**,导致耳机侧始终停在 IDLE(0),固件不认。
*
* 现在按 demo:
* 1. 构造 TranslationMode(mode=1, OPUS/PCM, ch=1, sampleRate)
* + setRecordingStrategy(STRATEGY_CUSTOM_RECORDING=0)
* 2. TranslationImpl.enterMode(mode, callback) 通知耳机进入 RECORD 模式
* 3. STRATEGY_CUSTOM_RECORDING 语义 → APP 用手机麦采音(与 demo 一致)
* 4. stop() 调 exitMode 让耳机回到 IDLE,避免状态机残留
*
* # 音频流向
* - 输入:手机麦 → PhoneMicCapture → pushFrame → 外部翻译服务
* - 输出:OUT_SPEAKER 由宿主自行播放(STRATEGY_CUSTOM_RECORDING 不走 RCSP 下发 TTS)
*
* # 与 DeviceRecordFeature 区分
* - 本类仍是 "手机麦 + 通知耳机进 RECORD 状态",和 demo 对齐
* - DeviceRecordFeature 用 `MODE_CALL_TRANSLATION + STRATEGY_DEVICE_ALWAYS_RECORDING`
* 直接从耳机麦抓 OPUS 上行帧,路径完全不同
*/
class RecordModeHandler(
private val context: Context,
@ -22,32 +48,105 @@ class RecordModeHandler(
bridge: TranslationAudioBridge,
) : BaseTranslationModeHandler(btManager, bridge) {
companion object {
private const val TAG = "RecordModeHandler"
}
override val modeId = TranslationModeIds.MODE_RECORD
override val inputStreams = listOf(TranslationStreams.IN_MIC)
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER)
private var mic: PhoneMicCapture? = null
@Volatile private var translationImpl: TranslationImpl? = null
@Volatile private var activeDevice: BluetoothDevice? = null
private val translationCallback = object : TranslationCallback {
override fun onModeChange(d: BluetoothDevice, m: TranslationMode) {
Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} strategy=${m.recordingStrategy}")
}
override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) {
// STRATEGY_CUSTOM_RECORDING 下耳机不上推音频;若固件仍推帧则忽略。
}
override fun onError(d: BluetoothDevice, code: Int, msg: String) {
Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg")
emitError(code, msg)
}
}
override fun start(args: Map<String, Any?>) {
if (working) return
val sampleRate = (args["sampleRate"] as? Int) ?: 16000
val frameMs = (args["frameDurationMs"] as? Int) ?: 20
// 1. 发 enterMode 让耳机进入 MODE_RECORD(0x01)状态,与 demo 对齐
val device = btManager.connectedDevice
if (device == null) {
emitError(-200, "no connected device; cannot enter MODE_RECORD")
return
}
val impl = TranslationImpl(btManager, NoOpAITranslationApi(), device)
if (!impl.isInit) {
runCatching { impl.destroy() }
emitError(-201, "RCSP not init for ${device.address}")
return
}
val sdkMode = TranslationMode(
TranslationMode.MODE_RECORD, // mode=1
Constants.AUDIO_TYPE_PCM, // RECORD 模式不需要 OPUS 编码,直接 PCM
1,
sampleRate,
).setRecordingStrategy(TranslationMode.STRATEGY_CUSTOM_RECORDING) // =0,APP 自己用手机麦采
impl.addTranslationCallback(translationCallback)
Log.i(TAG, "[APP->SDK] enterMode addr=${device.address} mode=${sdkMode.mode}(MODE_RECORD) type=${sdkMode.audioType} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(CUSTOM_RECORDING)")
impl.enterMode(sdkMode, translationCallback)
translationImpl = impl
activeDevice = device
// 2. 启动手机麦采集(STRATEGY_CUSTOM_RECORDING 语义)
mic = PhoneMicCapture(
context, sampleRate, frameMs,
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) },
onError = { code, msg -> emitError(code, msg) },
)
if (mic?.start() != true) return
if (mic?.start() != true) {
// 起麦失败:回滚耳机端模式,避免卡在 RECORD
exitHeadsetMode()
return
}
working = true
emitLog("RecordMode start sampleRate=$sampleRate")
emitLog("RecordMode start sampleRate=$sampleRate (headset in MODE_RECORD=1, phone mic active)")
}
override fun stop() {
if (!working) return
mic?.stop()
mic = null
exitHeadsetMode()
working = false
emitLog("RecordMode stop")
emitLog("RecordMode stop (headset back to MODE_IDLE=0)")
}
/** 让耳机 exitMode 回到 IDLE,并释放 TranslationImpl。幂等。 */
private fun exitHeadsetMode() {
val impl = translationImpl ?: return
val addr = activeDevice?.address
runCatching {
Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORD}")
impl.exitMode(object : OnRcspActionCallback<Int> {
override fun onSuccess(d: BluetoothDevice?, t: Int?) {
Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t")
}
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {
Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${err?.code} msg=${err?.message}")
}
})
}
runCatching { impl.removeTranslationCallback(translationCallback) }
runCatching { impl.destroy() }
translationImpl = null
activeDevice = null
}
override fun onTranslatedAudio(

102
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt

@ -1,17 +1,37 @@
package com.jielihome.jielihome.feature.translation.mode
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.util.Log
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.constant.Constants
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback
import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback
import com.jielihome.jielihome.audio.PhoneMicCapture
import com.jielihome.jielihome.feature.translation.AudioFormat
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationModeIds
import com.jielihome.jielihome.feature.translation.TranslationStreams
import com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi
/**
* MODE_RECORDING_TRANSLATION —— 流式录音翻译。等同于 RecordMode 的实时版,差别只在语义:
* 调用方实现实时 STT/翻译流水线、不缓存整段录音再发。
* MODE_RECORDING_TRANSLATION —— 流式录音翻译(mode=2)。等同于 RecordMode 的实时版,
* 差别只在语义:调用方实现实时 STT/翻译流水线、不缓存整段录音再发。
*
* # 与 JL_HomeSdkDemo 对齐
* Demo 会调 `TranslationImpl.enterMode(MODE_RECORDING_TRANSLATION=2, ...)` 让耳机进入状态机。
* 之前本类仅启动手机麦、**未发 enterMode**,耳机侧始终停在 MODE_IDLE(0),固件不认。
*
* 现在按 demo:
* 1. 构造 TranslationMode(mode=2, PCM, ch=1, sampleRate)
* + setRecordingStrategy(STRATEGY_CUSTOM_RECORDING=0)
* 2. TranslationImpl.enterMode 通知耳机进入录音翻译模式
* 3. APP 用手机麦采音并 pushFrame(与 demo 一致)
* 4. stop() 调 exitMode 让耳机回到 IDLE
*/
class RecordingTranslationModeHandler(
private val context: Context,
@ -19,31 +39,103 @@ class RecordingTranslationModeHandler(
bridge: TranslationAudioBridge,
) : BaseTranslationModeHandler(btManager, bridge) {
companion object {
private const val TAG = "RecTransModeHandler"
}
override val modeId = TranslationModeIds.MODE_RECORDING_TRANSLATION
override val inputStreams = listOf(TranslationStreams.IN_MIC)
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER)
private var mic: PhoneMicCapture? = null
@Volatile private var translationImpl: TranslationImpl? = null
@Volatile private var activeDevice: BluetoothDevice? = null
private val translationCallback = object : TranslationCallback {
override fun onModeChange(d: BluetoothDevice, m: TranslationMode) {
Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} strategy=${m.recordingStrategy}")
}
override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) {
// STRATEGY_CUSTOM_RECORDING 下耳机不上推音频;若固件仍推帧则忽略。
}
override fun onError(d: BluetoothDevice, code: Int, msg: String) {
Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg")
emitError(code, msg)
}
}
override fun start(args: Map<String, Any?>) {
if (working) return
val sampleRate = (args["sampleRate"] as? Int) ?: 16000
// 1. 发 enterMode 让耳机进入 MODE_RECORDING_TRANSLATION(0x02)状态,与 demo 对齐
val device = btManager.connectedDevice
if (device == null) {
emitError(-200, "no connected device; cannot enter MODE_RECORDING_TRANSLATION")
return
}
val impl = TranslationImpl(btManager, NoOpAITranslationApi(), device)
if (!impl.isInit) {
runCatching { impl.destroy() }
emitError(-201, "RCSP not init for ${device.address}")
return
}
val sdkMode = TranslationMode(
TranslationMode.MODE_RECORDING_TRANSLATION, // mode=2
Constants.AUDIO_TYPE_PCM,
1,
sampleRate,
).setRecordingStrategy(TranslationMode.STRATEGY_CUSTOM_RECORDING)
impl.addTranslationCallback(translationCallback)
Log.i(TAG, "[APP->SDK] enterMode addr=${device.address} mode=${sdkMode.mode}(MODE_RECORDING_TRANSLATION) type=${sdkMode.audioType} sr=${sdkMode.sampleRate} strategy=${sdkMode.recordingStrategy}(CUSTOM_RECORDING)")
impl.enterMode(sdkMode, translationCallback)
translationImpl = impl
activeDevice = device
// 2. 启动手机麦采集
mic = PhoneMicCapture(
context, sampleRate,
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) },
onError = { code, msg -> emitError(code, msg) },
)
if (mic?.start() != true) return
if (mic?.start() != true) {
exitHeadsetMode()
return
}
working = true
emitLog("RecordingTranslation start sampleRate=$sampleRate")
emitLog("RecordingTranslation start sampleRate=$sampleRate (headset in MODE_RECORDING_TRANSLATION=2)")
}
override fun stop() {
if (!working) return
mic?.stop()
mic = null
exitHeadsetMode()
working = false
emitLog("RecordingTranslation stop")
emitLog("RecordingTranslation stop (headset back to MODE_IDLE=0)")
}
/** 让耳机 exitMode 回到 IDLE,并释放 TranslationImpl。幂等。 */
private fun exitHeadsetMode() {
val impl = translationImpl ?: return
val addr = activeDevice?.address
runCatching {
Log.i(TAG, "[APP->SDK] exitMode addr=$addr mode=${TranslationMode.MODE_RECORDING_TRANSLATION}")
impl.exitMode(object : OnRcspActionCallback<Int> {
override fun onSuccess(d: BluetoothDevice?, t: Int?) {
Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t")
}
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {
Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${err?.code} msg=${err?.message}")
}
})
}
runCatching { impl.removeTranslationCallback(translationCallback) }
runCatching { impl.destroy() }
translationImpl = null
activeDevice = null
}
override fun onTranslatedAudio(

46
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt

@ -94,17 +94,31 @@ class RcspTranslationRuntime(
@Volatile private var rxTypeMismatch = 0L
@Volatile private var rxNullPayload = 0L
@Volatile private var rxLastReportMs = 0L
/** 首帧 flag:首次收到任意 AudioData 时立即打一条 INFO 级别日志(不受 1s 节流影响)。 */
@Volatile private var rxFirstLogged = false
/** 首帧(按 source 维度):uplink / downlink / mix 分别各打一条,确认链路通畅。 */
private val rxFirstPerSource = java.util.concurrent.ConcurrentHashMap<Int, Boolean>()
private val translationCallback = object : TranslationCallback {
override fun onModeChange(d: BluetoothDevice, m: TranslationMode) {
Log.d(TAG, "TranslationCallback.onModeChange: mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} ch=${m.channel}")
Log.i(TAG, "[SDK<-DEV] onModeChange addr=${d.address} mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} ch=${m.channel} strategy=${m.recordingStrategy}")
}
override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) {
rxCount++
val payloadSize = data.audioData?.size ?: 0
// 首帧(总):立即打点,避免节流导致"完全静默"误判
if (!rxFirstLogged) {
rxFirstLogged = true
Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST FRAME addr=${d.address} source=${data.source} type=${data.type} size=$payloadSize expect.type=${mode.audioType}")
}
// 首帧(按 source):分别打点,快速判断上行/下行是否都在推
if (rxFirstPerSource.putIfAbsent(data.source, true) == null) {
Log.i(TAG, "[SDK<-DEV] onReceiveAudioData FIRST source=${data.source} type=${data.type} size=$payloadSize")
}
val now = System.currentTimeMillis()
if (now - rxLastReportMs >= 1000L) {
Log.d(TAG, "RX stats (last 1s): total=$rxCount typeMismatch=$rxTypeMismatch nullPayload=$rxNullPayload expect.type=${mode.audioType} last.source=${data.source} last.type=${data.type} payloadSize=${data.audioData?.size ?: 0}")
Log.d(TAG, "[SDK<-DEV] RX stats (last 1s): total=$rxCount typeMismatch=$rxTypeMismatch nullPayload=$rxNullPayload expect.type=${mode.audioType} last.source=${data.source} last.type=${data.type} payloadSize=$payloadSize")
rxCount = 0; rxTypeMismatch = 0; rxNullPayload = 0; rxLastReportMs = now
}
if (data.type != mode.audioType) {
@ -124,11 +138,12 @@ class RcspTranslationRuntime(
AudioData.SOURCE_E_SCO_UP_LINK -> upDecoder?.feedEncoded(payload)
AudioData.SOURCE_E_SCO_DOWN_LINK -> downDecoder?.feedEncoded(payload)
AudioData.SOURCE_E_SCO_MIX -> stereoDecoder?.feedEncoded(payload)
else -> Log.w(TAG, "[SDK<-DEV] onReceiveAudioData unknown source=${data.source} dropped")
}
}
override fun onError(d: BluetoothDevice, code: Int, msg: String) {
Log.e(TAG, "TranslationCallback.onError code=$code msg=$msg")
Log.e(TAG, "[SDK<-DEV] TranslationCallback.onError code=$code msg=$msg")
this@RcspTranslationRuntime.onError(code, msg)
}
}
@ -142,10 +157,11 @@ class RcspTranslationRuntime(
/**
* 周期性 flush 间隔:每隔此毫秒数巡检一次 buffer,**只要非空就 flush**。
*
* 实测:低于 1s(试过 200ms)耳机端 RCSP 解码器会被频繁 reset 直至死机,
* 因此周期上限只能保持 1s。降低段间延迟改走"首段优先 flush"路线(B)。
* 历史实测:200ms 时耳机端 RCSP 解码器会被频繁 reset 直至死机;1s 长期稳定。
* 当前实验:500ms —— 通话翻译"播报中间断一下"的优化尝试。需真机长时压测
* (≥10 分钟连续讲话)确认无解码器 reset 风暴,否则回退到 1_000L。
*/
private const val PERIODIC_FLUSH_MS = 1_000L
private const val PERIODIC_FLUSH_MS = 1000L
/**
* 首段优先 flush 阈值(路线 B):每个 utterance 的**首段**达到此毫秒数对应字节量
* 就立即 flush,不等周期。让用户开口到对方听到的延迟尽可能小(< 200ms)。
@ -274,7 +290,7 @@ class RcspTranslationRuntime(
stereoDecoder?.start()
translationImpl.addTranslationCallback(translationCallback)
Log.d(TAG, "enterMode: mode=${mode.mode} type=${mode.audioType} sr=${mode.sampleRate} ch=${mode.channel} strategy=${mode.recordingStrategy} (waiting for onModeChange/onReceiveAudioData)")
Log.i(TAG, "[APP->SDK] enterMode addr=${device.address} mode=${mode.mode} type=${mode.audioType} sr=${mode.sampleRate} ch=${mode.channel} strategy=${mode.recordingStrategy} (waiting for onModeChange/onReceiveAudioData)")
translationImpl.enterMode(mode, translationCallback)
// OPUS 模式启动周期性 flush 巡检:每 [PERIODIC_FLUSH_MS] 切一次缓冲。
@ -368,7 +384,11 @@ class RcspTranslationRuntime(
buf.reset()
return@synchronized if (out.isEmpty()) null else out
}
// 2) 首段优先:仅当 leg 处于 pending 状态(首次 utterance 起始或 isFinal 重置后)。
// 2) 首段优先(路线 B):当前**实验中临时屏蔽**——为观察 500ms 周期 flush 的纯效果。
// 屏蔽后所有 flush 走两条路:a) isFinal=true 短路;b) PERIODIC_FLUSH_MS 周期。
// 实验完成后若需恢复,把下面 if (false) 改回 if (pending) 即可。
@Suppress("ConstantConditionIf")
if (false) {
val pending = firstSegmentPending.getOrDefault(outputStreamId, true)
if (pending) {
val firstFlushBytes = (sampleRateHz * 2 * FIRST_SEGMENT_FLUSH_MS / 1000L).toInt()
@ -380,6 +400,7 @@ class RcspTranslationRuntime(
return@synchronized if (out.isEmpty()) null else out
}
}
}
null
}
if (pendingFlush != null) {
@ -431,9 +452,14 @@ class RcspTranslationRuntime(
runCatching { flushWatcher?.shutdownNow() }
flushWatcher = null
runCatching {
Log.i(TAG, "[APP->SDK] exitMode addr=${device.address} mode=${mode.mode}")
translationImpl.exitMode(object : OnRcspActionCallback<Int> {
override fun onSuccess(d: BluetoothDevice?, t: Int?) {}
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {}
override fun onSuccess(d: BluetoothDevice?, t: Int?) {
Log.i(TAG, "[APP->SDK] exitMode onSuccess addr=${d?.address} t=$t")
}
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {
Log.w(TAG, "[APP->SDK] exitMode onError addr=${d?.address} code=${err?.code} msg=${err?.message}")
}
})
}
runCatching { translationImpl.removeTranslationCallback(translationCallback) }

62
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt

@ -109,6 +109,22 @@ class JieliNativeDevicePlugin(
}
override fun onDeviceFound(payload: Map<String, Any?>) {
val meta = mutableMapOf<String, Any?>(
"edrAddr" to payload["edrAddr"],
"deviceType" to payload["deviceType"],
"connectWay" to payload["connectWay"],
)
// 透传 ScanFeature 解析出的广播详情,供 UI 弹窗展示。
listOf(
"rawAdv",
"advRecords",
"advFlags",
"manufacturerCompanyId",
"manufacturerData",
"serviceUuids",
).forEach { key ->
if (payload.containsKey(key)) meta[key] = payload[key]
}
emit(DevicePluginEvent(
type = DevicePluginEventType.DEVICE_DISCOVERED,
discovered = DiscoveredDevice(
@ -116,11 +132,7 @@ class JieliNativeDevicePlugin(
name = (payload["name"] as? String) ?: "",
rssi = (payload["rssi"] as? Number)?.toInt(),
vendor = VENDOR_KEY,
metadata = mapOf(
"edrAddr" to payload["edrAddr"],
"deviceType" to payload["deviceType"],
"connectWay" to payload["connectWay"],
),
metadata = meta,
),
))
}
@ -128,6 +140,7 @@ class JieliNativeDevicePlugin(
override fun onConnectionState(address: String, state: Int) {
val session = _activeSession ?: return
if (session.deviceId != address) return
Log.d(TAG, "onConnectionState addr=$address state=$state")
when (state) {
2 -> session.setState(DeviceConnectionState.CONNECTING)
1 -> session.setState(DeviceConnectionState.LINK_CONNECTED)
@ -152,6 +165,7 @@ class JieliNativeDevicePlugin(
override fun onRcspInit(address: String, code: Int) {
val session = _activeSession ?: return
if (session.deviceId != address) return
Log.d(TAG, "onRcspInit addr=$address code=$code")
if (code == 0) {
session.setState(DeviceConnectionState.READY)
val pending = _pendingConnect
@ -200,6 +214,25 @@ class JieliNativeDevicePlugin(
"translation: ${payload["code"]} ${payload["message"] ?: ""}",
)
}
override fun onDeviceRecordStart(payload: Map<String, Any?>) {
_activeSession?.emitFeature("jieli.deviceRecord.start", payload)
}
override fun onDeviceRecordAudio(payload: Map<String, Any?>) {
_activeSession?.emitFeature("jieli.deviceRecord.audio", payload)
}
override fun onDeviceRecordStop(payload: Map<String, Any?>) {
_activeSession?.emitFeature("jieli.deviceRecord.stop", payload)
}
override fun onDeviceRecordError(payload: Map<String, Any?>) {
_activeSession?.emitError(
"device.feature_failed",
"deviceRecord: ${payload["code"]} ${payload["message"] ?: ""}",
)
}
}
// ─── NativeDevicePlugin 接口 ──────────────────────────────────────────
@ -223,7 +256,13 @@ class JieliNativeDevicePlugin(
override fun startScan(filter: DeviceScanFilter?, timeoutMs: Long?) {
requireInit()
val ms = (timeoutMs ?: 30_000L).toInt()
server.scanFeature.startScan(ms).onFailure {
val nameList = filter?.nameList?.filter { it.isNotEmpty() }
?.takeIf { it.isNotEmpty() }
?: filter?.namePrefix?.takeIf { it.isNotEmpty() }?.let { listOf(it) }
?: emptyList()
val uuidList = filter?.serviceUuids?.filter { it.isNotEmpty() } ?: emptyList()
val skipUnnamed = filter?.skipUnnamed ?: true
server.scanFeature.startScan(ms, nameList, uuidList, skipUnnamed).onFailure {
throw DeviceException("device.scan_failed", it.message, it)
}
}
@ -252,6 +291,7 @@ class JieliNativeDevicePlugin(
deviceId = deviceId,
initialName = (extra["name"] as? String) ?: "",
capabilities = capabilities,
otaCacheDir = context.cacheDir,
)
_activeSession = session
@ -259,11 +299,15 @@ class JieliNativeDevicePlugin(
_pendingConnect = future
_pendingConnectAddress = deviceId
val edrAddress = extra["edrAddr"] as? String
val deviceType = (extra["deviceType"] as? Number)?.toInt() ?: 0
val connectWay = (extra["connectWay"] as? Number)?.toInt() ?: 0
Log.d(TAG, "connect deviceId=$deviceId edr=$edrAddress dt=$deviceType cw=$connectWay")
server.connectFeature.connect(
bleAddress = deviceId,
edrAddress = extra["edrAddr"] as? String,
deviceType = (extra["deviceType"] as? Number)?.toInt() ?: 0,
connectWay = (extra["connectWay"] as? Number)?.toInt() ?: 0,
edrAddress = edrAddress,
deviceType = deviceType,
connectWay = connectWay,
).onFailure {
future.completeExceptionally(
DeviceException(DeviceErrorCode.CONNECT_FAILED, it.message, it)

56
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt

@ -1,6 +1,7 @@
package com.jielihome.jielihome.integration
import android.util.Log
import com.aiagent.device_plugin_interface.DeviceAssistantPort
import com.aiagent.device_plugin_interface.DeviceCallTranslationPort
import com.aiagent.device_plugin_interface.DeviceCapability
import com.aiagent.device_plugin_interface.DeviceConnectionState
@ -8,6 +9,7 @@ import com.aiagent.device_plugin_interface.DeviceErrorCode
import com.aiagent.device_plugin_interface.DeviceException
import com.aiagent.device_plugin_interface.DeviceFeatureEvent
import com.aiagent.device_plugin_interface.DeviceInfo
import com.aiagent.device_plugin_interface.DeviceOtaPort
import com.aiagent.device_plugin_interface.DeviceSessionEvent
import com.aiagent.device_plugin_interface.DeviceSessionEventType
import com.aiagent.device_plugin_interface.NativeDeviceSession
@ -17,6 +19,7 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.io.File
/**
* 杰理 [NativeDeviceSession] 实现 —— 包装 [JieliHomeServer] 的连接快照与事件。
@ -32,6 +35,7 @@ class JieliNativeDeviceSession internal constructor(
override val deviceId: String,
initialName: String,
override val capabilities: Set<DeviceCapability>,
private val otaCacheDir: File,
) : NativeDeviceSession {
companion object {
@ -59,6 +63,8 @@ class JieliNativeDeviceSession internal constructor(
// ─── 由 plugin 在事件回调中调用 ─────────────────────────────────────────
@Volatile private var _otaPort: JieliOtaPort? = null
internal fun setState(s: DeviceConnectionState) {
if (_state == s) return
_state = s
@ -69,6 +75,9 @@ class JieliNativeDeviceSession internal constructor(
))
if (s == DeviceConnectionState.DISCONNECTED) {
_disposed = true
// 端口随 session 收尾:补一帧 FAILED + 解订阅,UI 才能跳出 OTA 锁定。
runCatching { _otaPort?.shutdown(DeviceErrorCode.DISCONNECTED_REMOTE) }
_otaPort = null
}
}
@ -205,6 +214,31 @@ class JieliNativeDeviceSession internal constructor(
mapOf("ok" to true)
}
// ── 设备录音 ───────────────────────────────────────────────────────
"jieli.deviceRecord.start" -> {
if (server.translationFeature.isWorking()) server.translationFeature.stop()
val sampleRate = (args["sampleRate"] as? Number)?.toInt() ?: 16000
server.deviceRecordFeature.start(
mapOf("address" to deviceId, "sampleRate" to sampleRate)
).getOrElse {
throw DeviceException(
"device.feature_failed",
"deviceRecord.start failed: ${it.message}",
it,
)
}
mapOf("ok" to true)
}
"jieli.deviceRecord.stop" -> {
server.deviceRecordFeature.stop()
mapOf("ok" to true)
}
"jieli.deviceRecord.status" -> mapOf(
"recording" to server.deviceRecordFeature.isRecording()
)
"jieli.cmd.send" -> {
val opCode = (args["opCode"] as? Number)?.toInt()
?: throw DeviceException(
@ -251,6 +285,28 @@ class JieliNativeDeviceSession internal constructor(
override fun callTranslationPort(): DeviceCallTranslationPort = server.callTranslationPort
override fun assistantPort(): DeviceAssistantPort = server.assistantPort
override fun otaPort(): DeviceOtaPort? {
if (DeviceCapability.OTA !in capabilities) return null
// 懒创建:未 ready 时也允许构造(订阅事件流),但 start() 内部会再校验。
var port = _otaPort
if (port == null) {
synchronized(this) {
port = _otaPort
if (port == null && !_disposed) {
port = JieliOtaPort(
server = server,
deviceId = deviceId,
cacheDir = File(otaCacheDir, "ota"),
)
_otaPort = port
}
}
}
return port
}
override fun disconnect() {
if (_disposed) return
setState(DeviceConnectionState.DISCONNECTING)

280
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliOtaPort.kt

@ -0,0 +1,280 @@
package com.jielihome.jielihome.integration
import android.util.Log
import com.aiagent.device_plugin_interface.DeviceErrorCode
import com.aiagent.device_plugin_interface.DeviceException
import com.aiagent.device_plugin_interface.DeviceOtaPort
import com.aiagent.device_plugin_interface.DeviceOtaProgress
import com.aiagent.device_plugin_interface.DeviceOtaRequest
import com.aiagent.device_plugin_interface.DeviceOtaState
import com.jielihome.jielihome.api.JieliEventAdapter
import com.jielihome.jielihome.api.JieliEventListener
import com.jielihome.jielihome.core.JieliHomeServer
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
/**
* 杰理 [DeviceOtaPort] 实现 —— 桥接 [com.jielihome.jielihome.feature.ota.OtaFeature]。
*
* 不重写 OtaFeature 的状态机,只把它派的 `otaState` / `otaError` 事件翻译成
* [DeviceOtaProgress] 推到 [progressStream]。
*
* 请求子类支持矩阵:
* - [DeviceOtaRequest.File] ✓ 直接 path 传给 OtaFeature
* - [DeviceOtaRequest.Bytes] ✓ 落盘到 cacheDir/ota_<ts>.ufw 再走 path
* - [DeviceOtaRequest.Url] ✗ 杰理 SDK 无 HTTP 通道,抛 NOT_SUPPORTED
* - [DeviceOtaRequest.Vendor] ✓ payload['filePath'] + payload['fileFlag'] 透传
*
* 端口生命周期:
* - session disconnect 时由 [JieliNativeDeviceSession] 调 [shutdown] —— 主动 cancel +
* 补一帧 FAILED(disconnected_remote) 让上层取消订阅 / 跳出锁定 UI。
*/
class JieliOtaPort internal constructor(
private val server: JieliHomeServer,
private val deviceId: String,
private val cacheDir: File,
) : DeviceOtaPort {
companion object {
private const val TAG = "JieliOtaPort"
}
private val _running = AtomicBoolean(false)
@Volatile private var _totalBytes: Long = 0
@Volatile private var _tempFile: File? = null
@Volatile private var _disposed = false
private val _progress = MutableSharedFlow<DeviceOtaProgress>(
replay = 1,
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val progressStream: Flow<DeviceOtaProgress> = _progress.asSharedFlow()
override val isRunning: Boolean get() = _running.get() || server.otaFeature.isRunning()
private val listener: JieliEventListener = object : JieliEventAdapter() {
override fun onOtaState(payload: Map<String, Any?>) {
val stateName = payload["state"] as? String ?: return
val state = parseState(stateName) ?: return
val sent = (payload["sent"] as? Number)?.toLong() ?: 0L
val total = (payload["total"] as? Number)?.toLong() ?: _totalBytes
val percent = (payload["percent"] as? Number)?.toInt() ?: -1
val tsMs = (payload["tsMs"] as? Number)?.toLong() ?: System.currentTimeMillis()
emit(DeviceOtaProgress(
state = state,
sentBytes = sent,
totalBytes = total,
percent = percent,
tsMs = tsMs,
))
if (state == DeviceOtaState.DONE ||
state == DeviceOtaState.CANCELLED ||
state == DeviceOtaState.FAILED) {
finalize()
}
}
override fun onOtaError(payload: Map<String, Any?>) {
// OtaFeature 用整数 code;映射到 device.* 命名空间。
val rawCode = (payload["code"] as? Number)?.toInt() ?: -1
val msg = payload["message"] as? String
val mapped = mapErrorCode(rawCode)
emit(DeviceOtaProgress(
state = DeviceOtaState.FAILED,
sentBytes = -1,
totalBytes = _totalBytes,
percent = -1,
tsMs = System.currentTimeMillis(),
errorCode = mapped,
errorMessage = msg,
))
}
}
init {
server.addEventListener(listener)
}
// ─── DeviceOtaPort ────────────────────────────────────────────────────
@Synchronized
override fun start(request: DeviceOtaRequest) {
if (_disposed) throw DeviceException(DeviceErrorCode.NO_ACTIVE_SESSION)
if (!_running.compareAndSet(false, true) || server.otaFeature.isRunning()) {
_running.set(server.otaFeature.isRunning() || _running.get())
throw DeviceException(DeviceErrorCode.OTA_BUSY, "ota already running")
}
val (path, fileFlag) = try {
resolveFirmware(request)
} catch (e: DeviceException) {
_running.set(false)
throw e
} catch (t: Throwable) {
_running.set(false)
throw DeviceException(DeviceErrorCode.OTA_FILE_INVALID, t.message ?: "firmware error", t)
}
_totalBytes = File(path).length().coerceAtLeast(0L)
// 立即派一帧 INQUIRING,UI 不必等 OtaFeature 第一次回调。
emit(DeviceOtaProgress(
state = DeviceOtaState.INQUIRING,
sentBytes = 0,
totalBytes = _totalBytes,
percent = 0,
tsMs = System.currentTimeMillis(),
))
try {
server.otaFeature.start(
address = deviceId,
firmwareFilePath = path,
blockSize = request.blockSize ?: 512,
fileFlagBytes = fileFlag,
)
} catch (t: Throwable) {
_running.set(false)
cleanupTemp()
throw DeviceException(DeviceErrorCode.OTA_TRANSFER_FAILED, t.message, t)
}
}
override fun cancel() {
if (!_running.get() && !server.otaFeature.isRunning()) return
server.otaFeature.cancel()
// 状态变更通过 onOtaState(CANCELLED) 回到 finalize。
}
/** 由 [JieliNativeDeviceSession] 在 disconnect 时调用:补 FAILED 帧并解订阅。 */
fun shutdown(reason: String = DeviceErrorCode.DISCONNECTED_REMOTE) {
if (_disposed) return
_disposed = true
if (_running.get()) {
runCatching { server.otaFeature.cancel() }
emit(DeviceOtaProgress(
state = DeviceOtaState.FAILED,
sentBytes = -1,
totalBytes = _totalBytes,
percent = -1,
tsMs = System.currentTimeMillis(),
errorCode = reason,
errorMessage = "session terminated during ota",
))
}
runCatching { server.removeEventListener(listener) }
cleanupTemp()
_running.set(false)
}
// ─── 内部 ──────────────────────────────────────────────────────────────
private fun resolveFirmware(req: DeviceOtaRequest): Pair<String, ByteArray> {
val flag = ByteArray(0) // OtaFeature 默认 fileFlag
return when (req) {
is DeviceOtaRequest.File -> {
val f = File(req.filePath)
if (!f.isFile || f.length() <= 0) {
throw DeviceException(
DeviceErrorCode.OTA_FILE_INVALID,
"firmware file invalid: ${req.filePath}",
)
}
req.filePath to flag
}
is DeviceOtaRequest.Bytes -> {
if (req.bytes.isEmpty()) {
throw DeviceException(
DeviceErrorCode.OTA_FILE_INVALID,
"ota bytes empty",
)
}
if (!cacheDir.isDirectory && !cacheDir.mkdirs()) {
throw DeviceException(
DeviceErrorCode.OTA_FILE_INVALID,
"cannot create ota cache dir: ${cacheDir.absolutePath}",
)
}
val tmp = File(cacheDir, "ota_${System.currentTimeMillis()}.ufw")
tmp.writeBytes(req.bytes)
_tempFile = tmp
tmp.absolutePath to flag
}
is DeviceOtaRequest.Url -> {
throw DeviceException(
DeviceErrorCode.NOT_SUPPORTED,
"jieli ota does not support remote url; download in app and use File request",
)
}
is DeviceOtaRequest.Vendor -> {
if (req.vendorKey != "jieli") {
throw DeviceException(
DeviceErrorCode.INVALID_ARGUMENT,
"vendor mismatch: expected 'jieli', got '${req.vendorKey}'",
)
}
val path = req.payload["filePath"] as? String
?: throw DeviceException(
DeviceErrorCode.INVALID_ARGUMENT,
"jieli ota.vendor: payload['filePath'] required",
)
val f = File(path)
if (!f.isFile || f.length() <= 0) {
throw DeviceException(
DeviceErrorCode.OTA_FILE_INVALID,
"firmware file invalid: $path",
)
}
@Suppress("UNCHECKED_CAST")
val customFlag = (req.payload["fileFlag"] as? ByteArray)
?: (req.payload["fileFlag"] as? List<Number>)?.map { it.toByte() }?.toByteArray()
?: flag
path to customFlag
}
}
}
private fun finalize() {
_running.set(false)
cleanupTemp()
}
private fun cleanupTemp() {
_tempFile?.let { runCatching { it.delete() } }
_tempFile = null
}
private fun emit(p: DeviceOtaProgress) {
if (!_progress.tryEmit(p)) Log.w(TAG, "progress buffer full; dropped ${p.state}")
}
private fun parseState(name: String): DeviceOtaState? = when (name) {
"IDLE" -> DeviceOtaState.IDLE
"INQUIRING" -> DeviceOtaState.INQUIRING
"NOTIFYING_SIZE" -> DeviceOtaState.NOTIFYING_SIZE
"ENTERING" -> DeviceOtaState.ENTERING
"TRANSFERRING" -> DeviceOtaState.TRANSFERRING
"VERIFYING" -> DeviceOtaState.VERIFYING
"REBOOTING" -> DeviceOtaState.REBOOTING
"DONE" -> DeviceOtaState.DONE
"FAILED" -> DeviceOtaState.FAILED
"CANCELLED" -> DeviceOtaState.CANCELLED
else -> null
}
/** OtaFeature 的负数错误码映射到设备域命名空间。 */
private fun mapErrorCode(raw: Int): String = when (raw) {
-1 -> DeviceErrorCode.OTA_BUSY
-2 -> DeviceErrorCode.NO_ACTIVE_SESSION
-3 -> DeviceErrorCode.OTA_FILE_INVALID
-100 -> DeviceErrorCode.OTA_TRANSFER_FAILED
else -> DeviceErrorCode.OTA_TRANSFER_FAILED
}
}

268
local_plugins/device_jieli/lib/device_jieli.dart

@ -17,12 +17,12 @@ class JieliDevice {
this.deviceType,
this.connectWay,
this.rssi,
this.leftBattery,
this.rightBattery,
this.caseBattery,
this.leftCharging,
this.rightCharging,
this.caseCharging,
this.rawAdv,
this.advFlags,
this.manufacturerCompanyId,
this.manufacturerData,
this.serviceUuids = const [],
this.advRecords = const [],
});
final String name;
@ -31,13 +31,24 @@ class JieliDevice {
final int? deviceType;
final int? connectWay;
final int? rssi;
// 来自 BleScanMessage,扫到时就有;0 表示该位置未上报(如单耳设备右耳为 0)
final int? leftBattery;
final int? rightBattery;
final int? caseBattery;
final bool? leftCharging;
final bool? rightCharging;
final bool? caseCharging;
/// 原始广播包 hex(不含 "0x" 前缀)。
final String? rawAdv;
/// AD Type 0x01 Flags 字节值。
final int? advFlags;
/// AD Type 0xFF 厂商数据的前两字节(小端)解析出的 Company ID。
final int? manufacturerCompanyId;
/// AD Type 0xFF 厂商数据去掉 Company ID 后剩余字节的 hex(不含 "0x")。
final String? manufacturerData;
/// ScanRecord.serviceUuids 列表(大写字符串)。
final List<String> serviceUuids;
/// 按 BLE Core Spec §11 解析的每条 AD 记录:{len, type, data}。
final List<Map<String, String>> advRecords;
factory JieliDevice.fromMap(Map<dynamic, dynamic> m) => JieliDevice(
name: (m['name'] as String?) ?? '',
@ -46,19 +57,23 @@ class JieliDevice {
deviceType: m['deviceType'] as int?,
connectWay: m['connectWay'] as int?,
rssi: m['rssi'] as int?,
leftBattery: (m['leftBattery'] as num?)?.toInt(),
rightBattery: (m['rightBattery'] as num?)?.toInt(),
caseBattery: (m['caseBattery'] as num?)?.toInt(),
leftCharging: m['leftCharging'] as bool?,
rightCharging: m['rightCharging'] as bool?,
caseCharging: m['caseCharging'] as bool?,
rawAdv: m['rawAdv'] as String?,
advFlags: m['advFlags'] as int?,
manufacturerCompanyId: m['manufacturerCompanyId'] as int?,
manufacturerData: m['manufacturerData'] as String?,
serviceUuids:
(m['serviceUuids'] as List?)?.cast<String>() ?? const [],
advRecords: (m['advRecords'] as List?)
?.whereType<Map>()
.map((e) => e.map(
(k, v) => MapEntry(k.toString(), v?.toString() ?? ''),
))
.toList() ??
const [],
);
@override
String toString() => 'JieliDevice($name, $address, rssi=$rssi, '
'L=$leftBattery${leftCharging == true ? "+" : ""} '
'R=$rightBattery${rightCharging == true ? "+" : ""} '
'Case=$caseBattery${caseCharging == true ? "+" : ""})';
String toString() => 'JieliDevice($name, $address, rssi=$rssi)';
}
abstract class JieliEvent {
@ -282,6 +297,103 @@ class SpeechStopReason {
static const int stop = 1;
}
// ───── 设备录音事件 ─────
/// 上行/下行通道 ID 常量(与 Kotlin DeviceRecordFeature 保持一致)
class DeviceRecordStreams {
static const String inUplink = 'in.uplink';
static const String inDownlink = 'in.downlink';
}
class DeviceRecordStartEvent extends JieliEvent {
final String? address;
final int sampleRate;
final int tsMs;
const DeviceRecordStartEvent({
this.address,
required this.sampleRate,
required this.tsMs,
});
}
class DeviceRecordAudioEvent extends JieliEvent {
final String? address;
/// [DeviceRecordStreams.inUplink] 或 [DeviceRecordStreams.inDownlink]
final String streamId;
final int sampleRate;
final int channels;
final int bitsPerSample;
final int tsMs;
/// 16bit signed PCM 字节流(小端)
final Uint8List pcm;
const DeviceRecordAudioEvent({
this.address,
required this.streamId,
required this.sampleRate,
required this.channels,
required this.bitsPerSample,
required this.tsMs,
required this.pcm,
});
}
class DeviceRecordStopEvent extends JieliEvent {
final String? address;
final int tsMs;
const DeviceRecordStopEvent({this.address, required this.tsMs});
}
class DeviceRecordErrorEvent extends JieliEvent {
final String? address;
final int code;
final String? message;
const DeviceRecordErrorEvent({this.address, required this.code, this.message});
}
// ───── AI 助理通路事件(JieliAssistantPort:MODE_RECORD + DEVICE_ALWAYS_RECORDING)─────
// 与 SpeechXxxEvent 互斥;走 RCSP 翻译模式状态机,耳机端 OPUS → 16k PCM 自动持续上推。
class AssistantStartEvent extends JieliEvent {
final int sampleRate;
final int tsMs;
const AssistantStartEvent({required this.sampleRate, required this.tsMs});
}
class AssistantAudioEvent extends JieliEvent {
/// 'pcm16'
final String encoding;
final int sampleRate;
final int channels;
final int bitsPerSample;
final int sequence;
final int tsMs;
/// 16-bit signed PCM, mono, 16kHz, 20ms 帧
final Uint8List pcm;
const AssistantAudioEvent({
required this.encoding,
required this.sampleRate,
required this.channels,
required this.bitsPerSample,
required this.sequence,
required this.tsMs,
required this.pcm,
});
}
class AssistantEndEvent extends JieliEvent {
final int tsMs;
const AssistantEndEvent({required this.tsMs});
}
class AssistantErrorEvent extends JieliEvent {
final String code;
final String? message;
const AssistantErrorEvent(this.code, this.message);
}
// ───── OTA 事件 ─────
enum OtaState {
@ -405,6 +517,57 @@ class Jielihome {
(raw['code'] as int?) ?? 0,
raw['message'] as String?,
);
case 'assistantStart':
return AssistantStartEvent(
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
);
case 'assistantAudio':
return AssistantAudioEvent(
encoding: (raw['encoding'] as String?) ?? 'pcm16',
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
channels: (raw['channels'] as int?) ?? 1,
bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16,
sequence: (raw['sequence'] as num?)?.toInt() ?? 0,
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
pcm: raw['pcm'] as Uint8List,
);
case 'assistantEnd':
return AssistantEndEvent(
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
);
case 'assistantError':
return AssistantErrorEvent(
(raw['code'] as String?) ?? 'device.assistant.unknown',
raw['message'] as String?,
);
case 'deviceRecordStart':
return DeviceRecordStartEvent(
address: raw['address'] as String?,
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
);
case 'deviceRecordAudio':
return DeviceRecordAudioEvent(
address: raw['address'] as String?,
streamId: raw['streamId'] as String,
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
channels: (raw['channels'] as int?) ?? 1,
bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16,
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
pcm: raw['pcm'] as Uint8List,
);
case 'deviceRecordStop':
return DeviceRecordStopEvent(
address: raw['address'] as String?,
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
);
case 'deviceRecordError':
return DeviceRecordErrorEvent(
address: raw['address'] as String?,
code: (raw['code'] as int?) ?? 0,
message: raw['message'] as String?,
);
case 'otaState':
return OtaStateEvent(
state: _parseOtaState(raw['state'] as String?),
@ -454,9 +617,22 @@ class Jielihome {
});
}
Future<void> startScan({Duration timeout = const Duration(seconds: 30)}) async {
/// 启动扫描,可选复合过滤。
///
/// - [nameList]:设备名命中列表(精确匹配,忽略大小写);空 = 不按名过滤
/// - [uuidList]:UUID 命中列表 → `ScanFilter.setServiceUuid`;空 = 不按 UUID 过滤
/// - [skipUnnamed]:跳过没有 name 的广播(环境噪声),默认 true
Future<void> startScan({
Duration timeout = const Duration(seconds: 30),
List<String> nameList = const [],
List<String> uuidList = const [],
bool skipUnnamed = true,
}) async {
await _methodChannel.invokeMethod<bool>('startScan', {
'timeoutMs': timeout.inMilliseconds,
'nameList': nameList,
'uuidList': uuidList,
'skipUnnamed': skipUnnamed,
});
}
@ -631,6 +807,50 @@ class Jielihome {
});
}
// ───── AI 助理通路(JieliAssistantPort)─────
// 与 [speechStart] 不同:本通路走 RCSP `MODE_RECORD` +
// `STRATEGY_DEVICE_ALWAYS_RECORDING`,耳机自主持续推 OPUS,APP 不需要唤醒词
// 也不需要按键。Port 内部以 `packetSize=40` 解码 16k OPUS 帧,避免 SpeechFeature
// 路径下因 packetSize=200 导致的 80% 帧丢失。
//
// 订阅 [AssistantAudioEvent] 拿 16k/16bit/mono/20ms PCM 帧;
// [AssistantErrorEvent] / [AssistantEndEvent] 用于错误和退出通知。
Future<bool> assistantStart() async =>
(await _methodChannel.invokeMethod<bool>('assistantStart')) ?? false;
Future<void> assistantStop() async {
await _methodChannel.invokeMethod<bool>('assistantStop');
}
Future<bool> assistantIsRunning() async =>
(await _methodChannel.invokeMethod<bool>('assistantIsRunning')) ?? false;
// ───── 设备录音 ─────
// 订阅 [DeviceRecordStartEvent] / [DeviceRecordAudioEvent] /
// [DeviceRecordStopEvent] / [DeviceRecordErrorEvent] 接收录音状态和 PCM 数据。
/// 启动设备录音上行。
/// [address] 省略则取当前已连设备。
Future<void> deviceRecordStart({
String? address,
int sampleRate = 16000,
}) async {
await _methodChannel.invokeMethod<bool>('deviceRecordStart', {
'args': {
if (address != null) 'address': address,
'sampleRate': sampleRate,
},
});
}
Future<void> deviceRecordStop() =>
_methodChannel.invokeMethod('deviceRecordStop');
Future<Map<String, Object?>?> deviceRecordStatus() async {
final raw = await _methodChannel.invokeMethod<Map?>('deviceRecordStatus');
return raw?.cast<String, Object?>();
}
// ───── OTA ─────
/// 启动 OTA 升级。订阅 [OtaStateEvent] / [OtaErrorEvent] 看进度。

48
local_plugins/device_jieli/lib/src/jieli_device_plugin.dart

@ -91,7 +91,25 @@ class JieliDevicePlugin implements DevicePlugin {
@override
Future<void> startScan({DeviceScanFilter? filter, Duration? timeout}) async {
_requireInit();
await _home.startScan(timeout: timeout ?? const Duration(seconds: 30));
final filterNameList = filter?.nameList
?.where((e) => e.isNotEmpty)
.toList(growable: false);
final nameList = (filterNameList != null && filterNameList.isNotEmpty)
? filterNameList
: <String>[
if (filter?.namePrefix != null && filter!.namePrefix!.isNotEmpty)
filter.namePrefix!,
];
final uuidList = filter?.serviceUuids
?.where((e) => e.isNotEmpty)
.toList(growable: false) ??
const <String>[];
await _home.startScan(
timeout: timeout ?? const Duration(seconds: 30),
nameList: nameList,
uuidList: uuidList,
skipUnnamed: filter?.skipUnnamed ?? true,
);
_scanning = true;
_emit(const DevicePluginEvent(type: DevicePluginEventType.scanStarted));
}
@ -197,17 +215,26 @@ class JieliDevicePlugin implements DevicePlugin {
return;
}
if (raw is DeviceFoundEvent) {
final d = raw.device;
_emit(DevicePluginEvent(
type: DevicePluginEventType.deviceDiscovered,
discovered: DiscoveredDevice(
id: raw.device.address,
name: raw.device.name,
rssi: raw.device.rssi,
id: d.address,
name: d.name,
rssi: d.rssi,
vendor: kVendor,
metadata: {
if (raw.device.edrAddr != null) 'edrAddr': raw.device.edrAddr,
if (raw.device.deviceType != null) 'deviceType': raw.device.deviceType,
if (raw.device.connectWay != null) 'connectWay': raw.device.connectWay,
if (d.edrAddr != null) 'edrAddr': d.edrAddr,
if (d.deviceType != null) 'deviceType': d.deviceType,
if (d.connectWay != null) 'connectWay': d.connectWay,
if (d.rawAdv != null) 'rawAdv': d.rawAdv,
if (d.advFlags != null) 'advFlags': d.advFlags,
if (d.manufacturerCompanyId != null)
'manufacturerCompanyId': d.manufacturerCompanyId,
if (d.manufacturerData != null)
'manufacturerData': d.manufacturerData,
if (d.serviceUuids.isNotEmpty) 'serviceUuids': d.serviceUuids,
if (d.advRecords.isNotEmpty) 'advRecords': d.advRecords,
},
),
));
@ -239,6 +266,13 @@ class JieliDevicePlugin implements DevicePlugin {
_activeSession?.dispatchTranslationEvent(raw);
return;
}
if (raw is DeviceRecordStartEvent ||
raw is DeviceRecordAudioEvent ||
raw is DeviceRecordStopEvent ||
raw is DeviceRecordErrorEvent) {
_activeSession?.dispatchDeviceRecordEvent(raw);
return;
}
if (raw is ScanStatusEvent) {
_scanning = raw.started;
_emit(DevicePluginEvent(

68
local_plugins/device_jieli/lib/src/jieli_device_session.dart

@ -60,6 +60,11 @@ class JieliDeviceSession implements DeviceSession {
@override
Stream<DeviceSessionEvent> get eventStream => _evt.stream;
// 该 Dart 侧 session 是历史 facade,不再走 OTA 路径——OTA 由
// native-driven `JieliNativeDeviceSession.otaPort()` 提供,这里保持 null。
@override
DeviceOtaPort? otaPort() => null;
/// `JieliDevicePlugin.connect` 中等待握手完成。
Future<DeviceSession> waitReady({required Duration timeout}) {
if (_state == DeviceConnectionState.ready) return Future.value(this);
@ -156,6 +161,53 @@ class JieliDeviceSession implements DeviceSession {
}
}
/// 由 plugin 转发设备录音事件。
void dispatchDeviceRecordEvent(JieliEvent raw) {
if (_evt.isClosed) return;
if (raw is DeviceRecordAudioEvent) {
_evt.add(DeviceSessionEvent(
type: DeviceSessionEventType.feature,
deviceId: deviceId,
feature: DeviceFeatureEvent(
key: 'jieli.deviceRecord.audio',
data: {
'streamId': raw.streamId,
'sampleRate': raw.sampleRate,
'channels': raw.channels,
'bitsPerSample': raw.bitsPerSample,
'tsMs': raw.tsMs,
'pcm': raw.pcm,
},
),
));
} else if (raw is DeviceRecordStartEvent) {
_evt.add(DeviceSessionEvent(
type: DeviceSessionEventType.feature,
deviceId: deviceId,
feature: DeviceFeatureEvent(
key: 'jieli.deviceRecord.start',
data: {'sampleRate': raw.sampleRate, 'tsMs': raw.tsMs},
),
));
} else if (raw is DeviceRecordStopEvent) {
_evt.add(DeviceSessionEvent(
type: DeviceSessionEventType.feature,
deviceId: deviceId,
feature: DeviceFeatureEvent(
key: 'jieli.deviceRecord.stop',
data: {'tsMs': raw.tsMs},
),
));
} else if (raw is DeviceRecordErrorEvent) {
_evt.add(DeviceSessionEvent(
type: DeviceSessionEventType.error,
deviceId: deviceId,
errorCode: 'device.feature_failed',
errorMessage: 'jieli.deviceRecord: ${raw.code} ${raw.message ?? ''}',
));
}
}
/// 由 plugin 转发 RcspInitEvent。
void updateRcspInit(bool success) {
if (success) {
@ -298,6 +350,22 @@ class JieliDeviceSession implements DeviceSession {
);
return {'ok': ok};
// ── 设备录音 ─────────────────────────────────────────
case 'jieli.deviceRecord.start':
await Jielihome.instance.deviceRecordStart(
address: deviceId,
sampleRate: (args['sampleRate'] as int?) ?? 16000,
);
return {'ok': true};
case 'jieli.deviceRecord.stop':
await Jielihome.instance.deviceRecordStop();
return {'ok': true};
case 'jieli.deviceRecord.status':
final s = await Jielihome.instance.deviceRecordStatus();
return s ?? const <String, Object?>{};
// ── 自定义 RCSP 命令 ────────────────────────────────
case 'jieli.cmd.send':
final opCode = args['opCode'];

149
local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Assistant.kt

@ -0,0 +1,149 @@
package com.aiagent.device_plugin_interface
import kotlinx.coroutines.flow.Flow
/**
* AI 助理场景的音频载荷编码。
*
* 与 [CallAudioCodec] 并列但语义独立 —— AI 助理是单向对话(用户麦 → AI → 耳机扬声器),
* 没有通话翻译的 UPLINK/DOWNLINK 概念。
*/
enum class AssistantAudioCodec { OPUS, PCM_S16LE }
/** AI 助理场景的音频格式描述。 */
data class AssistantAudioFormat(
val codec: AssistantAudioCodec,
val sampleRate: Int,
val channels: Int,
val frameMs: Int,
) {
companion object {
/** 16 kHz / mono / 20 ms 一帧 OPUS(耳机原生格式) */
val OPUS_16K_MONO_20MS = AssistantAudioFormat(AssistantAudioCodec.OPUS, 16000, 1, 20)
/** 16 kHz / mono / 20 ms 一帧 PCM_S16LE = 640 字节 */
val PCM_S16LE_16K_MONO_20MS = AssistantAudioFormat(AssistantAudioCodec.PCM_S16LE, 16000, 1, 20)
}
}
/**
* device → 编排器:一帧用户麦克风音频(单路上行)。
*
* 与 [CallAudioFrame] 的区别:**无 leg 字段**。AI 助理场景只有"用户说话"一路输入,
* 不存在"远端通话音"的概念。
*/
data class AssistantAudioFrame(
val codec: AssistantAudioCodec,
val sampleRate: Int,
val channels: Int,
val bytes: ByteArray,
/** 单调递增帧序号;丢帧后**不得**回填。 */
val sequence: Long,
/** 单调递增的捕获时间戳(微秒)。 */
val timestampUs: Long,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AssistantAudioFrame) return false
return sequence == other.sequence && bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int {
var r = sequence.hashCode()
r = 31 * r + bytes.contentHashCode()
return r
}
}
/**
* 编排器 → device:把 AI 的 TTS 音频回灌给耳机扬声器播放。
*
* 与 [TranslatedAudioFrame] 的区别:**无 leg 字段**。AI 助理只有一个回放方向
* (AI 回复 → 戴耳机的用户),不存在"回灌给对端"的概念。
*
* @param isFinal 本帧是否为当前 utterance 的最后一帧。device 端可能据此决定是否
* 触发整段编码 / 一次性下发(例如 jieli RCSP 场景下按"一段 = 一个 AudioData"下发)。
*/
data class AssistantPlaybackFrame(
val codec: AssistantAudioCodec,
val sampleRate: Int,
val channels: Int,
val bytes: ByteArray,
val isFinal: Boolean = false,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AssistantPlaybackFrame) return false
return codec == other.codec && bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int {
var r = codec.hashCode()
r = 31 * r + bytes.contentHashCode()
return r
}
}
/** AI 助理场景下由 device 抛出的错误(不致命:编排器决定是否 stop)。 */
data class AssistantError(
val code: String,
val message: String,
)
/**
* AI 助理设备能力端口(device 侧)。
*
* 由设备厂商插件按需实现(例如杰理基于 RCSP `MODE_CALL_TRANSLATION` 的 `UP_LINK` 上行 +
* `OUT_UPLINK` 回灌落地一份 `JieliAssistantPort`)。`assistant_server` 编排器统一通过
* 本接口拿用户麦克风 PCM、回灌 AI TTS,不依赖具体 vendor。
*
* 与 [DeviceCallTranslationPort] 并列但**语义完全独立**:
* - 不存在 leg 概念(通话翻译特有的上下行)
* - 上行流只有一路(用户麦)
* - 下行回灌只有一路(耳机扬声器)
*
* 生命周期:
* ```
* idle → enter(format) → active → exit() → idle
* ```
*
* 铁律:
* - 同一设备同一时刻**至多一个** active 端口;重复 `enter` 抛
* `IllegalStateException("device.assistant.busy")`;
* - 协商格式([enter] 入参)必须出自 [supportedSourceFormats] 列出的范围;
* - [reportPlayback] 的 codec/sampleRate/channels 必须落在 [supportedSinkFormats] 内;
* - [exit] 后 [audioFrames]/[errors] 仍可订阅,但不再产新事件。
*/
interface DeviceAssistantPort {
/** 该端口能向上派发的用户麦克风音频格式集合。 */
fun supportedSourceFormats(): Set<AssistantAudioFormat>
/** 该端口能接受的 TTS 回灌格式集合。 */
fun supportedSinkFormats(): Set<AssistantAudioFormat>
/**
* 进入 AI 助理模式。
*
* @param sourceFormat 协商好的源音频格式(必须 ∈ [supportedSourceFormats])。
* device 侧据此决定是否做 OPUS↔PCM 转换。
*/
fun enter(sourceFormat: AssistantAudioFormat)
/**
* 用户麦克风上行音频帧流。`enter` 之后开始派发,`exit` 后停止。
* 必须是 broadcast / replayable 不变的实现,编排器多次订阅安全。
*/
val audioFrames: Flow<AssistantAudioFrame>
/**
* 把 AI 的 TTS 音频回灌给耳机扬声器。
*
* @throws IllegalArgumentException codec 不在 [supportedSinkFormats] 内
* @throws IllegalStateException 当前未在 active 状态
*/
fun reportPlayback(frame: AssistantPlaybackFrame)
/** 退出 AI 助理模式,归还设备 RCSP / 音频通道。 */
fun exit()
/** 错误流。**不致命**——编排器自行决定是否 [exit]。 */
val errors: Flow<AssistantError>
}

16
local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt

@ -68,5 +68,21 @@ interface NativeDeviceSession {
*/
fun callTranslationPort(): DeviceCallTranslationPort? = null
/**
* 该 session 暴露的 AI 助理端口;vendor 无该能力时返回 null。
*
* 给 native 编排器(assistant_server)vendor-agnostic 拿端口的入口——
* 与 [callTranslationPort] 语义独立(AI 助理为单向对话,无 UPLINK/DOWNLINK 两条腿)。
*/
fun assistantPort(): DeviceAssistantPort? = null
/**
* 该 session 暴露的 OTA 端口;vendor 无 [DeviceCapability.OTA] 时返回 null。
*
* 编排器 / UI 通过本接口拿到 vendor-agnostic 的 OTA 控制;不依赖具体厂商。
* 端口生命周期与 session 绑定 —— session disconnect 时端口必须自动收尾。
*/
fun otaPort(): DeviceOtaPort? = null
fun disconnect()
}

11
local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt

@ -54,9 +54,11 @@ data class DevicePluginConfig(
/** 扫描过滤条件。 */
data class DeviceScanFilter(
val namePrefix: String? = null,
val nameList: List<String>? = null,
val serviceUuids: List<String>? = null,
val vendor: String? = null,
val minRssi: Int? = null,
val skipUnnamed: Boolean? = null,
) {
companion object {
fun fromMap(map: Map<*, *>?): DeviceScanFilter? {
@ -64,9 +66,11 @@ data class DeviceScanFilter(
@Suppress("UNCHECKED_CAST")
return DeviceScanFilter(
namePrefix = map["namePrefix"] as? String,
nameList = (map["nameList"] as? List<*>)?.map { it.toString() },
serviceUuids = (map["serviceUuids"] as? List<*>)?.map { it.toString() },
vendor = map["vendor"] as? String,
minRssi = (map["minRssi"] as? Number)?.toInt(),
skipUnnamed = map["skipUnnamed"] as? Boolean,
)
}
}
@ -194,4 +198,11 @@ object DeviceErrorCode {
const val NO_ACTIVE_SESSION = "device.no_active_session"
const val PLUGIN_NOT_INITIALIZED = "device.plugin_not_initialized"
const val INVALID_ARGUMENT = "device.invalid_argument"
// OTA
const val OTA_BUSY = "device.ota_busy"
const val OTA_INQUIRE_REFUSED = "device.ota_inquire_refused"
const val OTA_VERIFY_FAILED = "device.ota_verify_failed"
const val OTA_FILE_INVALID = "device.ota_file_invalid"
const val OTA_TRANSFER_FAILED = "device.ota_transfer_failed"
}

170
local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Ota.kt

@ -0,0 +1,170 @@
package com.aiagent.device_plugin_interface
import kotlinx.coroutines.flow.Flow
/**
* OTA 升级请求 —— 多态请求层级(与 Dart [DeviceOtaRequest] 对齐)。
*
* 业务层只构造其中一种子类;插件实现按 `when` 分派。
* 厂商不支持的子类一律抛 [DeviceException]([DeviceErrorCode.NOT_SUPPORTED]),
* **禁止**静默退化(业务侧才有"是否回退到本地下载"的语义)。
*/
sealed class DeviceOtaRequest {
/** 单块大小。null = 走厂商默认。 */
abstract val blockSize: Int?
/** 整段 OTA 的超时;null = 不限。 */
abstract val timeoutMs: Long?
/** 本地文件:app 自己下载好后放在沙盒里。 */
data class File(
val filePath: String,
override val blockSize: Int? = null,
override val timeoutMs: Long? = null,
) : DeviceOtaRequest()
/** 内存字节流:固件已经在内存里。 */
data class Bytes(
val bytes: ByteArray,
override val blockSize: Int? = null,
override val timeoutMs: Long? = null,
) : DeviceOtaRequest() {
override fun equals(other: Any?): Boolean =
this === other || (other is Bytes && bytes.contentEquals(other.bytes))
override fun hashCode(): Int = bytes.contentHashCode()
}
/** 远程 URL:让 native 自己下载;不支持时抛 NOT_SUPPORTED 由调用方退回。 */
data class Url(
val url: String,
val headers: Map<String, String> = emptyMap(),
override val blockSize: Int? = null,
override val timeoutMs: Long? = null,
) : DeviceOtaRequest()
/** 厂商扩展:差分包 / 双备份 / fileFlag 等私有参数。 */
data class Vendor(
val vendorKey: String,
val payload: Map<String, Any?>,
override val blockSize: Int? = null,
override val timeoutMs: Long? = null,
) : DeviceOtaRequest()
companion object {
/**
* 从 method channel map 反序列化(kind 字段做 tag)。
* 不识别的 kind 抛 [DeviceException]([DeviceErrorCode.INVALID_ARGUMENT])。
*/
@Suppress("UNCHECKED_CAST")
fun fromMap(map: Map<*, *>): DeviceOtaRequest {
val kind = map["kind"] as? String
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota: kind required")
val blockSize = (map["blockSize"] as? Number)?.toInt()
val timeoutMs = (map["timeoutMs"] as? Number)?.toLong()
return when (kind) {
"file" -> File(
filePath = map["filePath"] as? String
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.file: filePath required"),
blockSize = blockSize,
timeoutMs = timeoutMs,
)
"bytes" -> Bytes(
bytes = (map["bytes"] as? ByteArray)
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.bytes: bytes required"),
blockSize = blockSize,
timeoutMs = timeoutMs,
)
"url" -> Url(
url = map["url"] as? String
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.url: url required"),
headers = (map["headers"] as? Map<String, String>) ?: emptyMap(),
blockSize = blockSize,
timeoutMs = timeoutMs,
)
"vendor" -> Vendor(
vendorKey = map["vendorKey"] as? String
?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.vendor: vendorKey required"),
payload = (map["payload"] as? Map<String, Any?>) ?: emptyMap(),
blockSize = blockSize,
timeoutMs = timeoutMs,
)
else -> throw DeviceException(
DeviceErrorCode.INVALID_ARGUMENT,
"ota: unknown kind \"$kind\"",
)
}
}
}
}
/** OTA 全局状态机(与 Dart [DeviceOtaState] 一一对应)。 */
enum class DeviceOtaState {
IDLE,
/** 容器层下载远程固件中(Url 请求专属,厂商插件看不到)。 */
DOWNLOADING,
INQUIRING,
NOTIFYING_SIZE,
ENTERING,
TRANSFERRING,
VERIFYING,
REBOOTING,
DONE,
FAILED,
CANCELLED,
}
/** OTA 进度事件。 */
data class DeviceOtaProgress(
val state: DeviceOtaState,
val sentBytes: Long,
val totalBytes: Long,
/** 0..100;未知或非 transferring 阶段可为 -1。 */
val percent: Int,
val tsMs: Long,
/** 终态时携带错误码(state ∈ {FAILED});其它阶段为 null。 */
val errorCode: String? = null,
val errorMessage: String? = null,
) {
fun toMap(): Map<String, Any?> = mapOf(
"state" to state.name,
"sentBytes" to sentBytes,
"totalBytes" to totalBytes,
"percent" to percent,
"tsMs" to tsMs,
"errorCode" to errorCode,
"errorMessage" to errorMessage,
)
val isTerminal: Boolean
get() = state == DeviceOtaState.DONE ||
state == DeviceOtaState.FAILED ||
state == DeviceOtaState.CANCELLED
}
/**
* OTA 端口(同 [DeviceCallTranslationPort] 的设计风格)。
*
* 单设备 OTA 互斥:同时只能跑一个 [start],重复 start 抛
* [DeviceException]([DeviceErrorCode.OTA_BUSY])。
*
* 生命周期与铁律:
* 1. `start` 之后 [progressStream] 必有终态(DONE/FAILED/CANCELLED),不得悬挂;
* 2. 设备掉线时端口须主动派 FAILED 收尾;
* 3. `cancel` 后短时间内(≤ 5s)须收到 CANCELLED;超时强制收尾;
* 4. **不**要求支持断点续传,但若实现了须在 SDK 内部完成,对外只露统一进度。
*/
interface DeviceOtaPort {
fun start(request: DeviceOtaRequest)
fun cancel()
val isRunning: Boolean
val progressStream: Flow<DeviceOtaProgress>
}
/** OTA 错误码补充(与 Dart 镜像)。 */
object OtaErrorCode {
const val BUSY = DeviceErrorCode.OTA_BUSY
const val INQUIRE_REFUSED = DeviceErrorCode.OTA_INQUIRE_REFUSED
const val VERIFY_FAILED = DeviceErrorCode.OTA_VERIFY_FAILED
const val FILE_INVALID = DeviceErrorCode.OTA_FILE_INVALID
const val TRANSFER_FAILED = DeviceErrorCode.OTA_TRANSFER_FAILED
}

1
local_plugins/device_plugin_interface/lib/device_plugin_interface.dart

@ -4,5 +4,6 @@ export 'src/device_event.dart';
export 'src/device_exception.dart';
export 'src/device_info.dart';
export 'src/device_manager.dart';
export 'src/device_ota.dart';
export 'src/device_plugin.dart';
export 'src/device_session.dart';

7
local_plugins/device_plugin_interface/lib/src/device_exception.dart

@ -26,6 +26,13 @@ class DeviceErrorCode {
static const String noActiveSession = 'device.no_active_session';
static const String invalidArgument = 'device.invalid_argument';
static const String pluginNotInitialized = 'device.plugin_not_initialized';
// OTA
static const String otaBusy = 'device.ota_busy';
static const String otaInquireRefused = 'device.ota_inquire_refused';
static const String otaVerifyFailed = 'device.ota_verify_failed';
static const String otaFileInvalid = 'device.ota_file_invalid';
static const String otaTransferFailed = 'device.ota_transfer_failed';
}
/// 设备域统一异常。

13
local_plugins/device_plugin_interface/lib/src/device_info.dart

@ -109,19 +109,32 @@ enum DeviceConnectionState {
}
/// 扫描过滤条件。所有字段均可选;`null` 表示不过滤。
///
/// - [namePrefix]:设备名前缀(单串,跨厂商);与 [nameList] 同时存在时各厂商
/// 实现可自行选择优先级,杰里实现优先 [nameList]。
/// - [nameList]:设备名命中列表(精确匹配);空 = 不按名过滤。
/// - [serviceUuids]:UUID 命中列表;杰理实现按 `BleScanMessage.flagContent`
/// 做忽略大小写的 contains 匹配。
@immutable
class DeviceScanFilter {
const DeviceScanFilter({
this.namePrefix,
this.nameList,
this.serviceUuids,
this.vendor,
this.minRssi,
this.skipUnnamed,
});
final String? namePrefix;
final List<String>? nameList;
final List<String>? serviceUuids;
final String? vendor;
final int? minRssi;
/// 是否跳过没有 `name` 的广播(环境 BLE 噪声过滤);`null` = 由实现选默认。
/// 杰理实现默认 `true`。
final bool? skipUnnamed;
}
/// 连接选项。

172
local_plugins/device_plugin_interface/lib/src/device_ota.dart

@ -0,0 +1,172 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// OTA 升级请求 —— 多态请求层级。
///
/// 业务层只构造其中一种子类;插件实现按 `runtimeType` 分派。
/// 厂商不支持的子类一律抛 `DeviceException(DeviceErrorCode.notSupported)`,
/// **禁止**静默退化(业务侧才有"是否回退到本地下载"的语义)。
@immutable
sealed class DeviceOtaRequest {
const DeviceOtaRequest({this.blockSize, this.timeout});
/// 单块大小。null = 走厂商默认(杰理 RCSP 当前 512)。
final int? blockSize;
/// 整段 OTA 的超时;null = 不限。
final Duration? timeout;
}
/// 本地文件:app 自己下载好后放在沙盒里。
class DeviceOtaFileRequest extends DeviceOtaRequest {
const DeviceOtaFileRequest({
required this.filePath,
super.blockSize,
super.timeout,
});
final String filePath;
}
/// 内存字节流:固件已经在 dart 内存里。
///
/// 实现方可以选择直接喂给 SDK,也可以落盘后走文件路径——通过 method
/// channel 传字节较大固件不经济,建议 < 4MB 用本子类,更大的用文件请求。
class DeviceOtaBytesRequest extends DeviceOtaRequest {
const DeviceOtaBytesRequest({
required this.bytes,
super.blockSize,
super.timeout,
});
final Uint8List bytes;
}
/// 远程 URL:让 native 自己下载。
///
/// 厂商插件**不支持**时抛 `device.not_supported`,由调用方退回到
/// "app 端下载 → 用 [DeviceOtaFileRequest]" 路径。
class DeviceOtaUrlRequest extends DeviceOtaRequest {
const DeviceOtaUrlRequest({
required this.url,
this.headers = const {},
super.blockSize,
super.timeout,
});
final String url;
final Map<String, String> headers;
}
/// 厂商扩展逃生口(差分包 / 双备份 / fileFlag 等私有参数)。
///
/// 使用时业务层就明确放弃了"厂商无关"的承诺。`vendorKey` 必须等于当前 active
/// vendor,否则插件抛 `device.invalid_argument`。
class DeviceOtaVendorRequest extends DeviceOtaRequest {
const DeviceOtaVendorRequest({
required this.vendorKey,
required this.payload,
super.blockSize,
super.timeout,
});
final String vendorKey;
final Map<String, Object?> payload;
}
/// OTA 全局状态机。
///
/// ```
/// idle
/// → downloading // [仅 Url 请求] 由容器层下载固件到本地,转 file 请求
/// → inquiring // 询问设备能否升级
/// → notifyingSize // 告知固件大小
/// → entering // 设备进入升级模式
/// → transferring(*progress*)
/// → verifying
/// → rebooting
/// → done | failed | cancelled
/// ```
///
/// `failed` / `cancelled` 是终态,回到 idle 之前不应再派进度事件。
///
/// 注:`downloading` 由 `device_manager` 容器层产生(不在厂商插件内)——
/// Url 请求统一由容器下载到沙盒后转换为 file 请求再分派给 vendor port,
/// 厂商插件只看到 `inquiring` 起始的标准流程。
enum DeviceOtaState {
idle,
downloading,
inquiring,
notifyingSize,
entering,
transferring,
verifying,
rebooting,
done,
failed,
cancelled,
}
/// OTA 进度事件。
///
/// - `transferring` 阶段进度上报频率应控制在 ≥ 5Hz / ≤ 20Hz;
/// - 非 transferring 阶段允许 sentBytes / totalBytes 不准确(可置 0),
/// UI 一律以 [state] 为主;
/// - `done` 时 [percent] = 100,`failed` / `cancelled` 时 [percent] 可能为 -1。
@immutable
class DeviceOtaProgress {
const DeviceOtaProgress({
required this.state,
required this.sentBytes,
required this.totalBytes,
required this.percent,
required this.tsMs,
this.errorCode,
this.errorMessage,
});
final DeviceOtaState state;
final int sentBytes;
final int totalBytes;
/// 0..100;未知或非 transferring 阶段可为 -1。
final int percent;
/// 事件时间戳(毫秒,挂墙时间),调试用。
final int tsMs;
/// 终态时携带错误码(state ∈ {failed});其它阶段为 null。
/// 命名空间 `device.<reason>`,参见 [DeviceErrorCode]。
final String? errorCode;
final String? errorMessage;
bool get isTerminal =>
state == DeviceOtaState.done ||
state == DeviceOtaState.failed ||
state == DeviceOtaState.cancelled;
}
/// OTA 端口(同 [DeviceCallTranslationPort] 的设计风格)。
///
/// 单设备 OTA 互斥:同时只能跑一个 [start],重复 start 抛
/// `DeviceException('device.ota_busy')`。
///
/// 生命周期与铁律:
/// 1. `start` 之后 [progressStream] 必有终态(done/failed/cancelled),不得悬挂;
/// 2. 设备掉线(`disconnected*`)时端口须主动派 `failed` 收尾;
/// 3. `cancel` 后短时间内(≤ 5s)须收到 `cancelled`,超时强制收尾;
/// 4. **不**要求支持断点续传,但若实现了须在 `progress.metadata` 里给出。
abstract class DeviceOtaPort {
/// 启动 OTA。已在跑时抛 `device.ota_busy`。
Future<void> start(DeviceOtaRequest request);
/// 取消当前 OTA;空跑时 no-op。
Future<void> cancel();
/// 当前是否在跑。
bool get isRunning;
/// 进度事件流(broadcast / multi-subscribe)。
Stream<DeviceOtaProgress> get progressStream;
}

10
local_plugins/device_plugin_interface/lib/src/device_session.dart

@ -3,6 +3,7 @@ import 'dart:async';
import 'device_capability.dart';
import 'device_event.dart';
import 'device_info.dart';
import 'device_ota.dart';
/// 已连接设备的会话句柄。生命周期:
///
@ -50,6 +51,15 @@ abstract class DeviceSession {
Map<String, Object?> args = const {},
]);
// -------- OTA 端口 --------
/// 该 session 暴露的 OTA 端口;vendor 无 [DeviceCapability.ota] 时返回 null。
///
/// 编排器 / UI 通过本接口拿到 vendor-agnostic 的 OTA 控制;不依赖具体厂商。
/// 端口生命周期与 session 绑定 —— session disconnect 时端口必须自动收尾
/// (`failed` 事件 + `isRunning=false`)。
DeviceOtaPort? otaPort() => null;
// -------- 关闭 --------
Future<void> disconnect();

Loading…
Cancel
Save