把连锁门店轮询抓拍和遮挡叫醒接进督导台-setDeviceSnapEnhanced与setMessageCallback

八十路一起刷直播,督导台为什么接不住

连锁便利、加盟餐饮,门口和收银早就挂了镜头。总部要的是一个人按店过一遍:货架齐不齐、收银台有没有人、镜头是不是被围裙挡住。旧做法两条:定时给八十路开直播,或让店长把官方 App 截图丢进群。路一多,码流先打穿自己;群里的图对不上店名,也对不上这一分钟。再买相机只增加同时在播的路数,掉线、遮挡还是要等第二天有人点开。

缺的不是再买一台相机,而是把「按节拍看一眼」和「异常自己喊人」接进已有的督导 HTTPS。这篇用 setDeviceSnapEnhanced 按队列抓图,用 setMessageCallbackalarmdeviceStatus,把 videoBlind / videoLoss / offline 推进群。

读完按这个顺序交差:

  1. 云开放平台创建应用,拿到 appId / appSecret
  2. 签名自测通过,能拿到 accessToken
  3. 抽两家店收银核绑定,再 bindDevice,读回 isMine=true
  4. 对店 A 收银打一刀 setDeviceSnapEnhanced,浏览器能打开返回的 url
  5. 调度器按节拍轮两家店,间隔 ≥ 1 秒,督导台刷出两张最新抓图。
  6. 第一闭环成立后,再订回调,遮挡或掉线时督导群进一条。

连锁门店镜头还停在店长 App,八十路直播把办公本先巡了一遍。本文用现行 OpenAPI 的 setDeviceSnapEnhanced 按节拍抓图,再用 setMessageCallbackalarm,deviceStatusvideoBlind / offline,带可运行 Node.js,验到两家店出图为止;派单和点播放第二层。


这篇只会碰到这几项能力

只列本篇会碰到的,不是开放平台功能全集。抓图见设备操作说明,推送见平台主动推送,类型见事件消息类型定义

  1. bindDevice / checkDeviceBindOrNot:绑到开发者账号。code 传底部安全码或改过的设备密码;没安全码且未改密,文档允许传空。别人账上的机 isMine=false,绑不进来。
  2. accessToken:管控面钥匙,有效期约 3 天,报 TK1002 再刷。不能塞进浏览器。
  3. listDeviceDetailsByPage:分页拉通道,pageSize 1~50,page 从 1 起。状态是 online / offline / sleep / upgrading。离线店不要进本轮抓拍。
  4. setDeviceSnapEnhanced:同一通道间隔 ≥ 1 秒,更短会回上一张图。url 约 2 小时有效,收到就转存。2017 年前老机改走 setDeviceSnap(间隔 ≥ 3 秒)。
  5. setMessageCallbackstatus=oncallbackUrlcallbackFlag 必填。本篇订 alarm,deviceStatus。细分类在 msgType。一个开发者账号一条回调地址。
  6. 回调必须公网可达,收到务必回 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 分钟,否则 SN1002nonce 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。先 checkDeviceBindOrNotisBind=false 再绑;isMine=true 已经是你的;isBind=trueisMine=false 在别人账上,先解绑或走交接,不要死磕。

text 复制代码
token=上一步拿到的 accessToken
deviceId=店A收银序列号
code=底部8位安全码或改过的设备密码

未改密且标签没有 8 位安全码,按文档把 code 传空。第一闭环只绑店 A、店 B 收银,不要循环闷头绑完全部加盟店。部分新机无法只靠 HTTP 绑定,见应用开发,先走 SDK,不要改签名。

成功:bindDevice 返回 code=0;再查一次,isBind=trueisMine=true

兜底:店长私人资产先从原账号解绑。新机 HTTP 绑不上,按应用开发说明改路径。

第四步:先打一刀抓拍,再写轮询调度

这篇抓图接口是 setDeviceSnapEnhancedchannelId 按字符串传。对账仍用 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 秒。老机改 setDeviceSnapoffline / 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

按勾,不要跳步。

  1. listOnlineCashiers 能看到两家店、deviceVersion、通道 online
  2. 店 A 收银 snapOnceurl 浏览器能打开。
  3. 隔 8 秒再打店 B,两张图店名对得上,不是同一张图复制两遍。
  4. 同一通道 1 秒内连打两次,第二张应是文档说的「上一张」;把间隔拉回 ≥ 1 秒再验。
  5. 拔店 A 网线再刷队列,该行不再出现在本轮抓拍里。

成功:上面五条都对得上。

兜底:列表有机、图是黑的,先对 channelId 和在线状态。url 过了约 2 小时打不开,是没转存。第三家店只加映射。


两家店出图之后,再把遮挡和掉线推进督导群

最小闭环已经成立,才加第二层。仍用同一套现行 OpenAPI,不要另起一套协议。

闭店后镜头被挡、通道丢流、设备掉线,靠下一轮抓拍会晚一拍。用 setMessageCallbackalarm,deviceStatuscallbackUrl 必须公网可达,basePush 联调传 "2"。用 getMessageCallback 读回 status=on

本篇只叫醒三类,营业中的 videoMotion 不要进群:videoBlind(遮挡)、videoLoss(丢流)、offline(掉线)。推送体以事件消息格式定义为准,看 didcidmsgType

PaaS 机开上报和动检走 setDeviceCameraStatusenableType设备能力开关为准: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 秒,或拔网线。日志先出现 videoBlindoffline,群里再进一条。收到就回 200,进群、补抓放到返回之后。

要图:按 did / cid / msgTypegetAlarmMessageByIdpicurlArray,或补一刀抓拍。要看这一分钟:同一路 bindDeviceLivestreamId=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 转存后再给督导台。序列号对外部打码。接口以现行文档为准。

开发文档参考:

相关推荐
EatFan1 小时前
前后端分离项目中 Token 到底应该怎么设计?Access Token + Refresh Token 实战
java·前端·后端
掘金者阿豪1 小时前
2026年,AI编程工具到底好不好用?这份调研想听听你的真实声音
后端
名字还没想好☜1 小时前
Go 用 json.Decoder 流式解析大 JSON:边读边处理不爆内存
开发语言·后端·golang·go·json
谢亮_vipxieliang2 小时前
ValidX时间段验证详解:ISO 8601标准与简化格式
java·spring boot·后端·spring·spring cloud·hibernate
不一样的少年_3 小时前
WebP 压缩到底在干嘛?小白也能看懂的原理拆解
前端·后端·图片资源
不一样的少年_3 小时前
PNG/JPG 如何变成 WebP?真相不是改后缀!
前端·后端·图片资源
Zane19943 小时前
String::compareTo凭什么能当参数传?方法引用的四种形式讲透
java·后端
星恒讯工业路由器3 小时前
四张网协同演进:新一代通信网对工业通信设备意味着什么?
网络·物联网·智能路由器·工业路由器·工业物联网·5g-a·新一代通信网
MuMuMu12233 小时前
越华环保集团碳惠小屋:面向碳普惠场景的边缘物联网终端系统设计与工程落地
物联网