Skip to content

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: uint16 in 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):

  1. No keyframe yet in this segment → write KEYFRAME, done.
  2. Time check: expected = t_prev + interval. Equal → continue. A positive multiple of interval and ≤ 255 samples → flush the run, write SKIP k. Otherwise (clock jump, long gap) → new KEYFRAME.
  3. State changed → flush, write STATE s. Pressure differs from the last written value by ≥ 2 units (0.2 hPa) and neither is 0 → flush, write PRESSURE d, d clamped to ±127 (the rest follows at the next sample).
  4. 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.
  5. 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:MM UTC. "--:--" 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 tides and TideWarmJob fills 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 answers 200 duplicate.
  • Processing, completeness, discard and quarantine: ingestion.md.

Decoder rules

  • Verify magic, version == 1, predictor == 1, interval ≠ 0, 16 + body_len == segment length and the CRC. Any mismatch raises Sailever::Sltb::CorruptSegment.
  • A point, PRESSURE, STATE or SKIP before the first keyframe is an error. A body of only END is valid.
  • Reject reserved opcodes, truncated records, RUN/SKIP with count 0, any record after END, END in a non-final segment, an unknown END reason, 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}, optional end.

Versioning

  • Additive data goes into EXT records.
  • 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–0xDF stay 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 = 2 as a constant, not the release version (DeviceRegistration sends the real version string).
  • A file that always gets 400 or 413 stays first in the queue. Because uploads go oldest first and stop at the first failure, it blocks every later file.