Shelly LoRa Add-on

Gateway • REST-API (DDF)

Shelly
Download Signed DDF min. OS version: 10384-07
Device image

With this DDF, Shelly devices with a LoRa add-on can be integrated, allowing one input and one output to be used in each case. Note: Please observe the regionally applicable regulations for LoRa usage. A Shelly with a LoRa add-on is operated in the same IP network as the myGEKKO controller. It serves as a LoRa master gateway and communicates with other Shelly devices (slaves) via LoRa.

Connect to the Wi-Fi access point of the Shelly via 192.168.33.1, then connect it to your Wi-Fi and perform a firmware update if necessary. Tested with 2.0.0-beta1, add-on 2.1.1.

Shelly Master (Gateway in Wi-Fi)

  • Create and activate script lora_master.js
  • Enable "Run on startup"
  • Activate LoRa add-on, configuration must be identical on all Shellys!
  • LoRa transport layer Device Address: 0001 (HEX)
  • Generate and note down the Cryptography-Key
  • Enable "User LoRa calls"
Script Code: lora_master.js
let lastByAddr = {};

function nowIso() {
  return new Date().toISOString();
}

function nowUnix() {
  return Math.floor(Date.now() / 1000);
}

// Shelly-safe Query Parser (NO decodeURIComponent!)
function parseQuery(qs) {
  let out = {};

  if (!qs) return out;

  let parts = qs.split("&");

  for (let i = 0; i < parts.length; i++) {
    let kv = parts[i].split("=");

    let key = kv[0];
    let val = kv.length > 1 ? kv[1] : "";

    if (key) out[key] = val;
  }

  return out;
}

Shelly.addEventHandler(function (ev) {
  if (!ev || !ev.info) return;

  let addr = ev.info.lr_addr || ev.info.addr || ev.info.sender;
  let data = ev.info.data;

  if (addr === undefined || data === undefined) return;

  addr = String(addr);

  try {
    data = JSON.parse(atob(String(data)));
  } catch (e) {
    try {
      data = atob(String(data));
    } catch (e2) {
      data = String(data);
    }
  }

  lastByAddr[addr] = {
    addr: addr,
    data: data,
    rssi: ev.info.rssi,
    snr: ev.info.snr,
    received: nowIso(),
    received_unix: nowUnix()
  };

  print("LoRa received: " + addr);
});

HTTPServer.registerEndpoint("lora", function (req, res) {
  let query = parseQuery(req.query);
  let addr = query.addr;

  if (!addr) {
    res.code = 400;
    res.headers = [["Content-Type", "application/json"]];
    res.body = JSON.stringify({ error: "addr missing" });
    res.send();
    return;
  }

  let result = lastByAddr[String(addr)];

  if (!result) {
    res.code = 404;
    res.headers = [["Content-Type", "application/json"]];
    res.body = JSON.stringify({ error: "not found", addr: addr });
    res.send();
    return;
  }

  res.code = 200;
  res.headers = [["Content-Type", "application/json"]];
  res.body = JSON.stringify(result);
  res.send();
});

print("Endpoint active: /script//lora?addr=");

Shelly Slave (LoRa End Devices)

  • Create and activate script lora_slave.js
  • Enable "Run on startup"
  • LoRa transport layer Device Address:
    • 0002 (HEX) – first device
    • 0003 to 000F (HEX) – additional slaves
  • Insert Cryptography-Key from the Master
  • Enable "User LoRa calls"
Script Code: lora_slave.js
let LORA_ID = 100;
let LORA_ADDR = "00000001";
let OUTPUT_ID = 0;

let di = 0;
let doo = 0;
let last = "";

function send(force) {
  // Send status as array
  let msg = JSON.stringify([di, doo]);

  if (!force && msg === last) return;
  last = msg;

  console.log("SENDING:", msg);

  Shelly.call("LoRa.Send", {
    id: LORA_ID,
    lr_addr: LORA_ADDR,
    data: btoa(msg)
  }, function (res, err, msg) {
    if (err !== 0) {
      console.log("LoRa Error:", err, msg);
    }
  });
}

function startPeriodicSend() {
  // a status heartbeat every 15 minutes
  Timer.set(15 * 60 * 1000, true, function () {
    send(true);
  });
}

function handleCommand(rawPayload) {
  let payload = rawPayload;

  // If data arrives base64 encoded, decode it
  try {
    payload = atob(rawPayload);
  } catch (e) {
    // if not base64, use rawPayload directly
  }

  console.log("LoRa RX raw:", rawPayload, "decoded:", payload);

  if (payload === "1" || payload === "ON" || payload === "true") {
    Shelly.call("Switch.Set", { id: OUTPUT_ID, on: true }, function (res, err, msg) {
      console.log("Switch.Set ON -> err:", err, "msg:", msg);
    });
  } else if (payload === "0" || payload === "OFF" || payload === "false") {
    Shelly.call("Switch.Set", { id: OUTPUT_ID, on: false }, function (res, err, msg) {
      console.log("Switch.Set OFF -> err:", err, "msg:", msg);
    });
  } else {
    console.log("Unknown payload:", payload);
  }
}

Shelly.addEventHandler(function (ev) {
  if (ev.name !== "lora") return;
  if (!ev.info) return;

  if (ev.info.component !== "lora:100") return;
  if (ev.info.event !== "user_rx") return;

  handleCommand(ev.info.data);
});

Shelly.addStatusHandler(function (e) {
  if (!e || !e.component || !e.delta) return;

  if (e.component === "input:0" && e.delta.state !== undefined) {
    di = e.delta.state ? 1 : 0;
    send(false);
  }

  if (e.component === "switch:0" && e.delta.output !== undefined) {
    doo = e.delta.output ? 1 : 0;
    send(false);
  }
});

// one-time random startup delay
var startDelay = Math.floor(Math.random() * 15 * 60 * 1000);
console.log("Starting in", startDelay, "ms");

Timer.set(startDelay, false, function () {
  send(true);
  startPeriodicSend();
});

console.log("LoRa combo script started");

DeviceStation

  • Domain: http://<IP_Shelly_Master>
  • Slave addresses: 1,2, ... ,15

"Device" System

  • Create one block per Shelly and select Dashboard
  • Link as widgets on the homepage or rooms/areas

"Light" or "Socket" System

  • Switching output: SET Output
  • Feedback: State Output
To Do: The current slave script does not use a send queue. In case of rapid status changes, multiple LoRa.Send calls can be started simultaneously, which can cause the Shelly error Too many calls in progress. It is recommended to implement a busy-flag or queue/debounce logic so that only one LoRa transmission process is active at a time.
Script code with queue and sequential input, output: lora_slave_queue.js

let LORA_ID = 100;
let LORA_ADDR = "00000001";
let OUTPUT_ID = 0;

let LORA_SEND_GAP_MS = 2000;

let di = 0;
let doo = 0;

let loraBusy = false;
let sendQueue = [];
let queuePos = 0;

let lastSent = "";
let lastQueued = "";
let nextSendAllowedAt = 0;

let retryTimerActive = false;

function getNowMs() {
  return Shelly.getUptimeMs();
}

function enqueueSend(force) {
  let msg = JSON.stringify([di, doo]);

  if (!force && msg === lastQueued) {
    return;
  }

  if (!force &&
      msg === lastSent &&
      queuePos >= sendQueue.length &&
      !loraBusy) {
    return;
  }

  sendQueue.push({
    msg: msg,
    force: force
  });

  lastQueued = msg;
  processSendQueue();
}

function processSendQueue() {
  if (loraBusy) {
    return;
  }

  if (queuePos >= sendQueue.length) {
    sendQueue = [];
    queuePos = 0;
    lastQueued = lastSent;
    return;
  }

  let now = getNowMs();

  if (now < nextSendAllowedAt) {
    if (!retryTimerActive) {
      retryTimerActive = true;
      Timer.set(nextSendAllowedAt - now, false, function () {
        retryTimerActive = false;
        processSendQueue();
      });
    }
    return;
  }

  let item = sendQueue[queuePos];
  queuePos++;

  loraBusy = true;
  lastSent = item.msg;

  console.log("SENDING:", item.msg, item.force ? "(force)" : "");

  Shelly.call("LoRa.Send", {
    id: LORA_ID,
    lr_addr: LORA_ADDR,
    data: btoa(item.msg)
  }, function (res, err, errmsg) {
    if (err !== 0) {
      console.log("LoRa Error:", err, errmsg);
    }

    nextSendAllowedAt = getNowMs() + LORA_SEND_GAP_MS;
    loraBusy = false;

    processSendQueue();
  });
}

function startPeriodicSend() {
  Timer.set(15 * 60 * 1000, true, function () {
    enqueueSend(true);
  });
}

function handleCommand(rawPayload) {
  let payload = rawPayload;

  try {
    payload = atob(rawPayload);
  } catch (e) {
    // use rawPayload directly
  }

  console.log("LoRa RX raw:", rawPayload, "decoded:", payload);

  if (payload === "1" || payload === "ON" || payload === "true") {
    Shelly.call("Switch.Set", { id: OUTPUT_ID, on: true }, function (res, err, msg) {
      console.log("Switch.Set ON -> err:", err, "msg:", msg);
    });
  } else if (payload === "0" || payload === "OFF" || payload === "false") {
    Shelly.call("Switch.Set", { id: OUTPUT_ID, on: false }, function (res, err, msg) {
      console.log("Switch.Set OFF -> err:", err, "msg:", msg);
    });
  } else {
    console.log("Unknown payload:", payload);
  }
}

Shelly.addEventHandler(function (ev) {
  if (ev.name !== "lora") return;
  if (!ev.info) return;

  if (ev.info.component !== "lora:100") return;
  if (ev.info.event !== "user_rx") return;

  handleCommand(ev.info.data);
});

Shelly.addStatusHandler(function (e) {
  if (!e || !e.component || !e.delta) return;

  if (e.component === "input:0" && e.delta.state !== undefined) {
    di = e.delta.state ? 1 : 0;
    enqueueSend(false);
  }

  if (e.component === "switch:0" && e.delta.output !== undefined) {
    doo = e.delta.output ? 1 : 0;
    enqueueSend(false);
  }
});

var startDelay = Math.floor(Math.random() * 15 * 60 * 1000);
console.log("Starting in", startDelay, "ms");

Timer.set(startDelay, false, function () {
  enqueueSend(true);
  startPeriodicSend();
});
Manufacturer Type Protocol Model Version ID
Shelly Gateway REST-API (DDF) 2 1 0x0D00002D00020100
No documents.
ID Name Unit Type Direction
0 Slave
1 State Input
2 State Output
3 RSSI
4 Received Unix
5 Received
6 Message Age
100 SET Output
200 Quality
*2026-06-15 13:30:01 (hw) ******************************
Last Commit: ac75e22f | 2026-06-10 07:25:55