分区相关
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/domain/entities/device_control_mode.dart';
|
||||
import 'package:maibu_satabot_v2/features/device_control_mode/presentation/widgets/control_mode_entry_gate.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -37,11 +39,18 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
StreamSubscription? _permissionSubscription; // 🔥 权限监听订阅
|
||||
bool _isShowingPermissionDialog = false; // 🔥 防止弹窗重复显示
|
||||
bool? _lastPermissionDialogState; // 🔥 记录上次弹窗状态,检测状态变化
|
||||
bool _controlSessionStarted = false;
|
||||
late final String _modeDeviceId = _cubit.state.targetDevice?.deviceName ?? '';
|
||||
bool _isLoadingPermission = true; // 🔥 标记是否正在加载权限状态
|
||||
|
||||
/// 缓存本地化实例:dispose 阶段 context 已失效 / 弹窗中需提前拼文案,
|
||||
/// 不能依赖 `AppLocalizations.of(context)`。
|
||||
late AppLocalizations _l10n;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_l10n = AppLocalizations.of(context);
|
||||
_cubit = context.read<RemoteControlCubit>();
|
||||
_devicesCubit = context.read<DevicesCubit>();
|
||||
|
||||
@@ -104,26 +113,6 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
]);
|
||||
// 2. 隐藏状态栏和虚拟按键
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final remoteCubit = context.read<RemoteControlCubit>();
|
||||
|
||||
// 🔥 倒车影像:现阶段改用摇杆(滑块)数据驱动,不再启动 Mock 假数据源。
|
||||
// 恢复 Mock:取消下行注释即可(需与 cubit 里 _syncReverseCameraFromJoystick 的调用二选一)。
|
||||
// remoteCubit.startReverseCameraMock();
|
||||
|
||||
// debugPrint('🔍 [RemoteControl] initState - targetDevice: ${remoteCubit.state.targetDevice}');
|
||||
|
||||
// 🔥 检查 targetDevice 是否存在
|
||||
if (remoteCubit.state.targetDevice == null) {
|
||||
// debugPrint('❌ [RemoteControl] targetDevice 为空,无法进入远程控制');
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 只在控制循环未启动时才启动
|
||||
remoteCubit.startControlLoop();
|
||||
// debugPrint('✅ [RemoteControl] 控制循环已启动');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -133,10 +122,12 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
|
||||
// 释放远程控制权限(type=2:退出远程遥控页面释放)
|
||||
final deviceId = _cubit.state.targetDevice?.deviceName;
|
||||
if (deviceId != null && deviceId.isNotEmpty) {
|
||||
if (_controlSessionStarted && deviceId != null && deviceId.isNotEmpty) {
|
||||
_cubit.releasePermission(deviceId: deviceId, type: 2).then((success) {
|
||||
CapsuleToast.show(
|
||||
success ? '已释放远程控制权限' : '控制权限释放失败',
|
||||
success
|
||||
? _l10n.translate('remote_control.toast_released')
|
||||
: _l10n.translate('remote_control.toast_release_failed'),
|
||||
showCheck: success,
|
||||
);
|
||||
});
|
||||
@@ -145,7 +136,17 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
_permissionSubscription?.cancel(); // 🔥 取消订阅
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
_cubit.stopControlLoop();
|
||||
// 🔥 退页先把车真正停下:归零双轴 + 连发 30 帧停止指令。
|
||||
// 只靠 stopControlLoop() 断掉 200ms 循环是不够的:设备只认“最后收到的
|
||||
// 运动帧”,若手指还压在摇杆上就退出(没走松手归零),已下发的高速帧会
|
||||
// 让车继续跑到它自己的失联保护为止。与新版 HUD 折叠/退出的 deactivate()
|
||||
// 同一口径。
|
||||
// 必须放在 stopControlLoop() 之前:stopAllMovement 末尾会按 _controlWanted
|
||||
// 决定是否重启循环,这里同步把意图关掉,就不会在退页后留下无主定时器。
|
||||
if (_controlSessionStarted) {
|
||||
unawaited(_cubit.stopAllMovement());
|
||||
_cubit.stopControlLoop();
|
||||
}
|
||||
_cubit.stopReverseCameraMock(); // 🔥 停止倒车影像 Mock,避免定时器空转
|
||||
// 🔥 重置权限状态,避免单例 hasPermission 残留导致下次进入不再查询接口
|
||||
_cubit.resetPermissionState();
|
||||
@@ -153,7 +154,18 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context) => ControlModeEntryGate(
|
||||
deviceId: _modeDeviceId,
|
||||
target: ControlModeTarget.remote,
|
||||
onReady: () {
|
||||
if (_cubit.state.targetDevice?.deviceName != _modeDeviceId) return;
|
||||
_controlSessionStarted = true;
|
||||
_cubit.startControlLoop();
|
||||
},
|
||||
child: _buildPage(context),
|
||||
);
|
||||
|
||||
Widget _buildPage(BuildContext context) {
|
||||
// 🔥 只监听 RemoteControlCubit,避免其他状态变化导致频繁 rebuild
|
||||
final remoteCubit = context.read<RemoteControlCubit>();
|
||||
final targetDevice = remoteCubit.state.targetDevice;
|
||||
@@ -380,12 +392,16 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
Future<void> _showPermissionDialog(RemoteControlState state) async {
|
||||
debugPrint('🔔 [权限弹窗] _showPermissionDialog 被调用');
|
||||
|
||||
final requestingDeviceId = state.requestingDeviceId ?? '未知设备';
|
||||
final requestingPlatform = state.requestingPlatform ?? '未知平台';
|
||||
final dialogContent =
|
||||
'$requestingPlatform 端正在请求控制权\n\n'
|
||||
'设备ID: $requestingDeviceId\n\n'
|
||||
'是否同意释放控制权?';
|
||||
final requestingDeviceId =
|
||||
state.requestingDeviceId ??
|
||||
_l10n.translate('remote_control.perm_unknown_device');
|
||||
final requestingPlatform =
|
||||
state.requestingPlatform ??
|
||||
_l10n.translate('remote_control.perm_unknown_platform');
|
||||
final dialogContent = _l10n
|
||||
.translate('remote_control.permission_request_body')
|
||||
.replaceAll('{platform}', requestingPlatform)
|
||||
.replaceAll('{deviceId}', requestingDeviceId);
|
||||
|
||||
debugPrint(
|
||||
'🔔 [权限弹窗] 准备显示弹窗 - requestingPlatform=$requestingPlatform, requestingDeviceId=$requestingDeviceId',
|
||||
@@ -447,15 +463,16 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
|
||||
// 🔥 关键修复:先重置弹窗标志位,再关闭弹窗
|
||||
// 避免 Navigator.pop() 后上下文失效导致后续代码不执行
|
||||
context.read<RemoteControlCubit>().resetPermissionCoolDown();
|
||||
context.read<RemoteControlCubit>().emit(
|
||||
context.read<RemoteControlCubit>().state.copyWith(
|
||||
showPermissionRequestDialog: false,
|
||||
),
|
||||
);
|
||||
// 🔥 一律用 await 之前就捕获的 remoteCubit,不再 context.read:
|
||||
// 异步回来后页面可能已销毁,在失效 Element 上 context.read 会抛。
|
||||
remoteCubit.resetPermissionCoolDown();
|
||||
remoteCubit.togglePermissionDialog(false);
|
||||
|
||||
// 🔥 关闭弹窗(在状态重置之后)
|
||||
Navigator.pop(dialogContext);
|
||||
// 🔥 关闭弹窗(在状态重置之后):弹窗可能已被上一点击关闭,
|
||||
// 不守卫就会在 defunct context 上 pop 抛异常。
|
||||
if (dialogContext.mounted && Navigator.canPop(dialogContext)) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
debugPrint('🔔 [权限弹窗] 弹窗已关闭,重置标志位');
|
||||
},
|
||||
child: Text(
|
||||
@@ -519,15 +536,14 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
|
||||
|
||||
// 🔥 关键修复:先重置弹窗标志位,再关闭弹窗
|
||||
// 避免 Navigator.pop() 后上下文失效导致后续代码不执行
|
||||
context.read<RemoteControlCubit>().resetPermissionCoolDown();
|
||||
context.read<RemoteControlCubit>().emit(
|
||||
context.read<RemoteControlCubit>().state.copyWith(
|
||||
showPermissionRequestDialog: false,
|
||||
),
|
||||
);
|
||||
// 🔥 同拒绝分支:用 await 前就捕获的 remoteCubit,不跨 await 用 context.read。
|
||||
remoteCubit.resetPermissionCoolDown();
|
||||
remoteCubit.togglePermissionDialog(false);
|
||||
|
||||
// 🔥 关闭弹窗(在状态重置之后)
|
||||
Navigator.pop(dialogContext);
|
||||
// 🔥 关闭弹窗(在状态重置之后):同样防重复点击后的 defunct pop。
|
||||
if (dialogContext.mounted && Navigator.canPop(dialogContext)) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
debugPrint('🔔 [权限弹窗] 弹窗已关闭,重置标志位');
|
||||
},
|
||||
child: Text(
|
||||
|
||||
Reference in New Issue
Block a user