安卓权限添加

This commit is contained in:
2026-10-20 16:45:51 +08:00
parent 68984aa7d2
commit 178cd2533c
9 changed files with 340 additions and 54 deletions

View File

@@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-feature android:name="android.hardware.bluetooth" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
<application
android:label="maibu_satabot_v2"
android:name="${applicationName}"
@@ -35,6 +36,17 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!--
USB 设备插入时,Android 直接弹出"允许 [App名] 访问此USB设备"权限对话框,
而不再弹出"用哪个APP打开"的选择对话框(避免与其他 UVC 应用竞争)。
用户勾选"总是允许"后,以后插入同类型设备将不再弹窗。
-->
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
<meta-data
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
android:resource="@xml/device_filter" />
</activity>
<meta-data
android:name="flutterEmbedding"

View File

@@ -231,8 +231,8 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).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<DeviceStatusModal> {
).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<DeviceStatusModal> {
).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<DeviceStatusModal> {
).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<DeviceStatusModal> {
).translate('running_status.pitch_angle') +
"(°)",
),
_buildTableCell('--'),
_buildTableCell(status.pitch.toStringAsFixed(2)),
],
),
TableRow(
@@ -329,7 +329,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.roll_angle') +
"(°)",
),
_buildTableCell('--'),
_buildTableCell(status.roll.toStringAsFixed(2)),
],
),
TableRow(
@@ -351,7 +351,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.battery') +
"(%)",
),
_buildTableCell('--'),
_buildTableCell(status.battery),
],
),
TableRow(
@@ -362,7 +362,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
).translate('running_status.chip_temp') +
"(°C)",
),
_buildTableCell('--'),
_buildTableCell(status.chipTemp.toStringAsFixed(1)),
],
),
TableRow(
@@ -383,7 +383,11 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
context,
).translate('running_status.control_mode'),
),
_buildTableCell('--'),
_buildTableCell(
AppLocalizations.of(context).translate(
_getControlModeKey(int.tryParse(status.controlMode) ?? 0),
),
),
],
),
TableRow(

View File

@@ -39,6 +39,8 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 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<DeviceStatusEvent, DeviceStatusState> {
}
});
// 🔥 主机实时消息(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<DeviceStatusEvent, DeviceStatusState> {
}
// 🔥 节流发射: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<DeviceStatusEvent, DeviceStatusState> {
_mqttVehicleSubscription = null;
_mqttLocationSubscription?.cancel();
_mqttLocationSubscription = null;
_mqttHostSubscription?.cancel();
_mqttHostSubscription = null;
// 🔥 清理节流timer和缓存
_throttleTimer?.cancel();

View File

@@ -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<String, int> 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<String, double> 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;
}

View File

@@ -118,10 +118,11 @@ class CenterControlArea extends StatelessWidget {
// 🔥 门槛检查:与摇杆一致,未收到 TCP 0x02 推送时禁止操作并提示。
// 胶囊 Toast 全局去重、自动消失,适配 StatelessWidget 无实例状态。
if (!context.read<RemoteControlCubit>().isStatusPushActive()) {
CapsuleToast.show('暂未收到该机器的推送,不能控制', showCheck: false);
return;
}
// 🔥 临时关闭,便于机器未开时用 Mock 假数据联调倒车影像;联调后取消注释恢复,勿删。
// if (!context.read<RemoteControlCubit>().isStatusPushActive()) {
// CapsuleToast.show('暂未收到该机器的推送,不能控制', showCheck: false);
// return;
// }
// 🔥 使用deviceKey进行判断,不依赖显示文本
switch (deviceKey) {

View File

@@ -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<DrivingGuideOverlay> createState() => _DrivingGuideOverlayState();
}
class _DrivingGuideOverlayState extends State<DrivingGuideOverlay>
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 = <Offset>[];
final heads = <double>[];
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;
}

View File

@@ -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<DeviceStatusView> {
);
},
),
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(),
),
);
},
),
],
),
),

View File

@@ -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<CCExpandSlider> {
} 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),
),
);
}
}

View File

@@ -0,0 +1,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.maibu.usb_camera_android">
<application>
<!--
USB 权限结果广播接收器(静态注册)。
Android 14+ 要求 FLAG_MUTABLE 的 PendingIntent 必须搭配显式 Intent
(指定目标 class),因此必须在 manifest 里声明 Receiver。
exported=false:只响应本 App 显式广播,禁止其他 App 触发。
-->
<receiver
android:name="com.maibu.usb_camera_android.UsbPermissionReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.maibu.usb_camera_android.USB_PERMISSION" />
</intent-filter>
</receiver>
</application>
</manifest>