周二凌晨 2:14,护理站大屏连着刷「客厅有人经过」。值班员习惯性划掉。同一分钟,3 床床头紧急按钮被按下------urgencyAlarm 混进 videoMotion 的沙堆里。十分钟后家属电话打进来。设备没坏:所有事件进了同一条管道,SaaS 不会分级。
一、为什么「事件进了后台」还是叫不醒人
1.1 养老 SaaS 买的不是告警条数,是「该响的那一声」
社区助老、日间照料、居家看护,验收口径通常只有三条:
- P0 必达:紧急按钮、设备呼叫、烟感燃气、呼叫无应答,必须在几十秒内叫醒值班,不能跟动检抢同一条红点。
- P1 可延:人形、动检、短暂离线,允许冷却、合并、班次汇总,否则第二天通知权限会被关掉。
- 响了能闭环:通知里要带点位、时间、通道;能回看、能对讲、能派工。平台负责把事件推出来,分级和派工是你的 SaaS 该做的事。
第一周我们把 setMessageCallback 调通了,群里开始响。第二周夜班,值班员把「全部已读」当成工作流。第三天早会有人说「设备肯定没报」------其实报了,只是 P0 和 P1 坐同一张椅子。
根因通常不是镜头不会叫,而是:
把「平台推过来的原始
msgType」直接当成「值班工单」,中间少了一层事件桥接。
1.2 技术背景:大类订阅 + 细类分级 + 两种对接方式
消息模型先画成三层(文档:事件消息类型定义、事件消息对接):
text
callbackFlag(setMessageCallback 里订的大类)
├─ alarm → 设备告警(呼叫 / 紧急按钮 / 动检 / 烟感...)
├─ deviceStatus → 上下线(看护机离线本身也是风险)
└─ iot / numberstat / faceAnalysis → 养老 MVP 先不要订
│
▼
msgType(具体事件,决定 P0 / P1)
│
▼
你的桥接层(归一化 + 分级 + 路由)
├─ P0 队列:电话 / 语音 / 值班强提醒
└─ P1 队列:冷却后进工单或班次摘要
推送和拉取不是二选一互斥,官方把职责划得很清楚:
| 方案 | 你暴露什么 | 实时性 | 故障时会怎样 |
|---|---|---|---|
| 平台主动推送 | 公网 HTTPS | 近实时 | 多次不回 HTTP 200 → 停推 |
| 消息通道订阅 | 无需对外开放口 | 取决于拉取间隔 | 通道最多留 2 天,能补拉 |
养老值班要的是左边这一列的「快」,加上右边这一列的「不丢」。桥接层同时接两头,用同一套 msgType → P0/P1 映射,不要写两套业务。
现行告警类型表里,和看护强相关、且已经写进类型定义的,建议先按下面这张表落库。不要凭印象发明字符串;联调第一周务必把 raw body 留下。
| 档位 | 官方 msgType | 含义 | SaaS 建议动作 |
|---|---|---|---|
| P0 | urgencyAlarm |
网关配件紧急按钮 | 立刻叫醒值班 + 派工,禁止冷却吞掉 |
| P0 | callEvent / callBellEvent |
设备呼叫 / 呼叫事件 | 同 P0,并引导接听或回看 |
| P0 | callNoAnswered |
呼叫无应答 | 同 P0,补拨或上门 |
| P0 | smokeAlarm / gasAlarm / fireAlarm |
烟感 / 燃气 / 火警 | 同 P0,走消防预案 |
| P0 | waterAlarm |
水浸(卫生间渗水常见) | 同 P0,可并行通知物业 |
| P1 | human / videoMotion / alarmPIR / mobileDetect |
人形 / 动检 / 红外 / 移动 | 冷却 + 班次摘要,默认不强响 |
| P1 | hoveringAlarm |
徘徊 | 走廊可升为「延时 P0」:持续 N 分钟再升级 |
| P1 | noZigbeeAir |
长久未人体感应 | 先 P1,超时未恢复再升 P0 |
| P1 | offline |
设备或通道下线 | 5~10 分钟未 online 再升 P0 |
| P1 观察 | electricity / litElec / alkElec |
电量 / 低电 | 运维工单,不进家属强提醒 |
| 默认 | 未识别的 msgType |
机型可能多报一类 | 按 P1 落库 + 周报人工晋升,不要丢弃 |
说明:个别看护机型可能在回调里带出类型表之外的字符串。以你设备当天推上来的原始 msgType 为准,表只是映射的起点。
1.3 解决思路:同步只 ACK,分级在出队之后
text
设备 / 配件产生事件
→ 开放平台按 callbackUrl HTTP POST
→ 桥接同步路径(目标 < 100ms)
① 读 body(失败也继续)
② 追加 inbox
③ 立刻回 HTTP 200
→ 工人异步
归一化 did / deviceId / msgDeviceId
按 msgType 打 P0 / P1
msgId 幂等
P1 冷却;P0 永不冷却
再去碰短信 / 工单 / 值班群
→ 入站心跳中断
重跑 setMessageCallback
用 pullMessages 补 2 天积压
一句话:平台要的是 200,值班员要的是「P0 单独响」。两件事不要抢同一条请求线程,更不要让动检和紧急按钮共享同一个「已读」。
二、从订回调到「P0 进电话、P1 进摘要」
2.1 准备(10 分钟)
- 打开 open.imou.com 创建应用,控制台 → 我的应用 → 应用信息,拿到
appId/appSecret。 - 点位台账用现行
listDeviceDetailsByPage,不要走已停维护的旧列表接口。 - 准备公网 HTTPS(联调用内网穿透,生产用正式证书;
http://127.0.0.1平台访问不到)。 - 现行网关:
https://openapi.lechange.cn/openapi/{method},请求壳为system+params+id(开发规范)。
bash
mkdir care-event-bridge && cd care-event-bridge
npm init -y
npm i express dotenv uuid
# Node 18+ 自带 fetch
bash
# .env
IMOU_APP_ID=lcdxxxxxxxxx
IMOU_APP_SECRET=your_secret
CALLBACK_URL=https://care-bridge.example.com/imou/callback
INBOX_FILE=./data/inbox.jsonl
P1_COOLDOWN_MS=180000
OFFLINE_ESCALATE_MS=600000
SILENCE_MS=300000
2.2 签名:以开发规范的 HMAC-SHA256 为准
先贴能跑的壳。现行文档的签名步骤是:拼原始串 → 对 appSecret 做 SHA-256 得到 password → 再 HMAC-SHA256 后 Base64。接口页里偶尔还能看到 32 位 hex 的旧样例,以开发规范为准 ,否则会在 SN1001 上卡一天。
javascript
// 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}`;
const password = crypto.createHash('sha256').update(appSecret, 'utf8').digest('hex');
return crypto.createHmac('sha256', password).update(raw, 'utf8').digest('base64');
}
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 || String(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 };
本地先对齐官方标准案例,跑不通就别往下写分级------后面所有 SN1001 / SN1002 / SN1005 都是这里没对齐。time 与服务器误差不能超过 5 分钟;nonce 5 分钟内不可复用。
javascript
// sign-selftest.js
const assert = require('assert');
const { calcSign } = require('./imou-client');
assert.strictEqual(
calcSign(1706511734, 'f5a1ae2d-c09c-4d39-a744-83a5c2c653c2', 'test123456789test123456789'),
'xjhCQBoJ9hRDsCjyDcHjtDNzRZ3ZJezcawsfWeiaoxU='
);
console.log('sign ok');
accessToken 有效约 3 天,遇到 TK1002 再刷新,不要每个业务请求都重新拿(accessToken)。
javascript
// 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);
})();
2.3 台账:先知道「这台机在不在线」,再谈分级
养老站点常见坑,是回调里出现一台后台没有的 SN------设备加在私人 App 里,没进开发者资产池。台账用 listDeviceDetailsByPage:pageSize 1~50,page 从 1,source 默认 bindAndShare。
javascript
// list-care-devices.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
async function listAllDevices(token) {
const rows = [];
let page = 1;
while (true) {
const data = await callOpenApi('listDeviceDetailsByPage', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token,
page,
pageSize: 50,
source: 'bindAndShare',
});
const chunk = (data && data.deviceList) || [];
rows.push(...chunk);
if (chunk.length < 50) break;
page += 1;
}
return rows;
}
(async () => {
const { accessToken } = await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {});
const devices = await listAllDevices(accessToken);
for (const d of devices) {
console.log(d.deviceId, d.deviceStatus, d.accessType, (d.ability || '').slice(0, 80));
}
})();
踩坑 :accessType=PaaS 才能走后面的 setDeviceCameraStatus。能力集里没有对应项(文档常写成 MotionDetect 这种首字母大写),强行开会失败------这是机型边界,不是签名错了。
2.4 设备侧:让该报的报上来
PaaS 设备用 setDeviceCameraStatus,enableType 首字母小写 ,合法值见设备能力开关说明。看护点位建议先开上报总开关和人形 / 动检,不要 把 closeCamera 当成「打开镜头」。
javascript
// enable-care-sensors.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
async function setEnable(token, deviceId, channelId, enableType, enable) {
const params = { token, deviceId, enableType, enable };
if (channelId !== undefined && channelId !== null) params.channelId = String(channelId);
return callOpenApi('setDeviceCameraStatus', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, params);
}
(async () => {
const { accessToken } = await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {});
const deviceId = process.env.DEVICE_ID || 'TESTQWERXXXX';
// 设备级:报警消息上报。关了之后,后面分级全是空谈
await setEnable(accessToken, deviceId, null, 'msgSW', true);
// 通道级:动检 / 人形。能力集没有就跳过,不要当成接口坏了
await setEnable(accessToken, deviceId, 0, 'motionDetect', true);
await setEnable(accessToken, deviceId, 0, 'aiHuman', true);
// 踩坑:closeCamera 的 enable=true 是「打开遮罩 / 关掉画面」
// 看护时段若要画面,应 enable=false
await setEnable(accessToken, deviceId, 0, 'closeCamera', false);
console.log('care sensors updated');
})();
卧室夜间若有隐私约定,用 closeCamera=true 或设备侧隐私计划(ccss),那是产品策略,不要和「上报开关」揉在一起。instantDisAlarm 是一键撤防------联调环境误开会让整晚空白,生产务必巡检。
2.5 登记回调:一个开发者账号,通常只有一个入口
javascript
// 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,deviceStatus',
basePush: '2',
});
const current = await callOpenApi('getMessageCallback', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, { token });
console.log('当前回调配置:', current);
})();
参数对照 setMessageCallback / getMessageCallback:
| 参数 | 取值 | 说明 |
|---|---|---|
status |
on / off |
订阅开关;on 时 callbackUrl、callbackFlag 必填 |
callbackUrl |
公网 HTTPS | 内网地址无效 |
callbackFlag |
alarm,deviceStatus |
大类,逗号分隔;细分类在消息体 msgType |
basePush |
"2"(默认) |
"1" 推 / "2" 不推开发者账号关联的消费端 App 消息 |
养老 MVP 先订 alarm,deviceStatus。numberstat / faceAnalysis 会把噪声抬上去,也更容易把同步路径拖死。一个 appId 通常只有一个回调地址------多机构分流是你桥接层按 SN 查租户表,不是平台给你开十条 URL。
2.6 归一化:普通告警是 did,不是 deviceId
普通告警体按官方事件消息格式定义长这样。桥接要按 did 读,人脸类才是 deviceId,分享 / 绑定通知是 msgDeviceId。第一周我们按 deviceId 做租户路由,紧急按钮全进了「未识别设备」。
json
{
"id": 2447736561,
"appId": "lcdxxxxxxxxx",
"did": "TESTQWERXXXX",
"cid": 0,
"msgType": "urgencyAlarm",
"time": 1475052555,
"cname": "3床床头配件联动通道",
"remark": "",
"token": "仅 platForm=4 的设备报警可能带云录像 token",
"desc": {}
}
上下线是另一张皮:id 为 -1,cid=-1 代表设备级上下线,msgType 为 online / offline。网关配件则是 deviceId + accessoriesId。
javascript
// normalize.js
function pickDeviceId(body) {
if (!body || typeof body !== 'object') return '';
return String(
body.did ||
body.deviceId ||
body.msgDeviceId ||
(Array.isArray(body.msgDeviceIds) ? body.msgDeviceIds[0] : '') ||
''
);
}
function pickChannelId(body) {
if (body == null) return 0;
if (body.cid !== undefined && body.cid !== null) return Number(body.cid);
if (body.channelId !== undefined && body.channelId !== null) return Number(body.channelId);
return 0;
}
function pickMsgId(body) {
if (body && body.id !== undefined && body.id !== null && Number(body.id) !== -1) {
return String(body.id);
}
const did = pickDeviceId(body);
const type = (body && body.msgType) || 'unknown';
const time = (body && (body.time || body.utcTime)) || Date.now();
return `${did}:${type}:${time}`;
}
function normalizeEvent(body) {
return {
msgId: pickMsgId(body),
deviceId: pickDeviceId(body),
channelId: pickChannelId(body),
msgType: String((body && body.msgType) || 'unknown'),
time: body && body.time,
cloudRecordToken: (body && body.token) || '',
raw: body,
};
}
module.exports = { normalizeEvent, pickDeviceId, pickMsgId };
2.7 分级表:代码先于解释
javascript
// classify.js
const P0 = new Set([
'urgencyAlarm',
'callEvent',
'callBellEvent',
'callNoAnswered',
'smokeAlarm',
'gasAlarm',
'fireAlarm',
'waterAlarm',
'hijackAlarm',
]);
const P1 = new Set([
'human',
'videoMotion',
'alarmPIR',
'mobileDetect',
'hoveringAlarm',
'noZigbeeAir',
'offline',
'online',
'electricity',
'litElec',
'alkElec',
'abAlarmSound',
]);
function classify(msgType) {
if (P0.has(msgType)) return 'P0';
if (P1.has(msgType)) return 'P1';
return 'P1'; // 未知类型先观察,不要当垃圾丢掉
}
function routeHint(msgType, level) {
if (level === 'P0') return 'wakeup'; // 电话 / 语音 / 值班强提醒
if (msgType === 'offline' || msgType === 'noZigbeeAir') return 'watch-then-escalate';
return 'digest'; // 冷却后进班次摘要
}
module.exports = { classify, routeHint, P0, P1 };
2.8 桥接服务:同步路径只落盘,任何分支都回 200
官方推送页写得很直白:多次不回 200,就不再往这个地址推(平台主动推送)。养老场景里最常见的死法,是同步路径里 await 了短信网关。
javascript
// inbox.js
const fs = require('fs');
const path = require('path');
const FILE = process.env.INBOX_FILE || './data/inbox.jsonl';
function enqueue(payload) {
fs.mkdirSync(path.dirname(FILE), { recursive: true });
const line = JSON.stringify({ receivedAt: Date.now(), payload });
fs.appendFileSync(FILE, line + '\n', { encoding: 'utf8' });
}
module.exports = { enqueue, FILE };
javascript
// ack-bridge.js
require('dotenv').config();
const express = require('express');
const { enqueue } = require('./inbox');
const app = express();
app.use(express.json({ limit: '1mb' }));
let lastInboundAt = 0;
function ack(res) {
if (!res.headersSent) res.status(200).json({ code: '0', msg: 'ok' });
}
app.post('/imou/callback', (req, res) => {
lastInboundAt = Date.now();
try {
enqueue(req.body || {});
} catch (err) {
console.error('enqueue failed', err.message);
}
ack(res);
});
app.get('/health', (_req, res) => {
res.json({ ok: true, lastInboundAt, silentMs: Date.now() - lastInboundAt });
});
app.listen(process.env.PORT || 8787, () => {
console.log('care ack-bridge on', process.env.PORT || 8787);
});
appendFileSync 在单机联调里通常是毫秒级。生产换成 Redis LPUSH 或 Kafka,原则不变:同步路径里只做不会被下游拖死的事。 JSON 解析失败、未知 msgType、租户表查不到,一律先 200,再在工人里记脏数据。不要让鉴权中间件把平台推送打成 401。
2.9 工人:P0 永不冷却,P1 必须冷却
javascript
// worker.js
require('dotenv').config();
const fs = require('fs');
const { FILE } = require('./inbox');
const { normalizeEvent } = require('./normalize');
const { classify, routeHint } = require('./classify');
const seen = new Set();
const p1Cool = new Map();
const watch = new Map();
const offset = { bytes: 0 };
function alreadySeen(msgId) {
if (seen.has(msgId)) return true;
seen.add(msgId);
if (seen.size > 20000) seen.clear();
return false;
}
function p1Cooled(deviceId, msgType) {
const key = `${deviceId}:${msgType}`;
const now = Date.now();
const prev = p1Cool.get(key) || 0;
if (now - prev < Number(process.env.P1_COOLDOWN_MS || 180000)) return true;
p1Cool.set(key, now);
return false;
}
async function dispatch(event, level, hint) {
// 这里接你的值班总线:短信 / 语音 / 工单
// 演示只打日志,生产不要在这条函数里再同步等待下游
console.log(JSON.stringify({
level,
hint,
msgType: event.msgType,
deviceId: event.deviceId,
channelId: event.channelId,
msgId: event.msgId,
}));
}
function handleOne(body) {
const event = normalizeEvent(body);
if (!event.deviceId || event.msgType === 'unknown') {
console.warn('dirty event', event.msgId);
}
if (alreadySeen(event.msgId)) return;
const level = classify(event.msgType);
const hint = routeHint(event.msgType, level);
if (event.msgType === 'offline') {
watch.set(event.deviceId, Date.now());
}
if (event.msgType === 'online') {
watch.delete(event.deviceId);
}
if (level === 'P1' && hint === 'digest' && p1Cooled(event.deviceId, event.msgType)) {
return; // 只有 P1 能被冷却吞掉
}
return dispatch(event, level, hint);
}
function tickWatch() {
const limit = Number(process.env.OFFLINE_ESCALATE_MS || 600000);
const now = Date.now();
for (const [deviceId, since] of watch.entries()) {
if (now - since >= limit) {
dispatch(
{ msgId: `esc-offline:${deviceId}:${since}`, deviceId, channelId: -1, msgType: 'offline' },
'P0',
'wakeup'
);
watch.delete(deviceId);
}
}
}
function consume() {
if (!fs.existsSync(FILE)) return;
const fd = fs.openSync(FILE, 'r');
const stat = fs.fstatSync(fd);
if (stat.size < offset.bytes) offset.bytes = 0;
const len = stat.size - offset.bytes;
if (len <= 0) {
fs.closeSync(fd);
return;
}
const buf = Buffer.alloc(len);
fs.readSync(fd, buf, 0, len, offset.bytes);
fs.closeSync(fd);
offset.bytes = stat.size;
for (const line of buf.toString('utf8').split('\n')) {
if (!line.trim()) continue;
try {
handleOne(JSON.parse(line).payload);
} catch (err) {
console.error('worker line failed', err.message);
}
}
}
setInterval(consume, 500);
setInterval(tickWatch, 30000);
console.log('care worker started');
联调顺序建议写成清单,方便值班同学按步骤验收:
- 跑
sign-selftest.js,必须打出sign ok。 - 跑
get-token.js,拿到At_开头的管理员 token。 - 跑
list-care-devices.js,确认看护机deviceStatus=online且在开发者池里。 - 跑
enable-care-sensors.js(按能力集取舍)。 - 起
ack-bridge.js,把公网 HTTPS 指到/imou/callback。 - 跑
set-callback.js,再getMessageCallback对一下 URL。 - 起
worker.js,先按紧急按钮,再走路触发动检:日志里应先出现P0 wakeup,动检则是P1 digest,三分钟内重复走动能被冷却。 - 拔网 10 分钟,应看到
offline被升级成 P0。
踩坑实录 :第一版把冷却写在 handleOne 最前面,紧急按钮连按两次,第二次被当成「重复动检」吞了。P0 的幂等只能按 msgId,不能按「同一设备 N 分钟内不再响」。
2.10 补拉:推送静默之后,靠消息通道找回 2 天
getMessageCallback 仍返回 status=on,不等于还在推。入站心跳超过 SILENCE_MS,先重订回调;窗口里的缺口用 pullMessages 补。通道需先走工单开通,group 只允许 group1 / group2 / group3。
javascript
// pull-fallback.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
const { enqueue } = require('./inbox');
async function pullOnce(token) {
const data = await callOpenApi('pullMessages', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token,
group: 'group1',
limit: 100,
autoCommit: false,
});
const messages = (data && data.messages) || [];
for (const m of messages) {
const body = typeof m.msgBody === 'string' ? JSON.parse(m.msgBody) : (m.msgBody || m);
enqueue(body);
}
if (messages.length) {
await callOpenApi('commitOffset', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token,
group: 'group1',
});
}
return { count: messages.length, hasMore: !!(data && data.hasMore), offsetReset: !!(data && data.offsetReset) };
}
(async () => {
const { accessToken } = await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {});
console.log(await pullOnce(accessToken));
})();
生产用 autoCommit=false,处理成功再 commitOffset。无 pending 位点去 commit 会返回 MC1003。offsetReset=true 表示超过 2 天保留期,位点被重置,部分历史可能丢------这就是「桥接挂了超过两天,补不回那一声紧急按钮」的上限。同一 appId+group 不要多实例抢拉,否则容易碰到并发消费限制。
推送通道活着的时候,同一条告警可能既 POST 到 webhook、又出现在 pullMessages 里。所以工人必须用 msgId(普通告警是字段 id)做幂等------这不是优化,是正确性。
三、生产里真正会炸的几条边界
3.1 ACK 时延、停推和「配置还在」
文档的红线是「多次不返回响应」,不是「业务没处理完」。同步路径里查库、下图片、调工单、发短信,在平台看来都是「这个地址今天不太行」。更阴的误判:停推之后 getMessageCallback 仍然 on,控制台看起来很健康,入站日志却一条没有。用 /health 的 lastInboundAt 做静默探测,不要用配置页自我安慰。
3.2 字段皮不一样,分级表会被空 deviceId 打穿
普通告警 did/cid,人脸 deviceId/channelId,绑定通知 msgDeviceId,上下线设备级 cid=-1。租户路由必须先归一化。配件告警还可能带 accessoriesId,工单标题里建议同时写下网关 SN 和配件 SN,否则上门的人会找错床位。
platForm=4 的设备,告警体才可能带云录像 token;没有这个字段不要调用回放相关接口硬解。图片类推送(人脸)文档写明平台侧保存约一天,要留证就立刻转存。
3.3 冷却、班次和误报治理
| 规则 | P0 | P1 |
|---|---|---|
| 幂等 | 仅 msgId |
msgId + 设备×类型冷却 |
| 值班触达 | 电话 / 语音 / 强提醒 | 摘要 / 工单,默认可静音 |
| 班次 | 夜班也必须响 | 白班可合并,夜班可升一级 |
| 未知类型 | 不默认升 P0 | 落库观察,周会晋升 |
白班走廊 human 可以完全静音;夜班同一条可以改成「延时 P0」。这是产品策略,不要写死在映射表里------映射表只回答「官方类型默认是哪一档」,班次表再回答「此刻要不要升级」。
hoveringAlarm、noZigbeeAir 适合做「观察窗口」:先记 P1,窗口结束还没对冲事件(比如随后的 online、随后的人形),再升 P0。不要一上来就把「人离开客厅」当成呼救。
3.4 性能与容量
同步路径目标压在 100ms 内:读 body、追加、回 200。工人可以慢,但不能堵 ACK。单站点几十台看护机,JSONL 够用;多机构上百站点,inbox 换 Redis,工人按 deviceId 哈希到不同消费者,避免一台走廊枪机的动检拖死所有 P0。
listDeviceDetailsByPage 的 pageSize 最大 50,对账请循环,不要假设「一页就是全部」。能力开关按通道写,卧室和走廊策略不同,不要图省事对整机 dev: 一开到底。
3.5 联调周我们真实踩过的坑
- 抄了旧笔记的 MD5 签名,标准案例对不上,接口页 32 位 hex 样例更添乱。对照开发规范改成 HMAC-SHA256 + Base64 后一次通过。
- 只认
deviceId,紧急按钮的did丢了,P0 进了未识别桶。 - 冷却覆盖 P0,老人连按两次按钮,第二次消失。
closeCamera=true当打开镜头,画面黑了还以为设备离线。msgSW没开,分级表再漂亮也收不到报。- 鉴权中间件把回调打成 401 ,平台侧多次失败后停推,
getMessageCallback还显示 on。 - 用了旧列表接口 ,字段对不上。现行分页是
listDeviceDetailsByPage。 - 未知
msgType直接丢弃,某型号看护机多报的一类呼叫,整整一周没进值班流。改成默认 P1 落库后,周报里一眼能看出来。
四、把「会叫」做成「会分级」
养老 SaaS 对接开放接口,难点很少是「能不能收到事件」,而是收到之后会不会把紧急按钮和窗帘晃动当成同一种红点。链路其实不长:HMAC-SHA256 拿 token → listDeviceDetailsByPage 对账 → setDeviceCameraStatus 打开该报的使能 → setMessageCallback 订 alarm,deviceStatus → 同步路径只回 200 → 工人按官方 msgType 打 P0/P1 → 静默后重订,并用 pullMessages 补 2 天。
延伸阅读可以顺着现行文档往下翻:
- 开发规范(签名 / 网关)
- 事件消息对接 · 平台主动推送 · 事件消息类型定义 · 事件消息格式定义
- setMessageCallback · getMessageCallback · 消息通道 pullMessages
- listDeviceDetailsByPage · setDeviceCameraStatus · 设备能力开关
分权(谁能看客厅、谁能对讲)是另一条链路,不要和事件分级绑在同一个接口里。画面回看、轻应用嵌入,也可以在 P0 工单里按需接,但那是「响了之后怎么看」,不是「先别让 P0 被动检淹死」。
如果你正在做护理站值班台或居家看护后台,可以先在 开放平台(open.imou.com) 创建开发者应用,按本文顺序跑通:签名自检 → 台账 → 使能 → 回调 ACK → P0/P1 工人。平台以视频技术和安全为核心,并开放低代码开发组件,方便把预览、回放和对讲嵌进自有 SaaS------更适合先把「该响的那一声」从沙堆里捡出来,再谈页面好不好看。