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.
This commit is contained in:
1 parent
60170efda2
commit
f9ddf860ea
11 files changed
+279
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
/data/gpx/*
|
||||
!/data/gpx/.gitkeep
|
||||
|
||||
# Garmin LiveTrack session IDs received via webhook
|
||||
/data/livetrack.json
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<uuid>/token/<token>` — the regex
|
||||
above only captures the `<uuid>` 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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -47,3 +47,9 @@ export const WAYPOINT_ICON = `
|
||||
</svg>`;
|
||||
|
||||
export const TRACK_ICON = GPX_ICON;
|
||||
|
||||
export const LIVETRACK_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 21s7-7.58 7-12a7 7 0 0 0-14 0c0 4.42 7 12 7 12z"/>
|
||||
<path d="M8.5 9a3.5 3.5 0 0 1 7 0" stroke-dasharray="1.5 2.2"/>
|
||||
</svg>`;
|
||||
@@ -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 = `<button type="button" class="sidebar-icon" title="LiveTrack" aria-label="LiveTrack">${LIVETRACK_ICON}</button>`;
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -49,3 +49,11 @@
|
||||
|
||||
<div class="gpx-tree"></div>
|
||||
</div>
|
||||
|
||||
<div id="livetrack-panel" class="basemap-panel" hidden>
|
||||
<div class="basemap-panel__header">
|
||||
<strong>LiveTrack sessions</strong>
|
||||
<button type="button" class="basemap-panel__close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<ul class="livetrack-panel__list"></ul>
|
||||
</div>
|
||||
Reference in new issue
Block a user