In the Web of Things (WoT), a Binding provides guidance for implementing a specific IoT protocol/platform by defining vocabulary terms that can be used in a TD and rules describing how WoT operations map to protocol interactions. This document proposes a binding for LoRaWAN, defining vocabulary and rules for describing interactions with LoRaWAN end devices via a LoRaWAN “network-facing interface” (typically a local Network Server, gateway bridge, or compatible service endpoint).

More specifically, this document defines a set of vocabulary terms that can be used inside a Thing Description or Thing Model document, and associated rules which allow description of WoT operations using LoRaWAN over the network. Additionally, relevant examples are provided to showcase different vocabulary terms and the associated behavior.

This document is a work in progress

Introduction

LoRaWAN is commonly used in asynchronous telemetry scenarios: devices emit uplinks periodically or on change, while downlinks are scheduled (often with delivery constraints). This proposed binding therefore emphasizes observeproperty/subscribeevent for uplinks and invokeaction/writeproperty for downlinks. A TD using this LoRaWAN binding conforms if all of the following are true:

LoRaWAN Architecture

LoRaWAN networks use a star-of-stars topology consisting of four logical roles: end node, gateways, network server, and application servers (plus a Join Server for OTAA activation). Understanding this separation of concerns is necessary background for the binding defined below. LoRaWAN Architecture Diagram

Uplink messages travel from an end device, are received by every gateway in range, and are deduplicated and routed by the Network Server to the correct Application Server. Downlink messages originate at the Application Server, pass through the Network Server — which selects a single best gateway for transmission — and are delivered to the end device only within its receive window.

Structure of a LoRaWAN Uplink Message

A LoRaWAN uplink is built from three nested layers: Phyical Layer, MAC Layer, and Frame Layer. Each layer is handled by a different component. No single component sees the message in fully decoded form — this is why a WoT Consumer cannot simply "read" a LoRaWAN device the way it would read an HTTP resource. LoRaWAN Uplink Message Structure

Payload Codec

Decrypting FRMPayload does not automatically produce structured application data. — It only recovers the raw byte string that the LoRaWAN end device originally packed into the uplink. Turning those bytes into typed values, such as temperature, humidity, battery voltage, alarms, counters, or device status, is the job of a payload codec, applied immediately after FRMPayload decryption, at the Application Server.

The codec is usually JavaScript functions not defined by LoRaWAN itself, but depends on the device, vendor, firmware version, and configuration. The codec specification is usually published in a device datasheet, a vendor portal, or an online documentation. It may follow a fixed binary layout, a TLV scheme, Cayenne LPP, a ZCL-based encoding, or a vendor-defined user-configurable format.

In the following, we show some examples of common codec schema.

Fixed Binary Layout with/without branching

In a fixed binary layout, each byte or group of bytes has a predefined meaning.

Example layout:

Byte Position Field Encoding
Byte 0 Report type Unsigned integer
Byte 1-2 Temperature Signed or unsigned 16-bit integer, big-endian, scale 0.01 °C
Byte 3-4 Unsigned 16-bit integer, big-endian, unit mV Battery voltage

Example payload:

01 07 6B 0C CC

Decoding result:

It's worth mentioning, as a decoder output is not standardized, it can return any measurement in any unit, as well as any additional information based on the configuration. So the final structured output could look like this:

{ "reportType": "status", "temperatureC": 18.99, "batteryV": 3.276 }

Branching: Some devices use the first byte, or sometimes the FPort, to indicate the payload type. The remaining bytes are decoded differently depending on that type. An example is provided in the following section.

Channel-Type-Value Layout

A Channel-Type-Value format uses a channel identifier and a type identifier before the value. The value length is not explicitly included. Instead, the type determines how many bytes must be read. A common example of this approach is Cayenne Low Power Payload, often called Cayenne LPP.

Generic structure:

[Channel][Type][Value]

Example payload:

01 67 00 EA 02 68 7A

Decoding:

Bytes Meaning Decoded Value
01 67 00 EA Channel 1, type 0x67 = temperature Raw value 234, decoded as 23.4 °C
02 68 7A Channel 2, type 0x68 = relative humidity Raw value 122, decoded as 61 %RH

Final structured output could look like this:

{ "channel1": { "type": "temperature", "value": 23.4, "unit": "°C" }, "channel2": { "type": "humidity", "value": 61, "unit": "%RH" } }

The advantage of this schema are compactness and interoperability. The disadvantage is that the decoder must know the type table in advance, which offers limited semantics and flexibility.

Type-Length-Value Layout

In a Type-Length-Value format, often abbreviated as TLV, each field contains a type, an explicit length, and then the value bytes.

Generic structure:

[Type][Length][Value]

Example payload:

01 02 01 10

Example interpretation:

Part Value Meaning
Type 0x01 Temperature
Length 0x02 Two value bytes follow
Value 0x0110 Raw value 272, decoded as 27.2 °C

TLV is more self-describing than a fixed binary layout because each value includes its own length. This makes it easier to add new field types in future firmware versions. However, it has slightly more overhead because every value carries additional metadata.

Vendor-Defined Configurable Layout

Some devices allow users to configure which measurements are sent and in what order. For example, some sensors allow customized extension based on the attached sensors.

Codec JS function example

The following is an example of a JavaScript function for uplink message decoding from the Netvox: R718A sensor that decodes a fixed binary layout with branching based on the fPort and the first byte of the payload. The code snippet is sourced from TTN device profile repository.

    
function decodeUplink(input) {
  // Output object — fields are added depending on which fPort and message type is received
  var data = {};

  // fPort identifies the message type.
  // This device uses fPort 6 for sensor data and fPort 7 for command responses.
  switch (input.fPort) {

    case 6: // ── SENSOR DATA PORT ─────────────────────────────────────────────

      // bytes[2] === 0x00 is a special flag meaning this is a VERSION/IDENTITY
      // frame, not a regular sensor reading. Sent once on join or on request.
      if (input.bytes[2] === 0x00)
      {
        // bytes[1]: device type identifier, resolved via a getDeviceName() lookup table not shown here
        data.Device = getDeviceName(input.bytes[1]);

        // bytes[3]: firmware version stored as integer, scaled by 10
        // e.g. 0x0F = 15 → 15/10 = 1.5 (meaning v1.5)
        data.SWver = input.bytes[3] / 10;

        // bytes[4]: hardware revision, raw integer (no scaling)
        data.HWver = input.bytes[4];

        // bytes[5–8]: manufacture date, each byte is one hex pair of the date string.
        // .toString(16) converts the byte to a hex string (e.g. 5 → "5").
        // padLeft(..., 2) zero-pads to 2 chars (e.g. "5" → "05"), ensuring
        // each byte always contributes exactly 2 characters.
        // Concatenated result example: 0x20,0x23,0x12,0x05 → "20231205" (Dec 5, 2023)
        data.Datecode = padLeft(input.bytes[5].toString(16), 2)
                      + padLeft(input.bytes[6].toString(16), 2)
                      + padLeft(input.bytes[7].toString(16), 2)
                      + padLeft(input.bytes[8].toString(16), 2);

        // Return early — version frames contain no sensor readings
        return {
          data: data,
        };
      }

      // ── REGULAR SENSOR FRAME (bytes[2] !== 0x00), sent every 15 minutes at the runtime──────────────────────────

      // bytes[3]: battery voltage.
      // The MSB (0x80 = 10000000) is used as a low-battery flag.
      // & 0x80 isolates that single bit; if it is 1, the battery is low.
      if (input.bytes[3] & 0x80)
      {
        // Mask off the flag bit with & 0x7F (01111111) to get the actual voltage value.
        // Divide by 10 to get volts (e.g. 0x23 = 35 → 35/10 = 3.5 V).
        // Append a warning string so the consumer is immediately aware.
        var tmp_v = input.bytes[3] & 0x7F;
        data.Volt = (tmp_v / 10).toString() + '(low battery)';
      }
      else
        // No low-battery flag — decode voltage directly (e.g. 0x24 = 36 → 3.6 V)
        data.Volt = input.bytes[3] / 10;

      data.Device = getDeviceName(input.bytes[1]);

      // bytes[4–5]: temperature, encoded as a 16-bit big-endian signed integer,
      // scaled by 100 (i.e. 2964 → 29.64 °C, 65436 → −1.00 °C).
      //
      // Negative temperatures are stored in two's complement:
      // the actual range 0x8000–0xFFFF represents −327.68 to −0.01 °C.
      // The most significant bit (MSB) of bytes[4] (checked with & 0x80) acts as the sign bit.
      if (input.bytes[4] & 0x80) // if MSB is set -> negative temperature
      {
        // Reassemble the 16-bit raw value from two bytes (big-endian) via bits shifting:
        // bytes[4] is the high byte → shift left 8 bits,
        // then OR with bytes[5] (low byte) to fill the lower 8 bits.
        // Example: bytes[4]=0xFF, bytes[5]=0x9C → 0xFF9C = 65436
        var tmpval = (input.bytes[4] << 8 | input.bytes[5]);

        // Reverse the two's complement encoding to get the magnitude, then negate.
        // 0x10000 (= 65536) is the 16-bit "clock size"; subtracting gives the distance
        // from zero. Divide by 100 to unscale, multiply by -1 to apply the sign.
        // Example: (65536 − 65436) / 100 * −1 = 100 / 100 * −1 = −1.00 °C
        data.Temp = (0x10000 - tmpval) / 100 * -1;
      }
      else
        // Positive temperature: combine bytes big-endian and unscale directly.
        // Example: bytes[4]=0x0B, bytes[5]=0x94 → 0x0B94 = 2964 → 2964/100 = 29.64 °C
        data.Temp = (input.bytes[4] << 8 | input.bytes[5]) / 100;

      // bytes[6–7]: relative humidity, 16-bit big-endian unsigned, scaled by 100.
      // Humidity is always positive so no sign-bit handling is needed.
      // Example: bytes[6]=0x17, bytes[7]=0x70 → 0x1770 = 6000 → 6000/100 = 60.00 %RH
      data.Humi = (input.bytes[6] << 8 | input.bytes[7]) / 100;
      break;

    case 7: // ── COMMAND RESPONSE PORT ────────────────────────────────────────

      data.Device = getDeviceName(input.bytes[1]);

      // bytes[0] identifies the specific command response type:
      // 0x81 = ACK for a write command (did it succeed?)
      // 0x82 = read-back of the current reporting interval configuration

      if (input.bytes[0] === 0x81)
      {
        // Resolve 0x81 to a human-readable command name via lookup table
        data.Cmd = getCmdId(input.bytes[0]);

        // bytes[2]: 0x00 means the downlink command was applied successfully,
        // any other value means the device rejected or failed to apply it
        data.Status = (input.bytes[2] === 0x00) ? 'Success' : 'Failure';
      }
      else if (input.bytes[0] === 0x82) // event-driven and heartbeat reporting pattern
      {

        data.Cmd = getCmdId(input.bytes[0]);

        // bytes[2–3]: minimum quiet period in seconds.
        // No uplink is sent during this window, even if readings change dramatically.
        // Prevents network flooding from rapid fluctuations.
        // Big-endian 16-bit: bytes[2] is high byte, bytes[3] is low byte.
        data.MinTime = (input.bytes[2] << 8 | input.bytes[3]);

        // bytes[4–5]: maximum heartbeat interval in seconds.
        // The device MUST send an uplink by this deadline even if nothing changed.
        // Allows the server to detect a lost/dead device if no packet arrives in time.
        data.MaxTime = (input.bytes[4] << 8 | input.bytes[5]);

        // bytes[6]: voltage change threshold that triggers an early uplink.
        // Scaled by 10 → e.g. 0x03 = 3 → 0.3 V drop triggers a send.
        data.BatteryChange = input.bytes[6] / 10;

        // bytes[7–8]: temperature change threshold in °C.
        // Big-endian 16-bit, scaled by 100 → e.g. 0x0032 = 50 → 0.50 °C triggers a send.
        data.TempChange = (input.bytes[7] << 8 | input.bytes[8]) / 100;

        // bytes[9–10]: humidity change threshold in %RH.
        // Big-endian 16-bit, scaled by 100 → e.g. 0x01F4 = 500 → 5.00 %RH triggers a send.
        data.HumiChange = (input.bytes[9] << 8 | input.bytes[10]) / 100;
      }
      break;

    default:
      // Any fPort not handled above is unexpected — return a descriptive error
      // so the LNS or WoT Consumer can flag it for investigation.
      return {
        errors: ['unknown FPort'],
      };
  }

  // Return the decoded fields for all non-error, non-early-return paths
  return {
    data: data,
  };
}
    
    

Based on the device datasheet, the following table shows the meaning of each byte in the payload. For FPort 0x06, the following table applies:

Payload Codec Table FPort 0x06

For FPort 0x07, the following table applies:

Payload Codec Table FPort 0x07

So an example message "010b01240b2d0bef000000" can be decoded as follows:

Byte(s) Hex Value Field Description / Calculation
1st byte 01 Version Version value
2nd byte 0B DeviceType 0x0B - R718A
3rd byte 01 ReportType Report type value
4th byte 24 Battery 3.6V; 24(Hex) = 36(Dec), 36 x 0.1V = 3.6V
5th - 6th byte 0B2D Temperature 28.61°C; 0B2D(Hex) = 2861(Dec), 2861 x 0.01 = 28.61°C
7th - 8th byte 0BEF Humidity 30.55%; 0BEF(Hex) = 3055(Dec), 3055 x 0.01 = 30.55%
9th - 11th byte 000000 Reserved Reserved bytes

URI format

This binding defines the URI scheme with following Augmented Backus-Naur Form (ABNF) [[RFC5234]]:

      "lorawan://" lorav:deviceEui "/" "{lorav:appKey}/device"
      

A device URI looks like lorawan://{lorav:deviceEui}/{lorav:appKey}/device. The device URI includes the device EUI and application key. Device identification are expressed in forms via vocabulary terms (Section 4.1), rather than being forced into the URI path.

LoRaWAN Vocabulary

This section defines vocabulary terms usable inside TDs.

URI terms

Vocabulary term Description Assignment Type
lorav:deviceEui LoRaWAN Device EUI (end device identifier) required string
lorav:appKey Application key that is supplied via the security definitions mechanism required string

Form terms

The following terms are specific to forms describing LoRaWAN end device interactions (e.g., reading sensor data).

Vocabulary term Description Assignment Type
lorav:mostSignificantByte When true, it describes that the byte order of the data in the LoRaWAN message is the most significant byte first (i.e., Big-Endian). When false, it describes the least significant byte first (i.e., Little-Endian). optional boolean
lorav:bitmask Bitmask masking the returned device byte blob to extract the value optional hex string
lorav:multiplier Multiplier to multiply the returned device byte blob with to extract the true value optional float

Payload DataType

The PayloadDataType class within a LoRaWAN binding instance serves as value for the lorav:type property to specify the expected data types of payload content in LoRaWAN messages. It offers a set of terms taken from XML Schema [[XMLSCHEMA11-2-20120405]] to cover the most common data types used in LoRaWAN messages. Note that the PayloadDataType entity is designed for describing established conventions in the LoRaWAN ecosystem; future development might remove this functionality or add new terms. Currently, the PayloadDataType class contains the following data types:

Mappings

This section describes the strategies and default values to use protocol specific concepts within the WoT Interaction model.

Default mappings

Map WoT Interaction model verbs to default values if any

Operation Default Binding

Possible mappings

TODO: This section should describe other mappings that can be used by TD designers. It is meant to be informative but it provides guidelines for implementers.

Examples

{
  "@context": [
    "https://www.w3.org/2022/wot/td/v1.1"
  ],
  "id": "urn:dragino-lht-65n-e3",
  "securityDefinitions": {
    "apikey_sc": {
      "scheme": "apikey",
      "in": "uri",
      "name": "appKey"
    }
  },
  "security": [
    "apikey_sc"
  ],
  "@type": [
    "Thing"
  ],
  "name": "Dragino LHT65N-E3 A84041B98D5CB233",
  "base": "lorawan://A84041B98D5CB233/{appKey}/device",
  "title": "Dragino LHT65N-E3",
  "properties": {
    "batteryLevel": {
      "type": "number",
      "readOnly": true,
      "observable": true,
      "forms": [
        {
          "href": "A84041B98D5CB233/0?quantity=2",
          "lorav:type": "xsd:short",
          "lorav:mostSignificantByte": true,
          "lorav:bitmask": "0x3FFF",
          "lorav:multiplier": 0.001
        }
      ]
    },
    "temperature": {
      "type": "number",
      "readOnly": true,
      "observable": true,
      "forms": [
        {
          "href": "A84041B98D5CB233/2?quantity=2",
          "lorav:type": "xsd:short",
          "lorav:mostSignificantByte": true,
          "lorav:multiplier": 0.01
        }
      ]
    },
    "humidity": {
      "type": "number",
      "readOnly": true,
      "observable": true,
      "forms": [
        {
          "href": "A84041B98D5CB233/4?quantity=2",
          "lorav:type": "xsd:short",
          "lorav:mostSignificantByte": true,
          "lorav:multiplier": 0.1
        }
      ]
    },
    "extTemperature": {
      "type": "number",
      "readOnly": true,
      "observable": true,
      "forms": [
        {
          "href": "A84041B98D5CB233/7?quantity=2",
          "lorav:type": "xsd:short",
          "lorav:mostSignificantByte": true,
          "lorav:multiplier": 0.01
        }
      ]
    }
  }
}