Save Your GPS Tracks, and Let Your Partner Know Where You Are

On-demand GPS tracking that only runs while you’re actually riding, saved as a permanent GPX archive — so your partner can see where you are on a solo ride, without your phone reporting your location the rest of the week.

HA OS · Android Companion App · Python (shell_command) · Leaflet · Cloudflare Access

The code blocks below are exactly what I run myself — think of them as a working demo/starting point rather than a drop-in package for every setup. Swap the placeholders in brackets ([your_device_id], [your_token], etc.) for your own values and adapt the rest to how your own instance is put together. Drop in your own screenshots where marked.

The problem

I ride solo off-road (enduro and mountain bike) most weekends. My partner wants to be able to see where I am and follow the route live — but I didn’t want my phone reporting my GPS position continuously for the rest of the week, or anyone with dashboard access seeing my location at any random moment of an ordinary day.

The setup that came out of it: a switch I flip before heading out, which turns on the phone’s high-accuracy location only for the duration of the ride. Arriving home switches everything back off automatically and leaves behind a permanent GPX file of that ride.

How it works

  1. You turn on input_boolean.away_track (a plain switch, with its card hidden from other dashboard users via a conditional card + condition: user).
  2. An automation calls the command_high_accuracy_mode notification command on the Android Companion App — the phone pushes its own location to Home Assistant; HA never polls for it.
  3. While the ride is on, a map card on the dashboard (hidden by a conditional card unless the switch is on) shows the live position and trail.
  4. Arriving home is detected through person.rider, resolved by a network-based presence tracker (in my case, the home router) — faster and more reliable than waiting for the phone’s own GPS to confirm the zone.
  5. On arrival, a second automation turns high accuracy back off, generates the GPX for that ride, and switches the boolean off.

Important caveat
For this to work at all, the Companion App needs “Location sent” set to “Exact”, permanently, in its location settings. I tested this thoroughly: with “Zone” selected, no path (regular reporting, high accuracy mode, or even a one-off request_location_update) delivers coordinates to HA — it’s a privacy master switch with no exceptions. Day-to-day privacy is handled on the dashboard side (hiding the map), not on the phone.

No signal, no updates
This whole setup depends on the phone having mobile data (4G/5G or similar) to push its location to Home Assistant in real time — there’s no offline fallback, Home Assistant can’t reach out and pull a position from the phone another way. In dead zones (not unusual on remote off-road tracks), you’ll simply get no updates for that stretch, showing up as a gap on the live map and in the recorded GPX. Worth keeping in mind before relying on this for actual safety in areas with unreliable coverage.

What you’ll need

  • Home Assistant with terminal/SSH access (for the Python script).
  • An Android phone with the Companion App — the high-accuracy command is Android-only, iOS doesn’t expose the same level of remote control.
  • A dedicated non-admin HA user with its own Long-Lived Access Token, so the script never uses your main account’s token.
  • Optional but recommended: a network-based presence tracker (router integration, etc.) to detect arriving home faster than plain GPS zone matching.

Helpers

Settings → Helpers:

  • input_boolean.away_track — Toggle (“Rider is out”)
  • input_select.vehicle_type — Dropdown (Bike / Motorbike / Enduro)

Scripts

script:
  enable_precise_location:
    alias: "Enable precise location"
    sequence:
      - service: notify.mobile_app_[your_device_id]
        data:
          message: "command_high_accuracy_mode"
          data:
            command: "turn_on"
      - service: notify.mobile_app_[your_device_id]
        data:
          message: "command_high_accuracy_mode"
          data:
            high_accuracy_update_interval: 5
            command: "high_accuracy_set_update_interval"

  disable_precise_location:
    alias: "Disable precise location"
    sequence:
      - service: notify.mobile_app_[your_device_id]
        data:
          message: "command_high_accuracy_mode"
          data:
            command: "turn_off"

  generate_route_gpx:
    alias: "Generate GPX for the ride"
    sequence:
      - service: shell_command.generate_route_gpx

shell_command

shell_command:
  generate_route_gpx: >-
    python3 /config/scripts/generate_gpx.py "{{ states.input_boolean.away_track.last_changed.isoformat() }}" "{{ states('input_select.vehicle_type') }}"

The two automations

“Away Track - turned on”

alias: "Away Track - turned on"
trigger:
  - platform: state
    entity_id: input_boolean.away_track
    to: "on"
action:
  - service: script.enable_precise_location
mode: single

“Away Track - arrived home”

alias: "Away Track - arrived home"
trigger:
  - platform: state
    entity_id: person.rider
    to: "home"
condition:
  - condition: state
    entity_id: input_boolean.away_track
    state: "on"
action:
  - service: script.disable_precise_location
  - service: script.generate_route_gpx
  - service: input_boolean.turn_off
    target:
      entity_id: input_boolean.away_track
mode: single

Order matters
The GPX is generated before the switch is turned off — the script needs to read the switch’s last_changed to know how far back to query the history, and that value stops meaning anything the moment it’s off.

Dashboard cards

The switch stays hidden from other dashboard users, and the live map only shows up while a ride is on — both handled with conditional cards, no custom frontend needed.

type: vertical-stack
cards:
  - type: conditional
    conditions:
      - condition: user
        users:
          - [your_user_id]
    card:
      type: entities
      title: Ride tracking
      entities:
        - entity: input_boolean.away_track
          name: I'm out riding

  - type: conditional
    conditions:
      - condition: state
        entity: input_boolean.away_track
        state: "on"
    card:
      type: map
      entities:
        - entity: device_tracker.[your_device_id]
      hours_to_show: 8
      default_zoom: 12

Find your own [your_user_id] from Developer Tools → Template: {{ state_attr('person.your_name', 'user_id') }}. The first card is only visible to you (hidden from your partner’s dashboard); the second is a normal map card with an 8-hour trail, wrapped so it only renders while the switch is on.

The GPX generator script

Goes in /config/scripts/generate_gpx.py. Standard library only, nothing to install. It queries HA’s history API and, for every ride, writes three files into /config/www/gpx/: the .gpx track, a per-ride .json with metadata, and a running index.json covering every ride so far (used by the web viewer below).

import sys
import os
import json
import urllib.request
from datetime import datetime

HA_URL = "http://localhost:8123"
TOKEN = "[your_long_lived_token]"
ENTITY_ID = "device_tracker.[your_device_id]"
OUTPUT_DIR = "/config/www/gpx"


def main():
    if len(sys.argv) < 2:
        print("Missing start timestamp")
        sys.exit(1)

    start = sys.argv[1]
    vehicle = sys.argv[2] if len(sys.argv) > 2 else "Unspecified"

    url = f"{HA_URL}/api/history/period/{start}?filter_entity_id={ENTITY_ID}"
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {TOKEN}"})

    with urllib.request.urlopen(req, timeout=15) as resp:
        data = json.loads(resp.read())

    history = data[0] if data else []
    points = []
    for entry in history:
        attrs = entry.get("attributes", {})
        lat = attrs.get("latitude")
        lon = attrs.get("longitude")
        alt = attrs.get("altitude")
        ts = entry.get("last_changed")
        if lat is None or lon is None:
            continue
        points.append((lat, lon, alt, ts))

    date_str = datetime.now().strftime("%Y-%m-%d")

    trkpts = "\n".join(
        f'    <trkpt lat="{lat}" lon="{lon}">'
        + (f'<ele>{alt}</ele>' if alt is not None else '')
        + f'<time>{ts}</time></trkpt>'
        for lat, lon, alt, ts in points
    )

    gpx = (
        '<?xml version="1.0" encoding="UTF-8"?>\n'
        '<gpx version="1.1" creator="HomeAssistant" '
        'xmlns="http://www.topografix.com/GPX/1/1">\n'
        "  <trk>\n"
        f"    <name>Ride {date_str}</name>\n"
        "    <trkseg>\n"
        + trkpts + "\n"
        "    </trkseg>\n"
        "  </trk>\n"
        "</gpx>\n"
    )

    os.makedirs(OUTPUT_DIR, exist_ok=True)
    gpx_filename = f"ride_{date_str}.gpx"
    with open(os.path.join(OUTPUT_DIR, gpx_filename), "w") as f:
        f.write(gpx)

    metadata = {
        "date": date_str,
        "start": start,
        "vehicle": vehicle,
        "num_points": len(points),
        "gpx": gpx_filename,
    }
    with open(os.path.join(OUTPUT_DIR, f"ride_{date_str}.json"), "w") as f:
        json.dump(metadata, f, ensure_ascii=False, indent=2)

    index_path = os.path.join(OUTPUT_DIR, "index.json")
    index = []
    if os.path.exists(index_path):
        with open(index_path) as f:
            index = json.load(f)
    index = [e for e in index if e.get("date") != date_str]
    index.append(metadata)
    index.sort(key=lambda e: e["date"], reverse=True)
    with open(index_path, "w") as f:
        json.dump(index, f, ensure_ascii=False, indent=2)

    print(f"GPX generated: {gpx_filename} ({len(points)} points)")


if __name__ == "__main__":
    main()

Step-by-step install

  1. Create a non-admin HA user and generate a Long-Lived Access Token from its own profile (Profile → Long-Lived Access Tokens).
  2. Over SSH or the “Terminal & SSH” add-on: mkdir -p /config/scripts /config/www/gpx.
  3. Create /config/scripts/generate_gpx.py with the content above, filling in TOKEN and ENTITY_ID. Lock it down: chmod 600 generate_gpx.py (it holds the token in plain text).
  4. In the Companion App on your Android phone: Settings → Companion App → Manage Sensors → Location → leave “Location sent” on “Exact”.
  5. Create the two helpers, the three script: entries, the shell_command:, and the two automations above.
  6. Test the script by hand before wiring anything to it: python3 /config/scripts/generate_gpx.py "2026-01-01T00:00:00+00:00" "Motorbike".
  7. Add the dashboard cards above and go test it for real.

The web viewer

The GPX files above are permanent, but a folder full of them isn’t much fun to browse. This is a single static HTML file, no backend of its own — it reads index.json (the running index the script above maintains) to list rides, and on selecting one, fetches its .gpx and draws it with Leaflet.

Read this before you expose it
/config/www/ (served at /local/...) does not go through Home Assistant’s authentication — that’s by design, and always has been. If your instance is reachable from the internet (a tunnel, reverse proxy, or Nabu Casa Remote UI), anyone with the URL can see your GPX rides with no login at all. See the security section below before exposing this.

What it includes:

  • Three switchable base layers: streets, satellite, topographic.
  • A hand-drawn SVG elevation profile (cumulative distance via the Haversine formula), with a button to collapse it.
  • A collapsible route list on narrow screens — built without position: fixed, because that breaks when the page is opened embedded as an iframe/webview from inside the Home Assistant app itself.

/config/www/gpx/index.html:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GPX Rides</title>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<style>
  :root{
    --bg:#EEF1EA; --paper:#FFFFFF; --paper-2:#E2E7DC; --ink:#1C231D;
    --muted:#5B6A5C; --line:#C4CDB9; --accent:#E0590C; --moss:#2F5233;
  }
  *{box-sizing:border-box;}
  body{margin:0;background:var(--bg);color:var(--ink);font-family:system-ui,sans-serif;}

  .app{ display:flex; flex-direction:column; height:100vh; }

  .topbar{
    display:flex; align-items:center; gap:8px; flex:none;
    padding:8px 12px; border-bottom:1px solid var(--line); background:var(--paper);
  }
  .topbar button{
    background:none;border:1px solid var(--line);border-radius:4px;
    padding:6px 12px;font-size:14px;cursor:pointer;color:var(--ink);
  }

  .content{ display:grid; grid-template-columns:300px 1fr; flex:1; min-height:0; }
  .content.collapsed{ grid-template-columns:0 1fr; }

  .rides{ overflow-y:auto; overflow-x:hidden; border-right:1px solid var(--line); padding:12px; }
  .content.collapsed .rides{ padding:0; border-right:none; }
  .rides h1{font-size:18px;margin:4px 0 14px;white-space:nowrap;}
  .ride-card{
    display:block;width:100%;text-align:left;background:var(--paper);
    border:1px solid var(--line);border-left:3px solid var(--line);
    border-radius:4px;padding:10px 12px;margin-bottom:8px;cursor:pointer;
    font-family:inherit;color:var(--ink);white-space:nowrap;
  }
  .ride-card:hover{background:var(--paper-2);}
  .ride-card.active{border-left-color:var(--accent);background:var(--paper-2);}
  .ride-card .date{font-weight:600;display:block;}
  .ride-card .detail{font-size:12px;color:var(--muted);}
  .empty{padding:20px;color:var(--muted);white-space:normal;}

  .main{display:flex;flex-direction:column;min-width:0;}
  #map{width:100%;flex:1;min-height:0;}

  .elevation-panel{
    border-top:1px solid var(--line);background:var(--paper);
    padding:8px 14px 4px;transition:height .15s ease;overflow:hidden;
  }
  .elevation-panel.hidden{height:0 !important;padding-top:0;padding-bottom:0;border-top:none;}
  .elevation-head{
    display:flex;justify-content:space-between;align-items:center;
    font-size:12px;color:var(--muted);margin-bottom:2px;
  }
  .elevation-head button{
    background:none;border:1px solid var(--line);border-radius:3px;
    padding:2px 8px;font-size:11px;cursor:pointer;color:var(--muted);
  }
  #elevation-chart{display:block;width:100%;height:110px;}
</style>
</head>
<body>
  <div class="app">
    <div class="topbar">
      <button id="toggle-rides">☰ Rides</button>
    </div>
    <div class="content" id="content">
      <nav class="rides" id="rides-panel">
        <h1>Recorded rides</h1>
        <div id="list"><p class="empty">Loading…</p></div>
      </nav>
      <div class="main">
        <div id="map"></div>
        <div class="elevation-panel" id="elevation-panel">
          <div class="elevation-head">
            <span id="elevation-summary">Elevation profile</span>
            <button id="toggle-elevation">Hide</button>
          </div>
          <svg id="elevation-chart" viewBox="0 0 900 110" preserveAspectRatio="none"></svg>
        </div>
      </div>
    </div>
  </div>

<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script>
  const map = L.map('map').setView([YOUR_LAT, YOUR_LON], 15);

  const streets = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', {
    attribution: 'Tiles &copy; Esri',
    maxZoom: 19
  });
  const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
    attribution: 'Tiles &copy; Esri',
    maxZoom: 19
  });
  const topo = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
    attribution: 'Map data: &copy; OpenStreetMap contributors, SRTM | Map style: &copy; OpenTopoMap',
    maxZoom: 17
  });

  streets.addTo(map);
  L.control.layers({
    'Streets': streets,
    'Satellite': satellite,
    'Topographic': topo
  }).addTo(map);

  let currentLayer = null;

  document.getElementById('toggle-rides').addEventListener('click', () => {
    document.getElementById('content').classList.toggle('collapsed');
    setTimeout(() => map.invalidateSize(), 160);
  });

  function closeRidesOnMobile(){
    if (window.innerWidth <= 700) {
      document.getElementById('content').classList.add('collapsed');
      setTimeout(() => map.invalidateSize(), 160);
    }
  }
  if (window.innerWidth <= 700) {
    document.getElementById('content').classList.add('collapsed');
  }

  async function loadIndex(){
    const list = document.getElementById('list');
    try {
      const resp = await fetch('index.json?v=' + Date.now(), {cache: 'no-store'});
      if (!resp.ok) throw new Error('no index.json yet');
      const rides = await resp.json();
      if (!rides.length){
        list.innerHTML = '<p class="empty">No rides recorded yet.</p>';
        return;
      }
      list.innerHTML = '';
      rides.forEach((ride, i) => {
        const btn = document.createElement('button');
        btn.className = 'ride-card';
        btn.innerHTML = `
          <span class="date">${ride.date}</span>
          <span class="detail">${ride.vehicle || 'unknown vehicle'} · ${ride.num_points} points</span>
        `;
        btn.addEventListener('click', () => {
          document.querySelectorAll('.ride-card').forEach(c => c.classList.remove('active'));
          btn.classList.add('active');
          showRide(ride.gpx);
          closeRidesOnMobile();
        });
        list.appendChild(btn);
        if (i === 0) { btn.classList.add('active'); showRide(ride.gpx); }
      });
    } catch (err) {
      list.innerHTML = '<p class="empty">No rides recorded yet.</p>';
    }
  }

  function haversine(a, b){
    const R = 6371000, toRad = d => d*Math.PI/180;
    const dLat = toRad(b[0]-a[0]), dLon = toRad(b[1]-a[1]);
    const la1 = toRad(a[0]), la2 = toRad(b[0]);
    const h = Math.sin(dLat/2)**2 + Math.cos(la1)*Math.cos(la2)*Math.sin(dLon/2)**2;
    return 2*R*Math.asin(Math.sqrt(h));
  }

  function drawElevation(points){
    const svg = document.getElementById('elevation-chart');
    const summary = document.getElementById('elevation-summary');
    svg.innerHTML = '';

    const withEle = points.filter(p => p[2] !== null && !isNaN(p[2]));
    if (withEle.length < 2){
      summary.textContent = 'No elevation data for this ride';
      return;
    }

    let dist = 0;
    const dists = [0];
    for (let i = 1; i < points.length; i++){
      dist += haversine(points[i-1], points[i]);
      dists.push(dist);
    }
    const eles = points.map(p => p[2]);
    const minE = Math.min(...withEle.map(p => p[2]));
    const maxE = Math.max(...withEle.map(p => p[2]));
    const spanE = Math.max(maxE - minE, 1);

    const w = 900, h = 110, pad = 8;
    const x = i => pad + (dists[i]/dist) * (w - 2*pad);
    const y = e => h - pad - ((e - minE)/spanE) * (h - 2*pad);

    let area = `M ${x(0)} ${h}`;
    points.forEach((p, i) => { area += ` L ${x(i)} ${y(p[2] ?? minE)}`; });
    area += ` L ${x(points.length-1)} ${h} Z`;

    const svgns = 'http://www.w3.org/2000/svg';
    const path = document.createElementNS(svgns, 'path');
    path.setAttribute('d', area);
    path.setAttribute('fill', 'rgba(47,82,51,0.25)');
    path.setAttribute('stroke', 'var(--moss)');
    path.setAttribute('stroke-width', '2');
    svg.appendChild(path);

    let gain = 0;
    for (let i = 1; i < eles.length; i++){
      const delta = eles[i] - eles[i-1];
      if (delta > 0) gain += delta;
    }
    summary.textContent =
      `${(dist/1000).toFixed(1)} km · ${minE.toFixed(0)}–${maxE.toFixed(0)} m · +${gain.toFixed(0)} m gain`;
  }

  document.getElementById('toggle-elevation').addEventListener('click', (e) => {
    const panel = document.getElementById('elevation-panel');
    panel.classList.toggle('hidden');
    e.target.textContent = panel.classList.contains('hidden') ? 'Show' : 'Hide';
  });

  async function showRide(gpxFile){
    const resp = await fetch(gpxFile + '?v=' + Date.now(), {cache: 'no-store'});
    const text = await resp.text();
    const xml = new DOMParser().parseFromString(text, 'text/xml');
    const points = [...xml.querySelectorAll('trkpt')].map(p => {
      const ele = p.querySelector('ele');
      return [
        parseFloat(p.getAttribute('lat')),
        parseFloat(p.getAttribute('lon')),
        ele ? parseFloat(ele.textContent) : null
      ];
    });

    if (currentLayer) map.removeLayer(currentLayer);
    if (!points.length) return;

    const latlngs = points.map(p => [p[0], p[1]]);
    currentLayer = L.layerGroup().addTo(map);
    L.polyline(latlngs, {color: '#ffffff', weight: 6, opacity: 0.9}).addTo(currentLayer);
    L.polyline(latlngs, {color: '#FF2E9A', weight: 3}).addTo(currentLayer);
    L.circleMarker(latlngs[0], {radius: 7, weight: 2, color: '#ffffff', fillColor: '#2F7A4A', fillOpacity: 1}).addTo(currentLayer);
    L.circleMarker(latlngs[latlngs.length - 1], {radius: 7, weight: 2, color: '#ffffff', fillColor: '#C33327', fillOpacity: 1}).addTo(currentLayer);
    map.fitBounds(L.latLngBounds(latlngs), {padding: [30, 30]});

    drawElevation(points);
  }

  loadIndex();
</script>
</body>
</html>

Swap YOUR_LAT/YOUR_LON for coordinates near your usual riding area — it’s just the initial view before a ride is loaded.

Security: locking down /local/gpx/*

If you reach your HA instance from outside your home through a tunnel exposed to the internet (in my case, Cloudflare Tunnel), anything under /config/www/ is visible to anyone with the URL — no login required. That includes your GPX rides and anything else you’ve dropped in that folder. I fixed it with a Cloudflare Access application (part of Zero Trust, free for personal use) scoped to that one path:

  1. Cloudflare Zero Trust dashboard → Access → Applications → Add an application.
  2. Type “Self-hosted”, your domain + path /local/gpx/*.
  3. Access policy: login via a one-time code sent to your email (or any SSO you already use), optionally with a country restriction.

With that in place, any request to that path is intercepted at Cloudflare’s edge — if you don’t pass that login, the request never even reaches your HA instance. The rest of your instance (already behind its own login) keeps working exactly as before.

If you don’t expose HA to the internet
If you only access HA over your local network or a VPN (WireGuard/Tailscale), this risk is much smaller — only someone already inside your network could reach that URL. Still worth checking what else is sitting in your www/ folder that probably shouldn’t be public.

1 Like