Skip to content

AtomBrick: ESP32 BLE Motor Controller to KMP Mobile App

AtomBrick is a Bluetooth motor controller. An ESP32 acts as a BLE peripheral driving up to five DC motors. A Kotlin Multiplatform app on Android and iOS connects, authenticates via passkey, and sends motor commands. This post walks the full stack, covering firmware, command protocol, and the mobile layer, with specific attention to where the two sides meet.

Prerequisites

  • Working knowledge of the Arduino framework and ESP32 GPIO/PWM
  • Android and Kotlin development experience
  • PlatformIO installed for firmware flashing
  • Basic familiarity with Kotlin Multiplatform and Compose

Stack versions

PlatformIO espressif32 · Kotlin 2.2.0 · Compose Multiplatform 1.8.2 · Kable 0.39.1 · Koin 4.1.0 · ESPMX1508 1.0.5

Background

Building a Bluetooth motor controller involves two independent protocol stacks that must agree on one shared contract: the bytes written to each BLE characteristic. The ESP32 side defines the GATT profile and the command encoding. The mobile app side must match it exactly. Any drift between the two produces silent failures: a write succeeds at the BLE layer but the motor does nothing because the command byte is wrong.

The AtomBrick design keeps this contract minimal. Two bytes per command, four command types, one characteristic per motor. That is the entire protocol.

Firmware and app versions must stay in sync

The 2-byte command protocol has no versioning. If you change a command byte value on the ESP32 and deploy without updating the app, every command silently misfires. Define the protocol constants in one place on each side and never hardcode them at the call site.

Implementation

1. Hardware: MX1508 motor channels

Each channel uses a dual H-bridge MX1508 driver controlled via two ESP32 GPIO pins with PWM. Five channels use ten GPIO pins total.

src/main.cpp
MX1508 motorA(INT1_A=12, INT2_A=13, CH1=0, CH2=1);
MX1508 motorB(INT1_B=27, INT2_B=26, CH1=0, CH2=1);
MX1508 motorC(INT1_C=25, INT2_C=33, CH1=0, CH2=1);
MX1508 motorD(INT1_D=32, INT2_D=35, CH1=0, CH2=1);
MX1508 motorE(INT1_E=34, INT2_E=39, CH1=0, CH2=1);

motorGo(speed) accepts 0 to 255. motorRev(speed) reverses polarity. motorStop() brakes.

GPIO 34, 35, 36, and 39 are input-only on ESP32

These pins have no output driver. On the current board revision, GPIO 34 and 39 are wired to INT1_E and INT2_E, which will not drive the motor. Rewire to output-capable pins before replicating this design.

2. BLE server: service and security

The ESP32 exposes one service with six characteristics: five for motor control and one for device naming.

Service: 0000180D-0000-1000-8000-00805F9B34FB
Motor 1: 00002A31-...  READ | WRITE | NOTIFY
Motor 2: 00002A32-...  READ | WRITE | NOTIFY
Motor 3: 00002A33-...  READ | WRITE | NOTIFY
Motor 4: 00002A34-...  READ | WRITE | NOTIFY
Motor 5: 00002A35-...  READ | WRITE | NOTIFY
Name:    00002A36-...  READ | WRITE

Pairing uses a static six-digit PIN with MITM protection and bonding so the PIN is entered once per phone, not once per connection:

src/main.cpp
1
2
3
4
5
BLESecurity *pSecurity = new BLESecurity();
pSecurity->setAuthenticationMode(ESP_LE_AUTH_BOND | ESP_LE_AUTH_REQ_MITM);
pSecurity->setCapability(ESP_IO_CAP_OUT);
pSecurity->setStaticPIN(123456);
pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);

After every disconnect, the firmware resumes advertising automatically in onDisconnect.

3. Command protocol

All motor commands are two bytes written to the motor's characteristic:

Byte 0 (command) Byte 1 (data) Effect
0x01 0x01 / 0x00 Motor on / off
0x02 speed (0 to 127) Forward at speed
0x03 speed (0 to 127) Reverse at speed
0x04 seconds (0 to 60) Run for duration, then stop

The firmware dispatches commands 1 to 3 synchronously:

src/motor.cpp
void processMotorCommand(MX1508 &motor, uint8_t cmd, int8_t value) {
    switch (cmd) {
        case 1:
            value == 1 ? motor.motorGo(128) : motor.motorStop();
            break;
        case 2:
            motor.motorGo(value);
            break;
        case 3:
            motor.motorRev(value);
            break;
        default:
            Serial.printf("Unknown command: %d\n", cmd);
    }
}

Command 0x04 (timer) is handled separately in main.cpp because it requires state across loop iterations:

src/main.cpp
if (command < 4) {
    processMotorCommand(motorA, command, data);
} else {
    currentMotor       = &motorA;
    timerMotorDuration = min((int)data, 60);
    motorStartTime     = millis();
    isMotorRunning     = true;
    currentMotor->motorGo(120);
}
src/main.cpp
// In loop(), checked every 100ms
if (isMotorRunning && currentMotor != nullptr) {
    unsigned long elapsed = (millis() - motorStartTime) / 1000;
    if (elapsed >= timerMotorDuration) {
        currentMotor->stopMotor();
        isMotorRunning = false;
    }
}

The millis() approach avoids blocking loop() and avoids the overhead of a FreeRTOS task for a single timer. Resolution is approximately 100ms given the delay(100) at the end of loop().

4. Mobile: scan and connect

The BleManager singleton owns the Scanner with a name prefix filter so only AtomBrick devices surface in results:

data/BLEManager.kt
val scanner = Scanner {
    filters { match { name = Filter.Name.Prefix("ATOM") } }
    logging  { engine = SystemLogEngine; level = Logging.Level.Events }
}

MotorViewModel collects the scan flow in viewModelScope and deduplicates by device identifier:

ui/motor/MotorViewModel.kt
scanJob = scanner.advertisements
    .onEach { adv ->
        bluetoothState = bluetoothState.copy(
            availableDevices = BleManager.updateDevicesList(adv, bluetoothState.availableDevices)
        )
    }
    .launchIn(viewModelScope)

Connection is a suspend call with a 20-second timeout:

ui/motor/MotorViewModel.kt
peripheral = Peripheral(device.advertisement ?: error("No advertisement"))
withTimeout(20_000) { peripheral?.connect() }

5. Mobile: write motor commands

The app mirrors the firmware protocol exactly. toggleMotor applies an optimistic UI update and reverts on failure:

ui/motor/MotorViewModel.kt
fun toggleMotor(motor: Motor, isOn: Boolean) {
    viewModelScope.launch {
        _motorState.update { it.copy(motors = it.motors.map { m ->
            if (m.id == motor.id) m.copy(isOn = isOn) else m
        })}
        try {
            peripheral?.services?.collect { services ->
                services?.let {
                    peripheral?.write(
                        characteristic = characteristicOf(services[2].serviceUuid, motor.characteristic),
                        data           = byteArrayOf(0x01, if (isOn) 0x01 else 0x00),
                        writeType      = WriteType.WithResponse
                    )
                }
            }
        } catch (e: Exception) {
            _motorState.update { it.copy(motors = it.motors.map { m ->
                if (m.id == motor.id) m.copy(isOn = !isOn) else m
            })}
        }
    }
}

Testing and Verification

Firmware: Flash via PlatformIO and monitor serial output. Use nRF Connect to exercise the characteristics directly before involving the app:

[Serial Monitor]
BLE Server Ready. Pairing PIN: 123456
Device Connected
Auth OK
Raw Value (Hex) A: 01 01   <- toggle on
Raw Value (Hex) A: 02 50   <- speed 80 forward

App on Android: declare and request permissions before scanning:

androidMain/AndroidManifest.xml
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

App on iOS: add NSBluetoothAlwaysUsageDescription to Info.plist.

Tap the Bluetooth icon on the Motor screen, select the device, enter the PIN, and the motor controls become active. The round-trip from tap to motor motion is under 100ms under normal BLE conditions.

Pitfalls

Service collection per command leaks coroutines

peripheral?.services?.collect { ... } inside each command function runs indefinitely. Three motor commands means three concurrent collectors, each holding a coroutine until disconnect. Cache the service reference once on connection and reuse it.

Only one motor can hold an active timer

currentMotor is a single global pointer. Sending a timer command to Motor 2 while Motor 1 is already running on a timer silently cancels Motor 1's timer. A Map<MX1508*, TimerState> structure supports concurrent timers across channels.

Production Considerations

The services[2] index for locating the AtomBrick service is fragile: it assumes service enumeration order is stable across firmware versions and iOS vs Android. Find the service by UUID instead:

ui/motor/MotorViewModel.kt
val service = services?.firstOrNull { it.serviceUuid == TARGET_SERVICE_UUID }
    ?: return@let

The current service uses SIG-assigned UUIDs (0x180D, 0x2A31 to 0x2A36) which nRF Connect will label as Heart Rate service characteristics. A custom 128-bit UUID makes the service self-documenting and avoids confusion in any generic BLE tool.

Wrapping Up

The complete AtomBrick system is roughly 300 lines of ESP32 firmware and 400 lines of shared Kotlin. The 2-byte command protocol is the contract between them. Keeping it small and explicit meant both sides were straightforward to implement and debug in isolation using nRF Connect before the app was involved.

Part 2 covers reconnection: what happens when the ESP32 power-cycles mid-session and the app needs to re-discover and re-authenticate without user intervention.


Comments