Render a session's track on the map
CI / check (push) Successful in 47s
CI / deploy (push) Successful in 19s

Clicking a LiveTrack session now fetches and draws its track (line +
latest position). Garmin's own API 403s any plain server-side request
(Cloudflare bot protection), so LiveTrackClient fetches the session
page instead and pulls the track out of the server-rendered data
embedded in it. The webhook now captures the session's token too,
since the id alone isn't enough to fetch anything.
This commit is contained in:
randy committed 2026-09-06 22:07:13 +02:00
1 parent 7083cec36c
commit f4b5237f01
7 files changed
+326 -29

No files matched your search

+39 -2
View File
@@ -6,6 +6,7 @@ require_relative "lib/maptool/gpx"
require_relative "lib/maptool/gpx_store"
require_relative "lib/maptool/gpsd_client"
require_relative "lib/maptool/livetrack_store"
require_relative "lib/maptool/livetrack_client"
module Maptool
class App < Sinatra::Base
@@ -228,14 +229,15 @@ module Maptool
end
# Called by the Apps Script watching Gmail for Garmin LiveTrack emails —
# it only extracts and forwards the session ID, nothing else.
# it only extracts and forwards the session id/token, nothing else.
post "/api/livetrack/session" do
halt 403 if LIVETRACK_WEBHOOK_SECRET.empty? || params[:secret] != LIVETRACK_WEBHOOK_SECRET
session_id = params[:session_id].to_s.strip
session_token = params[:session_token].to_s.strip
halt 400, json(error: "session_id is required") if session_id.empty?
LIVETRACK_STORE.add(session_id)
LIVETRACK_STORE.add(session_id, session_token)
json ok: true
end
@@ -243,6 +245,41 @@ module Maptool
json sessions: LIVETRACK_STORE.list
end
# Garmin's own API (livetrack.garmin.com/api/v2/...) 403s a plain server
# request no matter what headers it gets — see LiveTrackClient — so this
# fetches the session page itself and pulls the track out of that.
get "/api/livetrack/sessions/:id/track" do
session = LIVETRACK_STORE.find(params[:id])
halt 404, json(error: "unknown session") unless session
halt 404, json(error: "session has no token") if session["token"].to_s.empty?
begin
points = LiveTrackClient.track_points(session["id"], session["token"])
rescue LiveTrackClient::FetchError => e
halt 502, json(error: "could not fetch track: #{e.message}")
end
coordinates = points.filter_map { |p| p.dig("position", "lon") && p.dig("position", "lat") && [p["position"]["lon"], p["position"]["lat"]] }
halt 502, json(error: "track has no positions") if coordinates.empty?
last = points.last
json(
type: "FeatureCollection",
features: [
{ type: "Feature", geometry: { type: "LineString", coordinates: coordinates }, properties: {} },
{
type: "Feature",
geometry: { type: "Point", coordinates: coordinates.last },
properties: {
time: last["dateTime"],
speedMetersPerSec: last["speedMetersPerSec"],
heartRateBeatsPerMin: last["heartRateBeatsPerMin"],
},
},
]
)
end
get "/api/gnss/position" do
GNSS_LATEST_MUTEX.synchronize do
received_at = GNSS_LATEST[:received_at]