增加通讯协议

This commit is contained in:
xu
2026-10-14 09:59:42 +08:00
parent 32878a9a80
commit 950e9e6c9e
102 changed files with 5538 additions and 2679 deletions
+135 -30
View File
@@ -1,14 +1,14 @@
# 图数一体机遥控端 QT 界面(wuhan)
依据《QT界面详细设计说明书.md》实现的“玉兔工业无线遥控器(YTZT6K8EART2-aL25)”遥控端界面,
覆盖 ③开机密码(含密码警告/密码管理登录/密码管理/密码重置)④自检 ⑤作业选择 ⑥机器人底盘
⑦机械臂 ⑧气体检测仪 ⑨手动消除 ⑩手动消毒 ⑪调试维护 ⑫故障查询 共 10 个界面,
并配套 **UDP 收包 / 发包通信** 与内置模拟车载端。
覆盖 ③开机密码(含密码警告/密码管理登录/密码管理/密码重置)④自检 ⑤系统首页面 ⑥作业选择 ⑦机器人底盘
⑧机械臂 ⑨气体检测仪 ⑩消除作业 ⑪消毒作业 ⑫调试维护 ⑬故障查询 共 11 个界面,
并配套 **CAN 报文收发**的通信层(19 条报文经 `/dev/ttyS7` 串口收发,遥控器另占 `/dev/ttyS5`)。
## 环境与构建
- Qt 5.15.2 MinGW 32-bit(`F:\QT\5.15.2\mingw81_32`),编译套件 `Desktop Qt 5.15.2 MinGW 32-bit`。
- 用 Qt Creator 打开 `wuhan.pro` 直接构建运行即可(qmake 工程,`QT += network`)。
- 用 Qt Creator 打开 `wuhan.pro` 直接构建运行即可(qmake 工程,`QT += serialport`)。
命令行构建示例:
@@ -23,35 +23,141 @@ mingw32-make -j4
## 运行说明
- 开机密码默认 **6789**(可在“忘记密码/修改密码 → 密码管理登录(管理员密码 6789)→ 密码管理 → 重置密码”中修改)。
- 开机密码默认 **6789**(可在“忘记密码/修改密码 → 密码管理登录(管理员密码 **9527**)→ 密码管理 → 重置密码”中修改);管理员密码为固定值 **9527**,不可修改。
- 密码正确后进入自检,自检完成 2 秒自动进入“作业选择”;自检异常时可“忽略故障”继续。
- 底部状态条上的**模式徽标可点击**(或按键盘 **M**)在“行车模式 / 机械臂模式”间切换;
行车模式操作底盘,机械臂模式操作机械臂。
- 软急停:各页顶条右上角“急停”按钮,或按键盘 **E**;急停后需点击“复位”恢复。
- 底部状态条的模式徽标只作**显示**(不响应点击);行车模式操作底盘,机械臂模式操作机械臂。
演示用快捷键 **M** 可切换模式,但**遥控器在线时模式由遥控器报文决定,M 被忽略**。
- 软急停由**遥控器 bit4** 下发:上升沿触发急停、下降沿复位,面板不提供急停/复位控件
(顶条原有的“急停”按钮已移除)。触发后页面之上出现全屏红条“急停已触发,请用遥控器复位!”。
- ⑨⑩ 运行中“返回”无效(需先停止);调试维护页常显红色警告。
## 通信(UDP 收包 / 发包)
## 通信(19 条 CAN 报文,经 /dev/ttyS7 串口收发)
通信层位于 `comm/udpcommunication.cpp`,协议为 **JSON 文本**(`QJsonDocument`):
通信层位于 `comm/car.cpp`,按《洗消机器人遥控器通信协议20260810》sheet **RC** 实现全部
**19 条**报文(分类四标准帧 `DIDO/PWM/AI/BMS/MCU` 未实现)。所有报文均为 **29 位扩展帧、
信号一律 Intel(小端)位序**。
- 遥控端绑定本地端口 `9000`,向 `127.0.0.1:9001` 发包;
- 内置模拟车载端 `comm/mockvehicle.cpp` 绑定 `9001`,向 `9000` 回传遥测,并对命令作出响应。
串口是字节流,故线上帧统一为 **18 字节**,收发同一格式:
上行命令(UI→车载端,`{"t":...}`):`chassis_move` `arm_move` `arm_return` `arm_pause`
`gas_start` `gas_stop` `wash_start` `wash_stop` `ball_valve` `pump_power` `pump_rpm`
`set_param` `selfcheck_start` `estop` `estop_reset`。
| 偏移 | 长度 | 含义 |
| --- | --- | --- |
| 0 | 4 | 帧头 `0x5AA5FF00`(线上字节序 `00 FF A5 5A`,用于在字节流中定位报文起始) |
| 4 | 4 | CAN ID(29 位扩展帧),直接用于分流 |
| 8 | 1 | DLC,固定 `8` |
| 9 | 8 | CAN 数据段 |
| 17 | 1 | 校验和:canId 四字节 + DLC + 数据八字节求和取低 8 位 |
下行遥测(车载端→UI):`heartbeat` `chassis` `arm` `wash` `tank` `gas` `valve` `pump`
`estop` `selfcheck` `selfcheck_done`。
收包时逐字节找帧头、校验不过右移一字节继续找,容忍粘包、拆包与杂包。
可在 `config.ini`(程序目录)中配置 `net/local_port`、`net/remote_host`、`net/remote_port`、
`net/mock`(true 启用内置模拟端,false 连接真实车载端)、`net/selfcheck_fault`(自检故障演示)。
**上行 7 条**(遥控器 → 车端)按协议「报文周期时间」列分三组重发:**50ms** 底盘 / 机械臂1 /
机械臂2 / 急停,**100ms** 洗消指令 / 洗消调试,**1000ms** 气体检测仪。界面上层只更新控制状态,
编码发送由定时器完成。
`Chassis_ControlCmd 0x18208081`、`Arm_ControlCmd1 0x18218081`、`Arm_ControlCmd2 0x18228081`、
`Decontam_ControlCmd 0x18238081`、`Decontam_DebugCmd 0x18248081`、`Detector_ControlCmd 0x18258081`、
`Emergency_Cmd 0x18268081`。
**下行 12 条**(车端 → 遥控器)按 canId 分流写入 `Telemetry` 供各页刷新:
| CAN ID | 报文 | 周期 | 主要信号 |
| --- | --- | --- | --- |
| `0x18308180` | 底盘状态1(BMS) | 100 | 总电压、SOC、MCU 故障码与等级、10 路 BMS 故障等级 |
| `0x18318180` | 底盘状态2(BMS) | 100 | 7 路故障等级、4 路继电器粘连、手刹、车速、ready、整系统通信故障码 |
| `0x18328180` | 机械臂状态1 | 100 | 末端位置 X/Y/Z |
| `0x18338180` | 机械臂状态2 | 100 | 末端姿态 RX/RY/RZ、末端速度 |
| `0x18348180` | 机械臂状态3 | 100 | 关节角 J1~J4 |
| `0x18358180` | 机械臂状态4 | 100 | 关节角 J5~J6 |
| `0x18368180` | 洗消工作状态1 | 200 | 模式、启停、三泵上电、D1/D2 到位、液位、A/B 流量、气体流量、发泡倍数 |
| `0x18378180` | 洗消工作状态2 | 200 | 管路/空压机压力、三泵流量反馈、空压机与柱塞泵故障码 |
| `0x18388180` | 检测仪结果状态1 | 1000 | 主机状态、毒物种类与等级、告警位 |
| `0x18398180` | 检测仪结果状态2 | 1000 | 40 位有毒物浓度(ppb) |
| `0x183A8180` | 底盘状态3 | 100 | EPS 22 位 + 制动 24 位故障标志、EPS 故障码 |
| `0x183B8180` | 底盘状态4 | 100 | 挡位、驻车、四驱模式、方向盘角度与对中、左右制动电机故障 |
故障码按协议“信号值描述”列查表转中文(MCU 29 条、空压机 31 条、柱塞泵 7 条、EPS/制动 46 个
标志位、检测仪 4 个告警位等),页面显示中文描述,**首次出现**的故障才写入故障历史,
避免周期报文重复刷屏。
各信号的起始位/位长/精度与协议表一致;界面量程与组帧取值统一按协议「信号值描述」列收口:
车速 0~5.00 km/h、发泡倍数 6~12 倍、泡沫比例 0~10%、空压机流量 0~340 L/min、柱塞泵流量
0~24 L/min;调试维护页三泵转速上限各按协议(空压机 3000 / 柱塞泵 1450 / 叶片泵 2000 rpm)。
顶部「通信正常/异常」按收帧判定:连续 **2s** 未收到任何合法帧即判为异常(最慢的下行周期
1000ms)。串口打开失败(如开发机 Windows 上没有 `/dev/ttyS7`)时不启动定时器、不崩溃,
界面显示「通信异常」。
可在 `config.ini`(程序目录)中配置 `car/device`(默认 `/dev/ttyS7`)、`car/baud`(默认
`500000`)、`rc/device`(默认 `/dev/ttyS5`)、`rc/baud`(默认 `115200`)、`net/selfcheck_fault`
(自检故障演示)。
参数(车速、柱塞泵流量、发泡倍数、泡沫比例、空压机流量)与密码经 `QSettings` 持久化,上电自动读取。
`tools/can_sender.py` / `tools/rc_sender.py` 是旧的 UDP 联调工具,**已不适用于当前串口通道**,
留待需要时另写。
### 已知缺口
- **自检 10 项无对应 CAN 报文**,`sendSelfCheckStart()` 暂由本端跑完序列(见 `onSelfCheckTick`
的 TODO),仅为维持 ④自检 → ⑤作业选择 流程不中断;拿到真实报文后替换该本地驱动。
- **行车/机械臂模式**没有对应的下行信号:协议 12 条反馈里没有该字段,`telemetry.driveMode`
无处可填,故模式只由遥控器报文决定(遥控器离线时保持上次值,可用演示快捷键 **M** 切换)。
- **`Vehi_spd` 按协议取 8 位**(bit24、精度 0.01 → 0~2.55 km/h)。协议表里该信号与其后的
`Vehi_RdyStatus`(32)/`Reserve`(34)/`All_Comm_FaultCode`(40) 起始位自洽,按 8 位读才对齐;
若实测车速需要 0~5 km/h,说明该字段实为 16 位、其后信号整体后移 8 位,需同步调整。
- 分类四(标准帧,`DIDO/PWM/AI/BMS/MCU`)整体未实现。其 `Motorola LSB` 排列格式 =
**大端字节序 + 起始位为 LSB 位置**:16 位信号起始位 8/24/40/56 表示占字节对
(0,1)/(2,3)/(4,5)/(6,7),`value = data[起/8-1]<<8 | data[起/8]`,恰好占满 64 位、
无溢出。实现分类四时勿按 Intel 连续位序去读。
## 遥控器(串口 /dev/ttyS5,24 字节二进制帧)
遥控器不走车端 CAN 通道,独立占一路串口:`comm/uart.cpp` 打开 `rc/device`
(默认 **`/dev/ttyS5`**,115200 / 8 数据位 / 1 停止位 / 无校验),与车端 `/dev/ttyS7`
(见 `comm/car.cpp`)互不干扰。
帧长 **24 字节**、1 字节对齐、多字节字段**小端**,发送周期 100ms。帧头 `BYTE0=0x11`、
帧尾 `BYTE22=0x22`、`BYTE23 = BYTE0~BYTE22 之和的低 8 位`;校验不过即丢弃。
串口是字节流,收包在缓冲区里按帧长滑窗重新对齐,容忍粘包与拆包。
| 字节 | 含义 |
| --- | --- |
| 0 | 帧头 `0x11` |
| 1 | 标志位:bit0 行车模式 / bit1 机械臂模式 / bit2 机械臂使能 / bit3 底盘使能 / bit4 急停 |
| 2 | 预留 `0x00` |
| 3-4 | 整车档位(底盘 Y):0 后 / 2048 中 / 4096 前 |
| 5-6 | 整车转向(底盘 X):0 左 / 2048 中 / 4096 右 |
| 7-8 | 末端位置上下(臂 Z) |
| 9-10 | 末端位置左右(臂 X) |
| 11-12 | 末端位置前后(臂 Y) |
| 13-14 | 末端姿态横滚 roll |
| 15-16 | 末端姿态俯仰 pitch |
| 17-18 | 末端姿态偏航 yaw |
| 19 | 遥控器电量 0~100 |
| 20-21 | 预留 `0x00` |
| 22 | 帧尾 `0x22` |
| 23 | 和校验 |
分工:`RcCommunication` 只负责收包、校验、解析与链路超时(连续 500ms 无合法帧判为断开);
模式/急停/动作的判定集中在 `AppContext`。
- **模式**:bit1 机械臂模式须与 bit2 机械臂使能**同时为 1** 才切到机械臂模式,否则按 bit0
走行车模式。遥控器在线时模式由遥控器决定,离线时才回落到车载端 `chassis` 报文的 `mode` 字段。
- **急停**:bit4 **上升沿触发急停、下降沿复位**,两者都由遥控器下发(一并上行 `estop` /
`estop_reset`),面板只显示红条提示、不提供复位控件。链路中断不会触发复位——超时导致的
bit4 读为 0 不算遥控器指令。
- **动作**:摇杆以 ±10%(1638~2458)为中立死区,方向**变化时才下发**,命令复用现有
`chassis_move` / `arm_move`,并与界面按钮同一套安全联锁(模式 + 使能 + 未急停)。
档位与转向可同时下发(边前进/后退边转向);链路中断会立即撤销所有遥控器动作,
避免最后一条指令被一直挂住。
- **优先级**:遥控器与界面都能操作,同时操作时**遥控器优先**——遥控器正在操作(链路在线且
有轴偏离中位)时,底盘页与机械臂页的动作按钮变灰,强行按下会提示“遥控器操作中”。
- **显示**:顶条右侧为遥控器链路(●正常/断开)与电量;顶条不再显示时钟与急停按钮。
- 底盘页的方向按钮同样支持多键同按:按住“前进”再按住“左转向”,会下发 `dir=fwd` +
`turn=left`,两键都松开才停车。
## 目录结构
- `app/` 全局常量、配色、应用上下文(AppContext)、QSS 样式、闪烁工具
- `comm/` UDP 通信层 + 内置模拟车载端
- `comm/` 车端 CAN 通信层(`car`,19 条报文经 `/dev/ttyS7`)+ 遥控器串口通道(`uart`,`/dev/ttyS5`)
- `widgets/` 公共组件:TitleBar / StatusStrip / LedIndicator / NumPadDialog /
ConfirmDialog / AlarmBanner / ParamControl / DataCard / FaultTable
- `pages/` 各界面(继承 BasePage),对应关系如下:
@@ -65,15 +171,14 @@ mingw32-make -j4
| ③ 密码管理 | passwordmgmt | PasswordMgmtPage |
| ③ 密码重置 | passwordreset | PasswordResetPage |
| ④ 自检 | selfcheck | SelfCheckPage |
| ⑤ 作业选择 | jobselect | JobSelectPage |
| ⑥ 机器人底盘 | chassis | ChassisPage |
| ⑦ 机械臂 | arm | ArmPage |
| ⑧ 气体检测仪 | gasdetector | GasDetectorPage |
| ⑨/⑩ 共用基类 | washbase | WashBasePage |
| ⑨ 手动消除 | manualclean | ManualCleanPage |
| ⑩ 手动消毒 | manualdisinfect | ManualDisinfectPage |
| ⑪ 调试维护 | debugmaintain | DebugMaintainPage |
| ⑫ 故障查询 | faultquery | FaultQueryPage |
| ⑤ 系统首页面 | home | HomePage |
| ⑥ 作业选择 | jobselect | JobSelectPage |
| ⑦ 机器人底盘 | chassis | ChassisPage |
| ⑧ 机械臂 | arm | ArmPage |
| ⑨ 气体检测仪 | gasdetector | GasDetectorPage |
| ⑩/⑪ 消除/消毒作业 | washpage | WashPage |
| ⑫ 调试维护 | debugmaintain | DebugMaintainPage |
| ⑬ 故障查询 | faultquery | FaultQueryPage |
- `mainwindow.h/.cpp` 主窗口(QStackedWidget 页面路由 + 全局急停横幅)
- `mainwindow.h/.cpp` 主窗口(QStackedWidget 页面路由 + 全局急停横幅,横幅内含“复位”按钮)
+157 -9
View File
@@ -1,9 +1,26 @@
#include "appcontext.h"
#include "udpcommunication.h"
#include "mockvehicle.h"
#include "car.h"
#include <QApplication>
#include <QDateTime>
namespace {
// 遥控器摇杆满量程 0~4096、中位 2048;取 ±10% 死区,即 1638~2458 视为中立
const int kAxisMid = 2048;
const int kAxisDead = 410;
int axisDir(int v)
{
if (v > kAxisMid + kAxisDead) return +1;
if (v < kAxisMid - kAxisDead) return -1;
return 0;
}
// 六个机械臂轴,顺序与 sendArmMove 的轴名一致(见 pages/arm.cpp)
const char* const kArmAxes[6] = {"z", "x", "y", "roll", "pitch", "yaw"};
} // namespace
AppContext* AppContext::instance()
{
static AppContext* s = nullptr;
@@ -25,18 +42,31 @@ AppContext::AppContext(QObject* parent)
if (!m_settings->contains(keyFoamPercent()))m_settings->setValue(keyFoamPercent(), 3);
if (!m_settings->contains(keyAirFlow())) m_settings->setValue(keyAirFlow(), 100);
if (!m_settings->contains("param/pwd")) m_settings->setValue("param/pwd", "6789");
if (!m_settings->contains("param/admin_pwd"))m_settings->setValue("param/admin_pwd", "6789");
// 通信层(UDP 收包发包)
m_comm = new UdpCommunication(&telemetry, this);
// 通信层(19 条 CAN 报文经 /dev/ttyS7 收发,见 comm/car.h)
m_comm = new CarCommunication(&telemetry, this);
// 内置模拟车载端(离线演示:回环 UDP 收发)
m_vehicle = new MockVehicle(this);
// 遥控器(串口 /dev/ttyS5,24 字节二进制帧,见 comm/uart.h)
m_rc = new RcCommunication(this);
// 通信层故障信号 → 写入故障历史
connect(m_comm, &UdpCommunication::faultAppended,
connect(m_comm, &CarCommunication::faultAppended,
this, &AppContext::appendFault);
// 遥控器报文 → 模式 / 急停 / 动作 / 显示
connect(m_rc, &RcCommunication::frameReceived, this, &AppContext::onRcFrame);
connect(m_rc, &RcCommunication::linkChanged, this, &AppContext::onRcLinkChanged);
#if 0
// 行车/机械臂模式改由底盘报文的 mode 字段决定(遥控器在线时让位给遥控器报文)。
// 停用原因:协议 sheet RC 的 12 条下行报文里没有该 mode 字段,telemetry.driveMode
// 无处可填,此连接只会一直把模式按 0(行车)写。待协议补充该信号后恢复。
connect(m_comm, &CarCommunication::telemetryUpdated, this, [this]() {
if (m_rc->linkOk()) return;
setMode(telemetry.driveMode == 0 ? Mode::Drive : Mode::Arm);
});
#endif
seedFaultHistory();
}
@@ -117,9 +147,10 @@ void AppContext::setPassword(const QString& pwd)
m_settings->sync();
}
// 管理员密码为固定值,不持久化、不可修改
QString AppContext::adminPassword() const
{
return m_settings->value("param/admin_pwd", "6789").toString();
return "9527";
}
void AppContext::resetEstop()
@@ -144,4 +175,121 @@ void AppContext::setMode(Mode m)
if (mode == m) return;
mode = m;
emit modeChanged(m);
if (m_comm) m_comm->setArmMode(m == Mode::Arm); // 同步到 Arm_ControlCmd1 的 Arm_modeCmd/Arm_enable
}
// ===================== 遥控器(24 字节二进制帧) =====================
void AppContext::onRcFrame(const RcFrame& f)
{
// 存入遥测快照,供界面显示
telemetry.rcBattery = f.battery;
telemetry.rcDriveMode = f.driveMode;
telemetry.rcArmMode = f.armMode;
telemetry.rcArmEnabled = f.armEnabled;
telemetry.rcChassisEnabled = f.chassisEnabled;
telemetry.rcEstop = f.estop;
telemetry.rcGear = f.gear;
telemetry.rcSteer = f.steer;
telemetry.rcPosZ = f.posZ;
telemetry.rcPosX = f.posX;
telemetry.rcPosY = f.posY;
telemetry.rcRoll = f.roll;
telemetry.rcPitch = f.pitch;
telemetry.rcYaw = f.yaw;
// 模式:bit1 机械臂模式须与 bit2 机械臂使能同时为 1 才视为机械臂
setMode((f.armMode && f.armEnabled) ? Mode::Arm : Mode::Drive);
// 急停与复位都由遥控器 bit4 下发:上升沿触发,下降沿复位。面板只显示横幅提示。
if (f.estop != m_rcEstopPrev) {
m_rcEstopPrev = f.estop;
if (f.estop) triggerEstop();
else resetEstop();
}
// 动作:与界面按钮同一套安全联锁(模式 + 使能 + 未急停)
applyRcChassis(f.gear, f.steer, f.chassisEnabled);
applyRcArm(f);
updateRcDriving();
}
void AppContext::applyRcChassis(int gear, int steer, bool enabled)
{
const bool allowed = enabled && !estopTriggered && (mode == Mode::Drive);
const int g = allowed ? axisDir(gear) : 0;
const int s = allowed ? axisDir(steer) : 0;
if (g == m_rcGearDir && s == m_rcSteerDir) return; // 方向未变,不重复发包
m_rcGearDir = g;
m_rcSteerDir = s;
sendChassisCommand();
}
void AppContext::applyRcArm(const RcFrame& f)
{
const bool allowed = f.armEnabled && !estopTriggered && (mode == Mode::Arm);
const int vals[6] = {f.posZ, f.posX, f.posY, f.roll, f.pitch, f.yaw};
for (int i = 0; i < 6; ++i) {
const int d = allowed ? axisDir(vals[i]) : 0;
if (d == m_rcAxisDir[i]) continue; // 方向未变,不重复发包
m_rcAxisDir[i] = d;
m_comm->sendArmMove(QString::fromLatin1(kArmAxes[i]), d);
}
}
// 档位与转向可同时下发(边前进/后退边转向);两者都回中才停车。
// 仅转向(档位回中)时发 stop + turn,由车载端决定是否原地转向。
void AppContext::sendChassisCommand()
{
QString dir = "stop";
if (m_rcGearDir > 0) dir = "fwd";
else if (m_rcGearDir < 0) dir = "back";
QString turn;
if (m_rcSteerDir > 0) turn = "right";
else if (m_rcSteerDir < 0) turn = "left";
if (dir == "stop" && turn.isEmpty()) {
m_comm->sendChassisMove("stop", 0);
return;
}
const double spd = (dir == "stop") ? 0.0 : getDouble(keySpeed(), 4.5);
m_comm->sendChassisMove(dir, spd, turn);
}
void AppContext::updateRcDriving()
{
// 遥控器正在操作:链路在线且任一已生效轴偏离中位
bool driving = (m_rcGearDir != 0) || (m_rcSteerDir != 0);
for (int i = 0; i < 6; ++i) {
if (m_rcAxisDir[i] != 0) { driving = true; break; }
}
if (driving == m_rcDriving) return;
m_rcDriving = driving;
emit rcDrivingChanged(driving);
}
void AppContext::onRcLinkChanged(bool ok)
{
telemetry.rcLinkOk = ok;
if (!ok) {
// 链路中断:撤销所有遥控器动作,否则最后一条指令会被一直挂住。
// 不动急停状态——丢包导致的 bit4 读为 0 不应被当成遥控器下发的复位。
if (m_rcGearDir != 0 || m_rcSteerDir != 0) {
m_rcGearDir = 0;
m_rcSteerDir = 0;
m_comm->sendChassisMove("stop", 0);
}
for (int i = 0; i < 6; ++i) {
if (m_rcAxisDir[i] == 0) continue;
m_rcAxisDir[i] = 0;
m_comm->sendArmMove(QString::fromLatin1(kArmAxes[i]), 0);
}
}
updateRcDriving();
}
+27 -6
View File
@@ -5,9 +5,9 @@
#include <QSettings>
#include <QVector>
#include "global.h"
#include "uart.h"
class UdpCommunication;
class MockVehicle;
class CarCommunication;
// 故障历史记录
struct FaultRecord {
@@ -25,8 +25,8 @@ class AppContext : public QObject
public:
static AppContext* instance();
UdpCommunication* comm() const { return m_comm; }
MockVehicle* vehicle() const { return m_vehicle; }
CarCommunication* comm() const { return m_comm; }
RcCommunication* rc() const { return m_rc; }
// 遥测数据快照(由通信层填充)
Telemetry telemetry;
@@ -68,17 +68,38 @@ public:
// 模式切换(行车/机械臂,由面板机械旋钮或演示按钮触发)
void setMode(Mode m);
// 遥控器:链路状态与“正在操作”判据(遥控器优先时界面按钮让位)
bool rcDriving() const { return m_rcDriving; }
signals:
void modeChanged(Mode m);
void estopChanged(bool triggered);
void commChanged(bool ok);
void rcDrivingChanged(bool driving);
private slots:
void onRcFrame(const RcFrame& f);
void onRcLinkChanged(bool ok);
private:
explicit AppContext(QObject* parent = nullptr);
void seedFaultHistory();
void applyRcChassis(int gear, int steer, bool enabled);
void applyRcArm(const RcFrame& f);
void sendChassisCommand(); // 按当前档位/转向方向下发(两者可同时)
void updateRcDriving();
QSettings* m_settings = nullptr;
UdpCommunication* m_comm = nullptr;
MockVehicle* m_vehicle = nullptr;
CarCommunication* m_comm = nullptr;
RcCommunication* m_rc = nullptr;
// 遥控器动作边沿状态:只在方向变化时下发,避免 100ms 刷屏
int m_rcGearDir = 0; // -1=后 0=中 +1=前
int m_rcSteerDir = 0; // -1=左 0=中 +1=右
int m_rcAxisDir[6] = {0, 0, 0, 0, 0, 0}; // 顺序见 kArmAxes
bool m_rcEstopPrev = false;
bool m_rcDriving = false;
};
// 便捷访问宏
+32 -4
View File
@@ -1,8 +1,10 @@
#include "global.h"
#include <QStyle>
#include <QApplication>
#include <QAbstractButton>
#include <QPushButton>
#include <QEvent>
#include <QKeyEvent>
#include <QGraphicsDropShadowEffect>
// 启动 500ms 闪烁:每 500ms 切换一次动态属性,QSS 据此在 40%↔100% 透明度间切换
@@ -61,7 +63,7 @@ bool ButtonFocusGlow::eventFilter(QObject* obj, QEvent* ev)
if (ev->type() == QEvent::FocusIn) {
if (!w->graphicsEffect()) { // 挂一圈发光(偏移 0 → 四周光晕)
QGraphicsDropShadowEffect* g = new QGraphicsDropShadowEffect(w);
g->setBlurRadius(55); // 光晕大小(越大越强)
g->setBlurRadius(40); // 光晕大小(越大越强)
g->setOffset(0, 0);
g->setColor(m_glow);
w->setGraphicsEffect(g);
@@ -80,6 +82,33 @@ void installButtonFocusGlow(QApplication* app)
app->installEventFilter(new ButtonFocusGlow(QColor("#7FBBFF"), app)); // 亮蓝,深色底更醒目
}
// ===================== 方向键(旋钮)隔离 =====================
ArrowKeyGuard::ArrowKeyGuard(QObject* parent)
: QObject(parent)
{
}
bool ArrowKeyGuard::eventFilter(QObject* obj, QEvent* ev)
{
if (ev->type() == QEvent::KeyPress) {
// 只在焦点落在按钮上时拦截:否则 QAbstractButton 会拿方向键去换焦
QWidget* fw = QApplication::focusWidget();
if (fw && qobject_cast<QAbstractButton*>(fw)) {
const int k = static_cast<QKeyEvent*>(ev)->key();
if (k == Qt::Key_Up || k == Qt::Key_Down
|| k == Qt::Key_Left || k == Qt::Key_Right)
return true;
}
}
return QObject::eventFilter(obj, ev);
}
void installArrowKeyGuard(QApplication* app)
{
if (app)
app->installEventFilter(new ArrowKeyGuard(app));
}
QString appStyleSheet()
{
const QString s = QString(R"QSS(
@@ -108,7 +137,6 @@ QPushButton {
border: 1px solid BD;
border-radius: 12px;
font-size: 40px;
min-height: 56px;
}
QPushButton:hover { background-color: PRI; color: TXT; border-color: PRI; }
QPushButton:pressed { background-color: PRI; color: #FFFFFF; border-color: PRI; }
@@ -165,7 +193,7 @@ QHeaderView::section {
font-size: 40px;
}
QTableWidget::item { padding: 6px; }
QTableWidget::item:selected { background-color: rgba(61,139,253,0.15); color: TXT; }
QTableWidget::item:selected { background-color: PRI; color: #FFFFFF; }
/* ===== 数字键盘 ===== */
QPushButton[numpad="true"] {
@@ -177,7 +205,7 @@ QPushButton[numpad="true"]:pressed { background-color: PRI; color: #FFFFFF; }
QPushButton:focus {
background-color: PRI;
color: #FFFFFF;
border: 2px solid #8ABFFF;
border: 4px solid #8ABFFF;
}
QPushButton:focus:disabled {
background-color: DIS; color: DIST; border: 1px solid DIS;
+58 -12
View File
@@ -12,9 +12,10 @@
class QApplication;
// ===================== 画布与全局常量 =====================
// 屏幕分辨率(1920×1080 横屏);若部署在更低分辨率屏,可按同一比例整体缩放
// 屏幕分辨率(1920×1200);窗口按此固定尺寸,页面布局靠 layout 自适应,
// 富余的垂直空间由各页的 addStretch 分配(控件像素尺寸与字号不随屏幕缩放)
static const int SCREEN_W = 1920;
static const int SCREEN_H = 1080;
static const int SCREEN_H = 1200;
// ===================== 工业风格配色体系(QSS 十六进制) =====================
namespace C {
@@ -44,25 +45,32 @@ enum PageId {
PageMgmt, // 密码管理
PageReset, // 密码重置
PageSelfCheck, // ④ 自检
PageJob, // ⑤ 作业选择
PageChassis, // ⑥ 机器人底盘
PageArm, // ⑦ 机械臂
PageGas, // ⑧ 气体检测仪
PageClean, // ⑨ 手动消除
PageDisinfect, // ⑩ 手动消毒
PageDebug, // ⑪ 调试维护
PageFault, // ⑫ 故障查询
PageHome, // ⑤ 系统首页面(自检完成后进入)
PageJob, // ⑥ 作业选择
PageChassis, // ⑦ 机器人底盘
PageArm, // ⑧ 机械臂
PageGas, // ⑨ 气体检测仪
PageClean, // ⑩ 消除作业
PageDisinfect, // ⑪ 消毒作业
PageDebug, // ⑫ 调试维护
PageFault, // ⑬ 故障查询
PageCount
};
// ===================== 遥测数据(车载端 → UI) =====================
// 由通信层解析 UDP 报文后填充,页面 500ms 定时读取刷新
// 由通信层解析车端反馈报文后填充,页面刷新时读取
struct Telemetry {
// 底盘
int soc = 85; // 电量 %
double volt = 360.0; // 电压 V
double speed = 3.45; // 车速 km/h
bool handbrake = true; // 手刹
int driveMode = 0; // 行车/机械臂模式:0=行车 1=机械臂(遥控器拨钮→串口→底盘报文 mode)
int rdyStatus = 0; // 底盘 ready:0=初始 1=高压上电完成 2=准备就绪
int gearPos = 0; // 挡位:0=N 1=D 2=R
int parking = 0; // 驻车:0=未驻车 1=左电机 2=右电机 3=完全驻车
int awdMode = 0; // 四驱模式:0=二驱 1=四驱 2=四驱锁定
double steerAngle = 0.0; // 方向盘角度
QStringList chassisFaults; // 底盘故障(多条 2s 轮换)
// 机械臂
@@ -74,13 +82,17 @@ struct Telemetry {
bool armHomed = false; // 是否已回位(用于底盘“机械臂未回位”告警)
// 洗消
QString washMode = "--"; // 手动消除 / 手动消毒
QString washMode = "--"; // 消除作业 / 消毒作业
bool washStart = false; // 洗消启停反馈
double flowA = 0.0; // A 药剂流量 L/min
double flowB = 0.0; // B 药剂流量 L/min
double gasFlow = 0.0; // 气体流量 L/min
double press = 0.0; // 管路压力 MPa
double airPress = 0.0; // 空压机压力 MPa
double foam = 0.0; // 发泡倍数
double flowPlungerFb = 0.0; // 柱塞泵流量反馈 L/min
double flowAirFb = 0.0; // 空压机流量反馈 L/min
double flowVaneFb = 0.0; // 叶片泵流量反馈 L/min
QStringList washFaults; // 洗消故障代码
// 液位
@@ -106,6 +118,23 @@ struct Telemetry {
bool leaf = false; // 叶片泵上电
QStringList pumpFaults; // 泵故障代码
// 遥控器(24 字节二进制帧,见 comm/uart.h)
bool rcLinkOk = false; // 遥控器链路是否在线
int rcBattery = 0; // 遥控器电量 %
bool rcDriveMode = false; // bit0 行车模式
bool rcArmMode = false; // bit1 机械臂模式
bool rcArmEnabled = false; // bit2 机械臂使能
bool rcChassisEnabled = false;// bit3 底盘使能
bool rcEstop = false; // bit4 急停
int rcGear = 2048; // 整车档位(底盘 Y)0~4096,中位 2048
int rcSteer = 2048; // 整车转向(底盘 X)
int rcPosZ = 2048; // 末端位置上下
int rcPosX = 2048; // 末端位置左右
int rcPosY = 2048; // 末端位置前后
int rcRoll = 2048; // 末端姿态横滚
int rcPitch = 2048; // 末端姿态俯仰
int rcYaw = 2048; // 末端姿态偏航
// 全局
bool commOk = true; // 通信状态
bool estop = false; // 急停触发
@@ -139,6 +168,23 @@ private:
// 为整个应用安装按钮焦点光圈
void installButtonFocusGlow(QApplication* app);
// ===================== 方向键(旋钮)隔离 =====================
// 方向键在本机是"旋钮"键位,只由 ParamControl 用来调值。但 QAbstractButton 会把
// 方向键当成沿焦点链换焦(等同 Tab/Shift+Tab),停在按钮上时按方向键焦点会乱跳。
// 这个全局过滤器把"焦点在按钮上时"的方向键吞掉,方向键只做调值、换焦只走 Tab。
class ArrowKeyGuard : public QObject
{
Q_OBJECT
public:
explicit ArrowKeyGuard(QObject* parent = nullptr);
protected:
bool eventFilter(QObject* obj, QEvent* ev) override;
};
// 为整个应用安装方向键隔离
void installArrowKeyGuard(QApplication* app);
// 全局 QSS 样式表
QString appStyleSheet();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
[param]
speed=4.5
speed=3.608
pump_flow=17
foam_ratio=6
foam_percent=3
@@ -3,7 +3,7 @@ debug/mainwindow.o
debug/global.o
debug/appcontext.o
debug/udpcommunication.o
debug/mockvehicle.o
debug/rccommunication.o
debug/titlebar.o
debug/statusstrip.o
debug/ledindicator.o
@@ -20,20 +20,19 @@ debug/passwordmgmtlogin.o
debug/passwordmgmt.o
debug/passwordreset.o
debug/selfcheck.o
debug/home.o
debug/jobselect.o
debug/chassis.o
debug/arm.o
debug/gasdetector.o
debug/washbase.o
debug/manualclean.o
debug/manualdisinfect.o
debug/washpage.o
debug/debugmaintain.o
debug/faultquery.o
debug/moc_mainwindow.o
debug/moc_global.o
debug/moc_appcontext.o
debug/moc_udpcommunication.o
debug/moc_mockvehicle.o
debug/moc_rccommunication.o
debug/moc_titlebar.o
debug/moc_statusstrip.o
debug/moc_ledindicator.o
@@ -50,12 +49,11 @@ debug/moc_passwordmgmtlogin.o
debug/moc_passwordmgmt.o
debug/moc_passwordreset.o
debug/moc_selfcheck.o
debug/moc_home.o
debug/moc_jobselect.o
debug/moc_chassis.o
debug/moc_arm.o
debug/moc_gasdetector.o
debug/moc_washbase.o
debug/moc_manualclean.o
debug/moc_manualdisinfect.o
debug/moc_washpage.o
debug/moc_debugmaintain.o
debug/moc_faultquery.o
@@ -3,7 +3,7 @@ release/mainwindow.o
release/global.o
release/appcontext.o
release/udpcommunication.o
release/mockvehicle.o
release/rccommunication.o
release/titlebar.o
release/statusstrip.o
release/ledindicator.o
@@ -20,20 +20,19 @@ release/passwordmgmtlogin.o
release/passwordmgmt.o
release/passwordreset.o
release/selfcheck.o
release/home.o
release/jobselect.o
release/chassis.o
release/arm.o
release/gasdetector.o
release/washbase.o
release/manualclean.o
release/manualdisinfect.o
release/washpage.o
release/debugmaintain.o
release/faultquery.o
release/moc_mainwindow.o
release/moc_global.o
release/moc_appcontext.o
release/moc_udpcommunication.o
release/moc_mockvehicle.o
release/moc_rccommunication.o
release/moc_titlebar.o
release/moc_statusstrip.o
release/moc_ledindicator.o
@@ -50,12 +49,11 @@ release/moc_passwordmgmtlogin.o
release/moc_passwordmgmt.o
release/moc_passwordreset.o
release/moc_selfcheck.o
release/moc_home.o
release/moc_jobselect.o
release/moc_chassis.o
release/moc_arm.o
release/moc_gasdetector.o
release/moc_washbase.o
release/moc_manualclean.o
release/moc_manualdisinfect.o
release/moc_washpage.o
release/moc_debugmaintain.o
release/moc_faultquery.o
+3
View File
@@ -0,0 +1,3 @@
[param]
foam_ratio=6.5
air_flow=100.1
+868
View File
@@ -0,0 +1,868 @@
#include "car.h"
#include <QApplication>
#include <QDebug>
#include <QSerialPort>
#include <QSettings>
#include <QtGlobal>
#include <cstring>
namespace {
// ===================== 线格式 =====================
// 一帧 = 32 位帧头 + 32 位 CAN ID + DLC + 8 字节数据 + 8 位校验和,共 18 字节。
// 多字节字段按主机小端排列,与车载端网关一致;帧头用于在字节流中定位报文起始。
#pragma pack(push, 1)
struct CanFrame {
quint32 header; // 固定帧头 0x5AA5FF00(线上字节序 00 FF A5 5A)
quint32 canId; // 29 位扩展帧 ID,直接用于分流
quint8 dlc; // 数据长度码,固定 8
quint8 data[8]; // 8 字节 CAN 原生数据段
quint8 checksum; // canId 4 字节 + dlc + data[8],共 13 字节求和取低 8 位
};
#pragma pack(pop)
const quint32 kFrameHeader = 0x5AA5FF00u;
const int kFrameLen = int(sizeof(CanFrame));
const int kDataLen = 8;
quint8 canChecksum(quint32 canId, quint8 dlc, const quint8* data)
{
quint8 sum = 0;
for (int i = 0; i < 4; ++i) sum += quint8((canId >> (8 * i)) & 0xFF);
sum += dlc;
for (int i = 0; i < kDataLen; ++i) sum += data[i];
return sum;
}
// ===================== CAN ID(sheet RC) =====================
// 分类一 遥控器控制指令(上行,7 条)
const quint32 kIdChassisCmd = 0x18208081;
const quint32 kIdArmCmd1 = 0x18218081;
const quint32 kIdArmCmd2 = 0x18228081;
const quint32 kIdDecontamCmd = 0x18238081;
const quint32 kIdDecontamDbg = 0x18248081;
const quint32 kIdDetectorCmd = 0x18258081;
const quint32 kIdEmergency = 0x18268081;
// 分类二/三 VCU 状态反馈(下行,12 条)
const quint32 kIdVehicleFb1 = 0x18308180; // 底盘状态1 BMS
const quint32 kIdVehicleFb2 = 0x18318180; // 底盘状态2 BMS
const quint32 kIdArmFb1 = 0x18328180; // 机械臂状态1 末端位置
const quint32 kIdArmFb2 = 0x18338180; // 机械臂状态2 末端姿态/速度
const quint32 kIdArmFb3 = 0x18348180; // 机械臂状态3 关节角 J1~J4
const quint32 kIdArmFb4 = 0x18358180; // 机械臂状态4 关节角 J5~J6
const quint32 kIdDecontamFb1 = 0x18368180; // 洗消工作状态1
const quint32 kIdDecontamFb2 = 0x18378180; // 洗消工作状态2
const quint32 kIdDetectorFb1 = 0x18388180; // 检测仪结果状态1
const quint32 kIdDetectorFb2 = 0x18398180; // 检测仪结果状态2
const quint32 kIdVehicleFb3 = 0x183A8180; // 底盘状态3 EPS/制动故障位
const quint32 kIdVehicleFb4 = 0x183B8180; // 底盘状态4 挡位/驻车/方向盘/制动电机
// ===================== 位运算 =====================
// 分类一/二/三全部是扩展帧、信号一律 Intel(小端):起始位即最低位序号,
// 位序由低到高、可跨字节(如 bit47 长度 2 就横跨 byte5/byte6)。
quint64 intelBits(const quint8* d, int start, int len)
{
quint64 v = 0;
for (int i = 0; i < len; ++i) {
const int bit = start + i;
if (d[bit >> 3] & (1u << (bit & 7)))
v |= quint64(1) << i;
}
return v;
}
qint64 intelSigned(const quint8* d, int start, int len)
{
const quint64 raw = intelBits(d, start, len);
const quint64 signBit = quint64(1) << (len - 1);
return qint64((raw ^ signBit) - signBit); // 符号扩展
}
void setIntelBits(quint8* d, int start, int len, quint64 v)
{
for (int i = 0; i < len; ++i) {
const int bit = start + i;
const quint8 mask = quint8(1u << (bit & 7));
if (v & (quint64(1) << i)) d[bit >> 3] |= mask;
else d[bit >> 3] &= quint8(~mask);
}
}
// 物理值 = 总线原始整数值 × 精度 + 偏移量
double phys(quint64 raw, double scale, double offset = 0.0)
{
return double(raw) * scale + offset;
}
// ===================== 协议配置文件 =====================
// 与 AppContext 用同一份 config.ini(程序目录)。
QString cfgPath()
{
return QApplication::applicationDirPath() + "/config.ini";
}
// ===================== 故障码 → 中文 =====================
struct CodeText { quint8 code; const char* text; };
const char* lookup(const CodeText* tbl, int n, quint8 code)
{
for (int i = 0; i < n; ++i)
if (tbl[i].code == code) return tbl[i].text;
return nullptr;
}
const CodeText kMcuFaults[] = {
{6, "电机缺相预警"}, {17, "控制器过温警告"}, {18, "电机过温警告"},
{19, "控制器温度传感器警告"}, {20, "电机温度传感器警告"}, {21, "控制器过载警告"},
{22, "电机超速警告"}, {23, "直流母线电压过压警告"}, {24, "直流母线电压欠压警告"},
{35, "电流霍尔传感U相器故障"}, {36, "电流霍尔传感W相器故障"}, {37, "VCU报文丢失故障"},
{38, "VCU循环计数错误"}, {39, "VCU校验和错误"}, {40, "CAN BussOff故障"},
{49, "母线硬件过压故障"}, {50, "相电流硬件过流故障"}, {51, "IGBT短路故障"},
{52, "驱动欠压故障"}, {53, "旋变故障"}, {54, "电机超速故障"},
{55, "低压电源过压故障"}, {56, "低压电源欠压故障"}, {57, "直流母线电压过压故障"},
{58, "直流母线电压欠压故障"}, {59, "控制器过温故障"}, {60, "电机过温故障"},
{61, "电机缺相故障"}, {62, "驻坡失效故障"},
};
const CodeText kAirCompFaults[] = {
{2, "加速过流"}, {3, "减速过流"}, {4, "恒速过流"}, {5, "加速过压"},
{6, "减速过压"}, {7, "恒速过压"}, {8, "缓冲过载"}, {9, "欠压"},
{10, "控制器过载"}, {11, "电机过载"}, {12, "输入缺相"}, {13, "输出缺相"},
{14, "模块过热"}, {15, "外部故障"}, {16, "通信异常"}, {17, "接触器异常"},
{18, "电流检测故障"}, {19, "自学习故障"}, {20, "编码器故障"}, {21, "参数读写异常"},
{22, "变频器硬件故障"}, {23, "电机对地短路故障"}, {31, "PID反馈丢失"},
{40, "快速限流超时故障"}, {42, "速度偏差过大"}, {43, "电机超速"}, {45, "电机过温"},
{90, "编码器线数设定错误"}, {91, "未接编码器"}, {92, "初始位置错误"}, {94, "速度反馈错误"},
};
const CodeText kPlungerFaults[] = {
{1, "短路故障"}, {2, "欠压"}, {3, "过压"}, {4, "控制器过温"},
{5, "控制器温度传感器故障"}, {6, "电机过温"}, {7, "电机温度传感器故障"},
};
const CodeText kCommFaults[] = {
{1, "底盘电机通信异常"}, {2, "底盘BMS通信异常"}, {3, "机械臂通信异常"},
{4, "空压机通信异常"}, {5, "柱塞泵通信异常"}, {6, "叶片泵通信异常"},
{7, "气体检测仪通信异常"}, {8, "遥控器通信异常"},
};
const CodeText kDetectorWarns[] = {
{1, "进气嘴未接"}, {2, "氢气流量不足"}, {4, "仪器故障"}, {8, "浓度过高"},
};
// 底盘状态3(0x183A8180)的 46 个 1 bit 故障标志(bit0~bit45)
const char* const kVsb3Bits[46] = {
"EPS转子角未对中", "EPS转子角度断开", "EPS转子角度SPI故障", "EPSEEPROM故障",
"EPS方向盘中点未对中", "EPS方向盘角度超限", "EPS方向盘小齿信号断开", "EPS方向盘中齿信号断开",
"EPS过电流故障", "EPS电机A相电流异常", "EPS电机C相电流异常", "EPS电机B相电流异常",
"EPSVCU信号丢失", "EPS电机无助力", "EPS预驱配置失败", "EPS半堵转保护",
"EPS温度热保护", "EPS电压管理保护", "EPS转角响应不到位", "EPS转子角度PWM故障",
"EPS电压校验异常", "EPS热敏电阻开路或短路", "EPS热敏电阻温差过大",
"制动器过载故障", "制动器过温或过压故障", "制动器过流故障", "制动器电源欠压故障",
"制动器电源过压故障", "制动器压力不足", "制动器缺相故障", "制动器通讯故障",
"制动器电流采样故障", "制动器驱动故障", "制动器磁编故障", "制动器压力传感器故障",
"制动器踏板位置传感器故障", "制动器机械故障", "制动器堵转故障", "制动器急停按键闭合警告",
"制动器驻车时间过长", "制动器CAN1中断警告", "制动器CAN2中断警告", "制动器点火线断开警告",
"制动器电流异常警告", "制动器温度过高警告", "制动器输入电压过高警告",
};
// 各路 *_FaultLevel 统一为 0 正常 / 1~3 故障等级
const char* const kLevels[4] = {"", "一级故障", "二级故障", "三级故障"};
// 制动电机状态(Brake_*_Mot_fault):0x00~0x07
const char* const kBrakeMotFaults[8] = {
"", "电机开路", "电机短路", "制动执行超时", "解除制动执行超时",
"驱动电路故障", "供电电压过低", "供电电压过高",
};
void addFault(QVector<CanFault>& out, const QString& system, const char* codePrefix,
quint8 code, const char* text)
{
if (!text) return;
CanFault f;
f.system = system;
f.code = QString::fromLatin1(codePrefix) + QString::number(code);
f.desc = QString::fromUtf8(text);
out.append(f);
}
// 未在码表中登记的码也上报,避免“有故障但界面无提示”
void addFaultByCode(QVector<CanFault>& out, const QString& system, const char* codePrefix,
const CodeText* tbl, int n, quint8 code)
{
if (code == 0) return;
const char* t = lookup(tbl, n, code);
if (t) addFault(out, system, codePrefix, code, t);
else {
CanFault f;
f.system = system;
f.code = QString::fromLatin1(codePrefix) + QString::number(code);
f.desc = QString::fromLatin1(codePrefix) + QString::number(code);
out.append(f);
}
}
// 故障等级:>0 才上报,描述形如“单体电压过低二级故障”
void addLevelFault(QVector<CanFault>& out, const QString& system, const char* codePrefix,
const char* name, quint8 level)
{
if (level == 0 || level > 3) return;
CanFault f;
f.system = system;
f.code = QString::fromLatin1(codePrefix) + QString::number(level);
f.desc = QString::fromUtf8(name) + QString::fromUtf8(kLevels[level]);
out.append(f);
}
const char* const kSysChassis = "机器人底盘";
const char* const kSysWash = "洗消系统";
const char* const kSysDetector = "气体检测仪";
} // namespace
// ===================== 构造与生命周期 =====================
CarCommunication::CarCommunication(Telemetry* tel, QObject* parent)
: QObject(parent), m_tel(tel)
{
m_serial = new QSerialPort(this);
connect(m_serial, &QSerialPort::readyRead, this, &CarCommunication::onReadyRead);
// 串口配置与 AppContext 共用 config.ini(默认 /dev/ttyS7,500000 8N1)
QSettings s(cfgPath(), QSettings::IniFormat);
m_device = s.value("car/device", "/dev/ttyS7").toString();
m_baud = s.value("car/baud", 500000).toInt();
m_selfCheckFault = s.value("net/selfcheck_fault", true).toBool();
// 洗消四参数按存盘值初始化,否则开机帧发的是硬编码初值而非用户设定值。
// INI 读回的是文本("6.5"),须用 toDouble 解析;协议字段都是整数,四舍五入取整。
m_plungerFlow = qRound(qBound(0.0, s.value("param/pump_flow", 20).toDouble(), 24.0));
m_foamRatio = qRound(qBound(6.0, s.value("param/foam_ratio", 6).toDouble(), 12.0));
m_foamPercent = qRound(qBound(0.0, s.value("param/foam_percent", 3).toDouble(), 10.0));
m_airFlow = qRound(qBound(0.0, s.value("param/air_flow", 100).toDouble(), 340.0));
// 通信超时判定:最慢的下行报文周期 1000ms,连续 2s 无任何合法帧 → 通信异常
m_hbTimer = new QTimer(this);
m_hbTimer->setInterval(2000);
connect(m_hbTimer, &QTimer::timeout, this, &CarCommunication::onHeartbeatTimeout);
// 上行周期定时器,周期取自协议「报文周期时间」列
m_fastTimer = new QTimer(this); // 50ms:底盘 / 机械臂 / 急停
m_fastTimer->setInterval(50);
connect(m_fastTimer, &QTimer::timeout, this, &CarCommunication::onSendFast);
m_midTimer = new QTimer(this); // 100ms:洗消指令 / 洗消调试
m_midTimer->setInterval(100);
connect(m_midTimer, &QTimer::timeout, this, &CarCommunication::onSendMid);
m_slowTimer = new QTimer(this); // 1000ms:气体检测仪
m_slowTimer->setInterval(1000);
connect(m_slowTimer, &QTimer::timeout, this, &CarCommunication::onSendSlow);
// 本端自检序列(见 onSelfCheckTick 的 TODO)
m_selfCheckTimer = new QTimer(this);
m_selfCheckTimer->setInterval(450);
connect(m_selfCheckTimer, &QTimer::timeout, this, &CarCommunication::onSelfCheckTick);
if (m_tel) m_tel->js.resize(6);
}
CarCommunication::~CarCommunication()
{
stop();
}
bool CarCommunication::start()
{
if (m_serial->isOpen())
return true;
m_serial->setPortName(m_device);
m_serial->setBaudRate(m_baud);
m_serial->setDataBits(QSerialPort::Data8);
m_serial->setStopBits(QSerialPort::OneStop);
m_serial->setParity(QSerialPort::NoParity);
m_serial->setFlowControl(QSerialPort::NoFlowControl);
if (!m_serial->open(QIODevice::ReadWrite)) {
// 开发机(Windows)没有 /dev/ttyS7 时走这里:不启动定时器,界面显示通信异常
qWarning() << "车端串口打开失败:" << m_device << m_serial->errorString();
setCommOk(false);
return false;
}
m_buf.clear();
m_hbTimer->start();
m_fastTimer->start();
m_midTimer->start();
m_slowTimer->start();
return true;
}
void CarCommunication::stop()
{
m_hbTimer->stop();
m_fastTimer->stop();
m_midTimer->stop();
m_slowTimer->stop();
m_selfCheckTimer->stop();
if (m_serial->isOpen()) m_serial->close();
m_buf.clear();
}
void CarCommunication::setCommOk(bool ok)
{
if (ok == m_lastCommOk) return;
m_lastCommOk = ok;
if (m_tel) m_tel->commOk = ok;
emit commOkChanged(ok);
}
// ===================== 收包与分流 =====================
void CarCommunication::onReadyRead()
{
m_buf.append(m_serial->readAll());
// 串口是字节流:逐字节找帧头,校验不过则右移一字节继续找(容忍粘包、拆包与杂包)。
// 尾部不足一帧的残段留在 m_buf 里等下次数据。
int off = 0;
while (off + kFrameLen <= m_buf.size()) {
CanFrame f;
std::memcpy(&f, m_buf.constData() + off, kFrameLen);
if (f.header == kFrameHeader && f.dlc == kDataLen
&& f.checksum == canChecksum(f.canId, f.dlc, f.data)) {
handleFrame(f.canId, f.data);
off += kFrameLen;
} else {
++off;
}
}
if (off > 0) m_buf.remove(0, off);
// 防御:长期收不到帧头时缓冲会一直涨,超过上限只保留尾部一帧长度
if (m_buf.size() > 4096) m_buf.remove(0, m_buf.size() - kFrameLen);
}
void CarCommunication::handleFrame(quint32 canId, const quint8* d)
{
if (!m_tel) return;
// 收到任意合法帧即刷新链路(CAN 无独立心跳报文)
m_hbTimer->start();
setCommOk(true);
switch (canId) {
// ---------- 0x18308180 底盘状态1(BMS):电池总况与故障等级 ----------
case kIdVehicleFb1: {
m_tel->volt = phys(intelBits(d, 0, 16), 0.1);
m_tel->soc = int(phys(intelBits(d, 16, 16), 0.1));
m_vsf1Faults.clear();
addFaultByCode(m_vsf1Faults, QString::fromUtf8(kSysChassis), "MCU",
kMcuFaults, int(sizeof(kMcuFaults) / sizeof(CodeText)),
quint8(intelBits(d, 32, 8)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "MCUL",
"底盘电机", quint8(intelBits(d, 40, 2)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "CELLL",
"单体电压过低", quint8(intelBits(d, 45, 2)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "CELLHL",
"单体电压过高", quint8(intelBits(d, 47, 2)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "VOLTL",
"总电压过低", quint8(intelBits(d, 49, 2)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "VOLTHL",
"总电压过高", quint8(intelBits(d, 51, 2)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "DLTPL",
"放电低温", quint8(intelBits(d, 53, 2)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "DHTPL",
"放电高温", quint8(intelBits(d, 55, 2)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "CLTPL",
"充电低温", quint8(intelBits(d, 57, 2)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "CHTPL",
"充电高温", quint8(intelBits(d, 59, 2)));
addLevelFault(m_vsf1Faults, QString::fromUtf8(kSysChassis), "TIL",
"温度不均衡", quint8(intelBits(d, 61, 2)));
break;
}
// ---------- 0x18318180 底盘状态2(BMS):手刹 / 车速 / 继电器 / 通信 ----------
case kIdVehicleFb2: {
m_vsf2Faults.clear();
addLevelFault(m_vsf2Faults, QString::fromUtf8(kSysChassis), "VIL",
"压差不均衡", quint8(intelBits(d, 0, 2)));
addLevelFault(m_vsf2Faults, QString::fromUtf8(kSysChassis), "DOCL",
"放电过流", quint8(intelBits(d, 2, 2)));
addLevelFault(m_vsf2Faults, QString::fromUtf8(kSysChassis), "COCL",
"充电过流", quint8(intelBits(d, 4, 2)));
addLevelFault(m_vsf2Faults, QString::fromUtf8(kSysChassis), "INCL",
"内部通讯", quint8(intelBits(d, 6, 2)));
addLevelFault(m_vsf2Faults, QString::fromUtf8(kSysChassis), "EXCL",
"外部通讯", quint8(intelBits(d, 8, 2)));
addLevelFault(m_vsf2Faults, QString::fromUtf8(kSysChassis), "SOCL",
"SOC过低", quint8(intelBits(d, 10, 2)));
addLevelFault(m_vsf2Faults, QString::fromUtf8(kSysChassis), "INSL",
"绝缘", quint8(intelBits(d, 12, 2)));
// 继电器粘连:0=正常 非 0=故障
struct { int bit; const char* name; const char* code; } relays[] = {
{14, "加热继电器粘连", "HEATW"},
{16, "充电继电器粘连", "CHGW"},
{18, "总负继电器粘连", "NEGW"},
{20, "总正继电器粘连", "POSW"},
};
for (const auto& r : relays) {
if (intelBits(d, r.bit, 2) != 0)
addFault(m_vsf2Faults, QString::fromUtf8(kSysChassis), r.code, 1, r.name);
}
m_tel->handbrake = intelBits(d, 22, 2) != 0;
// Vehi_spd:协议表为 8 位、精度 0.01(0~2.55 km/h)。其后 Vehi_RdyStatus(32)、
// Reserve(34)、All_Comm_FaultCode(40) 的起始位与 8 位布局自洽,故按协议取值。
m_tel->speed = phys(intelBits(d, 24, 8), 0.01);
m_tel->rdyStatus = int(intelBits(d, 32, 2));
const quint8 comm = quint8(intelBits(d, 40, 8)); // All_Comm_FaultCode
addFaultByCode(m_vsf2Faults, QString::fromUtf8(kSysChassis), "COMM",
kCommFaults, int(sizeof(kCommFaults) / sizeof(CodeText)), comm);
break;
}
// ---------- 0x183A8180 底盘状态3:EPS / 制动故障位 ----------
case kIdVehicleFb3: {
m_vsf3Faults.clear();
for (int i = 0; i < 46; ++i) {
if (intelBits(d, i, 1) != 0)
addFault(m_vsf3Faults, QString::fromUtf8(kSysChassis), "EPS", quint8(i),
kVsb3Bits[i]);
}
const quint8 eps = quint8(intelBits(d, 48, 8));
if (eps != 0) {
CanFault f;
f.system = QString::fromUtf8(kSysChassis);
f.code = QString("EPSC") + QString::number(eps);
f.desc = QString("EPS故障码") + QString::number(eps);
m_vsf3Faults.append(f);
}
break;
}
// ---------- 0x183B8180 底盘状态4:挡位 / 驻车 / 方向盘 / 制动电机 ----------
case kIdVehicleFb4: {
m_vsf4Faults.clear();
m_tel->gearPos = int(intelBits(d, 0, 2)); // 0=N 1=D 2=R
m_tel->parking = int(intelBits(d, 2, 2)); // 0=未驻车 1=左电机 2=右电机 3=完全驻车
m_tel->awdMode = int(intelBits(d, 4, 2)); // 0=二驱 1=四驱 2=四驱锁定
m_tel->steerAngle = double(intelBits(d, 8, 16));
const quint8 align = quint8(intelBits(d, 24, 8));
if (align == 0x11) addFault(m_vsf4Faults, QString::fromUtf8(kSysChassis), "ALIGN", 1, "方向盘对中失败");
else if (align != 0xEE && align != 0x55)
addFault(m_vsf4Faults, QString::fromUtf8(kSysChassis), "ALIGN", 0, "方向盘未对中");
// 注:协议表把两个信号的“信号值描述”写反了(Brake_Right 描述成左电机、
// Brake_Left 描述成右电机),此处以信号名为准。
const quint8 br = quint8(intelBits(d, 32, 8));
if (br != 0) addFault(m_vsf4Faults, QString::fromUtf8(kSysChassis), "BRKR", br,
br < 8 ? kBrakeMotFaults[br] : "制动电机故障");
const quint8 bl = quint8(intelBits(d, 40, 8));
if (bl != 0) addFault(m_vsf4Faults, QString::fromUtf8(kSysChassis), "BRKL", bl,
bl < 8 ? kBrakeMotFaults[bl] : "制动电机故障");
break;
}
// ---------- 0x18328180 机械臂状态1:末端位置 ----------
case kIdArmFb1: {
m_tel->pos = QString("X:%1 Y:%2 Z:%3")
.arg(intelSigned(d, 0, 16))
.arg(intelSigned(d, 16, 16))
.arg(intelSigned(d, 32, 16));
break;
}
// ---------- 0x18338180 机械臂状态2:末端姿态与末端速度 ----------
case kIdArmFb2: {
m_tel->pose = QString("RX:%1 RY:%2 RZ:%3")
.arg(intelSigned(d, 0, 16))
.arg(intelSigned(d, 16, 16))
.arg(intelSigned(d, 32, 16));
m_tel->vel = double(intelSigned(d, 48, 8));
break;
}
// ---------- 0x18348180 机械臂状态3:关节角 J1~J4 ----------
case kIdArmFb3: {
if (m_tel->js.size() < 6) m_tel->js.resize(6);
for (int i = 0; i < 4; ++i)
m_tel->js[i] = double(intelSigned(d, i * 16, 16));
break;
}
// ---------- 0x18358180 机械臂状态4:关节角 J5~J6 ----------
case kIdArmFb4: {
if (m_tel->js.size() < 6) m_tel->js.resize(6);
m_tel->js[4] = double(intelSigned(d, 0, 16));
m_tel->js[5] = double(intelSigned(d, 16, 16));
break;
}
// ---------- 0x18368180 洗消工作状态1:模式 / 泵 / 球阀 / 液位 / 流量 ----------
case kIdDecontamFb1: {
const quint8 mode = quint8(intelBits(d, 0, 2));
m_tel->washMode = (mode == 3) ? QString::fromUtf8("消毒作业")
: (mode == 2) ? QString::fromUtf8("消除作业")
: QString("--");
m_tel->washStart = intelBits(d, 2, 1) != 0;
m_tel->plunger = intelBits(d, 3, 1) != 0;
m_tel->air = intelBits(d, 4, 1) != 0;
m_tel->leaf = intelBits(d, 5, 1) != 0;
m_tel->d1open = intelBits(d, 8, 1) != 0;
m_tel->d1close = !m_tel->d1open;
m_tel->d2open = intelBits(d, 9, 1) != 0;
m_tel->d2close = !m_tel->d2open;
m_washFaults.clear();
if (intelBits(d, 10, 1) != 0) // Sys_fault:无分路码,只报总故障
addFault(m_washFaults, QString::fromUtf8(kSysWash), "SYS", 1, "洗消系统故障");
if (intelBits(d, 6, 1) != 0)
addFault(m_washFaults, QString::fromUtf8(kSysWash), "LVL", 1, "A组分/清水液位低");
if (intelBits(d, 7, 1) != 0)
addFault(m_washFaults, QString::fromUtf8(kSysWash), "LVL", 2, "B组分液位低");
m_tel->levelA = double(intelBits(d, 16, 8));
m_tel->levelFoam = double(intelBits(d, 24, 8));
m_tel->flowA = double(intelBits(d, 32, 8));
m_tel->flowB = double(intelBits(d, 40, 8));
m_tel->gasFlow = double(intelBits(d, 48, 8));
m_tel->foam = double(intelBits(d, 56, 8));
break;
}
// ---------- 0x18378180 洗消工作状态2:压力 / 流量反馈 / 泵故障码 ----------
case kIdDecontamFb2: {
m_tel->press = phys(intelBits(d, 0, 8), 0.1);
m_tel->airPress = phys(intelBits(d, 8, 8), 0.1);
m_tel->flowPlungerFb = double(intelBits(d, 16, 8));
m_tel->flowAirFb = double(intelBits(d, 32, 8));
m_tel->flowVaneFb = double(intelBits(d, 40, 8));
m_pumpFaults.clear();
addFaultByCode(m_pumpFaults, QString::fromUtf8(kSysWash), "AIR",
kAirCompFaults, int(sizeof(kAirCompFaults) / sizeof(CodeText)),
quint8(intelBits(d, 48, 8)));
addFaultByCode(m_pumpFaults, QString::fromUtf8(kSysWash), "PLG",
kPlungerFaults, int(sizeof(kPlungerFaults) / sizeof(CodeText)),
quint8(intelBits(d, 56, 8)));
break;
}
// ---------- 0x18388180 检测仪结果状态1:状态 / 毒物种类与等级 / 告警 ----------
case kIdDetectorFb1: {
const quint8 sta = quint8(intelBits(d, 0, 8));
const quint8 warn = quint8(intelBits(d, 24, 8));
// 2=计算目标基线 3=等待稳定 4=检测 5=等待点火 6=确定初始基线
m_tel->gasHost = (warn & 0x04) ? QString("fault")
: (sta == 4) ? QString("detecting")
: QString("standby");
// Toxic_types 是位组合:1=P类 2=S类 4=HNO类 8=CH类 16=AS类
const quint8 types = quint8(intelBits(d, 8, 8));
static const struct { quint8 bit; const char* name; } kTypes[] = {
{1, "P类"}, {2, "S类"}, {4, "HNO类"}, {8, "CH类"}, {16, "AS类"},
};
QStringList names;
for (const auto& t : kTypes)
if (types & t.bit) names << QString::fromUtf8(t.name);
m_tel->gasType = names.isEmpty() ? QString("--") : names.join("/");
const quint8 lvl = quint8(intelBits(d, 16, 8));
m_tel->gasLevel = (lvl >= 1 && lvl <= 5) ? QString("%1级").arg(lvl) : QString("--");
m_detectorFaults.clear();
for (const CodeText& w : kDetectorWarns)
if (warn & w.code)
m_detectorFaults.append(CanFault{QString::fromUtf8(kSysDetector),
QString::fromLatin1("W") + QString::number(w.code),
QString::fromUtf8(w.text)});
break;
}
// ---------- 0x18398180 检测仪结果状态2:40 位浓度(ppb) ----------
case kIdDetectorFb2: {
m_tel->gasConc = double(intelBits(d, 0, 40));
break;
}
default:
return; // 未知 canId 不触发刷新(分类四本次未实现)
}
cacheFaults();
emit telemetryUpdated();
}
// 把各帧的故障合并进 Telemetry,并把“新出现”的故障写入故障历史
void CarCommunication::cacheFaults()
{
if (!m_tel) return;
const QVector<QVector<CanFault>*> groups = {
&m_vsf1Faults, &m_vsf2Faults, &m_vsf3Faults, &m_vsf4Faults,
};
QStringList chassis;
for (const QVector<CanFault>* g : groups)
for (const CanFault& f : *g) chassis << f.desc;
m_tel->chassisFaults = chassis;
QStringList wash;
for (const CanFault& f : m_washFaults) wash << f.desc;
for (const CanFault& f : m_detectorFaults) wash << f.desc;
m_tel->washFaults = wash;
QStringList pump;
for (const CanFault& f : m_pumpFaults) pump << f.desc;
m_tel->pumpFaults = pump;
// 周期报文会反复报到同一故障,只在首次出现时写故障历史
const QVector<QVector<CanFault>*> all = {
&m_vsf1Faults, &m_vsf2Faults, &m_vsf3Faults, &m_vsf4Faults,
&m_washFaults, &m_pumpFaults, &m_detectorFaults,
};
for (const QVector<CanFault>* g : all) {
for (const CanFault& f : *g) {
const QString key = f.system + "|" + f.code;
if (m_reportedFaults.contains(key)) continue;
m_reportedFaults.insert(key);
emit faultAppended(f.system, f.code, f.desc);
}
}
}
// ===================== 发包 =====================
void CarCommunication::sendFrame(quint32 canId, const quint8* data)
{
if (!m_serial->isOpen()) return;
CanFrame f;
f.header = kFrameHeader;
f.canId = canId;
f.dlc = kDataLen;
std::memcpy(f.data, data, kDataLen);
f.checksum = canChecksum(canId, f.dlc, f.data);
m_serial->write(reinterpret_cast<const char*>(&f), kFrameLen);
}
// 50ms 组:Chassis_ControlCmd / Arm_ControlCmd1 / Arm_ControlCmd2 / Emergency_Cmd
void CarCommunication::onSendFast()
{
{
quint8 d[8] = {0};
setIntelBits(d, 0, 1, 1); // Chassis_modeCmd 有效
setIntelBits(d, 1, 2, 0); // MCU_modeCmd 底盘预留
setIntelBits(d, 3, 1, 1); // MCU_enable 有效
setIntelBits(d, 4, 4, 0); // Reserve 置 0
setIntelBits(d, 8, 16, m_gear);
setIntelBits(d, 24, 16, m_targetSpd);
setIntelBits(d, 40, 16, m_turning);
sendFrame(kIdChassisCmd, d);
}
{
quint8 d[8] = {0};
setIntelBits(d, 0, 1, m_armMode ? 1 : 0); // Arm_modeCmd
setIntelBits(d, 1, 1, m_armMode ? 1 : 0); // Arm_enable
setIntelBits(d, 2, 6, 0); // Reserve
setIntelBits(d, 8, 16, m_updown);
setIntelBits(d, 24, 16, m_leftright);
setIntelBits(d, 40, 16, m_frontrear);
sendFrame(kIdArmCmd1, d);
}
{
quint8 d[8] = {0};
setIntelBits(d, 0, 16, m_roll);
setIntelBits(d, 16, 16, m_pitch);
setIntelBits(d, 32, 16, m_yaw);
setIntelBits(d, 48, 8, m_homingPause);
sendFrame(kIdArmCmd2, d);
}
{
quint8 d[8] = {0};
setIntelBits(d, 0, 8, m_emergStop);
sendFrame(kIdEmergency, d);
}
}
// 100ms 组:Decontam_ControlCmd / Decontam_DebugCmd
void CarCommunication::onSendMid()
{
{
quint8 d[8] = {0};
setIntelBits(d, 0, 2, m_decontamMode);
setIntelBits(d, 2, 1, m_decontamStart ? 1 : 0);
setIntelBits(d, 3, 5, 0); // Reserve 置 0
// 发泡倍数 / 泡沫比例 / 空压机流量 / 柱塞泵流量:均由页面保存时经 sendSetParam
// 写入缓存(构造时先按 config.ini 初始化),这里直接读缓存组帧,不再每帧读文件。
// 取值范围按协议「信号值描述」列收口:发泡倍数 6-12 倍、泡沫比例 0-10%、
// 空压机流量 0-340 L/min;界面 ParamControl 的可调范围与之一致。
setIntelBits(d, 8, 8, quint64(qBound(6, m_foamRatio, 12)));
setIntelBits(d, 16, 8, quint64(qBound(0, m_foamPercent, 10)));
setIntelBits(d, 24, 8, quint64(qBound(0, m_airFlow, 340)));
setIntelBits(d, 32, 8, quint64(qBound(0, m_plungerFlow, 24)));
sendFrame(kIdDecontamCmd, d);
}
{
quint8 d[8] = {0};
setIntelBits(d, 0, 1, m_debugMode ? 1 : 0);
setIntelBits(d, 1, 1, m_plungerOn ? 1 : 0);
setIntelBits(d, 2, 1, m_airOn ? 1 : 0);
setIntelBits(d, 3, 1, m_vaneOn ? 1 : 0);
setIntelBits(d, 4, 1, m_d1Open ? 1 : 0);
setIntelBits(d, 5, 1, m_d2Open ? 1 : 0);
setIntelBits(d, 6, 2, 0); // Debug_Reserve1
setIntelBits(d, 8, 16, quint64(qBound(0, int(m_airRpm), 3000)));
setIntelBits(d, 24, 16, quint64(qBound(0, int(m_plungerRpm), 1450)));
setIntelBits(d, 40, 16, quint64(qBound(0, int(m_vaneRpm), 2000)));
// 界面每个泵只有一组“启动/停止”,上电与启动同源
setIntelBits(d, 56, 1, m_airOn ? 1 : 0);
setIntelBits(d, 57, 1, m_plungerOn ? 1 : 0);
setIntelBits(d, 58, 1, m_vaneOn ? 1 : 0);
sendFrame(kIdDecontamDbg, d);
}
}
// 1000ms 组:Detector_ControlCmd
void CarCommunication::onSendSlow()
{
quint8 d[8] = {0};
setIntelBits(d, 0, 8, m_detectorCmd);
sendFrame(kIdDetectorCmd, d);
}
// ===================== 上行命令 → 控制状态 =====================
void CarCommunication::sendChassisMove(const QString& dir, double speed, const QString& turn)
{
// 档位推杆量:后 0 / 中 2048 / 前 4096
if (dir == "fwd") m_gear = 4096;
else if (dir == "back") m_gear = 0;
else m_gear = kRcAxisMid;
// 转向推杆量:左 0 / 中 2048 / 右 4096
if (turn == "left") m_turning = 0;
else if (turn == "right") m_turning = 4096;
else m_turning = kRcAxisMid;
// Vehi_TargetSpd 精度 0.01 km/h,量程 0~500(即 0~5.00 km/h)
m_targetSpd = quint16(qBound(0, int(qRound(speed * 100.0)), 500));
}
void CarCommunication::sendArmMove(const QString& axis, int dir)
{
// 轴位推杆量:dir +1 / -1 / 0 → 4096 / 0 / 中位
const quint16 v = (dir > 0) ? 4096 : (dir < 0) ? 0 : kRcAxisMid;
if (axis == "z") m_updown = v;
else if (axis == "x") m_leftright = v;
else if (axis == "y") m_frontrear = v;
else if (axis == "roll") m_roll = v;
else if (axis == "pitch") m_pitch = v;
else if (axis == "yaw") m_yaw = v;
}
void CarCommunication::sendArmReturn()
{
m_homingPause = 0xAA; // 系统回位
}
void CarCommunication::sendArmPause()
{
m_homingPause = 0x55; // 暂停回位
}
void CarCommunication::sendGasStart()
{
m_detectorCmd = 0xAA; // 启动检测
}
void CarCommunication::sendGasStop()
{
m_detectorCmd = 0x55; // 停止检测
}
void CarCommunication::sendWashStart(const QString& mode)
{
m_decontamMode = (mode == "disinfect") ? 3 : 2; // 3=手动消毒 2=手动消除
m_decontamStart = true;
}
void CarCommunication::sendWashStop()
{
m_decontamStart = false;
}
void CarCommunication::sendBallValve(int valve, bool open)
{
if (valve == 1) m_d1Open = open;
else m_d2Open = open;
m_debugMode = true; // 球阀属于调试指令
}
void CarCommunication::sendPumpPower(const QString& pump, bool on)
{
if (pump == "plunger") m_plungerOn = on;
else if (pump == "air") m_airOn = on;
else if (pump == "leaf") m_vaneOn = on; // 叶片泵 → VanePump
m_debugMode = true;
}
void CarCommunication::sendPumpRpm(const QString& pump, int rpm)
{
if (pump == "plunger") m_plungerRpm = quint16(qBound(0, rpm, 1450));
else if (pump == "air") m_airRpm = quint16(qBound(0, rpm, 3000));
else if (pump == "leaf") m_vaneRpm = quint16(qBound(0, rpm, 2000));
}
void CarCommunication::sendSetParam(const QString& key, double value)
{
// 参数缓存与 config.ini 同源:页面保存时先落盘再调这里,周期帧只读缓存。
if (key == "param/speed")
m_targetSpd = quint16(qBound(0, int(qRound(value * 100.0)), 500));
else if (key == "param/pump_flow")
// 字段是 0~24 的整数,界面刻度是 0.1:必须四舍五入而不是截断,
// 否则 0.1 累加出的尾差(面板 5.0 实际存 4.9999999999999982)会下发成 4
m_plungerFlow = qRound(qBound(0.0, value, 24.0));
else if (key == "param/foam_ratio")
m_foamRatio = qRound(qBound(6.0, value, 12.0));
else if (key == "param/foam_percent")
m_foamPercent = qRound(qBound(0.0, value, 10.0));
else if (key == "param/air_flow")
m_airFlow = qRound(qBound(0.0, value, 340.0));
}
void CarCommunication::sendSelfCheckStart()
{
m_selfCheckIndex = 0;
m_selfCheckTimer->start();
}
void CarCommunication::sendEstop()
{
m_emergStop = 0x55;
}
void CarCommunication::sendEstopReset()
{
m_emergStop = 0;
}
// TODO:CAN 矩阵(sheet RC)没有自检报文,故暂由本端跑完 10 项序列,仅为维持
// ④自检 → ⑤作业选择 的流程不中断(onSelfCheckDone 是唯一点亮“作业选择”的地方)。
// 拿到真实自检报文后,改为解析该报文并删除此本地驱动。
void CarCommunication::onSelfCheckTick()
{
bool ok = true;
if (m_selfCheckFault && m_selfCheckIndex == 6) ok = false; // 演示:球阀D1关闭故障
emit selfCheckItem(m_selfCheckIndex, ok);
++m_selfCheckIndex;
if (m_selfCheckIndex >= 10) {
m_selfCheckTimer->stop();
emit selfCheckDone(!m_selfCheckFault);
}
}
void CarCommunication::onHeartbeatTimeout()
{
setCommOk(false);
}
+152
View File
@@ -0,0 +1,152 @@
#ifndef CAR_H
#define CAR_H
#include <QByteArray>
#include <QObject>
#include <QSet>
#include <QString>
#include <QTimer>
#include <QVector>
#include "global.h"
class QSerialPort;
// 摇杆量协议范围 0~4096,中位 2048(分类一各轴、Vehi_gear/Vehi_turning 通用)
static const int kRcAxisMid = 2048;
// CAN 反馈只给故障码,这里带上系统与中文描述:
// system 取界面故障历史既有的四个分组名,desc 供页面直接显示,code 供故障查询追溯。
struct CanFault {
QString system;
QString code;
QString desc;
};
// ===================== 车端通信层(CAN 报文经 /dev/ttyS7 串口收发) =====================
// 依据《洗消机器人遥控器通信协议20260810》sheet RC 实现的 19 条报文:
// 上行 分类一 7 条(遥控器 → 车端):Chassis_ControlCmd / Arm_ControlCmd1 / Arm_ControlCmd2 /
// Decontam_ControlCmd / Decontam_DebugCmd / Detector_ControlCmd / Emergency_Cmd,
// 按协议「报文周期时间」列分 50 / 100 / 1000ms 三组重发;
// 下行 分类二/三 12 条(车端 → 遥控器):底盘 4 条、机械臂 4 条、洗消 2 条、气体检测仪 2 条,
// 按 canId 分流转 handleFrame() 写入 Telemetry。
// 全部为 29 位扩展帧、信号一律 Intel(小端)位序。
// 串口是字节流,故线上帧统一为 18 字节:帧头 0x5AA5FF00 + canId + DLC + 8 字节数据 + 校验和。
// 通信超时判定:连续 2s 未收到任何合法帧则判为通信异常(最慢的下行周期 1000ms)。
class CarCommunication : public QObject
{
Q_OBJECT
public:
explicit CarCommunication(Telemetry* tel, QObject* parent = nullptr);
~CarCommunication() override;
bool start();
void stop();
QString deviceName() const { return m_device; }
// 行车/机械臂模式(面板旋钮或遥控器报文)→ Arm_ControlCmd1 的 Arm_modeCmd / Arm_enable
void setArmMode(bool arm) { m_armMode = arm; }
// ---------- 上行命令(分类一)----------
// 命令只更新本端控制状态,实际报文由周期定时器编码发送(CAN 控制帧本就是周期报文)。
// turn 非空("left"/"right")时与 dir 同时下发,支持边前进/后退边转向
void sendChassisMove(const QString& dir, double speed, const QString& turn = QString());
void sendArmMove(const QString& axis, int dir); // dir: +1 / -1 / 0(停止)
void sendArmReturn();
void sendArmPause();
void sendGasStart();
void sendGasStop();
void sendWashStart(const QString& mode); // "clean" / "disinfect"
void sendWashStop();
void sendBallValve(int valve, bool open); // valve: 1=D1, 2=D2
void sendPumpPower(const QString& pump, bool on); // "plunger"/"air"/"leaf"
void sendPumpRpm(const QString& pump, int rpm);
void sendSetParam(const QString& key, double value);
void sendSelfCheckStart(); // 启动本端自检序列(不发 CAN)
void sendEstop();
void sendEstopReset();
signals:
void telemetryUpdated(); // 任意遥测包到达(页面据此刷新)
void commOkChanged(bool ok); // 通信状态变化
void selfCheckItem(int index, bool ok); // 自检单项结果(0~9)
void selfCheckDone(bool allOk); // 自检完成
void faultAppended(const QString& system, const QString& code, const QString& desc);
private slots:
void onReadyRead();
void onSendFast(); // 50ms 组:底盘 / 机械臂 / 急停
void onSendMid(); // 100ms 组:洗消指令 / 洗消调试
void onSendSlow(); // 1000ms 组:气体检测仪
void onHeartbeatTimeout();
void onSelfCheckTick();
private:
// 收包:按 canId 分流;发包:18 字节封帧后写串口
void handleFrame(quint32 canId, const quint8* d);
void sendFrame(quint32 canId, const quint8* data);
void cacheFaults();
void setCommOk(bool ok);
Telemetry* m_tel = nullptr;
QSerialPort* m_serial = nullptr;
QByteArray m_buf; // 串口字节流缓冲:帧头滑窗重同步用
QTimer* m_hbTimer = nullptr;
QTimer* m_fastTimer = nullptr;
QTimer* m_midTimer = nullptr;
QTimer* m_slowTimer = nullptr;
QTimer* m_selfCheckTimer = nullptr;
QString m_device = "/dev/ttyS7";
int m_baud = 500000;
bool m_lastCommOk = true;
bool m_armMode = false;
// 本地自检序列(CAN 矩阵无自检报文,见 .cpp TODO)
int m_selfCheckIndex = 0;
bool m_selfCheckFault = true;
// ---- 分类一 上行控制状态 ----
// Chassis_ControlCmd 0x18208081
quint16 m_gear = kRcAxisMid; // Vehi_gear:后 0 / 中 2048 / 前 4096
quint16 m_turning = kRcAxisMid; // Vehi_turning:左 0 / 中 2048 / 右 4096
quint16 m_targetSpd = 0; // Vehi_TargetSpd,精度 0.01,0~500(0~5.00 km/h)
// Arm_ControlCmd1/2 0x18218081 / 0x18228081 —— 六轴位 0~4096
quint16 m_updown = kRcAxisMid;
quint16 m_leftright = kRcAxisMid;
quint16 m_frontrear = kRcAxisMid;
quint16 m_roll = kRcAxisMid;
quint16 m_pitch = kRcAxisMid;
quint16 m_yaw = kRcAxisMid;
quint8 m_homingPause = 0x55; // System_homing_pause:0xAA=系统回位 0x55=暂停回位
// Decontam_ControlCmd 0x18238081
// 四个参数缓存与 config.ini 的 param/* 同源:构造时按存盘值初始化,
// 页面保存时经 sendSetParam 更新,周期帧只读缓存(不再每帧读文件)。
quint8 m_decontamMode = 0; // 2=手动消除 3=手动消毒
bool m_decontamStart = false;
int m_plungerFlow = 20; // PlungerPump_flow,L/min,0~24(param/pump_flow)
int m_foamRatio = 6; // Expansion_ratioCmd,倍,6~12(param/foam_ratio)
int m_foamPercent = 3; // Foam_ratio,%,0~10(param/foam_percent)
int m_airFlow = 100; // AirComp_flow,L/min,0~340(param/air_flow)
// Decontam_DebugCmd 0x18248081
bool m_debugMode = false; // 0=正常模式 1=调试模式(球阀/泵操作置 1)
bool m_plungerOn = false, m_airOn = false, m_vaneOn = false;
bool m_d1Open = false, m_d2Open = false;
quint16 m_airRpm = 0, m_plungerRpm = 0, m_vaneRpm = 0;
// Detector_ControlCmd 0x18258081 / Emergency_Cmd 0x18268081
quint8 m_detectorCmd = 0x55; // 0xAA=启动检测 0x55=停止检测
quint8 m_emergStop = 0; // 0x55=急停 0=正常
// ---- 下行故障缓存 ----
// 底盘故障由 VSFB1~4 四帧共同构成,各自重建后再合并,避免互相覆盖。
// 气体检测仪告警周期 1000ms、洗消故障 200ms,分开存放,否则后到的会把先到的清掉。
QVector<CanFault> m_vsf1Faults, m_vsf2Faults, m_vsf3Faults, m_vsf4Faults;
QVector<CanFault> m_washFaults, m_pumpFaults, m_detectorFaults;
QSet<QString> m_reportedFaults; // 已记入故障历史的“系统|码”,周期性报文只上报新增故障
};
#endif // CAR_H
-223
View File
@@ -1,223 +0,0 @@
#include "mockvehicle.h"
#include <QSettings>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QHostAddress>
#include <QNetworkDatagram>
#include <QtMath>
MockVehicle::MockVehicle(QObject* parent)
: QObject(parent)
{
QSettings s;
m_localPort = s.value("net/local_port", 9000).toInt(); // 发送到遥控端
m_remotePort = s.value("net/remote_port", 9001).toInt(); // 本端监听
m_enabled = s.value("net/mock", true).toBool();
m_plantSelfCheckFault = s.value("net/selfcheck_fault", false).toBool();
if (!m_enabled)
return;
m_socket = new QUdpSocket(this);
m_socket->bind(QHostAddress::AnyIPv4, m_remotePort,
QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint);
connect(m_socket, &QUdpSocket::readyRead, this, &MockVehicle::onReadyRead);
m_sendTimer = new QTimer(this);
connect(m_sendTimer, &QTimer::timeout, this, &MockVehicle::onSendTick);
m_sendTimer->start(500);
m_selfCheckTimer = new QTimer(this);
connect(m_selfCheckTimer, &QTimer::timeout, this, &MockVehicle::onSelfCheckTick);
}
void MockVehicle::sendJson(const QJsonObject& obj)
{
QByteArray data = QJsonDocument(obj).toJson(QJsonDocument::Compact);
m_socket->writeDatagram(data, QHostAddress("127.0.0.1"), m_localPort);
}
void MockVehicle::onReadyRead()
{
while (m_socket->hasPendingDatagrams()) {
QNetworkDatagram dg = m_socket->receiveDatagram();
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(dg.data(), &err);
if (err.error == QJsonParseError::NoError && doc.isObject())
handleCommand(doc.object());
}
}
void MockVehicle::handleCommand(const QJsonObject& o)
{
const QString t = o.value("t").toString();
if (t == "chassis_move") {
const QString dir = o.value("dir").toString();
m_speed = o.value("speed").toDouble();
if (dir == "stop") m_speed = 0;
} else if (t == "arm_move") {
int d = o.value("dir").toInt();
if (d == 0) return;
if (m_js.size() >= 6)
m_js[1] = qBound(-90.0, m_js[1] + d * 1.0, 90.0);
} else if (t == "arm_return") {
m_armReturn = "returning";
m_armHomed = false;
} else if (t == "arm_pause") {
m_armReturn = "idle";
} else if (t == "gas_start") {
m_gasRunning = true;
} else if (t == "gas_stop") {
m_gasRunning = false;
} else if (t == "wash_start") {
m_washRunning = true;
m_washMode = (o.value("mode").toString() == "disinfect") ? "手动消毒" : "手动消除";
m_pPlunger = true; m_pAir = true; m_pLeaf = true;
} else if (t == "wash_stop") {
m_washRunning = false;
m_pPlunger = false; m_pAir = false; m_pLeaf = false;
} else if (t == "ball_valve") {
int v = o.value("valve").toInt();
bool open = o.value("open").toBool();
if (v == 1) m_valve1Open = open;
else m_valve2Open = open;
} else if (t == "pump_power") {
const QString p = o.value("pump").toString();
bool on = o.value("on").toBool();
if (p == "plunger") m_pPlunger = on;
else if (p == "air") m_pAir = on;
else if (p == "leaf") m_pLeaf = on;
} else if (t == "pump_rpm") {
const QString p = o.value("pump").toString();
int rpm = o.value("rpm").toInt();
if (p == "plunger") m_plungerRpm = rpm;
else if (p == "air") m_airRpm = rpm;
else if (p == "leaf") m_leafRpm = rpm;
} else if (t == "selfcheck_start") {
m_selfCheckActive = true;
m_selfCheckIndex = 0;
m_selfCheckTimer->start(450);
} else if (t == "estop") {
m_estop = true;
m_washRunning = false; m_gasRunning = false;
m_pPlunger = false; m_pAir = false; m_pLeaf = false;
QJsonObject o2; o2["t"] = "estop"; o2["triggered"] = true;
sendJson(o2);
} else if (t == "estop_reset") {
m_estop = false;
QJsonObject o2; o2["t"] = "estop"; o2["triggered"] = false;
sendJson(o2);
}
// set_param 仅用于演示,无需额外处理
}
void MockVehicle::onSendTick()
{
sendHeartbeat();
sendTelemetry();
}
void MockVehicle::sendHeartbeat()
{
QJsonObject o; o["t"] = "heartbeat"; o["ok"] = true;
sendJson(o);
}
void MockVehicle::sendTelemetry()
{
// 底盘
{
QJsonObject o;
QJsonArray faults;
if (m_estop) faults << QStringLiteral("急停触发");
o["t"] = "chassis";
o["soc"] = 85; o["volt"] = 360.0; o["speed"] = m_speed;
o["handbrake"] = true; o["faults"] = faults;
sendJson(o);
}
// 机械臂
{
if (m_armReturn == "returning") {
bool allZero = true;
for (double& a : m_js) { if (qAbs(a) > 1.0) a *= 0.7; else a = 0; if (qAbs(a) > 0.1) allZero = false; }
if (allZero) { m_armReturn = "done"; m_armHomed = true; }
}
QJsonObject o;
QJsonArray js;
for (double a : m_js) js << a;
o["t"] = "arm";
o["js"] = js;
o["pos"] = QString("X:%1 Y:%2 Z:%3").arg(1200).arg(300).arg(800);
o["pose"] = "RX:0 RY:0 RZ:0";
o["vel"] = (m_armReturn == "returning") ? 0.5 : 0.0;
o["return_state"] = m_armReturn;
o["homed"] = m_armHomed;
sendJson(o);
}
// 洗消
{
QJsonObject o;
o["t"] = "wash";
o["mode"] = m_washRunning ? m_washMode : "--";
o["flowA"] = m_washRunning ? 20.0 : 0.0;
o["flowB"] = m_washRunning ? 0.5 : 0.0;
o["gasFlow"] = m_washRunning ? 100.0 : 0.0;
o["press"] = m_washRunning ? 0.3 : 0.0;
o["airPress"] = m_washRunning ? 0.35 : 0.0;
o["foam"] = (m_washMode == "手动消毒") ? 6.0 : 8.0;
o["faults"] = QJsonArray();
sendJson(o);
}
// 液位
{
QJsonObject o; o["t"] = "tank"; o["a"] = 100.0; o["foam"] = 30.0;
sendJson(o);
}
// 气体检测仪
{
static int step = 0; step++;
double conc = m_gasRunning ? 250.0 + 50.0 * qSin(step * 0.4) : 0.0;
QJsonObject o;
o["t"] = "gas";
o["host"] = m_gasRunning ? "detecting" : "standby";
o["type"] = m_gasRunning ? "S类" : "--";
o["level"] = m_gasRunning ? "2级" : "--";
o["conc"] = qMax(0.0, conc);
sendJson(o);
}
// 球阀
{
QJsonObject o;
o["t"] = "valve";
o["d1open"] = m_valve1Open; o["d1close"] = !m_valve1Open;
o["d2open"] = m_valve2Open; o["d2close"] = !m_valve2Open;
o["fault"] = "";
sendJson(o);
}
// 泵
{
QJsonObject o;
o["t"] = "pump";
o["plunger"] = m_pPlunger; o["air"] = m_pAir; o["leaf"] = m_pLeaf;
o["faults"] = QJsonArray();
sendJson(o);
}
}
void MockVehicle::onSelfCheckTick()
{
// 自检 10 项:通信系统/底盘通信/机械臂通信/洗消通信/传感器信号/清水液位/球阀D1开/球阀D1关/球阀D2开/球阀D2关
bool ok = true;
if (m_plantSelfCheckFault && m_selfCheckIndex == 6) ok = false; // 球阀D1关闭故障
QJsonObject o; o["t"] = "selfcheck"; o["item"] = m_selfCheckIndex; o["ok"] = ok;
sendJson(o);
m_selfCheckIndex++;
if (m_selfCheckIndex >= 10) {
m_selfCheckTimer->stop();
m_selfCheckActive = false;
QJsonObject o2; o2["t"] = "selfcheck_done"; o2["all_ok"] = !m_plantSelfCheckFault;
sendJson(o2);
}
}
-58
View File
@@ -1,58 +0,0 @@
#ifndef MOCKVEHICLE_H
#define MOCKVEHICLE_H
#include <QObject>
#include <QUdpSocket>
#include <QTimer>
#include <QVector>
// ===================== 内置模拟车载端(离线演示) =====================
// 以独立 UDP 套接字在回环地址上与遥控端通信:向 UI 端口周期发送遥测,
// 接收并响应 UI 下发的控制命令(行驶/机械臂/球阀/泵/洗消/自检/急停等)。
class MockVehicle : public QObject
{
Q_OBJECT
public:
explicit MockVehicle(QObject* parent = nullptr);
bool enabled() const { return m_enabled; }
private slots:
void onReadyRead();
void onSendTick();
void onSelfCheckTick();
private:
void sendJson(const QJsonObject& obj);
void sendTelemetry();
void sendHeartbeat();
void handleCommand(const QJsonObject& o);
QUdpSocket* m_socket = nullptr;
QTimer* m_sendTimer = nullptr;
QTimer* m_selfCheckTimer = nullptr;
int m_localPort = 9000; // 遥控端收包端口(本端发送目标)
int m_remotePort = 9001; // 本端绑定端口(遥控端发包目标)
bool m_enabled = true;
bool m_plantSelfCheckFault = false;
// 模拟车辆状态
bool m_estop = false;
bool m_gasRunning = false;
bool m_washRunning = false;
QString m_washMode = "手动消除";
bool m_valve1Open = false; // D1 控制状态(开/关)
bool m_valve2Open = false;
bool m_pPlunger = false, m_pAir = false, m_pLeaf = false;
double m_speed = 3.45;
int m_airRpm = 300, m_plungerRpm = 200, m_leafRpm = 200;
QVector<double> m_js{30, 20, 0, 90, 45, 0};
bool m_armHomed = true;
QString m_armReturn = "idle";
// 自检序列
bool m_selfCheckActive = false;
int m_selfCheckIndex = 0;
};
#endif // MOCKVEHICLE_H
+151
View File
@@ -0,0 +1,151 @@
#include "uart.h"
#include <QApplication>
#include <QDebug>
#include <QSerialPort>
#include <QSettings>
namespace {
const int kFrameLen = 24;
const quint8 kHead = 0x11;
const quint8 kTail = 0x22;
// 收不到帧头时缓冲上限,超过则只保留尾部一帧长度,避免长期涨下去
const int kBufMax = 1024;
// 小端无符号 16 位
inline int leU16(const char* p)
{
return int(quint8(p[0]) | (quint8(p[1]) << 8));
}
// 摇杆量按协议范围 0~4096 收口(外部输入,越界值不采信)
inline int axis(const char* p)
{
return qBound(0, leU16(p), 4096);
}
// 校验帧头、帧尾与和校验(BYTE0~BYTE22 之和的低 8 位)
bool parseFrame(const char* d, RcFrame& f)
{
if (quint8(d[0]) != kHead || quint8(d[22]) != kTail)
return false;
quint8 sum = 0;
for (int i = 0; i < 23; ++i)
sum += quint8(d[i]);
if (sum != quint8(d[23]))
return false;
const quint8 flags = quint8(d[1]);
f.driveMode = (flags & 0x01) != 0;
f.armMode = (flags & 0x02) != 0;
f.armEnabled = (flags & 0x04) != 0;
f.chassisEnabled = (flags & 0x08) != 0;
f.estop = (flags & 0x10) != 0;
f.gear = axis(d + 3);
f.steer = axis(d + 5);
f.posZ = axis(d + 7);
f.posX = axis(d + 9);
f.posY = axis(d + 11);
f.roll = axis(d + 13);
f.pitch = axis(d + 15);
f.yaw = axis(d + 17);
f.battery = qBound(0, int(quint8(d[19])), 100);
return true;
}
} // namespace
RcCommunication::RcCommunication(QObject* parent)
: QObject(parent)
{
m_serial = new QSerialPort(this);
connect(m_serial, &QSerialPort::readyRead, this, &RcCommunication::onReadyRead);
// 与 AppContext 共用程序目录的 config.ini:默认构造读不到 rc/* 这些键
// (本工程没有 setOrganizationName/setApplicationName)
QSettings s(QApplication::applicationDirPath() + "/config.ini", QSettings::IniFormat);
m_device = s.value("rc/device", "/dev/ttyS5").toString();
m_baud = s.value("rc/baud", 115200).toInt();
// 协议发送周期 100ms,连续 500ms 无合法帧判为链路断开
m_linkTimer = new QTimer(this);
m_linkTimer->setInterval(500);
connect(m_linkTimer, &QTimer::timeout, this, &RcCommunication::onLinkTimeout);
}
RcCommunication::~RcCommunication()
{
stop();
}
bool RcCommunication::start()
{
if (m_serial->isOpen())
return true;
m_serial->setPortName(m_device);
m_serial->setBaudRate(m_baud);
m_serial->setDataBits(QSerialPort::Data8);
m_serial->setStopBits(QSerialPort::OneStop);
m_serial->setParity(QSerialPort::NoParity);
m_serial->setFlowControl(QSerialPort::NoFlowControl);
if (!m_serial->open(QIODevice::ReadWrite)) {
qWarning() << "遥控器串口打开失败:" << m_device << m_serial->errorString();
return false;
}
m_buf.clear();
m_linkTimer->start();
return true;
}
void RcCommunication::stop()
{
m_linkTimer->stop();
if (m_serial->isOpen()) m_serial->close();
m_buf.clear();
}
void RcCommunication::onReadyRead()
{
m_buf.append(m_serial->readAll());
// 逐字节扫描合法帧:容忍前导垃圾与粘包,未对齐时向后找下一个帧头。
// 串口是字节流,一帧可能被切开,尾部不足一帧的残段留在 m_buf 等下次数据。
int off = 0;
while (off + kFrameLen <= m_buf.size()) {
RcFrame f;
if (parseFrame(m_buf.constData() + off, f)) {
onFrame(f);
off += kFrameLen;
} else {
++off;
}
}
if (off > 0) m_buf.remove(0, off);
if (m_buf.size() > kBufMax) m_buf.remove(0, m_buf.size() - kFrameLen);
}
void RcCommunication::onFrame(const RcFrame& frame)
{
m_linkTimer->start(); // 重置链路超时
if (!m_linkOk) {
m_linkOk = true;
emit linkChanged(true);
}
emit frameReceived(frame);
}
void RcCommunication::onLinkTimeout()
{
if (m_linkOk) {
m_linkOk = false;
emit linkChanged(false);
}
}
+73
View File
@@ -0,0 +1,73 @@
#ifndef UART_H
#define UART_H
#include <QByteArray>
#include <QObject>
#include <QTimer>
#include <QMetaType>
#include <QString>
class QSerialPort;
// ===================== 遥控器帧(24 字节二进制协议) =====================
// 周期 100ms,1 字节对齐,多字节字段小端。
// BYTE0=0x11 帧头,BYTE22=0x22 帧尾,BYTE23 = BYTE0~BYTE22 之和的低 8 位。
// 本结构只承载解析结果,不含任何控制策略。
struct RcFrame {
// BYTE1 控制模式与使能标志位
bool driveMode = false; // bit0 行车模式
bool armMode = false; // bit1 机械臂模式
bool armEnabled = false; // bit2 机械臂使能
bool chassisEnabled = false; // bit3 底盘使能
bool estop = false; // bit4 急停
// 摇杆量:0~4096,中位 2048
int gear = 2048; // BYTE3-4 整车档位(底盘模式-左摇杆 Y 轴)
int steer = 2048; // BYTE5-6 整车转向(底盘模式-左摇杆 X 轴)
int posZ = 2048; // BYTE7-8 末端位置上下(左摇杆 Z 轴)
int posX = 2048; // BYTE9-10 末端位置左右(左摇杆 X 轴)
int posY = 2048; // BYTE11-12 末端位置前后(左摇杆 Y 轴)
int roll = 2048; // BYTE13-14 末端姿态横滚(右摇杆 X 轴)
int pitch = 2048; // BYTE15-16 末端姿态俯仰(右摇杆 Y 轴)
int yaw = 2048; // BYTE17-18 末端姿态偏航(右摇杆 Z 轴)
int battery = 0; // BYTE19 遥控器电量 0~100
};
Q_DECLARE_METATYPE(RcFrame)
// ===================== 遥控器串口接收层 =====================
// 独占 rc/device(默认 /dev/ttyS5),115200 / 8 数据位 / 1 停止位 / 无校验,
// 与车端 /dev/ttyS7(见 car.h)互不干扰。
// 只负责收包、校验、解析与链路超时判定,不决定遥控器数据的用途。
class RcCommunication : public QObject
{
Q_OBJECT
public:
explicit RcCommunication(QObject* parent = nullptr);
~RcCommunication() override;
bool start();
void stop();
QString deviceName() const { return m_device; }
bool linkOk() const { return m_linkOk; }
signals:
void frameReceived(const RcFrame& frame); // 收到一帧合法报文
void linkChanged(bool ok); // 链路状态变化
private slots:
void onReadyRead();
void onLinkTimeout();
private:
void onFrame(const RcFrame& frame);
QSerialPort* m_serial = nullptr;
QByteArray m_buf; // 串口是字节流,粘包/拆包在此缓冲里重新对齐
QTimer* m_linkTimer = nullptr;
QString m_device = "/dev/ttyS7";
int m_baud = 115200;
bool m_linkOk = false;
};
#endif // UART_H
-242
View File
@@ -1,242 +0,0 @@
#include "udpcommunication.h"
#include <QSettings>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QHostAddress>
#include <QDateTime>
#include <QNetworkDatagram>
UdpCommunication::UdpCommunication(Telemetry* tel, QObject* parent)
: QObject(parent), m_tel(tel)
{
m_socket = new QUdpSocket(this);
connect(m_socket, &QUdpSocket::readyRead, this, &UdpCommunication::onReadyRead);
// 网络配置(默认回环:本地 9000 收,向 127.0.0.1:9001 发)
QSettings s;
m_localPort = s.value("net/local_port", 9000).toInt();
m_remoteHost = s.value("net/remote_host", "127.0.0.1").toString();
m_remotePort = s.value("net/remote_port", 9001).toInt();
m_mock = s.value("net/mock", true).toBool();
// 通信超时判定:连续 2s 无心跳 → 通信异常
m_hbTimer = new QTimer(this);
m_hbTimer->setInterval(2000);
connect(m_hbTimer, &QTimer::timeout, this, &UdpCommunication::onHeartbeatTimeout);
}
bool UdpCommunication::start()
{
if (m_socket->state() != QAbstractSocket::UnconnectedState)
return true;
bool ok = m_socket->bind(QHostAddress::AnyIPv4, m_localPort,
QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint);
if (ok) m_hbTimer->start();
return ok;
}
void UdpCommunication::stop()
{
m_hbTimer->stop();
m_socket->close();
}
void UdpCommunication::sendJson(const QJsonObject& obj)
{
QByteArray data = QJsonDocument(obj).toJson(QJsonDocument::Compact);
m_socket->writeDatagram(data, QHostAddress(m_remoteHost), m_remotePort);
}
// ===================== 上行命令 =====================
void UdpCommunication::sendChassisMove(const QString& dir, double speed)
{
QJsonObject o; o["t"] = "chassis_move"; o["dir"] = dir; o["speed"] = speed;
sendJson(o);
}
void UdpCommunication::sendArmMove(const QString& axis, int dir)
{
QJsonObject o; o["t"] = "arm_move"; o["axis"] = axis; o["dir"] = dir;
sendJson(o);
}
void UdpCommunication::sendArmReturn()
{
QJsonObject o; o["t"] = "arm_return";
sendJson(o);
}
void UdpCommunication::sendArmPause()
{
QJsonObject o; o["t"] = "arm_pause";
sendJson(o);
}
void UdpCommunication::sendGasStart()
{
QJsonObject o; o["t"] = "gas_start";
sendJson(o);
}
void UdpCommunication::sendGasStop()
{
QJsonObject o; o["t"] = "gas_stop";
sendJson(o);
}
void UdpCommunication::sendWashStart(const QString& mode)
{
QJsonObject o; o["t"] = "wash_start"; o["mode"] = mode;
sendJson(o);
}
void UdpCommunication::sendWashStop()
{
QJsonObject o; o["t"] = "wash_stop";
sendJson(o);
}
void UdpCommunication::sendBallValve(int valve, bool open)
{
QJsonObject o; o["t"] = "ball_valve"; o["valve"] = valve; o["open"] = open;
sendJson(o);
}
void UdpCommunication::sendPumpPower(const QString& pump, bool on)
{
QJsonObject o; o["t"] = "pump_power"; o["pump"] = pump; o["on"] = on;
sendJson(o);
}
void UdpCommunication::sendPumpRpm(const QString& pump, int rpm)
{
QJsonObject o; o["t"] = "pump_rpm"; o["pump"] = pump; o["rpm"] = rpm;
sendJson(o);
}
void UdpCommunication::sendSetParam(const QString& key, double value)
{
QJsonObject o; o["t"] = "set_param"; o["key"] = key; o["value"] = value;
sendJson(o);
}
void UdpCommunication::sendSelfCheckStart()
{
QJsonObject o; o["t"] = "selfcheck_start";
sendJson(o);
}
void UdpCommunication::sendEstop()
{
QJsonObject o; o["t"] = "estop";
sendJson(o);
}
void UdpCommunication::sendEstopReset()
{
QJsonObject o; o["t"] = "estop_reset";
sendJson(o);
}
// ===================== 收包与解析 =====================
void UdpCommunication::onReadyRead()
{
while (m_socket->hasPendingDatagrams()) {
QNetworkDatagram dg = m_socket->receiveDatagram();
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(dg.data(), &err);
if (err.error != QJsonParseError::NoError || !doc.isObject())
continue;
parse(doc.object());
}
}
void UdpCommunication::parse(const QJsonObject& o)
{
const QString t = o.value("t").toString();
if (t == "heartbeat") {
bool ok = o.value("ok").toBool();
m_hbTimer->start(); // 重置超时计时
if (ok != m_lastCommOk) {
m_lastCommOk = ok;
if (m_tel) m_tel->commOk = ok;
emit commOkChanged(ok);
}
return;
}
if (t == "chassis") {
m_tel->soc = o.value("soc").toInt();
m_tel->volt = o.value("volt").toDouble();
m_tel->speed = o.value("speed").toDouble();
m_tel->handbrake = o.value("handbrake").toBool();
m_tel->chassisFaults.clear();
for (const QJsonValue& v : o.value("faults").toArray())
m_tel->chassisFaults << v.toString();
} else if (t == "arm") {
const QJsonArray a = o.value("js").toArray();
m_tel->js.clear();
for (const QJsonValue& v : a) m_tel->js << v.toDouble();
m_tel->pos = o.value("pos").toString();
m_tel->pose = o.value("pose").toString();
m_tel->vel = o.value("vel").toDouble();
m_tel->returnState = o.value("return_state").toString("idle");
m_tel->armHomed = o.value("homed").toBool();
} else if (t == "wash") {
m_tel->washMode = o.value("mode").toString("--");
m_tel->flowA = o.value("flowA").toDouble();
m_tel->flowB = o.value("flowB").toDouble();
m_tel->gasFlow = o.value("gasFlow").toDouble();
m_tel->press = o.value("press").toDouble();
m_tel->airPress = o.value("airPress").toDouble();
m_tel->foam = o.value("foam").toDouble();
m_tel->washFaults.clear();
for (const QJsonValue& v : o.value("faults").toArray())
m_tel->washFaults << v.toString();
} else if (t == "tank") {
m_tel->levelA = o.value("a").toDouble();
m_tel->levelFoam = o.value("foam").toDouble();
} else if (t == "gas") {
m_tel->gasHost = o.value("host").toString("standby");
m_tel->gasType = o.value("type").toString("--");
m_tel->gasLevel = o.value("level").toString("--");
m_tel->gasConc = o.value("conc").toDouble();
} else if (t == "valve") {
m_tel->d1open = o.value("d1open").toBool();
m_tel->d1close = o.value("d1close").toBool();
m_tel->d2open = o.value("d2open").toBool();
m_tel->d2close = o.value("d2close").toBool();
m_tel->valveFault = o.value("fault").toString();
if (!m_tel->valveFault.isEmpty())
emit faultAppended("洗消系统", m_tel->valveFault, m_tel->valveFault);
} else if (t == "pump") {
m_tel->plunger = o.value("plunger").toBool();
m_tel->air = o.value("air").toBool();
m_tel->leaf = o.value("leaf").toBool();
m_tel->pumpFaults.clear();
for (const QJsonValue& v : o.value("faults").toArray())
m_tel->pumpFaults << v.toString();
} else if (t == "estop") {
m_tel->estop = o.value("triggered").toBool();
emit estopReceived(m_tel->estop);
} else if (t == "selfcheck") {
emit selfCheckItem(o.value("item").toInt(), o.value("ok").toBool());
} else if (t == "selfcheck_done") {
emit selfCheckDone(o.value("all_ok").toBool());
} else {
return; // 未知类型不触发刷新
}
emit telemetryUpdated();
}
void UdpCommunication::onHeartbeatTimeout()
{
if (m_lastCommOk) {
m_lastCommOk = false;
if (m_tel) m_tel->commOk = false;
emit commOkChanged(false);
}
}
-70
View File
@@ -1,70 +0,0 @@
#ifndef UDPCOMMUNICATION_H
#define UDPCOMMUNICATION_H
#include <QObject>
#include <QUdpSocket>
#include <QTimer>
#include "global.h"
// ===================== UDP 通信层(收包 / 发包) =====================
// 与车载端经 UDP 报文通信,JSON 文本协议:
// 上行(UI→车载端)命令见各 sendXxx();
// 下行(车载端→UI)遥测见 parse() 中 "t" 字段分支。
// 本类同时承担“通信超时判定”:连续 2s 未收到心跳则判为通信异常。
class UdpCommunication : public QObject
{
Q_OBJECT
public:
explicit UdpCommunication(Telemetry* tel, QObject* parent = nullptr);
bool start();
void stop();
// 是否启用内置模拟车载端(离线演示)
bool mockEnabled() const { return m_mock; }
int localPort() const { return m_localPort; }
// ---------- 上行命令(发包) ----------
void sendChassisMove(const QString& dir, double speed);
void sendArmMove(const QString& axis, int dir); // dir: +1 / -1 / 0(停止)
void sendArmReturn();
void sendArmPause();
void sendGasStart();
void sendGasStop();
void sendWashStart(const QString& mode); // "clean" / "disinfect"
void sendWashStop();
void sendBallValve(int valve, bool open); // valve: 1=D1, 2=D2
void sendPumpPower(const QString& pump, bool on); // "plunger"/"air"/"leaf"
void sendPumpRpm(const QString& pump, int rpm);
void sendSetParam(const QString& key, double value);
void sendSelfCheckStart();
void sendEstop();
void sendEstopReset();
signals:
void telemetryUpdated(); // 任意遥测包到达(页面据此刷新)
void commOkChanged(bool ok); // 通信状态变化
void estopReceived(bool triggered); // 收到急停
void selfCheckItem(int index, bool ok); // 自检单项结果(0~9)
void selfCheckDone(bool allOk); // 自检完成
void faultAppended(const QString& system, const QString& code, const QString& desc);
private slots:
void onReadyRead();
void onHeartbeatTimeout();
private:
void sendJson(const QJsonObject& obj);
void parse(const QJsonObject& obj);
Telemetry* m_tel = nullptr;
QUdpSocket* m_socket = nullptr;
QTimer* m_hbTimer = nullptr;
int m_localPort = 9000;
QString m_remoteHost = "127.0.0.1";
int m_remotePort = 9001;
bool m_mock = true;
bool m_lastCommOk = true;
};
#endif // UDPCOMMUNICATION_H
+1
View File
@@ -8,6 +8,7 @@ int main(int argc, char *argv[])
a.setStyleSheet(appStyleSheet());
installButtonFocusGlow(&a); // 按钮获得焦点时显示光圈
installArrowKeyGuard(&a); // 方向键只做旋钮调值,不在按钮间换焦
MainWindow w;
w.showFullScreen();
+25 -19
View File
@@ -1,6 +1,6 @@
#include "mainwindow.h"
#include "appcontext.h"
#include "udpcommunication.h"
#include "car.h"
#include "confirmdialog.h"
#include "base.h"
@@ -10,12 +10,12 @@
#include "passwordmgmt.h"
#include "passwordreset.h"
#include "selfcheck.h"
#include "home.h"
#include "jobselect.h"
#include "chassis.h"
#include "arm.h"
#include "gasdetector.h"
#include "manualclean.h"
#include "manualdisinfect.h"
#include "washpage.h"
#include "debugmaintain.h"
#include "faultquery.h"
@@ -29,7 +29,7 @@ MainWindow::MainWindow(QWidget* parent)
{
m_ctx = AppContext::instance();
setWindowTitle("玉兔工业无线遥控器 YTZT6K8EART2-aL25");
setWindowTitle("玉兔工业无线遥控器");
setWindowFlags(Qt::FramelessWindowHint | Qt::Window);
setFixedSize(SCREEN_W, SCREEN_H);
@@ -40,11 +40,12 @@ MainWindow::MainWindow(QWidget* parent)
buildPages();
// 全局急停横幅(覆盖于页面之上)
m_estopBanner = new QLabel("急停已触发,请复位", this);
// 全局急停横幅(覆盖于页面之上):仅作提示。
// 急停与复位都由遥控器 bit4 下发,面板不提供复位控件。
m_estopBanner = new QLabel("急停已触发,请用遥控器复位!", this);
m_estopBanner->setAlignment(Qt::AlignCenter);
m_estopBanner->setStyleSheet(
"background-color:#E5484D;color:#FFFFFF;font-size:30px;font-weight:700;");
"background-color:#E5484D;color:#FFFFFF;font-size:50px;font-weight:700;");
m_estopBanner->setGeometry(0, 98, SCREEN_W, 64);
m_estopBanner->hide();
@@ -52,11 +53,14 @@ MainWindow::MainWindow(QWidget* parent)
// 启动通信层(UDP 收包发包)
m_ctx->comm()->start();
m_ctx->rc()->start(); // 遥控器接收(独立端口)
// 初始页面:开机密码
// m_stack->setCurrentIndex(PageBoot);
// 免密码直接进自检页(恢复原流程时改回上面的 PageBoot)
m_stack->setCurrentIndex(PageSelfCheck);
// 免密码直接进自检页
// m_stack->setCurrentIndex(PageSelfCheck);
// 免登录直接进作业选择页(恢复原流程时改回上面的 PageBoot)
m_stack->setCurrentIndex(PageJob);
}
MainWindow::~MainWindow()
@@ -79,12 +83,13 @@ void MainWindow::buildPages()
add(PageMgmt, new PasswordMgmtPage(m_ctx));
add(PageReset, new PasswordResetPage(m_ctx));
add(PageSelfCheck, new SelfCheckPage(m_ctx));
add(PageHome, new HomePage(m_ctx));
add(PageJob, new JobSelectPage(m_ctx));
add(PageChassis, new ChassisPage(m_ctx));
add(PageArm, new ArmPage(m_ctx));
add(PageGas, new GasDetectorPage(m_ctx));
add(PageClean, new ManualCleanPage(m_ctx));
add(PageDisinfect, new ManualDisinfectPage(m_ctx));
add(PageClean, new WashPage(m_ctx, WashPage::Clean));
add(PageDisinfect, new WashPage(m_ctx, WashPage::Disinfect));
add(PageDebug, new DebugMaintainPage(m_ctx));
add(PageFault, new FaultQueryPage(m_ctx));
@@ -93,10 +98,13 @@ void MainWindow::buildPages()
void MainWindow::onNavigate(PageId id)
{
// 记录故障查询返回来源
// 记录故障查询返回来源(自检页 / 系统首页面 进入时原路返回,其它一律回作业选择页)
if (id == PageFault && m_faultPage) {
PageId cur = static_cast<PageId>(m_stack->currentIndex());
m_faultPage->setReturnPage((cur == PageSelfCheck) ? PageSelfCheck : PageJob);
if (cur == PageSelfCheck || cur == PageHome)
m_faultPage->setReturnPage(cur);
else
m_faultPage->setReturnPage(PageJob);
}
if (id >= 0 && id < PageCount)
m_stack->setCurrentIndex(id);
@@ -110,14 +118,12 @@ void MainWindow::onEstopChanged(bool triggered)
void MainWindow::keyPressEvent(QKeyEvent* ev)
{
// 演示用快捷键:M 切换行车/机械臂模式,E 触发/复位急停
// 演示用快捷键:M 切换行车/机械臂模式。
// 遥控器在线时模式由遥控器报文决定,此处不抢(与底盘页 showEvent 同一规则)。
switch (ev->key()) {
case Qt::Key_M:
m_ctx->setMode(m_ctx->mode == Mode::Drive ? Mode::Arm : Mode::Drive);
break;
case Qt::Key_E:
if (m_ctx->estopTriggered) m_ctx->resetEstop();
else m_ctx->triggerEstop();
if (!m_ctx->rc()->linkOk())
m_ctx->setMode(m_ctx->mode == Mode::Drive ? Mode::Arm : Mode::Drive);
break;
default:
QMainWindow::keyPressEvent(ev);
+74 -58
View File
@@ -1,13 +1,14 @@
#include "arm.h"
#include "titlebar.h"
#include "statusstrip.h"
#include "alarmbanner.h"
#include "datacard.h"
#include "udpcommunication.h"
#include "car.h"
#include "confirmdialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
//#include <QGridLayout> // 末端按钮原为 2×3 网格,改为竖列后暂不需要
#include <QPushButton>
#include <QLabel>
#include <QTimer>
@@ -34,8 +35,8 @@ ArmPage::ArmPage(AppContext* ctx, QWidget* parent)
// 左控制列(6 运动按钮 + 系统回位/暂停)
QWidget* left = new QWidget(this);
QVBoxLayout* leftLay = new QVBoxLayout(left);
leftLay->setContentsMargins(0, 0, 0, 0);
leftLay->setSpacing(8);
leftLay->setContentsMargins(10,10,20,10);
leftLay->setSpacing(30);
auto addMove = [&](const QString& t, const QString& axis, int dir) {
QPushButton* b = makeMoveButton(t, axis, dir);
@@ -49,11 +50,14 @@ ArmPage::ArmPage(AppContext* ctx, QWidget* parent)
addMove("前平移", "y", +1);
addMove("后平移", "y", -1);
m_returnBtn = button("系统回位/暂停", "", left);
m_returnBtn = button("系统回位/\n暂停", "", left);
m_returnBtn->setCheckable(true);
m_returnBtn->setFixedSize(180, 90);
m_returnBtn->setStyleSheet(QString("font-size:22px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;")
.arg(C::PANEL_HI, C::TEXT, C::BORDER));
m_returnBtn->setFixedSize(270, 120); // 两行文字,故加高
m_returnBtn->setStyleSheet(QString(
"QPushButton{font-size:40px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;}"
"QPushButton:hover,QPushButton:focus,QPushButton:checked"
"{background-color:%4;color:#FFFFFF;border:2px solid #8ABFFF;}")
.arg(C::PANEL_HI, C::TEXT, C::BORDER, C::PRIMARY));
connect(m_returnBtn, &QPushButton::toggled, this, &ArmPage::onReturnToggled);
leftLay->addWidget(m_returnBtn);
@@ -63,69 +67,72 @@ ArmPage::ArmPage(AppContext* ctx, QWidget* parent)
leftLay->addWidget(m_returnStatus);
leftLay->addStretch(1);
left->setFixedWidth(180);
left->setFixedWidth(300); // 270 按钮 + 左右边距 30,避免按钮被裁
main->addWidget(left);
// 中央卡片区(底盘/洗消/气体检测仪)
// 中央区:左侧三张状态卡 + 机械臂参数卡,并排放在中间
QWidget* center = new QWidget(this);
QVBoxLayout* centerLay = new QVBoxLayout(center);
QHBoxLayout* centerLay = new QHBoxLayout(center);
centerLay->setContentsMargins(0, 0, 0, 0);
centerLay->setSpacing(12);
m_chassisCard = new DataCard("底盘", center);
m_chassisCard->addField("电量", "85%");
m_chassisCard->addField("电压", "360V");
m_chassisCard->addField("故障", "无");
QWidget* cardsCol = new QWidget(center);
QVBoxLayout* cardsLay = new QVBoxLayout(cardsCol);
cardsLay->setContentsMargins(0, 0, 0, 0);
cardsLay->setSpacing(12);
m_washCard = new DataCard("洗消", center);
m_washCard->addField("A液位", "100L");
m_washCard->addField("泡沫液位", "30L");
m_chassisCard = new DataCard("底盘", cardsCol);
m_chassisCard->addField("底盘电量SOC", "85%");
m_chassisCard->addField("底盘电压", "360V");
m_chassisCard->addField("底盘故障", "无");
m_gasCard = new DataCard("气体检测仪", center);
m_gasCard->addField("主机状态", "待机");
m_gasCard->addField("浓度", "0 ppb");
m_washCard = new DataCard("洗消", cardsCol);
m_washCard->addField("A组分/清水液位", "100L");
m_washCard->addField("泡沫箱液位", "30L");
centerLay->addWidget(m_chassisCard, 1);
centerLay->addWidget(m_washCard, 1);
centerLay->addWidget(m_gasCard, 1);
m_gasCard = new DataCard("气体检测仪", cardsCol);
m_gasCard->addField("主机状态", "点火状态");
m_gasCard->addField("有毒物种类", "S级");
m_gasCard->addField("有毒物等级", "二级");
cardsLay->addWidget(m_chassisCard, 1);
cardsLay->addWidget(m_washCard, 1);
cardsLay->addWidget(m_gasCard, 1);
m_armCard = new DataCard("机械臂参数", center);
m_armCard->addField("末端位置", "XYZ");
m_armCard->addField("末端姿态", "RX/RY/RZ");
m_armCard->addField("末端速度", "0");
m_armCard->addField("Joint1关节角", "30°");
m_armCard->addField("Joint2关节角", "20°");
m_armCard->addField("Joint3关节角", "0°");
m_armCard->addField("Joint4关节角", "90°");
m_armCard->addField("Joint5关节角", "45°");
m_armCard->addField("Joint6关节角", "0°");
centerLay->addWidget(cardsCol, 1);
centerLay->addWidget(m_armCard, 1);
main->addWidget(center, 1);
// 右参数区(机械臂参数卡 + 末端按钮列)
// 右控制列(末端 6 姿态按钮,与左侧按钮列对称)
QWidget* right = new QWidget(this);
QVBoxLayout* rightLay = new QVBoxLayout(right);
rightLay->setContentsMargins(0, 0, 0, 0);
rightLay->setSpacing(12);
rightLay->setContentsMargins(10, 20, 20, 20);
rightLay->setSpacing(15);
m_armCard = new DataCard("机械臂参数", right);
m_armCard->addField("末端位置", "XYZ");
m_armCard->addField("姿态", "RX/RY/RZ");
m_armCard->addField("速度", "0");
m_armCard->addField("J1", "30°");
m_armCard->addField("J2", "20°");
m_armCard->addField("J3", "0°");
m_armCard->addField("J4", "90°");
m_armCard->addField("J5", "45°");
m_armCard->addField("J6", "0°");
rightLay->addWidget(m_armCard, 1);
// 末端 6 姿态按钮(2 列 × 3 行)
QGridLayout* endGrid = new QGridLayout();
endGrid->setSpacing(8);
auto addEnd = [&](const QString& t, const QString& axis, int dir, int r, int c) {
auto addEnd = [&](const QString& t, const QString& axis, int dir) {
QPushButton* b = makeMoveButton(t, axis, dir);
b->setFixedHeight(80);
m_endButtons << b;
endGrid->addWidget(b, r, c);
rightLay->addWidget(b);
};
addEnd("末端仰", "pitch", +1, 0, 0);
addEnd("末端俯", "pitch", -1, 0, 1);
addEnd("末端正偏航", "yaw", +1, 1, 0);
addEnd("末端负偏航", "yaw", -1, 1, 1);
addEnd("末端左横滚", "roll", -1, 2, 0);
addEnd("末端右横滚", "roll", +1, 2, 1);
rightLay->addLayout(endGrid);
addEnd("末端仰", "pitch", +1);
addEnd("末端俯", "pitch", -1);
addEnd("末端正偏航", "yaw", +1);
addEnd("末端负偏航", "yaw", -1);
addEnd("末端左横滚", "roll", -1);
addEnd("末端右横滚", "roll", +1);
right->setFixedWidth(430);
right->setFixedWidth(300); // 与左列同宽,按钮 270 不被裁
main->addWidget(right);
root->addLayout(main, 1);
@@ -141,9 +148,10 @@ ArmPage::ArmPage(AppContext* ctx, QWidget* parent)
m_returnStatus->setText("");
});
connect(ctx->comm(), &UdpCommunication::telemetryUpdated, this, &ArmPage::refresh);
connect(ctx->comm(), &CarCommunication::telemetryUpdated, this, &ArmPage::refresh);
connect(ctx, &AppContext::modeChanged, this, [this](Mode) { updateModeGating(); });
connect(ctx, &AppContext::estopChanged, this, [this](bool) { updateModeGating(); });
connect(ctx, &AppContext::rcDrivingChanged, this, [this](bool) { updateModeGating(); });
updateModeGating();
refresh();
}
@@ -151,10 +159,17 @@ ArmPage::ArmPage(AppContext* ctx, QWidget* parent)
QPushButton* ArmPage::makeMoveButton(const QString& text, const QString& axis, int dir)
{
QPushButton* b = button(text, "", this);
b->setFixedSize(180, 90);
b->setStyleSheet(QString("font-size:24px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;")
.arg(C::PANEL_HI, C::TEXT, C::BORDER));
b->setFixedSize(270, 76);
b->setStyleSheet(QString(
"QPushButton{font-size:45px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;}"
"QPushButton:hover,QPushButton:focus,QPushButton:pressed"
"{background-color:%4;color:#FFFFFF;border:2px solid #8ABFFF;}")
.arg(C::PANEL_HI, C::TEXT, C::BORDER, C::PRIMARY));
connect(b, &QPushButton::pressed, this, [this, axis, dir]() {
if (ctx()->rcDriving()) {
ConfirmDialog::toast(this, "遥控器操作中");
return;
}
if (ctx()->mode != Mode::Arm) {
ConfirmDialog::toast(this, "请切换至机械臂模式");
return;
@@ -169,7 +184,8 @@ QPushButton* ArmPage::makeMoveButton(const QString& text, const QString& axis, i
void ArmPage::updateModeGating()
{
bool arm = (ctx()->mode == Mode::Arm) && !ctx()->estopTriggered;
// 非机械臂模式、急停触发或遥控器正在操作时禁用动作按钮(遥控器优先)
bool arm = (ctx()->mode == Mode::Arm) && !ctx()->estopTriggered && !ctx()->rcDriving();
for (QPushButton* b : m_moveButtons) b->setEnabled(arm);
for (QPushButton* b : m_endButtons) b->setEnabled(arm);
}
+81 -24
View File
@@ -4,7 +4,7 @@
#include "alarmbanner.h"
#include "datacard.h"
#include "paramcontrol.h"
#include "udpcommunication.h"
#include "car.h"
#include "confirmdialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -29,13 +29,13 @@ ChassisPage::ChassisPage(AppContext* ctx, QWidget* parent)
QHBoxLayout* main = new QHBoxLayout();
main->setContentsMargins(36, 16, 36, 16);
main->setSpacing(32);
main->setSpacing(0); // 各段间距改用显式 addSpacing,便于单独调整(底盘列位置见各段注释)
// 左控制列(垂直居中 + 平均分布)
QWidget* left = new QWidget(this);
QVBoxLayout* leftLay = new QVBoxLayout(left);
leftLay->setContentsMargins(0, 0, 0, 0);
leftLay->setSpacing(8);
leftLay->setContentsMargins(20, 20, 0, 0);
leftLay->setSpacing(30);
QPushButton* fwd = makeDirButton("前进", "fwd");
QPushButton* back = makeDirButton("后退", "back");
@@ -54,6 +54,7 @@ ChassisPage::ChassisPage(AppContext* ctx, QWidget* parent)
leftLay->addStretch(1);
left->setFixedWidth(180);
main->addWidget(left);
main->addSpacing(32);
// 中央卡片区(洗消/机械臂/气体检测仪)
QWidget* center = new QWidget(this);
@@ -76,36 +77,49 @@ ChassisPage::ChassisPage(AppContext* ctx, QWidget* parent)
centerLay->addWidget(m_washCard, 1);
centerLay->addWidget(m_armCard, 1);
centerLay->addWidget(m_gasCard, 1);
// 中间区域由「卡片列」与「底盘状态列」平均分配(两列拉伸因子都为 1)
main->addWidget(center, 1);
main->addSpacing(47); // 中间卡片列 ↔ 底盘列:由 32 加大到 47
// 右参数区
QWidget* right = new QWidget(this);
QVBoxLayout* rightLay = new QVBoxLayout(right);
rightLay->setContentsMargins(0, 0, 0, 0);
rightLay->setSpacing(12);
// 底盘状态列
QWidget* chassisCol = new QWidget(this);
QVBoxLayout* chassisLay = new QVBoxLayout(chassisCol);
chassisLay->setContentsMargins(0, 0, 0, 0);
chassisLay->setSpacing(12);
m_chassisCard = new DataCard("底盘状态", right);
m_chassisCard = new DataCard("底盘状态", chassisCol);
m_chassisCard->addField("电量SOC", "85%");
m_chassisCard->addField("电压U", "360V");
m_chassisCard->addField("车速V", "3.45 km/h");
m_chassisCard->addField("手刹", "有效");
m_chassisFieldFault = m_chassisCard->addField("故障", "无");
rightLay->addWidget(m_chassisCard, 1);
chassisLay->addWidget(m_chassisCard, 1);
main->addWidget(chassisCol, 1); // 与中间卡片列平均分配中间区域
main->addSpacing(17); // 底盘列 ↔ 车速设置列:由 32 减小到 17(底盘列整体右移 15px)
// 车速设置列(垂直居中)
QWidget* right = new QWidget(this);
QVBoxLayout* rightLay = new QVBoxLayout(right);
rightLay->setContentsMargins(0, 0, 0, 0);
rightLay->setSpacing(12);
// 车速设置
double speed = ctx->getDouble(ctx->keySpeed(), 4.5);
m_speed = new ParamControl("车速设置", "km/h", 0.5, 0.0, 10.0, false, false, right);
// 协议 Vehi_TargetSpd:精度 0.01、0~500 → 物理范围 0~5.00 km/h
// 每档 = 显示精度的最小单位(1 位小数 → 0.1),比协议的 0.01 粗,够用
m_speed = new ParamControl("车速设置", "km/h", 0.1, 0.0, 5.0, false, false, right);
m_speed->setValue(speed);
m_speed->setStyleSheet("background:transparent;");
m_speed->setStyleSheet("ParamControl{background:transparent;}"); // 只作用于本控件,避免影响子控件
connect(m_speed, &ParamControl::valueChanged, this, &ChassisPage::onSpeedChanged);
rightLay->addStretch(1);
rightLay->addWidget(m_speed);
rightLay->addStretch(1);
right->setFixedWidth(380);
main->addWidget(right);
root->addLayout(main, 1);
m_statusStrip = new StatusStrip(ctx, this);
m_statusStrip->setModeVisible(false); // 本页固定行车模式,底部不显示模式徽标
connect(m_statusStrip, &StatusStrip::backClicked, this, [this]() { emit navigate(PageJob); });
root->addWidget(m_statusStrip);
@@ -115,38 +129,81 @@ ChassisPage::ChassisPage(AppContext* ctx, QWidget* parent)
m_saveTimer->setInterval(1000);
connect(m_saveTimer, &QTimer::timeout, this, &ChassisPage::onSpeedSaved);
connect(ctx->comm(), &UdpCommunication::telemetryUpdated, this, &ChassisPage::refresh);
connect(ctx->comm(), &CarCommunication::telemetryUpdated, this, &ChassisPage::refresh);
connect(ctx, &AppContext::modeChanged, this, [this](Mode) { updateModeGating(); });
connect(ctx, &AppContext::estopChanged, this, [this](bool) { updateModeGating(); });
connect(ctx, &AppContext::rcDrivingChanged, this, [this](bool) { updateModeGating(); });
updateModeGating();
refresh();
}
void ChassisPage::showEvent(QShowEvent* ev)
{
BasePage::showEvent(ev);
// 每次进入底盘页自动切换到行车模式;遥控器在线时模式由遥控器报文决定,不抢
if (!ctx()->rc()->linkOk())
ctx()->setMode(Mode::Drive);
}
QPushButton* ChassisPage::makeDirButton(const QString& text, const QString& dir)
{
QPushButton* b = button(text, "", this);
b->setFixedSize(180, 150);
b->setStyleSheet(QString("font-size:26px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;")
.arg(C::PANEL_HI, C::TEXT, C::BORDER));
b->setFixedSize(140, 100);
b->setStyleSheet(QString(
"QPushButton{font-size:45px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;}"
"QPushButton:hover,QPushButton:focus,QPushButton:pressed"
"{background-color:%4;color:#FFFFFF;border:2px solid #8ABFFF;}")
.arg(C::PANEL_HI, C::TEXT, C::BORDER, C::PRIMARY));
connect(b, &QPushButton::pressed, this, [this, dir]() {
if (ctx()->rcDriving()) {
ConfirmDialog::toast(this, "遥控器操作中");
return;
}
if (ctx()->mode != Mode::Drive) {
ConfirmDialog::toast(this, "请切换至行车模式");
return;
}
ctx()->comm()->sendChassisMove(dir, ctx()->getDouble(ctx()->keySpeed(), 4.5));
m_heldDirs.insert(dir);
sendDirCommand();
});
connect(b, &QPushButton::released, this, [this]() {
ctx()->comm()->sendChassisMove("stop", 0);
connect(b, &QPushButton::released, this, [this, dir]() {
m_heldDirs.remove(dir);
sendDirCommand();
});
return b;
}
// 同时按住前进/后退与左/右转向时一并下发;两者都松开才停车
void ChassisPage::sendDirCommand()
{
const QString dir = m_heldDirs.contains("fwd") ? "fwd"
: m_heldDirs.contains("back") ? "back" : QString("stop");
const QString turn = m_heldDirs.contains("right") ? "right"
: m_heldDirs.contains("left") ? "left" : QString();
if (dir == "stop" && turn.isEmpty()) {
ctx()->comm()->sendChassisMove("stop", 0);
return;
}
// 仅转向(档位回中)时发 stop + turn,由车载端决定是否原地转向
const double spd = (dir == "stop") ? 0.0 : ctx()->getDouble(ctx()->keySpeed(), 4.5);
ctx()->comm()->sendChassisMove(dir, spd, turn);
}
void ChassisPage::updateModeGating()
{
// 非行车模式或急停触发时禁用方向按钮
bool drive = (ctx()->mode == Mode::Drive) && !ctx()->estopTriggered;
// 非行车模式、急停触发或遥控器正在操作时禁用方向按钮(遥控器优先)
const bool drive = (ctx()->mode == Mode::Drive) && !ctx()->estopTriggered && !ctx()->rcDriving();
for (QPushButton* b : m_dirButtons)
b->setEnabled(drive);
// 让位时清掉本地按住状态:按钮被禁用后不会再收到 released,否则方向会被一直挂住。
// 让位给遥控器时不补发 stop,否则会覆盖遥控器刚下发的那条指令。
if (!drive && !m_heldDirs.isEmpty()) {
m_heldDirs.clear();
if (!ctx()->rcDriving())
ctx()->comm()->sendChassisMove("stop", 0);
}
}
void ChassisPage::refresh()
+6
View File
@@ -3,6 +3,7 @@
#include "base.h"
#include <QVector>
#include <QSet>
class TitleBar;
class StatusStrip;
@@ -19,6 +20,9 @@ class ChassisPage : public BasePage
public:
explicit ChassisPage(AppContext* ctx, QWidget* parent = nullptr);
protected:
void showEvent(QShowEvent* ev) override; // 进入页面即切至行车模式
private slots:
void refresh();
void updateModeGating();
@@ -27,6 +31,7 @@ private slots:
private:
QPushButton* makeDirButton(const QString& text, const QString& dir);
void sendDirCommand(); // 按当前按住的方向组合下发(档位与转向可同时)
TitleBar* m_titleBar = nullptr;
StatusStrip* m_statusStrip = nullptr;
@@ -39,6 +44,7 @@ private:
QTimer* m_saveTimer = nullptr;
int m_chassisFieldFault = -1;
QVector<QPushButton*> m_dirButtons;
QSet<QString> m_heldDirs; // 当前按住的方向,可多键同时按
};
#endif // CHASSISPAGE_H
+142 -68
View File
@@ -4,7 +4,7 @@
#include "datacard.h"
#include "ledindicator.h"
#include "paramcontrol.h"
#include "udpcommunication.h"
#include "car.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QPushButton>
@@ -19,7 +19,7 @@ DebugMaintainPage::DebugMaintainPage(AppContext* ctx, QWidget* parent)
root->setSpacing(0);
m_titleBar = new TitleBar(ctx, this);
m_titleBar->setTitle("洗消调试");
m_titleBar->setTitle("调试维护");
root->addWidget(m_titleBar);
// 警告横幅(常显红闪)
@@ -34,35 +34,38 @@ DebugMaintainPage::DebugMaintainPage(AppContext* ctx, QWidget* parent)
// 左球阀控制区
QWidget* left = new QWidget(this);
QVBoxLayout* leftLay = new QVBoxLayout(left);
leftLay->setContentsMargins(0, 0, 0, 0);
leftLay->setSpacing(16);
leftLay->setContentsMargins(20, 10, 10, 10);
leftLay->setSpacing(60);
leftLay->addWidget(makeValveGroup(1));
leftLay->addWidget(makeValveGroup(2));
leftLay->addStretch(1);
left->setFixedWidth(300);
main->addWidget(left);
// 中央卡片区
// 中央卡片区(左右两列:左列 底盘/机械臂/气体检测仪 纵向排列,右列 洗消参数)
QWidget* center = new QWidget(this);
QVBoxLayout* centerLay = new QVBoxLayout(center);
QHBoxLayout* centerLay = new QHBoxLayout(center);
centerLay->setContentsMargins(0, 0, 0, 0);
centerLay->setSpacing(10);
centerLay->setSpacing(28); // 与 main 的列间距一致
QHBoxLayout* miniRow = new QHBoxLayout();
miniRow->setSpacing(10);
m_chassisCard = new DataCard("底盘", center);
QWidget* centerLeft = new QWidget(center);
QVBoxLayout* centerLeftLay = new QVBoxLayout(centerLeft);
centerLeftLay->setContentsMargins(0, 0, 0, 0);
centerLeftLay->setSpacing(10);
m_chassisCard = new DataCard("底盘", centerLeft);
m_chassisCard->addField("电量", "85%");
m_chassisCard->addField("电压", "360V");
m_armCard = new DataCard("机械臂", center);
m_armCard = new DataCard("机械臂", centerLeft);
m_armCard->addField("末端位置", "XYZ");
m_armCard->addField("姿态", "RX/RY/RZ");
m_gasCard = new DataCard("气体检测仪", center);
m_gasCard = new DataCard("气体检测仪", centerLeft);
m_gasCard->addField("主机状态", "待机");
m_gasCard->addField("浓度", "0 ppb");
miniRow->addWidget(m_chassisCard, 1);
miniRow->addWidget(m_armCard, 1);
miniRow->addWidget(m_gasCard, 1);
centerLay->addLayout(miniRow, 1);
centerLeftLay->addWidget(m_chassisCard, 1);
centerLeftLay->addWidget(m_armCard, 1);
centerLeftLay->addWidget(m_gasCard, 1);
centerLay->addWidget(centerLeft, 1);
m_washCard = new DataCard("洗消参数", center);
m_washCard->addField("洗消模式", "--");
@@ -79,11 +82,11 @@ DebugMaintainPage::DebugMaintainPage(AppContext* ctx, QWidget* parent)
// 右泵控制区
QWidget* right = new QWidget(this);
QVBoxLayout* rightLay = new QVBoxLayout(right);
rightLay->setContentsMargins(0, 0, 0, 0);
rightLay->setSpacing(8);
rightLay->addWidget(makePumpGroup("air", "空压机"));
rightLay->addWidget(makePumpGroup("plunger", "柱塞泵"));
rightLay->addWidget(makePumpGroup("leaf", "叶片泵"));
rightLay->setContentsMargins(10, 10,20,10);
rightLay->setSpacing(40);
rightLay->addWidget(makePumpGroup("air", "空压机(rpm)"), 0, Qt::AlignHCenter);
rightLay->addWidget(makePumpGroup("plunger", "柱塞泵(rpm)"), 0, Qt::AlignHCenter);
rightLay->addWidget(makePumpGroup("leaf", "叶片泵(rpm)"), 0, Qt::AlignHCenter);
rightLay->addStretch(1);
right->setFixedWidth(420);
main->addWidget(right);
@@ -92,28 +95,56 @@ DebugMaintainPage::DebugMaintainPage(AppContext* ctx, QWidget* parent)
// 底部指示灯条
QWidget* bottom = new QWidget(this);
bottom->setObjectName("DebugBottom");
bottom->setFixedHeight(94);
bottom->setStyleSheet(QString("background-color:%1;border-top:1px solid %2;")
// 用 #DebugBottom 限定选择器:不带选择器的样式表会连子控件一起套上边框和底色
bottom->setStyleSheet(QString("QWidget#DebugBottom{background-color:%1;border-top:1px solid %2;}")
.arg(C::PANEL, C::BORDER));
QHBoxLayout* bottomLay = new QHBoxLayout(bottom);
bottomLay->setContentsMargins(36, 0, 24, 0);
// 左起 364 = 36(页面边距) + 300(球阀列) + 28(列间距),与中央“底盘”列左边缘对齐
bottomLay->setContentsMargins(364, 0, 24, 0);
bottomLay->setSpacing(48);
m_ledPlunger = new LedIndicator("柱塞泵", bottom);
m_ledAir = new LedIndicator("空压机", bottom);
m_ledLeaf = new LedIndicator("叶片泵", bottom);
m_ledLevel = new LedIndicator("液位低", bottom);
bottomLay->addWidget(m_ledPlunger);
bottomLay->addWidget(m_ledAir);
bottomLay->addWidget(m_ledLeaf);
bottomLay->addWidget(m_ledLevel);
auto makeLed = [&](const QString& text) {
LedIndicator* led = new LedIndicator(text, bottom);
led->setTextPixelSize(40); // 字号与卡片标题一致
led->setDotAfterText(true); // 圆点画在文字后面
bottomLay->addWidget(led);
return led;
};
m_ledPlunger = makeLed("柱塞泵");
m_ledAir = makeLed("空压机");
m_ledLeaf = makeLed("叶片泵");
m_ledLevel = makeLed("液位低");
bottomLay->addStretch(1);
QPushButton* back = button("返回", "", bottom);
back->setFixedSize(144, 76);
back->setFixedSize(160, 76);
back->setStyleSheet(cancelButtonStyle(40)); // 选中(悬停/按下/焦点)红底白字
connect(back, &QPushButton::clicked, this, [this]() { emit navigate(PageJob); });
bottomLay->addWidget(back);
root->addWidget(bottom);
connect(ctx->comm(), &UdpCommunication::telemetryUpdated, this, &DebugMaintainPage::refresh);
connect(ctx->comm(), &CarCommunication::telemetryUpdated, this, [this]() {
m_telemetrySeen = true; // 遥测一到,球阀到位灯改由 refresh() 接管
refresh();
});
// Tab 顺序(循环):返回 → D1控制 → D2控制 → 各泵(转速输入 → 启动 → 停止) → 回到返回。
// 这些按钮在控件级样式表里写了 :checked,而控件级样式表的优先级高于全局
// QPushButton:focus,默认焦点链下容易看起来“停不上去”,故显式排一遍保证都能停住
QWidget* tabChain[] = {
m_valve1Btn, m_valve2Btn,
m_airRpm->editor(), m_airStart, m_airStop,
m_plungerRpm->editor(), m_plungerStart, m_plungerStop,
m_leafRpm->editor(), m_leafStart, m_leafStop,
};
QWidget* prev = back;
for (QWidget* w : tabChain) {
if (!w) continue;
QWidget::setTabOrder(prev, w);
prev = w;
}
QWidget::setTabOrder(prev, back); // 回到起点,形成循环
refresh();
}
@@ -127,26 +158,36 @@ QWidget* DebugMaintainPage::makeValveGroup(int valve)
lay->setContentsMargins(12, 12, 12, 12);
lay->setSpacing(8);
QLabel* title = label(QString("D%1 球阀").arg(valve), 20, C::TEXT, true, grp);
QLabel* title = label(QString("D%1 球阀").arg(valve), 30, C::TEXT, true, grp);
lay->addWidget(title);
// 控制开/关(toggle)
QPushButton* ctrl = button(QString("D%1 控制:关闭").arg(valve), "", grp);
// 控制开/关(toggle):文字固定,底色表示状态——未选中=关闭(红)、选中=开启(绿)
QPushButton* ctrl = button(QString("D%1 控制").arg(valve), "", grp);
ctrl->setCheckable(true);
ctrl->setFixedHeight(64);
connect(ctrl, &QPushButton::toggled, this, [this, valve, ctrl](bool on) {
ctrl->setText(QString("D%1 控制:%2").arg(valve).arg(on ? "开启" : "关闭"));
ctrl->setFixedSize(180, 64); // 窄按钮,与泵组的“上电”一致
// 控件级样式表优先级高于全局 QSS,故 :focus 也在这里补,否则 Tab 选中看不出变化
ctrl->setStyleSheet(QString("QPushButton{background-color:%1;color:#FFFFFF;border:none;}"
"QPushButton:checked{background-color:%2;color:#FFFFFF;border:none;}"
"QPushButton:focus{border:4px solid #8ABFFF;}")
.arg(C::DANGER).arg(C::SUCCESS));
connect(ctrl, &QPushButton::toggled, this, [this, valve](bool on) {
ctx()->comm()->sendBallValve(valve, on);
});
lay->addWidget(ctrl);
lay->addWidget(ctrl, 0, Qt::AlignHCenter);
// 开启到位 / 关闭到位
LedIndicator* open = new LedIndicator("开启到位", grp);
open->setState(LedIndicator::Off);
LedIndicator* close = new LedIndicator("关闭到位", grp);
// 开启到位 / 关闭到位(同一行,居中)。未收到车端遥测时保持缺省显示
// (开启绿 / 关闭红),首次收到遥测后由 refresh() 按实际到位状态刷新
LedIndicator* open = new LedIndicator("开启", grp);
open->setState(LedIndicator::Green);
LedIndicator* close = new LedIndicator("关闭", grp);
close->setState(LedIndicator::Red);
lay->addWidget(open);
lay->addWidget(close);
QHBoxLayout* ioRow = new QHBoxLayout();
ioRow->setSpacing(10);
ioRow->addStretch(1);
ioRow->addWidget(open);
ioRow->addWidget(close);
ioRow->addStretch(1);
lay->addLayout(ioRow);
// 故障提示
QLabel* fault = label("", 16, C::DANGER, true, grp);
@@ -175,29 +216,60 @@ QWidget* DebugMaintainPage::makePumpGroup(const QString& pump, const QString& na
grp->setProperty("card", true);
grp->setStyleSheet(QString("QWidget[card=\"true\"]{background-color:%1;border:2px solid %2;border-radius:12px;}")
.arg(C::PANEL, C::BORDER));
// 不设固定宽度:卡片按内容自适应(由最宽的“空压机(rpm)”标签决定)。
// 固定宽度一旦小于布局最小宽度,标签会被压到小于文字宽度而裁字
QVBoxLayout* lay = new QVBoxLayout(grp);
lay->setContentsMargins(12, 8, 12, 8);
lay->setSpacing(4);
lay->setContentsMargins(30, 8, 20, 8);
lay->setSpacing(10);
ParamControl* rpm = new ParamControl(name + "转速", "rpm", 10, 0, 2000, false, false, grp);
// 上限按协议各泵量程:空压机 AirComp_Spd 0-3000、柱塞泵 PlungerPump_Spd 0-1450、
// 叶片泵 VanePump__Spd 0-2000 rpm。每档 = 显示精度的最小单位,转速是整数显示故为 1 rpm
const int rpmMax = (pump == "air") ? 3000 : (pump == "plunger") ? 1450 : 2000;
ParamControl* rpm = new ParamControl(name, "", 1, 0, rpmMax, false, false, grp);
rpm->setCentered(true);
rpm->setValue(200);
connect(rpm, &ParamControl::valueChanged, this, [this, pump](double v) {
ctx()->comm()->sendPumpRpm(pump, static_cast<int>(v));
});
lay->addWidget(rpm);
QPushButton* power = button("上电", "", grp);
power->setCheckable(true);
power->setFixedHeight(56);
connect(power, &QPushButton::toggled, this, [this, pump, power](bool on) {
power->setText(on ? "上电中" : "上电");
ctx()->comm()->sendPumpPower(pump, on);
// 启动 / 停止:互斥,选中态对应当前泵状态(绿=运行、红=停止)。
// 控件级样式表优先级高于全局 QSS,故 :focus 也在这里补,否则 Tab 选中看不出变化
QPushButton* start = button("启动", "", grp);
start->setCheckable(true);
start->setFixedSize(120, 60);
start->setStyleSheet(QString("QPushButton:checked{background-color:%1;color:#FFFFFF;border:none;}"
"QPushButton:focus{border:4px solid #8ABFFF;}")
.arg(C::SUCCESS));
QPushButton* stop = button("停止", "", grp);
stop->setCheckable(true);
stop->setFixedSize(120, 60);
stop->setStyleSheet(QString("QPushButton:checked{background-color:%1;color:#FFFFFF;border:none;}"
"QPushButton:focus{border:4px solid #8ABFFF;}")
.arg(C::DANGER));
connect(start, &QPushButton::clicked, this, [this, pump, start, stop]() {
start->setChecked(true);
stop->setChecked(false);
ctx()->comm()->sendPumpPower(pump, true);
});
lay->addWidget(power);
connect(stop, &QPushButton::clicked, this, [this, pump, start, stop]() {
stop->setChecked(true);
start->setChecked(false);
ctx()->comm()->sendPumpPower(pump, false);
});
QHBoxLayout* pwrRow = new QHBoxLayout();
pwrRow->setSpacing(12);
pwrRow->addStretch(1);
pwrRow->addWidget(start);
pwrRow->addWidget(stop);
pwrRow->addStretch(1);
lay->addLayout(pwrRow);
if (pump == "air") { m_airRpm = rpm; m_airPower = power; }
else if (pump == "plunger") { m_plungerRpm = rpm; m_plungerPower = power; }
else { m_leafRpm = rpm; m_leafPower = power; }
if (pump == "air") { m_airRpm = rpm; m_airStart = start; m_airStop = stop; }
else if (pump == "plunger") { m_plungerRpm = rpm; m_plungerStart = start; m_plungerStop = stop; }
else { m_leafRpm = rpm; m_leafStart = start; m_leafStop = stop; }
return grp;
}
@@ -220,11 +292,13 @@ void DebugMaintainPage::refresh()
m_washCard->setField(5, t.pumpFaults.value(0));
m_washCard->setField(6, t.pumpFaults.value(0));
// 球阀到位反馈
m_v1Open->setState(t.d1open ? LedIndicator::Green : LedIndicator::Off);
m_v1Close->setState(t.d1close ? LedIndicator::Red : LedIndicator::Off);
m_v2Open->setState(t.d2open ? LedIndicator::Green : LedIndicator::Off);
m_v2Close->setState(t.d2close ? LedIndicator::Red : LedIndicator::Off);
// 球阀到位反馈:未收到过车端遥测时不动,保留构造时的缺省显示(开启绿 / 关闭红)
if (m_telemetrySeen) {
m_v1Open->setState(t.d1open ? LedIndicator::Green : LedIndicator::Off);
m_v1Close->setState(t.d1close ? LedIndicator::Red : LedIndicator::Off);
m_v2Open->setState(t.d2open ? LedIndicator::Green : LedIndicator::Off);
m_v2Close->setState(t.d2close ? LedIndicator::Red : LedIndicator::Off);
}
// 球阀故障提示
bool v1Fault = t.valveFault.contains("D1");
@@ -234,10 +308,10 @@ void DebugMaintainPage::refresh()
if (v2Fault) { m_valveFault2->setText(t.valveFault); m_valveFault2->show(); startBlink(m_valveFault2, "blink"); }
else { stopBlink(m_valveFault2, "blink"); m_valveFault2->hide(); }
// 泵上电状态同步
if (m_airPower) m_airPower->setChecked(t.air);
if (m_plungerPower) m_plungerPower->setChecked(t.plunger);
if (m_leafPower) m_leafPower->setChecked(t.leaf);
// 泵启停状态同步(互斥组内先置“启动”再置“停止”,两者互补,结果总是一选中一未选)
if (m_airStart) { m_airStart->setChecked(t.air); m_airStop->setChecked(!t.air); }
if (m_plungerStart){ m_plungerStart->setChecked(t.plunger); m_plungerStop->setChecked(!t.plunger); }
if (m_leafStart) { m_leafStart->setChecked(t.leaf); m_leafStop->setChecked(!t.leaf); }
// 底部指示灯
m_ledPlunger->setState(t.plunger ? LedIndicator::Green : LedIndicator::Off);
+11 -5
View File
@@ -12,7 +12,7 @@ class QPushButton;
class QLabel;
class QCheckBox;
// ===================== ⑪ 调试维护界面(洗消调试) =====================
// ===================== ⑪ 调试维护界面 =====================
class DebugMaintainPage : public BasePage
{
Q_OBJECT
@@ -25,7 +25,7 @@ private slots:
private:
// 单个球阀组:控制开/关 + 开启到位 + 关闭到位 + 故障提示
QWidget* makeValveGroup(int valve);
// 单个泵组:转速调节 + 上电启停
// 单个泵组:转速调节 + 启动/停止
QWidget* makePumpGroup(const QString& pump, const QString& name);
TitleBar* m_titleBar = nullptr;
@@ -49,15 +49,21 @@ private:
ParamControl* m_airRpm = nullptr;
ParamControl* m_plungerRpm = nullptr;
ParamControl* m_leafRpm = nullptr;
QPushButton* m_airPower = nullptr;
QPushButton* m_plungerPower = nullptr;
QPushButton* m_leafPower = nullptr;
QPushButton* m_airStart = nullptr;
QPushButton* m_airStop = nullptr;
QPushButton* m_plungerStart = nullptr;
QPushButton* m_plungerStop = nullptr;
QPushButton* m_leafStart = nullptr;
QPushButton* m_leafStop = nullptr;
// 底部指示灯
LedIndicator* m_ledPlunger = nullptr;
LedIndicator* m_ledAir = nullptr;
LedIndicator* m_ledLeaf = nullptr;
LedIndicator* m_ledLevel = nullptr;
// 是否收到过车端遥测:未收到时球阀到位灯保持“开启=绿、关闭=红”的缺省显示
bool m_telemetrySeen = false;
};
#endif // DEBUGMAINTAINPAGE_H
+62 -22
View File
@@ -4,7 +4,8 @@
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QMessageBox>
#include <QTimer>
#include "confirmdialog.h"
#include "global.h"
FaultQueryPage::FaultQueryPage(AppContext* ctx, QWidget* parent)
@@ -13,58 +14,98 @@ FaultQueryPage::FaultQueryPage(AppContext* ctx, QWidget* parent)
m_categories << "机器人底盘" << "机械臂" << "洗消系统" << "气体检测仪";
QVBoxLayout* root = new QVBoxLayout(this);
root->setContentsMargins(36, 20, 36, 20);
root->setSpacing(16);
root->setContentsMargins(20, 20, 20, 20);
root->setSpacing(30);
// 顶条:标题 + 汇总数
m_title = label("历史故障", 28, C::TEXT, true, this);
m_title = label("历史故障", 50, C::TEXT, true, this);
root->addWidget(m_title);
QHBoxLayout* main = new QHBoxLayout();
main->setSpacing(28);
main->setSpacing(30);
// 表格
m_table = new FaultTable(this);
connect(m_table, &FaultTable::rowClicked, this, &FaultQueryPage::onRowClicked);
main->addWidget(m_table, 1);
// 分类按钮列(垂直居中 + 平均分布)
// 分类按钮列(左右各留 20px,按钮间距 40px,剩余高度由按钮等分)
QWidget* right = new QWidget(this);
QVBoxLayout* rightLay = new QVBoxLayout(right);
rightLay->setContentsMargins(0, 0, 0, 0);
rightLay->setSpacing(8);
rightLay->setContentsMargins(80, FaultTable::kHeaderHeight + 10, 30, 80); // 对齐表格内容区后再下移 10px
rightLay->setSpacing(60); // 按钮之间的间隔
for (const QString& cat : m_categories) {
QPushButton* b = button(cat + "故障", "", right);
b->setCheckable(true);
b->setFixedHeight(110);
b->setStyleSheet(QString("font-size:24px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;")
.arg(C::PANEL_HI, C::TEXT, C::BORDER));
b->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); // 纵向可拉伸 → 等分剩余空间
b->setStyleSheet(QString(
"QPushButton{font-size:32px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;}"
"QPushButton:hover,QPushButton:focus,QPushButton:checked"
"{background-color:%4;color:#FFFFFF;border:2px solid #8ABFFF;}")
.arg(C::PANEL_HI, C::TEXT, C::BORDER, C::PRIMARY));
connect(b, &QPushButton::toggled, this, &FaultQueryPage::onCategoryToggled);
m_categoryButtons << b;
rightLay->addStretch(1);
rightLay->addWidget(b);
rightLay->addWidget(b, 1);
}
rightLay->addStretch(1);
right->setFixedWidth(360);
main->addWidget(right);
root->addLayout(main, 1);
// 底部:返回
// 底部:返回(预留更大的行高空间)
QHBoxLayout* bottomLay = new QHBoxLayout();
bottomLay->setContentsMargins(0, 0, 40, 30);
bottomLay->addStretch(1);
QPushButton* back = button("返回", "", this);
back->setFixedSize(144, 76);
connect(back, &QPushButton::clicked, this, [this]() { emit navigate(m_returnPage); });
bottomLay->addWidget(back);
m_back = button("返回", "", this);
m_back->setFixedSize(250, 100);
m_back->setStyleSheet(cancelButtonStyle(60)); // 选中(悬停/按下/焦点)红底白字
connect(m_back, &QPushButton::clicked, this, [this]() { emit navigate(m_returnPage); });
bottomLay->addWidget(m_back);
root->addLayout(bottomLay);
// Tab 顺序(循环):表格 → 右侧分类按钮 → 返回 → 上一页 → 下一页 → 回到表格
QWidget* first = m_table->view();
QWidget* last = first;
for (QPushButton* b : m_categoryButtons) {
QWidget::setTabOrder(last, b);
last = b;
}
QWidget::setTabOrder(last, m_back);
QWidget::setTabOrder(m_back, m_table->prevButton());
QWidget::setTabOrder(m_table->prevButton(), m_table->nextButton());
QWidget::setTabOrder(m_table->nextButton(), first); // 回到表格,完成循环
refresh();
}
void FaultQueryPage::showEvent(QShowEvent* ev)
{
BasePage::showEvent(ev);
// 进入页面默认聚焦“分页”(上一页优先;首页禁用时退回下一页,再退回表格)
QTimer::singleShot(0, this, [this]() {
QWidget* prev = m_table->prevButton();
QWidget* next = m_table->nextButton();
if (prev->isEnabled()) prev->setFocus();
else if (next->isEnabled()) next->setFocus();
else m_table->view()->setFocus();
});
}
void FaultQueryPage::onCategoryToggled(bool on)
{
// 单选筛选:确定当前选中的分类
// 单选:选中一个分类时,取消其它分类(屏蔽信号,避免它们再触发本函数)
QPushButton* src = qobject_cast<QPushButton*>(sender());
if (on && src) {
for (QPushButton* b : m_categoryButtons) {
if (b != src && b->isChecked()) {
b->blockSignals(true);
b->setChecked(false);
b->blockSignals(false);
}
}
}
// 确定当前选中的分类(都没选 = 显示全部)
QString selected;
for (int i = 0; i < m_categoryButtons.size(); ++i) {
if (m_categoryButtons[i]->isChecked()) {
@@ -73,7 +114,6 @@ void FaultQueryPage::onCategoryToggled(bool on)
}
}
m_filter = selected;
Q_UNUSED(on);
refresh();
}
@@ -105,7 +145,7 @@ void FaultQueryPage::onRowClicked(int globalIndex)
if (globalIndex < 0 || globalIndex >= data.size())
return;
const FaultRecord& r = data[globalIndex];
QMessageBox::information(this, "故障详情",
ConfirmDialog::message(this, "故障详情",
QString("故障时间:%1\n分系统:%2\n故障代码:%3\n故障描述:%4")
.arg(r.time, r.system, r.code, r.desc));
}
+4
View File
@@ -17,6 +17,9 @@ public:
void setReturnPage(PageId id) { m_returnPage = id; }
protected:
void showEvent(QShowEvent* ev) override; // 进入页面时默认聚焦“分页”
private slots:
void refresh();
void onCategoryToggled(bool on);
@@ -25,6 +28,7 @@ private slots:
private:
FaultTable* m_table = nullptr;
QLabel* m_title = nullptr;
QPushButton* m_back = nullptr;
QVector<QPushButton*> m_categoryButtons;
QVector<QString> m_categories;
QString m_filter; // 空 = 全部
+8 -8
View File
@@ -2,7 +2,7 @@
#include "titlebar.h"
#include "statusstrip.h"
#include "datacard.h"
#include "udpcommunication.h"
#include "car.h"
#include "confirmdialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -28,8 +28,8 @@ GasDetectorPage::GasDetectorPage(AppContext* ctx, QWidget* parent)
// 中央卡片区(2×2)
QWidget* center = new QWidget(this);
QGridLayout* grid = new QGridLayout(center);
grid->setSpacing(16);
grid->setContentsMargins(0, 0, 0, 0);
grid->setSpacing(20);
grid->setContentsMargins(20, 0, 20, 0);
m_chassisCard = new DataCard("底盘", center);
m_chassisCard->addField("电量", "85%");
@@ -60,11 +60,11 @@ GasDetectorPage::GasDetectorPage(AppContext* ctx, QWidget* parent)
// 右侧按钮列(垂直居中 + 平均分布)
QWidget* right = new QWidget(this);
QVBoxLayout* rightLay = new QVBoxLayout(right);
rightLay->setContentsMargins(0, 0, 0, 0);
rightLay->setContentsMargins(40, 40, 40, 40);
rightLay->setSpacing(8);
m_start = button("启动", "primary", right);
m_start->setFixedHeight(180);
m_start->setFixedHeight(100);
connect(m_start, &QPushButton::clicked, this, [this]() {
this->ctx()->comm()->sendGasStart();
this->ctx()->gasRunning = true;
@@ -72,7 +72,7 @@ GasDetectorPage::GasDetectorPage(AppContext* ctx, QWidget* parent)
});
m_stop = button("停机", "", right);
m_stop->setFixedHeight(180);
m_stop->setFixedHeight(100);
connect(m_stop, &QPushButton::clicked, this, [this]() {
this->ctx()->comm()->sendGasStop();
this->ctx()->gasRunning = false;
@@ -90,9 +90,9 @@ GasDetectorPage::GasDetectorPage(AppContext* ctx, QWidget* parent)
root->addLayout(main, 1);
m_statusStrip = new StatusStrip(ctx, this);
m_statusStrip->setModeVisible(false); // 本页不显示模式徽标
connect(m_statusStrip, &StatusStrip::backClicked, this, [this]() {
if (this->ctx()->gasRunning) {
// 【设计建议】检测中返回弹提示
if (ConfirmDialog::confirm(this, "提示", "检测仍在进行,是否退出?"))
emit navigate(PageJob);
} else {
@@ -101,7 +101,7 @@ GasDetectorPage::GasDetectorPage(AppContext* ctx, QWidget* parent)
});
root->addWidget(m_statusStrip);
connect(ctx->comm(), &UdpCommunication::telemetryUpdated, this, &GasDetectorPage::refresh);
connect(ctx->comm(), &CarCommunication::telemetryUpdated, this, &GasDetectorPage::refresh);
updateGating();
refresh();
}
+149
View File
@@ -0,0 +1,149 @@
#include "home.h"
#include "titlebar.h"
#include "statusstrip.h"
#include "datacard.h"
#include "confirmdialog.h"
#include "car.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
#include <QPushButton>
#include "global.h"
HomePage::HomePage(AppContext* ctx, QWidget* parent)
: BasePage(ctx, parent)
{
QVBoxLayout* root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
m_titleBar = new TitleBar(ctx, this);
m_titleBar->setTitle("系统首页面");
root->addWidget(m_titleBar);
QHBoxLayout* main = new QHBoxLayout();
main->setContentsMargins(24, 20, 24, 20);
main->setSpacing(24);
// ===== 按钮统一样式(改这里即可同时调整对应的按钮)=====
// 左侧 2 个菜单按钮(字号 50px;尺寸见 addMenu 里的 setFixedSize)
const QString menuBtnStyle =
QString("QPushButton{font-size:50px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;}"
"QPushButton:hover,QPushButton:focus{background-color:%4;color:#FFFFFF;border:2px solid #8ABFFF;}")
.arg(C::PANEL_HI, C::TEXT, C::BORDER, C::PRIMARY);
// 右侧动作按钮(字号 55px;尺寸见下方各自的 setFixedSize)
const QString actionBtnStyle = "QPushButton{font-size:55px;}";
// 左侧菜单(按钮在列内水平居中、垂直整体居中,选中发亮)
QWidget* left = new QWidget(this);
QVBoxLayout* leftLay = new QVBoxLayout(left);
leftLay->setContentsMargins(10, 20, 10, 20);
leftLay->setSpacing(100);
auto addMenu = [&](const QString& t, PageId id) {
QPushButton* b = button(t, "", left);
b->setFixedSize(260, 100); // 固定尺寸(改这里同时调整 2 个菜单按钮)
b->setStyleSheet(menuBtnStyle);
connect(b, &QPushButton::clicked, this, [this, id]() { emit navigate(id); });
leftLay->addWidget(b, 0, Qt::AlignHCenter); // 不占拉伸因子,余量交给上下 stretch 均分
};
leftLay->addStretch(1);
addMenu("故障查询", PageFault);
addMenu("作业选择", PageJob);
leftLay->addStretch(1);
left->setFixedWidth(300);
main->addWidget(left);
// 中央卡片区(2×2,与作业选择页一致)
QWidget* center = new QWidget(this);
QGridLayout* grid = new QGridLayout(center);
grid->setSpacing(24);
grid->setContentsMargins(0, 0, 0, 0);
m_chassisCard = new DataCard("底盘", center);
m_chassisCard->addField("电量", "85%");
m_chassisCard->addField("电压", "360V");
m_chassisCard->addField("故障", "无");
m_armCard = new DataCard("机械臂", center);
m_armCard->addField("末端位置", "XYZ");
m_armCard->addField("姿态", "RX/RY/RZ");
m_washCard = new DataCard("洗消", center);
m_washCard->addField("A液位", "100L");
m_washCard->addField("泡沫液位", "30L");
m_gasCard = new DataCard("气体检测仪", center);
m_gasCard->addField("主机状态", "待机");
m_gasCard->addField("浓度", "0 ppb");
// 左列 底盘/机械臂,右列 洗消/气体检测仪
grid->addWidget(m_chassisCard, 0, 0);
grid->addWidget(m_washCard, 0, 1);
grid->addWidget(m_armCard, 1, 0);
grid->addWidget(m_gasCard, 1, 1);
main->addWidget(center, 1);
// 右侧按钮列(等分填满整列,选中发亮)
QWidget* right = new QWidget(this);
QVBoxLayout* rightLay = new QVBoxLayout(right);
rightLay->setContentsMargins(0, 0, 0, 0);
rightLay->setSpacing(24);
QPushButton* debug = button("调试维护", "", right);
debug->setFixedSize(240, 100);
debug->setStyleSheet(actionBtnStyle);
connect(debug, &QPushButton::clicked, this, [this]() { emit navigate(PageDebug); });
QPushButton* help = button("操作说明", "", right);
help->setFixedSize(240, 100);
help->setStyleSheet(actionBtnStyle);
connect(help, &QPushButton::clicked, this, &HomePage::onHelp);
QPushButton* spare = button("备用", "", right);
spare->setFixedSize(240, 100);
spare->setStyleSheet(actionBtnStyle);
spare->setEnabled(false); // 预留功能:置灰禁用,不接任何跳转
rightLay->addStretch(1);
rightLay->addWidget(debug, 0, Qt::AlignHCenter);
rightLay->addStretch(1);
rightLay->addWidget(help, 0, Qt::AlignHCenter);
rightLay->addStretch(1);
rightLay->addWidget(spare, 0, Qt::AlignHCenter);
rightLay->addStretch(1);
right->setFixedWidth(360);
main->addWidget(right);
root->addLayout(main, 1);
m_statusStrip = new StatusStrip(ctx, this);
m_statusStrip->setBackVisible(false);
m_statusStrip->setModeVisible(false); // 本页不显示模式徽标(菜单已移到左右两列)
root->addWidget(m_statusStrip);
connect(ctx->comm(), &CarCommunication::telemetryUpdated, this, &HomePage::refresh);
refresh();
}
void HomePage::refresh()
{
const Telemetry& t = ctx()->telemetry;
m_chassisCard->setField(0, QString("%1%").arg(t.soc));
m_chassisCard->setField(1, QString("%1V").arg(t.volt, 0, 'f', 0));
m_chassisCard->setField(2, t.chassisFaults.isEmpty() ? "无" : t.chassisFaults.join(";"));
m_chassisCard->setAlarm(2, !t.chassisFaults.isEmpty());
m_armCard->setField(0, t.pos);
m_armCard->setField(1, t.pose);
m_washCard->setField(0, QString("%1L").arg(t.levelA, 0, 'f', 0));
m_washCard->setField(1, QString("%1L").arg(t.levelFoam, 0, 'f', 0));
m_gasCard->setField(0, t.gasHost == "detecting" ? "检测中" : (t.gasHost == "fault" ? "故障" : "待机"));
m_gasCard->setField(1, QString("%1 ppb").arg(t.gasConc, 0, 'f', 0));
m_gasCard->setAlarm(1, t.gasConc > 500.0);
}
void HomePage::onHelp()
{
ConfirmDialog::message(this, "操作说明",
"系统自检将依次检测:通信系统、底盘通信、机械臂通信、洗消通信、"
"传感器信号、清水液位及电动球阀 D1/D2 的开启与关闭。\n"
"全部正常后方可进入作业选择界面。");
}
+30
View File
@@ -0,0 +1,30 @@
#ifndef HOMEPAGE_H
#define HOMEPAGE_H
#include "base.h"
class TitleBar;
class StatusStrip;
class DataCard;
// ===================== ⑤ 系统首页面 =====================
class HomePage : public BasePage
{
Q_OBJECT
public:
explicit HomePage(AppContext* ctx, QWidget* parent = nullptr);
private slots:
void refresh();
void onHelp();
private:
TitleBar* m_titleBar = nullptr;
StatusStrip* m_statusStrip = nullptr;
DataCard* m_chassisCard = nullptr;
DataCard* m_armCard = nullptr;
DataCard* m_washCard = nullptr;
DataCard* m_gasCard = nullptr;
};
#endif // HOMEPAGE_H
+44 -25
View File
@@ -2,7 +2,7 @@
#include "titlebar.h"
#include "statusstrip.h"
#include "datacard.h"
#include "udpcommunication.h"
#include "car.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
@@ -21,36 +21,43 @@ JobSelectPage::JobSelectPage(AppContext* ctx, QWidget* parent)
root->addWidget(m_titleBar);
QHBoxLayout* main = new QHBoxLayout();
main->setContentsMargins(36, 20, 36, 20);
main->setSpacing(32);
main->setContentsMargins(24, 20, 24, 20);
main->setSpacing(24);
// 左侧菜单(垂直居中 + 平均分布)
// ===== 按钮统一样式(改这里即可同时调整对应的按钮)=====
// 左侧 4 个菜单按钮(字号 50px;尺寸见 addMenu 里的 setFixedSize)
const QString menuBtnStyle =
QString("QPushButton{font-size:50px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;}"
"QPushButton:hover,QPushButton:focus{background-color:%4;color:#FFFFFF;border:2px solid #8ABFFF;}")
.arg(C::PANEL_HI, C::TEXT, C::BORDER, C::PRIMARY);
// 右侧动作按钮(字号 55px;尺寸见下方各自的 setFixedSize)
const QString actionBtnStyle = "QPushButton{font-size:55px;}";
// 左侧菜单(按钮在列内水平居中、垂直整体居中,选中发亮)
QWidget* left = new QWidget(this);
QVBoxLayout* leftLay = new QVBoxLayout(left);
leftLay->setContentsMargins(0, 0, 0, 0);
leftLay->setSpacing(8);
leftLay->setContentsMargins(10, 20, 10, 20);
leftLay->setSpacing(100);
auto addMenu = [&](const QString& t, PageId id) {
QPushButton* b = button(t, "", left);
b->setFixedHeight(130);
b->setStyleSheet(QString("font-size:24px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;")
.arg(C::PANEL_HI, C::TEXT, C::BORDER));
b->setFixedSize(260, 100); // 固定尺寸(改这里同时调整 4 个菜单按钮)
b->setStyleSheet(menuBtnStyle);
connect(b, &QPushButton::clicked, this, [this, id]() { emit navigate(id); });
leftLay->addStretch(1);
leftLay->addWidget(b);
leftLay->addWidget(b, 0, Qt::AlignHCenter); // 不占拉伸因子,余量交给上下 stretch 均分
};
leftLay->addStretch(1);
addMenu("机器人底盘", PageChassis);
addMenu("机械臂", PageArm);
addMenu("气体检测仪", PageGas);
addMenu("调试维护", PageDebug);
addMenu("故障查询", PageFault);
leftLay->addStretch(1);
left->setFixedWidth(240);
left->setFixedWidth(300);
main->addWidget(left);
// 中央卡片区(2×2)
QWidget* center = new QWidget(this);
QGridLayout* grid = new QGridLayout(center);
grid->setSpacing(16);
grid->setSpacing(24);
grid->setContentsMargins(0, 0, 0, 0);
m_chassisCard = new DataCard("底盘", center);
@@ -70,27 +77,38 @@ JobSelectPage::JobSelectPage(AppContext* ctx, QWidget* parent)
m_gasCard->addField("主机状态", "待机");
m_gasCard->addField("浓度", "0 ppb");
// 左列 底盘/机械臂,右列 洗消/气体检测仪
grid->addWidget(m_chassisCard, 0, 0);
grid->addWidget(m_armCard, 0, 1);
grid->addWidget(m_washCard, 1, 0);
grid->addWidget(m_washCard, 0, 1);
grid->addWidget(m_armCard, 1, 0);
grid->addWidget(m_gasCard, 1, 1);
main->addWidget(center, 1);
// 右侧按钮列(垂直居中 + 平均分布)
// 右侧按钮列(等分填满整列,选中发亮)
QWidget* right = new QWidget(this);
QVBoxLayout* rightLay = new QVBoxLayout(right);
rightLay->setContentsMargins(0, 0, 0, 0);
rightLay->setSpacing(8);
QPushButton* clean = button("手动消除", "primary", right);
clean->setFixedHeight(170);
rightLay->setSpacing(24);
QPushButton* clean = button("消除作业", "", right);
clean->setFixedSize(240, 100);
clean->setStyleSheet(actionBtnStyle);
connect(clean, &QPushButton::clicked, this, [this]() { emit navigate(PageClean); });
QPushButton* disinfect = button("手动消毒", "primary", right);
disinfect->setFixedHeight(170);
QPushButton* disinfect = button("消毒作业", "", right);
disinfect->setFixedSize(240, 100);
disinfect->setStyleSheet(actionBtnStyle);
connect(disinfect, &QPushButton::clicked, this, [this]() { emit navigate(PageDisinfect); });
// 返回:回系统首页面(⑤系统首页面 → ⑥作业选择)
QPushButton* back = button("返回", "", right);
back->setFixedSize(240, 100);
back->setStyleSheet(cancelButtonStyle(55)); // 选中(悬停/按下/焦点)红底白字
// connect(back, &QPushButton::clicked, this, [this]() { emit navigate(PageSelfCheck); }); // 原:回自检页
connect(back, &QPushButton::clicked, this, [this]() { emit navigate(PageHome); });
rightLay->addStretch(1);
rightLay->addWidget(clean);
rightLay->addWidget(clean, 0, Qt::AlignHCenter);
rightLay->addStretch(1);
rightLay->addWidget(disinfect);
rightLay->addWidget(disinfect, 0, Qt::AlignHCenter);
rightLay->addStretch(1);
rightLay->addWidget(back, 0, Qt::AlignHCenter);
rightLay->addStretch(1);
right->setFixedWidth(360);
main->addWidget(right);
@@ -99,9 +117,10 @@ JobSelectPage::JobSelectPage(AppContext* ctx, QWidget* parent)
m_statusStrip = new StatusStrip(ctx, this);
m_statusStrip->setBackVisible(false);
m_statusStrip->setModeVisible(false); // 本页不显示模式徽标(返回按钮已移到右侧列)
root->addWidget(m_statusStrip);
connect(ctx->comm(), &UdpCommunication::telemetryUpdated, this, &JobSelectPage::refresh);
connect(ctx->comm(), &CarCommunication::telemetryUpdated, this, &JobSelectPage::refresh);
refresh();
}
-28
View File
@@ -1,28 +0,0 @@
#include "manualclean.h"
#include "paramcontrol.h"
#include "udpcommunication.h"
#include "confirmdialog.h"
#include <QVBoxLayout>
#include "global.h"
ManualCleanPage::ManualCleanPage(AppContext* ctx, QWidget* parent)
: WashBasePage(ctx, "手动消除", "洗消(手动消除)", parent)
{
double flow = ctx->getDouble(ctx->keyPumpFlow(), 20);
m_pumpFlow = new ParamControl("柱塞泵流量", "L/min", 0.5, 0.0, 100.0, true, false, this);
m_pumpFlow->setValue(flow);
connect(m_pumpFlow, &ParamControl::saveClicked, this, [this]() {
this->ctx()->setDouble(this->ctx()->keyPumpFlow(), m_pumpFlow->value());
this->ctx()->comm()->sendSetParam(this->ctx()->keyPumpFlow(), m_pumpFlow->value());
ConfirmDialog::toast(this, "已保存");
});
paramsLayout()->addWidget(m_pumpFlow);
}
void ManualCleanPage::resetFactory()
{
// 出厂值 20
m_pumpFlow->setValue(20);
ctx()->setDouble(ctx()->keyPumpFlow(), 20);
ConfirmDialog::toast(this, "已恢复出厂值");
}
-23
View File
@@ -1,23 +0,0 @@
#ifndef MANUALCLEANPAGE_H
#define MANUALCLEANPAGE_H
#include "washbase.h"
class ParamControl;
// ===================== ⑨ 手动消除界面 =====================
class ManualCleanPage : public WashBasePage
{
Q_OBJECT
public:
explicit ManualCleanPage(AppContext* ctx, QWidget* parent = nullptr);
protected:
void resetFactory() override;
QString washModeTag() const override { return "clean"; }
private:
ParamControl* m_pumpFlow = nullptr;
};
#endif // MANUALCLEANPAGE_H
-49
View File
@@ -1,49 +0,0 @@
#include "manualdisinfect.h"
#include "paramcontrol.h"
#include "udpcommunication.h"
#include "confirmdialog.h"
#include <QVBoxLayout>
#include "global.h"
ManualDisinfectPage::ManualDisinfectPage(AppContext* ctx, QWidget* parent)
: WashBasePage(ctx, "手动消毒", "洗消(手动消毒)", parent)
{
// 发泡倍数(出厂 6)
m_foamRatio = new ParamControl("发泡倍数", "倍", 0.5, 0.0, 20.0, true, false, this);
m_foamRatio->setValue(ctx->getDouble(ctx->keyFoamRatio(), 6));
connect(m_foamRatio, &ParamControl::saveClicked, this, [this]() {
this->ctx()->setDouble(this->ctx()->keyFoamRatio(), m_foamRatio->value());
ConfirmDialog::toast(this, "已保存");
});
// 泡沫比例(出厂 3%)
m_foamPercent = new ParamControl("泡沫比例", "%", 0.5, 0.0, 20.0, true, false, this);
m_foamPercent->setValue(ctx->getDouble(ctx->keyFoamPercent(), 3));
connect(m_foamPercent, &ParamControl::saveClicked, this, [this]() {
this->ctx()->setDouble(this->ctx()->keyFoamPercent(), m_foamPercent->value());
ConfirmDialog::toast(this, "已保存");
});
// 空压机流量(出厂 100)
m_airFlow = new ParamControl("空压机流量", "L/min", 1, 0.0, 500.0, true, false, this);
m_airFlow->setValue(ctx->getDouble(ctx->keyAirFlow(), 100));
connect(m_airFlow, &ParamControl::saveClicked, this, [this]() {
this->ctx()->setDouble(this->ctx()->keyAirFlow(), m_airFlow->value());
ConfirmDialog::toast(this, "已保存");
});
paramsLayout()->addWidget(m_foamRatio);
paramsLayout()->addWidget(m_foamPercent);
paramsLayout()->addWidget(m_airFlow);
}
void ManualDisinfectPage::resetFactory()
{
m_foamRatio->setValue(6);
m_foamPercent->setValue(3);
m_airFlow->setValue(100);
ctx()->setDouble(ctx()->keyFoamRatio(), 6);
ctx()->setDouble(ctx()->keyFoamPercent(), 3);
ctx()->setDouble(ctx()->keyAirFlow(), 100);
ConfirmDialog::toast(this, "已恢复出厂值");
}
-25
View File
@@ -1,25 +0,0 @@
#ifndef MANUALDISINFECTPAGE_H
#define MANUALDISINFECTPAGE_H
#include "washbase.h"
class ParamControl;
// ===================== ⑩ 手动消毒界面 =====================
class ManualDisinfectPage : public WashBasePage
{
Q_OBJECT
public:
explicit ManualDisinfectPage(AppContext* ctx, QWidget* parent = nullptr);
protected:
void resetFactory() override;
QString washModeTag() const override { return "disinfect"; }
private:
ParamControl* m_foamRatio = nullptr; // 发泡倍数
ParamControl* m_foamPercent = nullptr; // 泡沫比例
ParamControl* m_airFlow = nullptr; // 空压机流量
};
#endif // MANUALDISINFECTPAGE_H
+13 -9
View File
@@ -1,7 +1,7 @@
#include "selfcheck.h"
#include "titlebar.h"
#include "ledindicator.h"
#include "udpcommunication.h"
#include "car.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
@@ -9,7 +9,7 @@
#include <QPushButton>
#include <QProgressBar>
#include <QTimer>
#include <QMessageBox>
#include "confirmdialog.h"
#include "global.h"
SelfCheckPage::SelfCheckPage(AppContext* ctx, QWidget* parent)
@@ -29,7 +29,7 @@ SelfCheckPage::SelfCheckPage(AppContext* ctx, QWidget* parent)
main->setSpacing(40);
// 两侧按钮统一样式:字号在此统一调整;按钮会拉伸填满整列(sideBtnH 为最小高度)
const int sideBtnH = 200;
const int sideBtnH = 130;
const QString sideBtnStyle = "font-size:60px;";
auto makeSideBtn = [&](const QString& text, const QString& variant, QWidget* parent) {
QPushButton* b = button(text, variant, parent);
@@ -127,8 +127,8 @@ SelfCheckPage::SelfCheckPage(AppContext* ctx, QWidget* parent)
root->addLayout(main, 1);
// 连接通信信号
connect(ctx->comm(), &UdpCommunication::selfCheckItem, this, &SelfCheckPage::onSelfCheckItem);
connect(ctx->comm(), &UdpCommunication::selfCheckDone, this, &SelfCheckPage::onSelfCheckDone);
connect(ctx->comm(), &CarCommunication::selfCheckItem, this, &SelfCheckPage::onSelfCheckItem);
connect(ctx->comm(), &CarCommunication::selfCheckDone, this, &SelfCheckPage::onSelfCheckDone);
}
void SelfCheckPage::showEvent(QShowEvent* ev)
@@ -200,12 +200,15 @@ void SelfCheckPage::onSelfCheckDone(bool allOk)
m_status->setStyleSheet(QString("color:%1;font-size:60px;font-weight:700;").arg(C::SUCCESS));
m_job->setEnabled(true);
m_job->setFocus(); // 自检完成后聚焦到“作业选择”
// 自检完成后停留在本界面,不自动跳转(需进入作业选择请手动点击)
// 【设计建议】自检完成 2s 后自动进入作业选择
// 自检完成 2s 后自动进入系统首页面(原目标为作业选择页)
// QTimer::singleShot(2000, this, [this]() {
// if (m_done && isVisible())
// emit navigate(PageJob);
// });
QTimer::singleShot(2000, this, [this]() {
if (m_done && isVisible())
emit navigate(PageHome);
});
} else {
m_status->setText("无法完成自检 请检查设备");
m_status->setStyleSheet(QString("color:%1;font-size:60px;font-weight:700;").arg(C::DANGER));
@@ -218,7 +221,8 @@ void SelfCheckPage::onSelfCheckDone(bool allOk)
void SelfCheckPage::onIgnoreFault()
{
emit navigate(PageJob);
// emit navigate(PageJob); // 原:直接进作业选择页
emit navigate(PageHome); // 忽略故障后进入系统首页面
}
void SelfCheckPage::onJobSelect()
@@ -228,7 +232,7 @@ void SelfCheckPage::onJobSelect()
void SelfCheckPage::onHelp()
{
QMessageBox::information(this, "操作说明",
ConfirmDialog::message(this, "操作说明",
"系统自检将依次检测:通信系统、底盘通信、机械臂通信、洗消通信、"
"传感器信号、清水液位及电动球阀 D1/D2 的开启与关闭。\n"
"全部正常后方可进入作业选择界面。");
+5 -2
View File
@@ -3,7 +3,7 @@
#include "alarmbanner.h"
#include "datacard.h"
#include "ledindicator.h"
#include "udpcommunication.h"
#include "car.h"
#include "confirmdialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
@@ -68,6 +68,7 @@ WashBasePage::WashBasePage(AppContext* ctx, const QString& title, const QString&
m_fFoam = m_washCard->addField("发泡倍数", "0");
m_fAirFault = m_washCard->addField("空压机故障代码", "");
m_fPlungerFault = m_washCard->addField("柱塞泵故障代码", "");
m_washCard->indentRows(); // 行文字缩进到中间偏左(卡片很宽,贴左缘会显得空)
main->addWidget(m_washCard, 1);
// 右侧操作列
@@ -109,6 +110,7 @@ WashBasePage::WashBasePage(AppContext* ctx, const QString& title, const QString&
m_factory = button("恢复出厂值", "", bottom);
m_factory->setFixedSize(220, 64);
m_factory->setStyleSheet(cancelButtonStyle(40)); // 选中(悬停/按下/焦点)红底白字
connect(m_factory, &QPushButton::clicked, this, &WashBasePage::onFactory);
bottomLay->addWidget(m_factory);
bottomLay->addSpacing(20);
@@ -134,6 +136,7 @@ WashBasePage::WashBasePage(AppContext* ctx, const QString& title, const QString&
m_back = button("返回", "", bottom);
m_back->setFixedSize(144, 76);
m_back->setStyleSheet(cancelButtonStyle(40)); // 选中(悬停/按下/焦点)红底白字
connect(m_back, &QPushButton::clicked, this, &WashBasePage::onBack);
bottomLay->addWidget(m_back);
@@ -145,7 +148,7 @@ WashBasePage::WashBasePage(AppContext* ctx, const QString& title, const QString&
connect(m_faultTimer, &QTimer::timeout, this, &WashBasePage::rotateFaults);
m_faultTimer->start();
connect(ctx->comm(), &UdpCommunication::telemetryUpdated, this, &WashBasePage::refresh);
connect(ctx->comm(), &CarCommunication::telemetryUpdated, this, &WashBasePage::refresh);
updateGating();
refresh();
}
+1 -1
View File
@@ -12,7 +12,7 @@ class QPushButton;
class QVBoxLayout;
class QTimer;
// ===================== 洗消页面基类(⑨手动消除 / ⑩手动消毒 共用) =====================
// ===================== 洗消页面基类(⑨消除作业 / ⑩消毒作业 共用) =====================
// 左侧状态卡 + 中央洗消参数卡 + 右侧启动/停止与参数 + 底部指示灯条
class WashBasePage : public BasePage
{
+369
View File
@@ -0,0 +1,369 @@
#include "washpage.h"
#include "titlebar.h"
#include "alarmbanner.h"
#include "datacard.h"
#include "ledindicator.h"
#include "paramcontrol.h"
#include "car.h"
#include "confirmdialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QPushButton>
#include <QLabel>
#include <QTimer>
#include "global.h"
WashPage::WashPage(AppContext* ctx, Mode mode, QWidget* parent)
: BasePage(ctx, parent), m_mode(mode)
{
const bool clean = (mode == Clean);
QVBoxLayout* root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
m_titleBar = new TitleBar(ctx, this);
m_titleBar->setTitle(clean ? "消除作业" : "消毒作业");
root->addWidget(m_titleBar);
m_banner = new AlarmBanner(this);
root->addWidget(m_banner);
QHBoxLayout* main = new QHBoxLayout();
main->setContentsMargins(36, 16, 36, 16);
main->setSpacing(28);
// 左侧状态卡(底盘/机械臂/气体检测仪)
QWidget* left = new QWidget(this);
QVBoxLayout* leftLay = new QVBoxLayout(left);
leftLay->setContentsMargins(0, 0, 0, 0);
leftLay->setSpacing(10);
m_chassisCard = new DataCard("底盘", left);
m_chassisCard->addField("电量", "85%");
m_chassisCard->addField("电压", "360V");
m_chassisCard->addField("故障", "无");
m_armCard = new DataCard("机械臂", left);
m_armCard->addField("末端位置", "XYZ");
m_armCard->addField("姿态", "RX/RY/RZ");
m_gasCard = new DataCard("气体检测仪", left);
m_gasCard->addField("主机状态", "待机");
m_gasCard->addField("浓度", "0 ppb");
leftLay->addWidget(m_chassisCard, 1);
leftLay->addWidget(m_armCard, 1);
leftLay->addWidget(m_gasCard, 1);
left->setFixedWidth(600); // 需容纳最长的“末端位置:X:1200 Y:300 Z:800”(42px 字号约 523px)
main->addSpacing(60); // 左列整体右移的量;空白由中间拉伸列让出,卡片宽度不变
main->addWidget(left);
// 中央洗消卡
m_washCard = new DataCard(clean ? "洗消(消除作业)" : "洗消(消毒作业)", this);
m_fMode = m_washCard->addField("洗消模式", "--");
m_fLevelA = m_washCard->addField("A组分液位", "100L");
m_fLevelFoam = m_washCard->addField("泡沫箱液位", "30L");
m_fFlowA = m_washCard->addField("A药剂流量", "0 L/min");
m_fFlowB = m_washCard->addField("B药剂流量", "0 L/min");
m_fGasFlow = m_washCard->addField("气体流量", "0 L/min");
m_fPress = m_washCard->addField("管路压力", "0 MPa");
m_fAirPress = m_washCard->addField("空压机压力", "0 MPa");
m_fFoam = m_washCard->addField("发泡倍数", "0");
m_fAirFault = m_washCard->addField("空压机故障代码", "");
m_fPlungerFault = m_washCard->addField("柱塞泵故障代码", "");
main->addSpacing(60);
main->addWidget(m_washCard, 1);
// 右侧操作列
QWidget* right = new QWidget(this);
QVBoxLayout* rightLay = new QVBoxLayout(right);
rightLay->setContentsMargins(20, 20, 20, 10);
rightLay->setSpacing(60);
m_start = button("启动", "primary", right);
m_start->setFixedSize(180, 100);
connect(m_start, &QPushButton::clicked, this, &WashPage::onStart);
m_stop = button("停止", "danger", right);
m_stop->setFixedSize(180, 100);
connect(m_stop, &QPushButton::clicked, this, &WashPage::onStop);
rightLay->addWidget(m_start, 0, Qt::AlignHCenter);
rightLay->addWidget(m_stop, 0, Qt::AlignHCenter);
// 参数区:按模式不同
m_paramsLayout = new QVBoxLayout();
m_paramsLayout->setSpacing(8);
rightLay->addLayout(m_paramsLayout);
rightLay->addStretch(1);
if (clean) {
// 柱塞泵流量(出厂 20;协议 PlungerPump_flow 0-24 L/min)
m_pumpFlow = new ParamControl("柱塞泵流量", "L/min", 0.1, 0.0, 24.0, true, false, this);
m_pumpFlow->setValue(ctx->getDouble(ctx->keyPumpFlow(), 20));
connect(m_pumpFlow, &ParamControl::saveClicked, this, [this]() {
this->ctx()->setDouble(this->ctx()->keyPumpFlow(), m_pumpFlow->value());
this->ctx()->comm()->sendSetParam(this->ctx()->keyPumpFlow(), m_pumpFlow->value());
refreshPumpValues(); // 底部数值跟着更新
ConfirmDialog::toast(this, "已保存");
});
m_paramsLayout->addWidget(m_pumpFlow);
} else {
// 发泡倍数(出厂 6;协议 Expansion_ratioCmd 6-12 倍)
m_foamRatio = new ParamControl("发泡倍数", "倍", 0.1, 6.0, 12.0, true, false, this);
m_foamRatio->setValue(ctx->getDouble(ctx->keyFoamRatio(), 6));
connect(m_foamRatio, &ParamControl::saveClicked, this, [this]() {
this->ctx()->setDouble(this->ctx()->keyFoamRatio(), m_foamRatio->value());
this->ctx()->comm()->sendSetParam(this->ctx()->keyFoamRatio(), m_foamRatio->value());
ConfirmDialog::toast(this, "已保存");
});
m_paramsLayout->addWidget(m_foamRatio);
// 泡沫比例(出厂 3%;协议 Foam_ratio 0-10%)
m_foamPercent = new ParamControl("泡沫比例", "%", 0.1, 0.0, 10.0, true, false, this);
m_foamPercent->setValue(ctx->getDouble(ctx->keyFoamPercent(), 3));
connect(m_foamPercent, &ParamControl::saveClicked, this, [this]() {
this->ctx()->setDouble(this->ctx()->keyFoamPercent(), m_foamPercent->value());
this->ctx()->comm()->sendSetParam(this->ctx()->keyFoamPercent(), m_foamPercent->value());
ConfirmDialog::toast(this, "已保存");
});
m_paramsLayout->addWidget(m_foamPercent);
// 空压机流量(出厂 100;协议 AirComp_flow 0-340 L/min)
m_airFlow = new ParamControl("空压机流量", "L/min", 1, 0.0, 340.0, true, false, this);
m_airFlow->setValue(ctx->getDouble(ctx->keyAirFlow(), 100));
connect(m_airFlow, &ParamControl::saveClicked, this, [this]() {
this->ctx()->setDouble(this->ctx()->keyAirFlow(), m_airFlow->value());
this->ctx()->comm()->sendSetParam(this->ctx()->keyAirFlow(), m_airFlow->value());
refreshPumpValues(); // 底部数值跟着更新
ConfirmDialog::toast(this, "已保存");
});
m_paramsLayout->addWidget(m_airFlow);
}
right->setFixedWidth(500); // 参数行需容纳 输入框150 + 单位约101 + 保存140 + 两个间距24 ≈ 415px
main->addSpacing(60); // 洗消卡 ↔ 右列的间隙;空白由中间拉伸列让出,卡片文字不受影响
main->addWidget(right);
root->addLayout(main, 1);
// 底部条:左右两个按钮纵向占满两行,中间上下两排(泵数值 / 指示灯)居中
QWidget* bottom = new QWidget(this);
bottom->setObjectName("WashBottom");
bottom->setFixedHeight(160);
// 用 #WashBottom 限定选择器:不带选择器的样式表会连子控件一起套上边框和底色
bottom->setStyleSheet(QString("QWidget#WashBottom{background-color:%1;border-top:1px solid %2;}")
.arg(C::PANEL, C::BORDER));
QHBoxLayout* bottomRoot = new QHBoxLayout(bottom);
bottomRoot->setContentsMargins(20, 8, 20, 8);
bottomRoot->setSpacing(12);
// 左:恢复出厂值(占满两行高度)
m_factory = button("恢复出厂值", "", bottom);
m_factory->setFixedSize(220, 100);
m_factory->setStyleSheet(cancelButtonStyle(40)); // 选中(悬停/按下/焦点)红底白字
connect(m_factory, &QPushButton::clicked, this, &WashPage::onFactory);
bottomRoot->addWidget(m_factory, 0, Qt::AlignVCenter);
// 中:上排三泵数值、下排指示灯,两排都水平居中
QVBoxLayout* midLay = new QVBoxLayout();
midLay->setSpacing(6);
QHBoxLayout* valueLay = new QHBoxLayout();
valueLay->setSpacing(40);
valueLay->addStretch(1);
auto addFlow = [&](const QString& name) {
valueLay->addWidget(label(name + ":", 40, C::TEXT_SUB, false, bottom));
QLabel* v = label("--", 40, C::TEXT, true, bottom);
valueLay->addWidget(v);
return v;
};
m_valPlunger = addFlow("柱塞泵");
m_valAir = addFlow("空压机");
m_valLeaf = addFlow("叶片泵");
refreshPumpValues(); // 开机先显示参数的出厂默认值
valueLay->addStretch(1);
QHBoxLayout* ledLay = new QHBoxLayout();
ledLay->setSpacing(10); // 8 个灯 40px 字号后总宽约 1300px
ledLay->addStretch(1);
auto makeLed = [&](const QString& text) {
LedIndicator* led = new LedIndicator(text, bottom);
led->setTextPixelSize(40); // 字号与卡片标题一致
ledLay->addWidget(led);
return led;
};
m_ledRun = makeLed("启停");
m_ledPlunger = makeLed("柱塞泵");
m_ledAir = makeLed("空压机");
m_ledLeaf = makeLed("叶片泵");
m_ledLevel = makeLed("液位低");
m_ledD1 = makeLed("D1");
m_ledD2 = makeLed("D2");
m_ledFault = makeLed("系统故障");
ledLay->addStretch(1);
midLay->addStretch(1);
midLay->addLayout(valueLay);
midLay->addLayout(ledLay);
midLay->addStretch(1);
bottomRoot->addLayout(midLay, 1);
// 右:返回(占满两行高度)
m_back = button("返回", "", bottom);
m_back->setFixedSize(170, 100);
m_back->setStyleSheet(cancelButtonStyle(40)); // 选中(悬停/按下/焦点)红底白字
connect(m_back, &QPushButton::clicked, this, &WashPage::onBack);
bottomRoot->addWidget(m_back, 0, Qt::AlignVCenter);
root->addWidget(bottom);
// 故障代码轮流循环显示(2s/条)
m_faultTimer = new QTimer(this);
m_faultTimer->setInterval(2000);
connect(m_faultTimer, &QTimer::timeout, this, &WashPage::rotateFaults);
m_faultTimer->start();
connect(ctx->comm(), &CarCommunication::telemetryUpdated, this, &WashPage::refresh);
updateGating();
refresh();
}
QString WashPage::washModeTag() const
{
return m_mode == Clean ? "clean" : "disinfect";
}
// 底部三个泵的数值:柱塞泵/空压机跟右侧参数的设定值(没改过就是出厂默认值),
// 叶片泵本页没有对应参数,先取遥测的实时流量
void WashPage::refreshPumpValues()
{
// 这里读的是配置值,一并按协议量程夹取(柱塞泵 0-24、空压机 0-340 L/min),
// 否则旧配置里超范围的历史值会与控件显示、实际上报的数值对不上
m_valPlunger->setText(QString("%1 L/min").arg(qBound(0.0, ctx()->getDouble(ctx()->keyPumpFlow(), 20), 24.0), 0, 'f', 1));
m_valAir->setText(QString("%1 L/min").arg(qBound(0.0, ctx()->getDouble(ctx()->keyAirFlow(), 100), 340.0), 0, 'f', 1));
m_valLeaf->setText(QString("%1 L/min").arg(ctx()->telemetry.flowB, 0, 'f', 1));
}
void WashPage::onStart()
{
ctx()->comm()->sendWashStart(washModeTag());
ctx()->washRunning = true;
updateGating();
}
void WashPage::onStop()
{
ctx()->comm()->sendWashStop();
ctx()->washRunning = false;
updateGating();
}
void WashPage::onBack()
{
if (ctx()->washRunning) {
ConfirmDialog::toast(this, "请先停止");
return;
}
emit navigate(PageJob);
}
void WashPage::onFactory()
{
if (ConfirmDialog::confirm(this, "恢复出厂值", "确定将参数恢复为出厂值?"))
resetFactory();
}
void WashPage::resetFactory()
{
if (m_mode == Clean) {
m_pumpFlow->setValue(20); // 出厂 20
ctx()->setDouble(ctx()->keyPumpFlow(), 20);
ctx()->comm()->sendSetParam(ctx()->keyPumpFlow(), 20);
} else {
m_foamRatio->setValue(6); // 出厂 6
m_foamPercent->setValue(3); // 出厂 3%
m_airFlow->setValue(100); // 出厂 100
ctx()->setDouble(ctx()->keyFoamRatio(), 6);
ctx()->setDouble(ctx()->keyFoamPercent(), 3);
ctx()->setDouble(ctx()->keyAirFlow(), 100);
ctx()->comm()->sendSetParam(ctx()->keyFoamRatio(), 6);
ctx()->comm()->sendSetParam(ctx()->keyFoamPercent(), 3);
ctx()->comm()->sendSetParam(ctx()->keyAirFlow(), 100);
}
refreshPumpValues(); // 底部数值跟着回到出厂值
ConfirmDialog::toast(this, "已恢复出厂值");
}
void WashPage::updateGating()
{
bool running = ctx()->washRunning;
m_start->setEnabled(!running);
m_stop->setEnabled(running);
m_back->setEnabled(!running);
}
void WashPage::refresh()
{
const Telemetry& t = ctx()->telemetry;
if (!t.commOk) m_banner->showAlarm("通信异常");
else m_banner->clear();
// 以泵状态同步运行状态
if (t.plunger || t.air || t.leaf) ctx()->washRunning = true;
else if (t.washMode == "--") ctx()->washRunning = false;
updateGating();
m_chassisCard->setField(0, QString("%1%").arg(t.soc));
m_chassisCard->setField(1, QString("%1V").arg(t.volt, 0, 'f', 0));
m_chassisCard->setField(2, t.chassisFaults.isEmpty() ? "无" : t.chassisFaults.join(";"));
m_armCard->setField(0, t.pos);
m_armCard->setField(1, t.pose);
m_gasCard->setField(0, t.gasHost == "detecting" ? "检测中" : (t.gasHost == "fault" ? "故障" : "待机"));
m_gasCard->setField(1, QString("%1 ppb").arg(t.gasConc, 0, 'f', 0));
m_washCard->setField(m_fMode, t.washMode);
m_washCard->setField(m_fLevelA, QString("%1L").arg(t.levelA, 0, 'f', 0));
m_washCard->setField(m_fLevelFoam, QString("%1L").arg(t.levelFoam, 0, 'f', 0));
m_washCard->setField(m_fFlowA, QString("%1 L/min").arg(t.flowA, 0, 'f', 1));
m_washCard->setField(m_fFlowB, QString("%1 L/min").arg(t.flowB, 0, 'f', 1));
m_washCard->setField(m_fGasFlow, QString("%1 L/min").arg(t.gasFlow, 0, 'f', 1));
m_washCard->setField(m_fPress, QString("%1 MPa").arg(t.press, 0, 'f', 2));
m_washCard->setField(m_fAirPress, QString("%1 MPa").arg(t.airPress, 0, 'f', 2));
m_washCard->setField(m_fFoam, QString("%1").arg(t.foam, 0, 'f', 1));
// 指示灯
m_ledRun->setState(ctx()->washRunning ? LedIndicator::Green : LedIndicator::Off);
m_ledPlunger->setState(t.plunger ? LedIndicator::Green : LedIndicator::Off);
m_ledAir->setState(t.air ? LedIndicator::Green : LedIndicator::Off);
m_ledLeaf->setState(t.leaf ? LedIndicator::Green : LedIndicator::Off);
m_ledLevel->setState(t.levelA < 10 ? LedIndicator::RedBlink : LedIndicator::Off);
m_ledD1->setState(t.d1open ? LedIndicator::Green : LedIndicator::Off);
m_ledD2->setState(t.d2open ? LedIndicator::Green : LedIndicator::Off);
m_ledFault->setState((!t.washFaults.isEmpty() || !t.pumpFaults.isEmpty())
? LedIndicator::RedBlink : LedIndicator::Off);
// 底部三个泵的数值
refreshPumpValues();
// 故障代码显示(静态更新,循环由 rotateFaults 驱动)
rotateFaults();
}
void WashPage::rotateFaults()
{
const Telemetry& t = ctx()->telemetry;
QStringList all = t.washFaults + t.pumpFaults;
if (all.isEmpty()) {
m_washCard->setField(m_fAirFault, "");
m_washCard->setField(m_fPlungerFault, "");
return;
}
m_faultIndex = m_faultIndex % all.size();
QString code = all[m_faultIndex];
m_washCard->setField(m_fAirFault, code);
m_washCard->setField(m_fPlungerFault, code);
m_faultIndex = (m_faultIndex + 1) % all.size();
}
+86
View File
@@ -0,0 +1,86 @@
#ifndef WASHPAGE_H
#define WASHPAGE_H
#include "base.h"
class TitleBar;
class AlarmBanner;
class DataCard;
class LedIndicator;
class ParamControl;
class QPushButton;
class QLabel;
class QVBoxLayout;
class QTimer;
// ===================== 洗消页(⑨消除作业 / ⑩消毒作业 共用) =====================
// 自包含页面:三列骨架、中央洗消卡、右侧启停与参数区、底部指示灯条都在本文件里。
// 两个页面只差 mode(标题、洗消标识、参数项及出厂值),调整布局改这一处即可。
class WashPage : public BasePage
{
Q_OBJECT
public:
enum Mode { Clean, Disinfect };
explicit WashPage(AppContext* ctx, Mode mode, QWidget* parent = nullptr);
private slots:
void onStart();
void onStop();
void onBack();
void onFactory();
void refresh();
void rotateFaults();
private:
void updateGating();
void resetFactory();
void refreshPumpValues(); // 刷新底部三个泵的数值
QString washModeTag() const; // "clean" / "disinfect"
Mode m_mode;
TitleBar* m_titleBar = nullptr;
AlarmBanner* m_banner = nullptr;
DataCard* m_chassisCard = nullptr;
DataCard* m_armCard = nullptr;
DataCard* m_gasCard = nullptr;
DataCard* m_washCard = nullptr;
QPushButton* m_start = nullptr;
QPushButton* m_stop = nullptr;
QPushButton* m_back = nullptr;
QPushButton* m_factory = nullptr;
QVBoxLayout* m_paramsLayout = nullptr;
// 参数:消除用柱塞泵流量;消毒用发泡倍数 / 泡沫比例 / 空压机流量
ParamControl* m_pumpFlow = nullptr;
ParamControl* m_foamRatio = nullptr;
ParamControl* m_foamPercent = nullptr;
ParamControl* m_airFlow = nullptr;
// 底部指示灯
LedIndicator* m_ledRun = nullptr;
LedIndicator* m_ledPlunger = nullptr;
LedIndicator* m_ledAir = nullptr;
LedIndicator* m_ledLeaf = nullptr;
LedIndicator* m_ledLevel = nullptr;
LedIndicator* m_ledD1 = nullptr;
LedIndicator* m_ledD2 = nullptr;
LedIndicator* m_ledFault = nullptr;
// 底部第二行:三个泵的当前流量值
QLabel* m_valPlunger = nullptr;
QLabel* m_valAir = nullptr;
QLabel* m_valLeaf = nullptr;
// 中央洗消卡字段索引
int m_fMode = -1, m_fLevelA = -1, m_fLevelFoam = -1;
int m_fFlowA = -1, m_fFlowB = -1, m_fGasFlow = -1;
int m_fPress = -1, m_fAirPress = -1, m_fFoam = -1;
int m_fAirFault = -1, m_fPlungerFault = -1;
QTimer* m_faultTimer = nullptr;
int m_faultIndex = 0;
};
#endif // WASHPAGE_H
Binary file not shown.
Binary file not shown.
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""车端 CAN 报文模拟器 / 上行抓包器(联调用,非工程代码)。
依据《洗消机器人遥控器通信协议20260810.xlsx》sheet RC(分类一/二/三,全部扩展帧、Intel 小端位序)。
线格式:32 位帧头 0x5AA5FF00 + 32 位 canId + DLC(8) + 8 字节数据 + 校验和,共 18 字节,
多字节字段按小端排列,与 comm/udpcommunication.cpp 的 UdpCanFrame 一致。
用法示例:
# 向 UI 的 9001 端口周期发 VCU 反馈,看底盘页电量/电压/车速刷新
python can_sender.py --soc 85 --volt 360 --speed 4.5
# 制造故障:MCU 码 61(电机缺相故障)+ 整系统通信异常(底盘BMS)
python can_sender.py --mcu-fault 61 --comm-fault 2
# 洗消 + 气体检测仪反馈
python can_sender.py --wash disinfect --level-a 88 --press 0.3 --gas-conc 250 --gas-type 2 --gas-level 2
# 反向:绑 9000 抓 UI 下发的控制指令并解出来
python can_sender.py --listen
"""
import argparse
import socket
import struct
import time
HDR = 0x5AA5FF00
# 分类一 遥控器控制指令(上行,UI → 车端)
ID_CHASSIS_CMD = 0x18208081
ID_ARM_CMD1 = 0x18218081
ID_ARM_CMD2 = 0x18228081
ID_DECONTAM_CMD = 0x18238081
ID_DECONTAM_DBG = 0x18248081
ID_DETECTOR_CMD = 0x18258081
ID_EMERGENCY = 0x18268081
# 分类二/三 VCU 状态反馈(下行,车端 → UI)
ID_VEHICLE_FB1 = 0x18308180
ID_VEHICLE_FB2 = 0x18318180
ID_ARM_FB1 = 0x18328180
ID_ARM_FB2 = 0x18338180
ID_ARM_FB3 = 0x18348180
ID_ARM_FB4 = 0x18358180
ID_DECONTAM_FB1 = 0x18368180
ID_DECONTAM_FB2 = 0x18378180
ID_DETECTOR_FB1 = 0x18388180
ID_DETECTOR_FB2 = 0x18398180
ID_VEHICLE_FB3 = 0x183A8180
ID_VEHICLE_FB4 = 0x183B8180
NAMES = {
ID_CHASSIS_CMD: "Chassis_ControlCmd", ID_ARM_CMD1: "Arm_ControlCmd1",
ID_ARM_CMD2: "Arm_ControlCmd2", ID_DECONTAM_CMD: "Decontam_ControlCmd",
ID_DECONTAM_DBG: "Decontam_DebugCmd", ID_DETECTOR_CMD: "Detector_ControlCmd",
ID_EMERGENCY: "Emergency_Cmd", ID_VEHICLE_FB1: "Vehicle_StatusFeedBack1",
ID_VEHICLE_FB2: "Vehicle_StatusFeedBack2", ID_ARM_FB1: "Arm_StatusFeedBack1",
ID_ARM_FB2: "Arm_StatusFeedBack2", ID_ARM_FB3: "Arm_StatusFeedBack3",
ID_ARM_FB4: "Arm_StatusFeedBack4", ID_DECONTAM_FB1: "Decontam_StatusFeedBack1",
ID_DECONTAM_FB2: "Decontam_StatusFeedBack2", ID_DETECTOR_FB1: "Detector_StatusFeedBack1",
ID_DETECTOR_FB2: "Detector_StatusFeedBack2", ID_VEHICLE_FB3: "Vehicle_StatusFeedBack3",
ID_VEHICLE_FB4: "Vehicle_StatusFeedBack4",
}
def set_bits(d, start, ln, v):
"""Intel(小端):起始位即最低位序号,位序由低到高,可跨字节。"""
v = int(v)
for i in range(ln):
b = start + i
m = 1 << (b & 7)
if v & (1 << i):
d[b >> 3] |= m
else:
d[b >> 3] &= ~m & 0xFF
def get_bits(d, start, ln):
v = 0
for i in range(ln):
b = start + i
if d[b >> 3] & (1 << (b & 7)):
v |= 1 << i
return v
def get_signed(d, start, ln):
raw = get_bits(d, start, ln)
sign = 1 << (ln - 1)
return (raw ^ sign) - sign
def checksum(can_id, dlc, d):
return (sum((can_id >> (8 * i)) & 0xFF for i in range(4)) + dlc + sum(d)) & 0xFF
def pack(can_id, d):
return struct.pack("<IIB8BB", HDR, can_id, 8, *d, checksum(can_id, 8, d))
def clamp(v, lo, hi):
return max(lo, min(hi, int(v)))
# ===================== 下行:构造分类二/三 反馈帧 =====================
def fb_vehicle1(a):
d = bytearray(8)
set_bits(d, 0, 16, clamp(round(a.volt * 10), 0, 0xFFFF)) # Total_voltage 精度 0.1
set_bits(d, 16, 16, clamp(round(a.soc * 10), 0, 0xFFFF)) # SOC 精度 0.1
set_bits(d, 32, 8, a.mcu_fault) # MCU_FaultCode
set_bits(d, 40, 2, a.mcu_level) # MCU_FaultLevel
set_bits(d, 45, 2, a.celllow_level) # UnderOver_FaultLevel
set_bits(d, 47, 2, a.cellhigh_level) # CellOver_FaultLevel(跨字节)
return d
def fb_vehicle2(a):
d = bytearray(8)
set_bits(d, 22, 2, 1 if a.handbrake else 0) # Handbrake
# Vehi_spd 16 位 0~500 × 0.01;其后字段整体后移 8 位
set_bits(d, 24, 16, clamp(round(a.speed * 100), 0, 500)) # Vehi_spd
set_bits(d, 48, 8, a.comm_fault) # All_Comm_FaultCode
return d
def fb_arm1(a):
d = bytearray(8)
set_bits(d, 0, 16, a.posx & 0xFFFF) # End_positionX (Signed mm)
set_bits(d, 16, 16, a.posy & 0xFFFF)
set_bits(d, 32, 16, a.posz & 0xFFFF)
return d
def fb_arm2(a):
d = bytearray(8)
set_bits(d, 0, 16, a.rx & 0xFFFF)
set_bits(d, 16, 16, a.ry & 0xFFFF)
set_bits(d, 32, 16, a.rz & 0xFFFF)
set_bits(d, 48, 8, clamp(a.endspd, -128, 127) & 0xFF) # End_spd (Signed)
return d
def fb_arm3(a):
d = bytearray(8)
for i in range(4):
set_bits(d, i * 16, 16, getattr(a, "j%d" % (i + 1)) & 0xFFFF)
return d
def fb_arm4(a):
d = bytearray(8)
set_bits(d, 0, 16, a.j5 & 0xFFFF)
set_bits(d, 16, 16, a.j6 & 0xFFFF)
return d
def fb_decontam1(a):
d = bytearray(8)
set_bits(d, 0, 2, a.wash_mode) # 2=消除 3=消毒
set_bits(d, 2, 1, 1 if a.wash_start else 0)
set_bits(d, 3, 1, 1 if a.plunger else 0)
set_bits(d, 4, 1, 1 if a.air else 0)
set_bits(d, 5, 1, 1 if a.leaf else 0)
set_bits(d, 8, 1, 1 if a.d1open else 0) # MBV_D1_Sta
set_bits(d, 9, 1, 1 if a.d2open else 0)
set_bits(d, 10, 1, 1 if a.sys_fault else 0)
set_bits(d, 16, 8, clamp(a.level_a, 0, 255))
set_bits(d, 24, 8, clamp(a.level_foam, 0, 255))
set_bits(d, 32, 8, clamp(a.flow_a, 0, 255))
set_bits(d, 40, 8, clamp(a.flow_b, 0, 255))
set_bits(d, 48, 8, clamp(a.air_flow_fb, 0, 255))
set_bits(d, 56, 8, clamp(a.expansion, 0, 255))
return d
def fb_decontam2(a):
d = bytearray(8)
set_bits(d, 0, 8, clamp(round(a.press * 10), 0, 255)) # Pipe_pressure 精度 0.1
set_bits(d, 8, 8, clamp(round(a.air_press * 10), 0, 255)) # AirComp_pressure
set_bits(d, 48, 8, a.air_fault) # AirComp_FaultCode
set_bits(d, 56, 8, a.plunger_fault) # PlungerPump_FaultCode
return d
def fb_detector1(a):
d = bytearray(8)
set_bits(d, 0, 8, a.detector_sta)
set_bits(d, 8, 8, a.gas_type) # Toxic_types 位组合
set_bits(d, 16, 8, a.gas_level)
set_bits(d, 24, 8, a.warn)
return d
def fb_detector2(a):
d = bytearray(8)
set_bits(d, 0, 40, clamp(a.gas_conc, 0, (1 << 40) - 1)) # Toxic_concentration 40 位
return d
FEEDBACK = [
(ID_VEHICLE_FB1, fb_vehicle1), (ID_VEHICLE_FB2, fb_vehicle2),
(ID_ARM_FB1, fb_arm1), (ID_ARM_FB2, fb_arm2),
(ID_ARM_FB3, fb_arm3), (ID_ARM_FB4, fb_arm4),
(ID_DECONTAM_FB1, fb_decontam1), (ID_DECONTAM_FB2, fb_decontam2),
(ID_DETECTOR_FB1, fb_detector1), (ID_DETECTOR_FB2, fb_detector2),
]
# ===================== 上行:解析分类一 控制帧 =====================
def describe(can_id, d):
if can_id == ID_CHASSIS_CMD:
return ("Chassis 模式Cmd=%d 使能=%d gear=%d(0后/2048中/4096前) "
"目标速度=%.2fkm/h turning=%d(0左/2048中/4096右)"
% (get_bits(d, 0, 1), get_bits(d, 3, 1), get_bits(d, 8, 16),
get_bits(d, 24, 16) * 0.01, get_bits(d, 40, 16)))
if can_id == ID_ARM_CMD1:
return ("Arm1 模式=%d 使能=%d 上下=%d 左右=%d 前后=%d"
% (get_bits(d, 0, 1), get_bits(d, 1, 1), get_bits(d, 8, 16),
get_bits(d, 24, 16), get_bits(d, 40, 16)))
if can_id == ID_ARM_CMD2:
return ("Arm2 横滚=%d 俯仰=%d 偏航=%d 回位/暂停=0x%02X(0xAA回位/0x55暂停)"
% (get_bits(d, 0, 16), get_bits(d, 16, 16), get_bits(d, 32, 16),
get_bits(d, 48, 8)))
if can_id == ID_DECONTAM_CMD:
return ("Decontam 模式=%d(2消除/3消毒) 启停=%d 发泡=%d 泡沫比例=%d "
"空压流量=%d 柱塞流量=%d"
% (get_bits(d, 0, 2), get_bits(d, 2, 1), get_bits(d, 8, 8),
get_bits(d, 16, 8), get_bits(d, 24, 8), get_bits(d, 32, 8)))
if can_id == ID_DECONTAM_DBG:
return ("DecontamDbg 调试=%d 柱塞上电=%d 空压上电=%d 叶片上电=%d D1=%d D2=%d "
"空压rpm=%d 柱塞rpm=%d 叶片rpm=%d 启动=%d/%d/%d"
% (get_bits(d, 0, 1), get_bits(d, 1, 1), get_bits(d, 2, 1), get_bits(d, 3, 1),
get_bits(d, 4, 1), get_bits(d, 5, 1), get_bits(d, 8, 16),
get_bits(d, 24, 16), get_bits(d, 40, 16),
get_bits(d, 56, 1), get_bits(d, 57, 1), get_bits(d, 58, 1)))
if can_id == ID_DETECTOR_CMD:
return "Detector 指令=0x%02X(0xAA启动/0x55停止)" % get_bits(d, 0, 8)
if can_id == ID_EMERGENCY:
return "Emergency 急停=0x%02X(0x55急停/0正常)" % get_bits(d, 0, 8)
return None
def listen(host, port, once):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))
print("监听 %s:%d,解 UI 下发的控制帧(Ctrl-C 退出)" % (host, port))
last = {}
try:
while True:
data, addr = sock.recvfrom(4096)
off = 0
while off + 18 <= len(data):
hdr, cid, dlc = struct.unpack_from("<IIB", data, off)
if hdr == HDR and dlc == 8:
body = data[off + 9:off + 17]
cks = data[off + 17]
if cks == checksum(cid, dlc, body):
txt = describe(cid, body)
if txt:
# 周期 50ms,只在内容变化时打印,避免刷屏
if once is None or last.get(cid) != body:
last[cid] = bytes(body)
print("%-16s %s" % (NAMES.get(cid, hex(cid)), txt))
off += 18
continue
off += 1
except KeyboardInterrupt:
pass
def main():
p = argparse.ArgumentParser()
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=9001, help="发送目标(UI 的 net/recv_port)")
p.add_argument("--listen", action="store_true", help="改为绑端口抓 UI 上行控制帧")
p.add_argument("--hz", type=float, default=10.0, help="反馈帧发送频率,默认 10Hz")
p.add_argument("--count", type=int, default=0, help="0=一直发")
# 电池 / 底盘
p.add_argument("--soc", type=float, default=85.0, help="电量 %")
p.add_argument("--volt", type=float, default=360.0, help="总电压 V(精度 0.1)")
p.add_argument("--speed", type=float, default=3.45, help="km/h,精度 0.01")
p.add_argument("--no-handbrake", action="store_true")
p.add_argument("--mcu-fault", type=int, default=0)
p.add_argument("--mcu-level", type=int, default=0)
p.add_argument("--celllow-level", type=int, default=0)
p.add_argument("--cellhigh-level", type=int, default=0)
p.add_argument("--comm-fault", type=int, default=0, help="整系统通信故障码 0~8")
# 机械臂
p.add_argument("--posx", type=int, default=1200)
p.add_argument("--posy", type=int, default=300)
p.add_argument("--posz", type=int, default=800)
p.add_argument("--rx", type=int, default=0)
p.add_argument("--ry", type=int, default=0)
p.add_argument("--rz", type=int, default=0)
p.add_argument("--endspd", type=int, default=0)
for i in range(1, 7):
p.add_argument("--j%d" % i, type=int, default=[30, 20, 0, 90, 45, 0][i - 1])
# 洗消
p.add_argument("--wash", choices=["clean", "disinfect"], default="clean")
p.add_argument("--wash-start", action="store_true")
p.add_argument("--plunger", action="store_true")
p.add_argument("--air", action="store_true")
p.add_argument("--leaf", action="store_true")
p.add_argument("--d1open", action="store_true")
p.add_argument("--d2open", action="store_true")
p.add_argument("--sys-fault", action="store_true")
p.add_argument("--level-a", type=float, default=100)
p.add_argument("--level-foam", type=float, default=30)
p.add_argument("--flow-a", type=float, default=20)
p.add_argument("--flow-b", type=float, default=0.5)
p.add_argument("--air-flow-fb", type=float, default=100)
p.add_argument("--expansion", type=float, default=6)
p.add_argument("--press", type=float, default=0.3, help="MPa,精度 0.1")
p.add_argument("--air-press", type=float, default=0.35)
p.add_argument("--air-fault", type=int, default=0)
p.add_argument("--plunger-fault", type=int, default=0)
# 气体检测仪
p.add_argument("--detector-sta", type=int, default=4, help="4=检测中")
p.add_argument("--gas-type", type=int, default=2, help="位组合 1=P 2=S 4=HNO 8=CH 16=AS")
p.add_argument("--gas-level", type=int, default=2)
p.add_argument("--warn", type=int, default=0, help="1=进气嘴未接 2=氢气不足 4=仪器故障 8=浓度过高")
p.add_argument("--gas-conc", type=float, default=250.0, help="ppb")
a = p.parse_args()
a.wash_mode = 3 if a.wash == "disinfect" else 2
a.handbrake = not a.no_handbrake
if a.listen:
listen(a.host, a.port, None)
return
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
period = 1.0 / a.hz
print("→ %s:%d 反馈帧 %d 条 @ %gHz 电量=%.1f%% 电压=%.1fV 车速=%.2fkm/h 洗消=%s"
% (a.host, a.port, len(FEEDBACK), a.hz, a.soc, a.volt, a.speed, a.wash))
sent = 0
try:
while a.count == 0 or sent < a.count:
for can_id, build in FEEDBACK:
sock.sendto(pack(can_id, build(a)), (a.host, a.port))
sent += 1
time.sleep(period)
except KeyboardInterrupt:
pass
print("已发送 %d 轮(每轮 %d 帧)" % (sent, len(FEEDBACK)))
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""遥控器帧模拟发送器(联调用,非工程代码)。
按 24 字节协议向 UI 的 net/rc_port(默认 9002)周期发送遥控器报文。
用法示例:
python rc_sender.py --mode drive --gear 4096 # 行车模式,档位推到"前"
python rc_sender.py --mode arm --posx 4096 # 机械臂模式(自动带上机械臂使能)
python rc_sender.py --estop # bit4=1,触发急停
python rc_sender.py --battery 42 # 电量显示 42%
python rc_sender.py --roll 4096 --pitch 0 # 右摇杆两轴反向
python rc_sender.py --bad-checksum # 校验和故意错,应被丢弃
"""
import argparse
import socket
import struct
import time
MID = 2048
def build_frame(a):
flags = 0
if a.mode == "drive":
flags |= 0x01 # bit0 行车模式
if not a.no_chassis_enable:
flags |= 0x08 # bit3 底盘使能
else:
flags |= 0x02 | 0x04 # bit1 机械臂模式须与 bit2 使能同时置 1
if a.estop:
flags |= 0x10 # bit4 急停
f = bytearray(24)
f[0] = 0x11 # 帧头
f[1] = flags
axes = (a.gear, a.steer, a.posz, a.posx, a.posy, a.roll, a.pitch, a.yaw)
for i, v in enumerate(axes):
struct.pack_into("<H", f, 3 + i * 2, v) # BYTE3 起,小端 int16
f[19] = max(0, min(100, a.battery))
f[22] = 0x22 # 帧尾
f[23] = sum(f[0:23]) & 0xFF # 和校验
if a.bad_checksum:
f[23] ^= 0xFF
return bytes(f)
def main():
p = argparse.ArgumentParser()
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=9002)
p.add_argument("--mode", choices=["drive", "arm"], default="drive")
for name in ("gear", "steer", "posz", "posx", "posy", "roll", "pitch", "yaw"):
p.add_argument("--" + name, type=int, default=MID)
p.add_argument("--battery", type=int, default=88)
p.add_argument("--estop", action="store_true")
p.add_argument("--no-chassis-enable", action="store_true")
p.add_argument("--bad-checksum", action="store_true")
p.add_argument("--hz", type=float, default=10.0, help="默认 10Hz,即协议要求的 100ms")
p.add_argument("--count", type=int, default=0, help="0=一直发")
a = p.parse_args()
frame = build_frame(a)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
print("→ %s:%d mode=%s flags=0x%02X checksum=0x%02X bytes=%d rate=%gHz"
% (a.host, a.port, a.mode, frame[1], frame[23], len(frame), a.hz))
period = 1.0 / a.hz
sent = 0
try:
while a.count == 0 or sent < a.count:
sock.sendto(frame, (a.host, a.port))
sent += 1
time.sleep(period)
except KeyboardInterrupt:
pass
print("已发送 %d 帧" % sent)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -15,7 +15,7 @@ AlarmBanner::AlarmBanner(QWidget* parent)
m_label = new QLabel(this);
m_label->setAlignment(Qt::AlignCenter);
m_label->setProperty("alert", true);
m_label->setStyleSheet("font-size:30px;font-weight:700;");
m_label->setStyleSheet("font-size:45px;font-weight:700;");
lay->addWidget(m_label);
hide(); // 默认隐藏
+50 -20
View File
@@ -15,47 +15,56 @@ ConfirmDialog::ConfirmDialog(QWidget* parent)
if (parent) setGeometry(parent->rect());
setStyleSheet("background-color: rgba(0,0,0,102);");
QWidget* panel = new QWidget(this);
panel->setObjectName("ConfirmPanel");
panel->setFixedSize(560, 260);
panel->setStyleSheet(QString(
m_panel = new QWidget(this);
m_panel->setObjectName("ConfirmPanel");
m_panel->setFixedSize(700, 420);
m_panel->setStyleSheet(QString(
"QWidget#ConfirmPanel{background-color:%1;border:2px solid %2;border-radius:16px;}")
.arg(C::PANEL, C::BORDER));
QVBoxLayout* lay = new QVBoxLayout(panel);
QVBoxLayout* lay = new QVBoxLayout(m_panel);
lay->setContentsMargins(28, 24, 28, 24);
lay->setSpacing(16);
lay->setSpacing(0); // 标题与正文不留间隙(与“操作说明”弹窗一致)
m_title = new QLabel("", panel);
m_title->setStyleSheet(QString("color:%1;font-size:28px;font-weight:700;").arg(C::TEXT));
m_title = new QLabel("", m_panel);
m_title->setStyleSheet(QString("background:transparent;color:%1;font-size:50px;font-weight:700;").arg(C::TEXT));
m_title->setAlignment(Qt::AlignCenter);
lay->addWidget(m_title);
lay->addSpacing(60); // 标题与正文之间空一行
m_body = new QLabel("", panel);
m_body->setStyleSheet(QString("color:%1;font-size:22px;").arg(C::TEXT));
m_body = new QLabel("", m_panel);
m_body->setStyleSheet(QString("background:transparent;color:%1;font-size:50px;").arg(C::TEXT));
m_body->setAlignment(Qt::AlignCenter);
m_body->setWordWrap(true);
lay->addWidget(m_body, 1);
lay->addWidget(m_body); // 不再拉伸:标题与正文紧挨着
lay->addStretch(1); // 剩余空间放在正文与按钮之间(标题/正文保持自然高度,标题顶到最上面)
QHBoxLayout* btnLay = new QHBoxLayout();
btnLay->addStretch(1);
QPushButton* ok = new QPushButton("确认", panel);
ok->setProperty("variant", "primary");
ok->setFixedSize(160, 72);
QPushButton* ok = new QPushButton("确认", m_panel);
// 常态灰底、选中(悬停/焦点)蓝底白字。
// 注意:该表设了 background-color,会压住全局的 variant/焦点规则,所以状态必须写在这里。
ok->setFixedSize(180, 80);
ok->setStyleSheet(QString(
"QPushButton{background-color:%1;color:%2;border:1px solid %3;border-radius:12px;font-size:60px;}"
"QPushButton:hover,QPushButton:focus{background-color:%4;color:#FFFFFF;border:2px solid #8ABFFF;}")
.arg(C::PANEL_HI, C::TEXT, C::BORDER, C::PRIMARY));
connect(ok, &QPushButton::clicked, this, &ConfirmDialog::accept);
QPushButton* cancel = new QPushButton("取消", panel);
cancel->setFixedSize(160, 72);
cancel->setStyleSheet(cancelButtonStyle(40)); // 选中时红底白字
connect(cancel, &QPushButton::clicked, this, &ConfirmDialog::reject);
m_cancel = new QPushButton("取消", m_panel);
m_cancel->setFixedSize(180, 80); // 与“确认”同尺寸
m_cancel->setStyleSheet(cancelButtonStyle(60)); // 与“确认”同字号;选中时红底白字
connect(m_cancel, &QPushButton::clicked, this, &ConfirmDialog::reject);
btnLay->addWidget(ok);
btnLay->addWidget(cancel);
btnLay->addSpacing(80); // “确认”与“取消”之间留 40px
btnLay->addWidget(m_cancel);
btnLay->addStretch(1);
lay->addSpacing(30); // 正文与按钮之间留 30px
lay->addLayout(btnLay);
QVBoxLayout* root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->addStretch(1);
root->addWidget(panel, 0, Qt::AlignHCenter);
root->addWidget(m_panel, 0, Qt::AlignHCenter);
root->addStretch(1);
}
@@ -70,6 +79,27 @@ bool ConfirmDialog::confirm(QWidget* parent, const QString& title, const QString
return dlg.exec() == QDialog::Accepted;
}
void ConfirmDialog::message(QWidget* parent, const QString& title, const QString& text)
{
ConfirmDialog dlg(parent);
dlg.m_panel->setFixedSize(900, 600); // 正文行数少 → 面板收窄
dlg.m_cancel->hide(); // 只保留“确认”
dlg.m_title->setStyleSheet(QString("background:transparent;color:%1;font-size:50px;font-weight:700;").arg(C::TEXT));
dlg.m_body->setStyleSheet(QString("background:transparent;color:%1;font-size:50px;").arg(C::TEXT));
dlg.m_title->setWordWrap(true);
dlg.m_title->setContentsMargins(0, 20, 0, 0); // 标题文字距该行顶部 10px
dlg.m_body->setContentsMargins(0, 0, 0, 0);
dlg.m_body->setAlignment(Qt::AlignCenter); // 正文居中(水平+垂直)
if (QBoxLayout* lay = qobject_cast<QBoxLayout*>(dlg.m_panel->layout())) {
lay->setSpacing(0); // 统一间隔置 0,各段间距单独控制
lay->setContentsMargins(28, 28, 28, 28); // 内容距面板四边 28px
//lay->insertSpacing(lay->count() - 1, 20); // “确认”按钮上方留 20px
}
dlg.setTitle(title);
dlg.setText(text);
dlg.exec();
}
void ConfirmDialog::toast(QWidget* parent, const QString& text, int ms)
{
QDialog* d = new QDialog(parent);
+6
View File
@@ -4,6 +4,7 @@
#include <QDialog>
class QLabel;
class QPushButton;
// ===================== 确认弹窗 =====================
// 标题 + 正文 + 确认/取消;另提供 2s 自动消失的轻提示(toast)
@@ -19,12 +20,17 @@ public:
// 模态确认,返回是否“确认”
static bool confirm(QWidget* parent, const QString& title, const QString& text);
// 独立信息弹窗(仅“确认”按钮,替代系统 QMessageBox;正文 50px)
static void message(QWidget* parent, const QString& title, const QString& text);
// 轻提示:显示 text,ms 毫秒后自动消失(用于“修改成功”等)
static void toast(QWidget* parent, const QString& text, int ms = 2000);
private:
QLabel* m_title = nullptr;
QLabel* m_body = nullptr;
QWidget* m_panel = nullptr;
QPushButton* m_cancel = nullptr;
};
#endif // CONFIRMDIALOG_H
+25 -8
View File
@@ -11,14 +11,14 @@ DataCard::DataCard(const QString& title, QWidget* parent)
setProperty("card", true);
QVBoxLayout* lay = new QVBoxLayout(this);
lay->setContentsMargins(16, 0, 16, 14);
lay->setSpacing(8);
lay->setContentsMargins(24, 0, 24, 20);
lay->setSpacing(16);
// 标题栏(高 44px)
// 标题栏
m_title = new QLabel(title, this);
m_title->setFixedHeight(44);
m_title->setFixedHeight(60);
m_title->setProperty("cardTitle", true);
m_title->setStyleSheet(QString("color:%1;font-size:24px;font-weight:700;border-bottom:1px solid %2;")
m_title->setStyleSheet(QString("color:%1;font-size:42px;font-weight:700;border-bottom:1px solid %2;")
.arg(C::TEXT, C::BORDER));
lay->addWidget(m_title);
@@ -36,15 +36,15 @@ int DataCard::addField(const QString& label, const QString& value, bool big)
int index = m_values.size();
QHBoxLayout* row = new QHBoxLayout();
row->setSpacing(8);
row->setSpacing(16);
QLabel* lab = new QLabel(label + ":", this);
lab->setProperty("sub", true);
lab->setStyleSheet(QString("color:%1;font-size:22px;").arg(C::TEXT_SUB));
lab->setStyleSheet(QString("color:%1;font-size:32px;").arg(C::TEXT_SUB));
QLabel* val = new QLabel(value, this);
val->setStyleSheet(QString("color:%1;%2").arg(C::TEXT,
big ? "font-size:64px;font-weight:700;" : "font-size:30px;font-weight:700;"));
big ? "font-size:50px;font-weight:700;" : "font-size:42px;font-weight:700;"));
row->addWidget(lab);
row->addWidget(val, 1);
@@ -89,3 +89,20 @@ void DataCard::setHighlight(bool on)
else
setStyleSheet(QString());
}
// 字段行文字从左缘缩进到「中间偏左」:行首弹性 2 : 数值行尾弹性 3
void DataCard::indentRows()
{
QVBoxLayout* lay = qobject_cast<QVBoxLayout*>(layout());
if (!lay)
return;
for (int i = 0; i < lay->count(); ++i) {
QHBoxLayout* row = qobject_cast<QHBoxLayout*>(lay->itemAt(i)->layout());
if (!row || row->count() != 2) // 字段行是 [标签][数值];标题与末尾 stretch 不是子布局
continue;
if (row->itemAt(0)->spacerItem()) // 已缩进过,避免重复插入
continue;
row->insertStretch(0, 2);
row->setStretch(2, 3); // 插入后 [0]=行首弹性 [1]=标签 [2]=数值
}
}
+1
View File
@@ -19,6 +19,7 @@ public:
void setField(int index, const QString& value);
void setAlarm(int index, bool on);
void setHighlight(bool on); // 主色描边突出
void indentRows(); // 字段行整体缩进到「中间偏左」(行首 : 行尾 = 2 : 3),只需调用一次
private:
QLabel* m_title = nullptr;
+116 -7
View File
@@ -5,6 +5,7 @@
#include <QHeaderView>
#include <QLabel>
#include <QPushButton>
#include <QKeyEvent>
#include "global.h"
FaultTable::FaultTable(QWidget* parent)
@@ -14,23 +15,26 @@ FaultTable::FaultTable(QWidget* parent)
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(10);
// 分页行(上一页 / 页码 / 下一页)
// 分页行(上一页 / 页码 / 下一页)——水平居中,字号与按钮加大
QHBoxLayout* nav = new QHBoxLayout();
nav->setSpacing(50);
nav->addStretch(1);
m_prev = new QPushButton("上一页", this);
m_prev->setFixedSize(160, 64);
m_prev->setFixedSize(240, 100);
m_prev->setStyleSheet("font-size:50px;");
connect(m_prev, &QPushButton::clicked, this, [this]() {
if (m_page > 0) { setPage(m_page - 1); emit pageChanged(m_page); }
});
m_pageLabel = new QLabel("1/1", this);
m_pageLabel->setAlignment(Qt::AlignCenter);
m_pageLabel->setStyleSheet(QString("color:%1;font-size:22px;").arg(C::TEXT));
m_pageLabel->setFixedWidth(140);
m_pageLabel->setStyleSheet(QString("color:%1;font-size:40px;").arg(C::TEXT));
m_pageLabel->setFixedWidth(180);
m_next = new QPushButton("下一页", this);
m_next->setFixedSize(160, 64);
m_next->setFixedSize(240, 100);
m_next->setStyleSheet("font-size:50px;");
connect(m_next, &QPushButton::clicked, this, [this]() {
if (m_page < pageCount() - 1) { setPage(m_page + 1); emit pageChanged(m_page); }
});
@@ -38,6 +42,7 @@ FaultTable::FaultTable(QWidget* parent)
nav->addWidget(m_prev);
nav->addWidget(m_pageLabel);
nav->addWidget(m_next);
nav->addStretch(1); // 尾部也留弹性 → 整行居中
lay->addLayout(nav);
// 表格
@@ -47,24 +52,127 @@ FaultTable::FaultTable(QWidget* parent)
m_table->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_table->setSelectionBehavior(QAbstractItemView::SelectRows);
m_table->setSelectionMode(QAbstractItemView::SingleSelection);
m_table->setTabKeyNavigation(false); // 关掉视图自带的 Tab 导航,改由下方 eventFilter 按行切换
m_table->verticalHeader()->setVisible(false);
m_table->horizontalHeader()->setFocusPolicy(Qt::NoFocus); // 表头不参与 Tab
m_table->verticalHeader()->setFocusPolicy(Qt::NoFocus);
m_table->setAlternatingRowColors(true);
m_table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
m_table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed);
m_table->setColumnWidth(0, 120);
m_table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Stretch);
m_table->verticalHeader()->setDefaultSectionSize(100);
m_table->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
m_table->horizontalHeader()->setFixedHeight(kHeaderHeight); // 固定表头高度(供对齐)
// 每页 6 行等分,平均填满表格可用高度
m_table->verticalHeader()->setSectionResizeMode(QHeaderView::Stretch);
connect(m_table, &QTableWidget::cellClicked, this, [this](int row, int) {
int global = m_page * m_pageSize + row;
if (global >= 0 && global < m_data.size())
emit rowClicked(global);
});
m_table->installEventFilter(this); // 拦截 Tab,实现按行切换
m_table->viewport()->setMouseTracking(true); // 悬停整行高亮
m_table->viewport()->installEventFilter(this); // 鼠标移出视口 → 取消高亮
connect(m_table, &QAbstractItemView::entered, this,
[this](const QModelIndex& idx) { setHoverRow(idx.row()); });
lay->addWidget(m_table, 1);
refresh();
}
QWidget* FaultTable::view() const { return m_table; }
QWidget* FaultTable::prevButton() const { return m_prev; }
QWidget* FaultTable::nextButton() const { return m_next; }
bool FaultTable::eventFilter(QObject* obj, QEvent* ev)
{
if (obj == m_table->viewport() && ev->type() == QEvent::Leave) {
setHoverRow(-1); // 鼠标移出表格 → 取消悬停高亮
return QWidget::eventFilter(obj, ev);
}
if (obj != m_table)
return QWidget::eventFilter(obj, ev);
// 焦点进入表格时选中一行:正向(Tab)进末行→回首行、否则选首行;反向(Shift+Tab)同理选末行
if (ev->type() == QEvent::FocusIn) {
QFocusEvent* fe = static_cast<QFocusEvent*>(ev);
const int last = m_table->rowCount() - 1;
const int col = qMax(0, m_table->currentColumn());
int row = -1;
if (fe->reason() == Qt::TabFocusReason) {
if (last >= 0 && (m_table->currentRow() < 0 || m_table->currentRow() >= last))
row = 0;
} else if (fe->reason() == Qt::BacktabFocusReason) {
if (last >= 0 && (m_table->currentRow() < 0 || m_table->currentRow() <= 0))
row = last;
}
if (row >= 0) {
m_table->setCurrentCell(row, col);
m_table->selectRow(row); // 显式整行选中(只设当前单元格时可能只高亮一格)
}
}
// 仅键盘(Tab/Shift+Tab)移走焦点时清除选中;鼠标点击/弹窗抢焦点时保留,便于看清当前行
if (ev->type() == QEvent::FocusOut) {
QFocusEvent* fe = static_cast<QFocusEvent*>(ev);
if (fe->reason() == Qt::TabFocusReason || fe->reason() == Qt::BacktabFocusReason)
m_table->clearSelection();
return QWidget::eventFilter(obj, ev);
}
// Tab / Shift+Tab:按行切换选中(每一行作为一个单位);回车/空格:等同鼠标点击
if (ev->type() == QEvent::KeyPress) {
QKeyEvent* ke = static_cast<QKeyEvent*>(ev);
// 方向键在本机是"旋钮"键位(只给 ParamControl 调值),表格里不参与导航:
// 换行只走 Tab/Shift+Tab,激活只走回车/空格
if (ke->key() == Qt::Key_Up || ke->key() == Qt::Key_Down
|| ke->key() == Qt::Key_Left || ke->key() == Qt::Key_Right)
return true;
if (ke->key() == Qt::Key_Tab || ke->key() == Qt::Key_Backtab) {
const bool back = (ke->key() == Qt::Key_Backtab) || (ke->modifiers() & Qt::ShiftModifier);
const int next = m_table->currentRow() + (back ? -1 : 1);
if (next >= 0 && next < m_table->rowCount()) {
m_table->setCurrentCell(next, qMax(0, m_table->currentColumn()));
m_table->selectRow(next); // 整行选中
return true; // 消费事件,焦点留在表格内
}
// 已在首/末行:不拦截,焦点按 Tab 顺序移到下一个控件
} else if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter
|| ke->key() == Qt::Key_Space) {
// 键盘激活:与鼠标 cellClicked 走同一个信号,弹窗逻辑只有一份
const int row = m_table->currentRow();
const int global = m_page * m_pageSize + row;
if (row >= 0 && global >= 0 && global < m_data.size()) {
emit rowClicked(global);
return true;
}
}
}
return QWidget::eventFilter(obj, ev);
}
// 鼠标悬停的整行高亮:颜色与“选中”一致(主色蓝底白字)
void FaultTable::setHoverRow(int row)
{
if (row == m_hoverRow)
return;
if (m_hoverRow >= 0) {
for (int c = 0; c < m_table->columnCount(); ++c)
if (QTableWidgetItem* it = m_table->item(m_hoverRow, c)) {
it->setData(Qt::BackgroundRole, QVariant());
it->setData(Qt::ForegroundRole, QVariant());
}
}
m_hoverRow = row;
if (m_hoverRow >= 0) {
for (int c = 0; c < m_table->columnCount(); ++c)
if (QTableWidgetItem* it = m_table->item(m_hoverRow, c)) {
it->setData(Qt::BackgroundRole, QColor(C::PRIMARY));
it->setData(Qt::ForegroundRole, QColor("#FFFFFF"));
}
}
}
void FaultTable::setData(const QVector<FaultRecord>& data)
{
m_data = data;
@@ -87,6 +195,7 @@ int FaultTable::pageCount() const
void FaultTable::refresh()
{
m_hoverRow = -1; // 单元格会重建,悬停行失效
m_table->setRowCount(0);
m_table->setRowCount(m_pageSize);
+13
View File
@@ -17,17 +17,29 @@ class FaultTable : public QWidget
public:
explicit FaultTable(QWidget* parent = nullptr);
// 表头高度:同页其他控件可据此与表格内容区对齐
static const int kHeaderHeight = 70;
void setData(const QVector<FaultRecord>& data);
void setPage(int p);
int page() const { return m_page; }
int pageCount() const;
// 供外部设置 Tab 顺序用(定义在 .cpp,避免用到未完整声明的类型)
QWidget* view() const;
QWidget* prevButton() const;
QWidget* nextButton() const;
signals:
void pageChanged(int page);
void rowClicked(int globalIndex);
protected:
bool eventFilter(QObject* obj, QEvent* ev) override; // 拦截 Tab(按行切换选中)、回车/空格(等同点击)
private:
void refresh();
void setHoverRow(int row); // 鼠标悬停的整行高亮
QTableWidget* m_table = nullptr;
QLabel* m_pageLabel = nullptr;
@@ -36,6 +48,7 @@ private:
QVector<FaultRecord> m_data;
int m_page = 0;
int m_pageSize = 6;
int m_hoverRow = -1;
};
#endif // FAULTTABLE_H
+15 -8
View File
@@ -9,9 +9,11 @@ QSize LedIndicator::sizeHint() const
QFont f = font();
f.setPixelSize(m_textPx);
const QFontMetrics fm(f);
const int textW = fm.horizontalAdvance(m_text);
if (m_dotAfter)
return QSize(textW + 10 + m_radius * 2 + 10, 52); // 文字 + 间距 + 圆点区 + 右侧 10px
// 宽度 = 文字起点(圆点区 + 左侧 10px) + 文字宽度 + 右侧 10px
const int w = textLeft() + fm.horizontalAdvance(m_text) + 10;
return QSize(w, 52); // 高度与构造函数 setFixedHeight(52) 一致
return QSize(textLeft() + textW + 10, 52); // 高度与构造函数 setFixedHeight(52) 一致
}
LedIndicator::LedIndicator(const QString& text, QWidget* parent)
@@ -55,6 +57,13 @@ void LedIndicator::paintEvent(QPaintEvent*)
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
// 文字度量:圆点位置取决于文字宽度(圆点在文字后时)
QFont f = p.font();
f.setPixelSize(m_textPx);
p.setFont(f);
const QFontMetrics fm(f);
const int textW = fm.horizontalAdvance(m_text);
// 中心圆点颜色
QColor color = QColor(C::DISABLED); // 灰
switch (m_state) {
@@ -70,7 +79,7 @@ void LedIndicator::paintEvent(QPaintEvent*)
if ((m_state == RedBlink || m_state == YellowBlink) && !m_on)
color.setAlpha(102);
const QPointF c(m_radius + 8, height() / 2.0);
const QPointF c(m_dotAfter ? textW + 10 + m_radius : m_radius + 8, height() / 2.0);
// 外发光
if (color.alpha() > 0) {
@@ -93,11 +102,9 @@ void LedIndicator::paintEvent(QPaintEvent*)
p.setPen(QPen(QColor(C::BORDER), 1));
p.drawEllipse(c, m_radius, m_radius);
// 文字
// 文字(圆点在文字后时,文字从控件左边缘开始)
p.setPen(QColor(C::TEXT));
QFont f = p.font();
f.setPixelSize(m_textPx);
p.setFont(f);
const QRectF textRect(textLeft(), 0, width() - textLeft() - 10, height());
const QRectF textRect(m_dotAfter ? 0 : textLeft(), 0,
m_dotAfter ? textW : width() - textLeft() - 10, height());
p.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, m_text);
}
+2
View File
@@ -19,6 +19,7 @@ public:
void setText(const QString& t);
void setTextPixelSize(int px) { m_textPx = px; updateGeometry(); update(); } // 文字字号
void setRadius(int r) { m_radius = r; updateGeometry(); update(); } // 圆形指示点半径
void setDotAfterText(bool after) { m_dotAfter = after; updateGeometry(); update(); } // 圆点画在文字后面
State state() const { return m_state; }
QSize sizeHint() const override; // 宽度按文字实际宽度计算,避免文字被裁
@@ -35,6 +36,7 @@ private:
bool m_on = true;
int m_radius = 16; // 圆形指示点 32×32
int m_textPx = 24; // 文字字号
bool m_dotAfter = false; // false=圆点在文字前,true=圆点在文字后
};
#endif // LEDINDICATOR_H
+142 -61
View File
@@ -18,101 +18,81 @@ NumPadDialog::NumPadDialog(QWidget* parent)
if (parent) setGeometry(parent->rect());
setStyleSheet("background-color: rgba(0,0,0,102);");
// 面板(居中)
QWidget* panel = new QWidget(this);
panel->setObjectName("NumPadPanel");
panel->setFixedSize(600, 730);
panel->setStyleSheet(QString(
// 面板(默认居中,可用 setAnchorWidget 定位到某个控件旁边)
m_panel = new QWidget(this);
m_panel->setObjectName("NumPadPanel");
m_panel->setStyleSheet(QString(
"QWidget#NumPadPanel{background-color:%1;border:4px solid %2;border-radius:16px;}"
"QPushButton[numpad=\"true\"]{background-color:#2D3947;color:#E6EDF3;border:1px solid #3D4A59;border-radius:12px;font-size:40px;}"
"QPushButton[numpad=\"true\"]:pressed{background-color:#2E7BE6;color:#FFFFFF;}"
"QPushButton[numpad=\"true\"]:focus{background-color:#3D8BFD;color:#FFFFFF;border:2px solid #8ABFFF;}")
.arg(C::PANEL, C::BORDER));
QVBoxLayout* panelLay = new QVBoxLayout(panel);
panelLay->setContentsMargins(24, 40, 24, 50);
panelLay->setSpacing(50);
m_panelLay = new QVBoxLayout(m_panel);
// 输入回显
m_echo = new QLineEdit(panel);
m_echo = new QLineEdit(m_panel);
m_echo->setReadOnly(true);
m_echo->setEchoMode(QLineEdit::Password);
m_echo->setAlignment(Qt::AlignCenter);
m_echo->setFixedSize(510, 100);
m_echo->setStyleSheet("font-size:70px;");
m_echo->setFocusPolicy(Qt::NoFocus); // 纯显示回显:不参与 Tab 选中
m_echo->setFocusPolicy(Qt::NoFocus); // 纯显示回显:不参与 Tab 选中
m_echo->setAttribute(Qt::WA_TransparentForMouseEvents); // 且点击穿透,不响应鼠标
panelLay->addWidget(m_echo, 0, Qt::AlignHCenter);
m_panelLay->addWidget(m_echo, 0, Qt::AlignHCenter);
// 数字键区(1-9, 0, 退格, 清除)
QGridLayout* grid = new QGridLayout();
grid->setSpacing(30);
// 数字键区(1-9, 0, 退格, 清除/小数点)
m_grid = new QGridLayout();
const QString digits[3][3] = {{"1","2","3"},{"4","5","6"},{"7","8","9"}};
for (int r = 0; r < 3; ++r)
for (int c = 0; c < 3; ++c) {
QPushButton* b = new QPushButton(digits[r][c], panel);
QPushButton* b = new QPushButton(digits[r][c], m_panel);
b->setProperty("numpad", true);
b->setStyleSheet("font-size:50px;");
b->setFixedSize(150, 120);
connect(b, &QPushButton::clicked, this, [this, d = digits[r][c]](){ onDigit(d); });
grid->addWidget(b, r, c);
m_grid->addWidget(b, r, c);
m_digits << b;
}
QPushButton* b0 = new QPushButton("0", panel);
QPushButton* b0 = new QPushButton("0", m_panel);
b0->setProperty("numpad", true);
b0->setStyleSheet("font-size:50px;");
b0->setFixedSize(150, 120);
connect(b0, &QPushButton::clicked, this, [this](){ onDigit("0"); });
grid->addWidget(b0, 3, 1);
m_grid->addWidget(b0, 3, 1);
m_digits << b0;
QPushButton* bBack = new QPushButton("退格", panel);
bBack->setProperty("numpad", true);
bBack->setStyleSheet("font-size:40px;");
bBack->setFixedSize(150, 120);
connect(bBack, &QPushButton::clicked, this, &NumPadDialog::onBackspace);
grid->addWidget(bBack, 3, 0);
m_backKey = new QPushButton("退格", m_panel);
m_backKey->setProperty("numpad", true);
connect(m_backKey, &QPushButton::clicked, this, &NumPadDialog::onBackspace);
m_grid->addWidget(m_backKey, 3, 0);
QPushButton* bClear = new QPushButton("清除", panel);
bClear->setProperty("numpad", true);
bClear->setStyleSheet("font-size:40px;");
bClear->setFixedSize(150, 108);
connect(bClear, &QPushButton::clicked, this, &NumPadDialog::onClear);
grid->addWidget(bClear, 3, 2);
m_clearKey = new QPushButton("清除", m_panel);
m_clearKey->setProperty("numpad", true);
connect(m_clearKey, &QPushButton::clicked, this, [this]() {
if (m_paramMode) onDot();
else onClear();
});
m_grid->addWidget(m_clearKey, 3, 2);
// 确认 / 取消(与数字键一致的灰底,选中时发亮)
QPushButton* bOk = new QPushButton("确认", panel);
bOk->setProperty("numpad", true);
bOk->setStyleSheet("font-size:60px;");
bOk->setFixedSize(160, 140);
connect(bOk, &QPushButton::clicked, this, &NumPadDialog::accept);
// 确认 / 取消
m_okKey = new QPushButton("确认", m_panel);
m_okKey->setProperty("numpad", true);
connect(m_okKey, &QPushButton::clicked, this, &NumPadDialog::accept);
QPushButton* bCancel = new QPushButton("取消", panel);
bCancel->setProperty("numpad", true);
bCancel->setStyleSheet(cancelButtonStyle(60)); // 选中时红底白字
bCancel->setFixedSize(160, 140);
connect(bCancel, &QPushButton::clicked, this, &NumPadDialog::reject);
m_cancelKey = new QPushButton("取消", m_panel);
m_cancelKey->setProperty("numpad", true);
connect(m_cancelKey, &QPushButton::clicked, this, &NumPadDialog::reject);
// 确认 / 取消:单独一行、水平居中,并与数字键盘留出间隔
QHBoxLayout* okRow = new QHBoxLayout();
okRow->setSpacing(80);
okRow->addStretch(1);
okRow->addWidget(bOk);
okRow->addWidget(bCancel);
okRow->addWidget(m_okKey);
okRow->addWidget(m_cancelKey);
okRow->addStretch(1);
panelLay->addLayout(grid);
panelLay->setAlignment(grid, Qt::AlignHCenter);
//panelLay->addSpacing(20);
panelLay->addLayout(okRow);
panelLay->addStretch(1);
// 居中放置面板
QVBoxLayout* root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->addStretch(1);
root->addWidget(panel, 0, Qt::AlignHCenter);
root->addStretch(1);
m_panelLay->addLayout(m_grid);
m_panelLay->setAlignment(m_grid, Qt::AlignHCenter);
m_panelLay->addLayout(okRow);
m_panelLay->addStretch(1);
applySizes(); // 应用尺寸(默认大字版)并摆放面板
refreshEcho();
}
@@ -128,6 +108,107 @@ void NumPadDialog::clear()
refreshEcho();
}
// 整体不透明度(0.0~1.0)
void NumPadDialog::setOpacity(qreal opacity)
{
setWindowOpacity(opacity);
}
// 面板贴在参照控件左侧 gap 像素处弹出(垂直对齐其中心,并钳位到屏幕内)
void NumPadDialog::setAnchorWidget(QWidget* ref, int gap)
{
m_anchor = ref;
m_gap = gap;
placePanel();
}
// 参数输入模式:回显显示实际数字(不掩码),“清除”键换成“小数点”
void NumPadDialog::setParamMode(bool on)
{
m_paramMode = on;
m_echo->setEchoMode(on ? QLineEdit::Normal : QLineEdit::Password);
m_clearKey->setText(on ? "." : "清除");
applySizes(); // 重设“小数点/清除”键的字号
}
// 紧凑尺寸(按键与面板更小),默认 false = 大字版
void NumPadDialog::setCompact(bool on)
{
m_compact = on;
applySizes();
}
// 按当前尺寸模式设置面板/按键的大小与字号,并重新摆放面板
void NumPadDialog::applySizes()
{
const int digitFont = m_compact ? 40 : 50;
const int fnFont = m_compact ? 30 : 40;
const int dotFont = m_compact ? 46 : 60;
const int okFont = m_compact ? 44 : 60;
const QSize digitSz = m_compact ? QSize(120, 70) : QSize(150, 120);
const QSize clearSz = m_compact ? QSize(120, 70) : QSize(150, 108);
const QSize okSz = m_compact ? QSize(130, 88) : QSize(160, 140);
m_panel->setFixedSize(m_compact ? QSize(460, 600) : QSize(600, 730));
m_panelLay->setContentsMargins(m_compact ? 20 : 24, m_compact ? 24 : 40,
m_compact ? 20 : 24, m_compact ? 26 : 50);
m_panelLay->setSpacing(m_compact ? 26 : 50);
m_grid->setSpacing(m_compact ? 18 : 30);
m_echo->setFixedSize(m_compact ? QSize(400, 70) : QSize(510, 100));
m_echo->setStyleSheet(QString("font-size:%1px;").arg(m_compact ? 48 : 70));
for (QPushButton* b : m_digits) {
b->setFixedSize(digitSz);
b->setStyleSheet(QString("font-size:%1px;").arg(digitFont));
}
m_backKey->setFixedSize(digitSz);
m_backKey->setStyleSheet(QString("font-size:%1px;").arg(fnFont));
m_clearKey->setFixedSize(clearSz);
m_clearKey->setStyleSheet(QString("font-size:%1px;").arg(m_paramMode ? dotFont : fnFont));
m_okKey->setFixedSize(okSz);
m_okKey->setStyleSheet(QString("font-size:%1px;").arg(okFont));
m_cancelKey->setFixedSize(okSz);
m_cancelKey->setStyleSheet(cancelButtonStyle(okFont)); // 选中时红底白字
placePanel();
}
// 计算面板位置:有参照控件 → 其左侧;否则居中
void NumPadDialog::placePanel()
{
const int w = m_panel->width();
const int h = m_panel->height();
int x = (width() - w) / 2;
int y = (height() - h) / 2;
if (m_anchor && m_anchor->window()) {
// 面板放在参照控件左侧;对话框覆盖整个窗口,其局部坐标即窗口坐标
const QPoint p = m_anchor->mapTo(m_anchor->window(), QPoint(0, 0));
x = p.x() - m_gap - w;
y = p.y() + m_anchor->height() / 2 - h / 2;
}
x = qBound(0, x, qMax(0, width() - w));
y = qBound(0, y, qMax(0, height() - h));
m_panel->move(x, y);
}
// 小数点:仅允许一个,空值时补前导 0
void NumPadDialog::onDot()
{
if (m_value.contains('.'))
return;
if (m_value.isEmpty())
m_value = "0";
if (m_value.size() >= m_maxLen)
return;
m_value += '.';
refreshEcho();
}
void NumPadDialog::onDigit(const QString& d)
{
if (m_value.size() >= m_maxLen)
+29
View File
@@ -2,11 +2,16 @@
#define NUMPADDIALOG_H
#include <QDialog>
#include <QVector>
class QLineEdit;
class QPushButton;
class QGridLayout;
class QVBoxLayout;
// ===================== 数字键盘弹窗(模态) =====================
// 半透明遮罩 + 键盘面板:顶部输入回显,数字键 1-9、0,退格、清除,确认/取消
// 两种尺寸:默认大字版(密码页用)、紧凑版(参数输入用,setCompact(true))
class NumPadDialog : public QDialog
{
Q_OBJECT
@@ -18,17 +23,41 @@ public:
QString value() const { return m_value; }
void clear();
void setOpacity(qreal opacity); // 整体不透明度(默认 1.0)
void setAnchorWidget(QWidget* ref, int gap = 20); // 面板贴参照控件左侧弹出
// 参数输入模式:回显显示实际数字(不掩码),并把“清除”键换成“小数点”
void setParamMode(bool on);
// 紧凑尺寸(按键/面板更小);默认 false = 大字版
void setCompact(bool on);
private slots:
void onDigit(const QString& d);
void onBackspace();
void onClear();
void onDot();
private:
void refreshEcho();
void placePanel();
void applySizes();
QWidget* m_panel = nullptr;
QWidget* m_anchor = nullptr; // 定位参照控件(如参数输入框)
QGridLayout* m_grid = nullptr;
QVBoxLayout* m_panelLay = nullptr;
QLineEdit* m_echo = nullptr;
QVector<QPushButton*> m_digits; // 数字键 0-9
QPushButton* m_backKey = nullptr;
QPushButton* m_clearKey = nullptr;
QPushButton* m_okKey = nullptr;
QPushButton* m_cancelKey = nullptr;
QString m_value;
int m_maxLen = 4;
int m_gap = 20;
bool m_paramMode = false;
bool m_compact = false;
};
#endif // NUMPADDIALOG_H
+113 -58
View File
@@ -1,8 +1,11 @@
#include "paramcontrol.h"
#include "numpaddialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QKeyEvent>
#include "global.h"
ParamControl::ParamControl(const QString& name, const QString& unit,
@@ -16,56 +19,50 @@ ParamControl::ParamControl(const QString& name, const QString& unit,
lay->setSpacing(6);
// 参数名
QLabel* nameLabel = new QLabel(name, this);
nameLabel->setProperty("sub", true);
nameLabel->setStyleSheet(QString("color:%1;font-size:22px;").arg(C::TEXT_SUB));
lay->addWidget(nameLabel);
m_nameLabel = new QLabel(name, this);
m_nameLabel->setProperty("sub", true);
m_nameLabel->setStyleSheet(QString("color:%1;font-size:40px;").arg(C::TEXT_SUB));
lay->addWidget(m_nameLabel);
// 数值 + 加减
QHBoxLayout* row = new QHBoxLayout();
row->setSpacing(12);
// 数值输入框 + 单位(同一行;框内只显示数值)
m_valRow = new QHBoxLayout();
m_valRow->setSpacing(12);
QPushButton* minus = new QPushButton("-", this);
minus->setFixedSize(96, 96);
minus->setStyleSheet(QString("font-size:44px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;")
.arg(C::PANEL_HI, C::TEXT, C::BORDER));
connect(minus, &QPushButton::clicked, this, &ParamControl::onMinus);
m_valueEdit = new QLineEdit(this);
m_valueEdit->setReadOnly(true);
m_valueEdit->setAlignment(Qt::AlignCenter);
m_valueEdit->setFocusPolicy(Qt::StrongFocus);
m_valueEdit->setFixedWidth(150); // 输入框长度(按需调整)
m_valueEdit->setStyleSheet("font-size:40px;font-weight:700;");
m_valueEdit->installEventFilter(this);
m_valRow->addStretch(0); // 行首弹性空间:setCentered(true) 时置 1,数值行即居中
m_valRow->addWidget(m_valueEdit);
m_valueLabel = new QLabel(this);
m_valueLabel->setAlignment(Qt::AlignCenter);
m_valueLabel->setStyleSheet(QString("color:%1;font-size:52px;font-weight:700;").arg(C::TEXT));
m_valueLabel->setMinimumWidth(180);
m_unitLabel = new QLabel(unit, this);
m_unitLabel->setStyleSheet(QString("color:%1;font-size:40px;").arg(C::TEXT_SUB));
// 同一行右侧还有按钮时,给单位固定占位,保证各参数的按钮 x 一致
// (110 取全项目最长单位“L/min”在 40px 字号下的宽度约 101px)
if (withSave || withFactory)
m_unitLabel->setFixedWidth(110);
m_valRow->addWidget(m_unitLabel);
QPushButton* plus = new QPushButton("+", this);
plus->setFixedSize(96, 96);
plus->setStyleSheet(QString("font-size:44px;background-color:%1;color:%2;border:1px solid %3;border-radius:12px;")
.arg(C::PANEL_HI, C::TEXT, C::BORDER));
connect(plus, &QPushButton::clicked, this, &ParamControl::onPlus);
row->addWidget(minus);
row->addWidget(m_valueLabel, 1);
row->addWidget(plus);
lay->addLayout(row);
// 保存 / 恢复出厂值(可选)
if (withSave || withFactory) {
QHBoxLayout* btnRow = new QHBoxLayout();
btnRow->setSpacing(12);
if (withSave) {
m_save = new QPushButton("保存", this);
m_save->setProperty("variant", "primary");
m_save->setMinimumHeight(56);
connect(m_save, &QPushButton::clicked, this, &ParamControl::saveClicked);
btnRow->addWidget(m_save);
}
if (withFactory) {
m_factory = new QPushButton("恢复出厂值", this);
m_factory->setMinimumHeight(56);
connect(m_factory, &QPushButton::clicked, this, &ParamControl::factoryResetClicked);
btnRow->addWidget(m_factory);
}
lay->addLayout(btnRow);
// 保存 / 恢复出厂值(可选):跟在单位后面,与输入框同一行、整体左对齐
if (withSave) {
m_save = new QPushButton("保存", this);
m_save->setProperty("variant", "primary");
m_save->setFixedSize(140, 60); // 固定宽高,不再随布局拉伸
connect(m_save, &QPushButton::clicked, this, &ParamControl::saveClicked);
m_valRow->addWidget(m_save);
}
if (withFactory) {
m_factory = new QPushButton("恢复出厂值", this);
m_factory->setMinimumHeight(56);
connect(m_factory, &QPushButton::clicked, this, &ParamControl::factoryResetClicked);
m_valRow->addWidget(m_factory);
}
m_valRow->addStretch(1);
lay->addLayout(m_valRow);
refresh();
}
@@ -76,32 +73,90 @@ void ParamControl::setValue(double v)
refresh();
}
QWidget* ParamControl::editor() const
{
return m_valueEdit;
}
void ParamControl::setEnabled(bool enabled)
{
QWidget::setEnabled(enabled);
}
void ParamControl::onMinus()
void ParamControl::setCentered(bool on)
{
m_value = qMax(m_min, m_value - m_step);
refresh();
emit valueChanged(m_value);
m_nameLabel->setAlignment(on ? Qt::AlignCenter : (Qt::AlignLeft | Qt::AlignVCenter));
m_valRow->setStretch(0, on ? 1 : 0); // 行首弹性空间与行尾均分 → 整行居中(含保存按钮)
}
void ParamControl::onPlus()
// 旋钮接口:steps > 0 调大、< 0 调小(每格 = m_step,自动钳位到 [min, max])
// 目前由数值框上的 ↑/↓ 键驱动,后续接面板机械旋钮也走这里
void ParamControl::adjustByKnob(int steps)
{
m_value = qMin(m_max, m_value + m_step);
refresh();
emit valueChanged(m_value);
if (steps == 0)
return;
const double old = m_value;
setValue(old + steps * m_step);
if (m_value != old)
emit valueChanged(m_value);
}
// 点击数值框 / 回车 → 弹出数字键盘(面板靠左、不透明度 80%);↑/↓ → 每档加减一次
bool ParamControl::eventFilter(QObject* obj, QEvent* ev)
{
if (obj == m_valueEdit) {
if (ev->type() == QEvent::MouseButtonPress) {
openNumPad();
return true;
}
if (ev->type() == QEvent::KeyPress) {
QKeyEvent* ke = static_cast<QKeyEvent*>(ev);
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
openNumPad();
return true;
}
// 旋钮档:↑ 加一档、↓ 减一档(每档 = m_step)。Tab/Shift+Tab 走焦点链、
// 空格激活按钮,各不重叠;按住方向键由按键自动重复继续调
if (ke->key() == Qt::Key_Up) {
adjustByKnob(1);
return true;
}
if (ke->key() == Qt::Key_Down) {
adjustByKnob(-1);
return true;
}
}
}
return QWidget::eventFilter(obj, ev);
}
void ParamControl::openNumPad()
{
NumPadDialog dlg(window()); // 以顶层窗口为父:遮罩覆盖整屏
dlg.setParamMode(true); // 参数模式:回显显示实际数字,“清除”换成“小数点”
dlg.setCompact(true); // 紧凑尺寸(不影响密码页的大键盘)
dlg.setAnchorWidget(m_valueEdit, 20); // 面板贴在输入框左侧 20px 处弹出
dlg.setMaxLen(6);
dlg.setValue(m_step < 1.0 ? QString::number(m_value, 'f', 1)
: QString::number(m_value, 'f', 0));
if (dlg.exec() != QDialog::Accepted)
return;
bool ok = false;
const double v = dlg.value().toDouble(&ok);
if (!ok)
return;
const double old = m_value;
setValue(v);
if (m_value != old)
emit valueChanged(m_value);
}
void ParamControl::refresh()
{
// 单位格式:整数不显示小数
QString s;
// 框内只显示数值(单位在框后面的标签里)
if (m_step < 1.0)
s = QString("%1").arg(m_value, 0, 'f', 1);
m_valueEdit->setText(QString::number(m_value, 'f', 1));
else
s = QString("%1").arg(m_value, 0, 'f', 0);
m_valueLabel->setText(s + " " + m_unit);
m_valueEdit->setText(QString::number(m_value, 'f', 0));
}
+17 -5
View File
@@ -4,10 +4,13 @@
#include <QWidget>
class QLabel;
class QLineEdit;
class QPushButton;
class QHBoxLayout;
// ===================== 参数调节控件 =====================
// 参数名 + 大数值显示 + 减/加按钮 + 保存按钮(可选)+ 恢复出厂值按钮(可选)
// 参数名 + 数值输入框(点击/回车弹出数字键盘,↑/↓ 按档加减)+ 保存按钮(可选)+ 恢复出厂值按钮(可选)
// 旋钮接口 adjustByKnob():调大/调小,数值框的 ↑/↓ 走的就是这条路径
class ParamControl : public QWidget
{
Q_OBJECT
@@ -19,18 +22,24 @@ public:
void setValue(double v);
double value() const { return m_value; }
void setEnabled(bool enabled);
void setCentered(bool on); // 参数名与数值行水平居中(默认左对齐)
QWidget* editor() const; // 数值输入框(供页面编排 Tab 顺序)
public slots:
// 旋钮接口:steps > 0 右旋调大、steps < 0 左旋调小;每格 = m_step
void adjustByKnob(int steps);
signals:
void valueChanged(double v);
void saveClicked();
void factoryResetClicked();
private slots:
void onMinus();
void onPlus();
protected:
bool eventFilter(QObject* obj, QEvent* ev) override; // 数值框:点击/回车 → 数字键盘,↑/↓ → 按档调值
private:
void refresh();
void openNumPad();
QString m_name;
QString m_unit;
@@ -38,7 +47,10 @@ private:
double m_min;
double m_max;
double m_value;
QLabel* m_valueLabel = nullptr;
QLineEdit* m_valueEdit = nullptr;
QLabel* m_unitLabel = nullptr;
QLabel* m_nameLabel = nullptr;
QHBoxLayout* m_valRow = nullptr;
QPushButton* m_save = nullptr;
QPushButton* m_factory = nullptr;
};
+38 -35
View File
@@ -1,6 +1,6 @@
#include "statusstrip.h"
#include "appcontext.h"
#include "udpcommunication.h"
#include "car.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
@@ -10,54 +10,51 @@ StatusStrip::StatusStrip(AppContext* ctx, QWidget* parent)
: QWidget(parent), m_ctx(ctx)
{
setObjectName("StatusStrip");
setFixedHeight(94);
setFixedHeight(120);
setStyleSheet(QString("QWidget#StatusStrip{background-color:%1;border-top:1px solid %2;}")
.arg(C::PANEL, C::BORDER));
QHBoxLayout* lay = new QHBoxLayout(this);
lay->setContentsMargins(36, 0, 24, 0);
lay->setSpacing(48);
lay->setSpacing(40);
auto makeVal = [this](const QString& label) {
QLabel* l = new QLabel(this);
l->setStyleSheet(QString("color:%1;font-size:22px;").arg(C::TEXT_SUB));
l->setProperty("label", label);
return l;
};
// 底部不再显示底盘/液位数据(改由各页面卡片展示),只保留模式徽标 + 返回按钮
//auto makeVal = [this](const QString& label) {
// QLabel* l = new QLabel(this);
// l->setStyleSheet(QString("color:%1;font-size:40px;").arg(C::TEXT_SUB));
// l->setProperty("label", label);
// return l;
//};
//m_battery = makeVal("底盘电量");
//m_voltage = makeVal("底盘电压");
//m_levelA = makeVal("清水液位");
//m_levelFoam = makeVal("泡沫液位");
//lay->addWidget(m_battery);
//lay->addWidget(m_voltage);
//lay->addWidget(m_levelA);
//lay->addWidget(m_levelFoam);
m_battery = makeVal("底盘电量");
m_voltage = makeVal("底盘电压");
m_levelA = makeVal("清水液位");
m_levelFoam = makeVal("泡沫液位");
lay->addWidget(m_battery);
lay->addWidget(m_voltage);
lay->addWidget(m_levelA);
lay->addWidget(m_levelFoam);
lay->addStretch(1);
// 模式徽标(点击可切换行车/机械臂模式,模拟面板机械旋钮)
m_mode = new QPushButton("行车模式", this);
m_mode->setFixedSize(180, 48);
m_mode->setStyleSheet(QString("background-color:%1;color:#FFFFFF;border-radius:8px;font-size:24px;font-weight:700;border:none;")
// 模式徽标(仅显示:行车/机械臂由遥控器拨钮经串口回传,界面不参与切换)
m_mode = new QLabel("行车模式", this);
m_mode->setFixedSize(260, 80);
m_mode->setAlignment(Qt::AlignCenter);
m_mode->setStyleSheet(QString("background-color:%1;color:#FFFFFF;border-radius:8px;font-size:40px;font-weight:700;")
.arg(C::SUCCESS));
m_mode->setCursor(Qt::PointingHandCursor);
connect(m_mode, &QPushButton::clicked, this, [this]() {
Mode next = (m_ctx->mode == Mode::Drive) ? Mode::Arm : Mode::Drive;
m_ctx->setMode(next);
});
lay->addWidget(m_mode);
// 返回按钮
m_back = new QPushButton("返回", this);
m_back->setFixedSize(144, 76);
m_back->setFixedSize(200, 80);
m_back->setStyleSheet(cancelButtonStyle(40)); // 选中(悬停/按下/焦点)红底白字
connect(m_back, &QPushButton::clicked, this, &StatusStrip::backClicked);
lay->addWidget(m_back);
connect(m_ctx->comm(), &UdpCommunication::telemetryUpdated, this, &StatusStrip::refresh);
connect(m_ctx->comm(), &CarCommunication::telemetryUpdated, this, &StatusStrip::refresh);
connect(m_ctx, &AppContext::modeChanged, this, [this](Mode m) {
m_mode->setText(m == Mode::Drive ? "行车模式" : "机械臂模式");
m_mode->setStyleSheet(QString("background-color:%1;color:#FFFFFF;border-radius:8px;font-size:24px;font-weight:700;")
m_mode->setStyleSheet(QString("background-color:%1;color:#FFFFFF;border-radius:8px;font-size:40px;font-weight:700;")
.arg(m == Mode::Drive ? C::SUCCESS : C::WARNING));
});
refresh();
@@ -73,11 +70,17 @@ void StatusStrip::setBackEnabled(bool enabled)
m_back->setEnabled(enabled);
}
void StatusStrip::setModeVisible(bool visible)
{
m_mode->setVisible(visible);
}
void StatusStrip::refresh()
{
const Telemetry& t = m_ctx->telemetry;
m_battery->setText(QString("底盘电量:%1%").arg(t.soc));
m_voltage->setText(QString("底盘电压:%1V").arg(t.volt, 0, 'f', 0));
m_levelA->setText(QString("清水液位:%1L").arg(t.levelA, 0, 'f', 0));
m_levelFoam->setText(QString("泡沫液位:%1L").arg(t.levelFoam, 0, 'f', 0));
// 底部已不显示遥测数据,保留空实现(仍连着 telemetryUpdated,方便以后加显示项)
//const Telemetry& t = m_ctx->telemetry;
//m_battery->setText(QString("底盘电量:%1%").arg(t.soc));
//m_voltage->setText(QString("底盘电压:%1V").arg(t.volt, 0, 'f', 0));
//m_levelA->setText(QString("清水液位:%1L").arg(t.levelA, 0, 'f', 0));
//m_levelFoam->setText(QString("泡沫液位:%1L").arg(t.levelFoam, 0, 'f', 0));
}
+8 -7
View File
@@ -7,8 +7,8 @@ class QLabel;
class QPushButton;
class AppContext;
// ===================== 底部状态条(高 94px) =====================
// 底盘电量、底盘电压、清水液位、泡沫液位 + 模式徽标 + 返回按钮
// ===================== 底部状态条 =====================
// 只保留:模式徽标(仅显示)+ 返回按钮。行车/机械臂由遥控器拨钮决定,界面不参与切换。
class StatusStrip : public QWidget
{
Q_OBJECT
@@ -17,6 +17,7 @@ public:
void setBackVisible(bool visible);
void setBackEnabled(bool enabled);
void setModeVisible(bool visible); // 固定行车模式的页面(如底盘页)可隐藏模式徽标
signals:
void backClicked();
@@ -26,11 +27,11 @@ private slots:
private:
AppContext* m_ctx;
QLabel* m_battery = nullptr;
QLabel* m_voltage = nullptr;
QLabel* m_levelA = nullptr;
QLabel* m_levelFoam = nullptr;
QPushButton* m_mode = nullptr;
//QLabel* m_battery = nullptr; // 底部不再显示数据
//QLabel* m_voltage = nullptr;
//QLabel* m_levelA = nullptr;
//QLabel* m_levelFoam = nullptr;
QLabel* m_mode = nullptr; // 模式徽标(只显示,不参与切换)
QPushButton* m_back = nullptr;
};
+35 -55
View File
@@ -1,14 +1,10 @@
#include "titlebar.h"
#include "ledindicator.h"
#include "appcontext.h"
#include "udpcommunication.h"
#include "confirmdialog.h"
#include "car.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QTime>
#include "global.h"
#include <QStyle>
TitleBar::TitleBar(AppContext* ctx, QWidget* parent)
: QWidget(parent), m_ctx(ctx)
@@ -18,34 +14,38 @@ TitleBar::TitleBar(AppContext* ctx, QWidget* parent)
setStyleSheet(QString("QWidget#TitleBar{background-color:%1;border-bottom:1px solid %2;}")
.arg(C::PANEL, C::BORDER));
// 左侧区(通信状态)
// 左侧区(车载端通信状态)
QWidget* leftBox = new QWidget(this);
QHBoxLayout* leftLay = new QHBoxLayout(leftBox);
leftLay->setContentsMargins(0, 0, 0, 0);
leftLay->setContentsMargins(260, 0, 0, 0);
leftLay->setSpacing(20);
m_comm = new LedIndicator("通信正常", leftBox);
m_comm->setFixedHeight(80); // 容纳放大后的文字,避免上下裁切
m_comm->setTextPixelSize(45);
m_comm->setState(LedIndicator::Green);
leftLay->addWidget(m_comm);
leftLay->addStretch(1);
// 右侧区(时间 + 软急停)
// 右侧区(遥控器状态:链路 + 电量)
QWidget* rightBox = new QWidget(this);
QHBoxLayout* rightLay = new QHBoxLayout(rightBox);
rightLay->setContentsMargins(0, 0, 0, 0);
rightLay->setSpacing(20);
m_time = new QLabel("", rightBox);
m_time->setStyleSheet(QString("color:%1;font-size:60px;").arg(C::TEXT_SUB));
m_time->setAlignment(Qt::AlignCenter);
m_estop = new QPushButton("急停", rightBox);
m_estop->setProperty("variant", "estop");
m_estop->setStyleSheet("font-size:60px;");
m_estop->setFixedSize(180, 80);
rightLay->setContentsMargins(0, 0, 20, 0);
rightLay->setSpacing(30);
rightLay->addStretch(1);
rightLay->addWidget(m_time);
rightLay->addWidget(m_estop);
m_rc = new LedIndicator("遥控器断开", rightBox);
m_rc->setFixedHeight(80);
m_rc->setTextPixelSize(45);
m_rc->setState(LedIndicator::RedBlink);
rightLay->addWidget(m_rc);
m_rcBat = new QLabel("--%", rightBox);
m_rcBat->setStyleSheet(QString("color:%1;font-size:60px;").arg(C::TEXT_SUB));
m_rcBat->setAlignment(Qt::AlignCenter);
rightLay->addWidget(m_rcBat);
// 左右两侧等宽 → 中间标题恰好居中于屏幕
const int sideW = 380; // 需 >= 较宽一侧所需宽度(右侧:时间 + 急停)
// 左区需 260(左边距)+ 240(圆点区与留白 60 + 文字 4×45)= 500
// 右区需 285(圆点区与留白 60 + 文字 5×45)+ 30 + 130(电量)+ 20 = 465
const int sideW = 520;
leftBox->setFixedWidth(sideW);
rightBox->setFixedWidth(sideW);
@@ -61,30 +61,16 @@ TitleBar::TitleBar(AppContext* ctx, QWidget* parent)
lay->addWidget(m_title, 1);
lay->addWidget(rightBox);
connect(m_estop, &QPushButton::clicked, this, [this]() {
if (m_ctx->estopTriggered) {
if (ConfirmDialog::confirm(window(), "急停复位", "确定复位急停并恢复系统?"))
m_ctx->resetEstop();
} else {
m_ctx->triggerEstop();
}
});
// 通信状态刷新
connect(m_ctx->comm(), &UdpCommunication::commOkChanged, this, [this](bool ok) {
// 车载端通信状态刷新
connect(m_ctx->comm(), &CarCommunication::commOkChanged, this, [this](bool ok) {
if (ok) { m_comm->setText("通信正常"); m_comm->setState(LedIndicator::Green); }
else { m_comm->setText("通信异常"); m_comm->setState(LedIndicator::RedBlink); }
});
// 急停状态刷新
connect(m_ctx, &AppContext::estopChanged, this, &TitleBar::updateEstop);
updateEstop();
// 时钟
m_clock = new QTimer(this);
connect(m_clock, &QTimer::timeout, this, &TitleBar::updateTime);
m_clock->start(1000);
updateTime();
// 遥控器链路与电量刷新
connect(m_ctx->rc(), &RcCommunication::linkChanged, this, &TitleBar::onRcLink);
connect(m_ctx->rc(), &RcCommunication::frameReceived, this, &TitleBar::onRcFrame);
onRcLink(m_ctx->rc()->linkOk());
}
void TitleBar::setTitle(const QString& title)
@@ -92,25 +78,19 @@ void TitleBar::setTitle(const QString& title)
m_title->setText(title);
}
void TitleBar::setEstopVisible(bool visible)
void TitleBar::onRcLink(bool ok)
{
m_estop->setVisible(visible);
}
void TitleBar::updateEstop()
{
if (m_ctx->estopTriggered) {
m_estop->setText("复位");
m_estop->setProperty("variant", "danger");
if (ok) {
m_rc->setText("遥控器正常");
m_rc->setState(LedIndicator::Green);
} else {
m_estop->setText("急停");
m_estop->setProperty("variant", "estop");
m_rc->setText("遥控器断开");
m_rc->setState(LedIndicator::RedBlink);
m_rcBat->setText("--%");
}
m_estop->style()->unpolish(m_estop);
m_estop->style()->polish(m_estop);
}
void TitleBar::updateTime()
void TitleBar::onRcFrame(const RcFrame& f)
{
m_time->setText(QTime::currentTime().toString("HH:mm"));
m_rcBat->setText(QString("%1%").arg(f.battery));
}
+7 -10
View File
@@ -2,15 +2,14 @@
#define TITLEBAR_H
#include <QWidget>
#include <QTimer>
#include "uart.h"
class QLabel;
class QPushButton;
class AppContext;
class LedIndicator;
// ===================== 顶条组件(高 98px) =====================
// 左侧“通信状态:●正常/异常”,中央页面标题,右侧时间 + 软急停按钮
// 左侧“车载端通信:●正常/异常”,中央页面标题,右侧“遥控器:●正常/断开 + 电量”
class TitleBar : public QWidget
{
Q_OBJECT
@@ -18,19 +17,17 @@ public:
explicit TitleBar(AppContext* ctx, QWidget* parent = nullptr);
void setTitle(const QString& title);
void setEstopVisible(bool visible);
private slots:
void updateEstop();
void updateTime();
void onRcLink(bool ok);
void onRcFrame(const RcFrame& f);
private:
AppContext* m_ctx;
LedIndicator* m_comm;
LedIndicator* m_comm = nullptr;
LedIndicator* m_rc = nullptr;
QLabel* m_rcBat = nullptr;
QLabel* m_title = nullptr;
QLabel* m_time = nullptr;
QPushButton* m_estop = nullptr;
QTimer* m_clock = nullptr;
};
#endif // TITLEBAR_H

Some files were not shown because too many files have changed in this diff Show More