Compare commits
3
Commits
car-2.38.0
...
car-2.39.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9c7c5ea9a | ||
|
|
65dc1ba847 | ||
|
|
776a11b182 |
@@ -20,7 +20,8 @@ MG4 head unit (Android 9, AAOS, MT2712)
|
|||||||
server and to ABRP, and updates itself.
|
server and to ABRP, and updates itself.
|
||||||
- **server/** — Go 1.27 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
|
- **server/** — Go 1.27 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
|
||||||
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
|
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
|
||||||
app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`.
|
app releases from the private forge, and serves the roads for the Grid's map from
|
||||||
|
OpenStreetMap (Overpass). It does **not** talk to ABRP. See `server/README.md`.
|
||||||
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
|
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
|
||||||
dashboards, Traefik routes). See `k8s/README.md`.
|
dashboards, Traefik routes). See `k8s/README.md`.
|
||||||
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
|
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
|
||||||
@@ -75,11 +76,11 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
|
|||||||
|
|
||||||
| Tab | Contents |
|
| Tab | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
|
||||||
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
||||||
| Live data | on/off, ABRP API key and user token, upload frequency |
|
| Live data | on/off, ABRP API key and user token, upload frequency |
|
||||||
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
|
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
|
||||||
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
|
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
|
||||||
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
|
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
|
||||||
|
|
||||||
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ Nils' MG4 is a fork of a chain of forks, all MIT:
|
|||||||
MIT requires the copyright notice to travel with any copy or substantial portion of the
|
MIT requires the copyright notice to travel with any copy or substantial portion of the
|
||||||
software. `LICENSE` names all of them; keep every line.
|
software. `LICENSE` names all of them; keep every line.
|
||||||
|
|
||||||
|
## Map data
|
||||||
|
|
||||||
|
The roads of the "Grid with map" style are © OpenStreetMap contributors, under the
|
||||||
|
[Open Database Licence](https://opendatacommons.org/licenses/odbl/); the style shows that line
|
||||||
|
whenever it draws them. The server fetches them from an Overpass API (`server/maps.go`).
|
||||||
|
|
||||||
## The EVHardware library
|
## The EVHardware library
|
||||||
|
|
||||||
`EVHardware/` (the shared vehicle layer from EVSuite) is source-available under the
|
`EVHardware/` (the shared vehicle layer from EVSuite) is source-available under the
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ APIs.
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
**Overview styles:** the Overview comes in seventeen looks, from an 80s fluorescent display to a
|
**Overview styles:** the Overview comes in eighteen looks, from an 80s fluorescent display to a
|
||||||
starship console, a watch face, a neon grid, a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the
|
starship console, a watch face, a neon grid (also over a map of the roads round the car), a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the
|
||||||
navigation, and what inspired it.
|
navigation, and what inspired it.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -81,14 +81,15 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
|
|||||||
```
|
```
|
||||||
|
|
||||||
The car sends live data to ABRP itself, with the ABRP credentials set on the Live data tab, so
|
The car sends live data to ABRP itself, with the ABRP credentials set on the Live data tab, so
|
||||||
ABRP and the server do not depend on each other. The server only stores. The car authenticates
|
ABRP and the server do not depend on each other. The server stores, relays the app's updates
|
||||||
|
from the forge and serves the roads for the Grid's map (from OpenStreetMap). The car authenticates
|
||||||
with the server using a token of its own, obtained by pairing with a phone (below).
|
with the server using a token of its own, obtained by pairing with a phone (below).
|
||||||
|
|
||||||
## The app
|
## The app
|
||||||
|
|
||||||
| Tab | What it shows |
|
| Tab | What it shows |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, lease allowance, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro, Futuristic, Sci-fi, Cartoon, MG, Minimal, Terminal, Nixie tubes, Chronograph, Grid, Time circuits, Quest, Visor and Empire ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, and Retro, Terminal, Grid and Visor with "Original") colours the whole app to match, e.g. turquoise for Retro. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
|
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, lease allowance, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro, Futuristic, Sci-fi, Cartoon, MG, Minimal, Terminal, Nixie tubes, Chronograph, Grid, Grid with map, Time circuits, Quest, Visor and Empire ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, and Retro, Terminal, Grid and Visor with "Original"). Grid with map lays the roads round the car on the Grid's floor: the app asks the car's server for the map tile it is in (not its position), the server fetches the roads from OpenStreetMap's Overpass API and keeps them, and the GPS runs for it only while that style is on screen colours the whole app to match, e.g. turquoise for Retro. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
|
||||||
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
|
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
|
||||||
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
|
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
|
||||||
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
|
|||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
Current: app 2.35.3 (installed on the car over the air), server 2.6.2 (pinned by digest in
|
Current: app 2.38.0 (installed on the car over the air, 2026-10-26), server 2.6.2 (pinned by digest in
|
||||||
`k8s/server.yaml`; applied and running since 2026-10-25).
|
`k8s/server.yaml`; applied and running since 2026-10-25).
|
||||||
|
|
||||||
- [x] On the car: QR pairing, values on the Overview, voice-key double press, full screen
|
- [x] On the car: QR pairing, values on the Overview, voice-key double press, full screen
|
||||||
@@ -138,6 +138,36 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
|||||||
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
||||||
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
||||||
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
||||||
|
- 2026-10-26 — Server 2.7.0 image pushed (`git.stinnesbeck.com/nils/mg4-server:2.7.0`, Go 1.27.3,
|
||||||
|
govulncheck clean) and pinned in `k8s/server.yaml` by digest
|
||||||
|
`sha256:e5da9452a9dbdfabe3340766cc51f18bb1418b2588ab534714550470abe1ce29` (checked with skopeo);
|
||||||
|
manifests valid (kubeconform). Not applied yet: the owner applies it.
|
||||||
|
- 2026-10-26 — App 2.39.0 and server 2.7.0: **Grid with map**, a new Overview style — the Grid
|
||||||
|
over the roads round the car from OpenStreetMap (owner's choice of design: roads through the
|
||||||
|
car's own server, drawn as the floor). Server: `POST /v1/map/roads` `{z:14, x, y}` (the tile,
|
||||||
|
not the position, and in the body so it stays out of access logs) fetches the tile's drivable
|
||||||
|
roads from Overpass (`OVERPASS_URL`, default the public instance, `off` disables; https only;
|
||||||
|
one request at a time, ≥ 1 s apart, identifying User-Agent), turns them into tile-local points
|
||||||
|
in 4 classes simplified with Douglas–Peucker (~1.5 m), caches them in memory (30 days, 3000
|
||||||
|
tiles; failures not cached), gzips when accepted, answers 503 when Overpass fails and never logs
|
||||||
|
the tile. Tests with a fake Overpass (classes, split at missing points, simplification, cache,
|
||||||
|
gzip, validation, 503 without caching, no tile in the log, config). App: `MapRoads` keeps the
|
||||||
|
3×3 tiles round the car (files/map, 30 days, 400 tiles; one at a time on a background thread;
|
||||||
|
a failed tile waits a minute), `RoadTile` parses them failing closed, `MapGeometry` does the
|
||||||
|
web-mercator arithmetic (both tested). GPS only while that style is shown and the app is in
|
||||||
|
front; the bearing from the GPS, or worked out from positions 8 m apart when it gives none.
|
||||||
|
`GridView` with a map draws the roads in the floor's perspective, heading up, 40 m floor steps,
|
||||||
|
up to 900 m ahead, far roads fainter, the car as a chevron, its position run on with the speed
|
||||||
|
between fixes, 20 fps while moving; the trail gives way to the map; "© OpenStreetMap
|
||||||
|
contributors" shown; otherwise the plain floor saying why (waiting for GPS, set up the server,
|
||||||
|
loading, not available). `OVERPASS_URL` in `k8s/server.yaml`; LICENSE.md notes the ODbL. Tested:
|
||||||
|
server unit tests; one real fetch of 3×3 tiles in central Berlin through the server code
|
||||||
|
(20–30 KB each; Overpass answered 502 a few times and the retry got them); on the emulator those
|
||||||
|
tiles from the app's cache with emulator GPS, north up and turning with a simulated drive, both
|
||||||
|
layouts, no GPS, no server; no crash. Not tried: the app fetching tiles from the deployed
|
||||||
|
server (not deployed yet) and anything on the car.
|
||||||
|
- 2026-10-26 — App 2.38.0 published (`car-2.38.0`) and installed on the car through the in-app
|
||||||
|
updater, from 2.35.3 (so Grid, Time circuits, Quest, Visor and Empire are on the car).
|
||||||
- 2026-10-26 — App 2.38.0: Overview styles **Visor** (after Halo's helmet display, `VisorView`)
|
- 2026-10-26 — App 2.38.0: Overview styles **Visor** (after Halo's helmet display, `VisorView`)
|
||||||
and **Empire** (after Age of Empires' interface, `EmpireView`). Visor: shield bar = battery (24
|
and **Empire** (after Age of Empires' interface, `EmpireView`). Visor: shield bar = battery (24
|
||||||
segments, red and blinking "BATTERY LOW" below 20 %), ammunition counter = range (number and
|
segments, red and blinking "BATTERY LOW" below 20 %), ammunition counter = range (number and
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Overview styles
|
# Overview styles
|
||||||
|
|
||||||
The Overview can look seventeen ways: pick one under **Settings → Overview style**. Every style shows
|
The Overview can look eighteen ways: pick one under **Settings → Overview style**. Every style shows
|
||||||
the same readings (battery, range, power, speed, gear, usage, trip, climate, odometer, time) and
|
the same readings (battery, range, power, speed, gear, usage, trip, climate, odometer, time) and
|
||||||
shows "—" for anything the car did not answer, never a made-up zero.
|
shows "—" for anything the car did not answer, never a made-up zero.
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ emulator at the car's resolution with **sample values** (87 km/h in D, 76 %, 18.
|
|||||||
emulator has no car; on the car the values are live.
|
emulator has no car; on the car the values are live.
|
||||||
|
|
||||||
**Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings →
|
**Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings →
|
||||||
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid and Visor can also show their original
|
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid (with or without the map) and Visor can also show their original
|
||||||
colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire always do; while one of those is shown, the whole app
|
colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire always do; while one of those is shown, the whole app
|
||||||
(navigation, buttons, switches) takes on its colours too.
|
(navigation, buttons, switches) takes on its colours too.
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire alway
|
|||||||
| [Nixie tubes](#nixie-tubes) | glowing glass tubes | own |
|
| [Nixie tubes](#nixie-tubes) | glowing glass tubes | own |
|
||||||
| [Chronograph](#chronograph) | a watch face | accent |
|
| [Chronograph](#chronograph) | a watch face | accent |
|
||||||
| [Grid](#grid) | neon lines on a glowing grid floor | original or accent |
|
| [Grid](#grid) | neon lines on a glowing grid floor | original or accent |
|
||||||
|
| [Grid with map](#grid-with-map) | the Grid, with the real roads round the car on its floor | original or accent |
|
||||||
| [Time circuits](#time-circuits) | a time machine's LED displays | own |
|
| [Time circuits](#time-circuits) | a time machine's LED displays | own |
|
||||||
| [Quest](#quest) | a fantasy game's interface | own |
|
| [Quest](#quest) | a fantasy game's interface | own |
|
||||||
| [Visor](#visor) | a soldier's helmet display | original or accent |
|
| [Visor](#visor) | a soldier's helmet display | original or accent |
|
||||||
@@ -162,6 +163,20 @@ films' other side.
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
## Grid with map
|
||||||
|
|
||||||
|
The Grid, with the roads round the car from OpenStreetMap laid on its floor as light lanes, in
|
||||||
|
the floor's perspective and turned with the direction you drive: motorways glow brightest, then
|
||||||
|
main roads, then the smaller streets, fading towards the horizon. The car is the chevron on the
|
||||||
|
floor, and between GPS fixes it runs on with the speed. The roads come through the car's own
|
||||||
|
server, which fetches them from OpenStreetMap and keeps them (see the README); the car also keeps
|
||||||
|
the last few hundred tiles itself. Without GPS, the server or the roads, the floor says so and
|
||||||
|
stays the plain grid. The light-cycle trail makes way for the map here; power stays on the disc.
|
||||||
|
Shown with sample values over real roads in central Berlin.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## Time circuits
|
## Time circuits
|
||||||
|
|
||||||
After the time machine in Back to the Future: three rows of LED read-outs in a metal panel,
|
After the time machine in Back to the Future: three rows of LED read-outs in a metal panel,
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 141
|
versionCode 142
|
||||||
versionName "2.38.0"
|
versionName "2.39.0"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||||
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ import android.os.SystemClock;
|
|||||||
* row of read-outs along the bottom. Unread values show "—", light nothing and leave gaps in
|
* row of read-outs along the bottom. Unread values show "—", light nothing and leave gaps in
|
||||||
* the trail; the floor stands still when the speed is unread.
|
* the trail; the floor stands still when the speed is unread.
|
||||||
*
|
*
|
||||||
|
* With a map ("Grid with map", {@link MapRoads}): the roads round the car from OpenStreetMap lie
|
||||||
|
* on the floor in its perspective, heading up, the bigger ones glowing brighter, with the car
|
||||||
|
* as a light marker a little way up the floor; between GPS fixes the car's position runs on
|
||||||
|
* with the speed. The floor's tiles are then 40 m, so they move with the roads, and the trail
|
||||||
|
* makes way for the map. Without a fix, a server or the roads, the plain floor with a line
|
||||||
|
* saying why.
|
||||||
|
*
|
||||||
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
|
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
|
||||||
* low battery is orange in the original (the films' other side) and white in the accent one,
|
* low battery is orange in the original (the films' other side) and white in the accent one,
|
||||||
* so it never looks like the accent itself. The floor and the disc animate (about 30 frames a
|
* so it never looks like the accent itself. The floor and the disc animate (about 30 frames a
|
||||||
@@ -32,6 +39,11 @@ final class GridView extends ClusterCanvasView {
|
|||||||
/** Battery segments in the light wall, disc segments, metres per grid tile. */
|
/** Battery segments in the light wall, disc segments, metres per grid tile. */
|
||||||
private static final int WALL = 20, DISC = 60;
|
private static final int WALL = 20, DISC = 60;
|
||||||
private static final float TILE_M = 4f;
|
private static final float TILE_M = 4f;
|
||||||
|
/**
|
||||||
|
* The map: metres per depth step of the floor, the car's depth (just above the read-outs),
|
||||||
|
* the nearest depth drawn, how far ahead, and the depth from which roads are drawn fainter.
|
||||||
|
*/
|
||||||
|
private static final float MAP_UNIT_M = 40f, CAR_DEPTH = 1.6f, NEAR = 0.6f, FAR_M = 900f, FADE_DEPTH = 5f;
|
||||||
|
|
||||||
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
@@ -44,6 +56,15 @@ final class GridView extends ClusterCanvasView {
|
|||||||
private final Typeface thin = Typeface.create("sans-serif-thin", Typeface.NORMAL);
|
private final Typeface thin = Typeface.create("sans-serif-thin", Typeface.NORMAL);
|
||||||
private final Typeface label = Typeface.create("sans-serif-condensed", Typeface.BOLD);
|
private final Typeface label = Typeface.create("sans-serif-condensed", Typeface.BOLD);
|
||||||
private final int accent, accentLight;
|
private final int accent, accentLight;
|
||||||
|
/** Null for the plain Grid. */
|
||||||
|
private final MapRoads map;
|
||||||
|
/** Per road class, near and far (from {@link #FADE_DEPTH}). */
|
||||||
|
private final Path[] roads = new Path[RoadTile.CLASSES * 2];
|
||||||
|
private float heading = Float.NaN;
|
||||||
|
/** The pen while a road is added: the last point, and whether it continues from there. */
|
||||||
|
private float penX, penY;
|
||||||
|
private boolean penDown;
|
||||||
|
private Path penPath;
|
||||||
|
|
||||||
private int main, light, dim, alert;
|
private int main, light, dim, alert;
|
||||||
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
|
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
|
||||||
@@ -52,7 +73,13 @@ final class GridView extends ClusterCanvasView {
|
|||||||
private int skyFor = -1;
|
private int skyFor = -1;
|
||||||
|
|
||||||
GridView(Context context) {
|
GridView(Context context) {
|
||||||
|
this(context, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
GridView(Context context, MapRoads map) {
|
||||||
super(context);
|
super(context);
|
||||||
|
this.map = map;
|
||||||
|
for (int i = 0; i < roads.length; i++) roads[i] = new Path();
|
||||||
accent = AccentColor.resolve(context, R.attr.evAccent);
|
accent = AccentColor.resolve(context, R.attr.evAccent);
|
||||||
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
|
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||||
line.setStyle(Paint.Style.STROKE);
|
line.setStyle(Paint.Style.STROKE);
|
||||||
@@ -83,16 +110,20 @@ final class GridView extends ClusterCanvasView {
|
|||||||
float horizon = h * 0.55f;
|
float horizon = h * 0.55f;
|
||||||
drawSky(c, w, horizon);
|
drawSky(c, w, horizon);
|
||||||
drawFloor(c, w, h, horizon, u);
|
drawFloor(c, w, h, horizon, u);
|
||||||
|
boolean mapShown = map != null && drawMap(c, w, h, horizon, u);
|
||||||
|
if (map != null && !mapShown) drawMapState(c, w, h, horizon, u);
|
||||||
drawGears(c, w, u);
|
drawGears(c, w, u);
|
||||||
drawClock(c, w, u);
|
drawClock(c, w, u);
|
||||||
drawSpeed(c, w, horizon, u);
|
drawSpeed(c, w, horizon, u);
|
||||||
drawWall(c, horizon, u);
|
drawWall(c, horizon, u);
|
||||||
drawDisc(c, w, u);
|
drawDisc(c, w, u);
|
||||||
drawTrail(c, w, h, u);
|
if (!mapShown) drawTrail(c, w, h, u);
|
||||||
drawReadouts(c, w, h, u);
|
drawReadouts(c, w, h, u);
|
||||||
|
if (mapShown) drawAttribution(c, w, h, u);
|
||||||
|
|
||||||
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
|
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
|
||||||
keepAnimating(moving, 33);
|
// The map costs more per frame: 20 a second is smooth enough for it.
|
||||||
|
keepAnimating(moving, mapShown ? 50 : 33);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
|
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
|
||||||
@@ -101,7 +132,8 @@ final class GridView extends ClusterCanvasView {
|
|||||||
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
|
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
|
||||||
lastFrame = now;
|
lastFrame = now;
|
||||||
if (v.speedKmh != null && v.speedKmh > 0.5f) {
|
if (v.speedKmh != null && v.speedKmh > 0.5f) {
|
||||||
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / TILE_M) % 1f;
|
float tile = map != null ? MAP_UNIT_M : TILE_M;
|
||||||
|
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / tile) % 1f;
|
||||||
}
|
}
|
||||||
if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f;
|
if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f;
|
||||||
}
|
}
|
||||||
@@ -138,6 +170,142 @@ final class GridView extends ClusterCanvasView {
|
|||||||
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
|
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Map ----------
|
||||||
|
|
||||||
|
/** The roads round the car on the floor; false when there is nothing to draw. */
|
||||||
|
private boolean drawMap(Canvas c, float w, float h, float horizon, float u) {
|
||||||
|
MapRoads.Fix fix = map.fix();
|
||||||
|
java.util.Collection<RoadTile> tiles = map.tiles();
|
||||||
|
if (fix == null || tiles.isEmpty()) return false;
|
||||||
|
// Heading up: ease round the shorter way; north up until the car has moved.
|
||||||
|
float target = Float.isNaN(fix.bearing) ? (Float.isNaN(heading) ? 0f : heading) : fix.bearing;
|
||||||
|
if (Float.isNaN(heading)) heading = target;
|
||||||
|
else heading = (heading + (((target - heading) % 360f + 540f) % 360f - 180f) * 0.2f + 360f) % 360f;
|
||||||
|
|
||||||
|
// Between fixes the car runs on with the speed, for at most 2.5 s.
|
||||||
|
double mpu = MapGeometry.metresPerUnit(fix.lat);
|
||||||
|
float ran = 0f;
|
||||||
|
if (v.speedKmh != null && !Float.isNaN(fix.bearing)) {
|
||||||
|
ran = v.speedKmh / 3.6f * Math.min(2.5f, (SystemClock.elapsedRealtime() - fix.elapsedMs) / 1000f);
|
||||||
|
}
|
||||||
|
double run = Math.toRadians(Float.isNaN(fix.bearing) ? 0f : fix.bearing);
|
||||||
|
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
|
||||||
|
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(run) / mpu;
|
||||||
|
|
||||||
|
float sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading));
|
||||||
|
float lane = w * 0.09f, depth = h - horizon, vx = w / 2f, m = (float) mpu;
|
||||||
|
double range = FAR_M / mpu;
|
||||||
|
for (Path p : roads) p.reset();
|
||||||
|
for (RoadTile tile : tiles) {
|
||||||
|
double ox = (double) tile.x * MapGeometry.EXTENT - carX, oy = (double) tile.y * MapGeometry.EXTENT - carY;
|
||||||
|
if (ox > range || oy > range || ox + 2 * MapGeometry.EXTENT < -range || oy + 2 * MapGeometry.EXTENT < -range) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
float fx = (float) ox, fy = (float) oy;
|
||||||
|
for (int i = 0; i < tile.points.length; i++) {
|
||||||
|
int[] b = tile.bounds[i];
|
||||||
|
if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue;
|
||||||
|
int cls = tile.classes[i];
|
||||||
|
int[] pts = tile.points[i];
|
||||||
|
penDown = false;
|
||||||
|
float pa = 0f, pf = 0f;
|
||||||
|
for (int k = 0; k < pts.length; k += 2) {
|
||||||
|
float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m;
|
||||||
|
float fwd = east * sin + north * cos, side = east * cos - north * sin;
|
||||||
|
if (k > 0) segment(cls, pa, pf, side, fwd, vx, horizon, lane, depth);
|
||||||
|
pa = side;
|
||||||
|
pf = fwd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.save();
|
||||||
|
c.clipRect(0, horizon, w, h);
|
||||||
|
line.setStrokeJoin(Paint.Join.ROUND);
|
||||||
|
line.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
// Far roads thin and faint, so the horizon stays a line rather than a tangle.
|
||||||
|
for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) {
|
||||||
|
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x30 : 0x50));
|
||||||
|
line.setStrokeWidth((cls == 0 ? 1.6f : 1f) * u);
|
||||||
|
c.drawPath(roads[cls * 2 + 1], line);
|
||||||
|
}
|
||||||
|
line.setColor(AccentColor.withAlpha(main, 0x80));
|
||||||
|
line.setStrokeWidth(1.4f * u);
|
||||||
|
c.drawPath(roads[3 * 2], line);
|
||||||
|
line.setColor(AccentColor.withAlpha(main, 0xC0));
|
||||||
|
line.setStrokeWidth(2f * u);
|
||||||
|
c.drawPath(roads[2 * 2], line);
|
||||||
|
glow(c, roads[2], main, 2.4f * u);
|
||||||
|
glow(c, roads[0], light, 3.2f * u);
|
||||||
|
line.setStrokeJoin(Paint.Join.MITER);
|
||||||
|
line.setStrokeCap(Paint.Cap.SQUARE);
|
||||||
|
c.restore();
|
||||||
|
|
||||||
|
// The car: a light-cycle chevron pointing ahead.
|
||||||
|
float cy = horizon + depth / CAR_DEPTH;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(vx, cy - 16f * u);
|
||||||
|
path.lineTo(vx + 11f * u, cy + 10f * u);
|
||||||
|
path.lineTo(vx, cy + 4f * u);
|
||||||
|
path.lineTo(vx - 11f * u, cy + 10f * u);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(light);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
glow(c, path, light, 1.5f * u);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One stretch of road from (a0, f0) to (a1, f1) in metres (side, ahead), cut at the near edge. */
|
||||||
|
private void segment(int cls, float a0, float f0, float a1, float f1, float vx, float horizon, float lane,
|
||||||
|
float depth) {
|
||||||
|
float d0 = CAR_DEPTH + f0 / MAP_UNIT_M, d1 = CAR_DEPTH + f1 / MAP_UNIT_M;
|
||||||
|
Path p = roads[cls * 2 + (Math.min(d0, d1) > FADE_DEPTH ? 1 : 0)];
|
||||||
|
if ((d0 < NEAR && d1 < NEAR) || (f0 > FAR_M && f1 > FAR_M)) {
|
||||||
|
penDown = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean cut = false;
|
||||||
|
if (d0 < NEAR || d1 < NEAR) {
|
||||||
|
float t = (NEAR - d0) / (d1 - d0);
|
||||||
|
float a = a0 + (a1 - a0) * t, d = NEAR;
|
||||||
|
if (d0 < NEAR) {
|
||||||
|
a0 = a;
|
||||||
|
d0 = d;
|
||||||
|
cut = true;
|
||||||
|
} else {
|
||||||
|
a1 = a;
|
||||||
|
d1 = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
float x0 = vx + a0 / MAP_UNIT_M * lane / d0, y0 = horizon + depth / d0;
|
||||||
|
float x1 = vx + a1 / MAP_UNIT_M * lane / d1, y1 = horizon + depth / d1;
|
||||||
|
if (!penDown || cut || p != penPath || x0 != penX || y0 != penY) p.moveTo(x0, y0);
|
||||||
|
p.lineTo(x1, y1);
|
||||||
|
penPath = p;
|
||||||
|
penX = x1;
|
||||||
|
penY = y1;
|
||||||
|
penDown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the map is not there, on the floor. */
|
||||||
|
private void drawMapState(Canvas c, float w, float h, float horizon, float u) {
|
||||||
|
MapRoads.State state = map.state();
|
||||||
|
String why = state == MapRoads.State.NO_SERVER ? "MAP · SET UP THE SERVER FIRST"
|
||||||
|
: state == MapRoads.State.WAITING_FOR_GPS ? "MAP · WAITING FOR GPS"
|
||||||
|
: state == MapRoads.State.UNAVAILABLE ? "MAP · NOT AVAILABLE, TRYING AGAIN"
|
||||||
|
: "MAP · LOADING";
|
||||||
|
caption(c, why, w / 2f, horizon + (h - horizon) * 0.2f, u, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OpenStreetMap's licence asks for its name wherever its data is shown. */
|
||||||
|
private void drawAttribution(Canvas c, float w, float h, float u) {
|
||||||
|
text.setTypeface(label);
|
||||||
|
text.setTextSize(12f * u);
|
||||||
|
text.setTextAlign(Paint.Align.RIGHT);
|
||||||
|
text.setStyle(Paint.Style.FILL);
|
||||||
|
text.setColor(dim);
|
||||||
|
c.drawText("© OpenStreetMap contributors", w - 26f * u, h - 80f * u, text);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Top ----------
|
// ---------- Top ----------
|
||||||
|
|
||||||
private void drawGears(Canvas c, float w, float u) {
|
private void drawGears(Canvas c, float w, float u) {
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private TerminalView terminalView;
|
private TerminalView terminalView;
|
||||||
private GridView gridView;
|
private GridView gridView;
|
||||||
private VisorView visorView;
|
private VisorView visorView;
|
||||||
|
/** "Grid with map": the same style over the roads round the car (MapRoads, fed by GPS here). */
|
||||||
|
private GridView gridMapView;
|
||||||
|
private MapRoads mapRoads;
|
||||||
|
private android.location.LocationListener mapListener;
|
||||||
|
private boolean inFront;
|
||||||
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
||||||
private View[] stylePanes;
|
private View[] stylePanes;
|
||||||
private View consumptionPane;
|
private View consumptionPane;
|
||||||
@@ -345,9 +350,11 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
mgPane = drawnPane(octagonView);
|
mgPane = drawnPane(octagonView);
|
||||||
terminalView = new TerminalView(this);
|
terminalView = new TerminalView(this);
|
||||||
gridView = new GridView(this);
|
gridView = new GridView(this);
|
||||||
|
mapRoads = new MapRoads(this);
|
||||||
|
gridMapView = new GridView(this, mapRoads);
|
||||||
visorView = new VisorView(this);
|
visorView = new VisorView(this);
|
||||||
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
||||||
new NixieView(this), new ChronographView(this), gridView, new TimeCircuitsView(this),
|
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
||||||
new QuestView(this), visorView, new EmpireView(this));
|
new QuestView(this), visorView, new EmpireView(this));
|
||||||
View[] canvasPanes = new View[canvasStyles.size()];
|
View[] canvasPanes = new View[canvasStyles.size()];
|
||||||
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
||||||
@@ -487,6 +494,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// Poll state and log only while the screen is up; onPause cancels it.
|
// Poll state and log only while the screen is up; onPause cancels it.
|
||||||
uiHandler.post(uiRefresh);
|
uiHandler.post(uiRefresh);
|
||||||
uiHandler.post(overviewRefresh);
|
uiHandler.post(overviewRefresh);
|
||||||
|
inFront = true;
|
||||||
|
updateMapLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -534,6 +543,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
||||||
uiHandler.removeCallbacks(uiRefresh);
|
uiHandler.removeCallbacks(uiRefresh);
|
||||||
uiHandler.removeCallbacks(overviewRefresh);
|
uiHandler.removeCallbacks(overviewRefresh);
|
||||||
|
inFront = false;
|
||||||
|
updateMapLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1725,9 +1736,56 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
|
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
|
||||||
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
|
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
|
||||||
gridView.setAccentPalette(!originalColours(OverviewStyle.GRID));
|
gridView.setAccentPalette(!originalColours(OverviewStyle.GRID));
|
||||||
|
gridMapView.setAccentPalette(!originalColours(OverviewStyle.GRID_MAP));
|
||||||
visorView.setAccentPalette(!originalColours(OverviewStyle.VISOR));
|
visorView.setAccentPalette(!originalColours(OverviewStyle.VISOR));
|
||||||
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
|
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
|
||||||
if (settingsPane != null) bindAccentColor();
|
if (settingsPane != null) bindAccentColor();
|
||||||
|
updateMapLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPS for the Grid's map, only while "Grid with map" is the style and the app is in front;
|
||||||
|
* every fix goes to {@link MapRoads}. Without the location permission there is no map (the
|
||||||
|
* floor says it waits for GPS), never a crash.
|
||||||
|
*/
|
||||||
|
private void updateMapLocation() {
|
||||||
|
android.location.LocationManager lm =
|
||||||
|
(android.location.LocationManager) getSystemService(LOCATION_SERVICE);
|
||||||
|
if (lm == null || mapRoads == null) return;
|
||||||
|
boolean wanted = inFront && overviewStyle() == OverviewStyle.GRID_MAP;
|
||||||
|
if (!wanted) {
|
||||||
|
if (mapListener != null) {
|
||||||
|
try {
|
||||||
|
lm.removeUpdates(mapListener);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
mapListener = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mapListener != null) return;
|
||||||
|
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
android.location.LocationListener listener = new android.location.LocationListener() {
|
||||||
|
@Override public void onLocationChanged(android.location.Location location) { mapRoads.onFix(location); }
|
||||||
|
@Override public void onStatusChanged(String provider, int status, android.os.Bundle extras) { }
|
||||||
|
@Override public void onProviderEnabled(String provider) { }
|
||||||
|
@Override public void onProviderDisabled(String provider) { }
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
lm.requestLocationUpdates(android.location.LocationManager.GPS_PROVIDER, 1000L, 0f, listener);
|
||||||
|
mapListener = listener;
|
||||||
|
android.location.Location last = lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER);
|
||||||
|
if (last != null && android.os.SystemClock.elapsedRealtimeNanos() - last.getElapsedRealtimeNanos()
|
||||||
|
< MapRoads.FIX_FRESH_MS * 1_000_000L) {
|
||||||
|
mapRoads.onFix(last);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
android.util.Log.w("MainActivity", "No GPS for the map: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1757,6 +1815,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
|
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
|
||||||
getString(R.string.overview_style_nixie),
|
getString(R.string.overview_style_nixie),
|
||||||
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
|
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
|
||||||
|
getString(R.string.overview_style_grid_map),
|
||||||
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
|
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
|
||||||
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire) };
|
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web-mercator arithmetic for the Grid's map: which tile the car is in, positions in the
|
||||||
|
* server's tile units (4096 across a zoom-14 tile) and metres per unit. Pure Java, unit-tested.
|
||||||
|
*/
|
||||||
|
final class MapGeometry {
|
||||||
|
|
||||||
|
static final int ZOOM = 14;
|
||||||
|
static final int EXTENT = 4096;
|
||||||
|
/** Tile units across the world at {@link #ZOOM}. */
|
||||||
|
static final double WORLD = (double) (1 << ZOOM) * EXTENT;
|
||||||
|
private static final double EQUATOR_M = 40_075_016.686;
|
||||||
|
|
||||||
|
private MapGeometry() {}
|
||||||
|
|
||||||
|
/** The position east, in tile units from the date line. */
|
||||||
|
static double worldX(double lon) {
|
||||||
|
return (lon + 180d) / 360d * WORLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The position south, in tile units from the top of the map. */
|
||||||
|
static double worldY(double lat) {
|
||||||
|
double r = Math.toRadians(lat);
|
||||||
|
return (1d - Math.log(Math.tan(r) + 1d / Math.cos(r)) / Math.PI) / 2d * WORLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int tileX(double lon) {
|
||||||
|
return clampTile((int) Math.floor(worldX(lon) / EXTENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int tileY(double lat) {
|
||||||
|
return clampTile((int) Math.floor(worldY(lat) / EXTENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clampTile(int t) {
|
||||||
|
return Math.max(0, Math.min((1 << ZOOM) - 1, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metres per tile unit on the ground at this latitude. */
|
||||||
|
static double metresPerUnit(double lat) {
|
||||||
|
return EQUATOR_M * Math.cos(Math.toRadians(lat)) / WORLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One number for a tile, for maps and file names. */
|
||||||
|
static long key(int x, int y) {
|
||||||
|
return ((long) x << 20) | y;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int keyX(long key) {
|
||||||
|
return (int) (key >> 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int keyY(long key) {
|
||||||
|
return (int) (key & 0xFFFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the position is one a map can show (web mercator stops short of the poles). */
|
||||||
|
static boolean usable(double lat, double lon) {
|
||||||
|
return Double.isFinite(lat) && Double.isFinite(lon) && Math.abs(lat) < 85d && Math.abs(lon) <= 180d
|
||||||
|
&& !(lat == 0d && lon == 0d);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.location.Location;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roads around the car for the Grid's map, and where the car is: the activity hands in
|
||||||
|
* GPS fixes ({@link #onFix}) while "Grid with map" is shown; this keeps the 3×3 tiles round
|
||||||
|
* the car loaded from the app's own cache (files/map, 30 days, at most {@value #DISK_TILES}
|
||||||
|
* tiles) or from the car's server ({@code POST /v1/map/roads}, server/maps.go), one at a time
|
||||||
|
* on a background thread, and drops tiles from memory once the car is two tiles away. The car
|
||||||
|
* asks only its own server, naming a tile rather than its position. Without a configured
|
||||||
|
* server, a fix or the roads, the Grid shows its plain floor ({@link #state}).
|
||||||
|
*/
|
||||||
|
final class MapRoads {
|
||||||
|
|
||||||
|
/** Why the map shows nothing (or {@link #SHOWING}). */
|
||||||
|
enum State { NO_SERVER, WAITING_FOR_GPS, LOADING, UNAVAILABLE, SHOWING }
|
||||||
|
|
||||||
|
/** One GPS fix: where, which way (degrees from north, NaN before the car has moved), when. */
|
||||||
|
static final class Fix {
|
||||||
|
final double lat, lon;
|
||||||
|
final float bearing;
|
||||||
|
final long elapsedMs;
|
||||||
|
|
||||||
|
Fix(double lat, double lon, float bearing, long elapsedMs) {
|
||||||
|
this.lat = lat;
|
||||||
|
this.lon = lon;
|
||||||
|
this.bearing = bearing;
|
||||||
|
this.elapsedMs = elapsedMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String TAG = "MapRoads";
|
||||||
|
private static final int DISK_TILES = 400;
|
||||||
|
private static final long DISK_AGE_MS = 30L * 24 * 60 * 60 * 1000;
|
||||||
|
private static final long RETRY_MS = 60_000L;
|
||||||
|
/** A fix older than this is not drawn from. */
|
||||||
|
static final long FIX_FRESH_MS = 15_000L;
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
private final File dir;
|
||||||
|
private final Map<Long, RoadTile> tiles = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, Long> failedAt = new ConcurrentHashMap<>();
|
||||||
|
private final Map<Long, Boolean> queued = new ConcurrentHashMap<>();
|
||||||
|
private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "map-roads");
|
||||||
|
t.setPriority(Thread.MIN_PRIORITY);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
private volatile Fix fix;
|
||||||
|
/** The last bearing while moving; GPS has none at a standstill. */
|
||||||
|
private volatile float bearing = Float.NaN;
|
||||||
|
private volatile boolean serverMissing;
|
||||||
|
/** Where the bearing was last worked out from, for a GPS that gives none. */
|
||||||
|
private Location anchor;
|
||||||
|
|
||||||
|
MapRoads(Context context) {
|
||||||
|
this.context = context.getApplicationContext();
|
||||||
|
dir = new File(this.context.getFilesDir(), "map");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A GPS fix. Main thread; the loading happens on the worker. */
|
||||||
|
void onFix(Location location) {
|
||||||
|
if (location == null || !MapGeometry.usable(location.getLatitude(), location.getLongitude())) return;
|
||||||
|
if (location.hasBearing() && location.hasSpeed() && location.getSpeed() > 1.5f) {
|
||||||
|
bearing = location.getBearing();
|
||||||
|
anchor = location;
|
||||||
|
} else if (anchor == null || location.getElapsedRealtimeNanos() - anchor.getElapsedRealtimeNanos() > 10_000_000_000L) {
|
||||||
|
anchor = location;
|
||||||
|
} else if (anchor.distanceTo(location) >= 8f) {
|
||||||
|
// No bearing from the GPS: the way from the last place 8 m or more behind.
|
||||||
|
bearing = (anchor.bearingTo(location) + 360f) % 360f;
|
||||||
|
anchor = location;
|
||||||
|
}
|
||||||
|
Fix f = new Fix(location.getLatitude(), location.getLongitude(), bearing, SystemClock.elapsedRealtime());
|
||||||
|
fix = f;
|
||||||
|
want(MapGeometry.tileX(f.lon), MapGeometry.tileY(f.lat));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The latest fix, or null when there is none or it is stale. */
|
||||||
|
Fix fix() {
|
||||||
|
Fix f = fix;
|
||||||
|
return f == null || SystemClock.elapsedRealtime() - f.elapsedMs > FIX_FRESH_MS ? null : f;
|
||||||
|
}
|
||||||
|
|
||||||
|
Collection<RoadTile> tiles() {
|
||||||
|
return tiles.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
State state() {
|
||||||
|
if (serverMissing) return State.NO_SERVER;
|
||||||
|
Fix f = fix();
|
||||||
|
if (f == null) return State.WAITING_FOR_GPS;
|
||||||
|
long here = MapGeometry.key(MapGeometry.tileX(f.lon), MapGeometry.tileY(f.lat));
|
||||||
|
if (tiles.containsKey(here)) return State.SHOWING;
|
||||||
|
return failedAt.containsKey(here) ? State.UNAVAILABLE : State.LOADING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queues the 3×3 tiles round (cx, cy), the car's own first, and forgets far ones. */
|
||||||
|
private void want(int cx, int cy) {
|
||||||
|
for (Long key : tiles.keySet()) {
|
||||||
|
if (Math.abs(MapGeometry.keyX(key) - cx) > 2 || Math.abs(MapGeometry.keyY(key) - cy) > 2) tiles.remove(key);
|
||||||
|
}
|
||||||
|
int[][] order = { {0, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, 0}, {1, -1}, {1, 1}, {-1, 1}, {-1, -1} };
|
||||||
|
long now = SystemClock.elapsedRealtime();
|
||||||
|
for (int[] d : order) {
|
||||||
|
int x = cx + d[0], y = cy + d[1];
|
||||||
|
if (x < 0 || y < 0 || x >= (1 << MapGeometry.ZOOM) || y >= (1 << MapGeometry.ZOOM)) continue;
|
||||||
|
long key = MapGeometry.key(x, y);
|
||||||
|
if (tiles.containsKey(key) || queued.containsKey(key)) continue;
|
||||||
|
Long failed = failedAt.get(key);
|
||||||
|
if (failed != null && now - failed < RETRY_MS) continue;
|
||||||
|
queued.put(key, Boolean.TRUE);
|
||||||
|
worker.execute(() -> {
|
||||||
|
try {
|
||||||
|
load(key, x, y);
|
||||||
|
} finally {
|
||||||
|
queued.remove(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** From the cache when fresh enough, else from the server; a stale copy beats nothing. */
|
||||||
|
private void load(long key, int x, int y) {
|
||||||
|
File file = new File(dir, MapGeometry.ZOOM + "_" + x + "_" + y + ".json");
|
||||||
|
RoadTile cached = null;
|
||||||
|
try {
|
||||||
|
if (file.isFile()) {
|
||||||
|
cached = RoadTile.parse(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8), x, y);
|
||||||
|
if (cached == null) file.delete();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Cached map tile unreadable: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
if (cached != null) {
|
||||||
|
tiles.put(key, cached);
|
||||||
|
if (System.currentTimeMillis() - file.lastModified() < DISK_AGE_MS) return;
|
||||||
|
}
|
||||||
|
ServerConfig config = ServerConfig.get(context);
|
||||||
|
if (!config.isConfigured()) {
|
||||||
|
serverMissing = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
serverMissing = false;
|
||||||
|
try {
|
||||||
|
JSONObject body = new JSONObject().put("z", MapGeometry.ZOOM).put("x", x).put("y", y);
|
||||||
|
ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/map/roads", config.token, body);
|
||||||
|
RoadTile tile = r.status == 200 ? RoadTile.parse(r.body, x, y) : null;
|
||||||
|
if (tile == null) {
|
||||||
|
// The tile stays out of the log: it says where the car is.
|
||||||
|
Log.w(TAG, "Map tile not loaded: HTTP " + r.status);
|
||||||
|
if (cached == null) failedAt.put(key, SystemClock.elapsedRealtime());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failedAt.remove(key);
|
||||||
|
tiles.put(key, tile);
|
||||||
|
save(file, r.body);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Map tile not loaded: " + e.getClass().getSimpleName());
|
||||||
|
if (cached == null) failedAt.put(key, SystemClock.elapsedRealtime());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void save(File file, String body) {
|
||||||
|
try {
|
||||||
|
if (!dir.isDirectory() && !dir.mkdirs()) return;
|
||||||
|
File tmp = new File(dir, file.getName() + ".tmp");
|
||||||
|
try (FileOutputStream out = new FileOutputStream(tmp)) {
|
||||||
|
out.write(body.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
if (!tmp.renameTo(file)) tmp.delete();
|
||||||
|
File[] all = dir.listFiles((d, name) -> name.endsWith(".json"));
|
||||||
|
if (all != null && all.length > DISK_TILES) {
|
||||||
|
Arrays.sort(all, (a, b) -> Long.compare(a.lastModified(), b.lastModified()));
|
||||||
|
for (int i = 0; i < all.length - DISK_TILES; i++) all[i].delete();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Map tile not cached: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ enum OverviewStyle {
|
|||||||
NIXIE(false, false),
|
NIXIE(false, false),
|
||||||
CHRONOGRAPH(true, false),
|
CHRONOGRAPH(true, false),
|
||||||
GRID(true, true),
|
GRID(true, true),
|
||||||
|
GRID_MAP(true, true),
|
||||||
TIME_CIRCUITS(false, false),
|
TIME_CIRCUITS(false, false),
|
||||||
QUEST(false, false),
|
QUEST(false, false),
|
||||||
VISOR(true, true),
|
VISOR(true, true),
|
||||||
@@ -53,7 +54,8 @@ enum OverviewStyle {
|
|||||||
|
|
||||||
/** Where the choice between the original palette and the accent colour is kept. */
|
/** Where the choice between the original palette and the accent colour is kept. */
|
||||||
String originalKey() {
|
String originalKey() {
|
||||||
return "original_colours_" + name();
|
// Grid with map is the Grid over a map: one choice for both.
|
||||||
|
return "original_colours_" + (this == GRID_MAP ? GRID : this).name();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roads of one map tile as the server sends them (server/maps.go): per road its class
|
||||||
|
* (0 motorway and trunk, 1 primary and secondary, 2 tertiary and unclassified, 3 residential)
|
||||||
|
* and its points in tile units, plus each road's bounding box for skipping far ones quickly.
|
||||||
|
* {@link #parse} fails closed: anything unexpected is no tile at all. Pure Java, unit-tested.
|
||||||
|
*/
|
||||||
|
final class RoadTile {
|
||||||
|
|
||||||
|
static final int CLASSES = 4;
|
||||||
|
/** Points may lie a little beyond the tile (a road is kept to its first point outside). */
|
||||||
|
private static final int SLACK = MapGeometry.EXTENT;
|
||||||
|
private static final int MAX_ROADS = 20_000;
|
||||||
|
|
||||||
|
final int x, y;
|
||||||
|
final int[] classes;
|
||||||
|
/** Per road: x0, y0, x1, y1, … in tile units. */
|
||||||
|
final int[][] points;
|
||||||
|
/** Per road: minX, minY, maxX, maxY. */
|
||||||
|
final int[][] bounds;
|
||||||
|
|
||||||
|
private RoadTile(int x, int y, int[] classes, int[][] points, int[][] bounds) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.classes = classes;
|
||||||
|
this.points = points;
|
||||||
|
this.bounds = bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tile in {@code json}, which must be the one asked for; null when it is not usable. */
|
||||||
|
static RoadTile parse(String json, int expectX, int expectY) {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject(json);
|
||||||
|
if (o.getInt("z") != MapGeometry.ZOOM || o.getInt("x") != expectX || o.getInt("y") != expectY
|
||||||
|
|| o.getInt("extent") != MapGeometry.EXTENT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JSONArray ways = o.getJSONArray("ways");
|
||||||
|
if (ways.length() > MAX_ROADS) return null;
|
||||||
|
int n = ways.length();
|
||||||
|
int[] classes = new int[n];
|
||||||
|
int[][] points = new int[n][];
|
||||||
|
int[][] bounds = new int[n][];
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
JSONObject way = ways.getJSONObject(i);
|
||||||
|
int c = way.getInt("c");
|
||||||
|
JSONArray p = way.getJSONArray("p");
|
||||||
|
if (c < 0 || c >= CLASSES || p.length() < 4 || p.length() % 2 != 0) return null;
|
||||||
|
int[] pts = new int[p.length()];
|
||||||
|
int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE;
|
||||||
|
for (int k = 0; k < pts.length; k++) {
|
||||||
|
int value = p.getInt(k);
|
||||||
|
if (value < -SLACK || value > MapGeometry.EXTENT + SLACK) return null;
|
||||||
|
pts[k] = value;
|
||||||
|
if (k % 2 == 0) {
|
||||||
|
minX = Math.min(minX, value);
|
||||||
|
maxX = Math.max(maxX, value);
|
||||||
|
} else {
|
||||||
|
minY = Math.min(minY, value);
|
||||||
|
maxY = Math.max(maxY, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
classes[i] = c;
|
||||||
|
points[i] = pts;
|
||||||
|
bounds[i] = new int[] { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
return new RoadTile(expectX, expectY, classes, points, bounds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ final class StylePalette {
|
|||||||
case SCIFI: return R.style.ThemeOverlay_EV_Style_SciFi;
|
case SCIFI: return R.style.ThemeOverlay_EV_Style_SciFi;
|
||||||
case CARTOON: return R.style.ThemeOverlay_EV_Style_Cartoon;
|
case CARTOON: return R.style.ThemeOverlay_EV_Style_Cartoon;
|
||||||
case NIXIE: return R.style.ThemeOverlay_EV_Style_Nixie;
|
case NIXIE: return R.style.ThemeOverlay_EV_Style_Nixie;
|
||||||
case GRID: return R.style.ThemeOverlay_EV_Style_Grid;
|
case GRID:
|
||||||
|
case GRID_MAP: return R.style.ThemeOverlay_EV_Style_Grid;
|
||||||
case TIME_CIRCUITS: return R.style.ThemeOverlay_EV_Style_TimeCircuits;
|
case TIME_CIRCUITS: return R.style.ThemeOverlay_EV_Style_TimeCircuits;
|
||||||
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
|
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
|
||||||
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
|
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
|
||||||
|
|||||||
@@ -136,6 +136,7 @@
|
|||||||
<string name="overview_style_terminal">Terminal</string>
|
<string name="overview_style_terminal">Terminal</string>
|
||||||
<string name="accent_original">Original</string>
|
<string name="accent_original">Original</string>
|
||||||
<string name="overview_style_grid">Grid</string>
|
<string name="overview_style_grid">Grid</string>
|
||||||
|
<string name="overview_style_grid_map">Grid with map</string>
|
||||||
<string name="overview_style_time_circuits">Time circuits</string>
|
<string name="overview_style_time_circuits">Time circuits</string>
|
||||||
<string name="overview_style_quest">Quest</string>
|
<string name="overview_style_quest">Quest</string>
|
||||||
<string name="overview_style_visor">Visor</string>
|
<string name="overview_style_visor">Visor</string>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/** The Grid map's tile arithmetic and the server's road tiles — pure JVM. */
|
||||||
|
public class MapGeometryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void tilesMatchTheServer() {
|
||||||
|
// Alexanderplatz, Berlin: the tile the server tests use too.
|
||||||
|
assertEquals(8802, MapGeometry.tileX(13.4125));
|
||||||
|
assertEquals(5373, MapGeometry.tileY(52.5219));
|
||||||
|
long key = MapGeometry.key(8802, 5373);
|
||||||
|
assertEquals(8802, MapGeometry.keyX(key));
|
||||||
|
assertEquals(5373, MapGeometry.keyY(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void metresPerUnitShrinkTowardsThePoles() {
|
||||||
|
// A zoom-14 tile is about 2.4 km across at the equator, in 4096 units.
|
||||||
|
assertEquals(0.597, MapGeometry.metresPerUnit(0), 0.001);
|
||||||
|
assertEquals(0.363, MapGeometry.metresPerUnit(52.52), 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void unusablePositions() {
|
||||||
|
assertFalse(MapGeometry.usable(Double.NaN, 13));
|
||||||
|
assertFalse(MapGeometry.usable(0, 0)); // a GPS that has nothing yet
|
||||||
|
assertFalse(MapGeometry.usable(89, 13));
|
||||||
|
assertTrue(MapGeometry.usable(52.52, 13.41));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void roadTilesParse() {
|
||||||
|
RoadTile t = RoadTile.parse("{\"z\":14,\"x\":8802,\"y\":5373,\"extent\":4096,\"ways\":["
|
||||||
|
+ "{\"c\":1,\"p\":[-115,1236,82,1080,100,1000]},{\"c\":3,\"p\":[0,0,10,10]}]}", 8802, 5373);
|
||||||
|
assertNotNull(t);
|
||||||
|
assertEquals(2, t.classes.length);
|
||||||
|
assertEquals(1, t.classes[0]);
|
||||||
|
assertEquals(6, t.points[0].length);
|
||||||
|
assertEquals(-115, t.bounds[0][0]);
|
||||||
|
assertEquals(1000, t.bounds[0][1]);
|
||||||
|
assertEquals(100, t.bounds[0][2]);
|
||||||
|
assertEquals(1236, t.bounds[0][3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void badRoadTilesAreNoTile() {
|
||||||
|
String head = "{\"z\":14,\"x\":1,\"y\":2,\"extent\":4096,\"ways\":";
|
||||||
|
assertNotNull(RoadTile.parse(head + "[]}", 1, 2));
|
||||||
|
assertNull("another tile", RoadTile.parse(head + "[]}", 1, 3));
|
||||||
|
assertNull("another zoom", RoadTile.parse("{\"z\":13,\"x\":1,\"y\":2,\"extent\":4096,\"ways\":[]}", 1, 2));
|
||||||
|
assertNull("unknown class", RoadTile.parse(head + "[{\"c\":7,\"p\":[0,0,1,1]}]}", 1, 2));
|
||||||
|
assertNull("one point", RoadTile.parse(head + "[{\"c\":1,\"p\":[0,0]}]}", 1, 2));
|
||||||
|
assertNull("odd count", RoadTile.parse(head + "[{\"c\":1,\"p\":[0,0,1]}]}", 1, 2));
|
||||||
|
assertNull("far outside", RoadTile.parse(head + "[{\"c\":1,\"p\":[0,0,99999,1]}]}", 1, 2));
|
||||||
|
assertNull("not JSON", RoadTile.parse("<html>", 1, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,5 +63,8 @@ public class OverviewStyleTest {
|
|||||||
assertEquals(R.style.ThemeOverlay_EV_Style_Retro,
|
assertEquals(R.style.ThemeOverlay_EV_Style_Retro,
|
||||||
StylePalette.appOverlay(OverviewStyle.RETRO, true, AccentColor.HOLBORN_BLUE));
|
StylePalette.appOverlay(OverviewStyle.RETRO, true, AccentColor.HOLBORN_BLUE));
|
||||||
assertEquals("original_colours_RETRO", OverviewStyle.RETRO.originalKey());
|
assertEquals("original_colours_RETRO", OverviewStyle.RETRO.originalKey());
|
||||||
|
// Grid with map shares the Grid's choice.
|
||||||
|
assertEquals(OverviewStyle.GRID.originalKey(), OverviewStyle.GRID_MAP.originalKey());
|
||||||
|
assertEquals(StylePalette.overlay(OverviewStyle.GRID), StylePalette.overlay(OverviewStyle.GRID_MAP));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -30,7 +30,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: server
|
||||||
# 2.6.2, pinned by digest so a re-pushed tag cannot change what runs.
|
# 2.6.2, pinned by digest so a re-pushed tag cannot change what runs.
|
||||||
image: git.stinnesbeck.com/nils/mg4-server@sha256:e4de1e75958d58ece8430a1913f9c1614d8ea1bf566f2e1ec5b449ef51844f77
|
image: git.stinnesbeck.com/nils/mg4-server@sha256:e5da9452a9dbdfabe3340766cc51f18bb1418b2588ab534714550470abe1ce29
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
@@ -50,6 +50,9 @@ spec:
|
|||||||
value: https://git.stinnesbeck.com
|
value: https://git.stinnesbeck.com
|
||||||
- name: FORGE_REPO
|
- name: FORGE_REPO
|
||||||
value: nils/mg4
|
value: nils/mg4
|
||||||
|
# The roads for the app's Grid map, from OpenStreetMap; "off" switches maps off.
|
||||||
|
- name: OVERPASS_URL
|
||||||
|
value: https://overpass-api.de/api/interpreter
|
||||||
- name: INFLUX_TOKEN
|
- name: INFLUX_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -23,6 +23,7 @@ Environment only; the server refuses to start when a required value is missing.
|
|||||||
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
|
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
|
||||||
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
|
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
|
||||||
| `FORGE_URL`, `FORGE_REPO`, `FORGE_TOKEN` | optional; app updates (below). The token only needs to read the repository |
|
| `FORGE_URL`, `FORGE_REPO`, `FORGE_TOKEN` | optional; app updates (below). The token only needs to read the repository |
|
||||||
|
| `OVERPASS_URL` | the Overpass API the map's roads come from; default `https://overpass-api.de/api/interpreter`, `off` switches maps off, otherwise https only |
|
||||||
| `LISTEN_ADDR` | default `:8080` |
|
| `LISTEN_ADDR` | default `:8080` |
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
@@ -57,6 +58,21 @@ shared ingest token): `update check` (how many releases, the newest pre-release)
|
|||||||
download started` (file, size), then `update handed out` (bytes sent, time taken) or `update
|
download started` (file, size), then `update handed out` (bytes sent, time taken) or `update
|
||||||
download cut short`. The forge's address and token never appear in the log.
|
download cut short`. The forge's address and token never appear in the log.
|
||||||
|
|
||||||
|
## Map roads
|
||||||
|
|
||||||
|
`POST /v1/map/roads` `{z, x, y}`, with the car's token: the drivable roads (motorway to
|
||||||
|
residential) of one web-mercator tile at zoom 14, for the app's "Grid with map" style. The car
|
||||||
|
names the tile, not its position, and in the body, so it stays out of access logs. The answer is
|
||||||
|
`{z, x, y, extent: 4096, ways: [{c, p: [x, y, …]}]}`: per road its class (0 motorway and trunk,
|
||||||
|
1 primary and secondary, 2 tertiary and unclassified, 3 residential and living streets) and its
|
||||||
|
points in tile units, simplified to about 1.5 m; gzipped when the car accepts it (20–30 KB a tile
|
||||||
|
in a city before that). The roads come from OpenStreetMap through `OVERPASS_URL`, one request at
|
||||||
|
a time and at most one a second, with a User-Agent naming this service, and are kept in memory
|
||||||
|
for 30 days (up to 3000 tiles); the car keeps its own copy too. 400 for anything but zoom 14 or
|
||||||
|
a tile outside it, 503 when Overpass is busy or unreachable (not cached, the car tries again a
|
||||||
|
minute later), 404 when maps are off. The log says `map tile fetched` with the car's installation
|
||||||
|
and the size, never the tile. Map data © OpenStreetMap contributors, ODbL.
|
||||||
|
|
||||||
## Settings from a phone
|
## Settings from a phone
|
||||||
|
|
||||||
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
|
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
// GET /v1/health unauthenticated liveness probe
|
// GET /v1/health unauthenticated liveness probe
|
||||||
// /v1/settings/…, /settings settings typed on a phone (handoff_http.go)
|
// /v1/settings/…, /settings settings typed on a phone (handoff_http.go)
|
||||||
// GET /v1/app/… app updates from the forge (releases.go)
|
// GET /v1/app/… app updates from the forge (releases.go)
|
||||||
|
// POST /v1/map/roads the roads of one map tile (maps.go)
|
||||||
//
|
//
|
||||||
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
|
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
|
||||||
// (pairing_http.go).
|
// (pairing_http.go).
|
||||||
@@ -31,6 +32,8 @@ type API struct {
|
|||||||
Handoff *Handoff
|
Handoff *Handoff
|
||||||
// Releases relays the app's releases from the private forge to the car (releases.go).
|
// Releases relays the app's releases from the private forge to the car (releases.go).
|
||||||
Releases *Releases
|
Releases *Releases
|
||||||
|
// Maps serves the roads for the Overview's map from OpenStreetMap (maps.go); nil is off.
|
||||||
|
Maps *Maps
|
||||||
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
|
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
|
||||||
// derived from the request when empty.
|
// derived from the request when empty.
|
||||||
PublicURL string
|
PublicURL string
|
||||||
@@ -50,6 +53,7 @@ func (a *API) Routes() http.Handler {
|
|||||||
a.pairingRoutes(mux)
|
a.pairingRoutes(mux)
|
||||||
a.handoffRoutes(mux)
|
a.handoffRoutes(mux)
|
||||||
a.releaseRoutes(mux)
|
a.releaseRoutes(mux)
|
||||||
|
a.mapRoutes(mux)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -34,6 +34,7 @@ type config struct {
|
|||||||
forgeURL string
|
forgeURL string
|
||||||
forgeRepo string
|
forgeRepo string
|
||||||
forgeToken string
|
forgeToken string
|
||||||
|
overpassURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
|
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
|
||||||
@@ -51,6 +52,7 @@ func loadConfig(getenv func(string) string) (config, error) {
|
|||||||
forgeURL: getenv("FORGE_URL"),
|
forgeURL: getenv("FORGE_URL"),
|
||||||
forgeRepo: getenv("FORGE_REPO"),
|
forgeRepo: getenv("FORGE_REPO"),
|
||||||
forgeToken: getenv("FORGE_TOKEN"),
|
forgeToken: getenv("FORGE_TOKEN"),
|
||||||
|
overpassURL: getenv("OVERPASS_URL"),
|
||||||
}
|
}
|
||||||
if c.listenAddr == "" {
|
if c.listenAddr == "" {
|
||||||
c.listenAddr = ":8080"
|
c.listenAddr = ":8080"
|
||||||
@@ -73,6 +75,18 @@ func loadConfig(getenv func(string) string) (config, error) {
|
|||||||
if c.forgeURL != "" && !strings.HasPrefix(c.forgeURL, "https://") {
|
if c.forgeURL != "" && !strings.HasPrefix(c.forgeURL, "https://") {
|
||||||
return config{}, errors.New("FORGE_URL must be an https URL")
|
return config{}, errors.New("FORGE_URL must be an https URL")
|
||||||
}
|
}
|
||||||
|
// The map's roads come from an Overpass API: the public one unless another is named, none
|
||||||
|
// with "off". Only https, like the forge.
|
||||||
|
switch c.overpassURL {
|
||||||
|
case "":
|
||||||
|
c.overpassURL = DefaultOverpassURL
|
||||||
|
case "off":
|
||||||
|
c.overpassURL = ""
|
||||||
|
default:
|
||||||
|
if !strings.HasPrefix(c.overpassURL, "https://") {
|
||||||
|
return config{}, errors.New("OVERPASS_URL must be an https URL or \"off\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
|
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
|
||||||
return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength)
|
return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength)
|
||||||
}
|
}
|
||||||
@@ -102,6 +116,9 @@ func main() {
|
|||||||
releases := &Releases{ForgeURL: cfg.forgeURL, Repo: cfg.forgeRepo, Token: cfg.forgeToken}
|
releases := &Releases{ForgeURL: cfg.forgeURL, Repo: cfg.forgeRepo, Token: cfg.forgeToken}
|
||||||
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
|
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
|
||||||
Releases: releases, PublicURL: cfg.publicURL}
|
Releases: releases, PublicURL: cfg.publicURL}
|
||||||
|
if cfg.overpassURL != "" {
|
||||||
|
api.Maps = NewMaps(cfg.overpassURL, mapUserAgent)
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.listenAddr,
|
Addr: cfg.listenAddr,
|
||||||
@@ -116,7 +133,8 @@ func main() {
|
|||||||
defer stop()
|
defer stop()
|
||||||
go func() {
|
go func() {
|
||||||
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket,
|
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket,
|
||||||
"pairing", pairing.Enabled(), "pairedDevices", len(devices), "appUpdates", releases.enabled())
|
"pairing", pairing.Enabled(), "pairedDevices", len(devices), "appUpdates", releases.enabled(),
|
||||||
|
"maps", api.Maps != nil)
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
slog.Error("server failed", "error", err)
|
slog.Error("server failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+358
@@ -0,0 +1,358 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Maps serves the roads around the car for the Overview's map (the "Grid with map" style),
|
||||||
|
// taken from OpenStreetMap through an Overpass API and cached here, so the car only ever
|
||||||
|
// talks to this server and asks Overpass nothing itself.
|
||||||
|
//
|
||||||
|
// POST /v1/map/roads {"z":14,"x":…,"y":…}
|
||||||
|
// → {"z","x","y","extent":4096,"ways":[{"c":class,"p":[x,y,x,y,…]}]}
|
||||||
|
//
|
||||||
|
// The car names a web-mercator tile at zoom 14 (about 1.5 km across in central Europe), not
|
||||||
|
// its position, and the request goes in the body so the tile stays out of access logs. Points
|
||||||
|
// are tile-local in 0..4096 (they may lie a little outside: a road is kept up to the first
|
||||||
|
// point beyond the edge), simplified to about 1.5 m. Classes: 0 motorway and trunk, 1 primary
|
||||||
|
// and secondary, 2 tertiary and unclassified, 3 residential and living streets.
|
||||||
|
//
|
||||||
|
// Overpass is asked one tile at a time, at most once a second; a tile is kept for 30 days,
|
||||||
|
// up to mapCacheTiles of them. The tile's coordinates never appear in the log.
|
||||||
|
type Maps struct {
|
||||||
|
OverpassURL string
|
||||||
|
UserAgent string
|
||||||
|
Client *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
tiles map[mapTile]mapEntry
|
||||||
|
|
||||||
|
fetchMu sync.Mutex // one Overpass request at a time
|
||||||
|
lastFetch time.Time
|
||||||
|
gap time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
mapZoom = 14
|
||||||
|
mapExtent = 4096
|
||||||
|
mapCacheTiles = 3000
|
||||||
|
mapCacheAge = 30 * 24 * time.Hour
|
||||||
|
maxOverpassBytes = 32 << 20
|
||||||
|
// The simplification tolerance in tile units (about 0.37 m each at 52° N).
|
||||||
|
mapTolerance = 4.0
|
||||||
|
// DefaultOverpassURL is the public instance of the Overpass API.
|
||||||
|
DefaultOverpassURL = "https://overpass-api.de/api/interpreter"
|
||||||
|
// mapUserAgent names this service to Overpass, as its usage policy asks.
|
||||||
|
mapUserAgent = "nils-mg4-server/2.7 (private car dashboard; roads for an in-car map)"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mapTile struct{ Z, X, Y int }
|
||||||
|
|
||||||
|
type mapEntry struct {
|
||||||
|
body []byte
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// roadClasses maps OSM's highway values to the four classes the car draws.
|
||||||
|
var roadClasses = map[string]int{
|
||||||
|
"motorway": 0, "motorway_link": 0, "trunk": 0, "trunk_link": 0,
|
||||||
|
"primary": 1, "primary_link": 1, "secondary": 1, "secondary_link": 1,
|
||||||
|
"tertiary": 2, "tertiary_link": 2, "unclassified": 2,
|
||||||
|
"residential": 3, "living_street": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
type roadTile struct {
|
||||||
|
Z int `json:"z"`
|
||||||
|
X int `json:"x"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
Extent int `json:"extent"`
|
||||||
|
Ways []road `json:"ways"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type road struct {
|
||||||
|
C int `json:"c"`
|
||||||
|
P []int `json:"p"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMaps(overpassURL, userAgent string) *Maps {
|
||||||
|
return &Maps{OverpassURL: overpassURL, UserAgent: userAgent, gap: time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Maps) client() *http.Client {
|
||||||
|
if m.Client != nil {
|
||||||
|
return m.Client
|
||||||
|
}
|
||||||
|
return &http.Client{Timeout: 60 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tileBounds is the tile's south, west, north and east edge in degrees.
|
||||||
|
func tileBounds(t mapTile) (s, w, n, e float64) {
|
||||||
|
scale := math.Exp2(float64(t.Z))
|
||||||
|
lon := func(x float64) float64 { return x/scale*360 - 180 }
|
||||||
|
lat := func(y float64) float64 { return math.Atan(math.Sinh(math.Pi*(1-2*y/scale))) * 180 / math.Pi }
|
||||||
|
return lat(float64(t.Y + 1)), lon(float64(t.X)), lat(float64(t.Y)), lon(float64(t.X + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tilePoint is a position in the tile's own units: 0..extent across, y downwards.
|
||||||
|
func tilePoint(t mapTile, lat, lon float64) (int, int) {
|
||||||
|
scale := math.Exp2(float64(t.Z)) * mapExtent
|
||||||
|
x := (lon + 180) / 360 * scale
|
||||||
|
r := lat * math.Pi / 180
|
||||||
|
y := (1 - math.Log(math.Tan(r)+1/math.Cos(r))/math.Pi) / 2 * scale
|
||||||
|
return int(math.Round(x - float64(t.X)*mapExtent)), int(math.Round(y - float64(t.Y)*mapExtent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// overpassQuery asks for the drivable roads in the tile, with a small margin so that roads
|
||||||
|
// crossing its edge reach a point beyond it.
|
||||||
|
func overpassQuery(t mapTile) string {
|
||||||
|
s, w, n, e := tileBounds(t)
|
||||||
|
dy, dx := (n-s)*0.03, (e-w)*0.03
|
||||||
|
box := fmt.Sprintf("%.6f,%.6f,%.6f,%.6f", s-dy, w-dx, n+dy, e+dx)
|
||||||
|
return "[out:json][timeout:50];way[\"highway\"~\"^(motorway|motorway_link|trunk|trunk_link|primary|primary_link|" +
|
||||||
|
"secondary|secondary_link|tertiary|tertiary_link|unclassified|residential|living_street)$\"](" + box +
|
||||||
|
");out geom(" + box + ");"
|
||||||
|
}
|
||||||
|
|
||||||
|
type overpassResult struct {
|
||||||
|
Elements []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
Geometry []*struct {
|
||||||
|
Lat float64 `json:"lat"`
|
||||||
|
Lon float64 `json:"lon"`
|
||||||
|
} `json:"geometry"`
|
||||||
|
} `json:"elements"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTile turns Overpass's answer into the tile the car reads. A missing point (outside
|
||||||
|
// the clipped geometry) splits a road in two.
|
||||||
|
func buildTile(t mapTile, raw []byte) ([]byte, error) {
|
||||||
|
var res overpassResult
|
||||||
|
if err := json.Unmarshal(raw, &res); err != nil {
|
||||||
|
return nil, fmt.Errorf("overpass answer: %w", err)
|
||||||
|
}
|
||||||
|
out := roadTile{Z: t.Z, X: t.X, Y: t.Y, Extent: mapExtent, Ways: []road{}}
|
||||||
|
for _, el := range res.Elements {
|
||||||
|
class, ok := roadClasses[el.Tags["highway"]]
|
||||||
|
if el.Type != "way" || !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var run [][2]int
|
||||||
|
flush := func() {
|
||||||
|
if pts := simplify(run, mapTolerance); len(pts) >= 2 {
|
||||||
|
flat := make([]int, 0, len(pts)*2)
|
||||||
|
for _, p := range pts {
|
||||||
|
flat = append(flat, p[0], p[1])
|
||||||
|
}
|
||||||
|
out.Ways = append(out.Ways, road{C: class, P: flat})
|
||||||
|
}
|
||||||
|
run = nil
|
||||||
|
}
|
||||||
|
for _, g := range el.Geometry {
|
||||||
|
if g == nil {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x, y := tilePoint(t, g.Lat, g.Lon)
|
||||||
|
if n := len(run); n > 0 && run[n-1] == [2]int{x, y} {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
run = append(run, [2]int{x, y})
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// simplify is Douglas–Peucker: the points that keep the line within tol of the original.
|
||||||
|
func simplify(pts [][2]int, tol float64) [][2]int {
|
||||||
|
if len(pts) < 3 {
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
keep := make([]bool, len(pts))
|
||||||
|
keep[0], keep[len(pts)-1] = true, true
|
||||||
|
type span struct{ a, b int }
|
||||||
|
stack := []span{{0, len(pts) - 1}}
|
||||||
|
for len(stack) > 0 {
|
||||||
|
s := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
best, at := 0.0, -1
|
||||||
|
for i := s.a + 1; i < s.b; i++ {
|
||||||
|
if d := segmentDistance(pts[i], pts[s.a], pts[s.b]); d > best {
|
||||||
|
best, at = d, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at >= 0 && best > tol {
|
||||||
|
keep[at] = true
|
||||||
|
stack = append(stack, span{s.a, at}, span{at, s.b})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out [][2]int
|
||||||
|
for i, k := range keep {
|
||||||
|
if k {
|
||||||
|
out = append(out, pts[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func segmentDistance(p, a, b [2]int) float64 {
|
||||||
|
px, py := float64(p[0]), float64(p[1])
|
||||||
|
ax, ay, bx, by := float64(a[0]), float64(a[1]), float64(b[0]), float64(b[1])
|
||||||
|
dx, dy := bx-ax, by-ay
|
||||||
|
if dx == 0 && dy == 0 {
|
||||||
|
return math.Hypot(px-ax, py-ay)
|
||||||
|
}
|
||||||
|
u := math.Max(0, math.Min(1, ((px-ax)*dx+(py-ay)*dy)/(dx*dx+dy*dy)))
|
||||||
|
return math.Hypot(px-ax-u*dx, py-ay-u*dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tile returns the tile's roads as JSON, from the cache or from Overpass, and whether it
|
||||||
|
// came from the cache.
|
||||||
|
func (m *Maps) Tile(ctx context.Context, t mapTile) ([]byte, bool, error) {
|
||||||
|
if body, ok := m.cached(t); ok {
|
||||||
|
return body, true, nil
|
||||||
|
}
|
||||||
|
m.fetchMu.Lock()
|
||||||
|
defer m.fetchMu.Unlock()
|
||||||
|
// Another request may have fetched it while this one waited.
|
||||||
|
if body, ok := m.cached(t); ok {
|
||||||
|
return body, true, nil
|
||||||
|
}
|
||||||
|
if wait := m.gap - time.Since(m.lastFetch); wait > 0 {
|
||||||
|
select {
|
||||||
|
case <-time.After(wait):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, false, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.lastFetch = time.Now()
|
||||||
|
raw, err := m.fetch(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
body, err := buildTile(t, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
m.store(t, body)
|
||||||
|
return body, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Maps) cached(t mapTile) ([]byte, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
e, ok := m.tiles[t]
|
||||||
|
if !ok || time.Since(e.at) > mapCacheAge {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return e.body, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// store keeps the tile, dropping the oldest when the cache is full.
|
||||||
|
func (m *Maps) store(t mapTile, body []byte) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.tiles == nil {
|
||||||
|
m.tiles = map[mapTile]mapEntry{}
|
||||||
|
}
|
||||||
|
if len(m.tiles) >= mapCacheTiles {
|
||||||
|
var oldest mapTile
|
||||||
|
var at time.Time
|
||||||
|
for k, e := range m.tiles {
|
||||||
|
if at.IsZero() || e.at.Before(at) {
|
||||||
|
oldest, at = k, e.at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(m.tiles, oldest)
|
||||||
|
}
|
||||||
|
m.tiles[t] = mapEntry{body: body, at: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch asks Overpass. The query goes in the body, so its errors never carry the tile.
|
||||||
|
func (m *Maps) fetch(ctx context.Context, t mapTile) ([]byte, error) {
|
||||||
|
form := url.Values{"data": {overpassQuery(t)}}.Encode()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.OverpassURL, strings.NewReader(form))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", m.UserAgent)
|
||||||
|
resp, err := m.client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("overpass could not be reached")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
||||||
|
return nil, fmt.Errorf("overpass answered %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxOverpassBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("overpass answer could not be read")
|
||||||
|
}
|
||||||
|
if len(raw) > maxOverpassBytes {
|
||||||
|
return nil, errors.New("overpass answer is too large")
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) mapRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("POST /v1/map/roads", a.ingest(a.mapRoads))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) mapRoads(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
|
||||||
|
if a.Maps == nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "maps are not enabled"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Z, X, Y *int
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil || req.Z == nil || req.X == nil || req.Y == nil {
|
||||||
|
return invalid("z, x and y are required")
|
||||||
|
}
|
||||||
|
if *req.Z != mapZoom {
|
||||||
|
return invalid("z must be %d", mapZoom)
|
||||||
|
}
|
||||||
|
limit := 1 << mapZoom
|
||||||
|
if *req.X < 0 || *req.X >= limit || *req.Y < 0 || *req.Y >= limit {
|
||||||
|
return invalid("x and y must be within the zoom level")
|
||||||
|
}
|
||||||
|
t := mapTile{Z: *req.Z, X: *req.X, Y: *req.Y}
|
||||||
|
data, cached, err := a.Maps.Tile(r.Context(), t)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("map tile not available", "installation", who(c), "error", err)
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "map data not available, try again later"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !cached {
|
||||||
|
slog.Info("map tile fetched", "installation", who(c), "ways", strings.Count(string(data), `"c":`),
|
||||||
|
"bytes", len(data))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&buf)
|
||||||
|
_, _ = zw.Write(data)
|
||||||
|
_ = zw.Close()
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
data = buf.Bytes()
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A tile in central Berlin.
|
||||||
|
var testTile = mapTile{Z: 14, X: 8802, Y: 5373}
|
||||||
|
|
||||||
|
// fakeOverpass answers every query with a primary road (with a gap in its geometry), a
|
||||||
|
// residential street with a needless middle point, and a footway the car does not draw.
|
||||||
|
func fakeOverpass(t *testing.T, calls *atomic.Int32, status int) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
s, w, n, e := tileBounds(testTile)
|
||||||
|
lat := func(f float64) float64 { return s + (n-s)*f }
|
||||||
|
lon := func(f float64) float64 { return w + (e-w)*f }
|
||||||
|
answer := fmt.Sprintf(`{"elements":[
|
||||||
|
{"type":"way","tags":{"highway":"primary"},"geometry":[
|
||||||
|
{"lat":%f,"lon":%f},{"lat":%f,"lon":%f},null,{"lat":%f,"lon":%f},{"lat":%f,"lon":%f}]},
|
||||||
|
{"type":"way","tags":{"highway":"residential"},"geometry":[
|
||||||
|
{"lat":%f,"lon":%f},{"lat":%f,"lon":%f},{"lat":%f,"lon":%f}]},
|
||||||
|
{"type":"way","tags":{"highway":"footway"},"geometry":[
|
||||||
|
{"lat":%f,"lon":%f},{"lat":%f,"lon":%f}]}]}`,
|
||||||
|
lat(0.1), lon(0.1), lat(0.2), lon(0.2), lat(0.6), lon(0.6), lat(0.7), lon(0.7),
|
||||||
|
lat(0.5), lon(0.1), lat(0.5), lon(0.5), lat(0.5), lon(0.9),
|
||||||
|
lat(0.3), lon(0.3), lat(0.4), lon(0.4))
|
||||||
|
return httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if r.Method != http.MethodPost || !strings.Contains(r.UserAgent(), "nils-mg4-server") {
|
||||||
|
t.Errorf("request %s %q", r.Method, r.UserAgent())
|
||||||
|
}
|
||||||
|
_ = r.ParseForm()
|
||||||
|
if q := r.PostForm.Get("data"); !strings.Contains(q, "out geom(") || !strings.Contains(q, "residential") {
|
||||||
|
t.Errorf("query %q", q)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
http.Error(rw, "busy", status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = rw.Write([]byte(answer))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapsAPI(overpass *httptest.Server) http.Handler {
|
||||||
|
api, _ := newTestAPI()
|
||||||
|
api.Maps = NewMaps(overpass.URL, mapUserAgent)
|
||||||
|
api.Maps.Client = overpass.Client()
|
||||||
|
api.Maps.gap = 0
|
||||||
|
return api.Routes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func tileBody(t mapTile) string {
|
||||||
|
return fmt.Sprintf(`{"z":%d,"x":%d,"y":%d}`, t.Z, t.X, t.Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapTileRoadsAreClassifiedSplitAndSimplified(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
overpass := fakeOverpass(t, &calls, http.StatusOK)
|
||||||
|
defer overpass.Close()
|
||||||
|
h := mapsAPI(overpass)
|
||||||
|
if rec := do(t, h, "POST", "/v1/map/roads", "", tileBody(testTile)); rec.Code != 401 {
|
||||||
|
t.Fatalf("without the car's token: %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("%d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got roadTile
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Extent != 4096 || got.X != testTile.X || got.Y != testTile.Y {
|
||||||
|
t.Fatalf("tile %+v", got)
|
||||||
|
}
|
||||||
|
// The primary road in two pieces, the street straightened to its ends, no footway.
|
||||||
|
if len(got.Ways) != 3 || got.Ways[0].C != 1 || got.Ways[1].C != 1 || got.Ways[2].C != 3 {
|
||||||
|
t.Fatalf("ways %+v", got.Ways)
|
||||||
|
}
|
||||||
|
if len(got.Ways[2].P) != 4 {
|
||||||
|
t.Fatalf("the straight street keeps only its ends: %v", got.Ways[2].P)
|
||||||
|
}
|
||||||
|
// Tile-local: 0.1 of the way in is about 410 units from the west edge.
|
||||||
|
if x := got.Ways[0].P[0]; x < 400 || x > 420 {
|
||||||
|
t.Fatalf("x %d", x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cached: the second car asking costs Overpass nothing.
|
||||||
|
if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 200 {
|
||||||
|
t.Fatalf("%d", rec.Code)
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("overpass asked %d times", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapTilesAreGzippedWhenTheCarAcceptsIt(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
overpass := fakeOverpass(t, &calls, http.StatusOK)
|
||||||
|
defer overpass.Close()
|
||||||
|
req := httptest.NewRequest("POST", "/v1/map/roads", strings.NewReader(tileBody(testTile)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mapsAPI(overpass).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "gzip" {
|
||||||
|
t.Fatalf("%d %v", rec.Code, rec.Header())
|
||||||
|
}
|
||||||
|
zr, err := gzip.NewReader(rec.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plain, _ := io.ReadAll(zr)
|
||||||
|
if !bytes.Contains(plain, []byte(`"extent":4096`)) {
|
||||||
|
t.Fatalf("%s", plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapRequestsAreChecked(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
overpass := fakeOverpass(t, &calls, http.StatusOK)
|
||||||
|
defer overpass.Close()
|
||||||
|
h := mapsAPI(overpass)
|
||||||
|
for _, body := range []string{`{}`, `{"z":15,"x":1,"y":1}`, `{"z":14,"x":-1,"y":1}`,
|
||||||
|
`{"z":14,"x":1,"y":16384}`, `not json`} {
|
||||||
|
if rec := do(t, h, "POST", "/v1/map/roads", testToken, body); rec.Code != 400 {
|
||||||
|
t.Errorf("%s: %d", body, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 0 {
|
||||||
|
t.Fatal("a bad request reached Overpass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestABusyOverpassIsATemporaryFailureAndNotCached(t *testing.T) {
|
||||||
|
var logged bytes.Buffer
|
||||||
|
previous := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
|
||||||
|
defer slog.SetDefault(previous)
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
overpass := fakeOverpass(t, &calls, http.StatusTooManyRequests)
|
||||||
|
defer overpass.Close()
|
||||||
|
h := mapsAPI(overpass)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 503 {
|
||||||
|
t.Fatalf("%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("a failure was cached: %d calls", calls.Load())
|
||||||
|
}
|
||||||
|
// Where the car is stays out of the log.
|
||||||
|
out := logged.String()
|
||||||
|
if !strings.Contains(out, "overpass answered 429") || strings.Contains(out, "8802") ||
|
||||||
|
strings.Contains(out, "5373") {
|
||||||
|
t.Fatalf("log:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapsAreOffWithoutOverpass(t *testing.T) {
|
||||||
|
api, _ := newTestAPI()
|
||||||
|
if rec := do(t, api.Routes(), "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 404 {
|
||||||
|
t.Fatalf("%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverpassURLConfiguration(t *testing.T) {
|
||||||
|
base := map[string]string{"INFLUX_URL": "http://influx", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
|
||||||
|
"INGEST_TOKEN": "0123456789abcdef01234567"}
|
||||||
|
load := func(overpass string) (config, error) {
|
||||||
|
return loadConfig(func(k string) string {
|
||||||
|
if k == "OVERPASS_URL" {
|
||||||
|
return overpass
|
||||||
|
}
|
||||||
|
return base[k]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if c, err := load(""); err != nil || c.overpassURL != DefaultOverpassURL {
|
||||||
|
t.Fatalf("default: %q %v", c.overpassURL, err)
|
||||||
|
}
|
||||||
|
if c, err := load("off"); err != nil || c.overpassURL != "" {
|
||||||
|
t.Fatalf("off: %q %v", c.overpassURL, err)
|
||||||
|
}
|
||||||
|
if _, err := load("http://overpass.example.com/api/interpreter"); err == nil {
|
||||||
|
t.Fatal("plain http accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTilePointsAndSimplification(t *testing.T) {
|
||||||
|
s, w, n, e := tileBounds(testTile)
|
||||||
|
if x, y := tilePoint(testTile, n, w); x != 0 || y != 0 {
|
||||||
|
t.Fatalf("north-west corner %d,%d", x, y)
|
||||||
|
}
|
||||||
|
if x, y := tilePoint(testTile, s, e); x != 4096 || y != 4096 {
|
||||||
|
t.Fatalf("south-east corner %d,%d", x, y)
|
||||||
|
}
|
||||||
|
line := [][2]int{{0, 0}, {10, 1}, {20, 0}, {30, 40}}
|
||||||
|
got := simplify(line, 4)
|
||||||
|
if len(got) != 3 || got[1] != [2]int{20, 0} {
|
||||||
|
t.Fatalf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user