← Back to Harvestree
Harvestree v5 LoRaWAN documentation

JS decoder

harvestree_decoder.js · FPort 1 reference codec

/**
 * 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,
    };
}