From 178cd2533c9dabd81d2488b4522ed80bc0c57633 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Tue, 20 Oct 2026 16:45:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=8D=93=E6=9D=83=E9=99=90=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 12 ++ lib/components/device_status_modal.dart | 30 +-- .../presentation/bloc/device_status_bloc.dart | 55 +++++- .../domain/usecase/diff_steer_usecase.dart | 48 ++--- .../widgets/center_control_area.dart | 9 +- .../widgets/driving_guide_overlay.dart | 186 ++++++++++++++++++ .../pages/device_status_page.dart | 15 ++ .../cc_ui_kit/lib/src/cc_expand_slider.dart | 21 +- .../android/src/main/AndroidManifest.xml | 18 ++ 9 files changed, 340 insertions(+), 54 deletions(-) create mode 100644 lib/features/remote_control/presentation/widgets/driving_guide_overlay.dart create mode 100644 packages/usb_camera_android/android/src/main/AndroidManifest.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3266c0a6..e20e4c5e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + + + + + + { ).translate('running_status.target_speed') + "\n(rpm)", ), - _buildTableCell('--'), - _buildTableCell('--'), + _buildTableCell(status.leftTargetSpeed.toStringAsFixed(0)), + _buildTableCell(status.rightTargetSpeed.toStringAsFixed(0)), ], ), TableRow( @@ -243,8 +243,8 @@ class _DeviceStatusModalState extends State { ).translate('running_status.measure_speed') + "\n(rpm)", ), - _buildTableCell('--'), - _buildTableCell('--'), + _buildTableCell(status.leftMeasureSpeed.toStringAsFixed(0)), + _buildTableCell(status.rightMeasureSpeed.toStringAsFixed(0)), ], ), TableRow( @@ -255,8 +255,8 @@ class _DeviceStatusModalState extends State { ).translate('running_status.current') + "(A)", ), - _buildTableCell('--'), - _buildTableCell('--'), + _buildTableCell(status.leftCurrent.toStringAsFixed(2)), + _buildTableCell(status.rightCurrent.toStringAsFixed(2)), ], ), TableRow( @@ -267,8 +267,8 @@ class _DeviceStatusModalState extends State { ).translate('running_status.motor_temp') + "(°C)", ), - _buildTableCell('--'), - _buildTableCell('--'), + _buildTableCell(status.leftMotorTemp.toStringAsFixed(1)), + _buildTableCell(status.rightMotorTemp.toStringAsFixed(1)), ], ), ], @@ -318,7 +318,7 @@ class _DeviceStatusModalState extends State { ).translate('running_status.pitch_angle') + "(°)", ), - _buildTableCell('--'), + _buildTableCell(status.pitch.toStringAsFixed(2)), ], ), TableRow( @@ -329,7 +329,7 @@ class _DeviceStatusModalState extends State { ).translate('running_status.roll_angle') + "(°)", ), - _buildTableCell('--'), + _buildTableCell(status.roll.toStringAsFixed(2)), ], ), TableRow( @@ -351,7 +351,7 @@ class _DeviceStatusModalState extends State { ).translate('running_status.battery') + "(%)", ), - _buildTableCell('--'), + _buildTableCell(status.battery), ], ), TableRow( @@ -362,7 +362,7 @@ class _DeviceStatusModalState extends State { ).translate('running_status.chip_temp') + "(°C)", ), - _buildTableCell('--'), + _buildTableCell(status.chipTemp.toStringAsFixed(1)), ], ), TableRow( @@ -383,7 +383,11 @@ class _DeviceStatusModalState extends State { context, ).translate('running_status.control_mode'), ), - _buildTableCell('--'), + _buildTableCell( + AppLocalizations.of(context).translate( + _getControlModeKey(int.tryParse(status.controlMode) ?? 0), + ), + ), ], ), TableRow( diff --git a/lib/features/devices/presentation/bloc/device_status_bloc.dart b/lib/features/devices/presentation/bloc/device_status_bloc.dart index d16a4894..2a3ace3d 100644 --- a/lib/features/devices/presentation/bloc/device_status_bloc.dart +++ b/lib/features/devices/presentation/bloc/device_status_bloc.dart @@ -39,6 +39,8 @@ class DeviceStatusBloc extends Bloc { // 🔥 MQTT机器状态订阅(车辆实时+定位,替代TCP 0x02) StreamSubscription? _mqttVehicleSubscription; StreamSubscription? _mqttLocationSubscription; + // 🔥 主机实时消息订阅(device/{sn}/host/realTime/message,新增,纯附加) + StreamSubscription? _mqttHostSubscription; // 🔥 节流相关:500ms节流控制0x02数据推送频率 Timer? _throttleTimer; @@ -301,6 +303,48 @@ class DeviceStatusBloc extends Bloc { } }); + // 🔥 主机实时消息(device/{sn}/host/realTime/message):新值到达即 copyWith 覆盖,复用 500ms 节流 emit + _mqttHostSubscription ??= _mowerRealtimeRepo.hostRealtimeStream.listen(( + host, + ) { + final base = _cachedStatus ?? RunningStatusEntity(); + _cachedStatus = base.copyWith( + voltage: host.batteryVoltage, + battery: host.batteryPercentage.toString(), + leftCurrent: host.leftMotorCurrent, + rightCurrent: host.rightMotorCurrent, + leftMotorTemp: host.leftMotorTemperature, + rightMotorTemp: host.rightMotorTemperature, + chipTemp: host.mainTemperature, + pitch: host.pitch, + roll: host.roll, + leftTargetSpeed: host.leftTargetRpm, + rightTargetSpeed: host.rightTargetRpm, + leftMeasureSpeed: host.leftMeasuredRpm, + rightMeasureSpeed: host.rightMeasuredRpm, + controlMode: + host.controlMode.isNotEmpty ? host.controlMode : base.controlMode, + workingArea: host.totalWorkArea.toString(), + // 🔥 供全功能模式(pv_control)展示:累计作业里程 + host 已推数据标记 + totalWorkDistance: host.totalWorkDistance, + hostDataReceived: true, + ); + + // 🔥 host 无坐标:GPS 缺省时用当前经纬度占位,保证能即时刷新(不覆盖 location 已写入的真坐标) + _cachedGps ??= GPSEntity(_cachedStatus!.latitude, _cachedStatus!.longitude); + + debugPrint( + '${LogTime.now()} 🛰️ [MQTT-主机实时] 已合并 → 电量=${host.batteryPercentage}% 电压=${host.batteryVoltage}V 控制模式=${host.controlMode}', + ); + + // 500ms 节流:与 location/车辆实时 发射链路一致 + if (_throttleTimer == null || !_throttleTimer!.isActive) { + _throttleTimer = Timer(_throttleDuration, () { + _emitCachedStatus(); + }); + } + }); + debugPrint('${LogTime.now()} ✅ [DeviceStatusBloc] MQTT机器状态监听已启动: $deviceSn'); _logger.log('✅ [DeviceStatusBloc] MQTT机器状态监听已启动: $deviceSn'); } @@ -434,10 +478,13 @@ class DeviceStatusBloc extends Bloc { } // 🔥 节流发射:500ms到期后发射缓存的最新数据 + // 🔥 放宽:允许仅有机器状态(host 先到、GPS 未到)时也能刷新 UI void _emitCachedStatus() { - if (_cachedStatus != null && _cachedGps != null && !isClosed) { - debugPrint('📤 [节流发射] 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) yaw=${_cachedStatus!.yaw} | ${DateTime.now().toString().substring(11, 19)}'); - emit(DeviceStatusUpdated(_cachedStatus!, _cachedGps!)); + if (_cachedStatus != null && !isClosed) { + final gps = _cachedGps ?? + GPSEntity(_cachedStatus!.latitude, _cachedStatus!.longitude); + debugPrint('📤 [节流发射] 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${gps.latitude}, ${gps.longitude}) yaw=${_cachedStatus!.yaw} 控制模式=${_cachedStatus!.controlMode} | ${DateTime.now().toString().substring(11, 19)}'); + emit(DeviceStatusUpdated(_cachedStatus!, gps)); } } @@ -569,6 +616,8 @@ class DeviceStatusBloc extends Bloc { _mqttVehicleSubscription = null; _mqttLocationSubscription?.cancel(); _mqttLocationSubscription = null; + _mqttHostSubscription?.cancel(); + _mqttHostSubscription = null; // 🔥 清理节流timer和缓存 _throttleTimer?.cancel(); diff --git a/lib/features/remote_control/domain/usecase/diff_steer_usecase.dart b/lib/features/remote_control/domain/usecase/diff_steer_usecase.dart index edaf3cce..66d4a42e 100644 --- a/lib/features/remote_control/domain/usecase/diff_steer_usecase.dart +++ b/lib/features/remote_control/domain/usecase/diff_steer_usecase.dart @@ -1,36 +1,28 @@ +/// 差速转向算法:摇杆坐标 -> 线速度/角速度(物理量) +/// +/// 协议变更(2026-10):0x00 指令不再下发左右轮速(int16), +/// 改为下发线速度(m/s)、角速度(rad/s) 的 double 值, +/// 速度到轮速的运动学换算改由机器端完成。 class DiffSteerUseCase { - static const int polarityHigh = 0; - static const int polarityLow = 1; + /// 线速度满量程(m/s),以后按车型配置 + static const double maxLinearSpeed = 1.0; - double _turnSpeedScale = 1500 / 100; - double _forwardSpeedScale = 3000 / 100; - int _speedAmplLimit = 3000; + /// 角速度满量程(rad/s),以后按车型配置 + static const double maxAngularSpeed = 2.0; - int _leftWheelDir = polarityHigh; - int _rightWheelDir = polarityHigh; - int _xDir = polarityHigh; - int _yDir = polarityHigh; + /// 摇杆满格值(CCJoystick 输出范围 ±100) + static const int stickFullScale = 100; - Map calculate(int x, int y) { - int turnSpeed = _signOperator((x * _turnSpeedScale).toInt(), _xDir); - int forwardSpeed = _signOperator((y * _forwardSpeedScale).toInt(), _yDir); + /// 摇杆坐标 (x, y) -> {'linear': 线速度 m/s, 'angular': 角速度 rad/s} + Map calculate(int x, int y) { + // 摇杆原始值先归一化为 0~1 比例,再乘满量程物理量 + double linear = (y / stickFullScale) * maxLinearSpeed; + double angular = (x / stickFullScale) * maxAngularSpeed; - // 倒车补偿 - int adjustedTurn = (forwardSpeed >= 0 ? turnSpeed : -turnSpeed); + // 倒车补偿:倒车时转向反向,保持与旧轮速版一致的操作手感 + // (若联调确认机器端已做该补偿,删除本行) + if (linear < 0) angular = -angular; - int left = _signOperator(forwardSpeed + adjustedTurn, _leftWheelDir); - int right = _signOperator(forwardSpeed - adjustedTurn, _rightWheelDir); - - return { - 'left': left.clamp(-_speedAmplLimit, _speedAmplLimit), - 'right': right.clamp(-_speedAmplLimit, _speedAmplLimit), - }; + return {'linear': linear, 'angular': angular}; } - - int _signOperator(int num, int polarity) => - polarity != polarityHigh ? -num : num; - - - - } diff --git a/lib/features/remote_control/presentation/widgets/center_control_area.dart b/lib/features/remote_control/presentation/widgets/center_control_area.dart index 57d4c226..505c9ead 100644 --- a/lib/features/remote_control/presentation/widgets/center_control_area.dart +++ b/lib/features/remote_control/presentation/widgets/center_control_area.dart @@ -118,10 +118,11 @@ class CenterControlArea extends StatelessWidget { // 🔥 门槛检查:与摇杆一致,未收到 TCP 0x02 推送时禁止操作并提示。 // 胶囊 Toast 全局去重、自动消失,适配 StatelessWidget 无实例状态。 - if (!context.read().isStatusPushActive()) { - CapsuleToast.show('暂未收到该机器的推送,不能控制', showCheck: false); - return; - } + // 🔥 临时关闭,便于机器未开时用 Mock 假数据联调倒车影像;联调后取消注释恢复,勿删。 + // if (!context.read().isStatusPushActive()) { + // CapsuleToast.show('暂未收到该机器的推送,不能控制', showCheck: false); + // return; + // } // 🔥 使用deviceKey进行判断,不依赖显示文本 switch (deviceKey) { diff --git a/lib/features/remote_control/presentation/widgets/driving_guide_overlay.dart b/lib/features/remote_control/presentation/widgets/driving_guide_overlay.dart new file mode 100644 index 00000000..8e7e5386 --- /dev/null +++ b/lib/features/remote_control/presentation/widgets/driving_guide_overlay.dart @@ -0,0 +1,186 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// 主屏“行驶辅助线”覆盖层:中央绿色行进箭头 + 两侧汇聚车道线(外黄内绿)。 +/// +/// 从 pv 全功能 HUD 的假 AR 导向层抽离而来,可独立复用: +/// - 自带流动动画(绿色箭头由底部向远处“递进流动”,无需外部驱动); +/// - 脊柱以画面底部正中为固定顶点,随 [steering](度,右正左负,-45~45) +/// 做等曲率圆弧弯曲;[steering]=0 时为笔直隧道; +/// - 采样用连续插值,静止即严格直线(杜绝整型下标量化导致的折线抖动)。 +/// +/// 纯装饰层,调用方用 `IgnorePointer` 包一层即可,不影响手势。 +class DrivingGuideOverlay extends StatefulWidget { + const DrivingGuideOverlay({super.key, this.steering = 0}); + + /// 转向角(度):驱动整条脊柱左右弯曲。 + final double steering; + + @override + State createState() => _DrivingGuideOverlayState(); +} + +class _DrivingGuideOverlayState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _flow; + + @override + void initState() { + super.initState(); + _flow = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _flow.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: AnimatedBuilder( + animation: _flow, + builder: (context, _) => CustomPaint( + size: Size.infinite, + painter: _DrivingGuidePainter( + progress: _flow.value, + steering: widget.steering, + ), + ), + ), + ); + } +} + +class _DrivingGuidePainter extends CustomPainter { + _DrivingGuidePainter({required this.progress, required this.steering}); + + /// 0..1 循环进度,驱动绿色箭头的流动。 + final double progress; + + /// 转向角(度,右正左负,-45~45):驱动辅助线左右弯曲。 + final double steering; + + /// 近端(底部顶点)纵向比例 + static const double _bottomT = 0.98; + + /// 远端(收束)纵向比例 + static const double _topT = 0.52; + + /// 满舵时脊柱总转角(弧度):越大弯得越急;嫌弯不够调大、嫌夸张调小。 + static const double _maxHeading = 0.5; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final cx = w / 2; + final bottomY = h * _bottomT; + final topY = h * _topT; + + // 近零死区:摇杆回中或极小漂移时强制视为直行,杜绝静态抖动 + final rawTurn = (steering / 45.0).clamp(-1.0, 1.0); + final turn = rawTurn.abs() < 0.02 ? 0.0 : rawTurn; + final totalHeading = turn * _maxHeading; + + // 航向积分采样脊柱中心线:底部正中出发、初始竖直向上 + const steps = 36; + final ds = (bottomY - topY) / steps; + final pts = []; + final heads = []; + double x = cx, y = bottomY, th = 0; + for (var i = 0; i <= steps; i++) { + pts.add(Offset(x, y)); + heads.add(th); + x += math.sin(th) * ds; + y -= math.cos(th) * ds; + th += totalHeading / steps; + } + + // 连续插值取脊柱点/切线角:避免整型下标量化导致的“折线抖动” + Offset spineAt(double f) { + final pos = (f * steps).clamp(0.0, steps.toDouble()); + final i = pos.floor().clamp(0, steps - 1); + return Offset.lerp(pts[i], pts[i + 1], pos - i)!; + } + + double headAt(double f) { + final pos = (f * steps).clamp(0.0, steps.toDouble()); + final i = pos.floor().clamp(0, steps - 1); + return heads[i] + (heads[i + 1] - heads[i]) * (pos - i); + } + + // 沿脊柱法线偏移半宽得到一条车道线(root/far 为相对 w 的半宽比例,近宽远窄汇聚) + void lane( + double rootHalf, + double farHalf, + Color color, + double widthPx, + double sign, + ) { + final p = Paint() + ..color = color + ..strokeWidth = widthPx + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + final path = Path(); + const seg = 40; + for (var s = 0; s <= seg; s++) { + final f = s / seg; + final half = (rootHalf + (farHalf - rootHalf) * f) * w; + final n = Offset(math.cos(headAt(f)), math.sin(headAt(f))); + final pt = spineAt(f) + n * (half * sign); + if (s == 0) { + path.moveTo(pt.dx, pt.dy); + } else { + path.lineTo(pt.dx, pt.dy); + } + } + canvas.drawPath(path, p); + } + + // 外黄内绿两侧车道线 + lane(0.14, 0.05, const Color(0xCCFFC24D), 4, -1); + lane(0.14, 0.05, const Color(0xCCFFC24D), 4, 1); + lane(0.08, 0.02, const Color(0xAA22E0A0), 3, -1); + lane(0.08, 0.02, const Color(0xAA22E0A0), 3, 1); + + // 中央绿色导向箭头:沿弯曲脊柱放置并随切线旋转,虚实渐隐 + const n = 4; + for (var i = 0; i < n; i++) { + final phase = (progress + i / n) % 1.0; // 0=近/底 → 1=远/顶 + final ay = h * (0.90 - phase * 0.34); + final f = ((bottomY - ay) / (bottomY - topY)).clamp(0.0, 1.0).toDouble(); + final half = w * (0.105 - phase * 0.05); + final drop = half * 0.72; + final env = math.sin(phase * math.pi); + final arrow = Paint() + ..color = const Color( + 0xFF22E0A0, + ).withValues(alpha: (0.12 + 0.8 * env).clamp(0.0, 0.95).toDouble()) + ..strokeWidth = 2.0 + 6.0 * env + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke; + final c = spineAt(f); + canvas.save(); + canvas.translate(c.dx, c.dy); + canvas.rotate(headAt(f)); + final path = Path() + ..moveTo(-half, drop) + ..lineTo(0, 0) + ..lineTo(half, drop); + canvas.drawPath(path, arrow); + canvas.restore(); + } + } + + @override + bool shouldRepaint(covariant _DrivingGuidePainter oldDelegate) => + oldDelegate.progress != progress || oldDelegate.steering != steering; +} diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart index 15aa1582..8d0b507c 100644 --- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart +++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart @@ -36,6 +36,7 @@ import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.d import 'qr_scanner_page.dart'; import 'bind_device_page.dart'; import '../../../local_camera/presentation/pages/local_camera_page.dart'; +import '../../../usb_camera/presentation/pages/usb_camera_page.dart'; import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart'; import '../../../../devices/domain/entities/device_entity.dart'; import 'package:get_it/get_it.dart'; @@ -1153,6 +1154,20 @@ class _DeviceStatusViewState extends State { ); }, ), + Divider(height: 1, color: ctx.appColors.divider), + _buildMenuOption( + ctx, + icon: Icons.usb, + label: 'USB 摄像头', + onTap: () { + Navigator.pop(ctx); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const UsbCameraPage(), + ), + ); + }, + ), ], ), ), diff --git a/packages/cc_ui_kit/lib/src/cc_expand_slider.dart b/packages/cc_ui_kit/lib/src/cc_expand_slider.dart index 356e6278..725d7f31 100644 --- a/packages/cc_ui_kit/lib/src/cc_expand_slider.dart +++ b/packages/cc_ui_kit/lib/src/cc_expand_slider.dart @@ -14,6 +14,11 @@ class CCExpandSlider extends StatefulWidget { final String svgCenter; final String svgEnd; final double iconSize; + + /// 图标额外旋转的 90° 段数。有些素材是按「横向滑块」预先旋过 90° 的 + /// (如 remote_flame*,外层配合 RotatedBox 使用),竖向摆放时需要传 1 转回正。 + /// 默认 0,不影响既有横 / 竖两种用法。 + final int iconQuarterTurns; final double borderRadius; final VoidCallback? onStart; final VoidCallback? onCenter; @@ -28,6 +33,7 @@ class CCExpandSlider extends StatefulWidget { required this.svgEnd, required this.label, this.iconSize = 22.0, + this.iconQuarterTurns = 0, this.borderRadius = 12.0, this.onStart, this.onCenter, @@ -292,12 +298,15 @@ class _CCExpandSliderState extends State { } else { color = Colors.white; } - return SvgPicture.asset( - assetPath, - width: widget.iconSize, - height: widget.iconSize, - fit: BoxFit.contain, - colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + return RotatedBox( + quarterTurns: widget.iconQuarterTurns, + child: SvgPicture.asset( + assetPath, + width: widget.iconSize, + height: widget.iconSize, + fit: BoxFit.contain, + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + ), ); } } diff --git a/packages/usb_camera_android/android/src/main/AndroidManifest.xml b/packages/usb_camera_android/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..2c7d035a --- /dev/null +++ b/packages/usb_camera_android/android/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + +