An unofficial React Native companion app for the Harley-Davidson X440 (India), built to replace the stock "H-D Connect" app with a better turn-by-turn navigation experience piped to the bike's instrument cluster.
Runs on iOS and Android from a single React Native codebase.
The stock H-D Connect app uses Mappls (MapmyIndia) for navigation. The turn-by-turn quality is poor. The rest of the app is fine (calls, music, telemetry, theft alerts), but the nav is the piece that matters most on a bike, and it's the piece that's worst.
The X440 has two Bluetooth LE peers:
- Cluster — the TFT instrument display. Receives navigation arrows, call info, music info from the phone.
- TCU dongle — a telematics tracker under the seat. Sends telemetry (fuel, battery, odometer, alerts) and receives remote commands.
These are independent BLE devices. The cluster's protocol is fully documented in this README (we reverse-engineered it). Our app talks directly to the cluster with the same protocol the stock app uses, but drives it with a proper turn-by-turn engine (Mapbox Directions).
In scope (v1):
- Cluster: turn-by-turn nav packets over BLE at 800 ms cadence
- Cluster: incoming call / missed call / SMS notifications
- Cluster: music info (song, album, play/pause, volume)
- Phone screen: live map with route, next maneuver, ETA
- Cross-platform: iOS + Android from one codebase
Out of scope (v1):
- Dongle read (telemetry HUD) — planned for v1.5
- Dongle commands (seat lock, immobilize, buzzer) — planned for v2 if we ever need it
- Dongle FOTA — never, that's a good way to brick a bike
- Anything that requires the Hero/Mappls backend (cloud sync, multi-vehicle, remote diagnostics)
Non-goals:
- Distributing the app publicly. This runs on your bike, sideloaded. See Legal below.
- Being feature-parity with H-D Connect. If you need theft alerts, keep H-D Connect installed alongside — it will still work.
- Harley-Davidson X440 (India-only, built by Hero MotoCorp for H-D India).
- Cluster firmware revisions we've verified against: TBD after first physical test.
- We do NOT support: US/EU H-D bikes (those use Harley's own H-D App with completely different hardware), the Hero Karizma XMR (different Mappls-based app with different cluster protocol — probably similar but untested), any other Hero X-line bikes.
┌────────────────────────────────────────────────────────────────┐
│ React Native app │
│ ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
│ │ Map / Nav UI │ │ Cluster feeder │ │ Telephony / │ │
│ │ (Mapbox) │──│ (800 ms tick) │ │ Music bridge │ │
│ └────────┬───────┘ └────────┬─────────┘ └────────┬───────┘ │
│ │ │ │ │
│ └───────────────────┴─────────────────────┘ │
│ │ │
│ ┌────────┴─────────┐ │
│ │ BLE service │ │
│ │ (ble-plx) │ │
│ └────────┬─────────┘ │
└──────────────────────────────┼─────────────────────────────────┘
│
▼
┌─────────────────────┐
│ X440 Cluster │
│ (Telit BlueMod │
│ or HMCL BLE UART) │
└─────────────────────┘
| Layer | Choice | Why |
|---|---|---|
| RN runtime | React Native 0.76+ (New Architecture on) | Fabric + TurboModules; native BLE is smoother |
| Language | TypeScript, strict | Non-negotiable for BLE protocol code |
| Maps | @rnmapbox/maps |
Mapbox tiles, well-maintained, both platforms |
| Routing | Mapbox Directions API (HTTP) | Returns banner_instructions + voice_instructions we can encode straight into the cluster wire format |
| Turn detection | In-app step engine using @turf/turf |
Full control over maneuver detection (we don't need Mapbox Nav SDK) |
| BLE | react-native-ble-plx |
The only serious cross-platform BLE lib for RN; supported by dotintent |
| State | Zustand | Small, no boilerplate, fine for this app's size |
| Background | react-native-background-actions (Android) + iOS CoreBluetooth background mode |
Keep BLE alive while phone is locked in a tank bag |
The native Mapbox Navigation SDKs (iOS + Android) exist and there are RN wrappers, but:
- The wrappers are third-party, sparsely maintained, and lag SDK releases badly.
- We don't actually need on-device navigation UX — the cluster is our UI. We only need: current step, distance to next maneuver, ETA. All of that falls out of a Directions API response + phone GPS + a few lines of turf.
- Rolling our own step engine also lets us map Mapbox maneuver types to cluster arrow letters cleanly, in TS, in one file.
If someone later wants a fancier phone-screen nav view, we can bolt the Navigation SDK on. For v1 we don't need it.
The cluster ships in two hardware revisions with different UUIDs. The app must handle both. The stock app picks based on a serviceType string stored during pairing; we'll auto-detect by probing.
"Telit" stack (legacy, Telit BlueMod BLE UART with credit flow):
Service 0000FEFB-0000-1000-8000-00805F9B34FB
RX (write) 00000001-0000-1000-8000-008025000000 phone → cluster data
TX (notify) 00000002-0000-1000-8000-008025000000 cluster → phone data
RX Credits 00000003-0000-1000-8000-008025000000 phone → cluster flow credits
TX Credits 00000004-0000-1000-8000-008025000000 cluster → phone flow credits
"HMCL" stack (newer clusters, custom UUIDs, same credit-flow protocol):
RX (write) 64AECB40-849A-44F1-934F-ADDC4B316423
TX (notify) B792A4BB-DB87-436A-9066-DB63C5FB3F00
RX Credits F535DD6E-7975-4ABD-9719-491E38A81179
TX Credits 36E8614B-2DED-45C1-9AD3-C9F59A01F21E
Auto-detect strategy: after discoverServices, enumerate characteristics; whichever set is present, use that one.
- The cluster advertises with a device name whose first 5 chars uniquely identify the bike (stored during initial setup).
- Standard Android bonding is required. On iOS, CoreBluetooth handles pairing automatically when you first read/write an authenticated characteristic.
- No PIN prompt during normal use — the bond happens once at first pair.
This is Telit's TIO ("Terminal I/O") credit-flow protocol. Same on both hardware revisions.
INITIAL
↓ connect + bond
GATT_DISCOVER_SERVICES
↓ writeDescriptor(TX_Credits.CCCD, ENABLE_INDICATION)
UART_CREDITS_TX_INDICATION
↓ descriptor write confirmed
UART_DATA_TX_NOTIFY
↓ writeDescriptor(TX_UART.CCCD, ENABLE_NOTIFICATION)
UART_CREDITS_TX_INDICATION_RECEIVED
↓ first TX_Credits indication received from cluster
UART_INITIAL_CREDITS
↓ writeCharacteristic(RX_Credits, uint8 = 10)
UART_CONNECTED
↓ ready — send/receive UART data
Credit accounting: cluster grants us N send-credits via TX_Credits indications; we grant cluster N receive-credits via RX_Credits writes. Initial value is 10. In practice, replenish whenever your local counter hits 1.
Every packet is text, wrapped in newlines, encoded as CP-1252 (Windows-1252):
\n <typeId> <fields...> \n
Fields are fixed-width ASCII, right-padded with ~ when shorter than expected.
| Type ID | Purpose | Format after \n<id> |
|---|---|---|
1 |
Turn-by-turn nav | see nav table below |
2 |
Incoming call basic | <number> |
3 |
Battery / network / user | <battBucket 1><network 1><userName 17>\n |
5 |
Missed calls | <missedCount 2> |
6 |
SMS count | 0<smsCount 2> |
7 |
Call state | <name1 17><state 1><name2 17>0<time><vol> — state: 1 ringing, 2 active, 4 outgoing |
9 |
Music info | <song 19><album 19>0<n1k><playState 1><durationChar 1><volumeChar 1> |
Battery bucket for type 3:
| % | Byte |
|---|---|
| 0–19 | 0 |
| 20–39 | 1 |
| 40–59 | 2 |
| 60–79 | 3 |
| 80+ | 4 |
Total format:
\n 1 <binaryDir><distance><unit><etaHour><meridian><etaMinute><demeridian>
<roundabout><noSignalPacket><remainDistance><remainUnit><remainTime>
<nextBinaryDir><secondRoundabout><roadName> \n
| Field | Len | Encoding |
|---|---|---|
binaryDir |
1 | Arrow letter (see table below) |
distance |
5 | Distance to next maneuver, zero-padded (00250) |
unit |
1 | k = km, 1 = meters |
etaHour |
2 | ETA hour (12-hour clock), zero-padded |
meridian |
1 | A or P (lowercase if app_type == 191, whatever that means — we always use uppercase) |
etaMinute |
2 | ETA minute, zero-padded |
demeridian |
1 | Second A/P (redundant with meridian, keep them equal) |
roundabout |
2 | Roundabout exit number, %02d |
noSignalPacket |
1 | 1 when GPS lost, else 0 |
remainDistance |
5 | Total remaining trip distance |
remainUnit |
1 | k or 1 |
remainTime |
~6 | e.g. 01H23M, or ~~~~~~ if unknown |
nextBinaryDir |
1 | Arrow letter for the maneuver after next |
secondRoundabout |
2 | %02d |
roadName |
var | Current road name; cap to ~20 chars to be safe with MTU |
Cadence: send every 800 ms while navigating.
Special-case packets (still type 1):
- Trip completed:
binaryDir = "H",distance = "00000",unit = "1". - Trip exit / cancel:
binaryDir = "G".
Mappls encodes maneuvers as integer IDs. Mapbox encodes them as {type, modifier} string pairs. Both map to the same single-ASCII-letter alphabet the cluster expects. Here's the joint table:
| Mappls ID | Letter | Mapbox equivalent | Meaning |
|---|---|---|---|
| 0, 9, 11, 13, 22, 23 | I |
continue, straight |
Continue straight |
| 1, 17 | E |
turn/slight left |
Slight left |
| 2 | C |
turn/left |
Left |
| 3, 10, 12, 14, 24, 25 | J |
turn/sharp left or roundabout enter |
Sharp left / roundabout |
| 4, 18 | F |
turn/slight right |
Slight right |
| 5 | D |
turn/right |
Right |
| 6 | O |
turn/uturn |
U-turn |
| 8 | H |
arrive |
Arrived at destination |
| 15, 19 | Z |
depart |
Trip start |
| 16, 20 | X |
notification |
Notification/warning |
| 26–30 | G |
— | Default fallback |
| 41 | P |
on ramp |
On-ramp |
| 42, 44 | K |
off ramp |
Off-ramp |
| 43 | L |
fork |
Fork |
| 51, 52, 53 | B |
merge |
Merge |
"Ferry Train" |
T |
— | Ferry |
| anything else | G |
— | Unknown fallback |
Full mapping table lives in src/cluster/arrows.ts in the code.
Cluster sends single-character commands on the TX_UART notify characteristic, wrapped in newlines: \n<cmdChar> or \n<cmdChar>\n<byte>.
| Char | ASCII | Action |
|---|---|---|
A |
65 | Music volume up |
F |
70 | Music volume down |
7 |
55 | Call volume up |
< |
60 | Call volume down |
2 |
50 | Play / pause toggle |
( |
40 | Skip to next track |
- |
45 | Skip to previous track |
At byte offset 3 (i.e. the byte after the command char), the cluster also sends:
| Value | Action |
|---|---|
10 (decimal) |
Accept incoming call |
15 |
End current call |
35 |
Media key: play/pause (equivalent to headset hook) |
We're not shipping dongle integration in v1, but here's what we know for later.
Advertised with service UUID 0010676E-6972-6565-6E69-676E4543544F (OTC Engineering IceSDK).
All reads/writes are wrapped in a custom 8-byte header + optional payload + checksum. Frames are queued at 75 ms intervals.
- Byte 0: opcode —
1int-read,2ext-read,3int-write,4ext-write - Bytes 1–4: 32-bit little-endian target address (e.g.
0x2AC4for SN) - Bytes 5–6: length
- Byte 7: checksum
- AES-128-ECB, no padding, no IV, no MAC.
- Static key hardcoded in the APK:
abcdefghijklmnop(16 ASCII bytes). - Only characteristics with address
< 0x0800are encrypted; everything else is plaintext. - Same key on every X440 dongle worldwide. Real access control is enforced server-side by Hero's backend, not on the dongle.
Not real authentication. The SMCERT characteristic (0x2042, 509 bytes) accepts a client "cert" blob, but since crypto is symmetric with a static key, there's no meaningful challenge/response. The REGPROC_W/REGPROC_R (0x2000/0x2AB4) and autenticate01/autenticate02 (0x81A/0x82A) chars round out the registration handshake but similarly aren't cryptographically meaningful.
Subscribe to notifications on CARDATA (0x287C). Decode bytes per the CSV bit layout in assets/bleCharacteristics.csv (varies by dongle type: OTC, Hero, OTC_v2, Hero_v2 — read the type byte first).
Fields include: Odometer, Fuel, Battery, GearShiftPattern, DrivingTime, SideStand, RollOver, EngineTempStatus, SeatLockStatus, ImmoStatus, Ignition, TheftAlert, FuelTheftAlert, BatteryRemovalAlert, SpeedingAlert, AccidentAlert, FalldownAlert, PanicAlert, ThrottleOpening, McuFwVer, Ch9Checksum.
seatLock = 1
seatUnlock = 2
demobilize = 4
immobilize = 8
close = 16
open = 32
demobilizeBle = 64
buzzer = 128
We are not going to build this in v1. It's easy to do, but the consequences of a bug are that you brick your bike or set off the alarm at 3 AM. Skip.
The stock app talks to these. Our app doesn't need any of them for v1 (we only need Mapbox for maps + directions). Listed here for future reference.
harleyapp.heromotocorp.com/api/ Main Hero API
prod-apim.heromotocorp.com/hdoneapp/api/ Prod APIM
hmcl-funapp-harley-ci-01t.azurewebsites.net/api/ Azure Functions backend
outpost.mappls.com/api/security/oauth/token Mappls OAuth
anchor.mappls.com/api/capsule/projects/devices Mappls IoT device binding
apis.mappls.com/advancedmaps/vapi/ Mappls routing
A live WebSocket for real-time location sharing exists in the stock app (LocationWebSocketClient), URL composed at runtime from Mappls Anchor.
Confirm the reverse-engineered protocol matches the real cluster on the actual bike before committing to the design.
- Install stock H-D Connect app on a spare Android phone.
- Enable "Bluetooth HCI snoop log" in Android developer options.
- Pair with the bike, run a short navigation session, receive a call, play music.
- Pull
/sdcard/btsnoop_hci.log. - Open in Wireshark, filter by the cluster's MAC.
- Cross-check every write against the wire format above. Note any deviations (packet lengths, unknown fields, ordering).
- Update the tables in this README if reality differs from decompiled expectations.
Exit criterion: we can predict every byte of a nav packet on paper before it's sent.
Minimum: an app that connects, handshakes, and sends one hardcoded nav packet.
-
expo initornpx react-native@latest init— plain RN (not Expo, we need to configure native BLE background modes). - Add
react-native-ble-plx, wire up iOSInfo.plist(NSBluetoothAlwaysUsageDescription, background modes:bluetooth-central) and AndroidAndroidManifest.xmlpermissions +FOREGROUND_SERVICE_CONNECTED_DEVICE. - Implement
src/cluster/scan.ts: scan for peripherals whose advertised name matches a stored prefix. StoredeviceId(peripheral UUID on iOS, MAC on Android). - Implement
src/cluster/handshake.ts: full Telit TIO state machine. Detect Telit vs HMCL UUIDs. EmitUART_CONNECTEDevent on success. - Implement
src/cluster/credits.ts: credit-flow counter, refill on threshold. - Implement
src/cluster/wire.ts:NavPacket,CallPacket,MusicPacket,BatteryPacketencoders. Fixed-width padding with~. CP-1252 encoding (useTextEncoderpolyfill oriconv-lite— CP-1252 is a superset of ASCII for our purposes so plain latin1 works too). - Smoke test: hardcode a nav packet with
binaryDir = "I",distance = "00500",unit = "m",remainTime = "01H30M",roadName = "Test Road". Send it. Confirm the cluster displays the arrow.
Exit criterion: cluster displays a static "turn straight in 500 m, Test Road" screen driven by our app.
- Mapbox account, access token in
.env(do NOT commit). - Set up
@rnmapbox/mapswith a base street style. -
src/nav/directions.ts: fetch a route from Mapbox Directions API withsteps=true, banner_instructions=true, overview=full. -
src/nav/step-engine.ts: track user position (react-native-geolocation-serviceor Expo Location). Determine current step. Compute distance-to-next-maneuver and ETA using turf. -
src/cluster/arrows.ts: Mapbox maneuver{type, modifier}→ cluster arrow letter table. -
src/cluster/feeder.ts: 800 mssetIntervalthat pulls current nav state, encodes aNavPacket, sends via cluster link.
Exit criterion: enter a destination in the app, click "Start", ride around the block, cluster shows real turn-by-turn arrows and updating distances.
- Telephony:
react-native-call-detection(Android) + CallKit observer (iOS). Encode\n7and\n5packets. - Music: Android
MediaSessionManager(needsMEDIA_CONTENT_CONTROLpermission which is a system permission — practical fallback is to require the user to install our app as an accessibility service; alternatively usereact-native-music-control). iOS usesMPNowPlayingInfoCenter+MPRemoteCommandCenter. - Battery:
react-native-device-infofor battery %; encode\n3packet. - Cluster→phone commands: parse the notify byte stream, map to media/call actions.
Exit criterion: incoming call shows caller name on cluster; music track name shows on cluster; volume buttons on cluster's joystick change phone volume.
- Android: foreground service with a persistent notification ("H-D nav connected"). Handles the BLE + GPS in the background.
- iOS:
bluetooth-centralbackground mode.Info.plistbackground modes. State preservation and restoration for CoreBluetooth (critical or the OS kills the connection after a few minutes). - Battery test: 2-hour ride, screen off, phone in tank bag. Cluster nav should not drop.
Exit criterion: nav survives phone screen lock and app backgrounding for a full ride.
- Dark mode map style for night rides.
- Voice guidance through paired helmet headset.
- Route preview screen with turn list.
- Saved destinations, quick-start "Home" / "Work".
- Android: BLE devices are identified by MAC address (persistent).
- iOS: BLE devices are identified by CoreBluetooth-assigned
peripheralUUID(persistent per app-installation, but different between installs and between phones for the same peripheral). - Store whichever
device.idreact-native-ble-plxgives you. Do not try to normalize to MAC.
- Android: we explicitly trigger bonding (
device.connect({ autoConnect: false })then observeBondState.BONDED). - iOS: bonding happens automatically the first time we access an authenticated characteristic. The OS shows the pairing prompt; the app has no control.
- Android: foreground service is mandatory for anything longer than ~30 s of continuous BLE work. Notification is user-visible.
FOREGROUND_SERVICE_CONNECTED_DEVICEpermission required on API 34+. - iOS: enable
bluetooth-centralinInfo.plistbackground modes. Implement CBCentralManager state preservation and restoration. When the app is suspended, the OS keeps the BLE session alive; when a notification arrives, the OS wakes the app briefly. Do NOT rely on JS timers in the background — the JS engine is suspended. All BLE responses must be handled from the native module callback, whichreact-native-ble-plxdoes correctly.
- CP-1252 = Windows-1252. For ASCII-range characters (letters, digits,
~) it's byte-identical to Latin-1, which is whatBuffer.from(str, 'latin1')gives you. If you ever need to send an accented character inroadName, useiconv-litewith thewindows-1252codec.
- The 800 ms nav ticker uses
setInterval. On iOS in the background, this stops. That's fine: the background BLE path is driven by GPS updates from CoreLocation (which do fire in the background), not by a timer.
- No emulators. BLE on emulators is either fake or nonexistent. Physical devices only.
- Two phones during development: one to reset and re-pair the cluster, one running the stock H-D Connect app in HCI-snoop mode for reference.
- The bike does not need to be running to test cluster BLE — turning the key to ACC/ON powers the cluster BT radio.
- Have a spare charged bike battery. Turning ignition on and off repeatedly to reset cluster state is normal during protocol work.
harley/
├── README.md This file
├── CLAUDE.md Guide for Claude Code sessions in this repo
├── docs/
│ ├── cluster-protocol.md Deeper protocol notes as they're discovered
│ └── snoop-notes.md Wireshark analysis from Phase 0
├── app/
│ ├── src/
│ │ ├── cluster/ BLE, handshake, wire format
│ │ ├── nav/ Directions, step engine
│ │ ├── screens/ React screens
│ │ ├── stores/ Zustand stores
│ │ └── util/
│ ├── ios/
│ ├── android/
│ ├── package.json
│ └── tsconfig.json
└── research/
├── decompiled/ Symlink to /tmp/harley_decompiled (not committed)
└── assets/ Copies of the app's BLE CSVs
- Personal use, own bike, own hardware. Reverse engineering for interoperability is protected in most jurisdictions but not all — check yours if you're not the person this was written for.
- Do NOT publish this repo publicly. Do NOT publish the AES key, the wire protocol, or the app. Hero MotoCorp and OTC Engineering will send lawyers; the AES key finding in particular is a real security issue that should go through responsible disclosure, not GitHub.
- Do NOT distribute compiled binaries.
- The stock H-D Connect app can continue to run alongside this one for features we don't cover (theft alerts, share live location, etc.). Cluster BLE is single-consumer though, so you can only be paired with one client at a time — expect to toggle.
- Cluster and dongle are two different BT devices. Don't confuse them.
- The nav packet loop runs at 800 ms, not 1 Hz. Cadence matters — the cluster expects it.
~is the padding character. Not space, not null.- CP-1252, not UTF-8. This will bite you the first time someone rides through a road named "Kärlek Väg".
- On iOS,
device.idis not a MAC. Never expose it in UI as a MAC. - Credit flow: if you stop replenishing credits, the cluster stops accepting writes. Silent failure. Log credit state during development.
- FOTA is a one-way trip. Do not touch it.