Status: Working draft v0.4 · Reverse-engineered from three HCI snoop captures + the official app's CSV/EEPROM session export (used as a known-plaintext key) for interoperability with owned hardware (LapWing / PerchWerks).
Device: Alfano 6 (kart lap timer / data logger), firmware shown as 1T, "Professional" mode.
Transport: Bluetooth Classic (BR/EDR) — SPP / RFCOMM. Not BLE. Web Bluetooth cannot reach this device; see §9 Implementation.
Confidence legend used throughout: ✅ Confirmed — directly observed and verified in the capture. 🟡 Inferred — strongly implied by structure/correlation, not yet proven. ❓ Unknown — needs another capture to resolve.
| Source | Android HCI snoop log (btsnoop_hci.log), BTSnoop v1, H4 (HCI UART) |
| Link | ACL handle 0x06, bonded Classic connection |
| Duration | ~307 s, full session-memory download |
| Device state | Engine OFF, no GPS lock ("No GPS Signal" on screen) — live telemetry values are idle/placeholder |
| Active profile | Dame |
| Active track | *P* SHORT OKC (Short config, Orlando Kart Center) |
A second capture ("only download last session" app setting, same engine-off state) was taken and used to validate the protocol. It performed the identical handshake and read only the 4 config pages + page 0x00F9 + one record fetch (4E26B2) — the same record ID and contents as the first capture. This confirms the page-numbering semantics, the always-first config pages, and the stability of the record ID (see §4.1 and §7.1).
Implication: All framing is mapped and now cross-validated across two sessions. All live-value semantics (RPM/speed/GPS/temps scaling and offsets) remain pending an engine-on + GPS-lock capture — but note the realistic caveat in §8: the official apps expose no live-telemetry view at all, so a continuous live stream may simply not exist over this interface.
| Parameter | Value |
|---|---|
| Protocol | RFCOMM over L2CAP (PSM 0x0003) |
| Service | Serial Port Profile (SPP) |
| SPP service UUID | 00001101-0000-1000-8000-00805F9B34FB |
| RFCOMM server channel | 6 (DLCI 12) |
| Flow control | Credit-based (CL field 0xF/0xE in PN) |
| Max frame size (N1) | 990 bytes |
| Initial credits | 7 (host→device), 2 (device→host) |
The phone performs SDP discovery (PSM 0x0001) first, then opens RFCOMM. A client using createRfcommSocketToServiceRecord(SPP_UUID) (Android) or an SDP-aware RFCOMM connect (desktop) will resolve channel 6 automatically — you do not need to hardcode the channel.
- SABM/UA on DLCI 0 (multiplexer start)
- PN (Parameter Negotiation) for DLCI 12 → credit-based FC, N1=990
- SABM/UA on DLCI 12 (open data channel)
- MSC modem-status handshake (both directions)
- Data exchange (UIH frames on DLCI 12)
- DISC/UA on teardown
Implementers using a high-level SPP socket API (Android
BluetoothSocket, Pythonpybluez, Rustbluetooth-serial-port/bluer) get all of the above for free — you read/write a byte stream and never touch RFCOMM framing directly. The credit octets, FCS, and DLCI are handled by the OS Bluetooth stack.
The application protocol rides on the SPP byte stream. There are two directions:
- TX = host → Alfano (commands)
- RX = Alfano → host (responses / records / live data)
Every command is a fixed 258-byte frame:
┌────────┬──────────────────────────┬───────────────────┬──────────┐
│ opcode │ arguments │ zero padding │ checksum │
│ 1 byte │ N bytes │ to fill 257 bytes │ 1 byte │
└────────┴──────────────────────────┴───────────────────┴──────────┘
byte 0 bytes 1 .. (256-?) ...zeros... byte 257
└──────────────────── 257 bytes covered by checksum ───────┘
checksum byte
- Total length: 258 bytes, always.
- Bytes after the arguments are zero-padded out to offset 256.
- Checksum (byte 257) = XOR of all 257 preceding bytes. ✅ Verified across 20 frames with varied payloads (20/20).
def build_command(opcode: int, args: bytes = b"") -> bytes:
body = bytes([opcode]) + args
body = body.ljust(257, b"\x00") # pad to 257
checksum = 0
for b in body:
checksum ^= b
return body + bytes([checksum]) # 258 bytes totalResponses are variable length (observed 1–514 bytes per logical message). They are delivered as the SPP byte stream; one logical response may arrive split across multiple OS read() calls (and was split across multiple RFCOMM UIH frames in the capture). Reassembly is by message structure, not by frame boundary.
Two response shapes matter:
- Fixed-magic records beginning
80 FF FE FD …— telemetry/session records (see §6). - Untagged blocks — 1-byte ack (
FF), the 16-byte live snapshot, and 129-byte summary/config blocks (see §5).
❓ The exact end-of-message delimiter for RX is not yet pinned down. In practice, drive the protocol request/response lockstep (send a command, read until the matching response is complete) rather than free-running parsing. The state machine in §7 does this.
| Opcode | Name (assigned) | Args (after opcode) | Count | Meaning |
|---|---|---|---|---|
0x2D |
INIT ✅ |
ASCII "000000" then zeros |
1 | Session init / hello. First frame sent. Device replies FF. |
0x36 |
STATUS 🟡 |
07 01 00 00 00 50 09 00 … |
1 | Config/status query. Device replies with 16-byte live snapshot. |
0x2B |
READ_PAGE ✅ |
<page:u16le> 00 80 |
75 | Read a memory/directory page. Device replies with a 129-byte block. |
0x17 |
FETCH_RECORD ✅ |
<recordID:3> 02 |
83 | Fetch the full record identified by a 3-byte ID. Device replies with an 80FFFEFD record. |
Observed page argument (u16 little-endian) progression:
0x0011, 0x000F, 0x000E, 0x0010, ← 4 config/metadata pages
0x00F9, 0x00F8, 0x00F7, … 0x00B4, ← descending sweep, ~70 session-record summaries (newest first)
0x00F8 ← (second pass / re-read begins)
✅ Interpretation (confirmed by the second capture): the first 4 pages (0x0011, 0x000F, 0x000E, 0x0010) are always read first and return device config + the driver-profile list — byte-identical in both captures. Page 0x00F9 is the newest/last session; the descending 0xF9…0xB4 sweep walks session history newest→oldest. The app's "only download last session" setting reads exactly the config pages + 0x00F9 + one fetch — that's how this was proven. Each session page returns a 129-byte summary containing the record's 3-byte ID, fetched via 0x17. The summary's leading 3 bytes are a stable fetch key for the session: 0x4E26B2 addressed the newest session in both captures despite being taken at different times. (This fetch key differs from the stamp inside the record — see §4.2.)
The 3-byte ID in the 0x17 command is the fetch key, taken from the front of the preceding 0x2B summary block.
0x2B page 0xF9 → RX summary starts 4e26b2 → TX 17 4e26b2 02 → RX 80fffefd 9828b2 …
(fetch key) (fetch key) (internal stamp ≠ key)
So treat the summary's leading 3 bytes purely as an opaque fetch handle; do not expect it to equal any field inside the record. Both values are stable for a given session across captures.
Single byte 0xFF, sent by the device immediately after INIT. Treat as "ready."
Returned after 0x36 STATUS. Example (engine off, idle):
07 ec02 ec02 c900 ec02 ec02 9324 08 4c3d
- Byte 0 =
0x07(type/length tag, 🟡). - Subsequent
u16lewords are live sensor channels. With engine off they read constant (0x02EC≈ 748 repeated). Field mapping requires engine-on capture. ❓ 0x2493(93 24LE) is a recurring device/session marker that also appears inside records (🟡 magic/serial).
⚠️ Reality check: this 16-byte block is sent once, as the response toSTATUSduring the handshake — it was not observed streaming, and the official phone/desktop apps expose no live-data view. A continuous live feed may not exist over this interface. The open experiment (see §8) is whether repeatedly polling0x36with the engine running returns changing values. If it does, this is the live channel; if not, LapWing's Alfano integration is session-download only — which is fine, since that path is fully mapped and post-session comparison is the core use case.
Returned by 0x2B READ_PAGE. Fixed length 129 bytes. Two sub-types observed:
- Config/driver pages (first few): contain ASCII driver names. Example decodes to:
Driver×N, and the active profileDame. Names are space-padded fixed-width fields separated by08bytes. - Session-record summaries: begin with the 3-byte record ID (offsets 0–2) used by
0x17. Remaining bytes are a per-session summary (lap counts, best lap, timestamps — exact layout ❓).
No byte offset is constant across all 129-byte blocks (they're polymorphic by page type), so parse them per request context, not by a single template.
Returned by 0x17 FETCH_RECORD. Variable length (observed 100–514 bytes; one logical record may split across reads). 71 records in this capture.
| Offset | Len | Field | Conf | Notes |
|---|---|---|---|---|
| 0 | 4 | Magic 80 FF FE FD |
✅ | Constant across all records |
| 4 | 3 | Internal record stamp | ✅ | Distinct from the 0x17 fetch key (record 9828B2 for fetch key 4E26B2); 🟡 likely a timestamp or internal sequence |
| 7 | 1 | Flags/type | 🟡 | e.g. 0x0B |
| 8 | 3 | Timestamp / counter | 🟡 | e.g. 27 2A 0E — varies per record |
| 11 | ~97 | Session header / summary telemetry | 🟡 | Lap counts, best lap, config (0c 03 01 1c 27 …), session metadata |
| 108 | var | Track name (ASCII) | ✅ | *P* SHORT OKC, space-padded |
| … | additional fields | ❓ | ||
| 289 | 16 | Driver name (ASCII) | ✅ | Dame fixed 16-wide, space-padded |
| ~313 | var | Sample / trackmap table | 🟡 | repeating 7-byte entries, see §6.3 |
Offsets 108 and 289 were stable across records in this capture but may shift with record length — locate the ASCII fields by scanning rather than assuming fixed offsets in production code.
- Track name carries a mode prefix:
*P*= Professional mode (matches on-device display). Track DB strings seen:*P* SHORT OKC,*P* LONG OKC,*P* ORLANDO KART. - Driver name: fixed 16-character, space-padded field (
Dame).
From ~offset 313 to end, a repeating 7-byte structure:
05 80 80 80 80 <idx:u16le> 05 80 80 80 80 <idx:u16le> …
<idx>increments monotonically across entries (≈ +0x66 per step in the sample seen).- 🟡 Likely a per-sample or per-beacon table. The
80 80 80 80quartet is probably a placeholder for GPS coordinates not recorded because there was no GPS lock — expect real lat/lon (or packed deltas) here in an engine-on/GPS capture. ❓
CONNECT (SPP, ch 6)
└─ TX INIT 2D "000000" … ── XOR
RX FF (ready)
└─ TX STATUS 36 07 01 00 00 00 50 09 … ── XOR
RX <16-byte live snapshot>
└─ for page in [0x0011, 0x000F, 0x000E, 0x0010]: (config/driver pages)
TX READ_PAGE 2B <page:u16le> 00 80 ── XOR
RX <129-byte config/driver block>
└─ for page in descending(0x00F9 … 0x00B4): (session records, newest→oldest)
TX READ_PAGE 2B <page:u16le> 00 80 ── XOR
RX <129-byte summary> → extract recordID[0:3]
TX FETCH_RECORD 17 <recordID:3> 02 ── XOR
RX <80FFFEFD record> → parse (§6)
└─ (repeat / idle)
DISCONNECT
🟡 The exact stop condition for the descending sweep (how the host knows it has reached the oldest record) is not yet confirmed — likely an empty/sentinel summary, or a count read from a config page. For a first implementation, stop when a summary is all-FF/empty or when the record ID stops advancing.
Two modes are confirmed, differing only in how many session pages get swept:
| Mode | Pages read | Records fetched | Use |
|---|---|---|---|
| Last session only | config (0x11,0x0F,0x0E,0x10) + 0x00F9 |
1 (newest) | Fast; maps to the app's "only download last session" toggle |
| Full memory dump | config + 0x00F9 descending to ~0x00B4 |
~70 (all stored) | Complete history |
Both begin with the identical handshake + config pages. For LapWing, last-session-only is the right default — a few hundred milliseconds, pulls the just-completed run. Offer full-dump as an explicit "import all history" action.
A full download is two phases, not one — this matches the app UI showing it pull "individual laps, pit out, and other chunks":
- Index phase (the
2B/17interleaved sweep in §7): retrieves ~7080FFFEFDsegment/header records — per-lap, pit-in/out, and other session "chunks." These carry metadata (track, driver, stamps, summary stats) but not the high-rate samples. - Bulk sample phase: after the index sweep, the app issues a long run of
17 FETCH_RECcommands with no interleaved2B, walking sequential memory addresses — the 3-byte fetch arg increments by 2 each call (e.g.78ce10 → 78d010 → 78d210 …rolling the high byte). This streams the raw high-rate sample memory: the actual per-sample telemetry. ~280 reads observed for one capture.
A client that only does phase 1 gets the lap list and summary stats but no detailed traces. To reconstruct full sessions you must do both phases and stitch the sample stream against the segment headers.
Scope warning: a full dump spans all stored sessions across all tracks, not one session — the driven capture contained SHORT OKC, LONG OKC, and ORLANDO KART headers. To isolate a single session, filter header records by track string + contiguous stamp/timestamp range before stitching samples.
Cross-referenced against the official app's session export (a ZIP of per-lap CSVs + EEPROM page dumps) for SN9363 / *P* SHORT OKC / Dame / 6 laps. The SN = device serial 9363 = 0x2493 = the 93 24 marker seen inside records.
GPS encoding (corrects v0.3): latitude and longitude are degrees × 1e6 (NOT ×1e7), each a 32-bit LE integer, lat then lon, adjacent. Real values cluster tightly at OKC start/finish (lat 28.412728, lon −81.379679, from projection_orthogonale2023.csv). The recurring 26 FB bytes were just the longitude's near-constant high bytes.
Phase-2 per-sample telemetry record = 36 bytes, logged at 10 Hz, stored back-to-back in the bulk sample stream. All 16 channels matched to CSV columns:
| Offset | Size | Field | Encoding / units |
|---|---|---|---|
| 0 | 4 | Latitude | i32 LE, deg × 1e6 |
| 4 | 4 | Longitude | i32 LE, deg × 1e6 |
| 8 | 2 | Altitude | u16 LE (≈ m, zero-point TBD) |
| 10 | 2 | RPM (engine, main) | u16 LE |
| 12 | 2 | Speed (GPS) | u16 LE, km/h × 10 |
| 14 | 2 | T1 (temp ch 1) | u16 LE (0 = unconnected) |
| 16 | 2 | T2 (temp ch 2) | u16 LE |
| 18 | 2 | Gf.X (lateral G) | u16 LE, g × 1000, ~1000 baseline 🟡 |
| 20 | 2 | Gf.Y (longitudinal G) | u16 LE, g × 1000, ~1000 baseline 🟡 |
| 22 | 2 | Orientation (heading) | u16 LE, deg × 100 |
| 24 | 2 | Speed rear (wheel sensor) | u16 LE (0 = unconnected) |
| 26 | 2 | RPM 1 (20 Hz sub-sample) | u16 LE |
| 28 | 2 | RPM 2 (50 Hz) | u16 LE |
| 30 | 2 | RPM 3 (50 Hz) | u16 LE |
| 32 | 2 | RPM 4 (50 Hz) | u16 LE |
| 34 | 2 | RPM 5 (50 Hz) | u16 LE |
Each 10 Hz record embeds 5 higher-rate RPM sub-samples (RPM logged at 50 Hz, GPS + everything else at 10 Hz). A ~56 s lap ≈ 560 records ≈ 20 KB.
Session summary / lap times (SN9363_….csv): lap times are milliseconds (56540, 56160, 56310, 56420, 56620, 56350 → lap 2 = 56.160 s = best). Each lap has 6 sector splits ("partiels") summing to the lap time. Per-lap summary channels: Min/Max RPM, Min/Max GPS speed, T1–T4, Vbattery, max EGT, Hdop. Vbattery ≈ 3620 matches the header field at record offset ~9 ⇒ that field is battery voltage (scale TBD, ~×100).
Lap files: a "6 lap" session exports 7 LAP_n CSVs — no outlap, but the in/pit lap is included (LAP_7 shorter). Map lap files to timed laps accordingly.
EEPROM pages ↔ READ_PAGE: export's EEPROM_PAGE_14.dat / EEPROM_PAGE_16.dat (128 B) correspond directly to READ_PAGE 0x0E / 0x10 (§4.1) — confirms the page-read path end-to-end.
Identifiers / other export files: app = "Android ADA CLASSIQUE 5.4.6". ZIP also holds PARAMETRE….dat (config), temps_moteur.csv (engine hour-meters), lap_theorique.csv (theoretical best lap), projection_orthogonale2023.csv (GPS→track-map projection reference: reference lat/lon, orientation, line coefficients).
To finish the spec, capture in this order and re-run the parser:
CSV export of a known session— DONE (v0.4). The per-sample 36-byte record (§7.3) is fully decoded against the app CSV. Remaining minor unknowns, none blocking a working decoder: (a) Gf.X/Gf.Y exact zero-point and sign convention (values sit ~1000 ⇒ likely(raw−1000)/1000g, unverified); (b) Altitude units/offset; (c) Vbattery scale; (d) the byte offsets of lap time + the 6 sector splits inside the80FFFEFDheader records (the summary CSV gives the values; alignment not yet done); (e) how phase-2 sample addresses map to individual laps/segments (likely via pointers in the segment headers).- Engine running + poll
0x36repeatedly — the official apps have no live view, so a live stream may not exist. Test it directly: with the engine running, sendSTATUS(0x36) on a loop and watch whether the 16-byte response changes. Changing values ⇒ a live channel LapWing can use for a real-time overlay. Static values (or no response to repeated polls) ⇒ accept that Alfano-over-BT is session-download only. - Change a setting in the app (driver name, track, sample rate) — reveals write opcodes (none seen in this read-only download capture).
- A second, longer session — confirm the
0x2Bpage-numbering scheme and the sweep stop condition.
Open questions to resolve:
- Full 129-byte summary layout (lap count / best lap offsets).
- Header fields at record offsets 7–107.
- RX message framing/delimiter (currently handled by request/response lockstep).
- Live-stream subscribe command (if any).
- Write/config-set commands.
- Web Bluetooth (browser / PWA): NOT POSSIBLE. The Web Bluetooth API is BLE/GATT only and cannot open a Classic RFCOMM/SPP socket. A pure web build of LapWing will never see this device.
- Tauri (LapWing's actual stack): YES, via the native layer.
- Tauri webview/JS frontend → same limitation as a browser, can't do RFCOMM.
- Tauri native shell → can. On Android (primary target), implement a Tauri plugin that uses
android.bluetooth.BluetoothSocket.createRfcommSocketToServiceRecord(SPP_UUID)in Kotlin/JNI and bridges the byte stream to JS over the plugin's IPC channel. - Desktop later: Linux
bluer(BlueZ RFCOMM), Windowswindows-rsBluetooth APIs, macOSIOBluetooth(most painful).
// 1. Connect SPP (channel resolved via SDP from SPP_UUID)
let mut sock = rfcomm_connect(device_addr, SPP_UUID)?;
// 2. Handshake
sock.write_all(&build_command(0x2D, b"000000"))?;
expect_byte(&mut sock, 0xFF)?; // ready
sock.write_all(&build_command(0x36, &hex!("07 01 00 00 00 50 09 00")))?;
let _snapshot = read_message(&mut sock)?; // 16-byte live snapshot
// 3. Config/driver pages
for page in [0x0011u16, 0x000F, 0x000E, 0x0010] {
sock.write_all(&build_read_page(page))?;
let block = read_message(&mut sock)?; // 129 bytes
parse_config_or_drivers(&block);
}
// 4. Session records (newest → oldest)
let mut page = 0x00F9u16;
loop {
sock.write_all(&build_read_page(page))?;
let summary = read_message(&mut sock)?; // 129 bytes
if is_empty_summary(&summary) { break; }
let rec_id = &summary[0..3];
sock.write_all(&build_command(0x17, &[rec_id[0], rec_id[1], rec_id[2], 0x02]))?;
let record = read_message(&mut sock)?; // 80FFFEFD …
parse_record(&record); // §6
page -= 1;
}
fn build_read_page(p: u16) -> [u8;258] {
build_command(0x2B, &[p as u8, (p>>8) as u8, 0x00, 0x80])
}
// build_command = opcode + args, zero-pad to 257, append XOR of those 257 bytes./alfano-bt/
ALFANO6_BT_PROTOCOL.md ← this file
src/
protocol.rs ← build_command(), parsers, record structs (pure, no I/O — unit-testable)
transport_desktop.rs ← bluer/serial RFCOMM for laptop testing
transport_android.rs ← Tauri plugin shim (Kotlin/JNI BluetoothSocket)
tests/
fixtures/ ← captured frames (hex) for offline parser tests
parse_tests.rs ← feed §A example frames, assert decoded fields
Keep protocol.rs I/O-free so it can be unit-tested against captured hex fixtures (the frames in §A) with no Bluetooth hardware in the loop.
Real bytes from the capture (truncated where noted). cr = RFCOMM credit octet (handled by OS stack; not part of the app payload).
TX INIT
2d 30 30 30 30 30 30 00 …(zeros)… 2d (258 B, ASCII "-000000…", XOR=0x2D)
TX READ_PAGE 0x00F9
2b f9 00 00 80 00 …(zeros)… 52 (258 B, XOR=0x52)
TX FETCH_RECORD 0x4E26B2
17 4e 26 b2 02 00 …(zeros)… cf (258 B, XOR=0xCF)
RX live snapshot (16 B, idle)
07 ec02 ec02 c900 ec02 ec02 9324 08 4c3d
RX driver-list summary (129 B) — decodes to Driver …, Dame
44 72 69 76 65 72 20 04 … 50 01 44 61 6d 65 20 20 … (… "Driver "… "P.Dame "…)
RX telemetry record (head, 80FFFEFD)
80 ff fe fd 9828b2 0b 272a0e 06 1a00 df00 4008 0000 1f80 1f29 …
… 2a 50 2a 20 53 48 4f 52 54 20 4f 4b 43 20 … (offset 108: "*P* SHORT OKC ")
… 44 61 6d 65 20 20 20 20 20 20 20 20 20 20 20 (offset 289: "Dame ")
… 05 80 80 80 80 <idx16> 05 80 80 80 80 <idx16> … (trailing sample table)
| Item | Value(s) observed |
|---|---|
| Active driver | Dame |
| Driver profile slots | Dame, Driver 2–Driver 6 |
| Track database | *P* SHORT OKC, *P* LONG OKC, *P* ORLANDO KART |
| Mode prefix | *P* = Professional |
| Device/session marker | 0x2493 (93 24 LE) |
| Stored session records | ~71 (this capture) |
End of spec v0.1. Generated from btsnoop_hci.log (handle 0x06, RFCOMM ch 6). Update confidence markers as engine-on/GPS captures land.