UNA Watch β BLE File Transfer Service (FTS)ο
This document is the wire protocol for reading and writing files on a UNA Watch over Bluetooth Low Energy. It is what a companion app or integration implements against; it does not describe the watchβs internal implementation.
The base protocol is the Adafruit CircuitPython BLE File Transfer
protocol
(service 0xFEBB). UNA implements that command set and adds a small set of
backward-compatible fast-transfer extensions (protocol version 5): windowed
reads and writes, a file digest query, and safe resume. A client that only knows
the classic (version 4) protocol interoperates unchanged.
Conventionsο
All multi-byte integers are little-endian.
All request/response layouts below are packed (no padding beyond the
reservedbytes shown). Byte offsets are given for each field.Paths are UTF-8, not NUL-terminated on the wire; their length is carried in a
pathLengthfield. Absolute paths,/-separated (e.g./Apps/Workout/Activity/202607/activity_...fit).Times are nanoseconds since the Unix epoch (
uint64).
Service and characteristicsο
Item |
UUID |
Properties |
|---|---|---|
File Transfer Service |
|
β |
Version |
|
Read |
Raw Transfer |
|
Write Without Response, Notify |
Note. The characteristic 16-bit IDs are UNA-specific (
ADAF0001/ADAF0002) and differ from Adafruitβs upstream (ADAF0100/ADAF0200). Use the UUIDs exactly as listed above β they are what the watch exposes.
All operations are a request written to Raw Transfer and one or more responses delivered as notifications on the same characteristic. Enable notifications (write the CCCD) before issuing commands.
Security. The watch requires a bonded, encrypted connection β pair with the watch before using FTS. Reads and writes on the characteristics are rejected on an unencrypted link.
Capability negotiationο
Read the Version characteristic β a single uint32 (little-endian):
4 β classic protocol only (Adafruit-compatible, stop-and-wait).
β₯ 5 β UNA fast-transfer extensions are available.
Gating rules for a version-5 client:
Read windowing needs no gate β it degrades automatically against a v4 watch (see Read windowing under the version-5 extensions below).
Write windowing and
DIGESTMUST be gated on version β₯ 5.
Command summaryο
ID |
Name |
Direction |
|---|---|---|
|
READ |
request |
|
READ_DATA |
response |
|
READ_PACING |
request (more) |
|
WRITE |
request |
|
WRITE_PACING |
response |
|
WRITE_DATA |
request (data) |
|
DELETE / status |
request / response |
|
MKDIR / status |
request / response |
|
LISTDIR / entry |
request / response |
|
MOVE / status |
request / response |
|
DIGEST / status |
request / response (UNA extension) |
Status codes (the status byte in responses):
Value |
Meaning |
|---|---|
|
OK |
|
ERROR (generic) |
|
ERROR_NO_FILE |
|
ERROR_PROTOCOL (malformed/wrong length) |
|
ERROR_READ_ONLY |
Notification sizing β read this firstο
A GATT notification cannot carry more than the negotiated ATT payload = MTU β
3 bytes. Every FTS response has a fixed header, so the usable data per
notification is smaller still. For READ_DATA (16-byte header) at MTU 220:
data per notification β€ (MTU β 3) β 16 = 201 bytes
Do not assume one request maps to one notification of the size you asked for.
A version-5 watch delivers a large read as multiple READ_DATA notifications (see
below); each READ_DATA is self-describing (chunkOffset, chunkLength), so
reassemble by offset and never trust a chunkLength beyond what the notification
actually delivered.
Classic operations (protocol version 4)ο
Read a fileο
READ 0x10 (request):
Off |
Size |
Field |
Notes |
|---|---|---|---|
0 |
1 |
command = |
|
1 |
1 |
reserved |
0 |
2 |
2 |
pathLength |
|
4 |
4 |
chunkOffset |
file offset to start at |
8 |
4 |
chunkSize |
bytes requested |
12 |
pathLength |
path |
UTF-8 |
READ_DATA 0x11 (response, one or more notifications):
Off |
Size |
Field |
Notes |
|---|---|---|---|
0 |
1 |
command = |
|
1 |
1 |
status |
|
2 |
2 |
reserved |
|
4 |
4 |
chunkOffset |
offset of this chunk |
8 |
4 |
totalLength |
total file size |
12 |
4 |
chunkLength |
bytes of data that follow |
16 |
chunkLength |
data |
READ_PACING 0x12 (request more) β after consuming a chunk, ask for the next:
Off |
Size |
Field |
Notes |
|---|---|---|---|
0 |
1 |
command = |
|
1 |
1 |
status = |
|
2 |
2 |
reserved |
|
4 |
4 |
chunkOffset |
next offset = contiguous end (first byte not yet received) |
8 |
4 |
chunkSize |
bytes requested |
Classic flow: send READ, receive one READ_DATA, send READ_PACING for the
next chunk, repeat until chunkOffset + chunkLength == totalLength.
Write a fileο
WRITE 0x20 (request):
Off |
Size |
Field |
Notes |
|---|---|---|---|
0 |
1 |
command = |
|
1 |
1 |
reserved |
0 |
2 |
2 |
pathLength |
|
4 |
4 |
offset |
start offset (0 for a fresh write; see Resume below) |
8 |
8 |
currentTime |
ns since epoch (file mtime) |
16 |
4 |
totalSize |
total bytes to be written |
20 |
pathLength |
path |
WRITE_PACING 0x21 (response / ACK):
Off |
Size |
Field |
Notes |
|---|---|---|---|
0 |
1 |
command = |
|
1 |
1 |
status |
|
2 |
2 |
reserved |
|
4 |
4 |
offset |
|
8 |
8 |
truncatedTime |
mtime as stored |
16 |
4 |
freeSpace |
bytes still expected (see below) |
WRITE_DATA 0x22 (request, carries data):
Off |
Size |
Field |
Notes |
|---|---|---|---|
0 |
1 |
command = |
|
1 |
1 |
status = |
|
2 |
2 |
reserved |
|
4 |
4 |
offset |
offset of this chunk |
8 |
4 |
dataSize |
bytes of data that follow |
12 |
dataSize |
data |
Classic flow: WRITE, then send WRITE_DATA chunks in ascending order, waiting
for a WRITE_PACING ACK after each. freeSpace == 0 is the terminal ACK.
bytesAcked = totalSize β freeSpace.
Delete / MkDir / List / Moveο
DELETE 0x30 β 0x31: request {command, reserved, pathLength} + path;
response {command, status}.
MKDIR 0x40 β 0x41: request {command, reserved, pathLength, reserved(4), currentTime(8)} + path; response {command, status, reserved(6), truncatedTime(8)}.
LISTDIR 0x50 β 0x51: request {command, reserved, pathLength} + path
(an absolute directory path, starting with /). One 0x51 notification per
entry, then a terminating entry with entryNumber == totalEntries and
pathLength == 0:
Off |
Size |
Field |
Notes |
|---|---|---|---|
0 |
1 |
command = |
|
1 |
1 |
status |
|
2 |
2 |
pathLength |
name length that follows |
4 |
4 |
entryNumber |
0-based |
8 |
4 |
totalEntries |
|
12 |
4 |
flags |
bit 0 = directory |
16 |
8 |
modificationTime |
ns since epoch |
24 |
4 |
fileSize |
(ignore for directories) |
28 |
pathLength |
name |
entry name (not full path) |
MOVE 0x60 β 0x61: request {command, reserved, oldPathLength(2), newPathLength(2)} + oldPath + one separator byte + newPath; response
{command, status}. Both paths are absolute (start with /).
Fast-transfer extensions (protocol version 5)ο
All of these run on the same ADAF0002 characteristic β no new services or
characteristics.
Read windowingο
Instead of one chunk per round-trip, request a large chunkSize (e.g. 4096).
The watch answers a single READ_PACING with a burst of READ_DATA
notifications totalling up to chunkSize, then waits for the next pacing request.
Each
READ_DATAis self-describing β reassemble bychunkOffset, not arrival order.Pace from the contiguous end: the next
READ_PACING.chunkOffset= the first byte you donβt yet have (initialOffset + contiguous bytes received), so a dropped notification is re-requested rather than skipped β not the raw count of bytes received.Each notification is capped to the payload limit (see Notification sizing above); a large
chunkSizesimply arrives as several notifications.
Auto-degrade: a v4 watch answers a large chunkSize with a single packet, so
a v5 client falls back to classic pacing automatically β no version gate needed.
Recommended window: 4096 bytes. Larger adds little (reads are round-trip bound).
Write windowing (credit-based, with recovery)ο
Stream WRITE_DATA without waiting per-chunk, bounded by a credit window.
Keep at most
WRITE_WINDOWbytes unacked: send back-to-back whilebytesSent β bytesAcked < WRITE_WINDOW; otherwise wait for an ACK.freeSpaceis a contiguous high-water-mark (an intentional v5 refinement of the classic ACK β it makes loss detectable).bytesAcked = totalSize β freeSpaceis the number of bytes held with no gap from the writeβs start; it never advances past a missing chunk. The terminal ACK (freeSpace == 0) is therefore only ever sent for a hole-free file.Send
WRITE_DATAin ascending offset order.Loss recovery (go-back-N). If
bytesAckedstops advancing while bytes are unacked β the window drains tobytesAckedwith no terminal ACK β a chunk was lost. RewindbytesSent = bytesAckedand resume; retransmits overwrite the gap. Arm a short βno ACK progressβ timeout to trigger the rewind.Recommended
WRITE_WINDOW: 2048 bytes (~10 chunks at MTU 220). Larger windows can reduce throughput (more drops β more retransmits) and donβt help β writes are round-trip bound. Raise only with digest-verified testing.
Recommended integrity check: after a windowed write, DIGEST the file and
compare (below).
DIGEST β integrity without read-back (UNA extension)ο
Verify a fileβs contents without transferring it back over BLE.
DIGEST 0x70 (request): {command, reserved, pathLength} + path.
DIGEST_STATUS 0x71 (response, 12 bytes):
Off |
Size |
Field |
Notes |
|---|---|---|---|
0 |
1 |
command = |
|
1 |
1 |
status |
OK / ERROR_NO_FILE / ERROR |
2 |
2 |
reserved |
|
4 |
4 |
fileSize |
total bytes hashed |
8 |
4 |
crc32 |
see below |
crc32 is standard CRC-32 (IEEE 802.3 / zlib: reflected poly 0xEDB88320,
init 0xFFFFFFFF, final XOR 0xFFFFFFFF) over the whole file β identical to
Java java.util.zip.CRC32 and Python zlib.crc32. Compute the expected value
with a stock library and compare crc32 + fileSize.
Resumeο
A WRITE with offset > 0 preserves the existing file head [0, offset) and
overwrites only from offset onward. So after a disconnect mid-write you may
reconnect and continue with WRITE offset = bytesAcked β no need to restart from
0. A fresh WRITE offset = 0 truncates (so a full re-upload also works).
bytesAcked durability. ACKs can lead the watchβs flush to storage. On a
clean disconnect the watch flushes before you reconnect, so [0, bytesAcked)
is committed and you can resume at raw bytesAcked. If the watch instead
reset / lost power mid-write, only data up to its last internal flush is
guaranteed β on reconnect, confirm the committed size (LISTDIR or DIGESTβs
fileSize) before resuming, and a DIGEST after any resumed transfer is cheap
insurance.
Lost terminal ACK. If the final WRITE_PACING is lost, your normal
stallβrewind resends the tail and the watch re-acknowledges the completed file
(freeSpace == 0) β the same loop concludes βdone.β After concluding done, drain
any leftover WRITE_PACING notifications before issuing the next command so a
stale one isnβt mistaken for the next commandβs response.
Compatibility matrixο
Client \ Watch |
v4 watch |
v5 watch |
|---|---|---|
v4 client |
classic |
classic (v5 features simply unused) |
v5 client |
classic (windowing auto-degrades; gate writes/DIGEST off) |
full fast transfer |
Base protocol Β© Adafruit Industries (MIT), extended by UNA. Report protocol issues on this repository.