八十路一起刷直播,督导台为什么接不住
连锁便利、加盟餐饮,门口和收银早就挂了镜头。总部要的是一个人按店过一遍:货架齐不齐、收银台有没有人、镜头是不是被围裙挡住。旧做法两条:定时给八十路开直播,或让店长把官方 App 截图丢进群。路一多,码流先打穿自己;群里的图对不上店名,也对不上这一分钟。再买相机只增加同时在播的路数,掉线、遮挡还是要等第二天有人点开。
缺的不是再买一台相机,而是把「按节拍看一眼」和「异常自己喊人」接进已有的督导 HTTPS。这篇用 setDeviceSnapEnhanced 按队列抓图,用 setMessageCallback 订 alarm 与 deviceStatus,把 videoBlind / videoLoss / offline 推进群。
读完按这个顺序交差:
- 在云开放平台创建应用,拿到
appId/appSecret。 - 签名自测通过,能拿到
accessToken。 - 抽两家店收银核绑定,再
bindDevice,读回isMine=true。 - 对店 A 收银打一刀
setDeviceSnapEnhanced,浏览器能打开返回的url。 - 调度器按节拍轮两家店,间隔 ≥ 1 秒,督导台刷出两张最新抓图。
- 第一闭环成立后,再订回调,遮挡或掉线时督导群进一条。
连锁门店镜头还停在店长 App,八十路直播把办公本先巡了一遍。本文用现行 OpenAPI 的
setDeviceSnapEnhanced按节拍抓图,再用setMessageCallback订alarm,deviceStatus收videoBlind/offline,带可运行 Node.js,验到两家店出图为止;派单和点播放第二层。
这篇只会碰到这几项能力
只列本篇会碰到的,不是开放平台功能全集。抓图见设备操作说明,推送见平台主动推送,类型见事件消息类型定义。
bindDevice/checkDeviceBindOrNot:绑到开发者账号。code传底部安全码或改过的设备密码;没安全码且未改密,文档允许传空。别人账上的机isMine=false,绑不进来。accessToken:管控面钥匙,有效期约 3 天,报TK1002再刷。不能塞进浏览器。listDeviceDetailsByPage:分页拉通道,pageSize1~50,page从 1 起。状态是online/offline/sleep/upgrading。离线店不要进本轮抓拍。setDeviceSnapEnhanced:同一通道间隔 ≥ 1 秒,更短会回上一张图。url约 2 小时有效,收到就转存。2017 年前老机改走setDeviceSnap(间隔 ≥ 3 秒)。setMessageCallback:status=on时callbackUrl、callbackFlag必填。本篇订alarm,deviceStatus。细分类在msgType。一个开发者账号一条回调地址。- 回调必须公网可达,收到务必回 HTTP 200;多次不回,平台停推。用
getMessageCallback回读。
你负责绑设备和店名映射,开放平台负责按你的节拍吐抓图、把遮挡和掉线推到你的 HTTPS。
text
各店 IPC(收银优先)
│
▼
listDeviceDetailsByPage 排出在线队列
│
▼
setDeviceSnapEnhanced(间隔 ≥ 1s)→ 督导台刷图
│
▼
遮挡 / 丢流 / 掉线 → setMessageCallback
│
▼
你的 HTTPS 先回 200 → 按 deviceId 进督导群
扫报警列表、只靠 App、一上来写原生,为什么巡不成
拿 getAlarmMessage 当巡店主路径会歪。它适合对账,补不上你自己定的看店节拍,也补不上「镜头被挡了谁该醒」------那是推送。
只靠官方 App,画面停在店长手机里。围裙一挡、网线一拔,红点在私人号,督导台是静的。
一上来写原生没必要。总部已有督导后台和企微,缺的是把抓图队列和异常接进现成页。八十路一起拉主码流,办公本风扇会先替你巡店。
动手:从创建应用到两家店按节拍出图
真正过关的是:调度器按节拍打出两家店收银的抓图 url,图能打开。图出来之前,不要订回调,不要开直播。
第一步:创建应用,先填门店点位表
打开 开放平台 创建应用。控制台「我的应用 - 应用信息」能看到 appId / appSecret 再往下做。
先不要写接口。用一张表收拢店、点位、序列号、通道。设备先配网上线,能在官方 App 预览再绑。第一闭环只做通两家店收银。
text
IMOU_APP_ID=lcdxxxxxxxxx
IMOU_APP_SECRET=你的密钥
PORT=8080
SNAP_GAP_MS=8000
CALLBACK_PUBLIC_URL=https://patrol.example.com/imou/callback
PATROL_WEBHOOK=https://你的群机器人或工单入口
text
# stores.json 填写模板(对齐写字段)
{
"店A收银序列号": { "storeId": "store-a", "storeName": "店A", "spot": "收银", "name": "店A-收银" },
"店B收银序列号": { "storeId": "store-b", "storeName": "店B", "spot": "收银", "name": "店B-收银" }
}
成功:控制台能看到应用;点位表每行都有店、点位、序列号。
兜底:还在店长私人号下的机,先标出来,第三步再核绑定。没有应用不要抄代码。第三家店只加映射行。
第二步:签名壳,先和文档案例对齐
请求走 https://openapi.lechange.cn/openapi/{method},壳子是 system + params + id。签名按开发规范:time:{time},nonce:{nonce},appSecret:{appSecret} 做 MD5 小写 32 位。time 和服务器误差不能超过 5 分钟,否则 SN1002;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 和 expireTime。
兜底:对不上标准案例,先别查业务接口,多半是字符串拼错。TK1002 再刷 token。签名算法若控制台提示不一致,以现行开发规范为准,不要混网上过时示例。
第三步:先核绑定,再一台一台 bindDevice
入账接口是 bindDevice。先 checkDeviceBindOrNot:isBind=false 再绑;isMine=true 已经是你的;isBind=true 且 isMine=false 在别人账上,先解绑或走交接,不要死磕。
text
token=上一步拿到的 accessToken
deviceId=店A收银序列号
code=底部8位安全码或改过的设备密码
未改密且标签没有 8 位安全码,按文档把 code 传空。第一闭环只绑店 A、店 B 收银,不要循环闷头绑完全部加盟店。部分新机无法只靠 HTTP 绑定,见应用开发,先走 SDK,不要改签名。
成功:bindDevice 返回 code=0;再查一次,isBind=true 且 isMine=true。
兜底:店长私人资产先从原账号解绑。新机 HTTP 绑不上,按应用开发说明改路径。
第四步:先打一刀抓拍,再写轮询调度
这篇抓图接口是 setDeviceSnapEnhanced。channelId 按字符串传。对账仍用 listDeviceDetailsByPage,只把 channelStatus=online 的收银推进队列。
抓拍策略先写死三条,别一上来「全部门店每秒一张」:
- 营业高峰只轮收银,门口那路放下一轮。
- 同一通道两次请求间隔 ≥ 1 秒;店与店之间再留空档,本篇默认 8 秒。
- 返回的
url立刻转存到自己的对象存储,不要把 2 小时临时链挂在督导台上过夜。
javascript
// patrol-snap.js
require('dotenv').config();
const { callOpenApi } = require('./openapi-client');
const STORES = require('./stores.json');
async function listOnlineCashiers(appId, appSecret, token) {
const rows = [];
let page = 1;
const pageSize = 50;
while (true) {
const data = await callOpenApi('listDeviceDetailsByPage', appId, appSecret, {
token,
page,
pageSize,
source: 'bind',
});
const chunk = data.deviceList || [];
for (const d of chunk) {
const meta = STORES[d.deviceId];
if (!meta || meta.spot !== '收银') continue;
const channels = d.channelList && d.channelList.length
? d.channelList
: [{ channelId: 0, channelStatus: d.deviceStatus }];
for (const ch of channels) {
const status = ch.channelStatus || d.deviceStatus;
if (status !== 'online') continue;
rows.push({
deviceId: d.deviceId,
channelId: String(ch.channelId),
deviceVersion: d.deviceVersion,
storeId: meta.storeId,
storeName: meta.storeName,
name: meta.name,
});
}
}
if (chunk.length < pageSize) break;
page += 1;
}
return rows;
}
async function snapOnce(appId, appSecret, token, deviceId, channelId) {
const data = await callOpenApi('setDeviceSnapEnhanced', appId, appSecret, {
token,
deviceId,
channelId: String(channelId),
});
return data.url;
}
async function rotateOnce(appId, appSecret, token, queue, lastAt) {
const now = Date.now();
const hits = [];
for (const row of queue) {
const key = `${row.deviceId}:${row.channelId}`;
const prev = lastAt.get(key) || 0;
if (now - prev < 1000) continue;
const url = await snapOnce(appId, appSecret, token, row.deviceId, row.channelId);
lastAt.set(key, Date.now());
hits.push({ ...row, url, snappedAt: Date.now() });
await new Promise((r) => setTimeout(r, Number(process.env.SNAP_GAP_MS || 8000)));
}
return hits;
}
module.exports = { listOnlineCashiers, snapOnce, rotateOnce };
先对店 A 收银单独跑 snapOnce,不要一上来 rotateOnce 扫完全表。
成功:控制台打出 deviceVersion 和一张能打开的 url,画面是这一分钟的。
兜底:图和上一秒一样,多半间隔小于 1 秒。老机改 setDeviceSnap。offline / sleep 先别进队列。
第五步:两家店按节拍出图,才算第一个闭环
把店 A、店 B 收银推进队列,跑一轮 rotateOnce。督导台这一步只读最新 url,不要在这里订消息、不要 bindDeviceLive。
text
# 第一轮调度对照(对齐写字段)
queue[0].storeId=store-a
queue[0].spot=收银
queue[0].channelId=0
queue[1].storeId=store-b
queue[1].spot=收银
queue[1].channelId=0
SNAP_GAP_MS=8000
按勾,不要跳步。
listOnlineCashiers能看到两家店、deviceVersion、通道online。- 店 A 收银
snapOnce的url浏览器能打开。 - 隔 8 秒再打店 B,两张图店名对得上,不是同一张图复制两遍。
- 同一通道 1 秒内连打两次,第二张应是文档说的「上一张」;把间隔拉回 ≥ 1 秒再验。
- 拔店 A 网线再刷队列,该行不再出现在本轮抓拍里。
成功:上面五条都对得上。
兜底:列表有机、图是黑的,先对 channelId 和在线状态。url 过了约 2 小时打不开,是没转存。第三家店只加映射。
两家店出图之后,再把遮挡和掉线推进督导群
最小闭环已经成立,才加第二层。仍用同一套现行 OpenAPI,不要另起一套协议。
闭店后镜头被挡、通道丢流、设备掉线,靠下一轮抓拍会晚一拍。用 setMessageCallback 订 alarm,deviceStatus,callbackUrl 必须公网可达,basePush 联调传 "2"。用 getMessageCallback 读回 status=on。
本篇只叫醒三类,营业中的 videoMotion 不要进群:videoBlind(遮挡)、videoLoss(丢流)、offline(掉线)。推送体以事件消息格式定义为准,看 did、cid、msgType。
PaaS 机开上报和动检走 setDeviceCameraStatus,enableType 以设备能力开关为准:msgSW 是报警上报,motionDetect 是动检。能力表没有单独的「遮挡开关」使能名,本机有没有遮挡,看能力集。
text
token=管理员 accessToken
status=on
callbackUrl=https://patrol.example.com/imou/callback
callbackFlag=alarm,deviceStatus
basePush=2
javascript
// patrol-server.js
require('dotenv').config();
const express = require('express');
const { callOpenApi } = require('./openapi-client');
const STORES = require('./stores.json');
const WAKE = new Set(['videoBlind', 'videoLoss', 'offline']);
const app = express();
app.use(express.json({ type: '*/*' }));
app.get('/health', (_req, res) => res.status(200).send('ok'));
app.post('/imou/callback', async (req, res) => {
res.status(200).json({ ok: true });
const body = req.body || {};
const msgType = body.msgType;
const deviceId = body.did || body.deviceId;
const channelId = body.cid != null ? String(body.cid) : String(body.channelId || '0');
const meta = STORES[deviceId];
console.log('callback', msgType, deviceId, channelId, meta && meta.name);
if (!WAKE.has(msgType) || !meta || !process.env.PATROL_WEBHOOK) return;
await fetch(process.env.PATROL_WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
storeId: meta.storeId,
name: meta.name,
msgType,
deviceId,
channelId,
}),
}).catch((e) => console.warn('webhook failed', e.message));
});
async function enableCallback() {
const tokenData = await callOpenApi('accessToken', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {});
await callOpenApi('setMessageCallback', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token: tokenData.accessToken,
status: 'on',
callbackUrl: process.env.CALLBACK_PUBLIC_URL,
callbackFlag: 'alarm,deviceStatus',
basePush: '2',
});
const current = await callOpenApi('getMessageCallback', process.env.IMOU_APP_ID, process.env.IMOU_APP_SECRET, {
token: tokenData.accessToken,
});
console.log('callback config', current);
}
app.listen(process.env.PORT || 8080, () => {
enableCallback().catch((e) => console.error(e));
});
用布遮住店 A 收银 10 秒,或拔网线。日志先出现 videoBlind 或 offline,群里再进一条。收到就回 200,进群、补抓放到返回之后。
要图:按 did / cid / msgType 调 getAlarmMessageById 拿 picurlArray,或补一刀抓拍。要看这一分钟:同一路 bindDeviceLive,streamId=1。客诉翻昨天:再走 queryCloudRecords,云存以现行回放文档为准。还是这一个客户端、一张门店表。
联调会踩的坑
| 现象 | 多半是什么 | 先做什么 |
|---|---|---|
| 两家店抓出同一张图 | 间隔小于 1 秒,平台回了上一张 | 把 SNAP_GAP_MS 拉到 ≥ 1000,先单通道验 |
url 下午还能开、晚上 404 |
抓图地址约 2 小时有效 | 收到立刻转存,不要把临时链当档案 |
| 老机抓不动,新机正常 | 性能差的机不该走 Enhanced | 按文档改 setDeviceSnap,间隔 ≥ 3 秒 |
| 队列缺店 | 只拉了第 1 页,或机还在私人号 | 翻页到不足一页;核 isMine |
| 订了回调,群不响 | URL 不是公网,或没回 200 | getMessageCallback 回读;先打 /health |
| 营业中群被刷爆 | 把 videoMotion 也进了值班规则 |
只放行遮挡、丢流、掉线 |
| 隔天抓拍和回调全挂 | token 过了约 3 天 | 收到 TK1002 再刷 |
真正省下的不是再买一路镜头
这篇省下的不是多一路预览,而是把巡店从「八十路同时播」收成「按节拍抓一张 + 异常自己喊人」。一套客户端、一张门店表、一个抓拍队列、一条回调。
只做单店预览的,走到第五步出图就够了。家里两台相机、出事了有人盯 App 的,不必上这一套。
appSecret 只放环境变量。抓图 url 转存后再给督导台。序列号对外部打码。接口以现行文档为准。
开发文档参考: