> For the complete documentation index, see [llms.txt](https://enless.gitbook.io/centre-aide/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://enless.gitbook.io/centre-aide/ressources/ressources-en/lora-sensors/codecs-and-decoding/chirpstack.md).

# Chirpstack

{% code title="TX T\&H MINI 600-050" expandable="true" %}

```
// 600-050 TX T&H MINI
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
// Expected length: 30 bytes
//
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
//  6..7 : Ambient temperature (INT16, /10)
// 10..11: Humidity (UINT16, /10)
// 26..27: Alarm Status (UINT16, bitfield T/H)
// 28..29: Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 30) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 30.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(10, 12)) / 10;

  const alarmStatus = readUInt16BE(bytes.slice(26, 28));
  const status = readUInt16BE(bytes.slice(28, 30));

  // Battery: bits 3-2 of the Status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  // T/H alarms in Alarm Status (returned as 0/1)
  // 0x0001 : High temperature alarm
  // 0x0002 : Low temperature alarm
  // 0x0004 : High humidity alarm
  // 0x0008 : Low humidity alarm
  const HighTemperatureAlarm = (alarmStatus & 0x0001) ? 1 : 0;
  const LowTemperatureAlarm  = (alarmStatus & 0x0002) ? 1 : 0;
  const HighHumidityAlarm    = (alarmStatus & 0x0004) ? 1 : 0;
  const LowHumidityAlarm     = (alarmStatus & 0x0008) ? 1 : 0;

  return {
    data: {
      // Fields mapped in the 600-050 mapping
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,
      HighTemperatureAlarm,
      LowTemperatureAlarm,
      HighHumidityAlarm,
      LowHumidityAlarm
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// "installation packet = 3" frame that carries:
//
//  - tx_period           (minutes)  -> RE-Tx Time (secs)
//  - sampling_period     (minutes)  -> Sensor sampling period
//  - rbe (0/1)                      -> "RBE & LED" word (bit 12)
//  - temp_hi_threshold   (°C, *10)  -> P1
//  - temp_lo_threshold   (°C, *10)  -> P2
//  - hum_hi_threshold    (%, *10)   -> P3
//  - hum_lo_threshold    (%, *10)   -> P4

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  // --------- tx_period ----------
  let txPeriodMinutes = data.tx_period;
  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  // --------- sampling_period ----------
  let samplingPeriodMinutes = data.sampling_period;
  if (samplingPeriodMinutes == null) {
    errors.push("Missing required field: data.sampling_period (minutes).");
  } else {
    if (typeof samplingPeriodMinutes !== "number") {
      samplingPeriodMinutes = Number(samplingPeriodMinutes);
    }
    if (Number.isNaN(samplingPeriodMinutes)) {
      errors.push("data.sampling_period must be a number (minutes).");
    } else if (samplingPeriodMinutes < 1 || samplingPeriodMinutes > 60) {
      errors.push("data.sampling_period must be between 1 and 60 minutes.");
    }
  }

  // RBE only (no MotionGuard on 600-050)
  const rbe = !!data.rbe;

  // --------- Alarm thresholds ----------
  function normTemp(name, v) {
    if (v == null) return 0;
    if (typeof v !== "number") v = Number(v);
    if (Number.isNaN(v)) {
      errors.push(`data.${name} must be a number (°C).`);
      return 0;
    }
    if (v < -40 || v > 125) {
      errors.push(`data.${name} should be between -40 and 125 °C.`);
    }
    return Math.round(v * 10); // tenths of °C
  }

  function normHum(name, v) {
    if (v == null) return 0;
    if (typeof v !== "number") v = Number(v);
    if (Number.isNaN(v)) {
      errors.push(`data.${name} must be a number (%).`);
      return 0;
    }
    if (v < 0 || v > 100) {
      errors.push(`data.${name} should be between 0 and 100 %.`);
    }
    return Math.round(v * 10); // tenths of %
  }

  const tempHiRaw = normTemp("temp_hi_threshold", data.temp_hi_threshold);
  const tempLoRaw = normTemp("temp_lo_threshold", data.temp_lo_threshold);
  const humHiRaw  = normHum("hum_hi_threshold", data.hum_hi_threshold);
  const humLoRaw  = normHum("hum_lo_threshold", data.hum_lo_threshold);

  if (errors.length) {
    return { errors };
  }

  // Conversion tx_period (min) -> seconds (UINT16 BE)
  const txSeconds = Math.round(txPeriodMinutes * 60);
  if (txSeconds < 0 || txSeconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${txSeconds} s, out of 16-bit range.`
      ]
    };
  }

  // Conversion sampling_period (min) -> seconds (UINT16 BE)
  const samplingSeconds = Math.round(samplingPeriodMinutes * 60);
  if (samplingSeconds < 0 || samplingSeconds > 0xffff) {
    return {
      errors: [
        `sampling_period=${samplingPeriodMinutes} min -> ${samplingSeconds} s, out of 16-bit range.`
      ]
    };
  }

  // "RBE & LED (bits 15-12)" word:
  // bit12 = RBE, bits 13-15 (MotionGuard/LED) always 0 for the 600-050.
  let rbeLedWord = 0;
  if (rbe) rbeLedWord |= (1 << 12);

  const bytes = [];

  // Fixed (0) - 3 bytes
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed (0) - 2 bytes
  bytes.push(0x00, 0x00);

  // RE-Tx Time (secs) : big-endian
  bytes.push((txSeconds >> 8) & 0xff, txSeconds & 0xff);

  // Sensor sampling period (secs) : big-endian
  bytes.push((samplingSeconds >> 8) & 0xff, samplingSeconds & 0xff);

  // RBE & LED word (16 bits)
  bytes.push((rbeLedWord >> 8) & 0xff, rbeLedWord & 0xff);

  // P1..P4 : thresholds (temp/hum) encoded on 16 bits BE
  bytes.push((tempHiRaw >> 8) & 0xff, tempHiRaw & 0xff); // P1
  bytes.push((tempLoRaw >> 8) & 0xff, tempLoRaw & 0xff); // P2
  bytes.push((humHiRaw  >> 8) & 0xff, humHiRaw  & 0xff); // P3
  bytes.push((humLoRaw  >> 8) & 0xff, humLoRaw  & 0xff); // P4

  // P5..P12 = 0x0000
  for (let i = 0; i < 8; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed(1)
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX T\&H 600-051" expandable="true" %}

```
// 600-051 TX T&H AMB
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame:
// 0144C41F041200E9000001DD000000000000000000000000000000000000
//
// Size: 30 bytes
//
//  0..2  : Transmitter ID (24 bits, BE)
//  3     : Type (TX Type)
//  4     : Sequential Counter
//  5     : F/W (bits 5-0)
//  6..7  : Ambient Temperature  (INT16, /10 °C)
// 10..11 : Humidity (UINT16, /10 %)
// 24..25 : Alarm Status (UINT16, bitfield)
// 26..27 : Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 30) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 30.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(10, 12)) / 10;

  const alarmStatus = readUInt16BE(bytes.slice(26, 28));
  const status      = readUInt16BE(bytes.slice(28, 30));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  // --- T&H + MotionGuard alarms ---
  // Note: Modbus mapping exposes BOOLs for these alarms.
  // Reasonable assumption: bits 0..3 for T/H, bit 8 for MotionGuard.
  // We return 0 / 1 (integer) rather than true / false:
  const HighTemperatureAlarm = (alarmStatus & 0x0001) ? 1 : 0; // Temp Hi
  const LowTemperatureAlarm  = (alarmStatus & 0x0002) ? 1 : 0; // Temp Lo
  const HighHumidityAlarm    = (alarmStatus & 0x0004) ? 1 : 0; // Hum Hi
  const LowHumidityAlarm     = (alarmStatus & 0x0008) ? 1 : 0; // Hum Lo
  const motionGuardAlarm     = (alarmStatus & 0x0100) ? 1 : 0; // MotionGuard

  return {
    data: {
      // Fields mapped in mapping_600051.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,

      // Alarms (bitfield + 0/1 values)
      HighTemperatureAlarm,
      LowTemperatureAlarm,
      HighHumidityAlarm,
      LowHumidityAlarm,
      motionGuardAlarm
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// We send the INSTALLATION frame (packet = 3) which allows configuring:
//  - tx_period           : frame periodicity (min)      -> RE-Tx Time (s)
//  - sampling_period     : sensor sampling period (min)
//  - rbe                 : Report By Exception (bit 12 of the "RBE, MotionGuard & LED" word)
//  - MotionGuard         : enable MotionGuard (bit 13 of the same word)
//  - temp_hi_threshold   : high temperature threshold (°C, *10, INT16)
//  - temp_lo_threshold   : low temperature threshold (°C, *10, INT16)
//  - hum_hi_threshold    : high humidity threshold (%RH, *10, UINT16)
//  - hum_lo_threshold    : low humidity threshold (%RH, *10, UINT16)
//
// Generated format (37 bytes):
//  - 3  bytes : 0x000000                   (Fixed 0)
//  - 1  byte  : 0x03                      (Installation Packet = 3)
//  - 2  bytes : 0x0000                    (Fixed 0)
//  - 2  bytes : RE-Tx Time in seconds (min * 60, BE)
//  - 2  bytes : Sensor sampling period (min * 60, BE)
//  - 2  bytes : RBE/MotionGuard/LED flags (bits 15..12)
//  - P1..P4   : T° / RH thresholds (4 * 2 bytes)
//  - P5..P12  : 0x0000                      (unused)
//  - 1  byte  : 0x01                      (Fixed 1)
//
// Example Excel provided:
// 000000030000012C003C2000016300C801B800C80000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  // ---------- tx_period ----------
  let txPeriodMinutes = data.tx_period;
  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  // ---------- sampling_period ----------
  let samplingPeriod = data.sampling_period;
  if (samplingPeriod == null) {
    // default value from the mapping: 1 min
    samplingPeriod = 1;
  }
  if (typeof samplingPeriod !== "number") {
    samplingPeriod = Number(samplingPeriod);
  }
  if (Number.isNaN(samplingPeriod)) {
    errors.push("data.sampling_period must be a number (minutes).");
  } else if (samplingPeriod < 1 || samplingPeriod > 60) {
    errors.push("data.sampling_period must be between 1 and 60 minutes.");
  }

  // ---------- RBE & MotionGuard ----------
  const rbe = !!data.rbe;                 // bool
  const motionGuard = !!data.MotionGuard; // bool

  // ---------- Thresholds ----------
  function normNumber(val, name, min, max, isSigned) {
    if (val == null) {
      return 0;
    }
    if (typeof val !== "number") {
      val = Number(val);
    }
    if (Number.isNaN(val)) {
      errors.push(`data.${name} must be a number.`);
      return 0;
    }
    if (val < min || val > max) {
      errors.push(
        `data.${name} must be between ${min} and ${max}.`
      );
    }
    // clamp + round
    val = Math.max(min, Math.min(max, val));
    // scaling *10 to match the mapping (°C and %)
    const raw = Math.round(val * 10);

    if (isSigned) {
      // INT16
      if (raw < -32768 || raw > 32767) {
        errors.push(`data.${name} (scaled) out of INT16 range.`);
      }
      return raw & 0xffff;
    } else {
      // UINT16
      if (raw < 0 || raw > 0xffff) {
        errors.push(`data.${name} (scaled) out of UINT16 range.`);
      }
      return raw & 0xffff;
    }
  }

  const tempHi = normNumber(
    data.temp_hi_threshold,
    "temp_hi_threshold",
    -40,
    125,
    true
  );
  const tempLo = normNumber(
    data.temp_lo_threshold,
    "temp_lo_threshold",
    -40,
    125,
    true
  );
  const humHi = normNumber(
    data.hum_hi_threshold,
    "hum_hi_threshold",
    0,
    100,
    false
  );
  const humLo = normNumber(
    data.hum_lo_threshold,
    "hum_lo_threshold",
    0,
    100,
    false
  );

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const txSeconds = Math.round(txPeriodMinutes * 60);
  const sampSeconds = Math.round(samplingPeriod * 60);

  if (txSeconds < 0 || txSeconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${txSeconds} s, out of 16-bit range.`
      ]
    };
  }
  if (sampSeconds < 0 || sampSeconds > 0xffff) {
    return {
      errors: [
        `sampling_period=${samplingPeriod} min -> ${sampSeconds} s, out of 16-bit range.`
      ]
    };
  }

  // RBE / MotionGuard / LED word (bits 15-12)
  //  - bit13 : MotionGuard
  //  - bit12 : RBE
  //  (bit14 / bit15 : LED / reserved -> 0)
  let flags = 0;
  if (rbe)         flags |= (1 << 12);
  if (motionGuard) flags |= (1 << 13);

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((txSeconds >> 8) & 0xff, txSeconds & 0xff);

  // Sensor sampling period (seconds) : big-endian
  bytes.push((sampSeconds >> 8) & 0xff, sampSeconds & 0xff);

  // RBE / MotionGuard / LED flags
  bytes.push((flags >> 8) & 0xff, flags & 0xff);

  // P1 : High Temp Alarm (°C *10, INT16)
  bytes.push((tempHi >> 8) & 0xff, tempHi & 0xff);

  // P2 : Low Temp Alarm (°C *10, INT16)
  bytes.push((tempLo >> 8) & 0xff, tempLo & 0xff);

  // P3 : High Hum Alarm (% *10, UINT16)
  bytes.push((humHi >> 8) & 0xff, humHi & 0xff);

  // P4 : Low Hum Alarm (% *10, UINT16)
  bytes.push((humLo >> 8) & 0xff, humLo & 0xff);

  // P5..P12 = 0x0000
  for (let i = 0; i < 8; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX T\&H E-INK AMB 600-052" expandable="true" %}

```
// 600-052 TX T&H AMB E-INK
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame:
// 0144C41F041200E9000001DD000000000000000000000000000000000000
//
// Size: 30 bytes
//
//  0..2  : Transmitter ID (24 bits, BE)
//  3     : Type (TX Type)
//  4     : Sequential Counter
//  5     : F/W (bits 5-0)
//  6..7  : Ambient Temperature  (INT16, /10 °C)
// 10..11 : Humidity (UINT16, /10 %)
// 24..25 : Alarm Status (UINT16, bitfield)
// 26..27 : Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 30) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 30.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(10, 12)) / 10;

  const alarmStatus = readUInt16BE(bytes.slice(26, 28));
  const status      = readUInt16BE(bytes.slice(28, 30));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  // --- T&H + MotionGuard alarms ---
  // We return 0 / 1 here instead of true / false
  const HighTemperatureAlarm = (alarmStatus & 0x0001) ? 1 : 0; // Temp Hi
  const LowTemperatureAlarm  = (alarmStatus & 0x0002) ? 1 : 0; // Temp Lo
  const HighHumidityAlarm    = (alarmStatus & 0x0004) ? 1 : 0; // Hum Hi
  const LowHumidityAlarm     = (alarmStatus & 0x0008) ? 1 : 0; // Hum Lo
  const motionGuardAlarm     = (alarmStatus & 0x0100) ? 1 : 0; // MotionGuard

  return {
    data: {
      // Mapped fields (mapping_600052.json)
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,

      // Alarms (integer values 0 / 1)
      HighTemperatureAlarm,
      LowTemperatureAlarm,
      HighHumidityAlarm,
      LowHumidityAlarm,
      motionGuardAlarm
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// We send the INSTALLATION frame (packet = 3) which allows configuring:
//  - tx_period           : frame periodicity (min)      -> RE-Tx Time (s)
//  - sampling_period     : sensor sampling period (min)
//  - rbe                 : Report By Exception (bit 12 of the "RBE, MotionGuard & LED" word)
//  - MotionGuard         : enable MotionGuard (bit 13 of the same word)
//  - temp_hi_threshold   : high temperature threshold (°C, *10, INT16)
//  - temp_lo_threshold   : low temperature threshold (°C, *10, INT16)
//  - hum_hi_threshold    : high humidity threshold (%RH, *10, UINT16)
//  - hum_lo_threshold    : low humidity threshold (%RH, *10, UINT16)
//
// Generated format (37 bytes):
//  - 3  bytes : 0x000000                   (Fixed 0)
//  - 1  byte  : 0x03                      (Installation Packet = 3)
//  - 2  bytes : 0x0000                    (Fixed 0)
//  - 2  bytes : RE-Tx Time in seconds (min * 60, BE)
//  - 2  bytes : Sensor sampling period (min * 60, BE)
//  - 2  bytes : RBE/MotionGuard/LED flags (bits 15..12)
//  - P1..P4   : T° / RH thresholds (4 * 2 bytes)
//  - P5..P12  : 0x0000                      (unused)
//  - 1  byte  : 0x01                      (Fixed 1)
//
// Example Excel provided:
// 000000030000012C003C2000016300C801B800C80000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  // ---------- tx_period ----------
  let txPeriodMinutes = data.tx_period;
  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  // ---------- sampling_period ----------
  let samplingPeriod = data.sampling_period;
  if (samplingPeriod == null) {
    // default value from the mapping: 1 min
    samplingPeriod = 1;
  }
  if (typeof samplingPeriod !== "number") {
    samplingPeriod = Number(samplingPeriod);
  }
  if (Number.isNaN(samplingPeriod)) {
    errors.push("data.sampling_period must be a number (minutes).");
  } else if (samplingPeriod < 1 || samplingPeriod > 60) {
    errors.push("data.sampling_period must be between 1 and 60 minutes.");
  }

  // ---------- RBE & MotionGuard ----------
  const rbe = !!data.rbe;                 // bool
  const motionGuard = !!data.MotionGuard; // bool

  // ---------- Thresholds ----------
  function normNumber(val, name, min, max, isSigned) {
    if (val == null) {
      return 0;
    }
    if (typeof val !== "number") {
      val = Number(val);
    }
    if (Number.isNaN(val)) {
      errors.push(`data.${name} must be a number.`);
      return 0;
    }
    if (val < min || val > max) {
      errors.push(
        `data.${name} must be between ${min} and ${max}.`
      );
    }
    // clamp + round
    val = Math.max(min, Math.min(max, val));
    // scaling *10 to match the mapping (°C and %)
    const raw = Math.round(val * 10);

    if (isSigned) {
      // INT16
      if (raw < -32768 || raw > 32767) {
        errors.push(`data.${name} (scaled) out of INT16 range.`);
      }
      return raw & 0xffff;
    } else {
      // UINT16
      if (raw < 0 || raw > 0xffff) {
        errors.push(`data.${name} (scaled) out of UINT16 range.`);
      }
      return raw & 0xffff;
    }
  }

  const tempHi = normNumber(
    data.temp_hi_threshold,
    "temp_hi_threshold",
    -40,
    125,
    true
  );
  const tempLo = normNumber(
    data.temp_lo_threshold,
    "temp_lo_threshold",
    -40,
    125,
    true
  );
  const humHi = normNumber(
    data.hum_hi_threshold,
    "hum_hi_threshold",
    0,
    100,
    false
  );
  const humLo = normNumber(
    data.hum_lo_threshold,
    "hum_lo_threshold",
    0,
    100,
    false
  );

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const txSeconds = Math.round(txPeriodMinutes * 60);
  const sampSeconds = Math.round(samplingPeriod * 60);

  if (txSeconds < 0 || txSeconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${txSeconds} s, out of 16-bit range.`
      ]
    };
  }
  if (sampSeconds < 0 || sampSeconds > 0xffff) {
    return {
      errors: [
        `sampling_period=${samplingPeriod} min -> ${sampSeconds} s, out of 16-bit range.`
      ]
    };
  }

  // RBE / MotionGuard / LED word (bits 15-12)
  //  - bit13 : MotionGuard
  //  - bit12 : RBE
  //  (bit14 / bit15 : LED / reserved -> 0)
  let flags = 0;
  if (rbe)         flags |= (1 << 12);
  if (motionGuard) flags |= (1 << 13);

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((txSeconds >> 8) & 0xff, txSeconds & 0xff);

  // Sensor sampling period (seconds) : big-endian
  bytes.push((sampSeconds >> 8) & 0xff, sampSeconds & 0xff);

  // RBE / MotionGuard / LED flags
  bytes.push((flags >> 8) & 0xff, flags & 0xff);

  // P1 : High Temp Alarm (°C *10, INT16)
  bytes.push((tempHi >> 8) & 0xff, tempHi & 0xff);

  // P2 : Low Temp Alarm (°C *10, INT16)
  bytes.push((tempLo >> 8) & 0xff, tempLo & 0xff);

  // P3 : High Hum Alarm (% *10, UINT16)
  bytes.push((humHi >> 8) & 0xff, humHi & 0xff);

  // P4 : Low Hum Alarm (% *10, UINT16)
  bytes.push((humLo >> 8) & 0xff, humLo & 0xff);

  // P5..P12 = 0x0000
  for (let i = 0; i < 8; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX CO2 T\&H AMB 600-053" expandable="true" %}

```
// 600-053 TX CO2 T&H AMB
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const v = readUInt16BE(bytes);
  return v > 0x7fff ? v - 0x10000 : v;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame:
// 0000ea253611FF700000003203E800000000000000000000000000020240
//
// Expected payload: 30 bytes
//  0..2  : Transmitter ID (24 bits, BE)
//  3     : Type (TX Type)
//  4     : Sequential Counter
//  5     : F/W (bits 5-0)
//  6..7  : Ambient temperature (INT16, /10 °C)
// 10..11 : Humidity (UINT16, /10 %)
// 12..13 : CO2 (UINT16, ppm)
// 26..27 : Alarm Status (UINT16, bitfield)
// 28..29 : Status (UINT16, bitfield; bits 3-2 = battery level)
//           bit 6 = Old CO2 (0 = old measurement, 1 = new measurement)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 30) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 30.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3f;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(10, 12)) / 10;
  const co2 = readUInt16BE(bytes.slice(12, 14));

  const alarmStatus = readUInt16BE(bytes.slice(26, 28));
  const status = readUInt16BE(bytes.slice(28, 30));

  // Battery: bits 3-2 of the "status" word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  // CO2 + MotionGuard alarms -> 0 / 1 (and no longer bool)
  const HighCO2Alarm     = (alarmStatus & 0x0010) ? 1 : 0; // bit4
  const LowCO2Alarm      = (alarmStatus & 0x0020) ? 1 : 0; // bit5
  const motionGuardAlarm = (alarmStatus & 0x0100) ? 1 : 0; // bit8

  // CO2 measurement status: bit 6 of the status
  // 1 = new measurement, 0 = old measurement (Old CO2)
  const CO2Sampled = (status & 0x0040) ? 1 : 0;

  return {
    data: {
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,
      co2,
      HighCO2Alarm,
      LowCO2Alarm,
      motionGuardAlarm,
      CO2Sampled
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Installation frame "packet = 3"
//
// encodeDownlink({
//   fPort: 1,
//   data: {
//     tx_period: 5,      // minutes (1..720)
//     co2_period: 5,     // minutes (1..720)
//     led: 1,            // 0/1  -> bit 12 of the flags word
//     MotionGuard: 1,    // 0/1  -> bit 13 of the flags word
//     co2_hi_threshold: 800,  // ppm (0..5000)
//     co2_lo_threshold: 400   // ppm (0..5000)
//   }
// })
//
// Excel example:
// 000000030000012C012C300003200190000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  // ---- tx_period (minutes) ----
  let txPeriodMinutes = data.tx_period;
  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  // ---- co2_period (minutes) ----
  let co2PeriodMinutes =
    data.co2_period == null ? 30 : data.co2_period;
  if (typeof co2PeriodMinutes !== "number") {
    co2PeriodMinutes = Number(co2PeriodMinutes);
  }
  if (Number.isNaN(co2PeriodMinutes)) {
    errors.push("data.co2_period must be a number (minutes).");
  } else if (co2PeriodMinutes < 1 || co2PeriodMinutes > 720) {
    errors.push("data.co2_period must be between 1 and 720 minutes.");
  }

  // ---- CO2 thresholds ----
  function normCO2(val, name) {
    if (val == null) return 0;
    if (typeof val !== "number") {
      val = Number(val);
    }
    if (Number.isNaN(val)) {
      errors.push(`data.${name} must be a number (ppm).`);
      return 0;
    }
    if (val < 0 || val > 5000) {
      errors.push(`data.${name} must be between 0 and 5000 ppm.`);
    }
    const u16 = Math.round(val);
    return Math.max(0, Math.min(0xffff, u16)) & 0xffff;
  }

  const co2Hi = normCO2(data.co2_hi_threshold, "co2_hi_threshold");
  const co2Lo = normCO2(data.co2_lo_threshold, "co2_lo_threshold");

  // ---- LED / MotionGuard flags ----
  let flagsWord = 0;
  const led = !!data.led;
  const mg  = !!data.MotionGuard;

  // Here: bit12 = LED, bit13 = MotionGuard
  if (led) flagsWord |= 1 << 12;
  if (mg)  flagsWord |= 1 << 13;

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const txSeconds = Math.round(txPeriodMinutes * 60);
  const co2Seconds = Math.round(co2PeriodMinutes * 60);

  if (txSeconds < 0 || txSeconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${txSeconds} s, out of 16-bit range.`
      ]
    };
  }
  if (co2Seconds < 0 || co2Seconds > 0xffff) {
    return {
      errors: [
        `co2_period=${co2PeriodMinutes} min -> ${co2Seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed (0) - 3 bytes
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed (0)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (secs) = tx_period
  bytes.push((txSeconds >> 8) & 0xff, txSeconds & 0xff);

  // CO2 sampling period (secs)
  bytes.push((co2Seconds >> 8) & 0xff, co2Seconds & 0xff);

  // RBE / MotionGuard & LED word (here: LED + MotionGuard only)
  bytes.push((flagsWord >> 8) & 0xff, flagsWord & 0xff);

  // P1 : Hi CO2 Alarm (ppm)
  bytes.push((co2Hi >> 8) & 0xff, co2Hi & 0xff);

  // P2 : Lo CO2 Alarm (ppm)
  bytes.push((co2Lo >> 8) & 0xff, co2Lo & 0xff);

  // P3..P12 : all set to 0
  for (let i = 0; i < 10; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed (1)
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX LIGHT PIR T\&H AMB 600-062" expandable="true" %}

```
// 600-062 TX T&H PIR LIGHT
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

function readUInt32BE(bytes) {
  return (
    ((bytes[0] << 24) >>> 0) |
    (bytes[1] << 16) |
    (bytes[2] << 8) |
    bytes[3]
  ) >>> 0;
}

// ---------- UPLINK DECODER ----------
//
// Example frame:
// 0144bf22870c00d7000001dd000000000001000000000000000000000010
//
// Expected payload: 30 bytes
//  0..2  : Transmitter ID (24 bits, BE)
//  3     : Type (TX Type)
//  4     : Sequential Counter
//  5     : F/W (bits 5-0)
//  6..7  : Ambient temperature (INT16, /10 °C)
// 10..11 : Humidity (UINT16, /10 %)
// 16..17 : PIR count (UINT16, BE)
// 22..25 : Luminosity (UINT32, BE)
// 26..27 : Alarm Status (UINT16, bitfield)
// 28..29 : Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 30) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 30.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3f;

  // Main measurements
  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(10, 12)) / 10;

  const pirCount = readUInt16BE(bytes.slice(16, 18));

  // Raw luminosity (UINT32)
  const rawLuminosity = readUInt32BE(bytes.slice(22, 26));
  // Luminosity as 0/1 for Modbus mapping (0 = dark, 1 = light detected)
  const luminosityStatus = rawLuminosity > 0 ? 1 : 0;

  const alarmStatus = readUInt16BE(bytes.slice(26, 28));
  const status = readUInt16BE(bytes.slice(28, 30));

  // Battery: bits 3-2 of the "status" word (same for all TX)
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  // Alarm Status -> 0/1
  const HighTemperatureAlarm = (alarmStatus & 0x0001) ? 1 : 0;
  const LowTemperatureAlarm  = (alarmStatus & 0x0002) ? 1 : 0;
  const HighHumidityAlarm    = (alarmStatus & 0x0004) ? 1 : 0;
  const LowHumidityAlarm     = (alarmStatus & 0x0008) ? 1 : 0;
  const motionGuardAlarm     = (alarmStatus & 0x0100) ? 1 : 0;

  // Status (RBE, movement, …) -> pirStatus in 0/1
  const pirStatus = (status & 0x0010) ? 1 : 0; // "Movement detected"

  return {
    data: {
      // Main fields (mapping_600062.json)
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,

      // PIR count (if you want to expose it later)
      pirCount,

      // Statuses / alarms (UPLINK) in 0/1
      HighTemperatureAlarm,
      LowTemperatureAlarm,
      HighHumidityAlarm,
      LowHumidityAlarm,
      motionGuardAlarm,

      // Logic inputs in 0/1
      pirStatus,
      luminosityStatus

      // If needed one day:
      // rawLuminosity
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// We use the installation frame "packet = 3" with the config fields
// described in your Excel file. There are NO brightness alarm thresholds.
//
// encodeDownlink({
//   fPort: 1,
//   data: {
//     tx_period: 5,            // minutes
//     sampling_period: 30,     // minutes
//     rbe: 1,                  // 0/1
//     MotionGuard: 0,          // 0/1
//     temp_hi_threshold: 35.5, // °C
//     temp_lo_threshold: 20.0, // °C
//     hum_hi_threshold: 44.0,  // %
//     hum_lo_threshold: 20.0,  // %
//     pir_sensitivity: 1       // 0..2
//   }
// })

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  // ---- tx_period (minutes) ----
  let txPeriodMinutes = data.tx_period;
  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  // ---- sampling_period (minutes) ----
  let samplingPeriodMinutes =
    data.sampling_period == null ? 1 : data.sampling_period;
  if (typeof samplingPeriodMinutes !== "number") {
    samplingPeriodMinutes = Number(samplingPeriodMinutes);
  }
  if (Number.isNaN(samplingPeriodMinutes)) {
    errors.push("data.sampling_period must be a number (minutes).");
  } else if (samplingPeriodMinutes < 1 || samplingPeriodMinutes > 60) {
    errors.push("data.sampling_period must be between 1 and 60 minutes.");
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const txSeconds = Math.round(txPeriodMinutes * 60);
  const samplingSeconds = Math.round(samplingPeriodMinutes * 60);

  if (txSeconds < 0 || txSeconds > 0xffff) {
    errors.push(
      `tx_period=${txPeriodMinutes} min -> ${txSeconds} s, out of 16-bit range.`
    );
  }
  if (samplingSeconds < 0 || samplingSeconds > 0xffff) {
    errors.push(
      `sampling_period=${samplingPeriodMinutes} min -> ${samplingSeconds} s, out of 16-bit range.`
    );
  }

  // ---- Thresholds / options: normalization & clamp ----
  function normNumber(val, name, min, max, scale) {
    // scale = multiplication factor (e.g. 10 for °C*10)
    if (val == null) return 0;
    if (typeof val !== "number") {
      val = Number(val);
    }
    if (Number.isNaN(val)) {
      errors.push(`data.${name} must be a number.`);
      return 0;
    }
    if (val < min || val > max) {
      errors.push(
        `data.${name} must be between ${min} and ${max} (before scaling).`
      );
    }
    const scaled = Math.round(val * scale);
    const clamped = Math.max(-0x8000, Math.min(0xffff, scaled));
    return clamped & 0xffff;
  }

  // Temperature (°C, *10)
  const tempHi = normNumber(
    data.temp_hi_threshold,
    "temp_hi_threshold",
    -40,
    125,
    10
  );
  const tempLo = normNumber(
    data.temp_lo_threshold,
    "temp_lo_threshold",
    -40,
    125,
    10
  );

  // Humidity (%RH, *10)
  const humHi = normNumber(
    data.hum_hi_threshold,
    "hum_hi_threshold",
    0,
    100,
    10
  );
  const humLo = normNumber(
    data.hum_lo_threshold,
    "hum_lo_threshold",
    0,
    100,
    10
  );

  // PIR sensitivity (0..2)
  let pirSens =
    data.pir_sensitivity == null ? 0 : Number(data.pir_sensitivity);
  if (Number.isNaN(pirSens)) {
    errors.push("data.pir_sensitivity must be a number (0..2).");
    pirSens = 0;
  }
  if (pirSens < 0 || pirSens > 2) {
    errors.push("data.pir_sensitivity must be between 0 and 2.");
  }
  pirSens = Math.max(0, Math.min(2, Math.round(pirSens)));

  // RBE + MotionGuard in the word "RBE, MotionGuard & LED (bits 15-12)"
  let flagsWord = 0;
  const rbe = !!data.rbe;
  const mg  = !!data.MotionGuard;

  if (rbe) flagsWord |= 1 << 12;       // bit 12
  if (mg)  flagsWord |= 1 << 13;       // bit 13
  // bits 14-15 (LED) remain 0

  if (errors.length) {
    return { errors };
  }

  const bytes = [];

  // Fixed (0) - 3 bytes
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed (0)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (secs) = tx_period
  bytes.push((txSeconds >> 8) & 0xff, txSeconds & 0xff);

  // Sensor sampling period (secs)
  bytes.push((samplingSeconds >> 8) & 0xff, samplingSeconds & 0xff);

  // RBE, MotionGuard & LED word
  bytes.push((flagsWord >> 8) & 0xff, flagsWord & 0xff);

  // P1: Hi Temp Alarm (°C *10)
  bytes.push((tempHi >> 8) & 0xff, tempHi & 0xff);

  // P2: Lo Temp Alarm (°C *10)
  bytes.push((tempLo >> 8) & 0xff, tempLo & 0xff);

  // P3: Hi Hum Alarm (% *10)
  bytes.push((humHi >> 8) & 0xff, humHi & 0xff);

  // P4: Lo Hum Alarm (% *10)
  bytes.push((humLo >> 8) & 0xff, humLo & 0xff);

  // P5: unused (0x0000)
  bytes.push(0x00, 0x00);

  // P6: unused (0x0000)
  bytes.push(0x00, 0x00);

  // P7: PIR Sensitivity (0..2)
  bytes.push(0x00, pirSens & 0xff);

  // P8..P12: Fixed 0
  for (let i = 0; i < 5; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed (1)
  bytes.push(0x01);

  return {
    fPort: input.fPort || 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX WINDOW 600-065" expandable="true" %}

```
// 600-065 TX WINDOW
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame:
// 000037260302000000000000000000000007000000000000000000000028
//
// Expected payload: 30 bytes
//  0..2  : Transmitter ID (24 bits, BE)
//  3     : Type (TX Type)
//  4     : Sequential Counter
//  5     : F/W (bits 5-0)
// 16..17 : Window OC count (UINT16, BE)
// 26..27 : Alarm Status (UINT16, bitfield) -> not used
// 28..29 : Status (UINT16, bitfield; bits 3-2 = battery, bit5 = window open)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 30) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 30.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3f;

  // Window open count
  const windowSensorCount = readUInt16BE(bytes.slice(16, 18));

  const alarmStatus = readUInt16BE(bytes.slice(26, 28));
  void alarmStatus; // not used

  const status = readUInt16BE(bytes.slice(28, 30));

  // Battery → bits 3-2 -> 100,75,50,25
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  // Window open: bit5 → return 0/1
  const windowStatus = (status & 0x0020) ? 1 : 0;

  return {
    data: {
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,

      // ---- Boolean converted to 0/1 ----
      windowStatus,

      windowSensorCount
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// encodeDownlink({
//   fPort: 1,
//   data: {
//     tx_period: 60,
//     detection_sensitivity: 1   // 0..2
//   }
// })
//
// No MotionGuard, no RBE, no configurable sampling_period.

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  // ---- tx_period ----
  let txPeriodMinutes = data.tx_period;
  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  // ---- Window sensitivity ----
  let detSens =
    data.detection_sensitivity == null ?
    0 :
    Number(data.detection_sensitivity);

  if (Number.isNaN(detSens)) {
    errors.push("data.detection_sensitivity must be a number (0..2).");
    detSens = 0;
  }
  if (detSens < 0 || detSens > 2) {
    errors.push("data.detection_sensitivity must be between 0 and 2.");
  }
  detSens = Math.max(0, Math.min(2, Math.round(detSens)));

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16)
  const txSeconds = Math.round(txPeriodMinutes * 60);
  if (txSeconds < 0 || txSeconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${txSeconds} s is out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed (0)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0
  bytes.push(0x00, 0x00);

  // RE-Tx Time
  bytes.push((txSeconds >> 8) & 0xff, txSeconds & 0xff);

  // Sensor sampling -> FIXED at 0
  bytes.push(0x00, 0x00);

  // RBE / MG / LED -> always 0x0000
  bytes.push(0x00, 0x00);

  // P1 = detection sensitivity
  bytes.push(0x00, detSens & 0xff);

  // P2..P12 = 0
  for (let i = 0; i < 11; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed(1)
  bytes.push(0x01);

  return {
    fPort: input.fPort || 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX T\&H 600-021" expandable="true" %}

```
// 600-021 Ambient T&H Sensor
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Expected payload: 18 bytes
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
//  6..7 : Temperature (INT16, /10)
//  8..9 : Humidity (UINT16, /10)
// 16..17: Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 18) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 18.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(8, 10)) / 10;

  const status = readUInt16BE(bytes.slice(16, 18));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  return {
    data: {
      // Fields mapped in mapping.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // or another, chosen on the LNS side
//   data: { tx_period: 30 }   // in minutes
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2  bytes : 0x0000    (Fixed / Enter 1 or 0 -> left at 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 bytes = 0x0000 (thresholds left at 0)
//  - 1  byte  : 0x01      (Fixed 1)
//
// This format exactly reproduces the following hex string
// for tx_period = 30 min:
// 00000003000007080000000000000000000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000 (alarm thresholds ignored as requested)
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}

```

{% endcode %}

{% code title="TX VOC T\&H 600-022" expandable="true" %}

```
// 600-022 Ambient T&H / VOC Sensor
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Expected payload: 18 bytes
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
//  6..7 : Temperature (INT16, /10)
//  8..9 : Humidity (UINT16, /10)
// 10..11: VOC (UINT16, ppb)
// 14..15: Alarm Status (UINT16, not used here)
// 16..17: Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 18) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 18.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(8, 10)) / 10;

  const voc = readUInt16BE(bytes.slice(10, 12));       // ppb

  const status = readUInt16BE(bytes.slice(16, 18));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  return {
    data: {
      // Fields mapped in mapping_600022.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,
      voc
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // or another, chosen on the LNS side
//   data: { tx_period: 30 }   // in minutes
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2  bytes : 0x0000    (Fixed / Enter 1 or 0 -> left at 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 bytes = 0x0000 (thresholds left at 0)
//  - 1  byte  : 0x01      (Fixed 1)
//
// This format exactly reproduces the following hex string
// for tx_period = 30 min:
// 00000003000007080000000000000000000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000 (alarm thresholds ignored as requested)
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}

```

{% endcode %}

{% code title="TX CO2 VOC T\&H 600-023" expandable="true" %}

```
// 600-023 Ambient T&H / VOC / CO2 Sensor
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Expected payload: 18 bytes
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
//  6..7 : Temperature (INT16, /10)
//  8..9 : Humidity (UINT16, /10)
// 10..11: VOC (UINT16, ppb)
// 12..13: CO2 (UINT16, ppm)
// 14..15: Alarm Status (UINT16, not used here)
// 16..17: Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 18) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 18.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(8, 10)) / 10;

  const voc = readUInt16BE(bytes.slice(10, 12));   // ppb
  const co2 = readUInt16BE(bytes.slice(12, 14));   // ppm

  const status = readUInt16BE(bytes.slice(16, 18));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  return {
    data: {
      // Fields mapped in mapping_600023.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,
      voc,
      co2
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // or another, chosen on the LNS side
//   data: { tx_period: 30 }   // in minutes
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2  bytes : 0x0000    (Fixed / Enter 1 or 0 -> left at 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 bytes = 0x0000 (thresholds left at 0)
//  - 1  byte  : 0x01      (Fixed 1)
//
// This format exactly reproduces the following hex string
// for tx_period = 30 min:
// 00000003000007080000000000000000000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000 (alarm thresholds ignored as requested)
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
	fPort: 1,
    bytes
  };
}

```

{% endcode %}

{% code title="TX TEMP INS 600-031" expandable="true" %}

```
// 600-031 Temp Ins Sensor
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame: 00008A07071200CD000000020000
// Expected payload: 14 bytes
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
//  6..7 : Temperature (INT16, /10)
// 12..13: Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 14) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 14.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;


  const status = readUInt16BE(bytes.slice(12, 14));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  return {
    data: {
      // Fields mapped in mapping_600031.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // or another, chosen on the LNS side
//   data: { tx_period: 30 }   // in minutes
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2  bytes : 0x0000    (Fixed / Enter 1 or 0 -> left at 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 bytes = 0x0000 (thresholds left at 0)
//  - 1  byte  : 0x01      (Fixed 1)
//
// This format exactly reproduces the following hex string
// for tx_period = 30 min:
// 00000003000007080000000000000000000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000 (alarm thresholds ignored as requested)
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX TEMP CONT1 600-032" expandable="true" %}

```
// 600-032 Temp CONT1
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame: 00008A07071200CD000000020000
// Expected payload: 14 bytes
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
//  6..7 : Temperature (INT16, /10)
// 12..13: Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 14) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 14.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;


  const status = readUInt16BE(bytes.slice(12, 14));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  return {
    data: {
      // Fields mapped in mapping_600032.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // or another, chosen on the LNS side
//   data: { tx_period: 30 }   // in minutes
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2  bytes : 0x0000    (Fixed / Enter 1 or 0 -> left at 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 bytes = 0x0000 (thresholds left at 0)
//  - 1  byte  : 0x01      (Fixed 1)
//
// This format exactly reproduces the following hex string
// for tx_period = 30 min:
// 00000003000007080000000000000000000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000 (alarm thresholds ignored as requested)
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX TEMP CONT2 600-232" expandable="true" %}

```
// 600-232 Temp CONT2
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame: 0000BD0C0A1200CE00CA00010000
// Expected payload: 14 bytes
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
//  6..7 : Temperature 1 (INT16, /10)
//  8..9 : Temperature 2 (INT16, /10)
// 12..13: Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 14) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 14.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature1 = readInt16BE(bytes.slice(6, 8)) / 10;
  const temperature2 = readInt16BE(bytes.slice(8, 10)) / 10;


  const status = readUInt16BE(bytes.slice(12, 14));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  return {
    data: {
      // Fields mapped in mapping_600232.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature1,
      temperature2
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // or another, chosen on the LNS side
//   data: { tx_period: 30 }   // in minutes
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2  bytes : 0x0000    (Fixed / Enter 1 or 0 -> left at 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 bytes = 0x0000 (thresholds left at 0)
//  - 1  byte  : 0x01      (Fixed 1)
//
// This format exactly reproduces the following hex string
// for tx_period = 30 min:
// 00000003000007080000000000000000000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000 (alarm thresholds ignored as requested)
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX T\&H EXT 600-034" expandable="true" %}

```
// 600-034 External Tx T&H Sensor
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame: 0000D10E0A1200CA018200010000
// Expected payload: 14 bytes
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
//  6..7 : Temperature (INT16, /10)
//  8..9 : Humidity (UINT16, /10)
// 12..13: Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 14) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 14.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(8, 10)) / 10;

  // Alarm Status present but not used for now
  // const alarmStatus = readUInt16BE(bytes.slice(10, 12));

  const status = readUInt16BE(bytes.slice(12, 14));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  return {
    data: {
      // Fields mapped in mapping_600034.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // or another, chosen on the LNS side
//   data: { tx_period: 30 }   // in minutes
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2  bytes : 0x0000    (Fixed / Enter 1 or 0 -> left at 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 bytes = 0x0000 (thresholds left at 0)
//  - 1  byte  : 0x01      (Fixed 1)
//
// This format exactly reproduces the following hex string
// for tx_period = 30 min:
// 00000003000007080000000000000000000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000 (alarm thresholds ignored as requested)
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX 4/20mA 600-035" expandable="true" %}

```
// 600-035 4-20 mA Tx
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame: 0000C70D0A12000200020000
// Expected payload: 12 bytes
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
//  6..7 : Current 4–20 mA (UINT16, /1000, in mA)
// 10..11: Status (UINT16, bitfield; bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 12) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 12.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  // 4-20 mA value: example 0x0002 -> 0.002 mA
  const current = readUInt16BE(bytes.slice(6, 8)) / 1000;

  // Alarm status present but not used
  // const alarmStatus = readUInt16BE(bytes.slice(8, 10));

  const status = readUInt16BE(bytes.slice(10, 12));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  return {
    data: {
      // Fields mapped in mapping_600035.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      current
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,
//   data: {
//     tx_period: 30,      // minutes  (required)
//     loop_period: 500    // msecs    (optional, 0 by default)
//   }
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2  bytes : 0x0000    (Fixed / Enter 1 or 0 -> left at 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1        : 0x0000    (4-20 Hi Alarm, not used)
//  - P2        : 0x0000    (4-20 Lo Alarm, not used)
//  - P3        : loop_period (msecs, UINT16 BE)
//  - P4..P12   : 0x0000    (fixed)
//  - 1  byte  : 0x01      (Fixed 1)

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  // ----- tx_period (minutes) -----
  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  // ----- loop_period (msecs) -----
  let loopPeriodMs = data.loop_period;
  if (loopPeriodMs == null) {
    loopPeriodMs = 0; // default
  } else {
    if (typeof loopPeriodMs !== "number") {
      loopPeriodMs = Number(loopPeriodMs);
    }
    if (Number.isNaN(loopPeriodMs)) {
      errors.push("data.loop_period must be a number (msecs).");
    } else if (loopPeriodMs < 0 || loopPeriodMs > 65535) {
      errors.push("data.loop_period must be between 0 and 65535 msecs.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // ----- P1 : 4-20 Hi Alarm -> 0 -----
  bytes.push(0x00, 0x00);

  // ----- P2 : 4-20 Lo Alarm -> 0 -----
  bytes.push(0x00, 0x00);

  // ----- P3 : Loop Power / Loop Period (msecs) -----
  bytes.push((loopPeriodMs >> 8) & 0xff, loopPeriodMs & 0xff);

  // ----- P4..P12 : 0x0000 -----
  for (let i = 0; i < 9; i++) { // 9 remaining registers (P4 to P12)
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX PULSE 600-036" expandable="true" %}

```
// 600-036 Tx Pulse
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readUInt32BE(bytes) {
  return (
    (bytes[0] << 24) >>> 0 |
    (bytes[1] << 16) |
    (bytes[2] << 8) |
    bytes[3]
  ) >>> 0;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame:
// 000095080C1200000020000000170000001E00000000
//
// Expected payload: 22 bytes
//  0..2  : Transmitter ID (24 bits, BE)
//  3     : Type (TX Type)
//  4     : Sequential Counter
//  5     : F/W (bits 5-0)
//  6..9  : Ch1 count (UINT32, BE)
// 10..13 : Ch2 count (UINT32, BE)
// 14..17 : OC count  (UINT32, BE)
// 20..21 : Status (UINT16, bitfield)
//
//  - bits 3-2 : battery level
//  - bits 5,6,7 : input states (Ch1/Ch2/OC)
//  - bits 8,9,10 : debounce states (Ch1/Ch2/OC)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 22) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 22.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const ch1Count = readUInt32BE(bytes.slice(6, 10));
  const ch2Count = readUInt32BE(bytes.slice(10, 14));
  const ocCount  = readUInt32BE(bytes.slice(14, 18));

  const alarmStatus = readUInt16BE(bytes.slice(18, 20)); // eslint-disable-line no-unused-vars

  const status = readUInt16BE(bytes.slice(20, 22));

  // ------- Battery : bits 3-2 -------
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  // ------- Status / debounce bits -------
  // Input state bits (Open/Closed)
  const CH1_STATUS_BIT = 5;
  const CH2_STATUS_BIT = 6;
  const OC_STATUS_BIT  = 7;

  // Debounce status bits (0 = DIS, 1 = EN)
  const DEBOUNCE1_BIT = 8;
  const DEBOUNCE2_BIT = 9;
  const DEBOUNCE3_BIT = 10;

  // States 0/1 instead of true/false
  const ch1Status = (status >> CH1_STATUS_BIT) & 0x01;
  const ch2Status = (status >> CH2_STATUS_BIT) & 0x01;
  const ocStatus  = (status >> OC_STATUS_BIT)  & 0x01;

  // Debounce : 0/1 values (ON/OFF)
  const debounce1 = (status >> DEBOUNCE1_BIT) & 0x01;
  const debounce2 = (status >> DEBOUNCE2_BIT) & 0x01;
  const debounce3 = (status >> DEBOUNCE3_BIT) & 0x01;

  return {
    data: {
      // Main fields (see mapping_600036.json)
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,

      // States + counters (states in 0/1)
      ch1Status,
      ch1Count,
      ch2Status,
      ch2Count,
      ocStatus,
      ocCount,

      // Debounce status (0 = DIS, 1 = EN)
      debounce1,
      debounce2,
      debounce3
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// tx_period (packet=3) or debounce (packet=22)

function encodeDownlink(input) {
  const data = input.data || {};

  const hasDebounce =
    data.debounce1_count != null ||
    data.debounce2_count != null ||
    data.debounce3_count != null;

  if (hasDebounce) {
    return encodeDebouncePacket(data);
  } else {
    return encodeTxPeriodPacket(data);
  }
}

// ----- Sub-function: tx_period frame (installation packet = 3) -----

function encodeTxPeriodPacket(data) {
  const errors = [];
  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}

// ----- Sub-function: debounce frame (installation packet = 22) -----

function encodeDebouncePacket(data) {
  const errors = [];

  let d1 = data.debounce1_count != null ? data.debounce1_count : 0;
  let d2 = data.debounce2_count != null ? data.debounce2_count : 0;
  let d3 = data.debounce3_count != null ? data.debounce3_count : 0;

  function normDeb(val, name) {
    if (typeof val !== "number") {
      val = Number(val);
    }
    if (Number.isNaN(val)) {
      errors.push(`data.${name} must be a number (count).`);
      return 0;
    }
    if (val < 0 || val > 10) {
      errors.push(`data.${name} must be between 0 and 10.`);
    }
    return Math.max(0, Math.min(10, Math.round(val)));
  }

  d1 = normDeb(d1, "debounce1_count");
  d2 = normDeb(d2, "debounce2_count");
  d3 = normDeb(d3, "debounce3_count");

  if (errors.length) {
    return { errors };
  }

  const bytes = [];

  // Fixed(0) : 3 bytes
  bytes.push(0x00, 0x00, 0x00);

  // Pulse debounce installation packet = 22 -> 0x16
  bytes.push(0x16);

  // Fixed(0) : 2 bytes
  bytes.push(0x00, 0x00);

  // Byte count = 5 : 0x0005
  bytes.push(0x00, 0x05);

  // Configured Tx type = 4 -> 0x04
  bytes.push(0x04);

  // PD1 : Ch1 debounce count
  bytes.push(0x00, d1 & 0xff);

  // PD2 : Ch2 debounce count
  bytes.push(0x00, d2 & 0xff);

  // PD3 : OC debounce count
  bytes.push(0x00, d3 & 0xff);

  // Extra = 0
  bytes.push(0x00, 0x00);

  // Fixed(1)
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX PULSE ATEX 600-037" expandable="true" %}

```
// 600-037 Tx Pulse ATEX
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readUInt32BE(bytes) {
  return (
    (bytes[0] << 24) >>> 0 |
    (bytes[1] << 16) |
    (bytes[2] << 8) |
    bytes[3]
  ) >>> 0;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame:
// 000095080C1200000020000000170000001E00000000
//
// Expected payload: 22 bytes
//  0..2  : Transmitter ID (24 bits, BE)
//  3     : Type (TX Type)
//  4     : Sequential Counter
//  5     : F/W (bits 5-0)
//  6..9  : Ch1 count (UINT32, BE)
// 10..13 : Ch2 count (UINT32, BE)
// 14..17 : OC count  (UINT32, BE)
// 20..21 : Status (UINT16, bitfield)
//
//  - bits 3-2 : battery level
//  - bits 5,6,7 : input states (Ch1/Ch2/OC)
//  - bits 8,9,10 : debounce states (Ch1/Ch2/OC)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 22) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 22.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3F;

  const ch1Count = readUInt32BE(bytes.slice(6, 10));
  const ch2Count = readUInt32BE(bytes.slice(10, 14));
  const ocCount  = readUInt32BE(bytes.slice(14, 18));

  const alarmStatus = readUInt16BE(bytes.slice(18, 20)); // eslint-disable-line no-unused-vars

  const status = readUInt16BE(bytes.slice(20, 22));

  // ------- Battery : bits 3-2 -------
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  // ------- Status / debounce bits -------
  // Input state bits (Open/Closed)
  const CH1_STATUS_BIT = 5;
  const CH2_STATUS_BIT = 6;
  const OC_STATUS_BIT  = 7;

  // Debounce status bits (0 = DIS, 1 = EN)
  const DEBOUNCE1_BIT = 8;
  const DEBOUNCE2_BIT = 9;
  const DEBOUNCE3_BIT = 10;

  // States in 0/1 instead of true/false
  const ch1Status = (status >> CH1_STATUS_BIT) & 0x01;
  const ch2Status = (status >> CH2_STATUS_BIT) & 0x01;
  const ocStatus  = (status >> OC_STATUS_BIT)  & 0x01;

  // Debounce : 0/1 values (ON/OFF)
  const debounce1 = (status >> DEBOUNCE1_BIT) & 0x01;
  const debounce2 = (status >> DEBOUNCE2_BIT) & 0x01;
  const debounce3 = (status >> DEBOUNCE3_BIT) & 0x01;

  return {
    data: {
      // Main fields (see mapping_600036/600037.json)
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,

      // States + counters
      ch1Status,
      ch1Count,
      ch2Status,
      ch2Count,
      ocStatus,
      ocCount,

      // Debounce status (0 = DIS, 1 = EN)
      debounce1,
      debounce2,
      debounce3
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Two types of frames:
//  1) "standard" frame (installation packet = 3) for tx_period
//  2) "pulse debounce" frame (installation packet = 22) for debounce1/2/3
//
// Rule:
//  - if at least one of the debounce*_count fields is present → debounce frame
//  - otherwise → tx_period frame
//
// ---- 1) tx_period frame (packet = 3)
//
// encodeDownlink({ fPort: 1, data: { tx_period: 30 } })
//
// ---- 2) debounce frame (packet = 22, type = 5 ATEX)
//
// encodeDownlink({
//   fPort: 1,
//   data: {
//     debounce1_count: 5,
//     debounce2_count: 5,
//     debounce3_count: 3
//   }
// })
//
// Excel example for ATEX:
// 000000160000000505000500040003000001

function encodeDownlink(input) {
  const data = input.data || {};

  const hasDebounce =
    data.debounce1_count != null ||
    data.debounce2_count != null ||
    data.debounce3_count != null;

  if (hasDebounce) {
    return encodeDebouncePacketAtex(data);
  } else {
    return encodeTxPeriodPacket(data);
  }
}

// ----- Sub-function: tx_period frame (installation packet = 3) -----

function encodeTxPeriodPacket(data) {
  const errors = [];
  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}

// ----- Sub-function: ATEX debounce frame (packet = 22, type = 5) -----

function encodeDebouncePacketAtex(data) {
  const errors = [];

  let d1 = data.debounce1_count != null ? data.debounce1_count : 0;
  let d2 = data.debounce2_count != null ? data.debounce2_count : 0;
  let d3 = data.debounce3_count != null ? data.debounce3_count : 0;

  function normDeb(val, name) {
    if (typeof val !== "number") {
      val = Number(val);
    }
    if (Number.isNaN(val)) {
      errors.push(`data.${name} must be a number (count).`);
      return 0;
    }
    if (val < 0 || val > 10) {
      errors.push(`data.${name} must be between 0 and 10.`);
    }
    return Math.max(0, Math.min(10, Math.round(val)));
  }

  d1 = normDeb(d1, "debounce1_count");
  d2 = normDeb(d2, "debounce2_count");
  d3 = normDeb(d3, "debounce3_count");

  if (errors.length) {
    return { errors };
  }

  const bytes = [];

  // Fixed(0) : 3 bytes
  bytes.push(0x00, 0x00, 0x00);

  // Pulse debounce installation packet = 22 -> 0x16
  bytes.push(0x16);

  // Fixed(0) : 2 bytes
  bytes.push(0x00, 0x00);

  // Byte count = 5 : 0x0005
  bytes.push(0x00, 0x05);

  // Configured Tx type = 5 (ATEX) -> 0x05
  bytes.push(0x05);

  // PD1 : Ch1 debounce count
  bytes.push(0x00, d1 & 0xff);

  // PD2 : Ch2 debounce count
  bytes.push(0x00, d2 & 0xff);

  // PD3 : OC debounce count
  bytes.push(0x00, d3 & 0xff);

  // Extra = 0
  bytes.push(0x00, 0x00);

  // Fixed(1)
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX PULSE LED 600-038" expandable="true" %}

```
// 600-038 Tx Pulse LED
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const val = readUInt16BE(bytes);
  return val > 0x7fff ? val - 0x10000 : val;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

function readUInt32BE(bytes) {
  return (
    (bytes[0] << 24) +
    (bytes[1] << 16) +
    (bytes[2] << 8) +
    bytes[3]
  ) >>> 0; // force unsigned
}

// ---------- UPLINK DECODER ----------
//
// Example frame: 0000AA0A0D1200000000000000000000000A00000000
// Expected payload: 22 bytes
//  0..2 : Transmitter ID (24 bits, BE)
//  3    : Type (TX Type)
//  4    : Sequential Counter
//  5    : F/W (bits 5-0)
// 14..17: Pulse OC (UINT32, total pulses -> ocCount)
// 20..21: Status (UINT16, bits 3-2 = battery level)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 22) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 22.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5] & 0x3f;


  const ocCount = readUInt32BE(bytes.slice(14, 18));

  const status = readUInt16BE(bytes.slice(20, 22));

  // Battery: bits 3-2 of the status word
  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits] || null;

  return {
    data: {
      // Fields mapped in mapping_600038.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      ocCount
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// No debounce on 600-038: we only handle the transmission period.
//
// ChirpStack-side usage example:
//
// encodeDownlink({
//   fPort: 1,              // or left empty, we will return 1 by default
//   data: { tx_period: 30 }  // in minutes
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2  bytes : 0x0000    (Fixed / Enter 1 or 0 -> left at 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 bytes = 0x0000 (thresholds left at 0)
//  - 1  byte  : 0x01      (Fixed 1)
//
// For tx_period = 30 min, we get:
// 00000003000007080000000000000000000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  let txPeriodMinutes = data.tx_period;

  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> left at 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1..P12 = 0x0000 (alarm thresholds ignored)
  for (let i = 0; i < 12; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: input.fPort != null ? input.fPort : 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX CONTACT 600-039" expandable="true" %}

```
// 600-039 Tx Contact
// ChirpStack codec: decodeUplink + encodeDownlink
// -------------------------------------------------
// ---------- Helpers ----------

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readUInt32BE(bytes) {
  return (
    (bytes[0] << 24) +
    (bytes[1] << 16) +
    (bytes[2] << 8)  +
    bytes[3]
  ) >>> 0; // force unsigned
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

// ---------- UPLINK DECODER ----------
//
// Example frame:
//   0000B40B0E1200000018000000280000005600070061
//
// Expected payload: 22 bytes
//  0..2  : Transmitter ID (24 bits, BE)
//  3     : Type (TX Type)
//  4     : Sequential Counter
//  5     : F/W (bits 5-0)
//  6..9  : Ch1 Count (UINT32, BE)
// 10..13 : Ch2 Count (UINT32, BE)
// 14..17 : OC Count  (UINT32, BE)
// 20..21 : Status (UINT16, bitfield; bits 3-2 = battery level,
//                 bits 4..6 = contact states according to the documentation)

function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 22) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 22.`
      ]
    };
  }

  const id         = readUInt24BE(bytes.slice(0, 3));
  const type       = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion  = bytes[5] & 0x3F;

  const ch1Count = readUInt32BE(bytes.slice(6, 10));
  const ch2Count = readUInt32BE(bytes.slice(10, 14));
  const ocCount  = readUInt32BE(bytes.slice(14, 18));

  const status = readUInt16BE(bytes.slice(20, 22));

  // Battery: bits 3-2 of the status word
  const batteryBits   = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel  = batteryLevels[batteryBits] || null;

  // Contact states
  // Interpretation: bits 4,5,6 -> Ch1, Ch2, OC (1 = closed / active)
  // We return 0/1 instead of true/false
  const ch1Status = (status >> 5) & 0x01;
  const ch2Status = (status >> 6) & 0x01;
  const ocStatus  = (status >> 7) & 0x01;

  return {
    data: {
      // Fields mapped in mapping_600039.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      ch1Status,
      ch1Count,
      ch2Status,
      ch2Count,
      ocStatus,
      ocCount
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Supported parameters on the ChirpStack side:
//
// encodeDownlink({
//   fPort: 1, // or left in the device profile
//   data: {
//     tx_period: 5,        // in minutes (required)
//     input1_period: 5,    // in seconds (P1 - optional, default 0)
//     input2_period: 10    // in seconds (P2 - optional, default 0)
//   }
// })
//
// Generated frame (37 bytes):
//  - 3  bytes : 0x000000 (Fixed 0)
//  - 1  byte  : 0x03      (Installation Packet = 3)
//  - 2  bytes : 0x0000    (Fixed 0)
//  - 2  bytes : RE-Tx time in seconds (minutes * 60, BE)
//  - 2 bytes : 0x0000    (Enter 1 or 0 -> 0)
//  - 2  bytes : 0x0000    (TWU (hrs) -> 0)
//  - P1        : Input 1 delay before Tx (secs)  -> UINT16 BE
//  - P2        : Input 2 delay before Tx (secs)  -> UINT16 BE
//  - P3..P12   : 0x0000 (unused)
//  - 1  byte  : 0x01      (Fixed 1)
//
// Excel sheet example (tx_period=5, P1=5, P2=10):
// 000000030000012C000000000005000A000000000000000000000000000000000000000001

function encodeDownlink(input) {
  const data = input.data || {};
  const errors = [];

  // ---- tx_period (minutes) ----
  let txPeriodMinutes = data.tx_period;
  if (txPeriodMinutes == null) {
    errors.push("Missing required field: data.tx_period (minutes).");
  } else {
    if (typeof txPeriodMinutes !== "number") {
      txPeriodMinutes = Number(txPeriodMinutes);
    }
    if (Number.isNaN(txPeriodMinutes)) {
      errors.push("data.tx_period must be a number (minutes).");
    } else if (txPeriodMinutes < 1 || txPeriodMinutes > 720) {
      errors.push("data.tx_period must be between 1 and 720 minutes.");
    }
  }

  // ---- input1_period (secs, P1) ----
  let input1Period = data.input1_period;
  if (input1Period == null) {
    input1Period = 0;
  } else {
    if (typeof input1Period !== "number") {
      input1Period = Number(input1Period);
    }
    if (Number.isNaN(input1Period)) {
      errors.push("data.input1_period must be a number (seconds).");
    } else if (input1Period < 0 || input1Period > 60) {
      errors.push("data.input1_period must be between 0 and 60 seconds.");
    }
  }

  // ---- input2_period (secs, P2) ----
  let input2Period = data.input2_period;
  if (input2Period == null) {
    input2Period = 0;
  } else {
    if (typeof input2Period !== "number") {
      input2Period = Number(input2Period);
    }
    if (Number.isNaN(input2Period)) {
      errors.push("data.input2_period must be a number (seconds).");
    } else if (input2Period < 0 || input2Period > 60) {
      errors.push("data.input2_period must be between 0 and 60 seconds.");
    }
  }

  if (errors.length) {
    return { errors };
  }

  // Conversion minutes -> seconds (UINT16 BE)
  const seconds = Math.round(txPeriodMinutes * 60);
  if (seconds < 0 || seconds > 0xffff) {
    return {
      errors: [
        `tx_period=${txPeriodMinutes} min -> ${seconds} s, out of 16-bit range.`
      ]
    };
  }

  // P1 / P2 in UINT16
  const p1 = Math.round(input1Period);
  const p2 = Math.round(input2Period);

  const bytes = [];

  // Fixed 0 (3 bytes)
  bytes.push(0x00, 0x00, 0x00);

  // Installation Packet = 3
  bytes.push(0x03);

  // Fixed 0 (2 bytes)
  bytes.push(0x00, 0x00);

  // RE-Tx Time (seconds) : big-endian
  bytes.push((seconds >> 8) & 0xff, seconds & 0xff);

  // Fixed / Enter 1 or 0 -> 0
  bytes.push(0x00, 0x00);

  // TWU (hrs) -> 0
  bytes.push(0x00, 0x00);

  // P1: Input 1 delay before Tx (secs)
  bytes.push((p1 >> 8) & 0xff, p1 & 0xff);

  // P2: Input 2 delay before Tx (secs)
  bytes.push((p2 >> 8) & 0xff, p2 & 0xff);

  // P3..P12 = 0x0000
  for (let i = 0; i < 10; i++) {
    bytes.push(0x00, 0x00);
  }

  // Fixed 1
  bytes.push(0x01);

  return {
    fPort: 1,
    bytes
  };
}
```

{% endcode %}

{% code title="TX VALVE 600-060" expandable="true" %}

```
// 600-060 Thermostatic Valve
// ChirpStack codec: decodeUplink + encodeDownlink

function readUInt16BE(bytes) {
  return (bytes[0] << 8) + bytes[1];
}

function readInt16BE(bytes) {
  const v = readUInt16BE(bytes);
  return v > 0x7fff ? v - 0x10000 : v;
}

function readUInt24BE(bytes) {
  return (bytes[0] << 16) + (bytes[1] << 8) + bytes[2];
}

function writeUInt16BE(arr, v) {
  arr.push((v >> 8) & 0xff, v & 0xff);
}

function clampInt16(v) {
  v = Math.round(v);
  if (v < -32768) return -32768;
  if (v > 32767) return 32767;
  return v;
}

function clampUInt16(v) {
  v = Math.round(v);
  if (v < 0) return 0;
  if (v > 65535) return 65535;
  return v;
}

function clampPercent(v) {
  v = Math.round(v);
  if (v < 0) return 0;
  if (v > 100) return 100;
  return v;
}

function to01(v) {
  if (v === 1 || v === "1" || v === true) return 1;
  return 0;
}

// -------------------------------------------------
// UPLINK - Periodic - 30 bytes
// -------------------------------------------------
function decodeUplink(input) {
  const bytes = input.bytes;

  if (!bytes || bytes.length !== 30) {
    return {
      errors: [
        `Unsupported payload length: ${bytes ? bytes.length : 0} bytes. Expected 30.`
      ]
    };
  }

  const id = readUInt24BE(bytes.slice(0, 3));
  const type = bytes[3];
  const seqCounter = bytes[4];
  const fwVersion = bytes[5];

  const temperature = readInt16BE(bytes.slice(6, 8)) / 10;
  const humidity = readUInt16BE(bytes.slice(8, 10)) / 10;
  const currentSetpoint = readInt16BE(bytes.slice(10, 12)) / 10;
  const externalTemperature = readInt16BE(bytes.slice(12, 14)) / 10;

  // byte 14..15 = physical motorPosition, mapped
  // byte 16..17 = motorTargetPosition, not mapped
  const motorPosition = readUInt16BE(bytes.slice(14, 16));

  const operationSettings = readUInt16BE(bytes.slice(26, 28));
  const status = readUInt16BE(bytes.slice(28, 30));

  const batteryBits = (status >> 2) & 0x03;
  const batteryLevels = [100, 75, 50, 25];
  const batteryLevel = batteryLevels[batteryBits];

  const summerModeBit = (operationSettings & (1 << 10)) ? 1 : 0;
  const controlModeBit = (operationSettings & (1 << 13)) ? 1 : 0;

  // 0 = Temperature control
  // 1 = Motor position control
  // 2 = Summer mode
  const controlMode = summerModeBit ? 2 : controlModeBit;

  const displayStatus = (status & (1 << 0)) ? 1 : 0;
  const seizureStatus = (status & (1 << 4)) ? 1 : 0;
  const childLockStatus = (status & (1 << 5)) ? 1 : 0;
  const windowStatus = (status & (1 << 6)) ? 1 : 0;
  const extTempSensorStatus = (status & (1 << 7)) ? 1 : 0;
  const setpointToleranceStatus = (status & (1 << 8)) ? 1 : 0;
  const hydronicBalancingStatus = (status & (1 << 9)) ? 1 : 0;
  const calibrationStatus = (status & (1 << 11)) ? 1 : 0;
  const motorStatus = (status & (1 << 12)) ? 1 : 0;

  return {
    data: {
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,

      temperature,
      humidity,
      currentSetpoint,
      motorPosition,
      controlMode,

      setpointToleranceStatus,
      hydronicBalancingStatus,
      windowStatus,
      childLockStatus,
      seizureStatus,
      calibrationStatus,
      motorStatus,
      displayStatus,
      extTempSensorStatus,
      externalTemperature
    }
  };
}

// -------------------------------------------------
// DOWNLINK - Installation packet = 3
// -------------------------------------------------
function encodeDownlink(input) {
  const d = input.data || {};
  const errors = [];
  const bytes = [];

  let seconds = 0x0000;
  if (d.tx_period != null) {
    const tx = Number(d.tx_period);
    if (!Number.isFinite(tx) || tx < 1 || tx > 720) {
      errors.push("tx_period must be between 1 and 720 minutes.");
    } else {
      seconds = Math.round(tx * 60);
    }
  }

  let op = 0;

  if (to01(d.display)) op |= 1 << 0;
  if (to01(d.seizureDetectionEnable)) op |= 1 << 4;
  if (to01(d.childLockEnable)) op |= 1 << 5;
  if (to01(d.windowDetectionEnable)) op |= 1 << 6;
  if (to01(d.externalTemperatureSensorEnable)) op |= 1 << 7;
  if (to01(d.setpointToleranceEnable)) op |= 1 << 8;
  if (to01(d.hydronicBalancingEnable)) op |= 1 << 9;

  if (d.controlMode == null) {
    errors.push("Missing required field: controlMode (0=Temperature control, 1=Motor position control, 2=Summer mode).");
  } else {
    const cm = Number(d.controlMode);

    if (!Number.isFinite(cm) || !Number.isInteger(cm) || cm < 0 || cm > 2) {
      errors.push("controlMode must be 0 (Temperature control), 1 (Motor position control) or 2 (Summer mode).");
    } else if (cm === 1) {
      op |= 1 << 13;
    } else if (cm === 2) {
      op |= 1 << 10;
    }
  }

  let p1 = 0x0000;
  if (d.setpoint != null) {
    p1 = clampUInt16(clampInt16(Number(d.setpoint) * 10) & 0xffff);
  }

  let p2 = 0x0000;
  if (d.tolerance != null) {
    p2 = clampUInt16(Number(d.tolerance) * 10);
  }

  const p3 = 0x0000;
  const p4 = 0x0000;

  let p5 = 0x0000;
  if (d.targetPosition != null) {
    p5 = clampUInt16(clampPercent(Number(d.targetPosition)));
  }

  let p6 = 0x0000;
  if (d.setpointOffset != null) {
    p6 = clampUInt16(clampInt16(Number(d.setpointOffset) * 10) & 0xffff);
  }

  let p7 = 0x0000;
  if (d.maximumOpening != null) {
    p7 = clampUInt16(clampPercent(Number(d.maximumOpening)));
  }

  let p8 = 0x0000;
  if (d.minimumOpening != null) {
    p8 = clampUInt16(clampPercent(Number(d.minimumOpening)));
  }

  const p9 = 0x0000;
  const p10 = 0x0000;

  let p11 = 0x0000;
  if (d.externalTemperature != null) {
    p11 = clampUInt16(clampInt16(Number(d.externalTemperature) * 10) & 0xffff);
  }

  let p12 = 0x0000;
  if (d.gatewayTimeout != null) {
    p12 = clampUInt16(Number(d.gatewayTimeout) * 60);
  }

  if (errors.length) {
    return { errors };
  }

  bytes.push(0x00, 0x00, 0x00);
  bytes.push(0x03);
  bytes.push(0x00, 0x00);

  writeUInt16BE(bytes, seconds);

  bytes.push(0x00, 0x00);

  writeUInt16BE(bytes, op);

  writeUInt16BE(bytes, p1);
  writeUInt16BE(bytes, p2);
  writeUInt16BE(bytes, p3);
  writeUInt16BE(bytes, p4);
  writeUInt16BE(bytes, p5);
  writeUInt16BE(bytes, p6);
  writeUInt16BE(bytes, p7);
  writeUInt16BE(bytes, p8);
  writeUInt16BE(bytes, p9);
  writeUInt16BE(bytes, p10);
  writeUInt16BE(bytes, p11);
  writeUInt16BE(bytes, p12);

  bytes.push(0x01);

  return {
    fPort: input.fPort || 1,
    bytes
  };
}
```

{% endcode %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://enless.gitbook.io/centre-aide/ressources/ressources-en/lora-sensors/codecs-and-decoding/chirpstack.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
