For developers

Open Bluetooth protocol

Read force data from CableMate and PlateMate straight into your own app. The protocol is free to use, with no licence or agreement needed.

Overview

Both devices use standard Bluetooth Low Energy (GATT). Any BLE library works — CoreBluetooth, Android BLE, flutter_blue_plus, Web Bluetooth or Python's bleak. No pairing or bonding is needed: scan, connect, subscribe to notifications, and send 3-byte commands.

  • CableMate has Bluetooth built in. Its advertised name contains Cable (for example CableMate); match on that rather than the full name.
  • PlateMate connects over Bluetooth through the ForceMate Wireless module plugged into its USB port. The module advertises as FM BLE.

Only one app can be connected to a device at a time. If the ForceMate app is connected, close it first.

Request a larger MTU after connecting (CableMate accepts up to 185 bytes, the Wireless module up to 512). Each notification then carries several samples, which you need at higher sample rates.

This page describes CableMate firmware 13, ForceMate Wireless firmware 1.5 and PlateMate firmware 1.9.0. Last updated 5 October 2026.

CableMate

Services and characteristics

ServiceCharacteristicUUIDAccessContents
Device Information (0x180A) Manufacturer Name 0x2A29 Read UTF-8 string, "CC Athletics"
Device Information (0x180A) Model Number 0x2A24 Read UTF-8 string: the serial number, e.g. "CABLE-R3B1-1224-0012" or "CABLE12" (see Serial numbers)
Device Information (0x180A) Firmware Revision 0x2A26 Read 1 byte, unsigned integer (e.g. 13)
Battery (0x180F) Battery Level 0x2A19 Read 1 byte, 0–100 %
Battery (0x180F) Battery Power State 0x2A1A Read, Notify 1 byte bit field: 0x04 = on battery, 0x05 = charging
CableMate 9eadb0d7-2edb-4679-88b5-82bdbf09e9e2 Data & commands 9e508bce-63d9-4cdf-885a-7fcb2045e577 Write, Write without response, Notify Write 3-byte commands; force samples and command responses arrive as notifications

Commands and data share one characteristic. Subscribe to its notifications, then write commands to it.

Commands

Every command is exactly 3 bytes: [command, value 1, value 2]. Unused values are 0. Use a write with response.

Bytes (hex)CommandWhat it does
0D 01 00 Start streaming Starts sending samples. Streaming also starts automatically when you connect.
0D 00 00 Stop streaming Stops sending samples. The device stays connected.
0A 00 00 Get firmware version Answers with one notification of 1 byte: the firmware version.
01 RR 00 Set sample rate RR: 00 = 20 Hz, 02 = 88.77 Hz (default), 03 = 173.09 Hz, 06 = 988.4 Hz.
0E 01 00 Load cell power on Turns on the load-cell supply (on by default).
0E 00 00 Load cell power off Turns off the load-cell supply to save battery. No valid samples until it is on again.

Data format

Each sample is 19 bytes of ASCII text: an 8-digit timestamp, a space, an 8-character signed reading, then \r\n. One notification usually holds several samples back to back. Split the stream on \r\n.

00123456 -0004521\r\n
  • Timestamp: milliseconds since the CableMate was switched on.
  • Reading: calibrated ADC counts. The device's own calibration is already applied. Pulling on the cable makes the value more negative.
  • A 1-byte notification is the reply to Get firmware version, not a sample.

Converting to newtons

Multiply the reading by 0.000913315 to get newtons. Flip the sign if you want tension to be positive (the ForceMate app does). Zero the signal (tare) with the cable unloaded before each measurement.

Where that number comes from: a 200 kg, 2 mV/V load cell on a 24-bit ADC at gain 128 gives 10,737.4 counts per kg, and 9.80665 N / 10,737.4 = 0.000913315 N per count.

Good to know

  • Streaming starts by itself when you connect and stops when you disconnect.
  • CableMate stops advertising 5 minutes after it is switched on or last disconnected, to save battery. If you can't find it, switch it off and on again.
  • The timestamp only has room for 8 digits, so it runs out after about 27 hours of continuous use. Switching the device off and on resets it.

Example (Python)

import asyncio
from bleak import BleakClient, BleakScanner

DATA = "9e508bce-63d9-4cdf-885a-7fcb2045e577"
START = bytes([0x0D, 0x01, 0x00])
STOP = bytes([0x0D, 0x00, 0x00])
NEWTON_PER_COUNT = 0.000913315  # see "Converting to newtons"

buffer = bytearray()

def on_data(_, data: bytearray):
    buffer.extend(data)
    while (end := buffer.find(b"\n")) != -1:
        line = bytes(buffer[: end + 1])
        del buffer[: end + 1]
        if len(line) != 19:  # 1-byte command replies etc.
            continue
        parts = line.decode("ascii").split()
        if len(parts) == 2:
            t_ms, counts = int(parts[0]), int(parts[1])
            force_n = -counts * NEWTON_PER_COUNT  # tension positive
            print(t_ms, round(force_n, 2))

async def find_cablemate():
    found = await BleakScanner.discover(timeout=10, return_adv=True)
    for device, adv in found.values():
        if "cable" in (adv.local_name or device.name or "").lower():
            return device
    raise SystemExit("No CableMate found")

async def main():
    async with BleakClient(await find_cablemate()) as client:
        await client.start_notify(DATA, on_data)
        await client.write_gatt_char(DATA, START, response=True)
        await asyncio.sleep(10)
        await client.write_gatt_char(DATA, STOP, response=True)

asyncio.run(main())

PlateMate (via ForceMate Wireless)

The ForceMate Wireless module plugs into the PlateMate's USB port. For a dual-plate setup, plug it into one plate and connect the two plates with the plate-to-plate cable as usual. The module sends data from both plates.

Services and characteristics

ServiceCharacteristicUUIDAccessContents
Device Information (0x180A) Manufacturer Name 0x2A29 Read UTF-8 string
Device Information (0x180A) Model Number 0x2A24 Read UTF-8 string: the Wireless module serial number, e.g. "BRIDGE-R1B1-1025-0007" (see Serial numbers)
Device Information (0x180A) Firmware Revision 0x2A26 Read UTF-8 string, e.g. "1.5"
Battery (0x180F) Battery Level 0x2A19 Read 1 byte, 0–100 %
Battery (0x180F) Charging 0x2A1A Read, Notify 1 byte: 0 = on battery, 1 = charging (not the standard bit field)
Wireless 70684184-2b59-9db3-314c-8d1a31287b92 Force data 9e508bce-63d9-4cdf-885a-7fcb2045e577 Notify Binary force packets (see below). Writes are ignored.
Wireless 70684184-2b59-9db3-314c-8d1a31287b92 PlateMate commands fc164a04-36f1-4419-b850-22f9824971da Write, Notify 3-byte commands passed straight through to the PlateMate; its replies come back as notifications
Wireless 70684184-2b59-9db3-314c-8d1a31287b92 Wireless module commands 80684184-2b59-9db3-314c-8d1a31287b92 Write, Notify 3-byte commands for the Wireless module itself
Wireless 70684184-2b59-9db3-314c-8d1a31287b92 Plate connected 927b2831-1a8d-4c31-b39d-592b8441687d Read 1 byte: 1 = a plate is plugged in over USB, 0 = none
Wireless 70684184-2b59-9db3-314c-8d1a31287b92 Plate serial number 7e684184-2b59-9db3-314c-8d1a31287b92 Read UTF-8 string: serial number of the plugged-in plate, in any of the three formats (see Serial numbers)
Wireless 70684184-2b59-9db3-314c-8d1a31287b92 Plate type 82684184-2b59-9db3-314c-8d1a31287b92 Read 1 byte: 3 = PlateMate (0 = GroinMate, 1 = NordicMate, 4 = not a CC Athletics device)

The force data characteristic has the same UUID as the CableMate one. Look it up under the Wireless service so your app talks to the right device.

PlateMate commands

Write these 3-byte commands to the PlateMate commands characteristic. Subscribe to it first to get the replies.

Bytes (hex)CommandWhat it does
0D 01 00 Start streaming Starts sending samples. Send this after connecting — the ForceMate app may have left streaming off.
0D 00 00 Stop streaming Stops sending samples.
0A 00 00 Get plate info Answers with 5 bytes: firmware major, minor, build, second plate connected (1/0), ADC type.
0E 01 00 Load cell power on The Wireless module already sends this when you connect.
0E 00 00 Load cell power off The Wireless module sends this when you disconnect, to save battery.

Wireless module commands

Write these to the Wireless module commands characteristic. The module answers with [02, mode, FF] on success or [02, mode, 00] on failure.

Bytes (hex)ModeWhat it does
02 00 00 Total force per plate (default) One value per plate: the sum of its four load cells. Packet type 0 or 1.
02 01 00 Individual load cells Four values per plate, one per load cell. Packet type 2 or 3. Use this for centre of pressure.

Data format

PlateMate samples at 960 Hz. Each sample is one binary packet. One notification holds several packets back to back.

ByteContents
0Start marker 0x0D
1Packet type (table below)
2Sequence number, 0–255, wraps around. A gap means packets were lost.
3 …Force values in newtons, 32-bit float, little-endian, 4 bytes each
lastChecksum: sum of all previous bytes in the packet, modulo 256
TypeValuesPacket length
0x00 1 value: total force, plate 1 8 bytes
0x01 2 values: total force, plate 1 then plate 2 12 bytes
0x02 4 values: load cells 1–4 of plate 1 20 bytes
0x03 8 values: load cells 1–4 of plate 1, then 1–4 of plate 2 36 bytes

Example: one plate reading 700 N, sequence number 5:

0D 00 05 00 00 2F 44 85
  • Plate 1 is the plate the Wireless module is plugged into. Plate 2 is the plate connected to it by cable.
  • Values are already in newtons, with each plate's factory calibration applied. Tare (zero) with the plates unloaded before each measurement.
  • The byte 0x0D can also appear inside a float, so always check the packet type, the length and the checksum, and throw away packets that fail.
  • There is no timestamp in the packet. Use the sequence number and the sample rate.

Example (Python)

import asyncio, struct
from bleak import BleakClient, BleakScanner

DATA = "9e508bce-63d9-4cdf-885a-7fcb2045e577"
PLATE_CMD = "fc164a04-36f1-4419-b850-22f9824971da"
START = bytes([0x0D, 0x01, 0x00])
STOP = bytes([0x0D, 0x00, 0x00])
CHANNELS = {0: 1, 1: 2, 2: 4, 3: 8}  # packet type -> number of values

buffer = bytearray()

def on_data(_, data: bytearray):
    buffer.extend(data)
    offset = 0
    while offset < len(buffer):
        if buffer[offset] != 0x0D:
            offset += 1  # not a packet start
            continue
        if offset + 4 > len(buffer):
            break  # wait for more bytes
        count = CHANNELS.get(buffer[offset + 1])
        if count is None:
            offset += 1
            continue
        size = 3 + count * 4 + 1
        if offset + size > len(buffer):
            break  # wait for the rest of the packet
        packet = bytes(buffer[offset : offset + size])
        if sum(packet[:-1]) & 0xFF != packet[-1]:
            offset += 1  # false start (0x0D inside a value); resync
            continue
        offset += size
        sequence = packet[2]
        values = struct.unpack("<%df" % count, packet[3:-1])
        print(sequence, [round(v, 1) for v in values])
    del buffer[:offset]

async def find_wireless():
    found = await BleakScanner.discover(timeout=10, return_adv=True)
    for device, adv in found.values():
        if "fm ble" in (adv.local_name or device.name or "").lower():
            return device
    raise SystemExit("No ForceMate Wireless found")

async def main():
    async with BleakClient(await find_wireless()) as client:
        await client.start_notify(DATA, on_data)
        await client.write_gatt_char(PLATE_CMD, START, response=True)
        await asyncio.sleep(10)
        await client.write_gatt_char(PLATE_CMD, STOP, response=True)

asyncio.run(main())

Serial numbers

Serial numbers come in three formats, depending on when the device was made. All three are in use, so your app should accept all of them. Treat the serial as a string you store and show; to tell device types apart, use the advertised name and the Plate type characteristic rather than the serial.

FormatPatternExampleParts
Short (current) PP-RnnMMYY-NNNN PL-R070526-0006 2-letter prefix, revision, month + year, counter. 15 characters.
Long PREFIX-RnBn-MMYY-NNNN PLATE-R5B1-0125-0001 Prefix, revision + batch, month + year, counter.
Legacy PREFIXnn PLATE31 Prefix and a number, no dashes. Early devices only.
DeviceShort prefixLong and legacy prefix
PlateMate PL PLATE
CableMate — CABLE
ForceMate Wireless — BRIDGE
GroinMate GR GROIN
NordicMate NO NORDIC

The counter can be 2 to 4 digits. Legacy serials carry no revision or date.

import re

def parse_serial(serial: str) -> dict:
    s = serial.strip()
    if m := re.fullmatch(r"([A-Z]{2})-R(\d{2})(\d{2})(\d{2})-(\d{2,4})", s):
        prefix, rev, month, year, counter = m.groups()
    elif m := re.fullmatch(r"([A-Z]+)-R(\d+)B\d+-(\d{2})(\d{2})-(\d{2,4})", s):
        prefix, rev, month, year, counter = m.groups()
    elif m := re.fullmatch(r"([A-Z]+)(\d+)", s):
        return {"prefix": m[1], "revision": 0, "made": None, "counter": int(m[2])}
    else:
        return {"prefix": None, "revision": 0, "made": None, "counter": None}
    return {"prefix": prefix, "revision": int(rev),
            "made": f"20{year}-{month}", "counter": int(counter)}

The examples are trimmed-down versions of the scripts we use to test CableMate and the Wireless module in production. They need Python 3.8+ and pip install bleak.

Not covered

Firmware updates and the calibration commands are not part of the public protocol. They can overwrite a device's factory calibration or leave it unusable. Please don't send command numbers that aren't listed on this page.

Our devices also have an open USB protocol. It isn't documented here yet — email [email protected] for details.

Building an integration? We'd like to hear about it — and to let you know before the protocol changes. Get in touch.

If something doesn't work as described here, email [email protected]. Include the device's serial number and firmware version, and what you sent and received.