Skip to content

[LORA] Add LoRaWAN ABP uplink decryption with optional JavaScript decoders - #2403

Open
pwsh wants to merge 6 commits into
1technophile:developmentfrom
pwsh:feature/lora-lorawan-abp
Open

pwsh wants to merge 6 commits into
1technophile:developmentfrom
pwsh:feature/lora-lorawan-abp

Conversation

@pwsh

@pwsh pwsh commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Description:

Lets the LoRa gateway receive LoRaWAN devices that use ABP on a single channel. Until now these were published as encrypted hex only. Many inexpensive sensors (Dragino LDS02, LHT65, LSN50, …) can be set to ABP on a fixed frequency and data rate, which makes them usable with a single LilyGo board and no LoRaWAN network server.

Builds on #2402: its first commit is that fix. It is opt-in, and builds without the flags are unchanged (ttgo-lora32-v21 is 1,388,338 bytes with or without this PR).

-DLORA_LORAWAN (ESP32 only, about 8 KB):

  • Devices are declared at runtime through the existing MQTTtoLORA/config command, and kept in NVS with "save":true:
    {"lorawan":{"devaddr":"0187F184","nwkskey":"…","appskey":"…","name":"Front door","model":"LDS02"},"save":true}
    Devices can also be updated or removed ("remove":true). The LoRa state message lists them, without keys.
  • Uplinks from declared devices have their MIC (message integrity code) checked with the NwkSKey. Frames with a repeated frame counter are dropped, and the FRMPayload is decrypted with the AppSKey. Each one is published to LORAtoMQTT/<DEVADDR> with fcnt, fport and the decrypted payload hex. Packets from other devices, or that fail the MIC check, keep the current hex behaviour.

-DLORA_LORAWAN_JS + ${libraries.duktape} (about 200 KB):

  • Runs TTN-style decoders on the gateway, either Decoder(bytes, port) (v2) or decodeUplink(input) (v3), using Duktape 2.7.0 (MIT). Decoders are published raw to <base><gateway>/LORAWANdecoder/<DEVADDR> and stored in NVS (up to 3800 bytes). This topic is deliberately outside /commands/#: that subscription only accepts JSON up to JSON_MSG_BUFFER_MAX, and cmpToMainTopic() matches by prefix. A dedicated PicoMQTT subscription with its own size limit receives the script.
  • The decoded fields are merged into the published message, or decoder_error is set if the decoder fails. A fresh Duktape heap is used for each uplink, so a faulty decoder cannot leak state between uplinks.
  • NTP is started once MQTT is connected, so decoders that timestamp with new Date() get real time. Today the clock is only set for TLS connections.

Home Assistant: an optional per-device entities list (key, type, device class, unit, on/off payloads) creates discovery entities from the published fields.

WebUI: a new "Configure LoRaWAN" page (/lw) is added to the configuration menu:

  • the list of declared devices, with decoder status and last FCnt
  • an add/edit form for DevAddr, name, model, session keys, entities (JSON) and the decoder. Stored keys are never sent back to the browser, only a hint of their last bytes, and an empty key field keeps the current key.
  • "Test decoder" runs the decoder being edited on a payload hex, pre-filled with the device's last decrypted payload, and shows the result without saving
  • a delete button

Documentation: new section "Decoding LoRaWAN ABP devices" in docs/use/lora.md.

Tests on a LilyGo LoRa32 V2.1 (SX1276, 915 MHz), receiving a Dragino LDS02 door sensor on firmware v1.8.2 set to ABP, 903.9 MHz, DR0 (SF10/125 kHz):

  • Decryption: the MIC is verified and the payload decrypted correctly (checked against an offline AES-CMAC/AES-CTR reference).
  • The vendor's TTN v2 decoder runs unchanged. Example output:
    {"id":"0187F184","name":"LDS02 Door Sensor","model":"LDS02","fcnt":67,"fport":10,"payload":"8C6C0100003B00000000","BAT_V":3.18,"BAT_PCNT":"88.33","DOOR_OPEN_STATUS":1,"DOOR_STATE":"open","LAST_DOOR_OPEN_DURATION":0,"UTC_TIME":"2026-10-09T06:49:17.000Z","rssi":-34,"snr":10.5,"pferror":-2435,"packetSize":23}
  • The device, its entities and its decoder persist across reboots, and Home Assistant entities are created.
  • WebUI page: devices listed; edit form loads without exposing keys; "Test decoder" returns the expected fields for a real LDS02 payload; saving without changes keeps the keys and decoder.
  • Builds without warnings in the new code with the env's -w removed and -Wall added: stock env, LORA_LORAWAN, and LORA_LORAWAN + LORA_LORAWAN_JS. (The default [env] passes -w, which hides all warnings.) The new code is clang-format 9 clean.

Limitations: single channel and uplink only. There is no OTAA join, no downlinks and no MAC commands. Frame counters are tracked in RAM, so after a reboot the first frame is accepted whatever its counter. Decoders run without an execution time limit, so a decoder stuck in an infinite loop would block the loop task. This was not tested.

Checklist:

🤖 Generated with Claude Code

pwsh and others added 4 commits October 9, 2026 01:51
TheengsUtils::_rawToHex() formats each byte with "%02X\r", so the last
sprintf writes two bytes past the 2 * size hex digits (the '\r' and the
terminating null). LORAtoX() sized its buffer 2 * packetSize + 1, one
byte short, overflowing the stack for every binary LoRa packet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oders

Lets the LoRa gateway receive LoRaWAN devices using ABP on a single
channel (e.g. Dragino sensors set to a fixed frequency and data rate),
which previously could only be published as encrypted hex.

- Build flag LORA_LORAWAN (ESP32): devices (DevAddr, NwkSKey, AppSKey,
  name, model) are declared at runtime through the "lorawan" key of the
  MQTTtoLORA/config command and stored in NVS with "save":true.
- Uplinks from declared devices have their MIC verified, duplicate
  frame counters dropped and FRMPayload decrypted; they are published
  to LORAtoMQTT/<DEVADDR> with fcnt, fport and the decrypted payload.
  Other packets keep the existing raw hex behaviour.
- Build flag LORA_LORAWAN_JS adds Duktape to run TTN style decoders
  (Decoder(bytes, port) or decodeUplink(input)) uploaded as raw scripts
  to <base><gateway>/LORAWANdecoder/<DEVADDR>, outside /commands/#
  because that subscription only accepts JSON up to JSON_MSG_BUFFER_MAX.
  NTP is started so decoders can timestamp with new Date().
- Optional per device "entities" declarations create Home Assistant
  discovery entities from the published fields.

Without the flags the firmware is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds "Configure LoRaWAN" (/lw) to the configuration menu when built with
LORA_LORAWAN:
- list of the declared devices with their decoder status and last FCnt
- add/edit form for DevAddr, name, model, session keys, Home Assistant
  entities (JSON) and the JavaScript decoder; stored keys are never sent
  back to the browser (only a hint of their last bytes), an empty key
  field keeps the current key
- "Test decoder" runs the decoder being edited on a payload, prefilled
  with the last decrypted payload of the device, without saving
- delete button

LORAWANconfigureDevice() now reports configuration errors to the caller,
decoder storage is shared between MQTT and the WebUI (LORAWANsetDecoder),
and device declarations are serialized with a larger buffer so longer
entity lists are not truncated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pwsh and others added 2 commits October 9, 2026 17:49
The Information page builds its table by replacing JSON punctuation and
can't display nested values: the "lorawan" array of the LORA state was
shown as stray rows ("lorawan | [devaddr | ...", "fcnt_down | 12}]").
Show each device as a single row ("LoRaWAN <DEVADDR>" with its fields
joined) on that page; the MQTT state keeps the array.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant