Skip to content

Instantly share code, notes, and snippets.

@holly-hacker
Created August 15, 2026 00:37
Show Gist options
  • Select an option

  • Save holly-hacker/dfa54111cbdbe72f97bf5fcd02441307 to your computer and use it in GitHub Desktop.

Select an option

Save holly-hacker/dfa54111cbdbe72f97bf5fcd02441307 to your computer and use it in GitHub Desktop.
LLM-generated analysis of the Alientek's bluetooth protocol

Alientek ATK-XTOOL BLE Protocol

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.

Transport (BLE GATT)

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.

Frame format

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):

  1. Scan for 0xDF; discard anything before it.
  2. Read Dev (2B), Function (1B), Length (1B).
  3. Wait for Length + 5 bytes buffered, then read Data.
  4. Read checksum byte, validate.
  5. On mismatch, or if a partial frame sits unresolved >500ms, reset and resume scanning from the next byte (resync after corruption).
  6. 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.

Handshake

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 of 0xFFFF. Dev is static per-device-type routing, not a session/connection id.

Request/response model

  • Send a frame; poll (non-blocking) until a reply with matching Function arrives, 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.

Device registry

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.


ND1 — radiation dosimeter

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, or raw / 100000 → mSv if that result would exceed 999.99.
  • RealtimeRadiationValue: if RadiationUnit ≥ 3 (CPM/CPS), used as-is (integer count). Else raw / 100, or raw / 100000 if >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.

DM40 — digital multimeter

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=V 1=A 23=F(capacitance) 4=Diode/Cont. 5=Freq/Temp
  • GearRange (bits3-5): range index; 6/7 = AUTO/AUTO+ sentinels
  • GearMode (bits6-7): V/A: 0=DC 1=AC 2=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.) and GearMode==1 (Continuity) and MainValueMode==2: raw value has +65520 baked 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).


EL15 — electronic load

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=Battery
  • WorkMode (Basic): 0=CC 1=CV 2=CR 3=CP
  • WorkMode (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.


HP15 — hot plate (v1.8 only)

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.


HP20 — hot plate (v2 only, successor to HP15)

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:

  • getRepair response: 8 sequential values, call them G1-G8, offsets [0-1] through [14-15]. Individual meanings unknown.

  • setRepair request 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 of getRepair'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.dart or dynamic testing to resolve.


C2 — USB/power tester (v2 only)

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.


Out of scope

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

Appendix: analysis method

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment