ChirpStack LoRaWAN 编解码:JS 函数实战与避坑指南 1. 为什么要在 ChirpStack 里写 JS 编解码函数如果你接触过 LoRaWAN 网络大概率听说过 ChirpStack 这个开源的网络服务器。设备通过网关把数据传上来ChirpStack 负责解包、鉴权、路由最后把应用层数据推给你的业务系统。但这里有个问题终端设备上报的 payload 通常是一串紧凑的二进制字节业务系统根本看不懂。比如一个温湿度传感器上报01 02 1A 3B你得知道第一个字节是通道号后面两个字节是温度最后一个是湿度还得做定点数转换。这些活儿如果全丢给业务系统做每个接入方都得重复实现一遍维护起来非常痛苦。ChirpStack 的解决思路是在网络服务器内部提供一个JavaScript 编解码函数的挂载点。设备配置里可以分别指定解码函数decode和编码函数encode前者负责把上行二进制转成 JSON后者负责把下行 JSON 转成二进制。这样一来业务系统拿到的就是结构化的数据下行指令也能用人类可读的 JSON 表达。这个机制从 ChirpStack v3 一直延续到 v4虽然 v4 在架构上做了不少调整但 JS 编解码的核心逻辑基本没变。这篇文章主要面向刚上手 ChirpStack、需要自己写编解码脚本的开发者。我会从实际项目出发把 decode/encode 函数的输入输出约定、常见数据类型的处理方式、调试手段、以及几个容易踩的坑讲清楚。文中涉及的设备类型和业务场景都做了脱敏处理但代码逻辑是通用的你可以直接套用到自己的项目里。注意ChirpStack v3 和 v4 在设备配置界面上有差异v4 把编解码函数放在了 Device Profile 里而不是单个 Device 上。如果你用的是 v4记得去 Device Profile 的 Codec 标签页配置否则单个设备上找不到入口。2. 编解码函数的输入输出约定与运行环境2.1 decode 函数的入参结构ChirpStack 调用 decode 函数时会传入一个对象里面包含几个关键字段。最常用的是bytes它是一个字节数组每个元素是 0 到 255 之间的整数。注意不是 Buffer也不是十六进制字符串就是普通的 JS 数组。很多从其他平台迁移过来的开发者习惯性地用Buffer.from(bytes)去转在 ChirpStack 的 JS 运行时里不一定能直接用因为它的沙箱环境可能没有完整的 Node.js API。除了bytes入参里还有fPort表示 LoRaWAN 的端口号。这个字段非常有用因为同一个设备可能在不同端口上发送不同格式的数据。比如 fPort1 是周期上报fPort2 是告警上报fPort3 是心跳。你可以在 decode 函数里根据 fPort 做分支处理避免把所有解析逻辑混在一起。还有一个recvTime字段是接收时间戳。这个字段在需要做时间补偿或者判断数据时效性时会用到但大多数场景下用不上。另外 v4 里可能会传入deviceInfo之类的上下文但不同版本有差异建议不要过度依赖核心逻辑还是围绕 bytes 和 fPort 来写。function decode(fPort, bytes) { // fPort 是端口号bytes 是字节数组 // 返回一个对象ChirpStack 会把它作为 decoded 数据推给应用 return { data: { // 你的解析结果 } }; }上面这个结构是 v3 的常见写法。v4 里 decode 函数的签名可能略有不同有的版本是decode(fPort, bytes, variables)多了一个 variables 参数用于传入设备级变量。但核心思路一致拿到字节解析返回对象。2.2 encode 函数的入参结构encode 函数处理的是下行。ChirpStack 会把应用层传来的 JSON 对象传给你你需要把它转成字节数组返回。入参通常是一个对象里面包含data字段也就是你在应用层构造的 JSON。返回值必须是一个字节数组每个元素 0 到 255。function encode(fPort, data) { // data 是应用层传来的 JSON 对象 // 返回字节数组 var bytes []; // 填充 bytes return bytes; }这里有个容易忽略的点encode 函数的返回值必须是纯数组不能是 Buffer也不能是 Uint8Array。ChirpStack 内部会遍历这个数组把每个元素当作一个字节写入 LoRaWAN 帧。如果你返回了 Buffer某些版本会报错某些版本会静默失败表现就是下行数据发不出去但日志里又看不到明显异常。2.3 JS 运行时的能力边界ChirpStack 的 JS 编解码函数运行在一个沙箱环境里不是完整的 Node.js。这意味着你不能require模块不能用fs、net这些内置库也不能发起网络请求。可用的就是 ECMAScript 的基础语法加上一些 Math、Date、JSON 之类的全局对象。这个限制其实是好事。编解码函数本来就应该是纯函数输入确定输出确定不依赖外部状态。如果你发现自己的编解码逻辑需要查数据库或者调接口那说明架构设计有问题应该把这类逻辑放到应用层去做而不是塞进编解码函数里。提示不同版本的 ChirpStack 底层用的 JS 引擎可能不同有的用 Otto有的用 Goja。这两个引擎对 ES6 的支持程度有差异。保险起见编解码函数里尽量用 ES5 语法避免箭头函数、let/const、模板字符串这些特性否则可能在某些版本上跑不起来。3. 常见传感器数据类型的解析套路3.1 整数与定点数的处理传感器数据里最常见的就是整数和定点数。整数好办按字节拼接就行。比如两个字节表示一个 16 位无符号整数高位在前var value (bytes[0] 8) | bytes[1];如果是小端序就反过来var value (bytes[1] 8) | bytes[0];定点数稍微麻烦一点。很多传感器为了省字节会把浮点数乘以一个系数后转成整数上报。比如温度精确到 0.1 度实际值 23.5 度会上报成 235。解析的时候除以 10 就行var temp ((bytes[0] 8) | bytes[1]) / 10.0;但这里有个坑如果传感器上报的是有符号数直接按无符号解析会得到错误的正值。比如 -5 度乘以 10 是 -50用 16 位补码表示是 0xFFCE。如果你按无符号解析得到的是 65486除以 10 就是 6548.6 度明显不对。这时候需要做符号扩展var raw (bytes[0] 8) | bytes[1]; if (raw 0x8000) { raw raw - 0x10000; } var temp raw / 10.0;这个符号扩展的逻辑我见过太多人写错。有的是忘了判断最高位有的是减错了值。记住16 位有符号数的范围是 -32768 到 32767如果最高位是 1说明是负数减去 65536 就得到正确的负值。3.2 位域与标志位的拆解有些设备为了极致压缩数据会把多个状态塞进一个字节的不同位里。比如一个字节的 bit0 表示开关状态bit1-bit3 表示档位bit4 表示告警bit5-bit7 保留。这种就需要用位运算来拆var status bytes[0]; var powerOn status 0x01; var gear (status 1) 0x07; var alarm (status 4) 0x01;位运算的优先级容易搞混建议每个表达式都加括号。另外移位操作在 JS 里是 32 位的对于超过 32 位的数据要特别小心。不过 LoRaWAN 的 payload 通常很短很少超过 32 位这个问题不常见。还有一种情况是多个字节拼成一个长整数比如 4 个字节表示一个计数器。这时候用乘法和加法比移位更安全因为移位在超过 31 位时会出问题var counter bytes[0] * 16777216 bytes[1] * 65536 bytes[2] * 256 bytes[3];这样写虽然啰嗦但不会有溢出问题。16777216 是 2 的 24 次方65536 是 2 的 16 次方256 是 2 的 8 次方。按权重展开逻辑清晰。3.3 字节序的判断与统一字节序是编解码里最容易出错的地方。大端序Big-Endian是高位在前小端序Little-Endian是低位在前。不同厂商的设备可能用不同的字节序甚至同一个设备的不同字段用不同的字节序。我的经验是拿到一个新设备先别急着写解析代码先找厂商要协议文档确认每个字段的字节序。如果文档没写就找几个已知数值的报文来反推。比如设备上报温度 25.0 度你抓到的报文里有两个字节是 0x00 0xFA那 0x00FA 就是 250说明是大端序。如果是 0xFA 0x00那就是小端序。在代码里我习惯把字节序转换封装成辅助函数这样主逻辑里就不用反复写移位function readUInt16BE(bytes, offset) { return (bytes[offset] 8) | bytes[offset 1]; } function readUInt16LE(bytes, offset) { return (bytes[offset 1] 8) | bytes[offset]; }这样主逻辑里直接调readUInt16BE(bytes, 0)就行可读性也好很多。如果设备协议里混合了大小端这种封装能帮你少犯很多错。4. 从零写一个完整的编解码脚本4.1 需求拆解与协议映射假设我们有一个虚构的环境监测设备协议定义如下上行 fPort1payload 共 6 个字节。第 1 字节是设备状态位bit0 表示电源bit1 表示传感器故障。第 2-3 字节是大端序有符号整数表示温度单位 0.1 度。第 4-5 字节是大端序无符号整数表示湿度单位 0.1%。第 6 字节是电池电压单位 0.1V无符号。下行 fPort2payload 共 2 个字节。第 1 字节是命令字0x01 表示重启0x02 表示校准。第 2 字节是参数重启命令时忽略校准命令时表示校准值。这个协议不算复杂但涵盖了状态位、有符号数、无符号数、多字节拼接、下行编码等常见场景。我们一步步来实现。4.2 decode 函数的完整实现先写 decode。按照协议我们需要从 bytes 里提取各个字段然后组装成 JSON 返回。function decode(fPort, bytes) { if (fPort ! 1) { return { data: { error: unsupported fPort } }; } if (bytes.length 6) { return { data: { error: payload too short } }; } var status bytes[0]; var powerOn (status 0x01) 1; var sensorFault ((status 1) 0x01) 1; var tempRaw (bytes[1] 8) | bytes[2]; if (tempRaw 0x8000) { tempRaw tempRaw - 0x10000; } var temperature tempRaw / 10.0; var humidityRaw (bytes[3] 8) | bytes[4]; var humidity humidityRaw / 10.0; var battery bytes[5] / 10.0; return { data: { powerOn: powerOn, sensorFault: sensorFault, temperature: temperature, humidity: humidity, battery: battery } }; }这段代码有几个细节值得说。第一我加了 fPort 和长度校验这是防御性编程。实际项目中设备可能因为固件 bug 发错端口或者 payload 被截断如果不校验解析出来的就是垃圾数据还会污染业务系统。第二状态位解析用了 1而不是直接赋值这样返回的是布尔值业务系统用起来更直观。第三温度做了符号扩展湿度没有因为湿度按协议是无符号的。4.3 encode 函数的完整实现下行编码相对简单但也要注意参数校验和字节序。function encode(fPort, data) { if (fPort ! 2) { return []; } var cmd data.command; var param data.param || 0; var bytes []; if (cmd reboot) { bytes.push(0x01); bytes.push(0x00); } else if (cmd calibrate) { bytes.push(0x02); bytes.push(param 0xFF); } else { return []; } return bytes; }这里我用了字符串命令而不是数字因为应用层传 JSON 的时候字符串更可读。如果应用层传的是数字把判断条件改成数字比较就行。param 0xFF是为了确保参数在 0 到 255 之间防止应用层传了超出范围的值导致字节数组里出现非法元素。注意encode 函数返回空数组时ChirpStack 会认为没有下行数据不会发送任何帧。如果你希望发送一个空帧至少返回一个字节比如[0x00]。这个区别在实际调试时很容易搞混明明 encode 被调用了但设备就是收不到下行最后发现是返回了空数组。4.4 在 ChirpStack 里配置与验证脚本写好后需要配置到 ChirpStack 里。v3 的话进入 Device 详情页找到 Codec 或 Payload Codec 区域把 decode 和 encode 函数分别粘贴进去。v4 的话进入 Device Profile找到 Codec 标签页选择 JavaScript 类型然后填入函数。配置完成后ChirpStack 通常会提供一个测试入口你可以手动输入十六进制 payload 来验证 decode 结果。这个功能非常实用建议每次修改脚本后都先在这里测一遍确认无误再让真实设备上报。如果测试时发现返回结果不对先检查 bytes 数组的每个元素是不是你预期的值。有时候你以为设备发的是01 02 1A 3B实际上因为网关或网络的问题收到的可能是01 02 1A少了一个字节。这种情况下 decode 函数里的长度校验就能帮你快速定位问题。5. 调试技巧与常见错误排查5.1 用日志输出定位问题ChirpStack 的 JS 沙箱里通常支持console.log输出会写到 ChirpStack 的日志里。这是最直接的调试手段。你可以在 decode 函数的关键位置打日志比如console.log(fPort:, fPort); console.log(bytes:, JSON.stringify(bytes));然后去 ChirpStack 的日志里看输出。不同版本的日志位置不一样v3 可能在/var/log/chirpstack-network-server/下v4 可能在 Docker 容器的 stdout 里。找到日志后搜索你打的标记就能看到。但要注意生产环境里不要留太多日志尤其是高频上报的设备日志量会很恐怖。调试完成后记得把日志删掉或者加个开关控制。5.2 常见错误对照表下面这张表列了我遇到过的高频问题你可以对照排查现象可能原因排查方法decode 返回 undefined函数没有 return 语句或者 return 的对象结构不对检查函数末尾是否有 return返回的是否是{ data: {...} }解析出的数值明显偏大或偏小字节序搞反了或者定点数系数用错了用已知数值的报文反推确认字节序和系数负数解析成正数没有做符号扩展检查是否判断了最高位并减去 0x10000下行发不出去encode 返回了空数组或非法元素确认返回的是非空纯数组每个元素在 0-255 之间脚本报语法错误用了沙箱不支持的 ES6 语法改成 ES5 语法避免箭头函数、let/const部分字段解析正确部分错误偏移量算错了逐字节打印对照协议文档核对每个字段的起始位置这张表里的每一行都是我实际踩过的坑。尤其是字节序和符号扩展这两个几乎每个新手都会中招。我的建议是拿到新设备后先别写完整脚本先写一个最简单的 decode只打印 bytes 数组确认收到的数据和你预期的一致然后再逐步加解析逻辑。这样出问题时容易定位是哪一步引入的。5.3 版本差异带来的坑ChirpStack v3 和 v4 在编解码函数的调用方式上有一些差异如果不注意从 v3 迁移到 v4 时会很痛苦。v3 里decode 函数的签名通常是function decode(fPort, bytes)返回{ data: {...} }。v4 里有的版本签名变成了function decode(fPort, bytes, variables)返回结构也可能变成直接返回对象不需要包一层data。这个差异没有统一的文档说明不同小版本之间都可能不一样。我的做法是先在目标版本的 ChirpStack 里建一个测试设备写一个最简单的 decode返回一个固定值然后看应用层收到的是什么结构。根据实际收到的结构来调整脚本而不是照搬网上的示例。网上很多示例是 v3 的直接拿到 v4 上用很可能不兼容。另外v4 把编解码函数从 Device 移到了 Device Profile这意味着同一个 Profile 下的所有设备共用一套编解码逻辑。如果你的设备型号相同但固件版本不同payload 格式有差异就需要建多个 Device Profile或者在一个脚本里根据 deviceInfo 做分支。后者更灵活但代码会复杂一些。6. 编解码脚本的工程化建议6.1 把脚本当代码管理很多人写编解码脚本就是直接在 ChirpStack 的文本框里敲敲完保存也不留备份。这种做法在设备少的时候没问题但设备一多或者需要多人协作就会乱套。我的建议是把脚本存成独立的.js文件用 Git 管理每次修改都有记录。ChirpStack 的文本框只作为部署目标不作为编辑环境。具体做法是在本地建一个目录每个设备型号一个文件比如device-a-decode.js、device-a-encode.js。修改后在本地测试测试通过再粘贴到 ChirpStack 里。如果 ChirpStack 支持通过 API 配置编解码函数那就更好了可以写个脚本自动部署避免手动粘贴出错。6.2 写单元测试JS 编解码函数是纯函数非常适合写单元测试。你可以在本地用 Node.js 跑测试构造各种边界情况的输入验证输出是否符合预期。比如function testDecode() { var result decode(1, [0x01, 0x00, 0xFA, 0x01, 0x2C, 0x0A]); console.assert(result.data.temperature 25.0, temperature should be 25.0); console.assert(result.data.humidity 30.0, humidity should be 30.0); console.assert(result.data.battery 1.0, battery should be 1.0); }这个测试用例里温度原始值是 0x00FA 即 250除以 10 是 25.0。湿度是 0x012C 即 300除以 10 是 30.0。电池是 0x0A 即 10除以 10 是 1.0。跑一遍测试如果断言都通过说明基本逻辑没问题。边界情况也要测payload 长度为 0、长度为 5不足 6、温度为负数、湿度为最大值 65535、电池为 0。这些情况在实际运行中都会遇到提前测过上线后就少出问题。6.3 版本控制与回滚编解码脚本一旦上线就影响着所有该型号设备的数据解析。如果改错了可能导致大量设备数据异常。所以每次修改都要有版本记录并且保留回滚能力。我的做法是在脚本文件头部加一个版本注释// version: 1.2.0 // date: 2024-01-15 // changes: 修复温度符号扩展问题然后在 ChirpStack 里配置时把版本号也记在设备文档里。如果新版本出问题能快速找到上一个版本的文件回滚。如果 ChirpStack 支持多版本共存或者灰度发布那就更好了可以先在一小部分设备上验证新脚本确认无误再全量推送。6.4 性能考量编解码函数会在每次上行和下行时被调用虽然单次执行很快但如果设备数量大、上报频率高累积的 CPU 开销也不容忽视。优化方向主要是减少不必要的计算和内存分配。比如不要在 decode 里频繁创建临时对象和数组。能用基本类型就用基本类型能复用变量就复用。字符串拼接也要小心JSON.stringify在调试时用用可以生产环境里如果日志级别不够高就不要调用。另外避免在编解码函数里做复杂的循环和递归。LoRaWAN 的 payload 通常很短解析逻辑应该是线性的、常数时间的。如果你发现自己的脚本里有嵌套循环大概率是设计有问题应该重新审视协议解析方式。7. 几个真实项目里的经验教训7.1 浮点数精度问题JS 的浮点数运算有精度问题0.1 0.2不等于0.3。在编解码里这个问题主要体现在定点数转换上。比如温度原始值 235除以 10 得到 23.5这个没问题。但如果原始值是 233除以 10 得到 23.299999999999997直接返回给业务系统显示出来就很难看。解决办法是在返回前做一次四舍五入var temperature Math.round(tempRaw / 10.0 * 10) / 10;这样 23.299999999999997 会变成 23.3。虽然多了一次运算但避免了业务系统显示异常。湿度、电压这些定点数字段也建议同样处理。7.2 设备固件升级导致的协议变更我遇到过一个情况同一型号的设备固件从 1.0 升到 2.0 后payload 格式变了多了一个字节的状态字段导致后面所有字段的偏移量都变了。但设备型号没变Device Profile 也没变结果就是升级后的设备数据全部解析错误。这个问题的根源在于编解码脚本是绑定在 Device Profile 上的而 Device Profile 是按设备型号划分的不是按固件版本。如果厂商在固件升级时改了协议但没有新增型号就会出问题。应对策略有两个一是在 decode 函数里根据 payload 长度做分支长度是 6 走旧协议长度是 7 走新协议。二是推动厂商在固件升级时新增型号或者至少通知你协议变更以便及时更新脚本。前者更现实但代码会复杂一些后者更规范但依赖厂商的配合。7.3 下行编码的幂等性encode 函数应该是幂等的同样的输入永远产生同样的输出。但有些开发者会在 encode 里引入随机数或时间戳比如给每个下行包加一个序列号。这在某些场景下有用但会让调试变得困难因为同样的 JSON 每次生成的字节数组不一样。如果确实需要序列号建议由应用层生成后通过 data 传进来而不是在 encode 里生成。这样 encode 保持纯函数特性应用层也能控制序列号的生成逻辑。编解码函数的职责应该尽量单一只做格式转换不做业务逻辑。7.4 测试数据的积累每次调试新设备时我都会把原始的十六进制 payload 和解析后的 JSON 保存下来形成一个测试数据集。这个数据集在后续修改脚本时非常有用可以快速回归测试确保修改没有破坏原有逻辑。数据集可以是一个简单的 JSON 文件[ { fPort: 1, bytes: [1, 0, 250, 1, 44, 10], expected: { powerOn: true, sensorFault: false, temperature: 25.0, humidity: 30.0, battery: 1.0 } } ]然后写一个脚本遍历这个数据集对每条数据调用 decode比对实际输出和 expected。这样每次改完脚本跑一遍测试就能知道有没有引入回归问题。数据集越大覆盖的场景越多信心就越足。8. 从编解码延伸出去的几个实用思路8.1 用编解码函数做数据过滤decode 函数除了做格式转换还可以做简单的数据过滤。比如设备偶尔会上报异常值温度 200 度、湿度 150%这些明显超出物理范围的数据可以在 decode 里直接标记为无效而不是推给业务系统。if (temperature -40 || temperature 125) { return { data: { error: temperature out of range, raw: tempRaw } }; }这样业务系统收到的就是明确的错误标记而不是一个看起来正常但实际错误的值。过滤规则要根据具体传感器的量程来定不要拍脑袋。8.2 多端口协议的统一处理如果一个设备在多个端口上发送不同格式的数据decode 函数里可以用 switch 来分发function decode(fPort, bytes) { switch (fPort) { case 1: return decodePeriodic(bytes); case 2: return decodeAlarm(bytes); case 3: return decodeHeartbeat(bytes); default: return { data: { error: unknown fPort } }; } }这样每个端口的解析逻辑独立成函数主函数只做分发可读性和可维护性都好很多。每个子函数可以单独测试修改一个端口的逻辑不会影响其他端口。8.3 编解码脚本的复用不同设备型号的编解码脚本往往有很多相似之处比如都有温度、湿度、电池这些字段。可以把公共的解析逻辑抽出来做成辅助函数库然后在各个设备的脚本里复制粘贴。虽然 ChirpStack 不支持模块导入但你可以把辅助函数直接写在每个脚本的开头或者维护一个模板文件生成新脚本时从模板复制。我自己的做法是维护一个helpers.js里面放常用的字节读取、符号扩展、定点数转换函数。每次写新脚本时把 helpers 的内容复制到脚本开头然后写具体的解析逻辑。虽然有点笨但在 ChirpStack 的限制下这是最稳妥的方式。8.4 监控编解码失败率编解码函数出错时ChirpStack 通常会记录错误日志但不会自动告警。如果你的设备数量多建议在应用层做一个监控统计每个设备的上行数据里有多少是解析失败的。如果某个设备的失败率突然升高可能是设备固件出了问题或者编解码脚本有 bug。实现方式很简单在 decode 返回的对象里加一个_codecError字段应用层收到后统计这个字段的出现频率。或者更直接一点在 decode 里捕获异常返回一个固定的错误结构应用层根据这个结构做告警。function decode(fPort, bytes) { try { // 解析逻辑 return { data: { ... } }; } catch (e) { return { data: { _codecError: e.message, _raw: bytes } }; } }这样即使解析逻辑抛异常也不会导致整个上行处理失败应用层还能拿到原始数据做进一步分析。这个技巧在实际项目中非常实用尤其是设备刚上线、协议还不稳定的时候。8.5 编解码脚本的文档化最后一点也是容易被忽视的一点给编解码脚本写文档。不要觉得脚本里有注释就够了注释是给写代码的人看的文档是给使用数据的人看的。业务系统的开发者需要知道每个字段的含义、单位、取值范围这些信息应该整理成文档和脚本一起维护。文档可以很简单一个 Markdown 表格就行字段类型单位说明powerOnboolean-电源状态true 为开temperaturenumber摄氏度范围 -40 到 125humiditynumber百分比范围 0 到 100batterynumber伏特范围 0 到 5这样业务系统对接时就不用反复问你了。文档和脚本放在同一个目录下用 Git 一起管理修改脚本时同步更新文档。时间长了这就是团队的知识资产。我在实际项目里最大的体会是编解码脚本虽然看起来简单但它是设备和业务系统之间的桥梁一旦出错影响面很大。花时间把测试、版本管理、文档这些工程化的事情做好远比写一个能跑的脚本重要。尤其是设备数量上去之后一个小的解析错误可能导致成千上万条数据异常排查起来非常痛苦。提前做好防御比事后救火划算得多。