优化配对和远程开锁

This commit is contained in:
sky.min 2026-10-19 16:17:25 +08:00
parent 917a4341d0
commit ab18b60a3c
8 changed files with 631 additions and 26 deletions

View File

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

View File

@ -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';

View File

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

View File

@ -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),
], ],

View File

@ -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 =
configuredLockId != null &&
configuredLockId == currentLockId;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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), const SizedBox(height: 3),
_buildVideoPreview(), _buildVideoPreview(),
], ],
],
);
},
); );
}, },
), ),

View File

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

View File

@ -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;
}

View File

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