Revive yaris-obd: USB OBD adapter, real GPS/GNSS, offline map, hotspot, SQLite logging, dashcam retrieval

Squashed rewrite of the project revival work (replaces prior history on this
branch to remove personal data that had been accidentally committed - real
LAN IP, GPS coordinates, home WiFi SSID, and phone model). See PLAN.md for
full phase-by-phase details. Summary:

- Phase 2: adapt app_obd.py for a USB OBD-II adapter (was Bluetooth-only),
  auto-detect /dev/ttyUSB*, fix several pre-existing crash bugs.
- Phase 3/4: make the web UI work fully offline (vendored Socket.IO/Leaflet/
  Chart.js/FontAwesome, offline map tile downloader script, live GPS marker
  tracking fix).
- Phase 5: systemd deployment/autostart on the Pi.
- Phase 6: WiFi hotspot mode via NetworkManager (auto-switch between home
  WiFi and a local AP for in-car web UI access), mDNS hostname + port 80
  redirect so the UI is reachable at a plain hostname with no port number.
- Phase 7: fix app_db.py/app_sql.py bugs, wire up local SQLite trip logging.
- Phase 8: brand-agnostic USB dashcam file retrieval (lsblk transport-based
  detection, works with any dashcam brand/folder layout).
- Rewritten readme.md with a proper project overview.

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-08 18:01:35 -04:00
co-authored by Copilot
parent f91a3d479d
commit 261ef3f4db
46 changed files with 2150 additions and 255 deletions
+27
View File
@@ -0,0 +1,27 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Editor
.vscode/
# Downloaded offline map tiles (large, regenerate via scripts/download_map_tiles.py
# and deploy directly to the Pi - not tracked in git)
static/vendor/tiles/
# Runtime logs
*.log
# Local secrets (MAPTILER_KEY etc.) - never commit real credentials
.env
*.env
!db/creds.env
# Local SQLite trip-tracking databases (per-device data, not source)
db/sql/
# Imported dashcam footage (large, per-device data, not source)
dash/videos/
+330
View File
@@ -0,0 +1,330 @@
# yaris-obd Revival Plan
This document tracks the plan to revive and finish this project: a Raspberry Pi 3-based
OBD-II + GPS car tracker for a 2013 Toyota Yaris, hosting a local web UI over a self-broadcast
WiFi hotspot, with dashcam file retrieval and remote upload of itinerary/dashcam data.
Keep this file up to date as phases complete or plans change, so any future session/agent can
pick up the project without needing prior chat history.
## Hardware
- **Board**: Raspberry Pi 3 Model B Rev 1.2, 906MB RAM. Confirmed too weak to run an AI coding
agent — all agent-assisted dev work happens on a separate local machine; the Pi is only used
for hardware bring-up/testing/deployment via SSH (`yaris@<pi-ip>`).
- **GPS/GSM**: Waveshare **SIM868 GSM/GPRS/GNSS/Bluetooth HAT**. Also has unused cellular
(GSM/GPRS) data capability, earmarked as a fallback internet uplink (see Remote Server phase).
- **OBD-II adapter**: a cheap unbranded USB adapter using a **PL2303** USB-to-serial chip,
presumed ELM327-compatible (works with the `python-obd` library). Originally the project used
a Bluetooth OBD adapter; this has been switched to USB.
- **Car**: 2013 Toyota Yaris.
### SIM868 HAT bring-up notes (important gotchas)
- UART routing is a 3-position jumper: **A** = USB↔SIM868 (via onboard CP2102), **B** = Pi
GPIO↔SIM868, **C** = USB↔Pi console. Must be on **B** for the Pi to talk to the module over
its own GPIO UART.
- Pi-side UART setup required: `enable_uart=1` and `dtoverlay=disable-bt` in
`/boot/firmware/config.txt` (frees the full PL011 UART — `/dev/ttyAMA0`/`/dev/serial0` — from
the onboard Bluetooth controller), `raspi-config nonint do_serial_cons 1` (stops a login shell
from claiming the serial port), and disable the `hciuart` systemd service.
- **The SIM868 module chip itself needs its PWRKEY button pressed and held (~1-2s) to power on**
— this is separate from the board's 5V/PWR LED, which can be lit while the module chip is
fully off. AT commands get zero response if the module chip isn't powered, even with perfect
UART wiring/config. This is a manual step needed on every power cycle unless a P4
GPIO-power-on hardware mod (per Waveshare FAQ) is done — not pursued so far.
- Confirmed AT command baud rate: **115200**.
- GNSS workflow: `AT+CGNSPWR?` to check GNSS power state, `AT+CGNSPWR=1` to power on,
`AT+CGNSINF` to poll for a fix. Response format:
`+CGNSINF: run,fix,utc_time,lat,lon,alt,speed,course,fixmode,reserved1,hdop,pdop,vdop,reserved2,gps_sat_view,gnss_sat_used,gnss_sat_view,cn0,hpa,vpa`
— field 1 (`fix`) must be `'1'` for a valid fix.
- **The GPS/GNSS antenna is separate from the GSM antenna** — both need to be physically
connected to their respective u.FL connectors on the HAT. A real fix was obtained only after
discovering the GPS antenna wasn't plugged in (only GSM was).
- Confirmed real fix obtained near a window: ~80s cold-start TTFF, 5-7 satellites, HDOP ~1.2-1.6.
## Software Architecture Decisions
1. **Offline maps**: Pre-cache/download offline map tiles for the car's usual driving area so
the live map works fully in the car with no internet (not just numeric GPS readout).
Tooling: MapTiler Cloud free tier (raster XYZ tiles, `streets-v2` style) — one-time download
on the dev machine via `scripts/download_map_tiles.py`, output shipped to the Pi as static
PNGs. Bulk-downloading directly from `tile.openstreetmap.org` was ruled out — it violates the
OSM tile usage policy. Bounding box is a required CLI arg (the user's own driving area, not
committed here), zoom 8-15 by default (~14k tiles for a city-plus-suburbs-sized area, well
under MapTiler's 100k/month free quota).
2. **WiFi hotspot mode**: Auto-switch — the Pi joins home WiFi when in range (for
syncing/updates), and falls back to broadcasting its own AP otherwise. **Important scope
clarification**: the hotspot is *only* for local web-UI viewing on a phone/laptop while
driving — it is not part of the upload path. Remote-server uploads happen independently via
home WiFi (when in range) or the SIM868 cellular fallback (decision #5) — the phone connecting
to the hotspot never relays/carries upload traffic.
3. **Local/remote persistence**: Keep SQLite locally (reliable local write path per trip) +
MongoDB Atlas for remote sync when online. Fix existing bugs rather than replace the design.
4. **Remote server**: Self-hosted — a custom server/API (e.g. on a home server or VPS) receives
itinerary and dashcam uploads, rather than 3rd-party managed cloud services.
5. **Remote upload uplink**: Use the SIM868's GSM/GPRS cellular connection as a fallback uplink
for uploads when no WiFi is available (e.g. while driving), not just "sync when on home WiFi."
## Phases
### Phase 1: Real GPS integration — ✅ DONE
`modules/app_gps.py` rewritten to read real SIM868 GNSS data via serial AT commands
(`/dev/serial0` @ 115200 baud) instead of hardcoded fake coordinates. Background daemon thread
polls `AT+CGNSINF` every 2s, parses fix data, emits `gps_location` via socketio only on an actual
fix. Verified against real hardware (see bring-up notes above).
### Phase 2: OBD fixes for USB adapter — ✅ DONE
`modules/app_obd.py` fixed (commit "Fix app_obd.py bugs and adapt for USB OBD-II adapter",
pushed to `origin/shameless-aislop`):
- `connection` is never left undefined on failed connect (was a `NameError` crash).
- Added `_find_obd_port()`/`connect_obd()`: auto-detects `/dev/ttyUSB*` (the PL2303 adapter),
honors an `OBD_PORT` env override, falls back to python-obd's own port auto-scan (still covers
the old `/dev/rfcomm*` Bluetooth path).
- Fixed stale-response bug: `obd_speed()`/`obd_rpm()` now re-query every loop iteration instead
of reusing one response forever.
- Fixed `obd_rpm()` using the wrong lock, never updating the global `rpm`, and emitting to the
wrong socketio event (`obd_speed` instead of `obd_rpm`).
- Fixed `kill_obd()` being a no-op — now uses `threading.Event` flags actually checked in the
polling loops.
- Fixed inverted thread-start logic (threads were starting on failed connection, not success).
- Fixed `obm_error` → `obd_error` typo (frontend listens for `obd_error`).
- Added `obd` (python-obd) to `requirements.txt`.
- **Still untested**: a real connection to the actual car (adapter has only been tested
standalone on the Pi so far — correctly reports "no connection to car" with nothing attached).
### Phase 3: Web UI must work fully offline — ✅ DONE
The whole point of the project is a Pi hotspot with **no internet in the car**, but the current
UI silently depended on the internet for core functionality:
- `templates/index.html`/`dash.html`/`dtc.html` loaded Google Fonts, FontAwesome, Leaflet JS/CSS,
and the Socket.IO client from public CDNs — none of this loads offline, breaking layout, the
websocket connection itself, and the map entirely.
- `static/js/yaris.js` loaded live map tiles from `tile.openstreetmap.org` (also needs internet).
- A local `static/js/chart.min.js` existed but was unused — `index.html` loaded Chart.js from
`cdn.jsdelivr.net` instead (turned out the local copy was a stale Chart.js **v2.8.0** while
`yaris.js`'s config uses v3/v4-style `scales: {x:{...}, y:{...}}` syntax — that mismatch is
exactly why the CDN was used instead. Replaced the local file with a current v4.5.0 UMD build).
**What was done**:
- Vendored Socket.IO client (4.8.1, matches Flask-SocketIO 5.6.1's Engine.IO v4 protocol) under
`static/vendor/socketio/`, and Leaflet (1.9.4 JS/CSS + marker/layers images) under
`static/vendor/leaflet/`.
- FontAwesome 5.12.0 was already vendored locally at `static/fonts/` (self-consistent, just never
referenced) — repointed all three templates to it instead of the CDN.
- Dropped the Google Fonts (Nunito) `<link>` entirely rather than self-hosting 12 font-weight
files — Bootstrap's `--bs-font-sans-serif` variable already falls back cleanly to system fonts
(`-apple-system, Segoe UI, Roboto, ...`) after Nunito, so this is a clean no-cost simplification.
- Replaced `static/js/chart.min.js` with a current Chart.js v4.5.0 UMD build (auto-registering,
same drop-in behavior as the old CDN `<script src="https://cdn.jsdelivr.net/npm/chart.js">`).
- Offline map tiles: added `scripts/download_map_tiles.py`, a dev-machine-only one-time tile
downloader using the MapTiler Cloud free tier (raster XYZ, `streets-v2` style, 256px tiles).
Reads `MAPTILER_KEY` from an env var/CLI arg only — the key itself is never committed or
deployed, only the resulting static PNG tiles are. Bounding box is a required CLI arg (your own
driving area's coordinates - not committed/hardcoded) at zoom 8-15 (~14k tiles for a
city-plus-suburbs-sized area, well under MapTiler's 100k tiles/month free quota). Output goes to
`static/vendor/tiles/{z}/{x}/{y}.png` (gitignored — regenerate locally, rsync to the Pi as part
of deployment, don't bloat the git repo with binary tile data).
- **Tiles populated**: user signed up for a free MapTiler key and the full zoom 8-15 download
ran to completion — 14,241 downloaded, 24 skipped (out-of-bounds), 0 failed (14,265 tiles
total, ~285MB). Rsynced to the Pi's `static/vendor/tiles/` (tile counts verified identical
on both sides). Live Pi instance re-confirmed serving HTTP 200 with the complete offline
tile set.
- Updated `static/js/yaris.js`'s Leaflet init to point at `/static/vendor/tiles/{z}/{x}/{y}.png`
(`minZoom`/`maxZoom` 8/15 to match). The initial map center is just a generic placeholder (not
a real location) - it re-centers automatically to the real live GPS fix once one comes in, and
the marker is exposed as `carMarker` so Phase 4 can move it on live fixes.
- Added a repo-root `.gitignore` (Python caches, venvs, `.vscode/`, `static/vendor/tiles/`,
`*.env` except the already-tracked `db/creds.env` placeholder) — there wasn't one before, which
is why stray `__pycache__`/`.pyc` files kept showing up in source control.
- **Verified**: ran the Flask app in a local venv, confirmed all three pages (`/`, `/dtc`, `/dash`)
render with **zero external URLs** in the served HTML, every local static asset (JS, CSS, fonts,
Leaflet images) returns HTTP 200, and all vendored JS files pass a Node.js syntax check.
### Phase 4: Map/GPS display bug — ✅ DONE
`yaris.js`'s Leaflet marker (`carMarker`) was never moved on incoming `gps_location` socket
events — only two text spans (`gpsLocationX`/`Y`) got updated. Fixed: `carMarker.setLatLng(...)`
is now called on every `gps_location` event, and the map re-centers (zoom 15) on the first fix
only (`mapCentered` flag), so the user isn't fighting the map re-centering under them while
panning/zooming during a trip.
**Verified**: also used this as an opportunity to test Phase 3 end-to-end — ran
`scripts/download_map_tiles.py` for real with the user's actual MapTiler API key (smoke-tested
zoom 8-10 first, confirmed idempotent re-run skips existing tiles, confirmed a downloaded tile
is a real map image of the target driving area), then started the full zoom 8-15
download. Took a **headless Chromium screenshot** of the running app's `/` page and confirmed:
sidebar/icons/fonts render correctly (FontAwesome working), the Speed Graph (Chart.js v4) renders,
and **the Leaflet map renders real offline map tiles with the marker in place** — full offline
end-to-end proof, not just "no CDN URLs in the HTML".
- Fixed (was noted here as out of scope, now addressed in Phase 8): `/dash` and `/dtc` used to
render near-identical body content (both showed "DTC Codes") — `dash.html` now has real
dashcam-specific content.
- **Full tile download completed** (see Phase 3 update above) and rsynced to the Pi — Phase
3/4's offline map is now 100% functional end-to-end on the actual deployed device, not just
the dev machine.
### Phase 5: Pi deployment / autostart — ✅ DONE
Set up a proper venv (`/home/yaris/app/yaris-obd/venv`), installed all requirements (all found
as prebuilt ARM wheels via piwheels — no slow source compiles needed, even for gevent).
Added `deploy/yaris-obd.service` (systemd unit: runs as the `yaris` user, `Restart=on-failure`,
5s `ExecStartPre` sleep to let USB/serial devices enumerate after boot, logs to
`yaris-obd.log`) and `deploy/install_service.sh` (idempotent one-time install helper — also
creates the venv if missing). Installed and enabled on the Pi (`systemctl enable` — survives
reboot).
**Also had to fix Pi → GitHub auth for `git pull`**: the repo is private, and the Pi's existing
`~/.ssh/id_ed25519.pub` turned out to be an *inbound* SSH key (its private half lives on the dev
machine, not the Pi) — useless for the Pi to authenticate outbound. Generated a dedicated
ed25519 keypair **on the Pi itself** (`~/.ssh/yaris_deploy_key`), registered it as a **read-only
deploy key** on the GitHub repo (least-privilege — the Pi only ever needs to pull), and added an
`~/.ssh/config` entry so git transparently uses it for `github.com`. Switched the Pi's `origin`
remote to SSH. `git pull` now works standalone on the Pi with no PAT/interactive auth needed.
**Verified end-to-end**: pulled latest code onto the Pi (was 5 commits stale), confirmed all
Python deps import cleanly, ran the app manually first (HTTP 200, all local static assets —
Leaflet/Socket.IO/Chart.js/FontAwesome — served correctly from the Pi itself), then installed +
started the systemd service and confirmed `active (running)` + `enabled`. **Tested crash
recovery**: sent SIGKILL to the running process, confirmed systemd auto-restarted it (through the
5s `ExecStartPre` delay) and the app was serving HTTP 200 again within ~10s, with zero manual
intervention.
### Phase 6: WiFi hotspot mode — ✅ DONE
Used NetworkManager (already the network manager in use, not hostapd/dnsmasq directly — those
were leftover/unused) since it natively supports AP mode via `nmcli`. Created two connection
profiles on the Pi: `home-wifi` (your real home WiFi network, `autoconnect-priority 10`) and
`Hotspot` (AP mode, `ipv4.method shared`, its own dedicated SSID/password set locally on the Pi -
not committed here, WPA2-PSK, `autoconnect-priority -10`). NM's own autoconnect logic handles
"leaving home WiFi range -> fall back to hotspot" automatically since `home-wifi` has higher
priority and NM always tries it first.
For the reverse direction (home WiFi back in range while sitting in AP mode) added
`deploy/yaris-netwatch.sh` + `.service`/`.timer`: since the Pi's onboard BCM43438 radio can't be
an AP and background-scan for client networks simultaneously, a systemd timer runs every 3
minutes, briefly attempts reconnecting to `home-wifi`, and restores the hotspot if home WiFi still
isn't in range. Installed via `deploy/install_netwatch.sh`.
**Root-caused and fixed a real bug during testing**: the hotspot's SSID broadcast worked
immediately, but its DHCP server initially failed (`dnsmasq: failed to create listening socket
for 10.42.0.1: Address already in use`) — a pre-existing **orphaned system-wide `dnsmasq.service`**
(noted as "likely unused" in earlier investigation) was already bound to the address space
NetworkManager's own internal dnsmasq needs for the hotspot. Confirmed the system dnsmasq's
config was genuinely empty/default (nothing in this project depends on it) and disabled it
(`systemctl disable --now dnsmasq`).
Also added easy access without needing to know the Pi's IP:
- **Renamed the Pi's hostname** from `yarisobd` to `yaris` — `avahi-daemon` (already installed
and running) now publishes it as `yaris.local` via mDNS.
- **Port 80 -> 5000 redirect** via nftables (`deploy/nftables-yaris.conf` +
`install_port_redirect.sh`, loaded through an `include` in `/etc/nftables.conf`), so the app is
reachable at plain `http://yaris.local/` (no port number) instead of `:5000`.
**Verified on real hardware, very carefully** since the SSH session used to do all this testing
itself runs over `wlan0`: used a "dead-man's-switch" pattern for every risky test — a detached
background command (`setsid nohup bash -c 'sleep N && nmcli con up home-wifi' & disown`) armed
*before* each hotspot activation, so the Pi automatically reverts to home WiFi (and SSH access
comes back) even if something goes wrong, no physical access needed. Confirmed: hotspot broadcasts
and a real phone gets a genuine DHCP lease from NetworkManager's own DHCP server and can load the
web UI; `yaris-netwatch` correctly detects AP mode and reconnects to home WiFi when in range (and
is a no-op when already on home WiFi); `http://yaris.local/` and `http://<pi-lan-ip>/` both return
HTTP 200 with no port number from another machine on the network. (Real hotspot SSID/password and
the Pi's LAN IP are intentionally not recorded in this file — see local, non-committed notes.)
### Phase 7: app_db.py / app_sql.py fixes — DONE
Neither file was called from `app.py` or anywhere else - orphaned from a prior iteration. Fixed:
- `app_db.py`: applied a prior-session stash fix - resolves `.env` path via `__file__` (was
cwd-relative), fixes `os.getenv()` calls to pass the actual key names (was called with zero
args, an immediate `TypeError` if ever invoked), added a clear `RuntimeError` guard if
credentials are still empty placeholders, returns `client[mDB]`.
- `app_sql.py`: rewritten - resolves `db/sql/` via `__file__` + `os.makedirs` (was cwd-relative),
fixed `create_conn()` closing the connection immediately and returning `None` (so table
creation never ran and every caller got a dead connection), added `get_conn()` (lazy singleton
+ table creation for both `tracking` and `dtc` tables), `insert_tracking()`, and a background
`start_logging()`/`_log_loop()` thread (5s interval) that snapshots GPS (`app_gps.LocationX/Y`)
+ OBD speed (`app_obd.speed`) into the local `tracking` table. Used a deferred (function-local)
import of `app_obd` inside `_log_loop()` to keep the pre-existing `app_obd`<->`app_sql`
top-level circular import safe/explicit.
- Also fixed a Python 3.12+/3.14 compat bug found during testing: `sqlite3.version` (the
sqlite3 *module's* version) was removed/deprecated - switched to `sqlite3.sqlite_version`
(the actual SQLite library version), which is what was intended.
- Wired `app_sql.start_logging()` into `app.py` alongside the other startup calls
(`app_gps.start_gps()`, `app_obd.OBD()`).
- `db/sql/` added to `.gitignore` (per-device local trip data, not source).
- Verified locally: `get_conn()` creates `db/sql/<account>_<car>.db` with both tables;
`insert_tracking()` writes a real row; `start_logging()` thread starts and logs
"SQL tracking-logger thread started, alive: True"; full `app.py` run confirms the thread
starts cleanly and (with no real GPS/OBD hardware on the dev machine) correctly logs read
errors instead of crashing; `app_db.get_database()` raises the new clear `RuntimeError` instead
of crashing, since `db/creds.env` still holds empty placeholder credentials (real Mongo
credentials are a user-provided follow-up, out of scope here).
### Phase 8: Dashcam retrieval (app_dash.py) — DONE (tested without physical hardware)
Rewrote `app_dash.py` to be brand/device-agnostic: detection via `lsblk -J`, matching partitions
whose parent disk reports `TRAN == "usb"` (this is what safely distinguishes a plugged-in
dashcam/USB stick from the Pi's own onboard storage, which is always `mmc` transport - no more
hardcoded `/dev/sda`). Walks the whole mounted filesystem for video file extensions
(`.mp4/.mov/.avi/.ts/.mkv`) instead of assuming a specific brand's folder layout (DCIM, MOVIE,
NORM/EVENT/PARKING, etc. all just work), preserves each file's relative sub-folder path when
copying (avoids filename collisions across dashcam event/date folders), and skips files already
copied (same relative path + size) so re-running an import is cheap. Mounts read-only to avoid
any risk of corrupting the dashcam's SD card. Dropped the unused/uninstalled `tqdm` CLI-progress
dependency (never actually in `requirements.txt`) since this now runs headless inside the always-
on web server, not interactively.
Wired into `app.py`: `GET /api/dash/devices` (list detected USB storage) and
`POST /api/dash/import` (run the mount->copy->unmount pipeline), both JSON. Fixed the pre-existing
`/dash` vs `/dtc` duplicate-template bug — `dash.html` now has real dashcam content (scan/import
buttons + live status) instead of a copy-pasted "DTC Codes" body. Added a dedicated `dash.js`
(not the shared `yaris.js`, which unconditionally initializes the `/index`-only map/chart and
would throw/crash on `/dash` and `/dtc` since those pages don't have those DOM elements - a
pre-existing bug, worked around by not depending on that shared file for this page's new JS).
Added `dash/videos/` to `.gitignore` (imported footage is per-device data, not source).
**Tested thoroughly without the physical dashcam** (user didn't have it on hand this session):
1. Unit-tested the `lsblk`-JSON detection/filtering logic against a realistic synthetic sample
(Pi's own `mmcblk0` boot+root partitions plus a simulated USB dashcam partition) - confirmed
it correctly finds only the USB partition and ignores the Pi's own storage.
2. Integration-tested the real mount -> find-videos -> copy (preserving structure) -> dedupe ->
unmount pipeline on the actual Pi using a loop-device-backed FAT32 image (`mkfs.vfat` +
`losetup`) populated with fake nested dashcam video files (`DCIM/100EVENT/...`,
`DCIM/100NORMAL/...`) - confirmed real mount/copy/unmount all work, confirmed a second import
run correctly skips already-copied files, confirmed non-video files (e.g. `thumbs.db`) are
ignored. All simulation artifacts (loop device, test image, temp files) cleaned up afterward.
3. Verified the full `/dash` page + both new API routes locally (Flask dev run + headless
browser check): auto-scan on page load correctly reports "No USB dashcam/storage device
detected" (accurate, since none is plugged into the dev machine), Import button correctly
stays disabled until a device is found, no JS console errors, no regression on `/` or `/dtc`.
**Still to verify once the physical dashcam is available**: plug it into the Pi, hit "Scan for
dashcam" on `/dash`, confirm it's detected and its real videos import correctly into
`dash/videos/` on the Pi. No code changes should be needed for that - only the transport (loop
device vs. real USB) differs, and the detection/copy logic is transport-agnostic by design.
### Phase 9: Remote server for itinerary/dashcam upload — ⏳ TODO
Per architecture decisions #4 and #5: build a self-hosted server/API to receive itinerary
(from the SQLite/Mongo pipeline) and dashcam file uploads, with the Pi using its home WiFi when
available and falling back to the SIM868's GSM/GPRS cellular connection otherwise.
## Working Notes / Environment Gotchas
- **Pi credentials/SSH**: `ssh yaris@<pi-ip>`. Confirm current IP before connecting (varies by
network — check locally, not recorded here) — usually reachable via `yaris.local` too (mDNS,
see Phase 6).
- **Local dev repo**: work happens on a separate dev machine, not on the Pi. Push to
`origin/shameless-aislop` (not yet merged to `main`) when work is ready.
- **Git push auth**: if `git push` fails with a credential/auth error, run `gh auth login`
(device-code browser flow) then `gh auth setup-git` to wire git to use the `gh` CLI's stored
credentials — this fixed push auth issues in this environment without needing SSH keys or PATs.
- **Pi environment gap**: Debian Bookworm blocks system-wide `pip install` (PEP 668,
"externally-managed-environment"). Use a venv for the real app dependency install (Phase 5)
rather than `--break-system-packages` everywhere. (Resolved in Phase 5 — venv at
`/home/yaris/app/yaris-obd/venv`.)
- **Pi → GitHub auth for `git pull` (private repo)**: the Pi's pre-existing
`~/.ssh/id_ed25519.pub` is an *inbound* key only (private half is on the dev machine) — can't
be reused for the Pi's own outbound git auth. Generated a dedicated keypair on the Pi
(`~/.ssh/yaris_deploy_key`), added it as a GitHub **read-only deploy key** on this repo, and
added an `~/.ssh/config` entry pinning `github.com` to that key. `[email protected]:...` (SSH
remote) then works standalone on the Pi, no PAT/interactive login needed.
- **piwheels**: pip on the Pi automatically uses `piwheels.org` as an extra index (Raspberry Pi
OS default `pip.conf`) — this is why `gevent` (which normally needs a C compile) installed in
seconds from a prebuilt ARM wheel instead of taking many minutes to build from source.
- **python-obd note**: `obd.OBD()` (or with `portstr=`) performs the ELM327 handshake at
connection time; if there's no car on the other end, it raises rather than silently returning
a dead handle — `app_obd.py`'s `connect_obd()` already catches this and sets `connection = None`.
+14 -3
View File
@@ -1,5 +1,5 @@
from flask import Flask, render_template, make_response, json
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd
from flask import Flask, render_template, make_response, json, jsonify
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash
import time
from gevent.pywsgi import WSGIServer
@@ -10,7 +10,8 @@ app_events.socketio.init_app(app)
account = app_account.Account()
obd = app_obd.OBD()
car = app_car.Car()
gps = app_gps.get_gps()
gps = app_gps.start_gps()
app_sql.start_logging()
@app.route('/')
def index():
@@ -26,6 +27,16 @@ def dtc():
def dash():
return render_template('dash.html', account = account, car = car)
@app.route('/api/dash/devices')
def dash_devices():
devices = app_dash.list_usb_storage_partitions()
return jsonify({"devices": devices})
@app.route('/api/dash/import', methods=['POST'])
def dash_import():
summary = app_dash.import_dashcam()
return jsonify(summary)
# not sure if im gonna use this
@app.route('/api/obd_speed/')
def data():
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# One-time install of the yaris-obd WiFi hotspot <-> home-WiFi auto-switch
# watcher on the Pi. Run this ON THE PI as the `yaris` user (needs sudo):
# cd /home/yaris/app/yaris-obd && bash deploy/install_netwatch.sh
#
# Prerequisite: the "home-wifi" (home WiFi) and "Hotspot" (AP mode) NetworkManager
# connection profiles must already exist (see PLAN.md Phase 6 for the nmcli
# commands used to create them) with home-wifi having a higher
# connection.autoconnect-priority than Hotspot.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
sudo install -m 755 "$REPO_DIR/deploy/yaris-netwatch.sh" /usr/local/bin/yaris-netwatch.sh
sudo cp "$REPO_DIR/deploy/yaris-netwatch.service" /etc/systemd/system/yaris-netwatch.service
sudo cp "$REPO_DIR/deploy/yaris-netwatch.timer" /etc/systemd/system/yaris-netwatch.timer
sudo systemctl daemon-reload
sudo systemctl enable --now yaris-netwatch.timer
echo "Done. Check status with: systemctl status yaris-netwatch.timer"
echo "Logs: journalctl -t yaris-netwatch -f"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# One-time install of the port-80 -> port-5000 redirect on the Pi, so the
# web UI is reachable at "http://yaris.local/" (or "http://<pi-ip>/") without
# typing the port number. Run this ON THE PI (needs sudo):
# cd /home/yaris/app/yaris-obd && bash deploy/install_port_redirect.sh
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
NFT_FRAGMENT="/etc/nftables.yaris.conf"
NFT_MAIN="/etc/nftables.conf"
INCLUDE_LINE="include \"$NFT_FRAGMENT\""
sudo cp "$REPO_DIR/deploy/nftables-yaris.conf" "$NFT_FRAGMENT"
if ! grep -qF "$INCLUDE_LINE" "$NFT_MAIN"; then
echo "$INCLUDE_LINE" | sudo tee -a "$NFT_MAIN" > /dev/null
fi
sudo systemctl enable --now nftables.service
sudo systemctl restart nftables.service
echo "Done. Verify with: sudo nft list ruleset"
echo "Test with: curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# One-time install of the yaris-obd systemd service on the Pi.
# Run this ON THE PI as the `yaris` user (needs sudo for the systemctl bits):
# cd /home/yaris/app/yaris-obd && bash deploy/install_service.sh
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SERVICE_FILE="$REPO_DIR/deploy/yaris-obd.service"
SERVICE_NAME="yaris-obd.service"
if [ ! -d "$REPO_DIR/venv" ]; then
echo "No venv found at $REPO_DIR/venv - creating one and installing requirements..."
python3 -m venv "$REPO_DIR/venv"
"$REPO_DIR/venv/bin/pip" install --upgrade pip
"$REPO_DIR/venv/bin/pip" install -r "$REPO_DIR/requirements.txt"
fi
sudo cp "$SERVICE_FILE" "/etc/systemd/system/$SERVICE_NAME"
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
sudo systemctl restart "$SERVICE_NAME"
echo "Done. Check status with: sudo systemctl status $SERVICE_NAME"
echo "Logs: tail -f $REPO_DIR/yaris-obd.log (or: sudo journalctl -u $SERVICE_NAME -f)"
+10
View File
@@ -0,0 +1,10 @@
# Redirects incoming HTTP traffic on port 80 to the yaris-obd app (port 5000),
# so the web UI is reachable at "http://yaris.local/" or "http://<pi-ip>/"
# without needing to type the port number. Loaded by /etc/nftables.conf via
# an `include` directive (see deploy/install_port_redirect.sh).
table ip nat {
chain prerouting {
type nat hook prerouting priority -100;
tcp dport 80 redirect to :5000
}
}
+8
View File
@@ -0,0 +1,8 @@
[Unit]
Description=yaris-obd network watcher (hotspot -> home WiFi reconnect check)
After=NetworkManager.service
Wants=NetworkManager.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/yaris-netwatch.sh
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# yaris-netwatch.sh
#
# Runs periodically via yaris-netwatch.timer. If wlan0 is currently in
# AP/hotspot mode, briefly attempts to reconnect to the known home WiFi.
# If that succeeds, we're back on home WiFi (used for syncing itinerary/
# dashcam data to the remote server). If it fails (still out of range),
# reactivates the hotspot so the web UI stays reachable locally in the car.
#
# The Pi's onboard WiFi radio (BCM43438) cannot act as an AP and scan for
# client networks at the same time, so this brief switch-and-check is the
# only way to detect "home WiFi is back in range" while sitting in AP mode.
# The reverse direction (home WiFi -> hotspot, i.e. leaving range) is handled
# automatically by NetworkManager's own autoconnect-priority mechanism and
# needs no watcher: the "home-wifi" profile has a higher
# connection.autoconnect-priority than Hotspot, so NM tries it first whenever
# the device needs to reconnect, and falls through to Hotspot on its own once
# home WiFi drops out of range.
set -u
LOG_TAG="yaris-netwatch"
HOME_CON="home-wifi"
AP_CON="Hotspot"
IFACE="wlan0"
LAST_LOG="/tmp/yaris-netwatch-last.log"
active_con() {
nmcli -t -f DEVICE,CONNECTION device status | awk -F: -v d="$IFACE" '$1==d {print $2}'
}
current="$(active_con)"
if [ "$current" != "$AP_CON" ]; then
# Already on home WiFi (or something else) - nothing to do.
logger -t "$LOG_TAG" "wlan0 is on '$current', no action needed"
exit 0
fi
logger -t "$LOG_TAG" "wlan0 is on hotspot, checking for home WiFi ($HOME_CON)..."
if nmcli --wait 20 con up "$HOME_CON" >"$LAST_LOG" 2>&1; then
logger -t "$LOG_TAG" "home WiFi found, switched to '$HOME_CON'"
else
logger -t "$LOG_TAG" "home WiFi not found, restoring hotspot"
nmcli con up "$AP_CON" >>"$LAST_LOG" 2>&1
fi
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Periodically check if home WiFi is back in range while in hotspot mode
[Timer]
OnBootSec=1min
OnUnitActiveSec=3min
AccuracySec=15s
Unit=yaris-netwatch.service
[Install]
WantedBy=timers.target
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Yaris OBD/GPS tracker web UI
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=yaris
Group=yaris
WorkingDirectory=/home/yaris/app/yaris-obd
ExecStart=/home/yaris/app/yaris-obd/venv/bin/python app.py
Restart=on-failure
RestartSec=5
# Give the app a few seconds after boot for USB/serial devices to enumerate
# before the first connection attempt - it retries gracefully either way,
# but this avoids needless noisy "no connection" logs on every cold boot.
ExecStartPre=/bin/sleep 5
StandardOutput=append:/home/yaris/app/yaris-obd/yaris-obd.log
StandardError=append:/home/yaris/app/yaris-obd/yaris-obd.log
[Install]
WantedBy=multi-user.target
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+203 -24
View File
@@ -1,38 +1,217 @@
"""Dashcam USB retrieval.
Detects a dashcam plugged in as a USB mass-storage device (no hardcoded
/dev/sda, no brand-specific assumptions), mounts it read-only, copies any
video files it finds (any folder layout - DCIM/, MOVIE/, NORM/EVENT/PARKING/,
etc. - all common dashcam brands are covered since we just walk the whole
filesystem for video extensions instead of assuming a specific folder name),
then unmounts it again.
Detection is done via `lsblk`, filtering for partitions whose parent disk is
attached over USB (TRAN == "usb"). This is what actually distinguishes a
plugged-in dashcam/USB stick from the Pi's own storage (the boot/root
partitions are on the onboard SD card, transport "mmc", never "usb") - so
it's safe to run against real hardware without risk of touching the Pi's own
filesystem, regardless of which /dev/sdX letter the kernel happens to assign.
"""
import json
import os
import shutil
from tqdm import tqdm
import subprocess
def mount_device(device, mount_point):
os.system(f"sudo mount {device} {mount_point}")
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"}
def unmount_device(mount_point):
os.system(f"sudo umount {mount_point}")
# Filesystem types dashcams commonly ship formatted as (SD cards are almost
# always FAT32/exFAT; a few older units use NTFS).
DASHCAM_FS_TYPES = {"vfat", "exfat", "ntfs", "ntfs3"}
def copy_files(source_folder, destination_folder):
if not os.path.exists(destination_folder):
os.makedirs(destination_folder)
DEFAULT_MOUNT_POINT = "/mnt/dashcam"
DEFAULT_DESTINATION = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dash", "videos"
)
for root, dirs, files in os.walk(source_folder):
for file in tqdm(files, desc="Copying files", unit="file"):
source_path = os.path.join(root, file)
destination_path = os.path.join(destination_folder, file)
# Check if the file already exists in the destination folder
if not os.path.exists(destination_path):
shutil.copy2(source_path, destination_folder)
def list_usb_storage_partitions():
"""Return a list of dicts describing USB-attached storage partitions.
if __name__ == "__main__":
source_device = "/dev/sda"
mount_point = "/mnt/dash"
destination_folder = "~/yaris-obd/dash/videos"
Each dict has: path, fstype, label, size, mountpoint. Only partitions
whose parent disk reports TRAN "usb" are included - this is what makes
the detection brand/device-agnostic (no hardcoded /dev/sda) while still
being safe (never matches the Pi's own onboard mmc storage).
"""
try:
result = subprocess.run(
["lsblk", "-J", "-o", "NAME,PATH,TRAN,FSTYPE,LABEL,SIZE,MOUNTPOINT,TYPE"],
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print("Failed to run lsblk:", e)
return []
try:
# Mount the device
mount_device(source_device, mount_point)
data = json.loads(result.stdout)
except json.JSONDecodeError as e:
print("Failed to parse lsblk output:", e)
return []
# Copy files from source to destination
copy_files(os.path.join(mount_point, "DCIM"), os.path.expanduser(destination_folder))
partitions = []
def walk(devices, inherited_tran=None):
for device in devices:
tran = device.get("tran") or inherited_tran
if device.get("type") == "part" and tran == "usb":
partitions.append(
{
"path": device.get("path"),
"fstype": device.get("fstype"),
"label": device.get("label"),
"size": device.get("size"),
"mountpoint": device.get("mountpoint"),
}
)
children = device.get("children") or []
if children:
walk(children, tran)
walk(data.get("blockdevices", []))
return partitions
def find_dashcam_partition():
"""Pick the best candidate USB partition to import from, or None.
Prefers partitions with a recognized dashcam filesystem type; if several
are found, the first one is returned (a single dashcam SD card should
only expose one data partition).
"""
partitions = list_usb_storage_partitions()
if not partitions:
return None
recognized = [p for p in partitions if p["fstype"] in DASHCAM_FS_TYPES]
return (recognized or partitions)[0]
def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT):
"""Mount device_path read-only at mount_point. Returns True on success."""
os.makedirs(mount_point, exist_ok=True)
try:
subprocess.run(
["sudo", "mount", "-o", "ro", device_path, mount_point],
capture_output=True,
text=True,
check=True,
)
return True
except subprocess.CalledProcessError as e:
print(f"Failed to mount {device_path}: {e.stderr.strip()}")
return False
def unmount_device(mount_point=DEFAULT_MOUNT_POINT):
"""Unmount mount_point. Returns True on success (or if already unmounted)."""
try:
subprocess.run(
["sudo", "umount", mount_point],
capture_output=True,
text=True,
check=True,
)
return True
except subprocess.CalledProcessError as e:
# Not mounted is fine - nothing to clean up.
if "not mounted" in e.stderr.lower():
return True
print(f"Failed to unmount {mount_point}: {e.stderr.strip()}")
return False
def find_video_files(source_folder):
"""Walk source_folder, returning (absolute_path, relative_path) for every
file whose extension looks like a video, regardless of folder layout."""
found = []
for root, _dirs, files in os.walk(source_folder):
for file in files:
if os.path.splitext(file)[1].lower() in VIDEO_EXTENSIONS:
abs_path = os.path.join(root, file)
rel_path = os.path.relpath(abs_path, source_folder)
found.append((abs_path, rel_path))
return found
def copy_files(source_folder, destination_folder):
"""Copy every video file found under source_folder into destination_folder,
preserving its relative sub-folder path (dashcams often split files into
date/event sub-folders - flattening them risks filename collisions).
Files already present at the destination (same relative path, same size)
are skipped so re-running an import doesn't re-copy everything.
Returns a summary dict: {found, copied, skipped, errors}.
"""
os.makedirs(destination_folder, exist_ok=True)
videos = find_video_files(source_folder)
copied = 0
skipped = 0
errors = []
for abs_path, rel_path in videos:
dest_path = os.path.join(destination_folder, rel_path)
if os.path.exists(dest_path) and os.path.getsize(dest_path) == os.path.getsize(abs_path):
skipped += 1
continue
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
try:
shutil.copy2(abs_path, dest_path)
copied += 1
except OSError as e:
errors.append(f"{rel_path}: {e}")
return {"found": len(videos), "copied": copied, "skipped": skipped, "errors": errors}
def import_dashcam(destination_folder=DEFAULT_DESTINATION, mount_point=DEFAULT_MOUNT_POINT):
"""Full detect -> mount -> copy -> unmount pipeline.
Returns a summary dict describing what happened, always unmounting the
device again afterwards (even if copying fails partway through).
"""
partition = find_dashcam_partition()
if partition is None:
return {"status": "no_device", "message": "No USB dashcam/storage device detected."}
device_path = partition["path"]
# Already mounted elsewhere (e.g. by the desktop environment) - read
# straight from there instead of trying to mount it again ourselves.
already_mounted_at = partition.get("mountpoint")
mounted_here = False
try:
source = already_mounted_at
if not source:
if not mount_device(device_path, mount_point):
return {
"status": "mount_failed",
"device": device_path,
"message": f"Failed to mount {device_path}.",
}
mounted_here = True
source = mount_point
summary = copy_files(source, destination_folder)
summary["status"] = "ok"
summary["device"] = device_path
return summary
finally:
# Unmount the device (even if an exception occurs)
if mounted_here:
unmount_device(mount_point)
if __name__ == "__main__":
print(import_dashcam())
+12 -4
View File
@@ -2,19 +2,27 @@ from pymongo import MongoClient
from dotenv import load_dotenv
import os
load_dotenv("../db/creds.env")
# Load creds.env from the repo's db/ folder regardless of the process's cwd.
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(_BASE_DIR, "db", "creds.env"))
mUser = os.getenv()
mPass = os.getenv()
mDB = os.getenv()
mUser = os.getenv("mangosrv_user")
mPass = os.getenv("mangosrv_pass")
mDB = os.getenv("mangosrv_db")
def get_database():
if not mUser or not mPass or not mDB:
raise RuntimeError(
"MongoDB credentials are missing. Set mangosrv_user, mangosrv_pass, "
"and mangosrv_db in db/creds.env."
)
# Provide the mongodb atlas url to connect python to mongodb using pymongo
CONNECTION_STRING = "mongodb+srv://" + mUser + ":" + mPass + "@cluster.mongodb.net/" + mDB
# Create a connection using MongoClient. You can import MongoClient or use pymongo.MongoClient
client = MongoClient(CONNECTION_STRING)
return client[mDB]
# This is added so that many files can reuse the function get_database()
if __name__ == "__main__":
+10 -3
View File
@@ -5,6 +5,7 @@ import time, threading
socketio = SocketIO()
uptime_lock = threading.Lock()
thread_uptime = None
@socketio.on('connect')
def connect_event():
@@ -14,9 +15,15 @@ def connect_event():
uptime()
def uptime():
thread = Thread(target=loop_uptime)
thread.daemon = True
thread.start()
global thread_uptime
if thread_uptime is None or not thread_uptime.is_alive():
thread_uptime = Thread(target=loop_uptime)
thread_uptime.daemon = True
thread_uptime.start()
print(f"Uptime thread started, alive: {thread_uptime.is_alive()}")
else:
print(f"Uptime thread already running, alive: {thread_uptime.is_alive()}")
def loop_uptime():
uptime_lock.acquire()
+110 -13
View File
@@ -1,22 +1,119 @@
from modules.app_events import socketio
from threading import Thread
from threading import Thread, Lock
import serial
import time
# SIM868 GNSS/GSM/GPRS HAT is wired to the Pi's GPIO UART (jumper set to "B: Pi-SIM868"),
# exposed as /dev/serial0 once the Pi's onboard Bluetooth has been disabled/freed.
SERIAL_PORT = "/dev/serial0"
BAUDRATE = 115200
POLL_INTERVAL = 2 # seconds between AT+CGNSINF polls
LocationX = None
LocationY = None
_serial_lock = Lock()
_serial = None
_gps_thread = None
class GPS:
def __init__(self):
self.LocationX = None
self.LocationY = None
@socketio.on('gps')
def _get_serial():
global _serial
if _serial is None or not _serial.is_open:
_serial = serial.Serial(SERIAL_PORT, BAUDRATE, timeout=1)
time.sleep(0.3)
_serial.reset_input_buffer()
return _serial
def _send_at(cmd, wait=0.5):
"""Send an AT command to the SIM868 module and return the raw response."""
ser = _get_serial()
ser.write((cmd + "\r\n").encode())
time.sleep(wait)
return ser.read(1000).decode(errors="replace")
def _ensure_gnss_powered():
resp = _send_at("AT+CGNSPWR?")
if "+CGNSPWR: 0" in resp:
print("GNSS was off, powering it on...")
_send_at("AT+CGNSPWR=1", wait=1)
def _parse_cgnsinf(resp):
"""Parse a +CGNSINF response line into a dict, or None if there's no fix yet.
Format: +CGNSINF: run,fix,utc,lat,lon,alt,speed,course,fixmode,reserved1,
hdop,pdop,vdop,reserved2,gps_sat_view,gnss_sat_used,gnss_sat_view,cn0,hpa,vpa
"""
for line in resp.splitlines():
line = line.strip()
if line.startswith("+CGNSINF:"):
fields = line.split(":", 1)[1].strip().split(",")
if len(fields) < 8 or fields[1] != "1":
return None
lat, lon = fields[3], fields[4]
if not lat or not lon:
return None
return {
"lat": lat,
"lon": lon,
"alt": fields[5],
"speed": fields[6],
"course": fields[7],
}
return None
def _poll_loop():
global LocationX, LocationY
try:
with _serial_lock:
_ensure_gnss_powered()
except Exception as e:
print("Failed to initialize GNSS:", str(e))
while True:
try:
with _serial_lock:
resp = _send_at("AT+CGNSINF")
fix = _parse_cgnsinf(resp)
except Exception as e:
print("GPS read error:", str(e))
fix = None
if fix:
LocationX = fix["lat"]
LocationY = fix["lon"]
socketio.emit("gps_location", [LocationX, LocationY])
time.sleep(POLL_INTERVAL)
def start_gps():
"""Start the background thread that continuously polls the SIM868 for a GNSS fix."""
global _gps_thread
if _gps_thread is None or not _gps_thread.is_alive():
_gps_thread = Thread(target=_poll_loop)
_gps_thread.daemon = True
_gps_thread.start()
print(f"GPS thread started, alive: {_gps_thread.is_alive()}")
else:
print(f"GPS thread already running, alive: {_gps_thread.is_alive()}")
return GPS()
@socketio.on("gps")
def get_gps():
global LocationX
global LocationY
LocationX = "45.5202844"
LocationY = "-73.5493208"
"""Return the last known GPS location and emit it over the 'gps_location' event."""
Location = [LocationX, LocationY]
socketio.emit('gps_location', Location)
print("websocket should be sent now")
return print("socket.io emitted")
socketio.emit("gps_location", Location)
print("gps_location emitted:", Location)
return Location
+88 -50
View File
@@ -1,7 +1,7 @@
from modules import app_gps, app_sql, app_events
import obd, time, threading
import glob, obd, os, time, threading
from datetime import datetime
from threading import Thread
from threading import Event, Thread
status = None
speed = 0
@@ -16,88 +16,126 @@ class OBD:
obd_status = ["Disconnected", "Connected", "Connection...", "wtf"]
obd_speed_lock = threading.Lock()
obd_rpm_lock = threading.Lock()
thread_speed = None
thread_rpm = None
stop_speed = Event()
stop_rpm = Event()
connection = None
def _find_obd_port():
"""Locate the USB-serial OBD-II adapter.
Honors an OBD_PORT env var override; otherwise picks the first
/dev/ttyUSB* device found (the PL2303 USB adapter shows up here).
Returns None if nothing is found, letting python-obd fall back to its
own auto-scan (which also covers /dev/rfcomm* for the old Bluetooth setup).
"""
env_port = os.environ.get("OBD_PORT")
if env_port:
return env_port
ports = sorted(glob.glob("/dev/ttyUSB*"))
return ports[0] if ports else None
def connect_obd():
"""(Re)connect to the OBD-II adapter. Safe to call repeatedly."""
global connection
port = _find_obd_port()
try:
print(obd_status[2])
connection = obd.OBD() # put linux /dev/rfi... something in () for the rpi bluetooth adpater
connection = obd.OBD(portstr=port) if port else obd.OBD()
except Exception as e:
print("An error occurred:", str(e))
connection = None
return connection
connect_obd()
def get_speedtime():
global speed
date = datetime.now()
today = str(date.today())
time_now = str(date.strftime("%H:%M:%S"))
return [str(time_now), speed]
time_now = str(datetime.now().strftime("%H:%M:%S"))
return [time_now, speed]
def obd_connection():
if connection.is_connected():
global thread_speed, thread_rpm
if connection is None or not connection.is_connected():
connect_obd()
if connection is not None and connection.is_connected():
print(obd_status[1])
app_events.socketio.emit('obd_status', obd_status[1])
print("OBD connection established.")
if thread_speed is None or not thread_speed.is_alive():
stop_speed.clear()
thread_speed = Thread(target=obd_speed)
thread_speed.daemon = True
thread_speed.start()
print(f'thread_speed alive? {thread_speed.is_alive()}')
if thread_rpm is None or not thread_rpm.is_alive():
stop_rpm.clear()
thread_rpm = Thread(target=obd_rpm)
thread_rpm.daemon = True
thread_rpm.start()
print(f'thread_rpm alive? {thread_rpm.is_alive()}')
else:
print("OBD connection failed.")
print(obd_status[0])
app_events.socketio.emit('obd_status', obd_status[0])
app_events.socketio.emit('obd_speed', "-")
app_events.socketio.emit('obm_error', "-")
app_events.socketio.emit('obd_error', "-")
app_events.socketio.emit('obd_rpm', "-")
# debug speed for testing
thread_speed = Thread(target=obd_speed)
thread_speed.daemon = True
thread_speed.start()
# debug rpm for testing
thread_rpm = Thread(target=obd_rpm)
thread_rpm.daemon = True
thread_rpm.start()
def obd_speed():
obd_speed_lock.acquire()
cmd = obd.commands.SPEED # select an OBD command (sensor)
response = connection.query(cmd) # send the command and parse the response
loop = True
try:
while loop == True:
global speed
speed = speed + 1
time.sleep(10)
with obd_speed_lock:
cmd = obd.commands.SPEED # select an OBD command (sensor)
while not stop_speed.is_set():
if connection is None or not connection.is_connected():
print("OBD disconnected, stopping speed polling.")
break
response = connection.query(cmd) # send the command and parse the response
if response.is_null():
print("No data received. #" + str(get_speedtime()))
print(app_gps.get_gps())
app_events.socketio.emit('obd_speed', get_speedtime())
else:
print("Original value:", response.value)
print("Value in kph:", response.value.to("kph"))
app_events.socketio.emit('obd_speed', response.value.to("kph"))
finally:
obd_speed_lock.release()
speed = round(response.value.to("kph").magnitude, 1)
print("Speed:", speed, "kph")
app_events.socketio.emit('obd_speed', get_speedtime())
time.sleep(2)
def obd_rpm():
obd_speed_lock.acquire()
cmd = obd.commands.RPM # select an OBD command (sensor)
response = connection.query(cmd) # send the command and parse the response
loop = True
try:
while loop == True:
global rpm
speed = speed + 1
time.sleep(1)
with obd_rpm_lock:
cmd = obd.commands.RPM # select an OBD command (sensor)
while not stop_rpm.is_set():
if connection is None or not connection.is_connected():
print("OBD disconnected, stopping rpm polling.")
break
response = connection.query(cmd) # send the command and parse the response
if response.is_null():
print("No data received. #"+ str(get_speedtime()))
app_events.socketio.emit('obd_speed', get_speedtime())
print("No RPM data received. #" + str(get_speedtime()))
app_events.socketio.emit('obd_rpm', "-")
else:
print("RPM:", response.value)
app_events.socketio.emit('obd_speed', response.value.to("kph"))
finally:
obd_rpm_lock.release()
rpm = round(response.value.magnitude, 0)
print("RPM:", rpm)
app_events.socketio.emit('obd_rpm', rpm)
time.sleep(1)
def kill_obd(obd_type):
if obd_type == "speed":
obd_speed.loop = False
stop_speed.set()
elif obd_type == "rpm":
obd_rpm.loop = False
stop_rpm.set()
+104 -46
View File
@@ -1,25 +1,65 @@
import sqlite3
from modules import app_obd, app_car, app_account, app_gps
import os
import time
from datetime import datetime
from sqlite3 import Error
from threading import Thread, Lock
from modules import app_car, app_account, app_gps
# Resolve db/sql/ relative to the repo root regardless of the process's cwd
# (previously a relative "../db/sql/" path, which broke depending on how/from
# where app.py was launched).
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_SQL_DIR = os.path.join(_BASE_DIR, "db", "sql")
currentdatetime = datetime.now()
account = app_account.Account()
car = app_car.Car()
sCarModel = ''.join(letter for letter in car.model if letter.isalnum())
sql_file = account.name + "_" + sCarModel + ".db"
sql_path = "../db/sql/" + sql_file
sql_path = os.path.join(_SQL_DIR, sql_file)
LOG_INTERVAL = 5 # seconds between trip-tracking snapshots
_conn = None
_conn_lock = Lock()
_log_thread = None
SQL_CREATE_TRACKING_TABLE = """CREATE TABLE IF NOT EXISTS tracking (
id integer PRIMARY KEY,
VN text NOT NULL,
NAME text NOT NULL,
DATETIME datetime,
GPSX float,
GPSY float,
SPEED float,
ODOMETER float
);"""
SQL_CREATE_DTC_TABLE = """CREATE TABLE IF NOT EXISTS dtc (
id integer PRIMARY KEY,
NAME text NOT NULL,
STATUS text,
DTC_ID text
);"""
def create_conn(db_file):
"""Open a SQLite connection to db_file, creating its parent folder if needed.
Returns the open connection (or None on failure) - previously this closed
the connection immediately in a `finally` block and returned nothing,
so every caller always got a dead `None` connection.
"""
os.makedirs(os.path.dirname(db_file), exist_ok=True)
conn = None
try:
conn = sqlite3.connect(db_file)
print(sqlite3.version)
conn = sqlite3.connect(db_file, check_same_thread=False)
print("SQLite library version:", sqlite3.sqlite_version)
except Error as e:
print(e)
finally:
if conn:
conn.close()
return conn
def create_table(conn, create_table_sql):
""" create a table from the create_table_sql statement
@@ -33,46 +73,64 @@ def create_table(conn, create_table_sql):
except Error as e:
print(e)
def main():
db = sql_path
sql_create_tracking_table = """CREATE TABLE IF NOT EXISTS tracking (
id integer PRIMARY KEY,
VN text NOT NULL,
NAME text NOT NULL,
DATETIME datetime,
GPSX float,
GPSY float,
SPEED float,
ODOMETER float
);"""
sql_create_dtc_table = """CREATE TABLE IF NOT EXISTS dtc (
id integer PRIMARY KEY,
NAME text NOT NULL,
STATUS text,
DTC_ID text
);"""
# create a database connection
conn = create_conn(db)
# create tables
if conn is not None:
# create diagnostics table
create_table(conn, sql_create_tracking_table)
# create diagnostics table
create_table(conn, sql_create_dtc_table)
def get_conn():
"""Return the shared SQLite connection, opening + initializing tables on first use."""
global _conn
if _conn is None:
_conn = create_conn(sql_path)
if _conn is not None:
create_table(_conn, SQL_CREATE_TRACKING_TABLE)
create_table(_conn, SQL_CREATE_DTC_TABLE)
else:
print("Error! cannot create the database connection.")
return _conn
def insert_tracking(gps_x, gps_y, speed, odometer=None):
"""Insert a single trip-tracking snapshot row (GPS position + speed)."""
conn = get_conn()
if conn is None:
return
sql = """INSERT INTO tracking (VN, NAME, DATETIME, GPSX, GPSY, SPEED, ODOMETER)
VALUES (?, ?, ?, ?, ?, ?, ?)"""
with _conn_lock:
try:
conn.execute(sql, (car.model, account.name, datetime.now(), gps_x, gps_y, speed, odometer))
conn.commit()
except Error as e:
print("Failed to insert tracking row:", e)
def _log_loop():
# Deferred import to avoid a circular import at module load time
# (app_obd.py imports app_sql, and app_sql needs app_obd.speed here).
from modules import app_obd
while True:
gps_x = app_gps.LocationX
gps_y = app_gps.LocationY
speed = app_obd.speed
if gps_x is not None and gps_y is not None:
insert_tracking(gps_x, gps_y, speed)
time.sleep(LOG_INTERVAL)
def start_logging():
"""Start the background thread that periodically snapshots GPS+speed into SQLite."""
global _log_thread
if _log_thread is None or not _log_thread.is_alive():
get_conn() # make sure the db file/tables exist before the first insert
_log_thread = Thread(target=_log_loop)
_log_thread.daemon = True
_log_thread.start()
print(f"SQL tracking-logger thread started, alive: {_log_thread.is_alive()}")
else:
print(f"SQL tracking-logger thread already running, alive: {_log_thread.is_alive()}")
if __name__ == '__main__':
create_conn(sql_path)
# ## VN, NAME, DATE, TIME, GPS_X, GPS_Y, SPEED, ODOMETER
# (["VN", "NAME", "DATE", "TIME", "GPSX", "GPSY", "SPEED", "ODOMETER"])
# ([car.model, account.name, currentday, currenttime, GPSX, GPSY, SPEED, ODOMETER])
get_conn()
+69 -1
View File
@@ -1,5 +1,73 @@
# yaris-obd #
# yaris-obd 🚗📡
A Raspberry Pi 3-based OBD-II + GPS tracker for my 2013 Toyota Yaris — reads live vehicle data
over USB, tracks position with a SIM868 GNSS/GSM HAT, and serves it all from a local web UI you
can pull up on your phone by connecting to the car's own WiFi hotspot. Fully usable offline: no
internet needed in the car, offline map tiles included. Also handles pulling dashcam footage off
a USB dashcam for local/remote backup.
<table>
<tr>
<td width="120">
<a href="https://open.spotify.com/playlist/5u9vSw39FrIz7KghBkmgH4">
<img src="https://image-cdn-fa.spotifycdn.com/image/ab67706c0000d72c979ac180a23a70139d1ebd81" width="100" />
</a>
</td>
<td>
**rené lévesque bangers**
i have a dream
[![Listen on Spotify](https://img.shields.io/badge/Listen_on-Spotify-1DB954?style=for-the-badge&logo=spotify&logoColor=white)](https://open.spotify.com/playlist/5u9vSw39FrIz7KghBkmgH4)
</td>
</tr>
</table>
## Screenshot
![UI](https://github.com/quoije/yaris-obd/blob/698adee41532fed0ba1a7fe0ab83b26d569764cb/pics/ui.png)
## Features
- 📊 **Live OBD-II data** (speed, RPM, DTC codes) over a cheap USB ELM327-compatible adapter
- 🛰️ **Real GPS tracking** via a Waveshare SIM868 GNSS/GSM/Bluetooth HAT, plotted live on an
offline Leaflet map (pre-cached map tiles — no internet needed in the car)
- 📶 **Self-hosted WiFi hotspot** so you can load the web UI on your phone from inside the car,
with automatic fallback to your home WiFi when in range (`http://yaris.local` — no port number)
- 🎥 **Dashcam file retrieval** — detects a USB dashcam automatically (any brand/folder layout,
no hardcoded device path) and copies its footage locally
- 🗄️ Local SQLite trip logging, with an optional MongoDB remote sync path
- 🔌 Runs fully headless as a `systemd` service, autostarting on boot
## Hardware
| Part | Notes |
| --- | --- |
| Raspberry Pi 3 Model B | Web UI host + hotspot |
| Waveshare SIM868 HAT | GNSS (GPS) + GSM/GPRS, unused Bluetooth |
| USB OBD-II adapter | PL2303-based, ELM327-compatible (originally Bluetooth, switched to USB) |
| USB dashcam | Any brand — detected as generic USB mass storage |
| 2013 Toyota Yaris | The patient |
See [PLAN.md](./PLAN.md) for full hardware bring-up notes (SIM868 jumper/UART setup, antenna
gotchas, etc.) and the detailed project history.
## Getting started
```bash
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python3 app.py
```
The app serves on `http://<host>:5000`. On the Pi it also runs as a `systemd` service
(see [deploy/](./deploy)) and is reachable with no port number at `http://yaris.local` once the
mDNS + port-redirect setup described in [PLAN.md](./PLAN.md) is installed.
## Project status
This project was revived after a couple of years on the shelf. Progress is tracked phase-by-phase
in [PLAN.md](./PLAN.md) — currently through **Phase 8** (dashcam retrieval), with the remote
server/upload phase still to come.
+2
View File
@@ -3,3 +3,5 @@ Flask-SocketIO
gevent
pymongo
python-dotenv
pyserial
obd
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
One-time offline map tile downloader (Phase 3).
Downloads raster XYZ tiles from MapTiler Cloud for a bounding box covering
your usual driving area and saves them under static/vendor/tiles/{z}/{x}/{y}.png
so the web UI's Leaflet map works with zero internet access in the car.
This is a DEV-MACHINE-ONLY script. It needs internet + a free MapTiler API
key. The output (static PNG tiles) is what actually ships to the Pi -
the API key itself is never stored in the repo or deployed anywhere.
Get a free key: https://cloud.maptiler.com/account/keys/ (no credit card
required for the free tier, good for ~100,000 tile requests/month).
Usage:
export MAPTILER_KEY=your_key_here
python3 scripts/download_map_tiles.py \
--min-lat 40.00 --max-lat 41.00 --min-lon -75.00 --max-lon -74.00
# (bounding box is required - use your own driving area's coordinates,
# e.g. from https://boundingbox.klokantech.com/)
# optionally override the zoom range too:
# --min-zoom 8 --max-zoom 15
Re-running is safe/cheap: already-downloaded tiles are skipped.
"""
import argparse
import math
import os
import sys
import time
import urllib.request
import urllib.error
# zoom 15 gives good street-level detail; zoom 16 roughly quadruples tile
# count/quota usage on top of that, so it's opt-in via --max-zoom 16.
DEFAULT_MIN_ZOOM = 8
DEFAULT_MAX_ZOOM = 15
MAP_ID = "streets-v2"
TILE_SIZE = 256
OUTPUT_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "static", "vendor", "tiles"
)
def deg2num(lat, lon, zoom):
lat_rad = math.radians(lat)
n = 2.0 ** zoom
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return x, y
def iter_tiles(min_lat, max_lat, min_lon, max_lon, min_zoom, max_zoom):
for zoom in range(min_zoom, max_zoom + 1):
x1, y1 = deg2num(max_lat, min_lon, zoom)
x2, y2 = deg2num(min_lat, max_lon, zoom)
xlo, xhi = sorted((x1, x2))
ylo, yhi = sorted((y1, y2))
for x in range(xlo, xhi + 1):
for y in range(ylo, yhi + 1):
yield zoom, x, y
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--min-lat", type=float, required=True, help="Bounding box min latitude (your own driving area)")
parser.add_argument("--max-lat", type=float, required=True, help="Bounding box max latitude")
parser.add_argument("--min-lon", type=float, required=True, help="Bounding box min longitude")
parser.add_argument("--max-lon", type=float, required=True, help="Bounding box max longitude")
parser.add_argument("--min-zoom", type=int, default=DEFAULT_MIN_ZOOM)
parser.add_argument("--max-zoom", type=int, default=DEFAULT_MAX_ZOOM)
parser.add_argument("--api-key", default=os.environ.get("MAPTILER_KEY"),
help="MapTiler API key (defaults to MAPTILER_KEY env var)")
parser.add_argument("--delay", type=float, default=0.05,
help="Seconds to sleep between requests (be nice to the API)")
args = parser.parse_args()
if not args.api_key:
print("ERROR: no MapTiler API key given. Set MAPTILER_KEY env var or pass --api-key.",
file=sys.stderr)
print("Get a free key at https://cloud.maptiler.com/account/keys/", file=sys.stderr)
return 1
tiles = list(iter_tiles(args.min_lat, args.max_lat, args.min_lon, args.max_lon,
args.min_zoom, args.max_zoom))
total = len(tiles)
print(f"Planning to fetch up to {total} tiles (zoom {args.min_zoom}-{args.max_zoom}) "
f"into {os.path.abspath(OUTPUT_DIR)}")
downloaded = 0
skipped = 0
failed = 0
for i, (z, x, y) in enumerate(tiles, 1):
out_dir = os.path.join(OUTPUT_DIR, str(z), str(x))
out_path = os.path.join(out_dir, f"{y}.png")
if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
skipped += 1
continue
os.makedirs(out_dir, exist_ok=True)
url = f"https://api.maptiler.com/maps/{MAP_ID}/{TILE_SIZE}/{z}/{x}/{y}.png?key={args.api_key}"
try:
with urllib.request.urlopen(url, timeout=15) as resp:
data = resp.read()
with open(out_path, "wb") as f:
f.write(data)
downloaded += 1
except urllib.error.HTTPError as e:
if e.code == 400:
# Out of bounds tile (no data for this x/y/z combo) - skip silently.
skipped += 1
else:
failed += 1
print(f" [{i}/{total}] FAILED z={z} x={x} y={y}: {e}", file=sys.stderr)
except Exception as e:
failed += 1
print(f" [{i}/{total}] FAILED z={z} x={x} y={y}: {e}", file=sys.stderr)
if i % 200 == 0 or i == total:
print(f" [{i}/{total}] downloaded={downloaded} skipped={skipped} failed={failed}")
time.sleep(args.delay)
print(f"Done. downloaded={downloaded} skipped(existing/out-of-bounds)={skipped} failed={failed}")
return 0 if failed == 0 else 2
if __name__ == "__main__":
sys.exit(main())
+10 -3
View File
File diff suppressed because one or more lines are too long
+65
View File
@@ -0,0 +1,65 @@
// Dashcam page: scan for a USB dashcam, then trigger importing its videos.
// Uses plain fetch() (not socket.io / yaris.js) since this is a one-shot
// request/response flow, not a live data stream, and yaris.js assumes DOM
// elements (#map, #speedGraph) that don't exist on this page.
const deviceStatus = document.getElementById('dash-device-status');
const importStatus = document.getElementById('dash-import-status');
const scanBtn = document.getElementById('dash-scan-btn');
const importBtn = document.getElementById('dash-import-btn');
function describeDevice(device) {
const label = device.label ? ` "${device.label}"` : '';
return `${device.path}${label} (${device.size}, ${device.fstype || 'unknown fs'})`;
}
function scanForDashcam() {
deviceStatus.textContent = 'Scanning...';
importBtn.disabled = true;
fetch('/api/dash/devices')
.then(response => response.json())
.then(data => {
const devices = data.devices || [];
if (devices.length === 0) {
deviceStatus.textContent = 'No USB dashcam/storage device detected. Plug it in and scan again.';
return;
}
deviceStatus.textContent = 'Detected: ' + devices.map(describeDevice).join(', ');
importBtn.disabled = false;
})
.catch(err => {
deviceStatus.textContent = 'Error while scanning: ' + err;
});
}
function importDashcam() {
importStatus.textContent = 'Importing... this can take a while for large videos.';
importBtn.disabled = true;
fetch('/api/dash/import', { method: 'POST' })
.then(response => response.json())
.then(summary => {
if (summary.status !== 'ok') {
importStatus.textContent = summary.message || ('Import failed: ' + summary.status);
return;
}
let text = `Found ${summary.found} video(s), copied ${summary.copied}, skipped ${summary.skipped} (already imported).`;
if (summary.errors && summary.errors.length > 0) {
text += ` Errors: ${summary.errors.join('; ')}`;
}
importStatus.textContent = text;
})
.catch(err => {
importStatus.textContent = 'Error while importing: ' + err;
})
.finally(() => {
importBtn.disabled = false;
});
}
scanBtn.addEventListener('click', scanForDashcam);
importBtn.addEventListener('click', importDashcam);
// Auto-scan once when the page loads.
scanForDashcam();
+23 -6
View File
@@ -1,13 +1,19 @@
const socket = io();
// map
var map = L.map('map').setView([48.505, -0.09], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
// Offline tiles pre-downloaded via scripts/download_map_tiles.py (see PLAN.md
// Phase 3). Only zoom 8-15 is cached by default - matches DEFAULT_MIN_ZOOM/
// DEFAULT_MAX_ZOOM in that script.
// Generic placeholder center (not a real location) - re-centers automatically
// to the real live GPS fix once one comes in (see gps_location handler below).
var map = L.map('map', { minZoom: 8, maxZoom: 15 }).setView([48.505, -0.09], 12);
L.tileLayer('/static/vendor/tiles/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors',
minZoom: 8,
maxZoom: 15
}).addTo(map);
L.marker([51.5, -0.09]).addTo(map)
.bindPopup('your car... soon(tm)')
.openPopup();
var carMarker = L.marker(map.getCenter()).addTo(map)
.bindPopup('your car... soon(tm)');
// charts
@@ -95,6 +101,8 @@ socket.on('rpi_uptime', function(data) {
status.innerHTML = data;
});
let mapCentered = false;
socket.on('gps_location', function(data) {
console.log("location received")
const LocationX = document.getElementById('gpsLocationX');
@@ -102,4 +110,13 @@ socket.on('gps_location', function(data) {
LocationX.innerHTML = data[0];
LocationY.innerHTML = data[1];
const lat = data[0];
const lon = data[1];
if (lat != null && lon != null) {
carMarker.setLatLng([lat, lon]);
if (!mapCentered) {
map.setView([lat, lon], 15);
mapCentered = true;
}
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18 -7
View File
@@ -7,9 +7,8 @@
<title>Dashboard - yaris vroom vroom de course</title>
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.css') }}">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Nunito:200,200i,300,300i,400,400i,600,600i,700,700i,800,800i,900,900i&amp;display=swap">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.12.0/css/all.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
</head>
<body id="page-top">
@@ -58,12 +57,24 @@
</div>
</nav>
<div class="container-fluid">
<h3 class="text-dark mb-4">DTC Codes</h3>
<h3 class="text-dark mb-4">Dashcam</h3>
<div class="card shadow mb-4">
<div class="card-header py-3">
<p class="text-primary m-0 fw-bold">Detected device</p>
</div>
<div class="card-body">
<p id="dash-device-status">Checking for a USB dashcam...</p>
<button id="dash-scan-btn" class="btn btn-secondary" type="button">Scan for dashcam</button>
<button id="dash-import-btn" class="btn btn-primary" type="button" disabled>Import videos</button>
</div>
</div>
<div class="card shadow">
<div class="card-header py-3">
<p class="text-primary m-0 fw-bold">Output</p>
<p class="text-primary m-0 fw-bold">Import results</p>
</div>
<div class="card-body">
<p id="dash-import-status">No import has been run yet.</p>
</div>
<div class="card-body"></div>
</div>
</div>
</div>
@@ -74,7 +85,7 @@
</footer>
</div><a class="border rounded d-inline scroll-to-top" href="#page-top"><i class="fas fa-angle-up"></i></a>
</div>
<script src="{{ url_for('static', filename='js/yaris.js') }}"></script>
<script src="{{ url_for('static', filename='js/dash.js') }}"></script>
<script src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
+2 -3
View File
@@ -7,9 +7,8 @@
<title>Dashboard - yaris vroom vroom de course</title>
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.css') }}">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Nunito:200,200i,300,300i,400,400i,600,600i,700,700i,800,800i,900,900i&amp;display=swap">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.12.0/css/all.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
</head>
<body id="page-top">
+5 -6
View File
@@ -7,9 +7,8 @@
<title>Dashboard - yaris vroom vroom de course</title>
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.css') }}">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Nunito:200,200i,300,300i,400,400i,600,600i,700,700i,800,800i,900,900i&amp;display=swap">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.12.0/css/all.css">
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
</head>
<body id="page-top">
@@ -180,9 +179,9 @@
</footer>
</div><a class="border rounded d-inline scroll-to-top" href="#page-top"><i class="fas fa-angle-up"></i></a>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.2.0/socket.io.js"></script>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="{{ url_for('static', filename='js/chart.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/socketio/socket.io.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<script src="{{ url_for('static', filename='js/yaris.js') }}"></script>
<script src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>