---
name: ble-pwa-replacement
description: "Replace crappy BLE device apps with a Web Bluetooth PWA — research the protocol, build a clean PWA, deploy via Tailscale Funnel."
version: 1.0.0
author: Pipo
metadata:
  hermes:
    tags: [ble, bluetooth, pwa, web-bluetooth, reverse-engineering, led, iot]
---

# BLE PWA Replacement

When a user has a terrible BLE device Android app and wants a better replacement, build a Web Bluetooth PWA.

## Core Operating Principles

1. **If you have ADB, do it from the server — don't ask the user to tap things.** Grant permissions
   with `pm grant`, install APKs with `adb install`, launch with `am start`, drive UI with
   `input tap`. The user gets frustrated when they have to physically interact with the
   device for things adb can already do. Default to "I'll do it from here" and only
   ask the user to do things adb genuinely cannot do.
2. **The protocol extraction step is fast and definitive — do it first, don't guess.** When
   the user pushes back on ad-hoc guessing ("para un minuto, dame comandos específicos"),
   switch to APK decompilation immediately. The `baksmali` path produces all the bytes
   in one session, no app interaction needed.
3. **Native APK beats PWA when the protocol is uncertain.** If the PWA variant-switching
   isn't reliable, build a tiny native APK that broadcasts every known variant per tap
   (see `references/native-fallback-apk.md`). The user gets a tactile button to mash,
   the screen shows the exact bytes, and you iterate from the server without round-trips.
4. **The device's BLE name is the protocol selector.** The `com.ledlamp` app routes
   every command through a single `sendCharacteristic()` choke point that branches on
   the device name prefix (`LEDBLE`, `LEDDMX-03`, `LEDCAR-01`, `LEDSUN`, `LEDSMART`,
   `LEDLIKE`, `LEDSTAGE`, `LEDPHO`, `LEDLIGHT`). One function = the full command matrix.
   Read the name, pick the right bytes. No need to hunt for separate per-model classes.

## Trigger

- User says "this app is terrible, can you build a better one"
- User has a BLE device controlled by an Android app
- App ID is known (e.g. `com.ledlamp`, `com.xiaoyu.hlight`, `com.hle.lhzm`)

## Quick Check: Protocol Already Known?

Many cheap BLE LED strips share the same protocol family. Before reverse engineering, search:

```
site:github.com "<app_id>" ble protocol
"<app_id>" ble reverse engineer
elkbledom models.json <device_name_prefix>
```

The `elkbledom` Home Assistant integration has already reverse-engineered 24+ models. The `models.json` file documents protocols for ELK-BLEDOM, MELK, LEDBLE, XROCKER, JACKYLED, etc.

If the protocol is documented, skip straight to building the PWA.

## Protocol Structure (LED Strip Family)

Most cheap LED strips use a 9-byte protocol over BLE GATT:

```
Byte 0:  0x7e (start delimiter)
Byte 1:  Variant (0x00 for LEDBLE, 0x07 for ELK, 0x04 for some, 0xff for others)
Byte 2:  Command (0x04=power, 0x05=color, 0x01=brightness, 0x03=effect, 0x02=speed)
Bytes 3-7: Payload (depends on command)
Byte 8:  0xef (end delimiter)
```

### Common UUIDs

| Device Family | Service UUID | Write Char | Read Char |
|---|---|---|---|
| LEDBLE / BLELED | `0000ffe0-...-00805f9b34fb` | `0000ffe1` | `0000ffe2` |
| ELK-BLEDOM / MELK | `0000fff0-...-00805f9b34fb` | `0000fff3` | `0000fff4` |
| XROCKER | `0000ffe0-...-00805f9b34fb` | `0000ffe1` | `0000ffe2` |

### Common Commands (LEDBLE variant, byte[1]=0x00)

| Action | Hex |
|---|---|
| Power ON | `7e 00 04 01 00 00 00 00 ef` |
| Power OFF | `7e 00 04 00 00 00 ff 00 ef` |
| Set RGB | `7e 00 05 03 RR GG BB 00 ef` |
| Brightness | `7e 00 01 BB 00 00 00 00 ef` |
| Effect | `7e 00 03 EE 03 00 00 00 ef` |
| Speed | `7e 00 02 SS 00 00 00 00 ef` |

ELK variant (byte[1]=0x07): ON=`7e 07 04 ff 00 01 02 01 ef`, Color=`7e 07 05 03 RR GG BB 0a ef`

### LEDDMX Variant (CRITICAL — 9-byte assumption is WRONG)

`LEDDMX-*` devices (com.ledlamp app) are NOT the same protocol as LEDBLE/ELK, AND the
publicly-documented 9-byte `7E...EF` / `7B...BF` format does NOT match what an LEDDMX-03
actually accepts on the wire. **The btsnoop-verified format is 3-byte writes.**

#### ACTUAL LEDDMX-03 format (from HCI snoop capture)

Each command is a single 3-byte write to characteristic `0xffe1`:
```
Byte 0:  0x00 (constant prefix — looks like "session/type" marker, always 0x00)
Byte 1:  Header byte
            0x7B  = visual/control command (power, color, effect, brightness)
            0x8B  = timing/scheduling command
Byte 2:  Parameter byte
```

Observed examples from a real capture of the com.ledlamp app driving a LEDDMX-03:

| App action | Bytes sent to FFE1 |
|---|---|
| App connected, idle keepalive | `00 7B FF` (repeats constantly while app is foregrounded) |
| Power OFF (inferred) | `00 7B FE` |
| Color/preset select | `00 7B 07` (also seen: `0B`, `0C`) |
| Timing setting | `00 8B 10`, `00 8B 11` |
| Per-pixel writes (effect animation) | `00 <pixel_idx> <color> 00` — 3-4 byte writes with pixel index in byte 1 |

⚠️ **The `7E FF 04 01 FF FF FF FF EF` 9-byte "Power ON" command from GitHub repos
does NOT work on the LEDDMX-03.** The published 9-byte format (from `user154lt/LEDDMX-00`
and HA issue #105338) was derived from code-reading, not a working capture. Send the
3-byte form instead.

⚠️ **The LEDDMX-03 keeps the same UUIDs as LEDBLE** (`ffe0`/`ffe1`/`ffe2`), so the
"auto-detect from device name prefix" path is the only way to tell them apart.
Device name `LEDDMX-*` → use the 3-byte protocol. `LEDBLE-*` / `BLELED-*` → 9-byte LEDBLE.

#### Addressable strips (SETPIX > 1)

LEDDMX-03 is an addressable strip (default SETPIX=200). The standard visual commands set
all pixels to the same color. For per-pixel control (flags, custom patterns), use
pixel-indexed 3-byte writes:
```
[0x00, pixel_index_1based, color_value, 0x00]
```
e.g. set pixel #14 to color 0x45 → `00 0E 45 00`.

This is slow over BLE (~5–10s for 200 pixels at one write per ~30–50ms) but works for
static fills and slow snake animations. For smooth animation, only update pixels at
stripe boundaries (a shifted color pattern only changes ~6 pixels per frame).

### RGB Color Order
Some devices expect GRB or other non-RGB ordering. The com.ledlamp app exposes an "RGB Sort" setting (GRB/GBR/BRG/BGR/RGBW). Default for LEDDMX-03 is GRB — when sending RGB(255,0,0) the hardware interprets it as G=255,R=0,B=0 unless the order is swapped in software.

## Reverse Engineering (When Protocol Is Unknown)

For the working PWA deployment recipe (Tailscale Funnel + Web Bluetooth HTTPS, manifest gotchas, UI patterns), see `references/pwa-deployment.md`.

For the protocol matrix used by `com.ledlamp` / LEDDMX-* family, see `references/leddmx-protocols.md`.

For the native fallback APK pattern (multi-variant broadcast, build + install + grant + launch from server) when Web Bluetooth doesn't work in production, see `references/native-fallback-apk.md`.

### Method 1: Android HCI Snoop (easiest, but the workflow has sharp edges)

**On the phone (user does this):**
1. Enable Developer Options → "Enable Bluetooth HCI snoop log"
2. Use the official app to control the device (do a known sequence: ON, OFF, color A, color B, etc.)
3. Leave the phone on the capture screen, force-stop the app, and leave Bluetooth on

**On the agent's machine (via ADB over Tailscale wireless debugging):**

The most reliable capture path is to run the bugreport tool **on the device** and pull the
result, NOT `adb bugreport` from the PC (which frequently times out with
"Failed to connect to dumpstatez service: Connection refused" on Android 14+):

```bash
# On the phone (no root needed)
adb -s <phone_ip:port> shell "bugreportz"   # writes to /data/user_de/0/com.android.shell/files/bugreports/

# Pull the resulting zip
adb -s <phone_ip:port> pull \
  /data/user_de/0/com.android.shell/files/bugreports/<latest>.zip \
  /tmp/bugreport.zip
```

The latest bugreport is the most recent `bugreport-<device>-<build>-<date>.zip` file in
that directory. `bugreportz` takes 1–3 minutes; just poll the file's mtime.

**Extract the btsnoop log:**
```bash
unzip -o /tmp/bugreport.zip "FS/data/misc/bluetooth/logs/*"
# The file may be named btsnooz_hci.log (compressed) or btsnoop_hci.log
```

**Parse the btsnoop file** (don't rely on Wireshark — parse it in Python directly):
- The Android btsnoop format has packet flags: `type & 0x0F` = packet type (0=CMD, 1=ACL, 3=EVENT, 0x11=ACL recv), `flags & 0x10` = direction.
- HCI Commands (type 0) with opcodes like `0x4002`/`0x4202` are often where the device-specific
  vendor commands live — the payload bytes you want are embedded inside the command, not in
  ACL data.
- ACL data (type 1 sent, 0x11 received) carries standard ATT writes. Filter for `CID == 4`
  (ATT) and look for opcodes `0x52` (Write Request) or `0x12` (Write Command).
- Don't try to find the LED payload at the start of the data — for LEDDMX-03 it's always
  in a 3-byte field at `data[11:14]` for opcode `0x4202` (preceded by the HCI write header
  `02 42 00 10 00 0c 00 04 00 52 1a 00`).

**Correlate bytes with actions:** The byte sequence during ON, color changes, effect
selection should have 1–3 bytes that change between actions. Group the writes by
timestamp and look for byte changes that align with the user's actions.

### Method 2: APK Decompile (if protocol is encrypted/obfuscated)

1. Download APK from APKMirror or extract from phone
2. Open in jadx (`jadx-gui app.apk`)
3. Search for `BluetoothGattCharacteristic`, `writeCharacteristic`, `setValue`
4. Look for `setRGB`, `sendCommand`, `writeCmd` methods
5. If AES encrypted, look for key in native libs (`.so` files) via `ida free` or `ghidra`

**Faster alternative for protocol extraction: `baksmali` directly.** For apps in the
50+ MB range, `jadx` decompiles slowly and `androguard.get_source()` can hit a 5-min
timeout per class. `baksmali` is fast and the protocol bytes are in cleartext `.array-data`
literals in the smali. From the server:

```bash
# 1. Pull APK (no root needed — shell can read /data/app/.../base.apk)
APK=$(adb -s <phone_ip:port> shell pm path com.ledlamp | sed 's/package://' | tr -d '\r')
adb -s <phone_ip:port> pull "$APK" /tmp/app.apk

# 2. Get baksmali 2.5.2 (the bitbucket URL works; github releases redirects fail)
curl -sL "https://bitbucket.org/JesusFreke/smali/downloads/baksmali-2.5.2.jar" -o /tmp/baksmali.jar

# 3. Disassemble
cd /tmp && unzip -o app.apk "classes*.dex" -d app/
java -jar /tmp/baksmali.jar d app/classes.dex -o app/smali
java -jar /tmp/baksmali.jar d app/classes2.dex -o app/smali2

# 4. Find the protocol class (com.ledlamp uses com.home.net.NetConnectBle)
find app/smali* -name "NetConnectBle.smali" -o -name "*Command*.smali" | head

# 5. Dump every protocol array (the int[] literals sent to BLE)
grep -B 1 -A 12 'fill-array-data' app/smali/com/home/net/NetConnectBle.smali
```

Each `:array_xxx` block looks like:
```
:array_d4
.array-data 4
    0x7b
    0x4
    0x4
    0x1
    0xff
    0xff
    0xff
    0xff
    0xff
    0xbf
.end array-data
```

That `0x7B 0x04 0x04 0x01 0xFF 0xFF 0xFF 0xFF 0xFF 0xBF` is the literal bytes for that
command. The `:array_xxx` label is referenced from `filled-new-array/range {v5 .. v13}`
opcodes in the calling function. To map array → action, look at the function name
preceding the call (e.g. `turnOn`, `setRgb`, `setBrightness`, `setMode`).

**To get the function name → array mapping**, the smali flow is:
- `:cond_xx` labels mark branch targets (one per `if/else` arm in the original Java)
- The function name appears in the original Java's method signature — find it by
  grepping the .smali file for `^.method` lines
- The body of the function contains a chain of `if-eqz` checks against device-name
  prefixes (`"LEDDMX"`, `"LEDCAR-01-"`, etc.) and `goto`s to the right `:cond_xx`
  label, which loads the corresponding `:array_xxx` and calls `sendData([I)V`

This gets you the full protocol matrix in one read, with byte-perfect accuracy,
without ever running the original app.

### Method 3: Frida Hook (live capture)

```javascript
Java.perform(function () {
    var BTChar = Java.use('android.bluetooth.BluetoothGattCharacteristic');
    BTChar.setValue.overload('java.lang.String').implementation = function (value) {
        console.log('[BLE Write] ' + value);
        return this.setValue(value);
    };
});
```

Run: `frida -U -f com.example.app -l bt_hooks.js`

## Building the PWA

### Requirements
- Single HTML file with inline CSS/JS (simplest deployment)
- Web Bluetooth API (only works in Chromium browsers)
- HTTPS (mandatory for Web Bluetooth — Tailscale Funnel provides this)
- PWA manifest for "add to home screen"

### Key Implementation Details

**Scanning:** Use `navigator.bluetooth.requestDevice()` with `acceptAllDevices: true` and `optionalServices` listing known UUIDs.

**Writing commands:** Use `writeValueWithoutResponse()` for speed, fall back to `writeValue()` if it fails:

```javascript
async function send(char, bytes) {
    try {
        await char.writeValueWithoutResponse(new Uint8Array(bytes));
    } catch(e) {
        await char.writeValue(new Uint8Array(bytes));
    }
}
```

**Multi-protocol support:** Implement several protocol objects and auto-detect from device name. Include manual override chips.

**Color picker update rate:** Debounce slider changes to ~30ms to avoid flooding BLE:

```javascript
let colorTimeout;
slider.addEventListener('input', () => {
    updatePreview();
    clearTimeout(colorTimeout);
    colorTimeout = setTimeout(sendColor, 30);
});
```

### UI Layout (mobile-first, max-width 420px)

1. Scan view (connect button + device list + protocol chips)
2. Control view:
   - Power ON/OFF buttons (green/red)
   - Color preview rectangle
   - RGB sliders with color-coded thumbs
   - Brightness slider
   - Color presets (grid of 6-12 swatches)
   - Effects buttons (3-column grid)
   - Disconnect button
3. Toast notifications for status

## Deploy

Use the `deploy-public-site` skill for permanent deployment. Key points:
- Serve with `python3 -m http.server PORT --bind 127.0.0.1` as systemd service
- Expose via Tailscale Funnel (provides free HTTPS, critical for Web Bluetooth)
- PWA manifest must use relative paths (`start_url: "./"`, `src: "icon.png"`)

## Pitfalls

1. **Web Bluetooth requires HTTPS.** Chrome will refuse `navigator.bluetooth` on HTTP. Tailscale Funnel provides TLS automatically.
2. **Only Chrome supports Web Bluetooth.** Firefox and Safari do not. Inform the user.
3. **Device must not be connected to another app.** BLE devices only accept one connection at a time. Kill the original app first.
4. **Protocol variants are common.** Even within the same app ID, different firmware versions may use different variant bytes. Always support multiple variants and let the user switch.
5. **Some devices need a subscribe-before-write handshake.** If writes silently fail, try subscribing to the read/notify characteristic before writing.
6. **Manifest paths with Tailscale Funnel.** When served under a path prefix (e.g. `/led/`), use relative paths in the manifest (`"./"`, `"icon.png"`), not absolute (`"/"`, `"/icon.png"`) — absolute paths resolve to origin root, not the funnel prefix.
7. **The `com.ledlamp` app broadcasts devices as `LEDBLE-*` or `BLELED-*`.** These use the `0000ffe0/ffe1/ffe2` UUID family, not the `fff0/fff3/fff4` family used by ELK/MELK devices.
8. **`LEDDMX-*` devices are NOT the same as `LEDBLE-*`.** Despite sharing the same UUIDs (ffe0/ffe1/ffe2) and the same app (com.ledlamp), LEDDMX uses a completely different command set. Don't assume LEDBLE commands will work. The LEDDMX-03 also has two protocol sub-variants (7E...EF vs 7B...BF).
9. **🔴 SNIFF OR DECOMPILE — DON'T JUST GUESS.** The biggest waste of time is guessing the protocol and building a PWA that doesn't work. Two reliable sources of truth, in order of preference:
   - **APK decompilation** (fastest, definitive): pull the APK from the phone via
     `APK=$(adb -s <phone_ip:port> shell pm path com.<appid> | sed 's/package://' | tr -d '\r')`
     then `adb -s <phone_ip:port> pull "$APK" /tmp/app.apk` and `jadx -d decompiled /tmp/app.apk`.
     Search the decompiled Java for `sendData`, `writeCharacteristic`, `setRGB`, `setValue`.
     The `int[]{0x7B, ...}` literals are the actual bytes the app writes. This gives
     definitive answers in one session where sniffing had been ambiguous.
     **If the app is already installed, `pm path` is much faster than APKMirror/APKPure** —
     it gives you the exact on-device path with zero scraping drama.
   - **HCI snoop log** (slower, noisier, but works without app source): enable Bluetooth
     HCI snoop on the phone, use the app, pull the bugreport. See Method 1 below for
     the exact commands.
   - **Frustrated user signal**: nachlakes said "para un minuto... mañana me das comandos
     específicos para yo seguir en serie así vos no tenes que andar adivinando". When a
     user pushes back on ad-hoc guessing, **switch to APK decompilation immediately** —
     it's the path that turns the user's "make me a list to test" into "I already know
     all the answers". The btsnoop path is great in theory but produces noisy 3-byte
     fragments that look like the protocol when they're actually keepalive — only
     decompiled source disambiguates this.
   - **Per-model branch tree pattern**: In `com.home.net.NetConnectBle.java`, the
     `turnOn(String str)`, `turnOff(String str)`, `setRgb(...)` methods all start with
     a cascade of `if/else if` branches keyed off the device-name prefix
     (`str.contains("LEDCAR-01-")`, `str.contains("LEDDMX")`, `str.equalsIgnoreCase("LEDSMART")`,
     etc). **One function = one switch statement = one set of all model-specific command
     bytes**. You don't need to hunt for separate per-model classes. The function
     signature + body tells you the full command matrix in one read.
   - **The `sendCharacteristic(byte[])` choke point**: every command in
     `com.home.net.NetConnectBle` goes through one `sendCharacteristic(byte[] bArr)` that
     resolves FFE0/FFE1 and writes. So `grep "sendData(new int\["` finds every command
     the app can send, and you only need one choke point to understand the write path.
10. **🔴 The btsnoop "3-byte format" is usually keepalive noise, not the protocol.** In one LEDDMX-03 capture, the HCI logs showed mostly `00 7B FF` (a heartbeat/keepalive the app sends every ~1-2s while foregrounded). These look like the protocol but aren't — the actual control commands (ON, OFF, color, brightness) are 9-byte writes that rarely appear in the capture relative to keepalive. **If you see one byte pattern repeating constantly, suspect keepalive, not protocol.** Cross-reference with APK decompilation before committing to a "3-byte protocol" interpretation.
11. **Addressable strips (SETPIX > 1) need per-pixel protocol.** If the app shows a "SETPIX" setting or the device has individually-controllable LEDs, the standard color command only sets all pixels to one color. For LEDDMX-03, per-pixel control uses **9-byte writes** `[0x7B, pos, 0x07, R, G, B, 0, 0xFF, 0xBF]` from the `setCar02Rgb` function in NetConnectBle.java — same 9-byte shape, position goes in byte 1. Slow over BLE (~5-10s for 200 pixels). For smooth snake animations, only update the ~6 pixels at stripe boundaries per frame, not the whole strip.
12. **🔴 CORRECTION: the 9-byte LEDDMX format IS correct for LEDDMX-03.** Earlier pitfall #11 said the 9-byte format from `user154lt/LEDDMX-00` and HA issue #105338 didn't work on LEDDMX-03. **This was wrong.** The 9-byte format `7B FF 04 01 FF FF FF FF BF` (power on), `7B 04 04 00 FF FF FF FF BF` (off), `7B FF 07 R G B FF FF BF` (color), `7B FF 01 (b*32/100) b 01 FF FF BF` (brightness) — all of these are correct, confirmed by reading the actual `com.home.net.NetConnectBle.sendData()` calls in the decompiled APK. The reason earlier PWA tests seemed to fail was a bug in the manual spec (wrong byte positions), not a protocol mismatch. **Trust the decompiled source over btsnoop interpretation when they conflict.**
13. **`adb bugreport` from PC is unreliable on Android 14+.** It frequently fails with "Failed to connect to dumpstatez service: Connection refusal". Use `adb shell bugreportz` (runs on device) and then `adb pull` the resulting zip from `/data/user_de/0/com.android.shell/files/bugreports/`. The first attempt may not include your fresh data if HCI snoop wasn't on during a recent BT toggle cycle — restart Bluetooth to force a fresh log rotation.
14. **For Android 14+ phones on Tailscale, the user has to enable wireless ADB debugging on the phone first.** They show the IP/port in Developer Options → Wireless debugging. The agent then `adb connect <ip>:<port>` over Tailscale. Connection is **not** persistent — if the phone sleeps, toggles WiFi, or the user disables wireless debugging, the agent loses access and must ask the user to re-enable. Don't try to maintain long-running background tasks over this connection.
   - **Wireless debugging port can rotate.** `tailscale status | grep <phone>` may show a different port than what's in your long-term memory (e.g. memory had `100.111.239.9:36353`, but the port changed to `:40487`). Always check `adb devices` first; the active port is the one that returned `device`, not what's in MEMORY.md.
15. **Use `pm path` + `adb pull` for installed APKs.** When the user already has the app
    installed and ADB-over-Tailscale is working, the fastest APK acquisition is
    `APK=$(adb shell pm path com.ledlamp | sed 's/package://' | tr -d '\r')` then
    `adb pull "$APK" /tmp/app.apk`. This avoids APKMirror/APKPure scraping entirely
    (they use dynamic download endpoints, JS-rendered links, and sometimes 403 you).
    The path the user is told at `Developer Options → Bug Report` is the same one —
    works for any installed package, including user apps, not just system ones.
16. **Don't trust your last patch.** After a wrong protocol patch fails, **immediately
    re-decompile the source** rather than tweaking bytes. The user reported the LED still
    didn't turn on after the v1.1.0 update — the decompiled `turnOn` function has
    per-model branches and the LEDDMX-03 branch sends different bytes than the
    LEDCAR-01 branch. The "obvious" fix (e.g. swap byte 1 from `0xFF` to `0x04`) is
    correct only if you read the actual `if/else` chain — guessing leads to several
    more rounds of "no prende". When the user reports "no prende" after a fix,
    default to: re-decompile → re-read the function → confirm the literal `int[]` value
    → compare to the device-name branch condition.
17. **🔴 HCI snoop capture from ADB shell is BLOCKED on most modern Pixels.** On a
    Pixel 9a (Android 16, root user) we hit: `btmon: inaccessible or not found`,
    `hcidump: not found`, no `btmon`/`hcidump` in `/system/bin`, `setprop
    persist.bluetooth.btsnoopenable` rejected ("see dmesg"), and `/data/misc/bluetooth/`
    permission-denied to shell. The snoop is enabled (`INIT_gd_hal_snoop_logger_socket=true`)
    but it streams to a system socket, not a file we can read. **The reliable path
    is the user enabling the btsnoop themselves and pulling the log via bugreport
    or by turning it off and pulling `/data/misc/bluetooth/logs/btsnoop_hci.log`
    with adb root.** For Pixel 9a this requires either (a) the user toggling
    Developer Options → "Enable Bluetooth HCI snoop log" + run a `bugreportz` zip
    + we `adb pull` it, or (b) the user extracting the log file directly via
    `adb pull /data/misc/bluetooth/logs/btsnoop_hci.log` after re-enabling root
    in Developer Options. **Don't waste time running btmon/hcidump on the device
    shell** — they don't ship on AOSP 14+ user builds. If you need the actual
    HCI bytes, decompile the APK and trust `sendData(new int[]{...})` literals
    over your own interpretation of a noisy btsnoop.
18. **Wired the user into the loop — they see what you see.** When the protocol is
    uncertain and the BLE writes aren't producing visible action, a productive
    move is to hand control to the user: "do this action sequence in the original
    app, I monitor from here." Even if the agent-side capture fails, the user
    watching the device gives you an immediate signal of which command is the
    real protocol. The user in this session volunteered to do exactly that
    ("Yo estoy listo decime cuando empiezo snoopy dog") — embrace the
    collaboration, don't try to capture every byte yourself.

19. **🔴 When a single-variant PWA fails, build a native APK that broadcasts
    ALL known variants per command, not tweak the PWA further.** For the LEDDMX-03
    family the published specs split across at least 4 different 9-byte shapes
    (Variant A `7E...EF`, Variant B `7B...BF`, LEDBLE `7E 00...`, vendor
    `0x7B 0x04 0x04...0xBF`). Tweaking the PWA to "try the next variant" is
    slow and doesn't compose — every action needs a different byte layout. A
    native APK with one `writeBytes()` per variant gives you a single tap that
    tries all 4 in a row, with on-screen logging of exactly what went out. This
    is faster to build (plain `gradle assembleDebug`), installs cleanly with
    `adb install`, and gives the user a tactile button to mash while they
    watch the device. Build it directly from the server — see
    `references/native-fallback-apk.md` for the working template (single
    `MainActivity.java`, ~250 lines, no Android Studio needed). Run it with
    `adb shell am start -n com.igled.led/.MainActivity` after
    `pm grant <pkg> android.permission.BLUETOOTH_CONNECT BLUETOOTH_SCAN
    ACCESS_FINE_LOCATION` so the user doesn't get a permission dialog.

20. **🔴 When the user says "vos no podés hacer X desde adb?", the answer is
    usually YES for actions on their phone.** If the user has to touch the
    screen to do something that adb can do, push back on yourself first:
    - "Activate btsnoop" → can be done via `setprop` (sometimes works) or by
      instructing, but the user has the toggle in Developer Options
    - "Grant permissions" → ALWAYS `pm grant <pkg> android.permission.X` from
      shell, never make the user tap "Allow"
    - "Install APK" → ALWAYS `adb install`, never sideload
    - "Open the app" → ALWAYS `am start -n <pkg>/.MainActivity`
    - "Tap a button in the app" → harder, but for our own PWA/APK we can drive
      it via `input tap X Y` after dumping the layout with `uiautomator dump`
    The user in this session ("Por que mierda si tenes acceso total vía adb no
    haces estas cosas vos? La puta que lo parió") was right. The deferral
    pattern ("you do X, I'll do Y") is a defensive habit that wastes the
    user's time when ADB is available. Default to "I'll do it from here"
    and only ask the user to do things adb genuinely cannot do.

## Scripts

- `scripts/decompile-app.sh <package>` — pulls the APK from the connected device,
  installs jadx if missing, decompiles, and dumps every `sendData(new int[]{...})`
  call site with its enclosing function name and the bytes in hex. Use this as the
  first step when reverse-engineering an unknown BLE app — it gets you to the protocol
  matrix in ~30 seconds.
- `scripts/decompile-baksmali.sh <package>` — same goal but uses `baksmali` directly
  on the DEX. ~5x faster than jadx on APKs > 30MB; ideal when `decompile-app.sh`
  hits timeouts on obfuscated/large APKs. See the "Faster alternative" section
  under Method 2 in this SKILL.md for the underlying workflow.
