养殖 SaaS 笔记:乐橙 IoT 物模型管环境,视频 OpenAPI 管回看
eo.png` · 栏舍传感 + 摄像 → 一体看板
凌晨两点,养殖场值班群炸了:「3 号栋氨气超标,赶紧开风机!」
环控同事在传感平台里看到数值飙红,却要再切到乐橙 App / 另一套监控客户端找画面------SN 对不上、栋号对不上,十分钟后才对准镜头 。真正的问题不是没有传感器、也不是没有摄像机,是 环境数据与视频回传不在同一条业务台账里。
后来我们在 乐橙开放平台 用 listDeviceDetailsByPage 的 productId 分轨 :有值走 IoT 物模型 读温湿度/控风机;无值走视频 OpenAPI / 轻应用 出画。下面是可落地的「智慧养殖一体化」笔记。
养殖现场的两类设备,两种云协议
| 现场资产 | 业务诉求 | 云侧路径 | 列表线索 |
|---|---|---|---|
| 温湿度 / 氨气 / 料位 / 继电器 | 读数、阈值、告警、远程启停 | IoT 物模型 | productId 有值 |
| 栏舍 / 通道 IPC(乐橙摄像机) | 复核现场、留证、夜间巡查 | 视频 OpenAPI / 轻应用 | 常 无 productId;能力集含动检等 |
文档约定(见 IoT 物模型概述):productId 有值 = IoT 设备,可调:
| 接口 | 用途 |
|---|---|
getProductModel |
拉 Property / Service / Event 模板 |
getIotDeviceProperties |
读属性(参数名 properties = ref 列表) |
setIotDeviceProperties |
写属性 |
iotDeviceControl |
调 Service(如启停风机) |
视频侧仍用 bindDeviceLive / getKitToken 等现行接口出画;动检走 setMessageCallback 的 alarm,物模型事件订 iot ------两条推送可并存。
一体化不是「一个 MQTT 打天下」
| 错误做法 | 后果 |
|---|---|
| 摄像机也硬调物模型 | 无 productId,接口失败 |
| 传感器另写私有 HTTP,视频另写一套 | 栋号映射永久分叉 |
| 只做告警短信,不回传画面 | 值班不敢远程决策 |
| 只做九宫格监控,不读环境 | 看起来「智慧」,其实是传统安防 |
正确姿势:
text
一条设备台账(栋号 / 栏舍)
├─ iot 轨:物模型 Property / Service / Event
└─ video 轨:直播 / 回放 / 动检
同一告警中心:氨气超标 → 关联同栋摄像机 kitToken/HLS
架构
text
┌─ 栏舍传感(温湿度/氨气/继电器)─┐ ┌─ 栏舍 IPC ─┐
│ productId 有值 → IoT 物模型 │ │ 视频 OpenAPI │
└────────────────┬────────────────┘ └──────┬─────┘
▼ ▼
listDeviceDetailsByPage(统一同步)
│
▼
分轨:productId?
/ \
iot 轨 video 轨
getProductModel bindDeviceLive
get/set Properties 或 getKitToken
iotDeviceControl setMessageCallback(alarm)
callbackFlag=iot
\ /
▼
养殖看板:环境曲线 + 一键回看
| 能力 | 谁负责 | 说明 |
|---|---|---|
| 设备进池 | 绑定 / 配网 | 乐橙 App 能看 ≠ 开发者资产池有设备 |
| 是否 IoT | productId |
有值才调物模型 |
| ref 从哪来 | getProductModel |
用数字字符串 ref,不用英文 identifier |
| 布尔值 | 物模型约定 | 统一 0 / 1,不是 JSON true/false |
| 栋号映射 | 你的系统 | 云不替你建「3 号栋」 |
边界 :物模型 不能 替代 IPC 出画;视频接口 不能 读写温湿度。一体化发生在 你的 BFF 与看板,不是强行揉成一个 API。
Step 0 · 调用壳
javascript
// lib/platform-call.js
import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';
export function calcSign(time, nonce, appSecret) {
return crypto
.createHash('md5')
.update(`time:${time},nonce:${nonce},appSecret:${appSecret}`, 'utf8')
.digest('hex');
}
export async function platformCall(method, params = {}) {
const time = Math.floor(Date.now() / 1000);
const nonce = randomUUID();
const body = {
system: {
ver: '1.0',
appId: process.env.APP_ID,
sign: calcSign(time, nonce, process.env.APP_SECRET),
time,
nonce,
},
id: randomUUID(),
params,
};
const res = await fetch(`${process.env.OPENAPI_BASE}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.result?.code !== '0') {
throw new Error(`[${json.result?.code}] ${json.result?.msg}`);
}
return json.result.data;
}
let cached = { token: null, exp: 0 };
export async function adminToken() {
if (Date.now() < cached.exp) return cached.token;
const { accessToken, expireTime } = await platformCall('accessToken', {});
cached = { token: accessToken, exp: Date.now() + (expireTime - 300) * 1000 };
return accessToken;
}
bash
# .env.example
APP_ID=your_app_id
APP_SECRET=your_secret
OPENAPI_BASE=https://openapi.lechange.cn/openapi
CALLBACK_URL=https://farm.example.com/hooks/events
# 氨气告警阈值(示例):超过则关联同栋视频
AMMONIA_THRESHOLD=25
在 乐橙开放平台 创建应用后,控制台可拿到 appId / appSecret。传感类 IoT 设备与乐橙摄像机均需进入同一开发者资产池。
Step 1 · 栋号台账 + productId 分轨
javascript
// config/barns.js
/** deviceId → 栋号;传感与摄像必须进同一张表 */
export const deviceBarnMap = {
// 'SENSOR_SN_001': 'BARN_03',
// 'CAM_SN_001': 'BARN_03',
};
export function resolveBarn(deviceId, deviceName) {
if (deviceBarnMap[deviceId]) return deviceBarnMap[deviceId];
const m = String(deviceName || '').match(/^(BARN_\d+)/i);
return m ? m[1].toUpperCase() : 'UNMAPPED';
}
javascript
// services/classify.js
export function classifyDevice(dev) {
const isIot = Boolean(dev.productId);
const ability = [
dev.deviceAbility,
...(dev.channelList || []).map((c) => c.channelAbility),
]
.filter(Boolean)
.join(',');
let kind = 'unknown';
if (isIot) {
// 养殖侧常见:环控传感 / 继电器;以物模型 name 再细分
kind = /relay|fan|风机|开关/i.test(`${dev.deviceModel} ${dev.deviceName}`)
? 'actuator'
: 'sensor';
} else if (/AlarmMD|AudioTalk|PTZ|WLAN/i.test(ability) || !isIot) {
kind = 'camera';
}
return {
deviceId: dev.deviceId,
name: dev.deviceName,
productId: dev.productId || null,
status: dev.deviceStatus,
channelId: String(dev.channelList?.[0]?.channelId ?? 0),
track: isIot ? 'iot' : 'video',
kind,
ability,
};
}
javascript
// services/sync-farm-devices.js
import { platformCall, adminToken } from '../lib/platform-call.js';
import { classifyDevice } from './classify.js';
import { resolveBarn } from '../config/barns.js';
export async function syncFarmDevices({ pageSize = 50 } = {}) {
if (pageSize < 1 || pageSize > 50) throw new Error('pageSize 1..50');
const token = await adminToken();
const rows = [];
let page = 1;
for (;;) {
const data = await platformCall('listDeviceDetailsByPage', {
token,
page,
pageSize,
source: 'bindAndShare',
});
const list = data.deviceList ?? [];
if (!list.length) break;
for (const d of list) {
const base = classifyDevice(d);
rows.push({
...base,
barnCode: resolveBarn(d.deviceId, d.deviceName),
});
}
if (list.length < pageSize) break;
page += 1;
await new Promise((r) => setTimeout(r, 200));
}
return rows;
}
javascript
// scripts/run-sync-farm.js
import 'dotenv/config';
import { syncFarmDevices } from '../services/sync-farm-devices.js';
const rows = await syncFarmDevices();
console.table(
rows.map((r) => ({
barn: r.barnCode,
id: r.deviceId,
track: r.track,
kind: r.kind,
productId: r.productId,
status: r.status,
})),
);
console.log(
'UNMAPPED',
rows.filter((r) => r.barnCode === 'UNMAPPED').map((r) => r.deviceId),
);
踩坑 A :全是摄像机、无 productId ------ 正常 ,别对视频设备调 getProductModel。
踩坑 B :传感与摄像栋号分属两张 Excel ------ 告警永远对不准镜头;必须同一 barnCode。
踩坑 C :乐橙 App 能看、列表为空 ------ 未按接入指南绑进开发者资产池。
Step 2 · IoT 轨:拉物模型,用 name 找 ref
接口见 getProductModel / getIotDeviceProperties。
属性/服务通信用的是 数字字符串 ref ,不是英文 identifier。养殖产品各异,禁止写死别人家的 ref ------先 getProductModel,再按中文名模糊匹配。
javascript
// services/iot-thing.js
import { platformCall, adminToken } from '../lib/platform-call.js';
const modelCache = new Map();
export async function loadProductModel(productId) {
if (modelCache.has(productId)) return modelCache.get(productId);
const model = await platformCall('getProductModel', {
token: await adminToken(),
productId,
});
modelCache.set(productId, model);
return model;
}
/** 按名称关键字找 Property ref;找不到返回 null */
export function findPropertyRef(model, keywords = []) {
const props = model.properties || [];
for (const kw of keywords) {
const hit = props.find((p) =>
String(p.name || p.description || p.identifier || '').includes(kw),
);
if (hit?.ref != null) return String(hit.ref);
}
return null;
}
export function findServiceRef(model, keywords = []) {
const services = model.services || [];
for (const kw of keywords) {
const hit = services.find((s) =>
String(s.name || s.description || s.identifier || '').includes(kw),
);
if (hit?.ref != null) return String(hit.ref);
}
return null;
}
/** 官方参数名是 properties(ref 数组),不是 refs */
export async function readProperties(deviceId, productId, propertyRefs) {
return platformCall('getIotDeviceProperties', {
token: await adminToken(),
deviceId,
productId,
properties: propertyRefs.map(String),
});
}
export async function writeProperties(deviceId, productId, content) {
return platformCall('setIotDeviceProperties', {
token: await adminToken(),
deviceId,
productId,
content, // { "3301": 1 } ------ key 为 ref 字符串
});
}
export async function invokeService(deviceId, productId, ref, content = {}) {
return platformCall('iotDeviceControl', {
token: await adminToken(),
deviceId,
productId,
ref: String(ref),
content,
});
}
javascript
// scripts/probe-sensor.js
import 'dotenv/config';
import {
loadProductModel,
findPropertyRef,
readProperties,
} from '../services/iot-thing.js';
const productId = process.env.DEMO_PRODUCT_ID;
const deviceId = process.env.DEMO_SENSOR_SN;
const model = await loadProductModel(productId);
const tempRef = findPropertyRef(model, ['温度', '温']);
const humiRef = findPropertyRef(model, ['湿度', '湿']);
const ammoRef = findPropertyRef(model, ['氨', '氨气']);
const refs = [tempRef, humiRef, ammoRef].filter(Boolean);
if (!refs.length) {
console.log('未匹配到环境属性,打印全部 properties.name/ref 供人工配置:');
console.table(
(model.properties || []).map((p) => ({
ref: p.ref,
name: p.name,
access: p.accessMode,
})),
);
process.exit(1);
}
const state = await readProperties(deviceId, productId, refs);
console.log({
status: state.status,
properties: state.properties,
mapped: { tempRef, humiRef, ammoRef },
});
物模型三维(见 IoT 物模型概述):
text
Property --- 运行状态(温度、湿度、开关)
Service --- 可调用方法(启停风机、投料一次)
Event --- 运行事件(可经 setMessageCallback 的 iot 推送)
踩坑 D :properties 传 identifier 英文名 → 读失败;必须传 ref 。
踩坑 E :bool 写成 true → 应按文档传 0 / 1 。
踩坑 F :不同 productId 共用一套 ref 缓存 ------ 风机和温湿度探头模板不同,按 productId 分缓存。
Step 3 · IoT 轨:阈值与远程启停(先确认再下发)
写属性 / 调服务见 setIotDeviceProperties · iotDeviceControl。
javascript
// services/farm-control.js
import {
loadProductModel,
findServiceRef,
findPropertyRef,
writeProperties,
invokeService,
} from './iot-thing.js';
/**
* 示例:打开风机。
* 有的产品是写 bool 属性,有的是调 Service------以 getProductModel 为准。
*/
export async function startFan({ deviceId, productId, confirmToken }) {
if (confirmToken !== process.env.CONTROL_CONFIRM_TOKEN) {
throw new Error('二次确认失败:拒绝下发');
}
const model = await loadProductModel(productId);
const svc = findServiceRef(model, ['风机', '通风', '开启', '启动']);
if (svc) {
return invokeService(deviceId, productId, svc, {});
}
const sw = findPropertyRef(model, ['风机', '继电器', '开关']);
if (!sw) throw new Error('物模型中未找到风机相关 Service/Property');
// bool:1 开 0 关
return writeProperties(deviceId, productId, { [sw]: 1 });
}
javascript
// scripts/start-fan.js
import 'dotenv/config';
import { startFan } from '../services/farm-control.js';
const r = await startFan({
deviceId: process.env.DEMO_ACTUATOR_SN,
productId: process.env.DEMO_ACTUATOR_PRODUCT_ID,
confirmToken: process.env.CONTROL_CONFIRM_TOKEN,
});
console.log(r);
踩坑 G :看板做「一键开风机」无审计 ------ 养殖现场误触代价高,BFF 必须 二次确认 + 操作日志(谁、哪栋、何时)。
Step 4 · 视频轨:同栋回传(HLS 最小闭环)
传感告警要「点开即看」,视频轨用现行 bindDeviceLive 即可;Web 控件场景可换成 getKitToken + ImouPlayer。
javascript
// services/video-live.js
import { platformCall, adminToken } from '../lib/platform-call.js';
export async function getBarnLive(deviceId, channelId = '0', streamId = 1) {
return platformCall('bindDeviceLive', {
token: await adminToken(),
deviceId,
channelId: String(channelId),
streamId, // 1 标清:场区弱网更稳
liveMode: 'proxy',
});
}
javascript
// services/barn-link.js
/** 同一 barnCode 下关联摄像,供告警页一键出画 */
export function camerasOfBarn(devices, barnCode) {
return devices.filter(
(d) => d.barnCode === barnCode && d.track === 'video' && d.status === 'online',
);
}
export function sensorsOfBarn(devices, barnCode) {
return devices.filter(
(d) => d.barnCode === barnCode && d.track === 'iot',
);
}
Step 5 · 推送并轨:iot + alarm,先 200 再处理
登记回调见 setMessageCallback;流程说明见 事件消息推送。
javascript
// scripts/enable-farm-callback.js
import 'dotenv/config';
import { platformCall, adminToken } from '../lib/platform-call.js';
const token = await adminToken();
await platformCall('setMessageCallback', {
token,
status: 'on',
callbackUrl: process.env.CALLBACK_URL,
// 物模型事件 + 摄像动检/人形;上下线可按需加 deviceStatus
callbackFlag: 'iot,alarm',
basePush: '2',
});
console.log(await platformCall('getMessageCallback', { token }));
javascript
// server/farm-bridge.js
import express from 'express';
import 'dotenv/config';
import { syncFarmDevices } from '../services/sync-farm-devices.js';
import { camerasOfBarn } from '../services/barn-link.js';
import { getBarnLive } from '../services/video-live.js';
import {
loadProductModel,
findPropertyRef,
readProperties,
} from '../services/iot-thing.js';
const app = express();
app.use(express.json({ limit: '1mb' }));
let devices = [];
async function refresh() {
devices = await syncFarmDevices();
}
await refresh();
setInterval(() => refresh().catch(console.error), 10 * 60 * 1000);
app.post('/hooks/events', (req, res) => {
res.status(200).send('ok'); // ★ 必须先应答
queueMicrotask(() => handle(req.body).catch(console.error));
});
async function handle(msg) {
const msgType = msg?.msgType;
if (!msgType) return;
// 视频动检:记日志即可;养殖白天噪声大,别当氨气告警
if (msgType === 'videoMotion' || msgType === 'human') {
console.log('VIDEO_EVENT', msg.did || msg.deviceId, msgType);
return;
}
// 物模型事件(字段以事件消息格式为准,常见 msgType=iotEvent)
if (msgType === 'iotEvent' || msg.pid) {
console.log('IOT_EVENT', JSON.stringify({
did: msg.did || msg.deviceId,
pid: msg.pid || msg.productId,
event: msg.content?.event,
time: msg.time || msg.utcTime,
}));
}
}
/** 值班看板:某栋环境快照 + 可播摄像头列表 */
app.get('/api/v1/barns/:barnCode/dashboard', async (req, res) => {
const { barnCode } = req.params;
const cams = camerasOfBarn(devices, barnCode);
const sensors = devices.filter(
(d) => d.barnCode === barnCode && d.track === 'iot' && d.kind === 'sensor',
);
const readings = [];
for (const s of sensors) {
if (!s.productId || s.status !== 'online') continue;
const model = await loadProductModel(s.productId);
const refs = [
findPropertyRef(model, ['温度', '温']),
findPropertyRef(model, ['湿度', '湿']),
findPropertyRef(model, ['氨', '氨气']),
].filter(Boolean);
if (!refs.length) continue;
const state = await readProperties(s.deviceId, s.productId, refs);
readings.push({
deviceId: s.deviceId,
status: state.status,
properties: state.properties,
});
}
res.json({
barnCode,
readings,
cameras: cams.map((c) => ({
deviceId: c.deviceId,
name: c.name,
channelId: c.channelId,
})),
});
});
/** 告警联动:取同栋第一路在线摄像直播 */
app.get('/api/v1/barns/:barnCode/live', async (req, res) => {
const cams = camerasOfBarn(devices, req.params.barnCode);
if (!cams.length) return res.status(404).json({ msg: '该栋无在线摄像机' });
const cam = cams[0];
const live = await getBarnLive(cam.deviceId, cam.channelId, 1);
res.json({ deviceId: cam.deviceId, live });
});
app.listen(8791, () => console.log('farm bridge :8791'));
氨气阈值示例(轮询补偿;实时仍以 iot 事件为准):
javascript
// jobs/ammonia-watch.js
import 'dotenv/config';
import { syncFarmDevices } from '../services/sync-farm-devices.js';
import {
loadProductModel,
findPropertyRef,
readProperties,
} from '../services/iot-thing.js';
import { camerasOfBarn } from '../services/barn-link.js';
const THRESH = Number(process.env.AMMONIA_THRESHOLD || 25);
export async function scanAmmonia() {
const devices = await syncFarmDevices();
const sensors = devices.filter((d) => d.track === 'iot' && d.productId);
for (const s of sensors) {
const model = await loadProductModel(s.productId);
const ammoRef = findPropertyRef(model, ['氨', '氨气']);
if (!ammoRef) continue;
const state = await readProperties(s.deviceId, s.productId, [ammoRef]);
const val = Number(state.properties?.[ammoRef]);
if (Number.isNaN(val) || val < THRESH) continue;
const cams = camerasOfBarn(devices, s.barnCode);
console.warn('AMMONIA_ALERT', {
barn: s.barnCode,
sensor: s.deviceId,
value: val,
suggestCameras: cams.map((c) => c.deviceId),
// 下一步:推值班群 + 打开 /api/v1/barns/:barn/live
});
}
}
// 定时:setInterval(() => scanAmmonia().catch(console.error), 60_000)
// 或 CLI:node jobs/ammonia-watch.js(入口自行调用 scanAmmonia)
Step 6 · 验收清单
text
① run-sync-farm.js:传感 track=iot 且 productId 有值;摄像 track=video
② 同栋传感与摄像 barnCode 一致;UNMAPPED 清零
③ probe-sensor.js:温度/湿度/氨气 ref 匹配成功并读出数值
④ start-fan.js:二次确认后风机动作;无 token 被拒绝
⑤ enable-farm-callback.js:callbackFlag 含 iot(及按需 alarm)
⑥ /dashboard 返回 readings + cameras;/live 返回可播地址
⑦ 故意对摄像机调 getProductModel → 应失败或跳过(分轨校验)
踩坑 H :先写库再 200 → 物模型事件停推。
踩坑 I :把动检 videoMotion 当氨气告警 ------ 白天料线抖动会刷爆群。
踩坑 J :bindDeviceLive 地址短时有效,勿当永久 URL 入库。
能力边界
text
□ 物模型:环境量、开关量、Service 控制;不出 HLS
□ 视频:预览/回放/动检;不读氨气
□ 一体化:栋号台账 + 告警关联摄像
□ 档案 Excel / 兽药批号:另一条数据链,别塞进物模型 ref
性能与配额
text
□ getProductModel 按 productId 缓存;固件/物模型变更再失效
□ 环境读数:事件推送为主,轮询为辅(如 1~5 分钟)
□ 视频默认标清;告警复核再切高清
□ 列表同步 10~15 分钟;pageSize ≤ 50
安全与生产
text
□ 远程启停必须 RBAC + 二次确认 + 审计
□ 场区账号按养殖单元隔离,加盟场互不可见
□ appSecret / CONTROL_CONFIRM_TOKEN 仅 BFF
□ 弱网栏舍:直播失败时降级为最近云录像(另接回放接口)
和档案系统怎么拼
text
智慧养殖最小闭环(本文)
├─ 物模型:环境与控制
├─ 视频:复核与留证
└─ 档案/日报:生产数据(Excel/ERP)------ 用 barnCode + 日期对齐,勿混协议
本文结论
智慧养殖一体化的最小正确姿势:
text
listDeviceDetailsByPage 统一台账(barnCode)
→ productId?iot 轨 : video 轨
→ 物模型读环境 / 控设备 + 视频回传复核
→ setMessageCallback(iot,alarm) 进同一值班流
只有传感没有画面 ,值班不敢远程拍板;只有监控没有物模型 ,氨气超了还靠人闻;两套后台两套 SN,三分钟对不齐镜头。
注册与下一步
如果你正在做养殖 SaaS / 场区数字化,先在 乐橙开放平台 open.imou.com 创建应用,把环控 IoT 设备与乐橙摄像机绑进同一开发者资产池;按本文用 productId 分轨,跑通「氨气告警 → 同栋一键回看」,再叠加档案与投喂业务。
乐橙开放平台以视频技术与安全 为核心,开放 OpenAPI、IoT 物模型、轻应用、OpenSDK 等低代码开发组件,一站式帮助第三方厂商与个人开发者快速、低成本落地视频与物联网场景应用------环境数据与画面并进同一台账,智慧养殖才算真正「可值班」。
今晚先让 3 号栋的告警页能点开直播,比再买一套独立环控大屏更有用。