---
name: ble-led-controller
description: Case study for building one of Chicho's ad-hoc Android APKs — the BLE LEDDMX-03 LED-strip controller. Reverse-engineered protocol, build/grant/launch sequence, the strict device-name filter, persistence pattern, gravity-based dimmer, the banderazo flag animation, and the stop-all-animations pitfall.
---

# BLE LED controller (LEDDMX-03 family) — case study

A worked example of building one of Chicho's "replace the bad stock app with a clean native controller" APKs. The full working code is in `templates/ble-led-controller-MainActivity.java` (380 lines) — copy and modify, don't start from scratch.

The full opcode-to-byte map for the LEDDMX-03 family (LEDDMX/LEDBLE/LEDCAR/LEDPHO/LEDSUN/LEDSMART/LEDLIKE/LEDLIGHT/LEDSTAGE) is in `references/leddmx-protocol-map.md` — load before re-decompiling the reference APK.

## When to use this pattern

- The user bought a generic Chinese LED strip with a bloated stock app that asks for SMS/contacts and barely works
- Common family names: LEDBLE, LEDDMX-XX, LEDPHO, LEDCAR-00/01/02, LEDSUN, LEDSMART, LEDLIKE, LEDLIGHT, LEDSTAGE
- Goal: replace `com.ledlamp` (or similar) with a small native APK
- Hardware: 9-byte BLE frames, family `7B...BF` (or `7E...EF`, `70...0F`, `7A...AF`, `7D...DF` for other variants)
- GATT: service `0000fff0-0000-1000-8000-00805f9b34fb`, write char `0000fff2-0000-1000-8000-00805f9b34fb` (`PROPERTY_WRITE` or `PROPERTY_WRITE_NO_RESPONSE`)

## Quick opcode reference (LEDDMX default family `7B...BF`)

| Op | Bytes | Purpose |
|---|---|---|
| `turnOn` | `7B 04 04 01 FF FF FF FF BF` | Power on |
| `turnOff` | `7B 04 04 00 FF FF FF FF BF` | Power off |
| `setRgb(R,G,B)` | `7B FF 07 R G B FF FF BF` | Static color (opcode 07) |
| `setBrightness(L)` | `7B 01 L 01 FF FF FF FF BF` | L = 0-100 — **9 bytes, NOT 10** (opcode 01) |
| `setMode(M)` | `7B FF 13 M FF FF FF FF BF` | M = 1-128 dynamic mode (opcode 13) |
| `setSubarea(N)` | `7B FF 14 N FF FF FF FF BF` | Divide strip into N sub-zones |
| `setZoneRgb(R,G,B,Z)` | `7B 07 R G B Z FF FF BF` | Color of zone Z (0-indexed) |

The smali source for the protocol is `Lcom/home/net/NetConnectBle;` in the reference APK. The full matrix is in `references/leddmx-protocol-map.md`.

## Build (Gradle 8.5 + AGP 8.2.2 — AGP 8.5 requires Gradle 8.7+)

```bash
mkdir -p ~/led-controller/app/src/main/java/com/igled/led
cd ~/led-controller

# settings.gradle — rootProject.name = "LED Controller"; include ':app'
# build.gradle — plugins { id 'com.android.application' version '8.2.2' apply false }
# gradle.properties — org.gradle.jvmargs=-Xmx2048m; android.useAndroidX=true
# app/build.gradle — namespace 'com.igled.led', compileSdk 34, minSdk 26, targetSdk 34
# local.properties — sdk.dir=/opt/android-sdk

ANDROID_HOME=/opt/android-sdk gradle assembleDebug
# APK at app/build/outputs/apk/debug/app-debug.apk
```

Manifest needs `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` (API 31+), `ACCESS_FINE_LOCATION`. AppCompat theme is fine. Single `MainActivity` extending `Activity` (not `AppCompatActivity`) works.

## Install + pre-grant + launch (one shot)

```bash
# Find the current ADB port for the user's device first
adb devices

# Install, pre-grant runtime permissions (skips the "Allow" dialogs), and launch
adb -s <TAILSCALE_IP>:<PORT> install -r app/build/outputs/apk/debug/app-debug.apk
adb -s <TAILSCALE_IP>:<PORT> shell pm grant com.igled.led android.permission.BLUETOOTH_CONNECT
adb -s <TAILSCALE_IP>:<PORT> shell pm grant com.igled.led android.permission.BLUETOOTH_SCAN
adb -s <TAILSCALE_IP>:<PORT> shell pm grant com.igled.led android.permission.ACCESS_FINE_LOCATION
adb -s <TAILSCALE_IP>:<PORT> shell am start -n com.igled.led/.MainActivity
```

For adb-over-Tailscale setup (pairing, common operations, port rotation, the only working Health Connect intent, screen-on requirement), see `references/adb-over-tailscale.md` in this skill.

## Strict filter: connect ONLY to the user's specific device

Use `name.equals("LEDDMX-03-5A5B")` (Chicho's actual device name) in the scan callback — not a regex or `startsWith`. Strict = no TV, headphones, smartwatches getting hijacked. The user's real device name is whatever the device advertises in BLE advertisements, or whatever `adb shell pm dump com.ledlamp | grep -i name` shows.

**Filter evolution pitfall**: don't use `name.matches(".*LED.*")` (matches everything), don't use `name.startsWith("LEDDMX")` (matches all LEDDMX variants), don't use a long `||` chain of family prefixes (still grabs the wrong unit if there are two). `equals` is the only safe choice once the device is known.

## Persist device MAC for instant reconnect

Save `BluetoothDevice.getAddress()` to `SharedPreferences` after first successful connect. On next `startScan()` if the saved MAC exists, skip the scan and call `connectGatt` directly. Add a "Forget device" button to reset.

The strict `equals` filter only fires during the first scan. After that, the saved MAC path is used and the filter doesn't run again. To switch devices: tap "Olvidar device guardado", then the next scan re-applies the filter.

## Decompile the reference APK (`com.ledlamp` ~57 MB) to extract the protocol

```bash
adb -s <PORT> shell pm path com.ledlamp  # gives /data/app/~~.../base.apk
adb -s <PORT> shell cat /data/app/~~.../base.apk > /tmp/ledlamp.apk  # works for shell user, no root needed
unzip -o /tmp/ledlamp.apk 'classes*.dex' -d /tmp/work
# baksmali 2.5.2 (last version on Bitbucket — GitHub release URLs 404)
java -jar /tmp/baksmali-2.5.2.jar d /tmp/work/classes.dex -o /tmp/work/smali
# In smali, find Lcom/home/net/NetConnectBle; — all BLE methods are here
# Look for .array-data 4 inside the method body — those are the literal bytes
# Look for filled-new-array/range {vX..vY}, [I — registers hold the byte values
```

Key smali methods to read in `NetConnectBle.smali`:
- `turnOn(Ljava/lang/String;)V` — line ~14897
- `setRgb(IIILjava/lang/String;ZZZZZ)V` — line ~11240
- `setBrightness(ILjava/lang/String;ZZZZZ)V` — line ~4190
- `setMode(ZZZILjava/lang/String;)V` — line ~9502
- `setDmx0001Subarea(ILjava/lang/String;)V` — line ~8324
- `setDmxRgb(IIIILjava/lang/String;)V` — line ~8793

Each model family is identified by a `const-string` literal (`"LEDBLE"`, `"LEDDMX-XX"`, `"LEDSUN"`, etc.), then `invoke-virtual {pN, vK}, Ljava/lang/String;->contains/equalsIgnoreCase(Ljava/lang/CharSequence;)Z`, then a `goto/16 :goto_xx` if no match. The matched branch has the `filled-new-array` with the actual byte values in registers.

## Banderazo tricolor (Argentine / German flag mix)

Chicho wanted a "tricolor stripe" — 6 zones, 3 AR (celeste/blanco/celeste) + 3 DE (negro/rojo/dorado). He explicitly rejected the alternating-loop version.

**Two implementations are in the code; the loop is what actually works on Chicho's LEDDMX-03-5A5B:**

1. `cmdSubarea(6)` + six `cmdZoneRgb(R,G,B,zoneIdx)` — the smali-supported approach. On Chicho's strip the subarea opcode `0x14` was accepted by the firmware but the per-zone colors did NOT paint; the strip stayed on the last solid color. **Keep the methods in the code** for future LEDDMX-03 batches that may support it, but don't rely on it.
2. **0.5s alternating-color loop** (the one Chicho actually sees working) — cycles celeste → blanco → celeste → negro → rojo → dorado on the whole strip. Visually approximates the banderazo without needing per-pixel addressing. The `applyTricolorFlag` button runs this loop.

## Stop-all-animations pattern

A "Detener loop" button needs to cancel not just the running Runnable, but **any pending postDelayed callbacks** in the Handler. Use:

```java
handler.removeCallbacksAndMessages(null);  // wipes ALL scheduled Runnables
```

NOT `handler.removeCallbacks(flagLoopRunnable)` — that only kills the specific Runnable, leaving other postDelayed commands (test sequence, banderazo mid-flight) still in flight.

## Brightness slider listener pitfall (subtle but real)

When adding a sensor-based dimmer, the SeekBar listener needs to distinguish user touches from programmatic `setProgress()` calls (the sensor uses `setProgress` to mirror the current brightness, which fires the listener with `fromUser=false`).

**Wrong** — gates the user's manual touch on dimmer state:
```java
if (fromUser && !dimmerEnabled) cmdBrightLevel(progress);
```
Result: with the dimmer enabled, **manual slider touches become no-ops** even though the slider visually moves. Chicho hit this in the session that produced this skill.

**Right** — only the `fromUser` check matters; the dimmer sends bytes itself:
```java
if (fromUser) cmdBrightLevel(progress);
```
The sensor's `brightSlider.setProgress(bright)` call lands with `fromUser=false`, so the listener no-ops it and we avoid the feedback loop. The `dimmerEnabled` flag is only needed inside the sensor callback to gate the actual `cmdBrightLevel` call there.

## Sensor-based dimmer (gravity → brightness)

Map `Sensor.TYPE_GRAVITY` X axis to brightness 0-100. UX: tilt phone left = brighter, tilt right = dimmer (Chicho's preference; confirm polarity by testing once on the device).

```java
private SensorManager sensorManager;
private Sensor gravitySensor;  // falls back to TYPE_ACCELEROMETER
private boolean dimmerEnabled = false;
private int lastBright = -1;

private void toggleDimmer() {
    if (gravitySensor == null) { /* toast "no sensor" */ return; }
    dimmerEnabled = !dimmerEnabled;
    if (dimmerEnabled) {
        sensorManager.registerListener(sensorListener, gravitySensor, SensorManager.SENSOR_DELAY_UI);
    } else {
        sensorManager.unregisterListener(sensorListener);
    }
}

private final SensorEventListener sensorListener = new SensorEventListener() {
    @Override public void onSensorChanged(SensorEvent ev) {
        if (!dimmerEnabled) return;
        float gx = ev.values[0];  // positive = tilted right
        // Chicho's preferred polarity: LEFT = brighter
        float g = -gx / 9.81f;  // invert so left = positive
        int bright = Math.round(50f + g * 50f);
        bright = Math.max(0, Math.min(100, bright));
        if (Math.abs(bright - lastBright) < 2) return;  // deadzone, no spam
        lastBright = bright;
        brightSlider.setProgress(bright);   // fires listener with fromUser=false
        brightValue.setText(String.valueOf(bright));
        cmdBrightLevel(bright);
    }
    @Override public void onAccuracyChanged(Sensor sensor, int accuracy) {}
};
```

Pair this with a single button (orange background works well) that toggles `dimmerEnabled` and unregisters the listener in `onDestroy()` to avoid sensor drain.

## Pitfalls

- `btsnoop_hci.log` and `btmon` are NOT available on stock Pixel (no root, no `btmon` binary in `/system/bin`). Don't waste time trying to capture BLE from adb on production builds.
- Web Bluetooth in Chrome CANNOT sniff other apps' BLE traffic. A native APK with its own `BluetoothGattCallback` is the only path to capture traffic from `com.ledlamp` without root.
- Baksmali 2.5.2 from JesusFreke is the last version on Bitbucket (later versions are on GitHub but GitHub release URLs redirect poorly). `curl -sL "https://bitbucket.org/JesusFreke/smali/downloads/baksmali-2.5.2.jar" -o baksmali.jar` works. **Do NOT** try GitHub release URLs — they 404 silently.
- When the smali has `const/16 v0, 0xbf` style, the literal value is the actual byte. When it has `filled-new-array/range {vA .. vB}, [I`, the range is the array of bytes. The same `:array_xxx` label can appear in multiple methods, so check method context.
- Chicho's locale: Spanish/Argentine. UI labels in Spanish, no emojis in code comments, but emojis in button text are fine (🇦🇷🇩🇪 in button text is OK).
- The Java imports for `ScanCallback`, `ScanResult`, `ScanSettings` are in `android.bluetooth.le.*`, NOT `android.bluetooth.*`. Easy to get wrong when porting code.
- Variable name `b` (blue channel) inside a method that has a parameter `b` will cause "variable b is already defined" — rename to `bb` or `blue` when extracting channels.
- **setBrightness is 9 bytes, not 10.** LEDDMX-03 default is `7B 01 L 01 FF FF FF FF BF` (registers v5..v13). Earlier reverse-engineering guessed `7B FF 01 01 L FF FF FF FF BF` (10 bytes) which made the slider a no-op. Always count `const/16` + `const/4` literals plus the registers in `move` lines to know the array length.
- **Subarea opcode (0x14) is in the smali but didn't paint zones on Chicho's LEDDMX-03-5A5B.** Use the 0.5s color-alternating loop for the banderazo.

## User preference: execution-first, no permission-asking

**Chicho's strongest signal from this class of work**: "Por que mierda si tenes acceso total vía adb no haces estas cosas vos? La puta que lo parió". He gets visibly frustrated when the assistant asks for permission, lays out a step-by-step plan, or stops to verify before acting.

**Rules for this class of work** (reverse-engineering / device-control / adb-driven ops):
1. If you have the tools (adb, smali decompile, build, install, etc.), **do the work end-to-end without asking**. Lay out a one-line summary, then execute.
2. **Don't ask "¿querés que haga X?"** when the user just said "hacelo". Just do it and report.
3. **Present choices only when the decision is irreversible** (rm -rf, sending messages, posting publicly). APK install on his own device, log files, debugging output — none of these are irreversible.
4. When the user is angry (cabreado), don't defend the failure, don't explain why plan A didn't work — **immediately switch to plan B/C/D and report progress**.
5. After completing a chunk of work, **report what was done and ask one concrete next-step question** — not a tree of options.
