Detect a live LiveTrack session from its last point, not session["end"]
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 13s

session["end"].nil? was the active signal since this was first built,
on the assumption Garmin leaves it unset until the activity actually
stops. Checked directly against a real still-recording session today
(2026-09-20): end was already non-nil, set to exactly start + 24h — the
share link's own expiry, not an end time — so every currently-active
session was showing as "Ended". A genuinely ended session's end does
hold its real end time (confirmed against the 2026-09-06 session this
was originally, and only, verified against), which is what made the
bug easy to miss.

Recency of the last track point is a directly-observable signal
instead of reverse-engineering what a given `end` value means: Garmin
posts one roughly every postTrackPointFrequency seconds (~15, observed)
while genuinely still recording.
This commit is contained in:
randy committed 2026-09-20 16:42:52 +02:00
1 parent b7618edc25
commit 5d86cb1d04
2 files changed
+38 -8

No files matched your search

+26 -5
View File
@@ -1,5 +1,6 @@
require "net/http" require "net/http"
require "json" require "json"
require "time"
module Maptool module Maptool
# Fetches a Garmin LiveTrack session's track and metadata. # Fetches a Garmin LiveTrack session's track and metadata.
@@ -44,10 +45,22 @@ module Maptool
# totals are cumulative for the whole session), name/type from the # totals are cumulative for the whole session), name/type from the
# session details — nil-safe throughout since either can be missing. # session details — nil-safe throughout since either can be missing.
# #
# "active": Garmin doesn't set a session's `end` until the activity is # "active": originally `session["end"].nil?`, on the assumption Garmin
# actually stopped, so its absence means the session is still recording # doesn't set `end` until the activity actually stops — disproven
# — inferred from the one (already-ended) session this was built # against a real still-recording session (2026-09-20): `end` was
# against, not verified against a session actually caught mid-activity. # already non-nil, set to exactly `start + 24h`, the share link's own
# expiry, not an actual end time (a genuinely ended session's `end`
# instead reflects its real end — ~2h after start for the 2026-09-06
# session this was originally, and only, verified against). Recency of
# the last track point is a directly-observable signal instead of
# trying to reverse-engineer what a given `end` value means: Garmin
# posts one roughly every `postTrackPointFrequency` seconds (~15,
# observed) while genuinely still recording, so a point older than
# ACTIVE_WITHIN means the activity has actually stopped (or, at worst,
# lost signal for a while — same "looks ended until it isn't" behavior
# Garmin's own LiveTrack page shows in that case).
ACTIVE_WITHIN = 30 * 60 # seconds
def self.summarize(result) def self.summarize(result)
last = result.points.last || {} last = result.points.last || {}
session = result.session || {} session = result.session || {}
@@ -56,12 +69,20 @@ module Maptool
"activity_type" => last["activityType"], "activity_type" => last["activityType"],
"distance_meters" => last["totalDistanceMeters"], "distance_meters" => last["totalDistanceMeters"],
"duration_secs" => last["totalDurationSecs"], "duration_secs" => last["totalDurationSecs"],
"active" => session["end"].nil?, "active" => active?(last["dateTime"]),
"start" => session["start"], "start" => session["start"],
"end" => session["end"], "end" => session["end"],
} }
end end
def self.active?(last_point_time)
return false unless last_point_time
Time.parse(last_point_time) > Time.now.utc - ACTIVE_WITHIN
rescue ArgumentError, TypeError
false
end
private private
# The RSC payload serializes an absent field as the literal string # The RSC payload serializes an absent field as the literal string
+12 -3
View File
@@ -49,13 +49,22 @@ class LiveTrackClientTest < Minitest::Test
assert_equal "Test Ride", summary["session_name"] assert_equal "Test Ride", summary["session_name"]
assert_equal "CYCLING", summary["activity_type"] assert_equal "CYCLING", summary["activity_type"]
assert_equal 5000, summary["distance_meters"] assert_equal 5000, summary["distance_meters"]
refute summary["active"], "session has an end timestamp" refute summary["active"], "last track point is long in the past"
end end
def test_summarize_marks_active_when_session_has_no_end # Not session["end"].nil? (the original signal, before 2026-09-20 — see
# LiveTrackClient's own comment): verified live against a real,
# still-recording session that day, Garmin had already set `end` to
# exactly start + 24h (the share link's own expiry, not an end time),
# so that check called an active session "ended".
def test_summarize_marks_active_when_last_point_is_recent
payloads = @client.send(:parse_payloads, FAKE_PAGE) payloads = @client.send(:parse_payloads, FAKE_PAGE)
points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) }) points = @client.send(:clean, @client.send(:search, payloads) { |n| n["trackPoints"] if n["trackPoints"].is_a?(Array) })
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] }).merge("end" => nil) points[-1] = points[-1].merge("dateTime" => Time.now.utc.iso8601)
# end present and in the future, same shape Garmin actually sends for a
# session that's still live — active must come from the point, not this.
session = @client.send(:clean, @client.send(:search, payloads) { |n| n if n["sessionName"] })
.merge("end" => (Time.now.utc + 24 * 60 * 60).iso8601)
result = Maptool::LiveTrackClient::Result.new(points: points, session: session) result = Maptool::LiveTrackClient::Result.new(points: points, session: session)
assert Maptool::LiveTrackClient.summarize(result)["active"] assert Maptool::LiveTrackClient.summarize(result)["active"]