Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 

Repository files navigation

harley

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.


Why this exists

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:

  1. Cluster — the TFT instrument display. Receives navigation arrows, call info, music info from the phone.
  2. 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).


Scope

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.

Target bike

  • 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.

Architecture

┌────────────────────────────────────────────────────────────────┐
│                      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) │
                     └─────────────────────┘

Tech stack

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

Why not Mapbox Navigation SDK

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.


Cluster BLE protocol (reference)

GATT services

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.

Discovery & pairing

  • 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.

Handshake state machine

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.

Wire format (phone → cluster)

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

Nav packet (type 1) — the important one

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".

Arrow letter table

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.

Wire format (cluster → phone)

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)

Dongle BLE protocol (reference)

We're not shipping dongle integration in v1, but here's what we know for later.

Discovery

Advertised with service UUID 0010676E-6972-6565-6E69-676E4543544F (OTC Engineering IceSDK).

Transport ("VarEx")

All reads/writes are wrapped in a custom 8-byte header + optional payload + checksum. Frames are queued at 75 ms intervals.

  • Byte 0: opcode — 1 int-read, 2 ext-read, 3 int-write, 4 ext-write
  • Bytes 1–4: 32-bit little-endian target address (e.g. 0x2AC4 for SN)
  • Bytes 5–6: length
  • Byte 7: checksum

Encryption

  • AES-128-ECB, no padding, no IV, no MAC.
  • Static key hardcoded in the APK: abcdefghijklmnop (16 ASCII bytes).
  • Only characteristics with address < 0x0800 are 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.

"Authentication" (SMCERT)

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.

Telemetry read (easy, no auth needed)

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.

Commands (write to phoneControl 0x2274, single byte)

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.


Backend surface (informational)

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.


Implementation plan

Phase 0 — Physical validation (before writing code)

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.

Phase 1 — Cluster link (bare metal)

Minimum: an app that connects, handshakes, and sends one hardcoded nav packet.

  • expo init or npx react-native@latest init — plain RN (not Expo, we need to configure native BLE background modes).
  • Add react-native-ble-plx, wire up iOS Info.plist (NSBluetoothAlwaysUsageDescription, background modes: bluetooth-central) and Android AndroidManifest.xml permissions + FOREGROUND_SERVICE_CONNECTED_DEVICE.
  • Implement src/cluster/scan.ts: scan for peripherals whose advertised name matches a stored prefix. Store deviceId (peripheral UUID on iOS, MAC on Android).
  • Implement src/cluster/handshake.ts: full Telit TIO state machine. Detect Telit vs HMCL UUIDs. Emit UART_CONNECTED event on success.
  • Implement src/cluster/credits.ts: credit-flow counter, refill on threshold.
  • Implement src/cluster/wire.ts: NavPacket, CallPacket, MusicPacket, BatteryPacket encoders. Fixed-width padding with ~. CP-1252 encoding (use TextEncoder polyfill or iconv-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.

Phase 2 — Nav engine

  • Mapbox account, access token in .env (do NOT commit).
  • Set up @rnmapbox/maps with a base street style.
  • src/nav/directions.ts: fetch a route from Mapbox Directions API with steps=true, banner_instructions=true, overview=full.
  • src/nav/step-engine.ts: track user position (react-native-geolocation-service or 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 ms setInterval that pulls current nav state, encodes a NavPacket, 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.

Phase 3 — Ancillary packets

  • Telephony: react-native-call-detection (Android) + CallKit observer (iOS). Encode \n7 and \n5 packets.
  • Music: Android MediaSessionManager (needs MEDIA_CONTENT_CONTROL permission which is a system permission — practical fallback is to require the user to install our app as an accessibility service; alternatively use react-native-music-control). iOS uses MPNowPlayingInfoCenter + MPRemoteCommandCenter.
  • Battery: react-native-device-info for battery %; encode \n3 packet.
  • 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.

Phase 4 — Foreground / background

  • Android: foreground service with a persistent notification ("H-D nav connected"). Handles the BLE + GPS in the background.
  • iOS: bluetooth-central background mode. Info.plist background 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.

Phase 5 — Polish (never fully done, but stop here for v1)

  • 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".

Cross-platform notes

Identifiers

  • 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.id react-native-ble-plx gives you. Do not try to normalize to MAC.

Bonding

  • Android: we explicitly trigger bonding (device.connect({ autoConnect: false }) then observe BondState.BONDED).
  • iOS: bonding happens automatically the first time we access an authenticated characteristic. The OS shows the pairing prompt; the app has no control.

Background BLE

  • Android: foreground service is mandatory for anything longer than ~30 s of continuous BLE work. Notification is user-visible. FOREGROUND_SERVICE_CONNECTED_DEVICE permission required on API 34+.
  • iOS: enable bluetooth-central in Info.plist background 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, which react-native-ble-plx does correctly.

Charset

  • CP-1252 = Windows-1252. For ASCII-range characters (letters, digits, ~) it's byte-identical to Latin-1, which is what Buffer.from(str, 'latin1') gives you. If you ever need to send an accented character in roadName, use iconv-lite with the windows-1252 codec.

Timers

  • 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.

Testing

  • 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.

Repo layout (planned)

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

Legal

  • 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.

Non-obvious things to remember

  • 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.id is 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.

About

Unofficial React Native companion app for the Harley-Davidson X440 (India) — reverse-engineered cluster protocol + Mapbox turn-by-turn nav

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages