From f9ddf860eac3300134b586f55efb8d3daa824c20 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Sun, 6 Sep 2026 21:05:35 +0200 Subject: [PATCH] Add Garmin LiveTrack session tracking A Gmail-watching Apps Script (documented in docs/deployment.adoc) posts the session ID from each LiveTrack email to a new webhook, which stores it in data/livetrack.json; a new sidebar panel lists what's been received. --- .gitea/workflows/ci.yml | 1 + .gitignore | 3 ++ app.rb | 25 ++++++++++++ docker-compose.yaml | 3 ++ docs/deployment.adoc | 67 ++++++++++++++++++++++++++++++++ lib/maptool/livetrack_store.rb | 63 ++++++++++++++++++++++++++++++ public/css/style.css | 30 ++++++++++++++ public/js/icons.js | 6 +++ public/js/livetrack.js | 71 ++++++++++++++++++++++++++++++++++ public/js/map.js | 2 + views/index.erb | 8 ++++ 11 files changed, 279 insertions(+) create mode 100644 lib/maptool/livetrack_store.rb create mode 100644 public/js/livetrack.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 904d8f9..5ecded0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -82,6 +82,7 @@ jobs: GITEA_OAUTH_CLIENT_ID=${{ secrets.OAUTH_CLIENT_ID }} GITEA_OAUTH_CLIENT_SECRET=${{ secrets.OAUTH_CLIENT_SECRET }} MAPTOOL_ALLOWED_GITEA_USER=${{ secrets.MAPTOOL_ALLOWED_GITEA_USER }} + LIVETRACK_WEBHOOK_SECRET=${{ secrets.LIVETRACK_WEBHOOK_SECRET }} EOF chmod 600 /srv/docker/maptool/.env diff --git a/.gitignore b/.gitignore index cff8872..ecd6562 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ /data/gpx/* !/data/gpx/.gitkeep +# Garmin LiveTrack session IDs received via webhook +/data/livetrack.json + # Editor / OS .DS_Store *.swp diff --git a/app.rb b/app.rb index 99b7138..8d0f2df 100644 --- a/app.rb +++ b/app.rb @@ -5,6 +5,7 @@ require "securerandom" require_relative "lib/maptool/gpx" require_relative "lib/maptool/gpx_store" require_relative "lib/maptool/gpsd_client" +require_relative "lib/maptool/livetrack_store" module Maptool class App < Sinatra::Base @@ -21,6 +22,14 @@ module Maptool OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles") GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx")) + LIVETRACK_STORE = LiveTrackStore.new(File.join(__dir__, "data", "livetrack.json")) + + # Shared secret for the Garmin LiveTrack webhook (a Google Apps Script + # watching Gmail for LiveTrack emails, see docs/deployment.adoc) — not a + # Gitea-authenticated user, so it can't use the OAuth2 login flow above. + # Blank means the endpoint is disabled, same fallback story as the + # OAuth2 secrets: fine outside production, nothing works without it set. + LIVETRACK_WEBHOOK_SECRET = ENV.fetch("LIVETRACK_WEBHOOK_SECRET", "") # Login delegates to the Gitea instance the user already has an account # on, rather than maintaining a local username/password — see @@ -210,6 +219,22 @@ module Maptool json path: path, tree: GPX_STORE.tree end + # Called by the Apps Script watching Gmail for Garmin LiveTrack emails — + # it only extracts and forwards the session ID, 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 + halt 400, json(error: "session_id is required") if session_id.empty? + + LIVETRACK_STORE.add(session_id) + json ok: true + end + + get "/api/livetrack/sessions" do + json sessions: LIVETRACK_STORE.list + end + get "/api/gnss/position" do GNSS_LATEST_MUTEX.synchronize do received_at = GNSS_LATEST[:received_at] diff --git a/docker-compose.yaml b/docker-compose.yaml index 416f936..7fd8ce0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,6 +17,9 @@ services: GITEA_OAUTH_CLIENT_ID: ${GITEA_OAUTH_CLIENT_ID:?GITEA_OAUTH_CLIENT_ID is required, see docs/deployment.adoc} GITEA_OAUTH_CLIENT_SECRET: ${GITEA_OAUTH_CLIENT_SECRET:?GITEA_OAUTH_CLIENT_SECRET is required, see docs/deployment.adoc} MAPTOOL_ALLOWED_GITEA_USER: ${MAPTOOL_ALLOWED_GITEA_USER:?MAPTOOL_ALLOWED_GITEA_USER is required, see docs/deployment.adoc} + # Optional: blank disables the LiveTrack webhook endpoint rather than + # failing to start, since it's not needed for the app's core function. + LIVETRACK_WEBHOOK_SECRET: ${LIVETRACK_WEBHOOK_SECRET:-} volumes: # Uploaded GPX files and the offline PMTiles extract — kept outside # the image so a rebuild/redeploy doesn't wipe them (see diff --git a/docs/deployment.adoc b/docs/deployment.adoc index 4782117..d94c653 100644 --- a/docs/deployment.adoc +++ b/docs/deployment.adoc @@ -107,6 +107,73 @@ id/secret to store as the two `OAUTH_CLIENT_*` secrets above. usernames, e.g. `randy,friend1,friend2` — not their emails, the `login` field from Gitea's `/api/v1/user`. +== Garmin LiveTrack session webhook + +`POST /api/livetrack/session` (`session_id`, `secret` form params) records a +Garmin LiveTrack session ID to `data/livetrack.json`; `GET +/api/livetrack/sessions` lists them, newest first. Intended caller: a Google +Apps Script bound to the Gmail account that receives LiveTrack emails, +triggered on a timer, extracting the session ID from the email and posting +it here — the endpoint itself doesn't know anything about email or Garmin's +API, just the ID string. + +`secret` must match the `LIVETRACK_WEBHOOK_SECRET` repo secret (added to +`.env` the same way as the others above). Unlike the other secrets, this one +is optional — leaving it unset simply disables the endpoint (`halt 403` on +every call) rather than failing the deploy, since LiveTrack tracking isn't +core to the app. + +The caller side is a Google Apps Script bound to the Gmail account that +receives the LiveTrack emails (script.google.com, "New project"), run on a +time-driven trigger: + +[source,javascript] +---- +const WEBHOOK_URL = "https://map.priesol.net/api/livetrack/session"; +const WEBHOOK_SECRET = "PASTE_SAME_VALUE_AS_LIVETRACK_WEBHOOK_SECRET_IN_.env"; +const PROCESSED_LABEL = "livetrack-processed"; + +function checkLiveTrackEmail() { + const label = GmailApp.getUserLabelByName(PROCESSED_LABEL) || GmailApp.createLabel(PROCESSED_LABEL); + const threads = GmailApp.search(`from:noreply@garmin.com subject:LiveTrack -label:${PROCESSED_LABEL}`); + + threads.forEach(thread => { + // getBody() (raw HTML), not getPlainBody(): the email has no text/plain + // part, only text/html, and the link only exists as an href attribute + // — the auto-generated plain-text conversion isn't reliable for it. + const body = thread.getMessages()[0].getBody(); + const match = body.match(/https:\/\/livetrack\.garmin\.com\/session\/([\w-]+)/); + + if (match) { + UrlFetchApp.fetch(WEBHOOK_URL, { + method: "post", + payload: { session_id: match[1], secret: WEBHOOK_SECRET } + }); + } + + thread.addLabel(label); // stops it being reprocessed next run + }); +} +---- + +The Gmail label (rather than marking the message read/unread) tracks which +threads are already handled without touching the user's own inbox state. +`subject:LiveTrack` matches regardless of the account's language — Garmin +keeps "LiveTrack" as-is in the subject even in a localized +("Sledujte LiveTrack používateľa ...") one. + +Set the trigger from the editor: clock icon on the left (Triggers) → *Add +Trigger* → function `checkLiveTrackEmail`, time-driven, "Minutes timer", +every 5 minutes. + +Verified against a real "started an activity" LiveTrack email +(2026-09-06): sender `noreply@garmin.com`, link +`https://livetrack.garmin.com/session//token/` — the regex +above only captures the `` part, stopping at the next `/`. Garmin +also sends other LiveTrack notifications (e.g. activity ended) that +weren't checked — if one turns out to use a different link shape, the +webhook simply won't receive that session ID; nothing breaks. + == CI deploy job `.gitea/workflows/ci.yml`'s `deploy` job runs after `check` passes, only diff --git a/lib/maptool/livetrack_store.rb b/lib/maptool/livetrack_store.rb new file mode 100644 index 0000000..fc6dcfa --- /dev/null +++ b/lib/maptool/livetrack_store.rb @@ -0,0 +1,63 @@ +require "json" +require "fileutils" + +module Maptool + # JSON-file-backed store of Garmin LiveTrack session IDs, appended to by a + # webhook whenever a new LiveTrack email arrives (see docs/deployment.adoc). + # Validity/expiry of a session isn't tracked here — checking whether a + # given ID still has live data is up to whoever polls Garmin with it. + class LiveTrackStore + def initialize(path) + @path = path + @mutex = Mutex.new + FileUtils.mkdir_p(File.dirname(@path)) + File.write(@path, "[]") unless File.exist?(@path) + end + + # Records `session_id`, moving it to the front if already present. + def add(session_id) + @mutex.synchronize do + sessions = read + sessions.reject! { |s| s["id"] == session_id } + sessions.unshift({ "id" => session_id, "received_at" => Time.now.utc.iso8601 }) + File.write(@path, JSON.pretty_generate(sessions)) + end + end + + # Most recently received first. + def list + read + end + + private + + def read + JSON.parse(File.read(@path)) + rescue Errno::ENOENT, JSON::ParserError + [] + end + end +end + +if __FILE__ == $0 + require "tmpdir" + + Dir.mktmpdir do |dir| + store = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json")) + raise "expected empty list" unless store.list == [] + + store.add("abc123") + store.add("def456") + raise "expected 2 sessions" unless store.list.size == 2 + raise "expected newest first" unless store.list.first["id"] == "def456" + + store.add("abc123") + raise "expected re-added session moved to front, not duplicated" \ + unless store.list.map { |s| s["id"] } == %w[abc123 def456] + + reloaded = Maptool::LiveTrackStore.new(File.join(dir, "livetrack.json")) + raise "expected data to survive reload" unless reloaded.list.size == 2 + end + + puts "ok" +end diff --git a/public/css/style.css b/public/css/style.css index 6eadeb9..b84182d 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -430,3 +430,33 @@ html, body { background: #2a9d8f; box-shadow: 0 0 0 2px rgba(42, 157, 143, 0.4); } + +.livetrack-panel__list { + flex: 1; + overflow-y: auto; + list-style: none; + margin: 0; + padding: 8px 12px; +} + +.livetrack-panel__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 0; + border-bottom: 1px solid #f0f0f0; +} + +.livetrack-panel__id { + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.livetrack-panel__time { + flex-shrink: 0; + color: #888; + font-size: 12px; +} diff --git a/public/js/icons.js b/public/js/icons.js index 610264f..6cfc1af 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -47,3 +47,9 @@ export const WAYPOINT_ICON = ` `; export const TRACK_ICON = GPX_ICON; + +export const LIVETRACK_ICON = ` + + + +`; diff --git a/public/js/livetrack.js b/public/js/livetrack.js new file mode 100644 index 0000000..8d583d6 --- /dev/null +++ b/public/js/livetrack.js @@ -0,0 +1,71 @@ +import { LIVETRACK_ICON } from "/js/icons.js"; +import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; + +const SESSIONS_URL = "/api/livetrack/sessions"; + +// A sidebar icon that opens a side panel listing Garmin LiveTrack session +// IDs received via the webhook (see docs/deployment.adoc) — read-only, just +// so they're visible somewhere instead of only in data/livetrack.json. +export class LiveTrackPanelControl { + mount(sidebar) { + this._container = document.createElement("div"); + this._container.className = "sidebar-item"; + this._container.innerHTML = ``; + this._icon = this._container.querySelector(".sidebar-icon"); + this._icon.addEventListener("click", () => this._togglePanel()); + + this._panel = document.getElementById("livetrack-panel"); + this._list = this._panel.querySelector(".livetrack-panel__list"); + this._panel + .querySelector(".basemap-panel__close") + .addEventListener("click", () => this._togglePanel(false)); + + onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active")); + + sidebar.appendChild(this._container); + } + + _togglePanel(force) { + const open = force === undefined ? this._panel.hidden : force; + this._panel.hidden = !open; + this._icon.classList.toggle("active", open); + if (open) { + notifySidePanelOpen(this._panel); + this._loadSessions(); + } + } + + async _loadSessions() { + const response = await fetch(SESSIONS_URL); + const { sessions } = await response.json(); + this._renderSessions(sessions); + } + + _renderSessions(sessions) { + this._list.innerHTML = ""; + + if (sessions.length === 0) { + const li = document.createElement("li"); + li.className = "gpx-tree__empty"; + li.textContent = "No sessions yet"; + this._list.appendChild(li); + return; + } + + sessions.forEach(({ id, received_at }) => { + const li = document.createElement("li"); + li.className = "livetrack-panel__item"; + + const idSpan = document.createElement("span"); + idSpan.className = "livetrack-panel__id"; + idSpan.textContent = id; + + const timeSpan = document.createElement("span"); + timeSpan.className = "livetrack-panel__time"; + timeSpan.textContent = new Date(received_at).toLocaleString(); + + li.append(idSpan, timeSpan); + this._list.appendChild(li); + }); + } +} diff --git a/public/js/map.js b/public/js/map.js index ade5571..85d6998 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -7,6 +7,7 @@ import { import { RadarControl } from "/js/radar.js"; import { GpxPanelControl } from "/js/gpx.js"; import { GnssControl } from "/js/gnss.js"; +import { LiveTrackPanelControl } from "/js/livetrack.js"; import { LAYERS_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; @@ -127,6 +128,7 @@ layerToggle.setActive("online"); new GpxPanelControl(map).mount(sidebar); new RadarControl(map).mount(sidebar); new GnssControl(map).mount(sidebar); +new LiveTrackPanelControl().mount(sidebar); function applyStyle(name) { layerToggle.setActive(name); diff --git a/views/index.erb b/views/index.erb index fef330d..864156d 100644 --- a/views/index.erb +++ b/views/index.erb @@ -49,3 +49,11 @@
+ +