With some debugging and some trial and error I was able to decode the BLE ADV message and trigger MQTT message based on the button press. And ESP32 intercepts the message and decodes the non standard payload.
Right now it is a simple MQTT proxy which publishes the button press events, you can adapt to you needs. For now it is pure arduino but planning to port ito tasmota32-ble.
#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <NimBLEDevice.h>
// ======================= WiFi + MQTT CONFIG =======================
const char* WIFI_SSID = "HomeWifi-SSID";
const char* WIFI_PASSWORD = "wifipass";
const char* MQTT_HOST = "10.0.0.99";
const uint16_t MQTT_PORT = 1883;
// MQTT auth
const char* MQTT_USER = "mqttuser";
const char* MQTT_PASSWORD_MQTT = "mqttpass";
// Base topic, R5 addr is appended as suffix: <base>/<addr_hex>
const char* MQTT_BASE_TOPIC = "ble_gateway/sonoff_r5";
// OPTIONAL: put your R5 MAC here to filter only that device.
// Leave empty "" to see all BLE devices but only decode/publish R5.
static const char* TARGET_ADDR = "66:55:44:33:22:11"; // or "" if you want
WiFiClient espClient;
PubSubClient mqttClient(espClient);
// ======================= R5 EVENT STRUCT & DECODE =======================
struct R5Event {
uint8_t type; // message type
uint8_t version; // protocol version
uint8_t count; // counter
uint32_t addr; // 4-byte device ID
uint8_t cmd; // command
uint8_t outlet; // button index (0..5)
uint8_t keyAction; // 0=single, 1=double, 2=long, etc.
uint32_t seq; // 3-byte sequence
uint8_t rand; // random byte
};
// simple duplicate suppression
unsigned long lastEventTimeMs = 0;
uint32_t lastEventHash = 0;
// Track last event time per button (max 6 buttons)
unsigned long lastButtonEventMs[6] = {0,0,0,0,0,0};
bool buttonIsPressed[6] = {false,false,false,false,false,false};
const unsigned long RELEASE_TIMEOUT = 1000; // 1 seconds
uint32_t lastR5Address = 0;
// ---- R5 decode function ----
bool decodeR5(const std::vector<uint8_t>& raw, R5Event& ev) {
if (raw.size() < 29) {
return false;
}
// AD length must be 0x1B
if (raw[3] != 0x1B) {
return false;
}
// companyIdentifier must be 0xFFFF
if (raw[5] != 0xFF || raw[6] != 0xFF) {
return false;
}
// hardPattern: raw[7..12] must equal: EE 1B C8 78 F6 4A
const uint8_t expectedPattern[6] = {0xEE, 0x1B, 0xC8, 0x78, 0xF6, 0x4A};
for (int i = 0; i < 6; i++) {
if (raw[7 + i] != expectedPattern[i]) {
return false;
}
}
static const uint8_t ENCODE_TABLE[16] = {
0x41, 0x92, 0x53, 0x2A,
0xFC, 0xAB, 0xCE, 0x26,
0x0D, 0x1E, 0x99, 0x78,
0x00, 0x22, 0x99, 0xDE
};
// Payload is raw[13..28], 16 bytes
uint8_t payload[16];
for (int i = 0; i < 16; i++) {
payload[i] = raw[13 + i];
}
// First XOR pass with ENCODE_TABLE
uint8_t tmp[16];
for (int i = 0; i < 16; i++) {
tmp[i] = payload[i] ^ ENCODE_TABLE[i];
}
// Second XOR pass: last byte is "rand"
uint8_t last = tmp[15];
uint8_t dec[16];
// First 7 bytes
for (int i = 0; i < 7; i++) {
dec[i] = tmp[i];
}
// Next 8 bytes: tmp[7..14] XOR last
for (int i = 0; i < 8; i++) {
dec[7 + i] = tmp[7 + i] ^ last;
}
// Last byte
dec[15] = last;
ev.type = dec[0];
ev.version = dec[1];
ev.count = dec[2];
ev.addr = (uint32_t(dec[3]) << 24) |
(uint32_t(dec[4]) << 16) |
(uint32_t(dec[5]) << 8) |
uint32_t(dec[6]);
ev.cmd = dec[8];
ev.outlet = dec[9];
ev.keyAction = dec[10];
ev.seq = (uint32_t(dec[11]) << 16) |
(uint32_t(dec[12]) << 8) |
uint32_t(dec[13]);
ev.rand = dec[15];
return true;
}
// ======================= DUPLICATE FILTER =======================
bool isDuplicateEvent(const R5Event& ev) {
unsigned long now = millis();
// simple hash of outlet + action + seq
uint32_t h = ((uint32_t)ev.outlet << 24) ^
((uint32_t)ev.keyAction << 16) ^
(ev.seq & 0xFFFF);
if (h == lastEventHash && (now - lastEventTimeMs) < 1000) {
// same event within 1 second → ignore
return true;
}
lastEventHash = h;
lastEventTimeMs = now;
return false;
}
// ======================= WIFI + MQTT HELPERS =======================
void connectWiFi() {
Serial.print(F("Connecting to WiFi: "));
Serial.println(WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print('.');
}
Serial.println();
Serial.print(F("WiFi connected, IP: "));
Serial.println(WiFi.localIP());
}
void connectMQTT() {
while (!mqttClient.connected()) {
Serial.print(F("Connecting to MQTT... "));
String clientId = "esp32-r5-";
clientId += String((uint32_t)ESP.getEfuseMac(), HEX);
if (mqttClient.connect(clientId.c_str(), MQTT_USER, MQTT_PASSWORD_MQTT)) {
Serial.println(F("connected"));
} else {
Serial.print(F("failed, rc="));
Serial.print(mqttClient.state());
Serial.println(F(" retry in 2s"));
delay(2000);
}
}
}
void publishR5Event(const R5Event& ev) {
if (!mqttClient.connected()) {
connectMQTT();
}
char topic[128];
char payload[256];
// addr as 8 hex digits
char addrHex[9];
snprintf(addrHex, sizeof(addrHex), "%08X", ev.addr);
// topic: <base>/<addrHex>
snprintf(topic, sizeof(topic), "%s/%s", MQTT_BASE_TOPIC, addrHex);
const char* actionStr = "unknown";
switch (ev.keyAction) {
case 0x00: actionStr = "single"; break;
case 0x01: actionStr = "double"; break;
case 0x02: actionStr = "long"; break;
default: actionStr = "other"; break;
}
// status = b<button>_<action>, e.g. b1_single, b2_double, b3_long
char statusStr[32];
snprintf(statusStr, sizeof(statusStr), "b%u_%s", ev.outlet + 1, actionStr);
// simple JSON payload for Home Assistant / automations
snprintf(
payload,
sizeof(payload),
"{\"button\":%u,\"action\":\"%s\",\"status\":\"%s\","
"\"outlet\":%u,\"keyAction\":%u,\"seq\":%u,\"count\":%u}",
ev.outlet + 1,
actionStr,
statusStr,
ev.outlet,
ev.keyAction,
ev.seq,
ev.count
);
Serial.print(F("MQTT publish topic="));
Serial.print(topic);
Serial.print(F(" payload="));
Serial.println(payload);
mqttClient.publish(topic, payload, false);
}
// Publish a generic "event" for any R5 press to the shared topic:
// ble_gateway/sonoff_r5
void publishR5EventGlobal(const R5Event& ev) {
if (!mqttClient.connected()) {
connectMQTT();
}
char topic[128];
char payload[256];
// shared topic, no suffix
snprintf(topic, sizeof(topic), "%s", MQTT_BASE_TOPIC);
// addr as 8 hex digits (device id)
char addrHex[9];
snprintf(addrHex, sizeof(addrHex), "%08X", ev.addr);
const char* actionStr = "unknown";
switch (ev.keyAction) {
case 0x00: actionStr = "single"; break;
case 0x01: actionStr = "double"; break;
case 0x02: actionStr = "long"; break;
default: actionStr = "other"; break;
}
// status = b<button>_<action>, e.g. b1_single
char statusStr[32];
snprintf(statusStr, sizeof(statusStr), "b%u_%s", ev.outlet + 1, actionStr);
// Generic event payload with device_id
snprintf(
payload,
sizeof(payload),
"{\"device_id\":\"%s\",\"button\":%u,\"action\":\"%s\","
"\"status\":\"%s\",\"outlet\":%u,\"keyAction\":%u,"
"\"seq\":%u,\"count\":%u}",
addrHex,
ev.outlet + 1,
actionStr,
statusStr,
ev.outlet,
ev.keyAction,
ev.seq,
ev.count
);
Serial.print(F("MQTT publish GLOBAL topic="));
Serial.print(topic);
Serial.print(F(" payload="));
Serial.println(payload);
mqttClient.publish(topic, payload, false);
}
void publishR5Release(uint8_t outlet, uint32_t addr) {
if (!mqttClient.connected()) {
connectMQTT();
}
char topic[128];
char payload[256];
char addrHex[9];
snprintf(addrHex, sizeof(addrHex), "%08X", addr);
snprintf(topic, sizeof(topic), "%s/%s", MQTT_BASE_TOPIC, addrHex);
// status = b<button>_release, e.g. b1_release
char statusStr[32];
strcpy(statusStr, "release");
snprintf(
payload,
sizeof(payload),
"{\"button\":%u,\"action\":\"release\",\"status\":\"%s\"}",
outlet + 1,
statusStr
);
Serial.print(F("MQTT publish (release) topic="));
Serial.println(topic);
Serial.print(F(" payload="));
Serial.println(payload);
mqttClient.publish(topic, payload, false);
}
// Publish a generic "release" event to the shared topic
void publishR5ReleaseGlobal(uint8_t outlet, uint32_t addr) {
if (!mqttClient.connected()) {
connectMQTT();
}
char topic[128];
char payload[256];
snprintf(topic, sizeof(topic), "%s", MQTT_BASE_TOPIC);
char addrHex[9];
snprintf(addrHex, sizeof(addrHex), "%08X", addr);
// status = b<button>_release, e.g. b1_release
char statusStr[32];
snprintf(statusStr, sizeof(statusStr), "b%u_release", outlet + 1);
snprintf(
payload,
sizeof(payload),
"{\"device_id\":\"%s\",\"button\":%u,"
"\"action\":\"release\",\"status\":\"%s\"}",
addrHex,
outlet + 1,
statusStr
);
Serial.print(F("MQTT publish GLOBAL (release) topic="));
Serial.print(topic);
Serial.print(F(" payload="));
Serial.println(payload);
mqttClient.publish(topic, payload, false);
}
// ======================= BLE SCAN CALLBACKS =======================
class AdvertisedDeviceCallbacks : public NimBLEScanCallbacks {
void onResult(const NimBLEAdvertisedDevice* adv) override {
String addr = adv->getAddress().toString().c_str();
// Filter by BLE MAC, if configured
if (strlen(TARGET_ADDR) > 0 && addr != TARGET_ADDR) {
return;
}
const std::vector<uint8_t>& raw = adv->getPayload();
R5Event ev;
if (!decodeR5(raw, ev)) {
return; // not an R5 frame we care about
}
if (isDuplicateEvent(ev)) {
Serial.println(F("Duplicate R5 event ignored (within 1s)."));
return;
}
// Mark the button as pressed and update timestamp
lastButtonEventMs[ev.outlet] = millis();
buttonIsPressed[ev.outlet] = true;
Serial.println(F("---- Decoded R5 event ----"));
Serial.print(F("BLE MAC: "));
Serial.println(addr);
Serial.print(F("type=0x"));
Serial.print(ev.type, HEX);
Serial.print(F(" version=0x"));
Serial.println(ev.version, HEX);
Serial.print(F("count=0x"));
Serial.print(ev.count, HEX);
Serial.print(F(" addr=0x"));
Serial.println(ev.addr, HEX);
Serial.print(F("cmd=0x"));
Serial.print(ev.cmd, HEX);
Serial.print(F(" outlet="));
Serial.print(ev.outlet);
Serial.print(F(" keyAction=0x"));
Serial.println(ev.keyAction, HEX);
const char* actionStr = "unknown";
switch (ev.keyAction) {
case 0x00: actionStr = "single"; break;
case 0x01: actionStr = "double"; break;
case 0x02: actionStr = "long"; break;
default: actionStr = "other"; break;
}
Serial.print(F("Button: "));
Serial.print(ev.outlet + 1);
Serial.print(F(" Action: "));
Serial.println(actionStr);
Serial.print(F("seq=0x"));
Serial.print(ev.seq, HEX);
Serial.print(F(" rand=0x"));
Serial.println(ev.rand, HEX);
lastR5Address = ev.addr;
// Push to MQTT
publishR5Event(ev);
// Push to MQTT
publishR5EventGlobal(ev);
Serial.println();
}
};
// ======================= SETUP & LOOP =======================
void setup() {
Serial.begin(115200);
while (!Serial) {
delay(10);
}
Serial.println();
Serial.println(F("ESP32 Sonoff R5 → MQTT bridge"));
connectWiFi();
mqttClient.setServer(MQTT_HOST, MQTT_PORT);
connectMQTT();
NimBLEDevice::init("");
NimBLEScan* scan = NimBLEDevice::getScan();
scan->setScanCallbacks(new AdvertisedDeviceCallbacks(), /*wantDuplicates=*/true);
scan->setActiveScan(true);
scan->setInterval(45);
scan->setWindow(15);
scan->setMaxResults(0);
scan->start(0, false, false); // scan forever
}
void loop() {
if (!mqttClient.connected()) {
connectMQTT();
}
mqttClient.loop();
unsigned long now = millis();
// Check for releases
for (int i = 0; i < 6; i++) {
if (buttonIsPressed[i] && (now - lastButtonEventMs[i] > RELEASE_TIMEOUT)) {
// No activity for 2s → release
Serial.print(F("Button "));
Serial.print(i+1);
Serial.println(F(" released"));
publishR5Release(i, lastR5Address);
// publishR5ReleaseGlobal(i, lastR5Address);
buttonIsPressed[i] = false;
}
}
delay(10);
}