To take this from a DIY science project to a durable, professional tool, I designed a custom PCB and had it manufactured by JLCPCB. For the housing, standard 3D printing wasn't going to cut it, so I used JLC3DP to print a custom enclosure out of premium MJF Nylon. The result is a remote that doesn't just work flawlessly—it feels like a factory-grade product in the hand.
Here is what this custom remote packs:
- Dedicated Mechanical Controls: Individual buttons for Video Recording, Focus Lock (Half-Press), and Photo Shutter (Full-Press).
- Smart LED Feedback: A WS2812B status light that visually confirms connections, focus locks, and shutter releases.
- Pro-Grade Hardware: A dedicated physical power switch and a massively upgraded battery for weeks of standby time.
- Universal Compatibility: Works seamlessly with almost the entire modern Sony Alpha lineup.
In this post, I am going to walk you through exactly how I built it. Whether you are a filmmaker looking to upgrade your rig, or a maker wanting to dive into ESP32 Bluetooth protocols, I’ve included everything you need below—including the custom C++ code, the PCB schematics, and the 3D print files to build your own.
Let’s get into the build Part needed for this project
- Seeedstudio XIAOESP32C6
- Push button
- slide switch
- WS2812b 2020
- 6*M3 screws
- 2000 mah lipo battery
I used Fusion 360 to design the enclosure for this project and exported the STL files for 3D printing
I used JLC3DP for 3D printing the enclosure. I went with Nilone MFJ for the enclosure and 8001 transparent resin for the LED indicator.
I used EasyEDA to design the PCB and then exported the Gerber file for manufacturing.
Place all of the switches, LED, and XIAO in the designated place and solder all of the parts.
Position the power switch and secure it to the 3D-printed body with screws
Place the battery in the main body and secure it with glue.
Connect the battery wire to the power switch
Connect the power wires to the PCB
Put all of the button caps
Close the enclosure
Here is the code for this project
#include <Arduino.h>
#include <BLEDevice.h>
#include <Adafruit_NeoPixel.h>
// Please enter your camera bluetooth name. My Sony A7IV name is:
#define bleServerName "ILCE-6700"
// --- PIN DEFINITIONS ---
#define BTN_SHUTTER D1 // Video recording toggle
#define BTN_FOCUS D2 // Focus trigger
#define BTN_PHOTO D3 // Photo trigger
// LED - WS2812B 2020 on D10
#define LED_PIN D10
#define NUM_LEDS 1
#define LED_BRIGHTNESS 50 // Max brightness for the LED
// ================================================================
// --- STRUCT DEFINITION ---
// ================================================================
struct Button {
uint8_t pin;
bool lastState;
bool state;
unsigned long lastDebounceTime;
};
// --- BLE COMMAND BYTES (From freemote protocol) ---
// Video commands
uint8_t RECORD_DOWN[] = {0x01, 0x0f};
uint8_t RECORD_UP[] = {0x01, 0x0e};
// Photo & Focus commands
uint8_t FOCUS_DOWN[] = {0x01, 0x07}; // Shutter Half Down
uint8_t FOCUS_UP[] = {0x01, 0x06}; // Shutter Half Up
uint8_t SHUTTER_DOWN[] = {0x01, 0x09}; // Shutter Fully Down
uint8_t SHUTTER_UP[] = {0x01, 0x08}; // Shutter Fully Up
static BLEAddress *pServerAddress;
static boolean doPairing = false;
static boolean doConnect = false;
static boolean connected = false;
BLERemoteCharacteristic* remoteCommand;
BLERemoteCharacteristic* remoteNotify;
// Global State Variables
bool isRecording = false;
bool isFocusLocked = false;
bool isScanning = false; // Tracks if background BLE scan is running
unsigned long scanStartTime = 0; // Tracks when the scan started
// LED variables
unsigned long lastLEDUpdate = 0;
unsigned long visualFeedbackTimer = 0;
bool ledBlinkState = false;
Adafruit_NeoPixel led = Adafruit_NeoPixel(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);
// Initialize buttons (starting state HIGH due to internal pull-up)
Button btnVideo = {BTN_SHUTTER, HIGH, HIGH, 0};
Button btnFocus = {BTN_FOCUS, HIGH, HIGH, 0};
Button btnPhoto = {BTN_PHOTO, HIGH, HIGH, 0};
const unsigned long debounceDelay = 20; // 20ms debounce
// ================================================================
// --- COMMAND SEQUENCES ---
// ================================================================
void triggerVideoRecording() {
if (!connected) return;
if (!isRecording) {
Serial.println("Starting video recording...");
remoteCommand->writeValue(RECORD_DOWN, 2, true);
delay(50);
remoteCommand->writeValue(RECORD_UP, 2, true);
isRecording = true;
} else {
Serial.println("Stopping video recording...");
remoteCommand->writeValue(RECORD_DOWN, 2, true);
delay(50);
remoteCommand->writeValue(RECORD_UP, 2, true);
isRecording = false;
}
}
void triggerPhoto() {
if (!connected) return;
Serial.println("Taking picture...");
if (!isFocusLocked) {
remoteCommand->writeValue(FOCUS_DOWN, 2, true);
delay(100);
}
remoteCommand->writeValue(SHUTTER_DOWN, 2, true);
delay(150);
remoteCommand->writeValue(SHUTTER_UP, 2, true);
delay(50);
if (!isFocusLocked) {
remoteCommand->writeValue(FOCUS_UP, 2, true);
}
visualFeedbackTimer = millis();
}
void triggerFocus() {
if (!connected) return;
if (!isFocusLocked) {
Serial.println("Focus LOCKED (Half-press Down)");
remoteCommand->writeValue(FOCUS_DOWN, 2, true);
isFocusLocked = true;
} else {
Serial.println("Focus RELEASED (Half-press Up)");
remoteCommand->writeValue(FOCUS_UP, 2, true);
isFocusLocked = false;
}
}
// ================================================================
// --- BUTTON DEBOUNCE LOGIC ---
// ================================================================
bool checkButton(Button &btn) {
bool reading = digitalRead(btn.pin);
bool triggered = false;
if (reading != btn.lastState) {
btn.lastDebounceTime = millis();
}
if ((millis() - btn.lastDebounceTime) > debounceDelay) {
if (reading != btn.state) {
btn.state = reading;
if (btn.state == LOW) { // LOW means pressed (INPUT_PULLUP)
triggered = true;
}
}
}
btn.lastState = reading;
return triggered;
}
// ================================================================
// --- UTILITY & BLE CALLBACK FUNCTIONS ---
// ================================================================
void updateLED() {
unsigned long currentMillis = millis();
// 1. Photo Taken Feedback (Highest priority, temporary flash)
if (currentMillis - visualFeedbackTimer < 200) {
led.setPixelColor(0, led.Color(LED_BRIGHTNESS, LED_BRIGHTNESS, LED_BRIGHTNESS)); // White Flash
}
// 2. Not connected - Flashing Red
else if (!connected) {
if (currentMillis - lastLEDUpdate > 300) { // 300ms flash speed
lastLEDUpdate = currentMillis;
ledBlinkState = !ledBlinkState;
}
if (ledBlinkState) {
led.setPixelColor(0, led.Color(LED_BRIGHTNESS, 0, 0)); // Red ON
} else {
led.setPixelColor(0, led.Color(0, 0, 0)); // OFF
}
}
// 3. Pairing/Connecting - Solid Yellow
else if (doPairing || doConnect) {
led.setPixelColor(0, led.Color(LED_BRIGHTNESS, LED_BRIGHTNESS, 0));
}
// 4. Video Recording - Solid Red
else if (isRecording) {
led.setPixelColor(0, led.Color(LED_BRIGHTNESS, 0, 0));
}
// 5. Focus Locked - Solid Violet
else if (isFocusLocked) {
led.setPixelColor(0, led.Color(LED_BRIGHTNESS/2, 0, LED_BRIGHTNESS));
}
// 6. Connected (Standby) - Solid Green
else {
led.setPixelColor(0, led.Color(0, LED_BRIGHTNESS, 0));
}
led.show();
}
static void commandNotifyCallback(BLERemoteCharacteristic* pBLERemoteCharacteristic, uint8_t* pData, size_t length, bool isNotify) {
if (length >= 3) {
if (pData[0] == 0x02 && pData[1] == 0xD5) {
if (pData[2] == 0x20) {
Serial.println("Camera reports: Recording STARTED");
isRecording = true;
} else if (pData[2] == 0x00) {
Serial.println("Camera reports: Recording STOPPED");
isRecording = false;
}
}
}
}
static void notifyNotifyCallback(BLERemoteCharacteristic* pBLERemoteCharacteristic, uint8_t* pData, size_t length, bool isNotify) {
if (length >= 3) {
if (pData[0] == 0x02 && pData[1] == 0xD5) {
if (pData[2] == 0x20) {
Serial.println("Camera reports: Recording STARTED (from notify)");
isRecording = true;
} else if (pData[2] == 0x00) {
Serial.println("Camera reports: Recording STOPPED (from notify)");
isRecording = false;
}
}
}
}
class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice advertisedDevice) {
if (advertisedDevice.getName() == bleServerName) {
advertisedDevice.getScan()->stop();
isScanning = false; // Stop the background scan timer
pServerAddress = new BLEAddress(advertisedDevice.getAddress());
Serial.println("Camera found. Connecting!");
auto data = advertisedDevice.getPayload();
for (size_t i = 1; i < advertisedDevice.getPayloadLength(); i++) {
if (data[i-1] == 0x22) {
if ((data[i] & 0x40) == 0x40 && (data[i] & 0x02) == 0x02) {
doPairing = true;
} else {
doConnect = true;
}
}
}
}
}
};
class MyClientCallback : public BLEClientCallbacks {
void onConnect(BLEClient* pclient) {
Serial.println("Connected");
connected = true;
isRecording = false;
isFocusLocked = false;
}
void onDisconnect(BLEClient* pclient) {
connected = false;
isRecording = false;
isFocusLocked = false;
isScanning = false;
Serial.println("Disconnected");
}
};
bool connectToServer(BLEAddress pAddress) {
BLEClient* pClient = BLEDevice::createClient();
pClient->setClientCallbacks(new MyClientCallback());
if (pClient->connect(pAddress)) {
Serial.println(" - Connected to server");
doPairing = false;
doConnect = false;
BLERemoteService* pRemoteService = pClient->getService("8000FF00-FF00-FFFF-FFFF-FFFFFFFFFFFF");
if (pRemoteService == nullptr) return false;
remoteCommand = pRemoteService->getCharacteristic(BLEUUID((uint16_t)0xFF01));
remoteNotify = pRemoteService->getCharacteristic(BLEUUID((uint16_t)0xFF02));
if (remoteCommand == nullptr || remoteNotify == nullptr) return false;
remoteCommand->registerForNotify(commandNotifyCallback);
remoteNotify->registerForNotify(notifyNotifyCallback);
connected = true;
isRecording = false;
isFocusLocked = false;
return true;
} else {
Serial.println(" - Failed to connect, returning to scan mode");
doPairing = false;
doConnect = false;
isScanning = false; // Reset to allow searching again
return false;
}
}
class MySecurityCallbacks: public BLESecurityCallbacks {
uint32_t onPassKeyRequest(){ return 123456; }
void onPassKeyNotify(uint32_t pass_key){}
bool onSecurityRequest(){ return true; }
void onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl){
if(cmpl.success) Serial.println("Pairing success");
else Serial.println("Pairing failed");
}
bool onConfirmPIN(uint32_t pin) { return true; }
};
void pairOrConnect() {
isScanning = true;
scanStartTime = millis();
BLEScan* pBLEScan = BLEDevice::getScan();
pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
pBLEScan->setActiveScan(true);
Serial.println("BLE: Searching for camera (Background)...");
// Passing 'false' as the 3rd parameter makes the scan run in the background
pBLEScan->start(10, nullptr, false);
}
// ================================================================
// --- SETUP & LOOP ---
// ================================================================
void setup() {
Serial.begin(115200);
esp_log_level_set("*", ESP_LOG_VERBOSE);
pinMode(BTN_SHUTTER, INPUT_PULLUP);
pinMode(BTN_FOCUS, INPUT_PULLUP);
pinMode(BTN_PHOTO, INPUT_PULLUP);
Serial.println("Buttons initialized (Internal PULL-UP enabled)");
led.begin();
led.setBrightness(255); // Handled dynamically in updateLED()
led.clear();
led.show();
Serial.println("WS2812B LED initialized on D10");
Serial.println("=====================================");
Serial.println("3-Button Camera Remote Initialized");
Serial.println("D1: Video Toggle | D2: Focus Toggle | D3: Photo");
Serial.println("=====================================");
BLEDevice::init("Camera Remote v1.0");
BLEDevice::setEncryptionLevel(ESP_BLE_SEC_ENCRYPT);
BLEDevice::setSecurityCallbacks(new MySecurityCallbacks());
}
void loop() {
// 1. Check if the background scan timed out (11 seconds buffer for a 10 sec scan)
if (isScanning && (millis() - scanStartTime > 11000)) {
Serial.println("Scan timeout. Restarting search...");
isScanning = false;
BLEDevice::getScan()->clearResults();
}
// 2. Handle connections
if (doPairing || doConnect) {
connectToServer(*pServerAddress);
}
// 3. Read Buttons
if (checkButton(btnVideo)) triggerVideoRecording();
if (checkButton(btnFocus)) triggerFocus();
if (checkButton(btnPhoto)) triggerPhoto();
// 4. Update LED states (This now runs flawlessly because scanning isn't blocking)
updateLED();
// 5. If disconnected and not currently scanning, start a new scan
if (!connected && !isScanning && !doConnect && !doPairing) {
pairOrConnect();
}
delay(10);
}connecting to the cameraGo to the menu and select the Network menu. Turn on the Bluetooth function and also enable Bluetooth Pmt Ctrl. Now, it will start pairing the device. The camera will show up; click "OK." The OpenClick is now successfully connected to your camera.
Special ThanksA huge thanks to JLCPCB for fabricating the PCB and JLC3DP for supporting this project with their amazing 3D printing service.
JLC3DP is the future of manufacturing, offering a user-friendly online platform for advanced 3D printing with:
- ✅ Instant quoting & real-time tracking
- ✅ 48-hour lead time & door-to-door delivery
- ✅ 20+ material options
- ✅ Enterprise-grade quality
- ✅ Prices starting at just $0.3, with up to $1325in new user coupons!
✨ Try them out at JLC3DP.com






Comments