Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f087478736 | ||
|
|
646bdd32b5 | ||
|
|
ada68ed6ac | ||
|
|
286649e9c3 | ||
|
|
792c170fd6 | ||
|
|
c4c10972a1 | ||
|
|
bdf6bb477b | ||
|
|
4bc184fcdf | ||
|
|
5e8e3cf603 | ||
|
|
651c798d3a | ||
|
|
e9f204d6bd | ||
|
|
9aa32584e6 | ||
|
|
698adee415 |
-34
@@ -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/
|
||||
@@ -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
@@ -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`).
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
-45
@@ -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.**
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
Vendored
-248
@@ -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" "$@"
|
||||
Vendored
-82
@@ -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
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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,24 +8,14 @@ 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():
|
||||
print("hello "+ account.name)
|
||||
print("you're car model is:" + car.model)
|
||||
return render_template('index.html', account = account, car = car)
|
||||
|
||||
@app.route('/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")
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"owner_name": "quoije",
|
||||
"car_model": "Yaris 2013",
|
||||
"device_name": "Yaris OBD",
|
||||
"avatar_path": "img/yaris.png"
|
||||
}
|
||||
@@ -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 = ""
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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/"
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 -- *
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
yaris ALL=(root) NOPASSWD: /usr/bin/systemctl restart --no-block yaris-obd.service
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
[Manager]
|
||||
RuntimeWatchdogSec=10s
|
||||
RebootWatchdogSec=10min
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user