Attribution. The MPID protocol reverse-engineering, this document, the
HWiD.protoschema,hwportal/mpid.py,mpid_monitor.py, and thetools/RE scripts are the work of Mitch Capper (@mitchcapper), published under MIT in themodern_protocol_supportbranch of hismtxmiller/hotwheels-portalfork and discussed in mtxmiller/hotwheels-portal#1. They are vendored here (HotWheelsID) to give the cross-platform app a working, hardware-validated reference for portals running the modern firmware (no…-000cservice). Offline correctness is re-verified in our tree viatools/test_mpid.py.
This document describes the newer protocol spoken by current Hot Wheels id
Race Portals, recovered by decompiling the official Mattel app's native library
(libnative-lib.so → mpid-library, MpidLib.cpp) with Ghidra and validated
live against real hardware.
It supersedes the transport described in PROTOCOL.md. The
payload formats in the old doc (NFC UID, NDEF URI, speed float) are correct —
they match the decrypted data byte-for-byte — but the old …-000c "Portal
Control" service/characteristic layout does not exist on current firmware
(confirmed by Android and Windows BLE scans). On real hardware that same data is
delivered as an encrypted Protocol Buffers stream over the authentication
service, after a key-exchange handshake.
The reference implementation is hwportal/mpid.py; the
transport auto-detection lives in hwportal/portal.py.
| Layer | What it is |
|---|---|
| Transport | "MPID" (Mattel PID) — a generic Mattel BLE SDK (blekit) used across several toys |
| Key exchange | ECDH on NIST P-256 (micro-ecc), device authenticated by a signed factory token |
| Session key | 100-round AES-128-CTR stretch of the ECDH secret with the label "mattel" |
| Record crypto | AES-128-CTR per packet; CRC-8 header + body integrity |
| Application data | Protocol Buffers (car identity, speed/pass events, status heartbeat) |
Important: the portal authenticates itself to the app (anti-counterfeit), but it does not authenticate the client. The app never sends a certificate — only an ephemeral ECDH public key. So no Mattel secret is required to talk to the portal; everything needed is in the factory token the portal hands out.
Device name: HWiD. Negotiated MTU: 247.
All custom services use the base UUID af0a6ec7-XXXX-YYYY-84a0-91559fc6f0de.
The portal is the NXP variant of the Mattel blekit SDK
(MpidConfig.GetNXPConfig); the SDK also defines NRF / Magic Bullet / Magic Wand
variants on other base UUIDs, none of which use a …-000c service.
| Characteristic | Properties | Role | Notes |
|---|---|---|---|
af0a6ec7-0002-000a-… |
write, indicate | TX / RX | Encrypted frames, both directions (write to send; indications to receive) |
af0a6ec7-0003-000a-… |
read | FACTORY | 136-byte signed manufacturing token |
af0a6ec7-0004-000a-… |
write, indicate | SESSION | Client writes its public key + salt here |
| Characteristic | Properties | Role |
|---|---|---|
af0a6ec7-0002-000b-… |
write, indicate | OTA command |
af0a6ec7-0003-000b-… |
write-without-response | OTA data |
Plus the standard 1800/1801 GAP/GATT services. There is no …-000c
service.
Read once from …-0003-000a. It identifies the portal and carries its public
key, signed by a Mattel manufacturing key.
| Offset | Len | Field |
|---|---|---|
| 0 | 1 | Protocol version (0x01) |
| 1 | 24 | Serial (ASCII) — day[3] year[1] location[2] AP#[10] revision[2] item#[6] |
| 25 | 33 | Device public key, P-256 compressed (0x02/0x03 ‖ X) |
| 58 | 5 | Birth time |
| 63 | 2 | Machine number (big-endian) |
| 65 | 3 | Key ID (big-endian) — selects the manufacturing key |
| 68 | 64 | ECDSA-P256 signature over bytes [0:68] |
| 132 | 4 | Device salt (peer salt) |
The app verifies the signature with an embedded manufacturing public key chosen by Key ID. This is only the app verifying the portal — a client does not need it to connect. Known Key IDs (64-byte uncompressed P-256 keys baked into the app):
| KID | Key | KID | Key |
|---|---|---|---|
| 9999999 | test (dev) | 5 | pp |
| 8888888 | test #2 | 6 | techmods |
| 1 | fep | 9 | media1 |
| 2 | nxp_text | 10 | pp2 |
| 3 | augmoto | 11 | smartconnect gmn58 |
| 4 | rocketleague | 12 | smartconnect gld09 |
The portal tested here reports KID 9999999 (the test/development key) and firmware version
1.0.9(from the status heartbeat).
- Connect; request MTU 247; enable indications on TX/RX and SESSION.
- Read FACTORY → 136-byte token; parse the device compressed public key and the device salt.
- Generate an ephemeral P-256 key pair and a random 4-byte local salt.
- ECDH:
shared = ECDH(device_pub, our_priv)→ 32-byte X coordinate. - Derive the session key (see KDF below) → 16-byte AES key.
- Write SESSION =
compressed_pubkey (33) ‖ local_salt (4)= 37 bytes to…-0004-000a. The portal now derives the same session key. - Exchange encrypted packets on TX/RX (
…-0002-000a).
secret = ECDH_shared_X # 32 bytes
iv = bytes(16); iv[9:15] = b"mattel" # 00*9 'm''a''t''t''e''l' 00
for _ in range(100):
secret = AES128_CTR(key=secret[:16], counter=iv, data=secret[:32])
iv[4:8] = be32(be32(iv[4:8]) + 1) # 32-bit big-endian counter at iv[4:8]
session_key = secret[:16]
AES-128-CTR here is the tiny-AES variant: a 16-byte counter block incremented as
a 128-bit big-endian integer (identical to cryptography's CTR mode).
Built by mpid_make_packet_generic; parsed/reassembled by mpid_buffer_put.
+---------- 8-byte header ----------+ +------- body (length bytes) -------+
| 7E | counter(BE32) | length(BE16) | crc8 | <payload …> | crc8(payload) |
+----+---------------+--------------+------+-------------+----------------+
0 1..4 5..6 7 8 … 8+len-1
- preamble
0x7E - counter — uint32 big-endian; incremented per transmitted packet (first sent = 1)
- length — uint16 big-endian =
len(payload) + 1(or0for an empty packet) - header crc8 =
crc8(header[0:7]) - body =
payload ‖ crc8(payload)(so body length =length)
The body is encrypted in place with AES-128-CTR using the session key and a 16-byte IV built from the packet counter and the two salts:
iv = counter(BE32) ‖ saltA(4) ‖ saltB(4) ‖ 00 00 00 00
TX (we send): saltA = local_salt, saltB = peer_salt
RX (we receive): saltA = peer_salt, saltB = local_salt
Both endpoints compute the same IV for a given packet because each side's "local" salt is the other's "peer" salt.
Polynomial 0x07, init 0xFF, MSB-first, no input/output reflection,
no final XOR. Table-driven: crc = table[crc ^ byte].
Scan the indication byte stream for 0x7E, collect the 8-byte header and validate
its CRC, read length body bytes, decrypt, then verify crc8(body[:-1]) == body[-1] and deliver body[:-1] as the application payload.
Each decrypted payload is a Protocol Buffers message. The complete schema is
authoritative, not inferred: it was recovered from the FileDescriptor the app
embeds in its Unity IL2CPP metadata (global-metadata.dat, via
tools/decode_descriptor.py) and is committed as HWiD.proto. The
Python model is in hwportal/mpid.py (parse_message).
Portal → app messages are PortalToApp; app → portal are AppToPortal. (There
are also PortalToAccessory / AccessoryToPortal for the accessory ecosystem.)
| Field | Type | Meaning |
|---|---|---|
1 |
uint32 | timestamp_ms (≈ ms uptime) |
2 |
Event |
car / speed / gate / accessory event |
3 |
DeviceInfo |
status heartbeat (~every 7 s) |
4 |
CommandResponse |
ack to a command we sent |
5 |
bytes | accessory_message |
type (field 1) is an EventType enum:
| # | EventType | Notes |
|---|---|---|
| 0 | UnknownEventType | |
| 1 | LowBatteryEvent | |
| 2 | CarOnPortalEvent | car placed — carries car_info |
| 3 | CarOffPortalEvent | car removed |
| 4 | CarDriveByEvent | pass — carries speed_measurement |
| 5 | CarHistory | carries offline_race_sessions (stored offline races) |
| 6 | AccessoryAttachedEvent | with accessory_id |
| 7 | AccessoryDetachedEvent | |
| 8 | AccessoryIdentifiedEvent | |
| 9–12 | InfraredGate{A,B}{Blocked,Unblocked} | raw beam-break of the two IR gates |
Event fields: 1 type, 2 car_info (CarInfo), 3 speed_measurement
(SpeedMeasurement), 4 measurement_history (repeated, deprecated),
5 offline_race_sessions (repeated OfflineRaceSession), 6 accessory_id.
CarInfo: 1 tag_uid (bytes; 0x04 + 6-byte NFC UID) · 2 signature_status
(bool) · 3 car_ndef_data (bytes; NDEF URI record) · 4 signature (64 bytes) ·
5 publickey.
SpeedMeasurement: 1 timestamp_ms · 2 speed (float32, ×64 = scale mph) ·
3 t_ir1_in · 4 t_ir1_out · 5 t_ir2_in · 6 t_ir2_out (int32 enter/exit
timestamps of the two IR gates — the raw timing the speed is derived from) ·
7 estimated_car_count.
Speed derivation (recovered): the portal computes
speedpurely from the two gates' leading-edge timestamps —speed = 114000 / (t_ir2_in − t_ir1_in). The constant folds gate separation and the firmware tick period into one factor; it was solved from the portal's own reportedspeedvs.t_*_inand matches two independent passes to <0.01% (0.595039 × 191584 = 113999.9,0.4063 × 280562 = 113992.3). Trailing edges (t_*_out) do not fit the same constant — consistent with cars accelerating between the gates. Seespeed_from_gates()/SpeedMeasurement.reconstructed_speedinhwportal/mpid.py.Non-chipped cars: the portal only emits a tag-bearing
CarDriveByEventwhen it gets an NFC read, so tag-less cars currently produce nothing inNormalMode. The same formula would give their speed from the rawInfraredGate*Blockedevents (types 9–12), which have not been observed onNormalModefirmware — they are likely gated behindTestMode/FastMode. Usepython mpid_monitor.py --mode test --dumpand run a tag-less car to capture whether those events fire and what timing they carry.
OfflineRaceSession: 1 time_played · 2 top_speed (float32) · 3 scan_count.
| # | Field | Notes |
|---|---|---|
| 1 | firmware_version (uint32) | deprecated; use semantic_firmware_version |
| 2 | hardware_version (uint32) | |
| 3 | battery_level (float) | 0.0–1.0 |
| 4 | mode (DeviceMode) |
0 Unknown · 1 Fast · 2 Normal · 3 Test |
| 5 | boot_timestamp_sec (uint32) | uptime |
| 6 | serial_number (string) | NOTE: omitted by fw 1.0.9 — see below |
| 7 | battery_status (BatteryStatus) |
0 Unknown · 1 NotCharging · 2 Charging · 3 Full · 4 Problem |
| 8 | q_value (uint32) | battery fuel-gauge raw |
| 9 | i_value (uint32) | battery fuel-gauge raw |
| 10 | semantic_firmware_version (string) | e.g. "1.0.9" |
| 11 | accessory_attached (bool) |
Serial caveat (verified): firmware 1.0.9 does not include field 6 in its heartbeat (observed fields: 1,2,3,4,5,7,8,9,10). The portal serial is instead taken from the FACTORY token (the
1989os…string);portal.pybackfillsinfo.serial_numberfrom the token during the handshake.
type is a CommandType enum: 0 Unknown · 1 FastMode · 2 NormalMode · 3 ReqeustDeviceInfo [sic] · 4 TestMode · 5 Reset · 6 StartOTA · 7 SetLEDColor · 8 ResetLEDControl · 9 ClearBonding. Other Command fields: 2 ota_signature,
3 ota_publickey, 4 rgb_color (3 bytes, for SetLEDColor). AppToPortal itself:
1 timestamp_sec, 2 command, 3 accessory_message. Builders for these live in
hwportal/mpid.py (cmd_set_led_color, cmd_request_device_info, cmd_set_mode, …)
and are sent over the encrypted TX characteristic.
CommandResponse: 1 failed (bool) · 2 fail_message (string).
- NFC UID:
0x04+ 6 bytes →AA:BB:CC:DD:EE:FF. - NDEF URI record:
91 01 28 55 02+ ASCII →https://www.pid.mattel/<base64 car id>. The base64 is the Mattel car ID. - Speed: little-endian float32; multiply by 64 for "1:64 scale mph".
| Capture | Decodes to |
|---|---|
| heartbeat | DeviceInfo: v1.0.9, battery 1.0 (NotCharging), mode Normal, hw 7 |
| 133-byte packet | CarOnPortalEvent: UID 2A:7E:A2:F1:62:80, Mattel ID AQBBrl5bAAAGAF0TKZcEKn6i8WKA, 64-byte signature |
| 19-byte packet | CarOffPortalEvent (car removed) |
| 53-byte packet | CarDriveByEvent: speed 0.595 → 38.08 scale-mph, gates ir1 [-26240, 87376] ir2 [165344, 258944] |
All built from public, open-source code — only the composition was proprietary:
- micro-ecc (
uECC) — secp256r1 (P-256) ECDH + ECDSA, compressed-point decompress - tiny-AES-c — AES-128 (CTR used for the session and records)
- SHA-256 + HMAC-SHA256 — used in token verification
- CRC-8 (poly
0x07)
The reverse-engineering tooling lives in tools/:
| File | Purpose |
|---|---|
run_ghidra.ps1 + ghidra_decompile.java |
Headless Ghidra decompile of the MPID functions → mpid_decompiled.c |
extract_crc.py |
Pull the CRC-8 table / polynomial from the binary |
decode_descriptor.py |
Decode the embedded protobuf FileDescriptor → HWiD.proto |
pb_decode.py |
Minimal protobuf decoder for decrypted payloads |
test_mpid.py |
Offline tests (KDF, framing, CRC, ECDH, parse, commands) incl. real captured packets |
Live tools:
python mpid_monitor.py— handshake against the portal and print decoded events.python portal_app.py/python dashboard.py/python race_mode.py— go throughHotWheelsPortal, which auto-detects the MPID transport and emits the same event stream as the legacy path.