/**
* Payload Decoder
*
* for Harvestree v5 firmware family 26.11+ (bump this line when moving to a new on-wire family).
* Patch releases within the same family do not require decoder changes unless payload layout or keys change.
*
* Multiple measurement labels can be used as output from the decoder to indicate the configuration of the measurement port
* Number of the port is also specified at the end of the label after the first "_"
* If the configuration of measurement port needs to transmit multiple outputs the nature of the output will be specified after the second "_"
* These labels can be modified and simplified according to your application in the "GetMeasurement" function
*
* Node-RED: do not paste this file as-is. Use harvestree_decoder_nodered.js (Ctrl+A / Ctrl+C) or import harvestree_nodered_flow.json.
* Regenerate Node-RED assets after editing this file: node decoder/build_nodered_assets.js
*
* Changes in v26.11+:
* - PRESS_A (0x21): gauge pressure int16 LE ×0.1 psi (`pressA_*`) + SSC temperature int16 LE ×0.1 °C (`pressA_*_temp`)
*
* Changes in v26.10+:
* - SOUND_A (0x20): A-weighted sound level, int16 LE ×0.1 dB, key `soundA_*`
*
* Changes in v26.9+:
* - ACCT (0x11): AC current CT coil — float32 RMS + float32 Hz (`acct_*`, `acct_*_hz`), same layout as ACCS
* - `alarm_source_port` / `alarm_source_system` are emitted only when `status === 2` (ALARM); omitted otherwise
* - WEATHER (0x0F): still 5 bytes on wire (2+2+1); 5th byte ignored; `weatherA_*_frost` computed in decoder
* from T/RH (legacy G formula, same as pre-25.4 decoder post-process)
*
* Changes in v26.8+:
* - Vibration format: VibTxValues_t (9 bytes) — rms_lf, rms_hf (int16 ×100), cinq ratios S_i/S_tot (uint8 ×255)
* - Status decoding enhanced to extract alarm sources (port/system) separately
* - AC analog (0x0C, 0x0E): 4 bytes — Vrms mV (int16×0.1), estimated frequency Hz (int16×0.1); keys `*_hz`
* - WEATHER (0x0F): humidity on wire is int16 LE ×0.1 %RH (same encoding as temperature), not uint16
* - Float paths (0x11, 0x18–0x1F): one JSON key prefix per firmware port code (`PortType_t`), aligned with
* `external_doc/payload-decoding.html` / `harvestree-measurements-technical-reference.html` — same underlying `MoizMeasure*`
* functions may apply to several codes, but uplink labels stay specialized per code.
*
*/
// Chirpstack v4
function decodeUplink(input) {
switch(input.fPort) {
// Data message
case 1:
var decoded = harvestreeDecode(input.bytes);
return { data: decoded };
// Downlink response -- in development
case 2:
return {};
default:
return { errors: ["fPort not recognized"] };
}
}
// Chirpstack v3
function Decode(fPort, bytes) {
switch(fPort) {
// Data message
case 1:
return harvestreeDecode(bytes);
// Downlink response -- in development
case 2:
return {};
default:
return { errors: ["fPort not recognized"] };
}
}
// The Things Network
function Decoder(bytes, port) {
switch(port) {
// Data message
case 1:
return harvestreeDecode(bytes);
// Downlink response -- in development
case 2:
return {};
default:
return { errors: ["fPort not recognized"] };
}
}
function harvestreeDecode(bytes) {
var decoded = {};
const internal_size = 13;
if (!hasBytes(bytes, 0, internal_size)) {
return { errors: ["payload too short"] };
}
// bytes[0] reserved (0xFF on wire) — not exposed in JSON
// For identification
decoded.serial_number = (bytes[5] + (bytes[6] << 8) + (bytes[7] << 16)) >>> 0;
// Internal measurements
decoded.boardTemperature = bytes[8] - 40; // Temperature on electronic board
decoded.baseTemperature = bytes[9] - 40; // Temperature on hot side of thermogenerator = base temp
decoded.storageVoltage = bytes[10] + ((bytes[11] & 0b00000011) << 8) + 3300; // Voltage of capacitor or battery
decoded.thermogenVoltage = ((bytes[11] & 0b11111100) >> 2) + ((bytes[12] & 0b00001111) << 6); // Voltage of thermogenerator
// Status decoding (4 bits in upper nibble of bytes[12])
// Base status: 0=KEEPALIVE, 1=NORMAL, 2=ALARM
// If ALARM (2), additional bits indicate alarm sources:
// Bit 2: ALARM_SOURCE_PORT (port alarm active)
// Bit 3: ALARM_SOURCE_SYSTEM (system alarm active: HVT/HEAT/MOVE)
const encoded_status = (bytes[12] & 0b11110000) >> 4;
decoded.status = encoded_status & 0b00000011; // Base status (bits 0-1)
// Alarm source bits are only meaningful (and exposed) when status is ALARM (2)
if (decoded.status === 2) {
decoded.alarm_source_port = ((encoded_status & 0b00000100) !== 0) ? 1 : 0; // Bit 2
decoded.alarm_source_system = ((encoded_status & 0b00001000) !== 0) ? 1 : 0; // Bit 3
}
// Port measurements identification if message is not a Keepalive
if(decoded.status !== 0) {
const port1 = bytes[1];
const port2 = bytes[2];
const port3 = bytes[3];
const port4 = bytes[4];
// Port measurement decoding
const portConfigs = [port1, port2, port3, port4];
var shift = 0;
for (let i = 0; i < portConfigs.length; i++) {
const consumed = GetMeasurement(i + 1, bytes, portConfigs[i], shift, decoded);
if (consumed < 0) {
if (!Array.isArray(decoded.errors)) {
decoded.errors = [];
}
decoded.errors.push(`parsing stopped at port ${i + 1}`);
break;
}
shift += consumed;
}
}
return decoded;
}
function GetMeasurement(id, input, portConfig, shift, output) {
var key = ``;
const internal_size = 13;
const base = internal_size + shift;
function ensureSize(expected) {
if (!hasBytes(input, base, expected)) {
if (!Array.isArray(output.errors)) {
output.errors = [];
}
output.errors.push(`port_${id} payload too short for config 0x${portConfig.toString(16)}`);
return false;
}
return true;
}
switch (portConfig) {
// PT temperature probe (°C)
case 0x01:
if (!ensureSize(2)) { return -1; }
key = `pt_${id}`;
output[key] = readInt16LE(input, base) / 10.0;
return 2;
// ThermoCouple K probe (°C)
case 0x02:
if (!ensureSize(4)) { return -1; }
key = `tck_${id}_cjt`;
output[key] = readInt16LE(input, base) / 10.0;
key = `tck_${id}_hjt`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// ThermoCouple J probe (°C)
case 0x03:
if (!ensureSize(4)) { return -1; }
key = `tcj_${id}_cjt`;
output[key] = readInt16LE(input, base) / 10.0;
key = `tcj_${id}_hjt`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// ThermoCouple T probe (°C)
case 0x04:
if (!ensureSize(4)) { return -1; }
key = `tct_${id}_cjt`;
output[key] = readInt16LE(input, base) / 10.0;
key = `tct_${id}_hjt`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// ThermoCouple N probe (°C)
case 0x05:
if (!ensureSize(4)) { return -1; }
key = `tcn_${id}_cjt`;
output[key] = readInt16LE(input, base) / 10.0;
key = `tcn_${id}_hjt`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// ThermoCouple S probe (°C)
case 0x06:
if (!ensureSize(4)) { return -1; }
key = `tcs_${id}_cjt`;
output[key] = readInt16LE(input, base) / 10.0;
key = `tcs_${id}_hjt`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// ThermoCouple E probe (°C)
case 0x07:
if (!ensureSize(4)) { return -1; }
key = `tce_${id}_cjt`;
output[key] = readInt16LE(input, base) / 10.0;
key = `tce_${id}_hjt`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// ThermoCouple B probe (°C)
case 0x08:
if (!ensureSize(4)) { return -1; }
key = `tcb_${id}_cjt`;
output[key] = readInt16LE(input, base) / 10.0;
key = `tcb_${id}_hjt`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// ThermoCouple R probe (°C)
case 0x09:
if (!ensureSize(4)) { return -1; }
key = `tcr_${id}_cjt`;
output[key] = readInt16LE(input, base) / 10.0;
key = `tcr_${id}_hjt`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// InfraRed temperature probe ref A (°C)
case 0x0A:
if (!ensureSize(2)) { return -1; }
key = `irA_${id}`;
output[key] = readInt16LE(input, base) / 10.0;
return 2;
// Direct Current Differential Voltage (mV)
case 0x0B:
if (!ensureSize(2)) { return -1; }
key = `dcdv_${id}`;
output[key] = readInt16LE(input, base) / 10.0;
return 2;
// AC differential: fundamental Vrms (int16 x0.1 mV) + f (Hz); 26.8.x pipeline (Goertzel + K(f), 20–100 Hz band)
case 0x0C:
if (!ensureSize(4)) { return -1; }
key = `acdv_${id}`;
output[key] = readInt16LE(input, base) / 10.0;
key = `acdv_${id}_hz`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// Direct Current Voltage (mV)
case 0x0D:
if (!ensureSize(2)) { return -1; }
key = `dcv_${id}`;
output[key] = readInt16LE(input, base) / 10.0;
return 2;
// AC single-ended: same semantics as 0x0C (int16 x0.1 mV + Hz)
case 0x0E:
if (!ensureSize(4)) { return -1; }
key = `acv_${id}`;
output[key] = readInt16LE(input, base) / 10.0;
key = `acv_${id}_hz`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
// Weather Station sensor ref A (°C, %RH); 5 bytes on wire — 5th byte reserved/ignored (layout compat)
// frost: decoder post-process from T/RH (legacy G formula); not read from wire
case 0x0F:
if (!ensureSize(5)) { return -1; }
key = `weatherA_${id}_temp`;
var weatherTemp = readInt16LE(input, base) / 10.0;
output[key] = weatherTemp;
key = `weatherA_${id}_humidity`;
var weatherHum = readInt16LE(input, base + 2) / 10.0;
output[key] = weatherHum;
key = `weatherA_${id}_frost`;
output[key] = calculateFrost(weatherTemp, weatherHum);
return 5;
// Potentiometer (% or calibrated)
case 0x10:
if (!ensureSize(4)) { return -1; }
key = `pot_${id}`;
output[key] = readFloat32LE(input, base);
return 4;
// FourTwenty reading (mA or calibrated)
case 0x12:
if (!ensureSize(4)) { return -1; }
key = `fourtwenty_${id}`;
output[key] = readFloat32LE(input, base);
return 4;
// VIB dual-band: spectral LF/HF RMS + five S_i/S_tot energy ratios (×255)
case 0x13:
if (!ensureSize(9)) { return -1; }
key = `vib_${id}_rms_lf`;
output[key] = readInt16LE(input, base) / 100.0;
key = `vib_${id}_rms_hf`;
output[key] = readInt16LE(input, base + 2) / 100.0;
key = `vib_${id}_ratio_lf_0`;
output[key] = readUInt8(input, base + 4) / 255.0;
key = `vib_${id}_ratio_lf_1`;
output[key] = readUInt8(input, base + 5) / 255.0;
key = `vib_${id}_ratio_lf_2`;
output[key] = readUInt8(input, base + 6) / 255.0;
key = `vib_${id}_ratio_hf_0`;
output[key] = readUInt8(input, base + 7) / 255.0;
key = `vib_${id}_ratio_hf_1`;
output[key] = readUInt8(input, base + 8) / 255.0;
return 9;
// Dry contact measurement (0 or 1)
case 0x14:
if (!ensureSize(1)) { return -1; }
key = `dryc_${id}`;
output[key] = readUInt8(input, base);
return 1;
// Asynchronous dry contact (ALARM, measurement check: 0 or 1, same format as DRYC)
case 0xFF:
if (!ensureSize(1)) { return -1; }
key = `async_dryc_${id}`;
output[key] = readUInt8(input, base);
return 1;
// Digital temperature sensor (°C)
case 0x16:
if (!ensureSize(2)) { return -1; }
key = `dtempA_${id}`;
output[key] = readInt16LE(input, base) / 10.0;
return 2;
// DC magnetic field — Hall (float32); firmware MoizMeasureDCVFL
case 0x18:
if (!ensureSize(4)) { return -1; }
key = `dcmh_${id}`;
output[key] = readFloat32LE(input, base);
return 4;
// AC magnetic field — Hall RMS + Hz (float32 each); firmware MoizMeasureACVFL
case 0x19:
if (!ensureSize(8)) { return -1; }
key = `acmh_${id}`;
output[key] = readFloat32LE(input, base);
key = `acmh_${id}_hz`;
output[key] = readFloat32LE(input, base + 4);
return 8;
// DC current — differential shunt (float32); firmware MoizMeasureDCDVFL
case 0x1A:
if (!ensureSize(4)) { return -1; }
key = `dccs_${id}`;
output[key] = readFloat32LE(input, base);
return 4;
// DC current — Hall (float32); firmware MoizMeasureDCVFL
case 0x1B:
if (!ensureSize(4)) { return -1; }
key = `dcch_${id}`;
output[key] = readFloat32LE(input, base);
return 4;
// AC current — CT coil RMS + Hz; firmware MoizMeasureACDVFL
case 0x11:
if (!ensureSize(8)) { return -1; }
key = `acct_${id}`;
output[key] = readFloat32LE(input, base);
key = `acct_${id}_hz`;
output[key] = readFloat32LE(input, base + 4);
return 8;
// AC current — differential shunt RMS + Hz; firmware MoizMeasureACDVFL
case 0x1C:
if (!ensureSize(8)) { return -1; }
key = `accs_${id}`;
output[key] = readFloat32LE(input, base);
key = `accs_${id}_hz`;
output[key] = readFloat32LE(input, base + 4);
return 8;
// AC current — Hall RMS + Hz; firmware MoizMeasureACVFL
case 0x1D:
if (!ensureSize(8)) { return -1; }
key = `acch_${id}`;
output[key] = readFloat32LE(input, base);
key = `acch_${id}_hz`;
output[key] = readFloat32LE(input, base + 4);
return 8;
// DC differential voltage — external amplifier (float32); firmware MoizMeasureDCVFL
case 0x1E:
if (!ensureSize(4)) { return -1; }
key = `dcdvEx_${id}`;
output[key] = readFloat32LE(input, base);
return 4;
// AC differential voltage — external amplifier RMS + Hz; firmware MoizMeasureACVFL
case 0x1F:
if (!ensureSize(8)) { return -1; }
key = `acdvEx_${id}`;
output[key] = readFloat32LE(input, base);
key = `acdvEx_${id}_hz`;
output[key] = readFloat32LE(input, base + 4);
return 8;
// Sound level — A-weighted dB SPL (int16 x0.1 dB)
case 0x20:
if (!ensureSize(2)) { return -1; }
key = `soundA_${id}`;
output[key] = readInt16LE(input, base) / 10.0;
return 2;
// Pressure — TE MSP300 gauge psi (int16 x0.1) + SSC temperature °C (int16 x0.1)
case 0x21:
if (!ensureSize(4)) { return -1; }
key = `pressA_${id}`;
output[key] = readInt16LE(input, base) / 10.0;
key = `pressA_${id}_temp`;
output[key] = readInt16LE(input, base + 2) / 10.0;
return 4;
default:
if (portConfig === 0x00) {
return 0;
}
if (!Array.isArray(output.errors)) {
output.errors = [];
}
output.errors.push(`port_${id} unknown config 0x${portConfig.toString(16)}`);
return -1;
}
}
function hasBytes(bytes, offset, size) {
const isTypedArray = ArrayBuffer.isView(bytes) && !(bytes instanceof DataView);
const validBuffer = Array.isArray(bytes) || isTypedArray;
const length = Array.isArray(bytes) ? bytes.length : isTypedArray ? bytes.length : 0;
return validBuffer && length >= offset + size;
}
/** Legacy G / frost from T (°C) and RH (%). Wire byte 5 is not used. */
function calculateFrost(temp, hum) {
var g = 0;
if (temp < -15.0) {
g = (temp * (4.0 / 3.0)) + (hum * (4.0 / 5.0)) - 52;
} else if (temp < -5.0) {
g = (temp * (1.0 / 5.0)) + (hum * (2.0 / 5.0)) - 29;
} else if (temp <= 0.0) {
g = (temp * (-2.0 / 5.0)) + (hum * (2.0 / 5.0)) - 32;
} else {
g = 0;
}
if (g < 0) {
g = 0;
}
return g;
}
function readUInt8(bytes, offset) {
return bytes[offset] & 0xFF;
}
function readInt8(bytes, offset) {
var val = bytes[offset];
return (val & 0x80) ? val - 0x100 : val;
}
function readUInt16LE(bytes, offset) {
return ((bytes[offset + 1] << 8) | bytes[offset]) >>> 0;
}
function readInt16LE(bytes, offset) {
var val = (bytes[offset + 1] << 8) | bytes[offset];
return (val & 0x8000) ? val - 0x10000 : val;
}
function readFloat32LE(bytes, offset) {
// IEEE-754 single precision (32-bit float), little-endian
var b0 = bytes[offset];
var b1 = bytes[offset + 1];
var b2 = bytes[offset + 2];
var b3 = bytes[offset + 3];
var intBits =
(b3 << 24) |
(b2 << 16) |
(b1 << 8) |
b0;
var sign = ((intBits >>> 31) === 0) ? 1 : -1;
var exponent = (intBits >>> 23) & 0xFF;
var mantissa = intBits & 0x7FFFFF;
if (exponent === 255) {
return mantissa === 0 ? sign * Infinity : NaN;
}
if (exponent === 0) {
return sign * Math.pow(2, -126) * (mantissa / Math.pow(2, 23));
}
return sign * Math.pow(2, exponent - 127) * (1 + mantissa / Math.pow(2, 23));
}
if (typeof module !== "undefined") {
module.exports = {
decodeUplink,
Decode,
Decoder,
harvestreeDecode,
};
}