Track sync (SLTB)¶
| Garmin | Wear OS | Apple Watch | Server | |
|---|---|---|---|---|
| Status | ✅ | ✅ | ⏳ | ✅ |
| Encoder / decoder | source/sync/SltbEncoder.mc |
core/…/core/Sltb.kt |
— | app/services/sailever/sltb/decoder.rb |
| Segments, queue | source/sync/TrackSession.mc, source/sync/TrackQueue.mc |
core/…/core/TrackSession.kt, core/…/core/TrackQueue.kt |
— | — |
| Upload | source/sync/SyncEngine.mc |
wear/…/sync/SyncEngine.kt |
— | app/controllers/api/v1/track_segments_controller.rb |
| Processing | — | — | — | app/jobs/track_segment_ingest_job.rb, ingestion.md |
Reference implementation: docs/sltb/sltb.py (encoder + decoder, the conformance oracle) and docs/sltb/check.py (golden vector and ACTIVITY.fit round trip).
Format version: SLTB v1, predictor 1. Verified against code: 2026-10-11
Purpose¶
The watch records each track as a compact binary stream (SLTB, SailEver Track Binary) and uploads it in self-contained segments. One format and one endpoint serve every watch platform. The server does all heavy work (SOG, COG, distances, events, names) in background jobs.
A Connect IQ app cannot read back the FIT file it records, and the Garmin Activity API is closed. SLTB is therefore the only automatic way a track reaches the logbook. The FIT activity still goes to Garmin Connect as the user's own copy.
Terms¶
| Term | Meaning |
|---|---|
| Track | One recording from Start to Stop or Discard, pauses included. One tracks row. Identified per device by track_id = start time in Unix seconds. |
| Segment | A self-contained binary blob covering part of a track. The unit of storage and upload. Decodes on its own. Wire term only; the UI says "track" or "file". |
| File | A segment waiting on the watch (a Storage entry on Garmin, a file on Wear OS). |
| Sample | One position fix every interval seconds. |
| Keyframe | A record with absolute time, position, pressure and state. Every segment starts with one. |
End-to-end flow¶
WATCH SERVER
every 5 s: sample → SLTB encoder → open segment (checkpointed every 5 min)
close: first fix + 15 s · every 60 min · pause · stop · discard · 4 KB cap
→ file in local queue → one upload attempt (POST /api/v1/tracks/segments)
2xx → delete file other → keep file until the next explicit save point
→ validate envelope + header + CRC
→ store raw blob → 201 / 200 / 202
→ TrackSegmentIngestJob → TrackRecomputeJob
Binary format (SLTB v1)¶
Design¶
| Technique | Effect |
|---|---|
| Implicit timestamps | Samples are exactly interval seconds apart. Gaps are written as SKIP or reset with a keyframe. |
| No SOG / COG on the wire | The server derives them from positions (ingestion.md). |
| Linear prediction | Predicted P̂ₙ = Pₙ₋₁ + (Pₙ₋₁ − Pₙ₋₂); only the residual is stored. |
| Metric integer grid | Positions are projected onto a 1 m grid anchored at the keyframe. The encoder tracks the quantised position, so rounding error stays ≤ 0.71 m and never accumulates. |
| Variable-length records | 1 byte for the common case, 2–3 bytes for manoeuvres, run-length codes on prediction. |
| Change-only events | Pressure and vessel state are written only when they change. |
| Self-contained segments | Each segment starts with a keyframe and has its own CRC. |
Conventions¶
- Byte order: big-endian throughout.
- Positions: Garmin semicircles (
int32,deg × 2³¹ / 180). Differences are computed modulo 2³², so the antimeridian needs no special case. - Time: Unix epoch seconds, UTC,
uint32. - Pressure:
uint16in 0.1 hPa (1013.2 hPa → 10132).0= unknown. - Signed packed fields use two's complement of the stated width.
sextN(x)sign-extends an N-bit value.
Segment header (16 bytes)¶
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 2 | magic |
ASCII "SV" (0x53 0x56) |
| 2 | 1 | version |
1 |
| 3 | 1 | flags |
bit 0 FINAL (last segment of the track), bit 1 FIRST (seq 0), bits 2–7 = 0 |
| 4 | 4 | track_id |
uint32 track start time (Unix s), unique per device |
| 8 | 2 | seq |
uint16 segment number within the track, from 0 |
| 10 | 1 | interval |
uint8 seconds between samples. Both watch apps write 5. Must not be 0. |
| 11 | 1 | predictor |
uint8; 1 = linear |
| 12 | 2 | body_len |
uint16 body length in bytes |
| 14 | 2 | crc16 |
CRC-16/CCITT-FALSE over the body: poly 0x1021, init 0xFFFF, no reflection, no xor-out |
Body records¶
The first byte of a record is its opcode. Ranges are disjoint.
| Opcode | Name | Size | Payload | Meaning |
|---|---|---|---|---|
0x00–0x78 |
TINY | 1 B | — | One point. v = opcode; ry = v / 11 − 5, rx = v % 11 − 5. Range −5…+5 m per axis. |
0x79–0x7F |
RUN-S | 1 B | — | opcode − 0x77 points (2…8) on prediction. |
0x80–0xBF |
SMALL | 2 B | 1 B | One point. v = (opcode & 0x3F) << 8 \| b1; ry = sext7(v >> 7), rx = sext7(v & 0x7F). Range −64…+63 m. |
0xC0–0xCF |
MEDIUM | 3 B | 2 B | One point. v = (opcode & 0x0F) << 16 \| b1 << 8 \| b2; ry = sext10(v >> 10), rx = sext10(v & 0x3FF). Range −512…+511 m. |
0xD0–0xDF |
reserved | Kept for a possible PACK record. Decoder rejects. |
||
0xE0 |
RUN | 2 B | uint8 n |
n points (1…255) on prediction. |
0xE1 |
PRESSURE | 2 B | int8 d |
Pressure += d × 0.1 hPa, from the next point on. |
0xE2 |
STATE | 2 B | uint8 s |
New state byte, from the next point on. |
0xE3 |
SKIP | 2 B | uint8 k |
k samples (1…255) missing. Time += k × interval; velocity resets to (0, 0). |
0xE4–0xEE |
reserved | Decoder rejects. | ||
0xEF |
EXT | 3+N B | uint8 type, uint8 len, len bytes |
Extension. Unknown types are skipped. No type is defined yet. |
0xF0 |
KEYFRAME | 16 B | uint32 t, int32 lat, int32 lon, uint16 pres, uint8 state |
One point with absolute values. Resets origin, time, pressure, state and predictor. |
0xF1 |
END | 40 B | uint8 reason + totals |
Track end. Only in a FINAL segment. |
0xF2–0xFF |
reserved | Decoder rejects. |
Events (PRESSURE, STATE) come before the point they apply to.
END payload (39 bytes after the opcode, >BIIIIIIIIIH). All values are the watch's own totals; 0 = unknown.
| Field | Type | Meaning |
|---|---|---|
reason |
uint8 |
0 saved, 1 discarded, 2 recovered after a crash (totals 0) |
timer_s |
uint32 |
recorded seconds, pauses excluded |
distance_m |
uint32 |
device odometer |
sailed_m, motored_m, motorsailed_m |
3 × uint32 |
distance per vessel state group |
sailed_s, motored_s, motorsailed_s, stationary_s |
4 × uint32 |
time per group |
max_sog_ckn |
uint16 |
max SOG in 0.01 kn, capped at 65535 |
State byte¶
Bits 0–2 hold the vessel state, bits 3–7 are 0. The wire values and their meaning are defined once in vessel-state.md (0 engine, 1 motorsail, 2 sail, 3 anchor, 4 buoy, 5 mooring, 6 shoreline, 7 adrift). This is not the alphabetical order; decoders map by value (Sailever::VesselStates::WIRE).
Predictor and projection (predictor 1)¶
State: grid position P = (y, x) in metres from the keyframe origin, velocity V = (vy, vx) in metres per sample.
KEYFRAME: origin = (lat0, lon0); c = cos(lat0 in radians); P = V = (0,0); t = keyframe.t; emit
point(r): T = P + V + r; V = T − P; P = T; t += interval; emit
RUN / RUN-S: point((0,0)) n times
SKIP k: t += k × interval; V = (0,0)
Projection, identical on both sides, 64-bit floating point:
M = 6378137 × π / 2³¹ # metres per semicircle ≈ 0.009330692
y = rnd( wrap32(lat − lat0) × M ) # rnd(v) = floor(v + 0.5)
x = rnd( wrap32(lon − lon0) × M × c )
lat_deg = (lat0 + y / M) × 180 / 2³¹
lon_deg = (lon0 + x / (M × c)) × 180 / 2³¹ (normalised to −180…+180)
The decoder applies the exact inverse with the same c, so the only error is the 1 m quantisation. Valid to ±85° latitude.
Garmin¶
Monkey C Float is 32-bit. All projection maths uses Double and Long.
Encoder rules¶
For each sample (t, lat, lon, pressure p, state s):
- No keyframe yet in this segment → write
KEYFRAME, done. - Time check:
expected = t_prev + interval. Equal → continue. A positive multiple ofintervaland ≤ 255 samples → flush the run, writeSKIP k. Otherwise (clock jump, long gap) → newKEYFRAME. - State changed → flush, write
STATE s. Pressure differs from the last written value by ≥ 2 units (0.2 hPa) and neither is 0 → flush, writePRESSURE d,dclamped to ±127 (the rest follows at the next sample). - Residual
r = project(lat, lon) − (P + V). Zero → count it in the pending run. Otherwise flush the run and write the smallest record that fits (TINY, SMALL, MEDIUM). Out of range →KEYFRAME. V = T − P,P = T.
Flushing a run of n: 1 → TINY 0x3C; 2…8 → RUN-S 0x77 + n; ≥ 9 → RUN in chunks of ≤ 255. A run is flushed before any non-point record and at segment close.
Before the encoder (in TrackSession, both platforms):
| Rule | Value |
|---|---|
| Sample time | Snapped to the 5 s grid when the wall clock is within ±2 s of the expected slot. |
| Pressure | Exponential moving average, α = 0.2 per sample, then quantised to 0.1 hPa. A missing reading repeats the last smoothed value; no value yet = 0. |
| GPS quality gate | A sample below the WatchApp's GPS acceptance (quality ≥ 3 in release builds, ≥ 1 in debug builds) is dropped and becomes a SKIP. Rule and the quality mapping per platform: watchapp/recording.md. |
Segment boundaries¶
| Trigger | Segment closed |
|---|---|
| First close: ≥ 3 positions and ≥ 15 s after the segment's first sample | seq 0, FIRST |
| 3600 s of wall-clock time since the segment's first sample | next seq |
| Open body ≥ 4096 bytes (cap) | next seq |
| Pause | Open segment closed (if it holds points) |
| Stop | FINAL with END reason=0 and all totals. Only END if no points are left. |
| Discard, once any segment was closed or uploaded | FINAL with only END reason=1. Other queued files of the track are deleted first. |
| Crash recovery or stale paused track | Checkpoint closed as FINAL with END reason=2 |
Which close triggers which upload, and what the sailor sees: watchapp/sync-and-pairing.md.
- A track stopped before its first close produces one segment with
FIRST | FINAL. - A track that never had a usable fix produces nothing.
- The open segment and encoder state are checkpointed every 60 samples (5 min). A crash loses at most 5 min.
Local queue¶
| Garmin | Wear OS | |
|---|---|---|
| Storage | Application.Storage: sltb_q (index), sltb_<track_id>_<seq> (file), sltb_open (checkpoint) |
Files in the app directory (TrackQueue, TrackSession) |
| Budget | 64 KB (65 536 bytes) | 16 MiB |
| Warning | Over 75 % | Over 75 % |
| Full | New non-final segments are not stored and the SLTB encoder stops for this track. FINAL segments are always stored. Queued files are never deleted automatically. |
Same |
Discard files (END reason=1) are flagged in the queue. How queued and discard files are counted, shown and sent: watchapp/sync-and-pairing.md.
Upload rules (client)¶
A closed segment is written to the queue before the request, so a crash during the request loses nothing. One attempt per trigger, no retries, oldest first, and what the watch does with each response: watchapp/sync-and-pairing.md.
Upload request¶
POST /api/v1/tracks/segments, Authorization: Bearer <device_token>, Content-Type: application/json. The body is JSON with base64 data, because Connect IQ cannot send raw binary.
{
"format": "sltb",
"format_version": 1,
"trip_token": "0123456789abcdef0123",
"track_id": 1784880000,
"seq": 3,
"final": false,
"byte_size": 746,
"data": "U1YBAGpjG4AAAwUBAtrr8Gpj...",
"device_model": "006-B4631-00",
"app_version": 2,
"position": [41.10012, 9.40077]
}
| Field | Required | Server use |
|---|---|---|
format |
yes | Must be "sltb". |
format_version |
yes | Must be 1. |
track_id |
yes | Integer 1…2³²−1. Must equal the header. Becomes track_started_at. |
seq |
yes | Integer 0…65535. Must equal the header. |
data |
yes | Strict base64 of header + body. |
final |
no | Stored on the segment. The job uses the header flag. |
trip_token |
no | First 64 characters stored. Groups the track into a trip. |
device_model |
no | First 100 characters stored. |
app_version |
no | Integer, clamped to 0…65535. Both watch apps send the constant 2 (see Known deviations). |
byte_size |
no | Ignored; the server measures the payload. |
position |
no | [lat, lon], both numbers in range. Asks for tides. |
Garmin¶
device_model is System.DeviceSettings.partNumber (e.g. 006-B4631-00, mapped to a name by config/garmin_devices.yml).
Wear OS¶
device_model is Build.MODEL.
Upload responses¶
| Status | Body | Meaning |
|---|---|---|
201 |
{"status":"accepted","segment_id":4711,"tides":{…}} |
Stored, ingest job queued. |
200 |
{"status":"duplicate","track_id":"<uuid or null>","tides":{…}} |
Same (device, track_id, seq) already stored. track_id is null while the first copy is unprocessed or after a discard. |
202 |
{"status":"quarantined"} |
Envelope fine, but header or CRC invalid, or header track_id/seq differ from the envelope. Blob stored for inspection. |
400 |
{"error":"…"} |
Unsupported format, Unsupported format_version, Invalid track_id, Invalid seq, Missing data, Invalid base64 data. |
401 |
{"error":"Missing authorization token"} or {"error":"Invalid or revoked device token"} |
See device-api.md. |
413 |
{"error":"Request too large"} |
Request body over 64 KB. |
429 |
— | Over 120 requests per hour per device. |
Tides in the response¶
Tides are a paired-watch feature and travel only here. tides is present on 201 and 200 when position was valid and data was available:
{ "next_high_time": "14:32", "next_low_time": "20:47", "range_m": 0.4,
"station_name": "Open-Meteo Marine (41.1°N, 9.4°E)" }
- Times are
HH:MMUTC."--:--"when no such extreme is left in the data. range_m= highest minus lowest hourly sea level over today and tomorrow (UTC).- The server waits at most 2 s for Open-Meteo. On a miss the response has no
tidesandTideWarmJobfills the cache for the next upload. - Provider, grid and cache: ingestion.md.
Server rules¶
- Accept first, process later. The controller checks the envelope, header and CRC, stores the raw bytes and queues
TrackSegmentIngestJob. It never decodes the body inline. - Identity: unique
(device_id, track_started_at, seq). An insert conflict answers200 duplicate. - Processing, completeness, discard and quarantine: ingestion.md.
Decoder rules¶
- Verify
magic,version == 1,predictor == 1,interval ≠ 0,16 + body_len == segment lengthand the CRC. Any mismatch raisesSailever::Sltb::CorruptSegment. - A point,
PRESSURE,STATEorSKIPbefore the first keyframe is an error. A body of onlyENDis valid. - Reject reserved opcodes, truncated records,
RUN/SKIPwith count 0, any record afterEND,ENDin a non-final segment, an unknownENDreason, and positions that are not finite or have |lat| > 90°. - Hard limits: ≤ 20 000 points per segment. Point times within
[track_id − 1 day, track_id + 30 days]. - Output:
track_id,seq,final,interval,points[] = {recorded_at, latitude (7 decimals), longitude (7 decimals), baro_pressure_hpa (1 decimal, nil when 0), vessel_state}, optionalend.
Versioning¶
- Additive data goes into
EXTrecords. - A new predictor gets a new id; decoders reject unknown ids.
- An incompatible change is
version = 2. The server keeps a decoder for every version it ever accepted. PACK(a nibble per point for ±1 m residuals) was evaluated: it saves only 11 % and complicates the encoder.0xD0–0xDFstay reserved for it.
Golden vector¶
Input: origin 41.1°N 9.4°E, track_id = 1784880000, interval 5 s, twelve samples heading NE at about 3 m/s. Sail → engine at sample 6. Pressure 1013.2 → 1012.9 hPa at sample 8. Two samples missing before the last one, which is 33 m north. END: saved, 65 s, 150 m (80 m sailed, 70 m motored), 30 s sailed, 35 s motored, max 6.50 kn.
Segment (87 bytes):
53 56 01 03 6a 63 1b 80 00 00 05 01 00 47 e7 cf
f0 6a 63 1b 80 1d 3a 06 d4 06 af 37 c0 27 94 02
85 0a 79 46 3c e2 00 79 e1 fd 7a e3 02 90 80
f1 00 00 00 00 41 00 00 00 96 00 00 00 50 00 00 00 46
00 00 00 00 00 00 00 1e 00 00 00 23 00 00 00 00 00 00 00 00 02 8a
| Bytes | Record | Meaning |
|---|---|---|
53 56 01 03 |
header | "SV", v1, FINAL \| FIRST |
6a 63 1b 80 00 00 05 01 |
header | track_id 1784880000, seq 0, 5 s, predictor 1 |
00 47 e7 cf |
header | body 71 B, CRC 0xE7CF |
f0 6a631b80 1d3a06d4 06af37c0 2794 02 |
KEYFRAME | t₀, 41.1°, 9.4°, 1013.2 hPa, sail → point 0 |
85 0a |
SMALL | (+10, +10) → point 1 |
79 |
RUN-S | points 2–3 |
46 |
TINY | (+1, −1) → point 4 |
3c |
TINY | (0, 0) → point 5 |
e2 00 |
STATE | engine |
79 |
RUN-S | points 6–7 |
e1 fd |
PRESSURE | −0.3 hPa → 1012.9 |
7a |
RUN-S | points 8–10 |
e3 02 |
SKIP | 2 samples missing, velocity reset |
90 80 |
SMALL | (+33, 0) → point 11 at t₀ + 65 s |
f1 00 00000041 00000096 00000050 00000046 00000000 0000001e 00000023 00000000 00000000 028a |
END | saved; 65 s; 150 m; 80 / 70 / 0 m; 30 / 35 / 0 / 0 s; 6.50 kn |
Decoded points:
| t − t₀ (s) | lat | lon | hPa | state |
|---|---|---|---|---|
| 0 | 41.100000 | 9.400000 | 1013.2 | sail |
| 5 | 41.100090 | 9.400119 | 1013.2 | sail |
| 10 | 41.100180 | 9.400238 | 1013.2 | sail |
| 15 | 41.100270 | 9.400358 | 1013.2 | sail |
| 20 | 41.100368 | 9.400465 | 1013.2 | sail |
| 25 | 41.100467 | 9.400572 | 1013.2 | sail |
| 30 | 41.100566 | 9.400679 | 1013.2 | engine |
| 35 | 41.100665 | 9.400787 | 1013.2 | engine |
| 40 | 41.100764 | 9.400894 | 1012.9 | engine |
| 45 | 41.100862 | 9.401001 | 1012.9 | engine |
| 50 | 41.100961 | 9.401109 | 1012.9 | engine |
| 65 | 41.101258 | 9.401109 | 1012.9 | engine |
Conformance tests (each must reproduce the 87 bytes or decode them):
| Side | Test |
|---|---|
| Reference | uvx --with fitdecode python docs/sltb/check.py (also writes the Rails fixtures with --write-fixtures) |
| Garmin | source/sync/SltbEncoderTest.mc |
| Wear OS | core/src/test/kotlin/com/sailever/core/SltbEncoderTest.kt |
| Server | test/services/sailever/sltb/decoder_test.rb, fixtures test/fixtures/files/sltb/activity_*.sltb |
Measured size and accuracy¶
Reference data: docs/ACTIVITY.fit, 8.3 h of real boating, resampled to 5 s (5 531 samples), 10 segments.
| Metric | Result |
|---|---|
| Total size | 5 777 B: 1.04 B per sample, about 700 B per hour |
| Record mix | 94 % TINY, 0.7 % SMALL, 0 % MEDIUM |
| Position error | max 0.70 m, mean 0.38 m |
| Distance from decoded points | 33.92 nm, equal to the device odometer |
| Derived SOG vs. device speed | median Δ 0.19 kn, p95 0.72 kn |
Rough weather is estimated at up to 2.2 B per sample (not measured).
Known deviations¶
- Both watch apps send
app_version=2as a constant, not the release version (DeviceRegistrationsends the real version string). - A file that always gets
400or413stays first in the queue. Because uploads go oldest first and stop at the first failure, it blocks every later file.