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 exampleCableMate); 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
| Service | Characteristic | UUID | Access | Contents |
|---|---|---|---|---|
| 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) | Command | What 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
| Service | Characteristic | UUID | Access | Contents |
|---|---|---|---|---|
| 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) | Command | What 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) | Mode | What 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.
| Byte | Contents |
|---|---|
| 0 | Start marker 0x0D |
| 1 | Packet type (table below) |
| 2 | Sequence number, 0–255, wraps around. A gap means packets were lost. |
| 3 … | Force values in newtons, 32-bit float, little-endian, 4 bytes each |
| last | Checksum: sum of all previous bytes in the packet, modulo 256 |
| Type | Values | Packet 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
0x0Dcan 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.
| Format | Pattern | Example | Parts |
|---|---|---|---|
| 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. |
| Device | Short prefix | Long 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.