Breaking the Wi-Fi Barrier: True Peer-to-Peer LoRa with Shelly 1 Gen3

If you’ve ever tried to automate a gate motor at the end of a long driveway or control a borehole pump out in the field, you know the ultimate enemy of localized automation: Wi-Fi range.

At RitchieIOT, we advocate for robust, local, cloud-free control. That’s why we are incredibly excited about the Shelly 1 Gen3 combined with the Shelly LoRa Add-on.

However, out of the box, setting up two of these devices to talk directly to each other securely (peer-to-peer) requires custom scripting. Today, we are going to walk you through setting up a two-way, AES-encrypted LoRa communication link between two Shelly 1 Gen3 relays, completely bypassing the need for a central Wi-Fi network.

The Architecture: Sender & Receiver

Our goal is simple but powerful:

  1. The Sender: A Shelly 1 Gen3 sitting in your house. When you trigger a Virtual Button, it fires an encrypted LoRa packet over the air.
  2. The Receiver: A second Shelly 1 Gen3 sitting hundreds of meters away. It receives the packet, decrypts it, and triggers its relay.
  3. The Feedback Loop: Once the remote relay clicks on, the Receiver instantly beams a confirmation packet back to the Sender, updating a Virtual Boolean so you know the action actually happened.

Perfect Use Cases for this Setup

  • Remote Gate Control: Trigger your main gate from the house without laying CAT6 cable or relying on spotty outdoor Wi-Fi extenders.
  • Agricultural Pumps: Turn on distant irrigation relays and get confirmed feedback that the pump is running.
  • Outbuilding Lighting: Control and monitor lights in a disconnected shed or garage.

Under the Hood: Understanding the Code

Shelly devices run incredibly fast, but they do so using a highly optimized, stripped-down JavaScript engine called mJS. If you’ve tried using standard AES decryption scripts on a Gen3 device, you’ve likely seen it crash with errors like Function "trim" not found! or Function "charCodeAt" not found!.

Before we give you the full scripts, let’s look at the three most important snippets that make this setup crash-proof.

1. The mJS Memory Safe Helper

To save memory, Shelly’s firmware doesn’t return standard JavaScript strings after AES decryption. To prevent the script from crashing when trying to read the payload, we wrote a custom getCode() helper.

JavaScript

// Safely extract a byte value from Shelly's internal memory types
function getCode(obj, index) {
  if (typeof obj.at === "function") return obj.at(index);
  if (typeof obj.charCodeAt === "function") return obj.charCodeAt(index);
  let val = obj[index];
  if (typeof val === "number") return val;
  if (typeof val === "string" && typeof val.at === "function") return val.at(0);
  return 0;
}
  • Why it matters: This guarantees that whether the AES engine returns a C-string, an ArrayBuffer, or a standard string, our script can read the payload character-by-character without throwing a critical memory error.

2. Catching the Incoming Command

On the receiving end, we need to listen for the LoRa radio picking up a packet. We do this using Shelly.addEventHandler.

JavaScript

// Listen for Incoming LoRa Commands
Shelly.addEventHandler(function (event) {
  // Check that the event is specifically a LoRa radio reception
  if (event.info && event.info.event === "lora_received" && event.info.data) {
    
    let decryptedFull = decryptMessage(event.info.data, aesKey);
    let rawMessage = verifyAndExtractMessage(decryptedFull);

    if (rawMessage !== null) {
      let data = JSON.parse(rawMessage);
      
      // Trigger the local relay based on the JSON command sent
      if (data.command === "toggle") {
        Shelly.call("Switch.Toggle", { id: 0 });
      }
    }
  }
});
  • Why it matters: By checking for "lora_received" and decrypting the payload into standard JSON, we can send clean, targeted commands (like {"command": "toggle"}) over the air.

3. Broadcasting Feedback (Status vs. Event)

Here is where many developers get stuck: Hardware state changes do not trigger standard events. If we want the Receiver to tell the Sender that its relay turned on, we must use Shelly.addStatusHandler().

JavaScript

// Listen for Hardware Relay Changes
Shelly.addStatusHandler(function (status) {
  // Check if the output state of Switch 0 just changed
  if (status.component === "switch:0" && status.delta && status.delta.output !== undefined) {
    print("Relay state changed! Broadcasting feedback...");
    
    // Transmit the new state (true/false) back over LoRa
    sendLoRaMessage(JSON.stringify({ feedback: status.delta.output }));
  }
});
  • Why it matters: Because we are watching the hardware status, this feedback fires even if someone manually flips a physical wall switch connected to the remote Shelly. Your Sender (and your smart home dashboard) will always know the true state of the remote relay!

The Full Deployment Scripts

Ready to deploy? You’ll need two Shelly 1 Gen3 relays and two Shelly LoRa Add-on modules.

  1. Ensure both devices are updated to firmware 1.6 or higher.
  2. Set your regional LoRa frequency in both web interfaces (e.g., EU868).
  3. On the Sender, create a Virtual Button (button:200) and a Virtual Boolean (boolean:200).
  4. Generate a secure, 24-character AES key and paste it into both scripts below.

1. The Two-Way Sender Script

Load this onto the Shelly inside your house. It watches your virtual button to send the command, and updates your virtual boolean when the feedback arrives.

JavaScript

/**
 * @title Sender / Boolean Updater (Two-Way LoRa)
 */

// Replace this with your own 24-character key!
const aesKey = 'dd469421e5f4089a1418ea24ba37c61bdd469421e5f4089a1418ea24ba37c61b';
const CHECKSUM_SIZE = 4;

function getCode(obj, index) {
  if (typeof obj.at === "function") return obj.at(index);
  if (typeof obj.charCodeAt === "function") return obj.charCodeAt(index);
  let val = obj[index];
  if (typeof val === "number") return val;
  if (typeof val === "string" && typeof val.at === "function") return val.at(0);
  return 0;
}

function generateChecksum(msg) {
  let checksum = 0;
  for (let i = 0; i < msg.length; i++) checksum ^= getCode(msg, i);
  let hexChecksum = checksum.toString(16);
  while (hexChecksum.length < CHECKSUM_SIZE) hexChecksum = '0' + hexChecksum;
  
  let finalCalcChecksum = "";
  let startIdx = hexChecksum.length - CHECKSUM_SIZE;
  for (let i = startIdx; i < hexChecksum.length; i++) finalCalcChecksum += chr(getCode(hexChecksum, i));
  return finalCalcChecksum;
}

function encryptMessage(msg, keyHex) {
  function fromHex(hex) {
    const arr = new ArrayBuffer(hex.length / 2);
    for (let i = 0; i < hex.length; i += 2) {
      let byteStr = chr(getCode(hex, i)) + chr(getCode(hex, i + 1));
      arr[i / 2] = parseInt(byteStr, 16);
    }
    return arr;
  }
  const blockSize = 16;
  const paddingSize = (blockSize - msg.length % blockSize) % blockSize;
  for (let i = 0; i < paddingSize; i++) msg += ' ';
  const key = fromHex(keyHex);
  return AES.encrypt(msg, key, { mode: 'ECB' });
}

function sendLoRaMessage(payload) {
  let checkSumMessage = generateChecksum(payload) + payload;
  let encryptedMessage = encryptMessage(checkSumMessage, aesKey);
  Shelly.call('Lora.SendBytes', { id: 200, data: btoa(encryptedMessage) }, function (_, err_code, err_msg) {
    if (err_code !== 0) print("Send Error:", err_msg);
    else print("Sent LoRa Payload:", payload);
  });
}

function decryptMessage(base64EncMsg, keyHex) {
  function fromHex(hex) {
    const arr = new ArrayBuffer(hex.length / 2);
    for (let i = 0; i < hex.length; i += 2) {
      let byteStr = chr(getCode(hex, i)) + chr(getCode(hex, i + 1));
      arr[i / 2] = parseInt(byteStr, 16);
    }
    return arr;
  }
  let encMsg = atob(base64EncMsg);
  const key = fromHex(keyHex);
  return AES.decrypt(encMsg, key, { mode: 'ECB' });
}

function verifyAndExtractMessage(decryptedObj) {
  let end = decryptedObj.length - 1;
  while (end >= 0) {
    let code = getCode(decryptedObj, end);
    if (code === 32 || code === 0) end--;
    else break;
  }
  let cleanMsgLength = end + 1;
  if (cleanMsgLength <= CHECKSUM_SIZE) return null;
  
  let receivedChecksum = "";
  for (let i = 0; i < CHECKSUM_SIZE; i++) receivedChecksum += chr(getCode(decryptedObj, i));
  
  let message = "";
  let checksum = 0;
  for (let i = CHECKSUM_SIZE; i < cleanMsgLength; i++) {
    let code = getCode(decryptedObj, i);
    message += chr(code);
    checksum ^= code;
  }
  
  let calculatedChecksum = checksum.toString(16);
  while (calculatedChecksum.length < CHECKSUM_SIZE) calculatedChecksum = '0' + calculatedChecksum;
  
  let finalCalcChecksum = "";
  let startIdx = calculatedChecksum.length - CHECKSUM_SIZE;
  for (let i = startIdx; i < calculatedChecksum.length; i++) finalCalcChecksum += chr(getCode(calculatedChecksum, i));

  if (finalCalcChecksum === receivedChecksum) return message;
  return null;
}

// Handle both sending commands and receiving feedback
Shelly.addEventHandler(function (event) {
  // 1. Send command when virtual button is pressed
  if (event.component === "button:200" && event.info && event.info.event === "single_push") {
    print("Button pushed! Sending command...");
    sendLoRaMessage(JSON.stringify({ command: "toggle" }));
  }

  // 2. Receive feedback and update virtual boolean
  if (event.info && event.info.event === "lora_received" && event.info.data) {
    try {
      let decryptedFull = decryptMessage(event.info.data, aesKey);
      let rawMessage = verifyAndExtractMessage(decryptedFull);
      if (rawMessage !== null) {
        let data = JSON.parse(rawMessage);
        
        // If the payload contains relay feedback, update boolean:200
        if (data.feedback !== undefined) {
          print("Feedback received! Remote relay is:", data.feedback ? "ON" : "OFF");
          Shelly.call("Boolean.Set", { id: 200, value: data.feedback });
        }
      }
    } catch (e) {
      print("Error processing feedback:", JSON.stringify(e));
    }
  }
});

2. The Two-Way Receiver Script

Load this onto the remote Shelly (e.g., at your gate or pump). It listens for the encrypted command, triggers the relay, and automatically broadcasts its new status back to the house.

JavaScript

/**
 * @title TWO-WAY LoRa Receiver & Feedback Broadcaster
 * @description Receives commands, toggles relay, and sends status feedback. 100% mJS safe.
 */

// Must match the Sender's key exactly!
const aesKey = 'dd469421e5f4089a1418ea24ba37c61bdd469421e5f4089a1418ea24ba37c61b';
const CHECKSUM_SIZE = 4;
const LORA_ID = 100; // ID of the LoRa add-on on the Receiver device

// Safely extract a byte value from Shelly's internal memory types
function getCode(obj, index) {
  if (typeof obj.at === "function") return obj.at(index);
  if (typeof obj.charCodeAt === "function") return obj.charCodeAt(index);
  let val = obj[index];
  if (typeof val === "number") return val;
  if (typeof val === "string" && typeof val.at === "function") return val.at(0);
  return 0;
}

// --- ENCRYPTION & SENDING (For Feedback) ---
function generateChecksum(msg) {
  let checksum = 0;
  for (let i = 0; i < msg.length; i++) checksum ^= getCode(msg, i);
  let hexChecksum = checksum.toString(16);
  while (hexChecksum.length < CHECKSUM_SIZE) hexChecksum = '0' + hexChecksum;
  
  let finalCalcChecksum = "";
  let startIdx = hexChecksum.length - CHECKSUM_SIZE;
  for (let i = startIdx; i < hexChecksum.length; i++) finalCalcChecksum += chr(getCode(hexChecksum, i));
  return finalCalcChecksum;
}

function encryptMessage(msg, keyHex) {
  function fromHex(hex) {
    const arr = new ArrayBuffer(hex.length / 2);
    for (let i = 0; i < hex.length; i += 2) {
      let byteStr = chr(getCode(hex, i)) + chr(getCode(hex, i + 1));
      arr[i / 2] = parseInt(byteStr, 16);
    }
    return arr;
  }
  const blockSize = 16;
  const paddingSize = (blockSize - msg.length % blockSize) % blockSize;
  for (let i = 0; i < paddingSize; i++) msg += ' ';
  const key = fromHex(keyHex);
  return AES.encrypt(msg, key, { mode: 'ECB' });
}

function sendLoRaMessage(payload) {
  let checkSumMessage = generateChecksum(payload) + payload;
  let encryptedMessage = encryptMessage(checkSumMessage, aesKey);
  
  Shelly.call('Lora.SendBytes', { id: LORA_ID, data: btoa(encryptedMessage) }, function (_, err_code, err_msg) {
    if (err_code !== 0) print("Feedback Send Error:", err_msg);
    else print("Sent LoRa Feedback:", payload);
  });
}

// --- DECRYPTION & RECEIVING (For Commands) ---
function decryptMessage(base64EncMsg, keyHex) {
  function fromHex(hex) {
    const arr = new ArrayBuffer(hex.length / 2);
    for (let i = 0; i < hex.length; i += 2) {
      let byteStr = chr(getCode(hex, i)) + chr(getCode(hex, i + 1));
      arr[i / 2] = parseInt(byteStr, 16);
    }
    return arr;
  }
  let encMsg = atob(base64EncMsg);
  const key = fromHex(keyHex);
  return AES.decrypt(encMsg, key, { mode: 'ECB' });
}

function verifyAndExtractMessage(decryptedObj) {
  let end = decryptedObj.length - 1;
  while (end >= 0) {
    let code = getCode(decryptedObj, end);
    if (code === 32 || code === 0) end--;
    else break;
  }
  let cleanMsgLength = end + 1;
  if (cleanMsgLength <= CHECKSUM_SIZE) return null;
  
  let receivedChecksum = "";
  for (let i = 0; i < CHECKSUM_SIZE; i++) receivedChecksum += chr(getCode(decryptedObj, i));
  
  let message = "";
  let checksum = 0;
  for (let i = CHECKSUM_SIZE; i < cleanMsgLength; i++) {
    let code = getCode(decryptedObj, i);
    message += chr(code);
    checksum ^= code;
  }
  
  let calculatedChecksum = checksum.toString(16);
  while (calculatedChecksum.length < CHECKSUM_SIZE) calculatedChecksum = '0' + calculatedChecksum;
  
  let finalCalcChecksum = "";
  let startIdx = calculatedChecksum.length - CHECKSUM_SIZE;
  for (let i = startIdx; i < calculatedChecksum.length; i++) finalCalcChecksum += chr(getCode(calculatedChecksum, i));

  if (finalCalcChecksum === receivedChecksum) return message;
  return null;
}

// --- 1. EVENT HANDLER: Listen for Incoming LoRa Commands ---
Shelly.addEventHandler(function (event) {
  if (event.info && event.info.event === "lora_received" && event.info.data) {
    try {
      let decryptedFull = decryptMessage(event.info.data, aesKey);
      let rawMessage = verifyAndExtractMessage(decryptedFull);

      if (rawMessage !== null) {
        let action = "";
        try {
          let data = JSON.parse(rawMessage);
          action = data.command || data.relay;
        } catch (e) {
          action = rawMessage; 
        }

        if (action === "toggle" || action === "test") {
          Shelly.call("Switch.Toggle", { id: 0 });
        } else if (action === "on" || action === "off") {
          Shelly.call("Switch.Set", { id: 0, on: (action === "on") });
        }
      }
    } catch (e) {
      print("Error processing command:", JSON.stringify(e));
    }
  }
});

// --- 2. STATUS HANDLER: Listen for Hardware Relay Changes ---
Shelly.addStatusHandler(function (status) {
  // Check if the status update is for Switch 0, and if its output state changed
  if (status.component === "switch:0" && status.delta && status.delta.output !== undefined) {
    print("Relay state changed to: ", status.delta.output ? "ON" : "OFF", ". Broadcasting feedback...");
    
    // Send the new state back to the Sender
    sendLoRaMessage(JSON.stringify({ feedback: status.delta.output }));
  }
});