You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

120 lines
4.4 KiB

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},
);
}