ESP32 Secure BLE Key is an open-source Bluetooth Low Energy access-control system that uses an ESP32-C3, a native Kotlin Android app, and a Garmin Connect IQ watch app. It authenticates commands using HMAC-SHA256 challenge-response authentication and can trigger a car remote, gate, garage door, or similar low-voltage control.
While designed for automotive use, the system can be adapted to any application requiring a secure BLE-triggered action, such as doors, gates, or other access control systems. (This is not for keyless ignition/starting.)
Designed for both convenience and security:
- No need to carry physical keys for everyday access
- Lost keys alone cannot unlock the car without the additional authentication layer
- Access can be restored on a new device if your phone or watch is lost, with proper authentication
- Power-optimised for always-on operation in a parked vehicle
The ESP32-C3 sits inside the car, powered from the 12V system via a buck converter. A GPIO pin is wired directly across the car remote's button β no MOSFET or relay needed since both share the same 3.3V supply. When you want to unlock the car, the native Android app (or the Garmin watch app) connects over BLE, completes an HMAC-SHA256 challenge-response handshake using a pre-shared key (PSK), and sends a command. The ESP32-C3 verifies the HMAC and drives the GPIO to simulate a button press on the remote.
The C3 firmware uses ESP-IDF, FreeRTOS tickless idle, and dynamic frequency scaling. BLE modem sleep remains disabled for link reliability; actual battery draw depends on the board, converter, remote, and radio activity.
Native_Android_Application/ Native Kotlin multi-device Android app β recommended
Android_Flutter_Application/ LEGACY Flutter mobile app β unsupported
Garmin_Watch_App/ Garmin Connect IQ car watch app
Garmin_Gate_Watch_App/ Garmin Connect IQ gate watch app
ESP32-C3_Firmware/ ESP32-C3 ESP-IDF firmware (PlatformIO) β recommended
ESPHome_Gate_Firmware/ ESPHome gate BLE API-v2 firmware
ESP32_Arduino_Firmware/ LEGACY ESP32 Arduino proof of concept β unsupported
ESP32-C3_Arduino_Firmware/ LEGACY ESP32-C3 Arduino proof of concept β unsupported
Legacy firmware: Both Arduino firmware directories are retained only for historical reference and migration. They are not maintained, are not included in releases, and should not be used for new installations. Use
ESP32-C3_Firmware/for reliable simultaneous phone/watch connections.
Legacy mobile app:
Android_Flutter_Application/is retained for history and migration only. New development and release APKs useNative_Android_Application/.
Both Car and Gate now use API v2:
- The ESP32 generates a random 16-byte nonce
- The phone/watch authenticates the API-v2 domain, device binding, exact command, and nonce with HMAC-SHA256
- The phone/watch sends the HMAC along with a command byte
- The ESP32 verifies using constant-time comparison
- The nonce is rotated unconditionally after every verification attempt (success or failure) to prevent replay
Car uses the car-main binding and Gate uses gate-main, preventing a valid
response from being redirected between devices or actions. See
PROTOCOL.md for the byte-exact definition.
Additional protections include optional device-authentication gating on the phone (fingerprint, face, or device PIN), Car auto-disconnect timeouts (20s unauthenticated, 30min authenticated inactivity), and secure storage for the PSK on both the phone/watch and ESP32 (stored in NVS). The app gate can be disabled for faster frequent use; changing the PSK always requires device authentication.
The PSK can be updated over BLE from the phone app after initial setup. Version 2.6.0 encrypts and authenticates the replacement and verifies a signed storage receipt before saving it on the phone. See PROTOCOL.md.
Provisioning: The public placeholder PSK is disabled. Release OTA images require an existing key in NVS. For a fresh device, customize
DEFAULT_PSKprivately before building and flashing; never commit that key. Preserve NVS when updating an existing device.
The C3 firmware is designed for long term always-on operation in a parked vehicle:
| Metric | Value |
|---|---|
| Idle current draw | Measure on installed hardware |
| CPU idle frequency | 10 MHz (auto-scaled via DFS) |
| CPU active freq | 80 MHz |
| Sleep mode | Tickless idle + DFS configured |
| Wi-Fi | Excluded at build time |
| BLE modem sleep | Disabled for link reliability |
Achieved through ESP-IDF features unavailable in the Arduino framework:
CONFIG_PM_ENABLE+CONFIG_FREERTOS_USE_TICKLESS_IDLE- Dynamic frequency scaling between 10 MHz and 80 MHz
- Wi-Fi stack stripped from the build (saves ~100 KB flash, ~40 KB RAM)
- Application USB Serial/JTAG controller and both console routes disabled in production; ROM USB recovery remains available
- Button GPIO held in high-impedance idle (zero quiescent current)
- No three-hour scheduled restart; watchdog and BLE health recovery remain active
- Daily fallback restart deferred during firmware transfer, button pulse, or pending press confirmation
Android and Garmin Car use the short authenticated command/status path for normal presses. Android uses its ready connection's fresh nonce and sends one command write (two fragments at MTU 23); Garmin reads a challenge and sends two fragments. The existing ESP status reports the result without receipt polling or a follow-up acknowledgment exchange. If the response is lost, the client shows Unable to confirm and never automatically repeats the press.
The v2.8.0 receipt exchange added several BLE round trips and noticeable delay on real connections, so v2.8.1 clients no longer use it for presses. The Car's 30-second RAM receipt support remains available to older clients. Authenticated device proof and interrupted PSK-change recovery remain in use. No firmware update is needed for the v2.8.1 client changes.
Unsent queued presses expire after ten seconds. Tap Cancel on Android, or press SELECT again on the watch, to cancel a queued action. Android also cancels an unsent press when backgrounded. Gate keeps its existing response protocol.
PSK changes now require a connected Car running v2.8.0. The phone durably keeps encrypted old/candidate keys until the change is confirmed and saved. After an interruption, use Device Settings > Recover interrupted PSK change to verify which key is installed without pressing the remote. Customized bootstrap keys are saved to NVS on first boot; storage failure disables commands. See v2.8.0 release notes for upgrade order and the existing-installation provisioning caveat.
In the Android app, open a Car device's Settings > Device health and firmware version. Opening this view requests one authenticated snapshot. Refresh requests another; normal connections and background operation do not fetch health data. Closing the view cancels a request waiting for a connection.
The view shows the running firmware version, build fingerprint, OTA validation state, uptime, last reset reason, free/minimum memory, BLE connection count, and advertising/stale-connection recovery counts. Requested snapshots are included in the diagnostic log for export. Older Car firmware shows an upgrade message. Gate firmware and Garmin apps remain compatible and do not need an update for this feature.
After BLE OTA, keep power stable and allow about one minute after reboot before refreshing this view. Check that the expected version is running and OTA status: Validated appears. The firmware confirms a pending image only after a continuous minute of healthy advertising/BLE host maintenance and an active task watchdog. Failed health checks leave rollback enabled; validation has a two-minute limit. This checks firmware operation, not physical button wiring or radio range.
The installed 3V31A-BUCK-REG converter feeds the ESP's 3.3V rail directly. Firmware cannot determine its input current or car battery voltage from that regulated supply. No battery percentage or estimated current is displayed, and no manual measurements are needed to use the health view. Advertising intervals, TX power, and BLE sleep settings are unchanged by default. To tune advertising, use Advertising in the health view. Set the recent and inactive intervals (a fixed value or min-max range, 20-2000 ms) and the inactivity duration (5-3600 seconds), then Save to ESP. Values must use BLE's 0.625ms units; multiples of 5ms are easiest to enter. Settings persist in NVS and apply without rebooting or disconnecting existing clients. Restore defaults selects 50-100ms recent, 200-400ms inactive, and 60 seconds; press Save to apply. Increasing intervals may slow discovery.
The health snapshot reports the current advertising mode and saved intervals. Reading it does not extend the recent-activity window. Connection/disconnection events and authenticated commands do extend that window. A BLE timer switches to inactive mode at the chosen deadline, with maintenance as a fallback. Only the Android APK and Car OTA application need updating: existing OTA partitions, wiring, and Garmin apps are unchanged. No USB migration is needed on a Car already running the project's signed API-v2 BLE OTA firmware.
- MCU: ESP32-C3 SuperMini (recommended) or standard ESP32 (POC, not maintained)
- Power: Car 12V through a buck converter to 3.3V (powers both the ESP32 and the remote)
- Remote: Car remote with a physical button β GPIO wired directly across the button
- No MOSFET needed when the remote runs on the same 3.3V supply as the ESP32
Car 12V βββΊ Buck Converter βββΊ 3.3V βββ¬βββΊ ESP32-C3 VIN
ββββΊ Remote (replaces batteries)
ESP32-C3 GPIO 5 βββΊ Remote button (non-supply leg)
Remote button (other leg) βββΊ 3.3V or GND (depends on remote)
GPIO 5 is used because it has no strapping function on the C3 and is safe for clean digital I/O. Avoid GPIO 2/8/9 (strapping/LED/boot button) and GPIO 18/19 (USB-CDC needed for flashing).
Use a multimeter to check which side of the remote's button connects to the supply rail:
- One leg on 3.3V: The button pulls the encoder input HIGH when pressed. Set
BUTTON_ACTIVE_HIGH true. - One leg on GND: The button pulls the encoder input LOW when pressed. Set
BUTTON_ACTIVE_HIGH false.
Wire GPIO 5 to the other leg (the encoder input side).
Note: If your remote operates at a different voltage than 3.3V, you'll need a relay or N-Channel MOSFET (e.g. BS170) between the GPIO and the button, plus a separate regulator for the remote.
Requirements: PlatformIO (CLI or VS Code extension). The ESP-IDF toolchain is installed automatically by PlatformIO on first build.
- Open
ESP32-C3_Firmware/in PlatformIO - Edit
main/main.cand changeDEFAULT_PSKto your own secret (32+ characters recommended) - Optionally adjust
BUTTON_GPIO,BUTTON_ACTIVE_HIGH,BUTTON_PULSE_MS,BLE_DEVICE_NAME, and other configuration constants near the top of the file - Build and flash:
pio run -e esp32c3 -t upload - Production builds disable the application USB Serial/JTAG controller and
serial console for minor power savings while preserving ROM USB recovery.
To enable logging during development, see
BUILD_NOTES.mdfor the debug build steps.
The first upgrade from the original single-app layout to signed BLE OTA needs
four USB-flashed binaries at specific offsets while preserving NVS. See
ESP32-C3_Firmware/OTA_BOOTSTRAP.md for
the files, partition values, exact command, PSK-preservation warning, and later
mobile BLE-OTA procedure.
Release binaries are signed. Private signing keys and device PSKs are excluded
from Git; use your own keys when building a fork. Keep device PSKs in a password
manager and, for ESPHome, an ignored secrets.yaml. See
signing and credentials for build inputs and update compatibility.
The native Android app is Remote Key, with release application ID
dev.logiclabs.remotekey. See the
Android app README for features, build
instructions, and migration from the previous application ID.
Remote Key has no ads. Privacy and support: info@6675162.xyz. The privacy policy is also available offline inside the app.
Download the current APK from GitHub Releases, or build it with JDK 17 and Android SDK 36.
cd Native_Android_Application
$env:JAVA_HOME = 'C:\path\to\jdk-17'
.\gradlew.bat assembleReleaseThe APK is written to Native_Android_Application/app/build/outputs/apk/release/.
See Native_Android_Application/README.md for release-signing configuration.
On first launch, set the per-device PSKs in the app to match what you flashed onto the ESP32-C3. The app stores it in encrypted secure storage and requires device authentication by default. Use App Settings β Require device authentication to disable the launch gate during periods of frequent use. PSK changes remain protected by device authentication even when the main app gate is disabled.
Device Settings β Use cached device address enables an optional lower-latency direct connection. After three failed cached attempts the app automatically switches to filtered scanning. Failed connection attempts back off.
The native app also includes connection diagnostics, shareable logs, a seven-day operation history, and optional location pins for the latest 30 foreground sessions. Location recording is opt-in per device, allowing the mobile car to record locations while leaving a fixed gate disabled, and does not affect BLE operation.
Logs can be filtered by category, device, local calendar day, and search text. Export saves or shares a text file containing the matching displayed entries and filter details. Clear also respects the selected filters. Diagnostics remain available for 24 hours, operations for seven days, and locations for the latest 30 sessions; filters cannot recover expired records.
The dashboard starts with Car and Gate cards, but cards can be renamed, removed, or extended with additional Standalone or ESPHome devices. Each card keeps its own encrypted PSK, BLE address, history filter, and location preference. The Gate card displays authenticated live controller state, while Standalone car firmware can be updated in-app using signed BLE OTA images.
Switching away keeps phone connections available for two minutes. Returning within that window reuses them; otherwise the app reconnects when opened. Android shows a quiet connection-service notification during the grace period. An active firmware update finishes before the timeout disconnects the car.
Requirements: Connect IQ SDK with minSdkVersion 5.2.0 or newer. The
current Car and Gate builds target the Forerunner 165, 165 Music, 265, 265S,
and 965.
Note: The minimum SDK version may be able to be lowered for older devices, but this has not been tested. Any additional watch model must also be added to both app manifests and tested with its BLE implementation.
Configure each watch app's PSK through Garmin Connect or the Connect IQ Store app after installation. The value is entered through a masked password setting; use the Car PSK for BLE Car Key and the Gate PSK for BLE Gate Key. The two apps have separate identities and can be installed together.
Controls:
- SELECT β send unlock command (works whether connected or disconnected; auto-connects if needed)
- BACK β exit app (preserves BLE pairing for fast reconnect on next launch)
- MENU (long-press UP) β force unpair, useful after firmware updates that change the GATT service table
The watch retains healthy pairings across app sessions and discards stalled connections automatically. Each launch authenticates before showing ready; actual connection time depends on the watch and radio environment. Both watch apps close and disconnect after two minutes without user input.
| Characteristic | UUID (suffix) | Properties | Purpose |
|---|---|---|---|
| Challenge | ...7891 |
Read, Notify | 16-byte random nonce |
| Command | ...7892 |
Write, WriteNR | 1-byte command + 32-byte HMAC (single write) |
| Status | ...7893 |
Read, Notify | Result of last operation |
| PSK Update | ...7894 |
Write | Encrypted and authenticated PSK2 key change |
| Command Pt1 | ...7895 |
Write, WriteNR | Split write path: 1-byte cmd + first 16 HMAC bytes |
| Command Pt2 | ...7896 |
Write, WriteNR | Split write path: last 16 HMAC bytes |
Service UUID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
The ESPHome gate uses the distinct API-v2 service UUID
b1b2c3d4-e5f6-7890-abcd-ef1234567890. Packet sizes and characteristic
suffixes remain the same across the two API-v2 device families.
The split Pt1/Pt2 path exists because Garmin Connect IQ doesn't expose MTU negotiation, leaving the watch stuck at the default 23-byte ATT MTU. A 33-byte command can't fit in a single write at that MTU, so the watch sends it as two writes. The phone app uses the single-write path since it can negotiate a larger MTU.
0x01β Authenticate only (no button press)0x02β Authenticate and press remote button
Note: Additional commands will be needed for multi-button remotes or additional triggers.
| Status | Meaning |
|---|---|
READY |
Device booted, awaiting commands |
OK:AUTH |
Authentication succeeded (no action) |
OK:PRESSED |
Button press triggered |
PSK2:OK:<tag> |
Authenticated receipt: new PSK persisted |
PSK2:FAIL:<tag> |
Authenticated receipt: previous PSK remains active |
ERR:AUTH |
HMAC verification failed |
ERR:BUSY |
Button press rejected β previous press still active |
ERR:UNKNOWN_CMD |
Unrecognised command byte |
ERR:PSK_FORMAT |
PSK update payload malformed |
Key constants in main/main.c:
| Constant | Default | Description |
|---|---|---|
DEFAULT_PSK |
CHANGE_ME_before_flashing_32chars! |
Pre-shared key (change before flashing) |
BUTTON_GPIO |
GPIO_NUM_5 |
GPIO pin wired to remote button |
BUTTON_ACTIVE_HIGH |
true |
true if button connects to VCC, false if to GND |
BUTTON_PULSE_MS |
300 |
Button press duration in ms |
DEBUG_LED_ENABLED |
undefined | Define to enable debug LED on button press |
DEBUG_LED_GPIO |
GPIO_NUM_8 |
GPIO for debug LED (SuperMini onboard LED) |
BLE_DEVICE_NAME |
BLE-Device |
BLE advertised name |
BLE_TX_POWER |
3 |
TX power in dBm |
ADV_FAST_INTERVAL_MIN |
80 |
Fast advertising minimum (50 ms) |
ADV_FAST_INTERVAL_MAX |
160 |
Fast advertising maximum (100 ms) |
ADV_IDLE_INTERVAL_MIN |
320 |
Idle advertising minimum (200 ms) |
ADV_IDLE_INTERVAL_MAX |
640 |
Idle advertising maximum (400 ms) |
MAX_CONNECTIONS |
3 |
Simultaneous BLE connections |
UNAUTH_TIMEOUT_SEC |
20 |
Absolute deadline to authenticate |
AUTH_TIMEOUT_SEC |
1800 |
Authenticated inactivity timeout |
RESTART_INTERVAL_SEC |
10800 |
Soft restart when idle (3 hours) |
HARD_RESTART_SEC |
86400 |
Restart after 24 hours, deferred during OTA/button pulse |
PM_MAX_FREQ_MHZ |
80 |
DFS max CPU frequency |
PM_MIN_FREQ_MHZ |
10 |
DFS min CPU frequency |
This project is licensed under the MIT License. See LICENSE for details.