Measurement Export over Bluetooth LE (testo 320 & 330)
This page describes how a third-party application reads a staged measurement export from a testo device over Bluetooth LE.
- Devices: this interface is available on the testo 320 and testo 330.
- Device role: the device is the BLE peripheral / GATT server; your application is the BLE central / GATT client.
- Payload: the export is gzip-compressed JSON — the same bytes as the testo 300 QR code. An application that already decodes that QR payload reuses its parser unchanged.
Roadmap — changes coming in 2027
The interface described here is currently open and unauthenticated — this is an interim state. From 2027 the EU Cyber Resilience Act (CRA) applies (alongside the Radio Equipment Directive, RED), which will require device interfaces — including this Bluetooth interface — to be secured. How these changes will look is not yet defined. Design your integration defensively so a future security step can be added without breaking your client (e.g. keep the connect/read logic separate, ready for a future unlock step).
Overview & flow
The device exposes a single primary service (FC7B) that carries the measurement-export
characteristics. The standard Device Information Service (0x180A) is exposed alongside it.
sequenceDiagram
participant C as Central (your app)
participant D as Device (testo 330)
C->>D: Scan by name (T320/T330) & connect, no pairing
C->>D: Read EC01 -> {ready, size, crc}
loop until size bytes collected
C->>D: Write EC01 = uint32 LE offset
C->>D: Read EC02 -> one MTU-sized chunk
end
C->>C: verify CRC-32, gunzip, parse JSON
Why chunked?
A GATT attribute read is capped at 512 bytes, and a measurement export is typically larger. The transfer is therefore driven by a client-controlled offset window rather than the ATT read offset: you tell the device where to read from (the control characteristic), then read one MTU-sized slice (the payload characteristic). This works on any GATT stack with plain read/write.
1. Connect
The device does not advertise the FC7B service UUID. Instead it advertises a device name that
starts with T320 or T330 — discover it by scanning for that name prefix, then connect as a BLE
central. FC7B is the primary GATT service you use after connecting (see section 2).
The interface is currently open: the characteristics are plain read/write/notify with no pairing, no bonding and no link-layer encryption, and no application-level authentication. Once connected, the GATT database is immediately accessible — there is no unlock step.
This open advertising and name-based discovery is an interim state and is expected to change with the 2027 EU Cyber Resilience Act update; how exactly is not yet defined (see the roadmap note at the top).
No bonding
Because there is no pairing, do not assume a persistent bond. If your platform cached a stale bond from an earlier firmware, remove it before reconnecting.
2. GATT layout
Primary service FC7B
All custom characteristics live under the testo primary service
0000fc7b-0000-1000-8000-00805f9b34fb. The export characteristics use 128-bit testo vendor UUIDs built
from the vendor base 0000XXXX-c0d1-4e9e-aa76-bb27f81eb485, where the low 16 bits select the
characteristic.
| Characteristic | UUID | Properties | Purpose |
|---|---|---|---|
| Export Control (EC01) | 0000ec01-c0d1-4e9e-aa76-bb27f81eb485 |
read / write / notify | Read: status JSON {ready,size,crc}. Write: uint32 LE read-window offset. Notify: fires when a fresh export is staged. |
| Export Payload (EC02) | 0000ec02-c0d1-4e9e-aa76-bb27f81eb485 |
read | Returns one MTU-sized slice of the gzip-JSON payload, starting at the offset last written to EC01. |
Device Information Service 0x180A
Standard, read-only, using SIG-assigned 16-bit UUIDs. Useful to identify the connected device.
| Field | UUID | Example |
|---|---|---|
| Manufacturer Name | 0x2A29 |
Testo |
| Model Number | 0x2A24 |
material / model number |
| Serial Number | 0x2A25 |
device serial |
| Software Revision | 0x2A28 |
package / SW version |
| Firmware Revision | 0x2A26 |
firmware version (optional) |
3. Reading the export
Step 1 — read the status
Read the control characteristic (EC01). It returns a small UTF-8 JSON object:
{ "ready": true, "size": 20481, "crc": 3735928559 }
| Key | Type | Meaning |
|---|---|---|
ready |
bool | true when an export is staged. |
size |
number | Total length of the compressed payload in bytes. |
crc |
number | CRC-32 (IEEE, as zlib.crc32) over the compressed bytes. |
Optionally subscribe to EC01 notifications to be told when a fresh export becomes available.
Step 2 — the offset / chunk loop
Set the read window by writing a little-endian uint32 offset to EC01, then read EC02 to get the slice
starting at that offset. Repeat until size bytes are collected.
offset = 0
while offset < size:
write EC01 = uint32_le(offset) # set the read window
chunk = read EC02 # one slice, up to (ATT_MTU - 3) bytes, at least 20
append chunk to buffer
offset += len(chunk)
Chunk size
Each EC02 read returns up to ATT_MTU − 3 bytes (the largest read your MTU allows), and never fewer
than 20 bytes, so the transfer always makes progress even at the minimum MTU. A short read simply
means you reached the end — keep appending until offset == size.
Step 3 — verify, decompress, parse
- Check
crc32(buffer) == crcfrom the status. Mismatch → discard and retry. gunzip(buffer)→ UTF-8 bytes.- Parse as JSON. This is the same structure as the testo 300 QR-code payload.
4. Python example
End-to-end using bleak (cross-platform BLE). Use --scan to find
the device by its advertised name (T320/T330), then read. No pairing or authentication is required.
#!/usr/bin/env python3
"""Read a testo measurement export from a device (testo 320 / 330) over Bluetooth LE.
The interface is currently OPEN: no pairing and no authentication are required —
connect, read the control characteristic, then read the payload in MTU-sized chunks
driven by a client-controlled offset window, verify the CRC-32, gunzip and parse.
The payload is the same bytes as the testo 300 QR code.
Requirements:
pip install bleak # cross-platform BLE (macOS/Linux/Windows)
Usage:
python3 read_export_ble.py --scan # discover devices by name prefix (T320/T330)
python3 read_export_ble.py <BLE-ADDRESS> # read the export from that device
python3 read_export_ble.py <BLE-ADDRESS> -o export.bin # also dump the raw gzip stream
Notes:
* <BLE-ADDRESS> is the Bluetooth address/UUID (NOT the WiFi IP). Use --scan to find it.
On macOS bleak reports a CoreBluetooth UUID; on Linux/Windows a MAC like AA:BB:CC:DD:EE:FF.
* The terminal/app running this needs Bluetooth permission (macOS: System Settings ->
Privacy & Security -> Bluetooth).
"""
from __future__ import annotations
import argparse
import asyncio
import gzip
import json
import struct
import sys
import zlib
try:
from bleak import BleakClient, BleakScanner
except ImportError:
sys.exit("bleak is not installed. Run: pip install bleak")
# testo primary service + export characteristics (finalised vendor base).
SERVICE = "0000fc7b-0000-1000-8000-00805f9b34fb" # testo primary service
EC01 = "0000ec01-c0d1-4e9e-aa76-bb27f81eb485" # export control/status (read/write/notify)
EC02 = "0000ec02-c0d1-4e9e-aa76-bb27f81eb485" # export payload (read)
async def scan(timeout: float = 8.0, prefixes: tuple[str, ...] = ("T320", "T330")) -> None:
"""Discover and print testo devices, matched by advertised name prefix.
The device does not advertise the FC7B service UUID; it advertises a name that
starts with e.g. "T320" or "T330". We therefore match on the name prefix (and
still accept a device that happens to advertise FC7B, as a fallback).
"""
pfx = tuple(p.lower() for p in prefixes)
print(f"Scanning {timeout:.0f}s for devices whose name starts with {'/'.join(prefixes)} ...")
devices = await BleakScanner.discover(timeout=timeout, return_adv=True)
hits = []
for dev, adv in devices.values():
name = (adv.local_name or dev.name or "").strip()
uuids = [u.lower() for u in (adv.service_uuids or [])]
by_name = name.lower().startswith(pfx)
by_uuid = SERVICE in uuids or any(u.startswith("0000fc7b") for u in uuids)
if by_name or by_uuid:
hits.append((dev, adv, name))
if not hits:
print(f"No matching devices found (name prefix {'/'.join(prefixes)}). "
"Is the device on and advertising?")
return
print(f"Found {len(hits)} device(s):")
for dev, adv, name in hits:
print(f" {dev.address} rssi={adv.rssi} name={name!r}")
print("\nRun again with the address, e.g.:")
print(f" python3 {sys.argv[0]} {hits[0][0].address}")
async def read_export(address: str, out_path: str | None = None) -> dict:
async with BleakClient(address) as client:
# 1) Connect. The interface is open - no pairing, no authentication.
print(f"Connected to {address}")
# 2) Read export status from the control characteristic.
status = json.loads(bytes(await client.read_gatt_char(EC01)).decode("utf-8"))
if not status.get("ready"):
raise RuntimeError(f"no export staged on the device (status={status})")
size, crc = status["size"], status["crc"]
print(f"Export ready: size={size} bytes, crc={crc}")
# 3) Chunked read: set the window offset on EC01, read one slice from EC02.
buf = bytearray()
while len(buf) < size:
await client.write_gatt_char(EC01, struct.pack("<I", len(buf)), response=True)
chunk = bytes(await client.read_gatt_char(EC02))
if not chunk:
raise RuntimeError("empty chunk - transfer stalled")
buf += chunk
print(f"\r received {len(buf)}/{size} bytes", end="", flush=True)
print()
# 4) Verify CRC-32 over the compressed bytes, then gunzip -> UTF-8 JSON.
if (zlib.crc32(buf) & 0xFFFFFFFF) != crc:
raise ValueError("CRC mismatch - payload corrupt")
if out_path:
with open(out_path, "wb") as f:
f.write(buf)
print(f"Raw gzip payload written to {out_path} "
f"(inspect with: gzip -dc {out_path} | python3 -m json.tool)")
return json.loads(gzip.decompress(buf).decode("utf-8"))
def main() -> None:
ap = argparse.ArgumentParser(description="Read a testo measurement export over BLE.")
ap.add_argument("address", nargs="?", help="BLE address/UUID of the device (see --scan)")
ap.add_argument("--scan", action="store_true", help="discover testo devices by name prefix and exit")
ap.add_argument("--name", default="T320,T330",
help="comma-separated name prefixes to match while scanning (default: T320,T330)")
ap.add_argument("--timeout", type=float, default=8.0, help="scan timeout in seconds (default 8)")
ap.add_argument("-o", "--out", help="also write the raw gzip payload to this file")
args = ap.parse_args()
if args.scan:
prefixes = tuple(p.strip() for p in args.name.split(",") if p.strip())
asyncio.run(scan(args.timeout, prefixes))
return
if not args.address:
ap.error("provide a BLE address, or use --scan to discover one")
data = asyncio.run(read_export(args.address, args.out))
print(json.dumps(data, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
To inspect a saved payload offline, the concatenated EC02 chunks are a plain gzip stream:
gzip -dc export.bin | python3 -m json.tool
5. Minimum implementation checklist
- Discover by service UUID
FC7Band connect (no pairing, no authentication). - Read EC01 status; honour
ready,size,crc. - Offset/chunk loop over EC01/EC02 until
sizebytes collected. - Verify CRC-32, gunzip, parse UTF-8 JSON.
- (optional) Subscribe to EC01 notifications for fresh-export signalling.
- (optional) Read Device Information (
0x180A) to identify the device.