分区相关

This commit is contained in:
2026-11-06 08:44:05 +08:00
parent 85c83ec143
commit 8eea42b75d
21 changed files with 3245 additions and 439 deletions

View File

@@ -28,7 +28,15 @@ import '../../domain/entities/reverse_camera_data.dart';
import '../../domain/repositories/remote_control_repository.dart';
import '../../domain/usecase/remote_control_usecase.dart';
class RemoteControlCubit extends Cubit<RemoteControlState> {
/// 远程控制核心 Cubit(全局单例,极简模式远程遥控页与新版 HUD 共用)。
///
/// 🔥 自带前后台生命周期([WidgetsBindingObserver]):200ms 控制循环是 Dart VM 层的
/// Timer,退后台 / 锁屏后照旧跟发,而 main.dart 的全局回调只管会话标记与 TCP
/// 重连、不碰循环,所以“切后台那一刻手指还压在摇杆上”就会留下非零轴值;
/// 回前台 TCP 重连后车能自己走。本类自己接管:退后台挂起循环 + 归零,
/// 回前台且仍在控制态则重启(与 LocalCameraCubit 的自管生命周期同一取向)。
class RemoteControlCubit extends Cubit<RemoteControlState>
with WidgetsBindingObserver {
final RemoteControlRepository _repository;
final RequestControlPermissionUseCase _requestControlPermissionUseCase;
final DeviceRepository _deviceRepository; // 🔥 注入设备仓库
@@ -47,6 +55,15 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
int _currentOriginX = 0;
int _currentOriginY = 0;
/// 业务侧是否期望处于“控制态”:[startControlLoop] 置真、[stopControlLoop] 置假。
/// 它代表“意图”,与定时器的实时存在与否解耦:被后台挂起时仍为真,
/// 回前台靠它判断要不要自动重启循环;stopAllMovement 末尾也靠它判断该不该重调。
bool _controlWanted = false;
/// 因退到后台(inactive/paused/hidden)而临时挂起控制循环。
/// 挂起期间所有 startControlLoop 调用只记意图、不起定时器,回前台再恢复。
bool _suspendedByLifecycle = false;
// 🔥 权限请求处理标志位 - 防止竞态条件,避免重复显示弹窗
bool _isProcessingPermissionRequest = false;
@@ -83,6 +100,9 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 回滚:注释下行、改回 _initDeviceStatusListener() 即可。
_initTcpStatusListener();
// _initDeviceStatusListener(); // 🔥 旧:订阅 DeviceStatusBloc(MQTT),已停用
// 生命周期观察与本类同生同灭(close() 里成对移除):单例常驻,
// 不挂在某个页面上,所以从别的路径进控态也能被护住。
WidgetsBinding.instance.addObserver(this);
}
// 🔥 新增:订阅 DeviceStatusBloc 的状态流,而不是直接监听TCP
@@ -105,9 +125,8 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 第一步:所有数据先存入缓存,不管来多频繁都存最新值
final voltage = deviceState.status.voltage;
final battery = deviceState.status.battery;
final controlMode = deviceState.status.controlMode == '3'
? '远程模式'
: '本地模式';
// 只存协议原始值('3'=远程/'0'=本地等),展示文案由 UI 按当前语言翻译
final controlMode = deviceState.status.controlMode;
final c = await getNetworkDelay();
_cacheVoltage = voltage.toString();
@@ -167,7 +186,8 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
}
final status = RunningStatusEntity.fromFields(fields);
final controlMode = status.controlMode == '3' ? '远程模式' : '本地模式';
// 只存协议原始值('3'=远程/'0'=本地等),展示文案由 UI 按当前语言翻译
final controlMode = status.controlMode;
// 🔥 门槛:收到 TCP 0x02 即视为推送活跃(节流前刷新,轻量无阻塞)
_lastStatusPushTime = DateTime.now();
@@ -426,6 +446,12 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 🔥 修复:不强制关闭弹窗,让弹窗由用户操作控制
),
);
// 🔥 失权即归零 + 补发全停:控制循环只是“跳过发送”,
// _currentOriginX/Y 会原样留着;只改 hasPermission 的话,重新
// 拿回控制权的那一 tick 就会按失权前的推量原地发车。而且设备只认
// “最后收到的运动帧”,光清本地值不会让已在跑的车停下 ——
// 故用 stopAllMovement(它的停止帧无视权限强制发送)。
unawaited(stopAllMovement());
}
}
}
@@ -565,13 +591,115 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
});
emit(state.copyWith(status: RemoteControlStatus.controlling));
}*/
void startControlLoop() {
/// 🔥 双轴归零(静默):只清本地摇杆值并同步 UI,**不发停止帧**。
/// 需要连发停止帧压住 TCP 积压时用 [stopAllMovement];这里用于“只是不能让
/// 残留值下次复活”的场景(失权 / 急停 / 进控态)。
void _zeroAxesQuietly() {
if (isClosed) return;
if (_currentOriginX == 0 && _currentOriginY == 0) return;
_currentOriginX = 0;
_currentOriginY = 0;
emit(
state.copyWith(controlEntity: state.controlEntity.copyWith(x: 0, y: 0)),
);
_syncReverseCameraFromJoystick();
}
/// 回前台重启循环的延迟定时器(避开 TCP 重连窗口),close() 需成对取消。
Timer? _resumeTimer;
/// 🔥 退后台挂起控制循环:停定时器 + 归零 + 尽力补发一次全停。
///
/// 为什么不能只做“跳过发送”:系统不保证会给 [GestureDetector] 发 ACTION_CANCEL,
/// 下拉通知栏 / 进多任务预览(只到 inactive)时摇杆的三重松手兜底可能一个都没
/// 触发,非零轴值就这样留在单例上;回前台 reconnectAfterResume 把 socket 重建后,
/// 循环下一 tick 就会用这个没人碰的残留值发车。
void _suspendControlLoop() {
if (!_controlWanted || _suspendedByLifecycle) return;
// 先置挂起位:下方 stopAllMovement 末尾会调 startControlLoop,必须被拦住。
_suspendedByLifecycle = true;
_resumeTimer?.cancel();
_resumeTimer = null;
_timer?.cancel();
_timer = null;
debugPrint('🌙 [RemoteControl] [生命周期] 退后台:挂起控制循环 + 双轴归零');
_logger.logWithLevel(
'🌙 [RemoteControl] [生命周期] 退后台:挂起控制循环 + 双轴归零',
shouldLog: true,
);
// best-effort:socket 已被冻住时这些帧会被 sendRaw 如实判为失败(已有节流日志),
// 但本地轴值已归零,回前台重启循环也只会发零速帧。
unawaited(stopAllMovement());
}
/// 🔥 回前台恢复控制循环:仅当业务侧仍处于控制态。
void _resumeControlLoop() {
if (!_suspendedByLifecycle) return;
_suspendedByLifecycle = false;
if (!_controlWanted) {
// 后台期间用户已退页 / 折叠(stopControlLoop 已把意图关掉),不复活。
debugPrint('☀️ [RemoteControl] [生命周期] 回前台:已不在控制态,不重启循环');
return;
}
debugPrint('☀️ [RemoteControl] [生命周期] 回前台:延迟重启控制循环');
// 错开重连窗口:main.dart 的 _handleResume 要先等 500ms 才
// reconnectAfterResume();立刻起循环只会把首批指令打在空 socket 上被丢弃。
_resumeTimer?.cancel();
_resumeTimer = Timer(const Duration(milliseconds: 800), () {
if (isClosed || !_controlWanted || _suspendedByLifecycle) return;
startControlLoop();
});
}
@override
// 形参不跟父类叫 state:在 Cubit 里它会遮蔽 cubit 自己的 state getter,
// 后来人误用会拿到 AppLifecycleState 而不是 RemoteControlState。
// ignore: avoid_renaming_method_parameters
void didChangeAppLifecycleState(AppLifecycleState lifecycle) {
switch (lifecycle) {
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.hidden:
_suspendControlLoop();
break;
case AppLifecycleState.resumed:
_resumeControlLoop();
break;
case AppLifecycleState.detached:
// 进程即将销毁,无需也不应在这里做异步 IO。
break;
}
}
/// [resetAxes] 是否在起循环前把双轴静默归零。默认 true(冷启动:路由 / 旧页
/// initState / HUD activate / 回前台恢复);[stopAllMovement] 末尾的热重启必须
/// 传 false,理由见那里。
void startControlLoop({bool resetAxes = true}) {
// 🔥 已 close 的循环既不该起也不该写状态(定时器 tick 里另有 isClosed 兜底)。
if (isClosed) return;
_timer?.cancel();
// 进控制态的唯一入口:记下意图(后台挂起期间也记,回前台靠它恢复)。
_controlWanted = true;
if (_suspendedByLifecycle) {
// 🔥 后台挂起期间任何人调进来都只记意图、不起定时器:否则
// stopAllMovement 末尾、或后台里走完的权限流程会把循环重新点着。
debugPrint('🌙 [RemoteControl] [控制循环] 已挂起,仅记录控制意图不起定时器');
return;
}
final logPrefix = '⏰ [RemoteControl] [控制循环]';
//debugPrint('$logPrefix =========================================');
//debugPrint('$logPrefix 启动控制循环,间隔: 200ms');
///debugPrint('$logPrefix =========================================');
// 🔥 安全兼容:进控制态一律从“零速”起步。
// originX/Y 存在本单例的成员变量上,上一次会话若是“按住摇杆直接退页 /
// 切视图”(没走松手归零路径),残留的非零值会让本次刚进页面就持续
// 下发运动指令 —— 车自己开走。冷启动入口(路由 create / 旧页 initState /
// HUD activate)都经这里,故在源头清一次。
if (resetAxes) {
_zeroAxesQuietly();
}
// 🔥 启动模拟设备状态推送(用于测试)
// startSimulation();
@@ -582,6 +710,16 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
return;
}
// 🔥 锁定态门控(与 hasPermission / isEmergency 同层):旧版只在摇杆区域套
// IgnorePointer(ignoring: isLocked),靠 UI 遮挡;但 isLocked 住在单例里,会
// 跨页面残留,而新版 HUD 没有锁定入口也不读这个位。锁上一律不发运动帧,
// 两版行为一致且不依赖 UI。(退页时 resetPermissionState 会把它清掉,
// 不会出现“带着锁定进 HUD 导致摇杆失灵”。)
if (state.isLocked) {
// debugPrint('$logPrefix 🔒 锁定状态,跳过发送 - isLocked=true');
return;
}
// 🔥 参考Android版:直接读取成员变量,避免state竞态
final snapshot = MachineControlStatusEntity(
originX: _currentOriginX,
@@ -656,6 +794,9 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
void updateOriginY(int y) {
// debugPrint('📥 [updateOriginY] 被调用- y=$y');
// 🔥 页面销毁后可能还有迟到的 pointer 回调(CCJoystick 内部有 await),
// 不拦住就会在已 close 的对象上 emit。
if (isClosed) return;
// 🔥 参考Android版:直接更新成员变量
_currentOriginY = y;
@@ -671,6 +812,8 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
void updateOriginX(int x) {
// debugPrint('📥 [updateOriginX] 被调用- x=$x');
// 🔥 同 updateOriginY:防页面销毁后迟到的摇杆回调打到已 close 的对象。
if (isClosed) return;
// 🔥 参考Android版:直接更新成员变量
_currentOriginX = x;
@@ -714,6 +857,14 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
/// 🔥 安全方法:同时清零双轴,确保只发送一次完全停止指令
Future<void> stopAllMovement() async {
// 🔥 cubit 已 close(退登录/销毁单例)时:只清本地值就返回。
// 本方法多处被 unawaited() 调走(后台挂起 / 失权 / 松手),它的 await 链
// 比页面活得久,不拦住就会在已关闭对象上 emit,抛未捕获的 StateError。
if (isClosed) {
_currentOriginX = 0;
_currentOriginY = 0;
return;
}
// debugPrint(
// '🛑 [stopAllMovement] 开始执行- 当前成员变量: originX=$_currentOriginX, originY=$_currentOriginY',
// );
@@ -736,29 +887,58 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 🔥 安全底线:停止指令必须无视权限强制发送!
// debugPrint('🛑 [stopAllMovement] 双轴归零,发送停止指令- originX=0, originY=0');
await _sendStopCommandRepeatedly(updatedEntity); // 等待所有指令发送完成
final sentFrames = await _sendStopCommandRepeatedly(
updatedEntity,
); // 等待所有指令发送完成
if (sentFrames == 0) {
// 一帧都没出网(TCP 未连接):不能当“已停住”。本地轴值已归零,
// 重连后由控制循环发零速帧接管;这里留下一条可查证的 ERROR 痕迹。
debugPrint('🛑 [stopAllMovement] ⚠️ 停止指令全部未出网(TCP 未连接)');
_logger.logWithLevel(
'🛑 [stopAllMovement] ⚠️ 30 帧停止指令全部未出网(_socket=null),'
'本地轴值已归零,重连后由控制循环补发零速帧',
level: 'ERROR',
shouldLog: true,
);
}
// 🔥 恢复定时器
// 🔥 恢复定时器:仅当业务侧仍处于控制态。
// 上面这 ~400ms 窗口里页面可能已 dispose(stopControlLoop 把意图关掉了),
// 无条件重启会让循环在没人控制的页面上空转到 cubit close;后台挂起期间
// startControlLoop 自身也会拦住定时器,只留意图给回前台恢复。
await Future.delayed(const Duration(milliseconds: 200));
startControlLoop();
if (_controlWanted) {
// 🔥 热重启不清轴:上面这 ~450ms 里用户完全可能又把摇杆压回去了
// (updateOriginX/Y 会把非零值写回 _currentOriginX/Y)。若在这里再
// 调 _zeroAxesQuietly(),就会抹掉他手指正压着的目标量;而 UI 侧的
// 去重基准(HUD 的 _lastDriveY / 旧版摇杆的 _lastSentY)仍是那个非零值,
// 于是“手指明明压在摇杆上、车却不动”,得再推一下才恢复。
// 跨会话残留值的清理只在冷启动入口做(那里 resetAxes 默认 true)。
startControlLoop(resetAxes: false);
} else {
debugPrint('🛑 [stopAllMovement] 已不在控制态,不重启循环');
}
// debugPrint('>>> [stopAllMovement] 执行完成');
}
/// 🔥 统一方法:连续发送10次停止指令,彻底清空TCP缓冲区
Future<void> _sendStopCommandRepeatedly(
/// 🔥 统一方法:连续发送停止指令,彻底清空TCP缓冲区。
///
/// 返回真正写入 socket 的帧数:sendRaw 在未连接时会直接失败,
/// 调用方据此判断“停止指令是不是真的发出去了”(安全路径必须知道这一点)。
Future<int> _sendStopCommandRepeatedly(
MachineControlStatusEntity stopEntity,
) async {
// debugPrint(
// '🛑 [紧急停止] 开始连续发送10次停止指令- originX=${stopEntity.originX}, originY=${stopEntity.originY}',
// '🛑 [紧急停止] 开始连续发送 10 次停止指令- originX=${stopEntity.originX}, originY=${stopEntity.originY}',
// );
// 🔥 注意:_lastStopTime 已经在stopAllMovement() 中设置了,这里不需要再设置
// 🔥 本方法不做任何节流:停止帧必须一帧不少地发完。(旧注释写的“_lastStopTime
// 已在 stopAllMovement 里设置”是假信息 —— 全项目并没有这个变量,勿据此加节流。)
int sentCount = 0;
// 🔥 关键修复:每次发送间隔5ms,避免TCP合并/丢弃
for (int i = 0; i < 20; i++) {
_repository.sendControlMachineCmd(stopEntity);
sentCount++;
if (_repository.sendControlMachineCmd(stopEntity)) sentCount++;
if (i % 5 == 0) {
// debugPrint('🛑 [紧急停止] 已发送第${i + 1}次');
}
@@ -770,25 +950,38 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
// 🔥 延迟后再发10次(双重保险,对抗网络抖动)
await Future.delayed(const Duration(milliseconds: 100));
for (int i = 0; i < 10; i++) {
_repository.sendControlMachineCmd(stopEntity);
sentCount++;
if (_repository.sendControlMachineCmd(stopEntity)) sentCount++;
if (i < 9) {
await Future.delayed(const Duration(milliseconds: 5));
}
}
// debugPrint('>>> [紧急停止] 所有20次停止指令已发出');
return sentCount;
}
// 5. 停止控制循环
void stopControlLoop() {
// 🔥 退出控制态:意图一并关掉,否则 pending 中的 stopAllMovement 末尾
// 会把循环重新点着,回前台时也会错误地恢复一个已退页的控制循环。
_controlWanted = false;
_resumeTimer?.cancel();
_resumeTimer = null;
_timer?.cancel();
_timer = null;
emit(state.copyWith(status: RemoteControlStatus.initial));
}
void toggleLock() {
emit(state.copyWith(isLocked: !state.isLocked));
final willLock = !state.isLocked;
emit(state.copyWith(isLocked: willLock));
// 🔥 锁定瞬间归零 + 连发停止帧(与失权 / 急停同一道理):
// IgnorePointer 只拦住以后的推杆,手指正压在摇杆上那一划的
// _currentOriginX/Y 仍会留在单例上;而设备只认“最后收到的运动帧”,
// 光拦不发不会让已在跑的车停下。
if (willLock) {
unawaited(stopAllMovement());
}
}
void togglePermissionDialog(bool show) {
@@ -805,24 +998,61 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
emit(state.copyWith(showRightPip: !state.showRightPip));
// 🔥 切换前后视角(双击屏幕触发)
//
// 这是**纯 UI 层面的翻转**:WebRTCLocalPlayer 用 `isFrontMain: originY >= 0`
// 决定主画面算“前视”还是“后视”(见 remote_control_page 的双击入口)。
//
// 🔥 以前它还会 `_repository.sendControlMachineCmd(updatedEntity)` 直发一帧:
// ① 它绕过了控制循环的 hasPermission / isEmergency 两道门控,无权限或急停时
// 也能把指令弄到设备上;
// ② y=±1 经 diffSteer 换算后是非零线速度,等于双击一下屏幕车会动一下;
// ③ 控制循环读的是成员变量 _currentOriginY,不认这个改动,发出去也是脏值。
// 故只保留状态更新(视角照常生效),不再往设备发帧。
void toggleCameraView() {
if (isClosed) return;
final currentOriginY = state.controlEntity.originY;
// 🔥 originY >= 0 表示前视角,< 0 表示后视角
// 切换逻辑:如果当前是前视角(>=0),切换到后视角(-1);反之亦然
final newOriginY = currentOriginY >= 0 ? -1 : 1;
debugPrint('📷 [RemoteControlCubit] 切换前后视角: $currentOriginY -> $newOriginY');
final updatedEntity = state.controlEntity.copyWith(y: newOriginY);
emit(state.copyWith(controlEntity: updatedEntity));
// 🔥 发送 TCP 指令到设备
_repository.sendControlMachineCmd(updatedEntity);
}
// 🔥 全局护栏:close 之后的 emit 一律吃下,不让它抛 StateError。
//
// bloc 9.2.1 的 Cubit.emit 在已关闭时会 `throw StateError('Cannot emit new
// states after calling close')`(见 bloc_base.dart)。本 Cubit 是单例,而且
// 到处都是“比页面活得久”的异步回调(0x12 监听 / TCP 重连 / stopAllMovement 的
// await 链 / unawaited 全停),靠逐个加 isClosed 守卫总会有漏网的一条:
// 退登录把单例关掉后,一条迟到的回包就能在控制台抛未捕获异常(调试时直接红屏)。
// 因此这里集中拦:状态丢了就丢了(对象已无人监听),但绝不让异常外渗。
@override
@protected
@visibleForTesting
void emit(RemoteControlState state) {
if (isClosed) {
debugPrint(
'⚠️ [RemoteControl] Cubit 已关闭,忽略一次 emit(异步回调迟到)',
);
return;
}
super.emit(state);
}
@override
Future<void> close() {
// 与构造函数里的 addObserver 成对移除,否则回调会打在已关闭的对象上。
WidgetsBinding.instance.removeObserver(this);
_timer?.cancel();
_kickOutSub?.cancel(); // 🔥 0x12 监听:不取消会留着回调打在已 close 的对象上
_kickOutSub = null; // 且下次重新 init 后会叠出第二个监听器(重复弹窗)
_resumeTimer?.cancel();
_resumeTimer = null;
_controlWanted = false;
_suspendedByLifecycle = false;
_deviceStatusSub?.cancel(); // 🔥 取消订阅 DeviceStatusBloc
_tcpStatusSub?.cancel(); // 🔥 取消订阅 TCP 0x02 门槛监听
_reverseCameraSub?.cancel(); // 🔥 取消订阅倒车影像数据流
@@ -858,6 +1088,11 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
void updateEmergency(bool on) {
// debugPrint('🚨 [急停] ${on ? "触发急停!" : "解除急停"}');
// 🔥 先归零双轴:急停只是让循环“跳过发送”,_currentOriginX/Y 仍原样留着;
// 不归零的话,updateEmergency(false) 解除的那一 tick 就会按急停前的推量
// 原地复活(“急停→解除→车又自己走”)。触发与解除都清,语义变成
// “一旦碰过急停就必须重新推杆”。下面的全零急停帧会把已在跑的车压停。
_zeroAxesQuietly();
// ① 先置状态:急停时让 200ms 控制循环下一 tick 立刻停发运动指令;
// 解除时循环恢复正常发送。
updateFunction(emergency: on);
@@ -880,13 +1115,24 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
ignitionStatus: 0,
isEmergency: emergency,
);
int sentFrames = 0;
for (int i = 0; i < 10; i++) {
if (isClosed) return;
_repository.sendControlMachineCmd(frame);
if (_repository.sendControlMachineCmd(frame)) sentFrames++;
if (i < 9) {
await Future.delayed(const Duration(milliseconds: 5));
}
}
// 🔥 急停帧也是安全路径:一帧都没出网时必须留下痕迹,否则“按了急停但
// 设备没收到”会被当成已停住(与 stopAllMovement 同一口径)。
if (sentFrames == 0) {
debugPrint('🚨 [急停] ⚠️ 急停帧全部未出网(TCP 未连接)emergency=$emergency');
_logger.logWithLevel(
'🚨 [急停] ⚠️ 10 帧急停指令全部未出网(_socket=null),emergency=$emergency',
level: 'ERROR',
shouldLog: true,
);
}
}
void respondPermission(bool agreed, String deviceId) {
@@ -894,13 +1140,25 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
emit(state.copyWith(showPermissionRequestDialog: false));
// 2. 发送响应到服务器
// 🔥 不 rethrow:这里处于权限弹窗的按钮回调中间,异常一旦飞出去,
// 调用方的 `resetPermissionCoolDown()` / `togglePermissionDialog(false)` /
// `Navigator.pop()` 全都不会执行,barrierDismissible:false 的模态弹窗就
// 死在屏幕上(只能物理返回),同时附带一条未捕获异步异常。
// 发送失败时只做到这里为止:弹窗标志已复位(第 1 步),而权限态
// 不往下改——响应根本没出网,服务端仍认为本端持有控制权,保持原值才是
// 两边一致的状态;下次 activate / 走仅查询会从 HTTP 拿到真实结果。
try {
_repository.respondPermission(agreed, deviceId);
} catch (e) {
debugPrint(' ❌ TCP权限响应指令发送失败: $e');
debugPrint(' ❌ TCP权限响应指令发送失败(不抛出,保证弹窗能关闭): $e');
debugPrint(' ❌ 错误类型: ${e.runtimeType}');
_logger.logWithLevel(' TCP权限响应指令发送失败 $e', shouldLog: true);
rethrow;
_logger.logWithLevel(
' ❌ TCP权限响应指令发送失败(不抛出,保证弹窗能关闭): $e',
level: 'ERROR',
shouldLog: true,
);
debugPrint('${_getTimePrefix()} ====权限弹窗响应提前结束====');
return;
}
// 3. 根据用户选择更新控制状态
@@ -911,6 +1169,9 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
showPermissionRequestDialog: false,
),
);
// 🔥 主动让权同样要归零 + 全停(理由同 0x12 失权分支):用户“同意”的瞬间
// 可能手指还压在摇杆上,不归零则下次拿回控制权时会按旧推量复活。
unawaited(stopAllMovement());
} else {
// 用户拒绝 APP 继续保持控制权
// 🔥 必须同时设置 showPermissionRequestDialog: false,防止状态回退
@@ -1047,18 +1308,34 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
);
}
/// 🔥 退出遥控页时重置权限状态,避免单例状态残留导致下次进入不再查询接口
/// 🔥 退出遥控页时重置“会话级”状态(权限 / 弹窗 / 锁定 / 急停),避免单例残留
void resetPermissionState() {
if (!isClosed) {
emit(
state.copyWith(
hasPermission: false,
showPermissionRequestDialog: false,
// 🔥 锁定位一并复位:它是页面内的一次性 UI 态,残留到下一次会话
// 会让控制循环被上面的 isLocked 门控整体拦住(新版 HUD 没有
// 解锁按钮,到时候就是“推了不动”且无法自救)。
isLocked: false,
// 🔥 急停位同样复位(只改状态,不再补发帧):旧版“点急停直接退页”
// 会把 isEmergency=true 留在单例上,而新版 HUD 的急停按钮是页面
// 本地变量(_startControl 里置 false)——两边不一致时,HUD 显示
// “未急停”但循环被 isEmergency 拦住,同样是“推了不动”。
// 与新版 deactivate() 里的 updateEmergency(false) 同一口径;设备侧
// 已收到退页时的全停帧,下次会话循环一开就会发 emergency=0 帧。
// controlEntity 里的 emergency 位也要一并清:stopAllMovement 的停止
// 帧用的是这个实体,残留会让下次会话松手时错发一串急停帧。
isEmergency: false,
controlEntity: state.controlEntity.copyWith(emergency: false),
),
);
debugPrint('🔑 [RemoteControl] 已重置权限状态 hasPermission=false');
debugPrint(
'🔑 [RemoteControl] 已重置会话态 hasPermission=false, isLocked=false, isEmergency=false',
);
_logger.logWithLevel(
'🔑 [RemoteControl] 已重置权限状态 hasPermission=false',
'🔑 [RemoteControl] 已重置会话态 hasPermission=false, isLocked=false, isEmergency=false',
shouldLog: true,
);
}
@@ -1299,6 +1576,12 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
void clearAll() {
_timer?.cancel();
_timer = null;
_resumeTimer?.cancel();
_resumeTimer = null;
// 🔥 退出登录:控制意图与后台挂起位一并清掉,否则回前台会恢复一个
// 已不属于任何页面的控制循环。
_controlWanted = false;
_suspendedByLifecycle = false;
_cacheVoltage = null;
_cacheBattery = null;