Skip to content

Instantly share code, notes, and snippets.

@mcorrigan
Created December 20, 2025 00:20
Show Gist options
  • Select an option

  • Save mcorrigan/63657bcf6156560ac652fbca60452d48 to your computer and use it in GitHub Desktop.

Select an option

Save mcorrigan/63657bcf6156560ac652fbca60452d48 to your computer and use it in GitHub Desktop.
ESP32C3 Connects to Generic AB Shutter Clicker
/**
*
* This code is a BLE client for the AB Shutter3.
* It scans for the AB Shutter3 and connects to it.
* It then subscribes to the Input Report characteristic and waits for button events.
* When a button event is received, it prints the button event to the serial monitor.
*
* The code is written in C++ and uses the NimBLE library.
*
* The code is written for the ESP32 board.
*
* Dependencies:
* - NimBLE library: https://github.com/h2zero/NimBLE-Arduino
* - ESP32 board: https://www.espressif.com/en/products/socs/esp32
*
*/
#include <Arduino.h>
#include <NimBLEDevice.h>
#define SHUTTER_NAME "AB Shutter3"
NimBLEAdvertisedDevice* advDevice = nullptr;
NimBLEClient* pClient = nullptr;
// Store all Input Report characteristics
std::vector<NimBLERemoteCharacteristic*> inputReports;
bool doConnect = false;
uint32_t scanTimeMs = 5000;
// -----------------------------
// Notification callback (shared for all reports)
// -----------------------------
void notifyCallback(NimBLERemoteCharacteristic* chr, uint8_t* data, size_t length, bool isNotify) {
Serial.print("Notification received (handle ");
Serial.print(chr->getHandle());
Serial.print("): ");
for (size_t i = 0; i < length; i++) {
Serial.printf("%02X ", data[i]);
}
Serial.println();
// Typical reports: 2 bytes, e.g., 01 00 = press, 00 00 = release
if (length >= 2 && data[1] != 0) {
Serial.println(">>> Button pressed!");
} else if (length >= 1 && data[0] != 0) {
Serial.println(">>> Button pressed! (single-byte report)");
} else {
Serial.println("Button released!");
}
}
// -----------------------------
// Client callbacks
// -----------------------------
class ClientCallbacks : public NimBLEClientCallbacks {
void onConnect(NimBLEClient* client) override {
Serial.println("Connected to device");
}
void onDisconnect(NimBLEClient* client, int reason) override {
Serial.printf("Disconnected, reason=%d. Restarting scan\n", reason);
doConnect = false;
pClient = nullptr;
advDevice = nullptr;
inputReports.clear();
NimBLEDevice::getScan()->start(scanTimeMs, false, true);
}
void onAuthenticationComplete(NimBLEConnInfo& connInfo) override {
Serial.println("Pairing/encryption complete");
if (!connInfo.isEncrypted()) {
Serial.println("No encryption established");
return;
}
// (Re)Subscribe if pairing succeeded – in case initial subscribe failed due to auth
bool anySubscribed = false;
for (auto chr : inputReports) {
if (chr->canNotify()) {
if (chr->subscribe(true, notifyCallback, true)) {
Serial.printf("Subscribed (post-auth) to Input Report (handle %d)\n", chr->getHandle());
anySubscribed = true;
} else {
Serial.printf("Failed (post-auth) to subscribe to Input Report (handle %d)\n", chr->getHandle());
}
}
}
if (anySubscribed) {
Serial.println("Ready – waiting for button events!");
} else {
Serial.println("No successful subscriptions post-auth – disconnecting");
if (pClient && pClient->isConnected()) {
pClient->disconnect();
}
}
}
} clientCallbacks;
// -----------------------------
// Scan callbacks
// -----------------------------
class ScanCallbacks : public NimBLEScanCallbacks {
void onResult(const NimBLEAdvertisedDevice* advertisedDevice) override {
String name = advertisedDevice->getName().c_str();
Serial.printf("Discovered device: %s (RSSI: %d)\n", name.c_str(), advertisedDevice->getRSSI());
if (name == SHUTTER_NAME) {
Serial.println(">>> AB Shutter3 found! Stopping scan...");
NimBLEDevice::getScan()->stop();
advDevice = const_cast<NimBLEAdvertisedDevice*>(advertisedDevice);
doConnect = true;
}
}
void onScanEnd(const NimBLEScanResults& results, int reason) override {
Serial.printf("Scan ended, reason=%d, devices found=%d\n", reason, results.getCount());
}
} scanCallbacks;
// -----------------------------
// Connect & discover all Input Report characteristics, then subscribe
// -----------------------------
bool connectToShutter() {
if (!advDevice) return false;
if (!pClient) {
pClient = NimBLEDevice::createClient();
pClient->setClientCallbacks(&clientCallbacks, false);
}
Serial.println("Connecting to AB Shutter3...");
if (!pClient->connect(advDevice)) {
Serial.println("Failed to connect!");
NimBLEDevice::deleteClient(pClient);
pClient = nullptr;
return false;
}
// Discover HID service (0x1812)
NimBLERemoteService* hidService = pClient->getService(NimBLEUUID((uint16_t)0x1812));
if (!hidService) {
Serial.println("HID service (0x1812) not found!");
pClient->disconnect();
return false;
}
Serial.println("HID service found");
// Force rediscovery of characteristics
hidService->getCharacteristics(true);
// List ALL characteristics for debugging
Serial.println("Listing all characteristics in HID service:");
auto characteristics = hidService->getCharacteristics();
for (auto chr : characteristics) {
Serial.printf("UUID: %s, handle: %d, canNotify: %d\n",
chr->getUUID().toString().c_str(), chr->getHandle(), chr->canNotify() ? 1 : 0);
}
// Find ALL Input Report characteristics (0x2A4D) that support notifications
inputReports.clear();
for (auto chr : characteristics) {
// Try both short and long UUID for safety
NimBLEUUID reportUUID((uint16_t)0x2A4D);
NimBLEUUID longReportUUID("00002a4d-0000-1000-8000-00805f9b34fb");
if ((chr->getUUID().equals(reportUUID) || chr->getUUID().equals(longReportUUID)) && chr->canNotify()) {
inputReports.push_back(chr);
Serial.printf("Found notifiable Input Report (handle: %d)\n", chr->getHandle());
}
}
if (inputReports.empty()) {
Serial.println("No notifiable Input Report characteristics found!");
pClient->disconnect();
return false;
}
Serial.printf("Found %d Input Report characteristic(s). Attempting subscriptions...\n", inputReports.size());
// Attempt subscriptions immediately (will trigger pairing if required)
bool anySubscribed = false;
for (auto chr : inputReports) {
if (chr->canNotify()) {
if (chr->subscribe(true, notifyCallback, true)) {
Serial.printf("Subscribed to Input Report (handle %d)\n", chr->getHandle());
anySubscribed = true;
} else {
Serial.printf("Failed to subscribe to Input Report (handle %d) – may require pairing\n", chr->getHandle());
}
}
}
if (!anySubscribed) {
Serial.println("No initial subscriptions succeeded – waiting for pairing if needed");
// Don't disconnect yet; if pairing triggers, onAuth will try again
return true; // Stay connected
} else {
Serial.println("Ready – waiting for button events!");
return true;
}
}
// -----------------------------
// Setup
// -----------------------------
void setup() {
Serial.begin(115200);
while (!Serial) delay(10);
Serial.println("\nStarting BLE client for AB Shutter3...");
NimBLEDevice::init("ESP32_Central");
NimBLEDevice::setPower(3); // Max TX power (optional)
// Set to Just Works without bonding – try this first; if needed, enable bonding
NimBLEDevice::setSecurityAuth(/*bond*/ false, /*mitm*/ false, /*secure_connections*/ false);
NimBLEDevice::setSecurityIOCap(BLE_HS_IO_NO_INPUT_OUTPUT);
NimBLEScan* pScan = NimBLEDevice::getScan();
pScan->setScanCallbacks(&scanCallbacks, false);
pScan->setInterval(100);
pScan->setWindow(99);
pScan->setActiveScan(true);
pScan->setDuplicateFilter(false);
Serial.println("Starting initial scan...");
pScan->start(scanTimeMs, false);
}
// -----------------------------
// Loop
// -----------------------------
void loop() {
if (doConnect && advDevice && (!pClient || !pClient->isConnected())) {
doConnect = false;
if (!connectToShutter()) {
Serial.println("Connection failed – restarting scan");
NimBLEDevice::getScan()->start(scanTimeMs, false, true);
}
}
delay(50);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment