Smart Home: Shelly BLU Door/Window integration to MQTT without a dedicated Bluetooth Gateway

This article is purely for informational purposes. I’m not promoting any products or trying to sell anything — just sharing my findings and learnings.

Recently I got my hand on a Shelly BLU Door/Window sensor. My smart home setup is fairly vanilla and straightforward

  • a MQTT broker
  • a colorful collection of smart outlets, sensors, and switches connected to said MQTT broker

As somebody who considers himself technically interested and strongly in favor of privacy, I have absolutely no tolerance for any cloud-connected devices. Consequently, none of them have internet access and everything must work locally. Shelly devices have always been an easy choice for me, as most (if not all) of their products support local-only configuration without prices that lead straight to bankruptcy. Seriously, thanks! I really appreciate it.

As I am also lazy, I don’t want a wild mixture of different access technologies, using only Zigbee and Wifi as wireless technologies. But when I saw an recent offer for curiosity got the better of me. For smart outlets and monitoring I have a couple of Shelly 1PM installed, which, conveniently support Bluetooth functionalities, giving me hope to avoid an additional gateway hardware and support infrastructure.

SensorBluetooth1PMWiFiMQTTBroker

Solution

After the sensor arrived and I set out to implement my idea, I quickly realized it wouldn’t be that straightforward. While the my Shelly 1 PM’s do support Bluetooth, there is no click-and-forget way to implement my approach in the WebUI, but scripts let us do this easily. Looking around in the examples provided there I found the BLE in Scripting - Shelly BLU DoorWindow script actions demo-script. I am by no means a pro in Javascript, but reading and a little try-and-error gave me a good idea of whats going on and how to implement my approach. My full script is at the end of this post, most of it is still the same as the demo from the Shelly library. The documentation of the Shelly Scripting Language was a huge help to fill in the missing functionality gaps. So look there first, if you plan on modifying more.

Let’s dive in.

Finding the MAC Address of your Sensor

First you need to find your sensors MAC address. Many ways to do this, use whatever. I prefer the Shelly BLE Debug app (available for iOS/Android). Lets assume yours turns out to be the unambiguous 12:34:56:78:9A:BC.

Modifyning the script

Two key parts need modification

let SENSOR_MAC = "12:34:56:78:9A:BC"
let MQTT_TOPIC = "shellyplus1pm-cc123456789ABC/blu/" + SENSOR_MAC

Here is what they do:

  • SENSOR_MAC is the MAC address of your sensor you found in step 1
  • MQTT_TOPIC is, well, the MQTT topic where the sensor publishes to

Make sure you have MQTT enabled and connected on the 1PM!
You don’t need to use the same MQTT topic I do, in fact, you can pick anything that suits you. I just decided to ensure I could easily trace to which 1PM the sensor is connected when looking at my MQTT broker.

Finishing

After modifying everything, just run the script and observe hte debug output when upon triggering the sensor (e.g., by opening a door). You should see messages like this drop in

Shelly BTH packet:  {"encryption":false,"BTHome_version":2,"pid":83,"battery":100,"illuminance":5,"window":0,"rotation":0,"addr"
20:13:35  
:"12:34:56:78:9a:bc","rssi":-53}

At the same time the topic should pop up in your MQTT broker, with the corresponding payloads. In my case I decided on

  • the sensor state, i.e., open or closed under shellyplus1pm-cc123456789ABC/blu/window
  • the battery level under shellyplus1pm-cc123456789ABC/blu/battery
  • a low battery warning under shellyplus1pm-cc123456789ABC/blu/lowBatteryWarning

And thats it. Find the full script and links to sources below.

Script

Modified from the example found here.

Click to reveal full script
/**
 * This script uses the BLE scan functionality in scripting
 * Selects Shelly BLU DoorWindow from the aired advertisements, decodes
 * the service data payload and publishes it to a specified MQTT topic.
 */

let SENSOR_MAC = "12:34:56:78:9A:BC"
let MQTT_TOPIC = "shellyplus1pm-cc123456789abc/blu/" + SENSOR_MAC

// handle different states
function handleWindowClosed(parsed) {
  console.log("Window closed payload:", JSON.stringify(parsed));
  publishState(false, parsed.battery);
}

function handleWindowOpen(parsed) {
  console.log("Window opened payload:", JSON.stringify(parsed));
  publishState(true, parsed.battery);
}

// Publish State to MQTT
function publishState(isOpen, batteryPercent) {
  console.log("Publishing via MQTT: window=" + (isOpen ? "open" : "closed") + ", battery=" + batteryPercent);

  if (!MQTT.isConnected()) {
    console.log("MQTT not connected — skipping publish");
    return;
  }

  MQTT.publish(MQTT_TOPIC + "/window", isOpen ? "open" : "closed", 0, true);
  MQTT.publish(MQTT_TOPIC + "/battery", batteryPercent.toString(), 0, true);

  if (batteryPercent < 20) {
    MQTT.publish(MQTT_TOPIC + "/lowBatteryWarning", "true", 0, true);
  } else {
    MQTT.publish(MQTT_TOPIC + "/lowBatteryWarning", "false", 0, true);
  }  
  
}

let CONFIG = {
  shelly_blu_address: SENSOR_MAC,
  actions: [
    {
      cond: {
        window: 0,
      },
      action: handleWindowClosed,
    },
    {
      cond: {
        window: 1,
      },
      action: handleWindowOpen,
    },
  ],
};
// END OF CHANGE
const SCAN_PARAM_WANT = {
  duration_ms: BLE.Scanner.INFINITE_SCAN,
  active: true,
}

const ALLTERCO_MFD_ID_STR = "0ba9";
const BTHOME_SVC_ID_STR = "fcd2";

const uint8 = 0;
const int8 = 1;
const uint16 = 2;
const int16 = 3;
const uint24 = 4;
const int24 = 5;

// The BTH object defines the structure of the BTHome data
const BTH = {
  0x00: { n: "pid", t: uint8 },
  0x01: { n: "battery", t: uint8, u: "%" },
  0x02: { n: "temperature", t: int16, f: 0.01, u: "tC" },
  0x03: { n: "humidity", t: uint16, f: 0.01, u: "%" },
  0x05: { n: "illuminance", t: uint24, f: 0.01 },
  0x21: { n: "motion", t: uint8 },
  0x2d: { n: "window", t: uint8 },
  0x2e: { n: "humidity", t: uint8, u: "%" },
  0x3a: { n: "button", t: uint8 },
  0x3f: { n: "rotation", t: int16, f: 0.1 },
  0x45: { n: "temperature", t: int16, f: 0.1, u: "tC" },
};

function getByteSize(type) {
  if (type === uint8 || type === int8) return 1;
  if (type === uint16 || type === int16) return 2;
  if (type === uint24 || type === int24) return 3;
  //impossible as advertisements are much smaller;
  return 255;
}

// functions for decoding and unpacking the service data from Shelly BLU devices
const BTHomeDecoder = {
  utoi: function (num, bitsz) {
    const mask = 1 << (bitsz - 1);
    return num & mask ? num - (1 << bitsz) : num;
  },
  getUInt8: function (buffer) {
    return buffer.at(0);
  },
  getInt8: function (buffer) {
    return this.utoi(this.getUInt8(buffer), 8);
  },
  getUInt16LE: function (buffer) {
    return 0xffff & ((buffer.at(1) << 8) | buffer.at(0));
  },
  getInt16LE: function (buffer) {
    return this.utoi(this.getUInt16LE(buffer), 16);
  },
  getUInt24LE: function (buffer) {
    return (
      0x00ffffff & ((buffer.at(2) << 16) | (buffer.at(1) << 8) | buffer.at(0))
    );
  },
  getInt24LE: function (buffer) {
    return this.utoi(this.getUInt24LE(buffer), 24);
  },
  getBufValue: function (type, buffer) {
    if (buffer.length < getByteSize(type)) return null;
    let res = null;
    if (type === uint8) res = this.getUInt8(buffer);
    if (type === int8) res = this.getInt8(buffer);
    if (type === uint16) res = this.getUInt16LE(buffer);
    if (type === int16) res = this.getInt16LE(buffer);
    if (type === uint24) res = this.getUInt24LE(buffer);
    if (type === int24) res = this.getInt24LE(buffer);
    return res;
  },

  // Unpacks the service data buffer from a Shelly BLU device
  unpack: function (buffer) {
    //beacons might not provide BTH service data
    if (typeof buffer !== "string" || buffer.length === 0) return null;
    let result = {};
    let _dib = buffer.at(0);
    result["encryption"] = _dib & 0x1 ? true : false;
    result["BTHome_version"] = _dib >> 5;
    if (result["BTHome_version"] !== 2) return null;
    //can not handle encrypted data
    if (result["encryption"]) return result;
    buffer = buffer.slice(1);

    let _bth;
    let _value;
    while (buffer.length > 0) {
      _bth = BTH[buffer.at(0)];
      if (typeof _bth === "undefined") {
        console.log("BTH: Unknown type");
        break;
      }
      buffer = buffer.slice(1);
      _value = this.getBufValue(_bth.t, buffer);
      if (_value === null) break;
      if (typeof _bth.f !== "undefined") _value = _value * _bth.f;

      if (typeof result[_bth.n] === "undefined") {
        result[_bth.n] = _value;
      }
      else {
        if (Array.isArray(result[_bth.n])) {
          result[_bth.n].push(_value);
        }
        else {
          result[_bth.n] = [
            result[_bth.n],
            _value
          ];
        }
      }

      buffer = buffer.slice(getByteSize(_bth.t));
    }
    return result;
  },
};

let ShellyBLUParser = {
  getData: function (res) {
    let result = BTHomeDecoder.unpack(res.service_data[BTHOME_SVC_ID_STR]);
    result.addr = res.addr;
    result.rssi = res.rssi;
    return result;
  },
};

let last_packet_id = 0x100;
function scanCB(ev, res) {
  if (ev !== BLE.Scanner.SCAN_RESULT) return;
  // skip if there is no service_data member
  if (
    typeof res.service_data === "undefined" ||
    typeof res.service_data[BTHOME_SVC_ID_STR] === "undefined"
  )
    return;
  // skip if we are looking for name match but don't have active scan as we don't have name
  if (
    typeof CONFIG.shelly_blu_name_prefix !== "undefined" &&
    (typeof res.local_name === "undefined" ||
      res.local_name.indexOf(CONFIG.shelly_blu_name_prefix) !== 0)
  )
    return;
  // skip if we don't have address match
  if (
    typeof CONFIG.shelly_blu_address !== "undefined" &&
    CONFIG.shelly_blu_address !== res.addr
  )
    return;
  let BTHparsed = ShellyBLUParser.getData(res);
  // skip if parsing failed
  if (BTHparsed === null) {
    console.log("Failed to parse BTH data");
    return;
  }
  // skip, we are deduping results
  if (last_packet_id === BTHparsed.pid) return;
  last_packet_id = BTHparsed.pid;
  console.log("Shelly BTH packet: ", JSON.stringify(BTHparsed));
  // execute actions from CONFIG
  let aIdx = null;
  for (aIdx in CONFIG.actions) {
    // skip if no condition defined
    if (typeof CONFIG.actions[aIdx]["cond"] === "undefined") continue;
    let cond = CONFIG.actions[aIdx]["cond"];
    let cIdx = null;
    let run = true;
    for (cIdx in cond) {
      if (typeof BTHparsed[cIdx] === "undefined") run = false;
      if (BTHparsed[cIdx] !== cond[cIdx]) run = false;
    }
    // if all conditions evaluated to true then execute
    if (run) CONFIG.actions[aIdx]["action"](BTHparsed);
  }
}

function init() {  
  // get the config of ble component
  const BLEConfig = Shelly.getComponentConfig("ble");

  // exit if the BLE isn't enabled
  if (!BLEConfig.enable) {
    console.log(
      "Error: The Bluetooth is not enabled, please enable it from settings"
    );
    return;
  }

  // check if the scanner is already running
  if (BLE.Scanner.isRunning()) {
    console.log("Info: The BLE gateway is running, the BLE scan configuration is managed by the device");
  }
  else {
    // start the scanner
    const bleScanner = BLE.Scanner.Start(SCAN_PARAM_WANT);

    if (!bleScanner) {
      console.log("Error: Can not start new scanner");
    }
  }

  // subscribe a callback to BLE scanner
  BLE.Scanner.Subscribe(scanCB);
}

init();

List of Sources

[1] - Shelly Scripting Language Docs,
https://shelly-api-docs.shelly.cloud/gen2/Scripts/ShellyScriptLanguageFeatures
[2] - Original Script from ALLTERCO Github Repository,
https://github.com/ALLTERCO/shelly-script-examples/blob/54e90ea42fc7f3c145257c7b0cf70992a3373554/ble-shelly-dw.js