374 Commits
Author SHA1 Message Date
Mathieu Moran c6b1384b34 fix: pre-road audit (Phase 30) — upload-loop OSError, stats lock scope, server int guards 2026-08-15 22:25:17 -04:00
Mathieu Moran 386920b258 docs: drop duplicated device-identity section, add RTC + power to hardware table 2026-08-15 21:26:15 -04:00
Mathieu Moran 28f3643e59 docs: refresh project status + add MIT license 2026-08-15 21:23:07 -04:00
Mathieu Moran 7705473be3 docs(deploy): full hardware→software build guide + auto_install summary 2026-08-15 21:14:55 -04:00
Mathieu Moran 4801508415 docs: mark APK distribution as deployed 2026-08-15 21:10:27 -04:00
Mathieu Moran a050f3577a fix(pi): set a User-Agent on the APK updater so Cloudflare doesn't 403 it 2026-08-15 21:02:54 -04:00
Mathieu Moran 23c044b5e0 feat(pi): add Android app download link to the settings page 2026-08-15 20:57:15 -04:00
Mathieu Moran 5a5df2b323 feat: APK distribution pipeline — server hosting + Pi auto-update + /app routes 2026-08-15 20:52:38 -04:00
Mathieu Moran 5039b90bc6 feat(android): release signing + R8-verified signed APK (Phase 28.8) 2026-08-15 20:44:45 -04:00
Mathieu Moran d34d30e65f feat(android): branded app icon + native splash screen (Phase 28.8 polish) 2026-08-15 20:41:43 -04:00
Mathieu Moran ce7c7c63b4 docs: note system back handling in trip replay 2026-08-15 20:38:32 -04:00
Mathieu Moran 26fd915110 fix(android): system back from trip replay returns to the list 2026-08-15 20:37:36 -04:00
Mathieu Moran b775856dd9 fix(android): restore wordmark+subtitle header, content padding, OS light/dark theme 2026-08-15 20:34:29 -04:00
Mathieu Moran 699ce16dab feat(android): UI redesign — light web-matching theme, dark top bar + bottom nav (Phase 28.7) 2026-08-15 20:18:53 -04:00
Mathieu Moran 0cb89596b0 feat(android): remote portal access — login, stats, trips, dashcam (Phase 28.6) 2026-08-15 20:11:26 -04:00
Mathieu Moran 9fa5e2e2da feat(android): max-speed marker + jump on trip replay (parity with web FEAT-16) 2026-08-15 20:04:04 -04:00
Mathieu Moran e1afdf2952 fix(android): swap Trips/Settings tab mapping, inset the trip replay top bar under the status bar 2026-08-15 19:58:38 -04:00
Mathieu Moran a40fced666 feat(android): trip history list + route replay map (Phase 28.5) 2026-08-15 19:51:06 -04:00
Mathieu Moran cadde5aeee feat(android): live dashboard screen (Phase 28.4) 2026-08-15 19:40:47 -04:00
Mathieu Moran b6f8416b5c feat(android): upload all + per-file retry (Phase 28.3 complete) 2026-08-15 19:32:05 -04:00
Mathieu Moran 749feeb9d5 docs: M7 now-playing telemetry is Pi-side AVRCP broadcast, no Android app needed 2026-08-15 19:26:23 -04:00
Mathieu Moran 27ecc5492a docs: move Android Auto media-template telemetry idea into poc mediacenter plan 2026-08-15 19:25:14 -04:00
Mathieu Moran e7756282c3 docs: add FEAT-17 — Android Auto media-template live telemetry idea 2026-08-15 19:22:33 -04:00
Mathieu Moran bb7623211e docs: add DS3231 RTC to Hardware section (BUG-18) 2026-08-15 18:46:51 -04:00
Mathieu Moran 7a9be9d63f docs: BUG-25 security hardening + Phase 9.5 notes 2026-08-15 18:29:35 -04:00
Mathieu Moran 24250e06df security: validate sha256 digests, add login rate limiting, harden session cookies 2026-08-15 18:28:55 -04:00
Mathieu Moran 42d7f8eb18 fix: clamp max-speed label at edges + move trip location next to time
- placeMaxTick() clamps the label so it stays within the scrubber instead of
  overlapping the time/speed readouts when max speed is near an edge
- give the scrubber row more bottom margin (mb-3) so the tick label clears the
  readout row below
- move the trip location readout directly under the timestamp (left side),
  removing the separate bottom line
2026-08-15 18:14:21 -04:00
Mathieu Moran 0072322bc2 style: timeline max-speed tick as up-arrow + 'Max speed' label
Replace the circle dot on the scrubber with a small up-pointing arrow just
below the line and a 'Max speed' text label.
2026-08-15 18:05:38 -04:00
Mathieu Moran 7d6dd28680 feat: max-speed marker scrubs timeline + timeline tick (FEAT-16)
- Clicking the amber max-speed map marker now jumps the replay to that point
- Add an amber tick on the scrubber at the max-speed position (clickable too)
- Both on Pi and server portal
2026-08-15 18:00:06 -04:00
Mathieu Moran 87d63e8f32 feat: pin the fastest point on the trip replay map (FEAT-16)
When a route loads, add an amber circle + permanent 'Max speed: X km/h'
tooltip at the point with the highest speed. Shared history.js, so it
applies to both the Pi and the server portal.
2026-08-15 17:54:49 -04:00
Mathieu Moran bfc5ee1615 docs: add BUG-24 (OBD stall reconnect) + GPS replay jitter fix notes 2026-08-15 17:48:23 -04:00
Mathieu Moran b0ad6fde51 fix: force OBD reconnect after ~20s of stale null speed (BUG-24)
obd_speed() now counts consecutive null-speed polls. If the ELM327 returns
null for 10 polls (~20s) while still reporting CAR_CONNECTED, it force-closes
the connection so the reconnect loop re-handshakes instead of silently
flatlining until the next ignition cycle.
2026-08-15 17:47:32 -04:00
Mathieu Moran bfc8ddabd9 fix: smooth GPS-derived replay speed to kill jitter spikes
route_points (Pi) and get_trip_route (server) derived GPS speed from a
single 5s segment, so GPS position jitter could read 150+ km/h when the car
wasn't going that fast. Both now average over a trailing ~30s window (same
as the max-speed calc), so spikes are damped.
2026-08-15 17:38:45 -04:00
Mathieu Moran 2817930bdc feat: re-add trip ID column on the server portal
Restore the 'ID' column (and showId attribute) on the server trip list.
2026-08-15 17:29:49 -04:00
Mathieu Moran a05d4a9a13 style: bake in 90% zoom scale via root font-size
Set html { font-size: 90% } so the whole UI (sidebar, typography, spacing)
renders at the ~90% browser-zoom look the owner prefers, consistently on
both the Pi and the remote server.
2026-08-15 17:21:50 -04:00
Mathieu Moran 36076034c4 style: compact data-list table rows so Pi and server match
Add an explicit .375rem vertical cell padding for .data-list tables. The
CSS/templates were already byte-identical across Pi and server, so this
makes the compact row height deterministic on both and rules out any
Bootstrap-default drift or stale-cache difference.
2026-08-15 17:13:40 -04:00
Mathieu Moran dcca46ec68 style: drop the ID column from the server trip table to match the Pi
The portal's trip list had an extra 'ID' column the Pi's list doesn't
have. Remove it (and its showId data attribute) so both tables render the
same columns: checkbox, Started, km, Time, Max, Odometer, delete.
2026-08-15 17:02:41 -04:00
Mathieu Moran ff49609f2a docs: note Pi checkbox batch trip delete in Phase 29 2026-08-15 16:56:28 -04:00
Mathieu Moran 99035e86ee feat: checkbox batch trip delete on the Pi (local-only)
- Pi history page gets a select checkbox column + 'Delete selected' button
  (mirrors the server UI)
- POST /api/trips/delete batch endpoint
- app_sql.delete_trips_batch() + deleted_trip_markers table so a later trip
  rebuild can't resurrect locally-deleted trips
- Deletion is local-only: raw tracking rows are kept and the remote server
  is never notified (it keeps its archival copy recomputed from uploaded rows)
2026-08-15 16:55:30 -04:00
Mathieu Moran c9d36cca33 docs: close BUG-23 + record fix in Phase 29
Move BUG-23 to Resolved with commit 78aea2d and verification (Pi trips
57 -> 49 after rebuild). Add matching entry to PLAN.md Phase 29.
2026-08-15 16:08:29 -04:00
Mathieu Moran 78aea2dc88 fix: Pi trip builder drops GPS lock-in drift trips (BUG-23)
Add the server's minimum-trip filter to build_trip_summaries() — duration
< 30s OR distance < 0.05km is rejected — so GPS lock-in drift no longer
spawns false trips on the Pi. Also sort normalized rows by datetime (the
server already orders by recorded_at ASC), fixing clock-rollback trip
boundaries. Bump TRIP_BUILD_VERSION to force a Pi-side rebuild.
2026-08-15 16:05:27 -04:00
Mathieu Moran 94dcffa2cb docs: add BUG-23 — Pi/server trip counts diverge (57 vs 10)
Root-caused the discrepancy by replicating the server's trip builder over
live data: 58 raw groups -> 51 after the server's duration/distance floor
(7 short trips dropped that the Pi keeps) -> 10 after skipping 41 deleted
trip markers. Documents the min-trip filter mismatch and the row-set
mismatch as the actual bugs.
2026-08-15 15:56:32 -04:00
Mathieu Moran 47993a876f fix: diagnose.sh DB checks — wrong path, column, and tool
- DB lives under db/sql/<account>_<car>.db, not the repo root (was -maxdepth 1)
- Pick the most recently modified DB (active account) instead of the first
  found, which was the stale TestUser_SubaruWRX.db
- Query UPLOADED=0 instead of sent=0 (column was renamed long ago)
- Use the venv Python's sqlite3 for queries — the sqlite3 CLI isn't installed
  on the Pi, so integrity/row-count checks were silently failing
2026-08-15 14:22:35 -04:00
Mathieu Moran 5170dbce62 chore: update diagnose.sh for dual-T3U WiFi and Android app
- Network section now checks wlan1 (in-car AP, 10.42.0.1), wlan2 (uplink),
  and reports wlan0 as retired fallback (was: first-wlan guess)
- Header + manual commands reflect current layout: Android companion app,
  remote server at yaris.ceaf.dev, AP/uplink nmcli profiles
2026-08-15 14:16:10 -04:00
Mathieu Moran 3808054577 docs: add BUG-22 commit ref 2026-08-14 23:05:12 -04:00
Mathieu Moran 90cfd7bd04 docs: close BUG-22 (smoothed GPS max verified) 2026-08-14 23:04:42 -04:00
Mathieu Moran be3b196c09 fix: BUG-22 bump TRIP_BUILD_VERSION to force Pi trip rebuild 2026-08-14 23:03:28 -04:00
Mathieu Moran a0be96dfaa fix: BUG-22 smooth GPS-derived speeds in trip max (trailing window) 2026-08-14 23:00:53 -04:00
Mathieu Moran d9314969d8 fix: BUG-22 include GPS-derived speeds in trip max (Pi + server) 2026-08-14 22:56:37 -04:00
Mathieu Moran aaa0bdabc4 docs: add BUG-21 commit ref 2026-08-14 22:37:33 -04:00
Mathieu Moran 7c8f27b84d docs: close BUG-21 (GPS-speed fallback verified) 2026-08-14 22:37:21 -04:00
Mathieu Moran 2fee2de357 fix: BUG-21 GPS-speed fallback in server route replay 2026-08-14 22:34:29 -04:00
Mathieu Moran 0fa6cc4988 docs: add BUG-21 — OBD speed dropout mid-drive, GPS-only replay 2026-08-14 22:22:16 -04:00
Mathieu Moran 09eca4d39f docs: add BUG-20 commit ref 2026-08-14 22:05:21 -04:00
Mathieu Moran 9af71e2395 docs: close BUG-20 (trip IDs renumbered 1..10, sequence clamped) 2026-08-14 22:05:09 -04:00
Mathieu Moran 01c7fdf0c4 fix: BUG-20 clamp trips sequence after recompute + renumber migration 2026-08-14 21:45:26 -04:00
Mathieu Moran 25f57603c6 feat: FEAT-14 dual T3U — wlan2 uplink, wlan1 AP, onboard wlan0 fallback 2026-08-14 21:32:30 -04:00
Mathieu Moran c7329186f4 docs: note SIM868 RTC set via AT+CCLK (BUG-18 follow-up) 2026-08-14 19:03:09 -04:00
Mathieu Moran dc3a3d866d docs: add commit refs for BUG-17/18 2026-08-14 18:35:21 -04:00
Mathieu Moran bf4f3d377e docs: close BUG-17 + BUG-18 — DS3231 RTC verified across power loss 2026-08-14 18:35:10 -04:00
Mathieu Moran bfdd1c2285 docs: BUG-18 DS3231 installed + working (I²C 0x68), pending power-cycle test 2026-08-14 18:05:17 -04:00
Mathieu Moran c2462356e1 Fix slow OBD value load on dashboard (BUG-19) 2026-08-13 16:57:16 -04:00
Mathieu Moran e873db918d Add clear DTC codes button to Pi DTC page (FEAT-15) 2026-08-13 16:40:15 -04:00
Mathieu Moran b39faadf58 docs: correct BUG-15 commit ref 2026-08-13 16:24:14 -04:00
Mathieu Moran ab798205a4 docs: close BUG-15 — phone-OS limitation (dual-role), workaround documented 2026-08-13 16:23:14 -04:00
Mathieu Moran c44026e511 docs: BUG-18 plan — DS3231 (I²C) + SIM868 CR1220 RTC backup 2026-08-13 16:13:51 -04:00
Mathieu Moran f94c50b2ee docs: close BUG-16 (fixed + verified), update Phase 29 status 2026-08-13 15:53:52 -04:00
Mathieu Moran f5f20c315b fix: trip builder handles NULL-GPS speed-only rows (BUG-16 follow-up)
Trips starting/ending on speed-only rows violated the trips.*GPSX NOT NULL
constraint. Use the first/last GPS-bearing row for start/end coordinates, and
skip a trip that never got any GPS fix (no location to record).
2026-08-13 15:52:07 -04:00
Mathieu Moran e5ac14e38b fix: persist car-connected flag so trip building works across reboots (BUG-16)
connect_obd() set _car_ever_connected without a global declaration, so the
module-level flag was never set and was_car_ever_connected() always returned
False — local trip building has been silently blocked since BUG-10 landed.
Also persist the flag to the settings table so a drive followed by a reboot
still builds trips, and clean up dead code in was_car_ever_connected().
2026-08-13 15:41:30 -04:00
Mathieu Moran e0ae179c38 docs: BUG-16/17/18 — Phase 29 road-test findings (trip missing, clock drift, no RTC) 2026-08-13 15:22:00 -04:00
Mathieu Moran 2172de6055 docs: add Phase 29 — round-2 road-test bug fixes (2026-08-13) 2026-08-13 15:11:53 -04:00
Mathieu Moran 87418ba3f8 docs: FEAT-14 — second T3U to replace onboard wlan0 (dual AC radios) 2026-08-13 15:04:11 -04:00
Mathieu Moran 4ee768174c docs: FEAT-13 — flush uploaded tracking rows from local DB (idea) 2026-08-13 15:03:00 -04:00
Mathieu Moran 3d49764855 feat: log OBD speed before GPS fix so rolling starts aren't lost
- _log_loop: when GPS has no fix but OBD speed > 1, write speed-only rows
  (NULL coordinates) instead of dropping the motion
- insert_tracking: accept None gps_x/gps_y -> NULL GPSX/GPSY
- app_trip: keep NULL-coord rows through normalize; _segment returns 0 for
  missing coords; route_points drops them (no position to draw)
- tests: speed-only rows build a trip with max speed + zero distance
- docs: FEAT-12 + readme note; server already filters NULL-GPS safely
2026-08-13 02:02:45 -04:00
Mathieu Moran fa8ae0001a ui: use redacted dashboard screenshots + restore GitHub footer link
- pics/ui.png regenerated from redacted desktop + mobile screenshots
- restore GitHub footer link in Pi + server base.html (accidentally removed)
2026-08-12 23:44:40 -04:00
Mathieu Moran 495e0a99f9 docs: refresh readme status + dashboard screenshots
- Project status -> Phase 27 done, Phase 28 (Android app) in progress
- Added Android companion app to feature list
- Regenerated pics/ui.png (mobile 390px + desktop 1280px dashboards)
2026-08-12 22:58:59 -04:00
Mathieu Moran 7f6d6c3a89 fix: show correct 'reach the Pi' URL per uplink mode + BUG-15
- Settings: when the Pi is on the phone's hotspot (mode phone), show the
  Pi's hotspot IP (http://<ip>) with an LTE-only hint instead of the AP URL
- BUG-15 documents the phone dual-role routing loop (host hotspot + connect
  to AP simultaneously breaks the uplink)
2026-08-12 22:38:31 -04:00
Mathieu Moran 51417abf75 docs: phone-hotspot requires LTE-only phone (no simultaneous AP client)
- FEAT-07: documented the WiFi-sharing/routing loop that breaks the Pi's
  uplink when the phone hosts the hotspot and is also on the Pi's AP
2026-08-12 22:32:07 -04:00
Mathieu Moran 26597888ba fix: use public DNS on phone-hotspot (carrier resolver fails .dev tunnel)
- yaris_uplink configure() keyfile: dns=8.8.8.8;1.1.1.1 + ignore-auto-dns=true
  so the Pi doesn't rely on the phone's DHCP DNS, which failed to resolve
  yaris.ceaf.dev (NameResolutionError) and broke uploads on the hotspot
- test asserts the DNS lines are written to the keyfile
2026-08-12 21:14:46 -04:00
Mathieu Moran 81c7ee25fd test: align suite with shipped behavior (WiFi swap, BUG-10 guard, odometer_baseline)
- test_app_trip + test_trip_sql: mock was_car_ever_connected() so trip
  building runs (BUG-10 guard suppresses trips until car ECU reached)
- test_app_settings: include odometer_baseline in valid payload + assert save
- test_yaris_uplink: Hotspot -> Hotspot-local; restore_fallback no longer
  activates AP (reports no_connection); wait timeout writes fallback_restored
- test_app_network: local_hotspot mode removed by WiFi swap -> wifi/no-internet
2026-08-12 19:14:07 -04:00
Mathieu Moran 2e8e42a165 android: byte-level upload progress + debug logging; document 28.3
- UploadWorker: ProgressRequestBody (okio ForwardingSink) tracks real bytes
  sent including already-received chunks, instead of chunk count
- Robust chunk reads (loop skip/read) + Log.d/Log.e debug logging
- DashcamViewModel: upload state/progress debug logging
- android/plan.md + PLAN.md: 28.3 marked in progress with built items
2026-08-12 19:00:00 -04:00
Mathieu Moran 6398baaa6b feat: in-app test file generator for upload testing
adb-pushed files are invisible to the app due to scoped storage, so add a
'Create 120 MB test file' button in the Downloads tab that writes the file
through the app's own uid (visible in its contribution set).
2026-08-12 18:23:37 -04:00
Mathieu Moran 6f112ab622 fix: upload progress stuck at 0%
setProgressAsync (fire-and-forget) was dropping/coalescing updates before
they were persisted. Switch to awaited suspend setProgress() in both
UploadWorker and DownloadWorker so each progress write lands and the
LiveData observer actually sees pct advance.
2026-08-12 18:15:42 -04:00
Mathieu Moran 30b919bc03 feat: block re-upload of files already on server + visible upload progress
- Hide upload button when file is in uploadedNames (already on server)
- UploadWorker still guards via /status already_uploaded check
- Indeterminate progress bar during Hashing/Checking phases
- Upload label always shows percentage
2026-08-12 18:08:26 -04:00
Mathieu Moran 2012370cef ui: stack odometer under speed in replay timeline + GitHub footer link (Pi + server) 2026-08-12 17:24:34 -04:00
Mathieu Moran e8746e5d69 docs: FEAT-03 extended — simulated odometer in trip replay + trip list 2026-08-12 17:12:45 -04:00
Mathieu Moran 0d5aea5947 feat: simulated odometer in trip replay + trip list (continuous across trips)
- Pi route_points: emit per-point odometer (raw OBD value or baseline+prior+within-trip distance)
- Pi get_trip_route: SELECT ODOMETER, compute prior-trips distance + baseline
- Pi list_trips: fallback now starts from baseline (was 0)
- Server get_trip_route: per-point odometer (raw or simulated) with haversine accumulation
- Server list_trips: odo_start/end from baseline + cumulative (was hardcoded None)
- history.js: Odometer line in replay scrubber below speed
- Both history templates: trip-replay-odometer element
2026-08-12 16:59:00 -04:00
Mathieu Moran cfd9d03e81 ui: rework Pi settings page — grouped sections, icons, clearer labels
- Four logical sections: Car & identity, Connectivity, Server & uploads, Alerts
- FontAwesome icons on every card header for visual scanning
- Reordered cards (identity → in-car AP → phone hotspot → server → uploads → alerts)
- settings-section + settings-card styles added via block styles
- Improved helper text (device name, odometer, hotspot password)
- All element IDs preserved — JS behavior unchanged
2026-08-12 16:43:04 -04:00
Mathieu Moran 2cfbe67a86 ui: move Test connection + Sync now buttons to header, left of Save settings
- Header now has Test connection, Sync now, Save settings in a row
- remote-config-message alert moved below header for inline feedback
- Removed buttons from Remote server credentials card
2026-08-12 16:35:09 -04:00
Mathieu Moran 14981c066b docs: WiFi role swap + hotspot settings feature (FEAT-11)
- PLAN.md: Hardware section + Architecture #2/#5 updated for wlan1=AP(5GHz), wlan0=uplink
- FEATURES.md: FEAT-11 SHIPPED (hotspot SSID/password UI + 5GHz role swap)
- readme.md: hardware table + hotspot feature line updated
- deploy/readme.md: profile names/bands updated (home-wifi on wlan0, Hotspot-local 5GHz on wlan1)
2026-08-12 16:25:16 -04:00
Mathieu Moran e222dbdba7 net: AP on wlan1 uses 5GHz band for faster in-car access 2026-08-12 16:18:15 -04:00
Mathieu Moran b4b3643201 net: app_network.py reflects WiFi swap — uplink on wlan0, AP on wlan1 2026-08-12 16:17:21 -04:00
Mathieu Moran 4827b73e68 net: swap WiFi roles — fast T3U (wlan1) hosts AP, onboard (wlan0) carries uplink
- yaris_uplink.py: PRIMARY_INTERFACE=wlan0, AP_INTERFACE=wlan1, AP_CONNECTION=Hotspot-local
- install_netwatch.sh: WLAN=wlan0, WLAN_FB=wlan1, AP on wlan1
- _ap_client_count() reads the AP interface instead of hardcoded wlan0
2026-08-12 15:55:23 -04:00
Mathieu Moran 03358587ca fix: upload progress no longer stuck at 0%
UploadWorker now promotes to foreground + reports phase immediately:
- 'Hashing…' while computing SHA256
- 'Checking server…' during resume status check
- 'Uploading…' with per-chunk percentage
Downloads tab renders the phase label for uploads
2026-08-12 15:44:53 -04:00
Mathieu Moran 5b3c04b83d feat: server-presence indicator on Downloads tab
- CredentialStore tracks uploaded filenames in prefs
- UploadWorker success marks file as uploaded
- Downloads tab shows green CloudDone icon next to files already on server
- Upload button skipped for files already on server (dedup)
2026-08-12 15:42:51 -04:00
Mathieu Moran 0c81f1835d fix: FGS type for upload worker + cloud icon for server status
- DownloadWorker.createForegroundNotification now passes FOREGROUND_SERVICE_TYPE_DATA_SYNC
  (was defaulting to 'none' → InvalidForegroundServiceTypeException crash on upload)
- Dashcam file rows show CloudDone (green, on server) / CloudQueue (grey, not uploaded)
2026-08-12 15:33:32 -04:00
Mathieu Moran 8101663dd0 fix: plain SharedPreferences for credentials (EncryptedSharedPreferences crashed on Pixel)
- CredentialStore now uses getSharedPreferences instead of EncryptedSharedPreferences
- Still sandboxed by Android app isolation (other apps can't read it)
2026-08-12 15:23:53 -04:00
Mathieu Moran f4b93006e3 docs: finalize UI branding section — sidebar, logo, mobile dark theme 2026-08-12 05:39:30 -04:00
Mathieu Moran db971ab0e3 style: red underline on mobile topbar logo 2026-08-12 05:37:26 -04:00
Mathieu Moran 4a919b55a1 style: dark mobile UI — bottom nav + topbar match sidebar
- Mobile bottom nav: white → dark (#1c1c1e) bg, white icons/text
- Mobile topbar: white → dark bg on small screens, white text/controls
- Mobile logo color: dark → white for dark bg readability
- Desktop topbar remains white (contrasts with dark sidebar)
2026-08-12 05:34:24 -04:00
Mathieu Moran 6ac750f53b fix: hotspot save now restarts AP immediately (down + 1s wait + up) 2026-08-12 05:28:28 -04:00
Mathieu Moran a7ea97259d fix: remove trailing comma in hotspot POST — was returning (Response, None) tuple 2026-08-12 05:24:02 -04:00
Mathieu Moran 336815b49c feat: Pi hotspot SSID/password configurable from Settings page
- Settings page: SSID + password fields in In-car access card
- GET /api/hotspot reads current SSID (password never returned)
- POST /api/hotspot updates via nmcli (Hotspot-local profile on wlan0)
- deploy/yaris-hotspot.sudoers for privileged nmcli access
- Password is write-only with placeholder hint
2026-08-12 05:15:55 -04:00
Mathieu Moran c0aed4a997 fix: dashcam auto-loads + compact card style for both sections
- LaunchedEffect auto-refreshes on tab open (no more manual refresh needed)
- Imported Videos card matches USB Dashcam compact style
- Removed bulky Refresh button from imported videos
2026-08-12 05:10:55 -04:00
Mathieu Moran bc1516257a feat: USB dashcam file browser on Dashcam tab
- PiApi extended: listDashcamFiles, deleteDashcamFile, importDashcamFiles
- DTO: DashcamFilesResponse, DashcamDevice, DashcamFile
- DashcamScreen: USB Dashcam card with status, file count, Import All + Refresh
- ViewModel: loadDashcamFiles, deleteDashcamFile, importDashcamFiles
2026-08-12 05:06:39 -04:00
Mathieu Moran ffa84073ea fix: Settings header height matches other tabs + 48dp red underline
- Settings Column padding removed from around header
- Cards use horizontal padding individually
- YarisHeader underline: full-width → 48dp
2026-08-12 04:43:38 -04:00
Mathieu Moran e9309f4cdf style: YARIS logo with red underline + #1c1c1e dark theme
- Shared YarisHeader composable: bold YARIS + red accent bar + subtitle
- Dark theme: #1c1c1e background, #2a2a2c surface (matching web UI sidebar)
- Red accent #e63946 matches web logo underline
- Applied to Dashcam, Downloads, Settings screens
2026-08-12 04:36:55 -04:00
Mathieu Moran b69ae3a229 docs: UI branding — dark sidebar + YARIS-OBD Racing Sans One logo 2026-08-12 04:33:46 -04:00
Mathieu Moran 2989f60f9c style: minimalist Material icon buttons (download, upload, delete)
- DashcamScreen: Download + Delete as IconButton with Icons.Filled
- DownloadsScreen: CloudUpload + Delete as IconButton
- No text labels, just clean Material icons
2026-08-12 04:27:51 -04:00
Mathieu Moran 6b47dafdb8 style: yaris-obd lowercase logo 2026-08-12 04:23:42 -04:00
Mathieu Moran bb4ac8c490 feat: Material icons in nav bar + yaris-obd header on all screens
- Tab icons: Icons.Filled.Videocam / FileDownload / Settings
- Branding header (yaris-obd + Phase 28 subtitle) on Dashcam, Downloads, Settings
2026-08-12 04:20:27 -04:00
Mathieu Moran eab91956c9 fix: ASCII-safe tab icons + yaris-obd branding on Settings
- Tab icons: Cam / DL / Set (Unicode emoji kept corrupting in the source file)
- Settings top: yaris-obd title + Phase 28 subtitle restored
2026-08-12 04:18:11 -04:00
Mathieu Moran d613252155 style: dark sidebar + Racing Sans One YARIS wordmark logo
- Sidebar bg: navy (#1a1a2e) → dark gray (#1c1c1e)
- Logo: replaced fa-car icon + text with styled YARIS wordmark
  using Racing Sans One (free Google Font, OFL licensed)
- Red accent underline (#e63946) on logo
- Applied to both server (server/templates/base.html) and
  Pi (templates/base.html) + mobile topbar
2026-08-12 04:15:51 -04:00
Mathieu Moran 67dc9b897e refactor: merge Test tab into Settings, 3 tabs total
- Settings tab now has credential fields + network test (Test Pi, Test Server)
- Removed standalone Test tab
- Bottom nav: Dashcam, Downloads, Settings
2026-08-12 04:04:21 -04:00
Mathieu Moran 19942dc114 feat: Phase 28.3 — cellular upload relay + Settings tab
- UploadWorker: 40MB chunked upload to yaris.ceaf.dev (SHA256 check,
  /status resume, WorkManager foreground notification, progress)
- ServerApi extended: uploadStatus + uploadChunk endpoints
- CredentialStore: EncryptedSharedPreferences for server URL/device name/API key
- Settings tab: credential entry (server URL, device name, API key)
- Downloads tab: ↑ Upload button per file, upload progress with cancel
- Upload only shown when credentials configured
2026-08-12 04:00:45 -04:00
Mathieu Moran c8e26dde26 feat: Downloads tab UI polish — deleting spinner, progress redesign
- Deleting files show CircularProgressIndicator instead of delete button
- Active download card redesigned: spinner + filename + %, rounded progress bar
- AnimatedVisibility for progress section
- Empty state with icon
- Track _deletingNames in ViewModel for reactive UI
2026-08-12 03:46:33 -04:00
Mathieu Moran b523d77e16 feat: cancel button on active downloads
- ActiveDownload now carries workerId (UUID string) for cancellation
- cancelDownload() calls WorkManager.cancelWorkById()
- Downloads tab shows ✕ button on in-progress download cards
2026-08-12 03:35:03 -04:00
Mathieu Moran 78cd05c4cb docs: add BUG-14 (Nominatim slowness), update PLAN.md Phase 26 (14 bugs)
- BUG-14: Nominatim replaced with offline reverse_geocoder — instant, no network
- PLAN.md Phase 26: 13 → 14 bugs resolved, updated result summary
2026-08-12 03:31:50 -04:00
Mathieu Moran 0ddfe39901 refactor: ditch Nominatim, use reverse_geocoder exclusively for geocoding
Nominatim was slow (rate-limited, 1 req/sec, ~4s timeouts) and unreliable
from Docker/Cloudflare (blocked or throttled). It was the root cause of
location labels failing to appear during trip scrubbing.

- server/app.py: _geocode_cached now uses reverse_geocoder exclusively
  (offline, instant, city/province level). Removed Nominatim HTTP calls,
  ThreadPoolExecutor, concurrent.futures import.
- app.py (Pi): same — reverse_geocoder imported at module level,
  Nominatim + urllib.request removed.
- Batch endpoint simplified to sequential loop with lru_cache + dedup.
  No network dependency at all — geocoding is instant and always works.

reverse_geocoder provides city/province labels (e.g. 'Montreal, Quebec')
which is the right granularity for trip scrubbing.
2026-08-12 03:27:00 -04:00
Mathieu Moran e60b7d7d36 perf: geocode pipeline — batch POST + parallel server, 200→1 roundtrip
JS preload(): replaced N individual GET /api/geocode (concurrency 2) with
a single POST /api/geocode/batch — one HTTP roundtrip instead of up to
N/2 sequential ones. schedLoc debounce reduced 600→200ms.

Server geocode_batch(): ThreadPoolExecutor(max_workers=4) parallelises
Nominatim/reverse_geocoder calls instead of sequential loop. Deduplicates
coords before submitting futures. lru_cache absorbs duplicates within batch.

Pi geocode_batch_route(): deduplicate coords, removed stale 'if label:'
guard (label always non-empty after BUG-13 fix).
2026-08-12 03:15:30 -04:00
Mathieu Moran ad10a873b4 docs: close BUG-13, update PLAN.md Phase 26 (13 bugs resolved)
- BUG-13 moved from Open to Resolved with fix summary
- PLAN.md Phase 26: 12 → 13 bugs, added BUG-13 entry
- deploy memory: Unraid SSH deploy key + updated rebuild command
2026-08-12 03:08:57 -04:00
Mathieu Moran 830981934f fix: BUG-13 — server geocode returns raw coords instead of location names
Root cause: server _geocode_cached() returned None → /api/geocode
returned HTTP 204 with no body. JS frontend treated 204 as success,
r.json() on empty body threw → showed raw-coordinate fallback.

Server fixes:
- _geocode_cached() now always returns a non-empty string (coordinate
  fallback when both Nominatim and reverse_geocoder fail)
- /api/geocode always returns HTTP 200 with {label: '...'} (never 204)
- /api/geocode/batch always includes every coord key in result dict
- Nominatim call: jsonv2 + accept-language=en (aligned with Pi)
- reverse_geocoder: mode=1 (aligned with Pi)

Pi alignment:
- _geocode_cached() now checks for empty reverse_geocoder label before
  returning, falling through to coordinate string (same guard as server)
2026-08-12 02:53:44 -04:00
Mathieu Moran 81a0bfebd5 docs: add BUG-13 — server web UI shows raw coords instead of geocoded location
- Trips 18517, 18516 show (x,y) instead of address/street name
- Likely regression in geocoding pipeline or cache lookup
- Plan: rework server-side geocoding, backport to Pi if simple
2026-08-12 02:49:04 -04:00
Mathieu Moran b670fc5480 folder: delete github agent temp folder 2026-08-12 02:35:44 -04:00
Mathieu Moran 555af3694e docs: reformat FEATURES.md — summary table with dates, numerical order
- All 10 FEATs now in strict numerical order under single Feature ideas section
- Added summary table (ID, Status, Created, Title, Phase) for quick scanning
- Created dates sourced from git history (ef3a951 through bc29a01)
- Removed orphan Ideas that landed section (FEAT-06/07 merged into main flow)
2026-08-12 02:34:36 -04:00
Mathieu Moran 7a6c1750c9 docs: update Phase 28.1/28.2 status in PLAN.md + android/plan.md
- PLAN.md: Phase 28 header now shows 28.1 ✅, 28.2 ✅, 28.3–28.7 pending
- PLAN.md: added 28.1 gate-pass + 28.2 dashcam browser/download summaries
- android/plan.md: 28.2 rewritten from NOT STARTED to DONE with real
  implementation details (PiApi, DashcamScreen, DashcamViewModel,
  WorkManager DownloadWorker, Coil thumbnails, bottom nav)
2026-08-12 02:29:22 -04:00
Mathieu Moran e19511fc67 fix: downloads use WorkManager foreground service + inline progress
- DownloadWorker: foreground service with notification + setProgressAsync
- ActiveDownload state tracked via WorkManager LiveData observation
- Downloads tab shows progress bar + % for in-flight downloads
- Survives screen lock / background
- Fixed FGS type for Android 14+ (FOREGROUND_SERVICE_TYPE_DATA_SYNC)
2026-08-12 02:27:33 -04:00
Mathieu Moran bc29a01337 docs: FEAT-05 DTC sync shipped — multi-device selector, portal DTC page, Pi snapshot upload 2026-08-12 02:20:48 -04:00
Mathieu Moran ed2b3aacfc feat: downloads tab + public Movies/yaris-obd/ folder
- Downloads now go to Movies/yaris-obd/ (visible in file managers)
- New Downloads tab: list downloaded files, tap to play (system player),
  delete button
- FileProvider added for video playback intent
- Downloaded files auto-refresh after each download
2026-08-12 01:56:18 -04:00
Mathieu Moran e30b85d218 feat: portal-wide device selector — session-based, all pages now use selected device
- _portal_device_id() reads from session, falls back to DEVICE_NAME env var
- GET/POST /api/v1/portal/select-device for selector data + set
- Dropdown in server base.html topbar, JS populates from API, page reloads on change
- All portal pages (overview, trips, dashcam, DTC, device settings) now use selected device
2026-08-12 01:40:51 -04:00
Mathieu Moran 1556c65131 fix: delete uses @HTTP hasBody + JsonObject return type
- Retrofit @DELETE doesn't support @Body — switched to @HTTP(method=DELETE, hasBody=true)
- Return type changed from Map<String,Any?> to JsonObject (kotlinx-serialization
  can't deserialize untyped maps)
- Fixed double-slash in download URL (baseUrl trailing slash)
- Cleaned up download progress tracking (emit outside withContext(IO))
2026-08-12 01:35:11 -04:00
Mathieu Moran 3916f9879e feat: FEAT-05 — DTC history sync to remote server
- Server: dtc_events table (migrate_phase27_dtc.sql), db.py helpers
  (upsert_dtc_snapshot, list_dtc_events, delete_dtc_event)
- Server API: POST /api/v1/dtc/batch (device auth), GET/DELETE /api/v1/portal/dtc
- Server portal: /dtc page with code, description, MIL, status, timestamps, delete
- Server nav: DTC history in sidebar + mobile bottom nav
- Pi: _upload_dtc_snapshot() in app_upload.py — uploads when snapshot changes,
  skips when OBD disconnected or unchanged
2026-08-12 01:23:23 -04:00
Mathieu Moran 13cb8b4361 docs: Phase 27 DONE (3 shipped) — odometer sync, trip ID, hotspot docs; FEAT-08 shipped, FEAT-09 wontfix 2026-08-12 01:16:53 -04:00
Mathieu Moran 22bbee3b30 feat: Phase 28.2 — dashcam video browser + WiFi download
- DashcamScreen: LazyColumn of imported Pi videos with thumbnails (Coil),
  file size, date, upload status, converting progress
- DashcamViewModel: Retrofit-backed video list + download to phone storage
  + delete local copy
- PiApi extended: listVideos, getVideoFile (streaming), getThumbnail, deleteVideo
- DTOs: VideoListResponse, VideoFile, ConvertProgress
- Bottom nav: Dashcam tab + Test tab
- Verified: video list loads from real Pi over yaris 911 hotspot
2026-08-12 01:14:20 -04:00
Mathieu Moran 348bfdc48c feat: Phase 28.1 gate passed — dual-network test works
- Simplified to single OkHttpClient — Android OS routes correctly:
  10.42.0.1 → WiFi (Pi hotspot subnet)
  yaris.ceaf.dev → cellular (WiFi has no internet)
- Removed NetworkBinder-based per-socket binding (EPERM on Pixel,
  OS-routed approach works better for this use case)
- Verified on real Pixel: Pi GPS + server health both return 200
  simultaneously while connected to "yaris 911" hotspot
2026-08-12 01:09:13 -04:00
Mathieu Moran f1606ad1f6 fix: remove runBlocking(Dispatchers.Main) deadlock — app now starts
- NetworkBinder functions were suspend but never actually suspended
  (ConnectivityManager calls are synchronous) — made them plain fun
- MainActivity.discoverNetworks() now calls them directly, no coroutine
- Wrapped in try/catch so network discovery failure doesn't crash Activity
- Removed unused awaitNetwork() extension + coroutine imports
2026-08-12 00:54:27 -04:00
Mathieu Moran 40f92d8703 feat: Phase 28.1 — gradle wrapper + debug APK builds successfully
- gradlew + gradle-wrapper.jar (Gradle 8.11.1)
- Fixed manifest icon to @android:mipmap/sym_def_app_icon (no custom asset needed yet)
- Added adaptive icon XML + colors for future custom icon
- Removed deprecated buildfeatures.buildconfig from gradle.properties
- 17MB debug APK builds: ./gradlew assembleDebug
- local.properties auto-ignored (contains SDK path)
2026-08-12 00:48:05 -04:00
Mathieu Moran 8d64b1e8b0 feat: Phase 28.1.2 — dual-network binding + OkHttp clients
- NetworkBinder: discovers Pi hotspot WiFi (no-internet) and cellular networks
  via ConnectivityManager, creates per-network socket-bound OkHttpClients
- DualNetworkTestScreen: real OkHttp calls to 10.42.0.1/api/gps/status and
  yaris.ceaf.dev/health, simultaneous "Test Both" gate button
- MainActivity: discovers networks at startup, passes bound clients to screen
- Retrofit interfaces (PiApi, ServerApi) + DTOs (GpsStatus, HealthResponse)
- Uncommented OkHttp/Retrofit/kotlinx-serialization deps in build.gradle.kts
2026-08-12 00:34:41 -04:00
Mathieu Moran f1c83a2ee7 init: android project scaffold (Phase 28.1)
- Kotlin DSL build with version catalog (AGP 8.7, Kotlin 2.1, Compose BOM 2025.01)
- app module: Compose + Material 3, dark theme, network security config
- DualNetworkTestScreen — Phase 28.1 gate screen (buttons wired in 28.1.2)
- YarisApp, MainActivity, YarisOBDTheme
- ProGuard rules for Retrofit/OkHttp/Kotlinx Serialization/Coil/MapLibre/Room
- cleartext allowed for 10.42.0.1 + yaris.local
- gradle wrapper properties (needs Android Studio to generate wrapper jar)
2026-08-12 00:26:54 -04:00
Mathieu Moran 69109762a8 fix: always send device config on save — sync toggle now works both ways 2026-08-12 00:16:57 -04:00
Mathieu Moran 79222c8bb5 fix: disabling sync via settings page no longer returns spurious error 400 2026-08-12 00:02:54 -04:00
Mathieu Moran 0fea9718bc fix: prevent sync from overwriting Pi odometer — odometer is Pi→Server only 2026-08-11 23:53:42 -04:00
Mathieu Moran 0b2162a595 style: odometer field uses plaintext display — no stepper arrows, grey background 2026-08-11 23:44:05 -04:00
Mathieu Moran d35cea367f fix: odometer sync is Pi→Server only — portal displays read-only value from DB 2026-08-11 22:14:46 -04:00
Mathieu Moran 7de8948e08 docs: update Hardware and Architecture sections — dual-radio, GPIO power, 4G modem, phone hotspot uplink 2026-08-11 22:06:05 -04:00
Mathieu Moran 44cd8bd192 feat: odometer_baseline field on server portal device settings page 2026-08-11 19:52:51 -04:00
Mathieu Moran 32de77131c feat: odometer baseline syncs to server — push on save, pull on sync, +/api/device-config/push endpoint 2026-08-11 19:31:52 -04:00
Mathieu Moran 878ba4a958 fix: skip device config save when sync is enabled — prevents 'Invalid device config' blocking settings save 2026-08-11 18:57:40 -04:00
Mathieu Moran 34253b3ffb feat: manual odometer baseline — Settings field computes baseline + trip distance, Dashboard shows (manual) label 2026-08-11 18:51:11 -04:00
Mathieu Moran 887a001723 docs: Phase 27 started — 7 feature candidates ready 2026-08-11 18:40:35 -04:00
Mathieu Moran 19c538389b docs: Phase 26 rewritten — all 12 bugs documented consistently. Phase 27/28 cleaned up. 2026-08-11 18:29:03 -04:00
Mathieu Moran 9ebfea8be3 docs: readme updated, BUGS.md cleaned — all 12 bugs in Resolved, Open section empty 2026-08-11 18:25:07 -04:00
Mathieu Moran f017a301f3 docs: Phase 26 complete, Phase 27 ready — 7 feature candidates 2026-08-11 18:20:07 -04:00
Mathieu Moran 341437c2d4 docs: Phase 26 COMPLETE — BUG-02 closed, all 12 bugs resolved. GPS has 7 sats, needs clear sky for fix. 2026-08-11 18:17:31 -04:00
Mathieu Moran 0f6d9b20a2 docs: Phase 26 DONE — BUG-01 closed, all 11 bugs resolved. BUG-06/12 moved to Resolved. 2026-08-11 17:19:42 -04:00
Mathieu Moran e54391b5c9 docs: FEAT-06/07/08/09 — trip ID column, hotspot auto-connect, predictable IP, mDNS 2026-08-11 17:17:12 -04:00
Mathieu Moran 4704bb79f1 docs: BUG-01 test steps documented, FEAT-07 phone-hotspot auto-connect shipped 2026-08-11 17:11:34 -04:00
Mathieu Moran b4d622827a docs: revert BUG-01 — AP tested but phone-hotspot uplink not yet verified 2026-08-11 17:06:39 -04:00
Mathieu Moran ea47cff489 docs: BUG-01 closed — tested on iOS + Android, yaris.local resolves, Phase 26 DONE 2026-08-11 17:04:20 -04:00
Mathieu Moran 675ee67260 docs: revert BUG-01 to IN PROGRESS — needs owner test before closing 2026-08-11 16:47:58 -04:00
Mathieu Moran dc1c1e5f06 docs: BUG-01 closed — dual-radio bench-verified, Phase 26 DONE (11 closed, 1 hardware) 2026-08-11 16:46:57 -04:00
Mathieu Moran 0f76ae3f84 docs: Phase 26 updated — 10 closed, BUG-11/12 and GPS lat/lon fix documented 2026-08-11 16:43:06 -04:00
Mathieu Moran a4a92cc69b fix: Socket gps_location now passes lat/lon to renderGpsStatus, preventing overwrite with blanks 2026-08-11 16:37:01 -04:00
Mathieu Moran e8156bc39a docs: BUG-12 closed — OBD API fallback fixes dashboard blank state, GPS toFixed crash fixed 2026-08-11 16:32:15 -04:00
Mathieu Moran 91281b1e55 fix: add /api/obd/status endpoint, dashboard OBD fallback on page load, fix GPS string-to-number crash 2026-08-11 16:26:30 -04:00
Mathieu Moran e223a43170 docs: BUG-11 closed — GPS lat/lon from API, 30s staleness aligned across pages 2026-08-11 16:18:59 -04:00
Mathieu Moran 3202045654 fix: restore separate checkbox+ID cells, add matching headers (8 columns server) 2026-08-11 15:25:29 -04:00
Mathieu Moran e8c5bfec9a fix: put checkbox inside date cell — both Pi and server now share same 6-column layout 2026-08-11 15:22:05 -04:00
Mathieu Moran 22f4ae2e69 fix: merge ID into date cell, add empty checkbox header — columns now align 2026-08-11 15:17:51 -04:00
Mathieu Moran 5713417f4f Server: restore checkbox + batch-delete. Conditional ID (server only) and checkbox via data-show-id/data-batch-delete-url. 2026-08-11 15:11:12 -04:00
Mathieu Moran 2ee4862edd Remove checkbox column and batch-delete from trip history 2026-08-11 15:05:40 -04:00
Mathieu Moran 23ac6c218b feat: add trip ID column (#18517) to history table on Pi and server 2026-08-11 14:58:25 -04:00
Mathieu Moran f6a50b6bc1 docs: BUG-06 closed — migration shifted 3593 rows +4h EDT, recomputed 43 trips, times now correct 2026-08-11 14:53:18 -04:00
Mathieu Moran 7ce2a1e5f5 fix: BUG-06 — convert naive Pi timestamps to TZ-aware before upload so Postgres TIMESTAMPTZ stores correct instant 2026-08-11 14:47:37 -04:00
Mathieu Moran 9733036510 fix: BUG-11 — Dashboard GPS now shows lat/lon from API when stale, Status page uses same 30s staleness threshold 2026-08-11 14:36:58 -04:00
Mathieu Moran 7a6de8b589 docs: BUG-07 closed — history.js rewrite verified on Pi and server 2026-08-11 14:33:38 -04:00
Mathieu Moran fa93384a8e Rewrite history.js: loading spinner, active-row highlight, unified state, 300ms debounce, signal guards throughout trip loader 2026-08-11 05:37:15 -04:00
Mathieu Moran 3d6955f20f fix(phase26): fix broken click handler, remove replay button, row click only 2026-08-11 05:11:09 -04:00
Mathieu Moran 90edc73735 fix(phase26): debounce trip clicks, guard against stale renders 2026-08-11 05:07:35 -04:00
Mathieu Moran cdb3fc19c7 fix(phase26): abort preload fetches when switching trips to prevent server freeze 2026-08-11 05:01:18 -04:00
Mathieu Moran ad56380e23 fix(phase26): revert batch geocode, use rate-limited concurrent requests to avoid server freeze 2026-08-11 04:57:44 -04:00
Mathieu Moran 5018c6c531 fix(phase26): batch geocode endpoint for faster server trip scrubber 2026-08-11 04:44:37 -04:00
Mathieu Moran bdc7a9afb7 fix(phase26): server trip delete persists across recompute + batch delete with checkboxes 2026-08-11 04:18:58 -04:00
Mathieu Moran e6bc488e84 docs: update Phase 26 with dual-radio + trip fix details 2026-08-11 03:57:06 -04:00
Mathieu Moran cf096744d1 fix(phase26): BUG-10 side effect — preserve trips when OBD never connected 2026-08-11 03:56:00 -04:00
Mathieu Moran ae7f6358b2 docs: update BUG-01 dual-radio fix status 2026-08-11 03:46:18 -04:00
Mathieu Moran 592e049f59 fix(phase26): dual-radio — wlan0 permanent AP, wlan1 internet uplink (BUG-01) 2026-08-11 03:44:01 -04:00
Mathieu Moran 89bbc4fdb8 fix(phase26): skip netwatch AP scan when clients connected (BUG-01) 2026-08-11 03:11:13 -04:00
Mathieu Moran f9d967e71c docs: update Phase 26 bug count in PLAN.md 2026-08-11 03:02:02 -04:00
Mathieu Moran e83e5eaa7c docs: tighten features list in readme 2026-08-11 03:00:35 -04:00
Mathieu Moran 6355869a8d docs: fix bug count in readme project status 2026-08-11 02:58:01 -04:00
Mathieu Moran f7d305a036 docs: update Phase 26, simplify readme project status, de-emoji 2026-08-11 02:57:01 -04:00
Mathieu Moran 1487fd3478 docs: bold feature names, clean up readme 2026-08-11 02:55:27 -04:00
Mathieu Moran e733ab1ac1 docs: update Phase 26 progress, de-emoji readme, refresh status 2026-08-11 02:50:57 -04:00
Mathieu Moran 936064e963 docs: move BUG-09/10 from Open to Resolved 2026-08-11 02:47:26 -04:00
Mathieu Moran 7beb882136 docs: restructure BUGS.md, close BUG-09/10 (already deployed) 2026-08-11 02:45:40 -04:00
Mathieu Moran 5a3be5347a docs: close BUG-09/10, update resolved section 2026-08-11 02:41:12 -04:00
Mathieu Moran 7e913558f6 fix(phase26): geocode pre-caching (BUG-09) + gate trips on OBD presence (BUG-10) 2026-08-11 02:38:00 -04:00
Mathieu Moran a709517973 docs: close BUG-04 server trip merge (verified in prod) 2026-08-11 02:31:13 -04:00
Mathieu Moran 96fb8971eb fix(phase26): server trip detection mirrors Pi motion-aware logic (BUG-04) 2026-08-11 02:27:17 -04:00
Mathieu Moran 447d73bbe5 fix(phase26): close BUG-08 Pi trip deletion (verified in prod) 2026-08-11 02:12:40 -04:00
Mathieu Moran c5c478bef6 fix(phase26): add Pi trip deletion (BUG-08) 2026-08-11 02:07:59 -04:00
Mathieu Moran 786d5b8f3e docs: close BUG-03/05, add Phase 27 features + renumber Android to Phase 28 2026-08-11 02:04:55 -04:00
Mathieu Moran aa1f247d80 docs(bugs): record Phase 26 deploy/verify status for BUG-03 and BUG-05
Amend the Phase 26 progress note: BUG-03 was deployed to the Pi (pulled to
4f975ad, service active, /api/health ok) and real in-car behavior verification
is still pending; BUG-05 is code-ready but the unraid/server side is not yet
deployed (needs docker compose build api && docker compose up -d api).
2026-08-11 01:23:39 -04:00
Mathieu Moran 4f975ad079 docs(bugs): mark BUG-03 and BUG-05 FIXED (e2f2314) + update PLAN Phase 26
BUGS.md: BUG-03 and BUG-05 status -> FIXED with commit ref and fix summary.
PLAN.md: Phase 26 items 3 and 5 marked FIXED; add progress note.
2026-08-11 00:48:05 -04:00
Mathieu Moran e2f2314318 fix(phase26): persist DTC/MIL state + server speed_source (BUG-03, BUG-05)
BUG-03: DTC/MIL codes were never persisted; lost on OBD disconnect or reboot.
- Add new dtc table schema (code, description, MIL, first/last seen, IS_SET)
  and a migration that rebuilds the original unused NAME/STATUS/DTC_ID table.
- Persist MIL + codes to SQLite on each successful OBD poll (_persist_dtc_state).
- /api/dtc falls back to the last-persisted snapshot when OBD is offline/stale.
- Fix get_overview_stats to count the real dtc table (was querying non-existent
  dtc_codes table).
- Add cleared/since tracking via IS_SET + FIRST_SEEN_AT/LAST_SEEN_AT.

BUG-05: server trip replay hardcoded speed_source:'gps'; mirror Pi's
'obd if speed>0 else gps' logic in server get_trip_route.

Adds tests/test_dtc_persistence.py (7 tests).
2026-08-11 00:38:05 -04:00
Mathieu Moran cd0583ac84 docs: add FEAT-04 4G LTE modem (Huawei E3372h-153) to replace SIM868 2G hat 2026-08-11 00:03:15 -04:00
Mathieu Moran bd43003a7e docs: rename features.md to FEATURES.md (all caps) 2026-08-10 23:46:43 -04:00
Mathieu Moran ef3a9510ab docs: add features.md tracker with odometer-fallback idea 2026-08-10 23:45:52 -04:00
Mathieu Moran 3e626830d5 docs: sign off Phase 25 road test, add BUGS.md, renumber Android to Phase 27 2026-08-10 23:42:39 -04:00
Mathieu MoranandCopilot d216aac97e Refresh docs and reorder roadmap
Co-authored-by: Copilot <[email protected]>
2026-08-10 18:48:59 -04:00
Mathieu MoranandCopilot 457e3dcc08 Record dashboard card order swap
Co-authored-by: Copilot <[email protected]>
2026-08-10 18:43:46 -04:00
Mathieu MoranandCopilot b815e0aaaa Swap dashboard map and overview
Co-authored-by: Copilot <[email protected]>
2026-08-10 18:43:01 -04:00
Mathieu MoranandCopilot 6ee6215662 Record status action verification
Co-authored-by: Copilot <[email protected]>
2026-08-10 18:41:34 -04:00
Mathieu MoranandCopilot 8907c51016 Add status page quick actions
Co-authored-by: Copilot <[email protected]>
2026-08-10 18:40:22 -04:00
Mathieu MoranandCopilot ef38ec92b4 Record status page verification
Co-authored-by: Copilot <[email protected]>
2026-08-10 18:38:16 -04:00
Mathieu MoranandCopilot 5406935bf5 Add Pi status page
Co-authored-by: Copilot <[email protected]>
2026-08-10 18:37:26 -04:00
Mathieu MoranandCopilot a7302229d1 Tighten Pi dashboard layout
Co-authored-by: Copilot <[email protected]>
2026-08-10 18:25:30 -04:00
Mathieu MoranandCopilot f6de658a88 docs: record live Pi GPS status check
- note that GPS thread is alive on the Pi but has no current fix
- clarify that missing live updates are currently due to absent GNSS fix data

Co-authored-by: Copilot <[email protected]>
2026-08-10 18:18:50 -04:00
Mathieu MoranandCopilot be40bbc15f feat: add GPS status endpoint and dashboard indicator
- add /api/gps/status for thread/fix-age visibility
- surface GPS status in Pi health and live GPS cards
- add tests for app_gps status snapshots

Co-authored-by: Copilot <[email protected]>
2026-08-10 18:18:14 -04:00
Mathieu MoranandCopilot 675bffb4a4 feat: add server-side device deletion
- add portal API and UI action to delete non-default devices
- protect the configured portal default device from deletion
- document that trip/history pages still scope to the single DEVICE_NAME

Co-authored-by: Copilot <[email protected]>
2026-08-10 18:09:15 -04:00
Mathieu MoranandCopilot 690cf0a5d4 docs: record live Phase 25.5c deployment validation
- mark Phase 25.5c complete in PLAN
- record unraid migration/deploy and Pi API smoke tests
- note sync-ack metadata and temp provisioning helper validation

Co-authored-by: Copilot <[email protected]>
2026-08-10 18:05:39 -04:00
Mathieu MoranandCopilot ba682fbf3a feat: add multi-device provisioning and local remote-config UI
- add multi-device portal device management, key rotation, and sync request APIs
- add Pi settings UI for remote server URL, device name, and API key
- add local test-connection and sync-now actions
- make upload and config sync reuse dynamic shared credentials
- add Phase 25.5c migration and docs updates

Co-authored-by: Copilot <[email protected]>
2026-08-10 18:02:09 -04:00
Mathieu MoranandCopilot fbc41a5db5 docs: record live Phase 25.5b sync verification
- update plan with live portal route deployment status
- document fixed Pi sync blockers and successful round-trip test
- refresh passing test count after runtime sync coverage additions

Co-authored-by: Copilot <[email protected]>
2026-08-10 17:48:42 -04:00
Mathieu MoranandCopilot a114939914 fix: authenticate device config sync like upload client
- send X-Device-Name and X-Api-Key to /api/device-config
- require YARIS_DEVICE_NAME alongside shared upload creds
- fix server create_device output to match actual Pi env names
- document shared upload/config sync credential requirements

Co-authored-by: Copilot <[email protected]>
2026-08-10 17:42:40 -04:00
Mathieu MoranandCopilot 5a5058adca fix: reuse upload credentials for device config sync
- load server sync creds from db/upload_creds.env like uploads do
- fall back from YARIS_DEVICE_KEY to existing YARIS_API_KEY
- document shared credential flow for server-synced identity
- add test coverage for sync credential fallback

Co-authored-by: Copilot <[email protected]>
2026-08-10 17:37:52 -04:00
Mathieu MoranandCopilot 585da18225 feat: add remote device settings portal and Pi hot reload
- add /settings/device portal page and portal device-config GET endpoint
- add portal navigation and client-side device settings editor UI
- hot-reload Account/Car display config after local saves and server syncs
- document portal workflow and Phase 25.5b completion
- add runtime reload tests for account, car, and sync callback

Co-authored-by: Copilot <[email protected]>
2026-08-10 17:34:22 -04:00
Mathieu MoranandCopilot c4ccc3efb9 docs: record verified Pi and unraid deployment state
- note verified Pi IP after WiFi card change
- record successful 2026-08-10 Pi/unraid rollout
- document unraid checkout divergence and temp-clone deploy workaround

Co-authored-by: Copilot <[email protected]>
2026-08-10 17:26:17 -04:00
Mathieu MoranandCopilot cd004fbdde fix: wire Phase 25.5a deploy docs and sync connectivity
- use app_network status for device sync connectivity checks
- mount migrate_phase25_5a.sql in server docker compose
- document manual server upgrade migration steps
- record verified Pi and unraid SSH access details in PLAN

Co-authored-by: Copilot <[email protected]>
2026-08-10 17:22:02 -04:00
Mathieu MoranandCopilot 0bda44a7bc Phase 25.5a: Server-side device config API and Pi sync thread
- Created app_device_sync.py: background sync thread with connectivity-driven scheduling (5min online, 60sec backoff when offline)
- Server /api/device-config endpoints: GET for Pi device auth, POST for admin user auth
- Database migration adds config_version, owner_name, car_model, device_name, avatar_path to devices table
- Pi syncs config from server on network availability; offline-first design (loads from local JSON at startup)
- Settings UI fields read-only when server sync enabled

All 63 tests passing. Ready for testing with actual server connection.

Co-authored-by: Copilot <[email protected]>
2026-08-10 17:17:04 -04:00
Mathieu MoranandCopilot 2395cf6dc4 feat: add Phase 25.5a server-side device config API endpoints
- Add database migration (migrate_phase25_5a.sql):
  * Add owner_name, car_model, device_name, avatar_path columns to devices table
  * Add config_version and config_updated_at for cache invalidation

- Add server/modules/device_config.py:
  * get_device_config(device_id): retrieve config for Pi sync
  * update_device_config(device_id, data): update config with validation

- Add server API endpoints:
  * GET /api/device-config (authenticated with device_key, Pi fetches config)
  * POST /api/device-config (authenticated as user, admin updates config)

- Enhance server/modules/db.py:
  * Add get_device_by_id() function to retrieve all device config fields

Phase 25.5a enables server-side device identity management and connection-driven sync.
Pi will fetch from this endpoint when internet available, keeping config in sync.

Co-authored-by: Copilot <[email protected]>
2026-08-10 16:46:52 -04:00
Mathieu MoranandCopilot ed78700005 docs: update Phase 25.5 to reflect settings UI implementation
- Settings UI now implemented for device config editing
- Sync toggle blocks field edits when enabled
- All behaviors tested and verified on Pi
- Phase 25.5a will add server-side config endpoints and connection-driven sync

Co-authored-by: Copilot <[email protected]>
2026-08-10 16:44:03 -04:00
Mathieu MoranandCopilot d7b9bb634f feat: add device config settings UI with optional server sync
- Add app_device_config.py module for managing device configuration (owner_name, car_model, avatar_path, device_name)
- Add sync_enabled flag to control whether Pi syncs config from server
- When sync is enabled, device config fields are read-only (server is source of truth)
- When sync is disabled, user can edit device config via Settings page
- Add /api/device-config endpoint (GET to retrieve, POST to update)
- Add Device identity section to settings.html with form fields for all config values
- Update settings.js to load/save device config with sync toggle
- Gracefully handles both config file (at startup) and database (live updates via settings UI)
- All 63 tests passing

Phase 25.5: Offline-first device config with eventual consistency. Config loads from JSON at startup (no network needed), can be edited via settings if not syncing, and will sync from server on next connectivity when Phase 25.5a server endpoints are implemented.

Co-authored-by: Copilot <[email protected]>
2026-08-10 16:41:45 -04:00
Mathieu MoranandCopilot c65a635906 implement: Phase 25.5 device config from JSON file
- Create config/device.json with owner_name, car_model, device_name, avatar_path
- Update app_account.py to load owner_name and avatar_path from config
- Update app_car.py to load car_model and device_name from config
- Update base.html template to use avatar_path from config instead of hardcoded
- Add graceful fallback to hardcoded defaults if config file missing
- Document in deploy/readme.md and readme.md how to customize device identity
- Update PLAN.md Phase 25.5 to reflect current implementation and future server sync

Benefits:
- No code edits needed to customize Pi (edit JSON file only)
- Offline-first: all values loaded from local file at startup
- Connection-driven sync planned for Phase 25.5a (sync when internet available, not on timer)
- Server becomes source of truth after Phase 25.5a implementation

All 63 tests passing. Database filename still based on owner_name, with migration warning in docs.

Co-authored-by: Copilot <[email protected]>
2026-08-10 16:31:59 -04:00
Mathieu MoranandCopilot 69beb07430 docs: add Phase 25.5 device config sync plan
Plan for replacing hardcoded user/car/avatar with managed server-synced configuration, enabling multi-device and zero-code-edit deployment scenarios.

Co-authored-by: Copilot <[email protected]>
2026-08-10 16:21:49 -04:00
Mathieu MoranandCopilot 235ca65822 docs: add profile and car customization guide
Co-authored-by: Copilot <[email protected]>
2026-08-10 16:21:05 -04:00
Mathieu MoranandCopilot 2c0e9f7e25 docs: add RTL8812BU driver install to deploy guide
Co-authored-by: Copilot <[email protected]>
2026-08-10 16:14:38 -04:00
Mathieu MoranandCopilot d2f9ea4209 Re-add redacted UI screenshot after history purge
Co-authored-by: Copilot <[email protected]>
2026-08-10 16:10:04 -04:00
Mathieu MoranandCopilot ae0bcc7ea8 Add fresh-Pi deploy guide and uplink consistency fixes
- add deploy/readme.md with full fresh install + quick bootstrap block
- update README with deploy guide link and hardware photo
- fix app_network interface detection for wlan1-first setup
- make dispatcher hook handle wlan0 and wlan1
- update PLAN.md with Phase 24.8 completion notes

Co-authored-by: Copilot <[email protected]>
2026-08-10 15:54:36 -04:00
Mathieu MoranandCopilot 0cd798ee19 fix: use total trip distance as odometer fallback when PID 0xA6 unavailable
If no real odometer data exists (all NULL — expected on 2013 Yaris which
likely doesn't support PID 0xA6), fall back to SUM(distance_km) from the
computed trips table so the overview shows something meaningful.

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:59:53 -04:00
Mathieu MoranandCopilot 8f8c03a26a fix: server odometer stat shows 0 when no odometer data exists
COALESCE(MAX(odometer), 0) returned 0 when all tracking rows have NULL
odometer (expected before the car is connected — PID 0xA6 likely unsupported
on 2013 Yaris). Now returns NULL which the overview JS renders as '—'.

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:58:22 -04:00
Mathieu MoranandCopilot a7ab088cf5 docs: update PLAN.md and readme for phases 24.5-24.7
- PLAN.md: add Phase 24.7 section (diagnose.sh, map tile fixes, app.py cleanup)
- readme.md: update project status to reflect phases 24-24.7 complete,
  add remote portal to features list, add diagnostics/recovery section

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:55:37 -04:00
Mathieu MoranandCopilot f90a5e5826 fix: switch server portal map tiles to CARTO (OSM was rate-limiting/blocking)
OSM tile.openstreetmap.org was returning grey tiles on the portal (likely
rate-limiting the Cloudflare tunnel egress IP). Switch to CARTO Voyager
tiles which are free, CDN-backed, and don't have the same restrictions.

Also add data-tile-subdomains attribute support in history.js so tile
provider subdomains can be configured per-deployment.

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:51:18 -04:00
Mathieu MoranandCopilot 5e0b30759d fix: always add tile layer at map init, fix zoom bounds
- tiles.addTo(map) now called at init instead of lazily on first trip click
  (fixes blank map on page load on the server portal)
- minZoom/maxZoom read from data-min-zoom/data-max-zoom attributes with
  correct NaN fallback (parseInt || default)
- Pi template: data-min-zoom=8 data-max-zoom=15 (matches cached tile range)
- Server template: data-min-zoom=3 data-max-zoom=19 (full OSM range)

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:46:45 -04:00
Mathieu MoranandCopilot c287ab67a3 fix: map zoom limits configurable per-deployment via data attributes
Pi offline tiles only cover zoom 8-15; OSM live tiles support 0-19.
history.js now reads data-min-zoom/data-max-zoom from the #history-map div
instead of hardcoding 3/19. Pi template caps to 8-15 (prevents blank tiles
at extreme zooms), server template allows full 3-19 OSM range.

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:42:04 -04:00
Mathieu MoranandCopilot 264d38a6dc cleanup: fix duplicate app_gps import, remove debug prints from index route
Co-authored-by: Copilot <[email protected]>
2026-08-10 05:38:49 -04:00
Mathieu MoranandCopilot d96a971f15 fix: diagnose.sh DB backup line shows literal $DB_PATH
Co-authored-by: Copilot <[email protected]>
2026-08-10 05:31:50 -04:00
Mathieu MoranandCopilot 437434b972 feat: add scripts/diagnose.sh — system diagnostic & recovery tool
Read-only health check (no args) or auto-fix mode (--fix):
  - Service: active state, crash loop detection, recent log
  - Hardware: OBD USB adapter (/dev/ttyUSBx), SIM868 UART (/dev/serial0),
    dashcam USB mount, permissions
  - Network: hotspot IP, web UI reachability, internet, upload server
  - Storage: disk usage, SQLite integrity check, row counts, upload backlog,
    log file size
  - OBD status: parsed from log (last connect/disconnect/speed/errors)
  - Python env: venv present, key packages importable
  - Pi health: throttling flags decoded, CPU temp, free memory
  - Manual recovery command cheatsheet printed at the end

Exit code = number of critical issues found (0 = healthy).

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:31:03 -04:00
Mathieu MoranandCopilot c03cd6d48d fix: add OBD_SKIP_VOLTAGE_CHECK support for clone ELM327 adapters
Counterfeit ELM327 chips (common in cheap PL2303 USB adapters) often report
bad/zero voltage even when the OBD connection is working. python-obd's
check_voltage=True default rejects these. Add OBD_SKIP_VOLTAGE_CHECK=1 env
var to pass check_voltage=False to obd.OBD(), documented in yaris-obd.service.

Also document Phase 24.6 Yaris 2013 compatibility analysis in PLAN.md.

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:26:40 -04:00
Mathieu MoranandCopilot 10dea22f9a fix: Yaris 2013 OBD compatibility hardening
- _decode_odometer: catch all exceptions and return None instead of raising.
  PID 0xA6 (odometer) is not guaranteed on 2013 Toyota Yaris — a garbage
  response or unsupported PID previously raised ValueError through python-obd's
  decoder, which app_obd._query() caught and treated as a lost connection,
  killing the entire OBD session. Now it gracefully returns None.

- connect_obd: reset all sensor globals (rpm, coolant_temp, fuel_level,
  throttle_position, mil_on, dtc_codes, odometer, timestamps) on each new
  connection so the UI doesn't display stale readings from the previous
  ignition cycle while fresh polls are completing.

- yaris-obd.service: add commented OBD_PROTOCOL=6 hint for post-confirmation
  pinning (eliminates 3-5s auto-probe delay after first confirmed connection).

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:22:36 -04:00
Mathieu MoranandCopilot a56e4252d3 docs: document Phase 24.5 code audit in PLAN.md
Co-authored-by: Copilot <[email protected]>
2026-08-10 05:17:32 -04:00
Mathieu MoranandCopilot 1b39ac0cc8 fix: pre-car-test bug fixes from full code audit
- app_alerts.py: null guard on list_local_videos() result (.get('files', []))
- app_cellular.py: remove erroneous .encode(errors='replace') on str response body
- app_settings.py: initialize apn='' when isinstance check fails (NameError guard)
- app_upload.py: move json import to top of file instead of inside except handler
- server/app.py: wrap int(request.args.get('limit')) in try/except ValueError
- server/app.py: wrap os.path.commonpath() in try/except ValueError (cross-root paths)
- tests/test_app_settings.py: add missing trips_upload_enabled/video_convert_enabled
  to valid_payload() (fields added in Phase 23.5 but test not updated)

All 63 tests pass.

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:15:27 -04:00
Mathieu MoranandCopilot f30143c7b7 docs: document Phase 24 bug fixes and UI polish in PLAN.md
Co-authored-by: Copilot <[email protected]>
2026-08-10 05:07:20 -04:00
Mathieu MoranandCopilot 2f1e35414d Fix sidebar arrow direction: use CSS rotation not JS icon swap
The CSS already handles the arrow direction with transform:rotate(180deg) on
.app-sidebar.collapsed .sidebar-collapse-btn. The JS icon className swap was
fighting with CSS rotation causing a double-flip. Remove JS icon manipulation,
let CSS handle it. Also suppress the button transition on page load.

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:04:57 -04:00
Mathieu MoranandCopilot 3c3c8bba71 Fix sidebar collapse flash on page navigation
Suppress CSS transitions during initial state restore from localStorage.
Re-enable after two rAF frames so the toggle button still animates smoothly.

Co-authored-by: Copilot <[email protected]>
2026-08-10 05:00:22 -04:00
Mathieu MoranandCopilot 21e44a8586 Fix server sidebar collapsed state: hide badge and user footer text
- sidebar-env-badge hidden when collapsed
- sidebar-user-text collapses like nav link spans
- User icon stays centered when collapsed via sidebar-user-footer flex

Co-authored-by: Copilot <[email protected]>
2026-08-10 04:56:44 -04:00
Mathieu MoranandCopilot b172b89318 Simplify delete confirmation to native window.confirm
Bootstrap Modal async was unreliable. Replace with synchronous
window.confirm() which always works and needs no dependencies.

Co-authored-by: Copilot <[email protected]>
2026-08-10 04:52:03 -04:00
Mathieu MoranandCopilot 72e4c1b1d7 Fix trip delete button: layout and click handler
- text-nowrap on action cell so Replay+trash stay on same line
- Delete button inline with Replay (no whitespace between)
- Explicit deletable guard in click handler

Co-authored-by: Copilot <[email protected]>
2026-08-10 04:45:05 -04:00
Mathieu MoranandCopilot 7aa9ffda16 Add delete with confirmation for trips and dashcam
- Confirmation modal in server base.html (window.confirmDelete promise helper)
- Trip delete: trash button in Replay column (server-only via data-deletable on
  trip-list div), DELETE /api/v1/portal/trip/<id>, removes computed trip only
  (raw tracking_points kept)
- Dashcam delete: trash button next to download, DELETE /api/v1/portal/dashcam/<id>,
  removes from DB + disk (best-effort file removal)
- history.js: delete button gated on data-deletable='1' attribute; click handler
  intercepts before replay logic
- db.py: delete_trip(), delete_dashcam_file() helpers

Co-authored-by: Copilot <[email protected]>
2026-08-10 04:40:22 -04:00
Mathieu MoranandCopilot a13d74e18e Fix remote portal map tiles and geocode
- history.js reads tile URL from data-tile-url attribute on #history-map div,
  falling back to local offline tiles (Pi behavior unchanged)
- server history.html sets data-tile-url to live OSM tiles
- maxZoom raised to 19 (OSM supports it, local tiles were limited to 15)
- server geocode returns 'label' key to match what history.js/yaris.js expect

Co-authored-by: Copilot <[email protected]>
2026-08-10 04:34:49 -04:00
Mathieu MoranandCopilot e2a319ae08 Fix history.js null guard for speed and distance
formatSpeed and formatDistance return '—' on null/undefined values.
Server trips may have null avg/max speed if no speed data was recorded.

Co-authored-by: Copilot <[email protected]>
2026-08-10 04:30:54 -04:00
Mathieu MoranandCopilot 693848aba1 Fix remote portal trip list
- /api/trips now returns {trips:[...]} to match what history.js expects (result.trips)
- odo_start_km null guard in history.js (server trips have no odometer data)
- require_login returns 401 JSON for /api/* paths instead of HTML redirect

Co-authored-by: Copilot <[email protected]>
2026-08-10 04:27:57 -04:00
Mathieu MoranandCopilot 64b9481ce0 Phase 24: add password change page
- GET/POST /account/password with CSRF token and bcrypt re-hash
- 'Password' link in sidebar next to Logout
- change_password.html template

Co-authored-by: Copilot <[email protected]>
2026-08-10 04:22:28 -04:00
Mathieu MoranandCopilot c4dfcbaf25 Phase 24: remote trip + dashcam portal
- Add Flask-Login browser session auth (separate from device X-Api-Key auth)
  - modules/user_auth.py: User model, require_login decorator, bcrypt helpers
  - /login with CSRF token, /logout
  - scripts/create_user.py to provision admin user

- Add server-side trip computation from tracking_points
  - db.compute_trips_for_device(): gap > 5 min = new trip, haversine distance,
    upserts into trips table, auto-triggered in bg thread after each upload batch
  - db.list_trips(), db.get_trip_route(): same JSON shape as Pi API
  - db.get_overview_stats(): aggregated totals for overview page
  - db.list_dashcam_files(), db.get_dashcam_file(): browse/stream helpers

- Add web portal to server/app.py
  - Overview page (/): stat cards + recent trips + recent clips
  - Trip history (/history): reuses Pi history.js + data-list.js unchanged
    via /api/trips + /api/trip/<id> compatibility aliases
  - Dashcam archive (/dashcam): file list + inline video player
  - /api/v1/portal/dashcam/stream/<id>: Range-request streaming, path traversal guard
  - /api/geocode: same Nominatim→reverse_geocoder offline fallback as Pi

- Add server templates (server/templates/)
  - base.html: shared shell with 'Remote server' badge in sidebar + mobile topbar
  - login.html, index.html, history.html, dashcam.html, components.html

- DB schema: users + trips tables added to init.sql (fresh deploys) +
  migrate_phase24.sql (existing deployments)

- Infrastructure: Dockerfile uses repo-root build context to copy shared
  static/ assets; docker-compose updated; SECRET_KEY + DEVICE_NAME in .env;
  requirements: Flask-Login, bcrypt, reverse_geocoder

Co-authored-by: Copilot <[email protected]>
2026-08-10 03:58:45 -04:00
Mathieu MoranandCopilot 51c6be3a94 docs: update PLAN.md phase 23.5, README features/hardware/status, new screenshots
- PLAN.md: Phase 23.5 documents all work from this session
- README: hardware table adds T3U, features add geocoding/offline maps/T3U, status updated
- pics/ui.png: desktop + mobile side-by-side screenshot (updated)

Co-authored-by: Copilot <[email protected]>
2026-08-10 03:43:09 -04:00
Mathieu MoranandCopilot 58faded602 fix(dashboard): parseFloat GPS coords before toFixed call
Co-authored-by: Copilot <[email protected]>
2026-08-10 03:31:55 -04:00
Mathieu MoranandCopilot a2ae15c2f0 fix(dashboard): geocode fires on first GPS fix, gated by distance not timer
Co-authored-by: Copilot <[email protected]>
2026-08-10 03:29:32 -04:00
Mathieu MoranandCopilot c5982e905f fix(geocode): replace AbortSignal.timeout with AbortController for compatibility
- AbortSignal.timeout not supported on all mobile browsers
- Manual AbortController + setTimeout works everywhere
- Fixes location not showing on dashboard and history first point

Co-authored-by: Copilot <[email protected]>
2026-08-10 03:25:14 -04:00
Mathieu MoranandCopilot 11f07dec38 fix(geocode): accurate street name + always shows on dashboard
- Cache key uses 4 decimal places (~11m) instead of 3 (~111m) for correct street
- Dashboard geocodes on first GPS fix, then again every >50m moved (throttled 15s)
- Removed stale console.log

Co-authored-by: Copilot <[email protected]>
2026-08-10 03:21:37 -04:00
Mathieu MoranandCopilot c188fb9d14 feat(geocode): street-level via Nominatim when online, city fallback offline
- Try Nominatim first (road + city), 4s timeout
- Fall back to reverse_geocoder (city + region) if no internet
- LRU cache (512 entries, ~111m precision) — repeated lookups are instant
- No external calls from browser, all via /api/georecode on Pi

Co-authored-by: Copilot <[email protected]>
2026-08-10 03:18:36 -04:00
Mathieu MoranandCopilot e96c6a16bb fix(network): stable phone hotspot IP via dhcp-client-id=yaris-obd
- Phone DHCP caches by client-id so Pi always gets same lease
- Remove dashboard phone-hotspot banner (didn't work)

Co-authored-by: Copilot <[email protected]>
2026-08-10 03:11:41 -04:00
Mathieu MoranandCopilot a6c6cddf30 feat(dashboard): show Pi IP banner when connected to phone hotspot
- Banner appears on dashboard only when uplink mode is 'phone'
- Shows the Pi's current IP so you can reach it from your phone's browser
- Polls every 15s, hides automatically when not on phone hotspot

Co-authored-by: Copilot <[email protected]>
2026-08-10 03:06:39 -04:00
Mathieu MoranandCopilot 9f9974e197 fix(network): home-wifi-fb autoconnect=no, only activated by restore_fallback()
Co-authored-by: Copilot <[email protected]>
2026-08-10 02:56:25 -04:00
Mathieu MoranandCopilot 1299e35a88 fix(network): use wifi-sec shorthand in nmcli connection add
Co-authored-by: Copilot <[email protected]>
2026-08-10 02:53:41 -04:00
Mathieu MoranandCopilot 11e98de0ee feat(geocode): fully offline reverse geocode via local Pi API
- Remove all Nominatim/external calls from frontend
- Add /api/geocode?lat=&lon= route using reverse_geocoder (20MB offline KD-tree)
- JS hits /api/geocode only — works with no internet at all
- Falls back to raw coords if Pi route fails
- Add reverse_geocoder to requirements.txt

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:49:48 -04:00
Mathieu MoranandCopilot 91d475fd24 fix(map): show raw coords as fallback when geocode fails or times out
- 5s timeout on Nominatim fetch (AbortSignal.timeout)
- On network error, non-200, or empty result: show 'lat, lon' (5 decimal places)
- Applies to both dashboard live location and history scrubber

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:46:33 -04:00
Mathieu MoranandCopilot 548808fd83 feat(history): show reverse-geocoded location during trip scrubbing
- Location name (road + city) appears below time/speed in the timeline panel
- Debounced 600ms so dragging the scrubber doesn't spam Nominatim
- Deduped by ~3 decimal lat/lon so stationary replay doesn't re-fetch
- Cleared when a new trip is loaded

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:45:41 -04:00
Mathieu MoranandCopilot f1145a4dbb feat(map): reverse geocode location + recenter button on dashboard and history
- Dashboard: recenter button in GPS card header (hidden until first fix)
- Dashboard: estimated location name under lat/lon via Nominatim reverse geocode (throttled 30s)
- History: recenter button fits map back to trip bounds
- Both buttons use crosshairs icon, hidden until data is available

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:43:23 -04:00
Mathieu MoranandCopilot 08a3bef558 ui: tap any row to replay trip on mobile
- data-trip-id on the <tr> itself
- click handler checks row if no button found
- cursor: pointer on rows for discoverability

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:40:45 -04:00
Mathieu MoranandCopilot f27e2f2d4b ui: remove imperial units, hide Replay button on mobile
- formatDistance/formatSpeed: km/km/h only, no mi/mph
- Replay button hidden on mobile (d-none d-sm-table-cell)
- Action column header hidden on mobile to match
- data_table macro supports optional 'class' on column headers

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:38:50 -04:00
Mathieu MoranandCopilot 908e86d169 feat(network): wlan0 fallback if T3U dongle is absent
- PRIMARY_INTERFACE=wlan1, FALLBACK_INTERFACE=wlan0
- resolve_interface() checks /sys/class/net at startup and picks whichever exists
- INTERFACE is module-level, so all functions use the correct iface automatically
- restore_fallback() now also tries home-wifi-fb / Hotspot on wlan0 if wlan1 is primary but failed
- install_netwatch.sh creates home-wifi-fb profile on wlan0 (lower autoconnect-priority=5)
- wlan0 stays managed=yes (fallback) instead of being hard-disabled
- Status JSON now includes 'interface' field for diagnostics

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:35:06 -04:00
Mathieu MoranandCopilot e533f52e78 feat(network): switch all WiFi to wlan1 (TP-Link T3U dongle)
- INTERFACE = 'wlan1' in yaris_uplink.py
- install_netwatch.sh binds home-wifi, Hotspot, phone-hotspot to wlan1
- Disables wlan0 (onboard) via nmcli device set managed no
- WLAN variable at top of script for easy revert to wlan0 if needed

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:29:22 -04:00
Mathieu MoranandCopilot cc6e9c8de0 feat: add download button to Pi videos list
- /api/local-video/file?download=1 serves file as attachment
- Download link (green DL button) added to each row in local-videos.js
- Dashcam USB files skipped: no persistent mount for direct serve;
  use Import then download from Pi Videos

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:17:29 -04:00
Mathieu MoranandCopilot b711d421cc fix(videos): real ffmpeg progress via -progress pipe, exclude .converting.mp4
- ffmpeg -progress pipe:1 streams out_time_ms lines; we parse them in the
  reading loop so % = out_time_s / duration_s (actual encode position)
- find_video_files() skips *.converting.mp4 temp files so they are never
  listed, uploaded, or shown in the UI

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:13:45 -04:00
Mathieu MoranandCopilot afce229310 feat: odometer in trips/overview, trips upload toggle, fix video count
- OBD odometer now logged to tracking rows on every GPS snapshot
- trips table: START_ODOMETER/END_ODOMETER columns (migration)
- build_trip_summaries: picks odometer from first/last tracking row
- list_trips: uses real OBD odo, falls back to cumulative distance
- overview stats: odometer_km (max seen from OBD), video_count fixed
- history page: Odometer column shows start→end km per trip
- app_upload: TRIPS_UPLOAD_SETTING_KEY, is_trips_upload_enabled()
- _upload_tracking_batch: skips when trips upload is off
- settings: separate 'Upload trips & GPS' toggle from dashcam toggle

Co-authored-by: Copilot <[email protected]>
2026-08-10 02:07:31 -04:00
Mathieu MoranandCopilot 414fb78543 fix(videos): base convert % on elapsed/duration not file size
File size ratio was wrong since output bitrate != input bitrate.
Now uses ffprobe to get video duration, then elapsed/duration for %.

Co-authored-by: Copilot <[email protected]>
2026-08-10 01:50:49 -04:00
Mathieu MoranandCopilot 1fde8732c2 fix(videos): stop thumbnails reloading on every convert poll
Instead of full re-render every 10s, patch converting badges in-place.
Only does a full renderFiles() when file count changes (conversion done)
or on the final poll when all conversions finish.

Co-authored-by: Copilot <[email protected]>
2026-08-10 01:49:03 -04:00
Mathieu MoranandCopilot 7d5755180a fix(videos): set explicit 4Mbps bitrate for hw encode
Default h264_v4l2m2m bitrate was ~200kbps producing unusable quality.
4M video + 128k audio = ~100MB per 3-min clip, proper dashcam quality.

Co-authored-by: Copilot <[email protected]>
2026-08-10 01:43:28 -04:00
Mathieu MoranandCopilot e8ce33eded fix(videos): fix FileNotFoundError crashing convert thread
safe_video_path checks file existence — mp4 output doesn't exist yet.
Add _local_path_unchecked() for output paths that don't exist yet.

Co-authored-by: Copilot <[email protected]>
2026-08-10 01:35:31 -04:00
Mathieu MoranandCopilot d9d1537a13 feat(videos): show conversion progress % and elapsed time
- _converting dict now tracks started_at, source_size, tmp_path per file
- list_local_videos() reads tmp file size to compute pct complete
- Desktop badge: 'Converting 42% 1m30s'
- Mobile badge: '42%' (compact, fits under thumbnail)

Co-authored-by: Copilot <[email protected]>
2026-08-10 01:30:48 -04:00
Mathieu MoranandCopilot c995599512 feat(videos): add manual Convert button per MOV file on Pi
- New POST /api/local-video/convert route triggers queue_convert(force=True)
- queue_convert() now returns True/False/None and accepts force param
  to bypass the auto-convert toggle (manual always works)
- Convert button shown for non-MP4, non-converting files in video list
- Clicking spins the button and refreshes list after 1s to show badge

Co-authored-by: Copilot <[email protected]>
2026-08-10 01:28:56 -04:00
Mathieu MoranandCopilot ba712bd6db fix(dashcam): prevent site freeze during import/convert
- _convert_bg now runs in real threading.Thread (not gevent greenlet)
  so ffmpeg proc.wait() doesn't block gevent event loop
- _device_lock released before copy loop; only held for mount/unmount
  so /api/dash/files and other routes don't block during long copies
- Date now shows time (Aug 9, 10:30 PM) on mobile video rows
- Spacing added above converting spinner badge

Co-authored-by: Copilot <[email protected]>
2026-08-10 01:23:53 -04:00
Mathieu MoranandCopilot 77cd3fdadd feat(dashcam): show converting badge and date on mobile video rows
Co-authored-by: Copilot <[email protected]>
2026-08-10 01:18:03 -04:00
Mathieu MoranandCopilot 384050e78a fix(dashcam): show checkbox on mobile for file selection
Co-authored-by: Copilot <[email protected]>
2026-08-10 01:15:00 -04:00
Mathieu MoranandCopilot 71f8ce2993 feat(dashcam): async import with polling status
- Import now returns immediately with {status: 'started'}
- Backend runs copy in a daemon thread, tracks job state in _import_job
- New GET /api/dash/import/status endpoint for polling
- UI polls every 3s, shows progress bar until done
- Page reload mid-import resumes polling automatically
- Prevents double-import with 'busy' guard

Co-authored-by: Copilot <[email protected]>
2026-08-10 01:12:00 -04:00
Mathieu MoranandCopilot 7d124b0fa0 fix(dashcam): remove internal td borders on mobile rows
Co-authored-by: Copilot <[email protected]>
2026-08-10 01:01:17 -04:00
Mathieu MoranandCopilot 219d559de9 fix: dashcam mobile shows filename, RPM/fuel/throttle don't append unit when disconnected
- yaris.css: split mobile rules by list ID — video-list shows thumb+actions,
  dashcam-file-list shows dash-col-file+actions; thead display:none !important
- yaris.js: check data !== '-' before appending rpm/°C/% units so disconnected
  state shows — instead of '- rpm'

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:56:48 -04:00
Mathieu MoranandCopilot 522674c907 fix(dashcam): remove inline width from thumb td, control via CSS with !important
Inline style.width on td was overriding the mobile media query width rule.
Use dash-thumb-td class for desktop (9rem) and mobile (5rem !important).

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:53:02 -04:00
Mathieu MoranandCopilot 5b2854fbec fix(dashcam): use style.display instead of hidden attr (d-flex overrides hidden)
Bootstrap d-flex sets display:flex !important which beats the HTML hidden
attribute. Switch to style.display='none'/'flex' for both status bars.

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:51:27 -04:00
Mathieu MoranandCopilot b064b4f902 fix(dashcam): auto-hide import result bar after 10s
Co-authored-by: Copilot <[email protected]>
2026-08-10 00:48:31 -04:00
Mathieu MoranandCopilot ba760a04d6 fix(dashcam): proper mobile row reflow + import status at top with ETA
- yaris.css: use .dash-row class on all rows; only td:first-child and
  .dash-actions-td visible on mobile (<576px); buttons never overflow
- dash.js: add dash-row class + dash-actions-td wrapper div to dashcam
  file rows; import status moved to card footer at top with spinner + ETA
- local-videos.js: add dash-row class to video rows
- dash.html: import status bar (spinner + ETA + result) in device card
  at top; removed old footer import status from files card

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:46:05 -04:00
Mathieu MoranandCopilot 9f0f53a59c fix(dashcam): reflow table rows as flex cards on mobile so buttons are always visible
Replace column-hiding approach (still caused 691px overflow) with full
table reflow: on <576px each tr becomes a flex row, all tds hidden except
first (thumb+name) and .dash-actions-td (buttons). Buttons can never
overflow because the row is constrained to viewport width.

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:33:57 -04:00
Mathieu MoranandCopilot 01704d5429 feat(dashcam): mobile layout fix + optional hw-encode conversion on import
- yaris.css: on mobile (<576px) hide file/size/date/upload cols, show
  filename inside thumb cell, icon-only buttons, dash-actions-td nowrap
- local-videos.js: filename in thumb cell for mobile; Converting badge with
  spinner; auto-refresh every 10s while any file is converting
- app_video.py: CONVERT_SETTING, is_convert_enabled(), queue_convert(),
  _convert_bg() — h264_v4l2m2m + aac faststart, deletes original on success
- app_dash.py: call queue_convert() after each successful copy on import
- app_settings.py: expose video_convert_enabled in get/save settings
- settings.html: toggle for 'Convert imported videos for web playback'

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:30:59 -04:00
Mathieu MoranandCopilot dd2f95b9c1 revert(video): back to simple send_file for video serving (RPi3 can't transcode)
Co-authored-by: Copilot <[email protected]>
2026-08-10 00:21:19 -04:00
Mathieu MoranandCopilot a3659e8a8c fix(video): transcode audio to AAC for MP4 streaming (PCM unsupported in MP4)
Co-authored-by: Copilot <[email protected]>
2026-08-10 00:18:41 -04:00
Mathieu MoranandCopilot 51322d0e00 fix(dashcam): wrap action buttons in div, ffmpeg stream for MOV playback
- local-videos.js: action buttons go in a div.data-list-actions inside td
  so td stays a table cell (not flex); thumbnail td gets fixed width 9rem
- app.py: video route now pipes ffmpeg with -c copy -movflags
  frag_keyframe+empty_moov so browsers get a fragmented MP4 stream that
  starts playing immediately without needing the moov atom at the end

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:17:10 -04:00
Mathieu MoranandCopilot 5d9b2f6556 feat(dashboard): 3-col layout with Overview stats, speed chart fixes
- base.html: fix notification badge positioning (inline style, no overflow
  clip); remove py-4 from main; update footer text
- index.html: split bottom row into 3 cols (GPS / Overview / Speed);
  Overview card with 6 live stats from /api/stats; speed chart auto-expands
  on desktop; shown.bs.collapse triggers speedChart.resize()

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:13:23 -04:00
Mathieu MoranandCopilot ea03ef7f6c fix(dashcam): add missing dash-col CSS rules, correct MOV mime type and range clamp
- yaris.css: add dash-col-size/date/upload hide rules on mobile (<576px),
  dash-col-file truncation on tablet, btn-icon-mobile label hide
- app.py: clamp byte2 to file_size-1 to prevent Content-Length mismatch;
  detect MIME from file extension (.mov -> video/quicktime)

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:12:33 -04:00
Mathieu MoranandCopilot 375b93fcea fix: restore /api/stats route, remove dead player code, fix modal scroll
- app_sql.py: add get_overview_stats() with single SQL query (trip count,
  total km, duration, max speed, active DTC count)
- app.py: add /api/stats route (was missing after scp overwrote the file)
- local-videos.js: remove leftover old closePlayer/playVideo/renderFiles
  duplicate block that still had scrollIntoView and references to the old
  panel elements; new modal-based code is the only path now

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:07:14 -04:00
Mathieu MoranandCopilot 600fd41353 fix(dashcam): range-request video streaming, modal player, async thumbnails
- app.py: replace send_file with manual Range-aware chunked generator;
  returns 206 on partial requests so browsers can seek/buffer properly.
  Thumbnail route returns 202 while ffmpeg is generating.
- app_video.py: thumbnail generation moved to background gevent greenlet
  (_generate_thumbnail_bg). get_thumbnail_path returns immediately with
  RuntimeError('generating') if not cached; ffmpeg only ever runs once
  per video (result cached in dash/thumbnails/).
- dash.html: replaced bottom card player with Bootstrap modal (modal-xl,
  centered, dark bg) — video pops up in place regardless of scroll position.
- local-videos.js: rewritten to use Bootstrap modal; thumbnail loader
  polls with fetch() and retries on 202 every 2s (up to 15x) instead of
  setting img.src directly (which can't detect 202).

Co-authored-by: Copilot <[email protected]>
2026-08-10 00:01:49 -04:00
Mathieu MoranandCopilot 35ec705fe2 docs: update PLAN.md Phase 23 completion and README project status
Phase 23 section updated with full list of changes from all polish passes:
Overview stats card, speed chart rework, mobile dashcam table fixes,
trip history mobile layout, notification badge fix, footer text.
README project status updated to reflect Phases 1-23 complete.

Co-authored-by: Copilot <[email protected]>
2026-08-09 23:56:47 -04:00
Mathieu MoranandCopilot e2f7fa88df Speed history: rework chart and add live stats
- Replace 8-point scatter chart with proper 60-point line chart
  - Blue gradient fill (rgba 35%→2%), tension 0.4 smooth curve
  - No legend (obvious), no X axis grid, soft Y axis gridlines
  - Y axis auto-scales: max(60, sessionMax*1.2) rounded to 10s
  - Ticks: '0 km/h' labels on Y, time strings on X (max 6)
  - chart.update('none') for live updates (no animation stutter)
- Speed history card header now shows current speed + session max
  even when the graph is collapsed
- Footer bar inside expanded graph: session avg, point count, last reading time
- makeGradient() refreshed on every update in case canvas was resized
- obd_rpm now appends ' rpm' to the displayed value

Co-authored-by: Copilot <[email protected]>
2026-08-09 23:14:32 -04:00
Mathieu MoranandCopilot beddfec8a5 Phase 23 UX polish: compact layouts, collapsibles, test trip
Dashboard:
- OBD stat cards more compact (py-2, h6 health values, 'OBD' label fits)
- obd-val class prevents Disconnected wrapping
- Speed graph collapsed by default with toggle chevron button
- GPS map + coordinates merged into single card with footer coords
- Pi health rows use h6 (less tall), alert py-2

Dashcam:
- Device status is now a compact single-line bar
- Files on dashcam section is collapsible (hidden by default)
- Import status moved into the dashcam files card footer
- Imported videos on Pi moved to top, open by default
- Column headers trimmed (Select→empty, Actions→empty, Preview→empty)

Trip history:
- col-12 on mobile so list stacks above the map
- Shorter column labels (Distance→km, Duration→Time, Max speed→Max)
- history-map height reduced for mobile (22rem on small screens)

Settings:
- Cellular + ntfy merged into one collapsible card
- Alert thresholds collapsible (advanced)
- Phone hotspot instructions shortened
- Upload retention uses inline row layout (compact)
- All cards use shadow-sm, smaller headers

CSS:
- obd-val: word-break + smaller font for OBD status values
- collapse-chevron: smooth rotation transition
- #map height 18rem (was 20rem)
- history-map 32rem desktop / 22rem mobile (was 36rem)

Co-authored-by: Copilot <[email protected]>
2026-08-09 23:10:58 -04:00
Mathieu MoranandCopilot 6a519dbf9e PLAN: mark Phase 23 done
Co-authored-by: Copilot <[email protected]>
2026-08-09 22:59:08 -04:00
Mathieu MoranandCopilot 7edea446c5 Phase 23: UI redesign — responsive mobile + desktop layout
- Replace SB Admin 2 sidebar with a clean fixed sidebar (desktop)
  and a sticky bottom tab navigation (mobile, ≤767px)
- Sidebar collapses to icon-only mode with state persisted in localStorage
- Logo is a functional home link on all viewports
- Mobile: full-width content, bottom nav replaces sidebar
- Dashboard: 6 OBD stat cards (status, speed, RPM, coolant, throttle, fuel)
  wired to live socket events; removed all placeholder buttons/dropdowns
- GPS info section cleaned up (lat/lon display instead of X/Y paragraphs)
- DTC page: live /api/dtc wiring — MIL indicator + DTC table populated from API
- New /api/dtc route added to app.py (mil + codes)
- yaris.js: added obd_coolant_temp / obd_fuel_level / obd_throttle listeners
- yaris.css: full rewrite — layout variables, sidebar, bottom nav, OBD cards,
  border-start utilities, mobile padding for bottom nav

Co-authored-by: Copilot <[email protected]>
2026-08-09 22:57:12 -04:00
Mathieu MoranandCopilot cdb032b888 Add phone hotspot uplink
Prefer a configured phone hotspot for outbound synchronization, preserve secure write-only credentials, and recover through home WiFi or the local Pi hotspot.

Co-authored-by: Copilot <[email protected]>
2026-08-09 22:48:11 -04:00
Mathieu MoranandCopilot 5c707fe295 Plan phone hotspot uplink
Co-authored-by: Copilot <[email protected]>
2026-08-09 21:29:08 -04:00
Mathieu MoranandCopilot 3a6f12ba9d Add local settings management
Add cellular-first ntfy delivery with WiFi fallback and safe SIM868 HTTP header framing.

Co-authored-by: Copilot <[email protected]>
2026-08-09 21:26:15 -04:00
Mathieu MoranandCopilot b77ed838bd Clarify pre-install vehicle testing
Co-authored-by: Copilot <[email protected]>
2026-08-09 20:37:43 -04:00
Mathieu MoranandCopilot acf24be89e Plan final Yaris acceptance test
Co-authored-by: Copilot <[email protected]>
2026-08-09 20:35:52 -04:00
Mathieu MoranandCopilot 592a123c77 Expand remote portal dashcam scope
Co-authored-by: Copilot <[email protected]>
2026-08-09 20:33:42 -04:00
Mathieu MoranandCopilot b348a9944a Plan remote trip portal
Co-authored-by: Copilot <[email protected]>
2026-08-09 20:32:27 -04:00
Mathieu MoranandCopilot 96332adab6 Add offline and ntfy alerts
Co-authored-by: Copilot <[email protected]>
2026-08-09 20:28:42 -04:00
Mathieu MoranandCopilot 8b664667e5 Add Raspberry Pi health monitoring
Co-authored-by: Copilot <[email protected]>
2026-08-09 19:57:07 -04:00
Mathieu MoranandCopilot d3bc313e7f Add trip history and route replay
Co-authored-by: Copilot <[email protected]>
2026-08-09 19:48:06 -04:00
Mathieu MoranandCopilot 3a981f5c6d Add local dashcam video library
Co-authored-by: Copilot <[email protected]>
2026-08-09 19:04:47 -04:00
Mathieu MoranandCopilot c57457b934 Add selected dashcam imports
Co-authored-by: Copilot <[email protected]>
2026-08-09 18:34:52 -04:00
Mathieu MoranandCopilot e058ea8a41 Fix FAT dashcam file deletion
Co-authored-by: Copilot <[email protected]>
2026-08-09 18:23:52 -04:00
Mathieu MoranandCopilot 80d9c150fe Document dashcam browser completion
Co-authored-by: Copilot <[email protected]>
2026-08-09 18:19:37 -04:00
Mathieu MoranandCopilot 5a520bf022 Add dashcam USB file browser
Co-authored-by: Copilot <[email protected]>
2026-08-09 18:16:59 -04:00
Mathieu MoranandCopilot 796b34a0b2 Refresh project README
Co-authored-by: Copilot <[email protected]>
2026-08-09 18:10:44 -04:00
Mathieu MoranandCopilot 84362206f2 Clarify final UI redesign sequencing
Co-authored-by: Copilot <[email protected]>
2026-08-09 18:09:14 -04:00
Mathieu MoranandCopilot 68c9aeaf44 Complete shared UI foundation
Co-authored-by: Copilot <[email protected]>
2026-08-09 18:06:42 -04:00
Mathieu MoranandCopilot c367504c13 Add shared UI shell and list component
Co-authored-by: Copilot <[email protected]>
2026-08-09 18:03:32 -04:00
Mathieu MoranandCopilot 606f67d077 Document watchdog deployment status
Co-authored-by: Copilot <[email protected]>
2026-08-09 17:57:26 -04:00
Mathieu MoranandCopilot 3d9706b466 Add Pi hardware watchdog setup
Co-authored-by: Copilot <[email protected]>
2026-08-09 17:55:13 -04:00
Mathieu MoranandCopilot 0540a1a8eb Reorder roadmap phases by execution
Co-authored-by: Copilot <[email protected]>
2026-08-09 17:53:17 -04:00
Mathieu MoranandCopilot 7621c2a6e7 Document Phase 17 deployment
Co-authored-by: Copilot <[email protected]>
2026-08-09 17:49:18 -04:00
Mathieu MoranandCopilot cdbe8c89b1 Harden OBD connection and telemetry
Co-authored-by: Copilot <[email protected]>
2026-08-09 17:47:52 -04:00
Mathieu MoranandCopilot 33ea6958f4 Document Phase 12 deployment + handoff note for next session
Recorded Phase 12's deployment step (Pi restart + unraid server
docker compose build/up since server/app.py's CORS removal is baked
into the image at build time - a plain restart wouldn't pick it up)
and its verification (both confirmed on real infrastructure: Pi's
/dash returns HTTP 200 with no relay text, server's /health has no
Access-Control-* headers anymore).

Added a handoff note for whoever picks this up next: Phase 17 (OBD
hardening) was the next phase in progress, but no code was written
yet - only read app_events.py/app_gps.py for context and started
(but didn't finish, due to a tool permission interruption) installing
the python-obd package locally to check its actual API surface
before writing the DTC/protocol/diagnostics code. Reset that todo
back to pending to reflect no actual progress was made. Phases 13
and 14 are equally ready to start (no blocking deps) if a different
starting point makes more sense.

Co-authored-by: Copilot <[email protected]>
2026-08-09 17:17:21 -04:00
Mathieu MoranandCopilot f17c5c8681 Phase 12: remove phone-relay dashcam upload feature
Removes the phone-relay feature entirely per the Phase 11 finding
that it hit a fundamental phone-OS limitation (a browser page can't
route WiFi and cellular traffic simultaneously, no code fix possible)
- see PLAN.md for the full test writeup.

- app.py: dropped /api/dash/pending, /api/dash/file/<path>,
  /api/dash/relay/config, /api/dash/relay/heartbeat,
  /api/dash/relay/stop, /api/dash/relay/mark_uploaded routes and
  their now-unused imports (os, send_file, abort, safe_join).
- modules/app_upload.py: dropped list_pending_dashcam_files(),
  relay_heartbeat()/relay_stop()/is_relay_active(),
  RELAY_LEASE_SECONDS/_relay_lease_until, and the relay-lease check
  inside _upload_dashcam_files()/get_status(). Kept the chunked
  upload protocol, dashcam_hash_cache/_hash_dashcam_file() (still
  used by the Pi's own background uploader), and DASHCAM_CHUNK_SIZE -
  none of that was relay-specific.
- templates/dash.html: removed the 'Relay via phone' card.
- static/js/dash.js: removed all relay JS (relayOneFile,
  fetchChunkWithRetry, uploadChunkWithRetry, heartbeat timers,
  pending-file list rendering).
- server/app.py: removed the CORS after_request hook, added solely
  for the phone's cross-origin browser calls (the Pi's own Python
  requests-based client was never subject to CORS).
- readme.md: dropped the phone-relay feature bullet, updated the
  project-status blurb to explain the removal + point at the planned
  native Android app as the real fix for that use case.
- PLAN.md: marked Phase 12 done with a verification writeup.

Verified: py_compile on all touched Python files, node --check on
dash.js, grepped all touched files for any leftover relay/RELAY
reference (none), and smoke-tested by importing app.py + hitting it
with Flask's test client - app starts cleanly, every relay route is
gone from url_map, and both / and /dash still render HTTP 200 with
zero 'relay' occurrences in /dash's HTML.

Co-authored-by: Copilot <[email protected]>
2026-08-09 17:12:30 -04:00
Mathieu MoranandCopilot 0b729adb28 Rework Phases 12-17 for efficiency, add QOL phases (power resilience, watchdog, trip history, Pi health, alerts, settings)
Reordered/renumbered the roadmap based on dependency analysis:
- Phase 12 (remove relay) stays first - pure cleanup, no deps.
- New Phase 13: power-loss resilience + hardware watchdog - OS-level,
  independent, should land before the Pi lives in the car full-time
  (cars cut power abruptly at every key-off, a real SD-corruption risk
  nothing in the project currently addresses).
- New Phase 14: UI design-system foundation - pulls forward a small
  slice of the old end-of-roadmap redesign (shared layout + reusable
  list/table component) so the dashcam/video browsers, trip history,
  and settings pages are all built on it once instead of being built
  in the old template and then rebuilt during the redesign later.
- Phases 15/16 (dashcam + local video browsers) now explicitly build
  on Phase 14's foundation. Folded two more QOL ideas directly into
  Phase 16: ffmpeg thumbnails per video, and an optional
  upload-confirmed local retention/auto-cleanup policy.
- Phase 17 (OBD hardening) explicitly marked independent/parallel-
  frins to other phases - backend-only work, no UI dependency.
- New Phase 18: trip history + map replay (trips table + GPS-gap
  boundary detection + list/replay UI reusing the offline Leaflet map).
- New Phase 19: Pi health monitoring card (CPU temp/disk/uptime/RAM).
- New Phase 20: alerts/notifications via a simple outbound webhook
  (ntfy.sh/Telegram) for DTC-on, storage full, backlog growing,
  overheating, lost GPS fix.
- New Phase 21: consolidated Settings page (upload toggle, cellular
  APN, retention policy, alert config) - reduces routine SSH-editing.
- Phase 22: renamed 'Finish web UI redesign' - now scoped as the
  remaining cleanup after Phase 14's foundation + all functional
  phases land, rather than one big redesign at the very end.
- Phase 23: native Android app, unchanged content, still last/low
  priority per explicit user instruction.

Updated the session's todo tracking table to match the new phase
IDs/order and added todo_deps reflecting the same dependency chain.

Co-authored-by: Copilot <[email protected]>
2026-08-09 16:57:56 -04:00
Mathieu MoranandCopilot 5877f16c9a PLAN.md: add missing status icons, fix stale phase cross-reference
Formatting-only pass, no information changed:
- Added missing checkmarks to Phase 7 and Phase 9 headers (were plain
  'DONE' text while every other completed phase uses '✅ DONE').
- Added a status icon to the un-numbered SIM868 cellular fallback
  section ('⚠️ built, UNTESTED').
- Added a consistent '🚧 PLANNED' icon to Phases 12-17, which had no
  status icon at all.
- Fixed a stale cross-reference in the Phase 6 update ('see Phase 9
  update below' -> 'see Phase 11 update below' - the phone-relay
  content it points to has always lived under Phase 11).

Co-authored-by: Copilot <[email protected]>
2026-08-09 16:50:42 -04:00
Mathieu MoranandCopilot e280f9ac96 Plan native Android app + full UI redesign; expand OBD data scope; deprioritize Android app
Phase 16: complete web UI redesign - the current template is full of
never-replaced placeholder cruft (stub buttons, empty /dtc page); do
one real design pass once the functional phases land rather than
keep bolting onto the old admin-template style.

Phase 17: native Android companion app - the real fix for the
phone-relay goal that a web page could never deliver (true
simultaneous WiFi-to-Pi + cellular-to-server via
bindProcessToNetwork), plus a trip/location info view. Explicitly
deprioritized to the end of the roadmap per user - not urgent.

Also expanded Phase 15 (OBD hardening) scope to pull richer dashboard
data (coolant temp, fuel level, throttle, DTC/MIL status) alongside
speed/RPM, noting odometer/mileage support is not guaranteed on this
vehicle/year and needs real-car confirmation.

Co-authored-by: Copilot <[email protected]>
2026-08-09 16:48:27 -04:00
Mathieu MoranandCopilot 9e96b4327b Document phone-relay hard limitation, plan 4 new phases
Retested the hotspot+cellular phone-relay scenario more rigorously
(netwatch explicitly disabled during tests so it wouldn't yank the Pi
back to home WiFi mid-test - an earlier failed run turned out to be
caused by that, not a phone issue). Confirmed with WiFi fully off
(pure cellular) that requests to the public server still failed while
associated with the Pi's hotspot, and that this is a fundamental
OS-level 'single default network for the whole device' limitation -
not something a web page's JS can work around, only a native app could
(bindProcessToNetwork). Documented the decision to remove the feature.

Added Phase 12 (remove phone-relay), Phase 13 (dashcam USB file
browser: list+delete), Phase 14 (local imported-video browser +
HTML5 player), Phase 15 (OBD code hardening ahead of first real
car test - background reconnect loop, exception handling, protocol
override, better diagnostics).

Co-authored-by: Copilot <[email protected]>
2026-08-09 16:44:11 -04:00
Mathieu MoranandCopilot 45773f7459 Document phone-relay hotspot+cellular test findings in PLAN.md
Live-tested the away-from-home hotspot+cellular relay scenario. Found the
netwatch reconnect bug (documented in Phase 6 update) during this test.
Confirmed the relay architecture itself needs no code changes - the
TypeError: Failed to fetch was a phone-OS network-switching behavior
(won't route internet traffic over cellular while connected to a
no-internet WiFi), not a CORS/code issue - ruled out via a plain page
navigation test. Testing this fully requires being genuinely away from
home or manually forcing cellular use on the phone.

Co-authored-by: Copilot <[email protected]>
2026-08-09 15:56:17 -04:00
Mathieu MoranandCopilot 04cf74ccd3 Fix flaky hotspot->home WiFi reconnect in netwatch
Real-hardware finding: netwatch repeatedly reported 'home WiFi not found'
4 times over 13min while sitting right next to the router. Root cause:
a plain 'nmcli con up home-wifi' straight out of AP mode relies on NM's
scan cache for wlan0, which is stale/empty coming out of AP mode (the
radio can't scan while acting as an AP). A single con-up attempt didn't
wait for a fresh scan to land before giving up.

Fix: explicitly drop the AP connection first to free the radio, force a
fresh scan with a settle delay, and retry the connect attempt (longer
30s timeout) up to 2 times before conceding and restoring the hotspot.
Bumped the systemd unit's TimeoutStartSec to 180s to give the retry loop
enough headroom (was defaulting to 90s).

Co-authored-by: Copilot <[email protected]>
2026-08-09 15:44:19 -04:00
Mathieu MoranandCopilot fc18f71cfe Auto power-on the SIM868 module via its PWRKEY GPIO line
Root-caused a "GPS not working" report to the SIM868 module chip being
fully powered off (the HAT's 5V rail can be live while the chip itself
stays off until PWRKEY is triggered) - confirmed by getting zero AT
response at any baud rate while the app's GPS thread had the serial
port open normally.

Discovered this specific HAT revision has the "P4" GPIO power-on mod
wire installed (Waveshare FAQ / GSM_PWRKEY.rar script), letting PWRKEY
be triggered via physical pin 7 (BCM GPIO4) instead of a manual button
press. Verified live: pulsing it low for 4s then high brought the
module from zero AT response to responding immediately.

modules/app_gps.py: added _module_responds()/_pulse_pwrkey()/
_ensure_module_powered(), wired into _poll_loop() before the existing
GNSS power-on check. Checks AT first and only pulses PWRKEY if the
module isn't already responding, since PWRKEY is a toggle (pulsing an
already-on module would turn it back off).

Also fixed a related logging gap found while debugging: print()
diagnostics from app_gps.py were invisible in yaris-obd.log for
months because Python fully-buffers stdout when it isn't a TTY.
deploy/yaris-obd.service now sets PYTHONUNBUFFERED=1.

requirements.txt: added RPi.GPIO.
PLAN.md: documented both findings and the Pi deploy gotchas (systemd
unit file changes need a manual copy + daemon-reload; requirements.txt
changes need a manual venv pip install - neither is picked up by a
plain git pull + restart).

Co-authored-by: Copilot <[email protected]>
2026-08-09 15:14:28 -04:00
Mathieu MoranandCopilot e4b0823ec2 Update readme UI screenshot to reflect current dashboard
Retook pics/ui.png to show the current dashboard, including the
Dashcam nav item added in later phases. Also switched the readme's
image link from a hardcoded old-commit GitHub URL to a relative path
so it won't go stale again.

Co-authored-by: Copilot <[email protected]>
2026-08-09 14:51:40 -04:00
Mathieu Moran e80c60c9f1 Document real-hardware phone-relay test results in PLAN.md
Deployed and tested the phone-relay feature end-to-end for real:
found and fixed a deploy gap (CORS fix wasn't rebuilt into the actual
remote server's Docker image, only pulled on the Pi - surfaced as a
generic "TypeError: Failed to fetch" in the browser). After fixing,
measured real throughput from server-side chunk timestamps: ~1.11MB/s
via phone relay (home WiFi) vs ~9-10Mbit/s for the Pi's own direct
upload - statistically the same. Confirms the relay provides no speed
benefit when the phone is on the same home WiFi as the Pi, since the
download-to-phone leg pays the same congested-radio cost either way.
Documented this finding plus a follow-up idea (per-clip browse/select
upload from the phone, not yet designed).
2026-08-09 01:13:09 -04:00
Mathieu Moran 0d67a21ae1 Add phone-relay dashcam upload to bypass the Pi's slow WiFi
Root-caused the slow-upload complaint to the Pi's own congested 2.4GHz
WiFi radio (confirmed via iperf3 - same degraded throughput even on
pure local LAN traffic, ruling out Cloudflare/ISP). Since the Pi 3 B
has no 5GHz radio, add a way to bypass it for the internet-bound leg:
a phone opens /dash and manually relays pending dashcam files straight
to the remote server over its own network, reusing the exact same
chunked upload protocol the Pi's client already uses.

- server/app.py: add CORS headers (after_request) so the phone's
  browser can call the remote server cross-origin from a page served
  by the Pi.
- app.py: new endpoints - GET /api/dash/pending (list not-yet-uploaded
  files), GET /api/dash/file/<path> (Range-request-capable download,
  path-traversal safe), GET /api/dash/relay/config (hands the phone
  the device credentials), POST /api/dash/relay/heartbeat|stop (lease-
  based pause of the Pi's own background uploader, self-heals if the
  phone's tab closes uncleanly), POST /api/dash/relay/mark_uploaded.
- modules/app_upload.py: list_pending_dashcam_files(), relay lease
  helpers, _upload_dashcam_files() now pauses while a relay lease is
  active.
- modules/app_sql.py: new dashcam_hash_cache table so a file's sha256
  is only computed once (keyed by path+size+mtime) instead of being
  recomputed on every loop iteration/pending-list call - benefits the
  existing background uploader too.
- templates/dash.html / static/js/dash.js: new "Relay via phone" card
  - per-file and "relay all" buttons (manual trigger only, per user
  preference), downloads+uploads one 40MB slice at a time so the
  phone never holds a whole file in memory, retries chunks up to 3x.
- PLAN.md/readme.md/server/README.md updated (Phase 11).

Tested via a full local end-to-end simulation driving the real Pi and
server Flask apps side by side (Postgres mocked only at the db module
boundary - all HTTP/chunking/auth/CORS logic is real): verified Range
downloads (206), byte-for-byte reassembly via SHA256, path-traversal
rejection, and the relay lease correctly pausing/resuming the
background uploader. Not yet tested against a real phone/live network.
2026-08-09 00:15:27 -04:00
Mathieu Moran 8c33c669a5 Document Phase 8/10 real-hardware validation in PLAN.md and refresh READMEs
- PLAN.md: mark Phase 8 (dashcam retrieval) verified on real hardware, add
  Phase 10 documenting the OOM/multipart and Cloudflare body-size fixes plus
  the chunked/resumable upload redesign, both verified end-to-end against
  real dashcam footage. Update architecture decision #3 (Mongo -> Postgres
  supersession), add unraid/Pi deploy notes and memory-budget gotcha.
- readme.md: refresh feature list (remote upload, chunked dashcam uploads,
  toggle) and project status (all 10 phases done, only cellular fallback
  untested).
- server/README.md: document the new chunked upload endpoints and the
  Cloudflare body-size-limit rationale behind them.
2026-08-08 23:33:01 -04:00
Mathieu MoranandCopilot 7d7be79377 Add chunked, resumable dashcam upload to work around Cloudflare's 100MB body limit
Confirmed against the real deployed server: Cloudflare Tunnel's free
tier caps request bodies at ~100MB and returns a plain 413 - a direct
LAN upload of the same 250MB test file succeeds, the identical request
through yaris.ceaf.dev does not. Real dashcam clips average ~320MB, so
no dashcam video could ever complete an upload through the tunnel as
it stood, regardless of the earlier memory-streaming fix.

Server: new /api/v1/dashcam/upload/chunk (accepts one chunk at a time,
keyed by relative_path+sha256+chunk_index/total_chunks, reassembles and
verifies the sha256 once every chunk has arrived) and
/api/v1/dashcam/upload/status (reports which chunk indices a given
upload already has, or "already_uploaded" if it's done) endpoints.
Chunks are staged under STORAGE_DIR/.upload_tmp/<device>/<sha256>/ and
cleaned up after reassembly; a stale partial set left by a differently
-chunked earlier attempt is detected via a small marker file and wiped
before restarting. The original single-shot /upload endpoint is left
in place unchanged.

Client (modules/app_upload.py): _upload_dashcam_files() now calls
_upload_video_chunked(), which checks /status first (so a prior
partial upload resumes instead of restarting from byte 0) and then
POSTs any missing 40MB chunks (DASHCAM_CHUNK_SIZE, comfortably under
Cloudflare's cap) via the same streaming MultipartEncoder approach as
before.

Co-authored-by: Copilot <[email protected]>
2026-08-08 22:17:20 -04:00
Mathieu MoranandCopilot f88b57d368 Fix OOM crash during dashcam upload: stream multipart body instead of buffering in memory
First real-hardware test of dashcam retrieval kept crashing the whole
yaris-obd service (repeated SIGKILL/OOM on the Pi 3's ~900MB RAM) a few
minutes into every run, aborting in-flight file copies. Root cause:
_upload_dashcam_files() used requests.post(files={"file": f}), which
builds the entire multipart/form-data body in memory before sending -
fine for small payloads, but this dashcam's real clips average ~320MB
each (up to a couple hundred more with overhead), so every dashcam
upload attempt alone could push the process well past 300-600MB, and
the Pi's OOM killer stepped in immediately.

Reproduced in isolation: uploading a 300MB file the old way spiked the
client process from 28MB to 628MB RSS; switching to requests-toolbelt's
MultipartEncoder (which streams the file in chunks as the request body
is sent, instead of materializing it up front) kept it flat at ~29MB
for the same upload, with an identical byte-for-byte result on the
receiving end. No server-side changes needed - the wire format is
unchanged, still ordinary multipart/form-data.

Added requests-toolbelt to requirements.txt.

Co-authored-by: Copilot <[email protected]>
2026-08-08 21:56:22 -04:00
Mathieu MoranandCopilot 833f008671 Fix dashcam mount-point creation permission error
mount_device() used a bare os.makedirs() to create the mount point,
but the app runs as an unprivileged user while /mnt is root-owned -
surfaced as a real PermissionError during the first actual hardware
test. Create the mount point via sudo mkdir -p instead, consistent
with the existing sudo mount/umount calls in this module.

Co-authored-by: Copilot <[email protected]>
2026-08-08 20:52:15 -04:00
Mathieu MoranandCopilot acaf1747f3 Fix dashcam detection for whole-disk (superfloppy) SD card formats
First real-hardware test revealed the connected dashcam's SD card ships
factory-formatted with no partition table - lsblk reports it as a bare
"disk" with an fstype set directly, never as a child "part". The
previous detection logic only matched type=="part", so it silently
missed this real device entirely.

list_usb_storage_partitions() now also matches whole-disk USB devices
that have no children and an fstype set directly on them, in addition
to the existing partition-table case. Verified against a fixture built
from this dashcam's actual `lsblk -J` output.

Co-authored-by: Copilot <[email protected]>
2026-08-08 20:50:45 -04:00
Mathieu MoranandCopilot 6d61b70a06 Add SIM868 cellular fallback for tracking uploads (untested, no SIM card)
modules/app_cellular.py: uses the SIM868's built-in AT-command HTTP(S)
client (AT+HTTPINIT/HTTPPARA/HTTPACTION/HTTPREAD) instead of a real
ppp0 interface, since the module's GNSS (polled continuously by
app_gps.py) and its GSM/GPRS radio share the same physical UART
(/dev/serial0) - a pppd/chat dial-up would take over that port
entirely and starve GNSS polling. Staying in AT-command mode lets
cellular HTTP calls interleave safely through the same serial lock.

Only carries small JSON tracking batches, never dashcam video -
cellular is far too slow/limited for that, and the modem's HTTP AT
interface has too small an internal buffer anyway. Dashcam uploads
remain WiFi/Ethernet-only, unchanged.

modules/app_upload.py: tracking-batch upload always tries WiFi/
Ethernet first; only on a connection-level failure (not an HTTP error
status, which means the server was reached) does it retry over
cellular. Exposes last_network_path/cellular_fallback_configured in
/api/upload/status; UI shows which path the last batch used.

Feature-flagged off by default via new YARIS_CELLULAR_ENABLED/
YARIS_CELLULAR_APN/_APN_USER/_APN_PASS keys in db/upload_creds.env
(all blank by default) - has zero effect on the existing WiFi upload
path unless explicitly configured. Regression-tested against the real
deployed server that normal WiFi uploads are unaffected while
cellular stays unconfigured.

*** UNTESTED against real cellular hardware - no SIM card was
*** available. See docstrings in app_cellular.py and PLAN.md for
*** exactly what needs validation once a SIM card is available.

Co-authored-by: Copilot <[email protected]>
2026-08-08 20:34:53 -04:00
Mathieu MoranandCopilot cea62d8c10 Mark Phase 9 (remote server) as DONE in PLAN.md
Reflects the completed deployment: server running on unraid, exposed
at https://yaris.ceaf.dev via Cloudflare Tunnel, Pi-side upload client
live and backlog-drain tuned.

Co-authored-by: Copilot <[email protected]>
2026-08-08 20:21:22 -04:00
Mathieu MoranandCopilot 975526667e Optimize backlog upload speed for tracking rows
The upload loop previously always slept the full 30s UPLOAD_INTERVAL
between batches, even when a full batch was just sent (meaning more
rows were likely still queued). With GPS/OBD rows logged every 5s
locally, an offline/stale device could build up a large backlog that
took a very long time to drain at 100 rows/30s.

- Bump TRACKING_BATCH_SIZE 100 -> 500 (cuts round-trips per backlog).
- Only sleep the full UPLOAD_INTERVAL once actually caught up (a
  short batch came back); while a full batch keeps coming back, drain
  back-to-back with a short CATCHUP_INTERVAL (1s) instead.
- server/app.py: add a sane MAX_TRACKING_BATCH_POINTS (5000) cap on
  the tracking/batch endpoint as a defensive guard against a runaway
  client, independent of the Pi's own batch size.

Verified against the real deployed server: a simulated 1250-row
backlog now drains in 3 batches (~0.35s server round-trip time) instead
of 13 batches spread across ~6.5 minutes of idle sleeping.

Co-authored-by: Copilot <[email protected]>
2026-08-08 20:16:31 -04:00
Mathieu MoranandCopilot 42e679782e Add Pi-side upload client for Phase 9 remote server
- app_sql.py: add UPLOADED column + migration on tracking table, add
  settings and dashcam_uploads tables, add helpers for unsent-row
  queries, marking rows uploaded, and dashcam upload dedupe tracking.
- app_upload.py: new background upload client. Periodically batches
  unsent tracking rows to POST /api/v1/tracking/batch and uploads new
  dashcam files (sha256-verified) to POST /api/v1/dashcam/upload, with
  exponential backoff on failure. Exposes get_status() and a
  dashcam-upload enable/disable toggle backed by the settings table.
- app.py: start the upload client thread at startup, add
  GET /api/upload/status and POST /api/upload/dashcam_toggle routes.
- dash.html/dash.js: add a "Remote server upload" card with upload
  status and a toggle switch to pause dashcam video uploads while
  trip/GPS log uploads keep running (per user request - useful on a
  slow/metered connection).
- db/upload_creds.env: new gitignore-exempted template (mirrors
  db/creds.env) for YARIS_SERVER_URL/YARIS_DEVICE_NAME/YARIS_API_KEY.
- requirements.txt: add requests.

Tested against the real deployed unraid server: tracking batch upload
+ dedupe, dashcam upload + dedupe, and toggle-off correctly pausing
dashcam uploads while tracking uploads continue. Also verified the
full Flask app imports/runs cleanly and the new API routes + dash.html
toggle render correctly in a headless browser.

Co-authored-by: Copilot <[email protected]>
2026-08-08 20:05:42 -04:00
Mathieu MoranandCopilot 9bdf5bad2c Phase 9: scaffold remote server (Flask + Postgres, docker-compose for unraid)
- server/sql/init.sql: devices/tracking_points/dashcam_files schema
- server/modules/db.py: psycopg3 pool + query helpers
- server/modules/auth.py: X-Device-Name/X-Api-Key header auth
- server/app.py: /health, /api/v1/tracking/batch, /api/v1/dashcam/upload
- server/Dockerfile + docker-compose.yml: api (gunicorn) + postgres services
- server/scripts/create_device.py: one-time device/API-key registration
- server/README.md: unraid deployment + Cloudflare Tunnel exposure docs
- PLAN.md: Phase 9 marked in progress with full build notes

Co-authored-by: Copilot <[email protected]>
2026-08-08 19:50:52 -04:00
Mathieu MoranandCopilot 261ef3f4db 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]>
2026-08-08 18:01:35 -04:00
[email protected] f91a3d479d fix things, some things should work 2024-09-15 21:21:39 -04:00
Mathieu Moran 9fb02041be delete __pycache__ directory 2024-04-02 19:53:37 -04:00
quoije 669f06fa8d wip sql 2024-04-01 21:29:12 -04:00
quoije f605f26f2f restructure 2024-04-01 20:21:52 -04:00
quoije 0b712c4aa2 first tsv commit 2024-04-01 12:52:27 -04:00
quoije 0b8c64454e add raw to tsv, mangodb first commit, restructure 2024-04-01 11:37:13 -04:00
quoije ebd30a0bd8 add gps function 2024-03-17 22:37:25 -04:00
quoije 650f823df8 rm temp files 2024-03-09 15:44:57 -05:00
quoije 1637f19c6d put prod server 2024-03-09 15:37:12 -05:00
quoije adbc9ea69e test inputs 2024-03-02 13:40:52 -05:00
Mathieu Moran 00e14db023 loopy loop, doesn't work, too many loops 2023-11-28 23:04:06 -05:00
Mathieu Moran 82f0c92e6c add readme.md 2023-11-18 14:11:19 -05:00
194 changed files with 29557 additions and 804 deletions
+34
View File
@@ -0,0 +1,34 @@
# 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
!db/upload_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/
dash/thumbnails/
# Remote server: local secrets and runtime data (Postgres volume, uploaded
# dashcam files) - never commit real credentials or per-device data
server/.env
server/data/
+307
View File
@@ -0,0 +1,307 @@
# yaris-obd Bug Tracker
Standalone bug/issue tracker. One `## BUG-XX` per issue. Numbering sequential.
**Status**: `OPEN` → `IN PROGRESS` → `CLOSED` (with commit ref + verification).
When CLOSED, move the entire entry to Resolved — don't duplicate.
---
## Open bugs
### BUG-19 — Dashboard OBD values slow to appear while the car is running
- **Status**: `OPEN` 2026-08-13
- **Phase**: 29
- **Symptom**: on the Pi dashboard, the speed/RPM/coolant/fuel/throttle cards stay on
"—" for a long time after the page loads (or after ignition) before populating, even
though the car is running and OBD is connected.
- **Cause**: the value cards were populated **only** by socket.io push events from the
OBD polling threads — the dashboard never pulled a current snapshot on load, so a
freshly loaded page had to wait for the next poll tick (plus any reconnect/probe
latency). `/api/obd/status` only returned the connection state, not the actual
values, so there was no REST fallback either.
- **Fix**: emit a full current-value snapshot on socket connect, and extend
`/api/obd/status` to return live speed/RPM/coolant/fuel/throttle/MIL so the dashboard
fills the cards immediately on load (and every 15 s) via REST. Needs in-car
verification.
---
## Resolved
### BUG-23 — Pi and server trip counts diverge (server drops short trips the Pi keeps)
- **Status**: `CLOSED` 2026-08-15
- **Commit**: `78aea2d`
- **Fix**: the Pi's `build_trip_summaries()` now applies the same minimum-trip filter as the
server (`duration < 30s OR distance < 0.05km` → reject), so GPS lock-in drift while the
car is stationary no longer spawns false trips. It also sorts normalized rows by
`datetime` (the server orders by `recorded_at ASC`), so clock-rollback rows can't make a
trip span a backwards time jump. `TRIP_BUILD_VERSION` bumped 3 → 4 to force a Pi rebuild.
- **Verification**: unit tests updated and pass (12/12), including a new regression test for
short GPS-drift rejection. On the Pi, trips dropped **57 → 49** after rebuild
(`trip_build_state` = (8562, 4)). The remaining gap vs the portal's 10 is the 41
intentionally-deleted trips (server-side `deleted_trip_markers`) plus ~2 trips from the
server holding 279 more raw points than the Pi — both documented in the original analysis,
not regressions.
### BUG-24 — OBD adapter stalls mid-drive; speed flatlines until the next ignition cycle
- **Status**: `CLOSED` 2026-08-15
- **Commit**: `b0ad6fd`
- **Symptom**: on trip #8 (2026-08-13, 09:59–14:50), OBD speed went **102 → None** at
**14:14:31** and never recovered for the rest of the trip (~36 min). The log showed only
`No speed data received. #['14:16:35', 102.0]` — the ELM327 returned null speed responses
while still reporting `CAR_CONNECTED`, so the 10s reconnect loop never fired. Same stall
class as BUG-21 (which happened to recover on its own after ~20 min).
- **Fix**: `obd_speed()` now counts consecutive null-speed polls; after 10 nulls (~20s) it
force-closes the connection via `_mark_connection_lost()`, so `_reconnect_loop` re-runs
`connect_obd()` and the adapter is re-handshaken instead of silently flatlining until the
car is restarted.
- **Verification**: deployed to the Pi; the forced-reconnect path will be exercised on the
next real stall (in-car verification pending).
### BUG-25 — Security hardening for the internet-exposed remote portal
- **Status**: `CLOSED` 2026-08-15
- **Commit**: `24250e0`
- **Findings** (security review of `https://yaris.ceaf.dev`):
1. **Path traversal → arbitrary file write** in `POST /api/v1/dashcam/upload/chunk` — the
`sha256` form field was concatenated into the chunk staging path with no validation, so a
digest like `../../../../tmp/x` escaped `DASHCAM_STORAGE_DIR` and let a device-authenticated
caller write files anywhere inside the container (as root — no `USER` in the Dockerfile).
2. **No login rate limiting** — `/login` was brute-forceable.
3. **Session cookies lacked Secure/SameSite flags.**
- **Fix**:
1. `sha256` is now validated as a 64-char hex digest (`_is_valid_sha256`) on the single-shot
upload, chunk-status, and chunk-upload endpoints before it is used in any filesystem path.
2. In-memory failed-attempt limiter on `/login` — 8 failures / 15 min per client IP (keyed from
`CF-Connecting-IP`, falling back to `X-Forwarded-For` then `remote_addr`) plus per-username;
returns 429 when tripped. The app runs as a single gunicorn worker, so process-local state
is sufficient (documented in a code comment for a future multi-worker deployment).
3. `SESSION_COOKIE_HTTPONLY` + `SESSION_COOKIE_SAMESITE="Lax"` + `SESSION_COOKIE_SECURE`
enabled (Secure can be disabled with `COOKIE_SECURE=0` for plain-HTTP LAN login; env var
documented in `server/.env.example`).
- **Verification**: `python3 -m py_compile server/app.py` clean; deployed to unraid, `/health` OK.
- **Remaining exposure (not code)**: the Pi's own web app (192.168.50.220:5000) is unauthenticated
and binds all interfaces. It is not tunnel-exposed, but a compromised server container could
reach it if the 192.168.10.x (Unraid) and 192.168.50.x (home) subnets route to each other —
isolate those VLANs.
### BUG-22 — Trip max speed ignores GPS-only stretch (shows OBD max only)
- **Status**: `CLOSED` 2026-08-14
- **Commit**: `90cfd7b`
- **Fix**: `max_speed_kmh` was computed from OBD speeds only, so the top speed reached during
the 20-min OBD dropout wasn't reflected. Both trip builders now union OBD speeds with
GPS-derived speeds computed over a trailing ~30s window (`GPS_SPEED_WINDOW=6`) to average out
GPS position jitter — raw 5s segments swing wildly (e.g. 58→141→58 km/h, peaks ~162), so an
unsmoothed union overshoots the real top speed. Also bumped `TRIP_BUILD_VERSION` to force a
Pi-side trip rebuild.
- **Verification**: trip #9 max went 111 (OBD-only) → 162 (raw union) → **119** (smoothed), and
trip #8 → **127.8**, identical on the server and the Pi. The 135.6 the scrubber showed was a
single noisy 5s GPS sample, not the sustained top speed.
### BUG-21 — OBD speed drops out mid-drive, replay shows 0 km/h (GPS-only)
- **Status**: `CLOSED` 2026-08-14
- **Commit**: `7c8f27b`
- **Fix**: the Pi's OBD adapter (ELM327) stopped returning speed for ~20 min on 2026-08-13
(last OBD reading 22:23:50 UTC, back at 22:43:41 UTC), so the portal replay flatlined at
0 km/h for that stretch. The server's `get_trip_route()` now mirrors the Pi's `route_points`
GPS-speed fallback — when OBD speed is missing it derives speed from the previous point's
movement (haversine / elapsed time, ≥8 m, ≤220 km/h). The Pi's own replay already did this;
only the portal was missing it.
- **Verification**: trip #9 points at 22:23:55 previously returned speed 0.0 — now return
GPS-derived speeds (e.g. 61.9 km/h at 22:23:55, labelled `gps`). API rebuilt + healthy.
- **Follow-up**: see BUG-24 — the adapter stalls and returns null speed without dropping
the "connected" status; fixed with a reconnect-on-stale-speed guard.
### BUG-20 — Server trip IDs inflate far beyond the real trip count
- **Status**: `CLOSED` 2026-08-14
- **Commit**: `9af71e2`
- **Fix**: `trips.id BIGSERIAL` burned a sequence value on every upsert in
`compute_trips_for_device()` (which runs after every upload batch), so IDs climbed far past
the real trip count. Now the sequence is clamped to `MAX(id)` after each recompute
(`server/modules/db.py`), and a one-time migration (`server/sql/migrate_phase29_trip_ids.sql`)
renumbered existing trips compactly and reset the sequence.
- **Verification**: before — `COUNT=10`, `MAX(id)=27091`; after — `MIN(id)=1`, `MAX(id)=10`,
sequence `last_value=10` (next insert gets 11). API container rebuilt and healthy.
### BUG-18 — Pi needs offline timekeeping (no RTC)
- **Status**: `CLOSED` 2026-08-14
- **Commit**: `bf4f3d3`
- **Fix**: DS3231 RTC module installed on the free I²C bus (SDA pin 3, SCL pin 5, 3.3V pin 1,
GND pin 6) with a CR2032 (charge diode removed). Pi config: `dtparam=i2c_arm=on` +
`dtoverlay=i2c-rtc,ds3231` in `/boot/firmware/config.txt`, `i2c-tools` installed,
`fake-hwclock` disabled, `i2c-dev` persisted via `modules-load.d`. CR1220 also installed in
the SIM868 HAT holder (secondary RTC backup).
- **Verification**: DS3231 probes at `0x68` (`UU`), its 24C32 EEPROM at `0x57`, `/dev/rtc0`
registered. Power-cycle test passed — after a full unplug/replug the boot log reads
`setting system clock to 2026-08-14…` from the RTC (was `2000-01-01` before), and
`hwclock -r` matches system time; the `SET TIME!` recovery line no longer appears (stable
oscillator on CR2032). (Debugging note: the first scans were an empty bus due to a data-line
wiring issue, resolved during re-soldering.) SIM868 HAT RTC was also set to the correct time
via `AT+CCLK` (was factory-default 2004) — its CR1220 hold will be confirmed on the next
power cycle.
### BUG-17 — Server trip timestamps wrong after an offline period
- **Status**: `CLOSED` 2026-08-14
- **Commit**: `bf4f3d3`
- **Fix**: root cause was BUG-18 — no RTC, so offline the Pi ran on a stale `fake-hwclock`
time and NTP snapped the clock forward mid-trip. Fixed by installing the DS3231 (BUG-18).
- **Verification**: RTC power-cycle test passed (BUG-18). Historical trips 25136/25137 keep
their bad timestamps (raw tracking rows were recorded with a wrong clock and can't be
retroactively corrected) — future trips will be correct.
### BUG-15 — Phone can't host the hotspot and connect to the Pi's AP at once
- **Status**: `CLOSED` 2026-08-13
- **Commit**: `ab79820`
- **Resolution**: Confirmed a **phone-OS limitation**, not a Pi bug. A single phone
can't cleanly be both the Pi's internet uplink (hotspot/AP host) and its WiFi client
(on the Pi's AP) at the same time — doing so routes the hotspot's traffic back through
the no-internet AP, creating a routing loop (`NameResolutionError` on uploads).
- **Workaround (documented)**: the phone must be **LTE-only** (WiFi off) to serve as the
Pi's uplink. Reach the Pi UI from that phone over the hotspot at
`http://<pi-hotspot-ip>` (Settings → "In-car hotspot"), or from a second device on the AP.
- **Pi-side hardening shipped**: `phone-hotspot` profile forces public DNS
(`8.8.8.8`/`1.1.1.1`), and the Settings page surfaces the correct URL per uplink mode.
### BUG-16 — Trip missing from Pi local history (present on server)
- **Status**: `CLOSED` 2026-08-13
- **Commits**: `e5ac14e`, `f5f20c3`
- **Fix**: two root causes:
1. `connect_obd()` set `_car_ever_connected` **without a `global` declaration**, so the
module-level flag was never set and `was_car_ever_connected()` always returned `False` —
local trip building had been silently blocked ever since BUG-10 landed. Added the `global`
declaration and now persist the flag to the settings table (`car_ever_connected`) so it
survives reboots.
2. Speed-only rows (NULL GPS, from FEAT-12) made trips start/end with NULL coordinates,
violating the `trips.*GPSX NOT NULL` constraint. The trip builder now uses the first/last
GPS-bearing row for start/end coordinates, and skips a trip that never got any fix.
- **Verification**: backfilled the persisted flag on the Pi (the car has been connected
before), triggered a rebuild — the local `trips` table now contains the 2026-08-13 drive
(e.g. a 48.5 km trip) plus several earlier trips that had been silently dropped.
`trip_build_state` advanced to LAST_TRACKING_ID=7009. Timestamps for the 08-13 data are
still wrong — that's BUG-17 (no RTC).
### BUG-13 — Server Web UI shows raw coordinates instead of geocoded location on trips
- **Status**: `CLOSED` 2026-08-12
- **Commit**: `8309819`
- **Fix**: Server `_geocode_cached()` now always returns a non-empty string with
coordinate fallback when Nominatim/reverse_geocoder fail. `/api/geocode` always
returns HTTP 200 with `{label}` (never 204). Batch endpoint includes all keys.
Nominatim params + reverse_geocoder mode aligned with Pi. Pi also hardened against
empty reverse_geocoder label.
- **Verification**: Deployed to Unraid (Docker rebuild) and Pi (git pull + restart).
Both healthy. Unraid now uses SSH deploy key for git fetch (was failing on HTTPS auth).
### BUG-14 — Geocode slow/unreliable — location labels fail to load during scrubbing
- **Status**: `CLOSED` 2026-08-12
- **Commit**: `0ddfe39`
- **Fix**: Ditched Nominatim entirely — it was slow (1 req/sec rate limit, ~4s
timeouts) and often blocked/throttled from Docker/Cloudflare IPs. Replaced with
reverse_geocoder exclusively: an offline Python library that maps lat/lon →
city/province instantly with no network. JS `preload()` switched from N individual
GET requests to single `POST /api/geocode/batch`. Server batch endpoint simplified
to sequential loop with lru_cache + dedup.
- **Verification**: `POST /api/geocode/batch` returns `{"Montreal, Quebec",
"Longueuil, Quebec"}` instantly on Pi. Server Docker rebuilt and healthy.
### BUG-01 — WiFi/hotspot unreachable from phone while cell data is on
- **Status**: `CLOSED` 2026-08-11
- **Fix**: Dual-radio — wlan0="yaris 911" at 10.42.0.1 (password "yaris911!"),
wlan1=uplink. Never touches wlan0. Phone-hotspot auto-connect on wlan1 within ~60s,
falls back to home-wifi. Settings page shows in-car access card.
- **Verification**: iOS + Android connect to AP, `yaris.local` resolves. Phone-hotspot
uplink tested: auto-connects when hotspot enabled, falls back when disabled. wlan0 AP
stays up throughout.
### BUG-02 — Micro-USB power port on the Pi is physically damaged
- **Status**: `CLOSED` 2026-08-11
- **Fix**: Hard-wired 5V/GND directly to GPIO header — red to Pin 2 (5V),
black to Pin 6 (GND), bypassing the worn micro-USB port.
- **Verification**: `vcgencmd get_throttled` returns `0x0` — no undervoltage,
no throttling. Pi runs at full 1.2 GHz. GPS has fix.
### BUG-03 — DTC/MIL codes never persisted
- **Status**: `CLOSED` 2026-08-11
- **Commit**: `e2f2314`
- **Fix**: `save_dtc_state()` persists MIL+codes each poll. `/api/dtc` falls back to
`get_persisted_dtc_state()` when OBD offline. Cleared codes tracked via IS_SET timestamps.
- **Verification**: Deployed to Pi, `/api/dtc` returns persisted snapshot, stats show `active_dtc: 0`.
### BUG-04 — Server merges two trips into one
- **Status**: `CLOSED` 2026-08-11
- **Commit**: `96fb897`
- **Fix**: Replaced time-gap splitting with Pi's motion-aware logic: OBD > 1 km/h primary,
GPS >= 8m × 3 consecutive fallback, 5-min inactivity gap.
- **Verification**: Server trips cleared + recomputed. 17 trips on both Pi and server.
### BUG-05 — Server route replay speed_source always "gps"
- **Status**: `CLOSED` 2026-08-11
- **Commit**: `e2f2314`
- **Fix**: `"obd" if speed > 0 else "gps"` matching Pi logic.
- **Verification**: Docker rebuild, `/health` OK LAN + Cloudflare.
### BUG-11 — Dashboard GPS shows "Stale fix" while Status shows "Live fix"
- **Status**: `CLOSED` 2026-08-11
- **Commit**: `9733036`
- **Fix**: `renderGpsStatus()` now reads `status.latitude`/`status.longitude` from
the `/api/gps/status` response (not just socket events). Status page now uses the
same 30s staleness threshold (`fix_age_seconds > 30` → "Stale fix") instead of
calling any fix "Live" regardless of age.
- **Verification**: Deployed to Pi, dashboard GPS card now shows lat/lon coordinates
even when fix is stale. Both pages agree on fix status.
### BUG-06 — Server timestamps are shifted (naive local time stored as UTC)
- **Status**: `CLOSED` 2026-08-11
- **Commit**: `7ce2a1e` (upload fix), manual migration on server
- **Fix**:
1. `_to_tz_aware()` in `app_upload.py` converts naive SQLite timestamps to
TZ-aware ISO before upload (`2026-08-10 19:27:30` → `2026-08-10T19:27:30-04:00`).
2. One-time SQL migration on Unraid: shifted 3,593 tracking rows +4h EDT,
cleared deleted-trip markers, recomputed 43 trips from corrected data.
- **Verification**: Server trips now show Aug 10 at 7:27 PM / 7:58 PM (was 3:27 / 3:58).
### BUG-12 — Dashboard OBD shows "—" while Status page shows correct OBD state
- **Status**: `CLOSED` 2026-08-11
- **Commit**: `91281b1`
- **Fix**: Added `/api/obd/status` endpoint. Dashboard calls `refreshObdStatus()`
on load + every 15s. Socket events still update in real-time. Side fix: `Number()`
wrap on API string lat/lon prevents crash. Socket `gps_location` now passes lat/lon
to `renderGpsStatus` (`a4a92cc`).
- **Verification**: Dashboard shows "Adapter only" matching Status page. GPS lat/lon
display correctly. Confirmed on live Pi.
### BUG-02 — Micro-USB power port on the Pi is physically damaged
- **Status**: `CLOSED` 2026-08-11
- **Fix**: Hard-wired 5V/GND directly to GPIO header — red to Pin 2 (5V),
black to Pin 6 (GND), bypassing the worn micro-USB port.
- **Verification**: `vcgencmd get_throttled` returns `0x0` — no undervoltage,
no throttling. GPS has fix. Pi runs at full 1.2 GHz.
### BUG-07 — Trip replay on portal sometimes does nothing
- **Status**: `CLOSED` 2026-08-11
- **Commit**: `fa93384`
- **Fix**: Full rewrite of `static/js/history.js`: unified AbortController cancels fetch
+ all downstream rendering on trip switch; 300ms debounce prevents double-tap stacking;
loading spinner on map + `.table-active` row highlight give clear feedback; geocode
preload starts immediately before map rendering so cache is warm for the scrubber;
`signal.aborted` guards after every async step (route build, fitBounds, timeline init).
- **Verification**: Deployed to Pi and server. Rapid trip-switching no longer freezes.
Verified on both platforms 2026-08-11.
### BUG-08 — No trip deletion on local Pi
- **Status**: `CLOSED` 2026-08-11
- **Commit**: `c5c478b`
- **Fix**: `deletable` macro, `window.confirmDelete`, `DELETE /api/trip/<id>`, configurable
`data-delete-url` in shared `history.js`. Raw tracking preserved.
- **Verification**: Trip 1967 deleted (18→17 trips), 2028 tracking rows preserved.
### BUG-09 — Server trip scrubber geocoding is slow
- **Status**: `CLOSED` 2026-08-11
- **Fix**: `preloadGeocodes()` batch-fetches all unique coords (concurrency 4) on trip load.
`geocodePoint()` checks in-memory cache first.
- **Verification**: `preloadGeocodes` present in history.js on both Pi and server Docker container.
### BUG-10 — GPS-only false trips when car not connected
- **Status**: `CLOSED` 2026-08-11
- **Fix**: `_car_ever_connected` flag in `app_obd.py`. `build_trip_summaries()` returns
empty list when car was never connected.
- **Verification**: Pi bench-test shows 0 trips. No OBD = no GPS-only trips.
+304
View File
@@ -0,0 +1,304 @@
# yaris-obd Feature Requests
Ideas and requests for **new features** (distinct from bugs, which belong in `BUGS.md`, and the
phase-by-phase implementation history in `PLAN.md`). Use this file to record anything that should
be built eventually but isn't an active roadmap phase yet. When a feature gets picked up, create a
dedicated PLAN.md phase (or fold it into the relevant existing work) and mark it here.
## How to use this file
- **One `## FEAT-XX` section per idea.** Keep numbering sequential.
- **Status field**: `IDEA` (brainstormed, not planned) → `PLANNED` (has an assigned phase) →
`IN PROGRESS` → `SHIPPED` (deployed + verified) or `WONTFIX`/`SUPERSEDED` as appropriate.
- **Relate to roadmap**: when a feature lands in a phase, note the phase here and drop a link back
here from PLAN.md so the two stay in sync.
- **Keep each entry small and one-idea-per-entry** so it's easy to pick up and scope.
---
## Summary
| ID | Status | Title | Phase |
|----|--------|-------|-------|
| FEAT-01 | `IDEA` | Parallel/concurrent chunked dashcam uploads | Phase 27 |
| FEAT-02 | `IDEA` | QR / claim-code device pairing flow | Phase 27 |
| FEAT-03 | `SHIPPED` | Manual start odometer fallback | Phase 27 |
| FEAT-04 | `IDEA` | Replace SIM868 2G with 4G LTE modem | Phase 27 |
| FEAT-05 | `SHIPPED` | DTC history synced to remote server | Phase 27 |
| FEAT-06 | `SHIPPED` | Trip ID column in history table | Phase 26 |
| FEAT-07 | `SHIPPED` | Phone-hotspot auto-connect docs | Phase 22 |
| FEAT-08 | `SHIPPED` | Predictable IP on phone hotspot | Phase 22 |
| FEAT-09 | `WONTFIX` | mDNS (yaris.local) on phone hotspot | — |
| FEAT-10 | `IDEA` | Home Assistant integration | unassigned |
| FEAT-11 | `SHIPPED` | Hotspot SSID/password settings + 5GHz WiFi role swap | Phase 27 |
| FEAT-12 | `SHIPPED` | Log OBD speed before GPS fix (rolling-start capture) | Phase 27 |
| FEAT-13 | `IDEA` | Flush uploaded tracking rows from local DB | unassigned |
| FEAT-14 | `SHIPPED` | Second T3U to replace onboard wlan0 (dual AC radios) | Phase 29 |
| FEAT-15 | `SHIPPED` | Clear DTC codes from the DTC page | Phase 29 |
| FEAT-16 | `SHIPPED` | Max-speed marker on trip replay map | Phase 29 |
---
## Feature ideas
### FEAT-01 — Parallel/concurrent chunked dashcam uploads
- **Status**: `IDEA`
- **Area**: `modules/app_upload.py` (Pi upload client)
- **Phase**: Phase 27
- **Requested**: noted in Phase 10 of PLAN.md as a possible future optimization; not yet requested
explicitly by the owner.
- **Idea**: upload multiple dashcam chunks (and/or multiple files) concurrently instead of the
current strictly sequential one-chunk-then-one-file at a time. Would speed up the multi-hour
full-backlog drain of a large local dashcam archive, at the cost of more complexity (bounded
concurrency, per-file state, failure handling for the individual concurrent workers).
- **Outstanding considerations**: memory budget on the Pi (~900MB, no swap — must stay streaming),
and whether the sequential pace is actually annoying enough in practice to justify the added
complexity.
### FEAT-02 — QR / claim-code device pairing flow
- **Status**: `IDEA`
- **Area**: server device provisioning + Pi credential setup (Phase 25.5c)
- **Phase**: Phase 27
- **Requested**: discussed in Phase 25.5c as a possible follow-up to the manual copy/paste API-key
provisioning.
- **Idea**: zero-manual-copy provisioning of `YARIS_SERVER_URL` / `YARIS_DEVICE_NAME` /
`YARIS_API_KEY` onto the Pi — e.g. the portal shows a short-lived claim code / QR that the Pi
app (or owner) enters/scans once to adopt the device, instead of typing credentials by hand.
- **Outstanding considerations**: bigger scope than the current copy/paste flow; ship copy/paste
first, add pairing only if provisioning still feels annoying.
### FEAT-03 — Manual start odometer fallback when OBD odometer is unavailable
- **Status**: `SHIPPED` 2026-08-11 (extended 2026-08-12)
- **Area**: Settings page, Dashboard, trip history, `modules/app_settings.py`, `modules/app_sql.py`,
`modules/app_trip.py`, `server/modules/db.py`, `static/js/history.js`
- **Phase**: Phase 27
- **What**: Settings page now has an "Odometer baseline (km)" field. The app stores
this value and computes `odometer_km = baseline + cumulative trip distance`. Dashboard
shows "(manual)" label when using baseline. When baseline is 0, falls back to live
OBD reading (the original behavior). Also appears in `/api/stats` response.
- **2026-08-12 — simulated odometer in trip history**: the trip replay scrubber now
shows an "Odometer" line below speed. Per-point odometer is the raw OBD value when
the vehicle reports one (PID 0xA6), otherwise a **continuous simulation**:
`baseline + Σ(prior trips' distance) + within-trip distance`. The trip-list
"Odometer" column uses the same continuous formula on both Pi and server (previously
the server hardcoded `None` and the Pi ignored the baseline). The 2013 Yaris doesn't
report PID 0xA6, so every point uses the simulated value.
### FEAT-04 — Replace SIM868 2G GSM hat with 4G LTE modem (Huawei E3372h-153)
- **Status**: `IDEA`
- **Area**: modems / uplink (Pi `modules/app_cellular.py`, `modules/app_gps.py`, uplink
switching) — replaces the current SIM868 GSM/GNSS HAT + cellular fallback path
- **Phase**: Phase 27
- **Requested**: owner — just purchased a Huawei **E3372h-153 4G LTE Modem**. The 2G/3G-only
SIM868 hat will not work in Canada (2G networks are decommissioned), so the plan is to make the
4G modem the working cellular uplink instead.
- **Idea**: integrate the Huawei E3372h-153 as the primary cellular data uplink, replacing reliance
on the SIM868's GSM (AT+HTTP) path. The E3372h-153 is a USB "stick" modem — likely supported via
Realtek / cdc_ether / usb-storage based drivers and exposes a dial-up or LTE dongle interface the
Pi can use for connectivity (e.g. via NetworkManager / ModemManager, or a static APN config).
- **Outstanding considerations**:
- Determine the E3372h-153's local mode vs stick/dial mode and the correct Linux driver/interface
(hierarchical USB mode switching may be needed; it's the "153" variant which may use the RNDIS /
ECM / CDC-NCM profile).
- Whether to run it as the *primary* uplink or just another option in the existing phone-hotspot /
home-wifi / local-AP priority chain.
- What happens to the SIM868 — keep it purely for its **GPS/GNSS** role (it's the PWRKEY-toggled
GNSS receiver on `/dev/serial0`) while dropping its **2G GSM** cellular path, vs removing it
entirely.
- The SIM868's `app_gps.py` GNSS positioning must keep working (it's the car's GPS source) even if
the 2G-modem half is retired — separate the two concerns.
- **Keep this feature as-is (2G/GSM fallback) for countries where 2G/3G still works** — don't
remove the existing cellular fallback code; gate it so the 2G path remains available there.
- LTE SIM plan/APN specifics for Canadian carriers (Rogers/Bell/Telus) and whether the modem can
run the Pi's hotspot/SSID the same way `app_network.py` currently does.
### FEAT-05 — DTC history synced to remote server with code descriptions
- **Status**: `SHIPPED` 2026-08-12
- **Area**: `modules/app_upload.py`, `server/sql/migrate_phase27_dtc.sql`, `server/modules/db.py`, `server/app.py`, `server/templates/dtc.html`
- **Phase**: Phase 27
- **What**: Pi pushes current DTC snapshot to server via `POST /api/v1/dtc/batch`.
Server diffs against stored state — new codes inserted, existing updated, missing
codes auto-cleared. Portal has a new `/dtc` page showing code, description, MIL
state, active/cleared status, first/last seen timestamps, and manual delete.
Upload skips when OBD disconnected (avoids clearing server DTCs on Pi reboot).
Snapshot is hash-compared to avoid redundant uploads.
### FEAT-06 — Trip ID column in history table
- **Status**: `SHIPPED` 2026-08-11
- **Area**: `static/js/history.js`, `templates/history.html`, `server/templates/history.html`
- **Phase**: Phase 26
- **What**: Narrow `#ID` column on server history (hidden on Pi via `data-show-id`).
Checkbox column and batch-delete restored on server only.
### FEAT-07 — Phone-hotspot auto-connect documentation
- **Status**: `SHIPPED` 2026-08-11 (already implemented in Phase 22, documented here)
- **Area**: `deploy/yaris_uplink.py`, Settings page, netwatch timer
- **What**: The Pi auto-connects to a saved phone hotspot on wlan1 within ~60s of it
appearing in range, and falls back to home-wifi when it disappears. The wlan0 AP
stays up regardless. Test flow: Settings → save hotspot name + password → "Wait for
hotspot" → enable phone hotspot → verify mode changes to "Phone hotspot".
- **Known limitation (2026-08-12)**: the phone can't host the hotspot *and* connect to
the Pi's AP at the same time — that creates a WiFi-sharing/routing loop and the Pi
loses internet (`NameResolutionError` on uploads). To use the phone hotspot as the
Pi's uplink, the phone must be LTE-only (WiFi off). Browse the Pi UI from a second
device on the AP instead. Also, the phone's DHCP DNS was flaky for the `.dev` tunnel,
so `phone-hotspot` now forces `8.8.8.8` + `1.1.1.1` (see `deploy/yaris_uplink.py`).
### FEAT-08 — Predictable IP on phone hotspot (DHCP client-id)
- **Status**: `SHIPPED` 2026-08-11 (Phase 22)
- **Area**: `deploy/yaris_uplink.py`, phone-hotspot NM profile
- **What**: `dhcp-client-id=yaris-obd` in the NM keyfile gives the Pi a consistent DHCP
lease. The "Last phone IP" link on Settings shows the current address. Pixel testing
confirmed same IP across disconnects.
### FEAT-09 — mDNS (yaris.local) on phone hotspot
- **Status**: `WONTFIX` 2026-08-12 — hardware/OS limitation
- **Area**: Avahi, phone-hotspot NM profile
- **What**: Phone hotspot hosts (Android, iOS) don't relay multicast DNS. Avahi publishes
correctly on wlan0 AP and home WiFi. Settings "Last phone IP" link is the workaround.
Cannot be fixed in software — this is a phone-OS limitation.
### FEAT-10 — Home Assistant integration
- **Status**: `IDEA`
- **Area**: `modules/app_homeassistant.py` (new), `server/` (API endpoints), Pi-side MQTT/HTTP
- **Phase**: unassigned
- **Requested**: owner — wants the car's live telemetry and status visible in Home Assistant
dashboards alongside other smart-home devices.
- **Idea**: expose yaris-obd data (speed, RPM, coolant temp, fuel level, GPS position, DTC
alerts, ignition state, trip stats, cellular signal, etc.) as Home Assistant entities so
they appear automatically via MQTT discovery or a REST-based integration. The Pi already
collects all this data — the integration just needs to publish it in a format Home
Assistant can consume.
- **Possible approaches** (to scope later):
- **MQTT discovery**: Pi publishes to a local MQTT broker (e.g. Mosquitto on the same
network or on the HA host) with Home Assistant's auto-discovery topic schema
(`homeassistant/sensor/yaris/.../config`). HA picks up the entities automatically with
zero manual YAML. Works well on home WiFi; not available on phone hotspot or cellular.
- **REST API integration**: a custom Home Assistant integration (HACS or core) that polls
the Pi's `/api/stats` (or a new `/api/v1/ha/state` endpoint on the server) periodically.
Works from anywhere the server is reachable, including remotely via the self-hosted
server.
- **Hybrid**: MQTT when on home WiFi (real-time, low-latency), REST via server as a
fallback for remote/cellular visibility.
- **Entity ideas**:
- `sensor.yaris_speed` (km/h), `sensor.yaris_rpm`, `sensor.yaris_coolant_temp` (°C)
- `sensor.yaris_fuel_level` (%), `sensor.yaris_odometer` (km)
- `device_tracker.yaris_location` (lat/lon from GPS)
- `binary_sensor.yaris_ignition` (on/off), `binary_sensor.yaris_mil` (check engine light)
- `sensor.yaris_dtc_count`, `binary_sensor.yaris_dtc_active` (any active DTCs)
- `sensor.yaris_cellular_signal` (dBm or %), `sensor.yaris_uplink_mode` (home/phone/cell)
- `sensor.yaris_trip_distance`, `sensor.yaris_trip_duration` (current trip)
- `device_tracker.yaris_phone_hotspot` (when connected via phone hotspot — confirms car
is near the phone)
- **Outstanding considerations**:
- MQTT path only works when the Pi is on the same LAN as Home Assistant (home WiFi) — not
in the car on its own AP or cellular. This may be fine for "car is home" use cases
(charging status, maintenance reminders, trip summaries after returning).
- REST path via the self-hosted server works remotely but adds latency and depends on the
server being up.
- Decide whether to publish directly from the Pi or from the server (server already has
post-upload trip data; Pi has real-time data the server doesn't).
- Home Assistant OS vs container vs core install — MQTT discovery is universal, REST
would need a custom component or HACS add-on.
- Keep the integration opt-in and lightweight — don't add MQTT dependencies to the core
app.py startup unless the user explicitly configures it.
### FEAT-11 — Hotspot SSID/password settings + 5GHz WiFi role swap
- **Status**: `SHIPPED` 2026-08-12
- **Area**: `app.py`, `templates/settings.html`, `static/js/settings.js`,
`deploy/yaris-hotspot.sudoers`, `deploy/yaris_uplink.py`, `deploy/install_netwatch.sh`,
`modules/app_network.py`
- **Phase**: Phase 27
- **What**: Two related changes:
1. **Hotspot settings UI** — the Pi Settings page "In-car access" card now has SSID +
password fields (password write-only). `GET /api/hotspot` reads the current SSID via
nmcli; `POST /api/hotspot` updates SSID/password and restarts the AP immediately.
Backed by `deploy/yaris-hotspot.sudoers` (privileged nmcli on the `Hotspot-local`
profile).
2. **WiFi role swap** — the fast TP-Link T3U (wlan1) now hosts the in-car AP on **5GHz**
("vroom vroom yaris de course" at 10.42.0.1) for fast phone access (Android dev, video
browsing); the weak onboard BCM43438 (wlan0) carries the internet uplink (home WiFi
"BuzzBis" + phone hotspot, static IP 192.168.50.220). This was done because the AP on
the onboard radio was too slow for interactive Android work. Tradeoff: away-from-home
uploads now run over the weak radio (accepted — background task). A single radio cannot
be AP + client at once, so the phone-hotspot uplink must stay on wlan0.
- **Files**: `yaris_uplink.py` (PRIMARY=wlan0, AP_INTERFACE=wlan1, AP_CONNECTION=Hotspot-local),
`app_network.py` (uplink on wlan0, AP on wlan1), `install_netwatch.sh` (rebinds profiles).
### FEAT-12 — Log OBD speed before GPS fix (rolling-start capture)
- **Status**: `SHIPPED` 2026-08-13
- **Area**: `modules/app_sql.py` (`_log_loop`, `insert_tracking`), `modules/app_trip.py`
(`normalize_tracking_rows`, `_segment`, `route_points`)
- **What**: The tracking logger previously only wrote rows once GPS had a fix, so a rolling
start or mid-drive reboot lost the first ~1.5–2 min of movement (boot + GPS cold start).
Now, while GPS has no fix but OBD speed > 1 km/h, the logger writes speed-only rows with
NULL coordinates; trip building treats them as OBD-motion events (zero GPS distance). The
route polyline drops these rows (no position to draw).
- **Server parity note**: server `compute_trips_for_device` and `get_trip_route` already filter
`gps_lat IS NOT NULL`, so they safely ignore the NULL-coordinate rows — but the server's
recomputed trips don't yet include the speed-only leading motion (follow-up if needed).
### FEAT-13 — Flush uploaded tracking rows from local DB
- **Status**: `IDEA`
- **Area**: `modules/app_sql.py` (`tracking` table), `modules/app_upload.py`
- **Phase**: unassigned
- **What**: The local SQLite `tracking` table keeps every raw row forever — rows are only
marked `UPLOADED=1`, never deleted. Once rows are confirmed uploaded to the server, provide
a way to flush them locally (e.g. a settings toggle + retention window) so the local DB stays
bounded over years of driving.
- **Considerations**:
- Trip summaries (`trips` table) are derived and can be kept independently; but the local
route-replay polyline is built from raw `tracking` rows, so purging rows removes local route
detail. The server keeps full `tracking_points`, so replay stays available on the portal.
- Must be opt-in and only delete rows that are `UPLOADED=1` (and older than a grace window)
so a failed upload or re-send never loses data.
- Tracking data is small (~90 B/row, ~a few MB/month) so this is long-term hygiene, not
urgent — imported dashcam videos are the real disk consumer.
### FEAT-14 — Second T3U to replace onboard wlan0 (dual AC radios)
- **Status**: `SHIPPED` 2026-08-14
- **Area**: hardware + `deploy/yaris_uplink.py`, `deploy/install_netwatch.sh`, `modules/app_network.py`, `deploy/90-yaris-uplink`
- **Phase**: Phase 29
- **What**: Owner added a second TP-Link Archer T3U (RTL8812BU) to replace the onboard
BCM43438 as the internet uplink. Both radios are now AC1300 T3U dongles — `wlan1` (T3U #1)
hosts the 5GHz in-car AP, `wlan2` (T3U #2) carries the uplink (home-wifi + phone-hotspot),
and the onboard `wlan0` is retired to a weak fallback. Removes the old tradeoff where
away-from-home uploads ran over the weak onboard radio.
- **Changes**: `PRIMARY_INTERFACE=wlan2` / `FALLBACK_INTERFACE=wlan0` / `AP_INTERFACE=wlan1`
in `yaris_uplink.py` + `app_network.py`; `install_netwatch.sh` rebinds `home-wifi` +
`phone-hotspot` to wlan2 and recreates the `home-wifi-fb` fallback on wlan0; the NM
dispatcher (`90-yaris-uplink`) now listens on `wlan0|wlan1|wlan2`.
- **Verified**: the second T3U enumerates as `wlan2` (rtl88x2bu) and
`vcgencmd get_throttled` = `0x0` (no undervoltage with both dongles powered).
- **Open item**: interface-name pinning relies on USB enumeration order; if the two T3Us ever
swap ports, `wlan1`/`wlan2` swap — a MAC-based udev/`.link` rule is the robust follow-up.
### FEAT-15 — Clear DTC codes from the DTC page
- **Status**: `SHIPPED` 2026-08-13
- **Area**: `modules/app_obd.py`, `modules/app_sql.py`, `app.py`, `templates/dtc.html`
- **Phase**: Phase 29
- **What**: The Pi `/dtc` page now has a **Clear codes** button that sends OBD mode 04
(`CLEAR_DTC`) to the ECU and resets both the live and persisted DTC state, so codes can be
cleared right after a repair without a separate scanner. If the adapter isn't connected to
the car ECU it reports "not connected" (409), and if a fault is still physically present the
codes legitimately re-appear on the next sensor poll.
- **Files**: `clear_dtc_codes()` in `app_obd.py` (mode 04 + in-memory/socket reset),
`clear_dtc_state()` in `app_sql.py` (marks active rows `IS_SET=0`, resets `dtc_mil`),
`POST /api/dtc/clear` in `app.py`, and the button + confirm/status JS in `templates/dtc.html`.
### FEAT-16 — Max-speed marker on trip replay map
- **Status**: `SHIPPED` 2026-08-15
- **Area**: `static/js/history.js`
- **Phase**: Phase 29
- **What**: when a trip's route loads, the replay map now pins the fastest point with an
amber circle + a permanent "Max speed: X km/h" tooltip. It uses the per-point speed (OBD
when available, otherwise the smoothed GPS-derived value), so it matches the trip summary's
`max_speed_kmh`. Works identically on the Pi and the server portal (shared `history.js`).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-2026 quoije
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2500
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
# Android project ignores
*.iml
.gradle/
build/
local.properties
.idea/
*.apk
*.aab
*.jks
*.keystore
keystore/
keystore.properties
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
+135
View File
@@ -0,0 +1,135 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
// Release signing config loaded from keystore.properties (gitignored).
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.yarisobd.app"
compileSdk = 35
defaultConfig {
applicationId = "com.yarisobd.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
// Compose BOM
val composeBom = platform(libs.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
// Compose UI
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
debugImplementation(libs.compose.ui.tooling)
// Navigation
implementation(libs.navigation.compose)
// Activity + Compose integration
implementation(libs.activity.compose)
// Core
implementation(libs.core.ktx)
// Lifecycle
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
// Network
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
// Local storage — will be used in 28.3
// implementation(libs.room.runtime)
// implementation(libs.room.ktx)
// ksp(libs.room.compiler)
// Background work
implementation(libs.workmanager)
// Images
implementation(libs.coil.compose)
implementation(libs.coil.okhttp)
// Maps
implementation(libs.maplibre)
// Security
implementation(libs.security.crypto)
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}
+45
View File
@@ -0,0 +1,45 @@
# Add project specific ProGuard rules here.
# Keep rules for libraries used:
# Retrofit
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.KotlinExtensions
-dontwarn retrofit2.KotlinExtensions$*
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Kotlinx Serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class com.yarisobd.app.**$$serializer { *; }
-keepclassmembers class com.yarisobd.app.** {
*** Companion;
}
-keepclasseswithmembers class com.yarisobd.app.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Coil
-dontwarn coil.**
# MapLibre
-keep class org.maplibre.** { *; }
# Room
-keep class * extends androidx.room.RoomDatabase
-dontwarn androidx.room.paging.**
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internet: Pi hotspot API calls + remote server uploads -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Foreground service for uploads (28.3) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Notification permission for upload progress (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Access external storage for downloaded dashcam files -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:name=".YarisApp"
android:allowBackup="false"
android:fullBackupOnly="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.YarisOBD"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.YarisOBD.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,303 @@
package com.yarisobd.app
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.yarisobd.app.data.remote.dto.GpsStatus
import com.yarisobd.app.data.remote.dto.ObdStatus
import com.yarisobd.app.data.remote.dto.Stats
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val MIL_AMBER = Color(0xFFE6A817)
@Composable
fun DashboardScreen(
gps: GpsStatus?,
obd: ObdStatus?,
stats: Stats?,
piOnline: Boolean,
serverOnline: Boolean,
lastUpdate: Long,
onStartPolling: () -> Unit,
onStopPolling: () -> Unit,
) {
// Poll while this screen is composed; stop when the user navigates away.
DisposableEffect(Unit) {
onStartPolling()
onDispose { onStopPolling() }
}
Column(modifier = Modifier.fillMaxSize()) {
// Connection status bar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
StatusDot(online = piOnline, label = "Pi WiFi", color = MaterialTheme.colorScheme.primary)
StatusDot(online = serverOnline, label = "Server", color = MaterialTheme.colorScheme.tertiary)
Spacer(Modifier.weight(1f))
if (lastUpdate > 0L) {
Text(
"Updated ${SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(lastUpdate))}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
SpeedCard(obd, Modifier.weight(1f))
RpmCard(obd, Modifier.weight(1f))
}
EngineStatsCard(obd)
MilCard(obd, stats)
GpsCard(gps)
OverviewCard(stats)
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
private fun StatusDot(online: Boolean, label: String, color: Color) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(10.dp)
.background(if (online) color else MaterialTheme.colorScheme.surfaceVariant, CircleShape)
)
Spacer(Modifier.width(6.dp))
Text(
label,
style = MaterialTheme.typography.bodySmall,
color = if (online) color else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun SpeedCard(obd: ObdStatus?, modifier: Modifier = Modifier) {
val speed = obd?.speed
val connected = obd?.car_connected == true
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(Modifier.fillMaxWidth().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Filled.Speed, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp))
Spacer(Modifier.height(6.dp))
Text(speedText(speed), fontSize = 44.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
Text("km/h", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
Text(
if (connected) "OBD speed" else "OBD offline",
style = MaterialTheme.typography.bodySmall,
color = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
}
}
}
@Composable
private fun RpmCard(obd: ObdStatus?, modifier: Modifier = Modifier) {
val rpm = obd?.rpm
Card(
modifier = modifier,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(Modifier.fillMaxWidth().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("RPM", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(6.dp))
Text(intText(rpm), fontSize = 44.sp, fontWeight = FontWeight.Bold, color = rpmColor(rpm))
Text("rev/min", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun EngineStatsCard(obd: ObdStatus?) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Row(Modifier.fillMaxWidth().padding(16.dp)) {
StatCell("Coolant", "${intText(obd?.coolant_temp)} °C", Modifier.weight(1f))
StatCell("Fuel", "${intText(obd?.fuel_level)} %", Modifier.weight(1f))
StatCell("Throttle", "${intText(obd?.throttle_position)} %", Modifier.weight(1f))
}
}
}
@Composable
private fun StatCell(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp))
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun MilCard(obd: ObdStatus?, stats: Stats?) {
val mil = obd?.mil_on
val dtcCount = stats?.active_dtc ?: 0
val color = when {
mil == true -> MIL_AMBER
mil == false -> Color(0xFF2E7D32)
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
val label = when {
mil == true -> "MIL ON — Check Engine"
mil == false -> "No MIL"
else -> "MIL unknown"
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Warning, null, tint = color, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.titleSmall, color = color, fontWeight = FontWeight.SemiBold)
Text("DTCs stored: $dtcCount", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text("DTC", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun GpsCard(gps: GpsStatus?) {
val hasFix = gps?.has_fix == true
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.LocationOn,
null,
tint = if (hasFix) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text(
if (hasFix) "GPS fix" else "No GPS fix",
style = MaterialTheme.typography.titleSmall,
color = if (hasFix) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.weight(1f))
Text(
"age ${ageText(gps?.fix_age_seconds)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth()) {
StatCell("Lat", coordText(gps?.latitude), Modifier.weight(1f))
StatCell("Lon", coordText(gps?.longitude), Modifier.weight(1f))
StatCell("Thread", if (gps?.thread_alive == true) "alive" else "—", Modifier.weight(1f))
}
}
}
}
@Composable
private fun OverviewCard(stats: Stats?) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("Overview", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(10.dp))
Row(Modifier.fillMaxWidth()) {
StatCell("Trips", "${stats?.trip_count ?: 0}", Modifier.weight(1f))
StatCell("Total km", oneDecimal(stats?.total_km), Modifier.weight(1f))
StatCell("Max speed", "${intText(stats?.all_time_max_speed)} km/h", Modifier.weight(1f))
}
Spacer(Modifier.height(10.dp))
Row(Modifier.fillMaxWidth()) {
StatCell("Videos", "${stats?.video_count ?: 0}", Modifier.weight(1f))
StatCell("DTCs", "${stats?.active_dtc ?: 0}", Modifier.weight(1f))
StatCell("Odometer", odoText(stats), Modifier.weight(1f))
}
}
}
}
// --- formatting helpers ---
private fun speedText(v: Double?): String = v?.let { "%.0f".format(it) } ?: "—"
private fun intText(v: Double?): String = v?.let { "%.0f".format(it) } ?: "—"
private fun oneDecimal(v: Double?): String = v?.let { "%.1f".format(it) } ?: "—"
private fun coordText(v: Double?): String = v?.let { "%.4f".format(it) } ?: "—"
private fun ageText(v: Double?): String = v?.let { "%.0fs".format(it) } ?: "—"
private fun odoText(stats: Stats?): String {
val v = stats?.odometer_km ?: return "—"
return "%.0f".format(v)
}
@Composable
private fun rpmColor(rpm: Double?): Color = when {
rpm == null -> MaterialTheme.colorScheme.onSurface
rpm < 3000 -> Color(0xFF2E7D32)
rpm < 5000 -> MIL_AMBER
else -> MaterialTheme.colorScheme.error
}
@@ -0,0 +1,128 @@
package com.yarisobd.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.yarisobd.app.data.CredentialStore
import com.yarisobd.app.data.remote.PiApi
import com.yarisobd.app.data.remote.ServerApi
import com.yarisobd.app.data.remote.dto.GpsStatus
import com.yarisobd.app.data.remote.dto.ObdStatus
import com.yarisobd.app.data.remote.dto.Stats
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
class DashboardViewModel(application: Application) : AndroidViewModel(application) {
private val baseUrl = "http://10.42.0.1"
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
private val okHttp = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
private val api: PiApi = Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(PiApi::class.java)
private val _gps = MutableStateFlow<GpsStatus?>(null)
val gps: StateFlow<GpsStatus?> = _gps.asStateFlow()
private val _obd = MutableStateFlow<ObdStatus?>(null)
val obd: StateFlow<ObdStatus?> = _obd.asStateFlow()
private val _stats = MutableStateFlow<Stats?>(null)
val stats: StateFlow<Stats?> = _stats.asStateFlow()
private val _piOnline = MutableStateFlow(false)
val piOnline: StateFlow<Boolean> = _piOnline.asStateFlow()
private val _serverOnline = MutableStateFlow(false)
val serverOnline: StateFlow<Boolean> = _serverOnline.asStateFlow()
private val _lastUpdate = MutableStateFlow(0L)
val lastUpdate: StateFlow<Long> = _lastUpdate.asStateFlow()
private var pollJob: Job? = null
private var serverApi: ServerApi? = null
/** Begin the 2s poll loop. Fetches immediately, then every 2s. */
fun startPolling() {
if (pollJob?.isActive == true) return
pollJob = viewModelScope.launch {
while (isActive) {
fetch()
delay(2000)
}
}
}
fun stopPolling() {
pollJob?.cancel()
pollJob = null
}
fun refreshNow() {
viewModelScope.launch { fetch() }
}
private suspend fun fetch() {
// Pi (hotspot WiFi) — GPS + OBD + overview stats, all on 10.42.0.1
try {
val g = api.getGpsStatus()
val o = api.getObdStatus()
val s = api.getStats()
_gps.value = g
_obd.value = o
_stats.value = s
_piOnline.value = true
_lastUpdate.value = System.currentTimeMillis()
} catch (e: Exception) {
_piOnline.value = false
}
// Server (cellular) — lightweight /health check when credentials are set
val srv = serverApi()
if (srv != null) {
try {
srv.health()
_serverOnline.value = true
} catch (e: Exception) {
_serverOnline.value = false
}
} else {
_serverOnline.value = false
}
}
/** Lazily build the ServerApi from the stored server URL (built once). */
private fun serverApi(): ServerApi? {
val ctx = getApplication<Application>().applicationContext
val serverUrl = CredentialStore.getServerUrl(ctx)
if (!CredentialStore.isConfigured(ctx) || serverUrl.isBlank()) return null
serverApi?.let { return it }
val built = Retrofit.Builder()
.baseUrl(serverUrl.trimEnd('/') + "/")
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(ServerApi::class.java)
serverApi = built
return built
}
}
@@ -0,0 +1,301 @@
package com.yarisobd.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudQueue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.yarisobd.app.data.remote.dto.DashcamFile
import com.yarisobd.app.data.remote.dto.VideoFile
@Composable
fun DashcamScreen(
videos: List<VideoFile>,
totalSize: Long,
retentionDays: Int,
isLoading: Boolean,
error: String?,
downloadProgress: Map<String, DownloadProgress>,
dashcamFiles: List<DashcamFile>,
dashcamTotalSize: Long,
dashcamStatus: String,
dashcamLoading: Boolean,
onRefresh: () -> Unit,
onDownload: (VideoFile) -> Unit,
onDelete: (VideoFile) -> Unit,
onDeleteDashcam: (DashcamFile) -> Unit,
onImport: (List<String>?) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Auto-refresh on first load
LaunchedEffect(Unit) { onRefresh() }
// USB Dashcam section
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text("USB Dashcam", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
when (dashcamStatus) {
"ok" -> {
Text("${dashcamFiles.size} files · ${formatBytes(dashcamTotalSize)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (dashcamFiles.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Row {
Button(onClick = { onImport(null) }, modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) { Text("Import All", style = MaterialTheme.typography.bodySmall) }
Spacer(Modifier.width(4.dp))
Button(onClick = onRefresh, enabled = !dashcamLoading, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) { Text(if (dashcamLoading) "..." else "Refresh", style = MaterialTheme.typography.bodySmall) }
}
}
}
"no_device" -> Text("No USB dashcam connected", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
"error" -> Text("Failed to read dashcam", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
"" -> Text("Tap Refresh to scan", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
else -> Text(dashcamStatus, style = MaterialTheme.typography.bodySmall)
}
}
}
// Imported videos card — match USB dashcam style
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text("Imported Videos", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
Text("${videos.size} files · ${formatBytes(totalSize)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (retentionDays > 0) {
Text("Auto-delete after ${retentionDays}d", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
// Content
when {
isLoading && videos.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
error != null -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = error,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRefresh) { Text("Retry") }
}
}
}
videos.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No imported videos on the Pi.\nImport some from the dashcam first.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 16.dp, end = 16.dp, bottom = 16.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(videos, key = { it.path }) { video ->
val progress = downloadProgress[video.path]
VideoRow(
video = video,
downloadProgress = progress,
onDownload = { onDownload(video) },
onDelete = { onDelete(video) },
)
}
}
}
}
}
}
@Composable
private fun VideoRow(
video: VideoFile,
downloadProgress: DownloadProgress?,
onDownload: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Thumbnail
AsyncImage(
model = ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
.data("http://10.42.0.1/api/local-video/thumbnail?path=${video.path}")
.crossfade(true)
.build(),
contentDescription = "Thumbnail",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentScale = ContentScale.Crop,
)
Spacer(modifier = Modifier.width(12.dp))
// Info
Column(modifier = Modifier.weight(1f)) {
Text(
text = video.path.substringAfterLast("/"),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(2.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = video.formattedSize(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = video.formattedDate(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(2.dp))
if (video.converting) {
Text(
text = "Converting ${video.convert_progress?.pct ?: 0}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
} else if (downloadProgress != null) {
LinearProgressIndicator(
progress = {
if (downloadProgress.totalBytes > 0)
downloadProgress.bytesDownloaded.toFloat() / downloadProgress.totalBytes
else 0f
},
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
Text(
text = "Downloading ${downloadProgress.bytesDownloaded / 1_000_000}MB / ${downloadProgress.totalBytes / 1_000_000}MB",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
if (video.uploaded) Icons.Filled.CloudDone else Icons.Filled.CloudQueue,
contentDescription = if (video.uploaded) "On server" else "Not on server",
tint = if (video.uploaded) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
text = if (video.uploaded) "On server" else "Not uploaded",
style = MaterialTheme.typography.bodySmall,
color = if (video.uploaded) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Actions
Row {
IconButton(
onClick = onDownload,
enabled = !video.converting,
) {
Icon(Icons.Filled.Download, "Download", tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
}
}
private fun formatBytes(bytes: Long): String {
return when {
bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0)
bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0)
bytes >= 1_000 -> "%.1f KB".format(bytes / 1_000.0)
else -> "$bytes B"
}
}
@@ -0,0 +1,422 @@
package com.yarisobd.app
import android.app.Application
import android.os.Environment
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.yarisobd.app.data.CredentialStore
import com.yarisobd.app.data.remote.PiApi
import com.yarisobd.app.data.remote.dto.DashcamFile
import com.yarisobd.app.data.remote.dto.DashcamFilesResponse
import com.yarisobd.app.data.remote.dto.VideoFile
import com.yarisobd.app.data.remote.dto.VideoListResponse
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import kotlinx.serialization.json.Json
import java.io.File
import java.util.UUID
data class DownloadProgress(
val path: String,
val bytesDownloaded: Long,
val totalBytes: Long,
)
data class DownloadedFile(
val name: String,
val size: Long,
val file: File,
)
data class ActiveDownload(
val workerId: String, // UUID string to cancel
val filename: String,
val progress: Int,
val done: Boolean,
val status: String? = null,
)
class DashcamViewModel(application: Application) : AndroidViewModel(application) {
private val baseUrl = "http://10.42.0.1" // no trailing slash
private val okHttp = OkHttpClient.Builder().build()
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
private val api: PiApi = Retrofit.Builder()
.baseUrl("$baseUrl/") // Retrofit requires trailing slash
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(PiApi::class.java)
private val _videos = MutableStateFlow<List<VideoFile>>(emptyList())
val videos: StateFlow<List<VideoFile>> = _videos.asStateFlow()
private val _totalSize = MutableStateFlow(0L)
val totalSize: StateFlow<Long> = _totalSize.asStateFlow()
private val _retentionDays = MutableStateFlow(0)
val retentionDays: StateFlow<Int> = _retentionDays.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _downloadProgress = MutableStateFlow<Map<String, DownloadProgress>>(emptyMap())
val downloadProgress: StateFlow<Map<String, DownloadProgress>> = _downloadProgress.asStateFlow()
private val _downloadEvents = MutableSharedFlow<String>()
val downloadEvents = _downloadEvents.asSharedFlow()
private val _downloadedFiles = MutableStateFlow<List<DownloadedFile>>(emptyList())
val downloadedFiles: StateFlow<List<DownloadedFile>> = _downloadedFiles.asStateFlow()
private val _activeDownloads = MutableStateFlow<Map<String, ActiveDownload>>(emptyMap())
val activeDownloads: StateFlow<Map<String, ActiveDownload>> = _activeDownloads.asStateFlow()
private val _deletingNames = MutableStateFlow<Set<String>>(emptySet())
val deletingNames: StateFlow<Set<String>> = _deletingNames.asStateFlow()
// Upload state
private val _uploadProgress = MutableStateFlow<Map<String, ActiveDownload>>(emptyMap())
val uploadProgress: StateFlow<Map<String, ActiveDownload>> = _uploadProgress.asStateFlow()
// Files already on server (filename set)
private val _uploadedNames = MutableStateFlow<Set<String>>(emptySet())
val uploadedNames: StateFlow<Set<String>> = _uploadedNames.asStateFlow()
// Files whose last upload attempt failed (drives the per-file Retry button)
private val _failedUploads = MutableStateFlow<Set<String>>(emptySet())
val failedUploads: StateFlow<Set<String>> = _failedUploads.asStateFlow()
// USB dashcam state
private val _dashcamFiles = MutableStateFlow<List<DashcamFile>>(emptyList())
val dashcamFiles: StateFlow<List<DashcamFile>> = _dashcamFiles.asStateFlow()
private val _dashcamTotalSize = MutableStateFlow(0L)
val dashcamTotalSize: StateFlow<Long> = _dashcamTotalSize.asStateFlow()
private val _dashcamStatus = MutableStateFlow("")
val dashcamStatus: StateFlow<String> = _dashcamStatus.asStateFlow()
private val _dashcamLoading = MutableStateFlow(false)
val dashcamLoading: StateFlow<Boolean> = _dashcamLoading.asStateFlow()
private fun getDownloadDir(): File {
// Public Movies/yaris-obd/ — visible in file managers
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
"yaris-obd"
)
dir.mkdirs()
return dir
}
init {
loadVideos()
scanDownloads()
_uploadedNames.value = CredentialStore.getUploaded(getApplication())
}
fun loadVideos() {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
val response: VideoListResponse = api.listVideos()
_videos.value = response.files
_totalSize.value = response.total_size
_retentionDays.value = response.retention_days
} catch (e: Exception) {
_error.value = "Failed to load: ${e.message}"
} finally {
_isLoading.value = false
}
}
}
fun loadDashcamFiles() {
viewModelScope.launch {
_dashcamLoading.value = true
try {
val resp: DashcamFilesResponse = api.listDashcamFiles()
_dashcamStatus.value = resp.status
_dashcamFiles.value = resp.files
_dashcamTotalSize.value = resp.total_size
} catch (e: Exception) {
_dashcamStatus.value = "error"
_downloadEvents.emit("Dashcam: ${e.message}")
} finally {
_dashcamLoading.value = false
}
}
}
fun deleteDashcamFile(file: DashcamFile) {
viewModelScope.launch {
try {
api.deleteDashcamFile(mapOf("path" to file.path))
_downloadEvents.emit("✓ Deleted from dashcam: ${file.path}")
loadDashcamFiles()
} catch (e: Exception) {
_downloadEvents.emit("✗ Delete failed: ${e.message}")
}
}
}
fun importDashcamFiles(paths: List<String>?) {
viewModelScope.launch {
try {
api.importDashcamFiles(mapOf("paths" to (paths ?: emptyList())))
_downloadEvents.emit("⏳ Import started")
} catch (e: Exception) {
_downloadEvents.emit("✗ Import failed: ${e.message}")
}
}
}
fun downloadVideo(video: VideoFile) {
val url = "$baseUrl/api/local-video/file?path=${video.path}"
val filename = video.path.substringAfterLast("/")
val dir = getDownloadDir()
val localFile = File(dir, filename)
if (localFile.exists() && localFile.length() == video.size && video.size > 0) {
viewModelScope.launch {
_downloadEvents.emit("✓ Already downloaded: $filename")
}
return
}
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setInputData(
workDataOf(
DownloadWorker.KEY_PATH to video.path,
DownloadWorker.KEY_URL to url,
DownloadWorker.KEY_FILENAME to filename,
DownloadWorker.KEY_FILE_SIZE to video.size,
)
)
.addTag("download_${video.path}")
.build()
val wm = WorkManager.getInstance(getApplication())
val uuid = request.id
val workerId = uuid.toString()
wm.enqueue(request)
_activeDownloads.value = _activeDownloads.value + (filename to ActiveDownload(workerId, filename, 0, false))
wm.getWorkInfoByIdLiveData(uuid).observeForever { info ->
if (info == null) return@observeForever
val progress = info.progress.getInt("pct", 0)
val done = info.state == WorkInfo.State.SUCCEEDED || info.state == WorkInfo.State.FAILED
_activeDownloads.value = _activeDownloads.value + (filename to ActiveDownload(workerId, filename, progress, done))
when (info.state) {
WorkInfo.State.SUCCEEDED -> {
viewModelScope.launch {
_downloadEvents.emit("✓ Downloaded: $filename")
scanDownloads()
}
_activeDownloads.value = _activeDownloads.value - filename
}
WorkInfo.State.FAILED -> {
val err = info.outputData.getString("error") ?: "Unknown error"
viewModelScope.launch {
_downloadEvents.emit("✗ Download failed: $err")
}
_activeDownloads.value = _activeDownloads.value - filename
}
WorkInfo.State.CANCELLED -> {
_activeDownloads.value = _activeDownloads.value - filename
}
else -> {}
}
}
viewModelScope.launch {
_downloadEvents.emit("⏳ Downloading: $filename")
}
}
fun scanDownloads() {
val dir = getDownloadDir()
val files = dir.listFiles()?.filter { it.isFile }?.map { f ->
DownloadedFile(name = f.name, size = f.length(), file = f)
}?.sortedByDescending { it.file.lastModified() } ?: emptyList()
_downloadedFiles.value = files
}
fun generateTestFile(sizeMb: Int = 120) {
viewModelScope.launch {
val f = File(getDownloadDir(), "test-upload-${sizeMb}mb.mp4")
try {
withContext(Dispatchers.IO) {
f.outputStream().use { out ->
val buf = ByteArray(1 shl 20) // 1MB
repeat(sizeMb) { out.write(buf) }
}
}
scanDownloads()
_downloadEvents.emit("✓ Created test file: ${f.name} (${sizeMb} MB)")
} catch (e: Exception) {
_downloadEvents.emit("✗ Could not create test file: ${e.message}")
}
}
}
fun deleteDownloaded(file: DownloadedFile) {
_deletingNames.value = _deletingNames.value + file.name
viewModelScope.launch {
try {
val ok = file.file.delete()
if (ok) {
_downloadEvents.emit("✓ Deleted: ${file.name}")
} else {
_downloadEvents.emit("✗ Could not delete: ${file.name}")
}
} finally {
_deletingNames.value = _deletingNames.value - file.name
scanDownloads()
}
}
}
fun cancelDownload(dl: ActiveDownload) {
try {
val uuid = java.util.UUID.fromString(dl.workerId)
WorkManager.getInstance(getApplication()).cancelWorkById(uuid)
_activeDownloads.value = _activeDownloads.value - dl.filename
viewModelScope.launch {
_downloadEvents.emit("✕ Cancelled: ${dl.filename}")
}
} catch (_: Exception) {}
}
fun uploadFile(file: DownloadedFile) {
val app = getApplication<YarisApp>()
val ctx = app.applicationContext
android.util.Log.d("UploadWorker", "uploadFile called: ${file.name}")
if (!CredentialStore.isConfigured(ctx)) {
android.util.Log.e("UploadWorker", "Credentials not configured")
viewModelScope.launch { _downloadEvents.emit("✗ Server credentials not configured") }
return
}
// Clear any previous failure marker — this is a fresh attempt
_failedUploads.value = _failedUploads.value - file.name
val serverUrl = CredentialStore.getServerUrl(ctx)
val deviceName = CredentialStore.getDeviceName(ctx)
val apiKey = CredentialStore.getApiKey(ctx)
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(workDataOf(
UploadWorker.KEY_FILENAME to file.name,
UploadWorker.KEY_SERVER_URL to serverUrl,
UploadWorker.KEY_DEVICE_NAME to deviceName,
UploadWorker.KEY_API_KEY to apiKey,
))
.addTag("upload_${file.name}")
.build()
val wm = WorkManager.getInstance(app)
val uuid = request.id
val workerId = uuid.toString()
wm.enqueue(request)
_uploadProgress.value = _uploadProgress.value + (file.name to ActiveDownload(workerId, file.name, 0, false))
wm.getWorkInfoByIdLiveData(uuid).observeForever { info ->
if (info == null) return@observeForever
android.util.Log.d("UploadWorker", "State: ${info.state} pct=${info.progress.getInt("pct", -1)}")
val progress = info.progress.getInt("pct", 0)
val status = info.progress.getString("status")
val done = info.state == WorkInfo.State.SUCCEEDED || info.state == WorkInfo.State.FAILED
_uploadProgress.value = _uploadProgress.value + (file.name to ActiveDownload(workerId, file.name, progress, done, status))
when (info.state) {
WorkInfo.State.SUCCEEDED -> {
CredentialStore.markUploaded(getApplication(), file.name)
_uploadedNames.value = CredentialStore.getUploaded(getApplication())
_failedUploads.value = _failedUploads.value - file.name
viewModelScope.launch { _downloadEvents.emit("✓ Uploaded: ${file.name}") }
_uploadProgress.value = _uploadProgress.value - file.name
}
WorkInfo.State.FAILED -> {
val err = info.outputData.getString("error") ?: "Unknown error"
_failedUploads.value = _failedUploads.value + file.name
viewModelScope.launch { _downloadEvents.emit("✗ Upload failed: $err") }
_uploadProgress.value = _uploadProgress.value - file.name
}
WorkInfo.State.CANCELLED -> {
_uploadProgress.value = _uploadProgress.value - file.name
}
else -> {}
}
}
viewModelScope.launch { _downloadEvents.emit("⏳ Uploading: ${file.name}") }
}
fun uploadAll() {
val ctx = getApplication<YarisApp>().applicationContext
if (!CredentialStore.isConfigured(ctx)) {
viewModelScope.launch { _downloadEvents.emit("✗ Server credentials not configured") }
return
}
val pending = _downloadedFiles.value.filter { f ->
f.name !in _uploadedNames.value && f.name !in _uploadProgress.value
}
if (pending.isEmpty()) {
viewModelScope.launch { _downloadEvents.emit("✓ Nothing to upload — all files already on server") }
return
}
pending.forEach { uploadFile(it) }
viewModelScope.launch { _downloadEvents.emit("⏳ Queued ${pending.size} file(s) for upload") }
}
fun cancelUpload(dl: ActiveDownload) {
try {
val uuid = UUID.fromString(dl.workerId)
WorkManager.getInstance(getApplication()).cancelWorkById(uuid)
_uploadProgress.value = _uploadProgress.value - dl.filename
} catch (_: Exception) {}
}
fun deleteVideo(video: VideoFile) {
viewModelScope.launch {
try {
val result = api.deleteVideo(mapOf("path" to video.path))
val status = result["status"]?.let { it.toString().trim('"') } ?: "unknown"
if (status == "ok") {
_downloadEvents.emit("✓ Deleted: ${video.path}")
} else {
val msg = result["message"]?.let { it.toString().trim('"') } ?: status
_downloadEvents.emit("✗ Delete: $msg")
}
loadVideos()
} catch (e: Exception) {
_error.value = "Delete failed: ${e.message}"
}
}
}
}
@@ -0,0 +1,222 @@
package com.yarisobd.app
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import java.io.File
import java.util.concurrent.TimeUnit
class DownloadWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
private val okHttp = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.MINUTES) // slow Pi WiFi can take 5min+ per file
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val notificationManager = NotificationManagerCompat.from(context)
companion object {
const val KEY_PATH = "path"
const val KEY_URL = "url"
const val KEY_FILENAME = "filename"
const val KEY_FILE_SIZE = "file_size"
const val CHANNEL_ID = "yaris_downloads"
const val NOTIFICATION_ID_BASE = 2000
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Downloads",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Dashcam video downloads"
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
}
fun createForegroundNotification(context: Context, title: String, progress: Int): ForegroundInfo {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setContentIntent(pendingIntent)
.setContentText("$progress%")
.setProgress(100, progress, progress == 0)
return ForegroundInfo(
NOTIFICATION_ID_BASE,
builder.build(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val path = inputData.getString(KEY_PATH) ?: return@withContext Result.failure()
val url = inputData.getString(KEY_URL) ?: return@withContext Result.failure()
val filename = inputData.getString(KEY_FILENAME) ?: path.substringAfterLast("/")
val totalSize = inputData.getLong(KEY_FILE_SIZE, 0)
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
"yaris-obd"
)
dir.mkdirs()
val localFile = File(dir, filename)
// Skip if already downloaded with correct size
if (localFile.exists() && localFile.length() == totalSize && totalSize > 0) {
return@withContext Result.success(
workDataOf("filename" to filename, "bytes" to totalSize)
)
}
// Delete partial from previous failed attempt
localFile.delete()
setForeground(createForegroundInfo(filename))
try {
val request = Request.Builder().url(url).get().build()
val response = okHttp.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext Result.failure(
workDataOf("error" to "HTTP ${response.code}")
)
}
val body = response.body ?: return@withContext Result.failure(
workDataOf("error" to "Empty body")
)
val total = body.contentLength()
var downloaded = 0L
var lastProgressMs = 0L
body.source().use { source ->
localFile.sink().buffer().use { sink ->
val buffer = okio.Buffer()
while (true) {
val read = source.read(buffer, 8192)
if (read == -1L) break
sink.write(buffer, read)
downloaded += read
// Throttle progress updates to every 500ms
val now = System.currentTimeMillis()
if (now - lastProgressMs > 500) {
lastProgressMs = now
val pct = if (total > 0) ((downloaded * 100) / total).toInt() else 0
setForeground(createForegroundInfo(filename, pct, downloaded, total))
setProgress(workDataOf("pct" to pct))
}
}
}
}
// Final notification
showCompleteNotification(filename)
return@withContext Result.success(
workDataOf("filename" to filename, "bytes" to downloaded)
)
} catch (e: Exception) {
return@withContext Result.failure(
workDataOf("error" to (e.message ?: "Unknown error"))
)
}
}
private fun createForegroundInfo(
filename: String,
progress: Int = 0,
downloaded: Long = 0,
total: Long = 0,
): ForegroundInfo {
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
applicationContext, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle("Downloading $filename")
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setContentIntent(pendingIntent)
if (total > 0) {
builder.setContentText("$progress% — ${downloaded / 1_000_000} / ${total / 1_000_000} MB")
builder.setProgress(100, progress, false)
} else {
builder.setContentText("Downloading...")
builder.setProgress(0, 0, true)
}
return ForegroundInfo(
NOTIFICATION_ID_BASE + id.hashCode(),
builder.build(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private fun showCompleteNotification(filename: String) {
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
applicationContext, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle("Download complete")
.setContentText(filename)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
) {
notificationManager.notify(NOTIFICATION_ID_BASE + filename.hashCode(), builder.build())
}
} else {
notificationManager.notify(NOTIFICATION_ID_BASE + filename.hashCode(), builder.build())
}
}
}
@@ -0,0 +1,305 @@
package com.yarisobd.app
import android.content.Intent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import com.yarisobd.app.data.CredentialStore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
@Composable
fun DownloadsScreen(
files: List<DownloadedFile>,
activeDownloads: Map<String, ActiveDownload>,
activeUploads: Map<String, ActiveDownload>,
deletingNames: Set<String>,
credentialsConfigured: Boolean,
uploadedNames: Set<String>,
failedUploads: Set<String>,
onUploadAll: () -> Unit,
onDelete: (DownloadedFile) -> Unit,
onCancel: (ActiveDownload) -> Unit,
onUpload: (DownloadedFile) -> Unit,
onCancelUpload: (ActiveDownload) -> Unit,
onGenerateTestFile: () -> Unit,
onRefresh: () -> Unit,
) {
val context = LocalContext.current
Column(modifier = Modifier.fillMaxSize()) {
// Header card
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Downloads", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(4.dp))
Text("${files.size} files · Movies/yaris-obd/", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(10.dp))
val pendingCount = files.count { it.name !in uploadedNames && it.name !in activeUploads.keys }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (credentialsConfigured) {
Button(
onClick = onUploadAll,
enabled = pendingCount > 0,
modifier = Modifier.height(34.dp),
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
) {
Text(
if (pendingCount > 0) "Upload all ($pendingCount)" else "Upload all",
style = MaterialTheme.typography.bodySmall
)
}
}
Button(
onClick = onGenerateTestFile,
modifier = Modifier.height(34.dp),
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
) {
Text("Create 120 MB test file", style = MaterialTheme.typography.bodySmall)
}
}
}
}
// Active uploads
AnimatedVisibility(visible = activeUploads.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
activeUploads.entries.forEach { (filename, dl) ->
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f))
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
if (dl.done) {
Text("✓", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.width(8.dp))
Text(filename, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
} else {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.tertiary)
Spacer(Modifier.width(10.dp))
Column {
Text("↑ $filename", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(uploadLabel(dl), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (!dl.done) Button(
onClick = { onCancelUpload(dl) },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.height(30.dp), shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp)
) { Text("Cancel", style = MaterialTheme.typography.bodySmall) }
}
if (!dl.done) {
Spacer(Modifier.height(8.dp))
if (dl.progress > 0) {
LinearProgressIndicator(
progress = { dl.progress / 100f },
modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
color = MaterialTheme.colorScheme.tertiary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
} else {
// Indeterminate bar so hashing/checking phases show visible activity
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
color = MaterialTheme.colorScheme.tertiary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
}
}
}
// Active downloads with progress
AnimatedVisibility(visible = activeDownloads.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
activeDownloads.entries.forEach { (filename, dl) ->
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f))
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
if (dl.done) {
Text("✓", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.width(8.dp))
Text(filename, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
} else {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.width(10.dp))
Column {
Text(filename, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${dl.progress}%", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (!dl.done) {
Button(
onClick = { onCancel(dl) },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.height(30.dp),
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp)
) { Text("Cancel", style = MaterialTheme.typography.bodySmall) }
}
}
if (!dl.done) {
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { dl.progress / 100f },
modifier = Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
}
}
}
if (files.isEmpty() && activeDownloads.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("📥", style = MaterialTheme.typography.headlineLarge)
Spacer(Modifier.height(12.dp))
Text("No downloaded files yet", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("Download a video from the Dashcam tab", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else {
LazyColumn(contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(files, key = { it.name }) { file ->
val isDeleting = file.name in deletingNames
Card(
modifier = Modifier.fillMaxWidth().clickable(enabled = !isDeleting) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file.file)
context.startActivity(Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, "video/*"); addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) })
},
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(file.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (file.name in uploadedNames) {
Spacer(Modifier.width(6.dp))
Icon(Icons.Filled.CloudDone, "On server", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
}
}
Text(formatSize(file.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (isDeleting) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error)
} else {
val isUploading = file.name in activeUploads.keys
if (file.name in failedUploads) {
Button(
onClick = { onUpload(file) },
enabled = !isUploading,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.height(30.dp),
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp)
) { Text("Retry", style = MaterialTheme.typography.bodySmall) }
} else if (credentialsConfigured && file.name !in uploadedNames && !isUploading) {
IconButton(onClick = { onUpload(file) }) {
Icon(Icons.Filled.CloudUpload, "Upload", tint = MaterialTheme.colorScheme.tertiary)
}
}
IconButton(onClick = { onDelete(file) }) {
Icon(Icons.Filled.Delete, "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
}
}
}
}
}
}
private fun uploadLabel(dl: ActiveDownload): String {
val pct = "${dl.progress}%"
val status = dl.status ?: return pct
return "$status · $pct"
}
private fun formatSize(bytes: Long): String = when {
bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0)
bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0)
bytes >= 1_000 -> "%.1f KB".format(bytes / 1_000.0)
else -> "$bytes B"
}
@@ -0,0 +1,316 @@
package com.yarisobd.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Phase 28.1 gate screen — proves dual-network binding works on the real device
* before building anything else.
*
* Two networks, two OkHttpClients (each socket-bound to its respective network):
* - Pi client → WiFi → 10.42.0.1/api/gps/status
* - Server client → Cellular → yaris.ceaf.dev/health
*
* Both must succeed simultaneously while the phone is associated with the
* Pi's no-internet hotspot ("yaris 911").
*/
@Composable
fun DualNetworkTestScreen(
piClient: OkHttpClient,
serverClient: OkHttpClient,
) {
val scope = rememberCoroutineScope()
val scroll = rememberScrollState()
var piResult by remember { mutableStateOf<String?>(null) }
var serverResult by remember { mutableStateOf<String?>(null) }
var bothResult by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Text(
text = "yaris-obd",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Phase 28.1 — Network Binding Proof",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// Network status
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Network Status",
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Pi WiFi: ✓ Connected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Server: ✓ OS-routed (WiFi for Pi, cellular for internet)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Pi WiFi test
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Pi Hotspot (WiFi)",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "GET 10.42.0.1/api/gps/status",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(
onClick = {
isLoading = true
piResult = null
scope.launch {
piResult = callPi(piClient)
isLoading = false
}
},
enabled = !isLoading,
modifier = Modifier.weight(1f)
) {
Text("Test Pi")
}
}
piResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
ResultBlock(result)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Server test
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Remote Server (OS-routed)",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "GET yaris.ceaf.dev/health",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(
onClick = {
isLoading = true
serverResult = null
scope.launch {
serverResult = callServer(serverClient)
isLoading = false
}
},
enabled = !isLoading,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("Test Server")
}
}
serverResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
ResultBlock(result)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Simultaneous test
Button(
onClick = {
isLoading = true
piResult = null
serverResult = null
bothResult = null
scope.launch {
val piDeferred = async { callPi(piClient) }
val serverDeferred = async { callServer(serverClient) }
val pi = piDeferred.await()
val server = serverDeferred.await()
piResult = pi
serverResult = server
val piOk = pi?.startsWith("✓") == true
val serverOk = server?.startsWith("✓") == true
bothResult = when {
piOk && serverOk -> "✓ BOTH NETWORKS WORK SIMULTANEOUSLY — gate passed!"
piOk -> "⚠ Pi OK but server failed — check cellular data is on"
serverOk -> "⚠ Server OK but Pi failed — are you on the yaris 911 hotspot?"
else -> "✗ Both failed"
}
isLoading = false
}
},
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text(if (isLoading) "Testing..." else "Test Both Simultaneously")
}
bothResult?.let { result ->
Spacer(modifier = Modifier.height(12.dp))
val isSuccess = result.startsWith("✓")
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isSuccess)
MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = result,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Connect phone to \"yaris 911\" hotspot, ensure cellular data is ON, then test.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun ResultBlock(text: String) {
val isSuccess = text.startsWith("✓")
Card(
colors = CardDefaults.cardColors(
containerColor = if (isSuccess)
MaterialTheme.colorScheme.surfaceContainerLow
else MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = text,
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = if (isSuccess) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onErrorContainer
)
}
}
// --- Network call helpers ---
private suspend fun callPi(client: OkHttpClient): String = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("http://10.42.0.1/api/gps/status")
.get()
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
"✓ Pi OK (${response.code}) — ${response.body?.string()?.take(200) ?: "no body"}"
} else {
"✗ Pi HTTP ${response.code} — ${response.message}"
}
} catch (e: Exception) {
"✗ Pi error: ${e.javaClass.simpleName}: ${e.message}"
}
}
private suspend fun callServer(client: OkHttpClient): String = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("https://yaris.ceaf.dev/health")
.get()
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
"✓ Server OK (${response.code}) — ${response.body?.string() ?: "no body"}"
} else {
"✗ Server HTTP ${response.code} — ${response.message}"
}
} catch (e: Exception) {
"✗ Server error: ${e.javaClass.simpleName}: ${e.message}"
}
}
@@ -0,0 +1,277 @@
package com.yarisobd.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.DirectionsCar
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yarisobd.app.data.remote.dto.TripSummary
import com.yarisobd.app.ui.components.YarisHeader
import com.yarisobd.app.ui.theme.YarisOBDTheme
import okhttp3.OkHttpClient
class MainActivity : ComponentActivity() {
private val client = OkHttpClient()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
YarisOBDTheme {
YarisMainScreen(client = client)
}
}
}
}
@Composable
fun YarisMainScreen(client: OkHttpClient) {
val dashcamVm: DashcamViewModel = viewModel()
val dashboardVm: DashboardViewModel = viewModel()
val gps by dashboardVm.gps.collectAsState()
val obd by dashboardVm.obd.collectAsState()
val stats by dashboardVm.stats.collectAsState()
val piOnline by dashboardVm.piOnline.collectAsState()
val serverOnline by dashboardVm.serverOnline.collectAsState()
val lastUpdate by dashboardVm.lastUpdate.collectAsState()
val tripVm: TripViewModel = viewModel()
val trips by tripVm.trips.collectAsState()
val tripLabels by tripVm.tripLabels.collectAsState()
val tripsLoading by tripVm.isLoading.collectAsState()
val tripsError by tripVm.error.collectAsState()
val route by tripVm.route.collectAsState()
val routeLoading by tripVm.routeLoading.collectAsState()
val portalVm: PortalViewModel = viewModel()
val portalRoute by portalVm.route.collectAsState()
val portalRouteLoading by portalVm.routeLoading.collectAsState()
val videos by dashcamVm.videos.collectAsState()
val totalSize by dashcamVm.totalSize.collectAsState()
val retentionDays by dashcamVm.retentionDays.collectAsState()
val isLoading by dashcamVm.isLoading.collectAsState()
val error by dashcamVm.error.collectAsState()
val downloadProgress by dashcamVm.downloadProgress.collectAsState()
val downloadedFiles by dashcamVm.downloadedFiles.collectAsState()
val activeDownloads by dashcamVm.activeDownloads.collectAsState()
val deletingNames by dashcamVm.deletingNames.collectAsState()
val uploadProgress by dashcamVm.uploadProgress.collectAsState()
val uploadedNames by dashcamVm.uploadedNames.collectAsState()
val failedUploads by dashcamVm.failedUploads.collectAsState()
val dashcamFiles by dashcamVm.dashcamFiles.collectAsState()
val dashcamTotalSize by dashcamVm.dashcamTotalSize.collectAsState()
val dashcamStatus by dashcamVm.dashcamStatus.collectAsState()
val dashcamLoading by dashcamVm.dashcamLoading.collectAsState()
val context = androidx.compose.ui.platform.LocalContext.current
val credentialsConfigured = com.yarisobd.app.data.CredentialStore.isConfigured(context)
val snackbarHostState = remember { SnackbarHostState() }
var selectedTab by remember { mutableIntStateOf(0) }
var selectedTrip by remember { mutableStateOf<TripSummary?>(null) }
var selectedPortalTrip by remember { mutableStateOf<TripSummary?>(null) }
val navItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = Color.White,
selectedTextColor = Color.White,
indicatorColor = Color(0x26FFFFFF),
unselectedIconColor = Color(0x8CFFFFFF),
unselectedTextColor = Color(0x8CFFFFFF),
)
// Show download/delete events as snackbars
LaunchedEffect(Unit) {
dashcamVm.downloadEvents.collect { event ->
snackbarHostState.showSnackbar(event)
}
}
// System back from a trip replay returns to the list instead of exiting.
BackHandler(enabled = selectedTrip != null || selectedPortalTrip != null) {
when {
selectedTrip != null -> {
selectedTrip = null
tripVm.clearRoute()
}
selectedPortalTrip != null -> {
selectedPortalTrip = null
portalVm.clearRoute()
}
}
}
if (selectedTrip != null) {
TripReplayScreen(
trip = selectedTrip!!,
route = route,
loading = routeLoading,
onBack = {
selectedTrip = null
tripVm.clearRoute()
},
)
} else if (selectedPortalTrip != null) {
TripReplayScreen(
trip = selectedPortalTrip!!,
route = portalRoute,
loading = portalRouteLoading,
onBack = {
selectedPortalTrip = null
portalVm.clearRoute()
},
mapTileUrl = PORTAL_TILE_URL,
mapMinZoom = 0.0,
mapMaxZoom = 19.0,
)
} else {
Scaffold(
topBar = { YarisHeader() },
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
NavigationBar(containerColor = Color(0xFF1C1C1E)) {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
icon = { Icon(Icons.Filled.Dashboard, contentDescription = "Dashboard") },
label = { Text("Dashboard") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
icon = { Icon(Icons.Filled.Videocam, contentDescription = "Dashcam") },
label = { Text("Dashcam") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 2,
onClick = { selectedTab = 2 },
icon = { Icon(Icons.Filled.FileDownload, contentDescription = "Downloads") },
label = { Text("Downloads") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 3,
onClick = { selectedTab = 3 },
icon = { Icon(Icons.Filled.DirectionsCar, contentDescription = "Trips") },
label = { Text("Trips") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 4,
onClick = { selectedTab = 4 },
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
label = { Text("Settings") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 5,
onClick = { selectedTab = 5 },
icon = { Icon(Icons.Filled.Cloud, contentDescription = "Server") },
label = { Text("Server") },
colors = navItemColors,
)
}
}
) { padding ->
Column(modifier = Modifier.padding(padding).padding(start = 4.dp, end = 4.dp, bottom = 16.dp)) {
when (selectedTab) {
0 -> DashboardScreen(
gps = gps,
obd = obd,
stats = stats,
piOnline = piOnline,
serverOnline = serverOnline,
lastUpdate = lastUpdate,
onStartPolling = { dashboardVm.startPolling() },
onStopPolling = { dashboardVm.stopPolling() },
)
1 -> DashcamScreen(
videos = videos,
totalSize = totalSize,
retentionDays = retentionDays,
isLoading = isLoading,
error = error,
downloadProgress = downloadProgress,
dashcamFiles = dashcamFiles,
dashcamTotalSize = dashcamTotalSize,
dashcamStatus = dashcamStatus,
dashcamLoading = dashcamLoading,
onRefresh = { dashcamVm.loadVideos(); dashcamVm.loadDashcamFiles() },
onDownload = { dashcamVm.downloadVideo(it) },
onDelete = { dashcamVm.deleteVideo(it) },
onDeleteDashcam = { dashcamVm.deleteDashcamFile(it) },
onImport = { dashcamVm.importDashcamFiles(it) },
)
2 -> DownloadsScreen(
files = downloadedFiles,
activeDownloads = activeDownloads,
activeUploads = uploadProgress,
deletingNames = deletingNames,
credentialsConfigured = credentialsConfigured,
uploadedNames = uploadedNames,
failedUploads = failedUploads,
onUploadAll = { dashcamVm.uploadAll() },
onDelete = { dashcamVm.deleteDownloaded(it) },
onCancel = { dashcamVm.cancelDownload(it) },
onUpload = { dashcamVm.uploadFile(it) },
onCancelUpload = { dashcamVm.cancelUpload(it) },
onGenerateTestFile = { dashcamVm.generateTestFile() },
onRefresh = { dashcamVm.scanDownloads() },
)
3 -> TripListScreen(
trips = trips,
labels = tripLabels,
isLoading = tripsLoading,
error = tripsError,
onTripClick = { t ->
selectedTrip = t
tripVm.loadRoute(t.id)
},
onRefresh = { tripVm.loadTrips() },
)
4 -> SettingsScreen(client = client)
5 -> PortalScreen(
viewModel = portalVm,
onTripClick = { t ->
selectedPortalTrip = t
portalVm.loadRoute(t.id)
},
)
}
}
}
}
}
@@ -0,0 +1,249 @@
package com.yarisobd.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.yarisobd.app.data.CredentialStore
import com.yarisobd.app.data.remote.dto.PortalDashcamFile
import com.yarisobd.app.data.remote.dto.PortalStats
import com.yarisobd.app.data.remote.dto.TripSummary
import java.util.Locale
@Composable
fun PortalScreen(
viewModel: PortalViewModel,
onTripClick: (TripSummary) -> Unit,
) {
val loggedIn by viewModel.loggedIn.collectAsState()
val loggingIn by viewModel.loggingIn.collectAsState()
val error by viewModel.error.collectAsState()
val stats by viewModel.stats.collectAsState()
val trips by viewModel.trips.collectAsState()
val tripLabels by viewModel.tripLabels.collectAsState()
val dashcam by viewModel.dashcam.collectAsState()
val context = LocalContext.current
val serverUrl = CredentialStore.getServerUrl(context)
var username by remember { mutableStateOf(CredentialStore.getPortalUsername(context)) }
var password by remember { mutableStateOf(CredentialStore.getPortalPassword(context)) }
Column(modifier = Modifier.fillMaxSize()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
if (loggedIn) "Connected to server" else "Remote server",
style = MaterialTheme.typography.bodyMedium,
color = if (loggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
serverUrl.ifBlank { "not configured" },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1.5f)
)
if (loggedIn) {
Button(onClick = { viewModel.logout() }) { Text("Logout") }
}
}
if (!loggedIn) {
LoginCard(
username = username,
password = password,
loggingIn = loggingIn,
error = error,
onUsernameChange = { username = it },
onPasswordChange = { password = it },
onLogin = { viewModel.login(username, password) },
)
} else {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
StatsCard(stats)
Text(
"Trips (${trips.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
if (trips.isEmpty()) {
Text(
"No trips on server yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
trips.forEach { trip ->
TripCard(trip, tripLabels[trip.id], onClick = { onTripClick(trip) })
}
Text(
"Dashcam archive (${dashcam.size})",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
dashcam.take(200).forEach { f -> DashcamRow(f) }
Spacer(Modifier.height(8.dp))
}
}
}
}
@Composable
private fun LoginCard(
username: String,
password: String,
loggingIn: Boolean,
error: String?,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLogin: () -> Unit,
) {
Card(
Modifier.fillMaxWidth().padding(16.dp),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(Modifier.padding(16.dp)) {
Text("Portal sign in", style = MaterialTheme.typography.titleMedium)
Text(
"View trips and dashcam from anywhere (cellular).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = username, onValueChange = onUsernameChange,
label = { Text("Username") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password, onValueChange = onPasswordChange,
label = { Text("Password") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
if (error != null) {
Spacer(Modifier.height(8.dp))
Text(error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(12.dp))
Button(onClick = onLogin, enabled = !loggingIn, modifier = Modifier.fillMaxWidth()) {
if (loggingIn) {
CircularProgressIndicator(Modifier.height(18.dp), strokeWidth = 2.dp)
} else {
Text("Sign in")
}
}
}
}
}
@Composable
private fun StatsCard(stats: PortalStats?) {
Card(
Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(Modifier.padding(14.dp)) {
Text("Overview", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(10.dp))
Row(Modifier.fillMaxWidth()) {
Stat("Trips", "${stats?.trip_count ?: 0}", Modifier.weight(1f))
Stat("Total km", oneDecimal(stats?.total_distance_km), Modifier.weight(1f))
Stat("Top speed", "${intText(stats?.top_speed_kmh)} km/h", Modifier.weight(1f))
}
Spacer(Modifier.height(10.dp))
Row(Modifier.fillMaxWidth()) {
Stat("Drive time", durationText(stats?.total_drive_time_s ?: 0), Modifier.weight(1f))
Stat("Videos", "${stats?.video_count ?: 0}", Modifier.weight(1f))
Stat("Odometer", intText(stats?.odometer_km), Modifier.weight(1f))
}
}
}
}
@Composable
private fun Stat(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun DashcamRow(f: PortalDashcamFile) {
Card(
Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
f.relative_path.substringAfterLast("/"),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
formatBytes(f.size_bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
private fun intText(v: Double?): String = v?.let { "%.0f".format(Locale.US, it) } ?: "—"
private fun oneDecimal(v: Double?): String = v?.let { "%.1f".format(Locale.US, it) } ?: "—"
private fun durationText(seconds: Int): String {
if (seconds < 60) return "${seconds}s"
val h = seconds / 3600
val m = (seconds % 3600) / 60
return if (h > 0) "${h}h ${m}m" else "${m}m"
}
private fun formatBytes(bytes: Long): String = when {
bytes >= 1_000_000_000 -> "%.1f GB".format(Locale.US, bytes / 1_000_000_000.0)
bytes >= 1_000_000 -> "%.1f MB".format(Locale.US, bytes / 1_000_000.0)
bytes >= 1_000 -> "%.1f KB".format(Locale.US, bytes / 1_000.0)
else -> "$bytes B"
}
@@ -0,0 +1,282 @@
package com.yarisobd.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.yarisobd.app.data.CredentialStore
import com.yarisobd.app.data.remote.PortalApi
import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest
import com.yarisobd.app.data.remote.dto.GeocodeCoord
import com.yarisobd.app.data.remote.dto.LatLon
import com.yarisobd.app.data.remote.dto.PortalDashcamFile
import com.yarisobd.app.data.remote.dto.PortalStats
import com.yarisobd.app.data.remote.dto.TripRoute
import com.yarisobd.app.data.remote.dto.TripSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.Locale
import java.util.concurrent.TimeUnit
/** In-memory cookie jar — keeps the Flask session cookie for the app session. */
class SimpleCookieJar : CookieJar {
private val store = mutableMapOf<String, MutableList<Cookie>>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val list = store.getOrPut(url.host) { mutableListOf() }
for (c in cookies) {
list.removeAll { it.name == c.name }
list.add(c)
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
store[url.host] ?: emptyList()
fun clear() = store.clear()
}
class PortalViewModel(application: Application) : AndroidViewModel(application) {
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
private val cookieJar = SimpleCookieJar()
private val okHttp = OkHttpClient.Builder()
.cookieJar(cookieJar)
.followRedirects(false)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private var baseUrl: String = ""
private var api: PortalApi? = null
private val _loggedIn = MutableStateFlow(false)
val loggedIn: StateFlow<Boolean> = _loggedIn.asStateFlow()
private val _loggingIn = MutableStateFlow(false)
val loggingIn: StateFlow<Boolean> = _loggingIn.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _stats = MutableStateFlow<PortalStats?>(null)
val stats: StateFlow<PortalStats?> = _stats.asStateFlow()
private val _trips = MutableStateFlow<List<TripSummary>>(emptyList())
val trips: StateFlow<List<TripSummary>> = _trips.asStateFlow()
private val _tripLabels = MutableStateFlow<Map<Int, TripLabels>>(emptyMap())
val tripLabels: StateFlow<Map<Int, TripLabels>> = _tripLabels.asStateFlow()
private val _route = MutableStateFlow<TripRoute?>(null)
val route: StateFlow<TripRoute?> = _route.asStateFlow()
private val _routeLoading = MutableStateFlow(false)
val routeLoading: StateFlow<Boolean> = _routeLoading.asStateFlow()
private val _dashcam = MutableStateFlow<List<PortalDashcamFile>>(emptyList())
val dashcam: StateFlow<List<PortalDashcamFile>> = _dashcam.asStateFlow()
init {
val ctx = getApplication<Application>().applicationContext
val u = CredentialStore.getPortalUsername(ctx)
val p = CredentialStore.getPortalPassword(ctx)
if (u.isNotBlank() && p.isNotBlank()) {
login(u, p)
}
}
fun login(username: String, password: String) {
viewModelScope.launch {
_loggingIn.value = true
_error.value = null
try {
val ok = doLogin(username, password)
if (ok) {
CredentialStore.savePortalCredentials(getApplication(), username, password)
_loggedIn.value = true
loadAll()
} else {
_error.value = "Login failed — check username/password"
}
} catch (e: Exception) {
_error.value = "Login failed: ${e.message}"
} finally {
_loggingIn.value = false
}
}
}
fun logout() {
viewModelScope.launch(Dispatchers.IO) {
runCatching {
val url = baseUrl.ifBlank { CredentialStore.getServerUrl(getApplication()).trimEnd('/') }
okHttp.newCall(Request.Builder().url("$url/logout").get().build()).execute().close()
}
}
cookieJar.clear()
_loggedIn.value = false
_stats.value = null
_trips.value = emptyList()
_tripLabels.value = emptyMap()
_route.value = null
_dashcam.value = emptyList()
}
fun loadStats() {
viewModelScope.launch {
val a = ensureApi() ?: return@launch
try {
_stats.value = a.stats()
} catch (e: Exception) {
handleError(e)
}
}
}
fun loadTrips() {
viewModelScope.launch {
val a = ensureApi() ?: return@launch
try {
val mapped = a.trips().map { it.toTripSummary() }
_trips.value = mapped
geocode(mapped, a)
} catch (e: Exception) {
handleError(e)
}
}
}
fun loadDashcam() {
viewModelScope.launch {
val a = ensureApi() ?: return@launch
try {
_dashcam.value = a.dashcam()
} catch (e: Exception) {
handleError(e)
}
}
}
fun loadRoute(tripId: Int) {
viewModelScope.launch {
val a = ensureApi() ?: return@launch
_routeLoading.value = true
_error.value = null
try {
_route.value = a.tripRoute(tripId)
} catch (e: Exception) {
handleError(e)
} finally {
_routeLoading.value = false
}
}
}
fun clearRoute() {
_route.value = null
}
private suspend fun loadAll() {
loadStats()
loadTrips()
loadDashcam()
}
/** GET /login for the CSRF token, then POST /login with credentials. */
private suspend fun doLogin(username: String, password: String): Boolean =
withContext(Dispatchers.IO) {
val url = CredentialStore.getServerUrl(getApplication()).trimEnd('/')
if (url.isBlank()) return@withContext false
baseUrl = url
val getResp = okHttp.newCall(Request.Builder().url("$url/login").get().build()).execute()
getResp.use { resp ->
if (resp.code == 302) return@withContext true // already logged in
val html = resp.body?.string() ?: return@withContext false
val token = Regex("name=\"csrf_token\" value=\"([^\"]+)\"")
.find(html)?.groupValues?.get(1) ?: return@withContext false
val form = FormBody.Builder()
.add("csrf_token", token)
.add("username", username)
.add("password", password)
.build()
val postResp = okHttp.newCall(Request.Builder().url("$url/login").post(form).build()).execute()
postResp.use { pr ->
val loc = pr.header("Location") ?: ""
pr.code == 302 && !loc.contains("/login")
}
}
}
/** Best-effort geocode of trip start/end via the server's batch endpoint. */
private suspend fun geocode(trips: List<TripSummary>, a: PortalApi) {
val coords = mutableListOf<GeocodeCoord>()
for (t in trips) {
t.start?.takeIf { it.latitude != null && it.longitude != null }?.let {
coords += GeocodeCoord(it.latitude!!, it.longitude!!)
}
t.end?.takeIf { it.latitude != null && it.longitude != null }?.let {
coords += GeocodeCoord(it.latitude!!, it.longitude!!)
}
}
if (coords.isEmpty()) return
try {
val result = a.geocodeBatch(GeocodeBatchRequest(coords))
val labels = mutableMapOf<Int, TripLabels>()
for (t in trips) {
labels[t.id] = TripLabels(labelFor(result, t.start), labelFor(result, t.end))
}
_tripLabels.value = labels
} catch (_: Exception) {
// Geocoding is best-effort.
}
}
private fun labelFor(result: Map<String, String>, ll: LatLon?): String {
val lat = ll?.latitude ?: return "—"
val lon = ll?.longitude ?: return "—"
val key = String.format(Locale.US, "%.3f,%.3f", lat, lon)
return result[key] ?: String.format(Locale.US, "%.4f, %.4f", lat, lon)
}
private fun handleError(e: Exception) {
if (e is HttpException && e.code() == 401) {
_loggedIn.value = false
_error.value = "Session expired — please log in again"
} else {
_error.value = e.message
}
}
private fun ensureApi(): PortalApi? {
val url = CredentialStore.getServerUrl(getApplication()).trimEnd('/')
if (url.isBlank()) return null
val existing = api
if (existing != null && baseUrl == url) return existing
baseUrl = url
val built = Retrofit.Builder()
.baseUrl("$url/")
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(PortalApi::class.java)
api = built
return built
}
}
@@ -0,0 +1,138 @@
package com.yarisobd.app
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.yarisobd.app.data.CredentialStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
@Composable
fun SettingsScreen(client: OkHttpClient) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var serverUrl by remember { mutableStateOf(CredentialStore.getServerUrl(context)) }
var deviceName by remember { mutableStateOf(CredentialStore.getDeviceName(context)) }
var apiKey by remember { mutableStateOf("") }
var saved by remember { mutableStateOf(false) }
val configured = CredentialStore.isConfigured(context)
// Network test state
var piResult by remember { mutableStateOf<String?>(null) }
var serverResult by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
) {
Spacer(Modifier.height(16.dp))
// Server credentials
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Remote Server", style = MaterialTheme.typography.titleMedium)
Text(
if (configured) "✓ Configured" else "Credentials needed for cellular upload",
style = MaterialTheme.typography.bodySmall,
color = if (configured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = serverUrl, onValueChange = { serverUrl = it; saved = false },
label = { Text("Server URL") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall,
placeholder = { Text("https://yaris.ceaf.dev") }
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = deviceName, onValueChange = { deviceName = it; saved = false },
label = { Text("Device Name") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall,
placeholder = { Text("yaris-2013") }
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = apiKey, onValueChange = { apiKey = it; saved = false },
label = { Text("API Key") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodySmall
)
Spacer(Modifier.height(12.dp))
Button(
onClick = {
CredentialStore.save(context, serverUrl, deviceName, apiKey)
saved = true
},
modifier = Modifier.fillMaxWidth()
) { Text(if (saved) "✓ Saved" else "Save") }
}
}
Spacer(Modifier.height(16.dp))
// Network test
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Network Test", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Row { Button(onClick = {
piResult = null
scope.launch { piResult = testPi(client) }
}, modifier = Modifier.weight(1f)) { Text("Test Pi") } }
piResult?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("✓")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 4.dp)) }
Spacer(Modifier.height(8.dp))
Row { Button(onClick = {
serverResult = null
scope.launch { serverResult = testServer(client) }
}, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) { Text("Test Server") } }
serverResult?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("✓")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 4.dp)) }
}
}
}
}
private suspend fun testPi(client: OkHttpClient): String = withContext(Dispatchers.IO) {
try {
val res = client.newCall(Request.Builder().url("http://10.42.0.1/api/gps/status").get().build()).execute()
if (res.isSuccessful) "✓ Pi OK (${res.code})" else "✗ Pi HTTP ${res.code}"
} catch (e: Exception) { "✗ ${e.message}" }
}
private suspend fun testServer(client: OkHttpClient): String = withContext(Dispatchers.IO) {
try {
val res = client.newCall(Request.Builder().url("https://yaris.ceaf.dev/health").get().build()).execute()
if (res.isSuccessful) "✓ Server OK (${res.code})" else "✗ Server HTTP ${res.code}"
} catch (e: Exception) { "✗ ${e.message}" }
}
@@ -0,0 +1,173 @@
package com.yarisobd.app
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.yarisobd.app.data.remote.dto.TripSummary
import java.util.Locale
@Composable
fun TripListScreen(
trips: List<TripSummary>,
labels: Map<Int, TripLabels>,
isLoading: Boolean,
error: String?,
onTripClick: (TripSummary) -> Unit,
onRefresh: () -> Unit,
) {
// One retry on first composition if the initial load failed (e.g. app started
// before the phone joined the Pi hotspot).
LaunchedEffect(Unit) {
if (trips.isEmpty() && !isLoading) onRefresh()
}
Column(modifier = Modifier.fillMaxSize()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"${trips.size} trips",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Button(onClick = onRefresh, enabled = !isLoading) {
Text("Refresh")
}
}
when {
isLoading && trips.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
error != null && trips.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("🚗", style = MaterialTheme.typography.headlineLarge)
Spacer(Modifier.height(8.dp))
Text(error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(12.dp))
Button(onClick = onRefresh) { Text("Retry") }
}
}
}
trips.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"No trips recorded yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(trips, key = { it.id }) { trip ->
TripCard(trip, labels[trip.id], onClick = { onTripClick(trip) })
}
}
}
}
}
}
@Composable
fun TripCard(trip: TripSummary, label: TripLabels?, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(Modifier.fillMaxWidth().padding(14.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
dateText(trip.start_datetime),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
timeText(trip.start_datetime) + " → " + timeText(trip.end_datetime),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
"%.1f km".format(Locale.US, trip.distance_km),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
TripStat("Duration", durationText(trip.duration_seconds), Modifier.weight(1f))
TripStat("Avg", "%.0f km/h".format(Locale.US, trip.average_speed_kmh), Modifier.weight(1f))
TripStat("Max", "%.0f km/h".format(Locale.US, trip.max_speed_kmh), Modifier.weight(1f))
}
Spacer(Modifier.height(8.dp))
Text(
(label?.start ?: coordText(trip.start)) + " → " + (label?.end ?: coordText(trip.end)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun TripStat(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
}
}
private fun dateText(dt: String): String = if (dt.length >= 10) dt.substring(0, 10) else dt
private fun timeText(dt: String): String = if (dt.length >= 16) dt.substring(11, 16) else dt
private fun durationText(seconds: Int): String {
if (seconds < 60) return "${seconds}s"
val h = seconds / 3600
val m = (seconds % 3600) / 60
return if (h > 0) "${h}h ${m}m" else "${m}m"
}
private fun coordText(ll: com.yarisobd.app.data.remote.dto.LatLon?): String {
val lat = ll?.latitude ?: return "—"
val lon = ll?.longitude ?: return "—"
return "%.4f, %.4f".format(Locale.US, lat, lon)
}
@@ -0,0 +1,178 @@
package com.yarisobd.app
import android.graphics.Color
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
import com.yarisobd.app.data.remote.dto.RoutePoint
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.Property
import org.maplibre.android.style.layers.PropertyFactory
import org.maplibre.android.style.layers.RasterLayer
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.android.style.sources.RasterSource
import org.maplibre.android.style.sources.TileSet
/** Pi serves offline MapTiler raster tiles (zoom 8–15) at this path. */
internal const val PI_TILE_URL = "http://10.42.0.1/static/vendor/tiles/{z}/{x}/{y}.png"
/** Online raster basemap for server trips (works away from the Pi hotspot). */
internal const val PORTAL_TILE_URL = "https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png"
@Composable
fun TripMapView(
points: List<RoutePoint>,
currentIndex: Int,
maxIndex: Int,
modifier: Modifier = Modifier,
onMaxClick: (() -> Unit)? = null,
tileUrl: String = PI_TILE_URL,
minZoom: Double = 8.0,
maxZoom: Double = 15.0,
) {
val context = LocalContext.current
val mapView = remember { MapView(context) }
val mapRef = remember { mutableStateOf<MapLibreMap?>(null) }
val styleReady = remember { mutableStateOf(false) }
val currentOnMaxClick = rememberUpdatedState(onMaxClick)
DisposableEffect(Unit) {
mapView.onCreate(null)
mapView.onStart()
mapView.onResume()
onDispose {
mapView.onPause()
mapView.onStop()
mapView.onDestroy()
}
}
LaunchedEffect(Unit) {
mapView.getMapAsync { map ->
mapRef.value = map
map.setMinZoomPreference(minZoom)
map.setMaxZoomPreference(maxZoom)
map.setStyle(buildTripStyle(tileUrl, minZoom, maxZoom)) { _ ->
styleReady.value = true
}
// Tap the amber max-speed marker to jump the timeline to it.
map.addOnMapClickListener { latLng ->
val screen = map.projection.toScreenLocation(latLng)
val hits = map.queryRenderedFeatures(screen, "max-marker")
if (hits.isNotEmpty()) {
currentOnMaxClick.value?.invoke()
true
} else {
false
}
}
}
}
// Draw the route + fit camera once points arrive (and the style is ready).
LaunchedEffect(points, styleReady.value, maxIndex) {
val map = mapRef.value ?: return@LaunchedEffect
if (!styleReady.value || points.isEmpty()) return@LaunchedEffect
applyRoute(map, points, maxIndex)
}
// Move the cursor marker as the timeline scrubs/plays.
LaunchedEffect(currentIndex, styleReady.value, points) {
val map = mapRef.value ?: return@LaunchedEffect
if (!styleReady.value || points.isEmpty()) return@LaunchedEffect
val idx = currentIndex.coerceIn(0, points.size - 1)
val p = points[idx]
map.style?.getSourceAs<GeoJsonSource>("cursor")?.setGeoJson(
FeatureCollection.fromFeatures(
listOf(Feature.fromGeometry(Point.fromLngLat(p.longitude, p.latitude)))
)
)
}
AndroidView(factory = { mapView }, modifier = modifier)
}
private fun buildTripStyle(tileUrl: String, minZoom: Double, maxZoom: Double): Style.Builder {
val tileSet = TileSet("2.1.0", tileUrl)
tileSet.minZoom = minZoom.toFloat()
tileSet.maxZoom = maxZoom.toFloat()
tileSet.attribution = "MapTiler"
val raster = RasterSource("tiles", tileSet, 256)
val rasterLayer = RasterLayer("tiles-layer", "tiles")
val routeSource = GeoJsonSource("route")
val routeLayer = LineLayer("route-layer", "route")
routeLayer.setProperties(
PropertyFactory.lineColor(Color.parseColor("#E63946")),
PropertyFactory.lineWidth(5f),
PropertyFactory.lineCap(Property.LINE_CAP_ROUND),
PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND),
)
val cursorSource = GeoJsonSource("cursor")
val cursorLayer = CircleLayer("cursor-layer", "cursor")
cursorLayer.setProperties(
PropertyFactory.circleRadius(8f),
PropertyFactory.circleColor(Color.WHITE),
PropertyFactory.circleStrokeColor(Color.parseColor("#E63946")),
PropertyFactory.circleStrokeWidth(3f),
)
val maxSource = GeoJsonSource("max")
val maxLayer = CircleLayer("max-marker", "max")
maxLayer.setProperties(
PropertyFactory.circleRadius(8f),
PropertyFactory.circleColor(Color.parseColor("#F6C23E")),
PropertyFactory.circleStrokeColor(Color.WHITE),
PropertyFactory.circleStrokeWidth(2f),
)
return Style.Builder()
.withSource(raster)
.withLayer(rasterLayer)
.withSource(routeSource)
.withLayer(routeLayer)
.withSource(cursorSource)
.withLayer(cursorLayer)
.withSource(maxSource)
.withLayer(maxLayer)
}
private fun applyRoute(map: MapLibreMap, points: List<RoutePoint>, maxIndex: Int) {
val lngLats = points.map { Point.fromLngLat(it.longitude, it.latitude) }
val line = LineString.fromLngLats(lngLats)
val feature = Feature.fromGeometry(line)
map.style?.getSourceAs<GeoJsonSource>("route")?.setGeoJson(
FeatureCollection.fromFeatures(listOf(feature))
)
if (maxIndex in points.indices) {
val mp = points[maxIndex]
map.style?.getSourceAs<GeoJsonSource>("max")?.setGeoJson(
FeatureCollection.fromFeatures(
listOf(Feature.fromGeometry(Point.fromLngLat(mp.longitude, mp.latitude)))
)
)
}
val boundsBuilder = LatLngBounds.Builder()
lngLats.forEach { boundsBuilder.include(LatLng(it.latitude(), it.longitude())) }
map.animateCamera(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), 60), 800)
}
@@ -0,0 +1,272 @@
package com.yarisobd.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.yarisobd.app.data.remote.dto.RoutePoint
import com.yarisobd.app.data.remote.dto.TripMeta
import com.yarisobd.app.data.remote.dto.TripRoute
import com.yarisobd.app.data.remote.dto.TripSummary
import com.yarisobd.app.ui.theme.YarisDark
import kotlinx.coroutines.delay
import java.util.Locale
private val MAX_AMBER = Color(0xFFF6C23E)
@Composable
fun TripReplayScreen(
trip: TripSummary,
route: TripRoute?,
loading: Boolean,
onBack: () -> Unit,
mapTileUrl: String = PI_TILE_URL,
mapMinZoom: Double = 8.0,
mapMaxZoom: Double = 15.0,
) {
val points = route?.points ?: emptyList()
val maxIndex = remember(points) {
var idx = -1
var best = 0.0
for (i in points.indices) {
if (points[i].speed > best) {
best = points[i].speed
idx = i
}
}
if (best > 0.0) idx else -1
}
var currentIndex by remember(route) { mutableIntStateOf(0) }
var playing by remember(route) { mutableStateOf(false) }
// Animate the scrubber while playing.
LaunchedEffect(playing, points.size) {
if (!playing || points.isEmpty()) return@LaunchedEffect
while (playing && currentIndex < points.size - 1) {
delay(200)
currentIndex++
}
if (currentIndex >= points.size - 1) playing = false
}
Column(modifier = Modifier.fillMaxSize()) {
// Top bar: back + title (dark, matching the global header)
Surface(color = YarisDark) {
Row(
Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Color.White)
}
Column {
Text(
"Trip #${trip.id}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
dateText(trip.start_datetime),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.7f)
)
}
}
}
// Map
Box(Modifier.fillMaxWidth().height(300.dp)) {
if (loading && points.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (points.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No route points", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
TripMapView(
points = points,
currentIndex = currentIndex,
maxIndex = maxIndex,
modifier = Modifier.fillMaxSize(),
onMaxClick = {
if (maxIndex >= 0) {
currentIndex = maxIndex
playing = false
}
},
tileUrl = mapTileUrl,
minZoom = mapMinZoom,
maxZoom = mapMaxZoom,
)
}
}
// Stats + scrubber
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).navigationBarsPadding().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
TripStatsHeader(route?.trip ?: TripMeta())
Card(
Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(Modifier.padding(14.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
FilledIconButton(
onClick = {
if (points.isEmpty()) return@FilledIconButton
if (currentIndex >= points.size - 1) currentIndex = 0
playing = !playing
},
enabled = points.isNotEmpty(),
) {
Icon(
if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (playing) "Pause" else "Play"
)
}
Spacer(Modifier.width(10.dp))
Text(
pointLabel(points, currentIndex),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
modifier = Modifier.weight(1f)
)
if (maxIndex >= 0) {
Text(
"Max ${"%.0f".format(Locale.US, points[maxIndex].speed)} km/h",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
color = MAX_AMBER,
modifier = Modifier.clickable {
currentIndex = maxIndex
playing = false
}
)
}
}
Spacer(Modifier.height(4.dp))
BoxWithConstraints(Modifier.fillMaxWidth()) {
Slider(
value = if (points.isEmpty()) 0f else currentIndex.toFloat(),
onValueChange = {
currentIndex = it.toInt()
playing = false
},
valueRange = 0f..(points.size - 1).coerceAtLeast(1).toFloat(),
enabled = points.size > 1,
)
if (maxIndex >= 0 && points.size > 1) {
val ratio = maxIndex.toFloat() / (points.size - 1).toFloat()
val thumbInset = 12.dp
val dot = 10.dp
val x = thumbInset + (maxWidth - thumbInset * 2) * ratio
Box(
Modifier
.align(Alignment.CenterStart)
.offset(x = x - dot / 2)
.size(dot)
.clip(CircleShape)
.background(MAX_AMBER)
.clickable {
currentIndex = maxIndex
playing = false
}
)
}
}
}
}
}
}
}
@Composable
private fun TripStatsHeader(meta: TripMeta) {
Card(
Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Row(Modifier.fillMaxWidth().padding(14.dp)) {
HeaderStat("Distance", "%.1f km".format(Locale.US, meta.distance_km), Modifier.weight(1f))
HeaderStat("Duration", durationText(meta.duration_seconds), Modifier.weight(1f))
HeaderStat("Avg", "%.0f km/h".format(Locale.US, meta.average_speed_kmh), Modifier.weight(1f))
HeaderStat("Max", "%.0f km/h".format(Locale.US, meta.max_speed_kmh), Modifier.weight(1f))
}
}
}
@Composable
private fun HeaderStat(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
}
private fun pointLabel(points: List<RoutePoint>, index: Int): String {
if (points.isEmpty()) return "—"
val p = points[index.coerceIn(0, points.size - 1)]
val t = if (p.datetime.length >= 19) p.datetime.substring(11, 19) else p.datetime
return "$t · ${"%.0f".format(Locale.US, p.speed)} km/h"
}
private fun dateText(dt: String): String = if (dt.length >= 10) dt.substring(0, 10) else dt
private fun durationText(seconds: Int): String {
if (seconds < 60) return "${seconds}s"
val h = seconds / 3600
val m = (seconds % 3600) / 60
return if (h > 0) "${h}h ${m}m" else "${m}m"
}
@@ -0,0 +1,128 @@
package com.yarisobd.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.yarisobd.app.data.remote.PiApi
import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest
import com.yarisobd.app.data.remote.dto.GeocodeCoord
import com.yarisobd.app.data.remote.dto.LatLon
import com.yarisobd.app.data.remote.dto.TripRoute
import com.yarisobd.app.data.remote.dto.TripSummary
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.Locale
import java.util.concurrent.TimeUnit
/** Geocoded start/end labels for one trip. */
data class TripLabels(val start: String, val end: String)
class TripViewModel(application: Application) : AndroidViewModel(application) {
private val baseUrl = "http://10.42.0.1"
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
private val okHttp = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
private val api: PiApi = Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(PiApi::class.java)
private val _trips = MutableStateFlow<List<TripSummary>>(emptyList())
val trips: StateFlow<List<TripSummary>> = _trips.asStateFlow()
private val _tripLabels = MutableStateFlow<Map<Int, TripLabels>>(emptyMap())
val tripLabels: StateFlow<Map<Int, TripLabels>> = _tripLabels.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _route = MutableStateFlow<TripRoute?>(null)
val route: StateFlow<TripRoute?> = _route.asStateFlow()
private val _routeLoading = MutableStateFlow(false)
val routeLoading: StateFlow<Boolean> = _routeLoading.asStateFlow()
init {
loadTrips()
}
fun loadTrips() {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
val resp = api.getTrips()
_trips.value = resp.trips
geocodeTripEndpoints(resp.trips)
} catch (e: Exception) {
_error.value = "Failed to load trips: ${e.message}"
} finally {
_isLoading.value = false
}
}
}
fun loadRoute(tripId: Int) {
viewModelScope.launch {
_routeLoading.value = true
_error.value = null
try {
_route.value = api.getTripRoute(tripId)
} catch (e: Exception) {
_error.value = "Failed to load route: ${e.message}"
} finally {
_routeLoading.value = false
}
}
}
fun clearRoute() {
_route.value = null
}
/** Best-effort: geocode every unique start/end coordinate in one batch call. */
private suspend fun geocodeTripEndpoints(trips: List<TripSummary>) {
val coords = mutableListOf<GeocodeCoord>()
for (t in trips) {
t.start?.takeIf { it.latitude != null && it.longitude != null }?.let {
coords += GeocodeCoord(it.latitude!!, it.longitude!!)
}
t.end?.takeIf { it.latitude != null && it.longitude != null }?.let {
coords += GeocodeCoord(it.latitude!!, it.longitude!!)
}
}
if (coords.isEmpty()) return
try {
val result = api.geocodeBatch(GeocodeBatchRequest(coords))
val labels = mutableMapOf<Int, TripLabels>()
for (t in trips) {
labels[t.id] = TripLabels(labelFor(result, t.start), labelFor(result, t.end))
}
_tripLabels.value = labels
} catch (_: Exception) {
// Geocoding is best-effort — the list still renders with raw coords.
}
}
private fun labelFor(result: Map<String, String>, ll: LatLon?): String {
val lat = ll?.latitude ?: return "—"
val lon = ll?.longitude ?: return "—"
val key = String.format(Locale.US, "%.3f,%.3f", lat, lon)
return result[key] ?: String.format(Locale.US, "%.4f, %.4f", lat, lon)
}
}
@@ -0,0 +1,218 @@
package com.yarisobd.app
import android.content.Context
import android.os.Environment
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.Buffer
import okio.BufferedSink
import okio.ForwardingSink
import okio.buffer
import okio.source
import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
class UploadWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
companion object {
const val TAG = "UploadWorker"
const val KEY_FILENAME = "filename"
const val KEY_SERVER_URL = "server_url"
const val KEY_DEVICE_NAME = "device_name"
const val KEY_API_KEY = "api_key"
const val CHUNK_SIZE = 40L * 1024 * 1024 // 40MB
}
private val okHttp = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.MINUTES)
.writeTimeout(5, TimeUnit.MINUTES)
.build()
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val filename = inputData.getString(KEY_FILENAME) ?: return@withContext Result.failure()
val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure()
val deviceName = inputData.getString(KEY_DEVICE_NAME) ?: return@withContext Result.failure()
val apiKey = inputData.getString(KEY_API_KEY) ?: return@withContext Result.failure()
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
"yaris-obd/$filename"
)
Log.d(TAG, "doWork start: $filename size=${file.length()} server=$serverUrl device=$deviceName")
if (!file.exists()) {
Log.e(TAG, "File not found: ${file.absolutePath}")
return@withContext Result.failure(workDataOf("error" to "File not found"))
}
try {
// Promote to foreground immediately so large files aren't killed during hashing
setForeground(DownloadWorker.createForegroundNotification(applicationContext, "Preparing $filename", 0))
setProgress(workDataOf("pct" to 0, "status" to "Hashing…"))
// Compute SHA256
Log.d(TAG, "Hashing $filename ...")
val sha256 = computeSha256(file)
Log.d(TAG, "SHA256 done: $sha256")
val relativePath = filename // Using filename as relative_path since flat dir
// Check status
setProgress(workDataOf("pct" to 0, "status" to "Checking server…"))
val statusUrl = "$serverUrl/api/v1/dashcam/upload/status?relative_path=$relativePath&sha256=$sha256"
Log.d(TAG, "Status check: $statusUrl")
val statusReq = Request.Builder().url(statusUrl)
.addHeader("X-Device-Name", deviceName)
.addHeader("X-Api-Key", apiKey)
.get().build()
val statusRes = okHttp.newCall(statusReq).execute()
val statusBody = statusRes.body?.string() ?: ""
Log.d(TAG, "Status response: HTTP ${statusRes.code} body=$statusBody")
val statusJson = JSONObject(statusBody)
if (statusJson.optString("status") == "already_uploaded") {
Log.d(TAG, "Already uploaded, skipping: $filename")
return@withContext Result.success(workDataOf("filename" to filename))
}
val receivedIndices = statusJson.optJSONArray("received_chunk_indices")
?.let { arr -> (0 until arr.length()).map { arr.getInt(it) } }
?.toSet() ?: emptySet()
val totalChunks = ((file.length() + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt()
Log.d(TAG, "Uploading: totalChunks=$totalChunks received=$receivedIndices")
setForeground(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", 0))
setProgress(workDataOf("pct" to 0, "status" to "Uploading…"))
// Upload missing chunks with byte-level progress
val fileSize = file.length()
val chunkSizes = LongArray(totalChunks) { i -> minOf(CHUNK_SIZE, fileSize - i * CHUNK_SIZE) }
var baseBytes = receivedIndices.sumOf { chunkSizes[it] }
var lastProgressMs = 0L
var lastNotifyMs = 0L
fun reportProgress(bytesWritten: Long) {
val now = System.currentTimeMillis()
val totalBytes = baseBytes + bytesWritten
val pct = ((totalBytes * 100) / fileSize).toInt().coerceIn(0, 100)
if (now - lastProgressMs >= 250) {
lastProgressMs = now
Log.d(TAG, "Progress: $pct% ($totalBytes/$fileSize bytes)")
setProgressAsync(workDataOf("pct" to pct, "status" to "Uploading…"))
}
if (now - lastNotifyMs >= 1000) {
lastNotifyMs = now
setForegroundAsync(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", pct))
}
}
for (i in 0 until totalChunks) {
if (i in receivedIndices) continue
val offset = i * CHUNK_SIZE
val chunkSize = chunkSizes[i].toInt()
val chunkBytes = ByteArray(chunkSize)
FileInputStream(file).use { fis ->
var skipped = 0L
while (skipped < offset) {
val s = fis.skip(offset - skipped)
if (s <= 0) break
skipped += s
}
var read = 0
while (read < chunkSize) {
val n = fis.read(chunkBytes, read, chunkSize - read)
if (n < 0) break
read += n
}
}
val chunkBody = chunkBytes.toRequestBody("application/octet-stream".toMediaType())
val multipart = okhttp3.MultipartBody.Builder()
.setType(okhttp3.MultipartBody.FORM)
.addFormDataPart("file", filename, chunkBody)
.addFormDataPart("relative_path", relativePath)
.addFormDataPart("sha256", sha256)
.addFormDataPart("chunk_index", i.toString())
.addFormDataPart("total_chunks", totalChunks.toString())
.build()
val body = ProgressRequestBody(multipart) { written -> reportProgress(written) }
val uploadReq = Request.Builder()
.url("$serverUrl/api/v1/dashcam/upload/chunk")
.addHeader("X-Device-Name", deviceName)
.addHeader("X-Api-Key", apiKey)
.post(body)
.build()
Log.d(TAG, "Uploading chunk $i/$totalChunks ($chunkSize bytes)...")
val uploadRes = okHttp.newCall(uploadReq).execute()
Log.d(TAG, "Chunk $i response: HTTP ${uploadRes.code}")
if (!uploadRes.isSuccessful) {
Log.e(TAG, "Chunk $i failed: HTTP ${uploadRes.code} body=${uploadRes.body?.string()}")
return@withContext Result.failure(workDataOf("error" to "Chunk $i: HTTP ${uploadRes.code}"))
}
baseBytes += chunkSizes[i]
val pct = ((baseBytes * 100) / fileSize).toInt().coerceIn(0, 100)
setForeground(DownloadWorker.createForegroundNotification(applicationContext, "Uploading $filename", pct))
setProgress(workDataOf("pct" to pct, "status" to "Uploading…"))
}
return@withContext Result.success(workDataOf("filename" to filename))
} catch (e: Exception) {
Log.e(TAG, "Upload exception", e)
return@withContext Result.failure(workDataOf("error" to (e.message ?: "Unknown error")))
}
}
private fun computeSha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { fis ->
val buffer = ByteArray(8192)
var read: Int
while (fis.read(buffer).also { read = it } != -1) {
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private class ProgressRequestBody(
private val delegate: RequestBody,
private val onBytesWritten: (Long) -> Unit,
) : RequestBody() {
override fun contentType() = delegate.contentType()
override fun contentLength() = delegate.contentLength()
override fun writeTo(sink: BufferedSink) {
var written = 0L
val countingSink = object : ForwardingSink(sink) {
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
written += byteCount
onBytesWritten(written)
}
}
val buffered = countingSink.buffer()
delegate.writeTo(buffered)
buffered.flush()
}
}
}
@@ -0,0 +1,28 @@
package com.yarisobd.app
import com.yarisobd.app.data.remote.dto.VideoFile
import java.text.DecimalFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val sizeFormat = DecimalFormat("#,##0.#")
private val dateFormat = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.getDefault())
fun VideoFile.formattedSize(): String {
val bytes = size.toDouble()
return when {
bytes >= 1_000_000_000 -> "${sizeFormat.format(bytes / 1_000_000_000)} GB"
bytes >= 1_000_000 -> "${sizeFormat.format(bytes / 1_000_000)} MB"
bytes >= 1_000 -> "${sizeFormat.format(bytes / 1_000)} KB"
else -> "${size.toLong()} B"
}
}
fun VideoFile.formattedDate(): String {
return try {
dateFormat.format(Date((modified_at * 1000).toLong()))
} catch (_: Exception) {
""
}
}
@@ -0,0 +1,19 @@
package com.yarisobd.app
import android.app.Application
import org.maplibre.android.MapLibre
class YarisApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
DownloadWorker.createChannel(this)
MapLibre.getInstance(this)
}
companion object {
lateinit var instance: YarisApp
private set
}
}
@@ -0,0 +1,53 @@
package com.yarisobd.app.data
import android.content.Context
object CredentialStore {
private const val PREFS = "yaris_creds"
private const val KEY_SERVER_URL = "server_url"
private const val KEY_DEVICE_NAME = "device_name"
private const val KEY_API_KEY = "api_key"
private const val KEY_UPLOADED = "uploaded_files"
private const val KEY_PORTAL_USERNAME = "portal_username"
private const val KEY_PORTAL_PASSWORD = "portal_password"
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun getServerUrl(context: Context): String = prefs(context).getString(KEY_SERVER_URL, "") ?: ""
fun getDeviceName(context: Context): String = prefs(context).getString(KEY_DEVICE_NAME, "") ?: ""
fun getApiKey(context: Context): String = prefs(context).getString(KEY_API_KEY, "") ?: ""
fun isConfigured(context: Context): Boolean =
getServerUrl(context).isNotBlank() && getDeviceName(context).isNotBlank() && getApiKey(context).isNotBlank()
fun save(context: Context, serverUrl: String, deviceName: String, apiKey: String) {
prefs(context).edit()
.putString(KEY_SERVER_URL, serverUrl)
.putString(KEY_DEVICE_NAME, deviceName)
.putString(KEY_API_KEY, apiKey)
.apply()
}
fun markUploaded(context: Context, filename: String) {
val set = getUploaded(context).toMutableSet()
set.add(filename)
prefs(context).edit().putStringSet(KEY_UPLOADED, set).apply()
}
fun getUploaded(context: Context): Set<String> =
prefs(context).getStringSet(KEY_UPLOADED, emptySet()) ?: emptySet()
fun getPortalUsername(context: Context): String =
prefs(context).getString(KEY_PORTAL_USERNAME, "") ?: ""
fun getPortalPassword(context: Context): String =
prefs(context).getString(KEY_PORTAL_PASSWORD, "") ?: ""
fun savePortalCredentials(context: Context, username: String, password: String) {
prefs(context).edit()
.putString(KEY_PORTAL_USERNAME, username)
.putString(KEY_PORTAL_PASSWORD, password)
.apply()
}
}
@@ -0,0 +1,85 @@
package com.yarisobd.app.data.remote
import com.yarisobd.app.data.remote.dto.DashcamFilesResponse
import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest
import com.yarisobd.app.data.remote.dto.GeocodeResult
import com.yarisobd.app.data.remote.dto.GpsStatus
import com.yarisobd.app.data.remote.dto.ObdStatus
import com.yarisobd.app.data.remote.dto.Stats
import com.yarisobd.app.data.remote.dto.TripRoute
import com.yarisobd.app.data.remote.dto.TripsResponse
import com.yarisobd.app.data.remote.dto.VideoListResponse
import kotlinx.serialization.json.JsonObject
import okhttp3.ResponseBody
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
/**
* REST API for the Pi's local web server (10.42.0.1 over WiFi hotspot).
*/
interface PiApi {
/** GPS fix status */
@GET("/api/gps/status")
suspend fun getGpsStatus(): GpsStatus
/** Live OBD values (speed, rpm, coolant, fuel, throttle, MIL) */
@GET("/api/obd/status")
suspend fun getObdStatus(): ObdStatus
/** Overview stats (trips, odometer, DTC count, video count) */
@GET("/api/stats")
suspend fun getStats(): Stats
/** Trip list (Phase 18) — newest first */
@GET("/api/trips")
suspend fun getTrips(): TripsResponse
/** Trip route points + metadata (Phase 18) */
@GET("/api/trip/{id}")
suspend fun getTripRoute(@Path("id") id: Int): TripRoute
/** Reverse-geocode a single coordinate (offline on the Pi) */
@GET("/api/geocode")
suspend fun geocode(@Query("lat") lat: Double, @Query("lon") lon: Double): GeocodeResult
/** Batch reverse-geocode — returns { "lat,lon": "label", ... } */
@POST("/api/geocode/batch")
suspend fun geocodeBatch(@Body body: GeocodeBatchRequest): Map<String, String>
/** List imported dashcam videos (Phase 17) */
@GET("/api/local-videos")
suspend fun listVideos(): VideoListResponse
/** Stream/download a video file with Range support */
@Streaming
@GET("/api/local-video/file")
suspend fun getVideoFile(@Query("path") path: String): ResponseBody
/** Get a cached thumbnail JPEG */
@GET("/api/local-video/thumbnail")
suspend fun getThumbnail(@Query("path") path: String): ResponseBody
/** Delete local copy only */
// --- USB dashcam endpoints ---
/** List files on USB dashcam (Phase 16) */
@GET("/api/dash/files")
suspend fun listDashcamFiles(): DashcamFilesResponse
/** Delete a single file from USB dashcam */
@HTTP(method = "DELETE", path = "/api/dash/file", hasBody = true)
suspend fun deleteDashcamFile(@Body body: Map<String, String>): JsonObject
/** Import files from USB dashcam (selected or all) */
@POST("/api/dash/import")
suspend fun importDashcamFiles(@Body body: Map<String, List<String>>): JsonObject
@HTTP(method = "DELETE", path = "/api/local-video", hasBody = true)
suspend fun deleteVideo(@Body body: Map<String, String>): JsonObject
}
@@ -0,0 +1,38 @@
package com.yarisobd.app.data.remote
import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest
import com.yarisobd.app.data.remote.dto.GeocodeResult
import com.yarisobd.app.data.remote.dto.PortalDashcamFile
import com.yarisobd.app.data.remote.dto.PortalStats
import com.yarisobd.app.data.remote.dto.PortalTripSummary
import com.yarisobd.app.data.remote.dto.TripRoute
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Remote server portal API (https://yaris.ceaf.dev) — requires a logged-in
* browser session (Flask session cookie), established by PortalViewModel.
*/
interface PortalApi {
@GET("/api/v1/portal/stats")
suspend fun stats(): PortalStats
@GET("/api/v1/portal/trips")
suspend fun trips(@Query("limit") limit: Int = 200): List<PortalTripSummary>
@GET("/api/v1/portal/trip/{id}")
suspend fun tripRoute(@Path("id") id: Int): TripRoute
@GET("/api/v1/portal/dashcam")
suspend fun dashcam(@Query("limit") limit: Int = 500): List<PortalDashcamFile>
@GET("/api/geocode")
suspend fun geocode(@Query("lat") lat: Double, @Query("lon") lon: Double): GeocodeResult
@POST("/api/geocode/batch")
suspend fun geocodeBatch(@Body body: GeocodeBatchRequest): Map<String, String>
}
@@ -0,0 +1,40 @@
package com.yarisobd.app.data.remote
import com.yarisobd.app.data.remote.dto.HealthResponse
import com.yarisobd.app.data.remote.dto.UploadStatus
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Query
interface ServerApi {
@GET("/health")
suspend fun health(): HealthResponse
/** Check which chunks already received */
@GET("/api/v1/dashcam/upload/status")
suspend fun uploadStatus(
@Query("relative_path") path: String,
@Query("sha256") sha256: String,
@Header("X-Device-Name") deviceName: String,
@Header("X-Api-Key") apiKey: String,
): UploadStatus
/** Upload one 40MB chunk */
@Multipart
@POST("/api/v1/dashcam/upload/chunk")
suspend fun uploadChunk(
@Part file: MultipartBody.Part,
@Part("relative_path") path: RequestBody,
@Part("sha256") sha256: RequestBody,
@Part("chunk_index") chunkIndex: RequestBody,
@Part("total_chunks") totalChunks: RequestBody,
@Header("X-Device-Name") deviceName: String,
@Header("X-Api-Key") apiKey: String,
): UploadStatus
}
@@ -0,0 +1,30 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class DashcamFilesResponse(
val status: String = "",
val message: String? = null,
val device: DashcamDevice? = null,
val files: List<DashcamFile> = emptyList(),
val count: Int = 0,
val total_size: Long = 0,
val errors: List<String> = emptyList(),
)
@Serializable
data class DashcamDevice(
val path: String = "",
val fstype: String = "",
val label: String? = null,
val size: String? = null,
val mountpoint: String? = null,
)
@Serializable
data class DashcamFile(
val path: String = "",
val size: Long = 0,
val modified: Double = 0.0,
)
@@ -0,0 +1,23 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* Response from Pi's GET /api/geocode (offline reverse geocoder).
*/
@Serializable
data class GeocodeResult(
val label: String = "",
)
/** Request body for Pi's POST /api/geocode/batch. */
@Serializable
data class GeocodeBatchRequest(
val coords: List<GeocodeCoord> = emptyList(),
)
@Serializable
data class GeocodeCoord(
val lat: Double = 0.0,
val lon: Double = 0.0,
)
@@ -0,0 +1,15 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* Response from Pi's GET /api/gps/status (Phase 25.5d).
*/
@Serializable
data class GpsStatus(
val thread_alive: Boolean = false,
val has_fix: Boolean = false,
val latitude: Double? = null,
val longitude: Double? = null,
val fix_age_seconds: Double? = null,
)
@@ -0,0 +1,11 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* Response from server's GET /health (Phase 9).
*/
@Serializable
data class HealthResponse(
val status: String = "",
)
@@ -0,0 +1,23 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* Response from Pi's GET /api/obd/status.
*
* Live OBD values — numeric fields are null while the Pi hasn't received a
* fresh value from the car yet.
*/
@Serializable
data class ObdStatus(
val adapter_present: Boolean = false,
val port: String? = null,
val car_connected: Boolean = false,
val status_label: String = "",
val speed: Double? = null,
val rpm: Double? = null,
val coolant_temp: Double? = null,
val fuel_level: Double? = null,
val throttle_position: Double? = null,
val mil_on: Boolean? = null,
)
@@ -0,0 +1,61 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* One trip from the server's GET /api/v1/portal/trips (flat lat/lon, ISO dates).
*/
@Serializable
data class PortalTripSummary(
val id: Int = 0,
val start_datetime: String = "",
val end_datetime: String = "",
val distance_km: Double = 0.0,
val duration_seconds: Int = 0,
val average_speed_kmh: Double = 0.0,
val max_speed_kmh: Double = 0.0,
val start_lat: Double? = null,
val start_lon: Double? = null,
val end_lat: Double? = null,
val end_lon: Double? = null,
val point_count: Int = 0,
val odo_start_km: Double? = null,
val odo_end_km: Double? = null,
) {
/** Map to the shared TripSummary shape so portal trips reuse the Pi UI. */
fun toTripSummary(): TripSummary = TripSummary(
id = id,
start_datetime = start_datetime,
end_datetime = end_datetime,
start = if (start_lat != null && start_lon != null) LatLon(start_lat, start_lon) else null,
end = if (end_lat != null && end_lon != null) LatLon(end_lat, end_lon) else null,
distance_km = distance_km,
duration_seconds = duration_seconds,
point_count = point_count,
average_speed_kmh = average_speed_kmh,
max_speed_kmh = max_speed_kmh,
odo_start_km = odo_start_km,
odo_end_km = odo_end_km,
)
}
/** Response from the server's GET /api/v1/portal/stats. */
@Serializable
data class PortalStats(
val trip_count: Int = 0,
val total_distance_km: Double = 0.0,
val total_drive_time_s: Int = 0,
val top_speed_kmh: Double = 0.0,
val video_count: Int = 0,
val odometer_km: Double? = null,
)
/** One file from the server's GET /api/v1/portal/dashcam. */
@Serializable
data class PortalDashcamFile(
val id: Int = 0,
val relative_path: String = "",
val size_bytes: Long = 0,
val uploaded_at: String = "",
val storage_path: String = "",
)
@@ -0,0 +1,18 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* Response from Pi's GET /api/stats (Phase 23 overview stats).
*/
@Serializable
data class Stats(
val trip_count: Int = 0,
val total_km: Double = 0.0,
val total_duration_s: Double = 0.0,
val all_time_max_speed: Double = 0.0,
val active_dtc: Int = 0,
val video_count: Int = 0,
val odometer_km: Double? = null,
val odometer_source: String = "",
)
@@ -0,0 +1,35 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* Response from Pi's GET /api/trip/{id}: trip metadata + route points.
*/
@Serializable
data class TripRoute(
val trip: TripMeta = TripMeta(),
val points: List<RoutePoint> = emptyList(),
)
@Serializable
data class TripMeta(
val id: Int = 0,
val start_datetime: String = "",
val end_datetime: String = "",
val distance_km: Double = 0.0,
val duration_seconds: Int = 0,
val point_count: Int = 0,
val average_speed_kmh: Double = 0.0,
val max_speed_kmh: Double = 0.0,
)
@Serializable
data class RoutePoint(
val id: Int = 0,
val datetime: String = "",
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val speed: Double = 0.0,
val speed_source: String = "",
val odometer: Double? = null,
)
@@ -0,0 +1,33 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* One trip from the Pi's GET /api/trips (Phase 18). Newest first.
*/
@Serializable
data class TripSummary(
val id: Int = 0,
val start_datetime: String = "",
val end_datetime: String = "",
val start: LatLon? = null,
val end: LatLon? = null,
val distance_km: Double = 0.0,
val duration_seconds: Int = 0,
val point_count: Int = 0,
val average_speed_kmh: Double = 0.0,
val max_speed_kmh: Double = 0.0,
val odo_start_km: Double? = null,
val odo_end_km: Double? = null,
)
@Serializable
data class LatLon(
val latitude: Double? = null,
val longitude: Double? = null,
)
@Serializable
data class TripsResponse(
val trips: List<TripSummary> = emptyList(),
)
@@ -0,0 +1,15 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class UploadStatus(
val status: String = "", // "already_uploaded", "in_progress", "not_started"
val received_chunk_indices: List<Int>? = null,
)
@Serializable
data class UploadChunkResponse(
val status: String = "", // "already_uploaded", "ok"
val error: String? = null,
)
@@ -0,0 +1,29 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class VideoListResponse(
val status: String = "",
val files: List<VideoFile> = emptyList(),
val count: Int = 0,
val total_size: Long = 0,
val errors: List<String> = emptyList(),
val retention_days: Int = 0,
)
@Serializable
data class VideoFile(
val path: String = "",
val size: Long = 0,
val modified_at: Double = 0.0,
val uploaded: Boolean = false,
val converting: Boolean = false,
val convert_progress: ConvertProgress? = null,
)
@Serializable
data class ConvertProgress(
val elapsed_s: Int = 0,
val pct: Int = 0,
)
@@ -0,0 +1,61 @@
package com.yarisobd.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.yarisobd.app.ui.theme.YarisDark
import com.yarisobd.app.ui.theme.YarisRed
/** Global dark top bar: YARIS wordmark + red underline + phase subtitle. */
@Composable
fun YarisHeader() {
Surface(color = YarisDark) {
Column(
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 10.dp)
) {
Text(
"YARIS-OBD",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Italic,
letterSpacing = 1.5.sp,
),
color = Color.White,
maxLines = 1,
)
Spacer(Modifier.height(2.dp))
Box(
Modifier
.width(48.dp)
.height(3.dp)
.background(YarisRed, RoundedCornerShape(2.dp))
)
Spacer(Modifier.height(6.dp))
Text(
"phase 28 — android companion",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.55f),
)
}
}
}
@@ -0,0 +1,104 @@
package com.yarisobd.app.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
// Brand colors — matching static/yaris.css (mobile web UI)
val YarisRed = Color(0xFFE63946) // --accent
val YarisDark = Color(0xFF1C1C1E) // --sidebar-bg (top bar + bottom nav)
val YarisBlue = Color(0xFF4E73DF) // --primary
val YarisBody = Color(0xFFF4F6FB) // body background
val YarisMuted = Color(0xFF6C757D) // text-muted
private val LightColorScheme = lightColorScheme(
primary = YarisBlue,
onPrimary = Color.White,
primaryContainer = Color(0xFFD6E0FB),
onPrimaryContainer = Color(0xFF1B2A5A),
secondary = YarisBlue,
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8EDFB),
onSecondaryContainer = Color(0xFF1B2A5A),
tertiary = Color(0xFF36B9CC),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFD7F2F8),
onTertiaryContainer = Color(0xFF0A3A42),
background = YarisBody,
onBackground = Color(0xFF212529),
surface = Color.White,
onSurface = Color(0xFF212529),
surfaceVariant = Color(0xFFEDF0F5),
onSurfaceVariant = YarisMuted,
surfaceContainerHigh = Color.White,
surfaceContainer = Color.White,
error = YarisRed,
onError = Color.White,
errorContainer = Color(0xFFFBE3E5),
onErrorContainer = Color(0xFF5A1016),
outline = Color(0xFFCED4DA),
)
private val DarkColorScheme = darkColorScheme(
primary = YarisBlue,
onPrimary = Color.White,
primaryContainer = Color(0xFF2B3B6E),
onPrimaryContainer = Color(0xFFD6E0FB),
secondary = YarisBlue,
onSecondary = Color.White,
background = Color(0xFF121316),
onBackground = Color(0xFFE0E0E0),
surface = YarisDark,
onSurface = Color(0xFFE0E0E0),
surfaceContainerHigh = Color(0xFF2A2A2C),
surfaceContainer = YarisDark,
surfaceVariant = Color(0xFF2A2A2C),
onSurfaceVariant = Color(0xFFAAAAAA),
error = YarisRed,
onError = Color.White,
errorContainer = Color(0xFF5A1016),
onErrorContainer = Color(0xFFFBE3E5),
outline = Color(0xFF3A3A3C),
)
@Composable
fun YarisOBDTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // Off by default — use our brand colors
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
// Dark top bar sits under the status bar, so status icons stay light.
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
@@ -0,0 +1,112 @@
package com.yarisobd.app.util
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import okhttp3.Dns
import okhttp3.OkHttpClient
import java.net.InetAddress
import java.net.Proxy
/**
* Discovers and binds OkHttp clients to specific Android [Network] objects
* for simultaneous WiFi + cellular connectivity.
*
* This is the core unlock for Phase 28 — the web UI (Phase 11/12) could
* never do this because browsers have no access to per-socket network binding.
* A native app using [Network.bindSocket] / [Network.socketFactory] can.
*/
object NetworkBinder {
/**
* Finds the WiFi network associated with the Pi hotspot ("yaris 911").
* Matches any TRANSPORT_WIFI network — on Pixel devices with "switch to
* mobile data automatically", the OS may still tag the WiFi with
* NET_CAPABILITY_INTERNET (relayed through cellular), so we can't
* require !INTERNET.
*
* Falls back to the active network if it's WiFi.
*/
fun findPiHotspotNetwork(context: Context): Network? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
// First: find any WiFi network (the only WiFi network the phone is on
// should be the Pi hotspot when using this app)
val wifiNetwork = cm.allNetworks.firstOrNull { network ->
val caps = cm.getNetworkCapabilities(network) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}
if (wifiNetwork != null) return wifiNetwork
// Fallback: if active network is WiFi, use it
val active = cm.activeNetwork
if (active != null) {
val caps = cm.getNetworkCapabilities(active)
if (caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) {
return active
}
}
return null
}
/**
* Finds the cellular network.
* Identified by: TRANSPORT_CELLULAR + NET_CAPABILITY_INTERNET
*/
fun findCellularNetwork(context: Context): Network? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return cm.allNetworks.firstOrNull { network ->
val caps = cm.getNetworkCapabilities(network) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
/**
* Returns the current default network (whatever Android picked).
* Useful as a fallback when cellular isn't available — the default
* network may still route internet traffic.
*/
fun findDefaultNetwork(context: Context): Network? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return cm.activeNetwork
}
/**
* Creates an OkHttpClient whose sockets are bound to [network].
*
* Uses [Network.socketFactory] so ALL sockets (including redirects,
* connection pooling, DNS) use the specified network interface.
* Also overrides DNS resolution through the same network to avoid
* leaking DNS queries to the wrong interface.
*/
fun createBoundClient(network: Network): OkHttpClient {
return OkHttpClient.Builder()
.socketFactory(network.socketFactory)
.dns(NetworkDns(network))
.proxy(Proxy.NO_PROXY) // Don't use system proxy — may route to wrong network
.build()
}
/**
* Creates an OkHttpClient for the default (system-chosen) network.
*/
fun createDefaultClient(): OkHttpClient {
return OkHttpClient.Builder().build()
}
/**
* DNS resolver that uses a specific [Network]'s name resolution.
* OkHttp's default Dns uses java.net.InetAddress which goes through
* the system default network — this pins DNS to the bound network.
*/
private class NetworkDns(private val network: Network) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
return network.getAllByName(hostname).toList()
}
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Gauge ring -->
<path
android:pathData="M54,54m-24,0a24,24 0 1,1 48,0a24,24 0 1,1 -48,0"
android:strokeColor="#FFFFFF"
android:strokeWidth="6"
android:fillColor="#00000000" />
<!-- Needle -->
<path
android:pathData="M54,54L72,38"
android:strokeColor="#FFFFFF"
android:strokeWidth="6"
android:strokeLineCap="round" />
<!-- Hub -->
<path
android:pathData="M54,54m-4,0a4,4 0 1,1 8,0a4,4 0 1,1 -8,0"
android:fillColor="#FFFFFF" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- API 31+ native splash: dark background + white gauge icon. -->
<style name="Theme.YarisOBD.Splash" parent="Theme.YarisOBD">
<item name="android:windowSplashScreenBackground">#1C1C1E</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="android:windowSplashScreenIconBackgroundColor">#1C1C1E</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#E63946</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">yaris-obd</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.YarisOBD" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
<!-- Pre-31 fallback: identical to the normal theme (no native splash). -->
<style name="Theme.YarisOBD.Splash" parent="Theme.YarisOBD" />
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="movies" path="Movies/yaris-obd/" />
</paths>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext to Pi hotspot (10.42.0.1) — local-only, no internet -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.42.0.1</domain>
<domain includeSubdomains="false">yaris.local</domain>
</domain-config>
</network-security-config>
+7
View File
@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
}
+19
View File
@@ -0,0 +1,19 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
# AndroidX package structure
android.useAndroidX=true
# Kotlin code style
kotlin.code.style=official
# Compose compiler (managed by Kotlin plugin in AGP 8.x+)
# No longer need composeVersion here — managed automatically
# Disable build features we don't use
android.defaults.buildfeatures.aidl=false
android.defaults.buildfeatures.renderscript=false
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false
+78
View File
@@ -0,0 +1,78 @@
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
compose-bom = "2025.01.00"
compose-compiler = "1.5.15" # Kotlin 2.1.x
okhttp = "4.12.0"
retrofit = "2.11.0"
kotlinx-serialization = "1.7.3"
kotlinx-coroutines = "1.9.0"
room = "2.6.1"
workmanager = "2.10.0"
coil = "3.0.4"
maplibre = "11.7.0"
security-crypto = "1.1.0-alpha06"
lifecycle = "2.8.7"
activity-compose = "1.9.3"
core-ktx = "1.15.0"
navigation-compose = "2.8.5"
[libraries]
# Compose BOM
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
# Navigation
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
# Activity + Compose
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
# Core
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
# Lifecycle
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
# Network
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
# Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
# Coroutines
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
# Local storage
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Background work
workmanager = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
# Images
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
# Maps
maplibre = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibre" }
# Security
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version = "2.1.0-1.0.29" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/<unknown>/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+414
View File
@@ -0,0 +1,414 @@
# yaris-obd Android Companion App — Implementation Plan
Native Android (Kotlin + Jetpack Compose) companion app for the yaris-obd Pi system.
Primary goal: dashcam file relay (download from Pi over hotspot WiFi → upload to remote
server over cellular, simultaneously) — the one thing the web UI cannot do (see main
PLAN.md Phase 11/12 for the platform limitation backstory).
This file is the ground-level implementation plan. The high-level architecture, API
surface, and scope decisions live in the main `PLAN.md` (Phase 28). This file tracks
day-to-day progress, per-sub-phase implementation details, and working notes specific
to the Android codebase.
## Dev environment
- **IDE:** Android Studio (Ladybug or newer) on Fedora dev machine
- **Language:** Kotlin 2.x, JDK 17 (Android Studio bundled)
- **Build:** Gradle with Kotlin DSL, version catalog (`gradle/libs.versions.toml`)
- **Emulator:** API 35 AVD, 4GB RAM, 2GB storage — UI/flow testing only (single virtual
NIC, can't test dual-network binding)
- **Real device:** owner's Pixel, USB debugging enabled — required for dual-network tests
- **Repo:** `android/` at project root, part of the same `yaris-obd` git repo
## Project scaffold
```
android/
├── plan.md # this file
├── .gitignore # Android-specific ignores
├── build.gradle.kts # root build file
├── settings.gradle.kts # project settings + version catalog
├── gradle/
│ └── libs.versions.toml # dependency version catalog
├── gradle.properties # JVM args, AndroidX, Compose compiler
├── gradlew / gradlew.bat # Gradle wrapper
├── app/
│ ├── build.gradle.kts # app module build
│ ├── proguard-rules.pro # R8/ProGuard rules
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/com/yarisobd/app/
│ │ │ ├── YarisApp.kt # Application class
│ │ │ ├── MainActivity.kt # single-activity host
│ │ │ ├── di/ # manual DI or Hilt modules
│ │ │ ├── data/
│ │ │ │ ├── local/ # Room DB, DAOs, entities
│ │ │ │ ├── remote/ # Retrofit services, DTOs
│ │ │ │ └── repository/ # repository layer
│ │ │ ├── domain/
│ │ │ │ └── model/ # domain models
│ │ │ ├── ui/
│ │ │ │ ├── theme/ # Material 3 theme, colors, typography
│ │ │ │ ├── navigation/ # NavHost, route definitions
│ │ │ │ ├── dashboard/ # live car dashboard screen
│ │ │ │ ├── dashcam/ # file browser + download screen
│ │ │ │ ├── upload/ # upload queue + progress screen
│ │ │ │ ├── trips/ # trip list + route replay screen
│ │ │ │ ├── settings/ # credentials + preferences screen
│ │ │ │ └── components/ # shared composables
│ │ │ └── util/
│ │ │ └── NetworkBinder.kt # dual-network socket binding
│ │ └── res/
│ │ ├── values/ # strings, colors, themes
│ │ ├── drawable/ # icons, splash
│ │ └── mipmap/ # app launcher icons
│ └── test/ # unit tests
│ └── androidTest/ # instrumented tests
```
## Sub-phases
### 28.1 — Scaffold + networking proof-of-concept ✅ DONE (gate passed 2026-08-12)
#### 28.1.1 — Project creation
- [x] Create Android project in `android/` via Android Studio (Empty Activity + Compose)
- [x] Kotlin DSL build files with version catalog
- [x] `.gitignore` for Android (`*.iml`, `.gradle/`, `build/`, `local.properties`, `.idea/`)
- [ ] Verify project builds and runs on emulator (needs Android Studio)
- [x] Commit: `f1c83a2` init: android project scaffold
#### 28.1.2 — Networking layer
- [x] Add dependencies: OkHttp 4.12, Retrofit 2.11, Kotlinx Serialization 1.7.3
- [x] Create `NetworkBinder.kt` — discovers WiFi (Pi hotspot) + cellular networks (kept for future use)
- [x] Create Retrofit service interfaces (`PiApi.kt`, `ServerApi.kt`) with placeholder endpoints
- [x] Create DTOs: `GpsStatus`, `HealthResponse`
- [x] Wire `DualNetworkTestScreen.kt` with real OkHttp calls + simultaneous "Test Both" button
- [x] Wire `MainActivity.kt` — simplified to single OkHttpClient, OS routes correctly
- [x] **Real-device gate test — PASSED 2026-08-12 on Pixel:**
- "Test Pi" → `✓ Pi OK (200)` with live GPS fix data
- "Test Server" → `✓ Server OK (200) — {"status":"ok"}`
- "Test Both Simultaneously" → `✓ BOTH NETWORKS WORK SIMULTANEOUSLY`
- Key finding: per-socket `Network.bindSocket()` not needed — Android OS routes
`10.42.0.1` through WiFi and `yaris.ceaf.dev` through cellular automatically
- [x] Commit: `348bfdc` feat: Phase 28.1 gate passed
#### 28.1.3 — Credential storage
- [ ] Add EncryptedSharedPreferences for server URL, device name, API key
- [ ] First-launch screen: text fields for server URL, device name, API key
- [ ] "Test connection" button on credentials screen (calls `/health` on server)
- [ ] Persist credentials after successful test
- [ ] Commit: `feat: encrypted credential storage + first-launch flow`
### 28.2 — Dashcam browser + download ✅ DONE (2026-08-12)
**Depends on: 28.1 (networking layer + credentials).**
#### What was built
- **Pi API extension** (`PiApi.kt`): `listVideos()` → `VideoListResponse`, `getVideoFile()`
(streaming `ResponseBody`), `getThumbnail()` (JPEG bytes), `deleteVideo()`
- **DTOs:** `VideoListResponse`, `VideoFile` (relative_path, size, mtime, sha256, uploaded,
thumbnail_url), `ConvertProgress`
- **DashcamScreen:** `LazyColumn` of imported Pi videos with thumbnails via Coil async image
loader, file size, date, upload status badge (Uploaded/Pending)
- **DashcamViewModel:** manages video list state, download to phone storage, delete local copy,
conversion progress polling
- **Downloads via WorkManager:** `DownloadWorker` foreground service with notification +
`setProgressAsync()` for inline progress bar. `ActiveDownload` state tracked via WorkManager
`LiveData` observation. Survives screen lock / background. FGS type declared as
`FOREGROUND_SERVICE_TYPE_DATA_SYNC` for Android 14+.
- **Downloads tab:** progress bar + % for in-flight downloads, downloaded files list
- **Bottom navigation:** Dashcam tab + Test tab
- **Verified:** video list loads from real Pi over "yaris 911" hotspot on the owner's Pixel
#### Commits
- Phase 28.2 — dashcam video browser + WiFi download
- fix: downloads use WorkManager foreground service + inline progress
### 28.3 — Cellular upload 🚧 IN PROGRESS (2026-08-12)
**Depends on: 28.2 (files on phone local storage).**
#### 28.3.1 — Server upload data layer ✅ DONE
- [x] Retrofit interface for server chunked upload (`data/remote/ServerApi.kt`):
- `GET /api/v1/dashcam/upload/status?sha256={hash}` → `UploadStatus`
- `POST /api/v1/dashcam/upload/chunk` → multipart (file chunk, sha256, chunk_index, total_chunks)
- [x] Data classes: `UploadStatus` (already_uploaded, received_chunk_indices, total_chunks)
#### 28.3.2 — Upload worker ✅ DONE
- [x] WorkManager `CoroutineWorker` (`UploadWorker.kt`):
- Input: filename, server URL, device name, API key
- Step 1: compute SHA256, check `/status` for already-received chunks
- Step 2: read file in 40MB chunks (matching `DASHCAM_CHUNK_SIZE`)
- Step 3: POST each missing chunk (skips already-received chunks)
- Step 4: verify server reports `already_uploaded` after last chunk
- [x] Foreground service notification with live % ("Uploading VID_00234.MOV — 62%")
- [x] Byte-level upload progress via `ProgressRequestBody` (okio `ForwardingSink`) — progress
reflects actual bytes sent including already-received chunks, not just chunk count
- [x] Robust chunk reads (loop `skip()`/`read()` instead of single calls)
- [x] Debug logging (`Log.d`/`Log.e`, tag `UploadWorker`) throughout
- [x] Re-upload guard: skips files already on server (`already_uploaded`)
#### 28.3.3 — Upload queue UI ✅ DONE (2026-08-15)
- [x] Downloads screen shows per-file upload status + inline progress bar
- [x] In-app test file generator for upload testing
- [x] "Upload all" button → enqueue all pending files (header card, shows pending count)
- [x] Retry button per failed file (tracks failed uploads via `failedUploads` StateFlow)
- [x] Commit: `feat: upload queue screen — upload all + per-file retry`
### 28.4 — Live dashboard ✅ DONE (2026-08-15) — MapLibre map deferred
**Depends on: 28.1 (networking layer). Independent of 28.2/28.3.**
#### 28.4.1 — Data layer ✅
- [x] Retrofit interface additions to `PiApi`:
- `GET /api/gps/status` → `GpsStatus` (has_fix, lat, lon, fix_age_seconds, thread_alive)
- `GET /api/obd/status` → `ObdStatus` (adapter_present, port, car_connected, speed, rpm,
coolant_temp, fuel_level, throttle_position, mil_on)
- `GET /api/stats` → `Stats` (trip_count, total_km, active_dtc, video_count, odometer_km)
- [x] Polling scheduler: `DashboardViewModel` with `startPolling()`/`stopPolling()` — fetches
immediately then every 2s; stopped via `DisposableEffect` when the tab loses focus.
- [x] Server `/health` check (cellular) alongside Pi polling for the connection bar.
#### 28.4.2 — Dashboard UI ✅ (map deferred)
- [x] Speed card: large number (km/h), "OBD speed"/"OBD offline" source label
- [x] RPM card: number with color scale (green < 3000, amber < 5000, red ≥ 5000)
- [x] Engine stats row: coolant temp (°C), fuel level (%), throttle (%)
- [x] MIL/DTC card: check-engine warning (amber = MIL on, green = no MIL) + stored DTC count
- [x] GPS card: fix status, lat/lon, fix age, thread alive (text — MapLibre map deferred)
- [x] Overview card: trips, total km, all-time max speed, videos, DTCs, odometer
- [x] Connection status bar: Pi WiFi dot + Server dot (green = online), last-update timestamp
- [x] Auto-refresh every 2s; polling stops when navigating away from the tab
- [ ] GPS map (MapLibre) — deferred: needs the MapLibre dep + basemap over hotspot (no internet).
### 28.5 — Trip history ✅ DONE (2026-08-15)
**Depends on: 28.1. Independent of 28.2–28.4.**
#### 28.5.1 — Data layer ✅
- [x] Retrofit interface additions to `PiApi`:
- `GET /api/trips` → `TripsResponse` (list of `TripSummary`)
- `GET /api/trip/{id}` → `TripRoute` (metadata + route points with lat/lon/speed/source)
- `GET /api/geocode?lat&lon` → `GeocodeResult`
- `POST /api/geocode/batch` → `Map<String,String>` (one call for all start/end labels)
- [x] `TripViewModel`: `loadTrips()` (with batch geocode of start/end), `loadRoute(id)`, `clearRoute()`
- [ ] Room cache (trip list + last route for offline) — deferred; Pi is the live source
#### 28.5.2 — Trip list UI ✅
- [x] `LazyColumn` of trips: date, start→end time, distance, duration, avg/max speed, start→end labels
- [x] Tap → opens `TripReplayScreen`
- [x] Newest first (Pi returns newest-first)
#### 28.5.3 — Route replay UI ✅
- [x] MapLibre `MapView` with the Pi's offline raster tiles (`/static/vendor/tiles/{z}/{x}/{y}.png`, zoom 8–15) — works with no internet
- [x] Route polyline (GeoJsonSource + LineLayer) + cursor circle marker, camera fit to bounds
- [x] Timeline scrubber (Slider) with timestamp + speed label
- [x] Play/pause button animating the cursor along the route (~5 fps)
- [x] Trip stats header: distance, duration, avg speed, max speed
- [x] System back button returns from replay to the list (BackHandler)
- [ ] Start/end markers + geocode label below scrubber — deferred
### 28.6 — Remote portal direct access ✅ DONE (2026-08-15)
**Depends on: 28.1 (credentials). Independent of 28.2–28.5. Stretch goal.**
#### 28.6.1 — Auth + data layer ✅
- [x] `PortalApi` (Retrofit) for the server portal:
- `GET /api/v1/portal/stats` → `PortalStats`
- `GET /api/v1/portal/trips` → `List<PortalTripSummary>` (mapped to shared `TripSummary`)
- `GET /api/v1/portal/trip/{id}` → `TripRoute` (reuses `RoutePoint`)
- `GET /api/v1/portal/dashcam` → `List<PortalDashcamFile>`
- `GET/POST /api/geocode` + batch (server-side reverse geocoder)
- [x] `PortalViewModel` with CSRF form login (`GET /login` → parse token → `POST /login`)
+ in-memory `CookieJar` for the Flask session cookie, 401 → auto "session expired"
- [x] Portal credentials (username/password) stored in `CredentialStore` (SharedPreferences);
auto-login on launch when present
- [ ] Dashcam video streaming/player — deferred (list only for now)
#### 28.6.2 — Portal screens ✅
- [x] New "Server" tab: login form → overview stats + trip list + dashcam archive
- [x] Reuses `TripCard` + `TripReplayScreen` from 28.5 (server serves the same route shape)
- [x] "Connected to server" indicator + server URL + logout
- [x] Tapping a server trip opens the same replay screen with an online CartoDB basemap
(`PORTAL_TILE_URL`, zoom 0–19) so it works over cellular away from the hotspot
- [ ] Video playback (stream endpoint) — deferred
### 28.7 — UI redesign: match the mobile web UI ✅ DONE (2026-08-15)
**Depends on: 28.1–28.6. Renumbers the old 28.7 (polish) to 28.8.**
#### 28.7.1 — Theme
- [x] Light theme matching `static/yaris.css`: content bg `#f4f6fb`, white cards,
primary `#4e73df`, accent/error red `#e63946`, text-muted `#6c757d`
- [x] App defaults to light (matches the web; the web has no dark mode)
#### 28.7.2 — Chrome (top bar + bottom nav)
- [x] Global dark top bar (`YarisHeader`): `#1c1c1e`, white italic "YARIS-OBD"
wordmark + red underline, status-bar aware
- [x] Dark bottom `NavigationBar` (`#1c1c1e`, white active / 55% white inactive)
mirroring the web's `.mobile-bottom-nav`
- [x] Removed the per-screen `YarisHeader` in favour of the one global top bar
- [x] Trip replay screen gets the same dark top bar
#### 28.7.3 — Component polish (follow-up)
- [ ] Card shadows (`shadow-sm`) + white card surfaces
- [ ] Blue primary buttons (`btn-primary` #4e73df)
- [ ] FontAwesome-style icons where they map to the web nav
- [ ] Page titles (`h3 fw-bold`) at the top of each screen
### 28.8 — Polish + distribution 🚧 IN PROGRESS
**Depends on: 28.1–28.7 (whichever subset ships).**
#### 28.8.1 — App polish ✅ (2026-08-15)
- [x] App icon: adaptive icon (red `#e63946` bg + white gauge foreground)
- [x] Splash screen: native API 31+ (`values-v31/themes.xml`, dark bg + gauge icon)
- [x] Dark theme follows the OS setting (done in 28.7)
- [x] App label: "Yaris OBD"
- [ ] Error handling audit — most screens have error states; dashboard still shows "—" when offline
- [ ] Accessibility audit — content descriptions present on most icons; touch targets ≥48dp mostly
#### 28.8.2 — Build + distribution ✅ (2026-08-15)
- [x] Release signing key: `android/keystore/release.keystore` (gitignored) + `keystore.properties`
- [x] ProGuard/R8 rules verified — release APK builds AND launches on the Pixel
- [x] APK output: `app/build/outputs/apk/release/app-release.apk` (~43 MB, signed)
- [x] Self-hosted distribution (server → Pi → phone):
- Server: `GET /api/v1/app/latest` (public version+sha256) + `GET /app/yaris-obd.apk`
(download) + `GET/POST /app/upload` (portal page to publish a build)
- Pi: `GET /app` (download page) + `GET /app/yaris-obd.apk`; `deploy/yaris_app_updater.py`
mirrors the latest APK from the server on a systemd timer
(`deploy/yaris-app-updater.{service,timer}` + `deploy/install_app_updater.sh`)
- Phone: connect to hotspot → `http://10.42.0.1/app` → download → install
- [x] Deployed 2026-08-15: `install_app_updater.sh` run on the Pi (timer active, APK 0.1.0
mirrored), server rebuilt with the app endpoints, and the release APK published to the
server's mounted `dashcam/app/` volume.
## Working notes
### Dual-network binding reference
The key mechanism — from the Android docs and the Phase 11 investigation:
```kotlin
val connectivityManager = context.getSystemService<ConnectivityManager>()
// Find the WiFi network (connected to Pi hotspot)
val wifiNetwork: Network? = connectivityManager.allNetworks
.firstOrNull { network ->
val caps = connectivityManager.getNetworkCapabilities(network)
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true &&
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == false
// Pi hotspot has no internet — we WANT this network for local API calls
}
// Find the cellular network
val cellNetwork: Network? = connectivityManager.allNetworks
.firstOrNull { network ->
val caps = connectivityManager.getNetworkCapabilities(network)
caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
}
// Create OkHttp clients bound to specific networks
fun OkHttpClient.bindToNetwork(network: Network?): OkHttpClient {
if (network == null) return this
return newBuilder()
.socketFactory(network.socketFactory)
.dns { network.getAllByName(it) }
.build()
}
```
The two clients then share nothing — different socket factories, different DNS resolvers —
fully independent network paths.
### Pi hotspot info
- SSID: "yaris 911" (WPA2-PSK)
- Subnet: `10.42.0.0/24`
- Pi IP: `10.42.0.1`
- mDNS: `yaris.local` (may not resolve on phone hotspot — documented limitation)
- App should use IP `10.42.0.1` directly, not rely on mDNS
### Chunked upload protocol (reused from Phase 10)
- Chunk size: 40MB (well under Cloudflare's 100MB free-tier limit)
- `/status?sha256=...` returns `{already_uploaded: true}` or `{received_chunk_indices: [0,1,3], total_chunks: 8}`
- `/chunk` POST: multipart with `file` (binary chunk), `sha256`, `chunk_index` (0-based), `total_chunks`
- Server reassembles when all chunks received, verifies SHA256, records in Postgres
- Entirely deduplicated by SHA256 — safe to re-upload chunks, server ignores duplicates
### Dependencies (tentative)
```toml
[versions]
kotlin = "2.1.0"
compose-bom = "2025.01.00"
okhttp = "4.12.0"
retrofit = "2.11.0"
kotlinx-serialization = "1.7.3"
room = "2.6.1"
workmanager = "2.10.0"
coil = "3.0.4"
maplibre = "11.5.0"
hilt = "2.51.1"
[libraries]
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-navigation = { group = "androidx.navigation", name = "navigation-compose" }
# Network
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
# Local storage
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Background work
workmanager = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
# Images
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
# Maps
maplibre = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibre" }
# Security
security-crypto = { group = "androidx.security", name = "security-crypto", version = "1.1.0-alpha06" }
# DI (optional — manual DI is fine for this scope; add Hilt only if boilerplate gets heavy)
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
```
### Real-device testing workflow
1. Build debug APK: `./gradlew assembleDebug`
2. Install on Pixel: `adb install app/build/outputs/apk/debug/app-debug.apk`
3. Connect phone to Pi hotspot
4. Confirm cellular data toggle is ON (Android should show both WiFi and LTE icons)
5. Run the app → check dual-network test screen
6. For upload tests: temporarily pause the Pi's own background uploader
(Settings → "Upload dashcam videos" toggle OFF) so the phone's upload isn't racing
the Pi's own uploader for the same files
### Environment gotchas
- **Emulator networking**: the Android emulator has its own virtual router. Accessing
`10.42.0.1` from the emulator goes through the host machine's network stack — it won't
reach the Pi unless the host is also on the Pi's WiFi hotspot. Prefer real-device testing
for any network-dependent code.
- **Pixel "switch to mobile data automatically"**: some Android versions have a setting
that auto-disables WiFi if it detects no internet. On the Pi hotspot (which has no
internet), this could silently drop the WiFi connection. The Phase 11 testing already
confirmed this is a hard platform limitation for the web UI; the native app's
`bindProcessToNetwork()` might bypass it. If the OS still kills the WiFi association,
the fallback is asking the user to enable "Keep WiFi on when no internet" in Developer
Options.
- **WorkManager + doze**: on Android 6+, WorkManager jobs may be deferred when the device
is in Doze mode. For uploads that the user explicitly triggered, consider using a
foreground service instead of (or in addition to) WorkManager so uploads continue
immediately.
- **40MB chunk memory**: the OkHttp `MultipartBody` will buffer each 40MB chunk in memory
before sending. On modern phones with 6GB+ RAM this is fine, but if it becomes an issue,
switch to a streaming multipart approach (OkHttp's `RequestBody` as `okio` pipe).
- **SHA256 of large files**: computing SHA256 of a 300MB file on a phone takes a few
seconds. Do it once when the download completes, store the result in Room, and reuse
for upload. Don't recompute on every upload attempt.
+24
View File
@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "yaris-obd"
include(":app")
+467 -8
View File
@@ -1,6 +1,6 @@
from flask import Flask, render_template, make_response, json
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd
import time
from flask import Flask, render_template, make_response, json, jsonify, request, send_file, Response
from modules import app_gps, app_events, app_car, app_account, app_obd, app_sql, app_dash, app_upload, app_video, app_health, app_alerts, app_settings, app_network, app_device_config, app_device_sync, app_remote_config, app_status
import os, subprocess, time
from gevent.pywsgi import WSGIServer
@@ -8,15 +8,25 @@ app = Flask(__name__)
app_events.socketio.init_app(app)
account = app_account.Account()
obd = app_obd.OBD()
obd = app_obd.start_obd()
car = app_car.Car()
gps = app_gps.get_gps()
gps = app_gps.start_gps()
app_sql.start_logging()
app_upload.start_uploading()
app_alerts.start_alerting()
def _reload_runtime_device_config():
account.reload_runtime_config()
car.reload_runtime_config()
app_device_sync.register_reload_callback(_reload_runtime_device_config)
app_device_sync.start_sync()
@app.route('/')
def index():
print("hello "+ account.name)
print("you're car model is:" + car.model)
return render_template('index.html', account = account, car = car)
return render_template('index.html', account=account, car=car)
@app.route('/dtc')
def dtc():
@@ -26,6 +36,426 @@ def dtc():
def dash():
return render_template('dash.html', account = account, car = car)
@app.route('/history')
def history():
return render_template('history.html', account=account, car=car)
@app.route('/settings')
def settings():
return render_template('settings.html', account=account, car=car)
@app.route('/status')
def status_page():
return render_template('status.html', account=account, car=car)
# ── Android app download (served on the car hotspot) ─────────────────────
# The APK is mirrored here from the remote server by deploy/yaris_app_updater.py
# so the phone can install/update over WiFi without leaving the hotspot.
APP_APK_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'app', 'yaris-obd.apk')
@app.route('/app')
def app_download_page():
apk_exists = os.path.isfile(APP_APK_PATH)
apk_size = os.path.getsize(APP_APK_PATH) if apk_exists else 0
return render_template('app.html', apk_exists=apk_exists, apk_size=apk_size)
@app.route('/app/yaris-obd.apk')
def app_download_apk():
if not os.path.isfile(APP_APK_PATH):
return "APK not downloaded yet — try again once the Pi has internet.", 404
return send_file(APP_APK_PATH, as_attachment=True, download_name='yaris-obd.apk',
mimetype='application/vnd.android.package-archive')
@app.route('/api/settings', methods=['GET', 'POST'])
def api_settings():
if request.method == 'GET':
return jsonify(app_settings.get_settings())
errors = app_settings.update_settings(request.get_json(silent=True))
if errors:
return jsonify(error="Invalid settings.", fields=errors), 400
return jsonify(app_settings.get_settings())
@app.route('/api/device-config', methods=['GET', 'POST'])
def api_device_config():
if request.method == 'GET':
return jsonify(app_device_config.get_device_config())
errors = {}
app_device_config.update_device_config(request.get_json(silent=True), errors)
if errors:
return jsonify(error="Invalid device config.", fields=errors), 400
_reload_runtime_device_config()
return jsonify(app_device_config.get_device_config())
@app.route('/api/remote-config', methods=['GET', 'POST'])
def api_remote_config():
if request.method == 'GET':
return jsonify(app_remote_config.get_remote_config())
errors = {}
app_remote_config.update_remote_config(request.get_json(silent=True), errors)
if errors:
return jsonify(error="Invalid remote config.", fields=errors), 400
app_upload.start_uploading()
app_device_sync.start_sync()
return jsonify(app_remote_config.get_remote_config())
@app.route('/api/remote-config/test', methods=['POST'])
def api_remote_config_test():
result = app_remote_config.test_connection()
return jsonify(result), 200 if result.get("ok") else 400
@app.route('/api/remote-config/sync-now', methods=['POST'])
def api_remote_config_sync_now():
try:
result = app_device_sync.sync_now()
except Exception as error:
return jsonify(status="error", message=str(error)), 502
status_codes = {
"synced": 200,
"no_change": 200,
"not_configured": 400,
"offline": 503,
}
return jsonify(result), status_codes.get(result["status"], 500)
@app.route('/api/uplink/status')
def uplink_status():
try:
return jsonify(app_network.get_status())
except app_network.NetworkConfigurationError as error:
return jsonify(error=str(error)), 503
@app.route('/api/uplink/config', methods=['POST', 'DELETE'])
def uplink_config():
try:
if request.method == 'DELETE':
app_network.remove_phone_hotspot()
else:
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify(error="Expected a JSON object."), 400
app_network.configure_phone_hotspot(
body.get("ssid"),
body.get("password"),
)
return jsonify(app_network.get_status())
except app_network.NetworkConfigurationError as error:
return jsonify(error=str(error)), 400
@app.route('/api/uplink/connect', methods=['POST'])
def uplink_connect():
try:
app_network.request_phone_connection()
except app_network.NetworkConfigurationError as error:
return jsonify(error=str(error)), 503
return jsonify(status="connection_requested"), 202
_HOTSPOT_PROFILE = "Hotspot-local" # AP profile name in NetworkManager (hosted on wlan1)
@app.route('/api/hotspot')
def hotspot_config():
"""Read current Pi hotspot SSID from NetworkManager (password is never returned)."""
ssid = ""
try:
result = subprocess.run(
["sudo", "-n", "nmcli", "-t", "-f", "802-11-wireless.ssid", "con", "show", _HOTSPOT_PROFILE],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
for line in result.stdout.strip().splitlines():
if line.startswith("802-11-wireless.ssid:"):
ssid = line.split(":", 1)[1]
break
except Exception:
pass
return jsonify(ssid=ssid)
@app.route('/api/hotspot', methods=['POST'])
def hotspot_config_update():
"""Update Pi hotspot SSID and/or password via NetworkManager."""
body = request.get_json(silent=True) or {}
ssid = (body.get("ssid") or "").strip()
password = (body.get("password") or "")
if not ssid:
return jsonify(error="SSID is required."), 400
if len(ssid) > 32:
return jsonify(error="SSID must be at most 32 characters."), 400
if password and len(password) < 8:
return jsonify(error="Password must be at least 8 characters."), 400
if password and len(password) > 63:
return jsonify(error="Password must be at most 63 characters."), 400
try:
subprocess.run(
["sudo", "-n", "nmcli", "con", "mod", _HOTSPOT_PROFILE, "802-11-wireless.ssid", ssid],
capture_output=True, text=True, timeout=10, check=True,
)
if password:
subprocess.run(
["sudo", "-n", "nmcli", "con", "mod", _HOTSPOT_PROFILE, "802-11-wireless-security.psk", password],
capture_output=True, text=True, timeout=10, check=True,
)
# Restart the hotspot so changes take effect immediately.
subprocess.run(
["sudo", "-n", "nmcli", "con", "down", _HOTSPOT_PROFILE],
capture_output=True, text=True, timeout=10,
)
time.sleep(1)
subprocess.run(
["sudo", "-n", "nmcli", "con", "up", _HOTSPOT_PROFILE],
capture_output=True, text=True, timeout=15, check=True,
)
return jsonify(message=f"Hotspot updated to '{ssid}' and restarted."
+ (" New password saved." if password else ""))
except subprocess.CalledProcessError as e:
return jsonify(error=f"Could not update hotspot: {e.stderr.strip() or 'nmcli failed'}"), 500
@app.route('/api/trips')
def trips():
return jsonify({"trips": app_sql.list_trips()})
@app.route('/api/gps/status')
def gps_status():
return jsonify(app_gps.get_status())
@app.route('/api/obd/status')
def obd_status():
port = app_obd._find_obd_port()
connected = app_obd._is_car_connected()
return jsonify({
"adapter_present": bool(port),
"port": port,
"car_connected": connected,
"status_label": "Connected" if connected else "Disconnected",
"speed": app_obd.get_fresh_speed(),
"rpm": app_obd.rpm,
"coolant_temp": app_obd.coolant_temp,
"fuel_level": app_obd.fuel_level,
"throttle_position": app_obd.throttle_position,
"mil_on": app_obd.get_fresh_mil(),
})
@app.route('/api/trip/<int:trip_id>')
def trip_route(trip_id):
trip = app_sql.get_trip_route(trip_id)
if trip is None:
return jsonify(error=f"Trip not found: {trip_id}"), 404
return jsonify(trip)
@app.route('/api/trip/<int:trip_id>', methods=['DELETE'])
def api_delete_trip(trip_id):
deleted = app_sql.delete_trip(trip_id)
if not deleted:
return jsonify(error=f"Trip not found: {trip_id}"), 404
return jsonify(status="deleted"), 200
@app.route('/api/trips/delete', methods=['POST'])
def api_delete_trips_batch():
"""Batch-delete computed trips on the Pi only (local archival cleanup).
The remote server is NOT told — it keeps its own archival copy of every
trip, recomputed from the uploaded tracking rows."""
body = request.get_json(silent=True) or {}
ids = body.get("ids")
if not isinstance(ids, list) or not ids:
return jsonify(error="expected {ids: [...]}"), 400
deleted = app_sql.delete_trips_batch(ids)
return jsonify(status="deleted", deleted=deleted), 200
import functools
import reverse_geocoder as rg
@functools.lru_cache(maxsize=512)
def _geocode_cached(lat4, lon4):
"""Reverse-geocode a coordinate to city/province using the offline
reverse_geocoder library. Always returns a non-empty label string."""
lat = lat4 / 10000.0
lon = lon4 / 10000.0
try:
results = rg.search([(lat, lon)], mode=1, verbose=False)
if results:
r = results[0]
label = ", ".join(p for p in [r.get("name", ""), r.get("admin1", "")] if p)
if label:
return label
except Exception:
pass
return f"{lat:.5f}, {lon:.5f}"
@app.route('/api/geocode')
def geocode_route():
try:
lat = float(request.args['lat'])
lon = float(request.args['lon'])
except (KeyError, ValueError):
return jsonify(error="lat and lon required"), 400
# Round to 3 decimals (~111m precision) for cache key
label = _geocode_cached(int(round(lat, 4) * 10000), int(round(lon, 4) * 10000))
return jsonify(label=label)
@app.route('/api/geocode/batch', methods=['POST'])
def geocode_batch_route():
body = request.get_json(silent=True) or {}
coords = body.get("coords")
if not isinstance(coords, list) or not coords:
return jsonify(error="expected {coords: [{lat, lon}, ...]}"), 400
# Deduplicate — same (lat4,lon4) would hit lru_cache anyway but avoids
# submitting redundant work.
seen = set()
result = {}
for item in coords:
try:
lat = float(item["lat"])
lon = float(item["lon"])
except (KeyError, ValueError, TypeError):
continue
lat4 = int(round(lat, 4) * 10000)
lon4 = int(round(lon, 4) * 10000)
if (lat4, lon4) not in seen:
seen.add((lat4, lon4))
result[f"{lat:.3f},{lon:.3f}"] = _geocode_cached(lat4, lon4)
return jsonify(result)
@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():
body = request.get_json(silent=True)
relative_paths = body.get("paths") if isinstance(body, dict) else None
started, err = app_dash.start_import_async(relative_paths=relative_paths)
if not started:
return jsonify({"status": "busy", "message": err}), 409
return jsonify({"status": "started"})
@app.route('/api/dash/import/status')
def dash_import_status():
job = app_dash.get_import_status()
if job is None:
return jsonify({"status": "none"})
return jsonify(job)
@app.route('/api/dash/files')
def dash_files():
summary = app_dash.list_dashcam_files()
return jsonify(summary)
@app.route('/api/dash/files', methods=['DELETE'])
def dash_delete_all():
summary = app_dash.delete_dashcam_files(delete_all=True)
status_code = 200 if summary["status"] in {"ok", "partial"} else 409
return jsonify(summary), status_code
@app.route('/api/dash/file', methods=['DELETE'])
def dash_delete_file():
body = request.get_json(silent=True) or {}
summary = app_dash.delete_dashcam_files(relative_path=body.get("path"))
status_codes = {
"ok": 200,
"partial": 409,
"invalid_path": 400,
"not_found": 404,
}
return jsonify(summary), status_codes.get(summary["status"], 409)
@app.route('/api/local-videos')
def local_videos():
return jsonify(app_video.list_local_videos())
@app.route('/api/local-video/file')
def local_video_file():
relative_path = request.args.get("path", "")
try:
path = app_video.get_local_video_path(relative_path)
except ValueError as error:
return jsonify(error=str(error)), 400
except FileNotFoundError:
return jsonify(error=f"Local video not found: {relative_path}"), 404
as_attachment = request.args.get("download") == "1"
return send_file(path, conditional=True, as_attachment=as_attachment)
@app.route('/api/dash/file/download')
def dash_file_download():
relative_path = request.args.get("path", "")
try:
path = app_dash.safe_video_path(app_dash.DEFAULT_MOUNT_POINT, relative_path)
except (ValueError, FileNotFoundError) as error:
return jsonify(error=str(error)), 400
return send_file(path, as_attachment=True)
@app.route('/api/local-video/thumbnail')
def local_video_thumbnail():
relative_path = request.args.get("path", "")
try:
path = app_video.get_thumbnail_path(relative_path)
except ValueError as error:
return jsonify(error=str(error)), 400
except FileNotFoundError:
return jsonify(error=f"Local video not found: {relative_path}"), 404
except RuntimeError as error:
if str(error) == "generating":
return jsonify(status="generating"), 202
return jsonify(error=str(error)), 422
return send_file(path, conditional=True, mimetype="image/jpeg")
@app.route('/api/local-video', methods=['DELETE'])
def local_video_delete():
body = request.get_json(silent=True) or {}
summary = app_video.delete_local_video(body.get("path"))
status_codes = {
"ok": 200,
"invalid_path": 400,
"not_found": 404,
"busy": 409,
"delete_failed": 409,
}
return jsonify(summary), status_codes.get(summary["status"], 409)
@app.route('/api/local-video/convert', methods=['POST'])
def local_video_convert():
body = request.get_json(silent=True) or {}
path = body.get("path", "")
if not path:
return jsonify({"status": "invalid_path", "message": "No path provided."}), 400
queued = app_video.queue_convert(path, force=True)
if queued is False:
return jsonify({"status": "busy", "message": "Already converting."}), 409
return jsonify({"status": "started"})
@app.route('/api/upload/status')
def upload_status():
return jsonify(app_upload.get_status())
@app.route('/api/health')
def health():
return jsonify(app_health.collect_health())
@app.route('/api/status')
def api_status():
return jsonify(app_status.collect_status())
@app.route('/api/status/restart-app', methods=['POST'])
def api_status_restart_app():
try:
return jsonify(app_status.request_app_restart()), 202
except RuntimeError as error:
return jsonify(error=str(error)), 503
@app.route('/api/alerts/status')
def alert_status():
return jsonify(app_alerts.get_status())
@app.route('/api/upload/dashcam_toggle', methods=['POST'])
def upload_dashcam_toggle():
body = request.get_json(silent=True) or {}
if not isinstance(body.get("enabled"), bool):
return jsonify(error="expected JSON body {'enabled': true|false}"), 400
app_upload.set_dashcam_upload_enabled(body["enabled"])
return jsonify(app_upload.get_status())
# not sure if im gonna use this
@app.route('/api/obd_speed/')
def data():
@@ -34,6 +464,35 @@ def data():
response.content_type = 'application/json'
return response
@app.route('/api/dtc')
def api_dtc():
fresh_mil = app_obd.get_fresh_mil()
fresh_codes = app_obd.get_fresh_dtc_codes()
if fresh_mil is None and not fresh_codes:
# OBD offline/stale (disconnected or not yet polled): fall back to the
# last-persisted DTC snapshot so codes survive a disconnect/reboot.
persisted = app_sql.get_persisted_dtc_state()
fresh_mil = persisted["mil"]
fresh_codes = persisted["codes"]
return jsonify({
"mil": fresh_mil,
"codes": fresh_codes,
})
@app.route('/api/dtc/clear', methods=['POST'])
def api_dtc_clear():
result = app_obd.clear_dtc_codes()
status_codes = {
"cleared": 200,
"not_connected": 409,
"error": 502,
}
return jsonify(result), status_codes.get(result["status"], 500)
@app.route('/api/stats')
def api_stats():
return jsonify(app_sql.get_overview_stats())
if __name__ == '__main__':
# Debug/Development
# app.run(debug=True, host="0.0.0.0", port="5000")
+6
View File
@@ -0,0 +1,6 @@
{
"owner_name": "quoije",
"car_model": "Yaris 2013",
"device_name": "Yaris OBD",
"avatar_path": "img/yaris.png"
}
+26
View File
@@ -0,0 +1,26 @@
##
## yaris-obd remote server upload client credentials
##
## Real values are set locally on the Pi only - never commit real credentials
## here. Get YARIS_DEVICE_NAME/YARIS_API_KEY by running
## `docker compose exec api python3 scripts/create_device.py <name>` on the
## server (see server/README.md).
##
YARIS_SERVER_URL = ""
YARIS_DEVICE_NAME = ""
YARIS_API_KEY = ""
##
## Optional SIM868 cellular fallback for trip/GPS-log uploads only (NOT
## dashcam video - too slow/limited over GPRS/3G). Only takes effect when a
## WiFi/Ethernet upload attempt fails outright (no route/DNS/timeout).
## UNTESTED - no SIM card was available during development, see
## modules/app_cellular.py. Leave YARIS_CELLULAR_ENABLED blank to keep this
## fully disabled (default) with zero effect on normal WiFi uploads.
##
YARIS_CELLULAR_ENABLED = ""
YARIS_CELLULAR_APN = ""
YARIS_CELLULAR_APN_USER = ""
YARIS_CELLULAR_APN_PASS = ""
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
case "$1" in
wlan0|wlan1|wlan2) ;;
*) exit 0 ;;
esac
case "$2" in
up|down|connectivity-change|dhcp4-change)
/usr/local/bin/yaris-uplink record >/dev/null 2>&1 || true
;;
esac
+453
View File
@@ -0,0 +1,453 @@
#!/usr/bin/env bash
#
# yaris-obd — one-shot installer for a freshly flashed Raspberry Pi OS.
#
# Takes a blank Raspberry Pi OS Bookworm (Lite or desktop) install all the way
# to a running yaris-obd deployment:
# * base packages + NetworkManager
# * the `yaris` service user (with sudo + serial/GPIO groups)
# * repo clone at /home/yaris/app/yaris-obd
# * RTL8812BU driver for the two TP-Link Archer T3U dongles (wlan1/wlan2)
# * NetworkManager profiles: home-wifi, Hotspot-local, optional phone-hotspot
# * every deploy/install_*.sh in the correct order
#
# Run ON THE PI as root (or via sudo):
# sudo bash auto_install.sh
#
# The only questions it asks are for the WiFi credentials it needs. You can
# instead pass them through the environment for fully non-interactive runs:
#
# HOME_WIFI_SSID="MyWiFi" \
# HOME_WIFI_PSK="secret" \
# HOTSPOT_SSID="yaris-obd" \
# HOTSPOT_PSK="in-car-password" \
# sudo -E bash auto_install.sh --non-interactive
#
# Optional flags:
# --non-interactive fail instead of prompting for missing credentials
# --skip-t3u-driver do not build the RTL8812BU driver (wlan1/wlan2)
# --no-watchdog do not enable the hardware watchdog
# --no-app-updater do not install the Android APK mirror timer
# --with-rtc configure the DS3231 RTC (I2C + overlay + fake-hwclock off)
# --with-sim868 configure the SIM868 UART (enable_uart, disable-bt,
# no serial login shell, hciuart off)
# -h|--help print this help
#
# Env vars honoured (all optional unless marked required):
# GIT_URL / GIT_BRANCH repo source (defaults to GitHub default branch)
# HOME_WIFI_SSID / HOME_WIFI_PSK home WiFi (required for netwatch)
# HOTSPOT_SSID / HOTSPOT_PSK in-car AP (SSID defaults to "yaris-obd")
# PHONE_HOTSPOT_SSID / PHONE_HOTSPOT_PSK optional phone hotspot uplink
# YARIS_PASSWORD set a password for the yaris user (else disabled)
# OWNER_NAME / CAR_MODEL / DEVICE_NAME prefill config/device.json
# YARIS_SERVER_URL / YARIS_DEVICE_NAME / YARIS_API_KEY prefill db/upload_creds.env
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
REPO_DIR="/home/yaris/app/yaris-obd"
REPO_URL="${GIT_URL:-https://github.com/quoije/yaris-obd.git}"
REPO_BRANCH="${GIT_BRANCH:-}"
# ---- flag defaults (env-overridable) ---------------------------------------
NONINTERACTIVE=${NONINTERACTIVE:-0}
SKIP_T3U_DRIVER=${SKIP_T3U_DRIVER:-0}
INSTALL_WATCHDOG=${INSTALL_WATCHDOG:-1}
INSTALL_APP_UPDATER=${INSTALL_APP_UPDATER:-1}
WITH_RTC=${WITH_RTC:-0}
WITH_SIM868=${WITH_SIM868:-0}
# ---- logging ----------------------------------------------------------------
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!>\033[0m %s\n' "$*" >&2; }
err() { printf '\033[1;31m!!>\033[0m %s\n' "$*" >&2; }
usage() {
sed -n '2,40p' "$0"
}
# ---- small helpers ----------------------------------------------------------
config_txt_path() {
if [ -f /boot/firmware/config.txt ]; then
printf '%s' /boot/firmware/config.txt
elif [ -f /boot/config.txt ]; then
printf '%s' /boot/config.txt
else
printf '%s' /boot/firmware/config.txt
fi
}
# Idempotently set a KEY=... style line in config.txt, replacing any existing
# (possibly commented) occurrence of the key, appending otherwise.
set_config_line() {
local file="$1" key="$2" line="$3"
if [ ! -f "$file" ]; then
warn "$file does not exist; skipping '$line'."
return 0
fi
if grep -qE "^[[:space:]]*#?[[:space:]]*${key}" "$file"; then
sed -i -E "s|^[[:space:]]*#?[[:space:]]*${key}.*|${line}|" "$file"
else
printf '%s\n' "$line" >> "$file"
fi
}
# Return the value of the named env var, an explicit default, or prompt (unless
# non-interactive). Prints the value to stdout.
prompt_value() {
local var="$1" prompt="$2" default="${3:-}"
local val="${!var:-}"
if [ -n "$val" ]; then
printf '%s' "$val"
return 0
fi
if [ -n "$default" ]; then
printf '%s' "$default"
return 0
fi
if [ "$NONINTERACTIVE" = "1" ]; then
err "Missing required value: $var ($prompt). Pass it via the environment or drop --non-interactive."
exit 1
fi
read -rp "$prompt: " val
printf '%s' "$val"
}
# Same as prompt_value but reads the answer without echoing it.
prompt_secret() {
local var="$1" prompt="$2" default="${3:-}"
local val="${!var:-}"
if [ -n "$val" ]; then
printf '%s' "$val"
return 0
fi
if [ -n "$default" ]; then
printf '%s' "$default"
return 0
fi
if [ "$NONINTERACTIVE" = "1" ]; then
err "Missing required value: $var ($prompt). Pass it via the environment or drop --non-interactive."
exit 1
fi
read -rsp "$prompt: " val
printf '\n' >&2
printf '%s' "$val"
}
# ---- install steps ----------------------------------------------------------
install_base_packages() {
info "Installing base packages..."
apt-get update
apt-get install -y \
git curl python3 python3-venv python3-pip \
network-manager nftables avahi-daemon dnsmasq-base iw
systemctl unmask NetworkManager 2>/dev/null || true
systemctl enable --now NetworkManager
}
create_yaris_user() {
info "Creating the 'yaris' service user..."
if ! id -u yaris >/dev/null 2>&1; then
if [ -n "${YARIS_PASSWORD:-}" ]; then
useradd --create-home --shell /bin/bash \
--password "$(openssl passwd -6 "$YARIS_PASSWORD")" yaris
else
useradd --create-home --shell /bin/bash --disabled-password yaris
fi
fi
usermod -aG sudo,dialout,gpio,i2c yaris
info "yaris user ready (groups: $(id -nG yaris | tr ' ' ','))"
}
clone_repo() {
info "Cloning $REPO_URL into $REPO_DIR..."
mkdir -p "$(dirname "$REPO_DIR")"
chown yaris:yaris "$(dirname "$REPO_DIR")"
if [ ! -d "$REPO_DIR/.git" ]; then
local branch_args=()
if [ -n "$REPO_BRANCH" ]; then
branch_args+=(--branch "$REPO_BRANCH")
fi
sudo -u yaris git clone "${branch_args[@]}" "$REPO_URL" "$REPO_DIR"
else
warn "$REPO_DIR already exists — attempting git pull."
sudo -u yaris git -C "$REPO_DIR" pull --ff-only || warn "git pull failed; continuing with the existing checkout."
fi
}
install_t3u_driver() {
if [ "$SKIP_T3U_DRIVER" = "1" ]; then
warn "Skipping RTL8812BU driver build (--skip-t3u-driver). wlan1/wlan2 will not exist."
return 0
fi
info "Building the RTL8812BU driver for the TP-Link Archer T3U dongles..."
apt-get install -y dkms build-essential bc raspberrypi-kernel-headers
rm -rf /tmp/88x2bu-20210702
git clone --depth 1 https://github.com/morrownr/88x2bu-20210702.git /tmp/88x2bu-20210702
( cd /tmp/88x2bu-20210702 && printf 'n\n' | ./install-driver.sh ) || {
warn "RTL8812BU driver build failed. The in-car AP/uplink on wlan1/wlan2 will be unavailable until fixed."
return 0
}
if [ -e /sys/class/net/wlan1 ]; then
info "wlan1 is present."
else
warn "wlan1 still not present after the driver build — check the dongle / USB ports."
fi
}
create_home_wifi() {
local ssid psk
ssid="$(prompt_value HOME_WIFI_SSID "Home WiFi SSID")"
psk="$(prompt_secret HOME_WIFI_PSK "Home WiFi password")"
info "Configuring NetworkManager profile 'home-wifi'..."
if nmcli connection show home-wifi >/dev/null 2>&1; then
nmcli connection modify home-wifi \
802-11-wireless.ssid "$ssid" \
wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$psk" \
connection.autoconnect yes connection.autoconnect-priority 30
else
nmcli connection add type wifi con-name home-wifi ifname wlan2 \
ssid "$ssid" \
wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$psk" \
connection.autoconnect yes connection.autoconnect-priority 30
fi
}
create_hotspot() {
local ssid psk
ssid="$(prompt_value HOTSPOT_SSID "In-car hotspot SSID" "yaris-obd")"
psk="$(prompt_secret HOTSPOT_PSK "In-car hotspot password")"
info "Configuring NetworkManager profile 'Hotspot-local' (5GHz AP on wlan1)..."
if nmcli connection show Hotspot-local >/dev/null 2>&1; then
nmcli connection modify Hotspot-local \
802-11-wireless.ssid "$ssid" \
802-11-wireless.mode ap \
802-11-wireless.band a \
wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$psk" \
ipv4.method shared ipv4.addresses 10.42.0.1/24 \
connection.autoconnect yes connection.autoconnect-priority 10
else
nmcli connection add type wifi con-name Hotspot-local ifname wlan1 \
ssid "$ssid" \
802-11-wireless.mode ap \
802-11-wireless.band a \
wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$psk" \
ipv4.method shared ipv4.addresses 10.42.0.1/24 \
connection.autoconnect yes connection.autoconnect-priority 10
fi
}
create_phone_hotspot() {
local ssid="${PHONE_HOTSPOT_SSID:-}"
if [ -z "$ssid" ]; then
if [ "$NONINTERACTIVE" = "1" ]; then
info "No phone-hotspot credentials provided; skipping phone hotspot."
return 0
fi
read -rp "Phone hotspot SSID (leave empty to skip): " ssid
if [ -z "$ssid" ]; then
info "Skipping phone hotspot."
return 0
fi
fi
local psk
psk="$(prompt_secret PHONE_HOTSPOT_PSK "Phone hotspot password")"
info "Configuring NetworkManager profile 'phone-hotspot'..."
if nmcli connection show phone-hotspot >/dev/null 2>&1; then
nmcli connection modify phone-hotspot \
802-11-wireless.ssid "$ssid" \
wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$psk" \
connection.autoconnect yes connection.autoconnect-priority 20 \
ipv4.dhcp-send-hostname yes ipv4.dhcp-hostname yaris \
802-11-wireless.mtu 1400
else
nmcli connection add type wifi con-name phone-hotspot ifname wlan2 \
ssid "$ssid" \
wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$psk" \
connection.autoconnect yes connection.autoconnect-priority 20 \
ipv4.dhcp-send-hostname yes ipv4.dhcp-hostname yaris \
802-11-wireless.mtu 1400
fi
}
enable_rtc() {
if [ "$WITH_RTC" != "1" ]; then
return 0
fi
info "Configuring the DS3231 RTC (--with-rtc)..."
apt-get install -y i2c-tools
local cfg
cfg="$(config_txt_path)"
set_config_line "$cfg" "dtparam=i2c_arm" "dtparam=i2c_arm=on"
set_config_line "$cfg" "dtoverlay=i2c-rtc,ds3231" "dtoverlay=i2c-rtc,ds3231"
printf '%s\n' "i2c-dev" > /etc/modules-load.d/i2c-dev.conf
systemctl disable --now fake-hwclock 2>/dev/null || true
warn "RTC overlay enabled — reboot for it to take effect, then verify with: i2cdetect -y 1 (expect UU at 0x68)."
}
enable_sim868() {
if [ "$WITH_SIM868" != "1" ]; then
return 0
fi
info "Configuring the SIM868 UART (--with-sim868)..."
local cfg
cfg="$(config_txt_path)"
set_config_line "$cfg" "enable_uart" "enable_uart=1"
set_config_line "$cfg" "dtoverlay=disable-bt" "dtoverlay=disable-bt"
if command -v raspi-config >/dev/null 2>&1; then
raspi-config nonint do_serial_cons 1 || true
fi
systemctl disable --now hciuart 2>/dev/null || true
warn "SIM868 UART enabled — reboot for it to take effect (serial0 frees the full PL011 UART)."
}
prefill_device_config() {
if [ -z "${OWNER_NAME:-}${CAR_MODEL:-}${DEVICE_NAME:-}" ]; then
return 0
fi
info "Prefilling config/device.json..."
python3 - "$REPO_DIR/config/device.json" "${OWNER_NAME:-}" "${CAR_MODEL:-}" "${DEVICE_NAME:-}" <<'PY'
import json, sys
path, owner, car, device = sys.argv[1:5]
with open(path) as f:
data = json.load(f)
if owner:
data["owner_name"] = owner
if car:
data["car_model"] = car
if device:
data["device_name"] = device
with open(path, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
}
prefill_upload_creds() {
if [ -z "${YARIS_SERVER_URL:-}${YARIS_DEVICE_NAME:-}${YARIS_API_KEY:-}" ]; then
return 0
fi
info "Prefilling db/upload_creds.env..."
local creds="$REPO_DIR/db/upload_creds.env"
python3 - "$creds" \
"YARIS_SERVER_URL" "${YARIS_SERVER_URL:-}" \
"YARIS_DEVICE_NAME" "${YARIS_DEVICE_NAME:-}" \
"YARIS_API_KEY" "${YARIS_API_KEY:-}" <<'PY'
import re, sys
path = sys.argv[1]
pairs = list(zip(sys.argv[2::2], sys.argv[3::2]))
with open(path) as f:
text = f.read()
for key, value in pairs:
if value == "":
continue
pattern = re.compile(rf"^{re.escape(key)}\s*=.*$", re.M)
replacement = f'{key} = "{value}"'
if pattern.search(text):
text = pattern.sub(replacement, text, count=1)
else:
text += replacement + "\n"
with open(path, "w") as f:
f.write(text)
PY
}
run_installers() {
info "Running deploy installers (as root; sudo inside them is a no-op)..."
info "install_service.sh"
bash "$REPO_DIR/deploy/install_service.sh"
info "install_port_redirect.sh"
bash "$REPO_DIR/deploy/install_port_redirect.sh"
info "install_netwatch.sh"
bash "$REPO_DIR/deploy/install_netwatch.sh"
info "install_video_tools.sh"
bash "$REPO_DIR/deploy/install_video_tools.sh"
if [ "$INSTALL_WATCHDOG" = "1" ]; then
info "install_watchdog.sh"
bash "$REPO_DIR/deploy/install_watchdog.sh" || warn "watchdog install failed (non-fatal)."
else
warn "Skipping watchdog install (--no-watchdog)."
fi
if [ "$INSTALL_APP_UPDATER" = "1" ]; then
info "install_app_updater.sh"
bash "$REPO_DIR/deploy/install_app_updater.sh" || warn "APK updater install failed (non-fatal)."
else
warn "Skipping APK updater install (--no-app-updater)."
fi
}
fix_ownership_and_data_dirs() {
info "Fixing repo ownership and creating writable data directories..."
mkdir -p "$REPO_DIR/db/sql" "$REPO_DIR/dash/videos" "$REPO_DIR/static/app"
chown -R yaris:yaris "$REPO_DIR"
}
print_summary() {
echo
info "Installation complete."
echo
echo "What was set up:"
echo " - yaris user (sudo, dialout, gpio, i2c)"
echo " - repo at $REPO_DIR"
echo " - yaris-obd.service (enabled + running on boot)"
echo " - nftables port 80 -> 5000 redirect"
echo " - yaris-netwatch.timer (home-wifi / phone-hotspot / Hotspot-local switching)"
echo " - ffmpeg thumbnails, hardware watchdog, APK mirror timer"
echo
echo "Verify:"
echo " sudo systemctl status yaris-obd.service --no-pager"
echo " curl -I http://127.0.0.1/"
echo
if [ "$WITH_RTC" = "1" ] || [ "$WITH_SIM868" = "1" ]; then
echo "A reboot is required for the RTC / SIM868 boot-config changes to apply."
fi
if [ -n "${OWNER_NAME:-}${CAR_MODEL:-}${DEVICE_NAME:-}${YARIS_SERVER_URL:-}${YARIS_DEVICE_NAME:-}${YARIS_API_KEY:-}" ]; then
echo "You prefilled device identity / upload credentials — restart the app to apply them:"
echo " sudo systemctl restart yaris-obd.service"
fi
echo "After reboot, open the web UI at http://yaris.local (or http://10.42.0.1 from the car hotspot)."
}
main() {
while [ $# -gt 0 ]; do
case "$1" in
--non-interactive) NONINTERACTIVE=1 ;;
--skip-t3u-driver) SKIP_T3U_DRIVER=1 ;;
--no-watchdog) INSTALL_WATCHDOG=0 ;;
--no-app-updater) INSTALL_APP_UPDATER=0 ;;
--with-rtc) WITH_RTC=1 ;;
--with-sim868) WITH_SIM868=1 ;;
-h|--help) usage; exit 0 ;;
*) err "Unknown option: $1"; usage; exit 1 ;;
esac
shift
done
if [ "$(id -u)" -ne 0 ]; then
err "This script must be run as root (or via sudo)."
exit 1
fi
install_base_packages
create_yaris_user
clone_repo
enable_rtc
enable_sim868
install_t3u_driver
create_home_wifi
create_hotspot
create_phone_hotspot
run_installers
prefill_device_config
prefill_upload_creds
fix_ownership_and_data_dirs
print_summary
}
main "$@"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# One-time install of the Android APK auto-updater on the Pi.
# Run ON THE PI as the `yaris` user (needs sudo):
# cd ~/app/yaris-obd && bash deploy/install_app_updater.sh
#
# Installs a systemd timer that polls the remote server for the latest APK
# and mirrors it to static/app/ so the phone can install from
# http://10.42.0.1/app without leaving the car hotspot.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
sudo systemctl stop yaris-app-updater.timer yaris-app-updater.service 2>/dev/null || true
sudo install -m 755 "$REPO_DIR/deploy/yaris_app_updater.py" /usr/local/bin/yaris-app-updater
sudo install -m 644 "$REPO_DIR/deploy/yaris-app-updater.service" /etc/systemd/system/yaris-app-updater.service
sudo install -m 644 "$REPO_DIR/deploy/yaris-app-updater.timer" /etc/systemd/system/yaris-app-updater.timer
sudo systemctl daemon-reload
sudo systemctl enable --now yaris-app-updater.timer
sudo systemctl start yaris-app-updater.service
echo "APK updater installed. Check: systemctl status yaris-app-updater.service"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# One-time install of the yaris-obd phone/home/local-hotspot 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" and "Hotspot" NetworkManager profiles must
# already exist (see PLAN.md Phase 6). The optional "phone-hotspot" profile is
# created through the Settings page.
#
# Since 2026-08-14 (FEAT-14): dual T3U — wlan1 (T3U #1) hosts the in-car AP
# ("Hotspot-local"), and a second T3U (wlan2) carries the internet uplink
# (home-wifi / phone-hotspot); the onboard BCM43438 (wlan0) is the fallback.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WLAN=wlan2 # T3U #2 — internet uplink
WLAN_FB=wlan0 # onboard BCM43438 — uplink fallback
AP_IFACE=wlan1 # T3U #1 — hosts the in-car AP
sudo systemctl stop yaris-uplink-wait.service 2>/dev/null || true
sudo systemctl stop yaris-netwatch.timer yaris-netwatch.service 2>/dev/null || true
sudo rm -f /usr/local/bin/yaris-netwatch.sh
sudo install -m 755 "$REPO_DIR/deploy/yaris_uplink.py" /usr/local/bin/yaris-uplink
sudo install -m 440 "$REPO_DIR/deploy/yaris-uplink.sudoers" /etc/sudoers.d/yaris-uplink
sudo visudo -cf /etc/sudoers.d/yaris-uplink
# Passwordless nmcli on the Hotspot-local profile so the Settings page can
# change the in-car hotspot SSID/password at runtime (FEAT-11).
sudo install -m 440 "$REPO_DIR/deploy/yaris-hotspot.sudoers" /etc/sudoers.d/yaris-hotspot
sudo visudo -cf /etc/sudoers.d/yaris-hotspot
sudo install -m 644 "$REPO_DIR/deploy/yaris-hotspot-dns.conf" \
/etc/NetworkManager/dnsmasq-shared.d/yaris-hotspot-dns.conf
sudo install -m 755 "$REPO_DIR/deploy/90-yaris-uplink" \
/etc/NetworkManager/dispatcher.d/90-yaris-uplink
# Bind the internet uplink to the second T3U (wlan2)
sudo nmcli connection modify home-wifi connection.interface-name "$WLAN"
# The in-car AP lives on T3U #1 (wlan1), forced to 5GHz
sudo nmcli connection modify Hotspot-local \
connection.interface-name "$AP_IFACE" \
802-11-wireless.band a \
ipv4.method shared \
ipv4.addresses 10.42.0.1/24
if nmcli connection show phone-hotspot >/dev/null 2>&1; then
sudo nmcli connection modify phone-hotspot \
connection.interface-name "$WLAN" \
ipv4.dhcp-send-hostname yes \
ipv4.dhcp-hostname yaris \
802-11-wireless.mtu 1400
fi
# Create wlan0 fallback profile (lower autoconnect-priority so wlan2 wins)
# home-wifi-fb: same SSID as home-wifi but on wlan0 (onboard)
HOME_SSID=$(nmcli -g 802-11-wireless.ssid connection show home-wifi 2>/dev/null || true)
HOME_PSK=$(sudo nmcli -g 802-11-wireless-security.psk connection show --show-secrets home-wifi 2>/dev/null || true)
if [ -n "$HOME_SSID" ] && [ -n "$HOME_PSK" ]; then
sudo nmcli connection delete home-wifi-fb 2>/dev/null || true
sudo nmcli connection add \
type wifi \
con-name home-wifi-fb \
ifname "$WLAN_FB" \
ssid "$HOME_SSID" \
wifi-sec.key-mgmt wpa-psk \
wifi-sec.psk "$HOME_PSK" \
connection.autoconnect no \
connection.autoconnect-priority 5
echo "Created home-wifi-fb fallback on $WLAN_FB"
fi
# Re-enable wlan0 (onboard) as a managed low-priority fallback
sudo nmcli device set "$WLAN_FB" managed yes 2>/dev/null || true
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 cp "$REPO_DIR/deploy/yaris-uplink-wait.service" /etc/systemd/system/yaris-uplink-wait.service
sudo systemctl daemon-reload
sudo systemctl enable --now yaris-netwatch.timer
echo "Done. Check status with: systemctl status yaris-netwatch.timer"
echo "Logs: journalctl -t yaris-netwatch -f"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# One-time install of the port-80 -> port-5000 redirect on the Pi, so the
# web UI is reachable at "http://yaris.local/" (or "http://<pi-ip>/") without
# typing the port number. Run this ON THE PI (needs sudo):
# cd /home/yaris/app/yaris-obd && bash deploy/install_port_redirect.sh
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
NFT_FRAGMENT="/etc/nftables.yaris.conf"
NFT_MAIN="/etc/nftables.conf"
INCLUDE_LINE="include \"$NFT_FRAGMENT\""
sudo cp "$REPO_DIR/deploy/nftables-yaris.conf" "$NFT_FRAGMENT"
if ! grep -qF "$INCLUDE_LINE" "$NFT_MAIN"; then
echo "$INCLUDE_LINE" | sudo tee -a "$NFT_MAIN" > /dev/null
fi
sudo systemctl enable --now nftables.service
sudo systemctl restart nftables.service
echo "Done. Verify with: sudo nft list ruleset"
echo "Test with: curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/"
+32
View File
@@ -0,0 +1,32 @@
#!/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"
STATUS_SUDOERS_FILE="$REPO_DIR/deploy/yaris-status-actions.sudoers"
DASHCAM_SUDOERS_FILE="$REPO_DIR/deploy/yaris-dashcam.sudoers"
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 install -m 440 "$STATUS_SUDOERS_FILE" /etc/sudoers.d/yaris-status-actions
sudo visudo -cf /etc/sudoers.d/yaris-status-actions
# Passwordless sudo for the dashcam mount/umount/delete operations the app runs
# at runtime (modules/app_dash.py) as the unprivileged `yaris` user.
sudo install -m 440 "$DASHCAM_SUDOERS_FILE" /etc/sudoers.d/yaris-dashcam
sudo visudo -cf /etc/sudoers.d/yaris-dashcam
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)"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Install ffmpeg/ffprobe for local dashcam thumbnails and media diagnostics.
# Run this ON THE PI as the `yaris` user:
# cd /home/yaris/app/yaris-obd && bash deploy/install_video_tools.sh
set -euo pipefail
sudo apt-get update
sudo apt-get install -y ffmpeg
ffmpeg -version | head -n 1
ffprobe -version | head -n 1
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Enable systemd's hardware watchdog on the Pi.
# Run this ON THE PI as the `yaris` user:
# cd /home/yaris/app/yaris-obd && bash deploy/install_watchdog.sh
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG_FILE="$REPO_DIR/deploy/yaris-watchdog.conf"
CONFIG_DIR="/etc/systemd/system.conf.d"
CONFIG_DEST="$CONFIG_DIR/yaris-watchdog.conf"
if [ ! -e /dev/watchdog0 ]; then
echo "No /dev/watchdog0 device found; refusing to enable a watchdog."
exit 1
fi
sudo install -D -m 0644 "$CONFIG_FILE" "$CONFIG_DEST"
# Manager configuration is not covered by daemon-reload. Re-exec systemd so the
# watchdog starts now without requiring an otherwise unnecessary reboot.
sudo systemctl daemon-reexec
runtime_watchdog="$(systemctl show --property=RuntimeWatchdogUSec --value)"
if [ "$runtime_watchdog" = "0" ]; then
echo "Runtime watchdog did not activate; check $CONFIG_DEST."
exit 1
fi
echo "Hardware watchdog enabled with RuntimeWatchdogUSec=$runtime_watchdog."
echo "Reboot once when convenient, then rerun this script to verify persistence."
+10
View File
@@ -0,0 +1,10 @@
# Redirects incoming HTTP traffic on port 80 to the yaris-obd app (port 5000),
# so the web UI is reachable at "http://yaris.local/" or "http://<pi-ip>/"
# without needing to type the port number. Loaded by /etc/nftables.conf via
# an `include` directive (see deploy/install_port_redirect.sh).
table ip nat {
chain prerouting {
type nat hook prerouting priority -100;
tcp dport 80 redirect to :5000
}
}
+431
View File
@@ -0,0 +1,431 @@
# yaris-obd — full build & install guide
This is the complete **hardware → software** walkthrough for building a `yaris-obd`
unit from fresh parts and getting it running on a freshly flashed Raspberry Pi OS.
It assumes you are comfortable with basic Linux (flashing an SD card, SSH, running
commands), basic soldering, and basic 12V/5V electronics — but have never touched
this particular project before. The end result is a Pi that auto-starts the
`yaris-obd` web app on boot and serves it at `http://yaris.local` over the car's
own WiFi hotspot, with GPS tracking, live OBD-II data, dashcam import, and
background uploads to a self-hosted server.
For the project's full phase-by-phase history and hardware bring-up notes, see
[`PLAN.md`](../PLAN.md).
---
## 1) Parts list
| Part | Purpose | Notes |
| --- | --- | --- |
| Raspberry Pi 3 Model B | Runs the app, logs data, hosts the hotspot | Pi 3 B is the reference build; a Pi 3B+/4 works too |
| Waveshare SIM868 HAT | GNSS (GPS) + optional GSM/GPRS | GSM needs a SIM and 2G coverage (often none in NA); GPS is the part in use |
| SIM868 antennas | GSM + GPS reception | Two **separate** antennas — GSM and GNSS each plug into their own u.FL port on the HAT |
| USB OBD-II adapter | Reads live vehicle data | PL2303-chip ELM327-compatible (USB). Appears as `/dev/ttyUSB*` |
| 2× TP-Link Archer T3U | WiFi — `wlan1` = in-car 5GHz AP, `wlan2` = internet uplink | RTL8812BU chipset, needs a DKMS driver (the installer builds it) |
| DS3231 RTC module | Keeps the clock correct with no internet | Optional but strongly recommended; on the I²C bus `i2c-1` |
| CR2032 cell | Backup battery for the RTC | Must be **non-rechargeable** type for the ZS-042 board (see below) |
| 5V power source | Powers the Pi | Either a quality USB PSU **or** a hard-wired 5V/GND feed to the GPIO header |
| USB dashcam | Records video; files are imported over USB | Any brand that enumerates as USB mass storage |
| SD card (≥16GB) | Pi boot + app storage | A2/U3 card recommended |
| Car | The patient | Reference car: 2013 Toyota Yaris |
---
## 2) Hardware assembly
> **Safety first.** Anything mounted in a car must not interfere with pedals,
> steering, airbags, visibility, or occupants. Route and secure every cable.
> Never do this work while driving. If you are tapping vehicle 12V, fuse the
> feed as close to the source as possible.
### 2.1 Raspberry Pi
- Fit the SD card after flashing (Section 3).
- If you plan to run the Pi without a keyboard/monitor, set up SSH during
flashing so you can work on it headlessly.
### 2.2 SIM868 HAT (GPS)
The SIM868 HAT stacks on the Pi's 40-pin GPIO header.
1. **Set the UART jumper to `B` (Pi ↔ SIM868).** This is the 3-position jumper on
the HAT: `A` = USB↔SIM868, `B` = Pi GPIO↔SIM868, `C` = USB↔Pi console. It must
be on **B** or the Pi cannot talk to the module.
2. **Connect both antennas.** The **GPS/GNSS antenna** and the **GSM antenna** go
into their **own separate u.FL sockets** on the HAT. A GPS fix is impossible
with only the GSM antenna connected — this was the single most common bring-up
mistake in the reference build.
3. **PWRKEY.** This HAT revision ships with the "P4" GPIO power-on mod wire
already installed (header pin 7 = BCM GPIO4). The app pulses it automatically
at boot to power the module chip on. If your board does not have that wire,
you must press the HAT's PWRKEY button for ~1–2s after every Pi power-up, or
add the mod wire yourself.
4. Run the installer with `--with-sim868` (Section 4) so the Pi frees the full
UART for the HAT (`enable_uart=1`, `dtoverlay=disable-bt`, no serial login
shell, `hciuart` disabled). Baud rate is **115200**.
### 2.3 DS3231 RTC (recommended)
Without an RTC the Pi boots with a wrong clock every time the car has no internet,
which corrupts trip timestamps.
| DS3231 (ZS-042) pin | Pi GPIO header pin |
| --- | --- |
| VCC | **Pin 1 — 3.3V** (not 5V) |
| GND | Pin 6 |
| SDA | Pin 3 (I²C SDA) |
| SCL | Pin 5 (I²C SCL) |
- **Remove the charge diode** on the ZS-042 board if you fit a non-rechargeable
CR2032 — the board's trickle-charge circuit is unsafe for non-rechargeable
cells.
- If `i2cdetect -y 1` shows nothing at `0x68`, your SDA/SCL are crossed or a
joint is cold. A lit LED only means power is present.
- Run the installer with `--with-rtc` (Section 4) to add the overlay, disable
`fake-hwclock`, and load the `i2c-dev` module.
### 2.4 USB OBD-II adapter
- Any PL2303/CH340-based ELM327 USB adapter works. It enumerates as
`/dev/ttyUSB0` (or the first available `/dev/ttyUSB*`).
- The app auto-detects the port; you can override it later with the `OBD_PORT`
env var in the systemd unit if needed.
### 2.5 WiFi dongles (2× T3U)
- Plug one T3U into each USB port. The **interface names follow USB enumeration
order**, so on a given Pi `wlan1` and `wlan2` are stable only as long as the
dongles stay in the same ports. If you ever swap ports, the AP and uplink roles
will swap too.
- The installer builds the RTL8812BU DKMS driver. On a Pi 3 the two identical
dongles are distinguished only by their position, not by MAC — a MAC-based
udev rule is a possible future improvement (not required for normal use).
- Expected roles:
- `wlan1` — hosts the in-car **AP** (5GHz, `10.42.0.1`)
- `wlan2` — carries the **internet uplink** (home WiFi + optional phone hotspot)
- `wlan0` — onboard radio, kept as a weak fallback only
### 2.6 Power
Two valid approaches:
- **Simplest:** a good 5V/2.5A+ USB PSU (the official Pi 3 PSU is ideal). Avoid
thin cables — voltage drop is the #1 cause of `vcgencmd get_throttled`
undervoltage flags.
- **Robust for permanent install (reference build):** hard-wire regulated 5V/GND
directly to the GPIO header — **red to Pin 2 (5V), black to Pin 6 (GND)** —
fed from a fused 12V→5V buck converter (≥3A) or a quality cigarette-lighter
USB supply. This bypasses the fragile micro-USB connector.
Whichever you choose, verify after install:
```bash
vcgencmd get_throttled
# 0x0 = no undervoltage, no throttling (what you want)
```
A non-zero current undervoltage/throttling bit means the power feed is
inadequate and must be fixed before the unit lives in the car.
### 2.7 Dashcam
- Any dashcam that mounts as generic USB mass storage works — the app detects USB
storage via `lsblk` and walks it for video files.
- **Known manual step:** many dashcams boot into a "select mode" menu (dashcam /
mass-storage / webcam) when they sense live USB data lines. To record, the USB
data lines must be absent; to import, they must be present. The project does
**not** switch this automatically — unplug/replug (or use a data-blocking
cable) when switching between recording and importing.
### 2.8 Final in-car placement
- Keep the GPS antenna with a skyward view (dash top is usually fine).
- Keep the Pi and dongles clear of direct sunlight and away from heater vents.
- Strap everything down; a loose Pi near the pedals is a hazard.
---
## 3) Flash Raspberry Pi OS
1. Download **Raspberry Pi Imager** and write **Raspberry Pi OS Bookworm** (Lite
is fine) to the SD card.
2. In Imager's advanced options (gear icon) set:
- hostname: `yaris`
- enable SSH (password or key)
- your primary username + password (the installer creates a separate `yaris`
service user; this is just for you to log in)
- optional: your home WiFi credentials so the Pi is reachable for the install
3. Boot the Pi, find it on your network, and SSH in:
```bash
ssh <your-user>@yaris.local
# or fall back to the IP if mDNS isn't working yet
```
---
## 4) Run the one-shot installer
The installer (`deploy/auto_install.sh`) takes a fresh OS from zero to running.
It installs packages, creates the `yaris` user, clones the repo, builds the T3U
driver, creates the NetworkManager WiFi profiles, and runs every
`deploy/install_*.sh` in the right order.
### 4.1 Get the script onto the Pi
**Option A — download it directly (recommended):**
```bash
curl -fsSL -o auto_install.sh \
https://raw.githubusercontent.com/quoije/yaris-obd/shameless-aislop/deploy/auto_install.sh
```
> Replace `shameless-aislop` with the repo's current default branch if it has
> changed since this was written.
**Option B — clone the repo and run from inside it:**
```bash
git clone https://github.com/quoije/yaris-obd.git
cd yaris-obd
# the script clones again into /home/yaris/app/yaris-obd; that is expected
```
### 4.2 Run it
The only required inputs are your **home WiFi** and **in-car hotspot**
credentials. Pass them through the environment (or let the script prompt you):
```bash
sudo -E bash auto_install.sh \
--with-rtc \
--with-sim868
```
with these environment variables set:
```bash
export HOME_WIFI_SSID="MyHomeWiFi"
export HOME_WIFI_PSK="home-wifi-password"
export HOTSPOT_SSID="yaris-obd" # in-car AP; default is "yaris-obd"
export HOTSPOT_PSK="in-car-password"
# optional phone hotspot uplink:
export PHONE_HOTSPOT_SSID="MyPhoneHotspot"
export PHONE_HOTSPOT_PSK="phone-hotspot-password"
```
Fully non-interactive (no prompts) — fails if a required value is missing:
```bash
sudo -E bash auto_install.sh --non-interactive --with-rtc --with-sim868
```
### 4.3 Flags
| Flag | Effect |
| --- | --- |
| `--with-rtc` | Enable the DS3231 RTC (I²C overlay, disable `fake-hwclock`) |
| `--with-sim868` | Enable the SIM868 UART (`enable_uart=1`, `disable-bt`, no serial console, `hciuart` off) |
| `--skip-t3u-driver` | Skip the RTL8812BU build (if you're not using T3U dongles) |
| `--no-watchdog` | Skip the 10s hardware watchdog |
| `--no-app-updater` | Skip the Android APK mirror timer |
| `--non-interactive` | Never prompt; error out on missing credentials |
| `-h` / `--help` | Print help |
Optional env vars also honoured: `GIT_URL`, `GIT_BRANCH`, `YARIS_PASSWORD`,
`OWNER_NAME`, `CAR_MODEL`, `DEVICE_NAME`, `YARIS_SERVER_URL`,
`YARIS_DEVICE_NAME`, `YARIS_API_KEY`.
### 4.4 What the installer does
1. Installs base packages (`git`, Python tooling, NetworkManager, nftables,
avahi, dnsmasq-base, `iw`) and enables NetworkManager.
2. Creates the `yaris` service user (groups: `sudo`, `dialout`, `gpio`, `i2c`).
3. Clones the repo to `/home/yaris/app/yaris-obd`.
4. Applies `--with-rtc` / `--with-sim868` boot-config changes if requested.
5. Builds the RTL8812BU driver (unless skipped).
6. Creates/updates the `home-wifi`, `Hotspot-local`, and optional `phone-hotspot`
NetworkManager profiles.
7. Runs, in order: `install_service.sh`, `install_port_redirect.sh`,
`install_netwatch.sh`, `install_video_tools.sh`, and (unless disabled)
`install_watchdog.sh` + `install_app_updater.sh`.
8. Optionally prefills `config/device.json` and `db/upload_creds.env` from env
vars, fixes repo ownership, and creates the writable data directories.
### 4.5 Reboot
If you used `--with-rtc` or `--with-sim868`, reboot so the boot-config changes
apply:
```bash
sudo reboot
```
---
## 5) Verify
After reboot, on the Pi:
```bash
sudo systemctl status yaris-obd.service --no-pager
sudo systemctl status yaris-netwatch.timer --no-pager
sudo nft list ruleset | grep -n "dport 80"
curl -I http://127.0.0.1/
curl -s http://127.0.0.1/api/gps/status
```
Expected:
- `yaris-obd.service` is `active (running)`
- `yaris-netwatch.timer` is active
- nftables redirects port `80` → `5000`
- local HTTP on port 80 returns 200/302
- `/api/gps/status` returns JSON (GPS thread alive)
From another device on your home WiFi:
- `http://yaris.local`
From a phone connected to the **in-car hotspot** (`HOTSPOT_SSID`):
- `http://yaris.local` — or `http://10.42.0.1` if the phone doesn't resolve mDNS
If the T3U dongles don't appear after the driver build:
```bash
ip link show wlan1
ip link show wlan2
```
If `wlan1` is missing, fix the driver first (see Section 9), then re-run
`sudo bash deploy/install_netwatch.sh`.
---
## 6) Configure device identity
The Pi shows an owner name, car model, device name, and avatar. Either edit the
file directly:
```bash
sudo nano /home/yaris/app/yaris-obd/config/device.json
```
```json
{
"owner_name": "Your Name",
"car_model": "Toyota Yaris 2013",
"device_name": "My Yaris OBD",
"avatar_path": "img/yaris.png"
}
```
or open the web UI → **Settings** and manage it there.
- **`owner_name`** — top-right of the topbar
- **`car_model`** — left sidebar, next to the car icon
- **`device_name`** — device identity used for remote-server sync
- **`avatar_path`** — avatar image, relative to `static/` (drop your image in
`static/img/` and update the path, or replace `static/img/yaris.png`)
Restart after editing the file:
```bash
sudo systemctl restart yaris-obd.service
```
> **Heads-up:** the local SQLite database filename is derived from `owner_name`
> (`db/sql/<owner_name>_<car_model>.db`). Changing `owner_name` later creates a
> new empty database and orphans the old one — rename the old file to match if
> you want to keep the data.
---
## 7) Connect to the remote server (optional but recommended)
Trip logs and dashcam footage upload to a self-hosted Flask + Postgres server
(`server/` in this repo). See [`server/README.md`](../server/README.md) for
deploying it.
Once the server is up and a device is registered, put the credentials on the Pi
either via the web UI (**Settings → Remote server credentials → Test connection →
Sync now**) or by prefilling `db/upload_creds.env`:
```bash
sudo nano /home/yaris/app/yaris-obd/db/upload_creds.env
```
```ini
YARIS_SERVER_URL = "https://your-server.example"
YARIS_DEVICE_NAME = "yaris-2013"
YARIS_API_KEY = "your-one-time-key"
```
Then enable **Sync with server** in Settings if you want the server to own the
device identity.
---
## 8) Daily use & updates
Update the Pi to the latest code:
```bash
cd /home/yaris/app/yaris-obd
git pull --ff-only
sudo bash deploy/install_service.sh # re-deploy the unit + sudoers
sudo systemctl restart yaris-obd.service
```
If any `deploy/` files changed, re-run the matching installer(s)
(`install_netwatch.sh`, `install_port_redirect.sh`, `install_app_updater.sh`, …).
In-car, connect your phone to the hotspot and browse `http://yaris.local`.
Dashcam files are imported from the **Dashcam** page; live telemetry is on the
**Dashboard**; past drives are under **History**.
---
## 9) Troubleshooting
```bash
bash /home/yaris/app/yaris-obd/scripts/diagnose.sh # read-only check
bash /home/yaris/app/yaris-obd/scripts/diagnose.sh --fix # attempt auto-fix
```
Common issues:
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `wlan1`/`wlan2` missing | RTL8812BU driver didn't build | Re-run the driver build from Section 4; check `dkms status` |
| GPS never gets a fix | GPS antenna on wrong u.FL / not seated; HAT jumper not on `B`; SIM868 chip not powered | Re-check antennas + jumper; confirm the app pulses PWRKEY (or press the button 1–2s) |
| Wrong time after a cold boot | No RTC or RTC not configured | `i2cdetect -y 1` should show `UU` at `0x68`; check wiring (3.3V!) |
| `vcgencmd get_throttled` ≠ `0x0` | Weak power feed | Use a better PSU/cable, or hard-wire 5V to GPIO pins 2 & 6 |
| Dashcam import hangs | `yaris` lacks passwordless sudo for mount/rm | Re-run `sudo bash deploy/install_service.sh` (installs `yaris-dashcam.sudoers`) |
| Hotspot SSID/password changes don't save | `yaris-hotspot.sudoers` missing | Re-run `sudo bash deploy/install_netwatch.sh` |
| `yaris.local` not resolving on a phone | mDNS blocked / phone doesn't relay mDNS on the hotspot | Use `http://10.42.0.1` directly |
| `pip install -r requirements.txt` fails | Outdated checkout with the concatenated `requests-toolbeltreverse_geocoder` line | `git pull --ff-only` (fixed in current repo) |
| SSH drops when NetworkManager is enabled (Bookworm Lite) | NM takes over the interface and has no profile for your current WiFi yet | Run the installer over **Ethernet**, or pre-create the profile before enabling NM; after the run, re-connect to your WiFi |
---
### Manual reference: the installers
`auto_install.sh` is just a wrapper around these individual scripts, which you can
also run by hand from `/home/yaris/app/yaris-obd`:
```bash
sudo bash deploy/install_service.sh # systemd unit + venv + sudoers
sudo bash deploy/install_port_redirect.sh # nftables port 80 -> 5000
sudo bash deploy/install_netwatch.sh # uplink watcher + hotspot profiles
sudo bash deploy/install_video_tools.sh # ffmpeg for dashcam thumbnails
sudo bash deploy/install_watchdog.sh # 10s hardware watchdog
sudo bash deploy/install_app_updater.sh # Android APK mirror timer
```
The manual order matters: `install_netwatch.sh` expects the `home-wifi` and
`Hotspot-local` profiles to already exist (create them with `nmcli`, or just let
`auto_install.sh` do it).
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=yaris-obd Android APK mirror from the remote server
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
TimeoutStartSec=600
Environment=YARIS_SERVER_URL=https://yaris.ceaf.dev
Environment=YARIS_APP_DIR=/home/yaris/app/yaris-obd/static/app
ExecStart=/usr/local/bin/yaris-app-updater
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Periodically mirror the Android APK to the car hotspot
[Timer]
OnBootSec=2min
OnUnitActiveSec=1h
RandomizedDelaySec=5min
Unit=yaris-app-updater.service
[Install]
WantedBy=timers.target
+13
View File
@@ -0,0 +1,13 @@
# Dashcam USB storage operations performed by the yaris-obd service while it
# runs as the unprivileged `yaris` user (modules/app_dash.py). The dashcam is
# mounted read-only by default and only remounted read-write for the brief
# duration of a validated delete operation, then restored.
#
# The mount point is fixed at /mnt/dashcam, so the mount/umount grants below are
# scoped to that directory. The `rm` grant is scoped to the `rm --` form the app
# uses after its own extension/realpath/mount-boundary validation.
yaris ALL=(root) NOPASSWD: /usr/bin/mkdir -p /mnt/dashcam
yaris ALL=(root) NOPASSWD: /usr/bin/mount -o r[ow] *
yaris ALL=(root) NOPASSWD: /usr/bin/mount -o * /mnt/dashcam
yaris ALL=(root) NOPASSWD: /usr/bin/umount /mnt/dashcam
yaris ALL=(root) NOPASSWD: /usr/bin/rm -- *
+4
View File
@@ -0,0 +1,4 @@
# Unicast fallback for phones/browsers that do not resolve mDNS on a WiFi
# network without internet. Avahi still publishes the same name over mDNS.
address=/yaris.local/10.42.0.1
address=/yaris/10.42.0.1
+5
View File
@@ -0,0 +1,5 @@
yaris ALL=(root) NOPASSWD: /usr/bin/nmcli -t -f 802-11-wireless.ssid con show Hotspot-local
yaris ALL=(root) NOPASSWD: /usr/bin/nmcli con mod Hotspot-local 802-11-wireless.ssid *
yaris ALL=(root) NOPASSWD: /usr/bin/nmcli con mod Hotspot-local 802-11-wireless-security.psk *
yaris ALL=(root) NOPASSWD: /usr/bin/nmcli con down Hotspot-local
yaris ALL=(root) NOPASSWD: /usr/bin/nmcli con up Hotspot-local
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=yaris-obd phone/home/local hotspot uplink watcher
After=NetworkManager.service
Wants=NetworkManager.service
[Service]
Type=oneshot
TimeoutStartSec=120
ExecStart=/usr/local/bin/yaris-uplink check
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Periodically select the preferred Yaris internet uplink
[Timer]
OnBootSec=1min
OnUnitActiveSec=3min
AccuracySec=15s
Unit=yaris-netwatch.service
[Install]
WantedBy=timers.target
+34
View File
@@ -0,0 +1,34 @@
[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
# Unbuffered stdout so print() diagnostics (GPS/GNSS status, etc.) actually reach
# the log file promptly instead of sitting in Python's stdout buffer indefinitely
# when it's redirected to a file instead of a TTY.
Environment=PYTHONUNBUFFERED=1
# Once OBD is confirmed working on this car, pin the protocol to skip the
# 3-5s auto-detect probe on every ignition-on: e.g. OBD_PROTOCOL=6 for
# ISO 15765-4 CAN 11-bit 500kbps (correct for 2013 Toyota Yaris).
# Leave unset during initial setup so auto-detect can find the right value.
#Environment=OBD_PROTOCOL=6
# If the cheap ELM327 adapter reports bad voltage and fails to connect,
# set this to 1 to skip the voltage check (common with counterfeit ELM chips).
#Environment=OBD_SKIP_VOLTAGE_CHECK=1
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
+1
View File
@@ -0,0 +1 @@
yaris ALL=(root) NOPASSWD: /usr/bin/systemctl restart --no-block yaris-obd.service
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Wait for the configured phone hotspot
After=NetworkManager.service
Wants=NetworkManager.service
Conflicts=yaris-netwatch.service
[Service]
Type=oneshot
TimeoutStartSec=180
ExecStartPre=-/usr/bin/systemctl stop yaris-netwatch.timer
ExecStart=/usr/local/bin/yaris-uplink wait
ExecStopPost=/usr/bin/systemctl start yaris-netwatch.timer
+3
View File
@@ -0,0 +1,3 @@
yaris ALL=(root) NOPASSWD: /usr/local/bin/yaris-uplink configure
yaris ALL=(root) NOPASSWD: /usr/local/bin/yaris-uplink remove
yaris ALL=(root) NOPASSWD: /usr/bin/systemctl start --no-block yaris-uplink-wait.service
+3
View File
@@ -0,0 +1,3 @@
[Manager]
RuntimeWatchdogSec=10s
RebootWatchdogSec=10min
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Mirror the latest Android APK from the remote server to the Pi's hotspot.
Polls GET /api/v1/app/latest on the server, downloads /app/yaris-obd.apk when
the sha256 differs from the local copy, verifies it, and atomically replaces
static/app/yaris-obd.apk — so the phone can install from http://10.42.0.1/app
without leaving the car WiFi.
Runs on a systemd timer (see deploy/yaris-app-updater.{service,timer}).
Stdlib only — no pip dependencies.
"""
import hashlib
import json
import os
import sys
import urllib.error
import urllib.request
SERVER_URL = os.environ.get("YARIS_SERVER_URL", "https://yaris.ceaf.dev").rstrip("/")
APP_DIR = os.environ.get("YARIS_APP_DIR", "/home/yaris/app/yaris-obd/static/app")
APK_PATH = os.path.join(APP_DIR, "yaris-obd.apk")
# Cloudflare blocks the default Python-urllib UA with 403 — identify ourselves.
_HEADERS = {"User-Agent": "yaris-obd-updater/1.0"}
def sha256_file(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def fetch_json(url, timeout=20):
req = urllib.request.Request(url, headers=_HEADERS)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
def main():
try:
manifest = fetch_json(f"{SERVER_URL}/api/v1/app/latest")
except urllib.error.HTTPError as e:
if e.code == 404:
print("No app published on the server yet; skipping.")
return 0
print(f"latest check failed: HTTP {e.code}")
return 1
except Exception as e:
print(f"latest check failed: {e}")
return 1
sha = manifest.get("sha256")
if not sha:
print("manifest missing sha256")
return 1
if os.path.isfile(APK_PATH):
try:
if sha256_file(APK_PATH) == sha:
print(f"APK up to date (version {manifest.get('version')})")
return 0
except Exception as e:
print(f"local hash failed: {e}")
os.makedirs(APP_DIR, exist_ok=True)
tmp = APK_PATH + ".part"
try:
req = urllib.request.Request(f"{SERVER_URL}/app/yaris-obd.apk", headers=_HEADERS)
with urllib.request.urlopen(req, timeout=600) as r, open(tmp, "wb") as f:
while True:
chunk = r.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
if sha256_file(tmp) != sha:
print("downloaded APK sha256 mismatch — discarding")
os.remove(tmp)
return 1
os.replace(tmp, APK_PATH)
print(f"Updated APK to version {manifest.get('version')} ({os.path.getsize(APK_PATH)} bytes)")
return 0
except Exception as e:
print(f"download failed: {e}")
if os.path.isfile(tmp):
try:
os.remove(tmp)
except Exception:
pass
return 1
if __name__ == "__main__":
sys.exit(main())
+494
View File
@@ -0,0 +1,494 @@
#!/usr/bin/env python3
"""Manage the Pi's phone-hotspot, home-WiFi, and local-AP priority."""
import json
import os
import subprocess
import sys
import tempfile
import time
import uuid
PHONE_CONNECTION = "phone-hotspot"
HOME_CONNECTION = "home-wifi"
AP_CONNECTION = "Hotspot-local"
# 2026-08-14 (FEAT-14): dual T3U — wlan1 (T3U #1) hosts the in-car AP so phones
# get 5GHz AC throughput; a second T3U (wlan2) carries the internet uplink
# (home-wifi / phone-hotspot); the onboard BCM43438 (wlan0) is only a fallback.
AP_INTERFACE = "wlan1"
PRIMARY_INTERFACE = "wlan2" # T3U #2 — internet uplink
FALLBACK_INTERFACE = "wlan0" # onboard BCM43438 — fallback if T3U #2 is absent
STATUS_PATH = "/var/lib/yaris-obd/uplink-status.json"
PHONE_KEYFILE = (
"/etc/NetworkManager/system-connections/"
"phone-hotspot.nmconnection"
)
class UplinkError(RuntimeError):
pass
def _interface_exists(iface):
"""Return True if the kernel sees this network interface."""
return os.path.exists(f"/sys/class/net/{iface}")
def resolve_interface():
"""Return PRIMARY_INTERFACE if it exists, else FALLBACK_INTERFACE."""
if _interface_exists(PRIMARY_INTERFACE):
return PRIMARY_INTERFACE
if _interface_exists(FALLBACK_INTERFACE):
return FALLBACK_INTERFACE
return PRIMARY_INTERFACE # return primary anyway; nmcli will error clearly
# Module-level convenience — resolved once at startup.
INTERFACE = resolve_interface()
def run_nmcli(*args, timeout=45, check=True):
process = subprocess.run(
["nmcli", *args],
capture_output=True,
text=True,
timeout=timeout,
)
if check and process.returncode != 0:
detail = (process.stderr or process.stdout).strip()
raise UplinkError(detail or f"nmcli exited {process.returncode}")
return process
def connection_exists(name):
return run_nmcli(
"-g",
"connection.id",
"connection",
"show",
name,
check=False,
).returncode == 0
def active_connection():
process = run_nmcli(
"-g",
"GENERAL.CONNECTION",
"device",
"show",
INTERFACE,
check=False,
)
return process.stdout.strip() if process.returncode == 0 else ""
def ipv4_address():
process = run_nmcli(
"-g",
"IP4.ADDRESS",
"device",
"show",
INTERFACE,
check=False,
)
return (
process.stdout.strip().split("/", 1)[0]
if process.returncode == 0
else ""
)
def internet_reachable():
process = run_nmcli(
"-g",
"GENERAL.IP4-CONNECTIVITY",
"device",
"show",
INTERFACE,
check=False,
)
return process.returncode == 0 and process.stdout.strip() == "4 (full)"
def phone_ssid():
process = run_nmcli(
"-g",
"802-11-wireless.ssid",
"connection",
"show",
PHONE_CONNECTION,
check=False,
)
return process.stdout.strip() if process.returncode == 0 else ""
def phone_is_visible():
ssid = phone_ssid()
if not ssid:
return False
process = run_nmcli(
"-t",
"--escape",
"no",
"-f",
"SSID",
"device",
"wifi",
"list",
"ifname",
INTERFACE,
check=False,
)
return process.returncode == 0 and ssid in process.stdout.splitlines()
def activate(name, wait=30):
if not connection_exists(name):
return False
process = run_nmcli(
"--wait",
str(wait),
"connection",
"up",
name,
timeout=wait + 5,
check=False,
)
return process.returncode == 0
def write_status(action, error=None):
previous = {}
try:
with open(STATUS_PATH, encoding="utf-8") as handle:
previous = json.load(handle)
except (FileNotFoundError, OSError, json.JSONDecodeError):
pass
current = active_connection()
last_phone_ip = previous.get("last_phone_ip")
if current == PHONE_CONNECTION:
last_phone_ip = ipv4_address() or last_phone_ip
status = {
"action": action,
"active_connection": current,
"interface": INTERFACE,
"error": error,
"last_phone_ip": last_phone_ip,
"updated_at": time.time(),
}
os.makedirs(os.path.dirname(STATUS_PATH), mode=0o755, exist_ok=True)
temporary_path = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=os.path.dirname(STATUS_PATH),
prefix=".uplink-status-",
delete=False,
) as handle:
temporary_path = handle.name
os.fchmod(handle.fileno(), 0o644)
json.dump(status, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_path, STATUS_PATH)
except OSError:
if temporary_path is not None:
try:
os.remove(temporary_path)
except FileNotFoundError:
pass
raise
def restore_fallback():
"""Try home-wifi on the uplink interface. The AP on wlan1 is never touched."""
if activate(HOME_CONNECTION):
write_status("connected_home")
return True
write_status("no_connection", "No home WiFi or phone hotspot available.")
return True
def _ap_client_count():
"""Return number of stations connected to the AP on wlan1 (T3U)."""
try:
proc = subprocess.run(
["iw", "dev", AP_INTERFACE, "station", "dump"],
capture_output=True, text=True, timeout=5,
)
return proc.stdout.count("Station ")
except (OSError, subprocess.SubprocessError):
return 0
def check_uplink():
"""Manage internet uplink on the uplink interface only. wlan1 AP is never touched."""
current = active_connection()
phone_configured = connection_exists(PHONE_CONNECTION)
if current == PHONE_CONNECTION:
if internet_reachable():
write_status("phone_connected")
return True
if activate(HOME_CONNECTION):
write_status("phone_without_internet_connected_home")
return True
if active_connection() == PHONE_CONNECTION or activate(PHONE_CONNECTION):
write_status(
"phone_connected_without_internet",
"Phone hotspot is connected but internet is not reachable.",
)
return True
return _connect_home_or_idle()
if phone_configured:
# Only take down the phone hotspot if it's up — never touch the AP on wlan1
was_phone = current == PHONE_CONNECTION
if was_phone:
run_nmcli("connection", "down", PHONE_CONNECTION, check=False)
run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False)
time.sleep(5)
if not phone_is_visible():
if not was_phone and active_connection():
write_status("phone_not_in_range_current_connection_preserved")
return True
return _connect_home_or_idle()
if activate(PHONE_CONNECTION):
time.sleep(3)
if internet_reachable():
write_status("connected_phone")
return True
if activate(HOME_CONNECTION):
write_status("phone_without_internet_connected_home")
return True
if active_connection() == PHONE_CONNECTION:
write_status(
"phone_connected_without_internet",
"Phone hotspot is connected but internet is not reachable.",
)
return True
return _connect_home_or_idle()
if active_connection() == HOME_CONNECTION:
write_status("phone_unavailable_home_preserved")
return True
return _connect_home_or_idle()
# Phone not configured. Home wifi is the only uplink.
if current == HOME_CONNECTION:
write_status("home_connected_no_phone")
return True
# Not on home-wifi — try to connect
return _connect_home_or_idle()
def _connect_home_or_idle():
"""Try home-wifi on the uplink interface. The AP on wlan1 is never touched."""
if activate(HOME_CONNECTION):
write_status("connected_home")
return True
write_status("no_uplink_available", "No home WiFi or phone hotspot in range.")
return True
def wait_for_phone(timeout=120):
if not connection_exists(PHONE_CONNECTION):
raise UplinkError("No phone hotspot is configured.")
if active_connection() == PHONE_CONNECTION:
write_status("phone_connected")
return True
write_status("waiting_for_phone")
time.sleep(10)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
run_nmcli("device", "wifi", "rescan", "ifname", INTERFACE, check=False)
time.sleep(5)
if phone_is_visible():
return check_uplink()
restored = _connect_home_or_idle()
if restored:
write_status(
"phone_wait_timed_out_fallback_restored",
"Phone hotspot did not appear within two minutes.",
)
return restored
def validate_credentials(payload):
if not isinstance(payload, dict):
raise UplinkError("Expected a JSON object.")
ssid = payload.get("ssid")
password = payload.get("password")
if not isinstance(ssid, str) or not ssid or len(ssid.encode("utf-8")) > 32:
raise UplinkError("SSID must be between 1 and 32 bytes.")
if "\x00" in ssid or "\n" in ssid or "\r" in ssid:
raise UplinkError("SSID contains unsupported characters.")
if ";" in ssid:
raise UplinkError("SSID cannot contain a semicolon.")
if not isinstance(password, str) or not 8 <= len(password.encode("utf-8")) <= 63:
raise UplinkError("Password must be between 8 and 63 bytes.")
if "\x00" in password or "\n" in password or "\r" in password:
raise UplinkError("Password contains unsupported characters.")
return ssid, password
def keyfile_escape(value):
leading_spaces = len(value) - len(value.lstrip(" "))
trailing_spaces = len(value) - len(value.rstrip(" "))
end = len(value) - trailing_spaces if trailing_spaces else len(value)
middle = value[leading_spaces:end].replace("\\", "\\\\")
return "\\s" * leading_spaces + middle + "\\s" * trailing_spaces
def configure():
raw = sys.stdin.buffer.read(2049)
if len(raw) > 2048:
raise UplinkError("Configuration request is too large.")
try:
payload = json.loads(raw)
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise UplinkError("Invalid configuration JSON.") from error
ssid, password = validate_credentials(payload)
was_active = active_connection() == PHONE_CONNECTION
run_nmcli("connection", "delete", PHONE_CONNECTION, check=False)
keyfile = f"""[connection]
id={PHONE_CONNECTION}
uuid={uuid.uuid4()}
type=wifi
interface-name={INTERFACE}
autoconnect=true
autoconnect-priority=20
[wifi]
mode=infrastructure
ssid={keyfile_escape(ssid)}
mtu=1400
[wifi-security]
key-mgmt=wpa-psk
psk={keyfile_escape(password)}
[ipv4]
method=auto
dhcp-send-hostname=true
dhcp-hostname=yaris
dhcp-client-id=yaris-obd
# Use public DNS instead of the phone's DHCP-provided resolver — some
# carrier/hotspot resolvers fail to resolve the server hostname (e.g. the
# .dev Cloudflare tunnel), which broke uploads on the phone hotspot.
dns=8.8.8.8;1.1.1.1;
ignore-auto-dns=true
[ipv6]
method=auto
"""
temporary_path = None
try:
os.makedirs(os.path.dirname(PHONE_KEYFILE), mode=0o700, exist_ok=True)
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=os.path.dirname(PHONE_KEYFILE),
prefix=".phone-hotspot-",
delete=False,
) as handle:
temporary_path = handle.name
os.fchmod(handle.fileno(), 0o600)
handle.write(keyfile)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_path, PHONE_KEYFILE)
temporary_path = None
run_nmcli("connection", "reload")
if not connection_exists(PHONE_CONNECTION):
raise UplinkError("NetworkManager did not load the phone hotspot.")
except (OSError, UplinkError, subprocess.TimeoutExpired):
if temporary_path is not None:
try:
os.remove(temporary_path)
except FileNotFoundError:
pass
try:
os.remove(PHONE_KEYFILE)
except FileNotFoundError:
pass
run_nmcli("connection", "reload", check=False)
run_nmcli("connection", "delete", PHONE_CONNECTION, check=False)
if was_active:
try:
restore_fallback()
except (OSError, subprocess.TimeoutExpired, UplinkError):
pass
raise
write_status("phone_configured")
if was_active:
check_uplink()
def remove():
was_active = active_connection() == PHONE_CONNECTION
run_nmcli("connection", "delete", PHONE_CONNECTION, check=False)
try:
os.remove(PHONE_KEYFILE)
except FileNotFoundError:
pass
if was_active:
restore_fallback()
else:
write_status("phone_removed")
def record_current_connection():
current = active_connection()
actions = {
PHONE_CONNECTION: "network_phone_connected",
HOME_CONNECTION: "network_home_connected",
AP_CONNECTION: "network_local_hotspot_active",
}
write_status(actions.get(current, "network_disconnected"))
def main():
if len(sys.argv) != 2 or sys.argv[1] not in {
"check",
"configure",
"connect",
"record",
"wait",
"remove",
}:
print(
"Usage: yaris-uplink {check|configure|connect|record|wait|remove}",
file=sys.stderr,
)
return 2
action = sys.argv[1]
try:
if action == "configure":
configure()
elif action == "remove":
remove()
elif action == "wait":
wait_for_phone()
elif action == "record":
record_current_connection()
else:
check_uplink()
except (OSError, subprocess.TimeoutExpired, UplinkError) as error:
try:
write_status(f"{action}_failed", str(error))
except OSError:
pass
print(str(error), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+27 -2
View File
@@ -1,5 +1,30 @@
import json
import os
class Account:
def __init__(self):
self.name = "quoije"
config = self._load_config()
self.name = config.get("owner_name", "quoije")
self.avatar_path = config.get("avatar_path", "img/yaris.png")
self.pp_image = None
self.car = None
self.car = None
def reload_runtime_config(self):
"""Refresh display-facing fields from the live device config store."""
from . import app_device_config
config = app_device_config.get_device_config()
self.name = config.get("owner_name", "quoije")
self.avatar_path = config.get("avatar_path", "img/yaris.png")
def _load_config(self):
"""Load device config from JSON file, return dict with defaults."""
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "device.json")
try:
if os.path.exists(config_path):
with open(config_path, 'r') as f:
return json.load(f)
except (IOError, json.JSONDecodeError):
pass
return {}
+526
View File
@@ -0,0 +1,526 @@
import json
import sqlite3
import threading
import time
from urllib.parse import urlparse
import requests
from modules import (
app_cellular,
app_dash,
app_gps,
app_health,
app_obd,
app_sql,
app_video,
)
CHECK_INTERVAL = 60
RETRY_INTERVAL = 5 * 60
DASHCAM_CHECK_INTERVAL = 5 * 60
POWER_EVENT_GRACE_SECONDS = 5 * 60
SETTING_DEFAULTS = {
"alerts_enabled": "0",
"alert_ntfy_url": "",
"alert_ntfy_token": "",
"alert_temperature_c": "80",
"alert_disk_percent": "90",
"alert_tracking_backlog": "1000",
"alert_video_backlog": "10",
"alert_gps_loss_seconds": "60",
"alert_repeat_seconds": "3600",
}
ALERT_TITLES = {
"mil": "Check-engine light",
"disk": "Pi storage nearly full",
"upload_backlog": "Upload backlog growing",
"temperature": "Pi temperature high",
"power": "Pi power or throttling warning",
"gps_loss": "GPS fix lost while moving",
}
_thread = None
_last_run_at = None
_last_run_error = None
_last_conditions = {}
_moving_started_at = None
_last_dashcam_check_at = None
_last_dashcam_usage = {"status": "not_checked"}
def _number_setting(key, minimum):
raw = app_sql.get_setting(key, SETTING_DEFAULTS[key])
try:
value = float(raw)
except (TypeError, ValueError):
return float(SETTING_DEFAULTS[key]), f"{key} must be numeric"
if value < minimum:
return float(SETTING_DEFAULTS[key]), f"{key} must be at least {minimum}"
return value, None
def get_config():
errors = {}
values = {}
minimums = {
"alert_temperature_c": 1,
"alert_disk_percent": 1,
"alert_tracking_backlog": 1,
"alert_video_backlog": 1,
"alert_gps_loss_seconds": 1,
"alert_repeat_seconds": 60,
}
for key, minimum in minimums.items():
values[key], error = _number_setting(key, minimum)
if error:
errors[key] = error
url = app_sql.get_setting("alert_ntfy_url", "")
parsed = urlparse(url)
if url and (parsed.scheme not in {"http", "https"} or not parsed.netloc):
errors["alert_ntfy_url"] = "alert_ntfy_url must be an http(s) URL"
values.update({
"enabled": app_sql.get_setting("alerts_enabled", "0") == "1",
"ntfy_url": url,
"ntfy_token": app_sql.get_setting("alert_ntfy_token", ""),
"errors": errors,
})
return values
def send_ntfy(url, token, title, message, priority="default", tags=None):
headers = {
"Title": title,
"Priority": priority,
}
if tags:
headers["Tags"] = ",".join(tags)
if token:
headers["Authorization"] = f"Bearer {token}"
body = message.encode("utf-8")
if app_cellular.is_configured():
cellular_failure = None
try:
status_code, _response_body = app_cellular.http_post(
url,
body,
headers,
content_type="text/plain; charset=utf-8",
)
if 200 <= status_code < 300:
return
cellular_failure = f"HTTP {status_code}"
except app_cellular.CellularError:
cellular_failure = "modem error"
print(
f"Cellular notification failed ({cellular_failure}); "
"trying WiFi/Ethernet."
)
response = requests.post(
url,
data=body,
headers=headers,
timeout=5,
)
response.raise_for_status()
def _transport_error(error):
if isinstance(error, app_cellular.CellularError):
return "Cellular notification failed"
if isinstance(error, requests.Timeout):
return "Notification request timed out"
if isinstance(error, requests.HTTPError):
status_code = (
error.response.status_code
if error.response is not None
else "unknown"
)
return f"Notification server returned HTTP {status_code}"
if isinstance(error, requests.ConnectionError):
return "Notification connection failed"
return "Notification request failed"
def gps_loss_while_moving(speed, fix_age, threshold, now=None):
global _moving_started_at
now = time.monotonic() if now is None else now
if speed is None or speed <= 1:
_moving_started_at = None
return False
if _moving_started_at is None:
_moving_started_at = now
last_fix_at = None if fix_age is None else now - fix_age
loss_started_at = (
_moving_started_at
if last_fix_at is None
else max(_moving_started_at, last_fix_at)
)
return now - loss_started_at >= threshold
def _dashcam_usage(now=None):
global _last_dashcam_check_at, _last_dashcam_usage
now = time.monotonic() if now is None else now
if (
_last_dashcam_check_at is None
or now - _last_dashcam_check_at >= DASHCAM_CHECK_INTERVAL
):
_last_dashcam_usage = app_dash.dashcam_storage_usage()
_last_dashcam_check_at = now
return _last_dashcam_usage
def _power_condition(throttling, now=None):
now = time.time() if now is None else now
try:
event_until = float(
app_sql.get_setting("alert_power_event_until", "0")
)
except (TypeError, ValueError) as error:
raise ValueError("alert_power_event_until is invalid") from error
if throttling is None:
return {
"active": now < event_until,
"current": [],
"occurred": [],
"newly_occurred": [],
"recent_event": now < event_until,
"raw": None,
}
current = set(throttling["current"] if throttling else [])
occurred = set(throttling["occurred"] if throttling else [])
raw_seen = app_sql.get_setting("alert_throttling_seen")
if raw_seen is None:
seen = occurred
app_sql.set_setting(
"alert_throttling_seen", json.dumps(sorted(occurred))
)
else:
try:
seen = set(json.loads(raw_seen))
except (json.JSONDecodeError, TypeError) as error:
raise ValueError("alert_throttling_seen is invalid") from error
newly_occurred = occurred - seen
if raw_seen is not None and occurred != seen:
app_sql.set_setting(
"alert_throttling_seen", json.dumps(sorted(occurred))
)
if newly_occurred:
event_until = now + POWER_EVENT_GRACE_SECONDS
app_sql.set_setting(
"alert_power_event_until",
str(event_until),
)
return {
"active": bool(current) or now < event_until,
"current": sorted(current),
"occurred": sorted(occurred),
"newly_occurred": sorted(newly_occurred),
"recent_event": now < event_until,
"raw": throttling["raw"] if throttling else None,
}
def collect_conditions(config):
health = app_health.collect_health()
videos = app_video.list_local_videos()
pending_videos = sum(1 for video in (videos or {}).get("files", []) if not video["uploaded"])
pending_tracking = app_sql.count_unsent_tracking_rows()
speed = app_obd.get_fresh_speed()
fix_age = app_gps.get_fix_age()
temperature = health["temperature_c"]
disk = health["disk"]
throttling = health["throttling"]
dashcam_disk = _dashcam_usage()
power = _power_condition(throttling)
mil_on = app_obd.get_fresh_mil()
dtc_codes = [
item["code"] for item in app_obd.get_fresh_dtc_codes()
]
moving_without_fix = gps_loss_while_moving(
speed,
fix_age,
config["alert_gps_loss_seconds"],
)
return {
"mil": {
"active": mil_on is True,
"value": {
"mil_on": mil_on,
"dtc_codes": dtc_codes,
},
"message": (
"The vehicle MIL is on."
+ (f" Codes: {', '.join(dtc_codes)}" if dtc_codes else "")
),
"priority": "high",
"tags": ["warning", "car"],
},
"disk": {
"active": (
(
disk is not None
and disk["used_percent"] >= config["alert_disk_percent"]
)
or (
dashcam_disk["status"] == "ok"
and dashcam_disk["used_percent"]
>= config["alert_disk_percent"]
)
),
"value": {
"pi": disk,
"dashcam": dashcam_disk,
},
"message": (
(
f"Pi storage is {disk['used_percent']:.1f}% full. "
if disk
else "Pi disk usage is unavailable. "
)
+ (
f"Dashcam storage is {dashcam_disk['used_percent']:.1f}% full."
if dashcam_disk["status"] == "ok"
else f"Dashcam storage status: {dashcam_disk['status']}."
)
),
"priority": "high",
"tags": ["warning", "floppy_disk"],
},
"upload_backlog": {
"active": (
pending_tracking >= config["alert_tracking_backlog"]
or pending_videos >= config["alert_video_backlog"]
),
"value": {
"pending_tracking_rows": pending_tracking,
"pending_videos": pending_videos,
},
"message": (
f"{pending_tracking} tracking rows and {pending_videos} "
"dashcam videos are waiting to upload."
),
"priority": "default",
"tags": ["warning", "outbox_tray"],
},
"temperature": {
"active": (
temperature is not None
and temperature >= config["alert_temperature_c"]
),
"value": {"temperature_c": temperature},
"message": (
f"Pi CPU temperature is {temperature:.1f} °C."
if temperature is not None
else "Pi temperature is unavailable."
),
"priority": "high",
"tags": ["warning", "thermometer"],
},
"power": {
"active": power["active"],
"value": power,
"message": (
"Pi firmware reports: "
+ ", ".join(power["current"]).replace("_", " ")
+ "."
if power["current"]
else (
"Pi firmware recorded a recent power/throttling event: "
+ ", ".join(
power["newly_occurred"] or power["occurred"]
).replace("_", " ")
+ "."
if power["recent_event"]
else "Pi power and throttling state is normal."
)
),
"priority": "high",
"tags": ["warning", "electric_plug"],
},
"gps_loss": {
"active": moving_without_fix,
"value": {
"speed_kmh": speed,
"fix_age_seconds": fix_age,
},
"message": (
f"The vehicle is moving at {speed:.1f} km/h but the GPS fix "
+ (
"has never been acquired."
if fix_age is None
else f"is {fix_age:.0f} seconds old."
)
if speed is not None
else "GPS state unavailable."
),
"priority": "high",
"tags": ["warning", "round_pushpin"],
},
}
def _blank_state():
return {
"active": False,
"active_since": None,
"last_attempt_at": None,
"last_sent_at": None,
"recovery_pending": False,
"last_recovery_at": None,
"last_error": None,
"last_value": None,
}
def process_conditions(conditions, config, now=None, sender=send_ntfy):
now = time.time() if now is None else now
states = app_sql.get_alert_states()
configured = (
config["enabled"]
and bool(config["ntfy_url"])
and not config["errors"]
)
for key, condition in conditions.items():
previous = states.get(key, _blank_state())
state = previous.copy()
was_active = previous["active"]
state["active"] = condition["active"]
state["last_value"] = json.dumps(condition["value"], sort_keys=True)
if condition["active"]:
if not was_active:
state["active_since"] = now
state["recovery_pending"] = False
due = (
not was_active
or state["last_sent_at"] is None
or now - state["last_sent_at"] >= config["alert_repeat_seconds"]
)
retry_ready = (
not was_active
or state["last_attempt_at"] is None
or now - state["last_attempt_at"] >= (
CHECK_INTERVAL
if key == "power"
and condition["value"].get("recent_event")
else RETRY_INTERVAL
)
)
if configured and due and retry_ready:
state["last_attempt_at"] = now
try:
sender(
config["ntfy_url"],
config["ntfy_token"],
ALERT_TITLES[key],
condition["message"],
condition["priority"],
condition["tags"],
)
except (
requests.RequestException,
app_cellular.CellularError,
) as error:
state["last_error"] = _transport_error(error)
else:
state["last_sent_at"] = now
state["last_error"] = None
else:
state["active_since"] = None
if was_active and state["last_sent_at"] is not None:
state["recovery_pending"] = True
retry_ready = (
was_active
or state["last_attempt_at"] is None
or now - state["last_attempt_at"] >= RETRY_INTERVAL
)
if configured and state["recovery_pending"] and retry_ready:
state["last_attempt_at"] = now
try:
sender(
config["ntfy_url"],
config["ntfy_token"],
f"Resolved: {ALERT_TITLES[key]}",
f"{ALERT_TITLES[key]} is no longer active.",
"default",
["white_check_mark"],
)
except (
requests.RequestException,
app_cellular.CellularError,
) as error:
state["last_error"] = _transport_error(error)
else:
state["recovery_pending"] = False
state["last_recovery_at"] = now
state["last_error"] = None
app_sql.save_alert_state(key, state)
def check_alerts():
global _last_run_at, _last_run_error, _last_conditions
config = get_config()
conditions = collect_conditions(config)
process_conditions(conditions, config)
_last_conditions = conditions
_last_run_at = time.time()
_last_run_error = None
return conditions
def _loop():
global _last_run_error
while True:
try:
check_alerts()
except (
OSError,
ValueError,
RuntimeError,
sqlite3.Error,
requests.RequestException,
app_cellular.CellularError,
) as error:
_last_run_error = str(error)
print("Alert check failed:", error)
time.sleep(CHECK_INTERVAL)
def start_alerting():
global _thread
if _thread is None or not _thread.is_alive():
_thread = threading.Thread(target=_loop, name="alerts", daemon=True)
_thread.start()
print(f"Alert thread started, alive: {_thread.is_alive()}")
return _thread
def get_status():
config = get_config()
states = app_sql.get_alert_states()
thread_alive = _thread is not None and _thread.is_alive()
return {
"enabled": config["enabled"],
"configured": bool(config["ntfy_url"]) and not config["errors"],
"config_errors": config["errors"],
"thread_alive": thread_alive,
"last_run_at": _last_run_at,
"last_run_error": (
_last_run_error
or ("Alert thread is not running." if not thread_alive else None)
),
"conditions": _last_conditions,
"states": states,
}
+27 -2
View File
@@ -1,4 +1,29 @@
import json
import os
class Car:
def __init__(self):
self.model = "Yaris 2013"
self.obd = None
config = self._load_config()
self.model = config.get("car_model", "Yaris 2013")
self.device_name = config.get("device_name", "Yaris OBD")
self.obd = None
def reload_runtime_config(self):
"""Refresh display-facing fields from the live device config store."""
from . import app_device_config
config = app_device_config.get_device_config()
self.model = config.get("car_model", "Yaris 2013")
self.device_name = config.get("device_name", "Yaris OBD")
def _load_config(self):
"""Load device config from JSON file, return dict with defaults."""
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "device.json")
try:
if os.path.exists(config_path):
with open(config_path, 'r') as f:
return json.load(f)
except (IOError, json.JSONDecodeError):
pass
return {}
+249
View File
@@ -0,0 +1,249 @@
"""SIM868 GSM/GPRS cellular fallback for uploading trip/GPS tracking batches
when there is no WiFi/Ethernet path to the remote server.
*** UNTESTED - no SIM card was available to validate this against a real
*** cellular network. Written defensively (every AT exchange is wrapped so a
*** failure just raises CellularError, never crashes the app) but the exact
*** AT command dialect/timing may need small tweaks once a SIM is inserted
*** and this can actually be exercised. See "Verifying on real hardware"
*** below and the Phase 9 notes in PLAN.md.
Why AT-command HTTP instead of a real ppp0 network interface: the SIM868's
GNSS (used by modules/app_gps.py) and its GSM/GPRS radio are both driven over
the *same* physical UART (/dev/serial0). A classic pppd/chat dial-up would put
that UART into raw PPP framing for as long as the link is up, which would
starve/conflict with the GNSS AT+CGNSINF polling already running continuously
in app_gps.py. SIMCOM modules instead expose a built-in "HTTP(S) Application"
AT command set (AT+HTTPINIT / AT+HTTPPARA / AT+HTTPACTION / AT+HTTPREAD) that
stays in normal AT command mode the whole time, so it can safely interleave
with GNSS polling through the same serial lock (modules.app_gps._serial_lock) -
each exchange just briefly delays the next GPS poll, it never takes over the
port.
This only carries small JSON tracking-batch payloads - dashcam video uploads
remain WiFi/Ethernet-only (see modules/app_upload.py), both because cellular
data is far too slow/limited for video files and because the modem's HTTP
AT interface has a small internal buffer unsuited to multi-MB bodies.
"""
import os
import time
from modules import app_gps, app_sql
GPRS_BRINGUP_TIMEOUT = 30 # seconds to wait for AT+CIICR / AT+CIFSR
HTTP_ACTION_TIMEOUT = 60 # seconds to wait for the +HTTPACTION URC (2G/3G can be slow)
HTTP_USERDATA_MAX_BYTES = 1024
_gprs_context_up = False
class CellularError(Exception):
"""Raised for any cellular-modem/AT-command failure - always caught by
the caller (modules/app_upload.py), never allowed to crash the app."""
def is_configured():
"""True once the user has filled in an APN and flipped the enabled flag
in the Settings page or legacy environment configuration. Off by default -
nothing cellular-related runs unless explicitly opted into."""
enabled = app_sql.get_setting(
"cellular_enabled",
"1" if os.getenv("YARIS_CELLULAR_ENABLED", "") == "1" else "0",
)
return enabled == "1" and bool(get_apn())
def get_apn():
return app_sql.get_setting(
"cellular_apn", os.getenv("YARIS_CELLULAR_APN", "")
)
def _read_until(ser, terminators, timeout):
"""Read from the modem until one of the given substrings shows up in the
accumulated buffer, or timeout. Returns the buffer (decoded, best-effort)."""
deadline = time.time() + timeout
buf = ""
while time.time() < deadline:
chunk = ser.read(1024)
if chunk:
buf += chunk.decode(errors="replace")
if any(t in buf for t in terminators):
return buf
else:
time.sleep(0.1)
return buf
def _at(ser, cmd, terminators=("OK", "ERROR"), timeout=5):
"""Send one AT command and wait for a terminator. Raises CellularError
if the modem responds with ERROR or nothing recognizable within timeout."""
ser.reset_input_buffer()
ser.write((cmd + "\r\n").encode())
resp = _read_until(ser, terminators, timeout)
if "ERROR" in resp and "OK" not in resp:
raise CellularError(f"{cmd!r} -> {resp!r}")
if not any(t in resp for t in terminators):
raise CellularError(f"{cmd!r} timed out, got {resp!r}")
return resp
def _ensure_gprs_context():
"""Bring up the GPRS data context (APN attach) if not already up.
Reuses app_gps's serial connection/lock - see module docstring for why.
"""
global _gprs_context_up
with app_gps._serial_lock:
ser = app_gps._get_serial()
if _gprs_context_up:
# Cheap check that the context is still alive before trusting it.
try:
resp = _at(ser, "AT+CIFSR", terminators=("\r\n",), timeout=5)
if "." in resp and "ERROR" not in resp:
return
except CellularError:
pass
_gprs_context_up = False
_at(ser, "AT+CGATT=1", timeout=10)
apn = _validate_http_parameter(get_apn(), "APN")
apn_user = _validate_http_parameter(
os.getenv("YARIS_CELLULAR_APN_USER", ""),
"APN username",
)
apn_password = _validate_http_parameter(
os.getenv("YARIS_CELLULAR_APN_PASS", ""),
"APN password",
)
apn_cmd = f'AT+CSTT="{apn}","{apn_user}","{apn_password}"'
_at(ser, apn_cmd, timeout=10)
_at(ser, "AT+CIICR", timeout=GPRS_BRINGUP_TIMEOUT)
resp = _at(ser, "AT+CIFSR", terminators=("\r\n",), timeout=10)
if "ERROR" in resp or "." not in resp:
raise CellularError(f"AT+CIFSR did not return an IP: {resp!r}")
_gprs_context_up = True
def _validate_http_parameter(value, label):
if not isinstance(value, str) or any(
char in value for char in ('"', "\r", "\n")
):
raise CellularError(f"{label} contains unsupported characters")
return value
def http_post(
url,
body_bytes,
extra_headers,
content_type="application/json",
timeout=HTTP_ACTION_TIMEOUT,
):
"""POST a small body to `url` via the modem's built-in HTTP client.
Returns (status_code, response_body_bytes). Raises CellularError on any
failure (modem not responding, no network attach, HTTP-layer failure,
etc.) - callers should catch this broadly and treat it the same as a
network-unreachable condition.
"""
if not is_configured():
raise CellularError("cellular fallback not configured (APN/enabled flag not set)")
_validate_http_parameter(url, "URL")
_validate_http_parameter(content_type, "Content type")
for name, value in extra_headers.items():
_validate_http_parameter(name, "Header name")
_validate_http_parameter(value, f"{name} header")
header_lines = r"\r\n".join(
f"{name}: {value}" for name, value in extra_headers.items()
)
if header_lines:
header_lines += r"\r\n"
if len(header_lines.encode("utf-8")) > HTTP_USERDATA_MAX_BYTES:
raise CellularError("HTTP headers exceed the modem's 1024-byte limit")
_ensure_gprs_context()
with app_gps._serial_lock:
ser = app_gps._get_serial()
try:
_at(ser, "AT+HTTPTERM", timeout=3)
except CellularError:
pass # fine if nothing was initialized yet
_at(ser, "AT+HTTPINIT", timeout=5)
try:
_at(ser, 'AT+HTTPPARA="CID",1', timeout=5)
_at(ser, f'AT+HTTPPARA="URL","{url}"', timeout=5)
if url.lower().startswith("https"):
try:
_at(ser, "AT+HTTPSSL=1", timeout=5)
except CellularError:
# Older SIMCOM firmware/certificate stores sometimes
# don't support this - surfacing as a clear error is more
# useful than silently falling back to plain HTTP against
# an HTTPS-only endpoint.
raise CellularError(
"modem does not support AT+HTTPSSL - HTTPS upload via "
"cellular is unavailable on this firmware. Consider a "
"plain-HTTP router port-forward path for cellular use."
)
_at(ser, f'AT+HTTPPARA="CONTENT","{content_type}"', timeout=5)
if header_lines:
_at(
ser,
f'AT+HTTPPARA="USERDATA","{header_lines}"',
timeout=5,
)
_at(
ser,
f"AT+HTTPDATA={len(body_bytes)},{int(timeout * 1000)}",
terminators=("DOWNLOAD", "ERROR"),
timeout=5,
)
ser.write(body_bytes)
_read_until(ser, ("OK", "ERROR"), timeout=10)
action_resp = _at(
ser,
"AT+HTTPACTION=1",
terminators=("+HTTPACTION:",),
timeout=timeout,
)
status_code = 0
for line in action_resp.splitlines():
if "+HTTPACTION:" in line:
# +HTTPACTION: <method>,<status_code>,<data_len>
parts = line.split(":", 1)[1].strip().split(",")
if len(parts) >= 2:
status_code = int(parts[1])
break
read_resp = _at(ser, "AT+HTTPREAD", terminators=("OK", "ERROR"), timeout=15)
body = read_resp
return status_code, body
finally:
try:
_at(ser, "AT+HTTPTERM", timeout=5)
except CellularError:
pass
def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT):
return http_post(
url,
body_bytes,
extra_headers,
content_type="application/json",
timeout=timeout,
)
+611 -38
View File
@@ -1,38 +1,611 @@
import os
import shutil
from tqdm import tqdm
def mount_device(device, mount_point):
os.system(f"sudo mount {device} {mount_point}")
def unmount_device(mount_point):
os.system(f"sudo umount {mount_point}")
def copy_files(source_folder, destination_folder):
if not os.path.exists(destination_folder):
os.makedirs(destination_folder)
for root, dirs, files in os.walk(source_folder):
for file in tqdm(files, desc="Copying files", unit="file"):
source_path = os.path.join(root, file)
destination_path = os.path.join(destination_folder, file)
# Check if the file already exists in the destination folder
if not os.path.exists(destination_path):
shutil.copy2(source_path, destination_folder)
if __name__ == "__main__":
source_device = "/dev/sda"
mount_point = "/mnt/dash"
destination_folder = "~/yaris-obd/dash/videos"
try:
# Mount the device
mount_device(source_device, mount_point)
# Copy files from source to destination
copy_files(os.path.join(mount_point, "DCIM"), os.path.expanduser(destination_folder))
finally:
# Unmount the device (even if an exception occurs)
unmount_device(mount_point)
"""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 volumes 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.
Both real partitions and whole-disk "superfloppy" (no partition table)
layouts are matched - some dashcam SD cards ship factory-formatted that way.
"""
import json
import os
import shutil
import subprocess
import threading
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"}
# Filesystem types dashcams commonly ship formatted as (SD cards are almost
# always FAT32/exFAT; a few older units use NTFS).
DASHCAM_FS_TYPES = {"vfat", "exfat", "ntfs", "ntfs3"}
DEFAULT_MOUNT_POINT = "/mnt/dashcam"
DEFAULT_DESTINATION = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dash", "videos"
)
_device_lock = threading.Lock()
_local_video_state_lock = threading.Lock()
_local_videos_in_use = {}
_local_videos_deleting = set()
# --- Async import job state ---
_import_lock = threading.Lock()
_import_job = None # dict: {status, found, copied, skipped, errors, message} or None
def begin_local_video_use(relative_path):
with _local_video_state_lock:
if relative_path in _local_videos_deleting:
return False
_local_videos_in_use[relative_path] = (
_local_videos_in_use.get(relative_path, 0) + 1
)
return True
def end_local_video_use(relative_path):
with _local_video_state_lock:
uses = _local_videos_in_use.get(relative_path, 0)
if uses <= 1:
_local_videos_in_use.pop(relative_path, None)
else:
_local_videos_in_use[relative_path] = uses - 1
def begin_local_video_delete(relative_path):
with _local_video_state_lock:
if (
relative_path in _local_videos_in_use
or relative_path in _local_videos_deleting
):
return False
_local_videos_deleting.add(relative_path)
return True
def end_local_video_delete(relative_path):
with _local_video_state_lock:
_local_videos_deleting.discard(relative_path)
def list_usb_storage_partitions():
"""Return a list of dicts describing USB-attached storage volumes.
Each dict has: path, fstype, label, size, mountpoint. Only volumes whose
parent disk reports TRAN "usb" are included - this is what makes the
detection brand/device-agnostic (no hardcoded /dev/sda) while still
being safe (never matches the Pi's own onboard mmc storage).
Includes both real partitions (type "part", the common case) *and*
whole-disk USB devices with an `fstype` set directly on them (type
"disk" but no partition table - a "superfloppy" layout with no MBR/GPT).
Several real dashcam SD cards ship formatted this way straight from the
factory - lsblk reports them as a bare "disk" with an fstype, never as a
child "part", so excluding them would silently miss those cameras.
"""
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:
data = json.loads(result.stdout)
except json.JSONDecodeError as e:
print("Failed to parse lsblk output:", e)
return []
partitions = []
def walk(devices, inherited_tran=None):
for device in devices:
tran = device.get("tran") or inherited_tran
children = device.get("children") or []
is_partition = device.get("type") == "part"
is_superfloppy_disk = (
device.get("type") == "disk" and not children and device.get("fstype")
)
if tran == "usb" and (is_partition or is_superfloppy_disk):
partitions.append(
{
"path": device.get("path"),
"fstype": device.get("fstype"),
"label": device.get("label"),
"size": device.get("size"),
"mountpoint": device.get("mountpoint"),
}
)
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, read_only=True):
"""Mount device_path at mount_point. Returns True on success.
The app runs as an unprivileged user while /mnt is root-owned, so the
mount point directory itself has to be created via sudo too (not just
the mount call) - first real-hardware test surfaced this as a plain
PermissionError from a bare os.makedirs().
"""
try:
subprocess.run(
["sudo", "mkdir", "-p", mount_point],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
print(f"Failed to create mount point {mount_point}: {e.stderr.strip()}")
return False
try:
subprocess.run(
[
"sudo",
"mount",
"-o",
"ro" if read_only else "rw",
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 remount_device(mount_point=DEFAULT_MOUNT_POINT, read_only=True):
"""Remount an existing dashcam mount read-only or read-write."""
try:
subprocess.run(
[
"sudo",
"mount",
"-o",
f"remount,{'ro' if read_only else 'rw'}",
mount_point,
],
capture_output=True,
text=True,
check=True,
)
return True
except subprocess.CalledProcessError as e:
mode = "read-only" if read_only else "read-write"
print(f"Failed to remount {mount_point} {mode}: {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, excluding in-progress conversion temps."""
found = []
for root, _dirs, files in os.walk(source_folder):
for file in files:
if file.endswith(".converting.mp4"):
continue
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 _video_metadata(source_folder):
files = []
errors = []
total_size = 0
for abs_path, rel_path in find_video_files(source_folder):
try:
stat = os.stat(abs_path)
except OSError as e:
errors.append(f"{rel_path}: {e}")
continue
files.append(
{
"path": rel_path,
"size": stat.st_size,
"modified_at": stat.st_mtime,
}
)
total_size += stat.st_size
files.sort(key=lambda item: item["path"].lower())
return files, total_size, errors
def _mounted_source(partition, mount_point, read_only):
"""Return (source, mounted_here, remounted_existing) for one operation."""
existing_mount = partition.get("mountpoint")
if existing_mount:
if not read_only and not remount_device(existing_mount, read_only=False):
return None, False, False
return existing_mount, False, not read_only
if not mount_device(partition["path"], mount_point, read_only=read_only):
return None, False, False
return mount_point, True, False
def dashcam_storage_usage(mount_point=DEFAULT_MOUNT_POINT):
with _device_lock:
partition = find_dashcam_partition()
if partition is None:
return {"status": "no_device"}
source, mounted_here, _remounted = _mounted_source(
partition, mount_point, read_only=True
)
if source is None:
return {
"status": "mount_failed",
"path": partition["path"],
}
try:
usage = shutil.disk_usage(source)
used_percent = round(
usage.used / (usage.used + usage.free) * 100, 1
)
result = {
"status": "ok",
"path": partition["path"],
"total_bytes": usage.total,
"used_bytes": usage.used,
"free_bytes": usage.free,
"used_percent": used_percent,
}
except OSError as error:
result = {
"status": "read_failed",
"path": partition["path"],
"error": str(error),
}
finally:
if mounted_here and not unmount_device(mount_point):
result = {
"status": "unmount_failed",
"path": partition["path"],
}
return result
def list_dashcam_files(mount_point=DEFAULT_MOUNT_POINT):
"""List video files on the detected dashcam without copying them."""
with _device_lock:
partition = find_dashcam_partition()
if partition is None:
return {
"status": "no_device",
"message": "No USB dashcam/storage device detected.",
"files": [],
"total_size": 0,
}
source, mounted_here, _remounted = _mounted_source(
partition, mount_point, read_only=True
)
if source is None:
return {
"status": "mount_failed",
"device": partition["path"],
"message": f"Failed to mount {partition['path']} read-only.",
"files": [],
"total_size": 0,
}
try:
files, total_size, errors = _video_metadata(source)
return {
"status": "ok",
"device": partition,
"files": files,
"count": len(files),
"total_size": total_size,
"errors": errors,
}
finally:
if mounted_here:
unmount_device(mount_point)
def safe_video_path(source_folder, relative_path):
if not isinstance(relative_path, str) or not relative_path.strip():
raise ValueError("A non-empty relative video path is required.")
if os.path.isabs(relative_path):
raise ValueError("Video path must be relative to the dashcam.")
if os.path.splitext(relative_path)[1].lower() not in VIDEO_EXTENSIONS:
raise ValueError("Only recognized video files can be deleted.")
source_real = os.path.realpath(source_folder)
target_real = os.path.realpath(os.path.join(source_real, relative_path))
if os.path.commonpath([source_real, target_real]) != source_real:
raise ValueError("Video path escapes the dashcam mount.")
if not os.path.isfile(target_real):
raise FileNotFoundError(relative_path)
return target_real
def _remove_video_file(path):
try:
os.remove(path)
return
except PermissionError:
pass
try:
subprocess.run(
["sudo", "rm", "--", path],
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
raise OSError(e.stderr.strip() or f"Failed to delete {path}") from e
def delete_dashcam_files(relative_path=None, delete_all=False, mount_point=DEFAULT_MOUNT_POINT):
"""Delete one or all dashcam videos, restoring read-only state afterward."""
if not delete_all and relative_path is None:
return {"status": "invalid_path", "message": "A video path is required."}
with _device_lock:
partition = find_dashcam_partition()
if partition is None:
return {"status": "no_device", "message": "No USB dashcam/storage device detected."}
source, mounted_here, remounted_existing = _mounted_source(
partition, mount_point, read_only=False
)
if source is None:
return {
"status": "mount_failed",
"device": partition["path"],
"message": f"Failed to mount {partition['path']} read-write.",
}
result = None
cleanup_ok = True
try:
if delete_all:
targets = find_video_files(source)
else:
try:
target = safe_video_path(source, relative_path)
except ValueError as e:
result = {"status": "invalid_path", "message": str(e)}
except FileNotFoundError:
result = {
"status": "not_found",
"message": f"Video not found: {relative_path}",
}
else:
targets = [(target, relative_path)]
if result is None:
deleted = []
errors = []
for _abs_path, rel_path in targets:
try:
safe_path = safe_video_path(source, rel_path)
_remove_video_file(safe_path)
deleted.append(rel_path)
except (OSError, ValueError) as e:
errors.append(f"{rel_path}: {e}")
result = {
"status": "ok" if not errors else "partial",
"device": partition["path"],
"deleted": deleted,
"deleted_count": len(deleted),
"errors": errors,
}
finally:
if mounted_here:
cleanup_ok = unmount_device(mount_point)
if not cleanup_ok:
cleanup_ok = remount_device(mount_point, read_only=True)
elif remounted_existing:
cleanup_ok = remount_device(source, read_only=True)
if not cleanup_ok:
return {
"status": "cleanup_failed",
"message": "Files were processed, but the dashcam could not be returned to read-only state.",
"operation": result,
}
return result
def copy_files(source_folder, destination_folder, relative_paths=None):
"""Copy selected videos, or every video when relative_paths is omitted.
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)
errors = []
if relative_paths is None:
videos = find_video_files(source_folder)
else:
if not isinstance(relative_paths, list) or not relative_paths:
return {
"found": 0,
"copied": 0,
"skipped": 0,
"errors": ["Select at least one video to import."],
"invalid_selection": True,
}
videos = []
seen = set()
for relative_path in relative_paths:
if relative_path in seen:
continue
seen.add(relative_path)
try:
abs_path = safe_video_path(source_folder, relative_path)
except ValueError as e:
return {
"found": 0,
"copied": 0,
"skipped": 0,
"errors": [str(e)],
"invalid_selection": True,
}
except FileNotFoundError:
errors.append(f"Video not found: {relative_path}")
continue
videos.append((abs_path, relative_path))
copied = 0
skipped = 0
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
# Queue background conversion if enabled (import app_video lazily to avoid circular import)
from modules import app_video as _app_video
_app_video.queue_convert(rel_path)
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,
relative_paths=None,
):
"""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).
"""
with _device_lock:
partition = find_dashcam_partition()
if partition is None:
return {"status": "no_device", "message": "No USB dashcam/storage device detected."}
device_path = partition["path"]
source, mounted_here, _remounted = _mounted_source(
partition, mount_point, read_only=True
)
if source is None:
return {
"status": "mount_failed",
"device": device_path,
"message": f"Failed to mount {device_path}.",
}
# Lock released — copy runs without blocking other requests
try:
summary = copy_files(source, destination_folder, relative_paths)
summary["status"] = (
"invalid_selection" if summary.pop("invalid_selection", False) else "ok"
)
if summary["status"] == "invalid_selection":
summary["message"] = summary["errors"][0]
summary["device"] = device_path
return summary
finally:
if mounted_here:
with _device_lock:
unmount_device(mount_point)
def start_import_async(relative_paths=None):
"""Start a background import thread. Returns (True, None) if started,
or (False, message) if an import is already running."""
global _import_job
with _import_lock:
if _import_job and _import_job.get("status") == "running":
return False, "An import is already in progress."
_import_job = {
"status": "running",
"found": 0, "copied": 0, "skipped": 0,
"errors": [], "message": "",
}
def _run():
global _import_job
result = import_dashcam(relative_paths=relative_paths)
with _import_lock:
_import_job = result
t = threading.Thread(target=_run, daemon=True)
t.start()
return True, None
def get_import_status():
"""Return the current import job state dict, or None if no job has been run."""
with _import_lock:
return dict(_import_job) if _import_job else None
if __name__ == "__main__":
print(import_dashcam())
+23 -15
View File
@@ -2,22 +2,30 @@ from pymongo import MongoClient
from dotenv import load_dotenv
import os
load_dotenv("../db/creds.env")
# Load creds.env from the repo's db/ folder regardless of the process's cwd.
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
load_dotenv(os.path.join(_BASE_DIR, "db", "creds.env"))
mUser = os.getenv()
mPass = os.getenv()
mDB = os.getenv()
mUser = os.getenv("mangosrv_user")
mPass = os.getenv("mangosrv_pass")
mDB = os.getenv("mangosrv_db")
def get_database():
# Provide the mongodb atlas url to connect python to mongodb using pymongo
CONNECTION_STRING = "mongodb+srv://"+ mUser + ":" + mPass + "@cluster.mongodb.net/" + mDB
# Create a connection using MongoClient. You can import MongoClient or use pymongo.MongoClient
client = MongoClient(CONNECTION_STRING)
if not mUser or not mPass or not mDB:
raise RuntimeError(
"MongoDB credentials are missing. Set mangosrv_user, mangosrv_pass, "
"and mangosrv_db in db/creds.env."
)
# Provide the mongodb atlas url to connect python to mongodb using pymongo
CONNECTION_STRING = "mongodb+srv://" + mUser + ":" + mPass + "@cluster.mongodb.net/" + mDB
# Create a connection using MongoClient. You can import MongoClient or use pymongo.MongoClient
client = MongoClient(CONNECTION_STRING)
return client[mDB]
# This is added so that many files can reuse the function get_database()
if __name__ == "__main__":
# Get the database
dbname = get_database()
if __name__ == "__main__":
# Get the database
dbname = get_database()
+131
View File
@@ -0,0 +1,131 @@
"""
Device configuration management (owner name, car model, avatar, sync status).
Stores in SQLite with option to sync from server (Phase 25.5a).
"""
import json
import os
from . import app_sql
def get_device_config():
"""Get current device config including sync status."""
sync_enabled = _get_sync_enabled()
config = {
"owner_name": app_sql.get_setting("device_owner_name", ""),
"car_model": app_sql.get_setting("device_car_model", ""),
"device_name": app_sql.get_setting("device_device_name", ""),
"avatar_path": app_sql.get_setting("device_avatar_path", ""),
"odometer_baseline": float(app_sql.get_setting("odometer_baseline", "0") or "0"),
"sync_enabled": sync_enabled,
}
# Fill in any missing values from config file or defaults
config = _apply_defaults(config)
return config
def update_device_config(body, errors):
"""Update device config from request body. Returns errors dict if invalid."""
if not isinstance(body, dict):
errors["error"] = "Invalid request body"
return errors
# Check if sync is enabled; if so, only allow sync-related updates
sync_enabled = _get_sync_enabled()
if sync_enabled:
# Allow toggling sync_enabled off
if "sync_enabled" in body and not body["sync_enabled"]:
_set_sync_enabled(False)
# Toggling off is always allowed — don't error on locked fields
# (the JS always sends them alongside the toggle).
return errors
# Sync is staying enabled — locked fields are ignored silently.
# The JS always sends them; rejecting would break the save flow.
if "sync_enabled" in body and body["sync_enabled"]:
return errors
# User tried to edit locked fields without the sync toggle — reject.
if any(k in body for k in ["owner_name", "car_model", "avatar_path"]):
errors["error"] = "Cannot edit device config while server sync is enabled. Server is source of truth."
return errors
# Not syncing: allow all edits
if "owner_name" in body:
value = str(body["owner_name"]).strip()
if not value or len(value) > 100:
errors["owner_name"] = "Owner name must be 1-100 characters."
else:
app_sql.set_setting("device_owner_name", value)
if "car_model" in body:
value = str(body["car_model"]).strip()
if not value or len(value) > 100:
errors["car_model"] = "Car model must be 1-100 characters."
else:
app_sql.set_setting("device_car_model", value)
if "device_name" in body:
value = str(body["device_name"]).strip()
if not value or len(value) > 100:
errors["device_name"] = "Device name must be 1-100 characters."
else:
app_sql.set_setting("device_device_name", value)
if "avatar_path" in body:
value = str(body["avatar_path"]).strip()
if not value or len(value) > 255:
errors["avatar_path"] = "Avatar path must be 1-255 characters."
else:
app_sql.set_setting("device_avatar_path", value)
if "sync_enabled" in body:
if not isinstance(body["sync_enabled"], bool):
errors["sync_enabled"] = "Must be true or false."
elif body["sync_enabled"]:
from . import app_remote_config
remote = app_remote_config.get_remote_config()
if not remote["server_url"] or not remote["device_name"] or not remote["api_key_configured"]:
errors["sync_enabled"] = "Configure server URL, device name, and API key before enabling server sync."
else:
_set_sync_enabled(body["sync_enabled"])
if "sync_enabled" in body and body["sync_enabled"] and "sync_enabled" not in errors:
_set_sync_enabled(True)
return errors
def _apply_defaults(config):
"""Fill in any empty config values from config/device.json or hardcoded defaults."""
config_file_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "config", "device.json"
)
file_config = {}
try:
if os.path.exists(config_file_path):
with open(config_file_path, "r") as f:
file_config = json.load(f)
except (IOError, json.JSONDecodeError):
pass
# Use file config as source of truth for initial values, fallback to hardcoded
defaults = {
"owner_name": "quoije",
"car_model": "Yaris 2013",
"device_name": "Yaris OBD",
"avatar_path": "img/yaris.png",
}
for key in defaults:
if not config[key]:
config[key] = file_config.get(key, defaults[key])
return config
def _get_sync_enabled():
"""Check if device config sync from server is enabled."""
return app_sql.get_setting("device_sync_enabled", "0") == "1"
def _set_sync_enabled(enabled):
"""Set device config sync status."""
app_sql.set_setting("device_sync_enabled", "1" if enabled else "0")
+160
View File
@@ -0,0 +1,160 @@
"""Device configuration sync from remote server (Phase 25.5a).
Pi fetches device config from server when internet is available (connection-driven).
Stores in SQLite and hot-reloads app globals if config changes.
"""
import os
import subprocess
import threading
import time
import requests
from . import app_network, app_remote_config, app_sql
_SYNC_THREAD = None
_SYNC_STOP = False
_SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes
_RELOAD_CALLBACK = None
def _has_internet():
"""Use the existing NetworkManager-derived status instead of probing 8.8.8.8 over HTTP."""
try:
return app_network.get_status().get("internet_reachable", False)
except (
OSError,
subprocess.SubprocessError,
ValueError,
KeyError,
app_network.NetworkConfigurationError,
):
return False
def _server_credentials():
credentials = app_remote_config.server_credentials()
device_key = os.getenv("YARIS_DEVICE_KEY") or credentials["api_key"]
return credentials["server_url"], credentials["device_name"], device_key
def start_sync():
"""Start background device config sync thread."""
global _SYNC_THREAD, _SYNC_STOP
if _SYNC_THREAD is not None and _SYNC_THREAD.is_alive():
return
_SYNC_STOP = False
_SYNC_THREAD = threading.Thread(target=_sync_loop, daemon=True)
_SYNC_THREAD.start()
def register_reload_callback(callback):
"""Register a callback invoked after synced config is written locally."""
global _RELOAD_CALLBACK
_RELOAD_CALLBACK = callback
def stop_sync():
"""Stop background device config sync thread."""
global _SYNC_STOP
_SYNC_STOP = True
def _sync_loop():
"""Periodically sync device config from server when internet available."""
sync_interval = 300
offline_interval = 60
while not _SYNC_STOP:
try:
result = sync_now()
if result["status"] in {"synced", "no_change"}:
time.sleep(sync_interval)
else:
time.sleep(offline_interval)
except Exception as e:
print(f"Device config sync error: {e}")
time.sleep(60)
def sync_now():
"""Perform one immediate sync attempt."""
global _SYNC_CONFIG_VERSION
server_url, device_name, device_key = _server_credentials()
if not server_url or not device_name or not device_key:
return {"status": "not_configured", "message": "Remote server credentials are incomplete."}
if not _has_internet():
return {"status": "offline", "message": "Internet is not currently reachable."}
response = requests.get(
f"{server_url}/api/device-config",
headers={
"X-Device-Name": device_name,
"X-Api-Key": device_key,
},
timeout=5,
)
response.raise_for_status()
config = response.json()
new_version = config.get("config_version", 0)
if new_version != _SYNC_CONFIG_VERSION:
_SYNC_CONFIG_VERSION = new_version
_store_synced_config(config)
_acknowledge_sync(server_url, device_name, device_key, new_version)
_on_config_changed()
return {"status": "synced", "message": f"Synced config version {new_version}."}
_acknowledge_sync(server_url, device_name, device_key, new_version)
return {"status": "no_change", "message": "Already up to date."}
def _store_synced_config(config):
"""Store synced config in SQLite database."""
app_sql.set_setting("device_owner_name", config.get("owner_name", ""))
app_sql.set_setting("device_car_model", config.get("car_model", ""))
app_sql.set_setting("device_device_name", config.get("device_name", ""))
app_sql.set_setting("device_avatar_path", config.get("avatar_path", "img/yaris.png"))
app_sql.set_setting("device_config_version", str(config.get("config_version", 0)))
app_sql.set_setting("device_config_synced_at", str(int(time.time())))
# odometer_baseline is Pi→Server only — never overwrite the Pi's
# local value with whatever the server has (which could be stale).
def _on_config_changed():
"""Called when config changes and needs to be reloaded by app."""
if _RELOAD_CALLBACK is not None:
_RELOAD_CALLBACK()
def is_sync_enabled():
"""Check if device config sync from server is enabled."""
server_url, device_name, device_key = _server_credentials()
return bool(server_url) and bool(device_name) and bool(device_key)
def _acknowledge_sync(server_url, device_name, device_key, config_version):
requests.post(
f"{server_url}/api/v1/device-config/ack",
json={"config_version": config_version},
headers={
"X-Device-Name": device_name,
"X-Api-Key": device_key,
},
timeout=5,
).raise_for_status()
def get_synced_config_version():
"""Get the last-synced config version number."""
return int(app_sql.get_setting("device_config_version", "0"))
def get_synced_at_timestamp():
"""Get timestamp of last successful sync."""
return app_sql.get_setting("device_config_synced_at", "")
+1 -36
View File
@@ -1,45 +1,10 @@
from flask_socketio import SocketIO
from threading import Thread
from modules import app_obd
import time, threading
socketio = SocketIO()
uptime_lock = threading.Lock()
thread_uptime = None
@socketio.on('connect')
def connect_event():
print('Client connected')
print('Getting OBD status...')
print('Getting current OBD status...')
app_obd.obd_connection()
uptime()
def uptime():
global thread_uptime
if thread_uptime is None or not thread_uptime.is_alive():
thread_uptime = Thread(target=loop_uptime)
thread_uptime.daemon = True
thread_uptime.start()
print(f"Uptime thread started, alive: {thread_uptime.is_alive()}")
else:
print(f"Uptime thread already running, alive: {thread_uptime.is_alive()}")
def loop_uptime():
uptime_lock.acquire()
try:
loop = True
while loop == True:
boottime = time.clock_gettime(time.CLOCK_BOOTTIME)
# Convert to hours, minutes, and seconds
seconds = int(boottime)
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
time.sleep(1)
uptime = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
socketio.emit("rpi_uptime", uptime)
finally:
uptime_lock.release()
+212 -22
View File
@@ -1,22 +1,212 @@
from modules.app_events import socketio
from threading import Thread
class GPS:
def __init__(self):
self.LocationX = None
self.LocationY= None
@socketio.on('gps')
def get_gps():
global LocationX
global LocationY
LocationX = "45.5202844"
LocationY = "-73.5493208"
Location = [LocationX, LocationY]
socketio.emit('gps_location', Location)
print("websocket should be sent now")
return print("socket.io emitted")
from modules.app_events import socketio
from threading import Thread, Lock
import serial
import time
try:
import RPi.GPIO as GPIO
except (ImportError, RuntimeError):
# Not running on a Pi (e.g. local dev machine) - PWRKEY auto power-on is
# simply unavailable there; _pulse_pwrkey() below degrades gracefully.
GPIO = None
# 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
# This HAT revision has the "P4" GPIO power-on mod wire connected to the SIM868's
# PWRKEY line (confirmed 2026-08-09 by pulsing it and seeing the module respond to
# AT afterward) - physical header pin 7 (BCM GPIO4), same pin Waveshare's own
# GSM_PWRKEY.py reference script uses. This lets us auto power-on the module chip
# on every app start instead of requiring someone to press its PWRKEY button by
# hand after every Pi power cycle.
PWRKEY_PIN = 7 # BOARD numbering
PWRKEY_HOLD_SECONDS = 4 # matches Waveshare's reference script
LocationX = None
LocationY = None
last_fix_at = None
_serial_lock = Lock()
_serial = None
_gps_thread = None
class GPS:
def __init__(self):
self.LocationX = None
self.LocationY = None
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 _module_responds():
"""Check whether the SIM868 module chip is powered on and answering AT commands."""
try:
return "OK" in _send_at("AT", wait=0.3)
except Exception:
return False
def _pulse_pwrkey():
"""Pulse the PWRKEY line low then high, same sequence as Waveshare's own
GSM_PWRKEY.py reference script.
PWRKEY is a toggle, not a simple "power on" switch: pressing it while the
module is off powers it on, but pressing it again while already on powers
it back off. Callers must only invoke this after confirming via
`_module_responds()` that the module is NOT currently responding - never
unconditionally on every app start, or a normal restart while the module
is already up would accidentally shut it back down.
"""
if GPIO is None:
print("RPi.GPIO not available, cannot auto-power-on the SIM868 module - "
"press its PWRKEY button by hand.")
return
GPIO.setmode(GPIO.BOARD)
GPIO.setup(PWRKEY_PIN, GPIO.OUT)
GPIO.output(PWRKEY_PIN, GPIO.LOW)
time.sleep(PWRKEY_HOLD_SECONDS)
GPIO.output(PWRKEY_PIN, GPIO.HIGH)
GPIO.cleanup(PWRKEY_PIN)
def _ensure_module_powered():
"""Power on the SIM868 module chip via its PWRKEY line if it isn't already on.
The HAT's 5V rail can be live (board powered) while the module chip itself
stays fully off until PWRKEY is pulsed - this is what previously required a
manual button press after every Pi power cycle. Returns True once the
module is confirmed responding to AT commands, False if it still isn't
after attempting the power-on pulse (e.g. GPIO unavailable, or this board
doesn't have the P4 mod wire).
"""
if _module_responds():
return True
print("SIM868 module not responding to AT, pulsing PWRKEY to power it on...")
_pulse_pwrkey()
time.sleep(3) # give the module a few seconds to boot after PWRKEY release
for _ in range(5):
if _module_responds():
print("SIM868 module powered on and responding.")
return True
time.sleep(1)
print("SIM868 module still not responding after PWRKEY pulse - check the "
"P4 wiring or press the physical PWRKEY button.")
return False
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, last_fix_at
try:
with _serial_lock:
if _ensure_module_powered():
_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"]
last_fix_at = time.monotonic()
socketio.emit("gps_location", [LocationX, LocationY])
time.sleep(POLL_INTERVAL)
def get_fix_age():
return None if last_fix_at is None else time.monotonic() - last_fix_at
def get_status():
"""Snapshot of the current GPS/GNSS state for the local dashboard."""
return {
"thread_alive": _gps_thread.is_alive() if _gps_thread is not None else False,
"has_fix": LocationX is not None and LocationY is not None and last_fix_at is not None,
"latitude": LocationX,
"longitude": LocationY,
"fix_age_seconds": get_fix_age(),
}
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():
"""Return the last known GPS location and emit it over the 'gps_location' event."""
Location = [LocationX, LocationY]
socketio.emit("gps_location", Location)
print("gps_location emitted:", Location)
return Location

Some files were not shown because too many files have changed in this diff Show More