1. 为什么要在 ChirpStack 里写 JS 编解码函数
LoRaWAN 设备接入 ChirpStack 之后,很多刚上手的朋友会卡在同一个地方:网关连上了、设备也激活了、上行数据在 Live LoRaWAN Frames 里能看到,但 Application Server 里收到的 payload 永远是一串看不懂的十六进制。原因很简单,设备上报的是二进制字节流,而业务系统需要的是带物理意义的 JSON。中间这层翻译工作,就是 ChirpStack 里的Codec(编解码器)要干的事。
ChirpStack 从 2.x 版本开始,在 Device Profile 里内置了 JavaScript 编解码引擎,用的是ES5.1 语法的解释器(底层是 goja 之类的 JS 运行时),不是完整的 Node.js 环境。这一点非常关键,后面踩的坑基本都跟这个有关。你不需要额外部署一个服务去做转换,直接在设备配置文件里贴一段decode函数,上行数据就会自动变成结构化 JSON;再贴一段encode函数,下行指令也能从 JSON 反向打包成字节。
这套机制解决的核心问题是:把协议解析逻辑从后端服务里剥离出来,下沉到网络服务器层。以前的做法是应用服务器收到 base64 原始数据,自己写解析代码,设备一多、型号一杂,维护成本爆炸。现在每个 Device Profile 对应一套 Codec,设备接入即解析,应用侧拿到的就是干净的字段。
适合谁来参考这篇内容:已经跑通 ChirpStack 基础接入、手里有真实 LoRaWAN 节点、需要把原始 payload 转成业务字段的开发者;以及正在做设备对接、被各种私有协议折腾得够呛的物联网工程师。如果你还没搭好 ChirpStack 环境,建议先把网关和 NS 跑通再回来看编解码部分。
需要提前说明的是,ChirpStack 在 3.x 到 4.x 之间对 Codec 的存放位置做过调整,2.7 这个版本节点上,Codec 是挂在Device Profile上的,不是挂在单个 Device 上。这个设计逻辑是:同一型号的设备协议一致,共用一套解析规则,改一处全生效。理解这一点,后面配置的时候就不会到处找错地方。
2. ChirpStack 2.7 编解码机制的整体设计思路
2.1 Codec 挂在 Device Profile 而非 Device 上的原因
很多人第一次配置时会下意识地去 Device 详情页找编解码入口,结果翻遍整个页面也没找到。这不是你操作有问题,而是设计如此。ChirpStack 把 Codec 定义在 Device Profile 层级,背后的考量是协议与设备实例解耦。
打个比方,同一批出厂的温湿度传感器,烧录的固件一样、上报格式一样,它们共享同一个 Device Profile。如果 Codec 挂在每个 Device 上,你有 500 个设备就要维护 500 份一模一样的解析代码,改一个字段要改 500 次,这显然不合理。挂在 Profile 上,一份代码管一批设备,符合 DRY 原则。
实际操作路径是:Device Profiles → 选择对应 Profile → Codec 标签页。在这里你会看到两个编辑框,一个是 Uplink 的decode,一个是 Downlink 的encode。2.7 版本里这两个函数是分开填写的,不是写在一个对象里返回,这点和某些其他平台不一样,别搞混。
2.2 decode 与 encode 的输入输出契约
编解码函数不是随便写个函数就行,ChirpStack 对入参和返回值有明确的约定,写错了不会报语法错误,但运行时会静默失败或者返回空对象,非常难排查。
decode 函数的约定是这样的:
function Decode(fPort, bytes) { // fPort: 上行使用的端口号,整数 // bytes: 字节数组,注意是数组不是 Buffer return { // 返回一个对象,会被序列化成 JSON }; }encode 函数的约定:
function Encode(fPort, obj) { // fPort: 下行端口号 // obj: 应用侧下发的 JSON 对象 return [ // 返回字节数组 ]; }这里有几个容易翻车的细节。第一,bytes是普通数组,不是 Node.js 的 Buffer,所以bytes.readUInt16BE()这类方法统统不存在,你得用位运算手动拼。第二,函数名大小写在 2.7 里是大小写敏感的,Decode和decode在某些小版本上表现不一致,稳妥起见按官方示例的Decode/Encode来写。第三,返回值必须是可 JSON 序列化的对象,返回undefined或者循环引用对象都会导致解析结果为空。
2.3 为什么用 ES5.1 而不是完整 Node 环境
这是被问得最多的一个问题。ChirpStack 选择嵌入式 JS 引擎而不是直接跑 Node,核心原因是资源占用和安全性。网络服务器要同时处理成千上万设备的编解码,如果每个 Codec 都起一个 Node 进程,内存直接爆掉。嵌入式引擎启动快、内存小、沙箱隔离好,单个 Codec 写死循环也不会拖垮整个服务。
代价就是你用不了 ES6+ 的语法。let、const、箭头函数、模板字符串、解构赋值、Array.prototype.includes、Object.assign这些统统不支持。我实测过,写个const x = 1直接报语法错误,Codec 保存后上行数据全部解析失败。所以老老实实用var,用function,用字符串拼接。
提示:在 Codec 编辑器里保存时不会做完整的语法校验,有些错误要等真实数据上来才暴露。建议先在本地用支持 ES5 校验的工具过一遍再贴进去。
3. 核心细节解析与实操要点
3.1 字节序与位运算的正确姿势
LoRaWAN 设备上报的多字节数值,字节序(大端/小端)完全由设备固件决定,没有统一标准。这是解析时第一个要确认的事情。同一个温度值,大端和小端解析出来能差出几百倍,而且不会报错,只会给你一个看似合理的错误数字。
假设设备上报两个字节[0x01, 0x2C],如果这是大端表示,值是1*256 + 44 = 300;如果是小端,值是44*256 + 1 = 11265。所以拿到一个新设备,第一件事是对照设备文档确认字节序,没有文档就用已知的物理量反推。
在 ES5 环境里拼字节,标准写法是这样:
function Decode(fPort, bytes) { // 大端:高字节在前 var valueBE = (bytes[0] << 8) | bytes[1]; // 小端:低字节在前 var valueLE = (bytes[1] << 8) | bytes[0]; return { be: valueBE, le: valueLE }; }注意<<和|的优先级,<<高于|,所以(bytes[0] << 8) | bytes[1]的括号其实可以省,但加上更清晰。三字节、四字节同理,四字节要注意 JS 的位运算默认按 32 位有符号整数处理,超过0x7FFFFFFF会变成负数,需要特殊处理。
3.2 有符号数的处理陷阱
温度、加速度这类物理量经常是有符号的。设备上报的原始字节是无符号的,你需要手动做补码转换。比如一个字节表示的温度,范围 -40 到 85 度,设备可能用0xD8表示 -40。
转换逻辑是:如果最高位是 1,说明是负数,值等于原值减去 256(单字节)或 65536(双字节)。
function toSigned8(v) { return v > 127 ? v - 256 : v; } function toSigned16(v) { return v > 32767 ? v - 65536 : v; }我见过不少人在这一步偷懒,直接用无符号解析,结果冬天温度显示成 200 多度,排查半天才发现是符号位没处理。这个坑非常典型,建议把这两个辅助函数直接固化到你的 Codec 模板里。
3.3 浮点数的定点表示还原
很多低功耗传感器为了省流量,不上报 IEEE 754 浮点数,而是用定点数:把真实值乘以一个系数后取整上报。比如温度精度 0.1 度,实际 23.5 度上报成 235。解析时除以系数还原即可。
var rawTemp = (bytes[0] << 8) | bytes[1]; var temp = toSigned16(rawTemp) / 10.0;系数是多少必须看设备文档,常见的有 10、100、1000。这里有个精度陷阱:JS 的浮点运算会有精度问题,235 / 10得到23.5没问题,但某些系数下会出现23.499999999这种结果。如果业务对精度敏感,建议用parseFloat((raw / 10).toFixed(1))做一次规整。
3.4 位域字段的拆分
有些设备为了极致压缩,一个字节里塞多个状态位。比如 bit0-bit1 表示工作模式,bit2 表示告警,bit3-bit7 保留。这种要用移位和掩码拆。
var status = bytes[0]; var mode = status & 0x03; // 取低2位 var alarm = (status >> 2) & 0x01; // 取第3位掩码的写法要小心,& 0x03是取低两位,>> 2是右移两位。顺序不能反,先移位再掩码和先掩码再移位结果不同。这类字段建议在 Codec 里加注释标明每个位的含义,不然过两个月自己都看不懂。
4. 完整实操流程与关键环节实现
4.1 从零配置一个温湿度传感器 Codec
假设手里有一个虚构的温湿度节点,协议约定如下:上行端口固定为 2,payload 共 4 字节,前两字节是温度(大端有符号,精度 0.1),后两字节是湿度(大端无符号,精度 0.1)。
第一步,进入 Device Profile 的 Codec 页面,在 Uplink 编辑框填入:
function toSigned16(v) { return v > 32767 ? v - 65536 : v; } function Decode(fPort, bytes) { if (fPort !== 2) { return { error: "unexpected port" }; } if (bytes.length < 4) { return { error: "payload too short" }; } var rawTemp = (bytes[0] << 8) | bytes[1]; var rawHumi = (bytes[2] << 8) | bytes[3]; var temp = toSigned16(rawTemp) / 10.0; var humi = rawHumi / 10.0; return { temperature: parseFloat(temp.toFixed(1)), humidity: parseFloat(humi.toFixed(1)) }; }第二步,保存后触发一次设备上报,在 Application Server 的 Events 里看up事件,object字段应该出现{"temperature": 23.5, "humidity": 60.2}这样的结构。
第三步,如果解析结果不对,先别急着改代码,用 Live LoRaWAN Frames 里的原始 hex 手动验算一遍。比如原始数据是00EB0258,0x00EB = 235,除以 10 得 23.5,对得上说明代码没问题,对不上就是字节序或者系数搞错了。
4.2 下行 encode 函数的实现
下行指令通常用于配置设备参数,比如设置上报周期。假设协议约定:端口 3,第一字节是命令字0x01表示设置周期,后两字节是周期秒数(大端)。
function Encode(fPort, obj) { if (fPort !== 3) { return []; } var period = obj.period || 60; return [ 0x01, (period >> 8) & 0xFF, period & 0xFF ]; }在 Application Server 里通过 Enqueue 下发{"period": 300},设备就会收到01 01 2C。这里要注意& 0xFF不能省,因为 JS 位运算可能产生超出单字节范围的值,不掩码会导致数组里出现大于 255 的数,序列化时行为不可预期。
4.3 多端口协议的分发处理
真实设备往往一个端口传传感器数据,另一个端口传心跳,还有一个端口传告警。Codec 里要根据fPort做分发。
function Decode(fPort, bytes) { switch (fPort) { case 1: return decodeHeartbeat(bytes); case 2: return decodeSensor(bytes); case 4: return decodeAlarm(bytes); default: return { raw: bytes }; } }把每个端口的解析逻辑拆成独立函数,主函数只做分发,这样代码可读性和可维护性都好很多。我见过把所有逻辑堆在一个函数里、几百行 if-else 的 Codec,改一次错一次,强烈不建议。
4.4 参数计算过程实录
拿一个真实场景举例:设备上报加速度,三轴各两字节,大端有符号,单位是 mg(毫重力加速度),精度 1mg。原始数据FF 9C 00 64 01 2C。
第一轴:0xFF9C,最高位是 1,是负数,0xFF9C = 65436,减 65536 得-100,即 -100mg。第二轴:0x0064 = 100,即 100mg。第三轴:0x012C = 300,即 300mg。
如果业务需要换算成 g,除以 1000 即可。这个换算过程建议写在 Codec 里直接输出物理单位,别让应用侧再算一遍,减少出错环节。
5. 常见问题与排查技巧实录
5.1 上行解析结果为空的排查顺序
这是最高频的问题。按下面顺序排查,基本能覆盖 90% 的情况。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| object 字段为空 | 函数名写错 | 确认是Decode不是decode |
| object 字段为空 | 返回值不是对象 | 检查是否 return 了 undefined |
| 字段值全为 0 | bytes 索引越界 | 打印 bytes.length 确认长度 |
| 字段值明显错误 | 字节序搞反 | 大小端互换验证 |
| 负数变成大正数 | 符号位未处理 | 加补码转换函数 |
| 保存后无变化 | 未重新激活设备 | 部分版本需重新 join |
5.2 ES5 语法限制导致的静默失败
前面强调过 ES5.1 的限制,这里给一个具体的踩坑记录。有次我图省事写了const factor = 0.1;,保存时编辑器没报错,设备上报后 object 一直是空。查了半天日志才发现是语法错误导致整个 Codec 加载失败。改成var factor = 0.1;立刻正常。
类似的还有:用bytes.includes(0)判断包含,ES5 没有includes,要用indexOf(0) !== -1;用Object.assign合并对象,ES5 没有,要手动遍历赋值;用模板字符串`temp: ${t}`,要改成"temp: " + t。
注意:Codec 编辑器不会实时提示这些语法问题,建议在本地用 ES5 校验工具(比如把代码贴到支持 ES5 的在线校验器)先过一遍。
5.3 浮点精度与 JSON 序列化的坑
JS 的JSON.stringify对浮点数处理有时会出乎意料。比如0.1 + 0.2得到0.30000000000000004,直接序列化出去业务侧看到这个值会一脸懵。解决办法是在 Codec 里做规整,用toFixed控制小数位后再parseFloat转回数字。
另一个坑是NaN和Infinity,这两个值JSON.stringify后会变成null,业务侧拿到 null 可能触发空指针。所以除法运算前一定要判断除数不为 0,解析异常时返回明确的错误字段而不是 NaN。
5.4 调试手段与日志查看
ChirpStack 2.7 里 Codec 的报错不会直接显示在界面上,需要去 Application Server 的 Events 里看。上行事件里如果object为空但data有值,基本就是 Codec 执行出错。更详细的错误要去 Network Server 或 Application Server 的服务日志里翻,通常会有 JS 引擎抛出的异常堆栈。
我的习惯是先在 Codec 里加一个debug字段,把原始 bytes 和中间变量都返回出去,确认无误后再删掉。这样比盲猜高效得多。
return { debug_raw: bytes, debug_len: bytes.length, temperature: temp };5.5 独家避坑经验汇总
第一条,永远先确认字节序再写解析逻辑,别凭感觉。第二条,所有除法都加除数判断,避免 NaN。第三条,辅助函数写在 Codec 顶部,别指望外部库。第四条,改完 Codec 一定要用真实数据回归测试,别只看保存成功。第五条,保留一份能跑通的旧版本,改崩了能快速回滚。
还有个小技巧:如果设备型号多、协议杂,可以建一个自己的 Codec 代码片段库,把常用的字节序转换、补码转换、定点还原函数存起来,新设备直接复制粘贴,能省大量时间。这套东西用熟了之后,接一个新传感器从看文档到解析跑通,半小时以内能搞定。
最后分享一个我个人的习惯:每个 Codec 开头都写一段注释,标明设备型号、协议版本、字段定义和修改记录。这东西平时看着多余,等半年后设备固件升级、协议变了要改代码时,你会感谢当初写注释的自己。