Skip to content

Recording engine

Garmin Wear OS Apple Watch
Status ✅ 🟡 (no FIT / workout file, computed odometer) ⏳
Code source/TripContext.mc, source/DataModel.mc, source/TripRecorder.mc, source/TripStore.mc, source/sync/TrackSession.mc, source/Config.mc wear/…/recording/TripEngine.kt, wear/…/recording/TrackingService.kt, wear/…/data/TripStore.kt, core/…/TrackSession.kt —

Verified against code: 2026-10-11

Purpose

While a track runs, the watch reads GPS, speed, pressure and battery once per second. From them it computes everything the screens show: distance, timer, average and top speed, the split into sailed, motored, motorsailed and stationary, and the state period. Every 5 s it writes a position into the SLTB stream that goes to the server. A paused track is stored so it survives closing the app.

How a track starts, pauses and stops: lifecycle.md. Upload and pairing: sync-and-pairing.md. Wire format: ../shared/track-sync.md.

The 1 Hz tick

One timer fires every 1000 ms while the track runs. It does not run while the track is paused.

Each tick, in this order:

  1. Read the sensors into the data model: clock, position, SOG, max speed, distance, pressure, COG, battery.
  2. Every 5th tick: take a track sample (plot buffer and SLTB).
  3. Add the distance since the last tick to the state period and to the split of the current vessel state.
  4. Add the SOG to the state period average.
  5. Count one second: track timer and the time of the current state.
  6. Feed the auto-stats intervals (alerts-and-auto-stats.md).
  7. Count down the overlay on screen and show a waiting one.
  8. Check the alerts.
  9. Redraw.

Garmin

The tick reads Activity.getActivityInfo(). Steps 1–6 are skipped when it returns null. The tick also writes the FIT developer fields, just before the alert check.

Wear OS

The tick runs on the main thread from the foreground service (Handler.postDelayed, 1000 ms). When the track is paused the service stops itself, so no tick runs.

Sensors

Value Garmin source Wear OS source Unit on the watch
Position Activity.Info.currentLocation; Position.getInfo().position as fallback; continuous location events FusedLocationProviderClient, PRIORITY_HIGH_ACCURACY, 1000 ms interval and min interval degrees
SOG Activity.Info.currentSpeed (m/s × 3600 / 1852); 0 when null Location.speed × 3600 / 1852; 0 when there is no current fix or no speed kn
GPS quality Position.Quality 0–4 mapped from the fix (below) 0–4
Pressure Activity.Info.ambientPressure / 100; 0 when null Sensor.TYPE_PRESSURE, SENSOR_DELAY_NORMAL, latest value hPa
Battery System.getSystemStats().battery ACTION_BATTERY_CHANGED: level × 100 / scale %
COG Position.Info.heading, only at SOG ≥ 1.0 kn Location.bearing, only at SOG ≥ 1.0 kn whole degrees

COG smoothing and turn detection: ../shared/calculations.md.

Wear OS

  • GPS quality from the fix: no accuracy → 2; accuracy ≤ 10 m → 4; ≤ 25 m → 3; otherwise 2. A fix older than 10 s is not current: quality drops to 1 (a fix was seen) or 0 (never), and SOG reads 0.
  • Derived speed and bearing. Some providers (the emulator, some chipsets) leave out speed or bearing. They are then derived from the previous fix when the two are 0–10 s apart: speed = distance / time; bearing = bearing between the fixes, only when the boat moved at least 1 m.
  • No location permission: the track still records time and vessel state, without positions.

GPS acceptance

A sample goes into the SLTB stream only with GPS quality ≥ 3 (usable). Debug builds accept ≥ 1 (last known), because the Garmin simulator and the Android emulator give no better. A rejected sample becomes a gap (SKIP) on the wire instead of a frozen position.

The plot buffer has its own acceptance rule (track-view.md).

COG needs quality ≥ 2.

Distance

The track distance is the device odometer plus the distance of earlier runs of the same track:

track distance = distance offset + odometer

The offset is the track distance stored at the last pause that was resumed after the app had closed (see Persistence). It is 0 otherwise.

Each tick, the odometer gain since the last tick (only when positive) is added to: - the state period distance, - the sailed distance (state Sail), the motored distance (Engine) or the motorsailed distance (Motorsail). Distance in a stationary state goes into the track total and the state period only.

Garmin

The odometer is Activity.Info.elapsedDistance of the FIT session. It stops while the FIT session is paused.

Wear OS

The odometer is computed from the fixes: - A fix with accuracy worse than 30 m is not counted and does not become the reference (the first fix always does). - The distance from the reference fix is added only while the track is not paused, and only when the fix has no speed or its speed is ≥ 0.3 kn (no creep at anchor). - On resume the reference is dropped, so there is no jump across the pause.

Track timer

trackTotalSeconds is an independent counter. It counts one per tick while the track is not paused. It is stored at pause and restored on resume, so it never resets when the platform starts a new activity. Format H:MM:SS (hours not padded), e.g. 0:41:06.

The time of the current state is counted alongside: sailed, motored, motorsailed or stationary seconds. The four add up to the track timer.

Average speed

average = track distance × 3600 / trackTotalSeconds (kn). 0 while the timer or the distance is 0.

Max speed

The highest SOG seen on any tick of the track, unfiltered. Stored at pause and restored on resume.

State periods

A state period is the stretch since the vessel state was last changed. It resets to zero whenever a state is picked, also when the same state is picked again.

Value Rule
Distance sum of the odometer gains since the change
Average speed mean of the per-tick SOG values that were above 0 since the change (not distance / time)
Duration wall-clock time since the change

The dashboard shows distance and average for propulsion states and the duration for stationary states (dashboard.md).

Trip totals

On Stop, the watch adds the track to the trip totals:

Total Added on Stop
Track count + 1
Trip distance + track distance
Trip sailed distance + sailed distance
Trip motored distance + motored + motorsailed distance

A paused track is not in the trip totals yet; the stats summary adds it on top (lifecycle.md). A discarded track is never added. Start new trip clears the totals.

Day totals

The watch keeps today's totals across all tracks: distance, sailed, motored, motorsailed distance and seconds. They grow by the delta since the last segment close at every SLTB segment close, reset when the local date changes, and a discarded track's share is taken back out. No screen shows them today.

Plot buffer

A ring buffer of the last positions, sampled every 5 s, for the track view. Capacity, sampling and acceptance: track-view.md. Garmin allocates it only when recording starts, to save memory.

SLTB sampling

Every 5th tick, when there is a position that passes the GPS acceptance, the watch hands a sample (time, position, pressure, vessel state id) to the SLTB session. No sample is written while the track is paused or without a session. Time snapping, pressure smoothing, the encoder, segment boundaries, checkpoints, the END totals and the queue: ../shared/track-sync.md. When closed segments are uploaded: sync-and-pairing.md.

The END record carries the totals of this page (../shared/track-sync.md).

FIT recording

Garmin

Each run of a track records a Garmin activity. Its type follows the trip (lifecycle.md):

Trip Name Sport / sub-sport
Sailing Sailing SPORT_SAILING (32) / SUB_SPORT_GENERIC
Boating Boating FIT sport 23 (boating) / SUB_SPORT_GENERIC

Boating is passed as the number 23: ActivityRecording.SPORT_BOATING is deprecated, and Activity.SPORT_BOATING needs API 3.2.0 (the app's minimum is 3.1.0). Connect IQ fixes the sport when the activity is created, so a track can't change it later.

Developer fields:

Field id Name Message Type Content
0 engine_on record uint8 1 in Engine or Motorsail, else 0 (legacy)
1 sail_main record uint8 1 in Sail or Motorsail, else 0 (legacy; the server reads 1 as FULL)
2 sail_genoa record uint8 always 0 (legacy)
3 sail_mps record uint8 always 0 (legacy)
6 vessel_state record uint8 vessel state id (../shared/vessel-state.md)
4 trip_token session string, 24 chars the trip token (20 hex digits)
5 app_version session uint16 2, the developer-field layout version

The record fields are set on every tick and on every state change; Garmin writes records at its own record rate.

Pause stops the FIT session in memory without saving it. Stop saves it. Discard discards it. Closing the app while paused saves the FIT session as it is, so a track paused overnight and resumed the next day becomes two Garmin activities.

The FIT file is the backup in Garmin Connect and can be uploaded to the server by hand (../server/logbook.md). The watch never uploads it. When the SLTB queue is full, FIT still records.

Wear OS

No FIT, no Health Services exercise and no Health Connect record. SLTB is the only recording.

Apple Watch

⏳ An HKWorkoutSession with HKLiveWorkoutBuilder, activity type .sailing, saved to Apple Health with an HKWorkoutRoute. trip_token and app_version become workout metadata; vessel state changes become HKWorkoutEvent segment events. SLTB stays the source of truth.

Persistence

At Pause, the watch stores everything a resumed track needs:

  • track distance, sailed, motored and motorsailed distance,
  • track timer and sailed, motored, motorsailed and stationary seconds,
  • max speed,
  • state period distance, SOG sum and SOG count,
  • the plot buffer,
  • the pause start time,
  • the current vessel state (as the trip's last vessel state).

On Resume in the same app run, nothing is reloaded: the engine simply continues. On Resume track after the app was closed, a new engine starts from the stored values; the stored distance becomes the distance offset and the odometer starts at 0.

Not stored, so they start fresh after the app was closed: state period start time, barometer trend, COG history, speed chart, auto-stats intervals and the alert once-flags.

The SLTB session (track id, segment sequence, open checkpoint) is stored separately; see ../shared/track-sync.md.

The trip keeps: trip token, track count, trip distance, trip sailed and motored distance, last vessel state, and day totals.

Garmin

Application.Storage. The plot buffer is stored as the raw ring (two arrays plus count and next index), one key per axis, because one read needs room for the serialized value on top of the array.

Wear OS

SharedPreferences. The plot buffer is stored oldest first. The last vessel state is also written on every state change, not only at pause and stop.

Wear OS: foreground service

Recording runs in TrackingService, a foreground service of type location:

  • Ongoing notification on channel track (low importance), category workout, with an Ongoing Activity icon on the watch face that opens the app.
  • A partial wake lock keeps the 1 Hz tick alive with the screen off.
  • It registers location, pressure and battery on start and releases them all when it stops.
  • It stops itself when the track is paused, stopped or discarded.

Apple Watch

⏳ Planned in apple_watch_port_plan.md §2: - Position from CLLocationUpdate.liveUpdates(), kept alive by the workout session. - Pressure from CMAltimeter (kPa, converted to hPa). - Storage in files under Application Support; HKHealthStore.recoverActiveWorkoutSession after a crash or relaunch. - The same logic as here in the SailEverCore Swift package.

States and edge cases

  • No fix: SOG 0, no COG, no samples; the timer still counts.
  • No barometer: pressure 0, no baro trend, no baro alert, pressure 0 in SLTB.
  • Poor GPS: the plot still gets the point, SLTB gets a gap.
  • Queue full: SLTB stops for this track; Garmin keeps FIT.

Known deviations

  • State period duration is not stored. After the app was closed and the track resumed, the duration restarts at 0 while distance and average carry on. It also counts paused time when the track is paused and resumed in the same app run.
  • State period average is a mean of SOG samples, unlike the track average and the auto-stats average (distance / time).
  • Day totals are kept on both platforms but shown nowhere.