# 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 ----------
// Longueur attendue: 30 octets
//
//  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 = niveau batterie)

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));

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

  // Alarmes T/H dans Alarm Status (renvoyées en 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: {
      // Champs mappés dans le mapping 600-050
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,
      HighTemperatureAlarm,
      LowTemperatureAlarm,
      HighHumidityAlarm,
      LowHumidityAlarm
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Trame "installation packet = 3" qui transporte:
//
//  - tx_period           (minutes)  -> RE-Tx Time (secs)
//  - sampling_period     (minutes)  -> Sensor sampling period
//  - rbe (0/1)                      -> mot "RBE & LED" (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 seulement (pas de MotionGuard sur 600-050) ----------
  const rbe = !!data.rbe;

  // --------- Seuils d'alarme ----------
  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); // dixièmes de °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); // dixièmes de %
  }

  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) -> secondes (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) -> secondes (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.`
      ]
    };
  }

  // Mot "RBE & LED (bits 15-12)" :
  // bit12 = RBE, bits 13-15 (MotionGuard/LED) toujours à 0 pour le 600-050.
  let rbeLedWord = 0;
  if (rbe) rbeLedWord |= (1 << 12);

  const bytes = [];

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

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

  // Fixed (0) - 2 octets
  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 : seuils (temp/hum) codés sur 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 ----------
//
// Exemple de trame :
// 0144C41F041200E9000001DD000000000000000000000000000000000000
//
// Taille: 30 octets
//
//  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 = niveau batterie)

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));

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

  // --- Alarmes T&H + MotionGuard ---
  // Remarque : le mapping Modbus expose des BOOL pour ces alarmes.
  // Hypothèse raisonnable : bits 0..3 pour T/H, bit 8 pour MotionGuard.
  // On renvoie 0 / 1 (entier) plutôt que 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: {
      // Champs mappés dans mapping_600051.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,

      // Alarmes (bitfield + valeurs 0/1)
      HighTemperatureAlarm,
      LowTemperatureAlarm,
      HighHumidityAlarm,
      LowHumidityAlarm,
      motionGuardAlarm
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// On envoie la trame d’INSTALLATION (packet = 3) qui permet de configurer :
//  - tx_period           : périodicité de trame (min)      -> RE-Tx Time (s)
//  - sampling_period     : période de sampling capteur (min)
//  - rbe                 : Report By Exception (bit 12 du mot "RBE, MotionGuard & LED")
//  - MotionGuard         : enable MotionGuard (bit 13 du même mot)
//  - temp_hi_threshold   : seuil haut température (°C, *10, INT16)
//  - temp_lo_threshold   : seuil bas  température (°C, *10, INT16)
//  - hum_hi_threshold    : seuil haut humidité (%RH, *10, UINT16)
//  - hum_lo_threshold    : seuil bas  humidité (%RH, *10, UINT16)
//
// Format généré (37 octets) :
//  - 3  octets : 0x000000                   (Fixed 0)
//  - 1  octet  : 0x03                      (Installation Packet = 3)
//  - 2  octets : 0x0000                    (Fixed 0)
//  - 2  octets : RE-Tx Time en secondes (min * 60, BE)
//  - 2  octets : Sensor sampling period (min * 60, BE)
//  - 2  octets : RBE/MotionGuard/LED flags (bits 15..12)
//  - P1..P4   : seuils T° / HR (4 * 2 octets)
//  - P5..P12  : 0x0000                      (non utilisés)
//  - 1  octet  : 0x01                      (Fixed 1)
//
// Exemple Excel fourni :
// 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) {
    // valeur par défaut du 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

  // ---------- Seuils ----------
  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 + arrondi
    val = Math.max(min, Math.min(max, val));
    // mise à l'échelle *10 pour coller au mapping (°C et %)
    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 -> secondes (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.`
      ]
    };
  }

  // Mot RBE / MotionGuard / LED (bits 15-12)
  //  - bit13 : MotionGuard
  //  - bit12 : RBE
  //  (bit14 / bit15 : LED / réservé -> 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 ----------
//
// Exemple de trame :
// 0144C41F041200E9000001DD000000000000000000000000000000000000
//
// Taille: 30 octets
//
//  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 = niveau batterie)

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));

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

  // --- Alarmes T&H + MotionGuard ---
  // On renvoie ici 0 / 1 au lieu de 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: {
      // Champs mappés (mapping_600052.json)
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity,

      // Alarmes (valeurs entières 0 / 1)
      HighTemperatureAlarm,
      LowTemperatureAlarm,
      HighHumidityAlarm,
      LowHumidityAlarm,
      motionGuardAlarm
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// On envoie la trame d’INSTALLATION (packet = 3) qui permet de configurer :
//  - tx_period           : périodicité de trame (min)      -> RE-Tx Time (s)
//  - sampling_period     : période de sampling capteur (min)
//  - rbe                 : Report By Exception (bit 12 du mot "RBE, MotionGuard & LED")
//  - MotionGuard         : enable MotionGuard (bit 13 du même mot)
//  - temp_hi_threshold   : seuil haut température (°C, *10, INT16)
//  - temp_lo_threshold   : seuil bas  température (°C, *10, INT16)
//  - hum_hi_threshold    : seuil haut humidité (%RH, *10, UINT16)
//  - hum_lo_threshold    : seuil bas  humidité (%RH, *10, UINT16)
//
// Format généré (37 octets) :
//  - 3  octets : 0x000000                   (Fixed 0)
//  - 1  octet  : 0x03                      (Installation Packet = 3)
//  - 2  octets : 0x0000                    (Fixed 0)
//  - 2  octets : RE-Tx Time en secondes (min * 60, BE)
//  - 2  octets : Sensor sampling period (min * 60, BE)
//  - 2  octets : RBE/MotionGuard/LED flags (bits 15..12)
//  - P1..P4   : seuils T° / HR (4 * 2 octets)
//  - P5..P12  : 0x0000                      (non utilisés)
//  - 1  octet  : 0x01                      (Fixed 1)
//
// Exemple Excel fourni :
// 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) {
    // valeur par défaut du 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

  // ---------- Seuils ----------
  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 + arrondi
    val = Math.max(min, Math.min(max, val));
    // mise à l'échelle *10 pour coller au mapping (°C et %)
    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 -> secondes (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.`
      ]
    };
  }

  // Mot RBE / MotionGuard / LED (bits 15-12)
  //  - bit13 : MotionGuard
  //  - bit12 : RBE
  //  (bit14 / bit15 : LED / réservé -> 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 ----------
//
// Exemple de trame :
// 0000ea253611FF700000003203E800000000000000000000000000020240
//
// Payload attendu: 30 octets
//  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 = niveau batterie)
//           bit 6 = Old CO2 (0 = ancienne mesure, 1 = nouvelle mesure)

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));

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

  // Alarmes CO2 + MotionGuard -> 0 / 1 (et plus bool)
  const HighCO2Alarm     = (alarmStatus & 0x0010) ? 1 : 0; // bit4
  const LowCO2Alarm      = (alarmStatus & 0x0020) ? 1 : 0; // bit5
  const motionGuardAlarm = (alarmStatus & 0x0100) ? 1 : 0; // bit8

  // État de la mesure CO2 : bit 6 du status
  // 1 = nouvelle mesure, 0 = ancienne mesure (Old CO2)
  const CO2Sampled = (status & 0x0040) ? 1 : 0;

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

// ---------- DOWNLINK ENCODER ----------
//
// Trame d’installation "packet = 3"
//
// encodeDownlink({
//   fPort: 1,
//   data: {
//     tx_period: 5,      // minutes (1..720)
//     co2_period: 5,     // minutes (1..720)
//     led: 1,            // 0/1  -> bit 12 du mot flags
//     MotionGuard: 1,    // 0/1  -> bit 13 du mot flags
//     co2_hi_threshold: 800,  // ppm (0..5000)
//     co2_lo_threshold: 400   // ppm (0..5000)
//   }
// })
//
// Exemple Excel :
// 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.");
  }

  // ---- seuils CO2 ----
  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");

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

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

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

  // Conversion minutes -> secondes (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 octets
  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 (ici: LED + MotionGuard seulement)
  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 : tous à 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 ----------
//
// Exemple de trame :
// 0144bf22870c00d7000001dd000000000001000000000000000000000010
//
// Payload attendu: 30 octets
//  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 = niveau batterie)

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;

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

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

  // Luminosité brute (UINT32)
  const rawLuminosity = readUInt32BE(bytes.slice(22, 26));
  // Luminosité en 0/1 pour le mapping Modbus (0 = sombre, 1 = lumière détectée)
  const luminosityStatus = rawLuminosity > 0 ? 1 : 0;

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

  // Batterie: bits 3-2 du mot "status" (idem toutes les 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, mouvement, …) -> pirStatus en 0/1
  const pirStatus = (status & 0x0010) ? 1 : 0; // "Movement detected"

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

      // Compteur PIR (si tu veux l'exposer plus tard)
      pirCount,

      // Statuts / alarmes (UPLINK) en 0/1
      HighTemperatureAlarm,
      LowTemperatureAlarm,
      HighHumidityAlarm,
      LowHumidityAlarm,
      motionGuardAlarm,

      // Entrées logiques en 0/1
      pirStatus,
      luminosityStatus

      // Si besoin un jour :
      // rawLuminosity
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// On utilise la trame d’installation "packet = 3" avec les champs de config
// décrits dans ton Excel. Il n’y a PAS de seuils d’alarme sur la luminosité.
//
// 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 -> secondes (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.`
    );
  }

  // ---- Seuils / options : normalisation & clamp ----
  function normNumber(val, name, min, max, scale) {
    // scale = facteur de multiplication (ex: 10 pour °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;
  }

  // Température (°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
  );

  // Humidité (%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 dans le mot "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) restent à 0

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

  const bytes = [];

  // Fixed (0) - 3 octets
  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: non utilisé (0x0000)
  bytes.push(0x00, 0x00);

  // P6: non utilisé (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 ----------
//
// Exemple de trame :
// 000037260302000000000000000000000007000000000000000000000028
//
// Payload attendu: 30 octets
//  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) -> non utilisé
// 28..29 : Status (UINT16, bitfield; bits 3-2 = batterie, bit5 = fenêtre ouverte)

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;

  // Compteur d'ouverture fenêtre
  const windowSensorCount = readUInt16BE(bytes.slice(16, 18));

  const alarmStatus = readUInt16BE(bytes.slice(26, 28));
  void alarmStatus; // non utilisé

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

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

  // Fenêtre ouverte : bit5 → renvoyer 0/1
  const windowStatus = (status & 0x0020) ? 1 : 0;

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

      // ---- Booléen transformé en 0/1 ----
      windowStatus,

      windowSensorCount
    }
  };
}

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

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.");
    }
  }

  // ---- Sensibilité fenêtre ----
  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 -> secondes (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 → FIXE à 0
  bytes.push(0x00, 0x00);

  // RBE / MG / LED → toujours 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 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 ----------
//
// Exemple de trame : 00008A07071200CD000000020000
// Payload attendu: 14 octets
//  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 = niveau batterie)

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));

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

  return {
    data: {
      // Champs mappés dans mapping_600031.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // ou autre, choisi côté LNS
//   data: { tx_period: 30 }   // en minutes
// })
//
// Trame générée (37 octets) :
//  - 3  octets : 0x000000 (Fixed 0)
//  - 1  octet  : 0x03      (Installation Packet = 3)
//  - 2  octets : 0x0000    (Fixed 0)
//  - 2  octets : RE-Tx time en secondes (minutes * 60, BE)
//  - 2  octets : 0x0000    (Fixed / Enter 1 or 0 -> laissé à 0)
//  - 2  octets : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 octets = 0x0000 (seuils laissés à 0)
//  - 1  octet  : 0x01      (Fixed 1)
//
// Ce format reproduit exactement la chaîne hex suivante
// pour 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 -> secondes (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 -> laissé à 0
  bytes.push(0x00, 0x00);

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

  // P1..P12 = 0x0000 (seuils d'alarmes ignorés comme demandé)
  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 ----------
//
// Exemple de trame : 00008A07071200CD000000020000
// Payload attendu: 14 octets
//  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 = niveau batterie)

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));

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

  return {
    data: {
      // Champs mappés dans mapping_600032.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // ou autre, choisi côté LNS
//   data: { tx_period: 30 }   // en minutes
// })
//
// Trame générée (37 octets) :
//  - 3  octets : 0x000000 (Fixed 0)
//  - 1  octet  : 0x03      (Installation Packet = 3)
//  - 2  octets : 0x0000    (Fixed 0)
//  - 2  octets : RE-Tx time en secondes (minutes * 60, BE)
//  - 2  octets : 0x0000    (Fixed / Enter 1 or 0 -> laissé à 0)
//  - 2  octets : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 octets = 0x0000 (seuils laissés à 0)
//  - 1  octet  : 0x01      (Fixed 1)
//
// Ce format reproduit exactement la chaîne hex suivante
// pour 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 -> secondes (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 -> laissé à 0
  bytes.push(0x00, 0x00);

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

  // P1..P12 = 0x0000 (seuils d'alarmes ignorés comme demandé)
  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 ----------
//
// Exemple de trame : 0000BD0C0A1200CE00CA00010000
// Payload attendu: 14 octets
//  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 = niveau batterie)

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));

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

  return {
    data: {
      // Champs mappés dans mapping_600232.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature1,
      temperature2
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // ou autre, choisi côté LNS
//   data: { tx_period: 30 }   // en minutes
// })
//
// Trame générée (37 octets) :
//  - 3  octets : 0x000000 (Fixed 0)
//  - 1  octet  : 0x03      (Installation Packet = 3)
//  - 2  octets : 0x0000    (Fixed 0)
//  - 2  octets : RE-Tx time en secondes (minutes * 60, BE)
//  - 2  octets : 0x0000    (Fixed / Enter 1 or 0 -> laissé à 0)
//  - 2  octets : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 octets = 0x0000 (seuils laissés à 0)
//  - 1  octet  : 0x01      (Fixed 1)
//
// Ce format reproduit exactement la chaîne hex suivante
// pour 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 -> secondes (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 -> laissé à 0
  bytes.push(0x00, 0x00);

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

  // P1..P12 = 0x0000 (seuils d'alarmes ignorés comme demandé)
  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 ----------
//
// Exemple de trame : 0000D10E0A1200CA018200010000
// Payload attendu: 14 octets
//  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 = niveau batterie)

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 présent mais non exploité pour l’instant
  // const alarmStatus = readUInt16BE(bytes.slice(10, 12));

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

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

  return {
    data: {
      // Champs mappés dans mapping_600034.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      temperature,
      humidity
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,                // ou autre, choisi côté LNS
//   data: { tx_period: 30 }   // en minutes
// })
//
// Trame générée (37 octets) :
//  - 3  octets : 0x000000 (Fixed 0)
//  - 1  octet  : 0x03      (Installation Packet = 3)
//  - 2  octets : 0x0000    (Fixed 0)
//  - 2  octets : RE-Tx time en secondes (minutes * 60, BE)
//  - 2  octets : 0x0000    (Fixed / Enter 1 or 0 -> laissé à 0)
//  - 2  octets : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 octets = 0x0000 (seuils laissés à 0)
//  - 1  octet  : 0x01      (Fixed 1)
//
// Ce format reproduit exactement la chaîne hex suivante
// pour 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 -> secondes (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 -> laissé à 0
  bytes.push(0x00, 0x00);

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

  // P1..P12 = 0x0000 (seuils d'alarmes ignorés comme demandé)
  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 ----------
//
// Exemple de trame : 0000C70D0A12000200020000
// Payload attendu: 12 octets
//  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, en mA)
// 10..11: Status (UINT16, bitfield; bits 3-2 = niveau batterie)

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: exemple 0x0002 -> 0.002 mA
  const current = readUInt16BE(bytes.slice(6, 8)) / 1000;

  // Alarm status présent mais non utilisé
  // const alarmStatus = readUInt16BE(bytes.slice(8, 10));

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

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

  return {
    data: {
      // Champs mappés dans mapping_600035.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      current
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Input ChirpStack:
//
// encodeDownlink({
//   fPort: 1,
//   data: {
//     tx_period: 30,      // minutes  (obligatoire)
//     loop_period: 500    // msecs    (optionnel, 0 par défaut)
//   }
// })
//
// Trame générée (37 octets) :
//  - 3  octets : 0x000000 (Fixed 0)
//  - 1  octet  : 0x03      (Installation Packet = 3)
//  - 2  octets : 0x0000    (Fixed 0)
//  - 2  octets : RE-Tx time en secondes (minutes * 60, BE)
//  - 2  octets : 0x0000    (Fixed / Enter 1 or 0 -> laissé à 0)
//  - 2  octets : 0x0000    (TWU (hrs) -> 0)
//  - P1        : 0x0000    (4-20 Hi Alarm, non utilisé)
//  - P2        : 0x0000    (4-20 Lo Alarm, non utilisé)
//  - P3        : loop_period (msecs, UINT16 BE)
//  - P4..P12   : 0x0000    (fixe)
//  - 1  octet  : 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; // défaut
  } 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 -> secondes (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 -> laissé à 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 registres restants (P4 à 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 ----------
//
// Exemple de trame :
// 000095080C1200000020000000170000001E00000000
//
// Payload attendu: 22 octets
//  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 : niveau batterie
//  - bits 5,6,7 : état des entrées (Ch1/Ch2/OC)
//  - bits 8,9,10 : état anti-rebond (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));

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

  // ------- Bits de statut / anti-rebond -------
  // Bits des états d'entrée (Open/Closed)
  const CH1_STATUS_BIT = 5;
  const CH2_STATUS_BIT = 6;
  const OC_STATUS_BIT  = 7;

  // Bits de statut anti-rebond (0 = DIS, 1 = EN)
  const DEBOUNCE1_BIT = 8;
  const DEBOUNCE2_BIT = 9;
  const DEBOUNCE3_BIT = 10;

  // États 0/1 au lieu de true/false
  const ch1Status = (status >> CH1_STATUS_BIT) & 0x01;
  const ch2Status = (status >> CH2_STATUS_BIT) & 0x01;
  const ocStatus  = (status >> OC_STATUS_BIT)  & 0x01;

  // Anti-rebond : valeurs 0/1 (ON/OFF)
  const debounce1 = (status >> DEBOUNCE1_BIT) & 0x01;
  const debounce2 = (status >> DEBOUNCE2_BIT) & 0x01;
  const debounce3 = (status >> DEBOUNCE3_BIT) & 0x01;

  return {
    data: {
      // Champs principaux (voir mapping_600036.json)
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,

      // États + compteurs (états en 0/1)
      ch1Status,
      ch1Count,
      ch2Status,
      ch2Count,
      ocStatus,
      ocCount,

      // Statut anti-rebond (0 = DIS, 1 = EN)
      debounce1,
      debounce2,
      debounce3
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// tx_period (packet=3) ou anti-rebond (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);
  }
}

// ----- Sous-fonction : trame tx_period (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 -> laissé à 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
  };
}

// ----- Sous-fonction : trame anti-rebond (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 octets
  bytes.push(0x00, 0x00, 0x00);

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

  // Fixed(0) : 2 octets
  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 ----------
//
// Exemple de trame :
// 000095080C1200000020000000170000001E00000000
//
// Payload attendu: 22 octets
//  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 : niveau batterie
//  - bits 5,6,7 : état des entrées (Ch1/Ch2/OC)
//  - bits 8,9,10 : état anti-rebond (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));

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

  // ------- Bits de statut / anti-rebond -------
  // Bits des états d'entrée (Open/Closed)
  const CH1_STATUS_BIT = 5;
  const CH2_STATUS_BIT = 6;
  const OC_STATUS_BIT  = 7;

  // Bits de statut anti-rebond (0 = DIS, 1 = EN)
  const DEBOUNCE1_BIT = 8;
  const DEBOUNCE2_BIT = 9;
  const DEBOUNCE3_BIT = 10;

  // États en 0/1 au lieu de true/false
  const ch1Status = (status >> CH1_STATUS_BIT) & 0x01;
  const ch2Status = (status >> CH2_STATUS_BIT) & 0x01;
  const ocStatus  = (status >> OC_STATUS_BIT)  & 0x01;

  // Anti-rebond : valeurs 0/1 (ON/OFF)
  const debounce1 = (status >> DEBOUNCE1_BIT) & 0x01;
  const debounce2 = (status >> DEBOUNCE2_BIT) & 0x01;
  const debounce3 = (status >> DEBOUNCE3_BIT) & 0x01;

  return {
    data: {
      // Champs principaux (voir mapping_600036/600037.json)
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,

      // États + compteurs
      ch1Status,
      ch1Count,
      ch2Status,
      ch2Count,
      ocStatus,
      ocCount,

      // Statut anti-rebond (0 = DIS, 1 = EN)
      debounce1,
      debounce2,
      debounce3
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Deux types de trames :
//  1) Trame "standard" (installation packet = 3) pour tx_period
//  2) Trame "pulse debounce" (installation packet = 22) pour debounce1/2/3
//
// Règle :
//  - si au moins un des champs debounce*_count est présent → trame anti-rebond
//  - sinon → trame tx_period
//
// ---- 1) Trame tx_period (packet = 3)
//
// encodeDownlink({ fPort: 1, data: { tx_period: 30 } })
//
// ---- 2) Trame anti-rebond (packet = 22, type = 5 ATEX)
//
// encodeDownlink({
//   fPort: 1,
//   data: {
//     debounce1_count: 5,
//     debounce2_count: 5,
//     debounce3_count: 3
//   }
// })
//
// Donne l’exemple Excel pour 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);
  }
}

// ----- Sous-fonction : trame tx_period (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 -> laissé à 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
  };
}

// ----- Sous-fonction : trame anti-rebond ATEX (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 octets
  bytes.push(0x00, 0x00, 0x00);

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

  // Fixed(0) : 2 octets
  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 non signé
}

// ---------- UPLINK DECODER ----------
//
// Exemple de trame : 0000AA0A0D1200000000000000000000000A00000000
// Payload attendu: 22 octets
//  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 de pulses -> ocCount)
// 20..21: Status (UINT16, bits 3-2 = niveau batterie)

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));

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

  return {
    data: {
      // Champs mappés dans mapping_600038.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      ocCount
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Pas d’antirebond sur 600-038 : on ne gère que la période de transmission.
//
// Exemple d’usage côté ChirpStack :
//
// encodeDownlink({
//   fPort: 1,              // ou laissé vide, on renverra 1 par défaut
//   data: { tx_period: 30 }  // en minutes
// })
//
// Trame générée (37 octets) :
//  - 3  octets : 0x000000 (Fixed 0)
//  - 1  octet  : 0x03      (Installation Packet = 3)
//  - 2  octets : 0x0000    (Fixed 0)
//  - 2  octets : RE-Tx time en secondes (minutes * 60, BE)
//  - 2  octets : 0x0000    (Fixed / Enter 1 or 0 -> laissé à 0)
//  - 2  octets : 0x0000    (TWU (hrs) -> 0)
//  - P1..P12   : 12 * 2 octets = 0x0000 (seuils laissés à 0)
//  - 1  octet  : 0x01      (Fixed 1)
//
// Pour tx_period = 30 min, on obtient :
// 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 -> secondes (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 -> laissé à 0
  bytes.push(0x00, 0x00);

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

  // P1..P12 = 0x0000 (seuils d'alarmes ignorés)
  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 ----------
//
// Exemple de trame :
//   0000B40B0E1200000018000000280000005600070061
//
// Payload attendu: 22 octets
//  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 = niveau batterie,
//                 bits 4..6 = états de contact d'après la doc)

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));

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

  // Etats des contacts
  // Interprétation: bits 4,5,6 -> Ch1, Ch2, OC (1 = fermé / actif)
  // On renvoie 0/1 au lieu de true/false
  const ch1Status = (status >> 5) & 0x01;
  const ch2Status = (status >> 6) & 0x01;
  const ocStatus  = (status >> 7) & 0x01;

  return {
    data: {
      // Champs mappés dans mapping_600039.json
      id,
      type,
      fwVersion,
      batteryLevel,
      seqCounter,
      ch1Status,
      ch1Count,
      ch2Status,
      ch2Count,
      ocStatus,
      ocCount
    }
  };
}

// ---------- DOWNLINK ENCODER ----------
//
// Paramètres supportés côté ChirpStack :
//
// encodeDownlink({
//   fPort: 1, // ou laissé dans le device-profile
//   data: {
//     tx_period: 5,        // en minutes (obligatoire)
//     input1_period: 5,    // en secondes (P1 - optionnel, défaut 0)
//     input2_period: 10    // en secondes (P2 - optionnel, défaut 0)
//   }
// })
//
// Trame générée (37 octets) :
//  - 3  octets : 0x000000 (Fixed 0)
//  - 1  octet  : 0x03      (Installation Packet = 3)
//  - 2  octets : 0x0000    (Fixed 0)
//  - 2  octets : RE-Tx time en secondes (minutes * 60, BE)
//  - 2  octets : 0x0000    (Enter 1 or 0 -> 0)
//  - 2  octets : 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 (non utilisés)
//  - 1  octet  : 0x01      (Fixed 1)
//
// Exemple feuille Excel (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 -> secondes (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 en 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 %}


---

# Agent Instructions: 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:

```
GET https://enless.gitbook.io/centre-aide/ressources/capteurs/codecs-et-decodage/chirpstack.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
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.
