Multi-device GPS tracking dashboard — LilyGO T-SIM7000G (ESP32 + SIM7000G) firmware with live/batch tracking modes, SD offline buffer, and a PHP/MySQL/Leaflet web dashboard with per-device overlays, ride replay, stats, and GPX export.
Supports multiple GPS sources in one UI: the on-board firmware, any Traccar-tracked device (phone, ST-901L, etc.) via the Home Assistant integration, or any other client that POSTs to the HTTP ingest endpoint.
├── firmware/
│ └── gps_tracker_optimized/ # Arduino sketch (v3) for LilyGO T-SIM7000G
│ ├── gps_tracker_optimized.ino
│ └── config.h # Hardware pins, server, APN, API key
├── backend/ # PHP/MySQL web dashboard (Apache)
│ ├── index.html # Main SPA (Leaflet map + device picker + settings modal)
│ ├── admin.html # Admin panel (users + devices tables, gated on is_admin)
│ ├── map.js # Map rendering, per-device overlays, replay, stats, device settings
│ ├── script.js # Live auto-refresh
│ ├── style.css # Dark theme styles
│ ├── gps_tracker_add_data_to_db.php # Receive GPS points (device code → devices table)
│ ├── ingest_traccar.php # Traccar Client / OsmAnd phone-push compatibility shim
│ ├── pobierz_urzadzenia.php # List devices → JSON (populates the UI picker)
│ ├── pobierz_punkty.php # Points for a date; single device or all → JSON
│ ├── pobierz_ostatni_punkt.php # Latest point(s) → JSON (live tracking)
│ ├── pobierz_historie.php # Ride history grouped by device+date → JSON
│ ├── export_gpx.php # Export a single device's ride as GPX
│ ├── distance_meausure.php # Distance calculation helper
│ ├── auth.php # Session check + per-user device filter
│ ├── login.php / login.html # Username+password login
│ ├── logout.php # Session destroy
│ ├── change_password.php / change_password.html
│ ├── pobierz_me.php # Current-user JSON (display name, admin flag, API key)
│ ├── device/ # User-facing device management endpoints
│ │ ├── _common.php # Ownership check + POST guard helpers
│ │ ├── update.php # Rename device and/or change polyline color
│ │ └── delete_points.php # Range delete (all / stationary-only) with FK cascade
│ ├── admin/ # Admin-only endpoints (all require is_admin=1)
│ │ ├── _gate.php # 403s non-admins; shared helpers
│ │ ├── list_users.php # Users list with device + recent-activity counts
│ │ ├── list_devices.php # Devices list with owner + last-point timestamp
│ │ ├── reset_password.php # Force-reset a user's password (must_change_password=1)
│ │ ├── regen_api_key.php # Rotate a user's write API key (old key dies instantly)
│ │ ├── regen_view_key.php # Rotate a user's read-only embed token (for HA iframes etc.)
│ │ ├── toggle_user.php # Soft-disable a user (refuses self-disable)
│ │ └── reassign_device.php # Move a device to a different owner (keeps history)
│ ├── migrations/ # SQL migrations (run in order)
│ │ ├── 001_multi_device.sql # Single-table S_02 → devices + points schema
│ │ ├── 002_multi_user.sql # users table + devices.user_id
│ │ ├── 003_point_source.sql # points.source ENUM + parent_id for snap/interpolate
│ │ ├── 004_admin.sql # users.active for soft-disable
│ │ ├── 005_traccar_pull.sql # points.traccar_pos_id for pull idempotency
│ │ └── 006_read_api_key.sql # users.read_api_key for password-less embed (HA iframe)
│ ├── tools/
│ │ ├── bootstrap_admin.php # Seed the first admin (preserves legacy API key)
│ │ └── add_user.php # Create a user and mint a write API key
│ ├── gps_track_config.php.example # DB credentials template
│ ├── composer.json # PHP dependencies (google/protobuf)
│ ├── MULTI_USER_SETUP.md # Runbook for malinka multi-user deploy
│ └── UBUNTU_STANDALONE.md # Runbook for the public-facing ubuntu instance
├── integrations/
│ ├── malinka/ # On-malinka workers (systemd timers)
│ │ ├── snap_interpolate.py # OSRM /match + /route gap-fill; 5-min cadence
│ │ └── pull_from_traccar.py # Pull HA Traccar positions via REST; 2-min cadence
│ └── homeassistant/ # Legacy push-based integration (superseded by pull)
│ └── push-to-mototracker.py
├── hardware/
│ └── T-SIM7000G_Box_-_Box_PWR_Button.stl # 3D-printable power button cap
├── tools/
│ └── gen_demo_ride.py # Generate demo GPS data and insert into MySQL
└── img/ # Screenshots for this README
| Component | Details |
|---|---|
| Board | LilyGO T-SIM7000G (ESP32 + SIM7000G modem) |
| Cellular | 2G GSM / NB-IoT / CAT-M (no 4G) |
| GPS | Integrated SIM7000G GNSS, 1 Hz |
| SD card | Up to 32 GB (offline buffer) |
| Battery | 18650 Li-Ion with charging circuit |
| Power button | 3D-printed cap for the board's power switch |
Two tracking modes selectable in config.h:
- GPS + modem always on, sends 1 point/second via HTTP GET
- HTTP keep-alive reuses the TCP connection
- SD buffer on network failure, auto-recovers when connectivity returns
- Battery protection: auto-sleeps below 3.3 V
- LED: blink = sending, solid = no GPS fix
- Deep sleep 60 s between fixes, RTC memory batches 5 points per send
- SD offline buffer (up to 100 000 points ≈ 27 h at 1 pt/s)
- Low power consumption
GET /gpstrack/gps_tracker_add_data_to_db.php?k=<API_KEY>&v=<DEVICE_CODE>&la=<LAT>&lo=<LON>&s=<SPEED>&t=<TEMP>&h=<HUM>&b=<BAT>&ts=<TIMESTAMP>
| param | meaning |
|---|---|
k |
write API key (matches $gps_db_write_api_key) |
v |
device code (must exist in devices.code with active=1, e.g. S_02, HA_6) |
la, lo |
latitude, longitude (decimal degrees) |
s |
speed (km/h) |
t, h, b |
temperature (°C), humidity (%), battery (V) |
ts |
ISO/MySQL timestamp — optional; defaults to server time |
Short parameter names reduce transmission overhead over cellular. Unknown or inactive device codes are rejected.
- Open
firmware/gps_tracker_optimized/in Arduino IDE. - Edit
config.h: setIOT_SERVER,API_KEY,apn, and chooseTRACKING_MODE_LIVEorTRACKING_MODE_BATCH. - Select board: ESP32 Dev Module (
esp32:esp32:esp32). - Flash to LilyGO T-SIM7000G.
Self-hosted on a Raspberry Pi running Apache + PHP + MySQL.
Features:
- Leaflet map with device picker (select one device or "All devices")
- Single device → route coloured by speed (green → red), stats, replay
- All devices → each device drawn in its own solid colour with an endpoint marker
- Ride stats (single device): distance, duration, avg/max speed, temperature, battery
- Ride replay with adjustable speed slider (single device)
- Ride history grouped by device + date (last 200 rides)
- GPX export per device per day
- Live mode: auto-refreshes every 2 s when viewing today's ride
Two tables, grown by migrations:
CREATE TABLE devices (
id INT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(64) NOT NULL UNIQUE, -- used as `v` in the ingest URL
name VARCHAR(128) NOT NULL, -- shown in the UI picker
color VARCHAR(16) NOT NULL DEFAULT '#3498db',
active TINYINT(1) NOT NULL DEFAULT 1,
user_id INT NOT NULL, -- owning user (added by 002)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE points (
id INT AUTO_INCREMENT PRIMARY KEY,
device_id INT NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
lat DOUBLE NOT NULL,
lon DOUBLE NOT NULL,
speed DOUBLE,
temperature DOUBLE,
humidyty DOUBLE,
battery DOUBLE,
-- added by 003_point_source.sql (OSRM pipeline):
source ENUM('raw','snapped','interpolated') NOT NULL DEFAULT 'raw',
parent_id INT DEFAULT NULL, -- interpolated children point at the raw row they came from
-- added by 005_traccar_pull.sql (HA pull idempotency):
traccar_pos_id BIGINT DEFAULT NULL,
FOREIGN KEY (device_id) REFERENCES devices(id),
INDEX idx_device_timestamp (device_id, timestamp),
UNIQUE KEY uq_points_traccar (device_id, traccar_pos_id)
);source drives the OSRM pipeline: freshly-ingested rows land as raw, the snap worker rewrites them to snapped once matched to a road geometry, and the interpolator inserts interpolated children with parent_id pointing at the raw row they were derived from. The UI and exports treat all three sources the same for display purposes; deletes cascade children via parent_id so cleaning a stretch of raw history also purges its interpolated fill. traccar_pos_id makes the Traccar puller idempotent: re-running the worker after a crash cannot double-insert.
- Copy
backend/files to your web server root (e.g./var/www/html/gpstrack/). - Copy
gps_track_config.php.example→gps_track_config.phpand fill in credentials. - Run
composer installinsidebackend/(forgoogle/protobuf— legacy, not required for current HTTP mode). - Create the MySQL database and apply the schema migration:
mysql -u root -e "CREATE DATABASE gps_db_data"
for f in backend/migrations/*.sql; do
mysql -u root gps_db_data < "$f"
done| migration | what it does |
|---|---|
001_multi_device.sql |
Creates devices + points, seeds default device S_02 = LilyGO Main; if a legacy S_02 table exists it migrates rows into points with device_id=1 and renames the old one to S_02_backup. |
002_multi_user.sql |
Adds the users table and a nullable devices.user_id; the FK + NOT NULL are applied later by bootstrap_admin.php once every device has an owner. |
003_point_source.sql |
Adds points.source (raw/snapped/interpolated) and points.parent_id so the OSRM snap + route-interpolation workers can distinguish ingested rows from their road-matched fill. |
004_admin.sql |
Adds users.active for soft-disable; the admin panel uses this instead of deleting rows (so history is preserved). |
005_traccar_pull.sql |
Adds points.traccar_pos_id + a UNIQUE(device_id, traccar_pos_id) index so the malinka Traccar puller can dedup on re-run. |
006_read_api_key.sql |
Adds users.read_api_key (UNIQUE) — a per-user read-only embed token that grants password-less access to that user's read endpoints, used to drop the dashboard into a Home Assistant iframe without forcing a login. Existing users are auto-backfilled with a random key. |
- Seed the first admin user:
cd backend && sudo -u www-data php tools/bootstrap_admin.php <username> <initial-password>The bootstrap script reuses the legacy $gps_db_write_api_key from gps_track_config.php as the admin's write API key, so firmware and HA push keep working across the migration. The admin is flagged must_change_password and will be redirected to the change-password screen on first login.
- Add more devices by inserting into the
devicestable (remember to setuser_id):
INSERT INTO devices (code, name, color, user_id) VALUES
('MY_BIKE', 'Motorcycle', '#e74c3c', 1),
('MY_PHONE', 'Phone', '#3498db', 1);Admins see every active device; regular users only see rows where devices.user_id matches their user id.
Each user has their own bcrypt-hashed password and a unique 32-char write API key. A user
always needs at least one device to track, so add_user.php provisions the user and their
first device atomically (in a single DB transaction — rollback on failure):
cd backend
# Usage: add_user.php <username> <password> <device_code> <device_name> [--color=#rrggbb] [--admin]
sudo -u www-data php tools/add_user.php alice hunter2 ALICE_BIKE "Alice's Bike" --color=#9b59b6
sudo -u www-data php tools/add_user.php bob s3cret BOB_CAR "Bob's Car" --adminThe script prints the generated write API key and the new device code — share both with the user so their firmware / phone client can authenticate.
To give an existing user an additional device:
INSERT INTO devices (code, name, color, active, user_id)
VALUES ('ALICE_CAR', 'Alice Car', '#2ecc71', 1, <alice_id>);To hand over an admin-owned device to a user:
UPDATE devices SET user_id = <alice_id> WHERE code = 'ALICE_BIKE';Write-ingest (gps_tracker_add_data_to_db.php) validates that the device's user_id matches the API key's owner (admins bypass this check), so leaking a per-user key cannot be used to write into another user's devices.
Generate a demo motorcycle ride and insert it into MySQL:
python3 tools/gen_demo_ride.py | mysql -u root gps_db_data
# Or directly to remote server:
python3 tools/gen_demo_ride.py | ssh user@server 'sudo mysql -u root gps_db_data'Edit waypoints, date, and time at the top of the script. Note: gen_demo_ride.py predates the multi-device refactor and still emits inserts against a flat table. Update its output to target the points table with a device_id, or insert into a temporary table and copy over with a SELECT <device_id>, … INSERT.
On the malinka deployment, phones and OBD trackers send their positions to the Home Assistant Traccar add-on, and a pair of workers on the MotoTracker host (integrations/malinka/) pull and road-snap those positions into the MotoTracker database. Every Traccar device automatically gets a matching MotoTracker device the first time it's seen.
Two workers, both driven by systemd timers:
pull_from_traccar.py— 2-minute cadence; hits Traccar's REST API, auto-creates missing MotoTracker devices, and INSERTs raw rows.snap_interpolate.py— 5-minute cadence; flipssourcefromraw→snappedvia OSRM/matchand fills road-sampledinterpolatedchildren between consecutive fixes via OSRM/route.
The 2-min / 5-min offset is deliberate: raw rows land before the next snap pass so interpolation always has something to work on.
- Logs in to Traccar with
POST /api/session(cookie-based auth; a quotedTRACCAR_PASSWORDenv var avoids shell-interpolation surprises for passwords containing$,#,;, etc.). - Fetches
/api/devicesand callsensure_device()for each —INSERT … ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)atomically resolves the MotoTracker device id and creates missing rows asHA_<traccar_id>owned by the first admin. - For each device, fetches
/api/positions?deviceId&from&toacross a small lookback window (default 10 min), converts speed from knots to km/h (× 1.852), andINSERT IGNORE INTO points (…, source='raw', traccar_pos_id=…)— the unique index makes re-runs safe after a crash or network blip.
- Loads recent
source='raw'rows in per-device batches (default 100 positions) and POSTs them to OSRM/matchwith timestamps (annotations=false&gaps=split&tidy=true). Successfully-matched coordinates replace the raw lat/lon and the row is flipped tosource='snapped'. - Walks consecutive snapped pairs; when the gap sits between
MIN_GAP_M(40 m) andMAX_GAP_M(5 km), it calls OSRM/routeand inserts up toMAX_POINTS_PER_GAP(10) sampled points atPOINT_SPACING_M(25 m) between them assource='interpolated'withparent_idpointing at the second raw row of the pair. Routes whose distance is more thanMAX_ROUTE_RATIO(3×) the straight-line gap are skipped as nonsense. - Interpolated rows never become parents themselves, so the graph stays shallow and
DELETE … JOIN points p ON p.id = c.parent_idcleanly cascades one level.
-
Copy the workers to
/opt/mototracker/and make them executable. Install their only runtime deps:sudo apt install python3-requests python3-pymysql sudo mkdir -p /opt/mototracker /etc/mototracker sudo cp integrations/malinka/*.py /opt/mototracker/ -
Write env files (keep them
root:www-data 0640, and single-quote any password containing shell metacharacters):# /etc/mototracker/traccar.env TRACCAR_URL=http://192.168.1.142:8082 TRACCAR_EMAIL=you@example.com TRACCAR_PASSWORD='<your-traccar-password>' DB_HOST=localhost DB_USER=gps_db_user DB_PASS='<your-db-password>' DB_NAME=gps_db_data
# /etc/mototracker/snap.env OSRM_URL=http://192.168.1.142:5001 # HA OSRM add-on DB_HOST=localhost DB_USER=gps_db_user DB_PASS='<your-db-password>' DB_NAME=gps_db_data
-
Create matching systemd units (oneshot + timer) for each worker. A minimal pattern:
# /etc/systemd/system/pull-from-traccar.service [Service] Type=oneshot User=www-data EnvironmentFile=/etc/mototracker/traccar.env ExecStart=/usr/bin/python3 /opt/mototracker/pull_from_traccar.py # /etc/systemd/system/pull-from-traccar.timer [Timer] OnBootSec=30s OnUnitActiveSec=2min AccuracySec=10s [Install] WantedBy=timers.target
Same shape for
snap-interpolate.{service,timer}withOnUnitActiveSec=5min. -
Enable the timers and verify:
sudo systemctl enable --now pull-from-traccar.timer snap-interpolate.timer journalctl -u pull-from-traccar.service -n 50 mysql gps_db_data -e "SELECT d.code, p.source, COUNT(*) FROM points p JOIN devices d ON p.device_id=d.id WHERE DATE(p.timestamp)=CURDATE() GROUP BY d.id, p.source"
| env var | default | purpose |
|---|---|---|
TRACCAR_URL |
— | Base URL of the Traccar instance (e.g. http://192.168.1.142:8082) |
TRACCAR_EMAIL / TRACCAR_PASSWORD |
— | Traccar login; password must be single-quoted in the env file |
LOOKBACK_MINUTES |
10 |
How far back to scan /api/positions each run |
DEVICE_PREFIX |
HA_ |
Prefix used when auto-creating MotoTracker devices for new Traccar ids |
DB_HOST / DB_USER / DB_PASS / DB_NAME |
— | MotoTracker database connection |
| env var | default | purpose |
|---|---|---|
OSRM_URL |
— | OSRM base URL (HA add-on typically on port 5001) |
MATCH_BATCH_SIZE |
100 |
Positions per /match request |
MIN_GAP_M / MAX_GAP_M |
40 / 5000 |
Gap range eligible for interpolation fill |
POINT_SPACING_M |
25 |
Target spacing of interpolated children along the route |
MAX_POINTS_PER_GAP |
10 |
Cap on interpolated children per pair |
MAX_ROUTE_RATIO |
3.0 |
Skip /route results whose distance > ratio × straight-line gap |
integrations/homeassistant/push-to-mototracker.py is the older push-based approach that ran on Home Assistant and forwarded rows from Traccar's database out to MotoTracker's HTTP ingest. It's superseded by the pull architecture above — kept in-tree for reference only — and will not work against the HA Traccar add-on, whose embedded H2 database is not reachable from outside the container.
Each device has a cog button in the top bar (visible when the owner is viewing their own device) that opens a two-tab modal:
- Info — rename the device (1..128 chars) and pick the polyline colour (
#rrggbb). Changes apply on the next map load. - Cleanup — delete points in a date range, either everything or just stationary jitter (
speed IS NULL OR speed < 1 km/h). The delete cascades to interpolated children viaparent_idin a transaction, so a partial failure rolls back cleanly.
Endpoints live under backend/device/:
| endpoint | purpose |
|---|---|
POST device/update.php |
device_id, optional name, optional color. Validates both; an empty body is rejected. |
POST device/delete_points.php |
device_id, from, to (date or datetime), `filter=all |
Both endpoints share _common.php, which enforces POST and calls dev_load_owned() — regular users get a 403 on devices they don't own; admins bypass the ownership check and can clean up anyone's history.
backend/admin.html (linked from the top bar when is_admin=1) exposes a users table and a devices table with these actions, backed by backend/admin/:
- Reset password — forces
must_change_password=1; the user is redirected to the change-password screen on next login. - Rotate API key — mints a new 32-char
write_api_key; the previous key stops working instantly. Admins rotating their own key must also updategps_track_config.php's legacy$gps_db_write_api_keyor the firmware will be rejected. - View token — shows the user's read-only
read_api_keyand a ready-to-paste embed URL (index.html?token=...); a Regenerate button rotates the key, invalidating the old one immediately. See Embedding read-only in Home Assistant below. - Toggle user — soft-disables via
users.active=0; refuses self-disable so you can't lock yourself out. - Reassign device — moves
devices.user_idto a different owner without touching history; the new owner inherits all past points.
Every endpoint is gated by admin/_gate.php, which returns 403 unless the session user has is_admin=1. Read-only embed tokens (see below) never reach the admin surface, even if the token belongs to an admin.
The dashboard supports a password-less, read-only mode for embedding in iframes
(e.g. a Home Assistant webpage Lovelace card). It is keyed off a per-user
read_api_key rather than session cookies, so it survives across browser
sessions, works inside iframes that block third-party cookies, and can be
revoked instantly without touching the user's password.
Generate / reveal the token in the admin panel: User row → View token → copy the suggested embed URL.
http://<host>/gpstrack/index.html?token=<read_api_key>
When index.html is loaded with ?token=…, map.js stashes the token in
sessionStorage and appends it to every backend request. In token mode the
UI hides logout, the admin link, and the device-settings modal, and 401s do
not redirect to login.html (which would loop inside an iframe).
What the token can and cannot do:
| session login | ?token=… embed |
|
|---|---|---|
pobierz_*.php, export_gpx.php (read) |
yes | yes |
device/update.php, device/delete_points.php |
yes | 403 read_only_token |
change_password.php |
yes | 403 |
anything under admin/ |
only if is_admin=1 |
403 |
| device filter scope | admins see all, users see own | always scoped to the token's user, even for admin tokens |
Implementation: auth.php accepts the token via ?token=… or
Authorization: Bearer …, looks it up against users.read_api_key, sets
$auth_readonly = true, and exposes an auth_require_write() helper that
mutating endpoints call to bail out with 403 read_only_token.
Custom enclosure for the LilyGO T-SIM7000G board with a power button. Print in PLA or PETG.
Model: T-SIM7000G Box on Thingiverse
LilyGO T-SIM7000G official documentation, schematics, examples, and 3D files: github.com/Xinyuan-LilyGO/LilyGO-T-SIM7000G
Firmware (Arduino libraries):
Backend:
- Leaflet.js 1.9.4 (CDN)
- Font Awesome 6.5 (CDN)
- PHP 7.4+, MySQL 5.7+