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:
+27
@@ -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/
|
||||||
@@ -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`.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask, render_template, make_response, json
|
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
|
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash
|
||||||
import time
|
import time
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ app_events.socketio.init_app(app)
|
|||||||
account = app_account.Account()
|
account = app_account.Account()
|
||||||
obd = app_obd.OBD()
|
obd = app_obd.OBD()
|
||||||
car = app_car.Car()
|
car = app_car.Car()
|
||||||
gps = app_gps.get_gps()
|
gps = app_gps.start_gps()
|
||||||
|
app_sql.start_logging()
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
@@ -26,6 +27,16 @@ def dtc():
|
|||||||
def dash():
|
def dash():
|
||||||
return render_template('dash.html', account = account, car = car)
|
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
|
# not sure if im gonna use this
|
||||||
@app.route('/api/obd_speed/')
|
@app.route('/api/obd_speed/')
|
||||||
def data():
|
def data():
|
||||||
|
|||||||
Executable
+21
@@ -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"
|
||||||
Executable
+23
@@ -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/"
|
||||||
Executable
+24
@@ -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)"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Executable
+47
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
+204
-25
@@ -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 os
|
||||||
import shutil
|
import shutil
|
||||||
from tqdm import tqdm
|
import subprocess
|
||||||
|
|
||||||
def mount_device(device, mount_point):
|
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"}
|
||||||
os.system(f"sudo mount {device} {mount_point}")
|
|
||||||
|
|
||||||
def unmount_device(mount_point):
|
# Filesystem types dashcams commonly ship formatted as (SD cards are almost
|
||||||
os.system(f"sudo umount {mount_point}")
|
# always FAT32/exFAT; a few older units use NTFS).
|
||||||
|
DASHCAM_FS_TYPES = {"vfat", "exfat", "ntfs", "ntfs3"}
|
||||||
|
|
||||||
def copy_files(source_folder, destination_folder):
|
DEFAULT_MOUNT_POINT = "/mnt/dashcam"
|
||||||
if not os.path.exists(destination_folder):
|
DEFAULT_DESTINATION = os.path.join(
|
||||||
os.makedirs(destination_folder)
|
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
|
def list_usb_storage_partitions():
|
||||||
if not os.path.exists(destination_path):
|
"""Return a list of dicts describing USB-attached storage partitions.
|
||||||
shutil.copy2(source_path, destination_folder)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
Each dict has: path, fstype, label, size, mountpoint. Only partitions
|
||||||
source_device = "/dev/sda"
|
whose parent disk reports TRAN "usb" are included - this is what makes
|
||||||
mount_point = "/mnt/dash"
|
the detection brand/device-agnostic (no hardcoded /dev/sda) while still
|
||||||
destination_folder = "~/yaris-obd/dash/videos"
|
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:
|
try:
|
||||||
# Mount the device
|
data = json.loads(result.stdout)
|
||||||
mount_device(source_device, mount_point)
|
except json.JSONDecodeError as e:
|
||||||
|
print("Failed to parse lsblk output:", e)
|
||||||
|
return []
|
||||||
|
|
||||||
# Copy files from source to destination
|
partitions = []
|
||||||
copy_files(os.path.join(mount_point, "DCIM"), os.path.expanduser(destination_folder))
|
|
||||||
|
|
||||||
|
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:
|
finally:
|
||||||
# Unmount the device (even if an exception occurs)
|
if mounted_here:
|
||||||
unmount_device(mount_point)
|
unmount_device(mount_point)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(import_dashcam())
|
||||||
|
|||||||
+18
-10
@@ -2,22 +2,30 @@ from pymongo import MongoClient
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import os
|
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()
|
mUser = os.getenv("mangosrv_user")
|
||||||
mPass = os.getenv()
|
mPass = os.getenv("mangosrv_pass")
|
||||||
mDB = os.getenv()
|
mDB = os.getenv("mangosrv_db")
|
||||||
|
|
||||||
def get_database():
|
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
|
# Provide the mongodb atlas url to connect python to mongodb using pymongo
|
||||||
CONNECTION_STRING = "mongodb+srv://"+ mUser + ":" + mPass + "@cluster.mongodb.net/" + mDB
|
CONNECTION_STRING = "mongodb+srv://" + mUser + ":" + mPass + "@cluster.mongodb.net/" + mDB
|
||||||
|
|
||||||
# Create a connection using MongoClient. You can import MongoClient or use pymongo.MongoClient
|
# Create a connection using MongoClient. You can import MongoClient or use pymongo.MongoClient
|
||||||
client = MongoClient(CONNECTION_STRING)
|
client = MongoClient(CONNECTION_STRING)
|
||||||
|
return client[mDB]
|
||||||
|
|
||||||
# This is added so that many files can reuse the function get_database()
|
# This is added so that many files can reuse the function get_database()
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
# Get the database
|
# Get the database
|
||||||
dbname = get_database()
|
dbname = get_database()
|
||||||
+10
-3
@@ -5,6 +5,7 @@ import time, threading
|
|||||||
|
|
||||||
socketio = SocketIO()
|
socketio = SocketIO()
|
||||||
uptime_lock = threading.Lock()
|
uptime_lock = threading.Lock()
|
||||||
|
thread_uptime = None
|
||||||
|
|
||||||
@socketio.on('connect')
|
@socketio.on('connect')
|
||||||
def connect_event():
|
def connect_event():
|
||||||
@@ -14,9 +15,15 @@ def connect_event():
|
|||||||
uptime()
|
uptime()
|
||||||
|
|
||||||
def uptime():
|
def uptime():
|
||||||
thread = Thread(target=loop_uptime)
|
global thread_uptime
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
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():
|
def loop_uptime():
|
||||||
uptime_lock.acquire()
|
uptime_lock.acquire()
|
||||||
|
|||||||
+111
-14
@@ -1,22 +1,119 @@
|
|||||||
from modules.app_events import socketio
|
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:
|
class GPS:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.LocationX = None
|
self.LocationX = None
|
||||||
self.LocationY= 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():
|
def get_gps():
|
||||||
global LocationX
|
"""Return the last known GPS location and emit it over the 'gps_location' event."""
|
||||||
global LocationY
|
|
||||||
|
|
||||||
LocationX = "45.5202844"
|
|
||||||
LocationY = "-73.5493208"
|
|
||||||
Location = [LocationX, LocationY]
|
Location = [LocationX, LocationY]
|
||||||
|
socketio.emit("gps_location", Location)
|
||||||
socketio.emit('gps_location', Location)
|
print("gps_location emitted:", Location)
|
||||||
|
return Location
|
||||||
print("websocket should be sent now")
|
|
||||||
|
|
||||||
return print("socket.io emitted")
|
|
||||||
|
|||||||
+95
-57
@@ -1,7 +1,7 @@
|
|||||||
from modules import app_gps, app_sql, app_events
|
from modules import app_gps, app_sql, app_events
|
||||||
import obd, time, threading
|
import glob, obd, os, time, threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Thread
|
||||||
|
|
||||||
status = None
|
status = None
|
||||||
speed = 0
|
speed = 0
|
||||||
@@ -16,88 +16,126 @@ class OBD:
|
|||||||
obd_status = ["Disconnected", "Connected", "Connection...", "wtf"]
|
obd_status = ["Disconnected", "Connected", "Connection...", "wtf"]
|
||||||
obd_speed_lock = threading.Lock()
|
obd_speed_lock = threading.Lock()
|
||||||
obd_rpm_lock = threading.Lock()
|
obd_rpm_lock = threading.Lock()
|
||||||
|
thread_speed = None
|
||||||
|
thread_rpm = None
|
||||||
|
stop_speed = Event()
|
||||||
|
stop_rpm = Event()
|
||||||
|
|
||||||
try:
|
connection = None
|
||||||
print(obd_status[2])
|
|
||||||
connection = obd.OBD() # put linux /dev/rfi... something in () for the rpi bluetooth adpater
|
|
||||||
|
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(portstr=port) if port else obd.OBD()
|
||||||
|
except Exception as e:
|
||||||
|
print("An error occurred:", str(e))
|
||||||
|
connection = None
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
connect_obd()
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print("An error occurred:", str(e))
|
|
||||||
|
|
||||||
def get_speedtime():
|
def get_speedtime():
|
||||||
global speed
|
global speed
|
||||||
date = datetime.now()
|
time_now = str(datetime.now().strftime("%H:%M:%S"))
|
||||||
today = str(date.today())
|
return [time_now, speed]
|
||||||
time_now = str(date.strftime("%H:%M:%S"))
|
|
||||||
return [str(time_now), speed]
|
|
||||||
|
|
||||||
def obd_connection():
|
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])
|
print(obd_status[1])
|
||||||
app_events.socketio.emit('obd_status', obd_status[1])
|
app_events.socketio.emit('obd_status', obd_status[1])
|
||||||
print("OBD connection established.")
|
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:
|
else:
|
||||||
print("OBD connection failed.")
|
print("OBD connection failed.")
|
||||||
print(obd_status[0])
|
print(obd_status[0])
|
||||||
|
|
||||||
app_events.socketio.emit('obd_status', obd_status[0])
|
app_events.socketio.emit('obd_status', obd_status[0])
|
||||||
app_events.socketio.emit('obd_speed', "-")
|
app_events.socketio.emit('obd_speed', "-")
|
||||||
app_events.socketio.emit('obm_error', "-")
|
app_events.socketio.emit('obd_error', "-")
|
||||||
app_events.socketio.emit('obd_rpm', "-")
|
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():
|
def obd_speed():
|
||||||
obd_speed_lock.acquire()
|
global speed
|
||||||
cmd = obd.commands.SPEED # select an OBD command (sensor)
|
with obd_speed_lock:
|
||||||
response = connection.query(cmd) # send the command and parse the response
|
cmd = obd.commands.SPEED # select an OBD command (sensor)
|
||||||
loop = True
|
while not stop_speed.is_set():
|
||||||
try:
|
if connection is None or not connection.is_connected():
|
||||||
while loop == True:
|
print("OBD disconnected, stopping speed polling.")
|
||||||
global speed
|
break
|
||||||
speed = speed + 1
|
|
||||||
time.sleep(10)
|
response = connection.query(cmd) # send the command and parse the response
|
||||||
if response.is_null():
|
if response.is_null():
|
||||||
print("No data received. #"+ str(get_speedtime()))
|
print("No data received. #" + str(get_speedtime()))
|
||||||
print(app_gps.get_gps())
|
print(app_gps.get_gps())
|
||||||
app_events.socketio.emit('obd_speed', get_speedtime())
|
|
||||||
else:
|
else:
|
||||||
print("Original value:", response.value)
|
speed = round(response.value.to("kph").magnitude, 1)
|
||||||
print("Value in kph:", response.value.to("kph"))
|
print("Speed:", speed, "kph")
|
||||||
app_events.socketio.emit('obd_speed', response.value.to("kph"))
|
|
||||||
finally:
|
app_events.socketio.emit('obd_speed', get_speedtime())
|
||||||
obd_speed_lock.release()
|
time.sleep(2)
|
||||||
|
|
||||||
def obd_rpm():
|
def obd_rpm():
|
||||||
obd_speed_lock.acquire()
|
global rpm
|
||||||
cmd = obd.commands.RPM # select an OBD command (sensor)
|
with obd_rpm_lock:
|
||||||
response = connection.query(cmd) # send the command and parse the response
|
cmd = obd.commands.RPM # select an OBD command (sensor)
|
||||||
loop = True
|
while not stop_rpm.is_set():
|
||||||
try:
|
if connection is None or not connection.is_connected():
|
||||||
while loop == True:
|
print("OBD disconnected, stopping rpm polling.")
|
||||||
global rpm
|
break
|
||||||
speed = speed + 1
|
|
||||||
time.sleep(1)
|
response = connection.query(cmd) # send the command and parse the response
|
||||||
if response.is_null():
|
if response.is_null():
|
||||||
print("No data received. #"+ str(get_speedtime()))
|
print("No RPM data received. #" + str(get_speedtime()))
|
||||||
app_events.socketio.emit('obd_speed', get_speedtime())
|
app_events.socketio.emit('obd_rpm', "-")
|
||||||
else:
|
else:
|
||||||
print("RPM:", response.value)
|
rpm = round(response.value.magnitude, 0)
|
||||||
app_events.socketio.emit('obd_speed', response.value.to("kph"))
|
print("RPM:", rpm)
|
||||||
finally:
|
app_events.socketio.emit('obd_rpm', rpm)
|
||||||
obd_rpm_lock.release()
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
def kill_obd(obd_type):
|
def kill_obd(obd_type):
|
||||||
if obd_type == "speed":
|
if obd_type == "speed":
|
||||||
obd_speed.loop = False
|
stop_speed.set()
|
||||||
elif obd_type == "rpm":
|
elif obd_type == "rpm":
|
||||||
obd_rpm.loop = False
|
stop_rpm.set()
|
||||||
|
|||||||
+100
-42
@@ -1,25 +1,65 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from modules import app_obd, app_car, app_account, app_gps
|
import os
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlite3 import Error
|
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()
|
account = app_account.Account()
|
||||||
car = app_car.Car()
|
car = app_car.Car()
|
||||||
sCarModel = ''.join(letter for letter in car.model if letter.isalnum())
|
sCarModel = ''.join(letter for letter in car.model if letter.isalnum())
|
||||||
sql_file = account.name + "_" + sCarModel + ".db"
|
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):
|
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
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(db_file)
|
conn = sqlite3.connect(db_file, check_same_thread=False)
|
||||||
print(sqlite3.version)
|
print("SQLite library version:", sqlite3.sqlite_version)
|
||||||
except Error as e:
|
except Error as e:
|
||||||
print(e)
|
print(e)
|
||||||
finally:
|
return conn
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def create_table(conn, create_table_sql):
|
def create_table(conn, create_table_sql):
|
||||||
""" create a table from the create_table_sql statement
|
""" create a table from the create_table_sql statement
|
||||||
@@ -33,46 +73,64 @@ def create_table(conn, create_table_sql):
|
|||||||
except Error as e:
|
except Error as e:
|
||||||
print(e)
|
print(e)
|
||||||
|
|
||||||
def main():
|
|
||||||
db = sql_path
|
|
||||||
|
|
||||||
sql_create_tracking_table = """CREATE TABLE IF NOT EXISTS tracking (
|
def get_conn():
|
||||||
id integer PRIMARY KEY,
|
"""Return the shared SQLite connection, opening + initializing tables on first use."""
|
||||||
VN text NOT NULL,
|
global _conn
|
||||||
NAME text NOT NULL,
|
if _conn is None:
|
||||||
DATETIME datetime,
|
_conn = create_conn(sql_path)
|
||||||
GPSX float,
|
if _conn is not None:
|
||||||
GPSY float,
|
create_table(_conn, SQL_CREATE_TRACKING_TABLE)
|
||||||
SPEED float,
|
create_table(_conn, SQL_CREATE_DTC_TABLE)
|
||||||
ODOMETER float
|
else:
|
||||||
);"""
|
print("Error! cannot create the database connection.")
|
||||||
|
return _conn
|
||||||
|
|
||||||
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
|
def insert_tracking(gps_x, gps_y, speed, odometer=None):
|
||||||
conn = create_conn(db)
|
"""Insert a single trip-tracking snapshot row (GPS position + speed)."""
|
||||||
|
conn = get_conn()
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
|
||||||
# create tables
|
sql = """INSERT INTO tracking (VN, NAME, DATETIME, GPSX, GPSY, SPEED, ODOMETER)
|
||||||
if conn is not None:
|
VALUES (?, ?, ?, ?, ?, ?, ?)"""
|
||||||
# create diagnostics table
|
with _conn_lock:
|
||||||
create_table(conn, sql_create_tracking_table)
|
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)
|
||||||
|
|
||||||
# create diagnostics table
|
|
||||||
create_table(conn, sql_create_dtc_table)
|
|
||||||
|
|
||||||
|
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:
|
else:
|
||||||
print("Error! cannot create the database connection.")
|
print(f"SQL tracking-logger thread already running, alive: {_log_thread.is_alive()}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
create_conn(sql_path)
|
get_conn()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ## 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])
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
[](https://open.spotify.com/playlist/5u9vSw39FrIz7KghBkmgH4)
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Screenshot
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## 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
|
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.
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ Flask-SocketIO
|
|||||||
gevent
|
gevent
|
||||||
pymongo
|
pymongo
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
pyserial
|
||||||
|
obd
|
||||||
@@ -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())
|
||||||
Vendored
+10
-3
File diff suppressed because one or more lines are too long
@@ -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
@@ -1,13 +1,19 @@
|
|||||||
const socket = io();
|
const socket = io();
|
||||||
// map
|
// map
|
||||||
|
|
||||||
var map = L.map('map').setView([48.505, -0.09], 13);
|
// Offline tiles pre-downloaded via scripts/download_map_tiles.py (see PLAN.md
|
||||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
// Phase 3). Only zoom 8-15 is cached by default - matches DEFAULT_MIN_ZOOM/
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
// 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: '© <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors',
|
||||||
|
minZoom: 8,
|
||||||
|
maxZoom: 15
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
L.marker([51.5, -0.09]).addTo(map)
|
var carMarker = L.marker(map.getCenter()).addTo(map)
|
||||||
.bindPopup('your car... soon(tm)')
|
.bindPopup('your car... soon(tm)');
|
||||||
.openPopup();
|
|
||||||
|
|
||||||
// charts
|
// charts
|
||||||
|
|
||||||
@@ -95,6 +101,8 @@ socket.on('rpi_uptime', function(data) {
|
|||||||
status.innerHTML = data;
|
status.innerHTML = data;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mapCentered = false;
|
||||||
|
|
||||||
socket.on('gps_location', function(data) {
|
socket.on('gps_location', function(data) {
|
||||||
console.log("location received")
|
console.log("location received")
|
||||||
const LocationX = document.getElementById('gpsLocationX');
|
const LocationX = document.getElementById('gpsLocationX');
|
||||||
@@ -102,4 +110,13 @@ socket.on('gps_location', function(data) {
|
|||||||
LocationX.innerHTML = data[0];
|
LocationX.innerHTML = data[0];
|
||||||
LocationY.innerHTML = data[1];
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
Vendored
+661
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+6
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
+18
-7
@@ -7,9 +7,8 @@
|
|||||||
<title>Dashboard - yaris vroom vroom de course</title>
|
<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='bootstrap/css/bootstrap.min.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.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&display=swap">
|
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
|
||||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.12.0/css/all.css">
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body id="page-top">
|
<body id="page-top">
|
||||||
@@ -58,12 +57,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container-fluid">
|
<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 shadow">
|
||||||
<div class="card-header py-3">
|
<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>
|
||||||
<div class="card-body"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +85,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div><a class="border rounded d-inline scroll-to-top" href="#page-top"><i class="fas fa-angle-up"></i></a>
|
</div><a class="border rounded d-inline scroll-to-top" href="#page-top"><i class="fas fa-angle-up"></i></a>
|
||||||
</div>
|
</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='bootstrap/js/bootstrap.min.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||||
|
|||||||
+2
-3
@@ -7,9 +7,8 @@
|
|||||||
<title>Dashboard - yaris vroom vroom de course</title>
|
<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='bootstrap/css/bootstrap.min.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.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&display=swap">
|
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
|
||||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.12.0/css/all.css">
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body id="page-top">
|
<body id="page-top">
|
||||||
|
|||||||
@@ -7,9 +7,8 @@
|
|||||||
<title>Dashboard - yaris vroom vroom de course</title>
|
<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='bootstrap/css/bootstrap.min.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='yaris.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&display=swap">
|
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/fontawesome-all.min.css') }}">
|
||||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.12.0/css/all.css">
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body id="page-top">
|
<body id="page-top">
|
||||||
@@ -180,9 +179,9 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div><a class="border rounded d-inline scroll-to-top" href="#page-top"><i class="fas fa-angle-up"></i></a>
|
</div><a class="border rounded d-inline scroll-to-top" href="#page-top"><i class="fas fa-angle-up"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="{{ url_for('static', filename='js/chart.min.js') }}"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.2.0/socket.io.js"></script>
|
<script src="{{ url_for('static', filename='vendor/socketio/socket.io.min.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='vendor/leaflet/leaflet.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/yaris.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='bootstrap/js/bootstrap.min.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user