项目简介
LOT Sensor PC 是为 2026 湖南省物联网应用创新竞赛(技能赛)开发的 PC 端传感器管理平台。传感器设备通过 MQTT 上报数据,后端负责入库、计算和实时推送,前端以看板的形式展示实时数据、历史趋势和故障信息,并可以向设备下发控制指令,形成「采集 → 处理 → 展示 → 控制」的完整闭环。
比赛是三人组队,两位队友负责硬件和底层,应用层(平台软件)从前端、后端、数据库到识别服务都由我一个人完成。
🔗 在线演示:演示环境没有连接真实设备,展示的是比赛期间采集的历史数据;可以在「历史图表」里选择「自定义」时间范围(2026 年 9 月)查看图表。
功能详解
比赛的场景是一套水循环加热实验装置:水泵把水箱里的水打进管路,经过加热器后再流回水箱,管路上装有进水温度、出水温度、流量和压力传感器,水泵和加热器的通断由下位机控制。设备每秒通过 MQTT 上报一条数据,平台要做到四件事:看得见(实时和历史数据)、管得住(远程下发指令)、保安全(异常时自动断电)、能分析(计算指标和智能判定)。
页面一览
| 页面 | 做什么 |
|---|---|
| 首页概览 | 设备总数、在线设备数、监测指标数、最近告警四张卡片,进出水温度仪表盘,最新传感数据和运行状态 |
| 实时数据 | 最新几条数据的卡片和实时曲线,靠 WebSocket 推送自动刷新 |
| 汇总数据 | 全部历史数据的分页表格,可按设备、时间筛选,可勾选数据发起智能判定 |
| 指令配置 | 远程开关水泵和加热、切换手动 / 自动模式、设置各种阈值和目标值、定时开关、故障复位 |
| 操作历史 | 每一次开关和参数改动:什么时候、从什么改成什么、是谁改的 |
| 历史图表 | 计算指标明细表和近 20 张统计图表,时间范围可选 5 分钟到 24 小时或自定义 |
| 告警记录 | 故障、安全联锁、联动控制、阈值告警、数据质量和智能判定的异常记录,附分类统计饼图 |
| 智能判定记录 / 智能判定(自动) | 每次智能判定的结论、置信度和状态;自动判定的实时趋势和结论分布 |
1. 数据采集与实时推送
- 一条消息就是一行数据:设备把传感器读数(温度、流量、压力)和执行器状态(控制模式、水泵、加热)放在同一条 MQTT 消息里上报,后端按字段映射表解析后入库。
- 换赛题不改表结构:数据库给传感器预留了 10 个通用字段,哪个字段代表什么、单位是什么、页面上叫什么名字,都写在映射配置里。赛题换了设备字段,只改配置就行。
- 实时推送:后端处理完每条消息后,通过 WebSocket 推给浏览器,实时数据页、首页和指令配置页都会自动刷新。推送做了节流,同时保证不丢帧(见下文难点 1)。
- 设备在线状态:后端根据设备的上报心跳判断在线 / 离线(超过 5 秒没有数据就算离线),顶部导航栏和首页实时显示。
2. 数据查询与汇总
汇总数据页是一张同时包含传感器列和行为列的分页表格,可以按设备编号和时间范围筛选。「数据类型」一列会区分实时数据和断网期间补传上来的数据。表格下方有折线图和柱状图,勾选若干行后点「智能判定」,就能把这几条数据交给判定服务分析。
汇总数据页:按设备和时间筛选、勾选数据发起智能判定,下方是折线图与柱状图
3. 历史图表与计算指标
光有原始数据还不够,比赛要求算出各种工艺指标。历史图表页统一用一个时间选择器(5 分钟到 24 小时,或者自定义),选完后所有表格和图表都按这个范围重新查询数据库。
- 计算指标明细:累计指标(累计流量、累计加热时长、累计水泵运行时长)、滑动窗口统计、秒级和分钟级的平均温度 / 平均流速、加热效率与加热速度、换热效率、温度变化率、加热能耗,按标签页切换。
- 图表:温度曲线、流量与压力曲线、温度-流量相关性散点图、瞬时加热功率、累计耗电量与累计换热量、单位流量能耗、加热效率(实际升温 / 理论升温)、设备状态时间线、PID 占空比等。
- 自定义公式指标:可以自己写公式(比如
(field2 - field1) * field3)生成新指标。公式先解析成语法树,再编译成 SQL 查历史数据,或者直接在内存里算实时数据。全程不用eval,公式里只能用白名单里的字段和abs / round / min / max / sqrt / pow这几个函数,写了别的会在解析阶段直接报错,不会拼进 SQL。 - 大范围也能看全趋势:数据量超过上限时,按时间分桶降采样,不再只显示最后一小段(见下文难点 5、6)。
历史图表页的计算指标明细;右上角是触发「传感器掉线」安全联锁时弹出的红色提示
4. 远程指令与设备控制
- 配置驱动的指令页面:指令配置页上有哪些控件,全部由数据库里的指令配置表决定,前端按类型动态渲染成开关 / 下拉框、输入框、滑块或时间选择器。控件之间可以设置依赖关系,比如水泵、加热这些手动开关只在「手动模式」下显示,阈值和目标温度只在「自动模式」下显示。换赛题时改数据库就行,不用改前端代码。
- 可靠下发:先通过 MQTT 发给设备,发送成功后才写入数据库,避免页面显示的值和设备的实际状态对不上。设备离线时,指令先暂存在后端;等设备重新上线,自动补发并在页面上提示。
- 定时开关:可以设置「每天几点开泵 / 关泵、开加热 / 关加热」,后端每 0.5 秒检查一次,到点就自动下发。
- 操作历史:每一次状态变化都会记录来源:页面手动下发、离线补发、自动联锁、定时任务,或者设备自己上报的变化。查询时把底层的
0/1、on/off统一翻译成「开 / 关」。
5. 自动控制
每条上报消息进入后端后,会按固定顺序经过一条处理链。顺序就是优先级:保护类永远先于控制类。
数据质量检查 → 阈值告警 → 安全联锁 → 故障状态机 → 正常联动 → PID 恒温 → 水泵恒流速 → 定量 / 定温停机 → 计算指标
- 正常联动:9 条可以自由勾选的规则(比如「进出水温差超过 2℃ 就开泵」「压力高且温度持续上升就关加热」)。多条规则的结论按「关优先于开」合并:只要有一条说关,最终就关。另外还有 300ms 防抖,避免反复开关。
- PID 恒温:加热器只能开或关,所以用「时间比例控制」模拟 PWM:每个周期里按 PID 算出的占空比决定加热开多久、关多久,把出水温度稳定在目标值附近。
- 水泵恒流速:同样只有开关量,提供滞环通断和占空比两种算法,可以按需切换。
- 定量停机 / 定温停机:按时间积分算出本轮累计流量,或者监测出水温度,达到目标后关泵关热并锁定,不再重复下发。
- 执行器归属:同一个执行器可能有多个模块想控制,这里靠「让位」来解决:PID 开启后,联动规则不再控制加热;恒流速开启后,联动规则不再控制水泵;安全保护类模块要关闭时,其他模块一律让路。
6. 安全保护与告警
按严重程度分成三层,每一条规则都能单独开关,赛场上按赛题挑选需要启用的规则:
| 层级 | 触发条件举例 | 处置 |
|---|---|---|
| 阈值告警 | 温度、流量、压力超出页面上设置的上下限 | 记录并弹出黄色提示,默认不动执行器 |
| 安全联锁 | 传感器掉线、流量剧烈波动、温差过大、没开泵却开了加热等 8 种情况 | 立即关泵关热,弹出红色提示;条件消失后自动恢复 |
| 故障状态机 | 干烧、管道堵塞、漏水、水泵故障、空转等 6 种硬件故障 | 先保存故障前的状态快照,再断电,锁定指令页面,必须人工复位;复位后按快照恢复原来的状态 |
另外还有「数据质量」一层,在数据入库前拦截:数值突然跳变的毛刺先缓存、不入库,确认稳定后才写入;还能发现「指令已关但设备还在工作」的继电器粘连,以及进出水温度传感器装反的情况。
为了避免误报,水泵刚启动时流量和压力还不稳定,相关规则要等水泵预热完成才开始判断。同一个问题在冷却时间内只触发一次,防止刷屏。前端按严重程度分四级提示:故障用模态弹窗(必须处理)、安全联锁用红色提示、告警用黄色提示、联动下发用蓝色提示(只在真的下发了指令时才弹)。
所有记录都会进入告警记录页,可以按来源(系统检测 / 智能判定)和类型筛选,下方的环形图按「全部 / 当前筛选 / 当前页」统计各类记录的占比,方便看出哪类问题最多。
7. 智能判定
赛题会在现场提供一个「智能判定服务」(HTTP 接口),把数据发过去,它会返回「正常 / 干烧 / 漏水……」之类的结论。平台有三种触发方式,最后都走同一套「请求服务 → 解析结论 → 存库」的流程:
- 手动判定:在汇总数据页勾选几条数据,点「智能判定」按钮,弹窗显示结论和置信度。
- 自动判定:后端定时取最新的若干条数据去判定,「智能判定(自动)」页展示趋势图、结论分布饼图和明细。
- 实时判定:每来一条消息就判一次(默认关闭,按赛题需要再打开)。
自动判定和实时判定的结论如果命中了关键词(比如「干烧」「漏水」),会自动关泵关热,写一条故障记录并弹窗提醒;手动判定只看结果,不会动设备。结论和置信度从响应里的哪个字段读取都可以配置,拿到现场接口文档后改一下配置就能对上。判定服务没有启用时,系统会返回一个本地占位结果,并在页面上明确标注「不是真实结果」,避免被误当成真结论。
8. 断网补传
- 双向心跳:设备向平台上报数据,平台判断设备在不在线;平台也定时向设备发心跳,设备收不到心跳时就把数据先缓存在本地。
- 恢复后补传:重新收到平台心跳后,设备从单独的补传主题把缓存的数据传上来。
- 补传数据只入库,不参与控制:补传数据会打上「补传」标记,只出现在汇总数据里,不会被当成「最新数据」显示在实时页面上;也不会触发任何告警和控制逻辑,因为拿过去某个时刻的值去控制现在的水泵和加热是错的;同样不写操作历史,避免旧数据覆盖当前的控制状态。
技术栈
| 分层 | 技术 | 用在哪里 |
|---|---|---|
| 前端 | Vue 3、Vite、Vue Router、Pinia | 十几个功能页面,按功能拆分的 useXxxStore |
| UI / 图表 | Element Plus、ECharts(按需引入) | 看板、表格、实时曲线、历史图表、仪表盘、饼图 |
| 后端 | Node.js、Express | 按 controller / service 分层,MQTT 消息经 messageRouter 分发到各业务模块 |
| 数据 | MySQL(mysql2 连接池) | 窗口函数做累计值、分桶降采样和状态前向填充 |
| 通信 | MQTT.js、ws | MQTT 收发设备消息,WebSocket 把数据和告警实时推给前端 |
| 识别 | Python、Flask、Ultralytics YOLO11、OpenCV | 独立的图像识别服务,配离线安装包 |
系统架构
- 数据上行:传感器设备 → MQTT → Express 后端 → 写入 MySQL,同时经 WebSocket 推送到 Vue 看板。
- 指令下行:Vue 看板 → HTTP → Express 后端 → MQTT → 传感器设备(设备离线时暂存,上线后补发)。
- 断网补传:后端向设备发 PC 心跳;设备断线期间缓存数据,恢复后从单独的主题补传,后端只落库、标记为补传数据。
- 控制逻辑:联动规则、安全联锁、PID 加热、水泵调速、定量停机在后端运行。
- 智能判定:后端 → 现场判定服务 → 判定结果入库。
- 图像识别:YOLO11 + Flask 识别服务独立运行。
我负责的部分
应用层由我独立完成(硬件和底层由队友负责),包括:
- 前端:所有页面(实时数据、历史数据、设备管理、指令配置、操作记录、历史图表、故障信息、安全告警、判定记录等)和全局样式。
- 后端:Express 接口、MQTT 接入与消息分发、WebSocket 推送,以及联动、安全联锁、PID 恒温、恒流速、定量停机等控制算法。
- 数据库:MySQL 表结构设计修改、历史查询的 SQL 优化和索引。
- 识别服务:YOLO11 + Flask 识别接口,以及赛场断网环境下用的离线依赖包和安装、自检脚本。
- 文档:架构说明、配置速查表和一份「赛场应对手册」(
PLAYBOOK.md),写清楚现场遇到哪种情况该改哪个文件、怎么验证。
难点与解决
下面这些都是开发和联调过程中真实遇到的问题,大致按「实时链路 → 数据与查询 → 控制与保护 → 工程化」的顺序排列。
1. WebSocket 推送丢帧
问题:设备每秒上报一次,后端给推送做了节流。最早的写法是「收到消息后固定等满一个间隔再推」。但设备 1 秒上报一次的实际到达间隔会上下抖动,两条消息很容易落进同一个窗口,前一条被后一条覆盖。实测 20 秒丢 3 帧,页面会卡住 2 秒不动,而且每条数据都白白晚了 1 秒。
解决:改成按「距离上次广播过了多久」来算:已经满一个间隔就立刻推,没满就只等剩下的那点时间。设备上报频率不超过节流间隔时一帧都不丢;上报比间隔快时照样合并,只推最新的一条。
2. 一个步骤出错,安全保护整轮失效
问题:一条 MQTT 消息进来,后端要依次做入库、阈值告警、安全联锁、故障状态机、正常联动、PID 等十来个步骤。最早这些步骤共用一个 try/catch,其中任何一步抛异常(比如数据库抖一下写记录失败,或者 MQTT 刚好断线导致下发失败),后面的步骤这一轮就全部跳过。如果恰好跳过的是安全联锁和故障保护,对控制系统来说是最危险的。
解决:抽出一个 runStep 包装函数,每一步单独捕获异常:出错只记一行日志(写明是哪一步出的错),并返回一个和正常结果形状相同的兜底值,后面的步骤照常执行。进程级的未捕获异常也只记录、不退出,控制系统优先保证可用性。
3. 后端开了两个,指令一直「MQTT 发送失败」
问题:联调时指令页面会间歇性弹出「MQTT 发送失败」,但进程列表看起来一切正常。最后查出来,是机器上同时跑了两个后端:VS Code 终端里开着一个,又用启动脚本开了一个。第二个实例抢不到 HTTP 端口,不提供任何服务,但它已经用同一个固定的 MQTT client id 连上了 Broker。MQTT 协议规定同一个 client id 只能有一个活动连接,于是两个实例被 Broker 轮流踢下线。启动脚本只会杀掉占用端口的那个进程,所以这个「僵尸实例」怎么也清不掉。
解决:启动时监听 HTTP 服务的 error 事件,一旦发现端口被占用就立即退出,不让它留下来抢 MQTT 连接。这里还有一个坑:这个监听必须注册在创建 WebSocketServer 之前。因为 ws 库会在 HTTP server 上挂自己的 error 监听器,并把错误转发给 WebSocket 服务;而 WebSocket 服务上没有人监听 error,就会直接抛出异常,中断后面的监听器,导致我们的退出逻辑永远执行不到(实测验证过)。另外要区分:运行期的偶发异常只记录、不退出;启动期的端口冲突说明服务根本没起来,必须退出。
4. 断网补传的数据把「最新数据」顶掉了
问题:断网恢复后,设备会把缓存的数据补传上来。如果把补传数据当成普通上报来处理,会出三个问题:
- 补传数据入库时自增 id 最大,实时数据页和首页显示的「最新数据」其实变成了断网那段时间的旧值;
- 会拿过去某个时刻的读数去驱动现在的水泵和加热;
- 操作历史那条处理路径会把上报值回写成「当前预期的指令值」,直接污染控制基准。
解决:补传走单独的 MQTT 主题和单独的处理器,只入库、不参与任何实时逻辑:
- 入库时打上「补传」标记,「最新 N 条」的查询窗口会跳过带这个标记的行,补传数据只出现在汇总数据里;
- 不走控制链,不写操作历史,不参与设备在线判断,也不推送给 WebSocket;
- 平台发给设备的心跳和设备上报的心跳是两个方向,必须用不同的主题名,否则平台会收到自己发出的心跳。
5. 历史图表选大时间范围时只显示最后一小段
问题:历史图表为了不卡,限制了每次最多返回 N 条数据,原来的做法是「取最新的 N 条」。时间范围一大,数据量超过 N,图上就只剩挤在末尾的一小段,看不出整个范围的趋势。
解决:改成按时间等宽分桶降采样:把选定的范围切成大约 N 个桶。不同类型的数据取值方式不一样:
- 普通数值取桶内平均值;
- 开关状态是 0/1 的离散量,取平均没有意义,改成取桶内时间最新的一行,和阶梯图的展示方式一致;
- 累计值必须先在全部原始数据上用窗口函数逐行算好,再分桶取每个桶的最后一行。如果先分桶再累计,中间被丢掉的行就算不进去,结果对不上。
6. 加热耗电量查询 24 小时范围要 170 秒以上
问题:算耗电量时,要把每条传感器数据和「那一刻的加热开关状态」对上。最初写成了每行一次相关子查询,子查询的边界依赖外层的列,MySQL 没法走索引,复杂度相当于「传感器行数 × 行为行数」的平方级,查 24 小时要 170 秒以上。再加上项目支持两种上报模式(传感器和行为合并上报,或分两个主题上报),两张表的时间不能保证精确对齐,也不能直接按时间 JOIN。
解决:
- 把时间窗口内的行为记录和传感器记录
UNION成一条按时间排序的事件流,用窗口函数把加热状态向后填充到每一条传感器记录上,一次扫描算完;窗口开始之前的状态用一条能走索引的 seed 行兜底。 - 给两张高频大表
t_sensor_data、t_behavior_data补上c_time、d_no索引。原来它们只有主键,按时间筛选等于全表扫描。 - ECharts 改成按需引入,减小首屏包体积。
- 也试过在前端关动画、去掉平滑曲线、用 LTTB 采样,实测对卡顿没有明显帮助,反而让图变难看,最后回退了这部分改动。
7. 累计流量算不准
问题:流量按 L/min 上报,最初按「每条消息间隔 1 秒」来近似累加。一旦消息排队、设备短暂掉线或者上报频率变了,累计量就会偏。而且定量停机和首页的计算指标各自算一遍,用的时间基准不一样,同一段时间会算出两个不同的结果。
解决:
- 按相邻两条消息的真实时间差积分:流量 ÷ 60 × Δt;
- 时间统一取入库记录的时间,而不是服务器处理这条消息的时刻,避免 MQTT 排队延迟和时钟漂移;多处计算共用同一个时间基准;
- Δt 最多按 10 秒算,避免设备离线的那段空档被当成「一直在流」;
- 累计口径提供两种:从第一条数据开始全表累计(重启不归零),或者只统计本次启动以来。
8. 安全联锁的误报和漏报
问题:
- 水泵刚启动时流量和压力还不稳定,一开泵就会误报「流量异常」「流量剧烈波动」;毛刺过滤也会把开泵时正常的流量爬升当成毛刺拦下来。
- 水泵关着时,管路压力本来就接近 0,一停泵就会刷一条「压力过低」。
- 传感器掉线时,上报的数据里直接缺了那个字段,原来的规则判断不出来。
解决:
- 加了水泵预热宽限期(默认 3 秒,可以在指令配置页修改),流量和压力相关的规则要等水泵预热完成才开始判断。安全联锁、故障状态机、阈值告警和毛刺过滤共用同一份预热时长,改一处就全部生效。
- 预热计时有一个细节:某条消息里没有带水泵状态时,要保留计时继续走,而不是把计时清零,否则偶尔漏一个字段,预热就会重新开始算。
- 「压力过低」只在水泵开着时才判断。
- 新增一条规则:上报数据里缺少任意一个传感器字段,就判定为传感器掉线,强制关闭水泵和加热,同时通过 WebSocket 推送通知,在页面右上角弹出提示(见上文第 3 张截图)。整台设备一直没有消息进来的情况,由定时器单独检测。
9. 故障锁定被自动联锁绕过
问题:故障触发后,指令页面会被锁成只读,必须人工复位才能恢复。但告警规则的自动联锁走的是另一条代码路径,直接写数据库,不经过 HTTP 接口的锁定拦截,所以会绕过锁定改掉指令状态。另外,服务重启后内存里的故障状态被重置成「正常」,数据库里的复位按钮却还停在重启前的状态,导致复位开关卡死。
解决:自动联锁执行前也检查锁定状态,和手动下发用同一套判断;把数据库里持久化的复位按钮状态同步回内存里的故障状态,这样重启后两边也能保持一致。前端这边,故障状态框原来只靠 5 秒轮询刷新,最多会延迟 5 秒;改成收到故障广播或复位成功后立刻拉取一次最新状态,轮询只作为兜底。
10. 多个模块抢同一个执行器
问题:加热器可能同时被 PID 恒温、滞回带通断、联动规则、安全联锁、故障状态机、定量 / 定温停机控制,水泵也同时被恒流速、联动和各种保护模块控制。如果各自直接下发,就会互相打架,继电器反复开关。
解决:
- 让位规则:PID 开启后联动规则不再控制加热,恒流速开启后联动规则不再控制水泵;安全联锁、故障状态机、定量 / 定温停机要关的时候,其他模块一律让路。
- 结论合并:联动的多条规则先各自给出结论,按「关优先于开」合并;真正下发前还要同时满足三个条件:结论和当前状态不一样、执行器没有被别的模块接管、没有被 300ms 防抖拦下。
- 冷却:同一个「设备 + 执行器 + 目标状态」在冷却时间内(默认 30 秒)只处理一次。
- 记录要和实际一致:如果指令配置里找不到对应的开关,报文根本没有发出去,那就不写联动记录、不弹提示。否则页面上显示「水泵 关→开」,设备实际却没动,现场排查会被带偏。同理,状态没有真正变化时(比如「已关 → 继续关」)不写操作历史。
11. 只有开关量的加热器怎么做 PID 恒温
问题:加热器只能开或关,没法调功率,PID 算出来的连续输出不能直接用;而且设备上报的间隔并不固定。
解决:
- 用时间比例控制模拟 PWM:固定一个控制周期,PID 输出的占空比决定这个周期里加热开多久、关多久;
- 条件积分抗饱和:占空比已经顶到上限或下限、误差还在往同一个方向走时,暂停累加积分,避免积分饱和导致温度冲过头;
- 限制占空比每个周期的最大变化幅度,防止输出突然跳变;控制周期不能低于 2 秒,相邻两次指令至少间隔 0.5 秒,避免继电器频繁动作;
- 按「当前时间跨过了几个完整周期」来判断周期边界,所以即使上报间隔比控制周期还长,也能正确进入下一个周期、重新计算占空比;
- 每个周期的开关情况写入数据库,在历史图表里画成 PID 周期阶梯图和占空比曲线,方便调参。
12. 定时开关可能错过设定时刻
问题:定时开关每 0.5 秒检查一次。如果要求「当前时间正好等于设定时间」才触发,只要某一次检查变慢,或者服务刚好短暂重启,这个时刻就会被错过,当天不再执行,而且不会报任何错。
解决:触发条件改成「已经到点 + 还在 120 秒宽限窗口内 + 今天还没有触发过」,并按「设备 + 指令项 + 日期 + 设定时刻」去重,保证每个时刻每天只执行一次;超过宽限窗口的当天不再补触发。
收获
这个比赛是我们学校软件工程和通信工程实验室携手组队完成的,每个队伍三个人,两个底层一个应用层。我们所有人从25年春天开始学习相关技术,于26年9月20日完成比赛,这场旅程用了一年多的时间,在这个旅程里我认识了很多志同道合的朋友,感谢相遇和陪伴,也希望我们所有人能够越来越好!


