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.