汇率说明(2026-10):本文金额均按 1 美元 ≈ 6.71 元人民币、1 欧元 ≈ 7.88 元人民币换算。
上一期讲了怎么用 REST API 把 500 台设备在 1 个下午内批量上线。设备上线之后,紧接着就是第二个问题:数据上来了,但没人看得懂。
控制台里躺着这样一条上行:
IQcDDG4PAADWBIsC34IG
业务系统要的是:温度 23.8 ℃、湿度 65.1 %、电池 3.40 V、状态正常、上报周期 15 分钟。这两者之间隔着的那一层,就是本篇要讲的物模型解码脚本。
引子:一条上行里到底有什么
先把上面那串 Base64 拆开看。它来自 ThinkLink 收到的 LoRaWAN AS 标准报文里的 msg.userdata.payload 字段,20 个 Base64 字符,对应 15 个字节:
21 07 03 0C 6E 0F 00 00 D6 04 8B 02 DF 82 06
按 KS52 温湿度传感器的私有帧协议,逐段对应如下:
| 字节位置 | 原始值 | 含义 | 解析方式 | 结果 |
|---|---|---|---|---|
| 0-2 | 21 07 03 | 帧头固定标记 | 直接比对 | 合法帧 |
| 3-4 | 0C 6E | 本帧序号/保留 | 本模型不解析 | — |
| 5-6 | 0F 00 | 上报周期 | uint16 小端读 → 15(单位分钟) | 15 分钟 |
| 7 | 00 | 状态位 | bit0 = 故障,bit4 = 水浸 | 正常、无水浸 |
| 8-9 | D6 04 | 温度 | uint16 小端 → 1238,(1238−1000)/10 | 23.8 ℃ |
| 10-11 | 8B 02 | 湿度 | uint16 小端 → 651,×0.1 | 65.1 % |
| 12 | DF | 电池电压 | uint8 → 223,(223×1.6)/254+2.0 | 3.40 V |
| 13-14 | 82 06 | 保留/校验 | 本模型不解析 | — |
15 个字节,承载了 5 个业务量。这就是 LoRaWAN 终端普遍使用紧凑二进制而不是 JSON 的原因——不是工程师喜欢晦涩,是空口资源不允许。
一、为什么不能让设备直接发 JSON
这是每个初次接触 LoRaWAN 的人都会问的问题:既然应用层最终要的是 JSON,为什么不让设备端直接发 {"temperature":23.8,"humidity":65.1}?
答案是空中时间(airtime)。按 SX1262 射频、BW125 kHz、CR 4/5 计算:
| 载荷 | SF7 空中时间 | 相对 15 字节 |
|---|---|---|
| 15 字节(紧凑二进制) | 46.3 ms | 1.0× |
| 120 字节(带字段名与时间戳的 JSON) | 199.9 ms | 4.3× |
再把扩频因子这个变量加进来,差距会放大到难以忽视的程度——同样是 15 字节载荷:
| 扩频因子 | 空中时间 | 相对 SF7 |
|---|---|---|
| SF7 | 46.3 ms | 1.0× |
| SF10 | 329.7 ms | 7.1× |
| SF12 | 1155.1 ms | 24.9× |
按 15 分钟上报一次(每天 96 次)折算,单台设备每天处在高功率发送状态的时间从 4.4 秒(SF7)变成 111 秒(SF12)。这就是上一期电池寿命那篇里"链路预算决定电池寿命"的底层机制:多发的每一个字节、掉下来的每一级 SF,都要用电池还。
还有一个硬约束。中国 CN470 频段在 RP002-1.0.5 区域参数下,最低速率 DR0 的最大 MAC 载荷就是 15 字节。也就是说,上例这种帧长不是巧合,是被最差链路条件下的法规上限倒逼出来的设计。
所以 LoRaWAN 的分工是固定的:设备端负责把数据压到极限,平台端负责把它还原成人能读的东西。解码脚本就是还原这一层的全部工作量。
二、ThinkLink 把解码放在哪里
ThinkLink 里承担解码职责的模块叫物模型(Thing Model)。它的定位很清晰:把来自 LoRaWAN 网络服务器(NS)或其他协议接入的原始数据,解析成标准化的遥测数据(telemetry_data)和共享属性(shared_attrs),供表格、图表、仪表盘、告警和协议映射使用。
一个物模型包含四类配置,缺一不可:
| 配置项 | 作用 | 常见遗漏 |
|---|---|---|
| 解析脚本 | 把二进制帧解成具名字段 | 无 |
| 展示字段 | 定义前端表格/卡片显示哪些字段 | 写完脚本就以为完事了 |
| Modbus 映射 | 对外以 Modbus TCP 提供数据 | 工业对接时才发现没配 |
| BACnet 字段 | 对外以 BACnet/IP 提供数据,可生成点表 | 楼宇交付时才发现没配 |
平台文档里有一句提醒值得单独拎出来:脚本存在并不代表映射已经完成。这是实际项目里最常见的返工点——脚本跑通了,BMS 集成商问你要点表,才发现 BACnet 字段一个都没配。
物模型创建后需要填名称和 ID Name 两个字段。名称用于显示,ID Name 是程序与接口调用使用的标识,两者独立,只填名称会留下隐患。
三、脚本在哪里跑:沙盒规则要先知道
解析脚本在 vm2 沙盒中执行,与 Node.js 主进程完全隔离。这套限制不是形式主义,是每一条都会直接决定你脚本能不能跑通。
| 限制项 | 值 | 后果 |
|---|---|---|
| 执行超时 | 1000 ms | 超时立即中断,跳过本次解析 |
console |
不可用 | 无法打印调试,必须用物模型日志 |
async / await |
不支持 | 只能同步写 |
require / import |
不支持 | 不能引第三方包 |
eval |
禁用 | 不能动态执行字符串 |
| WebAssembly | 禁用 | 不能加载 .wasm |
setTimeout / setInterval |
不可用 | 不能做定时或延时 |
process / fs / path |
不可用 | 调用即 ReferenceError |
Buffer 是受限版本(SafeBuffer),只开放 6 个静态方法:Buffer.alloc、Buffer.from、Buffer.isBuffer、Buffer.byteLength、Buffer.compare、Buffer.concat。Buffer.allocUnsafe() 被明确屏蔽。Buffer 实例的读写方法(readUInt8、readUInt16LE 等)正常可用。
这几条限制合起来,指向一个结论:解码脚本应该是一个纯函数式的、同步的、无副作用的字节搬运过程。任何想在解码阶段做外部调用、查数据库、等待响应的设计,都不在这个沙盒的能力范围内——那部分工作属于触发模型和 RPC 模型。
3.1 平台注入的变量
脚本不需要声明入参,以下变量由平台直接注入:
| 变量 | 说明 |
|---|---|
device |
设备对象,vm.freeze 冻结,只读 |
msg |
本次上行原始消息体 |
thingModelId |
当前物模型 ID |
noticeAttrs |
本次触发的属性变更标志 |
org_params |
组织级环境变量(租户级配置,如统一阈值) |
subDevices |
父设备的子设备缓存映射 |
env / plugins |
挂载插件的环境变量与实例(高级功能) |
Buffer |
受限 SafeBuffer |
BufferTKL / PayloadParser / MSparser / Utils / TriggerHelper / RPCHelper |
tklHelper 工具库全部类,作为全局变量直接使用,无需 import |
device 对象里与解码最相关的属性:
| 属性 | 用途 |
|---|---|
eui |
设备 EUI,全局唯一主键 |
device_type |
NORMAL / SUB_DEVICE / VIRTUAL |
data_from |
"LoRaWAN" 或 "OTHER"(MQTT 直入) |
online / active_time |
在线状态与最近上行时间 |
shared_attrs |
平台与设备双向同步的属性(周期、阈值等) |
server_attrs |
仅平台侧可见的属性(告警阈值、联动 EUI) |
mac_attrs |
LoRaWAN MAC 层属性(频段、ADR) |
telemetry_data |
各物模型最新遥测快照,键为 thingModelId |
注意:device 以 vm.freeze 冻结,脚本里对它赋值不会报错但也不会生效。要向平台写回数据,唯一通道是返回值。访问不存在的属性返回 undefined,建议全程用可选链(?.)防御。
3.2 从 msg 里取什么
LoRaWAN 上行时,平台收到的是标准 AS 报文,脚本里最常用的四个字段:
| 数据 | 取法 |
|---|---|
| 载荷(Base64) | msg.userdata.payload |
| 端口号 | msg.userdata.port |
| 信号强度 | msg.gwrx[0].rssi |
| 信噪比 | msg.gwrx[0].lsnr |
如果是 MQTT 直接接入的 JSON 数据,则按 JSON 层级直接取字段。
3.3 返回值:四个字段,写错就丢数据
脚本必须返回一个固定结构的对象:
| 字段 | 默认值 | 用途 |
|---|---|---|
telemetry_data |
null | 遥测数据,用于仪表盘、趋势图、历史查询 |
shared_attrs |
null | 共享属性,设备与平台双向同步(周期、地址、阈值) |
server_attrs |
null | 仅平台侧保留的元数据 |
sub_device |
null | 子设备标识,填 485 地址等,平台会自动生成子设备并分配 EUI |
四个字段都可以是 null,但为 null 意味着本次上报不产生该类数据。实践中踩得最多的坑是:参数回包解析出来了,却写进了 telemetry_data,导致参数被当成测量值画进曲线里。
sub_device 是 LoRaWAN 场景里最容易被低估的一个字段,后面第五节单独讲。
四、第一段脚本:手写 Buffer 版
先写一个不依赖任何工具库的完整版本,把 15 字节帧逐段拆出来。下面这段就是物模型编辑器里"解析脚本"页签要填的全部内容(裸函数体,平台会包进函数后调用):
let payload = Buffer.from(msg?.userdata?.payload, "base64"); let port = msg?.userdata?.port || null;
function parseTelemetry(payload) { // 端口必须是 11,帧头必须是 21 07 03,长度必须是 15 if (port != 11) { return null } if (payload[0] != 0x21 || payload[1] != 0x07 || payload[2] != 0x03) { return null } if (payload.length != 15) { return null }
let telemetry_data = {}; telemetry_data.period_data = payload.readUInt16LE(5); telemetry_data.status = (payload[7] & 0x01) != 0 ? "fault" : "normal"; telemetry_data.leakStatus = (payload[7] & 0x10) != 0 ? true : false; telemetry_data.temperature = Number(((payload.readUInt16LE(8) - 1000) / 10).toFixed(2)); telemetry_data.humidity = Number((payload.readUInt16LE(10) / 10).toFixed(2)); let vbat = payload.readUInt8(12); telemetry_data.vbat = Number(((vbat * 1.6) / 254 + 2.0).toFixed(2)); telemetry_data.rssi = msg.gwrx[0].rssi; telemetry_data.snr = msg.gwrx[0].lsnr; return telemetry_data; }
return { sub_device: null, telemetry_data: parseTelemetry(payload), server_attrs: null, shared_attrs: null };
把开头的那串 Base64 喂进去,输出是:
{ period_data: 15, status: "normal", leakStatus: false, temperature: 23.8, humidity: 65.1, vbat: 3.40, rssi: -92, snr: 13.2 }
4.1 这段代码里藏着的 6 个决定
每一行都不是随意写的,逐条说明背后的判断:
第一,三重校验缺一不可。 端口、帧头、长度,三个条件任何一个漏掉,脚本迟早会解析到别的帧上去。端口 11 是测量数据,端口 214 是参数回包,端口 51 是透传——同一个设备会在这三个端口上发完全不同的东西,不做端口判断等于把三种报文扔给同一套解析逻辑。
第二,小端(LE)和大端(BE)必须查手册确认。 上例温度是 readUInt16LE,如果写成 readUInt16BE,1238 会变成 56,594,温度就成了 5559.4 ℃。这类错误不会报错,只会安静地产生荒谬的数值,往往在客户验收现场才被发现。
第三,偏移量与系数是设备协议给的,不是猜的。 温度用 (raw − 1000) / 10 是设备侧为避免负数而设计的编码,把 0 ℃ 编码成 1000。电池电压 (vbat × 1.6) / 254 + 2.0 是把 8 位 ADC 值映射回 2.0–3.6 V 区间。这两个公式来自设备手册,不能凭"看起来差不多"推导。
第四,toFixed 要在 Number() 里包一层。 toFixed 返回字符串,直接返回会让前端把它当文本处理,排序和阈值判断全部失效。
第五,RSSI 和 SNR 顺手带出来。 它们不在 payload 里,在 msg.gwrx[0] 里。把链路质量写进遥测,后续排查"某台设备数据跳变"时可以直接看是不是信号问题,省掉一次现场。
第六,解析失败返回 null,而不是抛异常。 返回 null 表示本次不产生遥测,平台跳过;抛异常会写进错误日志并中断流程。对于偶发的坏帧、其他端口的帧,null 是正确语义。
五、第二段脚本:用 tklHelper 声明式重写
手写 Buffer 版本可读性好,但每个新机型都要重写一遍校验和位移。ThinkLink 提供的 tklHelper 工具库把这套逻辑抽象成三段声明式配置:frameInfo(帧合法性)、appInfo(变量定义)、paraInfo(参数定义)。
库版本当前同步至 1.01.043(2026-10-12)。在物模型脚本、RPC 脚本和触发模型脚本中,它的六个类(BufferTKL / PayloadParser / MSparser / Utils / TriggerHelper / RPCHelper)都作为沙盒全局变量直接可用,无需 import。
5.1 frameInfo:帧合法性校验
| 字段 | 默认值 | 说明 |
|---|---|---|
port |
-1 | 端口号,正数时不匹配即丢弃,-1 不校验 |
dataLen |
-1 | 预期帧长,正数时不一致即丢弃 |
status |
-1 | 状态字节偏移,≥0 时自动读 uint8 写入 tdata.status |
battery |
-1 | 电池字节偏移,≥0 时按 (v×1.6)/254+2.0 换算写入 tdata.battery |
rssi |
false | true 时从 msg.gwrx[0] 读 RSSI/SNR |
tagList |
[] | 帧头校验数组,每项 { index, tag },不匹配整帧丢弃 |
subDevice |
{ index: -1, type: "uint8" } | 子设备地址在帧中的位置与类型,读出 0 则丢弃 |
对应本例:
let frameInfo = { port: 11, dataLen: 15, rssi: true, tagList: [ { index: 0, tag: 0x21 }, { index: 1, tag: 0x07 }, { index: 2, tag: 0x03 } ] };
上一节手写的三重校验,这里变成 4 行声明。
5.2 appInfo:变量定义
每项支持这些参数:
| 参数 | 说明 |
|---|---|
name / field_name |
展示名 / 程序字段名(tdata 的键) |
unit |
单位 |
index |
起始字节偏移 |
type |
类型字符串,见下表 |
coefficient |
系数,数值直接乘;字符串则引用另一个字段的解析值做二次乘法 |
decimal |
保留小数位,默认 0 |
illegal |
合法性检查,如 ">0x80"、"=0xFF",命中则该字段静默丢弃 |
options |
值映射表,如 { 0: "normal", 1: "fault" } |
postProcess |
JS 表达式字符串,输入变量为 value |
支持的类型覆盖得很全,写解码时不必自己处理位操作:
| 类别 | 类型字符串 |
|---|---|
| 整数 | int8 / uint8、int16 / uint16(默认大端)、int16le / uint16le、int24 / uint24、int32 / uint32(含 le 变体)、int40–int64 系列 |
| 浮点 | floatbe / floatle(IEEE 754 单精度)、floatcdab(字节序 2-3-0-1,常见于部分 Modbus 设备)、intcdab |
| 编码数值 | bcdbe<N> / bcdle<N>(BCD 码)、hexbe<N> / hexle<N>(转十六进制字符串) |
| 位字段 | bitbe<S>-<E> / bitle<S>-<E>,直接按位区间提取 |
| 布尔 | bool<N>(返回 1/0)、boolean<N>(返回 true/false) |
| 字符串 | string<N>(N 字节 ASCII) |
类型字符串大小写不敏感。floatcdab 和 intcdab 这两个类型是给 Modbus 设备的字节序坑准备的——不少仪表把 32 位值存成 2-3-0-1 顺序,手写位移很容易错,声明一下就行。
bitle3-9 这种位字段类型解决的是另一个高频场景:一个状态字节里塞了 8 个标志位,用 bitle0-0 取故障位、bitle4-4 取水浸位,就不用写 & 0x01、& 0x10 这种需要数零的掩码了。
5.3 声明式完整版
把前面所有东西合起来,同一台 KS52 的解码可以这样写:
let frameInfo = { port: 11, dataLen: 15, rssi: true, tagList: [ { index: 0, tag: 0x21 }, { index: 1, tag: 0x07 }, { index: 2, tag: 0x03 } ] };
let appInfo = [ { name: "状态", field_name: "status", unit: "", index: 7, type: "bitle0-0" }, { name: "水浸", field_name: "leakStatus", unit: "", index: 7, type: "bitle4-4" }, { name: "温度", field_name: "temperature", unit: "℃", index: 8, type: "uint16le", postProcess: "value = (value - 1000) * 0.1; return Number(value.toFixed(1))" }, { name: "湿度", field_name: "humidity", unit: "%", index: 10, type: "uint16le", coefficient: 0.1, decimal: 1 }, { name: "电池", field_name: "vbat", unit: "V", index: 12, type: "uint8", postProcess: "return Number(((value * 1.6) / 254 + 2.0).toFixed(2))" } ];
let payParser = new PayloadParser({ device, msg, frameInfo, appInfo }); let tdata = payParser.telemetry(); return { sub_device: null, telemetry_data: tdata, server_attrs: null, shared_attrs: null };
5.4 什么时候该用哪种写法
两种写法不是替代关系:
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 帧结构固定、字段规整 | tklHelper 声明式 | 40 行以内,校验完备,改字段只改表格 |
| 帧结构不规则、字段位置动态 | 手写 Buffer | 声明式表达不了条件分支 |
| 一帧含多个子设备数据 | MSparser 子类 |
按 ID 自动累加偏移(见 5.5) |
| 需要跨字段二次计算 | 手写 Buffer 或 postProcess |
coefficient 支持引用字段,复杂逻辑仍需手写 |
PayloadParser 的 telemetry() 按固定顺序执行:校验 port → dataLen → tagList → subDevice,然后提取 status / battery / rssi 及各 appInfo 字段,依次应用 illegal → coefficient → decimal → postProcess → options,最后第二轮处理 coefficient 为字段名的条目。知道这个顺序,才能判断"为什么我的 options 没生效"(它在 decimal 之后、postProcess 跳过之前应用)。
5.5 MSparser:一帧多个测点
有些采集器一帧里塞多个测点,格式是重复的 [ID | 字段数据...] 块。MSparser 是 PayloadParser 的子类,构造时必须传 extraData:
new MSparser({ device, msg, frameInfo, extraData })
每读到一个 ID,它按 extraData 里键名 user_0x<ID>_<j>(j = 0..3,每个 ID 最多 4 个字段)找到对应 appInfo 项并自动累加偏移。frameInfo.idType 指定 ID 的读取类型,通常是 "uint16be"。
六、参数回包:端口 214 与共享属性
解码脚本不只处理测量数据。设备上报的配置参数(周期、阈值、校准偏移)走端口 214,回包也要解析——解析结果应该写进 shared_attrs,而不是 telemetry_data。
这是 KS52 的参数地址表(COV 为变化触发阈值,偏移为校准值):
| 参数 | 地址(十进制 / 十六进制) | 类型与单位 | 适用机型 |
|---|---|---|---|
| 温度 COV | 144 / 0x90 | uint8 × 0.1 ℃ | KS52、KS53 |
| 湿度 COV | 145 / 0x91 | uint8 × 0.1 %RH | 仅 KS52 |
| 温度偏移 | 146 / 0x92 | int8 × 0.1 ℃ | KS52、KS53 |
| 湿度偏移 | 147 / 0x93 | int8 × 0.1 %RH | 仅 KS52 |
| 采集周期 | 142 / 0x8E | uint16 秒 | KS52、KS53 |
用 tklHelper 时,参数用 paraInfo 声明,键名格式是 <段名>_<地址>,段名有 app / cf / fw / radio / ds / status 六种:
let paraInfo = { app_142: { name: "采集周期", field_name: "measurePeriod", unit: "s", type: "uint16le" }, app_144: { name: "温度COV", field_name: "covTemperature", unit: "℃", type: "uint8", coefficient: 0.1, decimal: 1 }, app_145: { name: "湿度COV", field_name: "covHumidity", unit: "%", type: "uint8", coefficient: 0.1, decimal: 1 }, app_146: { name: "温度偏移", field_name: "offsetTemperature", unit: "℃", type: "int8", coefficient: 0.1, decimal: 1 } };
let payParser = new PayloadParser({ device, msg, frameInfo, appInfo, paraInfo }); let tdata = null, pdata = null; if (port === 214) { pdata = payParser.paras(); } else { tdata = payParser.telemetry(); } return { sub_device: null, telemetry_data: tdata, server_attrs: null, shared_attrs: pdata };
paras() 只在端口为 214 时工作,否则直接返回 null,所以端口判断不能省。
6.1 偏移与 COV 不是一回事
这两个参数在实际项目里经常被混淆,导致的后果是"校准完数据反而更不准":
| 概念 | 作用 | 单位 |
|---|---|---|
| COV(Change of Value) | 决定何时上传:变化超过阈值才发 | 0.1 ℃ / 0.1 %RH |
| 偏移(Offset) | 校准测量值本身 | 0.1 ℃ / 0.1 %RH |
偏移是有符号 8 位,范围 −128 至 127,即可表示 −12.8 至 +12.7,步进 0.1。负值用补码编码,−1.0 ℃ 写入 F6。读回时字节 ≥128 要减 256 再乘 0.1——这一步在解码脚本里必须显式处理,否则所有负校准值都会变成正数。
还有一条平台侧的经验:偏移是设备侧参数,不要同时在平台侧做一次补偿,否则误差会加倍。
七、sub_device:一台 DTU 变出 N 台设备
sub_device 字段是 ThinkLink 在"存量资产改造"场景里最实用的一处设计。
典型链路是这样的:一台 KC11(DIN 导轨、RS-485、AC 85–270 V 供电)或 KC25 DTU 通过 LoRaWAN 上报,一帧里包含多个 RS-485 从机(电表、水表、温湿度变送器)的数据。DTU 本身是一个 LoRaWAN 设备,但它背后挂着 8 台、16 台真实表计。
这时解码脚本在返回值里填:
return { sub_device: "03", telemetry_data: tdata, server_attrs: null, shared_attrs: null };
sub_device 填的是子设备的通信地址(如 485 地址)。ThinkLink 会据此在设备管理系统中虚拟生成一个子设备,并自动分配唯一 EUI。结果是:
- 主设备(DTU)保留 LoRaWAN 身份与链路属性
- 每个 RS-485 从机成为独立可查询、可告警、可画曲线的对象
- 上层系统不需要知道数据是从哪台 DTU 打包上来的
这才是"保留存量有线资产,只换最后一跳"能落地的技术前提——LoRaWAN 只解决无线这一段,设备资产管理仍然按物理表计逐台展开。
frameInfo.subDevice 配置(index ≥ 0 时按 type 读取地址写入 tdata.addr,读出 0 则丢弃整帧)与这一字段配合使用,可以从帧里自动取出从机地址。
八、解码之后:从字段到业务
脚本跑通只是第一步。一个能交付的物模型还要完成三件事。
8.1 展示字段
在物模型的"展示字段"里声明哪些字段进前端,配置包括:
| 配置项 | 说明 |
|---|---|
| 字段名 | 必须与 telemetry_data 中的键完全一致,大小写敏感 |
| 别名 | 前端显示名 |
| 单位 | ℃、%、V、m³ 等 |
| 类型 | number / string / boolean / object,数值类选 number 才能格式化 |
| 序号 | 表格或卡片中的显示顺序 |
| 图标 | 可选,支持内嵌 SVG |
字段名不一致是最常见的"脚本有数据但页面空白"的原因。
8.2 告警
告警由触发模型承担,而不是写在物模型里。推荐做法是在同一个触发脚本里同时处理告警的产生与消除:把 action 默认设为 "clear",只在条件满足时切换为 "new"。这样每次上行到达时,条件恢复正常的告警会自动消除,不需要人工干预。
tklHelper 提供 TriggerHelper 基类,继承后只需重写 _alarmProcess(info),不用手写完整的触发返回对象。Utils.LEVEL 定义四级告警(low / mid / high / urgent),Utils.ACTION 定义 no / new / clear 三种动作。
8.3 BACnet / Modbus 映射
这是楼宇和工业项目交付时的临门一脚。
BACnet 侧:在物模型里配置 BACnet 字段,字段名必须与物模型定义一致,配置包含对象类型(Analog Input、Binary Output 等)、单位、COV 增量、默认值、关联 RPC。配置完成后,进入运维管理 → BACnet,点击"增量生成",选择目标物模型,系统自动生成点表,包含对象类型、实例号、对象名称、属性描述、数据类型、单位、读写权限——可以直接交付给 BMS 集成商。调试阶段可用 YABE(Yet Another BACnet Explorer)验证数据是否正常发布。
Modbus 侧:物模型的 Modbus 映射把 telemetry_data 的字段映射到寄存器地址,让上位机以 Modbus TCP 直接读取。反向的控制指令由 RPC 模型处理,RPCHelper 提供 buildmodbusFrame06(FC06 写单寄存器)、buildmodbusFrame10(FC10 写多寄存器)、buildmodbusFrameRead(FC03/FC04 读)等帧构造方法,并内置 CRC-16/Modbus 计算。
一句话总结这一节:解码脚本决定数据能不能读出来,展示字段、告警、协议映射决定它能不能被业务用起来。
九、没有 console,怎么调试
沙盒里没有 console.log,这是初写脚本时最不适应的一点。可用的调试手段有四个:
第一,物模型编辑器的"测试"页签。 填入样例 Base64 与端口,直接看解析结果。常规测试要求设备真实存在,否则返回 "Device not found"。
第二,skipDeviceCheck 标志。 只想验证脚本逻辑、不想绑定真实设备时(比如配置 NFC 模板),测试操作支持 skipDeviceCheck = true。此时平台不再按 EUI 查找设备,而是注入一个合成占位设备(eui = "empty",归属当前租户),即使输入的 EUI 没有匹配设备也能完成测试。
第三,物模型日志。 运行期的实际输出和异常在这里查,替代 console 的作用。
第四,历史版本与还原。 每次保存物模型,平台都会记录一个版本快照。编辑器里点"历史版本"可查看详情并与相邻版本对比,也可还原——还原会生成一个新的版本记录,因此历史不会丢失。RPC 模型、物模型、触发模型与转发器脚本编辑器都有同样的功能。这意味着可以放心迭代,改坏了能退回去。
还有一个省事的办法:先查公开模型库。在市场插件 → 模型中心里,有"本组织模型"和"公开模型"两个页签。常见机型大概率已有现成物模型,直接复制过来改,比从零写快得多。
十、已有脚本怎么搬过来
如果团队此前在别的 LoRaWAN 平台上已经写过解析脚本,不必重写。ThinkLink 物模型提供 ChirpStack 兼容模式——已完成接口适配,把原来的解析代码复制进来即可运行。
这件事的价值在于迁移成本的量级。Eclipse 基金会与 PW Consulting 的调研显示,迁移成本通常占整个迁移预算的 35%–60%。解码脚本是 LoRaWAN 应用层资产里最琐碎、最难自动验证、也最容易在重写中引入新 bug 的部分。能原样搬过来的部分越多,不确定性就越小。
按工程师综合成本 350 元/小时估算,一个中等项目(10 类设备)从零编写并逐个联调解码脚本,通常需要 40–60 小时,即 1.4 万至 2.1 万元;复用现成脚本可以把这部分压到 1/3 左右。省下的不是"写代码的时间",是"现场验证的时间"——后者才是 LoRaWAN 项目里真正的成本大头。
十一、8 个常踩的坑
按现场出现频率排序:
1. 大小端搞反。 温度 1238 读成 56594,换算后 5559.4 ℃。不报错,只产生荒谬数值,往往到验收现场才暴露。查手册确认,不要靠"试出来哪个像"。
2. 忘了负数的补码。 有符号字段(int8 偏移、int16 温度)读回 ≥128(8 位)或 ≥32768(16 位)时必须减 256 / 65536。不做处理,所有负值都变正数。
3. toFixed 忘了包 Number()。 返回字符串,前端排序和阈值判断全部失灵。
4. 端口不判断。 端口 11(测量)、214(参数)、51(透传)三种报文结构完全不同,不做分支就是把三种报文喂给同一套逻辑。
5. 长度不校验。 固件升级后帧长变化,或收到其他型号设备的帧,不校验长度会读到越界位置。SafeBuffer 的越界读取返回 false 或抛错,但更糟的情况是读到合法范围内的错误位置。
6. 参数回包写进 telemetry_data。 上报周期会被当成测量值画进曲线,趋势图全乱。参数属于 shared_attrs,测量值属于 telemetry_data。
7. 展示字段名与脚本输出不一致。 脚本有数据、页面空白的头号原因。字段名大小写敏感。
8. 把脚本跑通当成活干完了。 展示字段、Modbus 映射、BACnet 字段是三个独立配置,脚本存在不代表映射完成。BACnet 点表要单独生成。
十二、把这一层想清楚
LoRaWAN 的技术分工是明确且不可颠倒的:终端把数据压到极限,网络把数据送到,平台把数据还原成业务。
解码脚本看起来是这三层里最不起眼的一层——15 个字节、40 行 JavaScript、半小时就能写完。但它决定的是整条链路的最终形态:
- 它决定电池能用多久(越紧凑的编码,越少的空中时间)——回看上一期那颗 2700 mAh 电池,SF 从 7 掉到 12,发送时间就是 24.9 倍
- 它决定数据能不能被业务系统消费(字段名、单位、类型在这里定型)
- 它决定交付时能不能拿出点表(BACnet / Modbus 映射挂在同一份字段定义上)
- 它决定存量资产能不能逐台管理(sub_device 让一台 DTU 背后 16 台表计各自成为独立对象)
ManThink 的整套产品线是围绕这条链路设计的:KS51/KS52/KS53/KS61 传感器产出紧凑帧,GD61x / GDI51 / GDI52 / GD73 / GDO51 网关负责回传,KC11 / KC25 / KC21 负责把 RS-485 上的存量表计接进来,ThinkLink 在平台侧完成解码、告警、协议映射与转发。
而 ThinkLink 的三种部署形态——Cloud(永久免费 1,000 台设备)、TKE 本地部署(1,000 台,数据处理与存储无上限)、TKG 网关内置(100 台,一台网关即构成完整系统)——共用同一套物模型、RPC 模型和触发规则,可以互相迁移。
这一点对解码脚本尤其重要:你在 Cloud 上写好的物模型,可以直接搬到 TKE 或 TKG 上跑。脚本是资产,不是某一种部署形态的附属品。
小结
- LoRaWAN 用紧凑二进制不是偏好,是必然:SF7 下 15 字节 46.3 ms,120 字节 JSON 199.9 ms(4.3×);CN470 DR0 的载荷上限就是 15 字节
- ThinkLink 的解码职责在物模型,脚本跑在 vm2 沙盒里:1000 ms 超时、无 console、无异步、无 require,唯一输出通道是返回值
- 返回值四个字段各有归属:测量值进
telemetry_data,配置参数进shared_attrs,子设备地址进sub_device - tklHelper 提供声明式写法:
frameInfo管校验、appInfo管字段、paraInfo管参数,覆盖 30 余种数据类型,含 Modbus 设备常见的 floatcdab 字节序 - 端口 214 的参数回包要单独解析;偏移与 COV 是两个概念,负偏移必须处理补码
sub_device让一台 DTU 背后的多台 RS-485 表计各自成为独立设备,是存量改造能落地的前提- 解码之后还要配展示字段、告警规则、BACnet/Modbus 映射,脚本跑通不等于交付完成
- 已有解析脚本可通过兼容模式直接复用,省下的主要是现场验证时间
下一篇计划讲触发模型与告警规则:数据解出来之后,怎么让它在越界的那一刻找到正确的人。
参考:ThinkLink 官方文档《物模型》https://www.think-link.net/docs/zh/Model/ThingModel.html 、《tklHelper》https://www.think-link.net/docs/zh/Model/tklHelper.html 、《RPC 模型》https://www.think-link.net/docs/zh/Model/RPCModel.html 、《触发模型》https://www.think-link.net/docs/zh/Model/TriggerModel.html 、KS52 温湿度传感器手册 https://www.think-link.net/docs/zh/Device/LoRaWANSensor/ks52.html ;空中时间按 Semtech LoRa Calculator(SX1262、BW125 kHz、CR 4/5)计算。