Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d55d9e9de | ||
|
|
7fbd427516 | ||
|
|
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())
|
||||
+1
-26
@@ -1,30 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
class Account:
|
||||
def __init__(self):
|
||||
config = self._load_config()
|
||||
self.name = config.get("owner_name", "quoije")
|
||||
self.avatar_path = config.get("avatar_path", "img/yaris.png")
|
||||
self.name = "quoije"
|
||||
self.pp_image = None
|
||||
self.car = None
|
||||
|
||||
def reload_runtime_config(self):
|
||||
"""Refresh display-facing fields from the live device config store."""
|
||||
from . import app_device_config
|
||||
|
||||
config = app_device_config.get_device_config()
|
||||
self.name = config.get("owner_name", "quoije")
|
||||
self.avatar_path = config.get("avatar_path", "img/yaris.png")
|
||||
|
||||
def _load_config(self):
|
||||
"""Load device config from JSON file, return dict with defaults."""
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "device.json")
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except (IOError, json.JSONDecodeError):
|
||||
pass
|
||||
return {}
|
||||
@@ -1,526 +0,0 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from modules import (
|
||||
app_cellular,
|
||||
app_dash,
|
||||
app_gps,
|
||||
app_health,
|
||||
app_obd,
|
||||
app_sql,
|
||||
app_video,
|
||||
)
|
||||
|
||||
|
||||
CHECK_INTERVAL = 60
|
||||
RETRY_INTERVAL = 5 * 60
|
||||
DASHCAM_CHECK_INTERVAL = 5 * 60
|
||||
POWER_EVENT_GRACE_SECONDS = 5 * 60
|
||||
|
||||
SETTING_DEFAULTS = {
|
||||
"alerts_enabled": "0",
|
||||
"alert_ntfy_url": "",
|
||||
"alert_ntfy_token": "",
|
||||
"alert_temperature_c": "80",
|
||||
"alert_disk_percent": "90",
|
||||
"alert_tracking_backlog": "1000",
|
||||
"alert_video_backlog": "10",
|
||||
"alert_gps_loss_seconds": "60",
|
||||
"alert_repeat_seconds": "3600",
|
||||
}
|
||||
|
||||
ALERT_TITLES = {
|
||||
"mil": "Check-engine light",
|
||||
"disk": "Pi storage nearly full",
|
||||
"upload_backlog": "Upload backlog growing",
|
||||
"temperature": "Pi temperature high",
|
||||
"power": "Pi power or throttling warning",
|
||||
"gps_loss": "GPS fix lost while moving",
|
||||
}
|
||||
|
||||
_thread = None
|
||||
_last_run_at = None
|
||||
_last_run_error = None
|
||||
_last_conditions = {}
|
||||
_moving_started_at = None
|
||||
_last_dashcam_check_at = None
|
||||
_last_dashcam_usage = {"status": "not_checked"}
|
||||
|
||||
|
||||
def _number_setting(key, minimum):
|
||||
raw = app_sql.get_setting(key, SETTING_DEFAULTS[key])
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return float(SETTING_DEFAULTS[key]), f"{key} must be numeric"
|
||||
if value < minimum:
|
||||
return float(SETTING_DEFAULTS[key]), f"{key} must be at least {minimum}"
|
||||
return value, None
|
||||
|
||||
|
||||
def get_config():
|
||||
errors = {}
|
||||
values = {}
|
||||
minimums = {
|
||||
"alert_temperature_c": 1,
|
||||
"alert_disk_percent": 1,
|
||||
"alert_tracking_backlog": 1,
|
||||
"alert_video_backlog": 1,
|
||||
"alert_gps_loss_seconds": 1,
|
||||
"alert_repeat_seconds": 60,
|
||||
}
|
||||
for key, minimum in minimums.items():
|
||||
values[key], error = _number_setting(key, minimum)
|
||||
if error:
|
||||
errors[key] = error
|
||||
|
||||
url = app_sql.get_setting("alert_ntfy_url", "")
|
||||
parsed = urlparse(url)
|
||||
if url and (parsed.scheme not in {"http", "https"} or not parsed.netloc):
|
||||
errors["alert_ntfy_url"] = "alert_ntfy_url must be an http(s) URL"
|
||||
|
||||
values.update({
|
||||
"enabled": app_sql.get_setting("alerts_enabled", "0") == "1",
|
||||
"ntfy_url": url,
|
||||
"ntfy_token": app_sql.get_setting("alert_ntfy_token", ""),
|
||||
"errors": errors,
|
||||
})
|
||||
return values
|
||||
|
||||
|
||||
def send_ntfy(url, token, title, message, priority="default", tags=None):
|
||||
headers = {
|
||||
"Title": title,
|
||||
"Priority": priority,
|
||||
}
|
||||
if tags:
|
||||
headers["Tags"] = ",".join(tags)
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
body = message.encode("utf-8")
|
||||
if app_cellular.is_configured():
|
||||
cellular_failure = None
|
||||
try:
|
||||
status_code, _response_body = app_cellular.http_post(
|
||||
url,
|
||||
body,
|
||||
headers,
|
||||
content_type="text/plain; charset=utf-8",
|
||||
)
|
||||
if 200 <= status_code < 300:
|
||||
return
|
||||
cellular_failure = f"HTTP {status_code}"
|
||||
except app_cellular.CellularError:
|
||||
cellular_failure = "modem error"
|
||||
print(
|
||||
f"Cellular notification failed ({cellular_failure}); "
|
||||
"trying WiFi/Ethernet."
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def _transport_error(error):
|
||||
if isinstance(error, app_cellular.CellularError):
|
||||
return "Cellular notification failed"
|
||||
if isinstance(error, requests.Timeout):
|
||||
return "Notification request timed out"
|
||||
if isinstance(error, requests.HTTPError):
|
||||
status_code = (
|
||||
error.response.status_code
|
||||
if error.response is not None
|
||||
else "unknown"
|
||||
)
|
||||
return f"Notification server returned HTTP {status_code}"
|
||||
if isinstance(error, requests.ConnectionError):
|
||||
return "Notification connection failed"
|
||||
return "Notification request failed"
|
||||
|
||||
|
||||
def gps_loss_while_moving(speed, fix_age, threshold, now=None):
|
||||
global _moving_started_at
|
||||
now = time.monotonic() if now is None else now
|
||||
if speed is None or speed <= 1:
|
||||
_moving_started_at = None
|
||||
return False
|
||||
if _moving_started_at is None:
|
||||
_moving_started_at = now
|
||||
last_fix_at = None if fix_age is None else now - fix_age
|
||||
loss_started_at = (
|
||||
_moving_started_at
|
||||
if last_fix_at is None
|
||||
else max(_moving_started_at, last_fix_at)
|
||||
)
|
||||
return now - loss_started_at >= threshold
|
||||
|
||||
|
||||
def _dashcam_usage(now=None):
|
||||
global _last_dashcam_check_at, _last_dashcam_usage
|
||||
now = time.monotonic() if now is None else now
|
||||
if (
|
||||
_last_dashcam_check_at is None
|
||||
or now - _last_dashcam_check_at >= DASHCAM_CHECK_INTERVAL
|
||||
):
|
||||
_last_dashcam_usage = app_dash.dashcam_storage_usage()
|
||||
_last_dashcam_check_at = now
|
||||
return _last_dashcam_usage
|
||||
|
||||
|
||||
def _power_condition(throttling, now=None):
|
||||
now = time.time() if now is None else now
|
||||
try:
|
||||
event_until = float(
|
||||
app_sql.get_setting("alert_power_event_until", "0")
|
||||
)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("alert_power_event_until is invalid") from error
|
||||
if throttling is None:
|
||||
return {
|
||||
"active": now < event_until,
|
||||
"current": [],
|
||||
"occurred": [],
|
||||
"newly_occurred": [],
|
||||
"recent_event": now < event_until,
|
||||
"raw": None,
|
||||
}
|
||||
current = set(throttling["current"] if throttling else [])
|
||||
occurred = set(throttling["occurred"] if throttling else [])
|
||||
raw_seen = app_sql.get_setting("alert_throttling_seen")
|
||||
if raw_seen is None:
|
||||
seen = occurred
|
||||
app_sql.set_setting(
|
||||
"alert_throttling_seen", json.dumps(sorted(occurred))
|
||||
)
|
||||
else:
|
||||
try:
|
||||
seen = set(json.loads(raw_seen))
|
||||
except (json.JSONDecodeError, TypeError) as error:
|
||||
raise ValueError("alert_throttling_seen is invalid") from error
|
||||
newly_occurred = occurred - seen
|
||||
if raw_seen is not None and occurred != seen:
|
||||
app_sql.set_setting(
|
||||
"alert_throttling_seen", json.dumps(sorted(occurred))
|
||||
)
|
||||
if newly_occurred:
|
||||
event_until = now + POWER_EVENT_GRACE_SECONDS
|
||||
app_sql.set_setting(
|
||||
"alert_power_event_until",
|
||||
str(event_until),
|
||||
)
|
||||
return {
|
||||
"active": bool(current) or now < event_until,
|
||||
"current": sorted(current),
|
||||
"occurred": sorted(occurred),
|
||||
"newly_occurred": sorted(newly_occurred),
|
||||
"recent_event": now < event_until,
|
||||
"raw": throttling["raw"] if throttling else None,
|
||||
}
|
||||
|
||||
|
||||
def collect_conditions(config):
|
||||
health = app_health.collect_health()
|
||||
videos = app_video.list_local_videos()
|
||||
pending_videos = sum(1 for video in (videos or {}).get("files", []) if not video["uploaded"])
|
||||
pending_tracking = app_sql.count_unsent_tracking_rows()
|
||||
speed = app_obd.get_fresh_speed()
|
||||
fix_age = app_gps.get_fix_age()
|
||||
|
||||
temperature = health["temperature_c"]
|
||||
disk = health["disk"]
|
||||
throttling = health["throttling"]
|
||||
dashcam_disk = _dashcam_usage()
|
||||
power = _power_condition(throttling)
|
||||
mil_on = app_obd.get_fresh_mil()
|
||||
dtc_codes = [
|
||||
item["code"] for item in app_obd.get_fresh_dtc_codes()
|
||||
]
|
||||
moving_without_fix = gps_loss_while_moving(
|
||||
speed,
|
||||
fix_age,
|
||||
config["alert_gps_loss_seconds"],
|
||||
)
|
||||
|
||||
return {
|
||||
"mil": {
|
||||
"active": mil_on is True,
|
||||
"value": {
|
||||
"mil_on": mil_on,
|
||||
"dtc_codes": dtc_codes,
|
||||
},
|
||||
"message": (
|
||||
"The vehicle MIL is on."
|
||||
+ (f" Codes: {', '.join(dtc_codes)}" if dtc_codes else "")
|
||||
),
|
||||
"priority": "high",
|
||||
"tags": ["warning", "car"],
|
||||
},
|
||||
"disk": {
|
||||
"active": (
|
||||
(
|
||||
disk is not None
|
||||
and disk["used_percent"] >= config["alert_disk_percent"]
|
||||
)
|
||||
or (
|
||||
dashcam_disk["status"] == "ok"
|
||||
and dashcam_disk["used_percent"]
|
||||
>= config["alert_disk_percent"]
|
||||
)
|
||||
),
|
||||
"value": {
|
||||
"pi": disk,
|
||||
"dashcam": dashcam_disk,
|
||||
},
|
||||
"message": (
|
||||
(
|
||||
f"Pi storage is {disk['used_percent']:.1f}% full. "
|
||||
if disk
|
||||
else "Pi disk usage is unavailable. "
|
||||
)
|
||||
+ (
|
||||
f"Dashcam storage is {dashcam_disk['used_percent']:.1f}% full."
|
||||
if dashcam_disk["status"] == "ok"
|
||||
else f"Dashcam storage status: {dashcam_disk['status']}."
|
||||
)
|
||||
),
|
||||
"priority": "high",
|
||||
"tags": ["warning", "floppy_disk"],
|
||||
},
|
||||
"upload_backlog": {
|
||||
"active": (
|
||||
pending_tracking >= config["alert_tracking_backlog"]
|
||||
or pending_videos >= config["alert_video_backlog"]
|
||||
),
|
||||
"value": {
|
||||
"pending_tracking_rows": pending_tracking,
|
||||
"pending_videos": pending_videos,
|
||||
},
|
||||
"message": (
|
||||
f"{pending_tracking} tracking rows and {pending_videos} "
|
||||
"dashcam videos are waiting to upload."
|
||||
),
|
||||
"priority": "default",
|
||||
"tags": ["warning", "outbox_tray"],
|
||||
},
|
||||
"temperature": {
|
||||
"active": (
|
||||
temperature is not None
|
||||
and temperature >= config["alert_temperature_c"]
|
||||
),
|
||||
"value": {"temperature_c": temperature},
|
||||
"message": (
|
||||
f"Pi CPU temperature is {temperature:.1f} °C."
|
||||
if temperature is not None
|
||||
else "Pi temperature is unavailable."
|
||||
),
|
||||
"priority": "high",
|
||||
"tags": ["warning", "thermometer"],
|
||||
},
|
||||
"power": {
|
||||
"active": power["active"],
|
||||
"value": power,
|
||||
"message": (
|
||||
"Pi firmware reports: "
|
||||
+ ", ".join(power["current"]).replace("_", " ")
|
||||
+ "."
|
||||
if power["current"]
|
||||
else (
|
||||
"Pi firmware recorded a recent power/throttling event: "
|
||||
+ ", ".join(
|
||||
power["newly_occurred"] or power["occurred"]
|
||||
).replace("_", " ")
|
||||
+ "."
|
||||
if power["recent_event"]
|
||||
else "Pi power and throttling state is normal."
|
||||
)
|
||||
),
|
||||
"priority": "high",
|
||||
"tags": ["warning", "electric_plug"],
|
||||
},
|
||||
"gps_loss": {
|
||||
"active": moving_without_fix,
|
||||
"value": {
|
||||
"speed_kmh": speed,
|
||||
"fix_age_seconds": fix_age,
|
||||
},
|
||||
"message": (
|
||||
f"The vehicle is moving at {speed:.1f} km/h but the GPS fix "
|
||||
+ (
|
||||
"has never been acquired."
|
||||
if fix_age is None
|
||||
else f"is {fix_age:.0f} seconds old."
|
||||
)
|
||||
if speed is not None
|
||||
else "GPS state unavailable."
|
||||
),
|
||||
"priority": "high",
|
||||
"tags": ["warning", "round_pushpin"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _blank_state():
|
||||
return {
|
||||
"active": False,
|
||||
"active_since": None,
|
||||
"last_attempt_at": None,
|
||||
"last_sent_at": None,
|
||||
"recovery_pending": False,
|
||||
"last_recovery_at": None,
|
||||
"last_error": None,
|
||||
"last_value": None,
|
||||
}
|
||||
|
||||
|
||||
def process_conditions(conditions, config, now=None, sender=send_ntfy):
|
||||
now = time.time() if now is None else now
|
||||
states = app_sql.get_alert_states()
|
||||
configured = (
|
||||
config["enabled"]
|
||||
and bool(config["ntfy_url"])
|
||||
and not config["errors"]
|
||||
)
|
||||
|
||||
for key, condition in conditions.items():
|
||||
previous = states.get(key, _blank_state())
|
||||
state = previous.copy()
|
||||
was_active = previous["active"]
|
||||
state["active"] = condition["active"]
|
||||
state["last_value"] = json.dumps(condition["value"], sort_keys=True)
|
||||
|
||||
if condition["active"]:
|
||||
if not was_active:
|
||||
state["active_since"] = now
|
||||
state["recovery_pending"] = False
|
||||
due = (
|
||||
not was_active
|
||||
or state["last_sent_at"] is None
|
||||
or now - state["last_sent_at"] >= config["alert_repeat_seconds"]
|
||||
)
|
||||
retry_ready = (
|
||||
not was_active
|
||||
or state["last_attempt_at"] is None
|
||||
or now - state["last_attempt_at"] >= (
|
||||
CHECK_INTERVAL
|
||||
if key == "power"
|
||||
and condition["value"].get("recent_event")
|
||||
else RETRY_INTERVAL
|
||||
)
|
||||
)
|
||||
if configured and due and retry_ready:
|
||||
state["last_attempt_at"] = now
|
||||
try:
|
||||
sender(
|
||||
config["ntfy_url"],
|
||||
config["ntfy_token"],
|
||||
ALERT_TITLES[key],
|
||||
condition["message"],
|
||||
condition["priority"],
|
||||
condition["tags"],
|
||||
)
|
||||
except (
|
||||
requests.RequestException,
|
||||
app_cellular.CellularError,
|
||||
) as error:
|
||||
state["last_error"] = _transport_error(error)
|
||||
else:
|
||||
state["last_sent_at"] = now
|
||||
state["last_error"] = None
|
||||
else:
|
||||
state["active_since"] = None
|
||||
if was_active and state["last_sent_at"] is not None:
|
||||
state["recovery_pending"] = True
|
||||
retry_ready = (
|
||||
was_active
|
||||
or state["last_attempt_at"] is None
|
||||
or now - state["last_attempt_at"] >= RETRY_INTERVAL
|
||||
)
|
||||
if configured and state["recovery_pending"] and retry_ready:
|
||||
state["last_attempt_at"] = now
|
||||
try:
|
||||
sender(
|
||||
config["ntfy_url"],
|
||||
config["ntfy_token"],
|
||||
f"Resolved: {ALERT_TITLES[key]}",
|
||||
f"{ALERT_TITLES[key]} is no longer active.",
|
||||
"default",
|
||||
["white_check_mark"],
|
||||
)
|
||||
except (
|
||||
requests.RequestException,
|
||||
app_cellular.CellularError,
|
||||
) as error:
|
||||
state["last_error"] = _transport_error(error)
|
||||
else:
|
||||
state["recovery_pending"] = False
|
||||
state["last_recovery_at"] = now
|
||||
state["last_error"] = None
|
||||
|
||||
app_sql.save_alert_state(key, state)
|
||||
|
||||
|
||||
def check_alerts():
|
||||
global _last_run_at, _last_run_error, _last_conditions
|
||||
config = get_config()
|
||||
conditions = collect_conditions(config)
|
||||
process_conditions(conditions, config)
|
||||
_last_conditions = conditions
|
||||
_last_run_at = time.time()
|
||||
_last_run_error = None
|
||||
return conditions
|
||||
|
||||
|
||||
def _loop():
|
||||
global _last_run_error
|
||||
while True:
|
||||
try:
|
||||
check_alerts()
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
sqlite3.Error,
|
||||
requests.RequestException,
|
||||
app_cellular.CellularError,
|
||||
) as error:
|
||||
_last_run_error = str(error)
|
||||
print("Alert check failed:", error)
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
|
||||
|
||||
def start_alerting():
|
||||
global _thread
|
||||
if _thread is None or not _thread.is_alive():
|
||||
_thread = threading.Thread(target=_loop, name="alerts", daemon=True)
|
||||
_thread.start()
|
||||
print(f"Alert thread started, alive: {_thread.is_alive()}")
|
||||
return _thread
|
||||
|
||||
|
||||
def get_status():
|
||||
config = get_config()
|
||||
states = app_sql.get_alert_states()
|
||||
thread_alive = _thread is not None and _thread.is_alive()
|
||||
return {
|
||||
"enabled": config["enabled"],
|
||||
"configured": bool(config["ntfy_url"]) and not config["errors"],
|
||||
"config_errors": config["errors"],
|
||||
"thread_alive": thread_alive,
|
||||
"last_run_at": _last_run_at,
|
||||
"last_run_error": (
|
||||
_last_run_error
|
||||
or ("Alert thread is not running." if not thread_alive else None)
|
||||
),
|
||||
"conditions": _last_conditions,
|
||||
"states": states,
|
||||
}
|
||||
+1
-26
@@ -1,29 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
class Car:
|
||||
def __init__(self):
|
||||
config = self._load_config()
|
||||
self.model = config.get("car_model", "Yaris 2013")
|
||||
self.device_name = config.get("device_name", "Yaris OBD")
|
||||
self.model = "Yaris 2013"
|
||||
self.obd = None
|
||||
|
||||
def reload_runtime_config(self):
|
||||
"""Refresh display-facing fields from the live device config store."""
|
||||
from . import app_device_config
|
||||
|
||||
config = app_device_config.get_device_config()
|
||||
self.model = config.get("car_model", "Yaris 2013")
|
||||
self.device_name = config.get("device_name", "Yaris OBD")
|
||||
|
||||
def _load_config(self):
|
||||
"""Load device config from JSON file, return dict with defaults."""
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "device.json")
|
||||
try:
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
except (IOError, json.JSONDecodeError):
|
||||
pass
|
||||
return {}
|
||||
@@ -1,249 +0,0 @@
|
||||
"""SIM868 GSM/GPRS cellular fallback for uploading trip/GPS tracking batches
|
||||
when there is no WiFi/Ethernet path to the remote server.
|
||||
|
||||
*** UNTESTED - no SIM card was available to validate this against a real
|
||||
*** cellular network. Written defensively (every AT exchange is wrapped so a
|
||||
*** failure just raises CellularError, never crashes the app) but the exact
|
||||
*** AT command dialect/timing may need small tweaks once a SIM is inserted
|
||||
*** and this can actually be exercised. See "Verifying on real hardware"
|
||||
*** below and the Phase 9 notes in PLAN.md.
|
||||
|
||||
Why AT-command HTTP instead of a real ppp0 network interface: the SIM868's
|
||||
GNSS (used by modules/app_gps.py) and its GSM/GPRS radio are both driven over
|
||||
the *same* physical UART (/dev/serial0). A classic pppd/chat dial-up would put
|
||||
that UART into raw PPP framing for as long as the link is up, which would
|
||||
starve/conflict with the GNSS AT+CGNSINF polling already running continuously
|
||||
in app_gps.py. SIMCOM modules instead expose a built-in "HTTP(S) Application"
|
||||
AT command set (AT+HTTPINIT / AT+HTTPPARA / AT+HTTPACTION / AT+HTTPREAD) that
|
||||
stays in normal AT command mode the whole time, so it can safely interleave
|
||||
with GNSS polling through the same serial lock (modules.app_gps._serial_lock) -
|
||||
each exchange just briefly delays the next GPS poll, it never takes over the
|
||||
port.
|
||||
|
||||
This only carries small JSON tracking-batch payloads - dashcam video uploads
|
||||
remain WiFi/Ethernet-only (see modules/app_upload.py), both because cellular
|
||||
data is far too slow/limited for video files and because the modem's HTTP
|
||||
AT interface has a small internal buffer unsuited to multi-MB bodies.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
from modules import app_gps, app_sql
|
||||
|
||||
GPRS_BRINGUP_TIMEOUT = 30 # seconds to wait for AT+CIICR / AT+CIFSR
|
||||
HTTP_ACTION_TIMEOUT = 60 # seconds to wait for the +HTTPACTION URC (2G/3G can be slow)
|
||||
HTTP_USERDATA_MAX_BYTES = 1024
|
||||
|
||||
_gprs_context_up = False
|
||||
|
||||
|
||||
class CellularError(Exception):
|
||||
"""Raised for any cellular-modem/AT-command failure - always caught by
|
||||
the caller (modules/app_upload.py), never allowed to crash the app."""
|
||||
|
||||
|
||||
def is_configured():
|
||||
"""True once the user has filled in an APN and flipped the enabled flag
|
||||
in the Settings page or legacy environment configuration. Off by default -
|
||||
nothing cellular-related runs unless explicitly opted into."""
|
||||
enabled = app_sql.get_setting(
|
||||
"cellular_enabled",
|
||||
"1" if os.getenv("YARIS_CELLULAR_ENABLED", "") == "1" else "0",
|
||||
)
|
||||
return enabled == "1" and bool(get_apn())
|
||||
|
||||
|
||||
def get_apn():
|
||||
return app_sql.get_setting(
|
||||
"cellular_apn", os.getenv("YARIS_CELLULAR_APN", "")
|
||||
)
|
||||
|
||||
|
||||
def _read_until(ser, terminators, timeout):
|
||||
"""Read from the modem until one of the given substrings shows up in the
|
||||
accumulated buffer, or timeout. Returns the buffer (decoded, best-effort)."""
|
||||
deadline = time.time() + timeout
|
||||
buf = ""
|
||||
while time.time() < deadline:
|
||||
chunk = ser.read(1024)
|
||||
if chunk:
|
||||
buf += chunk.decode(errors="replace")
|
||||
if any(t in buf for t in terminators):
|
||||
return buf
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
return buf
|
||||
|
||||
|
||||
def _at(ser, cmd, terminators=("OK", "ERROR"), timeout=5):
|
||||
"""Send one AT command and wait for a terminator. Raises CellularError
|
||||
if the modem responds with ERROR or nothing recognizable within timeout."""
|
||||
ser.reset_input_buffer()
|
||||
ser.write((cmd + "\r\n").encode())
|
||||
resp = _read_until(ser, terminators, timeout)
|
||||
if "ERROR" in resp and "OK" not in resp:
|
||||
raise CellularError(f"{cmd!r} -> {resp!r}")
|
||||
if not any(t in resp for t in terminators):
|
||||
raise CellularError(f"{cmd!r} timed out, got {resp!r}")
|
||||
return resp
|
||||
|
||||
|
||||
def _ensure_gprs_context():
|
||||
"""Bring up the GPRS data context (APN attach) if not already up.
|
||||
|
||||
Reuses app_gps's serial connection/lock - see module docstring for why.
|
||||
"""
|
||||
global _gprs_context_up
|
||||
|
||||
with app_gps._serial_lock:
|
||||
ser = app_gps._get_serial()
|
||||
|
||||
if _gprs_context_up:
|
||||
# Cheap check that the context is still alive before trusting it.
|
||||
try:
|
||||
resp = _at(ser, "AT+CIFSR", terminators=("\r\n",), timeout=5)
|
||||
if "." in resp and "ERROR" not in resp:
|
||||
return
|
||||
except CellularError:
|
||||
pass
|
||||
_gprs_context_up = False
|
||||
|
||||
_at(ser, "AT+CGATT=1", timeout=10)
|
||||
|
||||
apn = _validate_http_parameter(get_apn(), "APN")
|
||||
apn_user = _validate_http_parameter(
|
||||
os.getenv("YARIS_CELLULAR_APN_USER", ""),
|
||||
"APN username",
|
||||
)
|
||||
apn_password = _validate_http_parameter(
|
||||
os.getenv("YARIS_CELLULAR_APN_PASS", ""),
|
||||
"APN password",
|
||||
)
|
||||
apn_cmd = f'AT+CSTT="{apn}","{apn_user}","{apn_password}"'
|
||||
_at(ser, apn_cmd, timeout=10)
|
||||
|
||||
_at(ser, "AT+CIICR", timeout=GPRS_BRINGUP_TIMEOUT)
|
||||
|
||||
resp = _at(ser, "AT+CIFSR", terminators=("\r\n",), timeout=10)
|
||||
if "ERROR" in resp or "." not in resp:
|
||||
raise CellularError(f"AT+CIFSR did not return an IP: {resp!r}")
|
||||
|
||||
_gprs_context_up = True
|
||||
|
||||
|
||||
def _validate_http_parameter(value, label):
|
||||
if not isinstance(value, str) or any(
|
||||
char in value for char in ('"', "\r", "\n")
|
||||
):
|
||||
raise CellularError(f"{label} contains unsupported characters")
|
||||
return value
|
||||
|
||||
|
||||
def http_post(
|
||||
url,
|
||||
body_bytes,
|
||||
extra_headers,
|
||||
content_type="application/json",
|
||||
timeout=HTTP_ACTION_TIMEOUT,
|
||||
):
|
||||
"""POST a small body to `url` via the modem's built-in HTTP client.
|
||||
|
||||
Returns (status_code, response_body_bytes). Raises CellularError on any
|
||||
failure (modem not responding, no network attach, HTTP-layer failure,
|
||||
etc.) - callers should catch this broadly and treat it the same as a
|
||||
network-unreachable condition.
|
||||
"""
|
||||
if not is_configured():
|
||||
raise CellularError("cellular fallback not configured (APN/enabled flag not set)")
|
||||
_validate_http_parameter(url, "URL")
|
||||
_validate_http_parameter(content_type, "Content type")
|
||||
for name, value in extra_headers.items():
|
||||
_validate_http_parameter(name, "Header name")
|
||||
_validate_http_parameter(value, f"{name} header")
|
||||
header_lines = r"\r\n".join(
|
||||
f"{name}: {value}" for name, value in extra_headers.items()
|
||||
)
|
||||
if header_lines:
|
||||
header_lines += r"\r\n"
|
||||
if len(header_lines.encode("utf-8")) > HTTP_USERDATA_MAX_BYTES:
|
||||
raise CellularError("HTTP headers exceed the modem's 1024-byte limit")
|
||||
|
||||
_ensure_gprs_context()
|
||||
|
||||
with app_gps._serial_lock:
|
||||
ser = app_gps._get_serial()
|
||||
try:
|
||||
_at(ser, "AT+HTTPTERM", timeout=3)
|
||||
except CellularError:
|
||||
pass # fine if nothing was initialized yet
|
||||
|
||||
_at(ser, "AT+HTTPINIT", timeout=5)
|
||||
try:
|
||||
_at(ser, 'AT+HTTPPARA="CID",1', timeout=5)
|
||||
_at(ser, f'AT+HTTPPARA="URL","{url}"', timeout=5)
|
||||
if url.lower().startswith("https"):
|
||||
try:
|
||||
_at(ser, "AT+HTTPSSL=1", timeout=5)
|
||||
except CellularError:
|
||||
# Older SIMCOM firmware/certificate stores sometimes
|
||||
# don't support this - surfacing as a clear error is more
|
||||
# useful than silently falling back to plain HTTP against
|
||||
# an HTTPS-only endpoint.
|
||||
raise CellularError(
|
||||
"modem does not support AT+HTTPSSL - HTTPS upload via "
|
||||
"cellular is unavailable on this firmware. Consider a "
|
||||
"plain-HTTP router port-forward path for cellular use."
|
||||
)
|
||||
_at(ser, f'AT+HTTPPARA="CONTENT","{content_type}"', timeout=5)
|
||||
|
||||
if header_lines:
|
||||
_at(
|
||||
ser,
|
||||
f'AT+HTTPPARA="USERDATA","{header_lines}"',
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
_at(
|
||||
ser,
|
||||
f"AT+HTTPDATA={len(body_bytes)},{int(timeout * 1000)}",
|
||||
terminators=("DOWNLOAD", "ERROR"),
|
||||
timeout=5,
|
||||
)
|
||||
ser.write(body_bytes)
|
||||
_read_until(ser, ("OK", "ERROR"), timeout=10)
|
||||
|
||||
action_resp = _at(
|
||||
ser,
|
||||
"AT+HTTPACTION=1",
|
||||
terminators=("+HTTPACTION:",),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
status_code = 0
|
||||
for line in action_resp.splitlines():
|
||||
if "+HTTPACTION:" in line:
|
||||
# +HTTPACTION: <method>,<status_code>,<data_len>
|
||||
parts = line.split(":", 1)[1].strip().split(",")
|
||||
if len(parts) >= 2:
|
||||
status_code = int(parts[1])
|
||||
break
|
||||
|
||||
read_resp = _at(ser, "AT+HTTPREAD", terminators=("OK", "ERROR"), timeout=15)
|
||||
body = read_resp
|
||||
|
||||
return status_code, body
|
||||
finally:
|
||||
try:
|
||||
_at(ser, "AT+HTTPTERM", timeout=5)
|
||||
except CellularError:
|
||||
pass
|
||||
|
||||
|
||||
def http_post_json(url, body_bytes, extra_headers, timeout=HTTP_ACTION_TIMEOUT):
|
||||
return http_post(
|
||||
url,
|
||||
body_bytes,
|
||||
extra_headers,
|
||||
content_type="application/json",
|
||||
timeout=timeout,
|
||||
)
|
||||
+29
-602
@@ -1,611 +1,38 @@
|
||||
"""Dashcam USB retrieval.
|
||||
|
||||
Detects a dashcam plugged in as a USB mass-storage device (no hardcoded
|
||||
/dev/sda, no brand-specific assumptions), mounts it read-only, copies any
|
||||
video files it finds (any folder layout - DCIM/, MOVIE/, NORM/EVENT/PARKING/,
|
||||
etc. - all common dashcam brands are covered since we just walk the whole
|
||||
filesystem for video extensions instead of assuming a specific folder name),
|
||||
then unmounts it again.
|
||||
|
||||
Detection is done via `lsblk`, filtering for volumes whose parent disk is
|
||||
attached over USB (TRAN == "usb"). This is what actually distinguishes a
|
||||
plugged-in dashcam/USB stick from the Pi's own storage (the boot/root
|
||||
partitions are on the onboard SD card, transport "mmc", never "usb") - so
|
||||
it's safe to run against real hardware without risk of touching the Pi's own
|
||||
filesystem, regardless of which /dev/sdX letter the kernel happens to assign.
|
||||
Both real partitions and whole-disk "superfloppy" (no partition table)
|
||||
layouts are matched - some dashcam SD cards ship factory-formatted that way.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from tqdm import tqdm
|
||||
|
||||
VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".ts", ".mkv"}
|
||||
def mount_device(device, mount_point):
|
||||
os.system(f"sudo mount {device} {mount_point}")
|
||||
|
||||
# Filesystem types dashcams commonly ship formatted as (SD cards are almost
|
||||
# always FAT32/exFAT; a few older units use NTFS).
|
||||
DASHCAM_FS_TYPES = {"vfat", "exfat", "ntfs", "ntfs3"}
|
||||
def unmount_device(mount_point):
|
||||
os.system(f"sudo umount {mount_point}")
|
||||
|
||||
DEFAULT_MOUNT_POINT = "/mnt/dashcam"
|
||||
DEFAULT_DESTINATION = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dash", "videos"
|
||||
)
|
||||
_device_lock = threading.Lock()
|
||||
_local_video_state_lock = threading.Lock()
|
||||
_local_videos_in_use = {}
|
||||
_local_videos_deleting = set()
|
||||
def copy_files(source_folder, destination_folder):
|
||||
if not os.path.exists(destination_folder):
|
||||
os.makedirs(destination_folder)
|
||||
|
||||
# --- Async import job state ---
|
||||
_import_lock = threading.Lock()
|
||||
_import_job = None # dict: {status, found, copied, skipped, errors, message} or None
|
||||
|
||||
|
||||
def begin_local_video_use(relative_path):
|
||||
with _local_video_state_lock:
|
||||
if relative_path in _local_videos_deleting:
|
||||
return False
|
||||
_local_videos_in_use[relative_path] = (
|
||||
_local_videos_in_use.get(relative_path, 0) + 1
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def end_local_video_use(relative_path):
|
||||
with _local_video_state_lock:
|
||||
uses = _local_videos_in_use.get(relative_path, 0)
|
||||
if uses <= 1:
|
||||
_local_videos_in_use.pop(relative_path, None)
|
||||
else:
|
||||
_local_videos_in_use[relative_path] = uses - 1
|
||||
|
||||
|
||||
def begin_local_video_delete(relative_path):
|
||||
with _local_video_state_lock:
|
||||
if (
|
||||
relative_path in _local_videos_in_use
|
||||
or relative_path in _local_videos_deleting
|
||||
):
|
||||
return False
|
||||
_local_videos_deleting.add(relative_path)
|
||||
return True
|
||||
|
||||
|
||||
def end_local_video_delete(relative_path):
|
||||
with _local_video_state_lock:
|
||||
_local_videos_deleting.discard(relative_path)
|
||||
|
||||
|
||||
def list_usb_storage_partitions():
|
||||
"""Return a list of dicts describing USB-attached storage volumes.
|
||||
|
||||
Each dict has: path, fstype, label, size, mountpoint. Only volumes whose
|
||||
parent disk reports TRAN "usb" are included - this is what makes the
|
||||
detection brand/device-agnostic (no hardcoded /dev/sda) while still
|
||||
being safe (never matches the Pi's own onboard mmc storage).
|
||||
|
||||
Includes both real partitions (type "part", the common case) *and*
|
||||
whole-disk USB devices with an `fstype` set directly on them (type
|
||||
"disk" but no partition table - a "superfloppy" layout with no MBR/GPT).
|
||||
Several real dashcam SD cards ship formatted this way straight from the
|
||||
factory - lsblk reports them as a bare "disk" with an fstype, never as a
|
||||
child "part", so excluding them would silently miss those cameras.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lsblk", "-J", "-o", "NAME,PATH,TRAN,FSTYPE,LABEL,SIZE,MOUNTPOINT,TYPE"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
print("Failed to run lsblk:", e)
|
||||
return []
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
print("Failed to parse lsblk output:", e)
|
||||
return []
|
||||
|
||||
partitions = []
|
||||
|
||||
def walk(devices, inherited_tran=None):
|
||||
for device in devices:
|
||||
tran = device.get("tran") or inherited_tran
|
||||
children = device.get("children") or []
|
||||
is_partition = device.get("type") == "part"
|
||||
is_superfloppy_disk = (
|
||||
device.get("type") == "disk" and not children and device.get("fstype")
|
||||
)
|
||||
if tran == "usb" and (is_partition or is_superfloppy_disk):
|
||||
partitions.append(
|
||||
{
|
||||
"path": device.get("path"),
|
||||
"fstype": device.get("fstype"),
|
||||
"label": device.get("label"),
|
||||
"size": device.get("size"),
|
||||
"mountpoint": device.get("mountpoint"),
|
||||
}
|
||||
)
|
||||
if children:
|
||||
walk(children, tran)
|
||||
|
||||
walk(data.get("blockdevices", []))
|
||||
return partitions
|
||||
|
||||
|
||||
def find_dashcam_partition():
|
||||
"""Pick the best candidate USB partition to import from, or None.
|
||||
|
||||
Prefers partitions with a recognized dashcam filesystem type; if several
|
||||
are found, the first one is returned (a single dashcam SD card should
|
||||
only expose one data partition).
|
||||
"""
|
||||
partitions = list_usb_storage_partitions()
|
||||
if not partitions:
|
||||
return None
|
||||
|
||||
recognized = [p for p in partitions if p["fstype"] in DASHCAM_FS_TYPES]
|
||||
return (recognized or partitions)[0]
|
||||
|
||||
|
||||
def mount_device(device_path, mount_point=DEFAULT_MOUNT_POINT, read_only=True):
|
||||
"""Mount device_path at mount_point. Returns True on success.
|
||||
|
||||
The app runs as an unprivileged user while /mnt is root-owned, so the
|
||||
mount point directory itself has to be created via sudo too (not just
|
||||
the mount call) - first real-hardware test surfaced this as a plain
|
||||
PermissionError from a bare os.makedirs().
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "mkdir", "-p", mount_point],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to create mount point {mount_point}: {e.stderr.strip()}")
|
||||
return False
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"mount",
|
||||
"-o",
|
||||
"ro" if read_only else "rw",
|
||||
device_path,
|
||||
mount_point,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to mount {device_path}: {e.stderr.strip()}")
|
||||
return False
|
||||
|
||||
|
||||
def remount_device(mount_point=DEFAULT_MOUNT_POINT, read_only=True):
|
||||
"""Remount an existing dashcam mount read-only or read-write."""
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"sudo",
|
||||
"mount",
|
||||
"-o",
|
||||
f"remount,{'ro' if read_only else 'rw'}",
|
||||
mount_point,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
mode = "read-only" if read_only else "read-write"
|
||||
print(f"Failed to remount {mount_point} {mode}: {e.stderr.strip()}")
|
||||
return False
|
||||
|
||||
|
||||
def unmount_device(mount_point=DEFAULT_MOUNT_POINT):
|
||||
"""Unmount mount_point. Returns True on success (or if already unmounted)."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "umount", mount_point],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Not mounted is fine - nothing to clean up.
|
||||
if "not mounted" in e.stderr.lower():
|
||||
return True
|
||||
print(f"Failed to unmount {mount_point}: {e.stderr.strip()}")
|
||||
return False
|
||||
|
||||
|
||||
def find_video_files(source_folder):
|
||||
"""Walk source_folder, returning (absolute_path, relative_path) for every
|
||||
file whose extension looks like a video, excluding in-progress conversion temps."""
|
||||
found = []
|
||||
for root, _dirs, files in os.walk(source_folder):
|
||||
for file in files:
|
||||
if file.endswith(".converting.mp4"):
|
||||
continue
|
||||
if os.path.splitext(file)[1].lower() in VIDEO_EXTENSIONS:
|
||||
abs_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(abs_path, source_folder)
|
||||
found.append((abs_path, rel_path))
|
||||
return found
|
||||
|
||||
|
||||
def _video_metadata(source_folder):
|
||||
files = []
|
||||
errors = []
|
||||
total_size = 0
|
||||
for abs_path, rel_path in find_video_files(source_folder):
|
||||
try:
|
||||
stat = os.stat(abs_path)
|
||||
except OSError as e:
|
||||
errors.append(f"{rel_path}: {e}")
|
||||
continue
|
||||
files.append(
|
||||
{
|
||||
"path": rel_path,
|
||||
"size": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
}
|
||||
)
|
||||
total_size += stat.st_size
|
||||
files.sort(key=lambda item: item["path"].lower())
|
||||
return files, total_size, errors
|
||||
|
||||
|
||||
def _mounted_source(partition, mount_point, read_only):
|
||||
"""Return (source, mounted_here, remounted_existing) for one operation."""
|
||||
existing_mount = partition.get("mountpoint")
|
||||
if existing_mount:
|
||||
if not read_only and not remount_device(existing_mount, read_only=False):
|
||||
return None, False, False
|
||||
return existing_mount, False, not read_only
|
||||
|
||||
if not mount_device(partition["path"], mount_point, read_only=read_only):
|
||||
return None, False, False
|
||||
return mount_point, True, False
|
||||
|
||||
|
||||
def dashcam_storage_usage(mount_point=DEFAULT_MOUNT_POINT):
|
||||
with _device_lock:
|
||||
partition = find_dashcam_partition()
|
||||
if partition is None:
|
||||
return {"status": "no_device"}
|
||||
source, mounted_here, _remounted = _mounted_source(
|
||||
partition, mount_point, read_only=True
|
||||
)
|
||||
if source is None:
|
||||
return {
|
||||
"status": "mount_failed",
|
||||
"path": partition["path"],
|
||||
}
|
||||
try:
|
||||
usage = shutil.disk_usage(source)
|
||||
used_percent = round(
|
||||
usage.used / (usage.used + usage.free) * 100, 1
|
||||
)
|
||||
result = {
|
||||
"status": "ok",
|
||||
"path": partition["path"],
|
||||
"total_bytes": usage.total,
|
||||
"used_bytes": usage.used,
|
||||
"free_bytes": usage.free,
|
||||
"used_percent": used_percent,
|
||||
}
|
||||
except OSError as error:
|
||||
result = {
|
||||
"status": "read_failed",
|
||||
"path": partition["path"],
|
||||
"error": str(error),
|
||||
}
|
||||
finally:
|
||||
if mounted_here and not unmount_device(mount_point):
|
||||
result = {
|
||||
"status": "unmount_failed",
|
||||
"path": partition["path"],
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def list_dashcam_files(mount_point=DEFAULT_MOUNT_POINT):
|
||||
"""List video files on the detected dashcam without copying them."""
|
||||
with _device_lock:
|
||||
partition = find_dashcam_partition()
|
||||
if partition is None:
|
||||
return {
|
||||
"status": "no_device",
|
||||
"message": "No USB dashcam/storage device detected.",
|
||||
"files": [],
|
||||
"total_size": 0,
|
||||
}
|
||||
|
||||
source, mounted_here, _remounted = _mounted_source(
|
||||
partition, mount_point, read_only=True
|
||||
)
|
||||
if source is None:
|
||||
return {
|
||||
"status": "mount_failed",
|
||||
"device": partition["path"],
|
||||
"message": f"Failed to mount {partition['path']} read-only.",
|
||||
"files": [],
|
||||
"total_size": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
files, total_size, errors = _video_metadata(source)
|
||||
return {
|
||||
"status": "ok",
|
||||
"device": partition,
|
||||
"files": files,
|
||||
"count": len(files),
|
||||
"total_size": total_size,
|
||||
"errors": errors,
|
||||
}
|
||||
finally:
|
||||
if mounted_here:
|
||||
unmount_device(mount_point)
|
||||
|
||||
|
||||
def safe_video_path(source_folder, relative_path):
|
||||
if not isinstance(relative_path, str) or not relative_path.strip():
|
||||
raise ValueError("A non-empty relative video path is required.")
|
||||
if os.path.isabs(relative_path):
|
||||
raise ValueError("Video path must be relative to the dashcam.")
|
||||
if os.path.splitext(relative_path)[1].lower() not in VIDEO_EXTENSIONS:
|
||||
raise ValueError("Only recognized video files can be deleted.")
|
||||
|
||||
source_real = os.path.realpath(source_folder)
|
||||
target_real = os.path.realpath(os.path.join(source_real, relative_path))
|
||||
if os.path.commonpath([source_real, target_real]) != source_real:
|
||||
raise ValueError("Video path escapes the dashcam mount.")
|
||||
if not os.path.isfile(target_real):
|
||||
raise FileNotFoundError(relative_path)
|
||||
return target_real
|
||||
|
||||
|
||||
def _remove_video_file(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
return
|
||||
except PermissionError:
|
||||
pass
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["sudo", "rm", "--", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise OSError(e.stderr.strip() or f"Failed to delete {path}") from e
|
||||
|
||||
|
||||
def delete_dashcam_files(relative_path=None, delete_all=False, mount_point=DEFAULT_MOUNT_POINT):
|
||||
"""Delete one or all dashcam videos, restoring read-only state afterward."""
|
||||
if not delete_all and relative_path is None:
|
||||
return {"status": "invalid_path", "message": "A video path is required."}
|
||||
|
||||
with _device_lock:
|
||||
partition = find_dashcam_partition()
|
||||
if partition is None:
|
||||
return {"status": "no_device", "message": "No USB dashcam/storage device detected."}
|
||||
|
||||
source, mounted_here, remounted_existing = _mounted_source(
|
||||
partition, mount_point, read_only=False
|
||||
)
|
||||
if source is None:
|
||||
return {
|
||||
"status": "mount_failed",
|
||||
"device": partition["path"],
|
||||
"message": f"Failed to mount {partition['path']} read-write.",
|
||||
}
|
||||
|
||||
result = None
|
||||
cleanup_ok = True
|
||||
try:
|
||||
if delete_all:
|
||||
targets = find_video_files(source)
|
||||
else:
|
||||
try:
|
||||
target = safe_video_path(source, relative_path)
|
||||
except ValueError as e:
|
||||
result = {"status": "invalid_path", "message": str(e)}
|
||||
except FileNotFoundError:
|
||||
result = {
|
||||
"status": "not_found",
|
||||
"message": f"Video not found: {relative_path}",
|
||||
}
|
||||
else:
|
||||
targets = [(target, relative_path)]
|
||||
|
||||
if result is None:
|
||||
deleted = []
|
||||
errors = []
|
||||
for _abs_path, rel_path in targets:
|
||||
try:
|
||||
safe_path = safe_video_path(source, rel_path)
|
||||
_remove_video_file(safe_path)
|
||||
deleted.append(rel_path)
|
||||
except (OSError, ValueError) as e:
|
||||
errors.append(f"{rel_path}: {e}")
|
||||
|
||||
result = {
|
||||
"status": "ok" if not errors else "partial",
|
||||
"device": partition["path"],
|
||||
"deleted": deleted,
|
||||
"deleted_count": len(deleted),
|
||||
"errors": errors,
|
||||
}
|
||||
finally:
|
||||
if mounted_here:
|
||||
cleanup_ok = unmount_device(mount_point)
|
||||
if not cleanup_ok:
|
||||
cleanup_ok = remount_device(mount_point, read_only=True)
|
||||
elif remounted_existing:
|
||||
cleanup_ok = remount_device(source, read_only=True)
|
||||
|
||||
if not cleanup_ok:
|
||||
return {
|
||||
"status": "cleanup_failed",
|
||||
"message": "Files were processed, but the dashcam could not be returned to read-only state.",
|
||||
"operation": result,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def copy_files(source_folder, destination_folder, relative_paths=None):
|
||||
"""Copy selected videos, or every video when relative_paths is omitted.
|
||||
|
||||
Files already present at the destination (same relative path, same size)
|
||||
are skipped so re-running an import doesn't re-copy everything.
|
||||
|
||||
Returns a summary dict: {found, copied, skipped, errors}.
|
||||
"""
|
||||
os.makedirs(destination_folder, exist_ok=True)
|
||||
errors = []
|
||||
|
||||
if relative_paths is None:
|
||||
videos = find_video_files(source_folder)
|
||||
else:
|
||||
if not isinstance(relative_paths, list) or not relative_paths:
|
||||
return {
|
||||
"found": 0,
|
||||
"copied": 0,
|
||||
"skipped": 0,
|
||||
"errors": ["Select at least one video to import."],
|
||||
"invalid_selection": True,
|
||||
}
|
||||
|
||||
videos = []
|
||||
seen = set()
|
||||
for relative_path in relative_paths:
|
||||
if relative_path in seen:
|
||||
continue
|
||||
seen.add(relative_path)
|
||||
try:
|
||||
abs_path = safe_video_path(source_folder, relative_path)
|
||||
except ValueError as e:
|
||||
return {
|
||||
"found": 0,
|
||||
"copied": 0,
|
||||
"skipped": 0,
|
||||
"errors": [str(e)],
|
||||
"invalid_selection": True,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
errors.append(f"Video not found: {relative_path}")
|
||||
continue
|
||||
videos.append((abs_path, relative_path))
|
||||
|
||||
copied = 0
|
||||
skipped = 0
|
||||
|
||||
for abs_path, rel_path in videos:
|
||||
dest_path = os.path.join(destination_folder, rel_path)
|
||||
|
||||
if os.path.exists(dest_path) and os.path.getsize(dest_path) == os.path.getsize(abs_path):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(abs_path, dest_path)
|
||||
copied += 1
|
||||
# Queue background conversion if enabled (import app_video lazily to avoid circular import)
|
||||
from modules import app_video as _app_video
|
||||
_app_video.queue_convert(rel_path)
|
||||
except OSError as e:
|
||||
errors.append(f"{rel_path}: {e}")
|
||||
|
||||
return {"found": len(videos), "copied": copied, "skipped": skipped, "errors": errors}
|
||||
|
||||
|
||||
def import_dashcam(
|
||||
destination_folder=DEFAULT_DESTINATION,
|
||||
mount_point=DEFAULT_MOUNT_POINT,
|
||||
relative_paths=None,
|
||||
):
|
||||
"""Full detect -> mount -> copy -> unmount pipeline.
|
||||
|
||||
Returns a summary dict describing what happened, always unmounting the
|
||||
device again afterwards (even if copying fails partway through).
|
||||
"""
|
||||
with _device_lock:
|
||||
partition = find_dashcam_partition()
|
||||
if partition is None:
|
||||
return {"status": "no_device", "message": "No USB dashcam/storage device detected."}
|
||||
|
||||
device_path = partition["path"]
|
||||
source, mounted_here, _remounted = _mounted_source(
|
||||
partition, mount_point, read_only=True
|
||||
)
|
||||
if source is None:
|
||||
return {
|
||||
"status": "mount_failed",
|
||||
"device": device_path,
|
||||
"message": f"Failed to mount {device_path}.",
|
||||
}
|
||||
|
||||
# Lock released — copy runs without blocking other requests
|
||||
try:
|
||||
summary = copy_files(source, destination_folder, relative_paths)
|
||||
summary["status"] = (
|
||||
"invalid_selection" if summary.pop("invalid_selection", False) else "ok"
|
||||
)
|
||||
if summary["status"] == "invalid_selection":
|
||||
summary["message"] = summary["errors"][0]
|
||||
summary["device"] = device_path
|
||||
return summary
|
||||
finally:
|
||||
if mounted_here:
|
||||
with _device_lock:
|
||||
unmount_device(mount_point)
|
||||
|
||||
|
||||
def start_import_async(relative_paths=None):
|
||||
"""Start a background import thread. Returns (True, None) if started,
|
||||
or (False, message) if an import is already running."""
|
||||
global _import_job
|
||||
with _import_lock:
|
||||
if _import_job and _import_job.get("status") == "running":
|
||||
return False, "An import is already in progress."
|
||||
_import_job = {
|
||||
"status": "running",
|
||||
"found": 0, "copied": 0, "skipped": 0,
|
||||
"errors": [], "message": "",
|
||||
}
|
||||
|
||||
def _run():
|
||||
global _import_job
|
||||
result = import_dashcam(relative_paths=relative_paths)
|
||||
with _import_lock:
|
||||
_import_job = result
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
return True, None
|
||||
|
||||
|
||||
def get_import_status():
|
||||
"""Return the current import job state dict, or None if no job has been run."""
|
||||
with _import_lock:
|
||||
return dict(_import_job) if _import_job else None
|
||||
for root, dirs, files in os.walk(source_folder):
|
||||
for file in tqdm(files, desc="Copying files", unit="file"):
|
||||
source_path = os.path.join(root, file)
|
||||
destination_path = os.path.join(destination_folder, file)
|
||||
|
||||
# Check if the file already exists in the destination folder
|
||||
if not os.path.exists(destination_path):
|
||||
shutil.copy2(source_path, destination_folder)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(import_dashcam())
|
||||
source_device = "/dev/sda"
|
||||
mount_point = "/mnt/dash"
|
||||
destination_folder = "~/yaris-obd/dash/videos"
|
||||
|
||||
try:
|
||||
# Mount the device
|
||||
mount_device(source_device, mount_point)
|
||||
|
||||
# Copy files from source to destination
|
||||
copy_files(os.path.join(mount_point, "DCIM"), os.path.expanduser(destination_folder))
|
||||
|
||||
finally:
|
||||
# Unmount the device (even if an exception occurs)
|
||||
unmount_device(mount_point)
|
||||
+4
-12
@@ -2,27 +2,19 @@ from pymongo import MongoClient
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# Load creds.env from the repo's db/ folder regardless of the process's cwd.
|
||||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
load_dotenv(os.path.join(_BASE_DIR, "db", "creds.env"))
|
||||
load_dotenv("../db/creds.env")
|
||||
|
||||
mUser = os.getenv("mangosrv_user")
|
||||
mPass = os.getenv("mangosrv_pass")
|
||||
mDB = os.getenv("mangosrv_db")
|
||||
mUser = os.getenv()
|
||||
mPass = os.getenv()
|
||||
mDB = os.getenv()
|
||||
|
||||
def get_database():
|
||||
if not mUser or not mPass or not mDB:
|
||||
raise RuntimeError(
|
||||
"MongoDB credentials are missing. Set mangosrv_user, mangosrv_pass, "
|
||||
"and mangosrv_db in db/creds.env."
|
||||
)
|
||||
|
||||
# Provide the mongodb atlas url to connect python to mongodb using pymongo
|
||||
CONNECTION_STRING = "mongodb+srv://"+ mUser + ":" + mPass + "@cluster.mongodb.net/" + mDB
|
||||
|
||||
# Create a connection using MongoClient. You can import MongoClient or use pymongo.MongoClient
|
||||
client = MongoClient(CONNECTION_STRING)
|
||||
return client[mDB]
|
||||
|
||||
# This is added so that many files can reuse the function get_database()
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""
|
||||
Device configuration management (owner name, car model, avatar, sync status).
|
||||
Stores in SQLite with option to sync from server (Phase 25.5a).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from . import app_sql
|
||||
|
||||
|
||||
def get_device_config():
|
||||
"""Get current device config including sync status."""
|
||||
sync_enabled = _get_sync_enabled()
|
||||
config = {
|
||||
"owner_name": app_sql.get_setting("device_owner_name", ""),
|
||||
"car_model": app_sql.get_setting("device_car_model", ""),
|
||||
"device_name": app_sql.get_setting("device_device_name", ""),
|
||||
"avatar_path": app_sql.get_setting("device_avatar_path", ""),
|
||||
"odometer_baseline": float(app_sql.get_setting("odometer_baseline", "0") or "0"),
|
||||
"sync_enabled": sync_enabled,
|
||||
}
|
||||
# Fill in any missing values from config file or defaults
|
||||
config = _apply_defaults(config)
|
||||
return config
|
||||
|
||||
|
||||
def update_device_config(body, errors):
|
||||
"""Update device config from request body. Returns errors dict if invalid."""
|
||||
if not isinstance(body, dict):
|
||||
errors["error"] = "Invalid request body"
|
||||
return errors
|
||||
|
||||
# Check if sync is enabled; if so, only allow sync-related updates
|
||||
sync_enabled = _get_sync_enabled()
|
||||
if sync_enabled:
|
||||
# Allow toggling sync_enabled off
|
||||
if "sync_enabled" in body and not body["sync_enabled"]:
|
||||
_set_sync_enabled(False)
|
||||
# Toggling off is always allowed — don't error on locked fields
|
||||
# (the JS always sends them alongside the toggle).
|
||||
return errors
|
||||
# Sync is staying enabled — locked fields are ignored silently.
|
||||
# The JS always sends them; rejecting would break the save flow.
|
||||
if "sync_enabled" in body and body["sync_enabled"]:
|
||||
return errors
|
||||
# User tried to edit locked fields without the sync toggle — reject.
|
||||
if any(k in body for k in ["owner_name", "car_model", "avatar_path"]):
|
||||
errors["error"] = "Cannot edit device config while server sync is enabled. Server is source of truth."
|
||||
return errors
|
||||
|
||||
# Not syncing: allow all edits
|
||||
if "owner_name" in body:
|
||||
value = str(body["owner_name"]).strip()
|
||||
if not value or len(value) > 100:
|
||||
errors["owner_name"] = "Owner name must be 1-100 characters."
|
||||
else:
|
||||
app_sql.set_setting("device_owner_name", value)
|
||||
|
||||
if "car_model" in body:
|
||||
value = str(body["car_model"]).strip()
|
||||
if not value or len(value) > 100:
|
||||
errors["car_model"] = "Car model must be 1-100 characters."
|
||||
else:
|
||||
app_sql.set_setting("device_car_model", value)
|
||||
|
||||
if "device_name" in body:
|
||||
value = str(body["device_name"]).strip()
|
||||
if not value or len(value) > 100:
|
||||
errors["device_name"] = "Device name must be 1-100 characters."
|
||||
else:
|
||||
app_sql.set_setting("device_device_name", value)
|
||||
|
||||
if "avatar_path" in body:
|
||||
value = str(body["avatar_path"]).strip()
|
||||
if not value or len(value) > 255:
|
||||
errors["avatar_path"] = "Avatar path must be 1-255 characters."
|
||||
else:
|
||||
app_sql.set_setting("device_avatar_path", value)
|
||||
|
||||
if "sync_enabled" in body:
|
||||
if not isinstance(body["sync_enabled"], bool):
|
||||
errors["sync_enabled"] = "Must be true or false."
|
||||
elif body["sync_enabled"]:
|
||||
from . import app_remote_config
|
||||
|
||||
remote = app_remote_config.get_remote_config()
|
||||
if not remote["server_url"] or not remote["device_name"] or not remote["api_key_configured"]:
|
||||
errors["sync_enabled"] = "Configure server URL, device name, and API key before enabling server sync."
|
||||
else:
|
||||
_set_sync_enabled(body["sync_enabled"])
|
||||
|
||||
if "sync_enabled" in body and body["sync_enabled"] and "sync_enabled" not in errors:
|
||||
_set_sync_enabled(True)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _apply_defaults(config):
|
||||
"""Fill in any empty config values from config/device.json or hardcoded defaults."""
|
||||
config_file_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(__file__)), "config", "device.json"
|
||||
)
|
||||
file_config = {}
|
||||
try:
|
||||
if os.path.exists(config_file_path):
|
||||
with open(config_file_path, "r") as f:
|
||||
file_config = json.load(f)
|
||||
except (IOError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
# Use file config as source of truth for initial values, fallback to hardcoded
|
||||
defaults = {
|
||||
"owner_name": "quoije",
|
||||
"car_model": "Yaris 2013",
|
||||
"device_name": "Yaris OBD",
|
||||
"avatar_path": "img/yaris.png",
|
||||
}
|
||||
for key in defaults:
|
||||
if not config[key]:
|
||||
config[key] = file_config.get(key, defaults[key])
|
||||
return config
|
||||
|
||||
|
||||
def _get_sync_enabled():
|
||||
"""Check if device config sync from server is enabled."""
|
||||
return app_sql.get_setting("device_sync_enabled", "0") == "1"
|
||||
|
||||
|
||||
def _set_sync_enabled(enabled):
|
||||
"""Set device config sync status."""
|
||||
app_sql.set_setting("device_sync_enabled", "1" if enabled else "0")
|
||||
@@ -1,160 +0,0 @@
|
||||
"""Device configuration sync from remote server (Phase 25.5a).
|
||||
|
||||
Pi fetches device config from server when internet is available (connection-driven).
|
||||
Stores in SQLite and hot-reloads app globals if config changes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from . import app_network, app_remote_config, app_sql
|
||||
|
||||
|
||||
_SYNC_THREAD = None
|
||||
_SYNC_STOP = False
|
||||
_SYNC_CONFIG_VERSION = 0 # Track last-synced version to detect changes
|
||||
_RELOAD_CALLBACK = None
|
||||
|
||||
|
||||
def _has_internet():
|
||||
"""Use the existing NetworkManager-derived status instead of probing 8.8.8.8 over HTTP."""
|
||||
try:
|
||||
return app_network.get_status().get("internet_reachable", False)
|
||||
except (
|
||||
OSError,
|
||||
subprocess.SubprocessError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
app_network.NetworkConfigurationError,
|
||||
):
|
||||
return False
|
||||
|
||||
|
||||
def _server_credentials():
|
||||
credentials = app_remote_config.server_credentials()
|
||||
device_key = os.getenv("YARIS_DEVICE_KEY") or credentials["api_key"]
|
||||
return credentials["server_url"], credentials["device_name"], device_key
|
||||
|
||||
|
||||
def start_sync():
|
||||
"""Start background device config sync thread."""
|
||||
global _SYNC_THREAD, _SYNC_STOP
|
||||
if _SYNC_THREAD is not None and _SYNC_THREAD.is_alive():
|
||||
return
|
||||
|
||||
_SYNC_STOP = False
|
||||
_SYNC_THREAD = threading.Thread(target=_sync_loop, daemon=True)
|
||||
_SYNC_THREAD.start()
|
||||
|
||||
|
||||
def register_reload_callback(callback):
|
||||
"""Register a callback invoked after synced config is written locally."""
|
||||
global _RELOAD_CALLBACK
|
||||
_RELOAD_CALLBACK = callback
|
||||
|
||||
|
||||
def stop_sync():
|
||||
"""Stop background device config sync thread."""
|
||||
global _SYNC_STOP
|
||||
_SYNC_STOP = True
|
||||
|
||||
|
||||
def _sync_loop():
|
||||
"""Periodically sync device config from server when internet available."""
|
||||
sync_interval = 300
|
||||
offline_interval = 60
|
||||
|
||||
while not _SYNC_STOP:
|
||||
try:
|
||||
result = sync_now()
|
||||
if result["status"] in {"synced", "no_change"}:
|
||||
time.sleep(sync_interval)
|
||||
else:
|
||||
time.sleep(offline_interval)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Device config sync error: {e}")
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
def sync_now():
|
||||
"""Perform one immediate sync attempt."""
|
||||
global _SYNC_CONFIG_VERSION
|
||||
|
||||
server_url, device_name, device_key = _server_credentials()
|
||||
if not server_url or not device_name or not device_key:
|
||||
return {"status": "not_configured", "message": "Remote server credentials are incomplete."}
|
||||
if not _has_internet():
|
||||
return {"status": "offline", "message": "Internet is not currently reachable."}
|
||||
|
||||
response = requests.get(
|
||||
f"{server_url}/api/device-config",
|
||||
headers={
|
||||
"X-Device-Name": device_name,
|
||||
"X-Api-Key": device_key,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
new_version = config.get("config_version", 0)
|
||||
|
||||
if new_version != _SYNC_CONFIG_VERSION:
|
||||
_SYNC_CONFIG_VERSION = new_version
|
||||
_store_synced_config(config)
|
||||
_acknowledge_sync(server_url, device_name, device_key, new_version)
|
||||
_on_config_changed()
|
||||
return {"status": "synced", "message": f"Synced config version {new_version}."}
|
||||
|
||||
_acknowledge_sync(server_url, device_name, device_key, new_version)
|
||||
return {"status": "no_change", "message": "Already up to date."}
|
||||
|
||||
|
||||
def _store_synced_config(config):
|
||||
"""Store synced config in SQLite database."""
|
||||
app_sql.set_setting("device_owner_name", config.get("owner_name", ""))
|
||||
app_sql.set_setting("device_car_model", config.get("car_model", ""))
|
||||
app_sql.set_setting("device_device_name", config.get("device_name", ""))
|
||||
app_sql.set_setting("device_avatar_path", config.get("avatar_path", "img/yaris.png"))
|
||||
app_sql.set_setting("device_config_version", str(config.get("config_version", 0)))
|
||||
app_sql.set_setting("device_config_synced_at", str(int(time.time())))
|
||||
# odometer_baseline is Pi→Server only — never overwrite the Pi's
|
||||
# local value with whatever the server has (which could be stale).
|
||||
|
||||
|
||||
def _on_config_changed():
|
||||
"""Called when config changes and needs to be reloaded by app."""
|
||||
if _RELOAD_CALLBACK is not None:
|
||||
_RELOAD_CALLBACK()
|
||||
|
||||
|
||||
def is_sync_enabled():
|
||||
"""Check if device config sync from server is enabled."""
|
||||
server_url, device_name, device_key = _server_credentials()
|
||||
return bool(server_url) and bool(device_name) and bool(device_key)
|
||||
|
||||
|
||||
def _acknowledge_sync(server_url, device_name, device_key, config_version):
|
||||
requests.post(
|
||||
f"{server_url}/api/v1/device-config/ack",
|
||||
json={"config_version": config_version},
|
||||
headers={
|
||||
"X-Device-Name": device_name,
|
||||
"X-Api-Key": device_key,
|
||||
},
|
||||
timeout=5,
|
||||
).raise_for_status()
|
||||
|
||||
|
||||
def get_synced_config_version():
|
||||
"""Get the last-synced config version number."""
|
||||
return int(app_sql.get_setting("device_config_version", "0"))
|
||||
|
||||
|
||||
def get_synced_at_timestamp():
|
||||
"""Get timestamp of last successful sync."""
|
||||
return app_sql.get_setting("device_config_synced_at", "")
|
||||
+36
-1
@@ -1,10 +1,45 @@
|
||||
from flask_socketio import SocketIO
|
||||
from threading import Thread
|
||||
from modules import app_obd
|
||||
import time, threading
|
||||
|
||||
socketio = SocketIO()
|
||||
uptime_lock = threading.Lock()
|
||||
thread_uptime = None
|
||||
|
||||
@socketio.on('connect')
|
||||
def connect_event():
|
||||
print('Client connected')
|
||||
print('Getting current OBD status...')
|
||||
print('Getting OBD status...')
|
||||
app_obd.obd_connection()
|
||||
uptime()
|
||||
|
||||
def uptime():
|
||||
global thread_uptime
|
||||
|
||||
if thread_uptime is None or not thread_uptime.is_alive():
|
||||
thread_uptime = Thread(target=loop_uptime)
|
||||
thread_uptime.daemon = True
|
||||
thread_uptime.start()
|
||||
print(f"Uptime thread started, alive: {thread_uptime.is_alive()}")
|
||||
else:
|
||||
print(f"Uptime thread already running, alive: {thread_uptime.is_alive()}")
|
||||
|
||||
def loop_uptime():
|
||||
uptime_lock.acquire()
|
||||
try:
|
||||
loop = True
|
||||
while loop == True:
|
||||
boottime = time.clock_gettime(time.CLOCK_BOOTTIME)
|
||||
# Convert to hours, minutes, and seconds
|
||||
seconds = int(boottime)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
time.sleep(1)
|
||||
uptime = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
socketio.emit("rpi_uptime", uptime)
|
||||
finally:
|
||||
uptime_lock.release()
|
||||
|
||||
|
||||
|
||||
|
||||
+13
-203
@@ -1,212 +1,22 @@
|
||||
from modules.app_events import socketio
|
||||
from threading import Thread, Lock
|
||||
import serial
|
||||
import time
|
||||
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
except (ImportError, RuntimeError):
|
||||
# Not running on a Pi (e.g. local dev machine) - PWRKEY auto power-on is
|
||||
# simply unavailable there; _pulse_pwrkey() below degrades gracefully.
|
||||
GPIO = None
|
||||
|
||||
# SIM868 GNSS/GSM/GPRS HAT is wired to the Pi's GPIO UART (jumper set to "B: Pi-SIM868"),
|
||||
# exposed as /dev/serial0 once the Pi's onboard Bluetooth has been disabled/freed.
|
||||
SERIAL_PORT = "/dev/serial0"
|
||||
BAUDRATE = 115200
|
||||
POLL_INTERVAL = 2 # seconds between AT+CGNSINF polls
|
||||
|
||||
# This HAT revision has the "P4" GPIO power-on mod wire connected to the SIM868's
|
||||
# PWRKEY line (confirmed 2026-08-09 by pulsing it and seeing the module respond to
|
||||
# AT afterward) - physical header pin 7 (BCM GPIO4), same pin Waveshare's own
|
||||
# GSM_PWRKEY.py reference script uses. This lets us auto power-on the module chip
|
||||
# on every app start instead of requiring someone to press its PWRKEY button by
|
||||
# hand after every Pi power cycle.
|
||||
PWRKEY_PIN = 7 # BOARD numbering
|
||||
PWRKEY_HOLD_SECONDS = 4 # matches Waveshare's reference script
|
||||
|
||||
LocationX = None
|
||||
LocationY = None
|
||||
last_fix_at = None
|
||||
|
||||
_serial_lock = Lock()
|
||||
_serial = None
|
||||
_gps_thread = None
|
||||
|
||||
from threading import Thread
|
||||
|
||||
class GPS:
|
||||
def __init__(self):
|
||||
self.LocationX = None
|
||||
self.LocationY= None
|
||||
|
||||
|
||||
def _get_serial():
|
||||
global _serial
|
||||
if _serial is None or not _serial.is_open:
|
||||
_serial = serial.Serial(SERIAL_PORT, BAUDRATE, timeout=1)
|
||||
time.sleep(0.3)
|
||||
_serial.reset_input_buffer()
|
||||
return _serial
|
||||
|
||||
|
||||
def _send_at(cmd, wait=0.5):
|
||||
"""Send an AT command to the SIM868 module and return the raw response."""
|
||||
ser = _get_serial()
|
||||
ser.write((cmd + "\r\n").encode())
|
||||
time.sleep(wait)
|
||||
return ser.read(1000).decode(errors="replace")
|
||||
|
||||
|
||||
def _module_responds():
|
||||
"""Check whether the SIM868 module chip is powered on and answering AT commands."""
|
||||
try:
|
||||
return "OK" in _send_at("AT", wait=0.3)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _pulse_pwrkey():
|
||||
"""Pulse the PWRKEY line low then high, same sequence as Waveshare's own
|
||||
GSM_PWRKEY.py reference script.
|
||||
|
||||
PWRKEY is a toggle, not a simple "power on" switch: pressing it while the
|
||||
module is off powers it on, but pressing it again while already on powers
|
||||
it back off. Callers must only invoke this after confirming via
|
||||
`_module_responds()` that the module is NOT currently responding - never
|
||||
unconditionally on every app start, or a normal restart while the module
|
||||
is already up would accidentally shut it back down.
|
||||
"""
|
||||
if GPIO is None:
|
||||
print("RPi.GPIO not available, cannot auto-power-on the SIM868 module - "
|
||||
"press its PWRKEY button by hand.")
|
||||
return
|
||||
GPIO.setmode(GPIO.BOARD)
|
||||
GPIO.setup(PWRKEY_PIN, GPIO.OUT)
|
||||
GPIO.output(PWRKEY_PIN, GPIO.LOW)
|
||||
time.sleep(PWRKEY_HOLD_SECONDS)
|
||||
GPIO.output(PWRKEY_PIN, GPIO.HIGH)
|
||||
GPIO.cleanup(PWRKEY_PIN)
|
||||
|
||||
|
||||
def _ensure_module_powered():
|
||||
"""Power on the SIM868 module chip via its PWRKEY line if it isn't already on.
|
||||
|
||||
The HAT's 5V rail can be live (board powered) while the module chip itself
|
||||
stays fully off until PWRKEY is pulsed - this is what previously required a
|
||||
manual button press after every Pi power cycle. Returns True once the
|
||||
module is confirmed responding to AT commands, False if it still isn't
|
||||
after attempting the power-on pulse (e.g. GPIO unavailable, or this board
|
||||
doesn't have the P4 mod wire).
|
||||
"""
|
||||
if _module_responds():
|
||||
return True
|
||||
|
||||
print("SIM868 module not responding to AT, pulsing PWRKEY to power it on...")
|
||||
_pulse_pwrkey()
|
||||
time.sleep(3) # give the module a few seconds to boot after PWRKEY release
|
||||
|
||||
for _ in range(5):
|
||||
if _module_responds():
|
||||
print("SIM868 module powered on and responding.")
|
||||
return True
|
||||
time.sleep(1)
|
||||
|
||||
print("SIM868 module still not responding after PWRKEY pulse - check the "
|
||||
"P4 wiring or press the physical PWRKEY button.")
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_gnss_powered():
|
||||
resp = _send_at("AT+CGNSPWR?")
|
||||
if "+CGNSPWR: 0" in resp:
|
||||
print("GNSS was off, powering it on...")
|
||||
_send_at("AT+CGNSPWR=1", wait=1)
|
||||
|
||||
|
||||
def _parse_cgnsinf(resp):
|
||||
"""Parse a +CGNSINF response line into a dict, or None if there's no fix yet.
|
||||
|
||||
Format: +CGNSINF: run,fix,utc,lat,lon,alt,speed,course,fixmode,reserved1,
|
||||
hdop,pdop,vdop,reserved2,gps_sat_view,gnss_sat_used,gnss_sat_view,cn0,hpa,vpa
|
||||
"""
|
||||
for line in resp.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("+CGNSINF:"):
|
||||
fields = line.split(":", 1)[1].strip().split(",")
|
||||
if len(fields) < 8 or fields[1] != "1":
|
||||
return None
|
||||
lat, lon = fields[3], fields[4]
|
||||
if not lat or not lon:
|
||||
return None
|
||||
return {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"alt": fields[5],
|
||||
"speed": fields[6],
|
||||
"course": fields[7],
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _poll_loop():
|
||||
global LocationX, LocationY, last_fix_at
|
||||
try:
|
||||
with _serial_lock:
|
||||
if _ensure_module_powered():
|
||||
_ensure_gnss_powered()
|
||||
except Exception as e:
|
||||
print("Failed to initialize GNSS:", str(e))
|
||||
|
||||
while True:
|
||||
try:
|
||||
with _serial_lock:
|
||||
resp = _send_at("AT+CGNSINF")
|
||||
fix = _parse_cgnsinf(resp)
|
||||
except Exception as e:
|
||||
print("GPS read error:", str(e))
|
||||
fix = None
|
||||
|
||||
if fix:
|
||||
LocationX = fix["lat"]
|
||||
LocationY = fix["lon"]
|
||||
last_fix_at = time.monotonic()
|
||||
socketio.emit("gps_location", [LocationX, LocationY])
|
||||
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
def get_fix_age():
|
||||
return None if last_fix_at is None else time.monotonic() - last_fix_at
|
||||
|
||||
|
||||
def get_status():
|
||||
"""Snapshot of the current GPS/GNSS state for the local dashboard."""
|
||||
return {
|
||||
"thread_alive": _gps_thread.is_alive() if _gps_thread is not None else False,
|
||||
"has_fix": LocationX is not None and LocationY is not None and last_fix_at is not None,
|
||||
"latitude": LocationX,
|
||||
"longitude": LocationY,
|
||||
"fix_age_seconds": get_fix_age(),
|
||||
}
|
||||
|
||||
|
||||
def start_gps():
|
||||
"""Start the background thread that continuously polls the SIM868 for a GNSS fix."""
|
||||
global _gps_thread
|
||||
if _gps_thread is None or not _gps_thread.is_alive():
|
||||
_gps_thread = Thread(target=_poll_loop)
|
||||
_gps_thread.daemon = True
|
||||
_gps_thread.start()
|
||||
print(f"GPS thread started, alive: {_gps_thread.is_alive()}")
|
||||
else:
|
||||
print(f"GPS thread already running, alive: {_gps_thread.is_alive()}")
|
||||
return GPS()
|
||||
|
||||
|
||||
@socketio.on("gps")
|
||||
@socketio.on('gps')
|
||||
def get_gps():
|
||||
"""Return the last known GPS location and emit it over the 'gps_location' event."""
|
||||
global LocationX
|
||||
global LocationY
|
||||
|
||||
LocationX = "45.5202844"
|
||||
LocationY = "-73.5493208"
|
||||
Location = [LocationX, LocationY]
|
||||
socketio.emit("gps_location", Location)
|
||||
print("gps_location emitted:", Location)
|
||||
return Location
|
||||
|
||||
socketio.emit('gps_location', Location)
|
||||
|
||||
print("websocket should be sent now")
|
||||
|
||||
return print("socket.io emitted")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user