Browse Source
工作区里累积的 Flutter 客户端改动,随本轮一并入库。主要涉及: - lib/devices/ 设备内核:DeviceHub / DevicePlugin / DeviceSession 抽象, 恒玄(bes)、EaiRec 支架(holder)、Smartcar 车载香薰(smartcar) 各自的插件实现, 业务层不再直接依赖任何厂商类;分层边界由 test/devices/ 下的测试守着 - local_plugins/device_plugin_interface、device_smartcar、recorder_holder: 纯 Dart 契约与原生侧实现 - lib/modules/ 能力模块注册表、语音纪要、OTA 升级等模块 - test/ 新增设备协议、Ogg Opus 时长、模块注册表、路由等金测试 - ios/sync_spm_platform.sh:修 Xcode 直接构建时 SPM 平台版本被打回 13.0 的问题 验证:dart analyze lib 无 error(264 条 warning 均为 HEAD 上既有的 unused_import / avoid_print 等 lint,非本次引入)。未跑真机与打包。 注:这批不是本次会话中由 Claude 编写的,此处仅做入库。main
242 changed files with 24986 additions and 4784 deletions
@ -0,0 +1,3 @@ |
|||
{ |
|||
"java.configuration.updateBuildConfiguration": "automatic" |
|||
} |
|||
@ -1 +1,23 @@ |
|||
include: package:flutter_lints/flutter.yaml |
|||
|
|||
analyzer: |
|||
exclude: |
|||
- "**/*.g.dart" |
|||
- "local_plugins/**" |
|||
- "build/**" |
|||
errors: |
|||
# 这几类几乎总是「写了没接上」或「删了没删干净」,提级成 warning 让 CI 拦住 |
|||
unused_import: warning |
|||
unused_element: warning |
|||
unused_field: warning |
|||
unused_local_variable: warning |
|||
dead_code: warning |
|||
dead_null_aware_expression: warning |
|||
avoid_print: warning |
|||
# 老代码里的大写常量不在本轮整改范围,先降噪 |
|||
constant_identifier_names: ignore |
|||
|
|||
linter: |
|||
rules: |
|||
avoid_print: true |
|||
prefer_const_constructors: false |
|||
|
|||
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1,106 @@ |
|||
#!/usr/bin/env python3 |
|||
"""校验 APK/AAB 里 64 位 .so 是否满足 Google Play 的 16KB 页对齐要求。 |
|||
|
|||
Google Play 自 2025-11-01 起要求 64 位原生库(arm64-v8a / x86_64)的每个 |
|||
PT_LOAD 段 p_align >= 0x4000(16KB),不合规直接拒收,且**无延期**。 |
|||
32 位(armeabi-v7a / x86)不受约束,脚本会列出但不判失败。 |
|||
|
|||
用法: |
|||
python3 check_16kb.py build/app/outputs/bundle/release/app-release.aab |
|||
python3 check_16kb.py build/app/outputs/flutter-apk/app-release.apk |
|||
|
|||
退出码: 0=全部合规, 1=有不合规的 64 位库, 2=用法/文件错误 |
|||
""" |
|||
import struct |
|||
import sys |
|||
import zipfile |
|||
|
|||
REQUIRED_ALIGN = 0x4000 |
|||
ABI64 = ("arm64-v8a", "x86_64") |
|||
|
|||
PT_LOAD = 1 |
|||
|
|||
|
|||
def max_load_align(data: bytes): |
|||
"""返回 ELF 所有 PT_LOAD 段里最小的 p_align;不是 64 位 ELF 返回 None。""" |
|||
if len(data) < 64 or data[:4] != b"\x7fELF": |
|||
return None |
|||
if data[4] != 2: # EI_CLASS: 2 = ELFCLASS64,32 位不参与判定 |
|||
return None |
|||
little = data[5] == 1 |
|||
end = "<" if little else ">" |
|||
|
|||
# Elf64_Ehdr: e_phoff@0x20, e_phentsize@0x36, e_phnum@0x38 |
|||
(e_phoff,) = struct.unpack_from(end + "Q", data, 0x20) |
|||
(e_phentsize,) = struct.unpack_from(end + "H", data, 0x36) |
|||
(e_phnum,) = struct.unpack_from(end + "H", data, 0x38) |
|||
|
|||
aligns = [] |
|||
for i in range(e_phnum): |
|||
off = e_phoff + i * e_phentsize |
|||
if off + 56 > len(data): |
|||
break |
|||
(p_type,) = struct.unpack_from(end + "I", data, off) |
|||
if p_type != PT_LOAD: |
|||
continue |
|||
# Elf64_Phdr: p_align 是最后一个字段,偏移 0x30 |
|||
(p_align,) = struct.unpack_from(end + "Q", data, off + 0x30) |
|||
aligns.append(p_align) |
|||
return min(aligns) if aligns else None |
|||
|
|||
|
|||
def abi_of(name: str): |
|||
for abi in ("arm64-v8a", "armeabi-v7a", "x86_64", "x86"): |
|||
if "/%s/" % abi in name: |
|||
return abi |
|||
return "?" |
|||
|
|||
|
|||
def main(argv): |
|||
if len(argv) != 2: |
|||
print(__doc__) |
|||
return 2 |
|||
path = argv[1] |
|||
try: |
|||
zf = zipfile.ZipFile(path) |
|||
except Exception as exc: # noqa: BLE001 |
|||
print("无法打开 %s: %s" % (path, exc)) |
|||
return 2 |
|||
|
|||
bad, ok, skipped = [], [], [] |
|||
with zf: |
|||
for info in zf.infolist(): |
|||
if not info.filename.endswith(".so"): |
|||
continue |
|||
data = zf.read(info.filename) |
|||
align = max_load_align(data) |
|||
abi = abi_of(info.filename) |
|||
if align is None: |
|||
# 32 位或非 ELF:不参与 16KB 判定 |
|||
skipped.append((info.filename, abi)) |
|||
continue |
|||
if align < REQUIRED_ALIGN: |
|||
bad.append((info.filename, abi, align)) |
|||
else: |
|||
ok.append((info.filename, abi, align)) |
|||
|
|||
print("包: %s" % path) |
|||
print("64 位 .so 合规 %d 个,不合规 %d 个;32 位/非 ELF 跳过 %d 个\n" |
|||
% (len(ok), len(bad), len(skipped))) |
|||
|
|||
if bad: |
|||
print("❌ 不合规(p_align < 0x4000,Play 会拒收):") |
|||
for name, abi, align in sorted(bad): |
|||
print(" %-60s %-12s p_align=0x%x" % (name, abi, align)) |
|||
print("") |
|||
print("修法:给产出该库的模块声明 NDK r28+(`ndkVersion`),或在 CMake 里加") |
|||
print(" target_link_options(<target> PRIVATE \"-Wl,-z,max-page-size=16384\")") |
|||
print(" 改完记得清掉该模块的 android/.cxx 缓存,否则会复用旧产物。") |
|||
return 1 |
|||
|
|||
print("✅ 全部 64 位 .so 均满足 16KB 页对齐") |
|||
return 0 |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
sys.exit(main(sys.argv)) |
|||
@ -0,0 +1,146 @@ |
|||
#!/usr/bin/env bash |
|||
# |
|||
# 把 Flutter 生成的 SPM 中间包的 iOS 平台版本,对齐到本工程的 deployment target。 |
|||
# |
|||
# ── 解决什么问题 ──────────────────────────────────────────────────────────── |
|||
# Flutter 3.29+ 的 Swift Package Manager 集成会生成 |
|||
# ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift |
|||
# 所有插件都挂在这个包下面。**它生成时的平台版本写死是 Flutter 自己的最低版本 |
|||
# (iOS 13.0)**,而本项目有十几个本地插件声明了 `.iOS("16.0")`。SPM 不允许依赖的 |
|||
# 平台版本高于上层包,于是 Xcode 报: |
|||
# |
|||
# The package product 'xxx' requires minimum platform version 16.0 |
|||
# for the iOS platform, but this target supports 13.0 |
|||
# |
|||
# Flutter 自己有补救(SwiftPackageManager.updateMinimumDeployment:读 Runner 的 |
|||
# IPHONEOS_DEPLOYMENT_TARGET 把生成包改写上去),但**只在 `flutter build/run` 里跑**。 |
|||
# 而 Xcode 直接构建时,SPM 解析发生在所有 Flutter 步骤之前,看到的还是 13.0 —— 所以 |
|||
# `flutter build ios` 能过、Xcode 里点构建就炸。 |
|||
# |
|||
# ⚠️ 任何重新生成插件清单的操作都会把它打回 13.0:flutter pub get / clean / install、 |
|||
# 改完 pubspec 的自动 pub get。也就是说这不是一次性问题,会反复出现。 |
|||
# |
|||
# ── 怎么用 ────────────────────────────────────────────────────────────────── |
|||
# ./ios/sync_spm_platform.sh # 在 apps/client 下随时手动跑,幂等 |
|||
# ./ios/sync_spm_platform.sh --check # 只检查不改写,不一致时退出码 1(给 CI 用) |
|||
# |
|||
# 挂进 Xcode(推荐,省得每次记着手动跑): |
|||
# Product → Scheme → Edit Scheme → Build → Pre-actions → + New Run Script Action |
|||
# Provide build settings from: Runner |
|||
# 脚本内容: "$SRCROOT/sync_spm_platform.sh" |
|||
# |
|||
# ⚠️ 不依赖 flutter 命令:Xcode 的 pre-action 环境 PATH 很干净,调 flutter 经常找不到。 |
|||
# 这里只做纯文本改写,几毫秒完成。代价是生成包必须已经存在(跑过一次 |
|||
# flutter pub get 即可),不存在时脚本会明确告诉你先跑什么。 |
|||
# |
|||
# ⚠️ 别改 Package.swift 本身来"修好"这个问题——它头上写着 Generated file. Do not edit., |
|||
# 下次 pub get 就被覆盖,属于假修好。本脚本做的是同一件事,但可重复执行。 |
|||
|
|||
# ⚠️ macOS 自带 bash 3.2 不认多字节字符边界:`$VAR,` 会被当成变量名 `VAR,`, |
|||
# 在 set -u 下报 unbound variable。所以本文件里凡是变量后面紧跟中文标点的, |
|||
# 一律写成 ${VAR}。 |
|||
set -euo pipefail |
|||
|
|||
IOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
|||
CLIENT_DIR="$(cd "$IOS_DIR/.." && pwd)" |
|||
PBXPROJ="$IOS_DIR/Runner.xcodeproj/project.pbxproj" |
|||
MANIFEST="$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift" |
|||
|
|||
CHECK_ONLY=0 |
|||
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1 |
|||
|
|||
red() { printf "\033[31m%s\033[0m\n" "$*"; } |
|||
green() { printf "\033[32m%s\033[0m\n" "$*"; } |
|||
blue() { printf "\033[34m%s\033[0m\n" "$*"; } |
|||
|
|||
# 把 .iOS(.v16) / .iOS("16.0") / 16 统一成可比较的 x.y 形式 |
|||
normalize() { |
|||
local v="$1" |
|||
v="${v#.v}" # .v16 → 16 |
|||
v="${v//\"/}" # 去引号 |
|||
[[ "$v" == *.* ]] || v="$v.0" # 16 → 16.0 |
|||
printf '%s' "$v" |
|||
} |
|||
|
|||
# 版本比较:$1 <= $2 ? |
|||
le() { [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]]; } |
|||
|
|||
[[ -f "$PBXPROJ" ]] || { red "✗ 找不到 $PBXPROJ"; exit 1; } |
|||
|
|||
# ── 1) 工程能接受的上限 ───────────────────────────────────────────────────── |
|||
# 生成包的平台版本必须 ≤ 每个链接它的 target,所以取全工程最低的那个 deployment target。 |
|||
CEILING="$(grep -o 'IPHONEOS_DEPLOYMENT_TARGET = [0-9.]*' "$PBXPROJ" \ |
|||
| awk '{print $3}' | sed 's/;$//' | sort -V | head -1)" |
|||
[[ -n "$CEILING" ]] || { red "✗ 没能从 project.pbxproj 读出 IPHONEOS_DEPLOYMENT_TARGET"; exit 1; } |
|||
CEILING="$(normalize "$CEILING")" |
|||
|
|||
# ── 2) 插件要求的下限 ─────────────────────────────────────────────────────── |
|||
# 扫本地插件的 Package.swift,取声明得最高的那个。 |
|||
FLOOR="0.0"; FLOOR_OWNER="" |
|||
while IFS= read -r f; do |
|||
raw="$(grep -o '\.iOS([^)]*)' "$f" | head -1 | sed 's/^\.iOS(//; s/)$//')" || true |
|||
[[ -n "$raw" ]] || continue |
|||
v="$(normalize "$raw")" |
|||
if ! le "$v" "$FLOOR"; then FLOOR="$v"; FLOOR_OWNER="${f#"$CLIENT_DIR"/}"; fi |
|||
done < <(find "$CLIENT_DIR/local_plugins" -name Package.swift -not -path '*/.*' 2>/dev/null | sort) |
|||
|
|||
blue "工程 deployment target(取最低) = ${CEILING} 本地插件最高要求 = ${FLOOR}${FLOOR_OWNER:+ (${FLOOR_OWNER})}" |
|||
|
|||
# ── 3) 插件要求超过工程上限 = 真问题,脚本救不了,直接点名 ─────────────────── |
|||
if ! le "$FLOOR" "$CEILING"; then |
|||
red "✗ 插件要求 iOS ${FLOOR},但工程最低 target 只有 ${CEILING} —— 这个脚本改不了。" |
|||
red " 要么把工程的 IPHONEOS_DEPLOYMENT_TARGET 抬到 ${FLOOR}(Runner + Podfile 都要)," |
|||
red " 要么把 ${FLOOR_OWNER} 的平台声明降下来。" |
|||
exit 1 |
|||
fi |
|||
|
|||
# ── 4) 对齐生成包 ─────────────────────────────────────────────────────────── |
|||
if [[ ! -f "$MANIFEST" ]]; then |
|||
red "✗ 生成包还不存在:${MANIFEST#"$CLIENT_DIR"/}" |
|||
red " 先在 apps/client 下跑一次 \`flutter pub get\`(或 \`flutter build ios --config-only\`)再执行本脚本。" |
|||
exit 1 |
|||
fi |
|||
|
|||
# ── 本地插件之间的相对路径依赖 ──────────────────────────────────────────── |
|||
# 若干本地插件的 Package.swift 用 `../../../<plugin>/ios/<plugin>` 引用兄弟插件 |
|||
# (agent_service→test、azure_speech→classic_bluetooth、recorder_holder→device_jieli、 |
|||
# chat_api→swift_sdk_local)。这些路径是按 `local_plugins/<name>/ios/<name>` 的真实 |
|||
# 目录写的,但 Flutter 把插件以符号链接挂在 `ephemeral/Packages/.packages/<name>` 下, |
|||
# SPM 不解析符号链接,`../../..` 就落到了 `ephemeral/`——于是报 |
|||
# "the package at 'ephemeral/<plugin>/ios/<plugin>' cannot be accessed"。 |
|||
# 这里在 ephemeral/ 下给每个本地插件目录补一个同名链接,让那些相对路径能落地。 |
|||
# 幂等;`flutter pub get` 不会清掉它们。 |
|||
ensure_local_plugin_links() { |
|||
local eph="$IOS_DIR/Flutter/ephemeral" |
|||
local plugins_dir="$CLIENT_DIR/local_plugins" |
|||
[ -d "$eph" ] || return 0 |
|||
for d in "$plugins_dir"/*/; do |
|||
local name |
|||
name="$(basename "$d")" |
|||
[ -e "$eph/$name" ] && continue |
|||
ln -s "../../../local_plugins/$name" "$eph/$name" |
|||
done |
|||
} |
|||
ensure_local_plugin_links |
|||
|
|||
CURRENT_RAW="$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')" |
|||
CURRENT="$(normalize "$CURRENT_RAW")" |
|||
|
|||
if [[ "$CURRENT" == "$CEILING" ]]; then |
|||
green "✓ 生成包已是 iOS ${CURRENT},无需改动" |
|||
exit 0 |
|||
fi |
|||
|
|||
if [[ $CHECK_ONLY -eq 1 ]]; then |
|||
red "✗ 生成包是 iOS ${CURRENT},应为 ${CEILING}(跑 ./ios/sync_spm_platform.sh 修)" |
|||
exit 1 |
|||
fi |
|||
|
|||
# 只替换 platforms 段里的第一个 .iOS(...),不碰文件其余内容 |
|||
/usr/bin/sed -i '' "s/\.iOS($(printf '%s' "$CURRENT_RAW" | sed 's/[.[\*^$\/]/\\&/g'))/.iOS(\"$CEILING\")/" "$MANIFEST" |
|||
|
|||
NEW="$(normalize "$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')")" |
|||
if [[ "$NEW" != "$CEILING" ]]; then |
|||
red "✗ 改写失败,现在是 ${NEW}"; exit 1 |
|||
fi |
|||
green "✓ 生成包平台版本 ${CURRENT} → ${CEILING},可以回 Xcode 构建了" |
|||
@ -0,0 +1,34 @@ |
|||
/// 版本号比较,与服务端 `comm.CompareVersion` 同口径:按 `.` 切段、每段只取 |
|||
/// 前导数字(`3+4` → 3,`0-beta` → 0),逐段按整数比。 |
|||
/// |
|||
/// 用 `int.parse` 的写法遇到 `1.0.0-beta` / `1.2.3+4` 会直接抛,被外层 catch |
|||
/// 吞成「无更新」——表现是「明明有新版本却说已是最新」。 |
|||
class VersionCompare { |
|||
VersionCompare._(); |
|||
|
|||
static List<int> split(String v) { |
|||
final s = v.trim(); |
|||
if (s.isEmpty) return const []; |
|||
return s.split('.').map((seg) { |
|||
final m = RegExp(r'^\d+').firstMatch(seg.trim()); |
|||
return m == null ? 0 : int.parse(m.group(0)!); |
|||
}).toList(); |
|||
} |
|||
|
|||
/// 负数:a < b;0:相等;正数:a > b |
|||
static int compare(String a, String b) { |
|||
final pa = split(a); |
|||
final pb = split(b); |
|||
if (pa.isEmpty || pb.isEmpty) return a.trim().compareTo(b.trim()); |
|||
final len = pa.length > pb.length ? pa.length : pb.length; |
|||
for (var i = 0; i < len; i++) { |
|||
final x = i < pa.length ? pa[i] : 0; |
|||
final y = i < pb.length ? pb[i] : 0; |
|||
if (x != y) return x > y ? 1 : -1; |
|||
} |
|||
return 0; |
|||
} |
|||
|
|||
/// [a] 是否比 [b] 新 |
|||
static bool isNewer(String a, String b) => compare(a, b) > 0; |
|||
} |
|||
@ -0,0 +1,666 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import '../../devices/device_hub.dart'; |
|||
import '../models/agent_module.dart'; |
|||
import '../models/emai_message.dart'; |
|||
import '../models/user_Info.dart' as user_info; |
|||
import '../utils/cue_tone.dart'; |
|||
import 'agent_module_defaults.dart'; |
|||
import 'assistant_directive_service.dart'; |
|||
import 'bailian_multimodal_service.dart'; |
|||
import 'emai_chat_store.dart'; |
|||
import 'mcp_token_service.dart'; |
|||
|
|||
/// 设备按键唤醒的 AI 实时对话会话——**厂商无关**,要求设备具备 |
|||
/// [DeviceCapability.aiWake]。 |
|||
/// |
|||
/// 把「设备按键唤醒」接到阿里百炼多模态上:设备麦的音频由插件解码成 16k PCM |
|||
/// (`openMic(ai)`)推给百炼,百炼回的 16k PCM 交给 `openSpeaker(ai)` 由插件/原生 |
|||
/// 编码后按节奏发回设备播放。整条链路上这里只搬运 PCM。 |
|||
/// |
|||
/// **握手是设备主导的**,App 只能应答(恒玄时序,其它厂商在插件里对齐到同一语义): |
|||
/// ``` |
|||
/// 设备按键 ──► wakeEvents(ptt) 恒玄: BB 64 |
|||
/// App ──► ai.accept 恒玄: AA 6A,必须 3 秒内,否则设备停止送音频 |
|||
/// …会话进行中… |
|||
/// App ──► ai.exit 恒玄: AA 65 |
|||
/// 设备 ──► wakeEvents(hangup) 恒玄: BB E5;2 秒不回就本地强制结束 |
|||
/// ``` |
|||
/// |
|||
/// 常驻服务:按键在任意页面(含 App 在后台、EMAI 页没打开)都要能唤醒。 |
|||
class DeviceAiSessionService extends GetxService { |
|||
static const String _tag = 'DeviceAiSession'; |
|||
static DeviceAiSessionService get to => Get.find<DeviceAiSessionService>(); |
|||
|
|||
/// 设备侧的会话与手机 EMAI 页各用一条独立的 WebSocket。 |
|||
/// 共用一个单例的话两边会互相 stop 掉对方、下行音频也会串。 |
|||
/// |
|||
/// ⚠️ **接哪个百炼应用要看是哪台设备唤醒的**:耳机/支架接 EMAI,车载香薰接 |
|||
/// 「Smartcar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例, |
|||
/// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。 |
|||
BailianMultimodalService _svc = BailianMultimodalService(); |
|||
String? _svcAppId; |
|||
|
|||
/// 厂商 → 该接哪个 agent 的 app_id。走的是「厂商 → 品类 → 默认编排第一个模块 |
|||
/// → 模块声明的 bailianAppId」这条链,**设备层不认识 agent、agent 层不认识厂商**。 |
|||
/// 查不到返回 null = 用全局默认(EMAI)。 |
|||
String? _resolveAgentAppId(String vendor) { |
|||
try { |
|||
return bailianAppIdForModule(AgentDefaults.primaryModuleForVendor(vendor)); |
|||
} catch (e) { |
|||
Logger.w(_tag, '解析 agent app_id 失败(回退默认): $e'); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/// 按需换一个指向目标 app_id 的服务实例。 |
|||
void _ensureService(String? appId) { |
|||
final want = (appId ?? '').trim(); |
|||
final cur = (_svcAppId ?? '').trim(); |
|||
if (want == cur) return; |
|||
Logger.i(_tag, |
|||
'切换设备侧百炼应用: ${cur.isEmpty ? '(默认)' : cur} → ${want.isEmpty ? '(默认)' : want}'); |
|||
// 旧实例可能还挂着 WebSocket,先停掉再丢 |
|||
unawaited(_svc.stop()); |
|||
_svc = BailianMultimodalService(appId: want.isEmpty ? null : want); |
|||
_svcAppId = want; |
|||
} |
|||
|
|||
/// 设备上行 PCM 就是 16k/16bit/mono,与百炼上行要求一致 |
|||
static const int _deviceRate = 16000; |
|||
|
|||
/// 上行攒够这么多字节再发一包:3200B = 100ms @16k。设备每 20ms 上来一包, |
|||
/// 逐包转发就是 50 条 WS 消息/秒,攒成 100ms 一条降到 1/5。 |
|||
static const int _micBatchBytes = 3200; |
|||
final BytesBuilder _micAccum = BytesBuilder(copy: false); |
|||
|
|||
/// 收到唤醒后必须在这个时间内应答。我们是立即回,这个常量只是把固件约束写在代码里。 |
|||
static const Duration _ackDeadline = Duration(seconds: 3); |
|||
|
|||
/// 发出 ai.exit 后等设备回 hangup 的时间,超时就本地强制结束 |
|||
static const Duration _stopAckTimeout = Duration(seconds: 2); |
|||
|
|||
StreamSubscription? _wakeSub; |
|||
StreamSubscription? _hubSub; |
|||
StreamSubscription? _micSub; |
|||
StreamSubscription? _eventSub; |
|||
StreamSubscription? _audioSub; |
|||
DeviceSession? _session; |
|||
DeviceAudioSource? _mic; |
|||
DeviceAudioSink? _spk; |
|||
Timer? _stopAckTimer; |
|||
Timer? _playoutTimer; |
|||
|
|||
/// 会话是否在跑(已应答,还没收到/发出结束) |
|||
final RxBool isActive = false.obs; |
|||
|
|||
/// 手机 EMAI 页(打开时由它自己 attach 上来)。 |
|||
/// 不用 `Get.put<EmaiSessionOwner>(controller)`:GetX 的 put 会对实例调 |
|||
/// onInit,而页面正是在自己的 onInit 里注册的,会递归到 Stack Overflow。 |
|||
EmaiSessionOwner? _phonePage; |
|||
|
|||
int _respondingBytes = 0; |
|||
DateTime? _respondingStartedAt; |
|||
|
|||
/// 上行是否被门控住(AI 正在设备里播报期间)。 |
|||
/// 设备没有把下发的音频接进自己的 AEC,播出去的 AI 声音会被麦克风原样收回, |
|||
/// duplex 模式下服务端 VAD 把它当成用户插话。没有 AEC 就只能做半双工。 |
|||
bool _uplinkGated = false; |
|||
Timer? _gateTimer; |
|||
bool _tearingDown = false; |
|||
static const Duration _gateTail = Duration(milliseconds: 500); |
|||
static const Duration _idleTimeout = Duration(seconds: 5); |
|||
Timer? _idleTimer; |
|||
DateTime? _firstAudioAt; |
|||
int _micPackets = 0; |
|||
int _downPackets = 0; |
|||
|
|||
EmaiChatStore get _store => EmaiChatStore.to; |
|||
EmaiMessage? _streamingUser; |
|||
EmaiMessage? _streamingAssistant; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
final hub = DeviceHub.to; |
|||
_wakeSub = hub.wakeEvents.listen(_onWake, onError: (e) { |
|||
Logger.e(_tag, '唤醒事件流错误: $e'); |
|||
}); |
|||
_hubSub = hub.sessionEvents.listen((e) { |
|||
if (e.type == DeviceSessionEventType.connectionStateChanged && |
|||
e.connectionState == DeviceConnectionState.disconnected && |
|||
isActive.value && |
|||
_session?.deviceId == e.deviceId) { |
|||
Logger.w(_tag, '设备断连,结束 AI 会话'); |
|||
_teardown(notifyDevice: false, playEndTone: false); |
|||
} |
|||
}); |
|||
Logger.i(_tag, '设备 AI 会话服务已就绪,等待按键唤醒'); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_wakeSub?.cancel(); |
|||
_hubSub?.cancel(); |
|||
_teardown(notifyDevice: true, playEndTone: false); |
|||
super.onClose(); |
|||
} |
|||
|
|||
// ---------------- 设备事件 ---------------- |
|||
|
|||
void _onWake(DeviceWakeEvent e) { |
|||
switch (e.reason) { |
|||
case WakeReason.ptt: |
|||
case WakeReason.voiceWake: |
|||
_handleStart(e.deviceId); |
|||
break; |
|||
case WakeReason.hangup: |
|||
Logger.i(_tag, '设备通知结束 AI'); |
|||
_teardown(notifyDevice: false); |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
} |
|||
|
|||
void _onMicPcm(Uint8List pcm) { |
|||
if (!isActive.value || pcm.isEmpty) return; |
|||
_micPackets++; |
|||
if (_micPackets == 1 || _micPackets % 50 == 0) { |
|||
Logger.i(_tag, |
|||
'设备上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}'); |
|||
} |
|||
// 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是 |
|||
// "真的没人说话",而不是一段数据缺口 |
|||
_micAccum.add(_uplinkGated ? Uint8List(pcm.length) : pcm); |
|||
if (_micAccum.length >= _micBatchBytes) { |
|||
_svc.pushAudio(_micAccum.takeBytes()); |
|||
} |
|||
} |
|||
|
|||
Future<void> _handleStart(String deviceId) async { |
|||
if (isActive.value) { |
|||
Logger.w(_tag, '已有会话在跑,忽略重复唤醒'); |
|||
return; |
|||
} |
|||
final hub = DeviceHub.to; |
|||
final session = |
|||
hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId) ?? |
|||
hub.sessionWith(DeviceCapability.aiWake); |
|||
if (session == null || session.state != DeviceConnectionState.ready) { |
|||
Logger.e(_tag, '设备未处于已连接状态,拒绝本次唤醒'); |
|||
return; |
|||
} |
|||
// ★ 先按厂商选中该说话的那个 agent(车载香薰 → Smartcar 车载助手), |
|||
// 再去看 isConfigured —— 反过来的话检查的是上一台设备的应用 |
|||
_ensureService(_resolveAgentAppId(session.vendor)); |
|||
Logger.i(_tag, |
|||
'设备按键唤醒 AI (${session.vendor},应用=${_svcAppId?.isNotEmpty == true ? _svcAppId : '默认'})'); |
|||
|
|||
// 起不来就别应答,直接让设备退出 AI 模式——应答之后设备会一直送音频, |
|||
// 而我们这边没有会话接,它会白等到超时 |
|||
if (!_svc.isConfigured) { |
|||
Logger.e(_tag, '百炼未配置(${_svc.configHint}),拒绝本次唤醒'); |
|||
await _sendExit(session); |
|||
return; |
|||
} |
|||
|
|||
// 固件要求 3 秒内确认,先应答再做后面的初始化。 |
|||
// 反过来的话,百炼握手(一次 WebSocket 往返)可能就吃掉大半个预算。 |
|||
final ackAt = DateTime.now(); |
|||
_session = session; |
|||
try { |
|||
await session.invokeFeature(DeviceFeatures.aiAccept); |
|||
} catch (e) { |
|||
Logger.e(_tag, '应答唤醒失败: $e'); |
|||
_session = null; |
|||
return; |
|||
} |
|||
isActive.value = true; |
|||
// 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持) |
|||
_aiStateUnsupported = false; |
|||
_quitAfterPlayout = false; |
|||
_micPackets = 0; |
|||
_downPackets = 0; |
|||
_uplinkGated = false; |
|||
_micAccum.clear(); |
|||
|
|||
await _yieldPhonePage(); |
|||
|
|||
try { |
|||
_mic = await session.openMic(route: AudioRoute.ai); |
|||
_micSub = _mic!.pcm.listen(_onMicPcm); |
|||
_eventSub = _svc.events.listen(_onBailianEvent); |
|||
// 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样 |
|||
_audioSub = _svc.audioOut.listen(_onDownstreamPcm); |
|||
_spk = await session.openSpeaker(route: AudioRoute.ai); |
|||
} catch (e) { |
|||
Logger.e(_tag, '打开设备音频通路失败: $e'); |
|||
await _teardown(notifyDevice: true, playEndTone: false); |
|||
return; |
|||
} |
|||
// 先给一声「叮」,让用户立刻知道已经在听了——百炼握手还要一次往返 |
|||
await _playCue(CueTone.ding(sampleRate: _deviceRate)); |
|||
_touchActivity(); |
|||
|
|||
final ok = await _svc.start( |
|||
mode: 'duplex', |
|||
downstreamRate: _deviceRate, |
|||
userId: _currentUserId(), |
|||
source: 'earphone', |
|||
mcpToken: await McpTokenService.get(), |
|||
); |
|||
final elapsed = DateTime.now().difference(ackAt); |
|||
if (elapsed > _ackDeadline) { |
|||
Logger.w( |
|||
_tag, |
|||
'会话初始化耗时 ${elapsed.inMilliseconds}ms,已超过固件 ' |
|||
'${_ackDeadline.inSeconds}s 预算(应答已提前回,通常无碍)'); |
|||
} |
|||
if (!ok) { |
|||
Logger.e(_tag, '百炼会话启动失败,结束'); |
|||
await _teardown(notifyDevice: true); |
|||
} |
|||
} |
|||
|
|||
// ---------------- 百炼事件 ---------------- |
|||
|
|||
void _onBailianEvent(BailianEvent e) { |
|||
Logger.i(_tag, '百炼事件 $e'); |
|||
switch (e.type) { |
|||
case BailianEventType.speechStarted: |
|||
case BailianEventType.speechContent: |
|||
case BailianEventType.speechEnded: |
|||
case BailianEventType.respondingStarted: |
|||
case BailianEventType.respondingContent: |
|||
case BailianEventType.respondingEnded: |
|||
_touchActivity(); |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
switch (e.type) { |
|||
case BailianEventType.speechStarted: |
|||
// 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声触发的 |
|||
if (!_uplinkGated) { |
|||
_spk?.discardPending(); |
|||
_cancelPlayoutTimer(); |
|||
} |
|||
_streamingUser = null; |
|||
break; |
|||
|
|||
case BailianEventType.speechContent: |
|||
_streamingUser ??= _store |
|||
.append(EmaiMessage(isUser: true, text: '', fromEarphone: true)); |
|||
_store.applyText(_streamingUser!, e.text); |
|||
if (e.isFinal) { |
|||
_store.finalize(_streamingUser); |
|||
_streamingUser = null; |
|||
} |
|||
break; |
|||
|
|||
case BailianEventType.speechEnded: |
|||
if (e.text.isNotEmpty && _streamingUser != null) { |
|||
_store.applyText(_streamingUser!, e.text); |
|||
} |
|||
_store.finalize(_streamingUser); |
|||
_streamingUser = null; |
|||
break; |
|||
|
|||
case BailianEventType.respondingStarted: |
|||
_respondingBytes = 0; |
|||
_firstAudioAt = null; |
|||
_respondingStartedAt = DateTime.now(); |
|||
_cancelPlayoutTimer(); |
|||
_streamingAssistant = null; |
|||
break; |
|||
|
|||
case BailianEventType.respondingContent: |
|||
if (e.directives.isNotEmpty && |
|||
Get.isRegistered<AssistantDirectiveService>()) { |
|||
AssistantDirectiveService.to.handle(e.directives); |
|||
} |
|||
// 「关闭对话」:模型会下发 quit 指令并同时回一句告别语。 |
|||
// 不能立刻结束——那样告别语还没播完就被掐了;标记下来,等播完再收尾。 |
|||
if (e.directives.any((d) => d.name == _quitDirective)) { |
|||
Logger.i(_tag, '收到 quit 指令,本轮播完后结束会话'); |
|||
_quitAfterPlayout = true; |
|||
} |
|||
// 纯指令帧没有文本,别建空气泡 |
|||
if (e.text.isEmpty && e.finishReason == 'command_calls') break; |
|||
_streamingAssistant ??= _store |
|||
.append(EmaiMessage(isUser: false, text: '', fromEarphone: true)); |
|||
_store.applyText(_streamingAssistant!, e.text); |
|||
break; |
|||
|
|||
case BailianEventType.respondingEnded: |
|||
if (e.text.isNotEmpty && _streamingAssistant != null) { |
|||
_store.applyText(_streamingAssistant!, e.text); |
|||
} |
|||
_store.finalize(_streamingAssistant); |
|||
_streamingAssistant = null; |
|||
_logResponseAudioStats(); |
|||
_schedulePlayoutDone(); |
|||
break; |
|||
|
|||
case BailianEventType.error: |
|||
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}'); |
|||
_store.dropIfEmpty(_streamingUser); |
|||
_streamingUser = null; |
|||
_store.dropIfEmpty(_streamingAssistant); |
|||
_streamingAssistant = null; |
|||
if (e.text.isNotEmpty) { |
|||
_store.append(EmaiMessage( |
|||
isUser: false, |
|||
text: e.text, |
|||
isFinal: true, |
|||
isError: true, |
|||
fromEarphone: true)); |
|||
_store.persist(); |
|||
} |
|||
if (e.fatal) _teardown(notifyDevice: true); |
|||
break; |
|||
|
|||
case BailianEventType.stateChanged: |
|||
// 把会话状态同步给设备,驱动它的指示灯/表情动画。 |
|||
// ⚠️ 只有声明了这个 feature 的厂商会真发字节,其余抛 not_supported 被吞掉。 |
|||
switch (e.state) { |
|||
case BailianDialogState.listening: |
|||
_pushAiState(DeviceAiState.listening); |
|||
break; |
|||
case BailianDialogState.thinking: |
|||
_pushAiState(DeviceAiState.thinking); |
|||
break; |
|||
case BailianDialogState.responding: |
|||
_pushAiState(DeviceAiState.speaking); |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
break; |
|||
|
|||
case BailianEventType.closed: |
|||
if (isActive.value) { |
|||
Logger.w(_tag, '百炼会话关闭,结束设备 AI'); |
|||
_teardown(notifyDevice: true); |
|||
} |
|||
break; |
|||
|
|||
default: |
|||
break; |
|||
} |
|||
} |
|||
|
|||
void _onDownstreamPcm(Uint8List pcm) { |
|||
if (!isActive.value || pcm.isEmpty) return; |
|||
if (_firstAudioAt == null) { |
|||
_firstAudioAt = DateTime.now(); |
|||
_openGate(); |
|||
} |
|||
_respondingBytes += pcm.length; |
|||
_downPackets++; |
|||
if (_downPackets == 1 || _downPackets % 50 == 0) { |
|||
Logger.i(_tag, '下行给设备 #$_downPackets (${pcm.length}B)'); |
|||
} |
|||
_spk?.write(pcm); |
|||
} |
|||
|
|||
/// 有动静就把静默计时重新拨回 5 秒。播报期间不计时。 |
|||
/// 设备是否支持 [DeviceFeatures.aiState]。不支持就别每次都试、别每次都打日志。 |
|||
bool _aiStateUnsupported = false; |
|||
|
|||
/// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`) |
|||
static const String _quitDirective = 'quit'; |
|||
|
|||
/// 收到 quit 指令,等这一轮告别语播完就收尾 |
|||
bool _quitAfterPlayout = false; |
|||
|
|||
/// 把 AI 会话状态推给设备(驱动指示灯/表情)。 |
|||
/// |
|||
/// **fire-and-forget**:设备不支持、或这条命令失败,都不该影响对话本身, |
|||
/// 所以既不 await 也不往上抛。 |
|||
void _pushAiState(DeviceAiState state) { |
|||
if (_aiStateUnsupported) return; |
|||
final s = _session; |
|||
if (s == null || s.state != DeviceConnectionState.ready) return; |
|||
s.invokeFeature(DeviceFeatures.aiState, {'state': state}).catchError( |
|||
(Object e) { |
|||
if (e is DeviceException && e.code == DeviceErrorCode.notSupported) { |
|||
// 这台设备没有表情/指示灯,记一次就够了 |
|||
_aiStateUnsupported = true; |
|||
Logger.i(_tag, '设备不支持 ai.state,后续不再推送会话状态'); |
|||
} else { |
|||
Logger.w(_tag, '推送 ai.state($state) 失败: $e'); |
|||
} |
|||
return <String, Object?>{}; |
|||
}, |
|||
); |
|||
} |
|||
|
|||
void _touchActivity() { |
|||
_idleTimer?.cancel(); |
|||
_idleTimer = null; |
|||
if (!isActive.value || _uplinkGated) return; |
|||
_idleTimer = Timer(_idleTimeout, () { |
|||
if (!isActive.value) return; |
|||
Logger.i(_tag, '双方 ${_idleTimeout.inSeconds}s 无声,自动结束 AI 会话'); |
|||
requestStop(); |
|||
}); |
|||
} |
|||
|
|||
/// 在设备里放一段提示音(走 AI 下行通道)。播放期间开门控。 |
|||
Future<void> _playCue(Uint8List pcm, {bool waitForPlayout = false}) async { |
|||
final spk = _spk; |
|||
if (spk == null || _session?.state != DeviceConnectionState.ready) return; |
|||
_openGate(); |
|||
await spk.write(pcm); |
|||
final ms = pcm.length * 1000 ~/ (_deviceRate * 2); |
|||
if (waitForPlayout) { |
|||
// 结束音要等它真的发完+播完,否则紧接着关通道会把它清掉 |
|||
await Future.delayed(Duration(milliseconds: ms + 400)); |
|||
} else { |
|||
_gateTimer?.cancel(); |
|||
_gateTimer = Timer(Duration(milliseconds: ms + 300), _closeGate); |
|||
} |
|||
} |
|||
|
|||
void _openGate() { |
|||
_gateTimer?.cancel(); |
|||
_gateTimer = null; |
|||
if (_uplinkGated) return; |
|||
_uplinkGated = true; |
|||
_idleTimer?.cancel(); |
|||
_idleTimer = null; |
|||
Logger.i(_tag, '上行门控开启(AI 播报中)'); |
|||
} |
|||
|
|||
void _closeGate() { |
|||
_gateTimer?.cancel(); |
|||
_gateTimer = null; |
|||
if (!_uplinkGated) return; |
|||
_uplinkGated = false; |
|||
Logger.i(_tag, '上行门控解除'); |
|||
_touchActivity(); |
|||
} |
|||
|
|||
void _logResponseAudioStats() { |
|||
final start = _firstAudioAt; |
|||
if (start == null || _respondingBytes == 0) return; |
|||
final ms = DateTime.now().difference(start).inMilliseconds; |
|||
final audioMs = _respondingBytes * 1000 ~/ (_deviceRate * 2); |
|||
Logger.i(_tag, |
|||
'本轮下行音频 ${_respondingBytes}B ≈ ${audioMs}ms(按16k算),实际用时 ${ms}ms'); |
|||
} |
|||
|
|||
/// 估算设备什么时候把这一轮播完,然后回 `LocalRespondingEnded`。 |
|||
void _schedulePlayoutDone() { |
|||
_cancelPlayoutTimer(); |
|||
const bytesPerSecond = _deviceRate * 2; |
|||
final totalMs = _respondingBytes * 1000 ~/ bytesPerSecond; |
|||
final elapsedMs = DateTime.now() |
|||
.difference(_respondingStartedAt ?? DateTime.now()) |
|||
.inMilliseconds; |
|||
// 上限 10 分钟,跟原生下行队列的容量对齐 |
|||
final remainMs = (totalMs - elapsedMs).clamp(0, 600000); |
|||
Logger.i(_tag, '预计还需 ${remainMs}ms 播完(已推 ${totalMs}ms 音频)'); |
|||
_playoutTimer = Timer(Duration(milliseconds: remainMs), () { |
|||
if (!isActive.value) return; |
|||
_svc.notifyLocalRespondingEnded(); |
|||
_gateTimer = Timer(_gateTail, _closeGate); |
|||
if (_quitAfterPlayout) { |
|||
_quitAfterPlayout = false; |
|||
Logger.i(_tag, '告别语已播完,按 quit 指令结束会话'); |
|||
_teardown(notifyDevice: true, playEndTone: false); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
void _cancelPlayoutTimer() { |
|||
_playoutTimer?.cancel(); |
|||
_playoutTimer = null; |
|||
} |
|||
|
|||
// ---------------- 结束 ---------------- |
|||
|
|||
/// 主动结束:发 ai.exit,等设备回 hangup;超时就本地强制收尾。 |
|||
Future<void> requestStop() async { |
|||
if (!isActive.value) return; |
|||
Logger.i(_tag, '请求设备退出 AI 模式'); |
|||
final s = _session; |
|||
if (s != null) await _sendExit(s); |
|||
_stopAckTimer?.cancel(); |
|||
_stopAckTimer = Timer(_stopAckTimeout, () { |
|||
if (!isActive.value) return; |
|||
Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到设备的结束应答,本地强制结束'); |
|||
_teardown(notifyDevice: false); |
|||
}); |
|||
} |
|||
|
|||
Future<void> _sendExit(DeviceSession s) async { |
|||
if (s.state != DeviceConnectionState.ready) return; |
|||
try { |
|||
await s.invokeFeature(DeviceFeatures.aiExit); |
|||
} catch (e) { |
|||
Logger.w(_tag, '发送 ai.exit 失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// [notifyDevice] 为 true 时补发 ai.exit:不是设备主导的结束,必须告诉设备退出。 |
|||
/// [playEndTone] 为 false 用于设备已经断连的场景。 |
|||
Future<void> _teardown({ |
|||
required bool notifyDevice, |
|||
bool playEndTone = true, |
|||
}) async { |
|||
if (_tearingDown) return; |
|||
_tearingDown = true; |
|||
try { |
|||
await _doTeardown(notifyDevice: notifyDevice, playEndTone: playEndTone); |
|||
} finally { |
|||
_tearingDown = false; |
|||
} |
|||
} |
|||
|
|||
Future<void> _doTeardown({ |
|||
required bool notifyDevice, |
|||
bool playEndTone = true, |
|||
}) async { |
|||
_stopAckTimer?.cancel(); |
|||
_stopAckTimer = null; |
|||
_idleTimer?.cancel(); |
|||
_idleTimer = null; |
|||
_cancelPlayoutTimer(); |
|||
_micAccum.clear(); |
|||
|
|||
if (isActive.value && playEndTone) { |
|||
// 先把还没播完的 AI 语音丢掉,不然「咚」要排在几十秒的队尾 |
|||
await _spk?.discardPending(); |
|||
await _playCue(CueTone.dong(sampleRate: _deviceRate), waitForPlayout: true); |
|||
} |
|||
_closeGate(); |
|||
|
|||
final wasActive = isActive.value; |
|||
isActive.value = false; |
|||
|
|||
_store.dropIfEmpty(_streamingUser); |
|||
_streamingUser = null; |
|||
_store.dropIfEmpty(_streamingAssistant); |
|||
_streamingAssistant = null; |
|||
if (wasActive) _store.persist(); |
|||
|
|||
await _eventSub?.cancel(); |
|||
_eventSub = null; |
|||
await _audioSub?.cancel(); |
|||
_audioSub = null; |
|||
await _micSub?.cancel(); |
|||
_micSub = null; |
|||
|
|||
await _spk?.close(); |
|||
_spk = null; |
|||
await _mic?.close(); |
|||
_mic = null; |
|||
await _svc.stop(); |
|||
|
|||
final s = _session; |
|||
if (wasActive && notifyDevice && s != null) await _sendExit(s); |
|||
_session = null; |
|||
if (wasActive) { |
|||
Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)'); |
|||
await _resumePhonePage(); |
|||
} |
|||
} |
|||
|
|||
// ---------------- 与手机 EMAI 页互斥 ---------------- |
|||
|
|||
void attachPhonePage(EmaiSessionOwner page) => _phonePage = page; |
|||
|
|||
void detachPhonePage(EmaiSessionOwner page) { |
|||
if (identical(_phonePage, page)) _phonePage = null; |
|||
} |
|||
|
|||
Future<void> _yieldPhonePage() async { |
|||
final ctrl = _phonePage; |
|||
if (ctrl == null) return; |
|||
Logger.i(_tag, '手机 EMAI 页正开着,让它让出会话'); |
|||
try { |
|||
await ctrl.yieldToEarphone(); |
|||
} catch (e) { |
|||
Logger.w(_tag, '通知 EMAI 页让出失败: $e'); |
|||
} |
|||
} |
|||
|
|||
Future<void> _resumePhonePage() async { |
|||
final ctrl = _phonePage; |
|||
if (ctrl == null) return; |
|||
try { |
|||
await ctrl.resumeFromEarphone(); |
|||
} catch (e) { |
|||
Logger.w(_tag, '通知 EMAI 页恢复失败: $e'); |
|||
} |
|||
} |
|||
|
|||
String _currentUserId() { |
|||
try { |
|||
return user_info.User.isLoggedIn() |
|||
? user_info.User.instance.uid |
|||
: 'eaimar-guest'; |
|||
} catch (_) { |
|||
return 'eaimar-guest'; |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 手机 EMAI 页实现这个接口,让设备会话能在不反向依赖 modules 层的前提下 |
|||
/// 通知它让出/恢复。EmaiController 在 onInit/onClose 里 |
|||
/// 调 [DeviceAiSessionService.attachPhonePage] / [DeviceAiSessionService.detachPhonePage]。 |
|||
abstract class EmaiSessionOwner { |
|||
/// 设备接管:停掉本页的播放与会话,并且不要自动重连 |
|||
Future<void> yieldToEarphone(); |
|||
|
|||
/// 设备结束:把本页的会话接回来 |
|||
Future<void> resumeFromEarphone(); |
|||
} |
|||
@ -0,0 +1,296 @@ |
|||
import 'dart:async'; |
|||
import 'dart:io'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:intl/intl.dart'; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
|
|||
import '../../core/utils/audio_foreground_service.dart'; |
|||
import '../../core/utils/logger.dart'; |
|||
import '../../devices/device_hub.dart'; |
|||
import 'recording_archive.dart'; |
|||
import 'wav_writer.dart'; |
|||
|
|||
/// **通话录音**的全局服务——厂商无关,要求设备具备 [DeviceCapability.callAudioTap]。 |
|||
/// |
|||
/// 两路音频 `openMic(callLocal)`(本端麦)+ `openMic(callPeer)`(对端声音), |
|||
/// 落成**双声道 WAV:左=本端,右=对端**。恒玄是 G.722 mode=0x03 交织上行, |
|||
/// 原生按帧标好 mic/spk 后分两路给出,这里按到达顺序配对。 |
|||
/// |
|||
/// 只在通话中可用([DeviceHub.inCall]),通话结束自动收尾。 |
|||
class DeviceCallRecordingService extends GetxService { |
|||
static const String _tag = 'DeviceCallRecording'; |
|||
static DeviceCallRecordingService get to => Get.find(); |
|||
|
|||
final RxBool isRecording = false.obs; |
|||
final RxInt elapsedMs = 0.obs; |
|||
final RxDouble level = 0.0.obs; |
|||
final RxList<double> waveform = <double>[].obs; |
|||
static const int sampleIntervalMs = 100; |
|||
static const int _maxSamples = 600; |
|||
|
|||
StreamSubscription? _hubSub; |
|||
Worker? _callWorker; |
|||
DeviceSession? _session; |
|||
DeviceAudioSource? _local; |
|||
DeviceAudioSource? _peer; |
|||
StreamSubscription<Uint8List>? _localSub; |
|||
StreamSubscription<Uint8List>? _peerSub; |
|||
|
|||
/// 暂存的对端帧,等本端帧到齐后一起交织写盘 |
|||
Uint8List? _pendingPeer; |
|||
int _frameCount = 0; |
|||
Timer? _silenceCheck; |
|||
WavWriter? _wav; |
|||
String? _path; |
|||
Timer? _tick; |
|||
Timer? _sampleTimer; |
|||
final Stopwatch _stopwatch = Stopwatch(); |
|||
bool _busy = false; |
|||
|
|||
/// 本次录音是不是设备按键发起的。设备侧处于录音态时,停止要额外补一条 |
|||
/// `record.stop` 让它自己也退出来,只发 `call.stop` 不足以清掉设备端的录音状态。 |
|||
bool _deviceRecordingActive = false; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_hubSub = DeviceHub.to.sessionEvents.listen(_onSessionEvent); |
|||
// 通话结束时如果还在录,收尾归档(不能让文件一直挂着) |
|||
_callWorker = ever<bool>(DeviceHub.to.inCall, (calling) async { |
|||
if (!calling && isRecording.value && !_busy) { |
|||
Logger.i(_tag, '通话结束,停止并归档'); |
|||
await stop(sendCommand: true); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_hubSub?.cancel(); |
|||
_callWorker?.dispose(); |
|||
_localSub?.cancel(); |
|||
_peerSub?.cancel(); |
|||
_tick?.cancel(); |
|||
_sampleTimer?.cancel(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
void _onSessionEvent(DeviceSessionEvent e) async { |
|||
switch (e.type) { |
|||
case DeviceSessionEventType.feature: |
|||
if (_busy) return; |
|||
final key = e.feature?.key; |
|||
// 通话开始**不再自动录音**。入口只有两个:录音页点开始、设备按录音键。 |
|||
if (key == DeviceFeatureKeys.recordStarted && !isRecording.value) { |
|||
if (!DeviceHub.to.inCall.value) return; // 不在通话里的按键归现场录音管 |
|||
Logger.i(_tag, '设备端按下录音键,开始通话录音'); |
|||
_deviceRecordingActive = true; |
|||
await start(sendCommand: true, deviceId: e.deviceId); |
|||
} else if (key == DeviceFeatureKeys.recordStopped && isRecording.value) { |
|||
Logger.i(_tag, '设备端结束录音,落盘并归档'); |
|||
_deviceRecordingActive = false; |
|||
await stop(sendCommand: true); |
|||
} |
|||
break; |
|||
case DeviceSessionEventType.connectionStateChanged: |
|||
if (e.connectionState == DeviceConnectionState.disconnected && |
|||
isRecording.value && |
|||
_session?.deviceId == e.deviceId) { |
|||
Logger.w(_tag, '设备断开,结束当前通话录音'); |
|||
stop(); |
|||
} |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
} |
|||
|
|||
Future<bool> start({bool sendCommand = false, String? deviceId}) async { |
|||
if (isRecording.value || _busy) return isRecording.value; |
|||
_busy = true; |
|||
try { |
|||
return await _start(sendCommand, deviceId); |
|||
} finally { |
|||
_busy = false; |
|||
} |
|||
} |
|||
|
|||
Future<bool> _start(bool sendCommand, String? deviceId) async { |
|||
final hub = DeviceHub.to; |
|||
final session = (deviceId == null |
|||
? null |
|||
: hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId)) ?? |
|||
hub.sessionWith(DeviceCapability.callAudioTap); |
|||
if (session == null) return false; |
|||
_session = session; |
|||
|
|||
try { |
|||
final docs = await getApplicationDocumentsDirectory(); |
|||
final dir = Directory('${docs.path}/call_recordings'); |
|||
if (!await dir.exists()) await dir.create(recursive: true); |
|||
final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); |
|||
_path = '${dir.path}/call_$ts.wav'; |
|||
_wav = await WavWriter.open(_path!, channels: 2); |
|||
|
|||
_pendingPeer = null; |
|||
_frameCount = 0; |
|||
_peer = await session.openMic(route: AudioRoute.callPeer); |
|||
_local = await session.openMic(route: AudioRoute.callLocal); |
|||
_peerSub = _peer!.pcm.listen(_onPeer); |
|||
_localSub = _local!.pcm.listen(_onLocal); |
|||
|
|||
// 进通话模式,设备才会双路上行。不管是不是 App 主动发起的都补发一次—— |
|||
// 只靠设备自己进通话模式时,它未必会把音频回传给 App。 |
|||
await session.invokeFeature(DeviceFeatures.callStart); |
|||
|
|||
// 2 秒还没收到任何音频帧就再补一次指令,并把情况打进日志 |
|||
_silenceCheck?.cancel(); |
|||
_silenceCheck = Timer(const Duration(seconds: 2), () async { |
|||
if (!isRecording.value) return; |
|||
if (_frameCount == 0) { |
|||
Logger.w(_tag, '进通话模式 2s 内没收到任何音频帧,重发 call.start'); |
|||
try { |
|||
await session.invokeFeature(DeviceFeatures.callStart); |
|||
} catch (_) {} |
|||
} else { |
|||
Logger.i(_tag, '通话音频已上行,2s 内收到 $_frameCount 帧'); |
|||
} |
|||
}); |
|||
|
|||
// 把进程钉住,否则切后台后录音会被国内 ROM 悄悄掐断 |
|||
unawaited(AudioForegroundService.start()); |
|||
|
|||
isRecording.value = true; |
|||
elapsedMs.value = 0; |
|||
level.value = 0; |
|||
waveform.clear(); |
|||
_stopwatch |
|||
..reset() |
|||
..start(); |
|||
_tick = Timer.periodic(const Duration(milliseconds: 200), (_) { |
|||
elapsedMs.value = _stopwatch.elapsedMilliseconds; |
|||
}); |
|||
_sampleTimer = Timer.periodic( |
|||
const Duration(milliseconds: sampleIntervalMs), |
|||
(_) => _sample(), |
|||
); |
|||
Logger.i(_tag, '开始录制: $_path'); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.e(_tag, '启动本地采集失败: $e'); |
|||
await _closeFile(save: false); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
Future<void> stop({bool sendCommand = false}) async { |
|||
if (!isRecording.value || _busy) return; |
|||
_busy = true; |
|||
try { |
|||
await _stop(sendCommand); |
|||
} finally { |
|||
_busy = false; |
|||
} |
|||
} |
|||
|
|||
Future<void> _stop(bool sendCommand) async { |
|||
_silenceCheck?.cancel(); |
|||
Logger.i(_tag, '停止通话录音: 收到 $_frameCount 帧,写入 ${_wav?.dataBytes} 字节'); |
|||
_tick?.cancel(); |
|||
_sampleTimer?.cancel(); |
|||
_stopwatch.stop(); |
|||
|
|||
final int seconds = elapsedMs.value ~/ 1000; |
|||
final String? path = _path; |
|||
final session = _session; |
|||
|
|||
if (sendCommand && |
|||
session != null && |
|||
session.state == DeviceConnectionState.ready) { |
|||
try { |
|||
// 设备侧还在录音态时先让它停录,再退出通话模式。顺序不能反: |
|||
// 先退通话模式的话,录音停止指令未必还能被正确处理。 |
|||
if (_deviceRecordingActive) { |
|||
await session.invokeFeature(DeviceFeatures.recordStop); |
|||
Logger.i(_tag, '已下发 record.stop,通知设备退出录音态'); |
|||
} |
|||
await session.invokeFeature(DeviceFeatures.callStop); |
|||
} catch (e) { |
|||
Logger.e(_tag, '下发停止指令失败: $e'); |
|||
} |
|||
} |
|||
_deviceRecordingActive = false; |
|||
|
|||
final dataBytes = _wav?.dataBytes ?? 0; |
|||
await _closeFile(save: true); |
|||
unawaited(AudioForegroundService.stop()); |
|||
isRecording.value = false; |
|||
elapsedMs.value = 0; |
|||
level.value = 0; |
|||
waveform.clear(); |
|||
_session = null; |
|||
|
|||
if (path != null && dataBytes > 0) { |
|||
await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag); |
|||
} else { |
|||
Logger.w(_tag, '本次没有收到音频数据,删除空文件'); |
|||
if (path != null) { |
|||
try { |
|||
final f = File(path); |
|||
if (await f.exists()) await f.delete(); |
|||
} catch (e) { |
|||
Logger.e(_tag, '删除空录音文件失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
_path = null; |
|||
} |
|||
|
|||
void _onPeer(Uint8List pcm) { |
|||
if (_wav == null || pcm.isEmpty) return; |
|||
_frameCount++; |
|||
_pendingPeer = pcm; |
|||
} |
|||
|
|||
void _onLocal(Uint8List pcm) { |
|||
if (_wav == null || pcm.isEmpty) return; |
|||
_frameCount++; |
|||
if (_frameCount <= 3 || _frameCount % 200 == 0) { |
|||
Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}'); |
|||
} |
|||
final peer = _pendingPeer; |
|||
_pendingPeer = null; |
|||
if (peer == null) return; |
|||
// 左声道=本端麦克风,右声道=对端声音 |
|||
_wav!.write(WavWriter.interleave(pcm, peer), onWritten: () { |
|||
level.value = WavWriter.rmsLevel(pcm); |
|||
}); |
|||
} |
|||
|
|||
void _sample() { |
|||
final list = waveform.toList(); |
|||
list.add(level.value); |
|||
if (list.length > _maxSamples) { |
|||
list.removeRange(0, list.length - _maxSamples); |
|||
} |
|||
waveform.assignAll(list); |
|||
} |
|||
|
|||
Future<void> _closeFile({required bool save}) async { |
|||
await _localSub?.cancel(); |
|||
await _peerSub?.cancel(); |
|||
_localSub = null; |
|||
_peerSub = null; |
|||
await _local?.close(); |
|||
await _peer?.close(); |
|||
_local = null; |
|||
_peer = null; |
|||
final w = _wav; |
|||
_wav = null; |
|||
if (w == null) return; |
|||
await w.close(save: save); |
|||
} |
|||
} |
|||
@ -0,0 +1,489 @@ |
|||
import 'dart:async'; |
|||
import 'dart:io'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
import 'package:path/path.dart' as p; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import '../../devices/device_hub.dart'; |
|||
import '../utils/device_file_match.dart'; |
|||
import '../utils/ogg_opus_duration.dart'; |
|||
import 'db/sqflite_api.dart'; |
|||
import 'recording_archive.dart'; |
|||
|
|||
/// 把「设备自己录在本机存储里的音频」同步进语音纪要 —— **厂商无关**。 |
|||
/// |
|||
/// 适用于任何声明了 [DeviceCapability.fileStore] 的设备。当前只有 EaiRec 支架 |
|||
/// (TF 卡,通道 B),但录音笔、眼镜都是同一个形态,接进来零改动。 |
|||
/// |
|||
/// 触发时机三个,缺一不可: |
|||
/// 1. 设备上线(连上后延时几秒,等厂商插件把文件通道握完手); |
|||
/// 2. 设备**主动推送**文件列表——多数硬件在「录音停止」后推一次, |
|||
/// 这是「刚录完立刻出现在列表里」的唯一及时来源; |
|||
/// 3. 用户在语音纪要页手动下拉/点同步([importNow])。 |
|||
/// |
|||
/// **导入成功并确认已上传服务端之后,会把设备上的原件删掉**([autoDeleteAfterImport])。 |
|||
/// |
|||
/// ⚠️ 这一步是必须的,不是可选的优化:协议里**没有任何自动回收/循环覆盖机制** |
|||
/// (EaiRec_protocol.md §5.3 有 `NO_SPACE = 0x08`,§5.10 的 `FILE_DELETE` 是 |
|||
/// 唯一的单文件清理手段且**只能 App 主动发**)。不删,卡就一直涨到满, |
|||
/// 然后设备直接录不进去。 |
|||
/// |
|||
/// ⚠️ **删除的门槛是「已经传到服务端」,不是「已经落到手机上」**: |
|||
/// 判据是本地库里这条记录的 `audiourl` 非空([MeetingUploadService] 上传成功后写的)。 |
|||
/// 所以删除通常发生在**后面某一轮**同步,不是导入的那一刻。 |
|||
/// 断网、上传失败、上传队列还没轮到 —— 这些情况下设备原件一律不动, |
|||
/// 这是刻意选的失败方向:宁可卡满要人工清,也不能把用户唯一那份录音销毁掉。 |
|||
class DeviceFileImportService extends GetxService { |
|||
static const String _tag = 'DeviceFileImport'; |
|||
|
|||
static DeviceFileImportService get to => Get.find(); |
|||
|
|||
/// 已导入的文件指纹(`设备id|文件名|字节数`)→ 语音纪要里的记录 id。 |
|||
/// |
|||
/// ⚠️ 指纹带上字节数是有意的:8.3 短文件名会**回绕复用**(`MIC0001.OGG` 删掉后 |
|||
/// 下次录音可能又叫这个名),只按文件名记会把新录音当成已导入而永远跳过。 |
|||
/// |
|||
/// 记 id 而不只是记「导过了」,是因为删除设备原件前要回查这条记录传上去没有。 |
|||
static const String _doneKey = 'device_files_imported'; |
|||
|
|||
/// 导入并确认上传后,删掉设备上的原件。默认开,理由见类文档。 |
|||
static const String _autoDeleteKey = 'device_files_auto_delete'; |
|||
|
|||
bool get autoDeleteAfterImport => |
|||
_storage.read<bool>(_autoDeleteKey) ?? true; |
|||
|
|||
set autoDeleteAfterImport(bool v) => _storage.write(_autoDeleteKey, v); |
|||
|
|||
/// 本轮清理掉的设备文件数,UI 可用来提示「已释放空间」 |
|||
final RxInt deletedCount = 0.obs; |
|||
|
|||
final GetStorage _storage = GetStorage(); |
|||
|
|||
/// 正在导入 |
|||
final RxBool isImporting = false.obs; |
|||
|
|||
/// 当前正在拉的文件名(没有则空串) |
|||
final RxString currentFile = ''.obs; |
|||
|
|||
/// 当前文件的进度 0~1;设备没给总长度时为 -1(不确定态) |
|||
final RxDouble progress = 0.0.obs; |
|||
|
|||
/// 本轮还剩几个没拉 |
|||
final RxInt pending = 0.obs; |
|||
|
|||
/// 最近一次失败原因,成功时清空。UI 可以拿它提示,但**不要弹窗**—— |
|||
/// 这是后台同步,失败下次连上还会再来。 |
|||
final RxString lastError = ''.obs; |
|||
|
|||
/// 本进程内累计导入成功的条数,UI 用它做「同步完成」提示 |
|||
final RxInt importedCount = 0.obs; |
|||
|
|||
Worker? _sessionsWorker; |
|||
StreamSubscription<List<DeviceFileEntry>>? _pushSub; |
|||
Timer? _debounce; |
|||
DeviceFileCancelToken? _cancel; |
|||
bool _running = false; |
|||
|
|||
/// 已经挂上推送订阅的那个 session 的 deviceId,避免重复订阅 |
|||
String _boundDeviceId = ''; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_sessionsWorker = ever<List<DeviceSession>>( |
|||
DeviceHub.to.sessions, |
|||
(_) => _onSessionsChanged(), |
|||
); |
|||
_onSessionsChanged(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_sessionsWorker?.dispose(); |
|||
_pushSub?.cancel(); |
|||
_debounce?.cancel(); |
|||
_cancel?.cancel(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
// ---------------- 触发 ---------------- |
|||
|
|||
void _onSessionsChanged() { |
|||
final s = DeviceHub.to.sessionWith(DeviceCapability.fileStore); |
|||
final store = s?.fileStore(); |
|||
if (s == null || store == null) { |
|||
_pushSub?.cancel(); |
|||
_pushSub = null; |
|||
_boundDeviceId = ''; |
|||
// 设备走了就把在途传输掐掉,不然会在超时里卡好几十秒 |
|||
_cancel?.cancel(); |
|||
return; |
|||
} |
|||
if (_boundDeviceId == s.deviceId) return; |
|||
_boundDeviceId = s.deviceId; |
|||
_pushSub?.cancel(); |
|||
_pushSub = store.listPushes.listen( |
|||
(_) { |
|||
Logger.i(_tag, '设备推送了文件列表,安排一次同步'); |
|||
_schedule(const Duration(seconds: 1)); |
|||
}, |
|||
onError: (Object e) => Logger.w(_tag, '文件列表推送流错误: $e'), |
|||
); |
|||
// ⚠️ 连上后不能立刻拉:厂商插件那边通道还在握手(支架要对时 + 查状态), |
|||
// 这几秒省不掉,抢在前面只会拿到一个「通道不可用」。 |
|||
_schedule(const Duration(seconds: 5)); |
|||
} |
|||
|
|||
void _schedule(Duration delay) { |
|||
_debounce?.cancel(); |
|||
_debounce = Timer(delay, () => unawaited(importNow())); |
|||
} |
|||
|
|||
/// 「导完了但还没删掉」时的回扫次数上限。 |
|||
/// |
|||
/// ⚠️ 刚导完那一轮基本删不掉任何东西:上传还在 [MeetingUploadService] 的队列里, |
|||
/// 而删除的门槛是「已上传」。所以要过几分钟再回来收一次。 |
|||
/// **必须有上限**:上传一直失败(没网/额度用尽)的话,无上限就是每 3 分钟 |
|||
/// 白拉一次 FILE_LIST,永远停不下来。删掉东西或导入新文件会把计数清零。 |
|||
static const int _maxCleanupRetries = 5; |
|||
static const Duration _cleanupRetryDelay = Duration(minutes: 3); |
|||
int _cleanupRetries = 0; |
|||
|
|||
/// 本轮导入成功的条数([importedCount] 是进程累计值,两者别混) |
|||
int _importedThisSweep = 0; |
|||
|
|||
/// 设备上还有「导过、但还没删」的文件时,安排一次回扫。 |
|||
void _scheduleCleanupRetry({required bool madeProgress}) { |
|||
if (!autoDeleteAfterImport) return; |
|||
if (madeProgress) _cleanupRetries = 0; |
|||
if (_doneMap().isEmpty) return; // 设备上没有我们导过的文件了 |
|||
if (_cleanupRetries >= _maxCleanupRetries) { |
|||
Logger.i(_tag, '回扫 $_cleanupRetries 次仍无可删文件(多半是上传没完成),暂停'); |
|||
return; |
|||
} |
|||
_cleanupRetries++; |
|||
Logger.i(_tag, '安排第 $_cleanupRetries 次清理回扫(${_cleanupRetryDelay.inMinutes} 分钟后)'); |
|||
_schedule(_cleanupRetryDelay); |
|||
} |
|||
|
|||
/// 手动触发一次同步。已在跑就直接返回,不排队。 |
|||
Future<void> importNow() async { |
|||
if (_running) return; |
|||
final session = DeviceHub.to.sessionWith(DeviceCapability.fileStore); |
|||
final store = session?.fileStore(); |
|||
if (session == null || store == null) return; |
|||
|
|||
_running = true; |
|||
isImporting.value = true; |
|||
lastError.value = ''; |
|||
deletedCount.value = 0; |
|||
_importedThisSweep = 0; |
|||
final cancel = DeviceFileCancelToken(); |
|||
_cancel = cancel; |
|||
try { |
|||
await _sweep(session, store, cancel); |
|||
} catch (e) { |
|||
lastError.value = '$e'; |
|||
Logger.w(_tag, '同步失败: $e'); |
|||
} finally { |
|||
_running = false; |
|||
isImporting.value = false; |
|||
currentFile.value = ''; |
|||
progress.value = 0; |
|||
pending.value = 0; |
|||
_cancel = null; |
|||
// ⚠️ 这里要用**本轮**的计数。importedCount 是进程累计值, |
|||
// 拿它判「有进展」会让重试计数永远清零,退化成无限回扫。 |
|||
_scheduleCleanupRetry( |
|||
madeProgress: deletedCount.value > 0 || _importedThisSweep > 0); |
|||
} |
|||
} |
|||
|
|||
/// 取消正在进行的同步(用户离开页面 / 主动断开设备) |
|||
void cancel() => _cancel?.cancel(); |
|||
|
|||
// ---------------- 主流程 ---------------- |
|||
|
|||
Future<void> _sweep( |
|||
DeviceSession session, |
|||
DeviceFileStore store, |
|||
DeviceFileCancelToken cancel, |
|||
) async { |
|||
// 设备正在录音时整轮都别做:通道 B 是单事务的,插一条文件传输进去 |
|||
// 会和录音抢链路(协议 §7.1)。等它录完自然会推列表,那时再来。 |
|||
if (store.isDeviceRecording) { |
|||
Logger.i(_tag, '设备正在录音,本轮跳过'); |
|||
return; |
|||
} |
|||
|
|||
var files = await store.list(); |
|||
|
|||
// 先清理再导入:先腾空间再往卡里写,卡快满时只有这个顺序救得回来。 |
|||
// 清完重新拉一次列表,免得拿着已删的文件去下载。 |
|||
final before = files.length; |
|||
await _cleanup(session, store, files, cancel); |
|||
if (cancel.isCancelled) return; |
|||
if (deletedCount.value > 0) { |
|||
files = await store.list(); |
|||
Logger.i(_tag, '清理后设备上剩 ${files.length} 个文件(清理前 $before)'); |
|||
} |
|||
|
|||
final done = _doneMap(); |
|||
final todo = <DeviceFileEntry>[]; |
|||
for (final f in files) { |
|||
// 0 字节的多半是刚建还没写的占位,拉下来是个空文件 |
|||
if (f.size <= 0) continue; |
|||
if (done.containsKey(_fingerprint(session.deviceId, f))) continue; |
|||
todo.add(f); |
|||
} |
|||
Logger.i(_tag, '设备上 ${files.length} 个文件,需要导入 ${todo.length} 个'); |
|||
if (todo.isEmpty) return; |
|||
|
|||
// 旧的先导,用户的时间线才是顺的 |
|||
todo.sort((a, b) { |
|||
final ta = a.modifiedAt; |
|||
final tb = b.modifiedAt; |
|||
if (ta == null || tb == null) return a.name.compareTo(b.name); |
|||
return ta.compareTo(tb); |
|||
}); |
|||
|
|||
pending.value = todo.length; |
|||
final tmpDir = Directory( |
|||
'${(await getApplicationDocumentsDirectory()).path}/DeviceImport'); |
|||
if (!await tmpDir.exists()) await tmpDir.create(recursive: true); |
|||
|
|||
for (final f in todo) { |
|||
if (cancel.isCancelled) { |
|||
Logger.i(_tag, '同步被取消,剩 ${pending.value} 个未导入'); |
|||
return; |
|||
} |
|||
// 每个文件都重新问一次:用户可能中途按了设备上的录音键 |
|||
if (store.isDeviceRecording) { |
|||
Logger.i(_tag, '设备开始录音,本轮中止'); |
|||
return; |
|||
} |
|||
await _importOne(session, store, f, tmpDir, cancel); |
|||
pending.value = pending.value - 1; |
|||
} |
|||
} |
|||
|
|||
Future<void> _importOne( |
|||
DeviceSession session, |
|||
DeviceFileStore store, |
|||
DeviceFileEntry f, |
|||
Directory tmpDir, |
|||
DeviceFileCancelToken cancel, |
|||
) async { |
|||
currentFile.value = f.name; |
|||
progress.value = 0; |
|||
final localName = _localName(session, f); |
|||
final destPath = '${tmpDir.path}/$localName'; |
|||
|
|||
try { |
|||
// ⚠️ 同名残留会被 SDK 当成断点续传的半成品接着写。**这正是要的行为**: |
|||
// 上一次没拉完的下次接着拉,不用从头来(BLE 上一个 10 分钟的录音要好几分钟)。 |
|||
await store.download( |
|||
f.name, |
|||
destPath: destPath, |
|||
cancelToken: cancel, |
|||
onProgress: (received, total) { |
|||
progress.value = total > 0 ? received / total : -1; |
|||
}, |
|||
); |
|||
} on DeviceException catch (e) { |
|||
if (e.code == DeviceErrorCode.cancelled) return; // 取消不是错误 |
|||
lastError.value = '${f.name}: ${e.message ?? e.code}'; |
|||
Logger.w(_tag, '拉取失败 ${f.name}: $e'); |
|||
return; |
|||
} |
|||
|
|||
if (cancel.isCancelled) return; |
|||
|
|||
final seconds = await _probeSeconds(destPath, f.size); |
|||
final id = await RecordingArchive.archive( |
|||
path: destPath, |
|||
seconds: seconds, |
|||
tag: _tag, |
|||
// 后台批量同步,一条弹一次会把屏幕刷满 |
|||
notify: false, |
|||
// 列表要按「什么时候录的」排,不是「什么时候同步的」 |
|||
createdAt: f.modifiedAt, |
|||
); |
|||
if (id == null) { |
|||
// 归档失败:文件还在暂存目录,**不记已导入**,下次连上重来。 |
|||
Logger.w(_tag, '归档失败,保留 $destPath 待下次重试'); |
|||
lastError.value = '${f.name}: 归档失败'; |
|||
return; |
|||
} |
|||
// 记下记录 id:删设备原件前要靠它回查这条传上去没有 |
|||
_markDone(session.deviceId, f, id); |
|||
importedCount.value = importedCount.value + 1; |
|||
_importedThisSweep++; |
|||
Logger.i(_tag, '已导入 ${f.name} → 语音纪要 id=$id (${seconds}s)'); |
|||
} |
|||
|
|||
// ---------------- 辅助 ---------------- |
|||
|
|||
/// 导入后在语音纪要里显示的名字。 |
|||
/// |
|||
/// 设备上是 8.3 短名(`MIC0016.OGG`),直接拿来当标题既看不出是什么时候录的, |
|||
/// 又会因为名字回绕而重复。所以拼成 `holder_20260910_203015_MIC0016.ogg`: |
|||
/// 前缀沿用 [RecordingArchive] 的约定,中间是设备记的录制时间。 |
|||
String _localName(DeviceSession session, DeviceFileEntry f) { |
|||
final t = f.modifiedAt ?? DateTime.now(); |
|||
String two(int v) => v.toString().padLeft(2, '0'); |
|||
final stamp = '${t.year}${two(t.month)}${two(t.day)}_' |
|||
'${two(t.hour)}${two(t.minute)}${two(t.second)}'; |
|||
final base = p.basenameWithoutExtension(f.name); |
|||
final ext = p.extension(f.name).toLowerCase(); |
|||
return '${session.vendor}_${stamp}_$base${ext.isEmpty ? '.ogg' : ext}'; |
|||
} |
|||
|
|||
/// 读音频时长(秒)。 |
|||
/// |
|||
/// 走 Ogg 容器的 granule position,见 [oggOpusSeconds] 里的说明 |
|||
/// (不解码、不依赖播放器,因为播放器解不了这种裸 Ogg Opus)。 |
|||
Future<int> _probeSeconds(String path, int sizeHint) async { |
|||
try { |
|||
final secs = await oggOpusSeconds(File(path)); |
|||
if (secs != null && secs > 0) return secs.round(); |
|||
} catch (e) { |
|||
Logger.w(_tag, '解析 Ogg 时长失败,退回按码率估: $e'); |
|||
} |
|||
// 兜底:非 Ogg 或文件损坏。16kbps = 2000 B/s,是支架当前固件的规格。 |
|||
// ⚠️ 这个估值会偏出一倍(真机实测),只是不得已时不至于显示 0。 |
|||
final est = sizeHint ~/ 2000; |
|||
return est > 0 ? est : 1; |
|||
} |
|||
|
|||
String _fingerprint(String deviceId, DeviceFileEntry f) => |
|||
'$deviceId|${f.name}|${f.size}'; |
|||
|
|||
/// 指纹 → 语音纪要记录 id。 |
|||
/// |
|||
/// ⚠️ 兼容旧格式:这个键早先存的是 `List<String>`(只有指纹、没有 id)。 |
|||
/// 那些条目读成 id=0,含义是「导过了,但不知道是哪条记录」—— |
|||
/// **id=0 的永远不会被自动删除**,宁可留在卡上让人工清。 |
|||
Map<String, int> _doneMap() { |
|||
final raw = _storage.read(_doneKey); |
|||
if (raw is Map) { |
|||
return raw.map((k, v) => MapEntry('$k', v is int ? v : 0)); |
|||
} |
|||
if (raw is List) { |
|||
return {for (final e in raw.whereType<String>()) e: 0}; |
|||
} |
|||
return <String, int>{}; |
|||
} |
|||
|
|||
void _writeDone(Map<String, int> m) { |
|||
// 无上限地长会让每次启动都读一坨;设备卡上撑死几百个文件,留 2000 条足够。 |
|||
// 超了从最早的开始丢(丢了最坏情况是重导一次,不会丢数据)。 |
|||
var out = m; |
|||
if (out.length > 2000) { |
|||
final keys = out.keys.toList().sublist(out.length - 2000); |
|||
out = {for (final k in keys) k: out[k]!}; |
|||
} |
|||
_storage.write(_doneKey, out); |
|||
} |
|||
|
|||
void _markDone(String deviceId, DeviceFileEntry f, int meetingId) { |
|||
_writeDone(_doneMap()..[_fingerprint(deviceId, f)] = meetingId); |
|||
} |
|||
|
|||
/// 已经确认传到服务端的录音:标题(大写)→ 本机音频路径。 |
|||
/// |
|||
/// 上传成功时 [MeetingUploadService] 把 audiourl 写回本地库 |
|||
/// (`SqfliteApi.editMeetingTitle(id, {'audiourl': ...})`),所以读本地库就够了。 |
|||
/// |
|||
/// ⚠️ **按标题反查而不是只认那张指纹表**,是为了自愈:指纹表可能是旧格式、 |
|||
/// 也可能因为超长被截断过,而标题里带着设备原文件名。 |
|||
/// |
|||
/// 读不到就返回空表:这一轮谁都不删,失败方向是「不删」。 |
|||
Future<Map<String, String>> _uploadedRecords() async { |
|||
try { |
|||
final list = await SqfliteApi.getMeetingList(); |
|||
final out = <String, String>{}; |
|||
for (final row in (list as List?) ?? const []) { |
|||
if (row is! Map) continue; |
|||
if ((row['audiourl'] ?? '').toString().isEmpty) continue; |
|||
final title = (row['title'] ?? '').toString(); |
|||
final path = (row['filepath'] ?? '').toString(); |
|||
if (title.isEmpty || path.isEmpty) continue; |
|||
out[title.toUpperCase()] = path; |
|||
} |
|||
return out; |
|||
} catch (e) { |
|||
Logger.w(_tag, '读本地记录失败,本轮不清理设备文件: $e'); |
|||
return const <String, String>{}; |
|||
} |
|||
} |
|||
|
|||
/// 这个设备文件是不是「已上传,且本机那份与它逐字节等长」。 |
|||
Future<bool> _safeToDelete( |
|||
Map<String, String> uploaded, DeviceFileEntry f) async { |
|||
final path = uploadedLocalPathFor(uploaded, f.name); |
|||
if (path == null) return false; |
|||
try { |
|||
final local = File(path); |
|||
if (!await local.exists()) return false; |
|||
final len = await local.length(); |
|||
if (len != f.size) { |
|||
Logger.w(_tag, |
|||
'${f.name} 本机 $len B 与设备 ${f.size} B 不符,不删(可能是同名的新录音)'); |
|||
return false; |
|||
} |
|||
return true; |
|||
} catch (e) { |
|||
Logger.w(_tag, '校验本机文件失败 ${f.name},不删: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 把「已导入且已上传」的设备原件删掉。 |
|||
/// |
|||
/// 在导入之前先跑:先腾空间再往里写,卡快满时这个顺序才救得回来。 |
|||
Future<void> _cleanup( |
|||
DeviceSession session, |
|||
DeviceFileStore store, |
|||
List<DeviceFileEntry> files, |
|||
DeviceFileCancelToken cancel, |
|||
) async { |
|||
if (!autoDeleteAfterImport) return; |
|||
if (files.isEmpty) return; |
|||
final uploaded = await _uploadedRecords(); |
|||
if (uploaded.isEmpty) return; |
|||
|
|||
final done = _doneMap(); |
|||
var changed = false; |
|||
for (final f in files) { |
|||
if (cancel.isCancelled) break; |
|||
// 设备一开始录音就停手:协议 §5.10 规定录音/传输中删除会回 BUSY, |
|||
// 而且正在写的那个文件绝对不能碰。 |
|||
if (store.isDeviceRecording) { |
|||
Logger.i(_tag, '设备开始录音,停止清理'); |
|||
break; |
|||
} |
|||
if (!await _safeToDelete(uploaded, f)) continue; |
|||
try { |
|||
await store.delete(f.name); |
|||
// 文件没了,指纹也不用留(名字回绕后同名新文件才不会被当成已导入) |
|||
if (done.remove(_fingerprint(session.deviceId, f)) != null) { |
|||
changed = true; |
|||
} |
|||
deletedCount.value = deletedCount.value + 1; |
|||
Logger.i(_tag, '设备原件已删除 ${f.name}(${f.size} B,已确认上传)'); |
|||
} catch (e) { |
|||
// 删不掉不是灾难:下一轮还会再试。指纹保留着,别丢。 |
|||
Logger.w(_tag, '删除设备文件失败 ${f.name}: $e'); |
|||
} |
|||
} |
|||
if (changed) _writeDone(done); |
|||
if (deletedCount.value > 0) { |
|||
Logger.i(_tag, '本轮清理了 ${deletedCount.value} 个设备文件'); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,284 @@ |
|||
import 'dart:async'; |
|||
import 'dart:io'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:intl/intl.dart'; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import '../../devices/device_hub.dart'; |
|||
import 'recording_archive.dart'; |
|||
import 'wav_writer.dart'; |
|||
|
|||
/// 设备主麦录音(现场录音)的全局采集与归档服务——**厂商无关**。 |
|||
/// |
|||
/// 替代原先各写一份的 BesRecordingService / HolderRecordingService: |
|||
/// 谁声明了 [DeviceCapability.micRecording] 就能录, |
|||
/// 设备按键起停走 `record.started` / `record.stopped` feature 事件, |
|||
/// 音频走 `openMic(mic)`(插件负责让设备开始上行、解码成 16k PCM、挑帧)。 |
|||
/// |
|||
/// 常驻:设备端按键在任意页面(含页面未打开)都要生效。 |
|||
class DeviceRecordingService extends GetxService { |
|||
static const String _tag = 'DeviceRecording'; |
|||
static DeviceRecordingService get to => Get.find(); |
|||
|
|||
/// 当前是否正在录(由设备端状态驱动) |
|||
final RxBool isRecording = false.obs; |
|||
|
|||
/// 已录毫秒数 |
|||
final RxInt elapsedMs = 0.obs; |
|||
|
|||
/// 当前电平 0~1,供波形使用 |
|||
final RxDouble level = 0.0.obs; |
|||
|
|||
/// 波形采样序列(固定 [sampleIntervalMs] 一格) |
|||
final RxList<double> waveform = <double>[].obs; |
|||
static const int sampleIntervalMs = 100; |
|||
static const int _maxSamples = 600; |
|||
|
|||
StreamSubscription? _hubSub; |
|||
DeviceSession? _session; |
|||
DeviceAudioSource? _src; |
|||
StreamSubscription<Uint8List>? _audioSub; |
|||
WavWriter? _wav; |
|||
String? _path; |
|||
Timer? _tick; |
|||
Timer? _sampleTimer; |
|||
final Stopwatch _stopwatch = Stopwatch(); |
|||
bool _firstPcmLogged = false; |
|||
DateTime? _cmdSentAt; |
|||
bool _busy = false; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_hubSub = DeviceHub.to.sessionEvents.listen(_onSessionEvent); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_hubSub?.cancel(); |
|||
_audioSub?.cancel(); |
|||
_tick?.cancel(); |
|||
_sampleTimer?.cancel(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
void _onSessionEvent(DeviceSessionEvent e) async { |
|||
switch (e.type) { |
|||
case DeviceSessionEventType.feature: |
|||
final key = e.feature?.key; |
|||
if (_busy) return; |
|||
// 通话中的录音键归通话录音管(DeviceCallRecordingService) |
|||
if (key == DeviceFeatureKeys.recordStarted && |
|||
!isRecording.value && |
|||
!DeviceHub.to.inCall.value) { |
|||
Logger.i(_tag, '设备端开始录音,启动本地采集'); |
|||
await start(deviceId: e.deviceId); |
|||
} else if (key == DeviceFeatureKeys.recordStopped && isRecording.value) { |
|||
Logger.i(_tag, '设备端结束录音,落盘并归档'); |
|||
await stop(); |
|||
} |
|||
break; |
|||
case DeviceSessionEventType.connectionStateChanged: |
|||
if (e.connectionState == DeviceConnectionState.disconnected && |
|||
isRecording.value && |
|||
_session?.deviceId == e.deviceId) { |
|||
Logger.w(_tag, '设备断开,结束当前录音'); |
|||
stop(); |
|||
} |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 开始本地采集。[sendCommand] = true 时同时下发设备开始指令(App 主动发起)。 |
|||
Future<bool> start({bool sendCommand = false, String? deviceId}) async { |
|||
if (isRecording.value || _busy) return isRecording.value; |
|||
_busy = true; |
|||
try { |
|||
return await _start(sendCommand, deviceId); |
|||
} finally { |
|||
_busy = false; |
|||
} |
|||
} |
|||
|
|||
Future<bool> _start(bool sendCommand, String? deviceId) async { |
|||
final hub = DeviceHub.to; |
|||
final session = (deviceId == null |
|||
? null |
|||
: hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId)) ?? |
|||
hub.sessionWith(DeviceCapability.micRecording); |
|||
if (session == null) return false; |
|||
_session = session; |
|||
|
|||
try { |
|||
final docs = await getApplicationDocumentsDirectory(); |
|||
final dir = Directory('${docs.path}/field_recordings'); |
|||
if (!await dir.exists()) await dir.create(recursive: true); |
|||
final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); |
|||
_path = '${dir.path}/${session.vendor}_$ts.wav'; |
|||
_wav = await WavWriter.open(_path!, channels: 1); |
|||
|
|||
_firstPcmLogged = false; |
|||
_cmdSentAt = null; |
|||
|
|||
if (sendCommand) { |
|||
// 顺序:先让设备进录音态,再开上行(支架的编码器必须在切状态之后开, |
|||
// 否则会被 toggle 顺手关掉;恒玄的 AA 59 与订阅先后无关)。 |
|||
final t0 = DateTime.now(); |
|||
final r = await session.invokeFeature(DeviceFeatures.recordStart); |
|||
_cmdSentAt = DateTime.now(); |
|||
if (r['ok'] == false) { |
|||
await _closeFile(save: false); |
|||
return false; |
|||
} |
|||
Logger.i(_tag, |
|||
'已下发 record.start,写入耗时 ${_cmdSentAt!.difference(t0).inMilliseconds}ms'); |
|||
} |
|||
|
|||
_src = await session.openMic(route: AudioRoute.mic); |
|||
_audioSub = _src!.pcm.listen(_onPcm); |
|||
|
|||
isRecording.value = true; |
|||
elapsedMs.value = 0; |
|||
level.value = 0; |
|||
waveform.clear(); |
|||
_stopwatch.reset(); |
|||
// 时长按**已写入的数据量**算,不用墙钟:显示值与文件实际播放长度永远一致 |
|||
// (恒玄收到 AA 59 后要 1.8~6.2s 才开始上行,还会把等待期缓存的音频补推)。 |
|||
final bytesPerMs = _src!.format.bytesPerMs; |
|||
_tick = Timer.periodic(const Duration(milliseconds: 200), (_) { |
|||
elapsedMs.value = _wav == null ? 0 : _wav!.dataBytes ~/ bytesPerMs; |
|||
}); |
|||
_sampleTimer = Timer.periodic( |
|||
const Duration(milliseconds: sampleIntervalMs), |
|||
(_) => _sample(), |
|||
); |
|||
Logger.i(_tag, '开始录制: $_path (${session.vendor})'); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.e(_tag, '启动本地采集失败: $e'); |
|||
await _closeFile(save: false); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 停止。[sendCommand] = true 时同时下发设备停止指令。 |
|||
Future<void> stop({bool sendCommand = false}) async { |
|||
if (!isRecording.value || _busy) return; |
|||
_busy = true; |
|||
try { |
|||
await _stop(sendCommand); |
|||
} finally { |
|||
_busy = false; |
|||
} |
|||
} |
|||
|
|||
Future<void> _stop(bool sendCommand) async { |
|||
_tick?.cancel(); |
|||
_sampleTimer?.cancel(); |
|||
_stopwatch.stop(); |
|||
|
|||
await _audioSub?.cancel(); |
|||
_audioSub = null; |
|||
await _src?.close(); |
|||
_src = null; |
|||
// 等队列写完再关文件,否则最后几帧会丢、WAV 头长度也对不上 |
|||
await _wav?.drain(); |
|||
|
|||
final int dataBytes = _wav?.dataBytes ?? 0; |
|||
final int seconds = elapsedMs.value ~/ 1000; |
|||
final String? path = _path; |
|||
final wallMs = _stopwatch.elapsedMilliseconds; |
|||
Logger.i( |
|||
_tag, |
|||
'录音统计: 音频${(dataBytes / 32000).toStringAsFixed(2)}s 数据${dataBytes}B ' |
|||
'墙钟${(wallMs / 1000).toStringAsFixed(2)}s ' |
|||
'写入${_wav?.writtenFrames}帧 丢${_wav?.droppedFrames}帧'); |
|||
|
|||
final session = _session; |
|||
if (sendCommand && |
|||
session != null && |
|||
session.state == DeviceConnectionState.ready) { |
|||
try { |
|||
await session.invokeFeature(DeviceFeatures.recordStop, { |
|||
if (path != null) |
|||
'fileName': path.split('/').last.replaceAll('.wav', ''), |
|||
}); |
|||
} catch (e) { |
|||
Logger.e(_tag, '下发停止指令失败: $e'); |
|||
} |
|||
} |
|||
|
|||
await _closeFile(save: true); |
|||
isRecording.value = false; |
|||
elapsedMs.value = 0; |
|||
level.value = 0; |
|||
waveform.clear(); |
|||
_session = null; |
|||
|
|||
if (path != null && dataBytes > 0) { |
|||
await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag); |
|||
} else { |
|||
Logger.w(_tag, '本次没有收到音频数据,删除空文件'); |
|||
if (path != null) { |
|||
try { |
|||
final f = File(path); |
|||
if (await f.exists()) await f.delete(); |
|||
} catch (e) { |
|||
Logger.e(_tag, '删除空录音文件失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
_path = null; |
|||
} |
|||
|
|||
void _onPcm(Uint8List pcm) { |
|||
final w = _wav; |
|||
if (w == null || pcm.isEmpty) return; |
|||
if (!_firstPcmLogged) { |
|||
_firstPcmLogged = true; |
|||
if (!_stopwatch.isRunning) _stopwatch.start(); |
|||
final sent = _cmdSentAt; |
|||
Logger.i( |
|||
_tag, |
|||
'首帧音频到达' |
|||
'${sent != null ? ",距 record.start ${DateTime.now().difference(sent).inMilliseconds}ms" : ""}' |
|||
',计时从此刻开始'); |
|||
} |
|||
// 串行写,绝不在回调里直接 await 文件(见 WavWriter);电平只是显示,不能挡写入 |
|||
w.write(pcm, onWritten: () { |
|||
try { |
|||
_updateLevel(pcm); |
|||
} catch (_) {} |
|||
}); |
|||
} |
|||
|
|||
void _sample() { |
|||
final list = waveform.toList(); |
|||
list.add(level.value); |
|||
if (list.length > _maxSamples) { |
|||
list.removeRange(0, list.length - _maxSamples); |
|||
} |
|||
waveform.assignAll(list); |
|||
} |
|||
|
|||
void _updateLevel(Uint8List pcm) { |
|||
level.value = WavWriter.rmsLevel(pcm); |
|||
} |
|||
|
|||
Future<void> _closeFile({required bool save}) async { |
|||
await _audioSub?.cancel(); |
|||
_audioSub = null; |
|||
await _src?.close(); |
|||
_src = null; |
|||
final w = _wav; |
|||
_wav = null; |
|||
if (w == null) return; |
|||
await w.close(save: save); |
|||
} |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import '../../devices/device_hub.dart'; |
|||
import 'wav_writer.dart'; |
|||
|
|||
/// 把设备的通话双路(本端 / 对端)录成一个**双声道 WAV** 的小工具, |
|||
/// 给「语音纪要 → 通话录音」这类要自己指定文件路径的页面用。 |
|||
/// |
|||
/// 与常驻的 DeviceCallRecordingService 的区别:这个是页面持有的一次性对象, |
|||
/// 文件放哪、什么时候停都由调用方决定,不归档。 |
|||
/// 左声道=本端麦克风,右声道=对端声音。 |
|||
class DeviceStereoRecorder { |
|||
DeviceStereoRecorder({this.onWavePcm}); |
|||
|
|||
static const String _tag = 'DeviceStereoRecorder'; |
|||
|
|||
/// 每写一对帧回调一次本端 PCM(驱动波形) |
|||
final void Function(Uint8List pcm)? onWavePcm; |
|||
|
|||
DeviceSession? _session; |
|||
DeviceAudioSource? _local; |
|||
DeviceAudioSource? _peer; |
|||
StreamSubscription? _localSub; |
|||
StreamSubscription? _peerSub; |
|||
WavWriter? _wav; |
|||
Uint8List? _pendingPeer; |
|||
bool _paused = false; |
|||
|
|||
bool get isRunning => _wav != null; |
|||
|
|||
/// 开始。没有具备通话双路抓音能力的设备返回 false。 |
|||
Future<bool> start(String filePath) async { |
|||
if (isRunning) return true; |
|||
final session = DeviceHub.to.sessionWith(DeviceCapability.callAudioTap); |
|||
if (session == null) { |
|||
Logger.w(_tag, '没有具备通话双路抓音能力的设备'); |
|||
return false; |
|||
} |
|||
try { |
|||
_session = session; |
|||
_wav = await WavWriter.open(filePath, channels: 2); |
|||
_pendingPeer = null; |
|||
_paused = false; |
|||
_peer = await session.openMic(route: AudioRoute.callPeer); |
|||
_local = await session.openMic(route: AudioRoute.callLocal); |
|||
_peerSub = _peer!.pcm.listen((pcm) { |
|||
if (_paused || pcm.isEmpty) return; |
|||
_pendingPeer = pcm; |
|||
}); |
|||
_localSub = _local!.pcm.listen((pcm) { |
|||
if (_paused || pcm.isEmpty) return; |
|||
final peer = _pendingPeer; |
|||
_pendingPeer = null; |
|||
if (peer == null) return; |
|||
_wav?.write(WavWriter.interleave(pcm, peer), |
|||
onWritten: () => onWavePcm?.call(pcm)); |
|||
}); |
|||
await session.invokeFeature(DeviceFeatures.callStart); |
|||
return true; |
|||
} catch (e) { |
|||
Logger.e(_tag, '启动失败: $e'); |
|||
await _shutdown(saveFile: false); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
void pause() => _paused = true; |
|||
|
|||
void resume() => _paused = false; |
|||
|
|||
Future<bool> stop({required bool save}) async { |
|||
if (!isRunning) return true; |
|||
await _shutdown(saveFile: save); |
|||
return true; |
|||
} |
|||
|
|||
Future<void> _shutdown({required bool saveFile}) async { |
|||
await _localSub?.cancel(); |
|||
await _peerSub?.cancel(); |
|||
_localSub = null; |
|||
_peerSub = null; |
|||
await _local?.close(); |
|||
await _peer?.close(); |
|||
_local = null; |
|||
_peer = null; |
|||
final s = _session; |
|||
_session = null; |
|||
if (s != null && s.state == DeviceConnectionState.ready) { |
|||
try { |
|||
await s.invokeFeature(DeviceFeatures.callStop); |
|||
} catch (e) { |
|||
Logger.w(_tag, 'call.stop 失败: $e'); |
|||
} |
|||
} |
|||
final w = _wav; |
|||
_wav = null; |
|||
await w?.close(save: saveFile); |
|||
} |
|||
} |
|||
@ -0,0 +1,136 @@ |
|||
import 'dart:async'; |
|||
import 'dart:io'; |
|||
import 'dart:math' as math; |
|||
import 'dart:typed_data'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
|
|||
/// 串行写入的 WAV 文件(16k / 16bit)。 |
|||
/// |
|||
/// ⚠️ **必须串行写**:Stream.listen 的回调即使标了 async 也不会被等待, |
|||
/// 音频每秒上百帧,多次 writeFrom 必然重叠;Dart 的 RandomAccessFile 同一时刻 |
|||
/// 只允许一个异步操作,重叠调用会抛 "An async operation is currently pending", |
|||
/// 那一帧就丢了(实测等效采样率只剩 1587~5331Hz,播放又快又糊)。 |
|||
/// 用 Future 链把写入排成队列:既不丢帧,也保证顺序。 |
|||
class WavWriter { |
|||
WavWriter._(this._raf, this.path, this.channels, this.sampleRate); |
|||
|
|||
static const String _tag = 'WavWriter'; |
|||
|
|||
final RandomAccessFile _raf; |
|||
final String path; |
|||
final int channels; |
|||
final int sampleRate; |
|||
|
|||
int dataBytes = 0; |
|||
int writtenFrames = 0; |
|||
int droppedFrames = 0; |
|||
Future<void> _chain = Future<void>.value(); |
|||
bool _closed = false; |
|||
|
|||
static Future<WavWriter> open(String path, |
|||
{int channels = 1, int sampleRate = 16000}) async { |
|||
final raf = await File(path).open(mode: FileMode.write); |
|||
final w = WavWriter._(raf, path, channels, sampleRate); |
|||
await raf.writeFrom(w._header(0)); |
|||
return w; |
|||
} |
|||
|
|||
/// 入队写一块 PCM。[onWritten] 在这一块真正落盘后回调(电平等只读用途)。 |
|||
void write(Uint8List pcm, {void Function()? onWritten}) { |
|||
if (_closed) return; |
|||
_chain = _chain.then((_) async { |
|||
if (_closed) return; |
|||
try { |
|||
await _raf.writeFrom(pcm); |
|||
dataBytes += pcm.length; |
|||
writtenFrames++; |
|||
onWritten?.call(); |
|||
} catch (e) { |
|||
droppedFrames++; |
|||
Logger.e(_tag, '写入 PCM 失败(已丢帧 $droppedFrames): $e'); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/// 等队列里的帧全部落盘。 |
|||
Future<void> drain() async { |
|||
try { |
|||
await _chain; |
|||
} catch (_) {} |
|||
} |
|||
|
|||
/// 关闭;[save] = true 时回填 WAV 头的长度。 |
|||
Future<void> close({required bool save}) async { |
|||
if (_closed) return; |
|||
await drain(); |
|||
_closed = true; |
|||
try { |
|||
if (save) { |
|||
await _raf.setPosition(0); |
|||
await _raf.writeFrom(_header(dataBytes)); |
|||
} |
|||
await _raf.close(); |
|||
} catch (e) { |
|||
Logger.e(_tag, '关闭录音文件失败: $e'); |
|||
} |
|||
} |
|||
|
|||
Uint8List _header(int dataLength) { |
|||
const bits = 16; |
|||
final byteRate = sampleRate * channels * (bits ~/ 8); |
|||
final h = ByteData(44); |
|||
void ascii(int off, String s) { |
|||
for (var i = 0; i < s.length; i++) { |
|||
h.setUint8(off + i, s.codeUnitAt(i)); |
|||
} |
|||
} |
|||
|
|||
ascii(0, 'RIFF'); |
|||
h.setUint32(4, 36 + dataLength, Endian.little); |
|||
ascii(8, 'WAVE'); |
|||
ascii(12, 'fmt '); |
|||
h.setUint32(16, 16, Endian.little); |
|||
h.setUint16(20, 1, Endian.little); |
|||
h.setUint16(22, channels, Endian.little); |
|||
h.setUint32(24, sampleRate, Endian.little); |
|||
h.setUint32(28, byteRate, Endian.little); |
|||
h.setUint16(32, channels * (bits ~/ 8), Endian.little); |
|||
h.setUint16(34, bits, Endian.little); |
|||
ascii(36, 'data'); |
|||
h.setUint32(40, dataLength, Endian.little); |
|||
return h.buffer.asUint8List(); |
|||
} |
|||
|
|||
/// PCM16 小端的 RMS 电平(0~1,开过根让波形更有跳动感)。 |
|||
/// |
|||
/// ⚠️ 不能用 `buffer.asInt16List(offsetInBytes, …)`:原生过来的 Uint8List 是 |
|||
/// 带 offset 的视图,偏移是奇数时直接抛 RangeError。按字节手工拼。 |
|||
static double rmsLevel(Uint8List pcm) { |
|||
final count = pcm.lengthInBytes ~/ 2; |
|||
if (count == 0) return 0; |
|||
var sum = 0.0; |
|||
for (var i = 0; i < count; i++) { |
|||
var v = (pcm[i * 2 + 1] << 8) | pcm[i * 2]; |
|||
if (v >= 0x8000) v -= 0x10000; |
|||
final n = v / 32768.0; |
|||
sum += n * n; |
|||
} |
|||
final rms = math.sqrt(sum / count); |
|||
return math.sqrt(rms).clamp(0.0, 1.0); |
|||
} |
|||
|
|||
/// 把两路单声道按字节交织成双声道(左=[left],右=[right])。 |
|||
static Uint8List interleave(Uint8List left, Uint8List right) { |
|||
final samples = (left.length < right.length ? left.length : right.length) ~/ 2; |
|||
final out = Uint8List(samples * 4); |
|||
for (var i = 0; i < samples; i++) { |
|||
final si = i * 2, di = i * 4; |
|||
out[di] = left[si]; |
|||
out[di + 1] = left[si + 1]; |
|||
out[di + 2] = right[si]; |
|||
out[di + 3] = right[si + 1]; |
|||
} |
|||
return out; |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
/// 「设备上这个文件,是不是已经安全落袋了」—— |
|||
/// **这是删除设备原件的唯一判据**,改它等于改一个不可逆操作的闸门。 |
|||
library; |
|||
|
|||
/// 归档标题形如 `<vendor>_<时间戳>_<设备文件名>` |
|||
/// (`holder_20260907_181224_MIC0049.ogg` ← 设备上的 `MIC0049.OGG`)。 |
|||
/// |
|||
/// 在「已上传」的记录里按这个后缀找对应的那条,返回它在本机的音频路径; |
|||
/// 找不到返回 null。[uploadedByTitle] 的 key 必须已大写。 |
|||
/// |
|||
/// ⚠️ **只匹配到名字还不够,调用方必须再比一次字节数**:设备用的是 8.3 短 |
|||
/// 文件名,删掉后会**回绕复用**(`MIC0001.OGG` 下次录音可能又叫这个名)。 |
|||
/// 只认名字的话,一条同名的新录音会被当成「已上传」直接删掉 —— 永久丢数据。 |
|||
/// 字节数的来源刻意选了「本机那份音频的实际长度」而不是数据库里的字段: |
|||
/// `meeting` 表**根本没有 size 列**(schema 见 database_helper.dart), |
|||
/// 而且比文件长度顺带证明了本机这份没被截断、确实是同一个文件。 |
|||
String? uploadedLocalPathFor( |
|||
Map<String, String> uploadedByTitle, |
|||
String deviceFileName, |
|||
) { |
|||
if (deviceFileName.isEmpty) return null; |
|||
final suffix = '_${deviceFileName.toUpperCase()}'; |
|||
for (final e in uploadedByTitle.entries) { |
|||
if (e.key.endsWith(suffix) && e.value.isNotEmpty) return e.value; |
|||
} |
|||
return null; |
|||
} |
|||
@ -0,0 +1,93 @@ |
|||
/// Ogg Opus 时长解析 —— **不解码、不依赖播放器**。 |
|||
/// |
|||
/// Opus 的 granule position 恒以 **48kHz** 计样本(RFC 7845 §4,与音频本身的 |
|||
/// 采样率无关),所以「最后一个 Ogg page 的 granulepos 减去 OpusHead 里的 |
|||
/// pre-skip,再除以 48000」就是精确时长。 |
|||
/// |
|||
/// 为什么不用现成的播放器: |
|||
/// - ⚠️ `just_audio` 的 `setFilePath` 对支架录的这种裸 Ogg Opus **两端都报 |
|||
/// `Source error`**(Android/iOS 真机实测),拿不到时长; |
|||
/// - ⚠️ 按码率估会明显失真:真机那条 38400 字节的录音实际 9.16s, |
|||
/// 按 16kbps 估是 19.2s,差一倍,列表上一眼就看得出来。 |
|||
library; |
|||
|
|||
import 'dart:io'; |
|||
|
|||
const List<int> _oggS = [0x4F, 0x67, 0x67, 0x53]; // 'OggS' |
|||
const List<int> _opusHead = [ |
|||
0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64 // 'OpusHead' |
|||
]; |
|||
|
|||
/// Opus 的 granulepos 单位,**恒为 48000**,不是音频采样率。 |
|||
/// 写成 16000 会把时长算成 3 倍。 |
|||
const int _opusGranuleRate = 48000; |
|||
|
|||
/// 从「文件头若干字节」和「文件尾若干字节」解出时长(秒)。 |
|||
/// |
|||
/// [head] 至少要覆盖第一个 page(含 OpusHead);[tail] 至少要覆盖最后一个 |
|||
/// page 的头部。不是 Ogg、或没有可用的 granulepos 时返回 null。 |
|||
double? oggOpusSecondsFrom(List<int> head, List<int> tail) { |
|||
if (head.length < 4 || _indexOf(head, _oggS) != 0) return null; // 不是 'OggS' |
|||
|
|||
final hi = _indexOf(head, _opusHead); |
|||
// OpusHead 布局:magic(8) version(1) channels(1) pre-skip(2, LE) |
|||
final preSkip = |
|||
hi >= 0 && hi + 12 <= head.length ? head[hi + 10] | (head[hi + 11] << 8) : 0; |
|||
|
|||
final ti = _lastIndexOf(tail, _oggS); |
|||
if (ti < 0 || ti + 14 > tail.length) return null; |
|||
|
|||
// page 头偏移 6 起的 8 字节小端就是 granulepos |
|||
var granule = 0; |
|||
for (var b = 7; b >= 0; b--) { |
|||
granule = (granule << 8) | tail[ti + 6 + b]; |
|||
} |
|||
if (granule <= preSkip) return null; |
|||
return (granule - preSkip) / _opusGranuleRate; |
|||
} |
|||
|
|||
/// 读文件解时长。不是 Ogg Opus 返回 null。 |
|||
/// |
|||
/// 只读头 4KB 和尾 128KB —— Ogg page 最大 65307 字节,128KB 必然覆盖到 |
|||
/// 最后一个 page,不用把整个文件读进内存(录音可能几十兆)。 |
|||
Future<double?> oggOpusSeconds(File file) async { |
|||
final raf = await file.open(); |
|||
try { |
|||
final len = await raf.length(); |
|||
if (len < 47) return null; // 连一个 OpusHead page 都放不下 |
|||
|
|||
await raf.setPosition(0); |
|||
final head = await raf.read(len < 4096 ? len : 4096); |
|||
|
|||
const tailLen = 128 * 1024; |
|||
final from = len > tailLen ? len - tailLen : 0; |
|||
await raf.setPosition(from); |
|||
final tail = await raf.read(len - from); |
|||
|
|||
return oggOpusSecondsFrom(head, tail); |
|||
} finally { |
|||
await raf.close(); |
|||
} |
|||
} |
|||
|
|||
int _indexOf(List<int> hay, List<int> needle) { |
|||
outer: |
|||
for (var i = 0; i + needle.length <= hay.length; i++) { |
|||
for (var j = 0; j < needle.length; j++) { |
|||
if (hay[i + j] != needle[j]) continue outer; |
|||
} |
|||
return i; |
|||
} |
|||
return -1; |
|||
} |
|||
|
|||
int _lastIndexOf(List<int> hay, List<int> needle) { |
|||
outer: |
|||
for (var i = hay.length - needle.length; i >= 0; i--) { |
|||
for (var j = 0; j < needle.length; j++) { |
|||
if (hay[i + j] != needle[j]) continue outer; |
|||
} |
|||
return i; |
|||
} |
|||
return -1; |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
/// 一条录音在列表里该显示的「录制时间」。 |
|||
/// |
|||
/// [deviceTime] 是设备自己记的时间(如 EaiRec 支架 TF 卡文件的 FAT 时间戳)。 |
|||
/// |
|||
/// ⚠️ **设备给的时间不能无条件相信**:支架在 RTC 对时之前写的文件,时间戳是 |
|||
/// 1980 / 2018 这类垃圾值(2026-09-10 真机那张卡上就有一批 `2018-08-10`)。 |
|||
/// 直接采信的话,这些记录会沉到列表最底下,用户按时间根本找不到。 |
|||
/// |
|||
/// 所以只接受「2020 年之后、且不超过明天」的值,其余退回当前时刻: |
|||
/// 显示成「刚导入」是次优但可用,显示成 2018 年是直接丢失。 |
|||
/// |
|||
/// ⚠️ **当前这个值在列表上还看不到效果,别以为这段是死代码把它删了。** |
|||
/// 服务端 `echomeet_addrecord` 把 `Creationtime` 写死成 `time.Now().Unix()` |
|||
/// (`EchomeetAddRecordReq` 里压根没有这个字段),而 `Synchrodata._synchrodata()` |
|||
/// 会拿服务端的值**整行 update** 覆盖本地库 —— 于是列表上显示的永远是「导入时刻」。 |
|||
/// 录制时间目前只活在标题里(`holder_20260907_181224_MIC0049.ogg`)。 |
|||
/// 要让它真正生效,服务端要做两件事:`EchomeetAddRecordReq` 加 `creationtime` |
|||
/// 字段,`AddRecord` 在传了合法值时用它而不是 `time.Now()`。 |
|||
/// 客户端这一侧已经按「服务端接受」的形状送上去了,接口一改就自动生效。 |
|||
int resolveRecordingCreationTime(DateTime? deviceTime, {DateTime? now}) { |
|||
final n = now ?? DateTime.now(); |
|||
if (deviceTime == null) return n.millisecondsSinceEpoch; |
|||
if (deviceTime.year < 2020) return n.millisecondsSinceEpoch; |
|||
if (deviceTime.isAfter(n.add(const Duration(days: 1)))) { |
|||
return n.millisecondsSinceEpoch; |
|||
} |
|||
return deviceTime.millisecondsSinceEpoch; |
|||
} |
|||
@ -0,0 +1,637 @@ |
|||
import 'dart:async'; |
|||
import 'dart:io'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import '../device_vendors.dart'; |
|||
import 'bes_bluetooth_service.dart'; |
|||
import 'bes_ota_port.dart'; |
|||
import 'bes_protocol.dart'; |
|||
|
|||
/// 恒玄(BES) 耳机的厂商插件——把 [BesBluetoothService](协议实现,冻结) |
|||
/// 适配成 [DevicePlugin] / [DeviceSession]。 |
|||
/// |
|||
/// **这一层只做映射,不碰字节。** feature key → 命令字节的对应表在 |
|||
/// [BesDeviceSession.invokeFeature],由 `test/devices/bes_session_test.dart` |
|||
/// 逐条断言发出的字节。 |
|||
class BesDevicePlugin implements DevicePlugin { |
|||
static const String _tag = 'BesDevicePlugin'; |
|||
|
|||
/// 协议实现。仍注册进 GetX 只是为了走 GetxService 的生命周期; |
|||
/// 业务层不允许 import 它(见 device_hub_boundary_test)。 |
|||
final BesBluetoothService link; |
|||
|
|||
BesDevicePlugin({BesBluetoothService? link}) |
|||
: link = link ?? BesBluetoothService(); |
|||
|
|||
final StreamController<DevicePluginEvent> _ctrl = |
|||
StreamController<DevicePluginEvent>.broadcast(); |
|||
BesDeviceSession? _session; |
|||
Worker? _statusWorker; |
|||
bool _initialized = false; |
|||
bool _disposed = false; |
|||
|
|||
@override |
|||
String get vendorKey => DeviceVendors.bes; |
|||
|
|||
@override |
|||
String get displayName => 'BES (恒玄)'; |
|||
|
|||
@override |
|||
Set<DeviceCapability> get capabilities => BesDeviceSession.caps; |
|||
|
|||
@override |
|||
DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; |
|||
|
|||
@override |
|||
DeviceSession? get activeSession => _session; |
|||
|
|||
@override |
|||
Stream<DevicePluginEvent> get eventStream => _ctrl.stream; |
|||
|
|||
@override |
|||
Future<void> initialize(DevicePluginConfig config) async { |
|||
_checkAlive(); |
|||
if (_initialized) return; |
|||
_initialized = true; |
|||
if (!Get.isRegistered<BesBluetoothService>()) { |
|||
Get.put<BesBluetoothService>(link, permanent: true); |
|||
} |
|||
_statusWorker = ever<int>(link.deviceStatus, _onStatus); |
|||
if (link.deviceStatus.value == 2) _onStatus(2); |
|||
_emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); |
|||
} |
|||
|
|||
void _onStatus(int v) { |
|||
switch (v) { |
|||
case 2: |
|||
if (_session != null) return; |
|||
final s = BesDeviceSession._(link); |
|||
_session = s; |
|||
_emit(DevicePluginEvent( |
|||
type: DevicePluginEventType.connectionStateChanged, |
|||
deviceId: s.deviceId, |
|||
connectionState: DeviceConnectionState.ready, |
|||
)); |
|||
break; |
|||
case 1: |
|||
_emit(DevicePluginEvent( |
|||
type: DevicePluginEventType.connectionStateChanged, |
|||
deviceId: link.connectedDeviceId.value, |
|||
connectionState: DeviceConnectionState.connecting, |
|||
)); |
|||
break; |
|||
default: |
|||
final s = _session; |
|||
_session = null; |
|||
s?._markDisconnected(); |
|||
_emit(DevicePluginEvent( |
|||
type: DevicePluginEventType.connectionStateChanged, |
|||
deviceId: s?.deviceId, |
|||
connectionState: DeviceConnectionState.disconnected, |
|||
)); |
|||
} |
|||
} |
|||
|
|||
void _emit(DevicePluginEvent e) { |
|||
if (!_ctrl.isClosed) _ctrl.add(e); |
|||
} |
|||
|
|||
// ---------- 扫描(恒玄没有扫描:只从系统配对列表里挑) ---------- |
|||
|
|||
@override |
|||
Future<void> startScan({DeviceScanFilter? filter, Duration? timeout}) async { |
|||
_emit(const DevicePluginEvent(type: DevicePluginEventType.scanStarted)); |
|||
for (final d in await bondedDevices()) { |
|||
_emit(DevicePluginEvent( |
|||
type: DevicePluginEventType.deviceDiscovered, |
|||
deviceId: d.id, |
|||
discovered: d)); |
|||
} |
|||
_emit(const DevicePluginEvent(type: DevicePluginEventType.scanStopped)); |
|||
} |
|||
|
|||
@override |
|||
Future<void> stopScan() async {} |
|||
|
|||
@override |
|||
Future<bool> isScanning() async => false; |
|||
|
|||
@override |
|||
Future<List<DiscoveredDevice>> bondedDevices() async { |
|||
final list = await link.getBondedDevices(); |
|||
return list |
|||
.map((d) => DiscoveredDevice( |
|||
id: (d['address'] ?? '') as String, |
|||
name: (d['name'] ?? '') as String, |
|||
vendor: vendorKey, |
|||
)) |
|||
.where((d) => d.id.isNotEmpty) |
|||
.toList(growable: false); |
|||
} |
|||
|
|||
@override |
|||
Future<bool> ensureReady() async { |
|||
try { |
|||
return await link.initialize(); |
|||
} catch (e) { |
|||
Logger.e(_tag, '恒玄蓝牙初始化失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// ---------- 连接 ---------- |
|||
|
|||
/// 连接并等到「已连接」。 |
|||
/// |
|||
/// 进过「连接中」又掉回 0 就提前判失败(原生 connectBlocking 失败会推 0); |
|||
/// 但仍要保留超时兜底:原生若把这次 connect 丢弃了(isConnecting 门禁), |
|||
/// 状态会停在 connectToDevice 自己写的 1,等不到 0。 |
|||
@override |
|||
Future<DeviceSession> connect(String deviceId, |
|||
{DeviceConnectOptions? options}) async { |
|||
_checkAlive(); |
|||
final name = (options?.extra['name'] as String?) ?? ''; |
|||
final timeout = options?.timeout ?? const Duration(seconds: 8); |
|||
await link.connectToDevice(deviceId, name); |
|||
final end = DateTime.now().add(timeout); |
|||
var sawConnecting = false; |
|||
while (DateTime.now().isBefore(end)) { |
|||
final s = link.deviceStatus.value; |
|||
if (s == 2) break; |
|||
if (s == 1) sawConnecting = true; |
|||
if (sawConnecting && s == 0) { |
|||
throw DeviceException(DeviceErrorCode.connectFailed, deviceId); |
|||
} |
|||
await Future.delayed(const Duration(milliseconds: 300)); |
|||
} |
|||
if (link.deviceStatus.value != 2) { |
|||
throw DeviceException(DeviceErrorCode.connectTimeout, deviceId); |
|||
} |
|||
// 状态流回调与这里是同一个事件循环,session 已经建好 |
|||
_onStatus(2); |
|||
return _session!; |
|||
} |
|||
|
|||
@override |
|||
List<DiscoveredDevice> get rememberedDevices => link |
|||
.getPairedDevices() |
|||
.map((d) => DiscoveredDevice( |
|||
id: (d['address'] ?? '') as String, |
|||
name: (d['name'] ?? '') as String, |
|||
vendor: vendorKey, |
|||
)) |
|||
.where((d) => d.id.isNotEmpty) |
|||
.toList(growable: false); |
|||
|
|||
@override |
|||
DiscoveredDevice? get lastKnownDevice { |
|||
final n = link.lastDeviceName; |
|||
final a = link.lastDeviceAddress; |
|||
if (n.isEmpty && a.isEmpty) return null; |
|||
return DiscoveredDevice(id: a, name: n, vendor: vendorKey); |
|||
} |
|||
|
|||
@override |
|||
Future<void> rememberDevice(DiscoveredDevice device) => |
|||
link.savePairedDevice({'address': device.id, 'name': device.name}); |
|||
|
|||
@override |
|||
Future<void> tryAutoConnect() => link.tryAutoConnect(); |
|||
|
|||
@override |
|||
Future<void> forgetDevice(String deviceId) => |
|||
link.removePairedDevice(deviceId); |
|||
|
|||
@override |
|||
Future<void> dispose() async { |
|||
_disposed = true; |
|||
_statusWorker?.dispose(); |
|||
_session?._markDisconnected(); |
|||
_session = null; |
|||
await _ctrl.close(); |
|||
} |
|||
|
|||
void _checkAlive() { |
|||
if (_disposed) throw StateError('BesDevicePlugin disposed'); |
|||
} |
|||
} |
|||
|
|||
/// 恒玄耳机会话。 |
|||
class BesDeviceSession extends DeviceSession { |
|||
BesDeviceSession._(this._link) { |
|||
_bind(); |
|||
} |
|||
|
|||
static const String _tag = 'BesSession'; |
|||
|
|||
static const Set<DeviceCapability> caps = { |
|||
DeviceCapability.connect, |
|||
DeviceCapability.bond, |
|||
DeviceCapability.battery, |
|||
DeviceCapability.macAddress, |
|||
DeviceCapability.micUplink, |
|||
DeviceCapability.speakerDownlink, |
|||
DeviceCapability.micRecording, |
|||
DeviceCapability.callAudioTap, |
|||
DeviceCapability.callState, |
|||
DeviceCapability.aiWake, |
|||
DeviceCapability.translationDownlink, |
|||
DeviceCapability.faceToFace, |
|||
DeviceCapability.ota, |
|||
DeviceCapability.customCommand, |
|||
}; |
|||
|
|||
final BesBluetoothService _link; |
|||
final StreamController<DeviceSessionEvent> _events = |
|||
StreamController<DeviceSessionEvent>.broadcast(); |
|||
final StreamController<DeviceWakeEvent> _wake = |
|||
StreamController<DeviceWakeEvent>.broadcast(); |
|||
final List<Worker> _workers = []; |
|||
StreamSubscription? _aiSub; |
|||
DeviceConnectionState _state = DeviceConnectionState.ready; |
|||
|
|||
/// 通话翻译下行的引用计数:A/B 两路都关了才 stopCallDownlink |
|||
int _callSinks = 0; |
|||
BesOtaPort? _ota; |
|||
|
|||
void _bind() { |
|||
_workers.add(ever<String>(_link.lastCmdType, (type) { |
|||
switch (type) { |
|||
case 'startMicRecording': |
|||
_feature(DeviceFeatureKeys.recordStarted); |
|||
break; |
|||
case 'stopMicRecording': |
|||
_feature(DeviceFeatureKeys.recordStopped); |
|||
break; |
|||
} |
|||
})); |
|||
_workers.add(ever<bool>(_link.isCallOngoing, (on) { |
|||
_feature(on ? DeviceFeatureKeys.callStarted : DeviceFeatureKeys.callEnded); |
|||
})); |
|||
void infoChanged(dynamic _) => _infoUpdated(); |
|||
_workers.add(ever<int>(_link.batteryLevel, (_) { |
|||
_feature(DeviceFeatureKeys.batteryUpdated); |
|||
_infoUpdated(); |
|||
})); |
|||
_workers.add(ever<int>(_link.batteryCase, infoChanged)); |
|||
_workers.add(ever<bool>(_link.chargingLeft, infoChanged)); |
|||
_workers.add(ever<bool>(_link.chargingRight, infoChanged)); |
|||
_workers.add(ever<bool>(_link.chargingCase, infoChanged)); |
|||
_workers.add(ever<String>(_link.deviceVersion, (_) { |
|||
_feature(DeviceFeatureKeys.firmwareUpdated); |
|||
_infoUpdated(); |
|||
})); |
|||
_workers.add(ever<String>(_link.deviceMac, (_) { |
|||
_feature(DeviceFeatureKeys.macUpdated); |
|||
_infoUpdated(); |
|||
})); |
|||
_workers.add(ever<String>(_link.connectedDeviceName, infoChanged)); |
|||
|
|||
// AI 通道:BB 64 唤醒 / BB E5 结束。应答 AA 6A 由业务层通过 ai.accept 发, |
|||
// 与原先 BesAiSessionService 的判断顺序一致(未配置时不应答、发 ai.exit)。 |
|||
_aiSub = _link.manager.onDeviceAiEvent().listen((event) { |
|||
switch (event['type']) { |
|||
case 'startAI': |
|||
_wake.add(DeviceWakeEvent(deviceId: deviceId, reason: WakeReason.ptt)); |
|||
break; |
|||
case 'stopAI': |
|||
_wake.add( |
|||
DeviceWakeEvent(deviceId: deviceId, reason: WakeReason.hangup)); |
|||
break; |
|||
} |
|||
}, onError: (e) => Logger.e(_tag, 'AI 事件流错误: $e')); |
|||
} |
|||
|
|||
void _feature(String key, [Map<String, Object?> data = const {}]) { |
|||
if (_events.isClosed) return; |
|||
_events.add(DeviceSessionEvent( |
|||
type: DeviceSessionEventType.feature, |
|||
deviceId: deviceId, |
|||
feature: DeviceFeatureEvent(key: key, data: data), |
|||
)); |
|||
} |
|||
|
|||
void _infoUpdated() { |
|||
if (_events.isClosed) return; |
|||
_events.add(DeviceSessionEvent( |
|||
type: DeviceSessionEventType.deviceInfoUpdated, |
|||
deviceId: deviceId, |
|||
deviceInfo: info, |
|||
)); |
|||
} |
|||
|
|||
void _markDisconnected() { |
|||
if (_state == DeviceConnectionState.disconnected) return; |
|||
_state = DeviceConnectionState.disconnected; |
|||
for (final w in _workers) { |
|||
w.dispose(); |
|||
} |
|||
_workers.clear(); |
|||
_aiSub?.cancel(); |
|||
_aiSub = null; |
|||
_ota?.onSessionGone(); |
|||
if (!_events.isClosed) { |
|||
_events.add(DeviceSessionEvent( |
|||
type: DeviceSessionEventType.connectionStateChanged, |
|||
deviceId: deviceId, |
|||
connectionState: DeviceConnectionState.disconnected, |
|||
)); |
|||
_events.close(); |
|||
} |
|||
if (!_wake.isClosed) _wake.close(); |
|||
} |
|||
|
|||
void _requireReady() { |
|||
if (_state != DeviceConnectionState.ready) { |
|||
throw DeviceException(DeviceErrorCode.noActiveSession, 'bes'); |
|||
} |
|||
} |
|||
|
|||
// ---------- DeviceSession ---------- |
|||
|
|||
@override |
|||
String get deviceId => _link.connectedDeviceId.value; |
|||
|
|||
@override |
|||
String get vendor => DeviceVendors.bes; |
|||
|
|||
@override |
|||
DeviceConnectionState get state => _state; |
|||
|
|||
@override |
|||
Set<DeviceCapability> get capabilities => caps; |
|||
|
|||
@override |
|||
Stream<DeviceSessionEvent> get eventStream => _events.stream; |
|||
|
|||
@override |
|||
Stream<DeviceWakeEvent> get wakeEvents => _wake.stream; |
|||
|
|||
static int? _nz(int v) => v >= 0 ? v : null; |
|||
|
|||
/// 真实 MAC:Android 系统给的就是;iOS 只能靠耳机自报(`BB 0A` 属性 7)。 |
|||
String? get macAddress { |
|||
if (Platform.isAndroid) { |
|||
final id = _link.connectedDeviceId.value; |
|||
return id.isEmpty ? null : id.toUpperCase(); |
|||
} |
|||
final m = _link.deviceMac.value; |
|||
return m.isEmpty ? null : m; |
|||
} |
|||
|
|||
@override |
|||
DeviceInfo get info { |
|||
final battery = DeviceBattery( |
|||
left: _nz(_link.batteryLeft.value), |
|||
right: _nz(_link.batteryRight.value), |
|||
caseBox: _nz(_link.batteryCase.value), |
|||
single: _nz(_link.batteryLevel.value), |
|||
chargingLeft: _link.chargingLeft.value, |
|||
chargingRight: _link.chargingRight.value, |
|||
chargingCase: _link.chargingCase.value, |
|||
); |
|||
final fw = _link.deviceVersion.value; |
|||
return DeviceInfo( |
|||
id: deviceId, |
|||
name: _link.connectedDeviceName.value, |
|||
vendor: vendor, |
|||
firmwareVersion: fw.isEmpty ? null : fw, |
|||
batteryPercent: battery.summary, |
|||
battery: battery, |
|||
macAddress: macAddress, |
|||
metadata: { |
|||
'inCall': _link.isCallOngoing.value, |
|||
'localId': _link.connectedDeviceId.value, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
@override |
|||
Future<int> readRssi() => |
|||
throw DeviceException(DeviceErrorCode.notSupported, 'rssi'); |
|||
|
|||
@override |
|||
Future<int?> readBattery() async { |
|||
_requireReady(); |
|||
await _link.queryBattery(); |
|||
return info.battery.summary; |
|||
} |
|||
|
|||
@override |
|||
Future<DeviceInfo> refreshInfo() async { |
|||
_requireReady(); |
|||
await _link.refreshDeviceState(); |
|||
return info; |
|||
} |
|||
|
|||
/// 取真实 MAC,iOS 上会主动发一次查询并等最多 [timeout]。 |
|||
Future<String?> resolveMac({Duration timeout = const Duration(seconds: 3)}) => |
|||
_link.resolveDeviceMac(timeout: timeout); |
|||
|
|||
// ---------- 音频 ---------- |
|||
|
|||
@override |
|||
Future<DeviceAudioSource> openMic({AudioRoute route = AudioRoute.mic}) async { |
|||
_requireReady(); |
|||
switch (route) { |
|||
case AudioRoute.mic: |
|||
// 现场录音:主麦与前馈麦交替上行,隔帧取主麦 |
|||
return _BesSource(route, _alternate(_link.audioStream)); |
|||
case AudioRoute.callLocal: |
|||
return _BesSource(route, _link.micPcmStream); |
|||
case AudioRoute.callPeer: |
|||
return _BesSource(route, _link.spkPcmStream); |
|||
case AudioRoute.ai: |
|||
return _BesSource( |
|||
route, |
|||
_link.manager |
|||
.onDeviceAiEvent() |
|||
.where((e) => e['type'] == 'micData' && e['data'] is Uint8List) |
|||
.map((e) => e['data'] as Uint8List) |
|||
.where((d) => d.isNotEmpty)); |
|||
default: |
|||
throw DeviceException(DeviceErrorCode.notSupported, 'openMic($route)'); |
|||
} |
|||
} |
|||
|
|||
static Stream<Uint8List> _alternate(Stream<Uint8List> raw) { |
|||
var take = true; |
|||
return raw.where((_) { |
|||
final t = take; |
|||
take = !take; |
|||
return t; |
|||
}); |
|||
} |
|||
|
|||
@override |
|||
Future<DeviceAudioSink> openSpeaker( |
|||
{AudioRoute route = AudioRoute.ai}) async { |
|||
_requireReady(); |
|||
switch (route) { |
|||
case AudioRoute.ai: |
|||
await _link.manager.startAiDownlink(); |
|||
return _BesAiSink(_link); |
|||
case AudioRoute.callLegA: |
|||
case AudioRoute.callLegB: |
|||
if (_callSinks == 0) await _link.startTranslationDownlink(); |
|||
_callSinks++; |
|||
return _BesCallSink(_link, route, () async { |
|||
_callSinks--; |
|||
if (_callSinks <= 0) { |
|||
_callSinks = 0; |
|||
await _link.stopTranslationDownlink(); |
|||
} |
|||
}); |
|||
default: |
|||
throw DeviceException( |
|||
DeviceErrorCode.notSupported, 'openSpeaker($route)'); |
|||
} |
|||
} |
|||
|
|||
// ---------- feature ---------- |
|||
|
|||
/// feature → 字节的对应表。**冻结**,改动看金测试。 |
|||
static const Map<String, int> featureCommands = { |
|||
DeviceFeatures.recordStart: BesCmd.micRecordStart, |
|||
DeviceFeatures.recordStop: BesCmd.micRecordStop, |
|||
DeviceFeatures.callStart: BesCmd.callModeStart, |
|||
DeviceFeatures.callStop: BesCmd.callModeStop, |
|||
DeviceFeatures.callQuery: BesCmd.callState, |
|||
DeviceFeatures.asrStarted: BesCmd.asrStarted, |
|||
DeviceFeatures.asrStopped: BesCmd.asrStopped, |
|||
DeviceFeatures.aiAccept: BesCmd.aiAccept, |
|||
DeviceFeatures.aiExit: BesCmd.aiExit, |
|||
DeviceFeatures.f2fStart: BesCmd.faceToFaceStart, |
|||
DeviceFeatures.f2fStop: BesCmd.faceToFaceStop, |
|||
DeviceFeatures.batteryQuery: BesCmd.status, |
|||
'bes.firmware.query': BesCmd.firmwareVersion, |
|||
}; |
|||
|
|||
@override |
|||
Future<Map<String, Object?>> invokeFeature(String featureKey, |
|||
[Map<String, Object?> args = const {}]) async { |
|||
_requireReady(); |
|||
final cmd = featureCommands[featureKey]; |
|||
if (cmd != null) { |
|||
await _link.sendData(BesCmd.frame(cmd)); |
|||
return {'sent': BesCmd.frame(cmd)}; |
|||
} |
|||
switch (featureKey) { |
|||
case DeviceFeatures.stateRefresh: |
|||
await _link.refreshDeviceState(); |
|||
return const {}; |
|||
case 'bes.probe': |
|||
await _link.probeCommands( |
|||
from: (args['from'] as int?) ?? 0x21, |
|||
to: (args['to'] as int?) ?? 0x7F, |
|||
); |
|||
return const {}; |
|||
case 'bes.send': |
|||
final bytes = (args['bytes'] as List?)?.cast<int>() ?? const <int>[]; |
|||
if (bytes.isEmpty) { |
|||
throw DeviceException(DeviceErrorCode.invalidArgument, 'bytes'); |
|||
} |
|||
await _link.sendData(bytes); |
|||
return {'sent': bytes}; |
|||
default: |
|||
throw DeviceException(DeviceErrorCode.notSupported, featureKey); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
DeviceOtaPort? otaPort() => _ota ??= BesOtaPort(this, _link); |
|||
|
|||
@override |
|||
Future<void> disconnect() => _link.disconnectDevice(); |
|||
} |
|||
|
|||
class _BesSource implements DeviceAudioSource { |
|||
_BesSource(this.route, Stream<Uint8List> src) { |
|||
_sub = src.listen(_ctrl.add, onError: _ctrl.addError); |
|||
} |
|||
|
|||
@override |
|||
final AudioRoute route; |
|||
final StreamController<Uint8List> _ctrl = StreamController.broadcast(); |
|||
StreamSubscription? _sub; |
|||
|
|||
@override |
|||
AudioFormat get format => AudioFormat.pcm16kMono; |
|||
|
|||
@override |
|||
Stream<Uint8List> get pcm => _ctrl.stream; |
|||
|
|||
@override |
|||
Future<void> close() async { |
|||
await _sub?.cancel(); |
|||
_sub = null; |
|||
await _ctrl.close(); |
|||
} |
|||
} |
|||
|
|||
/// AI 下行:G.722 编码与 60ms 组包、0xD6/0xE9 调速全在原生 `AiAudioDownlink`。 |
|||
class _BesAiSink implements DeviceAudioSink { |
|||
_BesAiSink(this._link); |
|||
final BesBluetoothService _link; |
|||
bool _closed = false; |
|||
|
|||
@override |
|||
AudioRoute get route => AudioRoute.ai; |
|||
|
|||
@override |
|||
AudioFormat get format => AudioFormat.pcm16kMono; |
|||
|
|||
@override |
|||
Future<void> write(Uint8List pcm) async { |
|||
if (_closed) return; |
|||
await _link.manager.pushAiPcm(pcm); |
|||
} |
|||
|
|||
@override |
|||
Future<void> discardPending() async { |
|||
if (_closed) return; |
|||
await _link.manager.flushAiDownlink(); |
|||
} |
|||
|
|||
@override |
|||
Future<void> close() async { |
|||
if (_closed) return; |
|||
_closed = true; |
|||
await _link.manager.stopAiDownlink(); |
|||
} |
|||
} |
|||
|
|||
/// 通话翻译下行:`AA 56` 84 字节包,G.722 + 20ms 节流在原生 `CallTranslationDownlink`。 |
|||
class _BesCallSink implements DeviceAudioSink { |
|||
_BesCallSink(this._link, this.route, this._onClose); |
|||
final BesBluetoothService _link; |
|||
@override |
|||
final AudioRoute route; |
|||
final Future<void> Function() _onClose; |
|||
bool _closed = false; |
|||
|
|||
String get _leg => route == AudioRoute.callLegA ? 'A' : 'B'; |
|||
|
|||
@override |
|||
AudioFormat get format => AudioFormat.pcm16kMono; |
|||
|
|||
@override |
|||
Future<void> write(Uint8List pcm) async { |
|||
if (_closed) return; |
|||
await _link.pushTranslationTtsPcm(_leg, pcm); |
|||
} |
|||
|
|||
@override |
|||
Future<void> discardPending() async {} |
|||
|
|||
@override |
|||
Future<void> close() async { |
|||
if (_closed) return; |
|||
_closed = true; |
|||
await _onClose(); |
|||
} |
|||
} |
|||
@ -0,0 +1,203 @@ |
|||
import 'dart:async'; |
|||
|
|||
import 'package:bes_ota_manager/bes_ota_manager.dart'; |
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import 'bes_bluetooth_service.dart'; |
|||
import 'bes_device_plugin.dart'; |
|||
|
|||
/// 恒玄 SPP OTA 2.0 端口。 |
|||
/// |
|||
/// 恒玄的固件推送由 `bes_ota_manager` 插件**自管**一条 SPP 链路,多数固件同时 |
|||
/// 只接受一条 SPP,所以: |
|||
/// 1. [start] 先断开 app 层连接、留 2s 让耳机拆完旧链路,再让插件连; |
|||
/// 2. 升级中 app 层的「已断开」是我们自己断的,**不能**当成 OTA 掉线; |
|||
/// 3. [close](退出升级页)先 stopOta 释放插件链路,再延时把 app 层连回来 |
|||
/// (成功 5s:设备在重启;其余 2s:等耳机拆链路)。 |
|||
/// |
|||
/// 看门狗、下载、屏幕常亮这些与厂商无关的事在通用 `OtaUpgradeController` 里。 |
|||
class BesOtaPort implements DeviceOtaPort { |
|||
BesOtaPort(this._session, this._link); |
|||
|
|||
static const String _tag = 'BesOtaPort'; |
|||
|
|||
final BesDeviceSession _session; |
|||
final BesBluetoothService _link; |
|||
final BesOtaManager _ota = BesOtaManager(); |
|||
final StreamController<DeviceOtaProgress> _progress = |
|||
StreamController<DeviceOtaProgress>.broadcast(); |
|||
|
|||
StreamSubscription<BesOtaEvent>? _sub; |
|||
bool _running = false; |
|||
bool _terminal = false; |
|||
bool _succeeded = false; |
|||
|
|||
/// 我们自己为了 OTA 断开了 app 层连接 |
|||
bool _handover = false; |
|||
String _address = ''; |
|||
String _name = ''; |
|||
int _lastPercent = -1; |
|||
|
|||
@override |
|||
bool get isRunning => _running; |
|||
|
|||
@override |
|||
Stream<DeviceOtaProgress> get progressStream => _progress.stream; |
|||
|
|||
@override |
|||
Future<void> start(DeviceOtaRequest request) async { |
|||
if (_running) throw DeviceException(DeviceErrorCode.otaBusy); |
|||
if (request is! DeviceOtaFileRequest) { |
|||
throw DeviceException( |
|||
DeviceErrorCode.notSupported, '${request.runtimeType}'); |
|||
} |
|||
// 断开前先抓住设备标识(disconnect 会清空服务字段) |
|||
final id = _link.connectedDeviceId.value; |
|||
_address = id.isNotEmpty ? id : _address; |
|||
final n = _link.connectedDeviceName.value; |
|||
_name = n.isNotEmpty ? n : _name; |
|||
if (_address.isEmpty) { |
|||
throw DeviceException(DeviceErrorCode.noActiveSession, 'no address'); |
|||
} |
|||
|
|||
_running = true; |
|||
_terminal = false; |
|||
_succeeded = false; |
|||
_lastPercent = -1; |
|||
_emit(DeviceOtaState.entering, 0); |
|||
|
|||
// 1) 释放 app 层 SPP,让插件自管 OTA 连接。 |
|||
_handover = true; |
|||
await _link.disconnectDevice(); |
|||
// disconnect 返回只代表本端 socket 已关,耳机侧链路拆除还要在空中传播, |
|||
// 立刻连 OTA 会被拒;留 2s 缓冲。 |
|||
await Future.delayed(const Duration(seconds: 2)); |
|||
if (!_running) return; // 期间被 cancel 了 |
|||
|
|||
// 2) 初始化原生 OTA 环境并订阅事件。 |
|||
await _ota.init(); |
|||
_sub ??= _ota.events.listen(_onEvent, onError: (Object e) { |
|||
_fail(DeviceErrorCode.otaTransferFailed, e.toString()); |
|||
}); |
|||
|
|||
// 3) 启动 SPP OTA 2.0。 |
|||
_emit(DeviceOtaState.transferring, 0); |
|||
await _ota.startOta( |
|||
address: _address, |
|||
name: _name, |
|||
filePath: request.filePath, |
|||
protocol: 'SPP', |
|||
userFlag: 1, |
|||
upgradeType: 1, |
|||
user: 1, |
|||
); |
|||
} |
|||
|
|||
void _onEvent(BesOtaEvent e) { |
|||
// 进入终态即锁定:迟到的进度/Canceled/Error 一律忽略, |
|||
// 否则成功会被迟到的 Canceled 覆盖成失败。 |
|||
if (_terminal) return; |
|||
switch (e.event) { |
|||
case 'onProgress': |
|||
_emitPercent(((e.progress ?? 0) * 100).round()); |
|||
break; |
|||
case 'onStatus': |
|||
// OTA_SEND_DATA_PROGRESS 状态消息带原始浮点百分比,其余状态文本只记日志 |
|||
final numeric = double.tryParse(e.message ?? ''); |
|||
if (numeric != null && numeric > 0) { |
|||
_emitPercent(numeric.clamp(0, 100).round()); |
|||
} else { |
|||
Logger.d(_tag, 'OTA 状态: ${e.message}'); |
|||
} |
|||
break; |
|||
case 'onSuccess': |
|||
_terminal = true; |
|||
_succeeded = true; |
|||
_running = false; |
|||
_emit(DeviceOtaState.done, 100); |
|||
_ota.stopOta(); |
|||
break; |
|||
case 'onError': |
|||
_fail(DeviceErrorCode.otaTransferFailed, |
|||
e.message?.isNotEmpty == true ? e.message! : 'code=${e.code}'); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 进度只在单调递增时上报,避免两个来源(整数 onProgress / 浮点 onStatus) |
|||
/// 高频交替导致数字来回跳。 |
|||
void _emitPercent(int p) { |
|||
if (p <= _lastPercent) return; |
|||
_lastPercent = p; |
|||
_emit(DeviceOtaState.transferring, p); |
|||
} |
|||
|
|||
void _fail(String code, String message) { |
|||
if (_terminal) return; |
|||
_terminal = true; |
|||
_running = false; |
|||
_emit(DeviceOtaState.failed, -1, errorCode: code, errorMessage: message); |
|||
// 失败即释放原生会话与 SPP 链路,否则它还占着唯一的 SPP |
|||
_ota.stopOta(); |
|||
} |
|||
|
|||
void _emit(DeviceOtaState st, int percent, |
|||
{String? errorCode, String? errorMessage}) { |
|||
if (_progress.isClosed) return; |
|||
_progress.add(DeviceOtaProgress( |
|||
state: st, |
|||
sentBytes: 0, |
|||
totalBytes: 0, |
|||
percent: percent, |
|||
tsMs: DateTime.now().millisecondsSinceEpoch, |
|||
errorCode: errorCode, |
|||
errorMessage: errorMessage, |
|||
)); |
|||
} |
|||
|
|||
@override |
|||
Future<void> cancel() async { |
|||
if (!_running) return; |
|||
_running = false; |
|||
_terminal = true; |
|||
await _ota.stopOta(); |
|||
_emit(DeviceOtaState.cancelled, -1); |
|||
} |
|||
|
|||
/// 会话没了(app 层断开)。升级中那次断开是我们自己发起的,忽略。 |
|||
void onSessionGone() { |
|||
if (_handover) return; |
|||
if (_running) _fail(DeviceErrorCode.disconnectedRemote, '设备断开'); |
|||
} |
|||
|
|||
@override |
|||
Future<void> close() async { |
|||
_running = false; |
|||
await _sub?.cancel(); |
|||
_sub = null; |
|||
// 先停 OTA 会话释放插件占用的 SPP,再回连 app 层,避免两条连接打架。 |
|||
try { |
|||
await _ota.stopOta(); |
|||
} catch (_) {} |
|||
_ota.dispose(); |
|||
if (!_progress.isClosed) await _progress.close(); |
|||
|
|||
if (!_handover || _address.isEmpty) return; |
|||
_handover = false; |
|||
final delay = _succeeded |
|||
? const Duration(seconds: 5) // 设备正在重启 |
|||
: const Duration(seconds: 2); // 等耳机拆完 OTA 链路再回连 |
|||
final addr = _address; |
|||
final name = _name; |
|||
Future.delayed(delay, () { |
|||
// 期间可能已(自动/手动)连上,避免重复发起 |
|||
if (_link.deviceStatus.value != 2) { |
|||
_link.connectToDevice(addr, name); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/// 供插件在会话对象被替换时判断归属。 |
|||
bool belongsTo(BesDeviceSession s) => identical(s, _session); |
|||
} |
|||
@ -0,0 +1,278 @@ |
|||
/// 恒玄(BES) 私有协议的**纯函数**编解码。 |
|||
/// |
|||
/// ⚠️ **协议冻结**:这里的每一个字节都对应已量产固件(Echo-one 0.0.2 / 0.0.3) |
|||
/// 的真实行为,由 `test/devices/bes_protocol_test.dart` 用真机抓的帧做金测试。 |
|||
/// 改任何一个常量、任何一处偏移,先看测试为什么红。 |
|||
/// |
|||
/// 这个文件不依赖 Flutter / GetX / 原生插件,只做 `List<int>` ↔ 结构体。 |
|||
library; |
|||
|
|||
/// App → 耳机 的命令(`AA xx`)。 |
|||
/// |
|||
/// 来源:厂商协议 + 真机实测(见各条注释)。请求码与响应码的关系**没有** |
|||
/// 统一规律:多数是 `响应 = 请求 | 0x80`(06→86、0E→8E),但电量是 09→0A。 |
|||
class BesCmd { |
|||
BesCmd._(); |
|||
|
|||
static const int sof = 0xAA; |
|||
|
|||
/// 固件版本查询,回 `BB 86`(或主动上报 `BB 07`) |
|||
static const int firmwareVersion = 0x06; |
|||
|
|||
/// 耳机状态(电量/位置/连接/MAC),回 `BB 0A` + ack `BB 89`。 |
|||
/// 实测扫出来的请求码,文档里那个「响应命令 20」两种进制都无回包。 |
|||
/// ⚠️ 固件 0.0.3 上此命令已不回 `BB 0A`。 |
|||
static const int status = 0x09; |
|||
|
|||
/// 按键配置查询,回 `BB 8C` |
|||
static const int keyConfig = 0x0C; |
|||
|
|||
/// 通话状态查询,回 `BB 8E`(data[6] 0x01=来电 / 0x02=通话中 / 0x03=结束) |
|||
static const int callState = 0x0E; |
|||
|
|||
/// EQ 配置查询,回 `BB 90` |
|||
static const int eqConfig = 0x10; |
|||
|
|||
/// 进入通话模式:耳机开始 G.722 mode=0x03 双路交织上行 |
|||
static const int callModeStart = 0x51; |
|||
|
|||
/// 退出通话模式 |
|||
static const int callModeStop = 0x52; |
|||
|
|||
/// 通话翻译下行 TTS 包(84 字节,由原生 `CallTranslationDownlink` 组包) |
|||
static const int translationTts = 0x56; |
|||
|
|||
/// 通知耳机 ASR 已开启 |
|||
static const int asrStarted = 0x57; |
|||
|
|||
/// 通知耳机 ASR 已关闭 |
|||
static const int asrStopped = 0x58; |
|||
|
|||
/// 开始设备端主麦录音(mSBC 上行),设备回 `BB D9` |
|||
static const int micRecordStart = 0x59; |
|||
|
|||
/// 停止设备端主麦录音,设备回 `BB DA` |
|||
static const int micRecordStop = 0x5A; |
|||
|
|||
/// 请求退出 AI 模式,耳机回 `BB E5` |
|||
static const int aiExit = 0x65; |
|||
|
|||
/// 开始面对面翻译,耳机回 `BB E6` |
|||
static const int faceToFaceStart = 0x66; |
|||
|
|||
/// 结束面对面翻译,耳机回 `BB E7` |
|||
static const int faceToFaceStop = 0x67; |
|||
|
|||
/// 应答 AI 唤醒(收到 `BB 64` 后 **3 秒内**必须发) |
|||
static const int aiAccept = 0x6A; |
|||
|
|||
/// 这一段会切换耳机工作模式(进通话/录音/AI),探测时一律不碰 |
|||
static bool switchesMode(int cmd) => cmd >= 0x51 && cmd <= 0x6A; |
|||
|
|||
static List<int> frame(int cmd) => <int>[sof, cmd & 0xFF]; |
|||
} |
|||
|
|||
/// 耳机 → App 的响应/上报(`BB xx`)。 |
|||
class BesResp { |
|||
BesResp._(); |
|||
|
|||
static const int sof = 0xBB; |
|||
|
|||
/// 主动上报固件版本 |
|||
static const int firmwarePush = 0x07; |
|||
|
|||
/// 响应 `AA 06` |
|||
static const int firmwareVersion = 0x86; |
|||
|
|||
/// 耳机状态帧(电量/位置/连接/MAC) |
|||
static const int status = 0x0A; |
|||
|
|||
/// `AA 09` 的 ack(`BB 89 04 00`),无数据 |
|||
static const int statusAck = 0x89; |
|||
|
|||
/// 周期上报的另一种状态帧,`[len,attr,value]×2`,值 0/1,含义未定 |
|||
static const int unknown18 = 0x18; |
|||
} |
|||
|
|||
/// `BB 0A` 状态帧里的属性号。 |
|||
class BesStatusAttr { |
|||
BesStatusAttr._(); |
|||
|
|||
static const int batteryLeft = 0x01; |
|||
static const int batteryRight = 0x02; |
|||
static const int batteryCase = 0x03; |
|||
|
|||
/// 耳机位置(是否在盒内),值常 >100,**不是电量** |
|||
static const int position = 0x04; |
|||
|
|||
/// 连接状态,值常 >100 |
|||
static const int connection = 0x05; |
|||
|
|||
/// 预留 12 字节,里面嵌着一个含义未知的 MAC,**不要拿去确权** |
|||
static const int reserved = 0x06; |
|||
|
|||
/// 耳机蓝牙地址 6 字节——耳机自报的真 MAC,iOS 上唯一可靠来源 |
|||
static const int macAddress = 0x07; |
|||
} |
|||
|
|||
/// `BB 0A` 解析结果。没出现的字段为 null。 |
|||
class BesStatus { |
|||
const BesStatus({ |
|||
this.batteryLeft, |
|||
this.batteryRight, |
|||
this.batteryCase, |
|||
this.chargingLeft = false, |
|||
this.chargingRight = false, |
|||
this.chargingCase = false, |
|||
this.mac, |
|||
}); |
|||
|
|||
final int? batteryLeft; |
|||
final int? batteryRight; |
|||
final int? batteryCase; |
|||
final bool chargingLeft; |
|||
final bool chargingRight; |
|||
final bool chargingCase; |
|||
|
|||
/// 大写、冒号分隔,如 `11:11:22:33:33:A4` |
|||
final String? mac; |
|||
|
|||
bool get hasBattery => |
|||
batteryLeft != null || batteryRight != null || batteryCase != null; |
|||
|
|||
/// 「只显示一个数」时用:左右取较低一侧;两只都在盒里只有盒电量时退回盒。 |
|||
int? get summaryBattery { |
|||
final lr = [batteryLeft, batteryRight].whereType<int>().toList(); |
|||
if (lr.isNotEmpty) return lr.reduce((a, b) => a < b ? a : b); |
|||
return batteryCase; |
|||
} |
|||
|
|||
@override |
|||
String toString() => |
|||
'BesStatus(L=$batteryLeft${chargingLeft ? '⚡' : ''} ' |
|||
'R=$batteryRight${chargingRight ? '⚡' : ''} ' |
|||
'C=$batteryCase${chargingCase ? '⚡' : ''} mac=$mac)'; |
|||
} |
|||
|
|||
/// 固件版本帧解析结果。 |
|||
class BesFirmware { |
|||
const BesFirmware({this.left, this.right, this.box}); |
|||
|
|||
final String? left; |
|||
final String? right; |
|||
final String? box; |
|||
|
|||
/// 优先左耳。 |
|||
String? get preferred => left ?? right ?? box; |
|||
} |
|||
|
|||
class BesProtocol { |
|||
BesProtocol._(); |
|||
|
|||
static String hex(Iterable<int> b) => b |
|||
.map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()) |
|||
.join(' '); |
|||
|
|||
static String macOf(Iterable<int> b) => b |
|||
.map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()) |
|||
.join(':'); |
|||
|
|||
/// 是不是一个 `BB` 开头、至少带命令号的帧。 |
|||
static bool isResponse(List<int> data) => |
|||
data.length >= 2 && (data[0] & 0xFF) == BesResp.sof; |
|||
|
|||
/// 解析 `BB 0A` 状态帧。 |
|||
/// |
|||
/// 帧格式 `BB | 0A | CMD Length(1) | 块… | checksum`, |
|||
/// 块是 `[len, 属性, 数据…]`,**len 计的是「属性 + 数据」的字节数**。 |
|||
/// |
|||
/// 三个踩过的坑(都会让电量永远显示不出来、且不报错): |
|||
/// 1. 起点是 **3** 不是 4:长度字段只有 1 字节,照抄固件版本帧的 `p = 4` |
|||
/// 会整体错位一格,第一个块长度读成 0x01 < 2 直接 break; |
|||
/// 2. 充电盒是属性 **0x03** 不是 0x04(0x04 是耳机位置,值常 >100); |
|||
/// 3. 电量字节最高位是充电指示位,必须 `& 0x7F`。 |
|||
/// |
|||
/// 属性 3(盒电量)**可选**:耳机不在盒内时这帧没有它。 |
|||
static BesStatus parseStatus(List<int> data) { |
|||
int? l, r, c; |
|||
var cl = false, cr = false, cc = false; |
|||
String? mac; |
|||
var p = 3; |
|||
while (p + 2 < data.length) { |
|||
final blockLen = data[p] & 0xFF; |
|||
if (blockLen < 2 || p + blockLen >= data.length) break; |
|||
final attr = data[p + 1] & 0xFF; |
|||
final raw = data[p + 2] & 0xFF; |
|||
final level = raw & 0x7F; |
|||
final charging = (raw & 0x80) != 0; |
|||
if (level <= 100) { |
|||
switch (attr) { |
|||
case BesStatusAttr.batteryLeft: |
|||
l = level; |
|||
cl = charging; |
|||
break; |
|||
case BesStatusAttr.batteryRight: |
|||
r = level; |
|||
cr = charging; |
|||
break; |
|||
case BesStatusAttr.batteryCase: |
|||
c = level; |
|||
cc = charging; |
|||
break; |
|||
} |
|||
} |
|||
if (attr == BesStatusAttr.macAddress && |
|||
blockLen >= 7 && |
|||
p + 7 < data.length) { |
|||
mac = macOf(data.sublist(p + 2, p + 8)); |
|||
} |
|||
p += blockLen + 1; |
|||
} |
|||
return BesStatus( |
|||
batteryLeft: l, |
|||
batteryRight: r, |
|||
batteryCase: c, |
|||
chargingLeft: cl, |
|||
chargingRight: cr, |
|||
chargingCase: cc, |
|||
mac: mac, |
|||
); |
|||
} |
|||
|
|||
/// 解析固件版本帧(`BB 86` / `BB 07`): |
|||
/// `BB CMD len(2) + 若干版本块 [len, 属性, 8字节版本]`, |
|||
/// 属性 0x01左 / 0x02右 / 0x04盒;版本取版本块后 3 字节。 |
|||
static BesFirmware parseFirmware(List<int> data) { |
|||
String? left, right, box; |
|||
var p = 4; |
|||
while (p + 1 < data.length) { |
|||
final blockLen = data[p] & 0xFF; |
|||
if (blockLen < 2 || p + blockLen >= data.length) break; |
|||
final attr = data[p + 1] & 0xFF; |
|||
final version = data.sublist(p + 2, p + 1 + blockLen); |
|||
if (version.length >= 8) { |
|||
final v = '${version[5]}.${version[6]}.${version[7]}'; |
|||
switch (attr) { |
|||
case 0x01: |
|||
left = v; |
|||
break; |
|||
case 0x02: |
|||
right = v; |
|||
break; |
|||
case 0x04: |
|||
box = v; |
|||
break; |
|||
} |
|||
} |
|||
p += blockLen + 1; |
|||
} |
|||
return BesFirmware(left: left, right: right, box: box); |
|||
} |
|||
|
|||
/// 通话状态查询 `BB 8E` 的 data[6]:0x01=来电 0x02=通话中 0x03=结束。 |
|||
/// 原生已经把 0x02/0x03 翻成 startMakeCall/stopMakeCall 事件,这里只作备查。 |
|||
static int? parseCallState(List<int> data) { |
|||
if (data.length < 7 || (data[1] & 0xFF) != 0x8E) return null; |
|||
return data[6] & 0xFF; |
|||
} |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
|
|||
import 'bes/bes_device_auth.dart'; |
|||
import 'bes/bes_device_plugin.dart'; |
|||
import 'device_hub.dart'; |
|||
import 'smartcar/smartcar_device_auth.dart'; |
|||
import 'smartcar/smartcar_device_plugin.dart'; |
|||
|
|||
/// 确权结论(厂商无关)。 |
|||
/// |
|||
/// ⚠️ [unknown] 的语义是**放行**,不是"失败"。判不出来时放行是刻意选的失败方向: |
|||
/// 判严的代价是合法用户一断网就用不了自己的设备;判宽只是未登记设备离线时 |
|||
/// 能用一会儿,联网第一次确权就会被拦下。 |
|||
enum DeviceAuthResult { |
|||
/// 白名单命中 / 服务端确认 / 已在 User.devices 里 |
|||
authorized, |
|||
|
|||
/// 服务端明确拒绝(这台 MAC 没登记) |
|||
denied, |
|||
|
|||
/// 未登录 / 断网 / 拿不到 MAC —— **放行** |
|||
unknown, |
|||
} |
|||
|
|||
/// 设备确权的业务层入口(登出重置、解绑清白名单、补校当前设备)。 |
|||
/// |
|||
/// 确权本身是各厂商插件在连接时就地做的(恒玄见 [BesDeviceAuth]),这里只把 |
|||
/// 需要从业务层触发的几个动作聚在一起,业务层不用知道是哪家的实现。 |
|||
class DeviceAuth { |
|||
DeviceAuth._(); |
|||
|
|||
/// 登出:换账号后同一台设备要重新登记到新账号名下。**白名单不清**。 |
|||
static void reset() { |
|||
BesDeviceAuth.reset(); |
|||
SmartcarDeviceAuth.reset(); |
|||
} |
|||
|
|||
/// 解绑:移出白名单,下次连上重新走服务端确权。 |
|||
/// |
|||
/// 两家都调:调用方只有一个 MAC,不知道它属于哪个厂商, |
|||
/// 而各家白名单是按 MAC 存的、互不相干,多清一次无副作用。 |
|||
static Future<void> forget(String mac) async { |
|||
await BesDeviceAuth.forget(mac); |
|||
await SmartcarDeviceAuth.forget(mac); |
|||
} |
|||
|
|||
/// 【调试/售后】清空白名单 |
|||
static Future<void> clearWhitelist() async { |
|||
await BesDeviceAuth.clearWhitelist(); |
|||
await SmartcarDeviceAuth.clearWhitelist(); |
|||
} |
|||
|
|||
/// 补校当前连着的、需要确权的设备(顺带完成绑定登记)。 |
|||
/// 「先连耳机后登录」时连接事件里的登记会因未登录跳过,设备管理页进来补一次。 |
|||
static Future<void> verifyConnected() async { |
|||
final hub = DeviceHub.to; |
|||
for (final s in hub.sessions.toList()) { |
|||
if (!s.supports(DeviceCapability.macAddress)) continue; |
|||
final p = hub.plugin(s.vendor); |
|||
if (p is BesDevicePlugin) { |
|||
await p.link.auth?.verifyConnected(); |
|||
} else if (p is SmartcarDevicePlugin) { |
|||
// Smartcar 的 MAC 来自广播,两端都拿得到(不像恒玄 iOS 只有 UUID) |
|||
await SmartcarDeviceAuth.verify( |
|||
mac: s.info.macAddress, |
|||
name: s.info.name, |
|||
); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../core/utils/logger.dart'; |
|||
import 'bes/bes_device_auth.dart'; |
|||
import 'bes/bes_device_plugin.dart'; |
|||
import 'device_hub.dart'; |
|||
import 'holder/holder_device_plugin.dart'; |
|||
import 'smartcar/smartcar_device_plugin.dart'; |
|||
|
|||
/// 装配设备中枢:注册所有厂商插件并初始化。由 InitialBinding 调用一次。 |
|||
/// |
|||
/// 新接一种芯片 = 在这里多 `registerPlugin` 一行 + `lib/devices/<vendor>/` 一个目录, |
|||
/// 业务层零改动。 |
|||
/// |
|||
/// 两个插件的 `initialize` 都是轻量的:恒玄在 iOS 上必须无条件建 CBCentralManager |
|||
/// 才能被动接管系统层连接,Android 只在绑过设备时申请权限;支架只在记住过设备时 |
|||
/// 注册后台扫连。所以这里不做「按绑定记录挑插件」,行为与改造前一致。 |
|||
void installDeviceHub() { |
|||
if (Get.isRegistered<DeviceHub>()) return; |
|||
final hub = Get.put<DeviceHub>(DeviceHub(), permanent: true); |
|||
|
|||
final bes = BesDevicePlugin(); |
|||
bes.link.auth = BesDeviceAuth(bes.link); |
|||
_register(hub, bes); |
|||
|
|||
_register(hub, HolderDevicePlugin()); |
|||
|
|||
// Smartcar 车载香薰。BLE 走 flutter_blue_plus(纯 Dart,无原生插件)。 |
|||
// initialize 里不申请权限也不起扫描,冷启动零副作用。 |
|||
_register(hub, SmartcarDevicePlugin()); |
|||
} |
|||
|
|||
void _register(DeviceHub hub, DevicePlugin p) { |
|||
try { |
|||
hub.registerPlugin(p); |
|||
p.initialize(const DevicePluginConfig()); |
|||
} catch (e) { |
|||
Logger.e('DeviceBootstrap', '初始化厂商插件 ${p.vendorKey} 失败: $e'); |
|||
} |
|||
} |
|||
@ -0,0 +1,265 @@ |
|||
import 'dart:async'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../core/utils/logger.dart'; |
|||
|
|||
/// 设备中枢——**业务层唯一的设备入口**。 |
|||
/// |
|||
/// 业务层(`lib/modules`、`lib/core`)只能 import 这个文件和 |
|||
/// `device_plugin_interface`,不能 import 任何 `lib/devices/<vendor>/` 下的东西, |
|||
/// `test/devices/device_hub_boundary_test.dart` 守着这条线。 |
|||
/// |
|||
/// 职责: |
|||
/// - 持有各厂商插件(恒玄 / EaiRec 支架 / 以后接的每一种芯片各一个); |
|||
/// - 汇总所有 `ready` 会话成一份可观测状态(连没连、能力集、电量、通话中…); |
|||
/// - 合流唤醒事件与会话事件,供常驻服务订阅; |
|||
/// - 「最近连过的设备」这类展示用信息也从这里拿。 |
|||
/// |
|||
/// 多设备模型:耳机和支架可以同时在线,所以是 `sessions` 列表而不是单个 |
|||
/// active。要「一台能录音的设备」就问 [sessionWith]。 |
|||
class DeviceHub extends GetxService { |
|||
static const String _tag = 'DeviceHub'; |
|||
static DeviceHub get to => Get.find<DeviceHub>(); |
|||
|
|||
final Map<String, DevicePlugin> _plugins = {}; |
|||
final Map<String, StreamSubscription> _pluginSubs = {}; |
|||
final Map<String, StreamSubscription> _sessionSubs = {}; |
|||
final Map<String, StreamSubscription> _wakeSubs = {}; |
|||
|
|||
/// 当前 `ready` 的会话,按连上的先后排。 |
|||
final RxList<DeviceSession> sessions = <DeviceSession>[].obs; |
|||
|
|||
/// 任一设备在线。 |
|||
final RxBool anyConnected = false.obs; |
|||
|
|||
/// 所有在线设备能力的并集。UI 显隐一律问它,不问厂商。 |
|||
final RxSet<DeviceCapability> capabilities = <DeviceCapability>{}.obs; |
|||
|
|||
/// 正在通话中(由声明了 [DeviceCapability.callState] 的设备上报)。 |
|||
final RxBool inCall = false.obs; |
|||
|
|||
/// 主设备(第一台在线的)的信息快照;没有设备时为 null。 |
|||
final Rxn<DeviceInfo> primaryInfo = Rxn<DeviceInfo>(); |
|||
|
|||
/// 主设备名;没有设备时为空串。 |
|||
final RxString primaryName = ''.obs; |
|||
|
|||
/// 每当任何会话的 info / 状态变了就 +1,给只想「重画一下」的 UI 用。 |
|||
final RxInt revision = 0.obs; |
|||
|
|||
final StreamController<DeviceWakeEvent> _wake = |
|||
StreamController<DeviceWakeEvent>.broadcast(); |
|||
final StreamController<DeviceSessionEvent> _events = |
|||
StreamController<DeviceSessionEvent>.broadcast(); |
|||
|
|||
/// 所有在线设备的唤醒事件合流。 |
|||
Stream<DeviceWakeEvent> get wakeEvents => _wake.stream; |
|||
|
|||
/// 所有在线设备的会话事件合流。 |
|||
Stream<DeviceSessionEvent> get sessionEvents => _events.stream; |
|||
|
|||
// ---------------- 插件管理 ---------------- |
|||
|
|||
Iterable<DevicePlugin> get plugins => _plugins.values; |
|||
|
|||
DevicePlugin? plugin(String vendorKey) => _plugins[vendorKey]; |
|||
|
|||
/// 注册一个厂商插件。重复 key 直接覆盖并报警(正常不该发生)。 |
|||
void registerPlugin(DevicePlugin p) { |
|||
if (_plugins.containsKey(p.vendorKey)) { |
|||
Logger.w(_tag, '重复注册厂商插件 ${p.vendorKey},覆盖旧实例'); |
|||
_pluginSubs.remove(p.vendorKey)?.cancel(); |
|||
} |
|||
_plugins[p.vendorKey] = p; |
|||
_pluginSubs[p.vendorKey] = p.eventStream.listen( |
|||
(e) => _onPluginEvent(p, e), |
|||
onError: (e) => Logger.e(_tag, '${p.vendorKey} 事件流错误: $e'), |
|||
); |
|||
// 插件可能在注册前就已经连上了(比如原生被动回连),补一次快照 |
|||
final s = p.activeSession; |
|||
if (s != null && s.state == DeviceConnectionState.ready) _attach(s); |
|||
Logger.i(_tag, '已注册厂商插件 ${p.vendorKey}(${p.displayName})'); |
|||
} |
|||
|
|||
// ---------------- 查询 ---------------- |
|||
|
|||
bool has(DeviceCapability c) => capabilities.contains(c); |
|||
|
|||
/// 第一台具备该能力的在线设备;没有为 null。 |
|||
DeviceSession? sessionWith(DeviceCapability c) { |
|||
for (final s in sessions) { |
|||
if (s.supports(c)) return s; |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
/// 主音频设备:第一台能上行麦克风的。 |
|||
DeviceSession? get primaryAudio => sessionWith(DeviceCapability.micUplink); |
|||
|
|||
/// 主设备 = **最近上线**的那台([_attach] 把新会话插在最前)。 |
|||
DeviceSession? get primary => sessions.isEmpty ? null : sessions.first; |
|||
|
|||
DeviceSession? sessionOf(String vendorKey) { |
|||
for (final s in sessions) { |
|||
if (s.vendor == vendorKey) return s; |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
/// 最近连过(或正连着)的设备名,用于断连后仍显示「我的那台设备」。 |
|||
/// 从没连过任何设备返回空串。 |
|||
String get lastKnownName { |
|||
if (primaryName.value.isNotEmpty) return primaryName.value; |
|||
for (final p in _plugins.values) { |
|||
final d = p.lastKnownDevice; |
|||
if (d != null && d.name.isNotEmpty) return d.name; |
|||
} |
|||
return ''; |
|||
} |
|||
|
|||
bool get hasKnownDevice => lastKnownName.isNotEmpty; |
|||
|
|||
// ---------------- 动作 ---------------- |
|||
|
|||
/// 冷启动/前台恢复时让每个插件各自回连记住的设备。 |
|||
Future<void> tryAutoConnectAll() async { |
|||
for (final p in _plugins.values) { |
|||
try { |
|||
await p.tryAutoConnect(); |
|||
} catch (e) { |
|||
Logger.e(_tag, '${p.vendorKey} 自动回连失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
Future<void> disconnectAll() async { |
|||
for (final s in sessions.toList()) { |
|||
try { |
|||
await s.disconnect(); |
|||
} catch (e) { |
|||
Logger.e(_tag, '${s.vendor} 断开失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 进功能页时刷一遍所有在线设备的状态(电量会掉、配置会变)。 |
|||
Future<void> refreshAll() async { |
|||
for (final s in sessions.toList()) { |
|||
try { |
|||
await s.refreshInfo(); |
|||
} catch (e) { |
|||
Logger.w(_tag, '${s.vendor} 刷新状态失败: $e'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// ---------------- 内部 ---------------- |
|||
|
|||
void _onPluginEvent(DevicePlugin p, DevicePluginEvent e) { |
|||
switch (e.type) { |
|||
case DevicePluginEventType.connectionStateChanged: |
|||
final s = p.activeSession; |
|||
if (e.connectionState == DeviceConnectionState.ready && s != null) { |
|||
_attach(s); |
|||
} else if (e.connectionState == DeviceConnectionState.disconnected) { |
|||
_detachVendor(p.vendorKey); |
|||
} |
|||
break; |
|||
case DevicePluginEventType.error: |
|||
Logger.w(_tag, '${p.vendorKey} 错误: ${e.errorCode} ${e.errorMessage}'); |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
} |
|||
|
|||
void _attach(DeviceSession s) { |
|||
if (sessions.any((x) => identical(x, s))) return; |
|||
// 同一厂商只保留最新的会话 |
|||
_detachVendor(s.vendor, silent: true); |
|||
// ★ 单活动设备:用户从车载香薰切到耳机(反之亦然),旧设备的链路要断掉, |
|||
// 不能两台同时在线——设备页只有一张图、一个电量,两台并存时展示和音频路由都会打架。 |
|||
// 新上线的设备**排在最前**成为主设备,设备页的图/名字/电量跟着它走。 |
|||
// (2026-09-10 用户反馈:切到耳机后香薰 BLE 没断、设备页图片也没换。) |
|||
// ⚠️ 被动回连(系统 ACL 把耳机连回来)同样会走到这里并顶掉当前设备,这是刻意的: |
|||
// "最近连上的那台就是用户此刻想用的那台"。 |
|||
for (final other in sessions.where((x) => x.vendor != s.vendor).toList()) { |
|||
Logger.i(_tag, '切换设备:${s.vendor} 上线,断开 ${other.vendor} ${other.info.name}'); |
|||
_detachVendor(other.vendor, silent: true); |
|||
other.disconnect().catchError((Object e) { |
|||
Logger.w(_tag, '${other.vendor} 断开失败: $e'); |
|||
}); |
|||
} |
|||
sessions.insert(0, s); |
|||
_sessionSubs[s.vendor] = s.eventStream.listen((e) { |
|||
_events.add(e); |
|||
_onSessionEvent(s, e); |
|||
}); |
|||
_wakeSubs[s.vendor] = s.wakeEvents.listen(_wake.add); |
|||
Logger.i(_tag, '设备上线: ${s.vendor} ${s.info.name} caps=${s.capabilities.map((c) => c.name).join(',')}'); |
|||
_recompute(); |
|||
} |
|||
|
|||
void _detachVendor(String vendor, {bool silent = false}) { |
|||
_sessionSubs.remove(vendor)?.cancel(); |
|||
_wakeSubs.remove(vendor)?.cancel(); |
|||
final before = sessions.length; |
|||
sessions.removeWhere((x) => x.vendor == vendor); |
|||
if (sessions.length != before && !silent) { |
|||
Logger.i(_tag, '设备下线: $vendor'); |
|||
} |
|||
_recompute(); |
|||
} |
|||
|
|||
void _onSessionEvent(DeviceSession s, DeviceSessionEvent e) { |
|||
switch (e.type) { |
|||
case DeviceSessionEventType.connectionStateChanged: |
|||
if (e.connectionState == DeviceConnectionState.disconnected) { |
|||
_detachVendor(s.vendor); |
|||
} |
|||
break; |
|||
case DeviceSessionEventType.deviceInfoUpdated: |
|||
_recompute(); |
|||
break; |
|||
case DeviceSessionEventType.feature: |
|||
final key = e.feature?.key; |
|||
if (key == DeviceFeatureKeys.callStarted) inCall.value = true; |
|||
if (key == DeviceFeatureKeys.callEnded) inCall.value = false; |
|||
break; |
|||
default: |
|||
break; |
|||
} |
|||
} |
|||
|
|||
void _recompute() { |
|||
anyConnected.value = sessions.isNotEmpty; |
|||
final caps = <DeviceCapability>{}; |
|||
for (final s in sessions) { |
|||
caps.addAll(s.capabilities); |
|||
} |
|||
capabilities.assignAll(caps); |
|||
final p = primary; |
|||
primaryInfo.value = p?.info; |
|||
primaryName.value = p?.info.name ?? ''; |
|||
if (p == null) inCall.value = false; |
|||
revision.value++; |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
for (final s in _pluginSubs.values) { |
|||
s.cancel(); |
|||
} |
|||
for (final s in _sessionSubs.values) { |
|||
s.cancel(); |
|||
} |
|||
for (final s in _wakeSubs.values) { |
|||
s.cancel(); |
|||
} |
|||
_wake.close(); |
|||
_events.close(); |
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
/// 厂商插件的 key。业务层只在「选品类 → 进哪个连接页」这类地方用到, |
|||
/// 其余判断一律走 [DeviceCapability]。 |
|||
class DeviceVendors { |
|||
DeviceVendors._(); |
|||
|
|||
/// 恒玄 BES 耳机(Echo-one),经典蓝牙 SPP / iOS GATT 桥 |
|||
static const String bes = 'bes'; |
|||
|
|||
/// EaiRec 录音支架,BLE + Opus |
|||
static const String holder = 'holder'; |
|||
|
|||
/// Smartcar 车载香薰(协议待接) |
|||
static const String smartcar = 'smartcar'; |
|||
} |
|||
@ -0,0 +1,437 @@ |
|||
import 'dart:async'; |
|||
import 'dart:io'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import '../device_vendors.dart'; |
|||
import 'holder_device_service.dart'; |
|||
import 'holder_file_store.dart'; |
|||
import 'holder_ota_port.dart'; |
|||
|
|||
/// EaiRec 录音支架的厂商插件——把 [HolderDeviceService](协议在 `recorder_holder` |
|||
/// 插件里,冻结)适配成 [DevicePlugin] / [DeviceSession]。 |
|||
/// |
|||
/// 与恒玄的差异全在这一层吸收:BLE 扫描而不是系统配对列表、Opus 而不是 G.722、 |
|||
/// 录音是「切状态 + 开编码器」两步而不是一条 `AA 59`。业务层看到的是同一套 |
|||
/// `openMic(mic)` / `record.start`。 |
|||
class HolderDevicePlugin implements DevicePlugin { |
|||
final HolderDeviceService link; |
|||
|
|||
HolderDevicePlugin({HolderDeviceService? link}) |
|||
: link = link ?? HolderDeviceService(); |
|||
|
|||
final StreamController<DevicePluginEvent> _ctrl = |
|||
StreamController<DevicePluginEvent>.broadcast(); |
|||
HolderDeviceSession? _session; |
|||
final List<Worker> _workers = []; |
|||
bool _initialized = false; |
|||
bool _disposed = false; |
|||
|
|||
@override |
|||
String get vendorKey => DeviceVendors.holder; |
|||
|
|||
@override |
|||
String get displayName => 'EaiRec (录音支架)'; |
|||
|
|||
@override |
|||
Set<DeviceCapability> get capabilities => HolderDeviceSession.caps; |
|||
|
|||
@override |
|||
DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; |
|||
|
|||
@override |
|||
DeviceSession? get activeSession => _session; |
|||
|
|||
@override |
|||
Stream<DevicePluginEvent> get eventStream => _ctrl.stream; |
|||
|
|||
@override |
|||
Future<void> initialize(DevicePluginConfig config) async { |
|||
_checkAlive(); |
|||
if (_initialized) return; |
|||
_initialized = true; |
|||
if (!Get.isRegistered<HolderDeviceService>()) { |
|||
Get.put<HolderDeviceService>(link, permanent: true); |
|||
} |
|||
_workers.add(ever<EchoMeetConnectionState>(link.connection, _onConn)); |
|||
_workers.add(ever<List<EchoMeetScannedDevice>>(link.scanned, (list) { |
|||
for (final d in list) { |
|||
_emit(DevicePluginEvent( |
|||
type: DevicePluginEventType.deviceDiscovered, |
|||
deviceId: d.address, |
|||
discovered: _discovered(d), |
|||
)); |
|||
} |
|||
})); |
|||
_workers.add(ever<bool>(link.isScanning, (on) { |
|||
_emit(DevicePluginEvent( |
|||
type: on |
|||
? DevicePluginEventType.scanStarted |
|||
: DevicePluginEventType.scanStopped)); |
|||
})); |
|||
if (link.isConnected) _onConn(EchoMeetConnectionState.connected); |
|||
_emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); |
|||
} |
|||
|
|||
DiscoveredDevice _discovered(EchoMeetScannedDevice d) => DiscoveredDevice( |
|||
id: d.address.isNotEmpty ? d.address : d.id, |
|||
name: d.name, |
|||
vendor: vendorKey, |
|||
metadata: {'advertisementHex': d.advertisementHex, 'sdkId': d.id}, |
|||
); |
|||
|
|||
void _onConn(EchoMeetConnectionState s) { |
|||
switch (s) { |
|||
case EchoMeetConnectionState.connected: |
|||
if (_session != null) return; |
|||
final sess = HolderDeviceSession._(link); |
|||
_session = sess; |
|||
_emit(DevicePluginEvent( |
|||
type: DevicePluginEventType.connectionStateChanged, |
|||
deviceId: sess.deviceId, |
|||
connectionState: DeviceConnectionState.ready, |
|||
)); |
|||
break; |
|||
case EchoMeetConnectionState.connecting: |
|||
_emit(DevicePluginEvent( |
|||
type: DevicePluginEventType.connectionStateChanged, |
|||
deviceId: link.connectedAddress, |
|||
connectionState: DeviceConnectionState.connecting, |
|||
)); |
|||
break; |
|||
default: |
|||
final sess = _session; |
|||
_session = null; |
|||
sess?._markDisconnected(); |
|||
_emit(DevicePluginEvent( |
|||
type: DevicePluginEventType.connectionStateChanged, |
|||
deviceId: sess?.deviceId, |
|||
connectionState: DeviceConnectionState.disconnected, |
|||
)); |
|||
} |
|||
} |
|||
|
|||
void _emit(DevicePluginEvent e) { |
|||
if (!_ctrl.isClosed) _ctrl.add(e); |
|||
} |
|||
|
|||
// ---------- 扫描 / 连接 ---------- |
|||
|
|||
@override |
|||
Future<bool> ensureReady() async { |
|||
if (link.isPermissionCoolingDown) return false; |
|||
// 用户主动进连接页:允许再申请一次系统蓝牙权限 |
|||
link.resetPermissionDenied(); |
|||
return link.requestPermission(); |
|||
} |
|||
|
|||
/// 用户拒绝过蓝牙权限且仍在冷却期——UI 据此置灰入口 |
|||
bool get isPermissionCoolingDown => link.isPermissionCoolingDown; |
|||
|
|||
@override |
|||
Future<void> startScan({DeviceScanFilter? filter, Duration? timeout}) => |
|||
link.startScan(); |
|||
|
|||
@override |
|||
Future<void> stopScan() => link.stopScan(); |
|||
|
|||
@override |
|||
Future<bool> isScanning() async => link.isScanning.value; |
|||
|
|||
@override |
|||
Future<List<DiscoveredDevice>> bondedDevices() async => |
|||
link.scanned.map(_discovered).toList(growable: false); |
|||
|
|||
@override |
|||
Future<DeviceSession> connect(String deviceId, |
|||
{DeviceConnectOptions? options}) async { |
|||
_checkAlive(); |
|||
final d = link.scanned.firstWhereOrNull( |
|||
(x) => x.address.toUpperCase() == deviceId.toUpperCase() || x.id == deviceId); |
|||
if (d == null) { |
|||
throw DeviceException(DeviceErrorCode.connectFailed, '未扫描到 $deviceId'); |
|||
} |
|||
final registered = await link.connect(d); |
|||
if (!registered) { |
|||
throw DeviceException(DeviceErrorCode.connectFailed, deviceId); |
|||
} |
|||
final ok = await link.waitConnected( |
|||
timeout: options?.timeout ?? const Duration(seconds: 25)); |
|||
if (!ok) throw DeviceException(DeviceErrorCode.connectTimeout, deviceId); |
|||
_onConn(EchoMeetConnectionState.connected); |
|||
return _session!; |
|||
} |
|||
|
|||
@override |
|||
List<DiscoveredDevice> get rememberedDevices => link.pairedDevices |
|||
.map((e) => DiscoveredDevice( |
|||
id: (e['address'] ?? '').toString(), |
|||
name: (e['name'] ?? '').toString(), |
|||
vendor: vendorKey, |
|||
)) |
|||
.where((d) => d.id.isNotEmpty) |
|||
.toList(growable: false); |
|||
|
|||
@override |
|||
DiscoveredDevice? get lastKnownDevice { |
|||
final n = link.lastDeviceName; |
|||
if (n.isEmpty) return null; |
|||
return DiscoveredDevice(id: link.connectedAddress, name: n, vendor: vendorKey); |
|||
} |
|||
|
|||
@override |
|||
Future<void> rememberDevice(DiscoveredDevice device) async { |
|||
// 支架在 connect 成功时自己会记;这里保持幂等 |
|||
} |
|||
|
|||
@override |
|||
Future<void> tryAutoConnect() => link.tryAutoConnect(); |
|||
|
|||
@override |
|||
Future<void> forgetDevice(String deviceId) async { |
|||
link.removePaired(deviceId); |
|||
if (link.isConnected && |
|||
link.connectedAddress.toUpperCase() == deviceId.toUpperCase()) { |
|||
await link.disconnect(); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<void> dispose() async { |
|||
_disposed = true; |
|||
for (final w in _workers) { |
|||
w.dispose(); |
|||
} |
|||
_session?._markDisconnected(); |
|||
_session = null; |
|||
await _ctrl.close(); |
|||
} |
|||
|
|||
void _checkAlive() { |
|||
if (_disposed) throw StateError('HolderDevicePlugin disposed'); |
|||
} |
|||
} |
|||
|
|||
class HolderDeviceSession extends DeviceSession { |
|||
HolderDeviceSession._(this._link) { |
|||
_bind(); |
|||
} |
|||
|
|||
static const String _tag = 'HolderSession'; |
|||
|
|||
static const Set<DeviceCapability> caps = { |
|||
DeviceCapability.scan, |
|||
DeviceCapability.connect, |
|||
DeviceCapability.battery, |
|||
DeviceCapability.macAddress, |
|||
DeviceCapability.micUplink, |
|||
DeviceCapability.micRecording, |
|||
// AB FOT 固件升级(协议实现在 recorder_holder/lib/src/ota/),见 [HolderOtaPort] |
|||
DeviceCapability.ota, |
|||
// 通道 B:TF 卡录音文件,见 [HolderFileStore]。 |
|||
// ⚠️ 这台硬件**只有**这条录音路径可用(没有 ae00 实时音频服务)。 |
|||
DeviceCapability.fileStore, |
|||
DeviceCapability.customCommand, |
|||
}; |
|||
|
|||
final HolderDeviceService _link; |
|||
final StreamController<DeviceSessionEvent> _events = |
|||
StreamController<DeviceSessionEvent>.broadcast(); |
|||
final List<Worker> _workers = []; |
|||
DeviceConnectionState _state = DeviceConnectionState.ready; |
|||
bool _lastRecording = false; |
|||
|
|||
void _bind() { |
|||
_lastRecording = _link.recording.value.isRecording; |
|||
_workers.add(ever<EchoMeetRecordingState>(_link.recording, (st) { |
|||
if (st.isRecording == _lastRecording) return; |
|||
_lastRecording = st.isRecording; |
|||
_feature(st.isRecording |
|||
? DeviceFeatureKeys.recordStarted |
|||
: DeviceFeatureKeys.recordStopped); |
|||
})); |
|||
_workers.add(ever<EchoMeetDeviceInfo?>(_link.deviceInfo, (_) { |
|||
_feature(DeviceFeatureKeys.batteryUpdated); |
|||
_infoUpdated(); |
|||
})); |
|||
_workers.add(ever<String>(_link.connectedNameRx, (_) => _infoUpdated())); |
|||
} |
|||
|
|||
void _feature(String key, [Map<String, Object?> data = const {}]) { |
|||
if (_events.isClosed) return; |
|||
_events.add(DeviceSessionEvent( |
|||
type: DeviceSessionEventType.feature, |
|||
deviceId: deviceId, |
|||
feature: DeviceFeatureEvent(key: key, data: data), |
|||
)); |
|||
} |
|||
|
|||
void _infoUpdated() { |
|||
if (_events.isClosed) return; |
|||
_events.add(DeviceSessionEvent( |
|||
type: DeviceSessionEventType.deviceInfoUpdated, |
|||
deviceId: deviceId, |
|||
deviceInfo: info, |
|||
)); |
|||
} |
|||
|
|||
void _markDisconnected() { |
|||
if (_state == DeviceConnectionState.disconnected) return; |
|||
_state = DeviceConnectionState.disconnected; |
|||
for (final w in _workers) { |
|||
w.dispose(); |
|||
} |
|||
_workers.clear(); |
|||
_ota?.onSessionGone(); |
|||
if (!_events.isClosed) { |
|||
_events.add(DeviceSessionEvent( |
|||
type: DeviceSessionEventType.connectionStateChanged, |
|||
deviceId: deviceId, |
|||
connectionState: DeviceConnectionState.disconnected, |
|||
)); |
|||
_events.close(); |
|||
} |
|||
} |
|||
|
|||
void _requireReady() { |
|||
if (_state != DeviceConnectionState.ready) { |
|||
throw DeviceException(DeviceErrorCode.noActiveSession, 'holder'); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
String get deviceId => _link.connectedAddress; |
|||
|
|||
@override |
|||
String get vendor => DeviceVendors.holder; |
|||
|
|||
@override |
|||
DeviceConnectionState get state => _state; |
|||
|
|||
@override |
|||
Set<DeviceCapability> get capabilities => caps; |
|||
|
|||
@override |
|||
Stream<DeviceSessionEvent> get eventStream => _events.stream; |
|||
|
|||
static final RegExp _macRe = RegExp(r'^([0-9A-F]{2}:){5}[0-9A-F]{2}$'); |
|||
|
|||
@override |
|||
DeviceInfo get info { |
|||
final i = _link.deviceInfo.value; |
|||
final lvl = i?.batteryLevel; |
|||
final addr = _link.connectedAddress.toUpperCase(); |
|||
return DeviceInfo( |
|||
id: deviceId, |
|||
name: _link.connectedName, |
|||
vendor: vendor, |
|||
firmwareVersion: i?.firmwareVersion, |
|||
batteryPercent: lvl, |
|||
battery: DeviceBattery(single: lvl, chargingSingle: i?.isCharging ?? false), |
|||
// iOS 上拿到的是 peripheral UUID,只有长得像 MAC 才当 MAC |
|||
macAddress: Platform.isAndroid || _macRe.hasMatch(addr) ? addr : null, |
|||
metadata: {'productId': i?.productId, 'deviceIsRecording': _link.deviceIsRecording}, |
|||
); |
|||
} |
|||
|
|||
@override |
|||
Future<int> readRssi() => |
|||
throw DeviceException(DeviceErrorCode.notSupported, 'rssi'); |
|||
|
|||
@override |
|||
Future<int?> readBattery() async { |
|||
_requireReady(); |
|||
await _link.refreshBattery(); |
|||
return info.battery.single; |
|||
} |
|||
|
|||
@override |
|||
Future<DeviceInfo> refreshInfo() async { |
|||
_requireReady(); |
|||
await _link.refreshBattery(); |
|||
return info; |
|||
} |
|||
|
|||
/// 主麦上行。**必须**打开设备编码器,否则设备不往手机推 Opus 流 |
|||
/// (设备按键发起的录音只切本地录音态、不开编码器,所以无条件补一条)。 |
|||
@override |
|||
Future<DeviceAudioSource> openMic({AudioRoute route = AudioRoute.mic}) async { |
|||
_requireReady(); |
|||
if (route != AudioRoute.mic) { |
|||
throw DeviceException(DeviceErrorCode.notSupported, 'openMic($route)'); |
|||
} |
|||
final ok = await _link.openEncoder(); |
|||
if (!ok) Logger.w(_tag, 'openEncoder 返回 false,可能收不到音频流'); |
|||
return _HolderSource(_link.audioFrames.map((f) => f.pcm)); |
|||
} |
|||
|
|||
@override |
|||
Future<Map<String, Object?>> invokeFeature(String featureKey, |
|||
[Map<String, Object?> args = const {}]) async { |
|||
_requireReady(); |
|||
switch (featureKey) { |
|||
case DeviceFeatures.recordStart: |
|||
// toggle 语义:设备已在录时再切一次会把它关掉,先看设备状态 |
|||
if (_link.deviceIsRecording) return const {'ok': true, 'already': true}; |
|||
final ok = await _link.startRecording(); |
|||
return {'ok': ok}; |
|||
case DeviceFeatures.recordStop: |
|||
if (!_link.deviceIsRecording) return const {'ok': true, 'already': true}; |
|||
final ok = await _link.stopRecording( |
|||
fileName: args['fileName'] as String?); |
|||
return {'ok': ok}; |
|||
case 'holder.toggle': |
|||
case 'record.toggle': |
|||
return {'ok': await _link.toggleRecording()}; |
|||
case DeviceFeatures.batteryQuery: |
|||
return {'ok': await _link.refreshBattery()}; |
|||
case DeviceFeatures.stateRefresh: |
|||
await _link.refreshBattery(); |
|||
return const {}; |
|||
default: |
|||
throw DeviceException(DeviceErrorCode.notSupported, featureKey); |
|||
} |
|||
} |
|||
|
|||
/// 主动断开 = 「不要它了」:支架 SDK 的 disconnect 会一并清掉自动回连记忆。 |
|||
HolderOtaPort? _ota; |
|||
HolderFileStore? _files; |
|||
|
|||
@override |
|||
DeviceFileStore? fileStore() => _files ??= HolderFileStore(_link); |
|||
|
|||
@override |
|||
DeviceOtaPort? otaPort() => _ota ??= HolderOtaPort(_link); |
|||
|
|||
@override |
|||
Future<void> disconnect() => _link.disconnect(); |
|||
} |
|||
|
|||
class _HolderSource implements DeviceAudioSource { |
|||
_HolderSource(Stream<Uint8List> src) { |
|||
_sub = src.listen(_ctrl.add, onError: _ctrl.addError); |
|||
} |
|||
|
|||
final StreamController<Uint8List> _ctrl = StreamController.broadcast(); |
|||
StreamSubscription? _sub; |
|||
|
|||
@override |
|||
AudioRoute get route => AudioRoute.mic; |
|||
|
|||
@override |
|||
AudioFormat get format => AudioFormat.pcm16kMono; |
|||
|
|||
@override |
|||
Stream<Uint8List> get pcm => _ctrl.stream; |
|||
|
|||
@override |
|||
Future<void> close() async { |
|||
await _sub?.cancel(); |
|||
_sub = null; |
|||
await _ctrl.close(); |
|||
} |
|||
} |
|||
@ -0,0 +1,120 @@ |
|||
import 'dart:async'; |
|||
import 'dart:io'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import 'holder_device_service.dart'; |
|||
|
|||
/// EaiRec 支架的设备端文件仓库 —— 通道 B(TF 卡,`0xFDA5`)。 |
|||
/// |
|||
/// **这台硬件唯一可用的录音路径就是这条**:它没有通道 A' 实时音频上行 |
|||
/// (真机 GATT 里根本没有 `ae00`,见 CLAUDE.md 支架那节),录音是 |
|||
/// 「录到 TF 卡、事后拉」。所以本类不是锦上添花,是支架录音能不能用的关键。 |
|||
/// |
|||
/// 协议与状态机都在 `recorder_holder` 的 `RecClient` 里,本类只做两件事: |
|||
/// 把它接到通用的 [DeviceFileStore] 上,以及把 SDK 的异常翻译成 [DeviceException]。 |
|||
class HolderFileStore implements DeviceFileStore { |
|||
HolderFileStore(this._link); |
|||
|
|||
static const String _tag = 'HolderFileStore'; |
|||
|
|||
final HolderDeviceService _link; |
|||
|
|||
@override |
|||
bool get isDeviceRecording => |
|||
_link.deviceIsRecording || (_link.recInfo.value?.recording ?? false); |
|||
|
|||
@override |
|||
Stream<List<DeviceFileEntry>> get listPushes => |
|||
_link.recFilesPushes.map((list) => list.map(_toEntry).toList()); |
|||
|
|||
@override |
|||
Future<List<DeviceFileEntry>> list() async { |
|||
try { |
|||
final files = await _link.listRecordings(); |
|||
return files.map(_toEntry).toList(growable: false); |
|||
} on RecException catch (e) { |
|||
throw DeviceException(DeviceErrorCode.fileTransferFailed, e.toString()); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<String> download( |
|||
String name, { |
|||
required String destPath, |
|||
void Function(int received, int total)? onProgress, |
|||
DeviceFileCancelToken? cancelToken, |
|||
}) async { |
|||
// ⚠️ 正在录的那个文件不能拉:设备还在往里写,拉下来是个残片, |
|||
// 而且 size 一直在变,断点续传的长度判断也会错。 |
|||
if (isDeviceRecording) { |
|||
throw DeviceException(DeviceErrorCode.fileBusy, '设备正在录音'); |
|||
} |
|||
|
|||
final dest = File(destPath); |
|||
await dest.parent.create(recursive: true); |
|||
|
|||
// SDK 的取消令牌与通用令牌是两套类型,这里做个桥接 |
|||
final tok = RecCancelToken(); |
|||
if (cancelToken?.isCancelled ?? false) { |
|||
tok.cancel(); |
|||
} else { |
|||
unawaited(cancelToken?.whenCancelled.then((_) => tok.cancel())); |
|||
} |
|||
|
|||
final sink = FileRecSink(dest); |
|||
await sink.open(); |
|||
try { |
|||
final result = await _link.downloadTo( |
|||
name: name, |
|||
sink: sink, |
|||
onProgress: onProgress, |
|||
cancelToken: tok, |
|||
); |
|||
if (!result.complete) { |
|||
// 没传完只有两种可能:被取消,或链路断了。前者不是故障。 |
|||
if (cancelToken?.isCancelled ?? false) { |
|||
throw DeviceException(DeviceErrorCode.cancelled, name); |
|||
} |
|||
throw DeviceException( |
|||
DeviceErrorCode.fileTransferFailed, '$name 未传输完整'); |
|||
} |
|||
// ⚠️ 落盘字节数必须与设备报的文件长度逐字节对上。 |
|||
// 这条是**删除设备原件的前置条件**:传输层虽然逐包校验了 checksum |
|||
// (RecClient.verifyChecksum 默认开)且 complete 判的是 bytes==fileSize, |
|||
// 但那看的是 sink 的计数,不是磁盘上真实的长度。写盘失败/被截断时 |
|||
// 两者会不一致,而我们马上要拿这个结果去做不可逆的删除。 |
|||
final onDisk = await dest.length(); |
|||
if (onDisk != result.fileSize) { |
|||
throw DeviceException(DeviceErrorCode.fileTransferFailed, |
|||
'$name 落盘 $onDisk B 与设备报的 ${result.fileSize} B 不符'); |
|||
} |
|||
Logger.i(_tag, |
|||
'已拉取 $name → $destPath ($onDisk B,' |
|||
'断点续传 ${result.resumes} 次)'); |
|||
return destPath; |
|||
} on RecException catch (e) { |
|||
throw DeviceException(DeviceErrorCode.fileTransferFailed, e.toString()); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
Future<void> delete(String name) async { |
|||
try { |
|||
await _link.deleteRecording(name); |
|||
} on RecException catch (e) { |
|||
throw DeviceException(DeviceErrorCode.featureFailed, e.toString()); |
|||
} |
|||
} |
|||
|
|||
DeviceFileEntry _toEntry(RecFileItem f) => DeviceFileEntry( |
|||
name: f.name, |
|||
size: f.size, |
|||
// RecFileItem.time 解不出合法 FAT 日期时就是 null,原样透传: |
|||
// 上层会退回「按导入时刻命名」,好过显示 1980 年 |
|||
modifiedAt: f.time, |
|||
metadata: {'ftime': f.ftime}, |
|||
); |
|||
} |
|||
@ -0,0 +1,222 @@ |
|||
import 'dart:async'; |
|||
import 'dart:io'; |
|||
|
|||
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
|||
import 'package:recorder_holder/recorder_holder.dart'; |
|||
import 'package:recorder_holder/recorder_holder_ota.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import 'holder_device_service.dart'; |
|||
|
|||
/// EaiRec 支架(中科蓝讯方案)的固件 OTA 端口 —— AB FOT 协议。 |
|||
/// |
|||
/// 协议实现全在 `recorder_holder/lib/src/ota/`(帧格式、CRC、TLV、状态机), |
|||
/// 本类只做两件事:把它接到通用的 [DeviceOtaPort] 上,以及处理**连接交接**。 |
|||
/// |
|||
/// ⚠️ **OTA 期间必须让出常规 BLE 连接**:一部手机对同一个外设只能有一条链路, |
|||
/// 而 `FotBleTransport` 自己用 flutter_blue_plus 连 OTA 服务(`0xFF12`, |
|||
/// 与常规的 `abc0`/`ae00`/`FDA5` 是不同的 service,设备重启进 OTA 模式后才暴露)。 |
|||
/// 所以 [start] 先 `setEnterOta(true)` 让原生放手,[close] 再 `setEnterOta(false)` |
|||
/// 把设备交还给常规会话。这与恒玄那条 SPP OTA 的交接思路一致。 |
|||
/// |
|||
/// ⚠️ 设备进 OTA 模式后**可能换一个蓝牙地址广播**,所以 `knownRemoteId` 只是 |
|||
/// Android 上的快路径,失败会自动退回按 service UUID 扫描(两端共用那条路径)。 |
|||
class HolderOtaPort implements DeviceOtaPort { |
|||
HolderOtaPort(this._link); |
|||
|
|||
static const String _tag = 'HolderOtaPort'; |
|||
|
|||
final HolderDeviceService _link; |
|||
|
|||
/// 原生插件单例。`setEnterOta` 只在插件层暴露(SDK 没转发),直接用它, |
|||
/// 少一层转发就少一处会忘记同步的地方。 |
|||
final RecorderHolder _plugin = RecorderHolder(); |
|||
|
|||
final StreamController<DeviceOtaProgress> _progress = |
|||
StreamController<DeviceOtaProgress>.broadcast(); |
|||
|
|||
FotBleTransport? _transport; |
|||
FotOtaSession? _session; |
|||
StreamSubscription<FotProgress>? _sub; |
|||
|
|||
bool _running = false; |
|||
bool _terminal = false; |
|||
bool _succeeded = false; |
|||
|
|||
/// 我们为了 OTA 把常规连接让出去了,[close] 时要还回来 |
|||
bool _handover = false; |
|||
int _lastPercent = -1; |
|||
|
|||
@override |
|||
bool get isRunning => _running; |
|||
|
|||
@override |
|||
Stream<DeviceOtaProgress> get progressStream => _progress.stream; |
|||
|
|||
@override |
|||
Future<void> start(DeviceOtaRequest request) async { |
|||
if (_running) throw DeviceException(DeviceErrorCode.otaBusy); |
|||
if (request is! DeviceOtaFileRequest) { |
|||
throw DeviceException( |
|||
DeviceErrorCode.notSupported, '${request.runtimeType}'); |
|||
} |
|||
|
|||
final file = File(request.filePath); |
|||
if (!await file.exists()) { |
|||
throw DeviceException(DeviceErrorCode.otaFileInvalid, request.filePath); |
|||
} |
|||
final bytes = await file.readAsBytes(); |
|||
if (bytes.isEmpty) { |
|||
throw DeviceException(DeviceErrorCode.otaFileInvalid, '固件为空'); |
|||
} |
|||
|
|||
_running = true; |
|||
_terminal = false; |
|||
_succeeded = false; |
|||
_lastPercent = -1; |
|||
_emit(DeviceOtaState.entering, 0); |
|||
|
|||
// 1) 让出常规连接。原生 setEnterOta(true) 会 disconnect 且不自动重连。 |
|||
final mac = _link.connectedAddress; |
|||
_handover = true; |
|||
await _plugin.setEnterOta(true); |
|||
// 设备要重启进 OTA 模式并重新广播,给它一点时间;连太早只会白等超时。 |
|||
await Future.delayed(const Duration(seconds: 2)); |
|||
if (!_running) return; // 期间被 cancel 了 |
|||
|
|||
// 2) 建 OTA 链路(自管连接,与原生那条互斥) |
|||
final config = FotOtaConfig( |
|||
knownRemoteId: Platform.isAndroid && mac.isNotEmpty ? mac : null, |
|||
); |
|||
final transport = FotBleTransport(config: config); |
|||
_transport = transport; |
|||
|
|||
final firmware = FotFirmware.fromBytes(bytes); |
|||
final session = FotOtaSession( |
|||
transport: transport, |
|||
firmware: firmware, |
|||
config: config, |
|||
); |
|||
_session = session; |
|||
_sub = session.progress.listen(_onProgress, onError: (Object e) { |
|||
_fail(DeviceErrorCode.otaTransferFailed, e.toString()); |
|||
}); |
|||
|
|||
try { |
|||
await transport.open(); |
|||
if (!_running) return; |
|||
await session.start(); |
|||
} catch (e) { |
|||
_fail(DeviceErrorCode.otaTransferFailed, e.toString()); |
|||
} |
|||
} |
|||
|
|||
void _onProgress(FotProgress p) { |
|||
// 终态锁定:迟到的事件一律忽略,否则成功会被收尾事件覆盖成失败 |
|||
if (_terminal) return; |
|||
switch (p.stage) { |
|||
case FotStage.connecting: |
|||
_emit(DeviceOtaState.entering, 0); |
|||
break; |
|||
case FotStage.handshaking: |
|||
case FotStage.querying: |
|||
_emit(DeviceOtaState.inquiring, 0); |
|||
break; |
|||
case FotStage.transferring: |
|||
_emitPercent(p.percent); |
|||
break; |
|||
case FotStage.finishing: |
|||
case FotStage.verifying: |
|||
// 压缩包在这一步解压+烧写,可能要几十秒,UI 别当成卡死 |
|||
_emit(DeviceOtaState.verifying, 100); |
|||
break; |
|||
case FotStage.completed: |
|||
_terminal = true; |
|||
_succeeded = true; |
|||
_running = false; |
|||
_emit(DeviceOtaState.done, 100); |
|||
break; |
|||
case FotStage.failed: |
|||
_fail(DeviceErrorCode.otaTransferFailed, |
|||
p.message ?? p.deviceStatus?.toString() ?? 'failed'); |
|||
break; |
|||
case FotStage.idle: |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 进度只在单调递增时上报,避免数字来回跳 |
|||
void _emitPercent(int p) { |
|||
if (p <= _lastPercent) return; |
|||
_lastPercent = p; |
|||
_emit(DeviceOtaState.transferring, p); |
|||
} |
|||
|
|||
void _fail(String code, String message) { |
|||
if (_terminal) return; |
|||
_terminal = true; |
|||
_running = false; |
|||
Logger.w(_tag, 'OTA 失败: $message'); |
|||
_emit(DeviceOtaState.failed, -1, errorCode: code, errorMessage: message); |
|||
} |
|||
|
|||
void _emit(DeviceOtaState st, int percent, |
|||
{String? errorCode, String? errorMessage}) { |
|||
if (_progress.isClosed) return; |
|||
_progress.add(DeviceOtaProgress( |
|||
state: st, |
|||
sentBytes: 0, |
|||
totalBytes: 0, |
|||
percent: percent, |
|||
tsMs: DateTime.now().millisecondsSinceEpoch, |
|||
errorCode: errorCode, |
|||
errorMessage: errorMessage, |
|||
)); |
|||
} |
|||
|
|||
@override |
|||
Future<void> cancel() async { |
|||
if (!_running) return; |
|||
_running = false; |
|||
_terminal = true; |
|||
await _teardownLink(); |
|||
_emit(DeviceOtaState.cancelled, -1); |
|||
} |
|||
|
|||
/// 会话没了(常规连接断开)。升级中那次断开是我们自己让出的,忽略。 |
|||
void onSessionGone() { |
|||
if (_handover) return; |
|||
if (_running) _fail(DeviceErrorCode.disconnectedRemote, '设备断开'); |
|||
} |
|||
|
|||
Future<void> _teardownLink() async { |
|||
await _sub?.cancel(); |
|||
_sub = null; |
|||
try { |
|||
_session?.dispose(); |
|||
} catch (_) {} |
|||
_session = null; |
|||
try { |
|||
await _transport?.close(); |
|||
} catch (_) {} |
|||
_transport = null; |
|||
} |
|||
|
|||
@override |
|||
Future<void> close() async { |
|||
_running = false; |
|||
await _teardownLink(); |
|||
if (!_progress.isClosed) await _progress.close(); |
|||
|
|||
if (!_handover) return; |
|||
_handover = false; |
|||
// 把设备交还给常规会话。升级成功时设备正在重启,多等几秒。 |
|||
await _plugin.setEnterOta(false); |
|||
final delay = _succeeded |
|||
? const Duration(seconds: 5) |
|||
: const Duration(seconds: 2); |
|||
Future.delayed(delay, () { |
|||
if (!_link.isConnected) _link.tryAutoConnect(); |
|||
}); |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue