凌晨围墙晃了四分钟、电梯推进电瓶车:一套视觉AI,怎么把老旧社区拆成两条 24 小时防护线?

凌晨两点十八,东围墙外有人贴着绿化带走了整整四分多钟。值班室电视墙亮着,没人抬头。第二天上午,三号楼电梯里又推进一辆电瓶车——业主群已经开始截消防条例。十六路镜头都在录,两件事都没拦住。


一、为什么「24 小时防护」最容易做成全天炸群

老旧小区改造里,安防验收常常只问两件事:画面清不清、回放能不能翻。过了三个月,物业办公室会稳定撞上两种尴尬。

第一种发生在夜里。围墙、绿化带、地下室入口没有固定岗,电视墙开着等于没开。有人沿墙根走了几分钟,硬盘里留下一段 videoMotion,值班群却一片安静——因为白天树枝、车灯、外卖员已经把动检刷成了背景音,保安把通知关了。

第二种发生在白天。电瓶车进电梯是消防红线,也是业主群里最容易发酵的截图。轿厢里明明有镜头,可事件只躺在 NVR 里。等物业翻到画面,人走了,车也进单元了。

两件事看起来都叫「智能分析」,技术上却不是同一根管子。

开放平台的消息是两层模型,类型全集见事件消息类型定义

callbackFlag(你订不订这一大类)
  ├─ alarm         → 设备告警:动检 / 人形 / 徘徊 / 拌线
  ├─ faceAnalysis  → 视觉智能:人脸、机动车、非机动车、区域/拌线入侵
  ├─ deviceStatus  → 上下线
  └─ iot / numberstat …
          │
          ▼
     msgType(这条具体是什么事)
  hoveringAlarm / human / videoMotion / crossLineDetection
  aiPerArea / aiPerLine
  aiNonVehDetect / aiNonVehArea / aiNonVehLine

对照社区两条业务线,建议先把表钉死:

业务线 点位 该订的大类 真正该响的 msgType SLA
周界夜巡 围墙枪机、绿化带、地下室口 alarm,有能力再加 faceAnalysis hoveringAlarmhumancrossLineDetectionaiPerAreaaiPerLine 夜班分钟级出警
电梯禁电瓶车 轿厢 / 厅门 必须订 faceAnalysis aiNonVehDetectaiNonVehAreaaiNonVehLine 全天秒级拦截/派单
运维保底 任意关键点 可选 deviceStatus offline / videoBlind 立刻修,不进保安出警群

这里有两个最容易写错的判断:

  1. 电瓶车检测不在 alarm 里。 aiNonVehDetect(非机动车检测)、aiNonVehArea(非机动车区域入侵)、aiNonVehLine(非机动车拌线)全部挂在 faceAnalysis。只订 alarm,周界动检会来,轿厢里的车永远不会进你的 webhook。
  2. 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
  1. 乐橙开放平台 创建应用,拿到 appId / appSecret
  2. 把小区摄像机绑进开发者资产池,列表只用现行 listDeviceDetailsByPage
  3. 网关: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: 能力集里的写法通常是首字母大写(如 MotionDetectHoveringAlarmAiHuman),使能开关enableType 却要求首字母小写。对不上就先别开——那是机型边界,不是签名错了。

现行能力开关里,和社区这两条线直接相关的常见项:

enableType(小写) 能力集常见形态 用在哪
motionDetect MotionDetect 周界兜底动检
hoveringAlarm HoveringAlarm 夜班徘徊,比裸动检干净
aiHuman / aiHumanCar AiHuman / AiHumanCar 人形 / 人车智能
aiCar AiCar 机动车,电梯场景一般不要开

官方使能表里没有名为 aiNonVeh 的开关。非机动车事件能不能出,看机型是否具备视觉分析能力,以及你有没有订上 faceAnalysis。合同和验收清单请写成两行:「设备能力」和「开放平台订阅」,避免「平台通了、轿厢永远不报车」。

2.5 设备侧使能:PaaS 走 setDeviceCameraStatus

accessType=PaaS 的设备,文档明确推荐 setDeviceCameraStatusmodifyDeviceAlarmStatus 只动「动检计划开关」,徘徊 / 人形请走使能接口。

// 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。电梯厅如果把候梯区也画进去,业主推行李箱、把车停在厅外等电梯,也会误报。

setDeviceAlarmRegionregion 是一串逗号分隔的 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 小时防护」如果要落在设备侧,用 modifyDeviceAlarmPlanbeginTime / 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=oncallbackFlag 必填。只写 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 结构同类,差别在「画面里出现了非机动车」还是「车压进了你画的区域 / 拌线」。周界若机型支持视觉分析,还可能收到 aiPerAreaaiPerLine——这比裸 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 必填 deviceIdchannelIdalarmIdmsgType。返回里的 picurlArrayaiCopyWritingaiTag 在开通智见类套餐时更完整。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 时间戳为准。

图片会过期。 faceAnalysispicUrlArray、详情接口的 picurlArray、抓图 URL,都是临时链。消防复盘如果只留外链,一周后工单就是一张裂图。P0 事件落自己的桶。

跨日计划。 modifyDeviceAlarmPlan 的一条 rule 不跨日。夜班窗口必须拆两段。改完用 deviceAlarmPlan 回读,不要信一次成功码。

不要一次订全世界。 numberstat、无关 iot、电梯点位上的 aiVehDetect 都会抬噪声。社区 MVP 先 alarm,faceAnalysis,稳定后再加 deviceStatus

子账号。 若物业中台不是用管理员 token 直连,授权粒度按 cam:序列号:通道号Config / Alarm。使能和查警详情所需最小权限,文档写在各接口页上,不要把管理员 token 塞进前端。


四、写完这两条线之后

老旧社区要的不是「再多一路能回放的镜头」,而是两件能被值班员执行的事:夜里围墙有人晃,有人去;电梯推进电瓶车,有单、有图、有时限。

落地时记住三句就够:

  1. 电瓶车走 faceAnalysisaiNonVeh*,不是 alarm 里的动检;
  2. 周界 24 小时靠夜班窗口 + hoveringAlarm / aiPerArea,不是全天 videoMotion
  3. 一个 callbackUrl 先回 200,再按点位角色和 msgType 分流。

如果还要接着做,可以看这几块现行文档,而不是旧版本协议栏目:

做社区改造、物业中台、电梯消防闭环的同学,可以把上面的 site-map + route 直接换成自己的楼栋表。需要对照接口调试时,从 乐橙开放平台 创建应用即可——平台以视频和安全能力为主,开放低代码组件和 OpenAPI,适合把摄像机事件接到已有的工单和值班系统里,而不是先重写一套播放器。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐