ARTICLE · 1355862
FUXA 源码解读 ⑥|报警系统
01
一句话结论
报警是服务端的一台单线程状态机:每秒 tick 一次,每条报警按自己的 checkdelay 限流,判定结果写进两张 SQLite 表(当前态与历史),再通过 socket 把分类计数推给所有客户端;客户端看板另外走一条 2 秒 REST 轮询拿明细。
02
入口与调用链
服务端入口在 alarms.create(runtime)(alarms/index.js:557-560),runtime/index.js:120 把它挂到 alarmsMgr。start()(index.js:33-43)只做一件事:setInterval(_checkStatus, 1000)。注意这是固定 1 秒的主循环,与设备数量、报警数量无关。
_checkStatus()(index.js:264-301)是三级状态机:
- INIT
——打开数据库(_init,:350-360); - LOAD
——_loadProperty() 重建内存报警表,_loadAlarms() 把库里的现状合并回内存(:428-460); - IDLE
——每个 tick 调 _checkAlarms()。
_checkAlarms()(:306-338)按 variableId 分组遍历 alarms,用 devices.getDeviceValue(variableSource, varId) 取当前值,只有 tag !== null 才判(:313);alarm.tagproperty.bitmask 非空时先做位掩码(_checkBitmask,:340-345)。有变化就 changed.push(alr),随后 _checkActions(changed) 执行动作报警,alarmstorage.setAlarms(changed) 落库,成功后 _emitAlarmsChanged()(:519-521)发 alarms-status:changed。
这个事件在 runtime/index.js:129 被 updateAlarmsStatus 接住(:732-756):不带 socket 参数时遍历所有连接,按 getSocketPermission(:722-730)逐个算可见范围,再 alarmsMgr.getAlarmsStatus(permission) 推给该客户端。计数是从数据库读的(index.js:73-100),不是内存。
客户端有两条互相独立的通道:
- 角标
:socket 推 alarms-status(hmi.service.ts:321-323)→ home.component.ts:118-120 更新顶栏计数; - 明细
:AlarmViewComponent 用 timer(0, 2000) 每 2 秒 GET /api/alarms(alarm-view.component.ts:50、:101-116),服务端 getAlarmsValues 读的是内存(index.js:105-127)。
确认走第三条:POST /api/alarmack(server/api/alarms/index.js:89-104)→ setAlarmAck(name, userId, permission)(index.js:190-227);传 null 表示全部确认,服务端逐条校验权限,只要有一条越权就整批回 401,且先校验后修改(:211-216),不会出现「确认了一半」。历史查询走 GET /api/alarmshistory,最终到 alarmstorage.getAlarmsHistory。
看板本身还有一层视图逻辑:AlarmViewComponent.showType 在 alarms 与 history 之间切换(alarm-view.component.ts:181-208),切历史时按 dateRange 组 start/end(默认近三天,:57-61),并把返回值走一遍 formatAlarmText(:210-216)——把文本模板里的 %d 替换成报警触发时的值。
03
核心数据结构
运行时对象 Alarm(index.js:572-695):name、type(highhigh/high/low/info/action)、subproperty(阈值、checkdelay、timedelay、ackmode、文本、配色)、tagproperty(报警属性,含 variableId、bitmask 与权限)、ontime/offtime/acktime、status、lastcheck、toremove、userack、value。getId() 返回 name + '^~^' + type(:586-588),这就是全系统的报警唯一键。
三张索引表:
| alarms | index.js:23 | variableId | |
| alarmsProperty | index.js:24 | ||
| actionsProperty | index.js:27 | name^~^action |
状态枚举(:712-723):VOID=''、ON='N'、OFF='NF'、ACK='NA';确认模式 float / ackactive / ackpassive。这四个字符串会原样写进数据库、原样发给前端,前端只做翻译(alarm-view.component.ts:134-140)。
SQLite 两表(alarmstorage.js:56-57):alarms(nametype PRIMARY KEY, type, status, ontime, offtime, acktime) 存当前活动报警;chronicle(Sn AUTOINCREMENT, nametype, type, status, text, grp, ontime, offtime, acktime, userack, value) 存历史。库文件跟着工程走:settings.workDir/alarms.fuxap.db(:45)。
关键设计:历史表按「一次报警」而不是「一次状态变化」记一行。写入用 INSERT OR REPLACE,Sn 由子查询 (SELECT Sn FROM chronicle WHERE ontime=? AND nametype=?) 决定(:190-193),所以同一次报警从 ON 到 NA 到 NF 反复改写同一行,最终只留终态加各时间戳。
位掩码与权限:一个标签可以挂多条报警,highhigh/high/low/info 各一条,数值型报警直接比大小;需要按位判断时给报警属性配 bitmask,_checkBitmask 把任何非零结果归一成 1(:340-345),等于「这一位为 1 就触发」。权限不是挂在标签上,而是挂在报警自己的 property 上:列表侧用 runtime.checkPermission(permission, alr.tagproperty)(:113-116),计数侧用 _canShowAlarmStatus 从 alarmsProperty 取同一个 property(:539-549),两处各按 show 过滤、口径一致,确认则额外要求 enabled(:201-207)。
04
关键流程

状态机
▲ 四个状态加三个参数:checkdelay 决定多久看一次、timedelay 决定连续多久才算、ackmode 决定什么时候从活动表删除。
1. 触发与持续判定。Alarm.check(time, dt, value, displayValue)(index.js:590-658)第一行就是节流:lastcheck + checkdelay*1000 > time 直接返回(:591)。通过后 lastcheck = time,计算 onrange = value >= min && value <= max(:596)。VOID 状态下,值不在区间就把 ontime 清零;在区间则用 dt(标签采样时间戳)记起点(:603-605),等 ontime + timedelay*1000 <= time 才置 ON 并把 displayValue 存进 value(:607-610)。用采样时间而不是检查时间,是为了让报警时间对齐数据时间;副作用是设备时间戳异常时会直接跨过 timedelay。
2. 确认。setAck(user)(:675-681)只做三件事:填 acktime、清 lastcheck(让下一 tick 立刻重判)、记 userack。真正把状态改成 ACK 的仍是 check():ON 分支里 if (this.acktime) 就转 ACK(:625-628)。isToAck()(:683-694)决定前端是否显示确认按钮:float 永远不可确认;ackpassive 只在 OFF 时可确认;ackactive 在 ON/OFF 都可确认。
3. 恢复与移除。 ON 时值离开区间 → OFF 并记 offtime(:614-618);若 ackmode=float 或已确认过,则 toRemove()(:620-622)。动作报警在 OFF 且已确认时也走移除(:642-645)。setAlarms 看到 toremove 会 DELETE FROM alarms WHERE nametype=?(alarmstorage.js:194-196),随后 _checkAlarms 调 alr.init() 把对象复位成 VOID(:326-328)。「删除」只删活动表,历史行保留。
4. 重启恢复。_loadAlarms() 从 alarms 表读回 status/ontime/offtime/acktime 覆盖内存(:445-450)。所以服务重启不会把已经在响的报警重新计时,也不会把已确认的报警变回未确认;只有 toremove 之后的行才真正消失。
5. 动作报警。_checkActions(:462-487)用 !alarms[i].offtime 保证一次报警只执行一次动作:SET_VALUE 直接 devices.setDeviceValue,RUN_SCRIPT 以 notLog: true 跑脚本。动作报警不参与列表(getAlarmsValues 里 type !== ACTION,:109),只通过计数接口里的 actions[] 推给前端做弹窗/切页/Toast(:85-92)。
6. 落库与广播。

处理链路
▲ 判定在内存,一致性靠「每次变化都先落库再广播」;确认这条路径是唯一的例外。
7. 过滤。getAlarmsValues(filter, permission) 先把内存里的活动报警拍平成 alritem(:110-112),再依次过权限与 _filterAlarm(:523-537):priority 是类型白名单,text/group 做包含匹配,tagIds 比对的是 alr.tagproperty.variableId(:117)。无 enabled 权限的用户仍能看到行,但 toack 被强制置 0(:118-120),确认按钮不出现。前端看板目前只传日期范围给历史接口,这几个过滤器主要由脚本和第三方集成使用。
05
设计取舍与坑
1. 主循环 1 秒,精度就锁在 1 秒。checkdelay/timedelay 都以秒为单位(TimeMultiplier=1000,:11),但真正能判定的最早时刻受 1 秒 tick 限制,timedelay=3 实际可能落在 3~4 秒之间。要亚秒级报警,改这里没有用,得改设备层的采样周期。
2. 历史按 ontime 查,跨天报警会漏。getAlarmsHistory 的 SQL 是 WHERE ontime BETWEEN ? AND ?(alarmstorage.js:158)。一条昨天 23:50 触发、今天 01:00 才恢复的报警,ontime 落在昨天;操作员查「今天」的历史就看不到它,而它恰恰是夜班最需要追溯的那条。此条记为 D-07。
3. 标签取不到,报警就冻住。_checkAlarms 里 tag === null 直接跳过(index.js:313),既不进也不出。删设备、改标签 id、设备离线导致 getDeviceValue 返回 null 时,已处于 ON 的报警会永久留在活动表里,角标一直计数,直到有人点清除。此条记为 D-08。
4. 确认不广播。setAlarmAck 只调 setAck + setAlarms(:214-221),没有 _emitAlarmsChanged;而计数是从数据库读的,所以「已确认」这件事不会立刻反映到角标,要等下一次任意报警状态变化触发广播。列表因为 2 秒轮询,反而先看到确认结果。两条通道的时间差容易被当成「确认没生效」。
5. 落库失败不重试。_checkAlarms 在 setAlarms reject 后直接把错误抛给上层(:331-333),内存里的状态已经改了、lastcheck 也推进了,下一次判定因为「值没变」不会再产生 changed。结果就是内存与数据库分叉:列表显示已恢复,重启后报警又回来。
6. ACK 到 NF 之间会闪一下 N。 ACK 状态下值离开区间,代码先把 status 写回 ON(:649-654)并记 offtime,下一轮才在 ON 分支转 OFF。前端 2 秒轮询有机会正好抓到这条 N,表现为「已确认的报警突然变回未确认,一秒后又消失」。代码里没有注释解释这个中间态,从 offtime 的判重(:650-652)看更像是有意为之的半成品。
7. checkdelay 填 0 等于关掉报警。_isAlarmEnabled 要求 checkdelay > 0(:499),动作报警的 _isActionsValid 同样(:513)。配置里把检查周期设成 0,这条报警会被静默丢弃:不报错、不进列表、不计数,只在启动日志的 alarmsFound 里少一条。排查时先看这里。
8. 少一个 break,但恰好无害。 VOID 分支在 timedelay 未到时没有 return,会落到下面 ON 分支(:607-612)。因为此时 acktime 必为 0,ON 分支只会走到最后的 return false,行为与预期一致;但这种隐式贯穿在读代码时极易误判,改动这段时务必补上。
9. 重启后报警文本里的 %d 会裸露。alarms 表只有 nametype/type/status/ontime/offtime/acktime 六列(alarmstorage.js:56),没有 value;_loadAlarms 恢复的也只有这四个时间/状态字段(:445-450)。于是重启时仍在响的报警,内存里 value 是 undefined,getAlarmsValues 原样返回(:112),前端 formatAlarmText 因为拿不到值就退回模板原文(alarm-view.component.ts:212-214),现场看到的是「温度超限 %d」而不是「温度超限 82」。历史表有 value 列,所以历史窗口不受影响——同一个报警在两个标签页里显示不同文本。此条记为 D-09。
06
小结 + 下一篇
报警这条链的骨架是「一台状态机 + 两张表 + 两条客户端通道」。判定与节流都在 Alarm.check 一个函数里,落库与广播在 _checkAlarms 收口,历史只保留每次报警的终态。真正需要盯住的三个点:checkdelay=0 的静默禁用、tag 取不到时的冻结、以及「当前态读内存、计数读数据库」带来的短暂不一致。
下一篇进入历史数据 DAQ:同一条 device-value:changed 事件怎样被存成时序数据、采样按变化还是按周期、查询为什么按 chunk 分包回传。














































































