ChirpStack v4 Decoder — v1.2.0
Same canonical source as the TTS v1.2.0 decoder — ES5.1, cross-platform.
// ############################################################
// _ .-') _ ('-. .-') _ ('-. _ .-')
// ( ( OO) ) _( OO) ( OO) ) _( OO) ( '.( OO )_
// \ .'_ (,------. ,(_)----. (,------. ,--. ,--.)
// ,`'--..._) | .---' | | | .---' | `.' |
// | | \ ' | | '--. / | | | |
// | | ' | (| '--. (_/ / (| '--. | |'.'| |
// | | / : | .--' / /___ | .--' | | | |
// | '--' / | `---. | | | `---. | | | |
// `-------' `------' `--------' `------' `--' `--'
// deZem GmbH - Harvy2 LoRaWAN Uplink Decoder
// ============================================================
// Platform : The Things Stack (TTS) / Chirpstack - ES5.1 compatible
// ------------------------------------------------------------
// Version : v1.2.0 (2026-04-22)
// Decoder : port 10 (measurement payload)
// port 30 (energy counter, kWh)
// port 31 (charge counter, Ah)
// port 42 (button press count)
// port 99 (reboot info)
// ------------------------------------------------------------
// Changelog: [v1.1.0...v1.2.0]
// - remove port 6 support (legacy payload for firmware < v1.0.0)
// - add port 42 support (button press count) for firmware >= v1.1.0
// - add port 99 support (hardware version and variant decoding) for firmware >= v1.1.0
// - add current limit validation for port 10 (1000A normal, 10000A Rogowski mode)
// - refactor: ES5.1 compatible (TTS, ChirpStack v3 and v4)
// ############################################################
// ============================================================
// Byte decode helpers (Little Endian, direct index access)
// ============================================================
function to_signed(val, bits) {
if ((val & (1 << (bits - 1))) > 0) {
var mask = Math.pow(2, bits) - 1;
val = (~val & mask) + 1;
val = val * -1;
}
return val;
}
function dec_u8(bytes, idx) {
return bytes[idx];
}
function dec_u16(bytes, idx) {
return (bytes[idx + 1] << 8) | bytes[idx];
}
function dec_u32(bytes, idx) {
return (bytes[idx + 3] << 24) | (bytes[idx + 2] << 16) | (bytes[idx + 1] << 8) | bytes[idx];
}
function dec_i8(bytes, idx) {
return to_signed(bytes[idx], 8);
}
function dec_i16(bytes, idx) {
return to_signed((bytes[idx + 1] << 8) | bytes[idx], 16);
}
function dec_i32(bytes, idx) {
return to_signed(dec_u32(bytes, idx), 32);
}
function dec_f16(bytes, idx) {
var h = (bytes[idx + 1] << 8) | bytes[idx];
var s = (h & 0x8000) >> 15;
var e = (h & 0x7c00) >> 10;
var f = h & 0x03ff;
if (e === 0) {
return (s ? -1 : 1) * 0.00006103515625 * (f / 1024.0);
}
if (e === 0x1f) {
return f ? NaN : (s ? -1 : 1) * Infinity;
}
return (s ? -1 : 1) * Math.pow(2, e - 15) * (1 + f / 1024.0);
}
function dec_f32(bytes, idx) {
var buf = new Uint8Array([bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3]]);
return new DataView(buf.buffer).getFloat32(0, true);
}
function dec_f64(bytes, idx) {
var buf = new Uint8Array([
bytes[idx],
bytes[idx + 1],
bytes[idx + 2],
bytes[idx + 3],
bytes[idx + 4],
bytes[idx + 5],
bytes[idx + 6],
bytes[idx + 7]
]);
return new DataView(buf.buffer).getFloat64(0, true);
}
// ============================================================
// Type system — each type bundles its byte size and decoder
// ============================================================
// prettier-ignore
var TYPE_U8 = { size: 1, decode: function(b, o) { return dec_u8(b, o); } };
// prettier-ignore
var TYPE_U16 = { size: 2, decode: function(b, o) { return dec_u16(b, o); } };
// prettier-ignore
var TYPE_U32 = { size: 4, decode: function(b, o) { return dec_u32(b, o); } };
// prettier-ignore
var TYPE_I8 = { size: 1, decode: function(b, o) { return dec_i8(b, o); } };
// prettier-ignore
var TYPE_I16 = { size: 2, decode: function(b, o) { return dec_i16(b, o); } };
// prettier-ignore
var TYPE_I32 = { size: 4, decode: function(b, o) { return dec_i32(b, o); } };
// prettier-ignore
var TYPE_F16 = { size: 2, decode: function(b, o) { return dec_f16(b, o); } };
// prettier-ignore
var TYPE_F32 = { size: 4, decode: function(b, o) { return dec_f32(b, o); } };
// prettier-ignore
var TYPE_F64 = { size: 8, decode: function(b, o) { return dec_f64(b, o); } };
// ============================================================
// Shared helpers
// ============================================================
function num_to_fixed(x, digits) {
return Number(x.toFixed(digits));
}
function merge(target, source) {
for (var k in source) {
if (source.hasOwnProperty(k)) {
target[k] = source[k];
}
}
return target;
}
function applyFixedPrecision(res, channels) {
for (var i = 0; i < channels.length; i++) {
var name = channels[i][0];
var fixed = channels[i][5];
if (res[name] !== undefined) {
res[name] = num_to_fixed(res[name], fixed);
}
}
return res;
}
function sumIfDefined(channels, keys, sumKey) {
var sum = 0;
var hasAny = false;
for (var i = 0; i < keys.length; i++) {
if (channels[keys[i]] !== undefined) {
sum += channels[keys[i]];
hasAny = true;
}
}
if (hasAny) {
channels[sumKey] = sum;
}
}
function formatCounterElapsed(seconds) {
var days = Math.floor(seconds / (24 * 3600));
var hours = Math.floor((seconds % (24 * 3600)) / 3600);
var minutes = Math.floor((seconds % 3600) / 60);
var secs = seconds % 60;
return "[D:" + days + "|H:" + hours + "|M:" + minutes + "|S:" + secs + "]";
}
// ============================================================
// Generic channel parsing
// ============================================================
function parseBitFlags(bytes, offset, count, bitNames) {
var flags = {};
for (var i = 0; i < count; i++) {
for (var n = 0; n < 8; n++) {
var idx = i * 8 + n;
if (bitNames[idx]) {
flags[bitNames[idx]] = (bytes[offset + i] & (1 << n)) !== 0;
}
}
}
return flags;
}
// Channel tuple: [name, mask, type, scale, offset, fixed]
// 0 1 2 3 4 5
function parseChannels(bytes, offset, flags, channels) {
var result = {};
var idx = offset;
for (var i = 0; i < channels.length; i++) {
var ch = channels[i];
if (flags[ch[1]]) {
result[ch[0]] = ch[3] * ch[2].decode(bytes, idx) + ch[4];
idx += ch[2].size;
}
}
return result;
}
// ============================================================
// Channel definition helpers
// ============================================================
// ============================================================
// Port 10 — channel definitions + decoder
// ============================================================
function makeBits_p10(prefix) {
return [prefix + "_ac_en", prefix + "_dc_en", prefix + "_freq_en", prefix + "_scaled_mode", prefix + "_voltage_mode", null, null, null];
}
function makeChannels_p10(prefix) {
return [
[prefix + "_ac_raw_A", prefix + "_ac_en", TYPE_F16, 1.0, 0, 6],
[prefix + "_dc_raw_A", prefix + "_dc_en", TYPE_F16, 1.0, 0, 6],
[prefix + "_freq", prefix + "_freq_en", TYPE_U16, 0.01, 0, 2],
[prefix + "_coeff", prefix + "_scaled_mode", TYPE_F16, 1.0, 0, 3]
];
}
var BITSET_P10 = [].concat(
["usb_powered", "ch_vsys_en", "ch_temp_en", null, null, null, null, null],
["ct_plus_mode", "rogowski_mode", null, null, null, null, null, null],
makeBits_p10("in1"),
makeBits_p10("in2"),
makeBits_p10("in3"),
makeBits_p10("in4")
);
var CHAN_SYSTEM_P10 = [
["vsys_V", "ch_vsys_en", TYPE_U8, 0.0075, 1.8, 3],
["temp_C", "ch_temp_en", TYPE_U8, 0.4, -22.0, 1]
];
var CHAN_PLUS_P10 = [
["in1_pow_factor", "ct_plus_mode", TYPE_F16, 1.0, 0, 4],
["in2_pow_factor", "ct_plus_mode", TYPE_F16, 1.0, 0, 4],
["in3_pow_factor", "ct_plus_mode", TYPE_F16, 1.0, 0, 4]
];
var CHANNELS_P10 = [].concat(
CHAN_SYSTEM_P10,
makeChannels_p10("in1"),
makeChannels_p10("in2"),
makeChannels_p10("in3"),
makeChannels_p10("in4"),
CHAN_PLUS_P10
);
var CHAN_POST_P10 = [
["in1_ac_A", null, null, 0, 0, 3],
["in2_ac_A", null, null, 0, 0, 3],
["in3_ac_A", null, null, 0, 0, 3],
["in4_ac_A", null, null, 0, 0, 3],
["in1_voltage_VAC", null, null, 0, 0, 2],
["in2_voltage_VAC", null, null, 0, 0, 2],
["in3_voltage_VAC", null, null, 0, 0, 2],
["in4_voltage_VAC", null, null, 0, 0, 2],
["sum_in1234_ac_A", null, null, 0, 0, 3],
["sum_in1234_dc_A", null, null, 0, 0, 3]
];
var CHAN_POST2_P10 = [
["in1_pow_app_VA", null, null, 0, 0, 2],
["in2_pow_app_VA", null, null, 0, 0, 2],
["in3_pow_app_VA", null, null, 0, 0, 2],
["in1_pow_act_W", null, null, 0, 0, 2],
["in2_pow_act_W", null, null, 0, 0, 2],
["in3_pow_act_W", null, null, 0, 0, 2],
["in1_pow_react_VAR", null, null, 0, 0, 2],
["in2_pow_react_VAR", null, null, 0, 0, 2],
["in3_pow_react_VAR", null, null, 0, 0, 2],
["sum_in123_pow_app_VA", null, null, 0, 0, 2],
["sum_in123_pow_act_W", null, null, 0, 0, 2],
["sum_in123_pow_react_VAR", null, null, 0, 0, 2]
];
var CHAN_ALL_P10 = [].concat(CHANNELS_P10, CHAN_POST_P10, CHAN_POST2_P10);
function decodePort10(bytes) {
if (bytes.length < 10) {
throw new Error("Port 10 payload too short: expected at least 10 bytes, got " + bytes.length);
}
var flags = parseBitFlags(bytes, 0, 6, BITSET_P10);
var channels = parseChannels(bytes, 10, flags, CHANNELS_P10);
var ct_plus_mode = flags.ct_plus_mode;
var rogowski_mode = flags.rogowski_mode;
var max_ac_scaled_A = rogowski_mode ? 10000 : 1000;
for (var ix = 1; ix <= 4; ix++) {
if (!flags["in" + ix + "_scaled_mode"]) continue;
var voltage_mode = flags["in" + ix + "_voltage_mode"];
var coeff = (channels["in" + ix + "_coeff"] || 1.0) * (voltage_mode ? 1000.0 : 1.0);
var ac_raw_A = channels["in" + ix + "_ac_raw_A"];
if (ac_raw_A !== undefined) {
var ac_scaled = ac_raw_A * coeff;
if (!voltage_mode && Math.abs(ac_scaled) > max_ac_scaled_A) {
throw new Error(
"Port 10 current limit exceeded: max " + max_ac_scaled_A + "A in " + (rogowski_mode ? "rogowski" : "normal") + " mode"
);
}
channels[voltage_mode ? "in" + ix + "_voltage_VAC" : "in" + ix + "_ac_A"] = ac_scaled;
}
var dc_raw_A = channels["in" + ix + "_dc_raw_A"];
if (dc_raw_A !== undefined) {
channels[voltage_mode ? "in" + ix + "_voltage_VDC" : "in" + ix + "_dc_A"] = dc_raw_A * coeff;
}
}
sumIfDefined(channels, ["in1_ac_A", "in2_ac_A", "in3_ac_A", "in4_ac_A"], "sum_in1234_ac_A");
sumIfDefined(channels, ["in1_dc_A", "in2_dc_A", "in3_dc_A", "in4_dc_A"], "sum_in1234_dc_A");
if (ct_plus_mode) {
var in4_voltage_VAC = channels.in4_voltage_VAC;
for (var j = 1; j <= 3; j++) {
channels["in" + j + "_pow_app_VA"] = in4_voltage_VAC * channels["in" + j + "_ac_A"];
}
for (var k = 1; k <= 3; k++) {
var app = channels["in" + k + "_pow_app_VA"];
var factor = Math.max(-1, Math.min(1, channels["in" + k + "_pow_factor"]));
var act = app * factor;
channels["in" + k + "_pow_act_W"] = act;
channels["in" + k + "_pow_react_VAR"] = Math.sqrt(app * app - act * act);
}
channels["sum_in123_pow_app_VA"] = channels.in1_pow_app_VA + channels.in2_pow_app_VA + channels.in3_pow_app_VA;
channels["sum_in123_pow_act_W"] = channels.in1_pow_act_W + channels.in2_pow_act_W + channels.in3_pow_act_W;
channels["sum_in123_pow_react_VAR"] = channels.in1_pow_react_VAR + channels.in2_pow_react_VAR + channels.in3_pow_react_VAR;
}
applyFixedPrecision(channels, CHAN_ALL_P10);
return merge({ flags: flags }, channels);
}
// ============================================================
// Port 30 — channel definitions + decoder
// ============================================================
function makeChannels_p30(prefix) {
return [
[prefix + "_kWh", prefix + "_power_en", TYPE_I32, 0.01, 0, 2],
[prefix + "_cosphi", prefix + "_cosphi_en", TYPE_F16, 1.0, 0, 3]
];
}
var BITSET_P30 = [].concat(
["in1_power_en", "in1_cosphi_en", null, null, null, null, null, null],
["in2_power_en", "in2_cosphi_en", null, null, null, null, null, null],
["in3_power_en", "in3_cosphi_en", null, null, null, null, null, null]
);
var CHANNELS_P30 = [].concat(
[["in_ctr_count", "dummy", TYPE_U32, 1.0, 0, 0]],
makeChannels_p30("in1"),
makeChannels_p30("in2"),
makeChannels_p30("in3")
);
var CHAN_POST_P30 = [["sum_in123_kWh", null, null, 0, 0, 2]];
var CHAN_ALL_P30 = [].concat(CHANNELS_P30, CHAN_POST_P30);
function decodePort30(bytes) {
if (bytes.length < 7) {
throw new Error("Port 30 payload too short: expected at least 7 bytes, got " + bytes.length);
}
var flags = parseBitFlags(bytes, 0, 3, BITSET_P30);
flags.dummy = true;
var channels = parseChannels(bytes, 3, flags, CHANNELS_P30);
sumIfDefined(channels, ["in1_kWh", "in2_kWh", "in3_kWh", "in4_kWh"], "sum_in123_kWh");
channels.in_ctr_time_elapse = formatCounterElapsed(channels.in_ctr_count);
applyFixedPrecision(channels, CHAN_ALL_P30);
return merge({ flags: flags }, channels);
}
// ============================================================
// Port 31 — channel definitions + decoder
// ============================================================
function makeChannels_p31(prefix) {
return [[prefix + "_Ah", prefix + "_amp_h_en", TYPE_I32, 0.01, 0, 2]];
}
var BITSET_P31 = [].concat(
["in1_amp_h_en", null, null, null, null, null, null, null],
["in2_amp_h_en", null, null, null, null, null, null, null],
["in3_amp_h_en", null, null, null, null, null, null, null],
["in4_amp_h_en", null, null, null, null, null, null, null]
);
var CHANNELS_P31 = [].concat(
[["in_ctr_count", "dummy", TYPE_U32, 1.0, 0, 0]],
makeChannels_p31("in1"),
makeChannels_p31("in2"),
makeChannels_p31("in3"),
makeChannels_p31("in4")
);
var CHAN_POST_P31 = [["sum_in1234_Ah", null, null, 0, 0, 2]];
var CHAN_ALL_P31 = [].concat(CHANNELS_P31, CHAN_POST_P31);
function decodePort31(bytes) {
if (bytes.length < 8) {
throw new Error("Port 31 payload too short: expected at least 8 bytes, got " + bytes.length);
}
var flags = parseBitFlags(bytes, 0, 4, BITSET_P31);
flags.dummy = true;
var channels = parseChannels(bytes, 4, flags, CHANNELS_P31);
sumIfDefined(channels, ["in1_Ah", "in2_Ah", "in3_Ah", "in4_Ah"], "sum_in1234_Ah");
channels.in_ctr_time_elapse = formatCounterElapsed(channels.in_ctr_count);
applyFixedPrecision(channels, CHAN_ALL_P31);
return merge({ flags: flags }, channels);
}
// ============================================================
// Port 42 + Port 99 — simple decoders
// ============================================================
function decodePort42(bytes) {
if (bytes.length !== 1) {
throw new Error("Port 42 payload size: expected 1 byte, got " + bytes.length);
}
return {
button_press_count: bytes[0]
};
}
function decodePort99(bytes) {
if (bytes.length < 7) {
throw new Error("Port 99 payload too short: expected at least 7 bytes, got " + bytes.length);
}
var result = {
reboot_counter: dec_u32(bytes, 0),
app_version_major: bytes[4],
app_version_minor: bytes[5],
app_version_patch: bytes[6],
app_version: bytes[4] + "." + bytes[5] + "." + bytes[6]
};
if (bytes.length >= 10) {
result.app_version_tweak = bytes[7];
result.app_version = bytes[4] + "." + bytes[5] + "." + bytes[6] + "." + bytes[7];
var hwVersionByte = bytes[8];
result.hw_version_patch = hwVersionByte & 0x07;
result.hw_version_minor = (hwVersionByte >> 3) & 0x07;
result.hw_version_major = (hwVersionByte >> 6) & 0x03;
result.hw_version = result.hw_version_major + "." + result.hw_version_minor + "." + result.hw_version_patch;
result.hw_variant = bytes[9];
var hex = bytes[9].toString(16).toUpperCase();
result.hw_variant_hex = "0x" + (hex.length < 2 ? "0" + hex : hex);
}
return result;
}
// ============================================================
// Entry points
// ============================================================
function decodePortX(bytes, port) {
switch (port) {
case 10:
return decodePort10(bytes);
case 30:
return decodePort30(bytes);
case 31:
return decodePort31(bytes);
case 42:
return decodePort42(bytes);
case 99:
return decodePort99(bytes);
}
throw new Error("No decoder for port: " + port);
}
// Entry point for The Things Stack and ChirpStack v4.
// TTS passes { bytes, fPort }, ChirpStack v4 passes the same shape.
function decodeUplink(input) {
return {
data: decodePortX(input.bytes, input.fPort),
warnings: [],
errors: []
};
}
// Entry point for ChirpStack v3 (legacy "Custom JavaScript codec functions").
// ChirpStack v3 expects a global Decode(fPort, bytes, variables) function
// and returns the data object directly (no { data, warnings, errors } wrapper).
// eslint-disable-next-line no-unused-vars
function Decode(fPort, bytes, variables) {
return decodePortX(bytes, fPort);
}
// CommonJS export for Node.js test environment. Ignored by TTS/ChirpStack runtimes.
/* global module */
if (typeof module !== "undefined" && module.exports) {
module.exports = {
decodeUplink: decodeUplink,
Decode: Decode,
decodePortX: decodePortX,
decodePort10: decodePort10,
decodePort30: decodePort30,
decodePort31: decodePort31,
decodePort42: decodePort42,
decodePort99: decodePort99,
dec_u8: dec_u8,
dec_u16: dec_u16,
dec_u32: dec_u32,
dec_i8: dec_i8,
dec_i16: dec_i16,
dec_i32: dec_i32,
dec_f16: dec_f16,
to_signed: to_signed,
parseBitFlags: parseBitFlags,
parseChannels: parseChannels,
sumIfDefined: sumIfDefined,
formatCounterElapsed: formatCounterElapsed,
num_to_fixed: num_to_fixed
};
}