Created
December 20, 2025 00:20
-
-
Save mcorrigan/63657bcf6156560ac652fbca60452d48 to your computer and use it in GitHub Desktop.
ESP32C3 Connects to Generic AB Shutter Clicker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * | |
| * 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