diff --git a/example/lib/global.dart b/example/lib/global.dart index 01727b9..122a577 100755 --- a/example/lib/global.dart +++ b/example/lib/global.dart @@ -13,6 +13,9 @@ final ValueNotifier appPeerId = ValueNotifier(null); final ValueNotifier videoTalkReady = ValueNotifier(false); final ValueNotifier videoTalkInitializing = ValueNotifier(false); +// 最近一次成功完成 Wi-Fi 配网的锁 ID;远程开锁只对该锁开放。 +final ValueNotifier wifiConfiguredLockId = ValueNotifier(null); + // lock.skychip.top 授权登录返回的动态 token,仅用于 WiFi 网关配网。 // 它与 cloud.skychip.top 的 OAuth token 属于不同认证域。 final ValueNotifier lockServiceAccessToken = diff --git a/example/lib/l10n.dart b/example/lib/l10n.dart index db51259..84aa4d2 100755 --- a/example/lib/l10n.dart +++ b/example/lib/l10n.dart @@ -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'; diff --git a/example/lib/tabs/device_scan_tab.dart b/example/lib/tabs/device_scan_tab.dart index c05b8ca..79105b2 100755 --- a/example/lib/tabs/device_scan_tab.dart +++ b/example/lib/tabs/device_scan_tab.dart @@ -63,6 +63,7 @@ class _DeviceScanTabState extends State { 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 { label: Text('${S.pairedStatus}: #$id'), backgroundColor: Colors.green.shade50, onDeleted: () { + wifiConfiguredLockId.value = null; pairedLockId.value = null; pairedLock.value = null; }, diff --git a/example/lib/tabs/lock_control_tab.dart b/example/lib/tabs/lock_control_tab.dart index 3ea6139..5d81dc5 100755 --- a/example/lib/tabs/lock_control_tab.dart +++ b/example/lib/tabs/lock_control_tab.dart @@ -34,6 +34,50 @@ class _LockControlTabState extends State { } } + Future _deleteLock() async { + final id = pairedLockId.value; + if (id == null || _loading) return; + final confirmed = await showDialog( + 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 { ), ], ), + 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), ], diff --git a/example/lib/tabs/video_talk_tab.dart b/example/lib/tabs/video_talk_tab.dart index f07f675..21ef3c1 100755 --- a/example/lib/tabs/video_talk_tab.dart +++ b/example/lib/tabs/video_talk_tab.dart @@ -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 { - // 与参考项目保持一致:为 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 _log = []; @@ -39,6 +38,9 @@ class _VideoTalkTabState extends State { bool _decoderReady = false; Future? _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 { bool _isCallMode = false; bool _preparingMonitor = false; Timer? _videoFrameTimeoutTimer; + bool _audioReady = false; + Future? _audioInitFuture; + Future _audioQueue = Future.value(); + int _audioPacketCount = 0; + int _audioGeneration = 0; StreamSubscription? _callSub; StreamSubscription? _statusSub; @@ -85,6 +92,7 @@ class _VideoTalkTabState extends State { _videoProcessTimer?.cancel(); _videoFrameTimeoutTimer?.cancel(); _videoBuffer.clear(); + unawaited(_stopAudio()); VideoDecodePlugin.setOnMissingSpsPpsListener(() {}); _releaseDecoder(); pairedLock.removeListener(_onPairedLockChanged); @@ -101,6 +109,8 @@ class _VideoTalkTabState extends State { _call = e; _status = TalkStatus.incomingCall; _activeLockPeerId = e.lockPeerId; + _videoLockId = pairedLock.value?.lockId; + _videoLockCloudUid = pairedLock.value?.cloudUid; }); }); @@ -131,9 +141,16 @@ class _VideoTalkTabState extends State { 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 { _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 { _onPairedLockChanged(); } + // ==================== 音频播放 ==================== + + Future _ensureAudioReady() { + if (_audioReady) return Future.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 _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 _playAudioPacket(Uint8List g711Data, int generation) async { + if (g711Data.isEmpty) return; + if (generation != _audioGeneration) return; + await _ensureAudioReady(); + if (!_audioReady || generation != _audioGeneration) return; + final pcm = [ + 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 _stopAudio() async { + _audioGeneration++; + _audioQueue = Future.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 _initDecoder() { @@ -536,7 +641,13 @@ class _VideoTalkTabState extends State { // ==================== 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 { 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 { Future _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 { _releaseDecoder(); } + Future _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( + 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 _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( @@ -798,21 +1038,30 @@ class _VideoTalkTabState extends State { ValueListenableBuilder( 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( + 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 { 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 { _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(), + ], + ], + ); + }, ); }, ), diff --git a/example/lib/tabs/wifi_config_tab.dart b/example/lib/tabs/wifi_config_tab.dart index 0debf24..6bf8b8c 100755 --- a/example/lib/tabs/wifi_config_tab.dart +++ b/example/lib/tabs/wifi_config_tab.dart @@ -80,6 +80,7 @@ class _WifiConfigTabState extends State { deviceName: lock.bluetooth.bluetoothDeviceName, mac: lock.mac, ); + wifiConfiguredLockId.value = lock.lockId; _log_(S.logWifiConfigCompleted); try { diff --git a/lib/src/device/starcloud_device_service.dart b/lib/src/device/starcloud_device_service.dart index 5b6809c..635e278 100755 --- a/lib/src/device/starcloud_device_service.dart +++ b/lib/src/device/starcloud_device_service.dart @@ -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 _fetchWrapper(int? cloudUid) { return SDKCommon.instance.fetchStarCloudUser(cloudUid); } @@ -136,6 +141,195 @@ class StarCloudDeviceService { ); } + Future 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 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 [0, 0, 0, 0], + needAuthor: 1, + signKey: List.from(lock.bluetooth.signKey), + privateKey: List.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.from(lock.bluetooth.signKey), + privateKey: List.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 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 [], + ); + } + Future resetLock({ required int lockId, int? cloudUid, @@ -171,4 +365,11 @@ class StarCloudDeviceService { }, ); } -} \ No newline at end of file +} + +class _RemoteUnlockReply { + const _RemoteUnlockReply({required this.status, required this.token}); + + final int status; + final List token; +} diff --git a/lib/src/talk/starcloud_talk_service.dart b/lib/src/talk/starcloud_talk_service.dart index fce2a99..22de3ff 100755 --- a/lib/src/talk/starcloud_talk_service.dart +++ b/lib/src/talk/starcloud_talk_service.dart @@ -35,6 +35,28 @@ class StarCloudTalkService { Stream get onTalkExpectResp => _talkExpectRespCtrl.stream; + /// 查询星图服务能否解析目标锁 peer。 + Future> resolvePeerAddresses(String peerId) { + _ensureInitialized(); + return _client!.resolvePeerAddresses(peerId); + } + + /// 通过星图中继发送加密后的 BLE 协议帧,并等待对应回包。 + Future sendBlePassthrough({ + required String peerId, + required String bluetoothDeviceName, + required List structData, + int timeoutSeconds = 30, + }) { + _ensureInitialized(); + return _client!.sendBlePassthrough( + peerId: peerId, + bluetoothDeviceName: bluetoothDeviceName, + structData: structData, + timeoutSeconds: timeoutSeconds, + ); + } + /// 当前通话状态 TalkStatus get status => _client?.talk.status ?? TalkStatus.idle;