WeatherXM network · Your own LoRaWAN (TTN, ChirpStack)

Update and configure your WS2001 (H2)

The H2 starts on the WeatherXM network. Connect over Bluetooth to update its firmware or to move it to The Things Network (TTN), ChirpStack or any other LoRaWAN network server with the Open Edition profile. It runs WeatherXM's own firmware, whose compact weather frame carries more data than the stock SenseCAP S2120 firmware, such as wind gust and rain total. Both network identities stay stored on the station, so you can switch back at any time.

Explore WeatherXM stations Payload decoder for your own network (.js) Firmware changelog

WeatherXM WS2001 (H2) Weather Station
Step 1 · Bluetooth

Connect to WS2001 (H2)

Make sure your station is powered on and within Bluetooth range.

  1. 1

    Stand next to the station

    Stay within a few metres during the update. A laptop or an Android phone both work.

  2. 2

    Press the station RESET button once (first-time pairing only)

    If the station has never been paired before, press RESET once. You only need this for the initial pairing; new computers can pair during the first 5 minutes after the restart. Don't hold RESET: holding it for 10 seconds resets the station to factory settings.

  3. 3

    Connect and pick "WeatherXM WS2001"

    Click Connect to WS2001 (H2) and select the station. If your computer asks to pair, accept it and let the pairing prompt finish.

TTN import file for the saved settings

The station never reveals its AppKey, so the file needs the AppKey you registered on your network server (in TTN: your end device > Overview > AppKey). Nothing is written to the station.

Don't have it any more? : the network settings form saves a new key on the station, and its import file carries the new key.

ws2001_uplink_decoder.js

// WeatherXM WS2001 / WS2000 uplink decoder (WeatherXM firmware V4.2 - V5.1+)
//
// Paste this whole file as-is into:
//   - The Things Stack v3:  Application > Payload formatters > Uplink > Custom JavaScript
//   - ChirpStack v4:        Device profile > Codec > JavaScript functions
//   - ChirpStack v3:        Device profile > Codec > Custom JavaScript (uses Decode() below)
//
// Frames on FPort 99 (AT+TESTMODE_TYPE=0, the default):
//   20-byte Open Edition weather frame (AT+NETPROFILE=1, V5.1+): the 16-byte frame plus wind direction
//         variability (spread, range, gust direction)
//   16-byte Open Edition weather frame (AT+NETPROFILE=1, V4.9 - V4.10)
//         Open Edition frames have no tag byte. Later firmware only appends fields, so any untagged frame of
//         16 bytes or more is decoded: the fields this decoder knows, plus a warning about bytes it does not
//         (update the decoder to read them).
//   0xF0  24-byte WeatherXM weather frame (AT+NETPROFILE=0, 1 Helium data credit)
//   0xF1  same, followed by a 64-byte ECDSA P-256 signature from the station's secure element
//   0xF2  12-byte boot frame (firmware/hardware version, sensor protocol version)
//   0xF3  GNSS scan (raw LR1110 NAV message; resolve with a GNSS solver)
// Frames on FPort 3 (legacy "Open LoRaWAN" TLV, AT+TESTMODE_TYPE=1) are decoded too.
// FPort 199 carries LoRa Basics Modem service messages (time sync for GNSS, device
// management); they hold no weather data and are returned as { frame: "modem_service" }.
//
// Source of truth: ws2001-firmware/tools/decoders/ws2001_uplink_decoder.js
// (byte layout: application/smtc_board/bsp/peripherals/ccl_sensor/sensor_data.c)

function wxmHex(bytes, start, end) {
  var s = "";
  for (var i = start; i < end && i < bytes.length; i++) {
    s += (bytes[i] < 16 ? "0" : "") + bytes[i].toString(16);
  }
  return s;
}

function wxmU16le(b, i) { return b[i] | (b[i + 1] << 8); }
function wxmU24le(b, i) { return b[i] | (b[i + 1] << 8) | (b[i + 2] << 16); }
function wxmU32le(b, i) { return (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0; }

function wxmDecodeOpenWeather(bytes) {
  var data = { frame: "weather" };

  var hd = (bytes[0] << 8) | bytes[1];
  data.humidity = (hd >> 9) === 0x7f ? null : hd >> 9;                                     // %RH
  data.wind_direction = (hd & 0x1ff) === 0x1ff ? null : hd & 0x1ff;                        // degrees

  var t = wxmU16le(bytes, 2);
  data.temperature = t === 0x7fff ? null : (t & 0x8000 ? t - 0x10000 : t) / 10;            // °C

  var buw = (bytes[4] << 8) | bytes[5];
  data.battery_low = (buw & 0x8000) !== 0;
  var uv = (buw >> 11) & 0x0f;
  data.uv_index = uv === 0x0f ? null : uv;
  data.wind_speed = (buw & 0x7ff) === 0x7ff ? null : (buw & 0x7ff) / 10;                   // m/s, mean of 12 s averages

  var g = wxmU16le(bytes, 6);
  data.wind_gust = g === 0xffff ? null : g / 10;                                           // m/s, max of 12 s peaks

  var r = wxmU16le(bytes, 8);
  data.rain_accumulated = r === 0xffff ? null : r / 10;                                    // mm, 0.1 mm rolling counter

  var rr = wxmU16le(bytes, 10);
  data.rain_rate = rr === 0xffff ? null : rr / 10;                                         // mm/h, max over the interval

  var lx = wxmU16le(bytes, 12);
  data.lux = lx === 0xffff ? null : lx * 10;                                               // lux (10 lux steps)

  var p = wxmU16le(bytes, 14);
  data.pressure = (p === 0xffff || p === 0) ? null : p / 10;                               // hPa

  // V5.1+: wind direction variability over the upload interval, from the 12-s samples at 0.5 m/s or more.
  // null = not available (calm interval, fewer than 5 such samples, no valid gust, or sensor error).
  var known = 16;
  if (bytes.length >= 20) {
    known = 20;
    data.wind_direction_sigma = bytes[16] === 0xff ? null : bytes[16];                     // degrees, standard deviation (Yamartino)
    data.wind_direction_range_start = wxmDir2(bytes[17]);                                  // degrees; the range runs clockwise
    data.wind_direction_range_end = wxmDir2(bytes[18]);                                    // from start to end, holding 90 % of the samples
    data.wind_direction_range_width = (data.wind_direction_range_start === null || data.wind_direction_range_end === null)
      ? null : (data.wind_direction_range_end - data.wind_direction_range_start + 360) % 360; // degrees
    data.wind_gust_direction = wxmDir2(bytes[19]);                                         // degrees, sample that carried the gust
  }

  // Bytes appended by newer firmware: keep the known fields, say what was left out
  if (bytes.length > known) {
    return {
      data: data,
      warnings: [(bytes.length - known) + " byte(s) after byte " + (known - 1) + " not decoded (newer firmware?). " +
        "Get the latest decoder: https://flash.weatherxm.com/decoders/ws2001_uplink_decoder.js"]
    };
  }
  return { data: data };
}

// Direction byte in 2-degree steps (0..179 = 0..358 degrees); 0xFF (and anything above 179) = not available
function wxmDir2(b) { return b > 179 ? null : b * 2; }

function wxmDecodeWeatherXM(bytes) {
  var data = {};
  var tag = bytes[0];
  if (tag !== 0xf0 && tag !== 0xf1 && tag !== 0xf2 && tag !== 0xf3) {
    if (bytes.length >= 16) return wxmDecodeOpenWeather(bytes);
    return { errors: ["unknown frame tag 0x" + wxmHex(bytes, 0, 1)] };
  }
  if (bytes.length < 3) return { errors: ["frame too short"] };
  data.firmware_version = (bytes[1] >> 4) + "." + (bytes[1] & 0x0f);
  var hw = (bytes[2] >> 5) & 0x07;
  data.hardware_version = hw > 0 ? "1." + (hw - 1) : "unknown";
  data.tx_power = bytes[2] & 0x1f;

  if (tag === 0xf2) {
    data.frame = "boot";
    if (bytes.length >= 12) {
      data.boot_nonce = wxmHex(bytes, 3, 11);
      data.sensor_protocol_version = bytes[11];
    }
    return { data: data };
  }
  if (tag === 0xf3) {
    return { data: { frame: "gnss_scan", gnss_status: bytes[1], gnss_length: bytes[2], gnss_raw: wxmHex(bytes, 3, bytes.length) } };
  }
  if (bytes.length < 24) return { errors: ["weather frame must be 24 bytes, got " + bytes.length] };

  data.frame = tag === 0xf1 ? "weather_signed" : "weather";
  data.upload_interval_min = bytes[3] & 0x7f;
  data.battery_low = (bytes[3] & 0x80) !== 0;
  data.samples = bytes[4];

  var t = wxmU16le(bytes, 5);
  data.temperature = t === 0x7fff ? null : (t & 0x8000 ? t - 0x10000 : t) / 10;          // °C

  var hd = (bytes[7] << 8) | bytes[8];
  data.humidity = (hd >> 9) === 0x7f ? null : hd >> 9;                                     // %RH
  data.wind_direction = (hd & 0x1ff) === 0x1ff ? null : hd & 0x1ff;                       // degrees

  var uw = (bytes[9] << 8) | bytes[10];
  data.uv_index = (uw >> 11) === 0x1f ? null : uw >> 11;
  data.wind_speed = (uw & 0x7ff) === 0x7ff ? null : (uw & 0x7ff) / 10;                    // m/s, mean of 12 s averages

  var g = wxmU16le(bytes, 11);
  data.wind_gust = g === 0xffff ? null : g / 10;                                           // m/s, max of 12 s peaks

  var r = wxmU32le(bytes, 13);
  data.rain_accumulated = r === 0x7fffffff ? null : r / 1000;                              // mm, lifetime counter
  data.rain_rate = wxmU16le(bytes, 17) / 10;                                               // mm/h, max over the interval

  var lx = wxmU24le(bytes, 19);
  data.lux = lx === 0x7fffff ? null : lx;

  var p = wxmU16le(bytes, 22);
  data.pressure = (p === 0xffff || p === 0) ? null : p / 10;                               // hPa

  if (tag === 0xf1 && bytes.length >= 88) data.signature = wxmHex(bytes, 24, 88);
  return { data: data };
}

function wxmDecodeLegacyTlv(bytes) {
  var data = {};
  var i = 0;
  while (i < bytes.length) {
    var id = bytes[i++];
    if (id === 0x01 && i + 10 <= bytes.length) {
      var rawTemp = (bytes[i] << 8) | bytes[i + 1];
      if (rawTemp & 0x8000) rawTemp -= 0x10000;
      data.temperature = rawTemp / 10;
      data.humidity = bytes[i + 2];
      data.lux = ((bytes[i + 3] << 24) >>> 0) + (bytes[i + 4] << 16) + (bytes[i + 5] << 8) + bytes[i + 6];
      data.uv_index = bytes[i + 7] / 10;
      data.wind_speed = ((bytes[i + 8] << 8) | bytes[i + 9]) / 10;
      i += 10;
    } else if (id === 0x02 && i + 8 <= bytes.length) {
      data.wind_direction = (bytes[i] << 8) | bytes[i + 1];
      var rawRain = ((bytes[i + 2] << 24) >>> 0) + (bytes[i + 3] << 16) + (bytes[i + 4] << 8) + bytes[i + 5];
      data.rain_rate = rawRain / 1000;
      var rawPress = (bytes[i + 6] << 8) | bytes[i + 7];
      data.pressure = rawPress / 10;
      i += 8;
    } else if (id === 0x03 && i + 1 <= bytes.length) {
      data.battery = bytes[i++];
    } else if (id === 0x04 && i + 9 <= bytes.length) {
      data.battery = bytes[i];
      data.hardware_version = bytes[i + 1] + "." + bytes[i + 2];
      data.firmware_version = bytes[i + 3] + "." + bytes[i + 4];
      data.upload_interval_min = (bytes[i + 5] << 8) | bytes[i + 6];
      data.gps_interval_min = (bytes[i + 7] << 8) | bytes[i + 8];
      i += 9;
    } else if (id === 0x05 && i + 4 <= bytes.length) {
      data.upload_interval_min = (bytes[i] << 8) | bytes[i + 1];
      data.gps_interval_min = (bytes[i + 2] << 8) | bytes[i + 3];
      i += 4;
    } else if (id === 0x06 && i + 1 <= bytes.length) {
      data.sensor_error_code = bytes[i++];
    } else {
      break;
    }
  }
  data.frame = "legacy_tlv";
  return { data: data, warnings: ["Legacy Open LoRaWAN format (no wind gust / rain total). Set AT+TESTMODE_TYPE=0 for the WeatherXM frame."] };
}

function wxmDecode(bytes, fPort) {
  if (fPort === 199) {
    return { data: { frame: "modem_service", raw: wxmHex(bytes || [], 0, bytes ? bytes.length : 0) } };
  }
  if (!bytes || bytes.length === 0) return { data: {} };
  if (fPort === 3) return wxmDecodeLegacyTlv(bytes);
  if (fPort === 99 || bytes[0] === 0xf0 || bytes[0] === 0xf1 || bytes[0] === 0xf2 || bytes[0] === 0xf3) {
    return wxmDecodeWeatherXM(bytes);
  }
  return { data: {}, warnings: ["Unexpected FPort " + fPort + ". WS2001 weather data arrives on FPort 99; use FPort 99 when testing or simulating an uplink."] };
}

// The Things Stack v3 and ChirpStack v4
function decodeUplink(input) {
  return wxmDecode(input.bytes, input.fPort);
}

// ChirpStack v3
function Decode(fPort, bytes, variables) {
  var r = wxmDecode(bytes, fPort);
  return r.data || {};
}
(0 events)
[Ready] WS2001 Bluetooth configurator initialized. Waiting for a station connection...