优化配对和远程开锁
This commit is contained in:
parent
917a4341d0
commit
ab18b60a3c
@ -13,6 +13,9 @@ final ValueNotifier<String?> appPeerId = ValueNotifier<String?>(null);
|
|||||||
final ValueNotifier<bool> videoTalkReady = ValueNotifier<bool>(false);
|
final ValueNotifier<bool> videoTalkReady = ValueNotifier<bool>(false);
|
||||||
final ValueNotifier<bool> videoTalkInitializing = ValueNotifier<bool>(false);
|
final ValueNotifier<bool> videoTalkInitializing = ValueNotifier<bool>(false);
|
||||||
|
|
||||||
|
// 最近一次成功完成 Wi-Fi 配网的锁 ID;远程开锁只对该锁开放。
|
||||||
|
final ValueNotifier<int?> wifiConfiguredLockId = ValueNotifier<int?>(null);
|
||||||
|
|
||||||
// lock.skychip.top 授权登录返回的动态 token,仅用于 WiFi 网关配网。
|
// lock.skychip.top 授权登录返回的动态 token,仅用于 WiFi 网关配网。
|
||||||
// 它与 cloud.skychip.top 的 OAuth token 属于不同认证域。
|
// 它与 cloud.skychip.top 的 OAuth token 属于不同认证域。
|
||||||
final ValueNotifier<String?> lockServiceAccessToken =
|
final ValueNotifier<String?> lockServiceAccessToken =
|
||||||
|
|||||||
@ -82,6 +82,12 @@ class S {
|
|||||||
static String get notPaired => isEn ? 'Not Paired' : '未配对';
|
static String get notPaired => isEn ? 'Not Paired' : '未配对';
|
||||||
static String get btnOpen => isEn ? 'Open' : '开锁';
|
static String get btnOpen => isEn ? 'Open' : '开锁';
|
||||||
static String get btnClose => isEn ? 'Close' : '关锁';
|
static String get btnClose => isEn ? 'Close' : '关锁';
|
||||||
|
static String get btnDeleteLock => isEn ? 'Delete lock' : '删除锁';
|
||||||
|
static String get confirmDeleteLockTitle =>
|
||||||
|
isEn ? 'Delete lock' : '删除锁';
|
||||||
|
static String get confirmDeleteLockMessage => isEn
|
||||||
|
? 'This removes the lock from the account and resets its local binding. Continue?'
|
||||||
|
: '此操作会从当前账号删除该锁,并重置锁端绑定信息,确定继续吗?';
|
||||||
|
|
||||||
// WiFi Tab
|
// WiFi Tab
|
||||||
static String get wifiTitle => isEn ? 'WiFi Config' : 'WiFi配网';
|
static String get wifiTitle => isEn ? 'WiFi Config' : 'WiFi配网';
|
||||||
@ -119,6 +125,33 @@ class S {
|
|||||||
static String get btnAccept => isEn ? 'Accept' : '接听';
|
static String get btnAccept => isEn ? 'Accept' : '接听';
|
||||||
static String get btnReject => isEn ? 'Reject' : '拒绝';
|
static String get btnReject => isEn ? 'Reject' : '拒绝';
|
||||||
static String get btnHangup => isEn ? 'Hang up' : '挂断';
|
static String get btnHangup => isEn ? 'Hang up' : '挂断';
|
||||||
|
static String get btnRemoteUnlock =>
|
||||||
|
isEn ? 'Remote unlock' : '远程开锁';
|
||||||
|
static String get confirmRemoteUnlockTitle =>
|
||||||
|
isEn ? 'Confirm remote unlock' : '确认远程开锁';
|
||||||
|
static String get confirmRemoteUnlockMessage => isEn
|
||||||
|
? 'Make sure it is safe to unlock the door.'
|
||||||
|
: '请确认现场安全后再远程开锁。';
|
||||||
|
static String get remoteUnlockUnavailable => isEn
|
||||||
|
? 'The lock service reports remote unlock as unsupported or disabled. Enable remote unlock for this lock, then refresh its details and try again.'
|
||||||
|
: '云端锁详情显示不支持或未开启远程开锁。请在锁端/管理端开启该锁的远程开锁权限,刷新锁详情后重试。';
|
||||||
|
static String get remoteUnlockNeedsGateway => isEn
|
||||||
|
? 'This lock is not connected to a gateway for remote unlock.'
|
||||||
|
: '该锁未绑定网关,无法使用网关远程开锁。';
|
||||||
|
static String get remoteUnlockPeerUnavailable => isEn
|
||||||
|
? 'The lock peer is missing or does not match the active video call.'
|
||||||
|
: '未获取到与本次视频通话匹配的锁 Peer,无法透传开锁。';
|
||||||
|
static String get remoteUnlockNeedsOnline => isEn
|
||||||
|
? 'The lock is offline. Remote unlock is unavailable.'
|
||||||
|
: '该锁当前离线,暂时无法远程开锁。';
|
||||||
|
static String get remoteUnlockNeedsLogin => isEn
|
||||||
|
? 'Please sign in on the Login tab first.'
|
||||||
|
: '请先在登录页完成锁服务账号登录。';
|
||||||
|
static String get remoteUnlockNeedsWifi => isEn
|
||||||
|
? 'Complete Wi-Fi setup for this lock before remote unlock.'
|
||||||
|
: '请先完成当前锁的 Wi-Fi 配置,再使用远程开锁。';
|
||||||
|
static String get remoteUnlockSuccess =>
|
||||||
|
isEn ? 'Remote unlock request succeeded.' : '远程开锁请求成功。';
|
||||||
static String get btnMonitor => isEn ? 'Monitor' : '监控';
|
static String get btnMonitor => isEn ? 'Monitor' : '监控';
|
||||||
static String get logTalkReady => isEn ? 'Talk Ready' : '视频对讲就绪';
|
static String get logTalkReady => isEn ? 'Talk Ready' : '视频对讲就绪';
|
||||||
static String get deviceIdLabel => isEn ? 'Device ID' : '设备ID';
|
static String get deviceIdLabel => isEn ? 'Device ID' : '设备ID';
|
||||||
|
|||||||
@ -63,6 +63,7 @@ class _DeviceScanTabState extends State<DeviceScanTab> {
|
|||||||
final lock = await StarCloudClient.instance.device
|
final lock = await StarCloudClient.instance.device
|
||||||
.pairDevice(scanResult: _devices[idx]);
|
.pairDevice(scanResult: _devices[idx]);
|
||||||
// 更新 ValueNotifier 的值
|
// 更新 ValueNotifier 的值
|
||||||
|
wifiConfiguredLockId.value = null;
|
||||||
pairedLockId.value = lock.lockId;
|
pairedLockId.value = lock.lockId;
|
||||||
pairedLock.value = lock;
|
pairedLock.value = lock;
|
||||||
|
|
||||||
@ -121,6 +122,7 @@ class _DeviceScanTabState extends State<DeviceScanTab> {
|
|||||||
label: Text('${S.pairedStatus}: #$id'),
|
label: Text('${S.pairedStatus}: #$id'),
|
||||||
backgroundColor: Colors.green.shade50,
|
backgroundColor: Colors.green.shade50,
|
||||||
onDeleted: () {
|
onDeleted: () {
|
||||||
|
wifiConfiguredLockId.value = null;
|
||||||
pairedLockId.value = null;
|
pairedLockId.value = null;
|
||||||
pairedLock.value = null;
|
pairedLock.value = null;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -34,6 +34,50 @@ class _LockControlTabState extends State<LockControlTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteLock() async {
|
||||||
|
final id = pairedLockId.value;
|
||||||
|
if (id == null || _loading) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(S.confirmDeleteLockTitle),
|
||||||
|
content: Text(S.confirmDeleteLockMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(S.isEn ? 'Cancel' : '取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: Text(S.btnDeleteLock),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final label = S.btnDeleteLock;
|
||||||
|
_log_('$label...');
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
await StarCloudClient.instance.device.resetLock(lockId: id);
|
||||||
|
wifiConfiguredLockId.value = null;
|
||||||
|
pairedLockId.value = null;
|
||||||
|
pairedLock.value = null;
|
||||||
|
final result = S.isEn ? "Success" : "成功";
|
||||||
|
_log_('$result: $label');
|
||||||
|
} catch (e) {
|
||||||
|
final result = S.isEn ? "Failed" : "失败";
|
||||||
|
_log_('$result: $e');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 监听语言切换通知
|
// 监听语言切换通知
|
||||||
@ -76,6 +120,16 @@ class _LockControlTabState extends State<LockControlTab> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: id == null || _loading ? null : _deleteLock,
|
||||||
|
icon: const Icon(Icons.delete_forever),
|
||||||
|
label: Text(S.btnDeleteLock),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
side: const BorderSide(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
LogArea(log: _log),
|
LogArea(log: _log),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_pcm_sound/flutter_pcm_sound.dart';
|
||||||
|
import 'package:starcloud/entity/star_cloud_lock_list.dart';
|
||||||
import 'package:flutter_sdk/starcloud_flutter_sdk.dart';
|
import 'package:flutter_sdk/starcloud_flutter_sdk.dart';
|
||||||
import 'package:video_decode_plugin/video_decode_plugin.dart';
|
import 'package:video_decode_plugin/video_decode_plugin.dart';
|
||||||
import '../l10n.dart';
|
import '../l10n.dart';
|
||||||
@ -16,16 +18,13 @@ class VideoTalkTab extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _VideoTalkTabState extends State<VideoTalkTab> {
|
class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||||
// 与参考项目保持一致:为 UDP 帧保留一个很短的乱序窗口,避免 P 帧
|
|
||||||
// 在前序帧尚未到达时被过早提交给解码器。
|
|
||||||
static const _maxVideoReorderDistance = 3;
|
static const _maxVideoReorderDistance = 3;
|
||||||
static const _videoReorderWait = Duration(milliseconds: 15);
|
static const _videoReorderWait = Duration(milliseconds: 15);
|
||||||
|
|
||||||
// 参考锁端原生 App:主动“监控”只请求 H264 视频。部分固件在首个
|
|
||||||
// TalkExpect 同时携带 G711 音频时不会回复协商包,最终导致请求超时。
|
|
||||||
static const TalkOptions _monitorOptions = TalkOptions(
|
static const TalkOptions _monitorOptions = TalkOptions(
|
||||||
preferredVideoTypes: [VideoTypeE.H264],
|
preferredVideoTypes: [VideoTypeE.H264],
|
||||||
preferredAudioTypes: [],
|
preferredAudioTypes: [AudioTypeE.G711],
|
||||||
);
|
);
|
||||||
|
|
||||||
final List<String> _log = [];
|
final List<String> _log = [];
|
||||||
@ -39,6 +38,9 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
bool _decoderReady = false;
|
bool _decoderReady = false;
|
||||||
Future<bool>? _decoderInitFuture;
|
Future<bool>? _decoderInitFuture;
|
||||||
bool _frameRendered = false;
|
bool _frameRendered = false;
|
||||||
|
bool _remoteUnlocking = false;
|
||||||
|
int? _videoLockId;
|
||||||
|
int? _videoLockCloudUid;
|
||||||
int _videoCount = 0;
|
int _videoCount = 0;
|
||||||
int _videoWidth = 640;
|
int _videoWidth = 640;
|
||||||
int _videoHeight = 480;
|
int _videoHeight = 480;
|
||||||
@ -56,6 +58,11 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
bool _isCallMode = false;
|
bool _isCallMode = false;
|
||||||
bool _preparingMonitor = false;
|
bool _preparingMonitor = false;
|
||||||
Timer? _videoFrameTimeoutTimer;
|
Timer? _videoFrameTimeoutTimer;
|
||||||
|
bool _audioReady = false;
|
||||||
|
Future<void>? _audioInitFuture;
|
||||||
|
Future<void> _audioQueue = Future<void>.value();
|
||||||
|
int _audioPacketCount = 0;
|
||||||
|
int _audioGeneration = 0;
|
||||||
|
|
||||||
StreamSubscription? _callSub;
|
StreamSubscription? _callSub;
|
||||||
StreamSubscription? _statusSub;
|
StreamSubscription? _statusSub;
|
||||||
@ -85,6 +92,7 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
_videoProcessTimer?.cancel();
|
_videoProcessTimer?.cancel();
|
||||||
_videoFrameTimeoutTimer?.cancel();
|
_videoFrameTimeoutTimer?.cancel();
|
||||||
_videoBuffer.clear();
|
_videoBuffer.clear();
|
||||||
|
unawaited(_stopAudio());
|
||||||
VideoDecodePlugin.setOnMissingSpsPpsListener(() {});
|
VideoDecodePlugin.setOnMissingSpsPpsListener(() {});
|
||||||
_releaseDecoder();
|
_releaseDecoder();
|
||||||
pairedLock.removeListener(_onPairedLockChanged);
|
pairedLock.removeListener(_onPairedLockChanged);
|
||||||
@ -101,6 +109,8 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
_call = e;
|
_call = e;
|
||||||
_status = TalkStatus.incomingCall;
|
_status = TalkStatus.incomingCall;
|
||||||
_activeLockPeerId = e.lockPeerId;
|
_activeLockPeerId = e.lockPeerId;
|
||||||
|
_videoLockId = pairedLock.value?.lockId;
|
||||||
|
_videoLockCloudUid = pairedLock.value?.cloudUid;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -131,9 +141,16 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
e.status == TalkStatus.hangup ||
|
e.status == TalkStatus.hangup ||
|
||||||
e.status == TalkStatus.timeout ||
|
e.status == TalkStatus.timeout ||
|
||||||
e.status == TalkStatus.rejected) {
|
e.status == TalkStatus.rejected) {
|
||||||
|
if (e.status == TalkStatus.timeout ||
|
||||||
|
e.status == TalkStatus.rejected) {
|
||||||
|
final paired = pairedLock.value;
|
||||||
|
_videoLockId = paired?.lockId;
|
||||||
|
_videoLockCloudUid = paired?.cloudUid;
|
||||||
|
}
|
||||||
_call = null;
|
_call = null;
|
||||||
_activeLockPeerId = null;
|
_activeLockPeerId = null;
|
||||||
_cancelVideoFrameTimeout();
|
_cancelVideoFrameTimeout();
|
||||||
|
unawaited(_stopAudio());
|
||||||
_releaseDecoder();
|
_releaseDecoder();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -143,6 +160,18 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
_dataSub = StarCloudClient.instance.talk.onTalkData.listen((e) {
|
_dataSub = StarCloudClient.instance.talk.onTalkData.listen((e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (e.isAudio) {
|
if (e.isAudio) {
|
||||||
|
_audioPacketCount++;
|
||||||
|
if (_audioPacketCount == 1) {
|
||||||
|
final audioSize = e.content.length;
|
||||||
|
_log_('First G711 audio packet received (size=`$audioSize`)');
|
||||||
|
}
|
||||||
|
final generation = _audioGeneration;
|
||||||
|
_audioQueue = _audioQueue
|
||||||
|
.then((_) => _playAudioPacket(e.content, generation))
|
||||||
|
.catchError((Object error, StackTrace stack) {
|
||||||
|
final errorText = error.toString();
|
||||||
|
_log_('Audio playback error: `$errorText`');
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
_videoCount++;
|
_videoCount++;
|
||||||
@ -192,6 +221,82 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
_onPairedLockChanged();
|
_onPairedLockChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 音频播放 ====================
|
||||||
|
|
||||||
|
Future<void> _ensureAudioReady() {
|
||||||
|
if (_audioReady) return Future<void>.value();
|
||||||
|
final pending = _audioInitFuture;
|
||||||
|
if (pending != null) return pending;
|
||||||
|
|
||||||
|
final future = _doInitAudio();
|
||||||
|
_audioInitFuture = future;
|
||||||
|
future.whenComplete(() {
|
||||||
|
if (identical(_audioInitFuture, future)) {
|
||||||
|
_audioInitFuture = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doInitAudio() async {
|
||||||
|
if (_audioReady) return;
|
||||||
|
await FlutterPcmSound.setLogLevel(LogLevel.error);
|
||||||
|
await FlutterPcmSound.setup(sampleRate: 8000, channelCount: 1);
|
||||||
|
await FlutterPcmSound.setFeedThreshold(1024);
|
||||||
|
_audioReady = true;
|
||||||
|
_log_('G711 audio player ready (8000 Hz, mono).');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playAudioPacket(Uint8List g711Data, int generation) async {
|
||||||
|
if (g711Data.isEmpty) return;
|
||||||
|
if (generation != _audioGeneration) return;
|
||||||
|
await _ensureAudioReady();
|
||||||
|
if (!_audioReady || generation != _audioGeneration) return;
|
||||||
|
final pcm = <int>[
|
||||||
|
for (final sample in g711Data) _decodeG711ALaw(sample),
|
||||||
|
];
|
||||||
|
if (pcm.isEmpty) return;
|
||||||
|
if (generation != _audioGeneration) return;
|
||||||
|
await FlutterPcmSound.feed(PcmArrayInt16.fromList(pcm));
|
||||||
|
await FlutterPcmSound.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
int _decodeG711ALaw(int value) {
|
||||||
|
final aLaw = (value & 0xff) ^ 0x55;
|
||||||
|
var sample = (aLaw & 0x0f) << 4;
|
||||||
|
final segment = (aLaw & 0x70) >> 4;
|
||||||
|
if (segment == 0) {
|
||||||
|
sample += 8;
|
||||||
|
} else if (segment == 1) {
|
||||||
|
sample += 0x108;
|
||||||
|
} else {
|
||||||
|
sample += 0x108;
|
||||||
|
sample <<= segment - 1;
|
||||||
|
}
|
||||||
|
final signed = (aLaw & 0x80) != 0 ? sample : -sample;
|
||||||
|
return signed.clamp(-32768, 32767);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stopAudio() async {
|
||||||
|
_audioGeneration++;
|
||||||
|
_audioQueue = Future<void>.value();
|
||||||
|
final pendingInit = _audioInitFuture;
|
||||||
|
if (pendingInit != null) {
|
||||||
|
try {
|
||||||
|
await pendingInit;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!_audioReady) return;
|
||||||
|
try {
|
||||||
|
await FlutterPcmSound.stop();
|
||||||
|
await FlutterPcmSound.clear();
|
||||||
|
await FlutterPcmSound.release();
|
||||||
|
} catch (_) {}
|
||||||
|
_audioReady = false;
|
||||||
|
_audioInitFuture = null;
|
||||||
|
_audioPacketCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 视频解码器 ====================
|
// ==================== 视频解码器 ====================
|
||||||
|
|
||||||
Future<bool> _initDecoder() {
|
Future<bool> _initDecoder() {
|
||||||
@ -536,7 +641,13 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
// ==================== PeerID 管理 ====================
|
// ==================== PeerID 管理 ====================
|
||||||
|
|
||||||
void _onPairedLockChanged() {
|
void _onPairedLockChanged() {
|
||||||
|
final paired = pairedLock.value;
|
||||||
|
if (paired != null && _activeLockPeerId == null) {
|
||||||
|
_videoLockId = paired.lockId;
|
||||||
|
_videoLockCloudUid = paired.cloudUid;
|
||||||
|
}
|
||||||
_startPeerIdPolling();
|
_startPeerIdPolling();
|
||||||
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 锁版 peerId 为空时定时轮询云端(每 5s),出现即回填并停止 —— 锁上线监控
|
/// 锁版 peerId 为空时定时轮询云端(每 5s),出现即回填并停止 —— 锁上线监控
|
||||||
@ -654,6 +765,8 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
if (target.isEmpty) return;
|
if (target.isEmpty) return;
|
||||||
|
|
||||||
final paired = pairedLock.value;
|
final paired = pairedLock.value;
|
||||||
|
_videoLockId = paired?.lockId;
|
||||||
|
_videoLockCloudUid = paired?.cloudUid;
|
||||||
final online = paired?.isOnline == true;
|
final online = paired?.isOnline == true;
|
||||||
if (!online) {
|
if (!online) {
|
||||||
_log_(S.isEn
|
_log_(S.isEn
|
||||||
@ -702,9 +815,14 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
Future<void> _acceptCall() async {
|
Future<void> _acceptCall() async {
|
||||||
if (_call == null) return;
|
if (_call == null) return;
|
||||||
if (!await _initDecoder()) return;
|
if (!await _initDecoder()) return;
|
||||||
|
_videoLockId = pairedLock.value?.lockId;
|
||||||
|
_videoLockCloudUid = pairedLock.value?.cloudUid;
|
||||||
_isCallMode = true;
|
_isCallMode = true;
|
||||||
_activeLockPeerId = _call!.lockPeerId;
|
_activeLockPeerId = _call!.lockPeerId;
|
||||||
StarCloudClient.instance.talk.accept(lockPeerId: _call!.lockPeerId);
|
StarCloudClient.instance.talk.accept(
|
||||||
|
lockPeerId: _call!.lockPeerId,
|
||||||
|
options: _monitorOptions,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _hangup() {
|
void _hangup() {
|
||||||
@ -716,6 +834,128 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
_releaseDecoder();
|
_releaseDecoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _remoteUnlock() async {
|
||||||
|
if (_remoteUnlocking) return;
|
||||||
|
final paired = pairedLock.value;
|
||||||
|
final int? lockId = paired?.lockId ?? _videoLockId;
|
||||||
|
if (lockId == null || lockId <= 0) {
|
||||||
|
final message = S.isEn ? 'Please pair a lock first.' : '请先配对锁后再试。';
|
||||||
|
_log_(message);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wifiConfiguredLockId.value != lockId) {
|
||||||
|
final message = S.remoteUnlockNeedsWifi;
|
||||||
|
_log_(message);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(S.confirmRemoteUnlockTitle),
|
||||||
|
content: Text(S.confirmRemoteUnlockMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(S.isEn ? 'Cancel' : '取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: Text(S.btnRemoteUnlock),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _remoteUnlocking = true);
|
||||||
|
try {
|
||||||
|
final fresh = await StarCloudClient.instance.device.getDevice(
|
||||||
|
lockId: lockId,
|
||||||
|
cloudUid: paired?.lockId == lockId
|
||||||
|
? paired?.cloudUid
|
||||||
|
: _videoLockCloudUid,
|
||||||
|
);
|
||||||
|
final enabledLock = await _ensureRemoteUnlockEnabled(fresh);
|
||||||
|
pairedLock.value = enabledLock;
|
||||||
|
if (enabledLock.hasGateway == 1) {
|
||||||
|
final token = lockServiceAccessToken.value?.trim() ?? '';
|
||||||
|
if (token.isEmpty) {
|
||||||
|
throw StateError(S.remoteUnlockNeedsLogin);
|
||||||
|
}
|
||||||
|
if (fresh.isOnline != true) {
|
||||||
|
throw StateError(S.remoteUnlockNeedsOnline);
|
||||||
|
}
|
||||||
|
await StarCloudClient.instance.device.remoteOpenLock(
|
||||||
|
lockId: enabledLock.lockId,
|
||||||
|
lockServiceAccessToken: token,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final activePeerId = (_activeLockPeerId ?? '').trim();
|
||||||
|
final String peerId = activePeerId.isNotEmpty
|
||||||
|
? activePeerId
|
||||||
|
: (enabledLock.peerId ?? '').trim();
|
||||||
|
if (peerId.isEmpty || enabledLock.peerId != peerId) {
|
||||||
|
throw StateError(S.remoteUnlockPeerUnavailable);
|
||||||
|
}
|
||||||
|
await StarCloudClient.instance.device.remoteOpenLockViaStarChart(
|
||||||
|
lock: enabledLock,
|
||||||
|
peerId: peerId,
|
||||||
|
talk: StarCloudClient.instance.talk,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_log_(S.remoteUnlockSuccess);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(S.remoteUnlockSuccess)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
final message = e is StateError
|
||||||
|
? e.message
|
||||||
|
: (e is StarCloudException ? e.message : e.toString());
|
||||||
|
_log_('${S.logFailed}: $message');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message.toString())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _remoteUnlocking = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StarCloudLock> _ensureRemoteUnlockEnabled(
|
||||||
|
StarCloudLock lock,
|
||||||
|
) async {
|
||||||
|
final featureValue = lock.lockFeature['remoteUnlock']?.toString();
|
||||||
|
if (featureValue == '1' && lock.lockSetting.remoteUnlock == 1) {
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log_(S.isEn
|
||||||
|
? 'Remote unlock is disabled in lock details; enabling it over BLE...'
|
||||||
|
: '锁详情中的远程开锁未开启,正在通过 BLE 自动开启...');
|
||||||
|
final context = await StarCloudLockContext.fromLock(lock);
|
||||||
|
final reply = await StarCloudClient.instance.lockSetting
|
||||||
|
.setRemoteUnlockByContext(context, enabled: true);
|
||||||
|
if (reply.status != 0) {
|
||||||
|
final status = reply.status;
|
||||||
|
_log_('Failed to enable remote unlock over BLE: status=$status.');
|
||||||
|
throw StateError(S.remoteUnlockUnavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
lock.lockFeature['remoteUnlock'] = 1;
|
||||||
|
lock.lockSetting.remoteUnlock = 1;
|
||||||
|
_log_(S.isEn ? 'Remote unlock enabled.' : '远程开锁已自动开启。');
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
@ -798,21 +1038,30 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
ValueListenableBuilder<bool>(
|
ValueListenableBuilder<bool>(
|
||||||
valueListenable: videoTalkReady,
|
valueListenable: videoTalkReady,
|
||||||
builder: (context, ready, _) {
|
builder: (context, ready, _) {
|
||||||
if (!ready) {
|
return ValueListenableBuilder<int?>(
|
||||||
return Padding(
|
valueListenable: wifiConfiguredLockId,
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
builder: (context, configuredLockId, _) {
|
||||||
child: Text(S.completeWifiTalkInitialization),
|
final currentLockId =
|
||||||
);
|
pairedLock.value?.lockId ?? _videoLockId;
|
||||||
}
|
final wifiConfigured =
|
||||||
return Column(
|
configuredLockId != null &&
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
configuredLockId == currentLockId;
|
||||||
children: [
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (!ready)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(S.completeWifiTalkInitialization),
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed:
|
onPressed:
|
||||||
_preparingMonitor ? null : _requestCall,
|
!ready || _preparingMonitor
|
||||||
|
? null
|
||||||
|
: _requestCall,
|
||||||
child: _preparingMonitor
|
child: _preparingMonitor
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 18,
|
width: 18,
|
||||||
@ -821,10 +1070,14 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Text(S.btnMonitor),
|
: Text(
|
||||||
|
S.btnMonitor,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
@ -835,16 +1088,52 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
|||||||
_status == TalkStatus.incomingCall)
|
_status == TalkStatus.incomingCall)
|
||||||
? _hangup
|
? _hangup
|
||||||
: null,
|
: null,
|
||||||
icon: const Icon(Icons.call_end),
|
icon: const Icon(Icons.call_end, size: 18),
|
||||||
label: Text(S.btnHangup),
|
label: Text(
|
||||||
|
S.btnHangup,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4, vertical: 12),
|
||||||
|
),
|
||||||
|
onPressed:
|
||||||
|
wifiConfigured ? _remoteUnlock : null,
|
||||||
|
icon: _remoteUnlocking
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.lock_open, size: 18),
|
||||||
|
label: Text(
|
||||||
|
_remoteUnlocking
|
||||||
|
? S.btnProcessing
|
||||||
|
: S.btnRemoteUnlock,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// 视频画面渲染区域
|
if (ready) ...[
|
||||||
const SizedBox(height: 3),
|
// 视频画面渲染区域
|
||||||
_buildVideoPreview(),
|
const SizedBox(height: 3),
|
||||||
],
|
_buildVideoPreview(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -80,6 +80,7 @@ class _WifiConfigTabState extends State<WifiConfigTab> {
|
|||||||
deviceName: lock.bluetooth.bluetoothDeviceName,
|
deviceName: lock.bluetooth.bluetoothDeviceName,
|
||||||
mac: lock.mac,
|
mac: lock.mac,
|
||||||
);
|
);
|
||||||
|
wifiConfiguredLockId.value = lock.lockId;
|
||||||
_log_(S.logWifiConfigCompleted);
|
_log_(S.logWifiConfigCompleted);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,14 +1,19 @@
|
|||||||
import 'package:starcloud/blue/blue_manage.dart';
|
import 'package:starcloud/blue/blue_manage.dart';
|
||||||
import 'package:starcloud/blue/blue_read_write.dart';
|
|
||||||
import 'package:starcloud/entity/star_cloud_lock_list.dart';
|
import 'package:starcloud/entity/star_cloud_lock_list.dart';
|
||||||
|
import 'package:starcloud/device/ble/protocol/syd/open_lock.dart';
|
||||||
import 'package:starcloud/sdk/business/sdk_common.dart';
|
import 'package:starcloud/sdk/business/sdk_common.dart';
|
||||||
|
import 'package:starcloud/tool/sm4_encipher/sm4.dart';
|
||||||
import 'package:starcloud/starcloud.dart';
|
import 'package:starcloud/starcloud.dart';
|
||||||
|
|
||||||
import '../core/callback_bridge.dart';
|
import '../core/callback_bridge.dart';
|
||||||
|
import '../core/starcloud_exception.dart';
|
||||||
|
import '../talk/starcloud_talk_service.dart';
|
||||||
|
|
||||||
class StarCloudDeviceService {
|
class StarCloudDeviceService {
|
||||||
StarCloudDeviceService();
|
StarCloudDeviceService();
|
||||||
|
|
||||||
|
static bool _starChartRemoteUnlockInFlight = false;
|
||||||
|
|
||||||
Future<dynamic> _fetchWrapper(int? cloudUid) {
|
Future<dynamic> _fetchWrapper(int? cloudUid) {
|
||||||
return SDKCommon.instance.fetchStarCloudUser(cloudUid);
|
return SDKCommon.instance.fetchStarCloudUser(cloudUid);
|
||||||
}
|
}
|
||||||
@ -136,6 +141,195 @@ class StarCloudDeviceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> remoteOpenLock({
|
||||||
|
required int lockId,
|
||||||
|
required String lockServiceAccessToken,
|
||||||
|
Duration timeout = const Duration(seconds: 60),
|
||||||
|
}) async {
|
||||||
|
final response = await SDKCommon.instance.netApi.deviceApi.remoteOpenLock(
|
||||||
|
lockId: lockId,
|
||||||
|
lockServiceAccessToken: lockServiceAccessToken,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
if (response.errcode != 0) {
|
||||||
|
throw StarCloudException(
|
||||||
|
'Remote unlock failed: ${response.errmsg}',
|
||||||
|
raw: response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> remoteOpenLockViaStarChart({
|
||||||
|
required StarCloudLock lock,
|
||||||
|
required String peerId,
|
||||||
|
required StarCloudTalkService talk,
|
||||||
|
int? cloudUid,
|
||||||
|
Duration timeout = const Duration(seconds: 35),
|
||||||
|
}) async {
|
||||||
|
if (_starChartRemoteUnlockInFlight) {
|
||||||
|
throw const StarCloudException('Another StarChart unlock is in progress.');
|
||||||
|
}
|
||||||
|
if (lock.hasGateway == 1 || lock.lockId <= 0 || peerId.isEmpty ||
|
||||||
|
lock.peerId != peerId || lock.bluetooth.bluetoothDeviceName.isEmpty ||
|
||||||
|
lock.bluetooth.privateKey.length != 16 ||
|
||||||
|
lock.bluetooth.signKey.length != 16 || lock.uid <= 0) {
|
||||||
|
throw const StarCloudException(
|
||||||
|
'StarChart unlock parameters are incomplete or do not match the lock.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lock.lockFeature['remoteUnlock']?.toString() != '1' ||
|
||||||
|
lock.lockSetting.remoteUnlock != 1) {
|
||||||
|
throw const StarCloudException('Remote unlock is not enabled for this lock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
_starChartRemoteUnlockInFlight = true;
|
||||||
|
try {
|
||||||
|
final addresses = await talk.resolvePeerAddresses(peerId);
|
||||||
|
if (addresses.isEmpty) {
|
||||||
|
throw const StarCloudException('StarChart cannot resolve the lock peer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final wrapper = await _fetchWrapper(cloudUid);
|
||||||
|
final api = SDKCommon.instance.netApi.deviceApi;
|
||||||
|
final clientId = wrapper.user.clientId;
|
||||||
|
final serverTimeResponse = await api.getServerTime(clientId: clientId);
|
||||||
|
if (serverTimeResponse.errcode != 0) {
|
||||||
|
throw StarCloudException(
|
||||||
|
'Unable to obtain lock server time: ${serverTimeResponse.errmsg}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final dynamic timeData = serverTimeResponse.data;
|
||||||
|
final dynamic rawDate = timeData is Map ? timeData['date'] : null;
|
||||||
|
final int? serverTimeMillis = rawDate is num
|
||||||
|
? rawDate.toInt()
|
||||||
|
: int.tryParse(rawDate?.toString() ?? '');
|
||||||
|
if (serverTimeMillis == null || serverTimeMillis <= 0) {
|
||||||
|
throw const StarCloudException('Lock server time response is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
String onlineToken = '';
|
||||||
|
if (lock.lockSetting.appUnlockOnline == 1) {
|
||||||
|
onlineToken = await api.getLockNetToken(
|
||||||
|
lockId: lock.lockId.toString(),
|
||||||
|
clientId: clientId,
|
||||||
|
);
|
||||||
|
if (onlineToken.isEmpty || onlineToken == '0') {
|
||||||
|
throw const StarCloudException('Unable to obtain online unlock token.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final command = OpenLockCommand(
|
||||||
|
lockID: lock.bluetooth.bluetoothDeviceName,
|
||||||
|
userID: lock.uid.toString(),
|
||||||
|
openMode: 0,
|
||||||
|
openTime: serverTimeMillis ~/ 1000,
|
||||||
|
onlineToken: onlineToken,
|
||||||
|
token: const <int>[0, 0, 0, 0],
|
||||||
|
needAuthor: 1,
|
||||||
|
signKey: List<int>.from(lock.bluetooth.signKey),
|
||||||
|
privateKey: List<int>.from(lock.bluetooth.privateKey),
|
||||||
|
);
|
||||||
|
var response = await _sendStarChartUnlockCommand(
|
||||||
|
talk: talk,
|
||||||
|
peerId: peerId,
|
||||||
|
bluetoothDeviceName: lock.bluetooth.bluetoothDeviceName,
|
||||||
|
command: command,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
if (response.status == 0x06) {
|
||||||
|
if (response.token.length != 4) {
|
||||||
|
throw const StarCloudException(
|
||||||
|
'Lock authentication response is malformed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final retryCommand = OpenLockCommand(
|
||||||
|
lockID: lock.bluetooth.bluetoothDeviceName,
|
||||||
|
userID: lock.uid.toString(),
|
||||||
|
openMode: 0,
|
||||||
|
openTime: serverTimeMillis ~/ 1000,
|
||||||
|
onlineToken: onlineToken,
|
||||||
|
token: response.token,
|
||||||
|
needAuthor: 1,
|
||||||
|
signKey: List<int>.from(lock.bluetooth.signKey),
|
||||||
|
privateKey: List<int>.from(lock.bluetooth.privateKey),
|
||||||
|
);
|
||||||
|
response = await _sendStarChartUnlockCommand(
|
||||||
|
talk: talk,
|
||||||
|
peerId: peerId,
|
||||||
|
bluetoothDeviceName: lock.bluetooth.bluetoothDeviceName,
|
||||||
|
command: retryCommand,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.status != 0x00) {
|
||||||
|
throw StarCloudException(
|
||||||
|
'Lock rejected remote unlock (status 0x${response.status.toRadixString(16)}).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_starChartRemoteUnlockInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_RemoteUnlockReply> _sendStarChartUnlockCommand({
|
||||||
|
required StarCloudTalkService talk,
|
||||||
|
required String peerId,
|
||||||
|
required String bluetoothDeviceName,
|
||||||
|
required OpenLockCommand command,
|
||||||
|
required Duration timeout,
|
||||||
|
}) async {
|
||||||
|
final passthrough = await talk.sendBlePassthrough(
|
||||||
|
peerId: peerId,
|
||||||
|
bluetoothDeviceName: bluetoothDeviceName,
|
||||||
|
structData: command.packageData(),
|
||||||
|
timeoutSeconds: timeout.inSeconds,
|
||||||
|
);
|
||||||
|
if (passthrough.status != 0) {
|
||||||
|
throw StarCloudException(
|
||||||
|
'StarChart BLE passthrough failed (status ${passthrough.status}).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final frame = passthrough.structData;
|
||||||
|
if (frame.length < 14 ||
|
||||||
|
frame[0] != 0xef ||
|
||||||
|
frame[1] != 0x01 ||
|
||||||
|
frame[2] != 0xee ||
|
||||||
|
frame[3] != 0x02) {
|
||||||
|
throw const StarCloudException('Lock BLE response frame is invalid.');
|
||||||
|
}
|
||||||
|
final int encryption = frame[7] & 0x0f;
|
||||||
|
final int encryptedLength = (frame[8] << 8) | frame[9];
|
||||||
|
final int originalLength = (frame[10] << 8) | frame[11];
|
||||||
|
if (encryptedLength < 0 ||
|
||||||
|
12 + encryptedLength > frame.length ||
|
||||||
|
originalLength < 7) {
|
||||||
|
throw const StarCloudException('Lock BLE response length is invalid.');
|
||||||
|
}
|
||||||
|
List<int> payload = frame.sublist(12, 12 + encryptedLength);
|
||||||
|
if (encryption == 3) {
|
||||||
|
payload = SM4.decrypt(
|
||||||
|
payload,
|
||||||
|
key: command.privateKey,
|
||||||
|
mode: SM4CryptoMode.ECB,
|
||||||
|
);
|
||||||
|
} else if (encryption != 0) {
|
||||||
|
throw StarCloudException(
|
||||||
|
'Unsupported lock BLE response encryption: $encryption',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.length < originalLength || originalLength < 7) {
|
||||||
|
throw const StarCloudException('Decrypted lock response length is invalid.');
|
||||||
|
}
|
||||||
|
payload = payload.sublist(0, originalLength);
|
||||||
|
if (payload[0] != 0x30 || payload[1] != 0x05) {
|
||||||
|
throw const StarCloudException('Lock returned a non-unlock BLE response.');
|
||||||
|
}
|
||||||
|
return _RemoteUnlockReply(
|
||||||
|
status: payload[6],
|
||||||
|
token: payload.length >= 6 ? payload.sublist(2, 6) : const <int>[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> resetLock({
|
Future<void> resetLock({
|
||||||
required int lockId,
|
required int lockId,
|
||||||
int? cloudUid,
|
int? cloudUid,
|
||||||
@ -172,3 +366,10 @@ class StarCloudDeviceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RemoteUnlockReply {
|
||||||
|
const _RemoteUnlockReply({required this.status, required this.token});
|
||||||
|
|
||||||
|
final int status;
|
||||||
|
final List<int> token;
|
||||||
|
}
|
||||||
|
|||||||
@ -35,6 +35,28 @@ class StarCloudTalkService {
|
|||||||
Stream<TalkExpectRespEvent> get onTalkExpectResp =>
|
Stream<TalkExpectRespEvent> get onTalkExpectResp =>
|
||||||
_talkExpectRespCtrl.stream;
|
_talkExpectRespCtrl.stream;
|
||||||
|
|
||||||
|
/// 查询星图服务能否解析目标锁 peer。
|
||||||
|
Future<List<String>> resolvePeerAddresses(String peerId) {
|
||||||
|
_ensureInitialized();
|
||||||
|
return _client!.resolvePeerAddresses(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过星图中继发送加密后的 BLE 协议帧,并等待对应回包。
|
||||||
|
Future<BlePassthroughResponse> sendBlePassthrough({
|
||||||
|
required String peerId,
|
||||||
|
required String bluetoothDeviceName,
|
||||||
|
required List<int> structData,
|
||||||
|
int timeoutSeconds = 30,
|
||||||
|
}) {
|
||||||
|
_ensureInitialized();
|
||||||
|
return _client!.sendBlePassthrough(
|
||||||
|
peerId: peerId,
|
||||||
|
bluetoothDeviceName: bluetoothDeviceName,
|
||||||
|
structData: structData,
|
||||||
|
timeoutSeconds: timeoutSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 当前通话状态
|
/// 当前通话状态
|
||||||
TalkStatus get status => _client?.talk.status ?? TalkStatus.idle;
|
TalkStatus get status => _client?.talk.status ?? TalkStatus.idle;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user