BLE matrix remote

OK - I’ve got a chinese remote with 20 buttons. I’ve got the matrix wired in to a C6 Zero. The intention is to use it as a long lasting remote on 2xAAA batteries, so I’ve got diodes connecting the rows to GPIO0 ready to use it for deep sleep. However, in order to also save power, I’m trying to use BLE. There is a C3 Zero acting as a BLE proxy.

The ESPHome code wasn’t causing the remote to be picked up in HA, so AI told me to ditch ESPHome and use Arduino code on the C6 to present BTHome. So far it is working, to a point, the payload is wrong and I’ve run out of free queries on countless AI going round in circles. It comes out as “sound detected” or “distance” and I just can’t shake it.

So the question is… do I really have to use Arduino code to produce the signal, or can I use ESPHome on the C6?

This is the ESPHome code that wasn’t causing the device to be detected in HA…

# -------------------------
# BLE ADVERTISING
# -------------------------
esp32_ble:
  id: ble_dev

esp32_ble_beacon:
  type: iBeacon
  uuid: "12345678-1234-1234-1234-1234567890ab"
  major: 1
  minor: 0
  measured_power: -59

#deep_sleep:
#  id: deep_sleep_1
#  run_duration: 3s
#  sleep_duration: 0s

# -------------------------
# KEYPAD
# -------------------------

matrix_keypad:
  id: remote_keypad
  rows:
    - pin: GPIO18
    - pin: GPIO19
    - pin: GPIO20
    - pin: GPIO21
    - pin: GPIO14
  columns:
    - pin: GPIO1
    - pin: GPIO2
    - pin: GPIO3
    - pin: GPIO22
  has_diodes: true
  keys: "1234567890ABCDEFGHIJ"

# -------------------------
# GLOBAL STATE (for BLE value)
# -------------------------
globals:
  - id: key_value
    type: int
    restore_value: no
    initial_value: '0'

# -------------------------
# BUTTON HANDLING
# -------------------------
binary_sensor:
  - platform: matrix_keypad
    keypad_id: remote_keypad
    name: "Key 1"
    key: "1"
    on_press:
      then:
        - lambda: |-
            id(key_value) = 1;
        - logger.log: "Key 1 pressed"

  - platform: matrix_keypad
    keypad_id: remote_keypad
    name: "Key 2"
    key: "2"
    on_press:
      then:
        - lambda: |-
            id(key_value) = 2;
        - logger.log: "Key 2 pressed"

# Add more keys as needed...

# -------------------------
# UPDATE BLE BEACON VALUE
# -------------------------
interval:
  - interval: 300ms
    then:
      - lambda: |-
          // Encode key into minor value
          id(ble_dev).set_minor(id(key_value));

Right - this is partially sorted, except for the sleep, which I’m having real problems with… but that will be in another reply. Using BTHome code, HA can’t either see some code, or it can’t be transmitted, but I get around this by transmitting the key in the manufacturer data, which CAN be seen by YAML. So the first thing is code on the C6 Arduinno.

#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEAdvertising.h>

// -------- KEYPAD CONFIG --------
const int ROWS = 5;
const int COLS = 4;

int rowPins[ROWS] = {18, 19, 20, 21, 14};
int colPins[COLS] = {1, 2, 3, 22};

char keys[ROWS][COLS] = {
  {'1','2','3','A'},
  {'4','5','6','B'},
  {'7','8','9','C'},
  {'*','0','#','D'},
  {'E','F','G','H'}
};

// --------------------
// BLE
// --------------------
BLEAdvertising *pAdvertising;

// --------------------
// SEND BTHOME PACKET
// --------------------
void sendBTHome(uint8_t key_id) {
  // Manufacturer Data (2 bytes vendor ID + 1 byte button ID)
  // Vendor ID can be any 2 bytes; here 0xFF 0xFF is a placeholder.
  uint8_t raw_md[] = {
    0xFF, 0xFF,   // manufacturer ID (example)
    key_id        // button ID 1–20
  };

  // Convert to String (BLE Arduino API wants String)
  String manufacturerData = "";
  for (int i = 0; i < 3; i++) {
    manufacturerData += (char)raw_md[i];
  }

  BLEAdvertisementData advData;
  advData.setFlags(0x06);
  advData.setManufacturerData(manufacturerData);   // ← now compiles
  pAdvertising->setAdvertisementData(advData);
  pAdvertising->start();

  Serial.print("BLE sent key ID: ");
  Serial.println(key_id);
}


// --------------------
// SETUP
// --------------------
void setup() {
  Serial.begin(115200);
  Serial.println("Starting keypad + BLE...");

  // Setup rows
  for (int r = 0; r < ROWS; r++) {
    pinMode(rowPins[r], OUTPUT);
    digitalWrite(rowPins[r], HIGH);
  }

  // Setup columns
  for (int c = 0; c < COLS; c++) {
    pinMode(colPins[c], INPUT_PULLUP);
  }

  // Init BLE
  BLEDevice::init("BLE-Remote-1");
  pAdvertising = BLEDevice::getAdvertising();
  BLEAddress addr = BLEDevice::getAddress();
  Serial.print("BLE MAC address: ");
  Serial.println(addr.toString().c_str());


  Serial.println("Ready.");
}

// --------------------
// LOOP
// --------------------
void loop() {
  for (int r = 0; r < ROWS; r++) {
    digitalWrite(rowPins[r], LOW);

    for (int c = 0; c < COLS; c++) {

      if (digitalRead(colPins[c]) == LOW) {
        delay(20); // debounce

        if (digitalRead(colPins[c]) == LOW) {

          char key = keys[r][c];

          Serial.print("Key pressed: ");
          Serial.println(key);

          // Convert to numeric ID (1–20)
          uint8_t key_id = (r * COLS) + c + 1;

          sendBTHome(key_id);
          
          // Small delay so receiver sees the press
          delay(500);

          // Send "no button pressed"
          sendBTHome(0);

          // Wait for release
          while (digitalRead(colPins[c]) == LOW) {
            delay(10);
          }
        }
      }
    }

    digitalWrite(rowPins[r], HIGH);
  }
}

That is picked up by a C3 running ESPHome which is listening for the specific MAC address of the remote…

esphome:
  name: ble-proxy
  friendly_name: BLE Proxy


esp32:
  board: esp32-c3-devkitm-1
  framework:
    type: esp-idf

# Enable logging
logger:
  level: DEBUG  # Or DEBUG; shows every scan packet

# Enable Home Assistant API
api:
  encryption:
    key: "*****"

ota:
  - platform: esphome
    password: "******"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Ble-Proxy Fallback Hotspot"
    password: "*******"

captive_portal:

esp32_ble_tracker:
  id: ble_tracker
  scan_parameters:
    duration: 30s
    interval: 100ms
    window: 50ms
    active: False
    continuous: True

  on_ble_advertise:
    - mac_address: AC:EB:E6:1E:59:6E
      then:
        - lambda: |-
            ESP_LOGI("ble_sniffer", "REMOTE MAC AC:EB:E6:1E:59:6E RSSI=%d",
                     x.get_rssi());

            // ESPHome only exposes the payload part of manufacturer data
            const auto &data_list = x.get_manufacturer_datas();
            for (const auto &entry : data_list) {
              const auto &data = entry.data;

              if (data.size() == 1) {
                uint8_t key_id = data[0];  // ESPHome strips the 0xFF 0xFF prefix

                ESP_LOGI("ble_sniffer", "REMOTE BUTTON ID=%d (raw_md=%s)",
                         key_id,
                         format_hex_pretty(data).c_str());

                id(remote_button_code).publish_state(key_id);

                char buf[64];
                snprintf(buf, sizeof(buf), "REMOTE BUTTON %d", key_id);
                id(last_seen_mac).publish_state(buf);
              }
            }

text_sensor:
  - platform: template
    id: last_seen_mac
    name: "BLE Last Seen MAC"

sensor:
  - platform: template
    id: remote_button_code
    name: "Remote Button Code"
    unit_of_measurement: ""
    icon: "mdi:numeric"

Net result… it’s seeable in HA…

This is a snippet to send a battery sensor through BTHome:

esp32_ble_server:
  id: ble
  manufacturer_data: [0x4C, 0, 0x23, 77, 0xF0 ]

[...]

script:
  - id: bt_send_battery
    then:
      - delay: 1s
      - logger.log: "BTHome: Send battery level"
      - lambda: |-
          // Get duration
          uint8_t d1 = (uint8_t)id(battery_percentage).state;
          uint16_t d2 = (uint16_t)(id(battery_voltage).state * 1000);
          // Create points to enable little endian conversion
          uint8_t *pD1 = (uint8_t*)&d1;
          uint8_t *pD2 = (uint8_t*)&d2;

          // Create and send BTHome payload
          std::vector<uint8_t> service_data = {
            0xD2, 0xFC,                         // BTHome UUID
            0x44,                               // BTHome Device Information
            0x01,                               // battery level (%)
            pD1[0],                             // Actual mesurement
            0x0C,                               // voltage
            pD2[0], pD2[1]                      // Actual mesurement
          };
          esphome::esp32_ble::global_ble->advertising_set_service_data(service_data);

You have to check the BTHome reference to determine what to put in the service_data.

Thank you. That’s very kind.

All I need now is the wake. This is where my weak electronics shows.

Exactly how I expected this to work, I’m not sure. With the unit in deep sleep for a remote, the 3.3v from the power supply keeps GPIO0 high. But how… when it’s sleeping… a voltage is supposed to come from the matrix and somehow drag it low… I’m not sure.

I’m well out of my depth here.

The issue was down to a number of factors. Some was behaviour of the board. I was heavily reliant on AI until some humans stepped in, and I told AI to stop giving me lip, shut up and code what I was telling it to do. Then, I made progress.

Thanks to a number of people, from a number of forums, being very kind and throwing things my way, the result was reached.

First the code for the C3 programmed via Arduino, noting that BLE transmission is a period broadcast and can’t be induced, so allowance was made to let it make some broadcasts.
The secret sauce was a way to keep certain pins high when the C3 went into deep sleep. That was the key. Then once they hit the columns, diodes feed to GPIO0 which is used as a wake. It takes about three seconds to wake so there is an LED across GPIO 1 so that the user knows when the remote has actually made a transmission.
The transmission consists of the button press coded into the third octet of the manufacturer code…

#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEAdvertising.h>
#include "esp_sleep.h"
#include <Adafruit_NeoPixel.h>

// ---------------- PINS ----------------
const int ROWS = 5;
const int COLS = 4;
const int TX_LED_PIN = 1;

int rowPins[ROWS] = {6, 7, 8, 9, 10};
int colPins[COLS] = {2, 3, 4, 5};

#define WAKE_PIN 0
#define LED_PIN    10
#define LED_COUNT  1
#define PIN 10
#define NUM_LEDS 1

Adafruit_NeoPixel strip(NUM_LEDS, PIN, NEO_GRB + NEO_KHZ800);

char keys[ROWS][COLS] = {
  {'1','2','3','A'},
  {'4','5','6','B'},
  {'7','8','9','C'},
  {'*','0','#','D'},
  {'E','F','G','H'}
};

// ---------------- BLE ----------------
BLEAdvertising *pAdvertising;

// ---------------- IDLE TIMER ----------------
unsigned long lastActivity = 0;
const unsigned long SLEEP_TIMEOUT = 50000;

// ---------------- BLE INIT ----------------
void initBLE() {
  Serial.println("[BLE] init");

  BLEDevice::init("BLE-Remote-1");

  // ---- GET + PRINT MAC ----
  BLEAddress addr = BLEDevice::getAddress();
  Serial.print("[BLE] MAC: ");
  Serial.println(addr.toString().c_str());

  pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->start();

  Serial.println("[BLE] ready");
}

// ---------------- SEND KEY ----------------
void sendKey(uint8_t key_id) {

  Serial.print("[BLE] send key: ");
  Serial.println(key_id);

  // ---- LED ON (TX START) ----
  digitalWrite(TX_LED_PIN, HIGH);

  uint8_t raw_md[] = { 0xFF, 0xFF, key_id };
  String manufacturerData((char*)raw_md, 3);

  BLEAdvertisementData advData;
  advData.setFlags(0x06);
  advData.setManufacturerData(manufacturerData);
  pAdvertising->setAdvertisementData(advData);
  delay(1300);
}

// ---------------- GPIO SETUP ----------------
void setupGPIO() {

  Serial.println("[GPIO] setup");

  // ROWS
  for (int r = 0; r < ROWS; r++) {
    pinMode(rowPins[r], OUTPUT);
    digitalWrite(rowPins[r], HIGH);
  }

  // COLS (input scan lines)
  for (int c = 0; c < COLS; c++) {
    pinMode(colPins[c], INPUT_PULLUP);
  }

  // WAKE PIN
  pinMode(WAKE_PIN, INPUT_PULLDOWN);

  // Set up output LED
  pinMode(TX_LED_PIN, OUTPUT);
  digitalWrite(TX_LED_PIN, LOW);

  // ---------------- FORCE LINES HIGH ----------------
  for (int c = 0; c < COLS; c++) {
    pinMode(colPins[c], OUTPUT);
    digitalWrite(colPins[c], HIGH);
  }

  // Hold the lines high - to power the wake signal
  gpio_deep_sleep_hold_en();
  for (int c = 0; c < COLS; c++) {
    gpio_hold_en((gpio_num_t)colPins[c]);
  }
}

// ---------------- SCAN ----------------
bool scanKeysOnce() {

  for (int r = 0; r < ROWS; r++) {
    digitalWrite(rowPins[r], LOW);
    for (int c = 0; c < COLS; c++) {
      if (digitalRead(colPins[c]) == LOW) {
        delay(20);
        if (digitalRead(colPins[c]) == LOW) {
          uint8_t key_id = (r * COLS) + c + 1;
          Serial.print("[KEY] ");
          Serial.println(keys[r][c]);
          sendKey(key_id);
          digitalWrite(rowPins[r], HIGH);
          return true;
        }
      }
    }
    digitalWrite(rowPins[r], HIGH);
  }

  return false;
}

// ---------------- RESET AFTER WAKE ----------------
void resetAfterWake() {

  Serial.println("[RESET] wake cleanup");
  gpio_deep_sleep_hold_dis();

  for (int c = 0; c < COLS; c++) {
    gpio_hold_dis((gpio_num_t)colPins[c]);
  }

  setupGPIO();
}

// ---------------- SLEEP ----------------
void goToSleep() {

  Serial.println("[SLEEP] preparing");

  // ---- Transmit and on-board LED OFF (TX END) ----
  // in case operation has turned on on-board LED.
  digitalWrite(TX_LED_PIN, LOW);
  strip.setBrightness(0);
  strip.setPixelColor(0, strip.Color(0, 0, 0));
  strip.show();
  delay(10);
  pinMode(10, INPUT);

  // Set up GPIO0 for wake up
  esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
  esp_deep_sleep_enable_gpio_wakeup(
    1ULL << WAKE_PIN,
    ESP_GPIO_WAKEUP_GPIO_HIGH
  );

  Serial.println("[SLEEP] entering...");
  delay(50);

  Serial.flush();
  esp_deep_sleep_start();
}

// ---------------- SETUP ----------------
void setup() {

  Serial.begin(115200);
  delay(1000);

  Serial.println("\nBOOT");

  initBLE();
  setupGPIO();
  resetAfterWake();

  lastActivity = millis();

  Serial.println("[RUN] active mode");

  // 1.5 second window before sleep.
  unsigned long startTime = millis();
  const unsigned long ACTIVE_WINDOW = 2000;

  pAdvertising->setMinInterval(20);  // Min interval in 0.625ms units (~62.5 ms)
  pAdvertising->setMaxInterval(20);  // Max interval (same for fixed)
  pAdvertising->start();

  scanKeysOnce();
  Serial.println("[KEY] Returned from scan");
  while (millis() - startTime < ACTIVE_WINDOW) {
    delay(50);
  }

  goToSleep();
}

// ---------------- LOOP ----------------
void loop() {}

Second the C3 receiver which, in this code, can receive from two remotes, or more if required, extending the number of remotes that can be serviced by one receiver.

It receives the broadcast, decodes the transmission and sends it to Home Assistant, giving a period before zeroising the received code, to prevent multiple broadcasts triggering it twice.
Captive portal has been removed, scanning set to active and a few other tweaks to make it as sensitive as possible.

esphome:
  name: ble-proxy
  friendly_name: BLE Proxy


esp32:
  board: esp32-c3-devkitm-1
  framework:
    type: esp-idf

# Enable logging
logger:
  level: DEBUG  # Or DEBUG; shows every scan packet

# Enable Home Assistant API
api:
  encryption:
    key: !secret BLEProxyapi

ota:
  - platform: esphome
    password: !secret BLEProxyota

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: light

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Ble-Proxy Fallback Hotspot"
    password: !secret BLEProxyappassword

globals:

  - id: remote1_last_update
    type: uint32_t
    restore_value: no
    initial_value: '0'

  - id: remote1_current_key
    type: uint8_t
    restore_value: no
    initial_value: '0'
  
  - id: remote2_last_update
    type: uint32_t
    restore_value: no
    initial_value: '0'

  - id: remote2_current_key
    type: uint8_t
    restore_value: no
    initial_value: '0'

esp32_ble_tracker:
  id: ble_tracker
  software_coexistence: true
  scan_parameters:
    duration: 30s
    interval: 100ms
    window: 100ms
    active: False
    continuous: True

  on_ble_advertise:
    - mac_address: AC:EB:E6:1A:5D:2E
      then:
        - lambda: |-
            ESP_LOGI("ble_sniffer", "REMOTE1 RSSI=%d", x.get_rssi());

            const auto &data_list = x.get_manufacturer_datas();
            for (const auto &entry : data_list) {
              const auto &data = entry.data;

              if (data.size() == 1) {
                uint8_t key_id = data[0];

                id(remote1_last_update) = millis();

                // Only publish if changed
                if (id(remote1_current_key) != key_id) {
                  id(remote1_current_key) = key_id;

                  id(remote1_button_code).publish_state(key_id);

                  char buf[64];
                  snprintf(buf, sizeof(buf), "REMOTE1 BUTTON %d", key_id);
                  id(remote1_last_seen).publish_state(buf);
                }
              }
            }

    - mac_address: B8:F8:62:2D:71:AA
      then:
        - lambda: |-
            ESP_LOGI("ble_sniffer", "REMOTE2 RSSI=%d", x.get_rssi());

            const auto &data_list = x.get_manufacturer_datas();
            for (const auto &entry : data_list) {
              const auto &data = entry.data;

              if (data.size() == 1) {
                uint8_t key_id = data[0];

                id(remote2_last_update) = millis();

                // Only publish if changed
                if (id(remote2_current_key) != key_id) {
                  id(remote2_current_key) = key_id;

                  id(remote2_button_code).publish_state(key_id);

                  char buf[64];
                  snprintf(buf, sizeof(buf), "REMOTE2 BUTTON %d", key_id);
                  id(remote2_last_seen).publish_state(buf);
                }
              }
            }

interval:
  - interval: 100ms
    then:
      - lambda: |-
          if (id(remote1_current_key) != 0 &&
              (millis() - id(remote1_last_update)) > 1000) {
 
            // No packets recently → consider released
            id(remote1_current_key) = 0;
            id(remote1_button_code).publish_state(0);
            id(remote1_last_seen).publish_state("IDLE");
          }   
          if (id(remote2_current_key) != 0 &&
              (millis() - id(remote2_last_update)) > 1000) {
 
            // No packets recently → consider released
            id(remote2_current_key) = 0;
            id(remote2_button_code).publish_state(0);
            id(remote2_last_seen).publish_state("IDLE");
          }            


text_sensor:
  - platform: template
    id: remote1_last_seen
    name: "BLE Remote 1 Last Seen"

  - platform: template
    id: remote2_last_seen
    name: "BLE Remote 2 Last Seen"

sensor:
  - platform: template
    id: remote1_button_code
    name: "Remote 1 Button Code"
    icon: "mdi:numeric"

  - platform: template
    id: remote2_button_code
    name: "Remote 2 Button Code"
    icon: "mdi:numeric"

Finally, here is the rough schematic for the C3. Noting an LED output on GPIO1 which will likely need an external resistor. I’m using 330ohm.