1. 为什么要在 ChirpStack 里折腾 JS 编解码函数
LoRaWAN 应用里有一个绕不开的环节:终端设备通过无线把数据发到网关,网关再转给网络服务器,网络服务器最终把数据以某种格式推给应用服务器。问题就出在这个“某种格式”上——设备端为了省电、省带宽,几乎不可能直接发 JSON 这种“人话”,它发出来的往往是一串紧凑的二进制字节流。ChirpStack 作为一套开源的 LoRaWAN 网络服务器组件,默认把上行数据以 Base64 编码的原始字节形式交给应用层,应用层拿到这串东西如果不去解码,基本等于看天书。
这就是 JS 编解码函数存在的意义。ChirpStack 允许你在设备配置文件(Device Profile)里挂一段 JavaScript 代码,当上行数据到达时自动执行这段代码,把原始字节翻译成结构化的 JSON 对象;反过来,下行数据也可以用它把 JSON 编码成设备能听懂的字节流。这套机制的好处是解码逻辑跟着设备走,不用在应用服务器里再写一遍,改起来也方便,改完即时生效,不用重启整个服务。
我接触这套东西是因为一个环境监测类的项目,现场部署了几十台自研的低功耗节点,每台上报的传感器数据字段和字节排布都不一样。一开始图省事,把所有解码逻辑都堆在应用侧,结果设备一多、固件一升级,应用侧的代码就跟着乱成一锅粥。后来把解码下沉到 ChirpStack 的 JS 函数里,按设备配置文件隔离,世界一下子清净了。这篇笔记就把我踩过的坑和总结出来的写法完整梳理一遍,适合刚上手 ChirpStack、对 JS 编解码还比较模糊的开发者参考,也适合已经用过但想搞清楚底层细节的人。
需要先明确一点:ChirpStack 的 JS 编解码函数运行在一个受限的 JavaScript 引擎里,它不是完整的 Node.js 环境,没有require、没有网络请求、没有文件系统,你能用的主要是语言本身和一些内置对象。理解这个边界,后面写代码时才不会写出“本地能跑、线上报错”的东西。
2. 编解码函数在 ChirpStack 里的位置与执行时机
2.1 上行解码与下行编码的分工
ChirpStack 的编解码函数其实是一对:Decode和Encode。名字很直白,但很多人第一次配置时会搞混它们各自处理什么数据。
Decode函数负责上行。当设备发来一帧数据,ChirpStack 会把原始字节以某种形式传给你的Decode函数,你负责把它翻译成 JSON。这个 JSON 会作为object字段附加到上行事件里,应用服务器订阅 MQTT 或调用 API 时就能直接拿到结构化数据。
Encode函数负责下行。当你要给设备下发指令,比如改采集周期、开关某个继电器,你在应用侧构造一个 JSON,ChirpStack 调用Encode把它转成字节数组,再通过 LoRaWAN 下行链路发出去。
这两个函数是成对出现的,但并不是必须都写。只做数据采集、不需要下发的场景,Encode可以留空或者只写个占位。不过我的习惯是即使暂时用不到下行,也把Encode的骨架搭好,免得后面要加功能时忘了数据格式约定。
2.2 函数签名与参数结构
不同版本的 ChirpStack 在函数签名上有细微差别,这是最容易让人困惑的地方。以较常见的 3.x 和 4.x 为例,Decode的典型签名是这样的:
function Decode(fPort, bytes, variables) { // fPort: 上行使用的端口号 // bytes: 原始字节数组 // variables: 设备配置文件里定义的变量,可选 return { /* 解码后的对象 */ }; }fPort是 LoRaWAN 的应用端口,取值范围 1 到 223。很多设备会用不同的端口区分不同的数据帧类型,比如端口 1 传传感器数据、端口 2 传心跳、端口 3 传告警。所以fPort在解码里非常关键,不能忽略。
bytes是核心,它是一个字节数组。注意,不同版本里它的具体类型可能略有差异,有的是普通数组,有的是带索引访问能力的对象,但共同点是你可以用下标访问每个字节,比如bytes[0]、bytes[1]。每个元素的取值范围是 0 到 255。
variables是设备配置文件里可以预定义的键值对,用来放一些设备相关的常量,比如校准系数、安装位置编号。这个参数在早期版本里可能不存在,如果你的环境里没有,忽略即可。
Encode的签名类似:
function Encode(fPort, obj, variables) { // obj: 要编码的 JSON 对象 return [ /* 字节数组 */ ]; }返回值必须是一个字节数组,ChirpStack 会把它作为下行载荷发出去。
2.3 执行时机与调试入口
理解执行时机对排查问题很重要。Decode是在 ChirpStack 收到上行帧、完成 MIC 校验和帧解析之后触发的,也就是说只有合法帧才会进入你的函数。如果设备发的帧本身没通过校验,你的Decode根本不会被调用,这时候别去怀疑 JS 代码,先查设备和网关。
调试方面,ChirpStack 的 Web 界面里有一个“设备事件”或者“Live LoRaWAN frames”的视图,能看到每一帧的原始数据和Decode执行后的结果。如果Decode抛异常,事件里会带上错误信息。我一般调试新设备的解码逻辑时,会先让设备发一帧,在事件里把bytes的实际内容抄下来,然后在本地用 Node.js 写个临时脚本反复试,确认无误再贴回 ChirpStack。这样比在 Web 界面里改一次试一次高效得多。
提示:ChirpStack 的 JS 引擎对语法错误比较敏感,一个多余的分号或者中文字符都可能导致整个函数加载失败。贴代码前建议先在本地用
node -c之类的工具做一次语法检查。
3. 字节操作的核心技巧与常见数据类型的解码
3.1 字节序问题:大端与小端
LoRaWAN 设备固件在把多字节数值塞进字节流时,字节序(Endianness)是最容易出错的地方。所谓大端,就是高位字节在前;小端就是低位字节在前。比如一个 16 位整数0x1234,大端排布是[0x12, 0x34],小端排布是[0x34, 0x12]。
很多传感器厂商的固件默认用大端,但也有一些用小端,甚至同一个设备里不同字段的字节序还不一样。这个没有统一标准,只能看设备文档,文档没写就实测。我的做法是拿一个已知数值去发,看字节流怎么排,反推字节序。
在 JS 里手动拼装多字节数值,大端的写法是:
var value = (bytes[0] << 8) | bytes[1]; // 大端 16 位小端则是:
var value = (bytes[1] << 8) | bytes[0]; // 小端 16 位32 位整数同理,只是要多移几次:
var value = (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];这里有个坑:JS 的位运算默认按 32 位有符号整数处理,当最高位是 1 时,结果会变成负数。比如0xFFFFFFFF经过位运算会得到-1。如果你的数据是无符号的,需要手动修正:
var value = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0;>>> 0这个操作会把结果转成无符号 32 位整数,是个很实用的小技巧。
3.2 有符号数的处理
温度、加速度这类物理量经常是有符号的。有符号数的解码要先判断最高位是不是 1,是的话说明是负数,需要做补码转换。以 16 位有符号数为例:
var raw = (bytes[0] << 8) | bytes[1]; if (raw & 0x8000) { raw = raw - 0x10000; }这段逻辑的意思是:如果第 15 位(最高位)是 1,说明这是个负数,用raw - 0x10000把它转回正确的负值。比如0xFFFF代表 -1,0x8000代表 -32768。
8 位有符号数类似:
var raw = bytes[0]; if (raw & 0x80) { raw = raw - 0x100; }我见过不少新手直接拿bytes[0]当温度用,结果零下的时候读出来是两百多,就是因为没做符号处理。
3.3 浮点数的解码
浮点数比整数麻烦,因为 IEEE 754 格式的位排布不是简单拼接。有些设备为了省事,会把浮点数乘以一个系数变成整数再发,比如温度乘以 100 发成 16 位整数,接收端再除以 100。这种方式简单可靠,我强烈推荐在自研设备上采用。
但如果设备确实发了 IEEE 754 单精度浮点(4 字节),就得手动还原。ChirpStack 的 JS 环境里不一定有DataView或者Float32Array,所以稳妥的做法是手动按位拼装:
function decodeFloat32(bytes, offset) { var bits = (bytes[offset] << 24) | (bytes[offset+1] << 16) | (bytes[offset+2] << 8) | bytes[offset+3]; var sign = (bits >>> 31) === 0 ? 1 : -1; var exponent = (bits >>> 23) & 0xFF; var mantissa = bits & 0x7FFFFF; if (exponent === 0) { return sign * Math.pow(2, -126) * (mantissa / Math.pow(2, 23)); } if (exponent === 0xFF) { return mantissa ? NaN : sign * Infinity; } return sign * Math.pow(2, exponent - 127) * (1 + mantissa / Math.pow(2, 23)); }这段代码看起来吓人,但逻辑就是按 IEEE 754 的定义把符号位、指数位、尾数位拆出来再组合。实际项目里如果设备发浮点,我会优先建议改固件发定点整数,实在改不了才用这段。
3.4 位域与标志位的提取
很多设备会把多个布尔状态或者小范围数值压缩到一个字节的不同位里,这叫位域。比如一个字节的 bit0 表示开关状态、bit1 到 bit3 表示档位、bit4 到 bit7 表示某种计数。解码时用位运算提取:
var flags = bytes[0]; var switchOn = flags & 0x01; // 取 bit0 var level = (flags >> 1) & 0x07; // 取 bit1-3 var counter = (flags >> 4) & 0x0F; // 取 bit4-7这里& 0x07是掩码,作用是只保留低 3 位,把其他位清零。掩码的构造方法是:要取 n 位,掩码就是(1 << n) - 1。比如取 3 位就是(1 << 3) - 1 = 7 = 0x07。
位域解码最容易犯的错是移位方向搞反,或者掩码位数算错。我的经验是拿一张纸把字节的 8 个位画出来,标上每个位代表什么,然后对着写代码,比凭空想要靠谱得多。
4. 从零写一个完整的解码函数
4.1 需求梳理与字节布局约定
假设我们有一台虚构的环境监测节点,它通过端口 1 上报数据,字节布局约定如下:
| 字节偏移 | 长度 | 含义 | 类型 | 说明 |
|---|---|---|---|---|
| 0 | 1 | 帧类型 | 无符号 | 固定 0x01 |
| 1 | 2 | 温度 | 有符号大端 | 单位 0.01 摄氏度 |
| 3 | 2 | 湿度 | 无符号大端 | 单位 0.01 % |
| 5 | 4 | 气压 | 无符号大端 | 单位 1 Pa |
| 9 | 1 | 电池电压 | 无符号 | 单位 0.1 V |
| 10 | 1 | 状态标志 | 位域 | bit0 告警,bit1-3 档位 |
这个布局是典型的“混合类型”场景,涵盖了有符号、无符号、多字节、位域几种情况,很适合拿来练手。
4.2 逐字段解码的实现
先写骨架,把fPort判断和帧类型校验加上:
function Decode(fPort, bytes, variables) { if (fPort !== 1) { return { error: "unsupported port", port: fPort }; } if (bytes.length < 11) { return { error: "payload too short", length: bytes.length }; } if (bytes[0] !== 0x01) { return { error: "unknown frame type", type: bytes[0] }; } // 后续字段解码 }这里我特意加了长度校验和帧类型校验。实际项目里设备可能因为固件 bug 发出截断的帧,或者发来没约定的帧类型,如果不校验,后面的下标访问会越界,Decode抛异常,整个上行事件就废了。加上校验后至少能返回一个有意义的错误对象,方便排查。
温度是有符号大端 16 位:
var tempRaw = (bytes[1] << 8) | bytes[2]; if (tempRaw & 0x8000) { tempRaw = tempRaw - 0x10000; } var temperature = tempRaw / 100;湿度是无符号大端 16 位:
var humidity = ((bytes[3] << 8) | bytes[4]) / 100;气压是无符号大端 32 位,注意用>>> 0修正符号:
var pressure = (((bytes[5] << 24) | (bytes[6] << 16) | (bytes[7] << 8) | bytes[8]) >>> 0);电池电压是单字节,单位 0.1V:
var battery = bytes[9] / 10;状态标志是位域:
var flags = bytes[10]; var alarm = (flags & 0x01) === 1; var level = (flags >> 1) & 0x07;最后组装返回对象:
return { frameType: "sensor", temperature: temperature, humidity: humidity, pressure: pressure, battery: battery, alarm: alarm, level: level };4.3 返回值设计的一些讲究
返回对象的字段命名我建议用清晰的英文小写加下划线或者驼峰,别用拼音也别用缩写。因为下游应用服务器、数据库、可视化面板都要消费这个对象,字段名清晰能省掉大量沟通成本。
数值的单位最好在字段名里体现,比如temperature默认是摄氏度,如果设备发的是华氏度,字段名就该叫temperature_f。我见过一个项目因为单位没约定清楚,前端显示的温度比实际高了三十多度,查了半天才发现是解码时把华氏当摄氏了。
另外,返回对象里可以带一些元信息,比如frameType、rssi、snr。不过rssi和snr其实 ChirpStack 在事件里已经提供了,不一定非要在Decode里返回,看你的下游怎么消费方便。
注意:
Decode的返回值必须是可序列化为 JSON 的对象。不要返回函数、undefined或者循环引用的结构,否则 ChirpStack 序列化时会出问题。
5. 下行编码函数的写法与对称性维护
5.1 编码函数的基本结构
Encode的职责是把应用侧构造的 JSON 转成字节数组。以刚才那台设备为例,假设我们要下发一个配置指令,包含采集周期和告警阈值:
function Encode(fPort, obj, variables) { if (fPort !== 2) { return []; } var bytes = []; bytes.push(0x02); // 配置帧类型 var period = obj.period || 60; bytes.push((period >> 8) & 0xFF); bytes.push(period & 0xFF); var threshold = obj.threshold || 0; bytes.push((threshold >> 8) & 0xFF); bytes.push(threshold & 0xFF); return bytes; }这里period是 16 位无符号,大端拆成两个字节。(period >> 8) & 0xFF取高字节,period & 0xFF取低字节。这个拆字节的写法是编码里最常用的,务必记牢。
5.2 编解码的对称性
编解码函数最好成对维护,字段顺序、字节序、单位约定都要一致。我习惯在代码顶部用注释把字节布局写清楚,这样过几个月回来看还能快速回忆起来:
// 上行帧(端口1): // [0] 帧类型 0x01 // [1-2] 温度 有符号大端 0.01℃ // ... // 下行帧(端口2): // [0] 帧类型 0x02 // [1-2] 采集周期 无符号大端 秒 // [3-4] 告警阈值 无符号大端 0.01℃这种注释看起来啰嗦,但真到了多人协作或者自己隔很久再维护的时候,价值巨大。
5.3 下行参数的校验
Encode里同样要做校验。应用侧传下来的 JSON 不一定符合预期,可能缺字段、可能类型不对、可能数值超范围。如果不校验,编码出来的字节流设备收到后可能行为异常,甚至死机。
if (typeof obj.period !== "number" || obj.period < 10 || obj.period > 3600) { return []; }返回空数组表示不下发,ChirpStack 会跳过这次下行。这比发一个错误的指令要安全得多。
6. 常见问题排查与避坑经验
6.1 解码结果不对的排查顺序
遇到解码结果和预期不符,我一般按这个顺序排查:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 字节流本身 | 看事件里的原始数据 | 设备发错、网关丢包 |
| 字节序 | 用已知值反推 | 大端小端搞反 |
| 符号处理 | 看负值场景 | 忘了补码转换 |
| 单位换算 | 对照设备文档 | 系数写错 |
| 位域掩码 | 手算验证 | 掩码位数错 |
| 下标偏移 | 数一遍 | 从 0 还是从 1 开始搞混 |
这个表我基本是背下来的,每次出问题照着走一遍,八九不离十。
6.2 JS 引擎的限制与坑
ChirpStack 的 JS 引擎不是完整的 Node.js,有几个限制要特别注意。第一是没有require,不能引入外部库,所有逻辑都得自己写。第二是没有console.log的输出通道,调试只能靠返回值。第三是某些 ES6+ 的语法可能不支持,比如箭头函数、模板字符串、let/const在旧版本里可能报错,稳妥起见用var和传统函数写法。
还有一个坑是数值精度。JS 的Number是双精度浮点,处理 32 位整数没问题,但如果设备发的是 64 位整数,直接拼装会丢精度。这种情况建议在设备端就拆成两个 32 位发,或者用字符串形式传。
6.3 性能与稳定性考量
Decode函数会在每一帧上行时执行,如果设备量大、上报频繁,函数的执行效率就有意义了。我的经验是避免在函数里做复杂的字符串操作和正则匹配,这些比位运算慢得多。能用位运算解决的别用字符串。
另外,函数里不要有依赖外部状态的逻辑,比如全局计数器。ChirpStack 可能在不同的上下文里执行你的函数,全局状态不保证可靠。所有需要的上下文都应该从参数里拿。
6.4 版本差异带来的兼容问题
ChirpStack 从 3.x 到 4.x 在编解码函数上有一些变化,比如参数个数、bytes的类型、返回值的处理方式。如果你维护的设备跨多个版本,建议在函数里做兼容处理,比如判断variables是否存在:
if (typeof variables === "undefined") { variables = {}; }这样无论哪个版本调用都不会因为参数缺失而报错。
7. 一些实战中总结的小技巧
第一个技巧是善用variables。设备配置文件里可以定义变量,比如设备的安装高度、校准偏移量。把这些放到variables里,解码函数就能通用化,不用为每台设备改代码。比如温度校准:
var offset = variables.tempOffset || 0; var temperature = tempRaw / 100 + offset;第二个技巧是返回对象里加一个raw字段,把原始字节的十六进制字符串带上:
var rawHex = ""; for (var i = 0; i < bytes.length; i++) { rawHex += ("0" + bytes[i].toString(16)).slice(-2); }这样下游排查问题时能直接看到原始数据,不用再去翻 ChirpStack 的事件。这个字段在调试期特别有用,上线稳定后可以去掉以节省带宽。
第三个技巧是给解码函数写单元测试。虽然 ChirpStack 里没法跑测试,但可以在本地 Node.js 里把函数复制出来,构造各种边界输入跑一遍。我一般会测这几种情况:正常帧、截断帧、全零帧、全 FF 帧、负温度帧。这几种覆盖了大部分边界。
第四个技巧是版本管理。编解码函数虽然短,但改错了影响面很大。我习惯在函数顶部写一个版本号和修改日期,配合 Git 管理。ChirpStack 本身不提供函数的版本历史,只能靠自己。
第五个技巧是关于错误返回的。Decode出错时返回一个带error字段的对象,比直接抛异常好。抛异常会导致整个上行事件处理失败,而返回错误对象至少能让下游知道这帧有问题,同时保留其他字段。当然,如果错误严重到无法解析,返回错误对象也是合理的。
8. 从解码函数延伸到数据管道的思考
编解码函数看似只是一个小环节,但它其实是整个 LoRaWAN 数据管道的入口。它的输出质量直接决定了后面存储、分析、展示的质量。我见过太多项目因为解码函数写得随意,导致后面数据清洗的工作量巨大。
一个设计良好的解码函数应该做到:字段命名规范、单位明确、错误可追溯、边界有处理、注释清晰。这几点做到了,下游无论是接时序数据库、接消息队列还是接可视化平台,都会顺畅很多。
另外,随着设备固件迭代,字节布局可能会变。这时候解码函数要能兼容新旧格式。我的做法是在帧里留一个版本字节,解码时根据版本走不同的分支。这样老设备继续用老逻辑,新设备用新逻辑,平滑过渡。
var version = bytes[0]; if (version === 0x01) { return decodeV1(bytes); } else if (version === 0x02) { return decodeV2(bytes); }把不同版本的解码逻辑拆成独立函数,主函数只做分发,这样代码清晰,维护起来也方便。
这套东西说到底就是“把设备的语言翻译成人能看懂的语言”,翻译得准不准、快不快、稳不稳,全看你对字节布局的理解和对 JS 边界的把握。多写几台设备,多踩几个坑,自然就熟了。