前言
以前工地安监的做法很朴素:镜头挂上,安全员盯 App 红点,出事了再翻回放、截图丢进项目部群。点位少、人盯得住的时候,这套还能交差。
点位一多就废了。大门被安全网蒙一下午,料场 4G 半夜断过电,App 里红点都在,项目部群是静的------第二天早会才知道。收藏回放解决不了「当时该谁去」。
开放平台这边,遮挡会出 videoBlind,掉线会出 offline,夜间料场走动会出 videoMotion。缺的不是再挂一路相机,而是用 setMessageCallback 把 alarm 和 deviceStatus 推到你自己的 HTTPS,再按点位进项目部。
读完按这个顺序交差:
- 在开放平台创建应用,把大门、料场设备绑进开发者资产。
- 签名自测通过,能拿到
accessToken。 callbackFlag写成alarm,deviceStatus,getMessageCallback能读回同一条地址。- 蒙镜头,桥接日志出现
videoBlind。 - 拔网或断 4G,日志出现
offline,且id为-1。 - 夜间料场走动进对应标段群;白天大门动检不进群。
工地两类漏报:镜头被挡(
videoBlind)、4G 掉线(offline)。App 有红点,项目部没有人。本文用现行 OpenAPI 的setMessageCallback同时订alarm+deviceStatus,带可运行 Node.js,验到群里响一条为止。
这篇会用到什么
- 同时订两类消息:
alarm管videoBlind/videoMotion,deviceStatus管offline/online。只订一类,另一类你收不到。 - 回调必须先回 HTTP 200。多次无响应,平台停推,工地会在你不知道的时候变聋。
- 一个开发者账号一条
callbackUrl,十个标段按did找项目部,不要做十套服务。 - 遮挡、离线当 P0,马上叫人;动检只在夜间料场、仓库门进群。
- 上下线体的
id固定是-1,cid为-1表示设备级,不能拿它去查告警详情。 - 4G 列表里的
sleep是省电,不是掉线,不要按offline打电话。
你负责绑设备和项目部群,开放平台负责把事件推到你的 HTTPS。
text
枪机 / 4G 云台
├─ 遮挡、动检 → alarm → videoBlind / videoMotion
└─ 上下线 → deviceStatus → offline / online
│
▼
你的 HTTPS(立刻 200)
│
▼
按 deviceId 找到标段 → 项目部群
为什么常见做法在工地扛不住
轮询 listDeviceDetailsByPage 适合对台账、对在线,不适合当值守主路径。扫得再勤也补不上推送那几秒,还烧调用次数。
只靠官方 App,红点停在安全员私人手机里,进不了标段群。卡没费、配电箱跳闸、镜头被防尘布挡住,都是当天的事,项目部要的是当时有人去,不是第二天的回放会。
一上来做原生客户端更没必要。项目部已经有钉钉或企微,缺的是桥,不是第三个 App。
动手:从创建应用到项目部群响了
部署只是开胃菜。真正过关的是:蒙镜头、断 4G、夜里在料场走一圈,群里各进一条该进的,不该进的别进。
第一步:创建应用,填齐现场台账
打开 乐橙开放平台 注册并创建应用。控制台里能看到 appId / appSecret 再往下做,没有这两样,后面签名都会废。把塔吊、大门、料场的设备绑进这个开发者资产,不要停在私人 App 号下。准备一个外网能 POST 进来的 HTTPS(联调用穿透,生产用正式证书)。
text
IMOU_APP_ID=lcdxxxxxxxxx
IMOU_APP_SECRET=你的密钥
CALLBACK_URL=https://bridge.example.com/imou/callback
DEVICE_GATE=大门序列号
DEVICE_YARD=料场序列号
HOOK_GATE=大门项目部机器人地址
HOOK_YARD=料场项目部机器人地址
HOOK_DEFAULT=总群机器人地址
成功:控制台能看到应用,设备在资产里显示在线。
兜底:设备还在私人 App 下,先做绑定,再谈回调。
第二步:签名壳,先和文档案例对齐
请求走 https://openapi.lechange.cn/openapi/{method},壳子是 system + params + id。签名按开发规范:time:{time},nonce:{nonce},appSecret:{appSecret} 做 MD5 小写 32 位。time 和服务器误差不能超过 5 分钟,nonce 5 分钟内不能重复,否则会撞 SN1005。
javascript
// openapi-client.js
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
const OPENAPI_BASE = 'https://openapi.lechange.cn/openapi';
function calcSign(time, nonce, appSecret) {
const raw = `time:${time},nonce:${nonce},appSecret:${appSecret}`;
return crypto.createHash('md5').update(raw, 'utf8').digest('hex');
}
async function callOpenApi(method, appId, appSecret, params = {}) {
const time = Math.floor(Date.now() / 1000);
const nonce = uuidv4();
const body = {
system: { ver: '1.0', appId, time, nonce, sign: calcSign(time, nonce, appSecret) },
id: uuidv4(),
params,
};
const res = await fetch(`${OPENAPI_BASE}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (!json.result || json.result.code !== '0') {
const msg = json.result ? `${json.result.code} ${json.result.msg}` : JSON.stringify(json);
throw new Error(`OpenAPI ${method} failed: ${msg}`);
}
return json.result.data;
}
module.exports = { callOpenApi, calcSign };
成功:calcSign(1706511734, 'f5a1ae2d-c09c-4d39-a744-83a5c2c653c2', 'test123456789test123456789') 得到 fd37b62889e4757c58b8f3bf05fb9976;再调 accessToken 能打印 token。
兜底:对不上标准案例,先别查业务接口,多半是字符串拼错。TK1002 再刷 token,不要每个请求都申。
第三步:动检要开,白天先别进群
工地白天吊臂、渣土车一直在动,动检如果全天进群,项目部会被刷死。PaaS 设备用 setDeviceCameraStatus 开 motionDetect(能力集是 MotionDetect,传参首字母小写),见设备能力开关。遮挡能不能出 videoBlind,以该机能力集为准,没有就不要硬开。
text
token=上一步拿到的 accessToken
deviceId=料场序列号
channelId=0
enableType=motionDetect
enable=true
成功:接口 code=0;现场用布或安全网蒙几秒,后面第四、五步的日志里能看到 videoBlind。
兜底:使能失败先查能力集,不要当成签名错了。大门白天的 videoMotion 只记日志,夜间料场才进群------规则写在第五步,这里先把开关打开。
第四步:登记回调,两类大类写在同一行
这篇必须跑通的接口是 setMessageCallback。status=on 时 callbackUrl 必填,必须公网可达。联调 basePush 用 "2",以桥接日志为准。
text
token=管理员 accessToken
status=on
callbackUrl=https://bridge.example.com/imou/callback
callbackFlag=alarm,deviceStatus
basePush=2
javascript
// set-callback.js
require('dotenv').config();
const { callOpenApi } = require('./openapi-client');
(async () => {
const token = (
await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {})
).accessToken;
await callOpenApi('setMessageCallback', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token,
status: 'on',
callbackUrl: process.env.CALLBACK_URL,
callbackFlag: 'alarm,deviceStatus',
basePush: '2',
});
const current = await callOpenApi(
'getMessageCallback',
process.env.IMOU_APP_ID,
process.env.IMOU_APP_SECRET,
{ token }
);
console.log(current);
})();
成功:getMessageCallback 读回的地址和 callbackFlag 与上面填写项一致。
兜底:只写出 alarm,掉线你永远收不到,改成 alarm,deviceStatus 再设一次。localhost 登记成功也收不到,先让公网 curl -X POST 打到自己的路径。
第五步:接收端先回 200,再按点位进群
同步路径零阻塞。多次不回 200,平台停推。上下线不是普通告警体,格式见事件消息格式定义:
json
{
"id": -1,
"did": "TESTDEVICEID001",
"dname": "料场东侧4G",
"cid": -1,
"msgType": "offline",
"type": 12,
"time": 1787208164
}
javascript
// bridge-server.js
require('dotenv').config();
const express = require('express');
const SITES = {
[process.env.DEVICE_GATE]: { site: '1号门', hook: process.env.HOOK_GATE },
[process.env.DEVICE_YARD]: { site: '料场', hook: process.env.HOOK_YARD },
};
const AFTER_HOURS = { start: 19, end: 7 };
function siteOf(did) {
return SITES[did] || { site: did, hook: process.env.HOOK_DEFAULT };
}
function isAfterHours(ts) {
const h = new Date(ts * 1000).getHours();
return h >= AFTER_HOURS.start || h < AFTER_HOURS.end;
}
function priority(msgType) {
if (msgType === 'videoBlind' || msgType === 'offline') return 'P0';
if (msgType === 'videoMotion') return 'P2';
return null;
}
function dedupeKey(ev) {
const did = ev.did || ev.deviceId;
if (ev.msgType === 'offline' || ev.msgType === 'online' || ev.id === -1) {
return `${did}:${ev.cid}:${ev.msgType}:${ev.time}`;
}
return String(ev.id);
}
const seen = new Set();
async function toProjectDept(ev) {
const pri = priority(ev.msgType);
if (!pri) return;
if (ev.msgType === 'videoMotion' && !isAfterHours(ev.time)) return;
const key = dedupeKey(ev);
if (seen.has(key)) return;
seen.add(key);
const { site, hook } = siteOf(ev.did || ev.deviceId);
const when = new Date(ev.time * 1000).toLocaleString('zh-CN', { hour12: false });
const text = `【工地 ${pri}】${site} ${ev.msgType}\n设备:${ev.did} 通道:${ev.cid}\n${when}\n去现场看,别只在群里回「收到」`;
console.log('[项目部]', text, hook ? '→ hook' : '→ 仅日志');
}
const app = express();
app.use(express.json({ limit: '1mb' }));
app.post('/imou/callback', (req, res) => {
res.status(200).json({ code: '0', msg: 'ok' });
const body = req.body || {};
const ev = body.msgType ? body : body.data || body;
setImmediate(() => toProjectDept(ev).catch((e) => console.error(e)));
});
app.get('/health', (_req, res) => res.status(200).send('ok'));
app.listen(process.env.PORT || 8080);
成功:浏览器打开 /health 返回 ok;curl -X POST 自己的回调,进程不堵、立刻 200。
兜底:把短信、钉钉、查库都写进同步路径,过不了这一步。先 200,再 setImmediate。
第六步:现场验收,群响了才算闭环
按勾,不要跳步。
- 蒙大门或料场镜头,日志出现
videoBlind,对应标段群进一条 P0。 - 拔网或断 4G,日志出现
offline,id为-1,群再进一条 P0。 - 晚上 19:00 后在料场走一圈,
videoMotion进料场群。 - 白天在大门前走动,不应进群,只留日志。
成功:上面四条都对得上。这就是「项目部群响了」。
兜底:有动检没掉线,回到第四步看 callbackFlag;有掉线蒙镜头没反应,查能力集,不要假设每台 4G 都能出 videoBlind。
videoBlind 和 offline 不要和动检共用冷却。镜头被挡、设备没了,就是该马上有人去。online 只用来合掉线单,不要每上一次线再喊一嗓子。
群响了之后,再谈取证
最小闭环已经成立,才加第二层。仍用同一套现行 OpenAPI,不要另起一套协议。
推送体里的云录像 token 不是每台都有(文档写明常见于 platForm 为 4 的设备)。没有 token 时,用云录像或本地卡回放接口事后取证。叫醒走回调,取证走存储,不要塞进同一次同步请求。
4G 短抖动可以给 offline 加 2~3 分钟确认窗,避免插拔卡刷屏;确认窗不要套到遮挡上。生产把内存 seen 换成 Redis。派单字段、SLA、值班表是下一篇的事,这篇停在「群里有该去的人看到」。
联调会踩的坑
| 现象 | 多半是什么 | 先做什么 |
|---|---|---|
| 登记成功但一条都没有 | URL 不是公网 / 证书 / 路径 404 | curl -X POST 打自己的回调 |
| 收过一阵突然没了 | 同步路径堵了,多次没回 200,平台停推 | 修好后重新 setMessageCallback |
| 有动检、没掉线 | callbackFlag 只写了 alarm |
改成 alarm,deviceStatus 再设一次 |
| 有掉线、蒙镜头没反应 | 机型没有遮挡能力 | 查能力集,用布遮实测 |
| 白天群被吊车刷爆 | 动检全天进群 | 动检加夜间窗;遮挡/离线不要加这段窗 |
把 sleep 当成掉线打电话 |
4G 省电 | 列表字段和回调 offline 分开看 |
拿上下线 id 去查告警 |
文档写明固定为 -1 |
用 did + cid + time 做幂等键 |
结尾
这篇省下的不是多一路镜头,而是安全员不用第二天翻回放,才在项目部群里问「谁去看过」。
只做值班大屏预览、不接群的,去看轻应用多路预览那篇就够了,不必在这里把回调搭起来。偶尔装两台相机、出事了有人盯 App 的散户,也没必要上这一套。
appSecret、机器人地址只放环境变量。公网回调建议加自有校验,日志里的手机号打码。接口以现行文档为准,不要混已标注不再维护的旧协议栏目。