凌晨两点十八,东围墙外有人贴着绿化带走了整整四分多钟。值班室电视墙亮着,没人抬头。第二天上午,三号楼电梯里又推进一辆电瓶车——业主群已经开始截消防条例。十六路镜头都在录,两件事都没拦住。
一、为什么「24 小时防护」最容易做成全天炸群
老旧小区改造里,安防验收常常只问两件事:画面清不清、回放能不能翻。过了三个月,物业办公室会稳定撞上两种尴尬。
第一种发生在夜里。围墙、绿化带、地下室入口没有固定岗,电视墙开着等于没开。有人沿墙根走了几分钟,硬盘里留下一段 videoMotion,值班群却一片安静——因为白天树枝、车灯、外卖员已经把动检刷成了背景音,保安把通知关了。
第二种发生在白天。电瓶车进电梯是消防红线,也是业主群里最容易发酵的截图。轿厢里明明有镜头,可事件只躺在 NVR 里。等物业翻到画面,人走了,车也进单元了。
两件事看起来都叫「智能分析」,技术上却不是同一根管子。
开放平台的消息是两层模型,类型全集见事件消息类型定义:
callbackFlag(你订不订这一大类)
├─ alarm → 设备告警:动检 / 人形 / 徘徊 / 拌线
├─ faceAnalysis → 视觉智能:人脸、机动车、非机动车、区域/拌线入侵
├─ deviceStatus → 上下线
└─ iot / numberstat …
│
▼
msgType(这条具体是什么事)
hoveringAlarm / human / videoMotion / crossLineDetection
aiPerArea / aiPerLine
aiNonVehDetect / aiNonVehArea / aiNonVehLine
对照社区两条业务线,建议先把表钉死:
| 业务线 | 点位 | 该订的大类 | 真正该响的 msgType | SLA |
|---|---|---|---|---|
| 周界夜巡 | 围墙枪机、绿化带、地下室口 | alarm,有能力再加 faceAnalysis | hoveringAlarm、human、crossLineDetection、aiPerArea、aiPerLine | 夜班分钟级出警 |
| 电梯禁电瓶车 | 轿厢 / 厅门 | 必须订 faceAnalysis | aiNonVehDetect、aiNonVehArea、aiNonVehLine | 全天秒级拦截/派单 |
| 运维保底 | 任意关键点 | 可选 deviceStatus | offline / videoBlind | 立刻修,不进保安出警群 |
这里有两个最容易写错的判断:
- 电瓶车检测不在
alarm里。aiNonVehDetect(非机动车检测)、aiNonVehArea(非机动车区域入侵)、aiNonVehLine(非机动车拌线)全部挂在faceAnalysis。只订alarm,周界动检会来,轿厢里的车永远不会进你的 webhook。 - 24 小时不等于 24 小时全开
videoMotion。 周界要的是「夜班该响的响」;电梯要的是「全天电瓶车必响」。把两件事都压成全天动检,值班群会在第一周被白班误报炸穿。
一句话架构:
周界枪机 ──alarm──┐
├── setMessageCallback ──► 同一 webhook(先 200)
电梯摄像机 ─faceAnalysis─┘
├─ 夜班:hoveringAlarm / aiPerArea → 派保安
└─ 全天:aiNonVeh* → 消防/秩序工单
推送流程见平台主动推送。下面从点位盘点写到可运行代码。
二、从点位盘点到双通道落地
2.1 总流程
┌──────────────┐ hoveringAlarm / human / aiPerArea
│ 围墙 / 绿化带 │ ──────────────────────────────────┐
└──────────────┘ │
┌──────────────┐ aiNonVehDetect / aiNonVehArea ▼
│ 电梯轿厢/厅门 │ ──────────────► 开放平台云端 ── POST /callback
└──────────────┘ setMessageCallback │
▼
┌─────────────────────┐
│ ① 立刻 HTTP 200 │
│ ② 归一化 did/deviceId│
│ ③ 按角色 + msgType │
└──────────┬──────────┘
┌───────────────────────────────┼────────────────┐
▼ ▼ ▼
周界夜巡通道 电梯禁入通道 运维通道
派保安 / 冷却 P0 消防工单 掉线/遮挡
getAlarmMessageById 补图 转存截图 不进值班群
2.2 工程准备
mkdir community-vision-guard && cd community-vision-guard
npm init -y
npm i express dotenv uuid
# Node 18+ 自带 fetch
# .env
IMOU_APP_ID=lcdxxxxxxxxx
IMOU_APP_SECRET=your_app_secret
CALLBACK_URL=https://guard.example.com/imou/callback
# 联调可用内网穿透;生产必须公网 HTTPS
- 在 乐橙开放平台 创建应用,拿到
appId/appSecret。 - 把小区摄像机绑进开发者资产池,列表只用现行
listDeviceDetailsByPage。 - 网关:
https://openapi.lechange.cn/openapi/{method},请求壳为system+params+id,签名见开发规范。
2.3 签名与请求壳
先贴能跑的壳。签名原始串是 time:{time},nonce:{nonce},appSecret:{appSecret},UTF-8 后做 MD5,得到 32 位小写。
// imou-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 };
本地先对齐官方标准案例,跑不通就别往下写业务。time 与服务器误差不能超过 5 分钟;nonce 5 分钟内不可复用,否则常见 SN1005。
// sign-selftest.js
const assert = require('assert');
const { calcSign } = require('./imou-client');
assert.strictEqual(
calcSign(
1706511734,
'f5a1ae2d-c09c-4d39-a744-83a5c2c653c2',
'test123456789test123456789'
),
'fd37b62889e4757c58b8f3bf05fb9976'
);
console.log('sign ok');
// get-token.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
(async () => {
const data = await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {});
console.log('accessToken:', data.accessToken);
console.log('expireTime(s):', data.expireTime);
})();
管理员 token 有效期约 3 天,见 accessToken。遇到 TK1002 再刷新,不必每个业务请求都重取。
2.4 先盘点能力,再谈「视觉 AI」
老旧小区改造最常见的翻车,是合同写了「电瓶车检测」,点位却是一台风吹日晒的普通枪机。能力集在设备详情里,不在销售话术里。
// list-devices.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
(async () => {
const token = (await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {})).accessToken;
const data = await callOpenApi('listDeviceDetailsByPage', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token, page: 1, pageSize: 50, source: 'bindAndShare',
});
for (const d of data.deviceList || []) {
const ch = (d.channelList || [])[0] || {};
console.log({
deviceId: d.deviceId,
name: d.deviceName,
status: d.deviceStatus,
accessType: d.accessType,
deviceAbility: d.deviceAbility,
channelAbility: ch.channelAbility,
});
}
})();
接口说明:listDeviceDetailsByPage。点位命名建议写成「东围墙-绿化带」「3栋-1单元电梯轿厢」,后面工单标题会好看很多。
踩坑 1: 能力集里的写法通常是首字母大写(如 MotionDetect、HoveringAlarm、AiHuman),使能开关 的 enableType 却要求首字母小写。对不上就先别开——那是机型边界,不是签名错了。
现行能力开关里,和社区这两条线直接相关的常见项:
| enableType(小写) | 能力集常见形态 | 用在哪 |
|---|---|---|
motionDetect | MotionDetect | 周界兜底动检 |
hoveringAlarm | HoveringAlarm | 夜班徘徊,比裸动检干净 |
aiHuman / aiHumanCar | AiHuman / AiHumanCar | 人形 / 人车智能 |
aiCar | AiCar | 机动车,电梯场景一般不要开 |
官方使能表里没有名为 aiNonVeh 的开关。非机动车事件能不能出,看机型是否具备视觉分析能力,以及你有没有订上 faceAnalysis。合同和验收清单请写成两行:「设备能力」和「开放平台订阅」,避免「平台通了、轿厢永远不报车」。
2.5 设备侧使能:PaaS 走 setDeviceCameraStatus
对 accessType=PaaS 的设备,文档明确推荐 setDeviceCameraStatus。modifyDeviceAlarmStatus 只动「动检计划开关」,徘徊 / 人形请走使能接口。
// enable-guard.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
async function tryEnable(appId, appSecret, token, deviceId, channelId, enableType) {
try {
await callOpenApi('setDeviceCameraStatus', appId, appSecret, {
token, deviceId, channelId, enableType, enable: true,
});
console.log('OK', deviceId, enableType);
} catch (e) {
console.warn('SKIP', deviceId, enableType, e.message);
}
}
(async () => {
const appId = process.env.IMOU_APP_ID;
const appSecret = process.env.IMOU_APP_SECRET;
const token = (await callOpenApi('accessToken', appId, appSecret, {})).accessToken;
// 周界枪机:动检 + 徘徊 + 人形
for (const t of ['motionDetect', 'hoveringAlarm', 'aiHuman']) {
await tryEnable(appId, appSecret, token, process.env.PERIMETER_DEVICE_ID, '0', t);
}
// 电梯机:不要为了「显得智能」顺手开 aiCar
for (const t of ['motionDetect', 'aiHuman']) {
await tryEnable(appId, appSecret, token, process.env.ELEVATOR_DEVICE_ID, '0', t);
}
})();
能力没有就跳过。验收时把「SKIP」日志留下,比在演示当天发现轿厢机没有非机动车分析要体面。
2.6 动检区域和计划:24 小时要拆成两套时间
周界枪机如果把整幅画面都当成动检区,树影、马路车灯、对面窗户都会进 videoMotion。电梯厅如果把候梯区也画进去,业主推行李箱、把车停在厅外等电梯,也会误报。
setDeviceAlarmRegion 的 region 是一串逗号分隔的 32 位整数:每一行一个整数,低 22 位对应画面从左到右 22 列,1 为检测块,高 10 位固定填 0。
// set-region.js —— 使用文档样例:收束检测带,避免整屏裸检
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
(async () => {
const token = (await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {})).accessToken;
await callOpenApi('setDeviceAlarmRegion', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token,
deviceId: process.env.PERIMETER_DEVICE_ID,
channelId: '0',
// 官方样例:收成一条竖向检测带,不要整屏 4194303
region: '4194303,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384,3216384',
});
await callOpenApi('setDeviceAlarmSensitivity', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token,
deviceId: process.env.PERIMETER_DEVICE_ID,
channelId: '0',
sensitive: 3, // 档位 1~5,夜班周界先从 3 试,树多再降
});
})();
「24 小时防护」如果要落在设备侧,用 modifyDeviceAlarmPlan。beginTime / endTime 是同一天的 HH:mm:ss,不会自动跨日。
// set-alarm-plan.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
function nightRules() {
// 22:00-06:00 必须拆成两段,否则 22:00→06:00 会被当成空窗口
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
return days.flatMap((period) => [
{ period, beginTime: '22:00:00', endTime: '23:59:59' },
{ period, beginTime: '00:00:00', endTime: '06:00:00' },
]);
}
function allDayRules() {
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
return days.map((period) => ({ period, beginTime: '00:00:00', endTime: '23:59:59' }));
}
(async () => {
const token = (await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {})).accessToken;
// 周界:设备侧先压成夜班窗口,白班误报少一半
await callOpenApi('modifyDeviceAlarmPlan', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token,
deviceId: process.env.PERIMETER_DEVICE_ID,
channelId: '0',
rules: nightRules(),
});
// 电梯:电瓶车没有「下班」,计划拉满 24h
await callOpenApi('modifyDeviceAlarmPlan', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token,
deviceId: process.env.ELEVATOR_DEVICE_ID,
channelId: '0',
rules: allDayRules(),
});
})();
踩坑 2: 第一次联调,我把周界计划写成一条 { beginTime: "22:00:00", endTime: "06:00:00" }。接口返回成功,夜里却完全不报。回读 deviceAlarmPlan 才发现:结束时间早于开始时间,窗口被收成空集。拆成「22:00–23:59」+「00:00–06:00」后,凌晨徘徊才重新出现。
业务侧还可以再做一层时间策略——设备计划用来减噪声,应用策略用来定 SLA。两层一起用,比只开全天动检稳。
2.7 订阅:周界 alarm + 电梯 faceAnalysis
// set-callback.js
require('dotenv').config();
const { callOpenApi } = require('./imou-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,faceAnalysis',
basePush: '2',
});
const current = await callOpenApi(
'getMessageCallback',
process.env.IMOU_APP_ID,
process.env.IMOU_APP_SECRET,
{ token }
);
console.log('当前回调配置:', current);
})();
参数见 setMessageCallback:
| 参数 | 建议值 | 说明 |
|---|---|---|
status | on | 开启订阅 |
callbackUrl | 公网 HTTPS | localhost 平台打不进 |
callbackFlag | alarm,faceAnalysis | 大类,逗号分隔 |
basePush | "2" | 联调时减少和消费端 App 交叉干扰 |
踩坑 3: status=on 时 callbackFlag 必填。只写 URL、不写 flag,接口可能成功,但 aiNonVehDetect 根本没订上。改完必须用 getMessageCallback 回读。
一个开发者账号通常只挂一个 callbackUrl。两类消息会进同一个 webhook,分流必须自己做。
2.8 两类消息体:字段名就不一样
周界侧(alarm,字段以 did / cid 为主):
{
"id": 2447736561,
"appId": "lcdxxxxxxxxx",
"did": "WALL_EAST_01",
"cid": 0,
"msgType": "hoveringAlarm",
"time": 1722670800,
"cname": "东围墙-绿化带",
"token": "f2dc8c09eeae4b5bad6abf522c93d825",
"desc": {}
}
电梯侧(faceAnalysis,字段以 deviceId / channelId 为主):
{
"appId": "lcdxxxxxxxxx",
"msgType": "aiNonVehDetect",
"deviceId": "ELEV_3_1",
"channelId": "0",
"localTime": "20260824101522",
"time": "20260824021522Z",
"token": "a1b2c3d4e5f64789aabbccddeeff0011",
"picUrlArray": [
"https://example.com/cabin.jpg",
"https://example.com/nonveh.jpg"
],
"desc": {}
}
aiNonVehArea / aiNonVehLine 结构同类,差别在「画面里出现了非机动车」还是「车压进了你画的区域 / 拌线」。周界若机型支持视觉分析,还可能收到 aiPerArea、aiPerLine——这比裸 videoMotion 更接近「有人越界」。
踩坑 4: 写死 const deviceId = body.did,电梯消息会变成「设备号为空」,点位表查不到楼栋,工单进默认群。人脸 / 车辆类 picUrlArray 平台侧保存时间很短(文档常见表述约一天),收到后应尽快转存。
2.9 点位角色表 + 归一化分流
物业系统能不能「看懂」,取决于这张表,而不是回调本身。
// site-map.js
module.exports = {
'WALL_EAST_01:0': {
role: 'perimeter',
community: '阳光花园(老改)',
spot: '东围墙-绿化带',
team: '保安夜班',
slaMinutes: 8,
},
'ELEV_3_1:0': {
role: 'elevator',
community: '阳光花园(老改)',
spot: '3栋1单元电梯轿厢',
team: '秩序维护',
slaMinutes: 3,
},
};
// normalize.js
const FACE_TYPES = new Set([
'aiFaceDetect', 'aiAFaceCompa', 'aiSFaceCompa', 'rtFaceDetect', 'rtFaceCompa',
'aiVehDetect', 'aiNonVehDetect',
'aiPerLine', 'aiVehLine', 'aiNonVehLine', 'aiUnknownLine',
'aiPerArea', 'aiVehArea', 'aiNonVehArea', 'aiUnknownArea',
]);
function normalizeEvent(raw) {
const msgType = raw.msgType || '';
const deviceId = String(raw.deviceId || raw.did || '');
const channelId = String(raw.channelId != null ? raw.channelId : raw.cid != null ? raw.cid : '0');
const eventId = String(
raw.id != null ? raw.id
: raw.alarmId != null ? raw.alarmId
: `${deviceId}:${channelId}:${msgType}:${raw.time || raw.localTime || ''}`
);
return {
family: FACE_TYPES.has(msgType) ? 'faceAnalysis' : 'alarm',
msgType,
deviceId,
channelId,
eventId,
time: raw.time,
localTime: raw.localTime,
token: raw.token,
picUrlArray: raw.picUrlArray || [],
raw,
};
}
module.exports = { normalizeEvent, FACE_TYPES };
// route.js
const SITE = require('./site-map');
const PERIMETER_DISPATCH = new Set([
'hoveringAlarm', 'human', 'crossLineDetection', 'aiPerArea', 'aiPerLine',
]);
const ELEVATOR_P0 = new Set(['aiNonVehDetect', 'aiNonVehArea', 'aiNonVehLine']);
function isNight(d = new Date()) {
const h = d.getHours();
return h >= 22 || h < 6;
}
function routeEvent(evt) {
const site = SITE[`${evt.deviceId}:${evt.channelId}`];
if (!site) return { action: 'drop', reason: 'unknown-spot' };
if (site.role === 'elevator') {
if (ELEVATOR_P0.has(evt.msgType)) {
return { action: 'dispatch', lane: 'elevator', priority: 'P0', site, notify: 'fire-order' };
}
return { action: 'observe', lane: 'elevator', priority: 'P3', site, notify: null };
}
if (site.role === 'perimeter') {
if (PERIMETER_DISPATCH.has(evt.msgType) && isNight()) {
return { action: 'dispatch', lane: 'perimeter', priority: 'P0', site, notify: 'night-guard' };
}
if (evt.msgType === 'videoMotion') {
return { action: 'observe', lane: 'perimeter', priority: 'P2', site, notify: null };
}
return { action: 'observe', lane: 'perimeter', priority: 'P3', site, notify: null };
}
return { action: 'drop', reason: 'no-lane' };
}
module.exports = { routeEvent, isNight };
路由规则用代码写死的好处是:白班树枝摇一下,不会叫起保安;凌晨有人沿墙根走,会叫;电梯里推进车,任何班次都叫。videoMotion 只入库,不当工单。
2.10 Webhook:先 200,再入队
平台写得很直白:收到推送后务必回 HTTP 200;多次无响应,就不再往这个地址推。见平台主动推送。
// server.js
require('dotenv').config();
const fs = require('fs');
const express = require('express');
const { normalizeEvent } = require('./normalize');
const { routeEvent } = require('./route');
const { enrichAndDispatch } = require('./dispatch');
const app = express();
app.use(express.json({ limit: '2mb' }));
const cooldown = new Map();
const COOLDOWN_MS = { perimeter: 120000, elevator: 45000 };
function allow(evt, lane) {
const key = `${lane}:${evt.deviceId}:${evt.channelId}:${evt.msgType}`;
const now = Date.now();
const until = cooldown.get(key) || 0;
if (now < until) return false;
cooldown.set(key, now + (COOLDOWN_MS[lane] || 60000));
return true;
}
app.post('/imou/callback', (req, res) => {
res.status(200).end(); // 同步路径只做这一件
const raw = req.body || {};
fs.appendFile('inbox.jsonl', JSON.stringify({ t: Date.now(), raw }) + '\n', () => {});
setImmediate(async () => {
const evt = normalizeEvent(raw);
const decision = routeEvent(evt);
if (decision.action !== 'dispatch') return;
if (!allow(evt, decision.lane)) return;
try {
await enrichAndDispatch(evt, decision);
} catch (e) {
console.error('async handle failed', e.message);
}
});
});
app.listen(process.env.PORT || 3000, () => console.log('callback up'));
同步路径里不要 await getAlarmMessageById、不要下大图、不要发企微。第一次联调我们把补图放在 handler 里,偶发超时后整晚停推;getMessageCallback 仍显示 status=on,入站日志却一条没有。配置还在,不等于还在推。
2.11 证据富化:工单要图,不要只留一句 msgType
// dispatch.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
async function adminToken() {
const data = await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {});
return data.accessToken;
}
async function enrichAlarm(evt) {
if (evt.picUrlArray.length) return { pics: evt.picUrlArray, aiTag: [], aiCopyWriting: '' };
if (evt.raw.id == null || evt.raw.id === -1) return { pics: [], aiTag: [], aiCopyWriting: '' };
try {
const token = await adminToken();
const detail = await callOpenApi('getAlarmMessageById', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token,
deviceId: evt.deviceId,
channelId: String(evt.channelId),
alarmId: String(evt.raw.id || evt.eventId),
msgType: evt.msgType,
});
return {
pics: detail.picurlArray || [],
thumbUrl: detail.thumbUrl,
aiTag: detail.aiTag || [],
aiCopyWriting: detail.aiCopyWriting || '',
cloudRecToken: detail.token,
};
} catch (e) {
console.warn('getAlarmMessageById failed:', e.message);
return { pics: [], aiTag: [], aiCopyWriting: '' };
}
}
async function maybeSnap(deviceId, channelId) {
try {
const token = await adminToken();
const data = await callOpenApi('setDeviceSnapEnhanced', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token, deviceId, channelId: String(channelId),
});
return data.url ? [data.url] : [];
} catch (e) {
console.warn('snap failed:', e.message);
return [];
}
}
async function enrichAndDispatch(evt, decision) {
let extra = await enrichAlarm(evt);
if (!extra.pics.length && decision.priority === 'P0') {
extra = { ...extra, pics: await maybeSnap(evt.deviceId, evt.channelId) };
}
const order = {
sourceAlarmId: evt.eventId,
community: decision.site.community,
spot: decision.site.spot,
team: decision.site.team,
slaMinutes: decision.site.slaMinutes,
priority: decision.priority,
title: decision.lane === 'elevator' ? '电梯疑似电瓶车进入' : '周界夜巡:有人徘徊/越界',
msgType: evt.msgType,
pics: extra.pics,
aiCopyWriting: extra.aiCopyWriting,
aiTag: extra.aiTag,
};
// 换成你们的物业工单 / 企微 / 短信
console.log('[DISPATCH]', JSON.stringify(order, null, 2));
}
module.exports = { enrichAndDispatch };
getAlarmMessageById 必填 deviceId、channelId、alarmId、msgType。返回里的 picurlArray、aiCopyWriting、aiTag 在开通智见类套餐时更完整。setDeviceSnapEnhanced 的 URL 大约 2 小时有效,P0 建议立刻转存到物业自己的对象存储。
电梯工单标题不要写成「动检触发」。业主和消防看的是「3 栋 1 单元电梯,疑似电瓶车进入」;保安夜班看的是「东围墙绿化带,徘徊 / 越界」。msgType 留在工单扩展字段里给研发复盘,不要直接甩给值班员。
三、边界、误报和生产环境
老旧社区不是实验室。树会摇,电梯厅会堵,4G 枪机会掉线。下面这些是我们真实踩过、后来写进运维手册的。
机型边界。 setDeviceCameraStatus 只支持详情里 accessType=PaaS 的设备。能力集没有 HoveringAlarm,强行开 hoveringAlarm 会失败。非机动车分析不是每台电梯半球都有——点位选型阶段就要用 listDeviceDetailsByPage 对能力,而不是上线后再补。
电梯厅误报。 车停在厅外、车头探进画面,aiNonVehDetect 可能报。优先用 aiNonVehArea / aiNonVehLine 把检测框收在轿厢内地板,而不是厅门全景。冷却可以设短一点(代码里 45 秒),但同一轿厢连续推进两辆车不该被第一辆的冷却吃掉——所以冷却 key 带上 msgType,不要只按设备号。
周界白班。 即使设备计划已经收成夜班,应用层仍要判断 isNight()。有人会在白天改计划做演示,忘了改回去;双保险比信任一次配置便宜。videoMotion 永远不要直接派保安。
停推比处理错更致命。 同步路径目标是 50ms 内回 200。入站心跳中断后,重跑 setMessageCallback,必要时用消息通道拉取补近 2 天积压(见事件消息对接)。getMessageCallback 仍为 on 不能当「还在推」的证据,要以 inbox 时间戳为准。
图片会过期。 faceAnalysis 的 picUrlArray、详情接口的 picurlArray、抓图 URL,都是临时链。消防复盘如果只留外链,一周后工单就是一张裂图。P0 事件落自己的桶。
跨日计划。 modifyDeviceAlarmPlan 的一条 rule 不跨日。夜班窗口必须拆两段。改完用 deviceAlarmPlan 回读,不要信一次成功码。
不要一次订全世界。 numberstat、无关 iot、电梯点位上的 aiVehDetect 都会抬噪声。社区 MVP 先 alarm,faceAnalysis,稳定后再加 deviceStatus。
子账号。 若物业中台不是用管理员 token 直连,授权粒度按 cam:序列号:通道号 切 Config / Alarm。使能和查警详情所需最小权限,文档写在各接口页上,不要把管理员 token 塞进前端。
四、写完这两条线之后
老旧社区要的不是「再多一路能回放的镜头」,而是两件能被值班员执行的事:夜里围墙有人晃,有人去;电梯推进电瓶车,有单、有图、有时限。
落地时记住三句就够:
- 电瓶车走
faceAnalysis的aiNonVeh*,不是alarm里的动检; - 周界 24 小时靠夜班窗口 +
hoveringAlarm/aiPerArea,不是全天videoMotion; - 一个
callbackUrl先回 200,再按点位角色和msgType分流。
如果还要接着做,可以看这几块现行文档,而不是旧版本协议栏目:
- 事件消息类型定义:把
aiNonVehDetect和hoveringAlarm从大类里拆开 - 设备能力开关说明:对照能力集再决定开什么使能
- 设备动检配置:区域、计划、灵敏度
- 根据 id 查询报警详情:给工单补图和智见文案
做社区改造、物业中台、电梯消防闭环的同学,可以把上面的 site-map + route 直接换成自己的楼栋表。需要对照接口调试时,从 开放平台 创建应用即可——平台以视频和安全能力为主,开放低代码组件和 OpenAPI,适合把摄像机事件接到已有的工单和值班系统里,而不是先重写一套播放器。