优化配对和远程开锁
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> videoTalkInitializing = ValueNotifier<bool>(false);
|
||||
|
||||
// 最近一次成功完成 Wi-Fi 配网的锁 ID;远程开锁只对该锁开放。
|
||||
final ValueNotifier<int?> wifiConfiguredLockId = ValueNotifier<int?>(null);
|
||||
|
||||
// lock.skychip.top 授权登录返回的动态 token,仅用于 WiFi 网关配网。
|
||||
// 它与 cloud.skychip.top 的 OAuth token 属于不同认证域。
|
||||
final ValueNotifier<String?> lockServiceAccessToken =
|
||||
|
||||
@ -82,6 +82,12 @@ class S {
|
||||
static String get notPaired => isEn ? 'Not Paired' : '未配对';
|
||||
static String get btnOpen => isEn ? 'Open' : '开锁';
|
||||
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
|
||||
static String get wifiTitle => isEn ? 'WiFi Config' : 'WiFi配网';
|
||||
@ -119,6 +125,33 @@ class S {
|
||||
static String get btnAccept => isEn ? 'Accept' : '接听';
|
||||
static String get btnReject => isEn ? 'Reject' : '拒绝';
|
||||
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 logTalkReady => isEn ? 'Talk Ready' : '视频对讲就绪';
|
||||
static String get deviceIdLabel => isEn ? 'Device ID' : '设备ID';
|
||||
|
||||
@ -63,6 +63,7 @@ class _DeviceScanTabState extends State<DeviceScanTab> {
|
||||
final lock = await StarCloudClient.instance.device
|
||||
.pairDevice(scanResult: _devices[idx]);
|
||||
// 更新 ValueNotifier 的值
|
||||
wifiConfiguredLockId.value = null;
|
||||
pairedLockId.value = lock.lockId;
|
||||
pairedLock.value = lock;
|
||||
|
||||
@ -121,6 +122,7 @@ class _DeviceScanTabState extends State<DeviceScanTab> {
|
||||
label: Text('${S.pairedStatus}: #$id'),
|
||||
backgroundColor: Colors.green.shade50,
|
||||
onDeleted: () {
|
||||
wifiConfiguredLockId.value = null;
|
||||
pairedLockId.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
|
||||
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),
|
||||
LogArea(log: _log),
|
||||
],
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
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:video_decode_plugin/video_decode_plugin.dart';
|
||||
import '../l10n.dart';
|
||||
@ -16,16 +18,13 @@ class VideoTalkTab extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
// 与参考项目保持一致:为 UDP 帧保留一个很短的乱序窗口,避免 P 帧
|
||||
// 在前序帧尚未到达时被过早提交给解码器。
|
||||
|
||||
static const _maxVideoReorderDistance = 3;
|
||||
static const _videoReorderWait = Duration(milliseconds: 15);
|
||||
|
||||
// 参考锁端原生 App:主动“监控”只请求 H264 视频。部分固件在首个
|
||||
// TalkExpect 同时携带 G711 音频时不会回复协商包,最终导致请求超时。
|
||||
static const TalkOptions _monitorOptions = TalkOptions(
|
||||
preferredVideoTypes: [VideoTypeE.H264],
|
||||
preferredAudioTypes: [],
|
||||
preferredAudioTypes: [AudioTypeE.G711],
|
||||
);
|
||||
|
||||
final List<String> _log = [];
|
||||
@ -39,6 +38,9 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
bool _decoderReady = false;
|
||||
Future<bool>? _decoderInitFuture;
|
||||
bool _frameRendered = false;
|
||||
bool _remoteUnlocking = false;
|
||||
int? _videoLockId;
|
||||
int? _videoLockCloudUid;
|
||||
int _videoCount = 0;
|
||||
int _videoWidth = 640;
|
||||
int _videoHeight = 480;
|
||||
@ -56,6 +58,11 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
bool _isCallMode = false;
|
||||
bool _preparingMonitor = false;
|
||||
Timer? _videoFrameTimeoutTimer;
|
||||
bool _audioReady = false;
|
||||
Future<void>? _audioInitFuture;
|
||||
Future<void> _audioQueue = Future<void>.value();
|
||||
int _audioPacketCount = 0;
|
||||
int _audioGeneration = 0;
|
||||
|
||||
StreamSubscription? _callSub;
|
||||
StreamSubscription? _statusSub;
|
||||
@ -85,6 +92,7 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
_videoProcessTimer?.cancel();
|
||||
_videoFrameTimeoutTimer?.cancel();
|
||||
_videoBuffer.clear();
|
||||
unawaited(_stopAudio());
|
||||
VideoDecodePlugin.setOnMissingSpsPpsListener(() {});
|
||||
_releaseDecoder();
|
||||
pairedLock.removeListener(_onPairedLockChanged);
|
||||
@ -101,6 +109,8 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
_call = e;
|
||||
_status = TalkStatus.incomingCall;
|
||||
_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.timeout ||
|
||||
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;
|
||||
_activeLockPeerId = null;
|
||||
_cancelVideoFrameTimeout();
|
||||
unawaited(_stopAudio());
|
||||
_releaseDecoder();
|
||||
}
|
||||
});
|
||||
@ -143,6 +160,18 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
_dataSub = StarCloudClient.instance.talk.onTalkData.listen((e) {
|
||||
if (!mounted) return;
|
||||
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;
|
||||
} else {
|
||||
_videoCount++;
|
||||
@ -192,6 +221,82 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
_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() {
|
||||
@ -536,7 +641,13 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
// ==================== PeerID 管理 ====================
|
||||
|
||||
void _onPairedLockChanged() {
|
||||
final paired = pairedLock.value;
|
||||
if (paired != null && _activeLockPeerId == null) {
|
||||
_videoLockId = paired.lockId;
|
||||
_videoLockCloudUid = paired.cloudUid;
|
||||
}
|
||||
_startPeerIdPolling();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// 锁版 peerId 为空时定时轮询云端(每 5s),出现即回填并停止 —— 锁上线监控
|
||||
@ -654,6 +765,8 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
if (target.isEmpty) return;
|
||||
|
||||
final paired = pairedLock.value;
|
||||
_videoLockId = paired?.lockId;
|
||||
_videoLockCloudUid = paired?.cloudUid;
|
||||
final online = paired?.isOnline == true;
|
||||
if (!online) {
|
||||
_log_(S.isEn
|
||||
@ -702,9 +815,14 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
Future<void> _acceptCall() async {
|
||||
if (_call == null) return;
|
||||
if (!await _initDecoder()) return;
|
||||
_videoLockId = pairedLock.value?.lockId;
|
||||
_videoLockCloudUid = pairedLock.value?.cloudUid;
|
||||
_isCallMode = true;
|
||||
_activeLockPeerId = _call!.lockPeerId;
|
||||
StarCloudClient.instance.talk.accept(lockPeerId: _call!.lockPeerId);
|
||||
StarCloudClient.instance.talk.accept(
|
||||
lockPeerId: _call!.lockPeerId,
|
||||
options: _monitorOptions,
|
||||
);
|
||||
}
|
||||
|
||||
void _hangup() {
|
||||
@ -716,6 +834,128 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
@ -798,21 +1038,30 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: videoTalkReady,
|
||||
builder: (context, ready, _) {
|
||||
if (!ready) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(S.completeWifiTalkInitialization),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
return ValueListenableBuilder<int?>(
|
||||
valueListenable: wifiConfiguredLockId,
|
||||
builder: (context, configuredLockId, _) {
|
||||
final currentLockId =
|
||||
pairedLock.value?.lockId ?? _videoLockId;
|
||||
final wifiConfigured =
|
||||
configuredLockId != null &&
|
||||
configuredLockId == currentLockId;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!ready)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(S.completeWifiTalkInitialization),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed:
|
||||
_preparingMonitor ? null : _requestCall,
|
||||
!ready || _preparingMonitor
|
||||
? null
|
||||
: _requestCall,
|
||||
child: _preparingMonitor
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
@ -821,10 +1070,14 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Text(S.btnMonitor),
|
||||
: Text(
|
||||
S.btnMonitor,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
@ -835,16 +1088,52 @@ class _VideoTalkTabState extends State<VideoTalkTab> {
|
||||
_status == TalkStatus.incomingCall)
|
||||
? _hangup
|
||||
: null,
|
||||
icon: const Icon(Icons.call_end),
|
||||
label: Text(S.btnHangup),
|
||||
icon: const Icon(Icons.call_end, size: 18),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 视频画面渲染区域
|
||||
const SizedBox(height: 3),
|
||||
_buildVideoPreview(),
|
||||
],
|
||||
if (ready) ...[
|
||||
// 视频画面渲染区域
|
||||
const SizedBox(height: 3),
|
||||
_buildVideoPreview(),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@ -80,6 +80,7 @@ class _WifiConfigTabState extends State<WifiConfigTab> {
|
||||
deviceName: lock.bluetooth.bluetoothDeviceName,
|
||||
mac: lock.mac,
|
||||
);
|
||||
wifiConfiguredLockId.value = lock.lockId;
|
||||
_log_(S.logWifiConfigCompleted);
|
||||
|
||||
try {
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
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/device/ble/protocol/syd/open_lock.dart';
|
||||
import 'package:starcloud/sdk/business/sdk_common.dart';
|
||||
import 'package:starcloud/tool/sm4_encipher/sm4.dart';
|
||||
import 'package:starcloud/starcloud.dart';
|
||||
|
||||
import '../core/callback_bridge.dart';
|
||||
import '../core/starcloud_exception.dart';
|
||||
import '../talk/starcloud_talk_service.dart';
|
||||
|
||||
class StarCloudDeviceService {
|
||||
StarCloudDeviceService();
|
||||
|
||||
static bool _starChartRemoteUnlockInFlight = false;
|
||||
|
||||
Future<dynamic> _fetchWrapper(int? 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({
|
||||
required int lockId,
|
||||
int? cloudUid,
|
||||
@ -171,4 +365,11 @@ 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 =>
|
||||
_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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user