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