1606 lines
68 KiB
Dart
1606 lines
68 KiB
Dart
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 - 所有状态已重置为初始值');
|
||
}
|
||
|
||
}
|