Files
flutterApp/lib/features/remote_control/presentation/bloc/remote_control_cubit.dart
2026-11-06 08:44:05 +08:00

1606 lines
68 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:dart_ping/dart_ping.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:maibu_satabot_v2/core/network/tcp/tcp_client.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_repository.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_event.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart';
import 'package:maibu_satabot_v2/features/remote_control/data/models/running_status_model.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/usecase/request_control_permission_usecase.dart';
import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_state.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/network/net_message_dispatcher.dart';
import '../../../devices/domain/entities/running_status_entity.dart';
import '../../data/reverse_camera_source.dart';
import '../../domain/entities/machine_control_status_entity.dart';
import '../../domain/entities/reverse_camera_data.dart';
import '../../domain/repositories/remote_control_repository.dart';
import '../../domain/usecase/remote_control_usecase.dart';
/// 远程控制核心 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; // 🔥 注入设备仓库
final TcpClient tcpClient; // 🔥 注入TCP客户端
Timer? _timer;
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
final NetMessageDispatcher dispatcher;
final DeviceStatusBloc deviceStatusBloc; // 🔥 注入 DeviceStatusBloc
StreamSubscription? _deviceStatusSub; // 🔥 订阅 DeviceStatusBloc 的状态流(MQTT,已停用)
StreamSubscription? _tcpStatusSub; // 🔥 订阅 TCP 0x02 机器状态推送(门槛 + 电压/电量/控制模式)
static const platform = MethodChannel('com.maibu.satabot/ping');
int _currentPing = 50;
final ILoggerService _logger = GetIt.I<ILoggerService>();
// 🔥 参考Android版:使用成员变量存储摇杆值,避免state竞态
int _currentOriginX = 0;
int _currentOriginY = 0;
/// 业务侧是否期望处于“控制态”:[startControlLoop] 置真、[stopControlLoop] 置假。
/// 它代表“意图”,与定时器的实时存在与否解耦:被后台挂起时仍为真,
/// 回前台靠它判断要不要自动重启循环;stopAllMovement 末尾也靠它判断该不该重调。
bool _controlWanted = false;
/// 因退到后台(inactive/paused/hidden)而临时挂起控制循环。
/// 挂起期间所有 startControlLoop 调用只记意图、不起定时器,回前台再恢复。
bool _suspendedByLifecycle = false;
// 🔥 权限请求处理标志位 - 防止竞态条件,避免重复显示弹窗
bool _isProcessingPermissionRequest = false;
// 🔥 模拟数据推送定时器
// Timer? _simulationTimer;
// 🔥 倒车影像数据源(现为 Mock;嵌入式就绪后,把 startReverseCameraMock 里的
// MockReverseCameraSource 换成解析真实回包的 Mqtt/Tcp 源即可,UI 不用动)。
ReverseCameraSource? _reverseCameraSource;
StreamSubscription<ReverseCameraData>? _reverseCameraSub;
// 🔥 获取带时间戳的日志前缀
String _getTimePrefix() {
final now = DateTime.now();
return '[${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}.${now.millisecond.toString().padLeft(3, '0')}]';
}
RemoteControlCubit(
this._repository,
this._requestControlPermissionUseCase,
this._deviceRepository, // 🔥 注入
this.tcpClient, // 🔥 注入TCP客户端
this.dispatcher,
this.deviceStatusBloc, // 🔥 注入
) : super(
RemoteControlState(
controlEntity: MachineControlStatusEntity(),
runningStatusModel: RunningStatusModel(),
),
) {
_initPacketListener();
// 🔥 门槛(hasReceivedStatusPush)与电压/电量/控制模式改由 TCP 0x02 驱动,
// 与 MQTT 定位链路解耦;MQTT(DeviceStatusBloc) 仅继续负责定位。
// 回滚:注释下行、改回 _initDeviceStatusListener() 即可。
_initTcpStatusListener();
// _initDeviceStatusListener(); // 🔥 旧:订阅 DeviceStatusBloc(MQTT),已停用
// 生命周期观察与本类同生同灭(close() 里成对移除):单例常驻,
// 不挂在某个页面上,所以从别的路径进控态也能被护住。
WidgetsBinding.instance.addObserver(this);
}
// 🔥 新增:订阅 DeviceStatusBloc 的状态流,而不是直接监听TCP
// 类全局变量
DateTime? _lastUiUpdateTime;
String? _cacheVoltage;
String? _cacheBattery;
String? _cacheCtrlMode;
int? _cachePing;
DateTime? _lastStatusPushTime; // 最后一次收到设备状态推送的时间
// 🔥 已停用:门槛与电压/电量/控制模式改由 TCP 0x02 驱动(见 _initTcpStatusListener)。
// 保留本方法用于回滚——恢复构造函数中的调用即可重新走 MQTT(DeviceStatusUpdated)。
// ignore: unused_element
void _initDeviceStatusListener() {
_deviceStatusSub?.cancel();
_deviceStatusSub = deviceStatusBloc.stream.listen((deviceState) async {
if (deviceState is DeviceStatusUpdated) {
// 第一步:所有数据先存入缓存,不管来多频繁都存最新值
final voltage = deviceState.status.voltage;
final battery = deviceState.status.battery;
// 只存协议原始值('3'=远程/'0'=本地等),展示文案由 UI 按当前语言翻译
final controlMode = deviceState.status.controlMode;
final c = await getNetworkDelay();
_cacheVoltage = voltage.toString();
_cacheBattery = battery.toString();
_cacheCtrlMode = controlMode;
_cachePing = c;
_lastStatusPushTime = DateTime.now(); // 记录最后一次推送时间
// 500ms节流,不到时间不刷新UI
final now = DateTime.now();
if (_lastUiUpdateTime != null &&
now.difference(_lastUiUpdateTime!) <
const Duration(milliseconds: 500)) {
return;
}
_lastUiUpdateTime = now;
// 间隔达标,统一一次刷新UI
emit(
state.copyWith(
runningStatusModel: state.runningStatusModel.copyWith(
voltage: _cacheVoltage,
battery: _cacheBattery,
controlMode: _cacheCtrlMode,
),
battery: int.tryParse(_cacheBattery ?? '') ?? 0,
ping: _cachePing,
hasReceivedStatusPush: true, // 标记已收到设备状态推送
// 🔥 标记为设备状态更新
updateType: 'device_status',
),
);
}
});
}
/// 🔥 门槛 + 运行状态专用监听:直接监听 TCP 0x02 机器状态推送。
///
/// 与 MQTT 定位链路彻底解耦:
/// - 门槛 hasReceivedStatusPush / _lastStatusPushTime 由 TCP 0x02 驱动;
/// - 电压/电量/控制模式 直接由 0x02 报文的 fromFields 解析(fields[0]/[21]/[20]);
/// - MQTT(DeviceStatusBloc) 仅继续负责定位,不再影响本页门槛与三个值。
void _initTcpStatusListener() {
_tcpStatusSub?.cancel();
_tcpStatusSub = tcpClient.packetStream
.where((p) => p.command == 0x02)
.listen((packet) async {
try {
final message = utf8.decode(packet.payload, allowMalformed: true);
if (message.trim().isEmpty) return;
final fields = message.trim().split(',');
if (fields.length < 18) {
debugPrint('⚠️ [TCP-0x02门槛] 字段不足:${fields.length},期望≥18,跳过');
return;
}
final status = RunningStatusEntity.fromFields(fields);
// 只存协议原始值('3'=远程/'0'=本地等),展示文案由 UI 按当前语言翻译
final controlMode = status.controlMode;
// 🔥 门槛:收到 TCP 0x02 即视为推送活跃(节流前刷新,轻量无阻塞)
_lastStatusPushTime = DateTime.now();
_cacheVoltage = status.voltage.toString();
_cacheBattery = status.battery;
_cacheCtrlMode = controlMode;
// 500ms节流,不到时间不刷新UI
final now = DateTime.now();
if (_lastUiUpdateTime != null &&
now.difference(_lastUiUpdateTime!) <
const Duration(milliseconds: 500)) {
// 节流期间仍需保证门槛标志已置位(否则首帧恰逢节流会导致门槛漏置)
if (!state.hasReceivedStatusPush && !isClosed) {
emit(state.copyWith(hasReceivedStatusPush: true));
}
return;
}
_lastUiUpdateTime = now;
// 🔥 ping 只在真正刷新UI时测(≤2Hz):0x02 原始推送可能高频,
// 若每帧 await getNetworkDelay 会造成大量并发 ping,移到节流后规避。
final c = await getNetworkDelay();
_cachePing = c;
if (!isClosed) {
emit(
state.copyWith(
runningStatusModel: state.runningStatusModel.copyWith(
voltage: _cacheVoltage,
battery: _cacheBattery,
controlMode: _cacheCtrlMode,
),
battery: int.tryParse(_cacheBattery ?? '') ?? 0,
ping: _cachePing,
hasReceivedStatusPush: true, // 🔥 门槛:TCP 0x02 到达即置位
updateType: 'device_status',
),
);
}
} catch (e) {
debugPrint('❌ [TCP-0x02门槛] 解析异常:$e');
}
});
}
// 🔥 超简单方法:传入 IP,得到 ping 值
// 🔥 模拟设备状态更新 - 用于测试
// void simulateDeviceStatusUpdate({
// String? voltage,
// String? battery,
// String? controlMode,
// int? ping,
// // 🔥 是否随机生成数据
// bool random = false,
// }) {
// // 如果开启随机模式,生成随机数据
// final rand = Random();
/* if (random) {
voltage = (22.0 + rand.nextDouble() * 4.0).toStringAsFixed(
1,
); // 22.0-26.0V
battery = (rand.nextInt(100) + 1).toString(); // 1-100%
controlMode = rand.nextBool() ? '远程模式' : '本地模式';
ping = rand.nextInt(150) + 20; // 20-170ms
} */
/* debugPrint(
'🔧 [模拟设备状态更新] voltage=$voltage V, battery=$battery%, controlMode=$controlMode, ping=$ping ms',
); */
// 更新缓存
/* if (voltage != null) _cacheVoltage = voltage;
if (battery != null) _cacheBattery = battery;
if (controlMode != null) _cacheCtrlMode = controlMode;
if (ping != null) _cachePing = ping;
// 直接触发状态更新(跳过节流,立即更新)
emit(
state.copyWith(
runningStatusModel: state.runningStatusModel.copyWith(
voltage: _cacheVoltage,
battery: _cacheBattery,
controlMode: _cacheCtrlMode,
),
battery: int.tryParse(_cacheBattery ?? '') ?? 0,
ping: _cachePing,
// 🔥 标记为设备状态更新
updateType: 'device_status',
),
);
} */
// 🔥 开始模拟设备状态推送(随机数据)
/* void startSimulation({int intervalMs = 500}) {
// 如果已经在运行,先停止
stopSimulation();
// debugPrint('🔔 [模拟推送] 开始模拟设备状态推送,间隔:${intervalMs}ms');
// 立即发送一次初始数据
simulateDeviceStatusUpdate(random: true);
// 定时推送随机数据
_simulationTimer = Timer.periodic(Duration(milliseconds: intervalMs), (
timer,
) {
if (!isClosed) {
simulateDeviceStatusUpdate(random: true);
} else {
stopSimulation();
}
});
} */
// 🔥 停止模拟设备状态推送
/* void stopSimulation() {
if (_simulationTimer != null) {
_simulationTimer!.cancel();
_simulationTimer = null;
debugPrint('🔔 [模拟推送] 已停止模拟设备状态推送');
}
} */
// 🔥 辅助方法:更新运行状态
void _updateStatusFromDevice(RunningStatusModel newStatus) {
// // debugPrint('✅ [_updateStatusFromDevice] 收到运行状态更新:$newStatus');
if (!isClosed) {
emit(
state.copyWith(
runningStatusModel: newStatus,
voltage: int.tryParse(newStatus.voltage) ?? 0,
battery: int.tryParse(newStatus.battery) ?? 0,
),
);
// debugPrint('✅ [更新运行状态] 电压:${newStatus.voltage}V, 电量:${newStatus.battery}%');
// _logger.logWithLevel('✅ [更新运行状态] 电压:${newStatus.voltage}V, 电量:${newStatus.battery}%');
}
}
// 🔥 辅助方法:更新运行状态
// 1. 初始化回包监听 (如 0x12 权限)
// void _initPacketListener() {
// _repository.responseStream.listen((packet) {
// if (packet.command == 0x12) {
// // 根据负载判断是否有权限,更新状态
// emit(state.copyWith(hasPermission: true));
// }
// });
// }
Future<void> _initPacketListener() async {
final logPrefix = '${_getTimePrefix()} 🔍 [RemoteControl] [0x12监听器]';
debugPrint('$logPrefix =========================================');
debugPrint('$logPrefix 开始初始化 0x12 监听器');
_logger.logWithLevel('$logPrefix 开始初始化 0x12 监听器', shouldLog: true);
_kickOutSub?.cancel(); // 防止重复监听
_kickOutSub = dispatcher.onCommand(0x12).listen((packet) {
final timeNow = _getTimePrefix();
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] ✅ 收到 0x12 原始包');
/* debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] payload长度: ${packet.payload.length}',
);*/
_logger.logWithLevel(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 收到 0x12 原始包,payload长度=${packet.payload.length}',
shouldLog: true,
);
// 先解析JSON判断是否为响应格式
String jsonStringForCheck;
try {
if (packet.payload.length > 2) {
jsonStringForCheck = utf8.decode(
packet.payload.sublist(0, packet.payload.length - 2),
);
} else {
jsonStringForCheck = utf8.decode(packet.payload);
}
final jsonMap = jsonDecode(jsonStringForCheck);
final respondData = jsonMap['respond'];
// 如果是响应格式,继续处理(更新权限状态)
// 如果是请求格式且弹窗已显示,忽略
if (respondData == null && state.showPermissionRequestDialog) {
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] ⚠️ 弹窗已显示(状态),忽略请求');
return;
}
} catch (e) {
// 解析失败,继续处理
}
try {
// 🔥 关键:手动去掉最后 2 个 CRC 字节
String jsonString;
if (packet.payload.length > 2) {
jsonString = utf8.decode(
packet.payload.sublist(0, packet.payload.length - 2),
);
} else {
jsonString = utf8.decode(packet.payload);
}
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] JSON内容: $jsonString');
_logger.logWithLevel(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 去除CRC后的JSON: $jsonString',
shouldLog: true,
);
final jsonMap = jsonDecode(jsonString);
// 🔥 区分两种数据格式
final requestType = jsonMap['request'];
final platform = jsonMap['platform'];
final respondData = jsonMap['respond'];
debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] requestType: $requestType, platform: $platform, hasRespond: ${respondData != null}',
);
// 情况 1: 响应格式 - {"respond":{"switchResult":true,"deviceId":"...","holder":"you"}}
if (respondData != null && respondData is Map) {
// 🔥 收到响应格式,更新权限状态
final switchResult = respondData['switchResult'];
/* debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] 📊 收到切换结果响应');
debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] switchResult: $switchResult',
);*/
_logger.logWithLevel(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 📊 收到切换结果响应 - switchResult: $switchResult',
shouldLog: true,
);
if (!isClosed) {
if (switchResult == true) {
// 切换成功,当前 APP 获得控制权
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] ✅ APP获得控制权');
debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 设置 hasPermission = true',
);
emit(state.copyWith(hasPermission: true));
} else {
// 切换失败或拒绝,APP 失去控制权
debugPrint('$timeNow 🔍 [RemoteControl] [0x12监听器] ❌ APP失去控制权');
debugPrint(
'$timeNow 🔍 [RemoteControl] [0x12监听器] 设置 hasPermission = false',
);
emit(
state.copyWith(
hasPermission: false,
// 🔥 修复:不强制关闭弹窗,让弹窗由用户操作控制
),
);
// 🔥 失权即归零 + 补发全停:控制循环只是“跳过发送”,
// _currentOriginX/Y 会原样留着;只改 hasPermission 的话,重新
// 拿回控制权的那一 tick 就会按失权前的推量原地发车。而且设备只认
// “最后收到的运动帧”,光清本地值不会让已在跑的车停下 ——
// 故用 stopAllMovement(它的停止帧无视权限强制发送)。
unawaited(stopAllMovement());
}
}
}
// 情况 2: 请求格式 - {"request":"switch_control","deviceId":"...","platform":"web",...}
else if (requestType == 'switch_control') {
final requestDeviceId = jsonMap['deviceId'];
final webLogPrefix =
'${_getTimePrefix()} 🚨 [RemoteControl] [Web端权限请求]';
// 🔥 新增:记录收到请求的时间,便于排查是否为后端持续推送
debugPrint(
'$webLogPrefix ⚡️⚡️⚡️ 收到Web端权限请求 - ${DateTime.now().toString()}',
);
_logger.logWithLevel(
'$webLogPrefix ⚡️⚡️⚡️ 收到Web端权限请求 - ${DateTime.now().toString()}',
shouldLog: true,
);
debugPrint('$webLogPrefix =========================================');
debugPrint('$webLogPrefix 收到 switch_control 请求');
debugPrint('$webLogPrefix platform: $platform');
debugPrint('$webLogPrefix requestDeviceId: $requestDeviceId');
_logger.logWithLevel(
'$webLogPrefix 收到 switch_control 请求 - platform: $platform, deviceId: $requestDeviceId',
shouldLog: true,
);
// 🔥 关键判断:只有当是其他平台(web)请求时才弹窗
if (platform != null && platform.toString().toLowerCase() != 'app') {
// 🔥 验证设备ID是否与当前控制的 targetDevice 一致
final currentDeviceId = state.targetDevice?.deviceName;
debugPrint('$webLogPrefix 当前控制设备ID: $currentDeviceId');
if (currentDeviceId != null && requestDeviceId == currentDeviceId) {
debugPrint('$webLogPrefix 设备ID匹配');
// 🔥 只有当弹窗还没显示时才弹出,防止重复弹窗叠加
if (state.showPermissionRequestDialog) {
debugPrint('$webLogPrefix ⚠️ 弹窗已显示,忽略重复请求');
_logger.logWithLevel(
'$webLogPrefix ⚠️ 弹窗已显示,忽略重复请求',
shouldLog: true,
);
return;
}
debugPrint('$webLogPrefix 弹出权限请求对话框');
_logger.logWithLevel(
'$webLogPrefix 设备ID匹配,弹出权限请求对话框',
shouldLog: true,
);
if (!isClosed) {
emit(
state.copyWith(
showPermissionRequestDialog: true,
requestingDeviceId: requestDeviceId?.toString(),
requestingPlatform: platform.toString(),
// 🔥 标记为弹窗状态更新
updateType: 'permission_dialog',
),
);
}
} else {
debugPrint('$webLogPrefix ⚠️ 设备ID不匹配,忽略');
debugPrint(
'$webLogPrefix currentDeviceId: $currentDeviceId, requestDeviceId: $requestDeviceId',
);
_logger.logWithLevel(
'$webLogPrefix ⚠️ 设备ID不匹配,忽略请求',
shouldLog: true,
);
}
} else {
debugPrint('$webLogPrefix ℹ️ APP自己的请求回显或platform为空,忽略不弹窗');
_logger.logWithLevel(
'$webLogPrefix ℹ️ APP自己的请求回显,忽略',
shouldLog: true,
);
}
debugPrint('$webLogPrefix =========================================');
}
// 情况 3: 异地登录通知 - {"request":"have_logged_in",...}
else if (requestType == 'have_logged_in') {
// 🔥 关键修复:检查是否是自身 0x03 认证触发的
if (tcpClient.isOwnAuthTriggeredKick()) {
debugPrint('${_getTimePrefix()} 🛡️ [RemoteControl] 自身认证触发的 have_logged_in,忽略');
_logger.logWithLevel(
'${_getTimePrefix()} 🛡️ [RemoteControl] 自身认证触发的 have_logged_in,忽略',
shouldLog: true,
);
return;
}
debugPrint('${_getTimePrefix()} ⚠️ [RemoteControl] 检测到真正的异地登录');
_logger.logWithLevel(
'${_getTimePrefix()} ⚠️ [RemoteControl] 检测到异地登录,打开弹窗提示',
shouldLog: true,
);
// 🔥 只有当弹窗还没显示时才弹出
if (!isClosed && !state.showPermissionRequestDialog) {
emit(state.copyWith(showPermissionRequestDialog: true));
}
} else {
debugPrint('${_getTimePrefix()} ℹ️ [RemoteControl] 未知类型的 0x12 包,忽略');
_logger.logWithLevel(
'${_getTimePrefix()} ℹ️ [RemoteControl] 未知类型的 0x12 包,忽略',
shouldLog: true,
);
}
} catch (e) {
debugPrint('${_getTimePrefix()} ❌ [RemoteControl] 解析失败:$e');
_logger.logWithLevel(
'${_getTimePrefix()} ❌ [RemoteControl] 解析失败:$e',
shouldLog: true,
);
}
});
}
// 2. 开启 200ms 控制循环 (在进入遥控页面或点击“开始”时调用)
/* void startControlLoop() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
// 🔥 关键修复:每次循环都重新读取最新的 state
final currentEntity = state.controlEntity;
// 🔥 安全检查:无权限或急停时不发送
if (!state.hasPermission || state.isEmergency) {
// debugPrint('⚠️ [定时器] 跳过发送 - hasPermission=${state.hasPermission}, isEmergency=${state.isEmergency}');
return;
}
// debugPrint('⏰ [定时器] 发送控制指令 - originX=${currentEntity.originX}, originY=${currentEntity.originY}');
// _logger.logWithLevel('真实的发送的实体 - originX: ${currentEntity.originX}, originY: ${currentEntity.originY}');
_repository.sendControlMachineCmd(currentEntity);
});
emit(state.copyWith(status: RemoteControlStatus.controlling));
}*/
/// 🔥 双轴归零(静默):只清本地摇杆值并同步 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();
_timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
if (isClosed) {
// debugPrint('$logPrefix ❌ Cubit已关闭,取消定时器');
timer.cancel();
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,
originY: _currentOriginY,
chassisLift: state.controlEntity.chassisLift,
mowerSpeed: state.controlEntity.mowerSpeed,
ignitionStatus: state.controlEntity.ignitionStatus,
isEmergency: state.isEmergency,
);
// 常规安全检查
if (!state.hasPermission) {
// debugPrint('$logPrefix ⚠️ 无权限,跳过发送 - hasPermission=false');
return;
}
if (state.isEmergency) {
// debugPrint('$logPrefix 🚨 急停状态,跳过发送 - isEmergency=true');
return;
}
// 发送控制指令
//debugPrint(
// '$logPrefix 📤 发送控制指令: originX=${snapshot.originX}, originY=${snapshot.originY}, mower=${snapshot.mowerSpeed}, lift=${snapshot.chassisLift}, ignition=${snapshot.ignitionStatus}, emergency=${snapshot.isEmergency}',
// );
_repository.sendControlMachineCmd(snapshot);
//debugPrint('$logPrefix ✅ 控制指令已发送');
});
}
//通过方法拿最新 state
MachineControlStatusEntity _getCurrentControlEntity() {
// 🔥 关键修复:必须copyWith创建新对象,避免引用竞态条件
final entity = state.controlEntity;
// _logger.logWithLevel(
// '真实的发送的实体 - originX: ${entity.originX}, originY: ${entity.originY}',
// );
return entity.copyWith(); // 返回副本,不是引用
}
// 3. 更新摇杆数据
void updateJoystick(double x, double y) {
final updatedEntity = state.controlEntity.copyWith(
x: x.toInt(),
y: y.toInt(),
);
emit(state.copyWith(controlEntity: updatedEntity));
}
// 4. 更新功能开关 (比如割刀速度、灯光、点火等)
void updateFunction({int? mower, int? lift, int? ignition, bool? emergency}) {
// debugPrint('🔧 [updateFunction] 调用 - mower: $mower, lift: $lift, ignition: $ignition, emergency: $emergency');
// _logger.logWithLevel(
// '🔧 [updateFunction] 调用 - mower: $mower, lift: $lift, ignition: $ignition, emergency: $emergency',
// );
final updatedEntity = state.controlEntity.copyWith(
mower: mower,
lift: lift,
ignition: ignition,
emergency: emergency,
);
//emit(state.copyWith(controlEntity: updatedEntity));
emit(
state.copyWith(
controlEntity: updatedEntity,
isEmergency: emergency ?? state.isEmergency,
),
);
// debugPrint('>>> [updateFunction] 状态已更新到emit');
// _logger.logWithLevel('>>> [updateFunction] 状态已更新到emit');
}
void updateOriginY(int y) {
// debugPrint('📥 [updateOriginY] 被调用- y=$y');
// 🔥 页面销毁后可能还有迟到的 pointer 回调(CCJoystick 内部有 await),
// 不拦住就会在已 close 的对象上 emit。
if (isClosed) return;
// 🔥 参考Android版:直接更新成员变量
_currentOriginY = y;
// 同时更新state(用于UI显示)
final updatedEntity = state.controlEntity.copyWith(y: y);
emit(state.copyWith(controlEntity: updatedEntity));
// debugPrint('📝 [updateOriginY] state已更新- originY=$y');
// 🔥 倒车影像:用当前摇杆(左杆前后)实时驱动引导线
_syncReverseCameraFromJoystick();
}
void updateOriginX(int x) {
// debugPrint('📥 [updateOriginX] 被调用- x=$x');
// 🔥 同 updateOriginY:防页面销毁后迟到的摇杆回调打到已 close 的对象。
if (isClosed) return;
// 🔥 参考Android版:直接更新成员变量
_currentOriginX = x;
// 同时更新state(用于UI显示)
final updatedEntity = state.controlEntity.copyWith(x: x);
emit(state.copyWith(controlEntity: updatedEntity));
// debugPrint('📝 [updateOriginX] state已更新- originX=$x');
// 🔥 倒车影像:用当前摇杆(右杆转向)实时驱动引导线摆动
_syncReverseCameraFromJoystick();
}
/// 🔥 开发阶段:暂用本地摇杆数据合成倒车影像(不依赖嵌入式回传)。
///
/// 激活规则(摇杆输出范围 -100~100,上推为正=前进、下拉为负=倒车,右杆为左右转向):
/// - 倒车(y<0) 或 正在转向(x≠0) → 显示引导线;纯前进直行或松手归零 → 自动收起。
/// - originX(-100~100) → steeringAngle(-45°~45°),驱动引导线左右摆动。
/// - 摇杆无测距传感器 → rearDistanceCm=null(颜色固定为绿)。
/// 嵌入式就绪后,删除本方法及 updateOriginX/Y 里的调用,改回 startReverseCameraMock 接入真实数据源。
void _syncReverseCameraFromJoystick() {
if (isClosed) return;
final int y = _currentOriginY;
final int x = _currentOriginX;
// 🔥 智能激活:倒车 或 正在转向 都显示引导线(解决“只动右杆不显示”)
final bool guideActive = y < 0 || x != 0;
final int gear = guideActive ? 2 : (y > 0 ? 1 : 0);
final double steering = (x.clamp(-100, 100) / 100.0) * 45.0;
final double speed = (y.abs().clamp(0, 100) / 100.0);
emit(
state.copyWith(
reverseCamera: ReverseCameraData(
gear: gear,
steeringAngle: steering,
speed: speed,
rearDistanceCm: null,
),
),
);
}
/// 🔥 安全方法:同时清零双轴,确保只发送一次完全停止指令
Future<void> stopAllMovement() async {
// 🔥 cubit 已 close(退登录/销毁单例)时:只清本地值就返回。
// 本方法多处被 unawaited() 调走(后台挂起 / 失权 / 松手),它的 await 链
// 比页面活得久,不拦住就会在已关闭对象上 emit,抛未捕获的 StateError。
if (isClosed) {
_currentOriginX = 0;
_currentOriginY = 0;
return;
}
// debugPrint(
// '🛑 [stopAllMovement] 开始执行- 当前成员变量: originX=$_currentOriginX, originY=$_currentOriginY',
// );
// 🔥 参考Android版:直接清零成员变量
_currentOriginX = 0;
_currentOriginY = 0;
// 🔥 关键修复:先暂停定时器,防止定时器在停止期间发送旧的运动指令
_timer?.cancel();
_timer = null; // 🔥 彻底清空,防止重复启用
final updatedEntity = state.controlEntity.copyWith(x: 0, y: 0);
// 一次性更新双轴(即使无权限也要更新本地state)
emit(state.copyWith(controlEntity: updatedEntity));
// 🔥 摇杆归零:同步倒车影像为非倒车态(引导线自动隐藏)
_syncReverseCameraFromJoystick();
// 🔥 安全底线:停止指令必须无视权限强制发送!
// debugPrint('🛑 [stopAllMovement] 双轴归零,发送停止指令- originX=0, originY=0');
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));
if (_controlWanted) {
// 🔥 热重启不清轴:上面这 ~450ms 里用户完全可能又把摇杆压回去了
// (updateOriginX/Y 会把非零值写回 _currentOriginX/Y)。若在这里再
// 调 _zeroAxesQuietly(),就会抹掉他手指正压着的目标量;而 UI 侧的
// 去重基准(HUD 的 _lastDriveY / 旧版摇杆的 _lastSentY)仍是那个非零值,
// 于是“手指明明压在摇杆上、车却不动”,得再推一下才恢复。
// 跨会话残留值的清理只在冷启动入口做(那里 resetAxes 默认 true)。
startControlLoop(resetAxes: false);
} else {
debugPrint('🛑 [stopAllMovement] 已不在控制态,不重启循环');
}
// debugPrint('>>> [stopAllMovement] 执行完成');
}
/// 🔥 统一方法:连续发送停止指令,彻底清空TCP缓冲区。
///
/// 返回真正写入 socket 的帧数:sendRaw 在未连接时会直接失败,
/// 调用方据此判断“停止指令是不是真的发出去了”(安全路径必须知道这一点)。
Future<int> _sendStopCommandRepeatedly(
MachineControlStatusEntity stopEntity,
) async {
// debugPrint(
// '🛑 [紧急停止] 开始连续发送 10 次停止指令- originX=${stopEntity.originX}, originY=${stopEntity.originY}',
// );
// 🔥 本方法不做任何节流:停止帧必须一帧不少地发完。(旧注释写的“_lastStopTime
// 已在 stopAllMovement 里设置”是假信息 —— 全项目并没有这个变量,勿据此加节流。)
int sentCount = 0;
// 🔥 关键修复:每次发送间隔5ms,避免TCP合并/丢弃
for (int i = 0; i < 20; i++) {
if (_repository.sendControlMachineCmd(stopEntity)) sentCount++;
if (i % 5 == 0) {
// debugPrint('🛑 [紧急停止] 已发送第${i + 1}次');
}
if (i < 19) {
await Future.delayed(const Duration(milliseconds: 5));
}
}
// 🔥 延迟后再发10次(双重保险,对抗网络抖动)
await Future.delayed(const Duration(milliseconds: 100));
for (int i = 0; i < 10; i++) {
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() {
final willLock = !state.isLocked;
emit(state.copyWith(isLocked: willLock));
// 🔥 锁定瞬间归零 + 连发停止帧(与失权 / 急停同一道理):
// IgnorePointer 只拦住以后的推杆,手指正压在摇杆上那一划的
// _currentOriginX/Y 仍会留在单例上;而设备只认“最后收到的运动帧”,
// 光拦不发不会让已在跑的车停下。
if (willLock) {
unawaited(stopAllMovement());
}
}
void togglePermissionDialog(bool show) {
emit(state.copyWith(showPermissionRequestDialog: show));
}
void requestControlPermission() {
// 1. 关闭弹窗
emit(state.copyWith(showPermissionRequestDialog: false));
}
void toggleLeftPip() => emit(state.copyWith(showLeftPip: !state.showLeftPip));
void toggleRightPip() =>
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));
}
// 🔥 全局护栏: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(); // 🔥 取消订阅倒车影像数据流
_reverseCameraSource?.stop();
return super.close();
}
void updateChassisLift(int i) {}
/// 🔥 启动倒车影像数据流(开发期用 Mock 造假数据,机器未开也能联调动画)。
/// 由遥控页 initState 调用;嵌入式就绪后只需替换这里传入的实现类。
void startReverseCameraMock() {
if (_reverseCameraSub != null) return; // 已启动,避免重复订阅
final source = MockReverseCameraSource();
_reverseCameraSource = source;
_reverseCameraSub = source.dataStream.listen((ReverseCameraData data) {
if (isClosed) return;
emit(state.copyWith(reverseCamera: data));
});
source.start();
debugPrint('🚗 [倒车影像] Mock 数据源已启动(嵌入式真数据接入前的联调用)');
}
/// 🔥 停止倒车影像数据流(退出遥控页调用,避免定时器空转)。
void stopReverseCameraMock() {
_reverseCameraSub?.cancel();
_reverseCameraSub = null;
_reverseCameraSource?.stop();
_reverseCameraSource = null;
emit(state.copyWith(reverseCamera: const ReverseCameraData.idle()));
debugPrint('🚗 [倒车影像] 数据源已停止');
}
void updateEmergency(bool on) {
// debugPrint('🚨 [急停] ${on ? "触发急停!" : "解除急停"}');
// 🔥 先归零双轴:急停只是让循环“跳过发送”,_currentOriginX/Y 仍原样留着;
// 不归零的话,updateEmergency(false) 解除的那一 tick 就会按急停前的推量
// 原地复活(“急停→解除→车又自己走”)。触发与解除都清,语义变成
// “一旦碰过急停就必须重新推杆”。下面的全零急停帧会把已在跑的车压停。
_zeroAxesQuietly();
// ① 先置状态:急停时让 200ms 控制循环下一 tick 立刻停发运动指令;
// 解除时循环恢复正常发送。
updateFunction(emergency: on);
// ② 再绕过控制循环的 isEmergency 门控,直发带急停位的指令帧(双重保险):
// - 按下:连发 emergency=1 + 全零,让设备明确收到「急停」,而不只靠「收不到指令」。
// - 取消:连发 emergency=0 + 全零,让设备明确「解除急停、恢复正常」。
_sendEmergencyFrames(emergency: on);
}
/// 双重保险:绕过控制循环门控,直接连续补发若干帧带急停位的指令。
///
/// [emergency] true=显式急停(emergency 位=1),false=显式恢复正常(emergency 位=0)。
/// 运动量一律全零,避免误带旧摇杆值;5ms 高频补发防丢包(安全路径,无视权限)。
Future<void> _sendEmergencyFrames({required bool emergency}) async {
final frame = MachineControlStatusEntity(
originX: 0,
originY: 0,
chassisLift: 0,
mowerSpeed: 0,
ignitionStatus: 0,
isEmergency: emergency,
);
int sentFrames = 0;
for (int i = 0; i < 10; i++) {
if (isClosed) return;
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) {
// 1. 关闭弹窗(立即关闭,防止重复点击)
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(' ❌ 错误类型: ${e.runtimeType}');
_logger.logWithLevel(
' ❌ TCP权限响应指令发送失败(不抛出,保证弹窗能关闭): $e',
level: 'ERROR',
shouldLog: true,
);
debugPrint('${_getTimePrefix()} ====权限弹窗响应提前结束====');
return;
}
// 3. 根据用户选择更新控制状态
if (agreed) {
emit(
state.copyWith(
hasPermission: false,
showPermissionRequestDialog: false,
),
);
// 🔥 主动让权同样要归零 + 全停(理由同 0x12 失权分支):用户“同意”的瞬间
// 可能手指还压在摇杆上,不归零则下次拿回控制权时会按旧推量复活。
unawaited(stopAllMovement());
} else {
// 用户拒绝 APP 继续保持控制权
// 🔥 必须同时设置 showPermissionRequestDialog: false,防止状态回退
emit(
state.copyWith(hasPermission: true, showPermissionRequestDialog: false),
);
}
debugPrint('${_getTimePrefix()} ====权限弹窗响应结束=====');
}
/// 请求控制权限(HTTP 查询接口 /forward/device/remoteControl)
///
/// [grabControl] true = 查询后若无权限立即发 TCP 0x12 switch_control 抢占(手动点击时使用);
/// false = 仅查询,把接口 remoteControl 结果如实同步到 UI(进入页面时使用)
Future<void> requestControlPermissionS(
String deviceName,
String deviceId, {
String source = '自动',
bool grabControl = false,
}) async {
final timePrefix = _getTimePrefix();
final logPrefix = '$timePrefix 🔑 [RemoteControl] [请求权限接口-$source]';
debugPrint('$logPrefix =========================================');
debugPrint('$logPrefix 开始请求控制权 (grabControl=$grabControl)');
debugPrint('$logPrefix deviceName: $deviceName');
debugPrint('$logPrefix platform: $deviceId');
debugPrint(
'$logPrefix 当前状态 hasPermission=${state.hasPermission}, showDialog=${state.showPermissionRequestDialog}',
);
_logger.logWithLevel(
'$logPrefix 开始请求控制权- deviceName: $deviceName, platform: $deviceId',
shouldLog: true,
);
// 1. 只有当弹窗不是因Web 端请求权限而显示时,才关闭弹窗
// 避免 Web 端请求权限的弹窗被自动关闭(一闪而过的问题)
if (state.requestingPlatform == null) {
debugPrint('$logPrefix 关闭权限请求弹窗 (非Web端触发)');
emit(state.copyWith(showPermissionRequestDialog: false));
} else {
debugPrint('$logPrefix 保留弹窗 (Web端请求触发)');
}
// 2. 调用 UseCase 获取 HTTP 返回的完整权限信息
debugPrint('$logPrefix 调用 HTTP 接口查询权限状态..');
final result = await _requestControlPermissionUseCase(
RequestControlPermissionParams(
deviceName: deviceName,
deviceId: deviceId,
),
);
// 3. 处理结果
result.fold(
(failure) {
final failLogPrefix =
'${_getTimePrefix()} 🔑 [RemoteControl] [请求权限接口-$source]';
debugPrint('$failLogPrefix APP请求控制权限失败: ${failure.message}');
_logger.logWithLevel(
'$failLogPrefix APP请求控制权限失败: ${failure.message}',
shouldLog: true,
);
// 🔥 修复:HTTP请求失败时不要自动打开弹窗,避免形成循环
// emit(state.copyWith(showPermissionRequestDialog: true));
debugPrint('$failLogPrefix HTTP请求失败,不自动打开弹窗');
},
(permissionInfo) async {
final bool hasPermission =
permissionInfo['hasPermission'] as bool? ?? false;
final String? owner = permissionInfo['owner'] as String?;
final successLogPrefix =
'${_getTimePrefix()} 🔑 [RemoteControl] [请求权限接口-$source]';
debugPrint(
'$successLogPrefix APP HTTP返回 - hasPermission=$hasPermission, owner=$owner',
);
_logger.logWithLevel(
'$successLogPrefix APP HTTP返回 - hasPermission=$hasPermission, owner=$owner',
shouldLog: true,
);
// 🔥 仅查询模式:不抢占,把接口 remoteControl 结果如实同步到 UI
if (!grabControl) {
debugPrint(
'$successLogPrefix 👀 仅查询模式,不发送TCP抢占,直接同步 remoteControl=$hasPermission',
);
_logger.logWithLevel(
'$successLogPrefix 👀 仅查询模式,直接同步 remoteControl=$hasPermission',
shouldLog: true,
);
if (state.hasPermission != hasPermission) {
emit(state.copyWith(hasPermission: hasPermission));
}
}
// 🔥 抢占模式: 如果没有权限 或owner为null,则发送TCP 请求
else if (!hasPermission || owner == null) {
debugPrint('$successLogPrefix ⚠️ APP无权限或owner为null,发送TCP请求...');
_logger.logWithLevel(
'$successLogPrefix ⚠️ APP无权限或owner为null,发送TCP请求',
shouldLog: true,
);
// 🔥 发送TCP 0x12 权限请求指令
_repository.sendTcpPermissionRequest(deviceName);
// 等待 TCP 回包(通过监听器更新状态
debugPrint('$successLogPrefix 📡 TCP请求已发送,等待回包确认');
} else {
debugPrint('$successLogPrefix APP已有权限,直接更新UI');
_logger.logWithLevel(
'$successLogPrefix APP已有权限,直接更新UI',
shouldLog: true,
);
debugPrint(
'$successLogPrefix 当前 hasPermission 状态: ${state.hasPermission}',
);
// 🔥 正确的状态更新:使用最新状态
if (state.hasPermission != true) {
debugPrint('$successLogPrefix 🔄 更新 hasPermission = true');
emit(state.copyWith(hasPermission: true));
debugPrint('$successLogPrefix ✅ hasPermission 状态已更新为 true');
} else {
debugPrint('$successLogPrefix ⚠️ hasPermission 已是 true,无需更新');
// 强制触发UI刷新:通过临时改变其他属性
emit(state.copyWith(ping: state.ping + 1));
emit(state.copyWith(ping: state.ping));
}
}
},
);
debugPrint(
'${_getTimePrefix()} 🔑 [RemoteControl] [请求权限接口-$source] =========================================',
);
}
/// 🔥 退出遥控页时重置“会话级”状态(权限 / 弹窗 / 锁定 / 急停),避免单例残留
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, isLocked=false, isEmergency=false',
);
_logger.logWithLevel(
'🔑 [RemoteControl] 已重置会话态 hasPermission=false, isLocked=false, isEmergency=false',
shouldLog: true,
);
}
}
/// 🔥 重置弹窗状态 - 在弹窗关闭后调用(已简化,不再需要标志位)
void resetPermissionCoolDown() {
// 标志位已移除,此方法保留以保持向后兼容性
}
Future<void> confirmPermissionResponse(
String deviceName,
String platform,
bool agreed,
) async {
final timePrefix = _getTimePrefix();
final logPrefix = '$timePrefix 🔑 [RemoteControl] [权限确认]';
debugPrint('$logPrefix ========进入TCP发送=================================');
debugPrint('$logPrefix ⚡️ confirmPermissionResponse 方法被调用');
debugPrint('$logPrefix 用户操作: ${agreed ? "同意" : "拒绝"}');
debugPrint('$logPrefix deviceName: $deviceName');
debugPrint('$logPrefix platform: $platform');
respondPermission(agreed, deviceName);
// 2. 调用 HTTP 接口获取最终权限状态
debugPrint('$logPrefix 📡 调用 HTTP 接口确认最终权限状态..');
/* final result = await _requestControlPermissionUseCase(
RequestControlPermissionParams(
deviceName: deviceName,
deviceId: platform,
),
);*/
// 3. 根据 HTTP 返回的真实权限状态更新UI
/* result.fold(
(failure) {
final failLogPrefix = '${_getTimePrefix()} 🔑 [RemoteControl] [权限确认]';
debugPrint('$failLogPrefix APP HTTP请求失败: ${failure.message}');
debugPrint('$failLogPrefix ⚠️ 保持当前状态不变');
},
(permissionInfo) {
final bool hasPermission =
permissionInfo['hasPermission'] as bool? ?? false;
final String? owner = permissionInfo['owner'] as String?;
final successLogPrefix =
'${_getTimePrefix()} 🔑 [RemoteControl] [权限确认]';
debugPrint(
'$successLogPrefix APP HTTP返回真实权限状态 hasPermission=$hasPermission, owner=$owner',
);
debugPrint(
'$successLogPrefix 🔄 正在更新UI - hasPermission: ${state.hasPermission} -> $hasPermission',
);
// 🔥 直接用HTTP 返回的权限状态覆盖
// emit(state.copyWith(hasPermission: hasPermission));
debugPrint(
'$successLogPrefix 📊 UI已同步完成- 当前 hasPermission=$hasPermission',
);
},
);*/
debugPrint(
'${_getTimePrefix()} 🔑 [RemoteControl] [权限确认] =========================================',
);
}
/// 发送底盘指令
void sendChassisCommand(int i) {
// // debugPrint('>>> [底盘指令] ${i}');
// _logger.logWithLevel('>>> [底盘指令] ${i}');
updateFunction(lift: i);
}
/// 发送割刀指令
void sendMowerCommand(int i) {
// // debugPrint('>>> [割刀指令] ${i}');
// _logger.logWithLevel('>>> [割刀指令] ${i}');
updateFunction(mower: i);
}
/// 发送点火指令
void sendFireCommand(int i) {
//void updateFunction({int? mower, int? lift, int? ignition, bool? emergency})
// updateFunction(mower:0, lift: 0, ignition: i, emergency: false);
// debugPrint('>>> [点火指令] ${i}');
// _logger.logWithLevel('>>> [点火指令] ${i}');
updateFunction(ignition: i);
}
// 发送障碍物识别指令
void toggleObstacleRecognition() {
emit(
state.copyWith(obstacleRecognitionFlag: !state.obstacleRecognitionFlag),
);
}
void toggleTopLeftExpand() {
emit(state.copyWith(topRightIsExpanded: !state.topRightIsExpanded));
}
/// 检查设备状态推送是否活跃(5秒内有推送视为活跃)
bool isStatusPushActive() {
if (!state.hasReceivedStatusPush) return false;
if (_lastStatusPushTime == null) return false;
// 超过5秒未收到新推送,视为推送已中断
return DateTime.now().difference(_lastStatusPushTime!) < const Duration(seconds: 5);
}
Future<int> getNetworkDelay() async {
try {
// 直接 Ping 你的服务器IP
final ping = Ping('1.95.137.212', count: 1, timeout: 1);
// 等待一次结果
final data = await ping.stream.first;
if (data.response != null && data.response!.time != null) {
// 返回和cmd 一样的毫秒值
return data.response!.time!.inMilliseconds;
} else {
return 9999;
}
} catch (e) {
return 9999;
}
}
//app退出远程遥控界面释放权限(type=2),退出机器人详情页释放权限(type=1)
Future<bool> releasePermission({
required String deviceId,
required int type,
}) async {
return await _repository.releasePermission(deviceId: deviceId, type: type);
}
/// 🔥 设置待控制的设备(从机器人列表点击进入时调用)
void setTargetDevice(DeviceEntity device) async {
debugPrint('🎯 [RemoteControl] ========== 开始设置目标设备 ==========');
debugPrint('🎯 [RemoteControl] 设备名称: ${device.deviceName}');
debugPrint('🎯 [RemoteControl] 当前TCP状态: ${tcpClient.isConnected ? "已连接" : "未连接"}');
// 🔥 关键修复:切换设备时清空所有缓存,防止显示上一个设备的数据
_clearAllCache();
debugPrint('✅ [RemoteControl] 已清空所有缓存数据');
debugPrint('📦 [RemoteControl] 更新targetDevice状态');
emit(state.copyWith(targetDevice: device));
debugPrint('✅ [RemoteControl] targetDevice状态已更新');
// 🔥 关键修复:切换设备时重置 DeviceStatusBloc 状态并清空缓存,
// 清除上一个设备的坐标/状态,防止路径规划显示旧车位置(与 switchDevice 对齐)
deviceStatusBloc.add(DeviceStatusReset());
// 🔥 MQTT机器状态订阅:切换设备时订阅新设备的topic(与DevicesCubit.switchDevice相同逻辑)
debugPrint('📡 [RemoteControl] 开始MQTT机器状态订阅: ${device.deviceName}');
deviceStatusBloc.startMqttRealtimeListening(device.deviceName);
// 🔥 关键修复:TCP 已在登录时建立,设备切换只用 HTTP
// 不再创建新 TCP 连接,避免重复发送 0x03 触发服务端推送 have_logged_in
if (tcpClient.isConnected) {
debugPrint('✅ [RemoteControl] TCP已连接,使用HTTP切换设备: ${device.deviceName}');
_deviceRepository.switchDevice("app", device.deviceName).then((result) {
result.fold(
(failure) {
debugPrint('❌ [RemoteControl] HTTP切换设备失败: ${failure.message}');
},
(success) {
debugPrint('✅ [RemoteControl] HTTP切换设备成功, code: $success');
},
);
});
} else {
// TCP未连接(登录时的初始化可能失败或还在进行中)
// 兜底:使用 connectBySwitch 建立连接
debugPrint('🔌 [RemoteControl] TCP未连接,兜底调用 connectBySwitch');
try {
await tcpClient.connectBySwitch(
host: TCPConsts.TCP_IP,
port: TCPConsts.TCP_PORT,
deviceName: device.deviceName,
);
debugPrint('✅ [RemoteControl] TCP兜底连接成功!');
} catch (e) {
debugPrint('❌ [RemoteControl] TCP兜底连接失败: $e');
}
}
debugPrint('🎯 [RemoteControl] ========== 目标设备设置完成 ==========');
}
/// 🔥 清除待控制设备(退出远程控制页时调用)
void clearTargetDevice() {
// debugPrint('🧹 [RemoteControl] 清除待控制设备');
// _logger.logWithLevel('🧹 [RemoteControl] 清除待控制设备');
// 🔥 关键修复:退出页面时清空所有缓存,防止数据滞留
_clearAllCache();
debugPrint('✅ [RemoteControl] 已清空所有缓存数据');
emit(state.copyWith(targetDevice: null));
}
/// 🔥 获取设备
DeviceEntity? getSelectedDevice() {
return state.targetDevice;
}
/// 🔥 清空所有缓存数据(解决数据滞留和跨设备数据显示问题)
void _clearAllCache() {
_cacheVoltage = null;
_cacheBattery = null;
_cacheCtrlMode = null;
_cachePing = null;
_lastUiUpdateTime = null;
_lastStatusPushTime = null; // 重置推送时间
// 🔥 同时重置 UI 状态为初始值
if (!isClosed) {
emit(
state.copyWith(
runningStatusModel: state.runningStatusModel.copyWith(
voltage: '--',
battery: '--',
controlMode: '--',
),
battery: 0,
ping: null,
hasReceivedStatusPush: false, // 重置推送接收标志
),
);
}
debugPrint('🗑️ [RemoteControl] 缓存已清空 - voltage, battery, controlMode, ping');
}
/// 🔥 退出登录时清空所有状态(比 _clearAllCache 更彻底,重置全部 state 字段)
void clearAll() {
_timer?.cancel();
_timer = null;
_resumeTimer?.cancel();
_resumeTimer = null;
// 🔥 退出登录:控制意图与后台挂起位一并清掉,否则回前台会恢复一个
// 已不属于任何页面的控制循环。
_controlWanted = false;
_suspendedByLifecycle = false;
_cacheVoltage = null;
_cacheBattery = null;
_cacheCtrlMode = null;
_cachePing = null;
_lastUiUpdateTime = null;
_lastStatusPushTime = null;
_currentOriginX = 0;
_currentOriginY = 0;
if (!isClosed) {
emit(RemoteControlState(
controlEntity: MachineControlStatusEntity(),
runningStatusModel: RunningStatusModel(),
));
}
debugPrint('🗑️ [RemoteControl] clearAll - 所有状态已重置为初始值');
}
}