State:
live-tested— bench-validated on an Aneng AN9002 (device-type AB_300, 11-byte) and a ZOYI ZT-5B (device-type S_5G, 10-byte) — see Verification. Driver:packages/protocol/src/drivers/bdm.ts. Source: ported fromwebspiderteam/Bluetooth-DMM-For-WindowsDecoderBluetoothDMM.cs(BDMDecode, 11-byte path), verified vs 36 annotated frames inBinary raw data.md.Device-types.
bdmdispatches on descrambledbyte[2]: AB_300 (3, 11 B) and S_5G (2, 10 B) are decoded; QB_5G (1) and P_66 (4) are not. The sections below describe the AB_300 11-byte layout in detail; S_5G shares the digit decode but uses a different annunciator bit layout (itsmax/min/autobit positions are not yet confirmed and read asfalse).
The bdm driver decodes a large family of rebadged "Bluetooth DMM" handheld multimeters — sold under the Aneng, BSIDE, ZOYI and BABATools brands — that all expose the same GATT layout (DevType 0 / service 0xFFF0) and the same on-wire frame. The meter does not handshake or answer requests; the moment a client subscribes to the notify characteristic it free-streams one 11-byte notification per LCD update. Each byte is XOR-scrambled with a fixed 11-element key; descrambling yields an 88-bit field from which four 7-segment digits, a set of unit annunciator bits, AC/DC bits, diode/continuity bits and status flags (max/min/hold/rel/auto/low-battery) are read at fixed bit offsets. There is no checksum and no sync word; framing keys off the constant first two raw bytes (0x1B 0x84). The driver is receive-only — it exposes no controls. Everything below is derived from bdm.ts; bit offsets and the descramble key are quoted directly from the code.
One decoder unlocks roughly a dozen rebadged clones. The model list comes from the driver header comment (bdm.ts:2-3):
| Brand | Models |
|---|---|
| Aneng | V05B, AN9002, ST207, AN999S |
| BSIDE | ZT-5B, ZT-300AB, ZT-5BQ |
| ZOYI | ZT-5B, ZT-300AB, ZT-5BQ, ZT-5566SE |
| BABATools | AD900 |
These meters advertise inconsistent BLE names ("BDM" is the common prefix), so discovery leans on the service-UUID filter rather than the name (bdm.ts:250-256).
GATT profile (bdm.ts:242-244, 253):
| Role | UUID |
|---|---|
| Service | 0000fff0-0000-1000-8000-00805f9b34fb (0xFFF0) |
| Notify | 0000fff4-0000-1000-8000-00805f9b34fb (0xFFF4) |
| Write | 0000fff3-0000-1000-8000-00805f9b34fb (0xFFF3) |
The write characteristic is declared for profile-completeness only; the driver never writes (no handshake, no keep-alive, no controls).
Routing. The 0xFFF0 service is shared by several unrelated families (owon-plus, owon-old, voltcraft), so service UUID alone is not enough to pick the decoder. match() accepts the device when it advertises 0xFFF0 or its name starts with "BDM" (bdm.ts:255-256), and the session then disambiguates by sniffing the first raw notification frame against each candidate driver's sniff() predicate.
Frame-sniff rule (looksLikeBdmFrame, bdm.ts:238-240). A frame is a BDM frame iff it is exactly 11 bytes long and starts with the constant raw header 0x1B 0x84:
bytes.length === 11 && bytes[0] === 0x1b && bytes[1] === 0x84This is distinct from the other 0xFFF0 families by length alone — owon-plus is 6 bytes, owon-old is 14, voltcraft is 15 (bdm.ts:235-236).
None. The driver's handshake() is a no-op (bdm.ts:261-263) and onRequest() is a no-op (bdm.ts:266-268): there is no AB-CD sync, no challenge/response, and no request/response keep-alive in this family. Subscribing to the 0xFFF4 notify characteristic is sufficient — the meter streams measurement notifications immediately and continuously.
One BLE notification carries exactly one 11-byte frame (FRAME_LEN = 11, bdm.ts:23). The frame has:
- No AB-CD sync word.
- No checksum.
- A constant 2-byte raw header: the first two raw bytes are always
0x1B 0x84(SYNC0/SYNC1,bdm.ts:25-26). This is the descrambled-back constant0x1B84and is used purely to sync the stream.
XOR descramble. Each raw byte i is XOR'd with DATASHIFT[i] and masked to 8 bits, then the 11 resulting bytes are concatenated MSB-first into an 88-character bit string the source calls newValue (descramble, bdm.ts:51-58):
bits += ((bytes[i] ^ DATASHIFT[i]) & 0xff).toString(2).padStart(8, '0');The fixed key (bdm.ts:21) is the first 11 of the open-source Bluetooth-DMM-For-Windows project's 20-byte datashift array (only 11 are used for the 11-byte BDM frame):
DATASHIFT = [65, 33, 115, 85, 162, 193, 50, 113, 102, 170, 59]
(Source literal { 65, 33, 115, 85, 256-94, 256-63, 50, 113, 102, 256-86, 59, ... }; the 256-n terms reduce to 162, 193, 170.)
After descrambling, all subsequent decoding indexes individual bits of the 88-bit string via on(i) = (bits[i] === '1') (bdm.ts:124). Bit 0 is the MSB of raw byte 0; bit 8*k is the MSB of raw byte k.
Framer / resync (BdmFramer, bdm.ts:199-231). The framer buffers incoming chunks and, although in practice one notification equals one frame, tolerates split/coalesced notifications like the uni-t FrameParser. On each pass it calls sync() then, if at least 11 bytes are buffered, emits a measurement frame of the first 11 bytes and consumes them (bdm.ts:205-211). sync() (bdm.ts:218-230) discards leading bytes until the buffer starts with 0x1B, and if a second byte is present discards again unless it is 0x84 — i.e. it slides the window to the next plausible 0x1B 0x84 header. reset() clears the buffer (bdm.ts:214-216).
decodeBdm(bytes, ts) (bdm.ts:121-194) is pure and never throws; it degrades gracefully. A frame shorter than 11 bytes returns a blank reading (bdm.ts:122, blank shape at bdm.ts:61-85). An unknown 7-segment glyph renders as '?' (→ non-numeric → displayValue null) rather than erroring.
Four 7-segment digits are read in a loop (bdm.ts:128-138). Each digit n (0..3) has a prefix bit (sign or decimal point), then a 7-bit segment field split into a 3-bit "first" group and a non-adjacent 4-bit "second" group:
| Per-digit field | Bit offset | Notes |
|---|---|---|
first (3 bits) |
(n+3)*8 .. +3 |
first half of segment lookup key |
| prefix bit | (n+3)*8 + 3 |
when set, emits prePoints[n] |
second (4 bits) |
(n+4)*8 + 4 .. +4 |
second half of segment lookup key |
The prefix glyphs are prePoints = ['-', '.', '.', '.'] (bdm.ts:128): digit 0's prefix is a leading minus sign, digits 1-3's prefixes are decimal points. The lookup key is the 7-char string first + second, resolved through the SEG table (bdm.ts:29-49):
Key (first+second) |
Glyph | Key | Glyph | |
|---|---|---|---|---|
0000000 |
(space) | 1111011 |
0 |
|
1111110 |
A |
0001010 |
1 |
|
0010011 |
U |
1011101 |
2 |
|
0110101 |
T |
1001111 |
3 |
|
0010111 |
O |
0101110 |
4 |
|
1110101 |
E |
1100111 |
5 |
|
1110100 |
F |
1110111 |
6 |
|
0110001 |
L |
1001010 |
7 |
|
0000100 |
- |
1111111 |
8 |
|
1101111 |
9 |
A U T O, E, F, L exist so the display can spell AUTO, error words (EFLO-style), and the L of an OL overload. The concatenated prefix + glyph for all four digits forms text, which is trimmed to displayText (bdm.ts:136-138).
Individual bits, accumulated in source order so metric prefixes land before the base unit ("mV", "kΩ", "µA", "nF") (bdm.ts:142-158):
| Bit | Appends | Bit | Appends |
|---|---|---|---|
| 57 | °C |
74 | m |
| 58 | °F |
75 | V |
| 64 | n |
76 | M |
| 65 | m |
77 | k |
| 66 | µ |
78 | Ω |
| 67 | F |
79 | Hz |
| 69 | % |
85 | µ |
| 72 | A |
84 | m |
| 68/73 | (AC/DC, see below) |
So displayUnit is assembled by concatenation: e.g. bit 74 (m) + bit 75 (V) → mV; bit 77 (k) + bit 78 (Ω) → kΩ; bit 66 (µ) + bit 72 (A) → µA; bit 64 (n) + bit 67 (F) → nF; bits 57/58 → °C/°F. Current prefixes appear in two places (bits 65/66 near the cap group, and bits 84/85 near the current group) because the meter drives the same A annunciator (bit 72) with prefix bits from whichever range bank is active.
| Field | Bit(s) | Code ref |
|---|---|---|
acdc |
'AC' if bit 68, else 'DC' if bit 73, else '' |
bdm.ts:160 |
diode |
bit 56 | bdm.ts:161 |
cont (continuity) |
bit 28 | bdm.ts:162 |
Reading.flags (bdm.ts:182-192):
| Flag | Bit | Code ref |
|---|---|---|
max |
71 | bdm.ts:183 |
min |
70 | bdm.ts:184 |
hold |
59 | bdm.ts:185 |
rel |
30 | bdm.ts:186 |
auto |
87 | bdm.ts:187 |
lowBattery |
31 | bdm.ts:188 |
hvWarning |
— | always false — not surfaced in the 11-byte frame (bdm.ts:189) |
peakMax |
— | always false (bdm.ts:190) |
peakMin |
— | always false (bdm.ts:191) |
Overload is detected textually: overload = displayText.includes('L') (bdm.ts:164). Because the decimal-point prefix floats with the selected range, the overload display can read "OL", "0.L", "0L." or ".0L" — all contain L. A reading is numeric only when it is not overloaded and matches the NUMERIC regex ^-?\d*\.?\d+$ (bdm.ts:114, 165). displayValue is Number(displayText) when numeric, else null (bdm.ts:166).
unitInfo(displayUnit) (types.ts:168-174) splits the displayed unit into an SI base and a prefix exponent (n→−9, µ→−6, m→−3, k→3, M→6). baseUnit is the SI base (e.g. Ω from kΩ, V from mV, F from nF); baseValue = displayValue * 10**exp (bdm.ts:168-169), so range changes (mV↔V, kΩ↔MΩ) keep a continuous normalized curve. bargraph is always 0 (bdm.ts:181) — this family has no analog bar.
functionFor(baseUnit, acdc, diode, cont) (bdm.ts:89-112) maps the decoded unit + mode to a range-independent function key so range steps stay one chart segment while a real mode change splits:
| Condition | function |
|---|---|
diode |
DIODE |
cont |
CONT |
baseUnit === 'V' |
${acdc}V (e.g. ACV/DCV) or V if no AC/DC |
baseUnit === 'A' |
${acdc}A or A |
baseUnit === 'Ω' |
OHM |
baseUnit === 'F' |
CAP |
baseUnit === 'Hz' |
Hz |
baseUnit === '%' |
% |
baseUnit === '°C' |
°C |
baseUnit === '°F' |
°F |
| else | baseUnit or '?' |
Note the diode/continuity checks come first, so they win over the unit-based mapping (a continuity reading carries Ω but reports CONT). The result feeds quantityKey (types.ts:49-51) as function|acdc, which controls chart-segment splitting.
Receive-only. The driver declares no controls map (none in bdm.ts:246-273), and the write characteristic, while present in the GATT profile, is never used. There are no soft-button commands (RANGE/SELECT/HOLD/REL/Hz/MAX-MIN) for this family.
verification: 'live-tested'. Two lines of evidence agree on this decoder:
- The original port from
DecoderBluetoothDMM.csBDMDecode(thedata.Count() == 11path), cross-checked against the 36 annotated frames in the source repo'sBinary raw data.md. - Physical hardware — bench-validated on an Aneng AN9002 (AB_300, 11-byte) and a ZOYI ZT-5B (S_5G, 10-byte). Confirmed live for the ZT-5B: value, unit (prefix+base), AC/DC, diode, continuity, hold, rel, low-battery. The S_5G
max/min/autobit positions remain unconfirmed and are leftfalse.
The two decoded device-types share the 4-digit value decode (descrambled bytes 3–7) but differ in annunciator bit layout; the Unit annunciators and Status flags tables above describe the AB_300 (11-byte) layout. For AB_300, max/min (bits 71/70) and % (bit 69) are decoded but not independently cross-confirmed. hvWarning, peakMax, and peakMin are not represented in the frame and are hard-coded false. The framer's split/coalesced-notification handling is defensive; in practice one notification equals one frame, so multi-frame resync is untested against real traffic.
- Driver:
packages/protocol/src/drivers/bdm.ts - Shared types:
packages/protocol/src/drivers/types.ts(Driver/DriverFramer),packages/protocol/src/types.ts(Reading,unitInfo) - Upstream:
webspiderteam/Bluetooth-DMM-For-Windows—DecoderBluetoothDMM.csBDMDecode(11-byte path),ParsedigitBDM(7-segment table),datashiftkey; verified vsBinary raw data.md(36 annotated frames).