location-tracker 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Condrey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
PKG-INFO ADDED
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: location-tracker
3
+ Version: 1.0.0
4
+ Summary: Self-hosted location tracking dashboard with encrypted cookies, SQLite storage, and adaptive polling
5
+ Project-URL: Homepage, https://github.com/dcondrey/location-tracker
6
+ Project-URL: Repository, https://github.com/dcondrey/location-tracker
7
+ Project-URL: Issues, https://github.com/dcondrey/location-tracker/issues
8
+ Author-email: David Condrey <davidcondrey@me.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: dashboard,geolocation,google-maps,location,self-hosted,tracking
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: System :: Monitoring
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: cryptography>=44.0
24
+ Requires-Dist: flask>=3.1.0
25
+ Requires-Dist: folium>=0.20.0
26
+ Requires-Dist: locationsharinglib>=5.0.3
27
+ Requires-Dist: pandas>=3.0.3
28
+ Requires-Dist: playwright-stealth>=2.0.3
29
+ Requires-Dist: playwright>=1.60.0
30
+ Requires-Dist: schedule>=1.2.2
31
+ Provides-Extra: build
32
+ Requires-Dist: pyinstaller>=6.0; extra == 'build'
33
+ Provides-Extra: dev
34
+ Requires-Dist: pip-audit>=2.7; extra == 'dev'
35
+ Requires-Dist: pytest>=8.0; extra == 'dev'
36
+ Requires-Dist: ruff>=0.11; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # Location Tracker
40
+
41
+ A self-hosted location tracking dashboard that polls Google Maps location sharing and visualizes movement history on an interactive map. Runs as a background daemon with a real-time web interface.
42
+
43
+ ## Features
44
+
45
+ - **Real-time tracking** -- Polls Google Maps shared locations with adaptive intervals (15s when driving, 10min when stationary)
46
+ - **Interactive dashboard** -- Dark-themed Leaflet map with path visualization, heatmaps, stop detection, and timeline scrubbing
47
+ - **Self-tracking** -- Track your own position via browser geolocation
48
+ - **Encrypted cookie storage** -- Google auth tokens encrypted at rest with Fernet; key stored in macOS Keychain
49
+ - **Auto cookie refresh** -- Headless browser automatically re-authenticates when cookies expire
50
+ - **SQLite storage** -- Location history stored in an indexed SQLite database with WAL mode
51
+ - **Mobile responsive** -- Bottom-sheet sidebar on phones, touch-friendly controls
52
+ - **Multiple export formats** -- JSON, CSV, and GeoJSON
53
+ - **Persistent daemon** -- Optional launchd integration to survive reboots
54
+ - **CLI query tools** -- Look up anyone's latest location or history from the terminal
55
+
56
+ ## Quick Start
57
+
58
+ ```bash
59
+ # Clone and set up
60
+ git clone https://github.com/dcondrey/location-tracker.git
61
+ cd location-tracker
62
+ ./setup.sh
63
+
64
+ # Or manually:
65
+ uv sync
66
+ uv run location-tracker setup
67
+
68
+ # Configure your Google account
69
+ uv run location-tracker config --email you@gmail.com
70
+
71
+ # Authenticate with Google (opens browser)
72
+ uv run location-tracker cookies
73
+
74
+ # Start tracking
75
+ uv run location-tracker on
76
+ ```
77
+
78
+ The dashboard will be available at **http://tracker** (or `http://localhost:7070` if DNS isn't configured).
79
+
80
+ ## Prerequisites
81
+
82
+ - **Python 3.11+**
83
+ - **macOS** (uses Keychain for cookie encryption, launchd for persistence)
84
+ - **uv** package manager ([install](https://docs.astral.sh/uv/getting-started/installation/))
85
+ - A Google account with [location sharing](https://support.google.com/maps/answer/7326816) enabled
86
+
87
+ ## Commands
88
+
89
+ ### Core
90
+
91
+ | Command | Description |
92
+ |---------|-------------|
93
+ | `on` | Start the tracker daemon and web dashboard |
94
+ | `off` | Stop the tracker |
95
+ | `status` | Check if the tracker is running |
96
+ | `cookies` | Open browser to authenticate with Google |
97
+ | `test` | Verify cookies are valid and list shared contacts |
98
+
99
+ ### Configuration
100
+
101
+ | Command | Description |
102
+ |---------|-------------|
103
+ | `config --email you@gmail.com` | Set the Google account email |
104
+ | `config` | Show current configuration |
105
+ | `setup` | First-time setup: install Chromium, configure DNS and port forwarding |
106
+ | `dns` | Set up `http://tracker` hostname |
107
+ | `dns --remove` | Remove hostname and port forwarding |
108
+ | `install` | Install as a launchd service (auto-start on login) |
109
+ | `install --remove` | Remove the launchd service |
110
+
111
+ ### Data
112
+
113
+ | Command | Description |
114
+ |---------|-------------|
115
+ | `where <person>` | Show someone's latest known location |
116
+ | `history <person> --days 7` | Show recent location history |
117
+ | `stats` | Print tracking statistics (distance, stops, dwell time) |
118
+ | `map --days 7 --output map.html` | Generate a static HTML map |
119
+ | `purge <days>` | Delete location records older than N days |
120
+
121
+ ## Dashboard
122
+
123
+ The web dashboard provides:
124
+
125
+ - **Map views** -- Road, Satellite, Hybrid, Terrain, and Dark map layers via Google/CARTO tiles
126
+ - **Visualization modes** -- Path view (color-coded routes with stop nodes), Heatmap, and Points
127
+ - **Time filtering** -- 24h, 3 days, 7 days, 30 days, 90 days, or all time
128
+ - **Timeline scrubber** -- Drag to view historical positions with date/time labels
129
+ - **Person cards** -- Click to focus; shows speed badge (Stationary/Walking/Driving/Highway)
130
+ - **Self-tracking** -- Enable browser geolocation to appear on the map yourself
131
+ - **Export** -- Download data as JSON, CSV, or GeoJSON
132
+ - **Toast notifications** -- Visual feedback for all actions
133
+
134
+ ## How It Works
135
+
136
+ ### Polling
137
+
138
+ The tracker polls Google Maps location sharing via [`locationsharinglib`](https://github.com/costastf/locationsharinglib). Polling frequency adapts to detected movement speed:
139
+
140
+ | Speed | Category | Poll Interval |
141
+ |-------|----------|--------------|
142
+ | > 60 km/h | Highway | 15 seconds |
143
+ | 10-60 km/h | Driving | 30 seconds |
144
+ | 1-10 km/h | Walking | 60 seconds |
145
+ | < 1 km/h | Stationary | 10 minutes |
146
+
147
+ The person being tracked receives no notification. This is a passive read of data they have already chosen to share.
148
+
149
+ ### Security
150
+
151
+ - **Cookie encryption** -- Google auth cookies are encrypted with `cryptography.Fernet` before writing to disk. The encryption key is stored in macOS Keychain, never on the filesystem.
152
+ - **Localhost only** -- The Flask server binds to `127.0.0.1`; not accessible from the network.
153
+ - **XSS protection** -- All user-controlled data is HTML-escaped before rendering.
154
+ - **Input validation** -- Lat/lon bounds checking, type coercion, and error handling on all API endpoints.
155
+ - **Atomic writes** -- SQLite with WAL mode for concurrent safety.
156
+
157
+ ### Data Storage
158
+
159
+ Location history is stored in a local SQLite database (`location_history.db`) with indexed columns for fast queries. Existing `location_history.json` files are automatically migrated on first run.
160
+
161
+ ## Project Structure
162
+
163
+ ```
164
+ location-tracker/
165
+ main.py # CLI entry point and daemon management
166
+ tracker.py # Location polling, stats, and map generation
167
+ dashboard.py # Flask web server and API endpoints
168
+ db.py # SQLite database layer
169
+ cookie_store.py # Encrypted cookie storage (Fernet + Keychain)
170
+ get_cookies.py # Browser-based Google authentication
171
+ templates/
172
+ index.html # Dashboard HTML
173
+ static/
174
+ style.css # Dashboard styles
175
+ app.js # Dashboard JavaScript
176
+ setup.sh # One-command install script
177
+ build.sh # PyInstaller standalone build
178
+ ```
179
+
180
+ ## License
181
+
182
+ MIT
README.md ADDED
@@ -0,0 +1,144 @@
1
+ # Location Tracker
2
+
3
+ A self-hosted location tracking dashboard that polls Google Maps location sharing and visualizes movement history on an interactive map. Runs as a background daemon with a real-time web interface.
4
+
5
+ ## Features
6
+
7
+ - **Real-time tracking** -- Polls Google Maps shared locations with adaptive intervals (15s when driving, 10min when stationary)
8
+ - **Interactive dashboard** -- Dark-themed Leaflet map with path visualization, heatmaps, stop detection, and timeline scrubbing
9
+ - **Self-tracking** -- Track your own position via browser geolocation
10
+ - **Encrypted cookie storage** -- Google auth tokens encrypted at rest with Fernet; key stored in macOS Keychain
11
+ - **Auto cookie refresh** -- Headless browser automatically re-authenticates when cookies expire
12
+ - **SQLite storage** -- Location history stored in an indexed SQLite database with WAL mode
13
+ - **Mobile responsive** -- Bottom-sheet sidebar on phones, touch-friendly controls
14
+ - **Multiple export formats** -- JSON, CSV, and GeoJSON
15
+ - **Persistent daemon** -- Optional launchd integration to survive reboots
16
+ - **CLI query tools** -- Look up anyone's latest location or history from the terminal
17
+
18
+ ## Quick Start
19
+
20
+ ```bash
21
+ # Clone and set up
22
+ git clone https://github.com/dcondrey/location-tracker.git
23
+ cd location-tracker
24
+ ./setup.sh
25
+
26
+ # Or manually:
27
+ uv sync
28
+ uv run location-tracker setup
29
+
30
+ # Configure your Google account
31
+ uv run location-tracker config --email you@gmail.com
32
+
33
+ # Authenticate with Google (opens browser)
34
+ uv run location-tracker cookies
35
+
36
+ # Start tracking
37
+ uv run location-tracker on
38
+ ```
39
+
40
+ The dashboard will be available at **http://tracker** (or `http://localhost:7070` if DNS isn't configured).
41
+
42
+ ## Prerequisites
43
+
44
+ - **Python 3.11+**
45
+ - **macOS** (uses Keychain for cookie encryption, launchd for persistence)
46
+ - **uv** package manager ([install](https://docs.astral.sh/uv/getting-started/installation/))
47
+ - A Google account with [location sharing](https://support.google.com/maps/answer/7326816) enabled
48
+
49
+ ## Commands
50
+
51
+ ### Core
52
+
53
+ | Command | Description |
54
+ |---------|-------------|
55
+ | `on` | Start the tracker daemon and web dashboard |
56
+ | `off` | Stop the tracker |
57
+ | `status` | Check if the tracker is running |
58
+ | `cookies` | Open browser to authenticate with Google |
59
+ | `test` | Verify cookies are valid and list shared contacts |
60
+
61
+ ### Configuration
62
+
63
+ | Command | Description |
64
+ |---------|-------------|
65
+ | `config --email you@gmail.com` | Set the Google account email |
66
+ | `config` | Show current configuration |
67
+ | `setup` | First-time setup: install Chromium, configure DNS and port forwarding |
68
+ | `dns` | Set up `http://tracker` hostname |
69
+ | `dns --remove` | Remove hostname and port forwarding |
70
+ | `install` | Install as a launchd service (auto-start on login) |
71
+ | `install --remove` | Remove the launchd service |
72
+
73
+ ### Data
74
+
75
+ | Command | Description |
76
+ |---------|-------------|
77
+ | `where <person>` | Show someone's latest known location |
78
+ | `history <person> --days 7` | Show recent location history |
79
+ | `stats` | Print tracking statistics (distance, stops, dwell time) |
80
+ | `map --days 7 --output map.html` | Generate a static HTML map |
81
+ | `purge <days>` | Delete location records older than N days |
82
+
83
+ ## Dashboard
84
+
85
+ The web dashboard provides:
86
+
87
+ - **Map views** -- Road, Satellite, Hybrid, Terrain, and Dark map layers via Google/CARTO tiles
88
+ - **Visualization modes** -- Path view (color-coded routes with stop nodes), Heatmap, and Points
89
+ - **Time filtering** -- 24h, 3 days, 7 days, 30 days, 90 days, or all time
90
+ - **Timeline scrubber** -- Drag to view historical positions with date/time labels
91
+ - **Person cards** -- Click to focus; shows speed badge (Stationary/Walking/Driving/Highway)
92
+ - **Self-tracking** -- Enable browser geolocation to appear on the map yourself
93
+ - **Export** -- Download data as JSON, CSV, or GeoJSON
94
+ - **Toast notifications** -- Visual feedback for all actions
95
+
96
+ ## How It Works
97
+
98
+ ### Polling
99
+
100
+ The tracker polls Google Maps location sharing via [`locationsharinglib`](https://github.com/costastf/locationsharinglib). Polling frequency adapts to detected movement speed:
101
+
102
+ | Speed | Category | Poll Interval |
103
+ |-------|----------|--------------|
104
+ | > 60 km/h | Highway | 15 seconds |
105
+ | 10-60 km/h | Driving | 30 seconds |
106
+ | 1-10 km/h | Walking | 60 seconds |
107
+ | < 1 km/h | Stationary | 10 minutes |
108
+
109
+ The person being tracked receives no notification. This is a passive read of data they have already chosen to share.
110
+
111
+ ### Security
112
+
113
+ - **Cookie encryption** -- Google auth cookies are encrypted with `cryptography.Fernet` before writing to disk. The encryption key is stored in macOS Keychain, never on the filesystem.
114
+ - **Localhost only** -- The Flask server binds to `127.0.0.1`; not accessible from the network.
115
+ - **XSS protection** -- All user-controlled data is HTML-escaped before rendering.
116
+ - **Input validation** -- Lat/lon bounds checking, type coercion, and error handling on all API endpoints.
117
+ - **Atomic writes** -- SQLite with WAL mode for concurrent safety.
118
+
119
+ ### Data Storage
120
+
121
+ Location history is stored in a local SQLite database (`location_history.db`) with indexed columns for fast queries. Existing `location_history.json` files are automatically migrated on first run.
122
+
123
+ ## Project Structure
124
+
125
+ ```
126
+ location-tracker/
127
+ main.py # CLI entry point and daemon management
128
+ tracker.py # Location polling, stats, and map generation
129
+ dashboard.py # Flask web server and API endpoints
130
+ db.py # SQLite database layer
131
+ cookie_store.py # Encrypted cookie storage (Fernet + Keychain)
132
+ get_cookies.py # Browser-based Google authentication
133
+ templates/
134
+ index.html # Dashboard HTML
135
+ static/
136
+ style.css # Dashboard styles
137
+ app.js # Dashboard JavaScript
138
+ setup.sh # One-command install script
139
+ build.sh # PyInstaller standalone build
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT
cookie_store.py ADDED
@@ -0,0 +1,100 @@
1
+ import logging
2
+ import os
3
+ import subprocess
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from cryptography.fernet import Fernet, InvalidToken
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+ KEYCHAIN_ACCOUNT = "location-tracker"
12
+ KEYCHAIN_SERVICE = "cookie-encryption-key"
13
+ ENCRYPTED_FILE = "cookies.enc"
14
+
15
+
16
+ def _keychain_get():
17
+ """Retrieve the encryption key from macOS Keychain."""
18
+ result = subprocess.run(
19
+ ["security", "find-generic-password",
20
+ "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w"],
21
+ capture_output=True, text=True,
22
+ )
23
+ if result.returncode == 0:
24
+ return result.stdout.strip().encode()
25
+ return None
26
+
27
+
28
+ def _keychain_set(key):
29
+ """Store the encryption key in macOS Keychain."""
30
+ subprocess.run(
31
+ ["security", "add-generic-password",
32
+ "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE,
33
+ "-w", key.decode(), "-U"],
34
+ capture_output=True,
35
+ )
36
+
37
+
38
+ def _get_or_create_key():
39
+ """Get existing key from Keychain or generate and store a new one."""
40
+ key = _keychain_get()
41
+ if key:
42
+ return key
43
+ key = Fernet.generate_key()
44
+ _keychain_set(key)
45
+ log.info("Generated new encryption key and stored in Keychain.")
46
+ return key
47
+
48
+
49
+ def encrypt_cookies(plaintext_path, encrypted_path=ENCRYPTED_FILE):
50
+ """Encrypt a plaintext cookies file and save to encrypted_path."""
51
+ key = _get_or_create_key()
52
+ fernet = Fernet(key)
53
+ with open(plaintext_path, "rb") as f:
54
+ data = f.read()
55
+ encrypted = fernet.encrypt(data)
56
+ with open(encrypted_path, "wb") as f:
57
+ f.write(encrypted)
58
+ os.unlink(plaintext_path)
59
+ log.info("Cookies encrypted and stored in %s. Plaintext removed.", encrypted_path)
60
+
61
+
62
+ def decrypt_to_tempfile(encrypted_path=ENCRYPTED_FILE):
63
+ """Decrypt cookies to a temporary file. Caller must delete when done."""
64
+ if not Path(encrypted_path).exists():
65
+ return None
66
+ key = _keychain_get()
67
+ if not key:
68
+ log.error("No encryption key found in Keychain. Re-run: location-tracker cookies")
69
+ return None
70
+ fernet = Fernet(key)
71
+ try:
72
+ with open(encrypted_path, "rb") as f:
73
+ encrypted = f.read()
74
+ plaintext = fernet.decrypt(encrypted)
75
+ except InvalidToken:
76
+ log.error("Failed to decrypt cookies (key mismatch). Re-run: location-tracker cookies")
77
+ return None
78
+ fd, tmp_path = tempfile.mkstemp(suffix=".txt", prefix="cookies_")
79
+ with os.fdopen(fd, "wb") as f:
80
+ f.write(plaintext)
81
+ return tmp_path
82
+
83
+
84
+ def has_encrypted_cookies(encrypted_path=ENCRYPTED_FILE):
85
+ """Check if encrypted cookies file exists."""
86
+ return Path(encrypted_path).exists()
87
+
88
+
89
+ def has_plaintext_cookies(plaintext_path="cookies.txt"):
90
+ """Check if legacy plaintext cookies exist (for migration)."""
91
+ return Path(plaintext_path).exists()
92
+
93
+
94
+ def migrate_plaintext_to_encrypted(plaintext_path="cookies.txt", encrypted_path=ENCRYPTED_FILE):
95
+ """Migrate existing plaintext cookies.txt to encrypted format."""
96
+ if has_plaintext_cookies(plaintext_path) and not has_encrypted_cookies(encrypted_path):
97
+ log.info("Migrating plaintext cookies to encrypted storage...")
98
+ encrypt_cookies(plaintext_path, encrypted_path)
99
+ return True
100
+ return False
dashboard.py ADDED
@@ -0,0 +1,242 @@
1
+ import csv
2
+ import io
3
+ import json
4
+ import logging
5
+ import threading
6
+ import time
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from flask import Flask, Response, jsonify, render_template, request
11
+
12
+ from tracker import LocationTracker
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ _TEMPLATE_DIR = Path(__file__).parent / "templates"
17
+ _STATIC_DIR = Path(__file__).parent / "static"
18
+
19
+
20
+
21
+ def _compute_speed_kmh(history):
22
+ """Compute recent speed in km/h from the last few data points across all people."""
23
+ import math
24
+ best_speed = 0.0
25
+ for person, locations in history.items():
26
+ if person == "Me" or len(locations) < 2:
27
+ continue
28
+ recent = locations[-5:]
29
+ for i in range(1, len(recent)):
30
+ prev, curr = recent[i - 1], recent[i]
31
+ lat1, lon1 = math.radians(prev['latitude']), math.radians(prev['longitude'])
32
+ lat2, lon2 = math.radians(curr['latitude']), math.radians(curr['longitude'])
33
+ dlat, dlon = lat2 - lat1, lon2 - lon1
34
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
35
+ dist_m = 6371000 * 2 * math.asin(math.sqrt(a))
36
+ try:
37
+ t1 = datetime.fromisoformat(prev['timestamp'])
38
+ t2 = datetime.fromisoformat(curr['timestamp'])
39
+ except (ValueError, TypeError):
40
+ continue
41
+ dt = (t2 - t1).total_seconds()
42
+ if dt > 0:
43
+ speed = (dist_m / dt) * 3.6
44
+ best_speed = max(best_speed, speed)
45
+ return best_speed
46
+
47
+
48
+ def _speed_info_for_points(points):
49
+ """Compute speed info for a person's location points."""
50
+ import math
51
+ if not points or len(points) < 2:
52
+ return {"speed_kmh": 0, "label": "Stationary", "cls": "badge-stationary"}
53
+ prev, last = points[-2], points[-1]
54
+ lat1, lon1 = math.radians(prev['latitude']), math.radians(prev['longitude'])
55
+ lat2, lon2 = math.radians(last['latitude']), math.radians(last['longitude'])
56
+ dlat, dlon = lat2 - lat1, lon2 - lon1
57
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
58
+ dist_m = 6371000 * 2 * math.asin(math.sqrt(a))
59
+ try:
60
+ t1 = datetime.fromisoformat(prev['timestamp'])
61
+ t2 = datetime.fromisoformat(last['timestamp'])
62
+ except (ValueError, TypeError):
63
+ return {"speed_kmh": 0, "label": "Stationary", "cls": "badge-stationary"}
64
+ dt = (t2 - t1).total_seconds()
65
+ if dt <= 0:
66
+ return {"speed_kmh": 0, "label": "Stationary", "cls": "badge-stationary"}
67
+ kmh = (dist_m / dt) * 3.6
68
+ if kmh < 1:
69
+ return {"speed_kmh": round(kmh, 1), "label": "Stationary", "cls": "badge-stationary"}
70
+ if kmh < 10:
71
+ return {"speed_kmh": round(kmh, 1), "label": "Walking", "cls": "badge-slow"}
72
+ if kmh < 60:
73
+ return {"speed_kmh": round(kmh, 1), "label": "Driving", "cls": "badge-moving"}
74
+ return {"speed_kmh": round(kmh, 1), "label": "Highway", "cls": "badge-fast"}
75
+
76
+
77
+ def _adaptive_interval(history, default_interval):
78
+ """Return (interval_seconds, speed_category) based on movement speed.
79
+
80
+ Polls aggressively during movement for accurate path tracking:
81
+ - Stationary (< 1 km/h for 5+ min): 600s (10 min)
82
+ - Slow (1-10 km/h, e.g. walking): 60s (1 min)
83
+ - Moderate (10-60 km/h, e.g. city driving): 30s
84
+ - Fast (> 60 km/h, e.g. highway): 15s
85
+ """
86
+ speed = _compute_speed_kmh(history)
87
+ if speed >= 60:
88
+ return 15, "fast"
89
+ elif speed >= 10:
90
+ return 30, "moderate"
91
+ elif speed >= 1:
92
+ return 60, "slow"
93
+ else:
94
+ # Check if stationary for 5+ minutes
95
+ for person, locations in history.items():
96
+ if person == "Me" or len(locations) < 2:
97
+ continue
98
+ last = locations[-1]
99
+ second_last = locations[-2]
100
+ try:
101
+ t1 = datetime.fromisoformat(second_last['timestamp'])
102
+ t2 = datetime.fromisoformat(last['timestamp'])
103
+ except (ValueError, TypeError):
104
+ continue
105
+ if (t2 - t1).total_seconds() >= 300:
106
+ return 600, "stationary"
107
+ return default_interval, "stationary"
108
+
109
+
110
+ def run_dashboard(data_file, cookies_file, email, port, poll_interval):
111
+ app = Flask(__name__, template_folder=str(_TEMPLATE_DIR), static_folder=str(_STATIC_DIR))
112
+ app.logger.setLevel(logging.WARNING)
113
+ tracker = LocationTracker(cookies_file, email, data_file)
114
+
115
+ self_name = "Me"
116
+ poll_state = {"interval": poll_interval, "category": "moderate"}
117
+ poll_lock = threading.Lock()
118
+
119
+ def background_poll():
120
+ while True:
121
+ try:
122
+ tracker.poll_location()
123
+ interval, category = _adaptive_interval(tracker.history, poll_interval)
124
+ with poll_lock:
125
+ poll_state["interval"] = interval
126
+ poll_state["category"] = category
127
+ log.info("Next poll in %ds (speed: %s)", interval, category)
128
+ time.sleep(interval)
129
+ except Exception as e:
130
+ log.error("Poll thread error: %s: %s", type(e).__name__, e)
131
+ time.sleep(poll_interval)
132
+
133
+ poll_thread = threading.Thread(target=background_poll, daemon=True)
134
+ poll_thread.start()
135
+
136
+ @app.route('/')
137
+ def index():
138
+ return render_template('index.html')
139
+
140
+ @app.route('/api/locations')
141
+ def api_locations():
142
+ days = request.args.get('days', '0', type=str)
143
+ try:
144
+ days_int = int(days) if days != '0' else None
145
+ except ValueError:
146
+ days_int = None
147
+ from datetime import timedelta
148
+ since = None
149
+ if days_int:
150
+ since = (datetime.now(UTC) - timedelta(days=days_int)).isoformat()
151
+ data = tracker.db.get_history_dict(since=since)
152
+ # Attach speed_info per person so the frontend doesn't need to recompute
153
+ speed_info = {}
154
+ for person, pts in data.items():
155
+ speed_info[person] = _speed_info_for_points(pts)
156
+ return jsonify({"locations": data, "speed_info": speed_info})
157
+
158
+ @app.route('/api/stats')
159
+ def api_stats():
160
+ return jsonify(tracker.get_stats())
161
+
162
+ @app.route('/api/self-location', methods=['POST'])
163
+ def api_self_location():
164
+ data = request.get_json()
165
+ if not data or 'latitude' not in data or 'longitude' not in data:
166
+ return jsonify({'error': 'missing fields'}), 400
167
+
168
+ try:
169
+ lat = float(data['latitude'])
170
+ lon = float(data['longitude'])
171
+ except (TypeError, ValueError):
172
+ return jsonify({'error': 'invalid coordinates'}), 400
173
+
174
+ if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
175
+ return jsonify({'error': 'coordinates out of range'}), 400
176
+
177
+ tracker.db.add_location(
178
+ person=self_name,
179
+ timestamp=datetime.now(UTC).isoformat(),
180
+ latitude=lat,
181
+ longitude=lon,
182
+ accuracy=data.get('accuracy'),
183
+ address=f"({lat:.4f}, {lon:.4f})",
184
+ )
185
+ return jsonify({'ok': True})
186
+
187
+ @app.route('/api/poll-status')
188
+ def api_poll_status():
189
+ with poll_lock:
190
+ return jsonify({
191
+ 'current_interval': poll_state['interval'],
192
+ 'speed_category': poll_state['category'],
193
+ })
194
+
195
+ @app.route('/api/export')
196
+ def api_export():
197
+ fmt = request.args.get('format', 'json')
198
+ history = tracker.history
199
+
200
+ if fmt == 'csv':
201
+ buf = io.StringIO()
202
+ writer = csv.writer(buf)
203
+ writer.writerow(['person', 'timestamp', 'latitude', 'longitude',
204
+ 'accuracy', 'battery', 'charging', 'address'])
205
+ for person, locations in history.items():
206
+ for loc in locations:
207
+ writer.writerow([
208
+ person, loc.get('timestamp'), loc.get('latitude'),
209
+ loc.get('longitude'), loc.get('accuracy'),
210
+ loc.get('battery'), loc.get('charging'),
211
+ loc.get('address'),
212
+ ])
213
+ return Response(buf.getvalue(), mimetype='text/csv',
214
+ headers={'Content-Disposition': 'attachment; filename=location-history.csv'})
215
+
216
+ if fmt == 'geojson':
217
+ features = []
218
+ for person, locations in history.items():
219
+ for loc in locations:
220
+ features.append({
221
+ "type": "Feature",
222
+ "geometry": {
223
+ "type": "Point",
224
+ "coordinates": [loc.get('longitude'), loc.get('latitude')],
225
+ },
226
+ "properties": {
227
+ "person": person,
228
+ "timestamp": loc.get('timestamp'),
229
+ "accuracy": loc.get('accuracy'),
230
+ "battery": loc.get('battery'),
231
+ "charging": loc.get('charging'),
232
+ "address": loc.get('address'),
233
+ },
234
+ })
235
+ geojson = {"type": "FeatureCollection", "features": features}
236
+ return Response(json.dumps(geojson), mimetype='application/geo+json',
237
+ headers={'Content-Disposition': 'attachment; filename=location-history.geojson'})
238
+
239
+ return jsonify(history)
240
+
241
+ log.info("Dashboard running at http://tracker (port %d)", port)
242
+ app.run(host='127.0.0.1', port=port, debug=False)