Wire protocol for Alientek's BLE test/measurement devices (DM40 multimeter, ND1 dosimeter, EL15 electronic load, HP15/HP20 hot plates, C2 USB tester), reverse-engineered from the official Android apps. Sufficient to implement a from-scratch client for all six devices; per-device sections note remaining gaps explicitly.
Sources: v1.8 (ATK-XTOOL 1.82.apk, .NET MAUI, IL-decompiled — exact) and
v2.0.12 (atk-xtool 2.0.12.apk, Flutter/Dart AOT, decompiled via blutter +
Ghidra — see Appendix). Where the two disagree or only one was verified,
it's noted inline.
| Service UUID | 0000ffff-0000-1000-8000-00805f9b34fb |
| Write characteristic | 0000ff01-0000-1000-8000-00805f9b34fb |
| Notify characteristic | 0000ff02-0000-1000-8000-00805f9b34fb (enable via CCCD 00002902-...) |
| Write chunk size | 20 bytes |
v1.8's code doesn't hardcode these UUIDs — it enumerates services/ characteristics and picks whichever supports write+read+notify (falling back to separate read/write characteristics). The fixed UUIDs above are what the hardware actually exposes (found in v2's binary strings) and are the simpler implementation choice.
Writes longer than 20 bytes are split into 20-byte chunks, written sequentially, no inter-chunk ack. Incoming notifications are appended to a single rolling receive buffer and parsed as a byte stream — a frame can span multiple notifications, and one notification can contain multiple or partial frames.
Little-endian throughout.
Offset Size Field
0 1 Flag 0xAF (app→device) / 0xDF (device→app)
1 2 Dev device-type id (u16)
3 1 Function command / frame-function code
4 1 Length length of Data (0-255)
5 Length Data payload
5+Len 1 Checksum
Total frame length = Length + 6.
Checksum: two's-complement mod-256 sum over Flag+Dev+Function+Length+Data:
checksum = (256 - (Flag + Dev_lo + Dev_hi + Function + Length + sum(Data)) % 256) % 256
i.e. sum of all frame bytes including checksum ≡ 0 (mod 256).
Receiver state machine (byte-stream parser over the rolling buffer):
- Scan for
0xDF; discard anything before it. - Read Dev (2B), Function (1B), Length (1B).
- Wait for
Length + 5bytes buffered, then read Data. - Read checksum byte, validate.
- On mismatch, or if a partial frame sits unresolved >500ms, reset and resume scanning from the next byte (resync after corruption).
- On success, dispatch the frame and drop consumed bytes.
Only device→app frames (0xDF) are parsed this way; the app doesn't parse
its own outgoing frames.
On connect, before any device-specific command:
- Send
Flag=0xAF, Dev=0xFFFF, Function=0x00, Length=0. - Response:
Function=0x00,Data[0..1]= device type (u16, see registry below). For DM40,Data[2]additionally carries a model byte. - All subsequent requests use
Dev = <device type>instead of0xFFFF.Devis static per-device-type routing, not a session/connection id.
- Send a frame; poll (non-blocking) until a reply with matching
Functionarrives, or 3000ms elapses (timeout). - One outstanding request at a time — no pipelining.
Function >= 0xD3(211): not treated as a reply to anything; dispatched immediately as an unsolicited/push frame (device-initiated events).- Each device manager polls "get realtime data" periodically as a combined heartbeat + live-data feed. v1.8 used ad hoc per-device intervals (~800ms typical). v2 standardized to ~200ms default. Treat repeated timeouts (2-3 consecutive failures) as disconnected.
- No pairing, authentication, or encryption anywhere in this protocol.
DevType wire values, and which app versions have a working command
handler for each (vs. only recognizing the identify response):
| DevType | Wire value | v1.8 | v2.0.12 |
|---|---|---|---|
| BLE03 | 0x0000 |
— | — (generic/unknown placeholder) |
| HP15 | 0x0207 |
yes | no — identified but unhandled, see HP15 below |
| HP20 | 0x0209 |
— | yes |
| DM40 | 0x0305 |
yes | yes |
| ND1 | 0x0306 |
yes | yes |
| EL15 | 0x0307 |
yes | yes |
| C2 | 0x030a |
— | yes |
| BLE (fallback) | 0xFFFF |
— | — |
C0 and DP100 (named in Alientek's marketing alongside these devices) do not appear anywhere in either app — no handler, UI, string, or asset. DP100 is confirmed USB-only via a separate non-BLE library; C0 is presumed similar. Out of scope for this document.
Version query removed in v2: v1.8 has a get-version command (function
0x02) for every device it supports. v2 dropped it for DM40, ND1, EL15,
and HP20 — no call site anywhere, no shared replacement. C2 is the only
v2 device that kept it. Function 0x02 likely still works at the wire
level against real firmware; v2's app simply stopped asking.
DevType = 0x0306. v1.8 source: ATKMobileBLE.ATKProtocol.ND1/. v2:
managecmd_nd1.dart — function codes confirmed identical to v1.8;
payload layout assumed unchanged (not independently re-verified in v2).
| Func | Name | Request | Response |
|---|---|---|---|
0x00 |
Register | — | DevType(u16) |
0x02 |
Get version | — | Boot_ver(u8) App_ver(u8) Hw_ver(u8) — v1.8 only |
0x03 |
Set alarm config | AlarmType(u8) RealtimeAlarmValue(u32) CumulativeAlarmValue(u32) |
ack |
0x04 |
Get alarm config | — | same shape as 0x03 |
0x05 |
Set time sync | sec min hour day month (year-2000), all u8 |
ack |
0x06 |
Set BLE name | 20B ASCII, NUL-padded | ack |
0x07 |
Get BLE name | — | ASCII, NUL-terminated |
0x08 |
Get realtime data | — | 21B, see below; heartbeat |
Realtime data (0x08), 21 bytes:
| Offset | Type | Field |
|---|---|---|
| 0 | u32 | RealtimeRadiationValue (raw) |
| 4 | i32 | RealtimeRadiationValue2 |
| 8 | u16 | Temperature (raw) |
| 10 | u16 | Humidity (raw) |
| 12 | u32 | RealtimeStorageValue (raw, session dose) |
| 16 | u32 | CumulativeStorageValue (raw, lifetime dose) |
| 20 | u8 | State (bitfield) |
State bits: 0=Sound, 1=Vibration, 2-4=RadiationUnit
(0=uSv/h 1=uGy/h 2=mR/h 3=CPM 4=CPS), 5-7=Battery (0-7).
Scaling:
- Temperature:
(value & 0x7FFF) / 10→ °C. Sign:value > 15= positive, else negate (exact firmware behavior, not a typo). - Humidity:
raw / 10→ %RH. - Storage values:
raw / 100→ uSv, orraw / 100000→ mSv if that result would exceed 999.99. - RealtimeRadiationValue: if RadiationUnit ≥ 3 (CPM/CPS), used as-is
(integer count). Else
raw / 100, orraw / 100000if >999.99. - Alarm values (0x03/0x04):
raw / 100→ uSv/h and mSv respectively. - Lat/long are not in any BLE payload — sourced from host GPS.
Unset sentinels: version/AlarmType bytes =
0xFF, coordinates =255.0.
DevType = 0x0305. v1.8 source: ATKMobileBLE.ATKProtocol.DM40/. v2:
managecmd_dm40.dart — function codes and realtime-data payload
byte layout both Ghidra-confirmed identical to v1.8.
Register response Data[2] = model byte: DM40A=65('A'), B=66, C=67
— only affects displayed range-label strings, not protocol.
| Func | Name | Request | Response |
|---|---|---|---|
0x00 |
Register | — | DevType(u16) DevModel(u8) |
0x02 |
Get version | — | v1.8 only, see registry note |
0x03 |
Set auto-range | Auto(u8) |
ack |
0x04 |
Set HOLD | SetHoldValue(u8) (1=hold) |
ack |
0x05 |
Set REL (zero) | SetRelValue(u8) |
ack |
0x06 |
Set gear | Gear(u8), packed — see below |
ack |
0x07 |
Set BLE name | 20B ASCII, NUL-padded | ack |
0x08 |
Get BLE name | — | ASCII, NUL-terminated |
0x09 |
Get realtime data | — | 11B, see below; heartbeat |
Gear byte (0x06): Gear = GearValue | (GearRange << 3) | (GearMode << 6).
GearValue(bits0-2):0=V1=A2=Ω3=F(capacitance)4=Diode/Cont.5=Freq/TempGearRange(bits3-5): range index;6/7= AUTO/AUTO+ sentinelsGearMode(bits6-7): V/A:0=DC1=AC2=AC+DC. Ω: toggles secondary display. Diode/Freq-Temp: toggles sub-function (Diode↔Continuity, Freq↔Temp).
Realtime data (0x09), 11 bytes:
| Offset | Type | Field |
|---|---|---|
| 0 | u8 | GearStatus (bitfield) |
| 1 | u8 | Status (bitfield) |
| 2 | u8 | SecInfo1 (bitfield) |
| 3 | u8 | SecInfo2 (bitfield) |
| 4 | u8 | MainInfo (bitfield) |
| 5 | u16 | SecValue1 |
| 7 | u16 | SecValue2 |
| 9 | u16 | MainValue |
GearStatus: bits0-2=GearValue, bits3-5=GearRange, bits6-7=GearMode (same
encoding as the set-gear byte).
Status: bits0-2=battery(0-7), bit3=charging, bit5=REL active,
bit6=lock-screen, bit7=HOLD active. (bit4 unused.)
MainInfo/SecInfo1/SecInfo2 (same layout, one per reading): bit0=sign
(1=negative), bits1-3=decimal power n, bits4-5=unit index (meaning
depends on gear, see unit tables below), bits6-7=display mode. v2 detail
(not wire-relevant): only MainInfo's mode bits are extracted at parse
time; SecInfo{1,2} keep the raw byte for on-demand extraction instead.
Value decode: value = (sign?-1:1) * MainValue / 10^power. Same formula
for SecValue1/2 with their own info byte.
Quirks:
- When
GearValue==4(Diode/Cont.) andGearMode==1(Continuity) andMainValueMode==2: raw value has+65520baked in —value = (sign?-1:1) * (MainValue + 65520) / 10^power. MainValue == 0xFFFF→ overload/open-circuit ("0L"), not numeric.
Unit tables (index via bits4-5 above): Voltage [mV,V], Current
[uA,mA,A], Resistance [Ω,KΩ,MΩ], Capacitance [nF,uF,mF], Diode
[V,Ω], Frequency [Hz,KHz,MHz], Temperature [℃,℉] (Frequency used
when GearMode=0, Temperature when GearMode=1, for GearValue=5).
DevType = 0x0307. v1.8 source: ATKMobileBLE.ATKProtocol.EL15/. v2:
managecmd_el15.dart. Function codes confirmed except setBleName
(inferred — see note) and getRealDataFunctionCode (no explicit v2
override found; inferred unchanged at 8). Payload layout assumed
unchanged, not independently re-verified in v2.
| Func | Name | Request | Response |
|---|---|---|---|
0x00 |
Register | — | DevType(u16) |
0x02 |
Get version | — | v1.8 only |
0x03 |
Set mode | Mode(u8), packed — see below |
ack |
0x04 |
Set measurement param | MeasurementPar(u32) |
ack |
0x05 |
Set battery discharge current | BatteryDischargeCurrent(u16) |
ack |
0x06 |
Set BLE name | 20B ASCII, NUL-padded | ack |
0x07 |
Get BLE name | — | ASCII, NUL-terminated |
0x08 |
Get realtime data | — | 22B, see below; heartbeat |
0x09 |
Set lock/clear | LockClear(u8) |
ack |
0x0A (10) |
Get discharge current | — | not decoded — new in v2, no v1.8 equivalent (v1.8 only had the setter) |
Mode byte: Mode = (WorkMode << 3) | MeasurementMode.
MeasurementMode:0=Basic,1=BatteryWorkMode(Basic):0=CC1=CV2=CR3=CPWorkMode(Battery):0=CAP(capacity test)1=DCR(internal resistance)
MeasurementPar (u32): target set-point for the selected WorkMode; raw
transport only, units not confirmed (write-only from the app's side, never
read back).
Realtime data (0x08), 22 bytes — all values 32-bit IEEE754 float LE (not fixed-point, unlike other devices):
| Offset | Type | Field |
|---|---|---|
| 0 | f32 | ViceValue3 |
| 4 | f32 | ViceValue2 |
| 8 | f32 | ViceValue1 |
| 12 | f32 | Current (A) |
| 16 | f32 | Voltage (V) |
| 20 | u16 | StateFlag (bitfield) |
Power = Current * Voltage (client-computed, not transmitted).
ViceValue1-3 meaning depends on mode: Basic — ViceValue1 = the
non-primary electrical quantity for WorkMode; ViceValue2 = temperature(°C)
or countdown(s) per TimerSwitch; ViceValue3 = elapsed work time(s).
Battery — ViceValue1 = capacity(Ah) or resistance(mΩ); ViceValue2/3 =
discharge current(mA) / discharge time(s) per WorkMode.
StateFlag bits: 0-2=MeasurementMode, 3-5=WorkMode, 6-8=WindSpeed,
9=OutSwitch, 10=LockFlag, 11=TimerSwitch, 12-15=AlarmInterface.
DevType = 0x0207. Dropped in v2 — the DevType is still recognized by
the identify handshake, but v2 has no command handler or UI for it; a real
HP15 connects and then hits "unrecognized device." This section applies to
v1.8 only. Source: ATKMobileBLE.ATKProtocol.HP15/.
Two modes: constant-temperature heating (3 presets T1-T3) and a 4-stage reflow-soldering profile. A persistent temperature-unit setting shifts raw wire values by fixed offsets (see below).
| Func | Name | Request | Response |
|---|---|---|---|
0x00 |
Register | — | DevType(u16) |
0x01 |
Connect ack | seq(u8) |
echo |
0x02 |
Get version | — | Boot_ver(u8) App_ver(u8) Hw_ver(u8) |
0x03 |
Set thermostat preset | PresetIndex(u8: 0-2) Value(u16) |
ack |
0x04 |
Get thermostat presets | — | T1(u16) T2(u16) T3(u16) |
0x05 |
Set active preset | PresetIndex(u8) |
ack |
0x06 |
Set reflow profile | 10B, see below | ack |
0x07 |
Get reflow profile | — | 10B, see below |
0x08 |
Set BLE name | 20B ASCII, NUL-padded | ack |
0x09 |
Get BLE name | — | ASCII, NUL-terminated |
0x0A (10) |
Get realtime data | — | 10B, see below; heartbeat |
Function 0x01 is sent once after 0x00 succeeds, before any other
command.
Reflow profile, 10 bytes (get/set 0x07/0x06):
| Offset | Type | Field |
|---|---|---|
| 0 | u8 | Heating_temperature |
| 1 | u8 | Heating_time |
| 2 | u8 | Housing_temperature (soaking) |
| 3 | u8 | Housing_time |
| 4 | u16 | Welding_temperature (soldering) |
| 6 | u8 | Welding_time |
| 7 | u8 | Cooling_temperature |
| 8 | u16 | Cooling_time |
Temperature fields are raw Celsius on the wire. When TemperatureUnit=1 (°F), the app applies a fixed per-field offset (not a real unit conversion) converting wire↔display:
| Field | wire→display | display→wire |
|---|---|---|
| Heating_temperature | +248 | −248 |
| Housing_temperature | +302 | −302 |
| Welding_temperature | +446 | −446 |
| Cooling_temperature | +122 | −122 |
When TemperatureUnit=0, raw byte used unmodified both directions.
Realtime data (0x0A), 10 bytes:
| Offset | Type | Field |
|---|---|---|
| 0 | u16 | Platform_temperature |
| 2 | u8 | Power_supply (W, rated) |
| 3 | u8 | InputVoltage (raw, /10 → V) |
| 4 | u8 | Nternal_resistance (raw) |
| 5 | u8 | Heating_power (W) |
| 6 | u16 | Stateinfo (bitfield) |
| 8 | u16 | StateInfo2 (bitfield) |
Stateinfo: bits0-9=TargetTemperature (0-350), bits10-11=ReflowHeatingStatus
(0=Heating 1=Soaking 2=Soldering 3=Cooling) or HeatingMode
(0=T1 1=T2 2=T3, depending on active interface), bits12-14=
InterfaceStatus (0=Main 1=Reflow 2=ConstantTemp 3=Settings
4=Info).
StateInfo2: bits0-5=HeatingTimeSecMin, bits6-11=HeatingTimeMinHour,
bit12=PlatformStatus, bit13=FanStatus, bit14=TemperatureUnit (governs the
offset table above).
Presets/reflow profile aren't pushed proactively — the app re-polls
0x04/0x07 after a unit change or reconnect; a client should do the same
rather than assuming cached values stay valid.
DevType = 0x0209. No v1.8 equivalent. Source: managecmd_hp20.dart,
hp20model.dart. Function codes and all payload structures below are
Ghidra-confirmed (see Appendix); semantic naming gaps are called out
explicitly where they remain.
Keeps HP15's two modes (constant-temp, reflow) and adds a third: repair
(0x0B/0x0C, no HP15 equivalent). Functions 0x01 (connect-ack) and
0x02 (get version) are confirmed absent — all 11 call sites in v2's
source were enumerated and neither is used.
| Func | Name | Request | Response |
|---|---|---|---|
0x00 |
Register | — | DevType(u16) |
0x03 |
Set thermostat preset | PresetIndex(u8) Value(u16) |
ack |
0x04 |
Get thermostat presets | — | 8B, 4×u16 — see below |
0x05 |
Set active preset | PresetIndex(u8) |
ack |
0x06 |
Set reflow profile | 10B, assumed same as HP15 (not re-verified) | ack |
0x07 |
Get reflow profile | — | 10B, assumed same as HP15 (not re-verified) |
0x08 |
Set BLE name | 20B ASCII, NUL-padded | ack |
0x09 |
Get BLE name | — | ASCII |
0x0A (10) |
Get realtime data | — | 11B, see below; heartbeat |
0x0B (11) |
Set repair profile | 16B, see below | ack |
0x0C (12) |
Get repair profile | — | 16B, see below |
Thermostat presets: expanded from HP15's 3 to 4 usable slots. App code
has setThermostatT1/T2/T3 (preset index 0/1/2) and setThermostatT5
(index 4) — no T4 (index 3) anywhere in the UI, though the wire protocol
accepts any index via 0x03.
getThermostat response (0x04), 8 bytes: four flat u16 LE values, no
bitfields, at offsets [0-1][2-3][4-5][6-7]. Matches the 4 usable presets
(T1/T2/T3/T5) — presumed Thermostat_t1, t2, t3, and whatever backs T5,
in that order.
Realtime data (0x0A), 11 bytes (one longer than HP15's 10):
| Offset | Type | Field |
|---|---|---|
| 0 | u16 | Platform_temperature |
| 2 | u8 | Power_supply (W) |
| 3 | i16 signed | Combines HP15's InputVoltage+InternalResistance u8 pair into one signed value (two's-complement, subtract 0x10000 if bit15 set). Real-world meaning not identified. |
| 5 | — | present, bounds-checked, not stored — reserved |
| 6 | u8 | Heating_power (W) |
| 7 | u16 | Stateinfo — same layout as HP15 |
| 9 | u16 | StateInfo2 — same as HP15 except minutes/hours field is bits6-12 (7 bits, was 6) and overlaps the bit12 PlatformStatus flag read separately. Confirmed present in compiled code both statically and via Ghidra decompile — not a tracing artifact, intent unresolved. |
setRepair (0x0B) / getRepair (0x0C), 16 bytes each, flat 8×u16 LE,
no bitfields:
-
getRepairresponse: 8 sequential values, call them G1-G8, offsets [0-1] through [14-15]. Individual meanings unknown. -
setRepairrequest is not send-back-what-you-got:[Thermostat_t1, Thermostat_t2, Thermostat_t3, G4, G5, G6, G7, G8]— the 3 current thermostat values, then only the last 5 ofgetRepair's 8 fields. G1-G3 are never sent back.This implies G1-G3 are read-only repair-mode telemetry (like realtime data) and G4-G8 are the actual configurable parameters; thermostat values are echoed alongside for unknown reasons (shared device state, presumably). No individual field names/units — needs UI-string tracing in
hp20setrepairpage.dartor dynamic testing to resolve.
DevType = 0x030a. No v1.8 equivalent. Source: managecmd_c2.dart,
c2model.dart. Variants seen in app strings: C2 Lite/Std/Pro, ATK-C2 Pro,
C2 USB Tester.
This is the complete command set — all 4 call sites in v2's source
enumerated. Alarm and auto-stop (c2alarm.dart, c2autostop.dart) make
zero BLE calls — they're host-side computation over already-polled data,
not separate commands, which explains why BLE-name codes (11/12) look
disconnected from the rest (0,2,4) with no gap-fillers in between.
| Func | Name | Notes |
|---|---|---|
0x00 |
Register | via device-specific RegisterDev(), not the shared factory path other devices use — presumed same 0xFFFF handshake, not independently confirmed |
0x02 |
Get version | only v2 device that kept this |
0x04 |
Get realtime data | heartbeat; payload not decoded |
0x0B (11) |
Set BLE name | |
0x0C (12) |
Get BLE name |
Realtime data payload not decoded. _parseC2RealData is ~5x the size
of HP20's equivalent and works over multiple data sub-slices rather than a
flat field set — consistent with a C2ProtocolGroup class in c2model.dart,
suggesting a multi-channel/multi-group record (plausible for a multi-port
tester). Not tractable to hand-trace at the depth used elsewhere in this
doc; dynamic capture (Frida hook or BLE sniff) is the likely path if this
is needed. Treat C2 as identify+BLE-name only until then.
Application-level, not wire protocol:
- Local history/logging (Realm DB per device) — no wire concept of stored history, only live polling.
- ND1's GPS tagging — sourced from host location services.
- Firmware update/OTA — no such commands found in either app version.
- C0, DP100 — not present in either app (see Device registry).
v1.8: ATKMobileBLE.dll extracted from the Xamarin assembly-store blob
(assemblies.blob, XABA/XALZ format) and IL-decompiled with
ilspycmd — near-exact C# source.
v2.0.12: libapp.so (Dart AOT snapshot) disassembled with
blutter against the exact Dart
runtime version (3.10.3, auto-detected), unobfuscated so class/method
names survive. Output is annotated ARM64 pseudocode, hand-traced for the
facts above. Function codes were found as literal arguments immediately
preceding sendCMDWaitResult(WithData) calls; the DevType table came
from constant-pool object dumps (pp.txt/objs.txt in blutter's output).
Sections marked "Ghidra-confirmed" were independently re-derived from an
actual Ghidra decompile and cross-checked against the blutter-based trace.
Tooling for this lives in ghidra/ — apply_blutter_symbols.py/.java
apply blutter's ~19k recovered symbol names to a Ghidra project. Two
non-obvious fixes needed: Dart's minimal ELF wrapper marks the instructions
segment non-executable (the Flutter engine mprotects it at runtime
instead), so Ghidra's default analysis finds nothing until that's flipped;
and many Dart AOT functions end in a branch to a shared runtime stub
rather than ret, which breaks Ghidra's normal flow-following function
boundaries unless bodies are pinned to blutter's own address ranges. See
ghidra/README.md for details.