As an independent creator, my workflow typically involves working alone. When I mount my camera in a high position, I always have to reach up to press the record button for each take. On some cameras, the record button is quite small, making it difficult to press it without looking. While there is a mobile phone app that allows me to start and stop recording, I generally prefer not to use my phone in the studio, as it can be distracting.
I thought about using a dedicated Bluetooth remote shutter control, but they typically cost around $33 USD. I've always enjoyed building my own tools, so I decided to create my own Bluetooth remote shutter control called OpenClick for just $12.
With OpenClick, the user can start and stop video recording in the camera. By pressing the button for about 4 seconds, the user can switch between Photo and Video modes.
I needed to figure out how to make the Sony camera and the ESP32 communicate, but thankfully, some people in the maker community had already worked out the solution.
Here are sources that helped me with this project:
So let get into the build
3D printing of the enclosureI used Fusion 360 to design the enclosure for this project and exported the STL files for 3D printing
Then I uploaded the STL files to JLC3DP.com and used 8001 transparent resin as the material for this print. After a few days of waiting, I received the print. It looks good
Then I used a black CD marker to highlight the REC text. I also added a small white tape to the back side of the LED, defining and visibly marking the button.
The the result looking neat and professional
Used EasyEDA to design the PCB for this project, then exported the Gerber file for manufacturing
I used a white PCB for the project, and you can utilise PCBA assembly from JLCPCB for assembling the PCB, which will make the building process much easier.
After receiving the PCB from JLCPCB, I soldered the pull-up resistor, push button, WS2812B LED, and the SeeedStudio XIAO ESP32-C6.
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"
// Button pin - D0 is typically GPIO 26 on ESP32
#define BUTTON_PIN D0
// LED pin - D1 is typically GPIO 27 on ESP32 for WS2812B
#define LED_PIN D1
#define NUM_LEDS 1
#define LED_BRIGHTNESS 50
// Video recording commands
uint8_t RECORD_HIGH[] = {0x01, 0x0f}; // Start recording
uint8_t RECORD_LOW[] = {0x01, 0x0e}; // Stop recording
// Photo commands (assuming similar to earlier photo commands)
uint8_t PRESS_TO_FOCUS[] = {0x01, 0x07};
uint8_t TAKE_PICTURE[] = {0x01, 0x09};
uint8_t SHUTTER_RELEASED[] = {0x01, 0x06};
uint8_t HOLD_FOCUS[] = {0x01, 0x08};
static BLEAddress *pServerAddress;
static boolean doPairing = false;
static boolean doConnect = false;
static boolean connected = false;
BLERemoteCharacteristic* remoteCommand;
BLERemoteCharacteristic* remoteNotify;
// Button state variables (PULL-DOWN configuration)
bool lastButtonState = LOW; // Changed to LOW for pull-down
bool buttonPressed = false;
unsigned long lastDebounceTime = 0;
unsigned long debounceDelay = 10; // Debounce time in milliseconds
unsigned long buttonPressStartTime = 0;
bool buttonBeingPressed = false;
#define MODE_SWITCH_TIME 4000 // 4 seconds to switch mode
// LED variables
unsigned long lastLEDUpdate = 0;
bool ledBlinkState = false;
// LED object
Adafruit_NeoPixel led = Adafruit_NeoPixel(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);
// Mode and recording state
enum Mode { VIDEO_MODE, PHOTO_MODE };
Mode currentMode = VIDEO_MODE;
bool isRecording = false;
// helper function for print debug
void printHex(uint8_t* data, size_t length) {
for (size_t i = 0; i < length; i++) {
if (data[i] < 0x10) {
Serial.print("0x0");
} else {
Serial.print("0x");
}
Serial.print(data[i], HEX);
if (i < length - 1) {
Serial.print(" ");
}
}
}
// Update LED based on mode, recording state and connection status
void updateLED() {
unsigned long currentMillis = millis();
if (!connected) {
// Not connected - Blue blinking (500ms interval)
if (currentMillis - lastLEDUpdate > 500) {
lastLEDUpdate = currentMillis;
ledBlinkState = !ledBlinkState;
if (ledBlinkState) {
led.setPixelColor(0, led.Color(0, 0, LED_BRIGHTNESS)); // Blue
} else {
led.setPixelColor(0, led.Color(0, 0, 0)); // Off
}
led.show();
}
} else if (currentMode == PHOTO_MODE) {
// Photo mode - Stable Violet
led.setPixelColor(0, led.Color(LED_BRIGHTNESS/2, 0, LED_BRIGHTNESS)); // Violet
led.show();
} else if (isRecording) {
// Video mode, Recording - Solid Red
led.setPixelColor(0, led.Color(LED_BRIGHTNESS, 0, 0)); // Red
led.show();
} else {
// Video mode, not recording - Solid Green
led.setPixelColor(0, led.Color(0, LED_BRIGHTNESS, 0)); // Green
led.show();
}
}
static void commandNotifyCallback(BLERemoteCharacteristic* pBLERemoteCharacteristic,
uint8_t* pData, size_t length, bool isNotify) {
Serial.print("Received from command channel: ");
printHex(pData, length);
if (isNotify) {
Serial.println (" - notify");
} else {
Serial.println (" - not notify");
}
// Check for recording status messages (only in video mode)
if (currentMode == VIDEO_MODE && length >= 3) {
if (pData[0] == 0x02 && pData[1] == 0xD5) {
if (pData[2] == 0x20) {
Serial.println("Recording STARTED");
isRecording = true;
updateLED();
} else if (pData[2] == 0x00) {
Serial.println("Recording STOPPED");
isRecording = false;
updateLED();
}
}
}
}
static void notifyNotifyCallback(BLERemoteCharacteristic* pBLERemoteCharacteristic,
uint8_t* pData, size_t length, bool isNotify) {
Serial.print("Received from notify channel: ");
printHex(pData, length);
if (isNotify) {
Serial.println (" - notify");
} else {
Serial.println (" - not notify");
}
// Check for recording status (only in video mode)
if (currentMode == VIDEO_MODE && length >= 3) {
if (pData[0] == 0x02 && pData[1] == 0xD5) {
if (pData[2] == 0x20) {
Serial.println("Recording STARTED (from notify)");
isRecording = true;
updateLED();
} else if (pData[2] == 0x00) {
Serial.println("Recording STOPPED (from notify)");
isRecording = false;
updateLED();
}
}
}
}
class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice advertisedDevice) {
Serial.print("BLE: something found: ");
Serial.println(advertisedDevice.getName().c_str());
if (advertisedDevice.getName() == bleServerName) { //Check if the name of the advertiser matches
advertisedDevice.getScan()->stop(); //Scan can be stopped, we found what we are looking for
pServerAddress = new BLEAddress(advertisedDevice.getAddress()); //Address of advertiser is the one we need
Serial.println("Camera found. Connecting!");
Serial.print("Payload: ");
printHex(advertisedDevice.getPayload(), advertisedDevice.getPayloadLength());
Serial.println("");
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) {
Serial.println("Camera is ready to paring");
doPairing = true;
} else {
doConnect = true;
Serial.println("Camera is not ready to paring, but try to connect");
}
}
}
}
}
};
class MyClientCallback : public BLEClientCallbacks {
void onConnect(BLEClient* pclient) {
Serial.println("Connected");
connected = true;
isRecording = false; // Reset recording state on new connection
currentMode = VIDEO_MODE; // Default to video mode
updateLED();
}
void onDisconnect(BLEClient* pclient) {
connected = false;
isRecording = false; // Reset recording state on disconnect
Serial.println("Disconnected");
updateLED();
}
};
// Connect to BLE server
bool connectToServer(BLEAddress pAddress) {
BLEClient* pClient = BLEDevice::createClient();
pClient->setClientCallbacks(new MyClientCallback());
// Connect to the remove BLE Server.
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) {
Serial.print("Failed to find our service UUID");
return false;
}
remoteCommand = pRemoteService->getCharacteristic(BLEUUID((uint16_t)0xFF01));
remoteNotify = pRemoteService->getCharacteristic(BLEUUID((uint16_t)0xFF02));
if (remoteCommand == nullptr) {
Serial.println("Failed to find our characteristic command");
return false;
}
if (remoteNotify == nullptr) {
Serial.println("Failed to find our characteristic notify");
return false;
}
Serial.println("Camera BLE service and characteristic found");
remoteCommand->registerForNotify(commandNotifyCallback);
remoteNotify->registerForNotify(notifyNotifyCallback);
connected = true;
isRecording = false; // Reset recording state on new connection
currentMode = VIDEO_MODE; // Default to video mode
updateLED();
return true;
} else {
Serial.println(" - fail to BLE connect");
return false;
}
}
// Accept any pair request from Camera
class MySecurityCallbacks: public BLESecurityCallbacks {
uint32_t onPassKeyRequest(){
Serial.println("PassKeyRequest");
return 123456;
}
void onPassKeyNotify(uint32_t pass_key){
Serial.print("The passkey Notify number:");
Serial.println(pass_key);
}
bool onSecurityRequest(){
Serial.println("SecurityRequest");
return true;
}
void onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl){
Serial.println("Authentication Complete");
if(cmpl.success){
Serial.println("Pairing success");
}else{
Serial.println("Pairing failed");
}
}
bool onConfirmPIN(uint32_t pin) {
return true;
}
};
void pairOrConnect() {
BLEScan* pBLEScan = BLEDevice::getScan();
pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
pBLEScan->setActiveScan(true);
Serial.println("BLE: Looking for camera");
pBLEScan->start(30);
Serial.println("BLE: end of searching");
}
// Function to toggle video recording (VIDEO MODE)
void toggleVideoRecording() {
if (connected && currentMode == VIDEO_MODE) {
if (!isRecording) {
// Start recording
Serial.println("VIDEO MODE: Starting video recording");
remoteCommand->writeValue(RECORD_HIGH, 2, true);
delay(50);
remoteCommand->writeValue(RECORD_LOW, 2, true);
isRecording = true;
updateLED();
delay(50);
} else {
// Stop recording
Serial.println("VIDEO MODE: Stopping video recording");
remoteCommand->writeValue(RECORD_HIGH, 2, true);
delay(50);
remoteCommand->writeValue(RECORD_LOW, 2, true);
isRecording = false;
updateLED();
delay(50);
}
}
}
// Function to take photo (PHOTO MODE)
void takePhoto() {
if (connected && currentMode == PHOTO_MODE) {
Serial.println("PHOTO MODE: Taking picture");
remoteCommand->writeValue(PRESS_TO_FOCUS, 2, true);
delay(100);
remoteCommand->writeValue(TAKE_PICTURE, 2, true);
delay(100);
remoteCommand->writeValue(SHUTTER_RELEASED, 2, true);
delay(100);
remoteCommand->writeValue(HOLD_FOCUS, 2, true);
Serial.println("PHOTO MODE: Picture taken");
}
}
// Function to switch between video and photo modes
void switchMode() {
if (currentMode == VIDEO_MODE) {
currentMode = PHOTO_MODE;
isRecording = false; // Ensure recording is stopped when switching to photo mode
Serial.println("Switched to PHOTO MODE (Violet LED)");
} else {
currentMode = VIDEO_MODE;
Serial.println("Switched to VIDEO MODE");
}
updateLED();
}
// Function to read button with debouncing and long press detection (PULL-DOWN)
bool readButton() {
bool reading = digitalRead(BUTTON_PIN);
unsigned long currentMillis = millis();
// Debouncing
if (reading != lastButtonState) {
lastDebounceTime = currentMillis;
}
if ((currentMillis - lastDebounceTime) > debounceDelay) {
// If the button state has changed:
if (reading != buttonPressed) {
buttonPressed = reading;
if (buttonPressed == HIGH) { // Button pressed (HIGH for pull-down)
buttonPressStartTime = currentMillis;
buttonBeingPressed = true;
Serial.println("Button pressed - starting timer");
} else { // Button released
buttonBeingPressed = false;
unsigned long pressDuration = currentMillis - buttonPressStartTime;
if (pressDuration < MODE_SWITCH_TIME) {
// Short press - trigger current mode action
Serial.print("Short press (");
Serial.print(pressDuration);
Serial.println(" ms)");
lastButtonState = reading;
return true; // Button was pressed and released quickly
}
// Long press already handled in checkLongPress()
}
}
}
lastButtonState = reading;
return false;
}
// Check for long press to switch mode
void checkLongPress() {
if (buttonBeingPressed && connected) {
unsigned long currentMillis = millis();
unsigned long pressDuration = currentMillis - buttonPressStartTime;
if (pressDuration >= MODE_SWITCH_TIME) {
// Long press detected - switch mode
Serial.println("Long press detected (4 seconds) - switching mode");
switchMode();
buttonBeingPressed = false; // Reset to prevent repeated switching
// Small delay to debounce mode switch
delay(500);
}
}
}
void setup() {
Serial.begin(115200); // default boot baudrate
esp_log_level_set("*", ESP_LOG_VERBOSE); // verbose logs
// Initialize button pin with PULL-DOWN configuration
pinMode(BUTTON_PIN, INPUT);
Serial.println("Button initialized on pin D0 (GPIO 26) - PULL-DOWN configuration");
// Initialize LED
led.begin();
led.setBrightness(LED_BRIGHTNESS);
led.clear();
led.show();
Serial.println("WS2812B LED initialized on pin D1 (GPIO 27)");
Serial.println("Camera Remote Controller Initialized");
Serial.println("=====================================");
Serial.println("Button Functions:");
Serial.println(" Short press (<4 sec): Trigger current mode action");
Serial.println(" Long press (4+ sec): Switch between VIDEO/PHOTO modes");
Serial.println("");
Serial.println("LED Status:");
Serial.println(" Blue blinking = Not connected");
Serial.println(" Solid Green = VIDEO mode, not recording");
Serial.println(" Solid Red = VIDEO mode, recording");
Serial.println(" Solid Violet = PHOTO mode");
Serial.println("");
Serial.println("Current mode: VIDEO MODE (Green LED)");
//Init BLE device
BLEDevice::init("Camera Remote v1.0");
BLEDevice::setEncryptionLevel(ESP_BLE_SEC_ENCRYPT);
BLEDevice::setSecurityCallbacks(new MySecurityCallbacks());
// Start scanning for camera
pairOrConnect();
}
void loop() {
if (doPairing || doConnect) {
connectToServer(*pServerAddress);
}
// Check for long press (mode switching)
checkLongPress();
// Check for short press (trigger action)
if (readButton()) {
Serial.print("Triggering action in ");
Serial.print(currentMode == VIDEO_MODE ? "VIDEO" : "PHOTO");
Serial.println(" mode");
if (currentMode == VIDEO_MODE) {
toggleVideoRecording();
} else {
takePhoto();
}
// Wait a bit after triggering to avoid multiple triggers
delay(500);
}
// Update LED status regularly (for blinking when disconnected)
updateLED();
// If not connected, try to reconnect
if (!connected) {
Serial.println("Not connected to camera. Attempting to reconnect...");
delay(2000); // Wait 2 seconds before trying to reconnect
pairOrConnect();
}
// Small delay to prevent overwhelming the loop
delay(10);
}Final assemblyAfter flashing the code, use small M3 screws to secure the PCB. Solder the battery to the battery pin on the back side of the PCB. Place the back cap and secure it with some glue if needed.Now we are done with building
Go 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 $123 in new user coupons!
✨ Try them out at JLC3DP.com
If you like my project and would like to see more projects,
you can buy me a coffee to keep supporting my work.
☕❤️







_t9PF3orMPd.png?auto=compress%2Cformat&w=40&h=40&fit=fillmax&bg=fff&dpr=2)

Comments