15 Commits
Author SHA1 Message Date
[email protected] 0d55d9e9de fix things, thread loop 2024-09-15 22:50:22 -04:00
Mathieu Moran 7fbd427516 Merge pull request #1 from quoije/main
working branch
2024-09-15 21:25:49 -04:00
[email protected] f087478736 fix things, some things should work 2024-09-15 21:21:39 -04:00
Mathieu Moran 646bdd32b5 delete __pycache__ directory 2024-04-02 19:53:37 -04:00
quoije ada68ed6ac wip sql 2024-04-01 21:29:12 -04:00
quoije 286649e9c3 restructure 2024-04-01 20:21:52 -04:00
quoije 792c170fd6 first tsv commit 2024-04-01 12:52:27 -04:00
quoije c4c10972a1 add raw to tsv, mangodb first commit, restructure 2024-04-01 11:37:13 -04:00
quoije bdf6bb477b add gps function 2024-03-17 22:37:25 -04:00
quoije 4bc184fcdf rm temp files 2024-03-09 15:44:57 -05:00
quoije 5e8e3cf603 put prod server 2024-03-09 15:37:12 -05:00
quoije 651c798d3a test inputs 2024-03-02 13:40:52 -05:00
Mathieu Moran e9f204d6bd loopy loop, doesn't work, too many loops 2023-11-28 23:04:06 -05:00
Mathieu Moran 9aa32584e6 add readme.md 2023-11-18 14:11:19 -05:00
Mathieu Moran 698adee415 add ui pics for readme 2023-11-18 14:08:04 -05:00
194 changed files with 799 additions and 29552 deletions
-34
View File
@@ -1,34 +0,0 @@
# 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
@@ -1,307 +0,0 @@
# 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
@@ -1,304 +0,0 @@
# 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
@@ -1,21 +0,0 @@
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
@@ -1,14 +0,0 @@
# 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
@@ -1,135 +0,0 @@
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
@@ -1,45 +0,0 @@
# 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
@@ -1,57 +0,0 @@
<?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>
@@ -1,303 +0,0 @@
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
}
@@ -1,128 +0,0 @@
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
}
}
@@ -1,301 +0,0 @@
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"
}
}
@@ -1,422 +0,0 @@
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}"
}
}
}
}
@@ -1,222 +0,0 @@
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())
}
}
}
@@ -1,305 +0,0 @@
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"
}
@@ -1,316 +0,0 @@
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}"
}
}
@@ -1,277 +0,0 @@
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)
},
)
}
}
}
}
}
@@ -1,249 +0,0 @@
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"
}
@@ -1,282 +0,0 @@
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
}
}
@@ -1,138 +0,0 @@
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}" }
}
@@ -1,173 +0,0 @@
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)
}
@@ -1,178 +0,0 @@
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)
}
@@ -1,272 +0,0 @@
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"
}
@@ -1,128 +0,0 @@
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)
}
}
@@ -1,218 +0,0 @@
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()
}
}
}
@@ -1,28 +0,0 @@
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) {
""
}
}
@@ -1,19 +0,0 @@
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
}
}
@@ -1,53 +0,0 @@
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()
}
}
@@ -1,85 +0,0 @@
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
}
@@ -1,38 +0,0 @@
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>
}
@@ -1,40 +0,0 @@
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
}
@@ -1,30 +0,0 @@
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,
)
@@ -1,23 +0,0 @@
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,
)
@@ -1,15 +0,0 @@
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,
)
@@ -1,11 +0,0 @@
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 = "",
)
@@ -1,23 +0,0 @@
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,
)
@@ -1,61 +0,0 @@
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 = "",
)
@@ -1,18 +0,0 @@
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 = "",
)
@@ -1,35 +0,0 @@
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,
)
@@ -1,33 +0,0 @@
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(),
)
@@ -1,15 +0,0 @@
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,
)
@@ -1,29 +0,0 @@
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,
)
@@ -1,61 +0,0 @@
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),
)
}
}
}
@@ -1,104 +0,0 @@
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
)
}
@@ -1,112 +0,0 @@
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()
}
}
}
@@ -1,23 +0,0 @@
<?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>
@@ -1,5 +0,0 @@
<?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>
@@ -1,5 +0,0 @@
<?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>
@@ -1,9 +0,0 @@
<?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>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#E63946</color>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">yaris-obd</string>
</resources>
@@ -1,10 +0,0 @@
<?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>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="movies" path="Movies/yaris-obd/" />
</paths>
@@ -1,8 +0,0 @@
<?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
@@ -1,7 +0,0 @@
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
@@ -1,19 +0,0 @@
# 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
@@ -1,78 +0,0 @@
[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
@@ -1,9 +0,0 @@
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
-248
View File
@@ -1,248 +0,0 @@
#!/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
@@ -1,82 +0,0 @@
@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
@@ -1,414 +0,0 @@
# 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
@@ -1,24 +0,0 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "yaris-obd"
include(":app")
+8 -467
View File
@@ -1,6 +1,6 @@
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 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 gevent.pywsgi import WSGIServer
@@ -8,25 +8,15 @@ app = Flask(__name__)
app_events.socketio.init_app(app)
account = app_account.Account()
obd = app_obd.start_obd()
obd = app_obd.OBD()
car = app_car.Car()
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()
gps = app_gps.get_gps()
@app.route('/')
def index():
return render_template('index.html', account=account, car=car)
print("hello "+ account.name)
print("you're car model is:" + car.model)
return render_template('index.html', account = account, car = car)
@app.route('/dtc')
def dtc():
@@ -36,426 +26,6 @@ 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():
@@ -464,35 +34,6 @@ 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
@@ -1,6 +0,0 @@
{
"owner_name": "quoije",
"car_model": "Yaris 2013",
"device_name": "Yaris OBD",
"avatar_path": "img/yaris.png"
}
-26
View File
@@ -1,26 +0,0 @@
##
## 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
@@ -1,12 +0,0 @@
#!/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
@@ -1,453 +0,0 @@
#!/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
@@ -1,20 +0,0 @@
#!/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
@@ -1,79 +0,0 @@
#!/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
@@ -1,23 +0,0 @@
#!/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
@@ -1,32 +0,0 @@
#!/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
@@ -1,11 +0,0 @@
#!/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
@@ -1,30 +0,0 @@
#!/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
@@ -1,10 +0,0 @@
# 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
@@ -1,431 +0,0 @@
# 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
@@ -1,11 +0,0 @@
[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
@@ -1,11 +0,0 @@
[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
@@ -1,13 +0,0 @@
# 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
@@ -1,4 +0,0 @@
# 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
@@ -1,5 +0,0 @@
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
@@ -1,9 +0,0 @@
[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
@@ -1,11 +0,0 @@
[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
@@ -1,34 +0,0 @@
[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
@@ -1 +0,0 @@
yaris ALL=(root) NOPASSWD: /usr/bin/systemctl restart --no-block yaris-obd.service
-12
View File
@@ -1,12 +0,0 @@
[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
@@ -1,3 +0,0 @@
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
@@ -1,3 +0,0 @@
[Manager]
RuntimeWatchdogSec=10s
RebootWatchdogSec=10min
-95
View File
@@ -1,95 +0,0 @@
#!/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
@@ -1,494 +0,0 @@
#!/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())
+1 -26
View File
@@ -1,30 +1,5 @@
import json
import os
class Account:
def __init__(self):
config = self._load_config()
self.name = config.get("owner_name", "quoije")
self.avatar_path = config.get("avatar_path", "img/yaris.png")
self.name = "quoije"
self.pp_image = 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
@@ -1,526 +0,0 @@
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,
}
+1 -26
View File
@@ -1,29 +1,4 @@
import json
import os
class Car:
def __init__(self):
config = self._load_config()
self.model = config.get("car_model", "Yaris 2013")
self.device_name = config.get("device_name", "Yaris OBD")
self.model = "Yaris 2013"
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
@@ -1,249 +0,0 @@
"""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,
)
+29 -602
View File
@@ -1,611 +1,38 @@
"""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
from tqdm import tqdm
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"}
def mount_device(device, mount_point):
os.system(f"sudo mount {device} {mount_point}")
# Filesystem types dashcams commonly ship formatted as (SD cards are almost
# always FAT32/exFAT; a few older units use NTFS).
DASHCAM_FS_TYPES = {"vfat", "exfat", "ntfs", "ntfs3"}
def unmount_device(mount_point):
os.system(f"sudo umount {mount_point}")
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()
def copy_files(source_folder, destination_folder):
if not os.path.exists(destination_folder):
os.makedirs(destination_folder)
# --- 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
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__":
print(import_dashcam())
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)
+10 -18
View File
@@ -2,30 +2,22 @@ from pymongo import MongoClient
from dotenv import load_dotenv
import os
# 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"))
load_dotenv("../db/creds.env")
mUser = os.getenv("mangosrv_user")
mPass = os.getenv("mangosrv_pass")
mDB = os.getenv("mangosrv_db")
mUser = os.getenv()
mPass = os.getenv()
mDB = os.getenv()
def get_database():
if not mUser or not mPass or not mDB:
raise RuntimeError(
"MongoDB credentials are missing. Set mangosrv_user, mangosrv_pass, "
"and mangosrv_db in db/creds.env."
)
# Provide the mongodb atlas url to connect python to mongodb using pymongo
CONNECTION_STRING = "mongodb+srv://" + mUser + ":" + mPass + "@cluster.mongodb.net/" + mDB
# 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]
# Create a connection using MongoClient. You can import MongoClient or use pymongo.MongoClient
client = MongoClient(CONNECTION_STRING)
# This is added so that many files can reuse the function get_database()
if __name__ == "__main__":
# Get the database
dbname = get_database()
# Get the database
dbname = get_database()
-131
View File
@@ -1,131 +0,0 @@
"""
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
@@ -1,160 +0,0 @@
"""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", "")
+36 -1
View File
@@ -1,10 +1,45 @@
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 current OBD status...')
print('Getting 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()
+14 -204
View File
@@ -1,212 +1,22 @@
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
from threading import Thread
class GPS:
def __init__(self):
self.LocationX = None
self.LocationY = 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")
@socketio.on('gps')
def get_gps():
"""Return the last known GPS location and emit it over the 'gps_location' event."""
global LocationX
global LocationY
LocationX = "45.5202844"
LocationY = "-73.5493208"
Location = [LocationX, LocationY]
socketio.emit("gps_location", Location)
print("gps_location emitted:", Location)
return Location
socketio.emit('gps_location', Location)
print("websocket should be sent now")
return print("socket.io emitted")

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