汇率说明(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。结果是:

这才是"保留存量有线资产,只换最后一跳"能落地的技术前提——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、半小时就能写完。但它决定的是整条链路的最终形态:

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 上跑。脚本是资产,不是某一种部署形态的附属品。


小结

下一篇计划讲触发模型与告警规则:数据解出来之后,怎么让它在越界的那一刻找到正确的人。


参考: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)计算。