Skip to content

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

  1. Check crc32(buffer) == crc from the status. Mismatch → discard and retry.
  2. gunzip(buffer) → UTF-8 bytes.
  3. 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 FC7B and connect (no pairing, no authentication).
  • Read EC01 status; honour ready, size, crc.
  • Offset/chunk loop over EC01/EC02 until size bytes 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.