Author SHA1 Message Date
nilsandClaude Opus 5.5 69b1adee2b App 2.40.0: Grid with map straight from above, full height
The map fills the whole screen, top-down and heading up, about 400 m
ahead and 450 m to each side, over a ground grid that turns with it.
The speed moves to a panel under the gears; the battery wall, the
clock, the gears and the disc stand on dark panels over the map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:12:06 +01:00
nilsandClaude Opus 5.5 90c2c75fc6 SPECS: app 2.39.2 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:06:51 +01:00
nilsandClaude Opus 5.5 2d7b324899 App 2.39.2: Grid with map seen from higher up
The map gets its own camera, tilted 45 degrees down from about 300 m up,
so roads keep their shape some 400 m ahead instead of collapsing into
the horizon. In map mode the floor starts at half height, the speed is a
little smaller, the floor's grid is fixed to the ground and turns with
the roads, and the power disc has a dark backing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:06:07 +01:00
nilsandClaude Opus 5.5 09cbd21634 k8s: pin server 2.8.0; app 2.39.1 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 23:56:28 +01:00
nilsandClaude Opus 5.5 6be2ba5193 App 2.39.1, server 2.8.0: map roads from OpenFreeMap, on demand
The public Overpass answered 429/502 and needs ~40 s a tile while the
car gives up after 8 s, so the server cancelled its queries and the
car's retries added load. Now the server fetches only the tiles a car
asks for, as OpenStreetMap vector tiles from OpenFreeMap (MAP_TILES_URL),
decodes the transportation layer with a small reader of its own, and
keeps each tile 30 days on a volume (MAP_CACHE_DIR). A fetch finishes
even after the car has stopped waiting; the car hears 503 with
Retry-After and gets the tile on its next try.

App: OpenFreeMap/OpenMapTiles attribution on the map, retry after 15 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 23:54:30 +01:00
nilsandClaude Opus 5.5 d9c7c5ea9a k8s: pin server 2.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 23:39:42 +01:00
nilsandClaude Opus 5.5 65dc1ba847 App 2.39.0, server 2.7.0: Grid with map
A new Overview style: the Grid over the roads round the car from
OpenStreetMap, drawn in the floor's perspective, heading up, the bigger
roads glowing brighter, the car as a chevron.

Server: POST /v1/map/roads {z:14,x,y} fetches a tile's drivable roads
from Overpass (OVERPASS_URL; one request at a time, at least a second
apart), simplifies them to tile-local points in four classes, caches
them for 30 days and never logs the tile.

App: MapRoads keeps the 3x3 tiles round the car from its own cache or
the server; GPS only while the style is shown; bearing from the GPS or
from successive positions. Without GPS, server or roads the plain floor
says why. OSM attribution on screen and in LICENSE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 23:34:21 +01:00
nilsandClaude Opus 5.5 776a11b182 SPECS: the car updated itself to 2.38.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 23:08:00 +01:00
25 changed files with 1948 additions and 25 deletions
+4 -3
View File
@@ -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 vector tiles (OpenFreeMap), fetched per tile on demand and cached on a volume. 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,
+8
View File
@@ -18,6 +18,14 @@ 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/), in vector tiles
© OpenMapTiles served by [OpenFreeMap](https://openfreemap.org). The style shows
"OpenFreeMap © OpenMapTiles · © OpenStreetMap contributors" whenever it draws them. The server
fetches the tiles (`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
+5 -4
View File
@@ -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 (OpenStreetMap, through OpenFreeMap). 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") colours the whole app to match, e.g. turquoise for Retro. 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 only that tile from OpenFreeMap's OpenStreetMap vector tiles and keeps it, and the GPS runs for it only while that style is on screen. 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 |
+71 -1
View File
@@ -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,76 @@ 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-27 — App 2.40.0: **Grid with map straight from above, full height** (owner: 2.39.2
"does not look right"; wanted the map over the whole height and fully top-down). The map is
the whole background in map mode: orthographic, heading up, 1.2 layout units per metre (about
400 m ahead, 125 m behind, 450 m to each side on the car's screen), the car 150 units above
the bottom edge; ground grid every 50 m fixed to the ground; roads from 250 m ahead fainter;
the camera, the sky and the floor are gone there. The speed moves to a panel under the gears
(120 units), and the battery wall, the clock, the gears and the disc get dark panels. The
plain Grid is unchanged. Emulator with the Berlin tiles, both layouts. Not tried on the car.
- 2026-10-27 — App 2.39.2 published (`car-2.39.2`).
- 2026-10-27 — App 2.39.2: **Grid with map seen from higher up** (owner: the map looked like a
fish-eye; wanted it more top-down). The map has its own pinhole camera now: 45° down, 300 m
up, the car on its axis just above the read-outs, the top of the floor about 420 m ahead
(scale there ~60 % of the car's, before ~1/20 at 900 m); the floor starts at half height in
map mode and the speed is 170 instead of 210 units; the floor's grid becomes a ground grid
(a line every 50 m, fixed to the ground, turning with the roads); roads from 250 m ahead
fainter; the power disc gets a dark backing over the map. Emulator with the Berlin tiles,
both layouts. Not tried on the car.
- 2026-10-26 — Server 2.8.0 image pushed and pinned in `k8s/server.yaml` by digest
`sha256:8b8d94e0562833d196162866b7fdc6910d5dd219f6eb84572d4d5c91b74f7ff1` (checked with skopeo);
app 2.39.1 published (`car-2.39.1`). Not applied yet: the owner applies it.
- 2026-10-26 — App 2.39.1 and server 2.8.0: **the map's roads come from OpenFreeMap, on
demand.** In the car, server 2.7.0 logged `overpass answered 502`, `overpass could not be
reached`, and the owner saw 429s: the public Overpass limits per IP, and a tile takes it ~40 s
while the car gives up after 8 s (`ServerHttp.TIMEOUT_MS`) — the server then cancelled the
query, threw the work away, and the car's retries added to the load. Owner's choice: fetch
only the tiles the car asks for (a self-hosted Overpass would need a whole region imported).
Server: the zoom-14 OpenStreetMap vector tile (OpenMapTiles schema) from the TileJSON at
`MAP_TILES_URL` (default OpenFreeMap, free, no key, no limits; its template re-read every 6 h,
https only), decoded by our own minimal protobuf/MVT reader (`mvt.go`, the `transportation`
layer; classes motorway/trunk, primary/secondary, tertiary, minor), simplified as before. A
fetch runs to its end without the car (requests wait ≤ 6 s, then 503 with `Retry-After: 10`),
at most two at once, one per tile; tiles kept 30 days in `MAP_CACHE_DIR` (a 1 Gi volume
`mg4-server-maps`, pod `fsGroup` 65532) and the last 300 in memory; `OVERPASS_URL` is gone.
Tests with an encoded vector tile and a fake tile host (classes, split lines, scaled extent,
malformed tiles, gzip from the host, fetched once and kept across a restart, a slow fetch
finished for the next try, failures not cached and no tile in the log, http template refused,
config). Real: the 9 Berlin tiles in 0.7 s (9–15 KB each). App 2.39.1: "OpenFreeMap ©
OpenMapTiles · © OpenStreetMap contributors" on the map, a tile the server did not have yet is
asked for again after 15 s (was a minute). Emulator with those tiles: same roads as before.
Not tried: the deployed server and the car.
- 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
+19 -2
View File
@@ -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,22 @@ films' other side.
![Grid with the navigation](screenshots/themes/grid-panel.png) ![Grid with the navigation](screenshots/themes/grid-panel.png)
![Grid, full screen](screenshots/themes/grid.png) ![Grid, full screen](screenshots/themes/grid.png)
## Grid with map
The Grid over a map: the roads round the car from OpenStreetMap (through OpenFreeMap) fill the
whole screen as light lanes, straight from above and turned with the direction you drive, about
400 m ahead and 450 m to each side: motorways glow brightest, then main roads, then the smaller
streets, the far ones fainter, over a ground grid every 50 m that turns and slides with them.
The speed, the battery wall, the power disc and the clock stand on dark panels over the map. 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 only the map tiles you drive through 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.
![Grid with map, with the navigation](screenshots/themes/grid-map-panel.png)
![Grid with map, full screen](screenshots/themes/grid-map.png)
## 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
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 141 versionCode 145
versionName "2.38.0" versionName "2.40.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,15 @@ 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
* under everything, heading up, the bigger ones glowing brighter, with the car as a light
* marker low on the screen; between GPS fixes the car's position runs on
* with the speed. The map is then the whole background, straight from above (about
* 400 m ahead, 450 m to each side), over a ground grid (a line every 50 m) that turns and slides
* with the roads; the speed, the battery wall, the disc and the clock stand on dark panels over
* it, 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 +41,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, straight from above: pixels (in layout units) per metre, the car's height above
* the bottom edge, roads from FADE_M ahead fainter, a ground-grid line every GRID_M.
*/
private static final float MAP_SCALE = 1.2f, CAR_FROM_BOTTOM = 150f, FADE_M = 250f, GRID_M = 50f;
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 +58,14 @@ 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_M} ahead). */
private final Path[] roads = new Path[RoadTile.CLASSES * 2];
private float heading = Float.NaN;
private final Path gridPath = new Path();
/** This frame's map: pixels per metre and where the car is on screen. */
private float mapPx, carSx, carSy;
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 +74,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);
@@ -80,19 +108,31 @@ final class GridView extends ClusterCanvasView {
advance(); advance();
c.drawColor(0xFF000000); c.drawColor(0xFF000000);
// With a map, the map is the whole background, seen from straight above, and the rest
// stands on dark panels over it.
boolean mapShown = map != null && map.fix() != null && !map.tiles().isEmpty();
float horizon = h * 0.55f; float horizon = h * 0.55f;
drawSky(c, w, horizon); if (mapShown) {
drawFloor(c, w, h, horizon, u); drawMap(c, w, h, u);
} else {
drawSky(c, w, horizon);
drawFloor(c, w, h, horizon, u);
}
if (map != null && !mapShown) drawMapState(c, w, h, horizon, u);
if (mapShown) drawPanels(c, w, horizon, u);
drawGears(c, w, u); drawGears(c, w, u);
drawClock(c, w, u); drawClock(c, w, u);
drawSpeed(c, w, horizon, u); if (mapShown) drawMapSpeed(c, w, u);
else drawSpeed(c, w, horizon, u);
drawWall(c, horizon, u); drawWall(c, horizon, u);
drawDisc(c, w, u); drawDisc(c, w, mapShown, 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. */
@@ -138,6 +178,171 @@ 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 from straight above, heading up, over a ground grid. */
private void drawMap(Canvas c, float w, float h, float u) {
MapRoads.Fix fix = map.fix();
java.util.Collection<RoadTile> tiles = map.tiles();
if (fix == null) return;
// 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;
mapPx = MAP_SCALE * u;
carSx = w / 2f;
carSy = h - CAR_FROM_BOTTOM * u;
float sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading));
float m = (float) mpu;
// Everything on screen is within this many metres of the car.
float reach = (float) Math.hypot(w / 2f, carSy) / mapPx + GRID_M;
double range = reach / mpu;
// The ground grid, fixed to the ground: it turns and slides with the roads.
gridPath.reset();
double carEast = carX * mpu, carNorth = -carY * mpu;
float offE = (float) (carEast % GRID_M), offN = (float) (carNorth % GRID_M);
int lines = (int) (reach / GRID_M) + 1;
for (int k = -lines; k <= lines; k++) {
float e = k * GRID_M - offE, n = k * GRID_M - offN;
gridLine(e, -reach, e, reach, sin, cos);
gridLine(-reach, n, reach, n, sin, cos);
}
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 + MapGeometry.EXTENT < -range || oy + 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];
float pa = 0f, pf = 0f;
Path prev = null;
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) {
Path p = roads[cls * 2 + (Math.min(pf, fwd) > FADE_M ? 1 : 0)];
if (p != prev) p.moveTo(screenX(pa), screenY(pf));
p.lineTo(screenX(side), screenY(fwd));
prev = p;
}
pa = side;
pf = fwd;
}
}
}
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
line.setColor(AccentColor.withAlpha(main, 0x26));
line.setStrokeWidth(1f * u);
c.drawPath(gridPath, line);
// Far roads thinner and fainter.
for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) {
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x60 : 0x90));
line.setStrokeWidth((cls == 0 ? 2.4f : cls == 3 ? 1.2f : 1.8f) * u);
c.drawPath(roads[cls * 2 + 1], line);
}
line.setColor(AccentColor.withAlpha(main, 0xA0));
line.setStrokeWidth(1.8f * u);
c.drawPath(roads[3 * 2], line);
line.setColor(AccentColor.withAlpha(main, 0xD8));
line.setStrokeWidth(2.4f * u);
c.drawPath(roads[2 * 2], line);
glow(c, roads[2], main, 2.8f * u);
glow(c, roads[0], light, 3.6f * u);
line.setStrokeJoin(Paint.Join.MITER);
line.setStrokeCap(Paint.Cap.SQUARE);
// The car: a light-cycle chevron pointing ahead.
path.reset();
path.moveTo(carSx, carSy - 18f * u);
path.lineTo(carSx + 12f * u, carSy + 11f * u);
path.lineTo(carSx, carSy + 5f * u);
path.lineTo(carSx - 12f * u, carSy + 11f * u);
path.close();
fill.setColor(light);
c.drawPath(path, fill);
glow(c, path, light, 1.5f * u);
}
private float screenX(float side) {
return carSx + side * mapPx;
}
private float screenY(float ahead) {
return carSy - ahead * mapPx;
}
/** A grid line between two ground points given east and north of the car, in metres. */
private void gridLine(float e0, float n0, float e1, float n1, float sin, float cos) {
gridPath.moveTo(screenX(e0 * cos - n0 * sin), screenY(e0 * sin + n0 * cos));
gridPath.lineTo(screenX(e1 * cos - n1 * sin), screenY(e1 * sin + n1 * cos));
}
/** Dark panels under the battery wall and the clock, so the map does not run through them. */
private void drawPanels(Canvas c, float w, float horizon, float u) {
chamfer(16f * u, 78f * u, 360f * u, horizon + 86f * u, 14f * u);
fill.setColor(0xC8000000);
c.drawPath(path, fill);
chamfer(w - 210f * u, 12f * u, w - 12f * u, 82f * u, 10f * u);
c.drawPath(path, fill);
// Behind the gears as well.
chamfer(w / 2f - 150f * u, 12f * u, w / 2f + 150f * u, 72f * u, 10f * u);
c.drawPath(path, fill);
}
/** The speed over the map: smaller, on a panel under the gears. */
private void drawMapSpeed(Canvas c, float w, float u) {
chamfer(w / 2f - 130f * u, 80f * u, w / 2f + 130f * u, 232f * u, 14f * u);
fill.setColor(0xC8000000);
c.drawPath(path, fill);
line.setColor(dim);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
text.setTypeface(thin);
text.setTextSize(120f * u);
text.setTextAlign(Paint.Align.CENTER);
glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, 194f * u, light, u);
caption(c, "KM/H", w / 2f, 220f * u, u, Paint.Align.CENTER);
}
/** 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);
}
/** The tiles' and the data's licences ask for their names wherever the map 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("OpenFreeMap © OpenMapTiles · © 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) {
@@ -225,8 +430,13 @@ final class GridView extends ClusterCanvasView {
// ---------- Power disc ---------- // ---------- Power disc ----------
/** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */ /** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */
private void drawDisc(Canvas c, float w, float u) { private void drawDisc(Canvas c, float w, boolean overMap, float u) {
float cx = w - 170f * u, cy = 238f * u, outer = 132f * u, ring = 22f * u; float cx = w - 170f * u, cy = 238f * u, outer = 132f * u, ring = 22f * u;
if (overMap) {
// The map runs under the disc: a dark backing keeps it readable.
fill.setColor(0xD0000000);
c.drawCircle(cx, cy, outer + 16f * u, fill);
}
line.setColor(dim); line.setColor(dim);
line.setStrokeWidth(1.5f * u); line.setStrokeWidth(1.5f * u);
c.drawCircle(cx, cy, outer + 10f * u, line); c.drawCircle(cx, cy, outer + 10f * u, line);
@@ -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,199 @@
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;
/** A tile the server did not have yet (it goes on fetching) is asked for again after this. */
private static final long RETRY_MS = 15_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;
+1
View File
@@ -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));
} }
} }
+32 -2
View File
@@ -1,5 +1,21 @@
# The car server (Go). One replica on purpose: pending pairing requests and the wrong-password # The car server (Go). One replica on purpose: pending pairing requests and the wrong-password
# counters are kept in memory. # counters are kept in memory.
# The roads of the app's Grid map, fetched on demand, are kept on this small volume.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mg4-server-maps
namespace: mg4
labels:
app.kubernetes.io/name: mg4-server
app.kubernetes.io/part-of: mg4
spec:
accessModes: ["ReadWriteOnce"]
# No storageClassName: the cluster's default class is used.
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -25,12 +41,13 @@ spec:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 65532 runAsUser: 65532
runAsGroup: 65532 runAsGroup: 65532
fsGroup: 65532
seccompProfile: seccompProfile:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
- name: server - name: server
# 2.6.2, pinned by digest so a re-pushed tag cannot change what runs. # Pinned by digest so a re-pushed tag cannot change what runs (version in SPECS.md).
image: git.stinnesbeck.com/nils/mg4-server@sha256:e4de1e75958d58ece8430a1913f9c1614d8ea1bf566f2e1ec5b449ef51844f77 image: git.stinnesbeck.com/nils/mg4-server@sha256:8b8d94e0562833d196162866b7fdc6910d5dd219f6eb84572d4d5c91b74f7ff1
ports: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080
@@ -50,6 +67,12 @@ 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: OpenStreetMap vector tiles, fetched only for the
# tiles a car asks for and kept on the volume below; "off" switches maps off.
- name: MAP_TILES_URL
value: https://tiles.openfreemap.org/planet
- name: MAP_CACHE_DIR
value: /var/cache/mg4-maps
- name: INFLUX_TOKEN - name: INFLUX_TOKEN
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@@ -81,6 +104,13 @@ spec:
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
capabilities: capabilities:
drop: ["ALL"] drop: ["ALL"]
volumeMounts:
- name: maps
mountPath: /var/cache/mg4-maps
volumes:
- name: maps
persistentVolumeClaim:
claimName: mg4-server-maps
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+22
View File
@@ -23,6 +23,8 @@ 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 |
| `MAP_TILES_URL` | a TileJSON naming the vector tiles the map's roads come from; default OpenFreeMap's `https://tiles.openfreemap.org/planet`, `off` switches maps off, otherwise https only |
| `MAP_CACHE_DIR` | where fetched map tiles are kept across restarts (the manifest mounts a volume there); empty keeps them in memory only |
| `LISTEN_ADDR` | default `:8080` | | `LISTEN_ADDR` | default `:8080` |
## Endpoints ## Endpoints
@@ -57,6 +59,26 @@ 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 minor
streets) 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, 3 minor streets) and its points in tile units, simplified to
about 1.5 m; gzipped when the car accepts it (10–15 KB a tile in a city before that).
The roads are fetched **only for the tiles a car asks for**: the OpenStreetMap vector tile
(OpenMapTiles schema) of that square from the TileJSON at `MAP_TILES_URL` (by default
OpenFreeMap: free, no key, no request limits; its template moves with each weekly build and is
looked up every 6 hours), decoded by a small reader of our own (`mvt.go`, the `transportation`
layer only). Each tile is fetched once and kept for 30 days in `MAP_CACHE_DIR` (and the last 300
in memory); at most two fetches run at once. A request waits up to 6 s; a slower fetch goes on
and is kept, and the car hears 503 with `Retry-After: 10` and gets the tile on its next try.
400 for anything but zoom 14 or a tile outside it, 503 when the tile host fails (not cached),
404 when maps are off. The log says `map tile fetched` with the size, never the tile.
Map data © OpenStreetMap contributors (ODbL), tiles © OpenMapTiles, served by OpenFreeMap.
## 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)
+4
View File
@@ -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 vector tiles (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
} }
+27 -1
View File
@@ -34,6 +34,8 @@ type config struct {
forgeURL string forgeURL string
forgeRepo string forgeRepo string
forgeToken string forgeToken string
mapTilesURL string
mapCacheDir 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 +53,8 @@ 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"),
mapTilesURL: getenv("MAP_TILES_URL"),
mapCacheDir: getenv("MAP_CACHE_DIR"),
} }
if c.listenAddr == "" { if c.listenAddr == "" {
c.listenAddr = ":8080" c.listenAddr = ":8080"
@@ -73,6 +77,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 vector tiles named by a TileJSON: OpenFreeMap unless another is
// named, none with "off". Only https, like the forge.
switch c.mapTilesURL {
case "":
c.mapTilesURL = DefaultMapTilesURL
case "off":
c.mapTilesURL = ""
default:
if !strings.HasPrefix(c.mapTilesURL, "https://") {
return config{}, errors.New("MAP_TILES_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 +118,15 @@ 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.mapTilesURL != "" {
if cfg.mapCacheDir != "" {
if err := os.MkdirAll(cfg.mapCacheDir, 0o700); err != nil {
slog.Error("map cache directory not usable", "error", err)
os.Exit(1)
}
}
api.Maps = NewMaps(cfg.mapTilesURL, cfg.mapCacheDir, mapUserAgent)
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.listenAddr, Addr: cfg.listenAddr,
@@ -116,7 +141,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)
+426
View File
@@ -0,0 +1,426 @@
package main
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Maps serves the roads around the car for the Overview's map (the "Grid with map" style). It
// fetches only the tiles a car asks for, as OpenStreetMap vector tiles (OpenMapTiles schema,
// by default from OpenFreeMap), keeps their roads and stores them, so each tile is fetched
// once and the car only ever talks to this server.
//
// 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 in the body so the tile stays out of access logs. Points are tile-local
// in 0..4096 (a little beyond at the edges), simplified to about 1.5 m. Classes: 0 motorway and
// trunk, 1 primary and secondary, 2 tertiary, 3 minor streets (residential, unclassified,
// living streets).
//
// A fetch runs to its end even when the car stops waiting (the car gives up after a few
// seconds), so its next try finds the tile. Tiles are kept in memory (the last mapMemoryTiles)
// and, with a cache directory, on disk for mapCacheAge. The tile's coordinates never appear
// in the log.
type Maps struct {
// TileJSONURL names the vector tiles: a TileJSON document whose first "tiles" template is
// used (OpenFreeMap moves its template with every weekly build).
TileJSONURL string
// CacheDir keeps fetched tiles across restarts; empty keeps them in memory only.
CacheDir string
UserAgent string
Client *http.Client
mu sync.Mutex
memory map[mapTile]mapEntry
inflight map[mapTile]*mapFlight
template string
tmplAt time.Time
// slots limits fetches running at once.
slots chan struct{}
}
const (
mapZoom = 14
mapExtent = 4096
mapMemoryTiles = 300
mapCacheAge = 30 * 24 * time.Hour
maxTileBytes = 8 << 20
mapFetchTime = 60 * time.Second
templateAge = 6 * time.Hour
// The simplification tolerance in tile units (about 0.37 m each at 52° N).
mapTolerance = 4.0
// DefaultMapTilesURL is OpenFreeMap's TileJSON: free OpenStreetMap vector tiles, no key.
DefaultMapTilesURL = "https://tiles.openfreemap.org/planet"
// mapUserAgent names this service to the tile host.
mapUserAgent = "nils-mg4-server/2.8 (private car dashboard; roads for an in-car map)"
)
// mapWait is how long a request waits for a fetch: less than the car's own 8 s.
var mapWait = 6 * time.Second
type mapTile struct{ Z, X, Y int }
type mapEntry struct {
body []byte
at time.Time
}
type mapFlight struct {
done chan struct{}
body []byte
err error
}
// roadClasses maps the OpenMapTiles transportation classes to the four the car draws; the
// rest (service, track, path, rail, ferry…) are left out.
var roadClasses = map[string]int{
"motorway": 0, "trunk": 0,
"primary": 1, "secondary": 1,
"tertiary": 2,
"minor": 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(tileJSONURL, cacheDir, userAgent string) *Maps {
return &Maps{TileJSONURL: tileJSONURL, CacheDir: cacheDir, UserAgent: userAgent}
}
func (m *Maps) client() *http.Client {
if m.Client != nil {
return m.Client
}
return &http.Client{Timeout: mapFetchTime}
}
// Tile returns the tile's roads as JSON and whether it came from a cache. Waiting ends with
// ctx; the fetch itself goes on and is kept.
func (m *Maps) Tile(ctx context.Context, t mapTile) ([]byte, bool, error) {
if body, ok := m.cached(t); ok {
return body, true, nil
}
m.mu.Lock()
if m.inflight == nil {
m.inflight = map[mapTile]*mapFlight{}
m.slots = make(chan struct{}, 2)
}
f, running := m.inflight[t]
if !running {
f = &mapFlight{done: make(chan struct{})}
m.inflight[t] = f
go m.run(t, f)
}
m.mu.Unlock()
select {
case <-f.done:
return f.body, false, f.err
case <-ctx.Done():
return nil, false, errors.New("still fetching")
}
}
func (m *Maps) run(t mapTile, f *mapFlight) {
defer func() {
m.mu.Lock()
delete(m.inflight, t)
m.mu.Unlock()
close(f.done)
}()
m.slots <- struct{}{}
defer func() { <-m.slots }()
ctx, cancel := context.WithTimeout(context.Background(), mapFetchTime)
defer cancel()
raw, err := m.fetch(ctx, t)
if err == nil {
f.body, err = buildTile(t, raw)
}
if err != nil {
f.err = err
return
}
m.store(t, f.body)
slog.Info("map tile fetched", "ways", strings.Count(string(f.body), `"c":`), "bytes", len(f.body))
}
func (m *Maps) cachePath(t mapTile) string {
return filepath.Join(m.CacheDir, fmt.Sprintf("%d_%d_%d.json", t.Z, t.X, t.Y))
}
func (m *Maps) cached(t mapTile) ([]byte, bool) {
m.mu.Lock()
e, ok := m.memory[t]
m.mu.Unlock()
if ok && time.Since(e.at) <= mapCacheAge {
return e.body, true
}
if m.CacheDir == "" {
return nil, false
}
info, err := os.Stat(m.cachePath(t))
if err != nil || time.Since(info.ModTime()) > mapCacheAge {
return nil, false
}
body, err := os.ReadFile(m.cachePath(t))
if err != nil || !json.Valid(body) {
return nil, false
}
m.remember(t, body, info.ModTime())
return body, true
}
func (m *Maps) store(t mapTile, body []byte) {
m.remember(t, body, time.Now())
if m.CacheDir == "" {
return
}
tmp := m.cachePath(t) + ".tmp"
if err := os.WriteFile(tmp, body, 0o600); err != nil {
slog.Warn("map tile not stored", "error", err)
return
}
if err := os.Rename(tmp, m.cachePath(t)); err != nil {
slog.Warn("map tile not stored", "error", err)
}
}
// remember keeps the tile in memory, dropping the oldest when full.
func (m *Maps) remember(t mapTile, body []byte, at time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
if m.memory == nil {
m.memory = map[mapTile]mapEntry{}
}
if _, ok := m.memory[t]; !ok && len(m.memory) >= mapMemoryTiles {
var oldest mapTile
var first time.Time
for k, e := range m.memory {
if first.IsZero() || e.at.Before(first) {
oldest, first = k, e.at
}
}
delete(m.memory, oldest)
}
m.memory[t] = mapEntry{body: body, at: at}
}
func (m *Maps) get(ctx context.Context, u string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", m.UserAgent)
resp, err := m.client().Do(req)
if err != nil {
// The URL holds the tile: name the failure, not the request.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, errors.New("tile host timed out")
}
return nil, errors.New("tile host 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("tile host answered %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, errors.New("tile host answer could not be read")
}
if int64(len(body)) > limit {
return nil, errors.New("tile host answer is too large")
}
return body, nil
}
// tileTemplate is the current {z}/{x}/{y} URL from the TileJSON, looked up every few hours.
func (m *Maps) tileTemplate(ctx context.Context) (string, error) {
m.mu.Lock()
tmpl, at := m.template, m.tmplAt
m.mu.Unlock()
if tmpl != "" && time.Since(at) < templateAge {
return tmpl, nil
}
body, err := m.get(ctx, m.TileJSONURL, 1<<20)
if err != nil {
return "", err
}
var doc struct {
Tiles []string `json:"tiles"`
}
if err := json.Unmarshal(body, &doc); err != nil || len(doc.Tiles) == 0 {
return "", errors.New("tile index has no tiles")
}
tmpl = doc.Tiles[0]
u, err := url.Parse(strings.NewReplacer("{z}", "0", "{x}", "0", "{y}", "0").Replace(tmpl))
if err != nil || u.Scheme != "https" || u.Host == "" ||
!strings.Contains(tmpl, "{z}") || !strings.Contains(tmpl, "{x}") || !strings.Contains(tmpl, "{y}") {
return "", errors.New("tile index names no usable https template")
}
m.mu.Lock()
m.template, m.tmplAt = tmpl, time.Now()
m.mu.Unlock()
return tmpl, nil
}
func (m *Maps) fetch(ctx context.Context, t mapTile) ([]byte, error) {
tmpl, err := m.tileTemplate(ctx)
if err != nil {
return nil, err
}
u := strings.NewReplacer("{z}", strconv.Itoa(t.Z), "{x}", strconv.Itoa(t.X), "{y}", strconv.Itoa(t.Y)).Replace(tmpl)
raw, err := m.get(ctx, u, maxTileBytes)
if err != nil {
return nil, err
}
// Some hosts send the tile gzipped whatever the request said.
if len(raw) > 2 && raw[0] == 0x1f && raw[1] == 0x8b {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, errors.New("tile is not readable")
}
raw, err = io.ReadAll(io.LimitReader(zr, maxTileBytes+1))
if err != nil || len(raw) > maxTileBytes {
return nil, errors.New("tile is not readable")
}
}
return raw, nil
}
// buildTile keeps the roads of the vector tile's transportation layer, in the car's format.
func buildTile(t mapTile, raw []byte) ([]byte, error) {
lines, err := decodeRoads(raw)
if err != nil {
return nil, err
}
out := roadTile{Z: t.Z, X: t.X, Y: t.Y, Extent: mapExtent, Ways: []road{}}
for _, l := range lines {
pts := simplify(l.points, mapTolerance)
if len(pts) < 2 {
continue
}
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: l.class, P: flat})
}
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 := ((px-ax)*dx + (py-ay)*dy) / (dx*dx + dy*dy)
u = max(0, min(1, u))
return math.Hypot(px-ax-u*dx, py-ay-u*dy)
}
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")
}
// The car waits a few seconds at most; the fetch goes on without it.
wait, cancel := context.WithTimeout(r.Context(), mapWait)
defer cancel()
data, _, err := a.Maps.Tile(wait, mapTile{Z: *req.Z, X: *req.X, Y: *req.Y})
if err != nil {
slog.Warn("map tile not available yet", "installation", who(c), "error", err)
w.Header().Set("Retry-After", "10")
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "map data not available yet, try again shortly"})
return nil
}
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
}
+361
View File
@@ -0,0 +1,361 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// A tile in central Berlin.
var testTile = mapTile{Z: 14, X: 8802, Y: 5373}
// ---------- A minimal vector-tile encoder for the tests ----------
func pbVarint(v uint64) []byte {
var b []byte
for v >= 0x80 {
b = append(b, byte(v)|0x80)
v >>= 7
}
return append(b, byte(v))
}
func pbKey(num, wire int) []byte { return pbVarint(uint64(num<<3 | wire)) }
func pbBytes(num int, data []byte) []byte {
return append(append(pbKey(num, 2), pbVarint(uint64(len(data)))...), data...)
}
func pbUint(num int, v uint64) []byte { return append(pbKey(num, 0), pbVarint(v)...) }
func pbPacked(num int, vs []uint32) []byte {
var d []byte
for _, v := range vs {
d = append(d, pbVarint(uint64(v))...)
}
return pbBytes(num, d)
}
func zz(v int) uint32 { return uint32((v << 1) ^ (v >> 31)) }
// lineCmds encodes line strings as MoveTo/LineTo commands with relative moves.
func lineCmds(lines ...[][2]int) []uint32 {
var out []uint32
x, y := 0, 0
for _, l := range lines {
out = append(out, 1|1<<3, zz(l[0][0]-x), zz(l[0][1]-y))
x, y = l[0][0], l[0][1]
out = append(out, uint32(2|(len(l)-1)<<3))
for _, p := range l[1:] {
out = append(out, zz(p[0]-x), zz(p[1]-y))
x, y = p[0], p[1]
}
}
return out
}
type testFeature struct {
class int // index into the values below
geomType uint64
geometry []uint32
}
// testVectorTile has a water layer (ignored) and a transportation layer with the given extent.
func testVectorTile(extent uint64) []byte {
values := []string{"primary", "minor", "service", "motorway"}
features := []testFeature{
// A primary road with a needless middle point.
{0, 2, lineCmds([][2]int{{100, 100}, {200, 200}, {300, 300}})},
// A minor street in two pieces.
{1, 2, lineCmds([][2]int{{0, 500}, {400, 500}}, [][2]int{{600, 500}, {900, 520}})},
// A service road and a motorway as a polygon: neither is drawn.
{2, 2, lineCmds([][2]int{{10, 10}, {20, 20}})},
{3, 3, lineCmds([][2]int{{10, 10}, {20, 20}})},
}
var layer []byte
layer = append(layer, pbUint(15, 2)...)
layer = append(layer, pbBytes(1, []byte("transportation"))...)
for _, f := range features {
var feat []byte
feat = append(feat, pbPacked(2, []uint32{0, uint32(f.class)})...)
feat = append(feat, pbUint(3, f.geomType)...)
feat = append(feat, pbPacked(4, f.geometry)...)
layer = append(layer, pbBytes(2, feat)...)
}
layer = append(layer, pbBytes(3, []byte("class"))...)
for _, v := range values {
layer = append(layer, pbBytes(4, pbBytes(1, []byte(v)))...)
}
layer = append(layer, pbUint(5, extent)...)
water := append(pbBytes(1, []byte("water")), pbUint(5, 4096)...)
return append(pbBytes(3, water), pbBytes(3, layer)...)
}
// ---------- A fake tile host ----------
type fakeHost struct {
*httptest.Server
tiles atomic.Int32
status int
delay time.Duration
gzip bool
}
func newFakeHost(t *testing.T) *fakeHost {
t.Helper()
h := &fakeHost{status: http.StatusOK}
mux := http.NewServeMux()
mux.HandleFunc("GET /planet", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"tilejson":"3.0.0","tiles":["%s/planet/build1/{z}/{x}/{y}.pbf"]}`, h.URL)
})
mux.HandleFunc("GET /planet/build1/{z}/{x}/{y}", func(w http.ResponseWriter, r *http.Request) {
h.tiles.Add(1)
if !strings.Contains(r.UserAgent(), "nils-mg4-server") {
t.Errorf("user agent %q", r.UserAgent())
}
time.Sleep(h.delay)
if h.status != http.StatusOK {
http.Error(w, "busy", h.status)
return
}
body := testVectorTile(4096)
if h.gzip {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
_, _ = zw.Write(body)
_ = zw.Close()
body = buf.Bytes()
}
_, _ = w.Write(body)
})
h.Server = httptest.NewTLSServer(mux)
return h
}
func mapsAPIWith(host *fakeHost, cacheDir string) http.Handler {
api, _ := newTestAPI()
api.Maps = NewMaps(host.URL+"/planet", cacheDir, mapUserAgent)
api.Maps.Client = host.Client()
return api.Routes()
}
func tileBody(t mapTile) string {
return fmt.Sprintf(`{"z":%d,"x":%d,"y":%d}`, t.Z, t.X, t.Y)
}
func decodeAnswer(t *testing.T, rec *httptest.ResponseRecorder) roadTile {
t.Helper()
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)
}
return got
}
// ---------- Tests ----------
func TestVectorTileRoadsAreClassifiedSplitAndSimplified(t *testing.T) {
lines, err := decodeRoads(testVectorTile(4096))
if err != nil {
t.Fatal(err)
}
if len(lines) != 3 || lines[0].class != 1 || lines[1].class != 3 || lines[2].class != 3 {
t.Fatalf("lines %+v", lines)
}
if fmt.Sprint(lines[1].points) != "[[0 500] [400 500]]" || fmt.Sprint(lines[2].points) != "[[600 500] [900 520]]" {
t.Fatalf("pieces %v %v", lines[1].points, lines[2].points)
}
// Another extent is scaled to 4096.
half, err := decodeRoads(testVectorTile(8192))
if err != nil || fmt.Sprint(half[0].points[2]) != "[150 150]" {
t.Fatalf("scaled %v %v", half, err)
}
body, err := buildTile(testTile, testVectorTile(4096))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), `{"c":1,"p":[100,100,300,300]}`) {
t.Fatalf("the straight road keeps only its ends: %s", body)
}
}
func TestMalformedVectorTilesAreRefused(t *testing.T) {
good := testVectorTile(4096)
for name, raw := range map[string][]byte{
"cut short": good[:len(good)-7],
"bad wire type": {0x1f, 0x00},
"length too long": {0x1a, 0x7f, 0x01},
} {
if _, err := decodeRoads(raw); err == nil {
t.Errorf("%s: accepted", name)
}
}
if lines, err := decodeRoads(nil); err != nil || len(lines) != 0 {
t.Fatalf("an empty tile is an empty map: %v %v", lines, err)
}
}
func TestMapTilesAreFetchedOnceAndKeptOnDisk(t *testing.T) {
host := newFakeHost(t)
host.gzip = true // some hosts gzip whatever was asked
defer host.Close()
dir := t.TempDir()
h := mapsAPIWith(host, dir)
if rec := do(t, h, "POST", "/v1/map/roads", "", tileBody(testTile)); rec.Code != 401 {
t.Fatalf("without the car's token: %d", rec.Code)
}
got := decodeAnswer(t, do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)))
if got.Extent != 4096 || got.X != testTile.X || got.Y != testTile.Y || len(got.Ways) != 3 {
t.Fatalf("tile %+v", got)
}
decodeAnswer(t, do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)))
// A restart: a new server with the same directory asks the host nothing.
decodeAnswer(t, do(t, mapsAPIWith(host, dir), "POST", "/v1/map/roads", testToken, tileBody(testTile)))
if n := host.tiles.Load(); n != 1 {
t.Fatalf("tile fetched %d times", n)
}
}
func TestASlowTileIsFinishedForTheCarsNextTry(t *testing.T) {
previous := mapWait
mapWait = 50 * time.Millisecond
defer func() { mapWait = previous }()
host := newFakeHost(t)
host.delay = 300 * time.Millisecond
defer host.Close()
h := mapsAPIWith(host, "")
rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile))
if rec.Code != 503 || rec.Header().Get("Retry-After") == "" {
t.Fatalf("the car should hear 'try again': %d", rec.Code)
}
time.Sleep(600 * time.Millisecond)
decodeAnswer(t, do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)))
if n := host.tiles.Load(); n != 1 {
t.Fatalf("tile fetched %d times", n)
}
}
func TestMapTilesAreGzippedWhenTheCarAcceptsIt(t *testing.T) {
host := newFakeHost(t)
defer host.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()
mapsAPIWith(host, "").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) {
host := newFakeHost(t)
defer host.Close()
h := mapsAPIWith(host, "")
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 host.tiles.Load() != 0 {
t.Fatal("a bad request reached the tile host")
}
}
func TestAFailingHostIsATemporaryFailureAndNotCached(t *testing.T) {
var logged bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
defer slog.SetDefault(previous)
host := newFakeHost(t)
host.status = http.StatusBadGateway
defer host.Close()
h := mapsAPIWith(host, t.TempDir())
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 n := host.tiles.Load(); n != 2 {
t.Fatalf("a failure was cached: %d fetches", n)
}
// Where the car is stays out of the log.
out := logged.String()
if !strings.Contains(out, "tile host answered 502") || strings.Contains(out, "8802") ||
strings.Contains(out, "5373") {
t.Fatalf("log:\n%s", out)
}
}
func TestATileIndexWithoutHttpsIsRefused(t *testing.T) {
index := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"tiles":["http://tiles.example.com/{z}/{x}/{y}.pbf"]}`))
}))
defer index.Close()
api, _ := newTestAPI()
api.Maps = NewMaps(index.URL, "", mapUserAgent)
api.Maps.Client = index.Client()
if rec := do(t, api.Routes(), "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 503 {
t.Fatalf("%d", rec.Code)
}
}
func TestMapsAreOffWithoutATileSource(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 TestMapTilesURLConfiguration(t *testing.T) {
base := map[string]string{"INFLUX_URL": "http://influx", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
"INGEST_TOKEN": "0123456789abcdef01234567"}
load := func(tiles string) (config, error) {
return loadConfig(func(k string) string {
if k == "MAP_TILES_URL" {
return tiles
}
return base[k]
})
}
if c, err := load(""); err != nil || c.mapTilesURL != DefaultMapTilesURL {
t.Fatalf("default: %q %v", c.mapTilesURL, err)
}
if c, err := load("off"); err != nil || c.mapTilesURL != "" {
t.Fatalf("off: %q %v", c.mapTilesURL, err)
}
if _, err := load("http://tiles.example.com/planet"); err == nil {
t.Fatal("plain http accepted")
}
}
func TestSimplification(t *testing.T) {
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)
}
}
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"errors"
"math"
)
// The roads of a Mapbox Vector Tile (spec 2.1) in the OpenMapTiles schema: the line features
// of the "transportation" layer whose "class" the car draws (roadClasses), scaled to
// mapExtent. A minimal protobuf reader, enough for that and nothing more; anything malformed
// is an error, never a guess.
type roadLine struct {
class int
points [][2]int
}
var errBadTile = errors.New("vector tile is malformed")
// pbReader walks protobuf fields.
type pbReader struct {
b []byte
i int
}
func (r *pbReader) done() bool { return r.i >= len(r.b) }
func (r *pbReader) varint() (uint64, error) {
var v uint64
for shift := uint(0); shift < 64; shift += 7 {
if r.i >= len(r.b) {
return 0, errBadTile
}
c := r.b[r.i]
r.i++
v |= uint64(c&0x7f) << shift
if c < 0x80 {
return v, nil
}
}
return 0, errBadTile
}
// field reads one field's key and, for length-delimited ones, its bytes.
func (r *pbReader) field() (num int, wire int, val uint64, data []byte, err error) {
key, err := r.varint()
if err != nil {
return 0, 0, 0, nil, err
}
num, wire = int(key>>3), int(key&7)
switch wire {
case 0:
val, err = r.varint()
case 1:
if r.i+8 > len(r.b) {
return 0, 0, 0, nil, errBadTile
}
r.i += 8
case 2:
var n uint64
n, err = r.varint()
if err == nil {
if n > uint64(len(r.b)-r.i) {
return 0, 0, 0, nil, errBadTile
}
data = r.b[r.i : r.i+int(n)]
r.i += int(n)
}
case 5:
if r.i+4 > len(r.b) {
return 0, 0, 0, nil, errBadTile
}
r.i += 4
default:
return 0, 0, 0, nil, errBadTile
}
return num, wire, val, data, err
}
// packed reads a repeated uint32 field, packed or not.
func packed(wire int, val uint64, data []byte, into []uint32) ([]uint32, error) {
if wire == 0 {
return append(into, uint32(val)), nil
}
if wire != 2 {
return nil, errBadTile
}
r := pbReader{b: data}
for !r.done() {
v, err := r.varint()
if err != nil {
return nil, err
}
into = append(into, uint32(v))
}
return into, nil
}
// decodeRoads returns the drawable roads of the tile.
func decodeRoads(tile []byte) ([]roadLine, error) {
r := pbReader{b: tile}
for !r.done() {
num, _, _, data, err := r.field()
if err != nil {
return nil, err
}
if num != 3 { // layers
continue
}
lines, isRoads, err := decodeLayer(data)
if err != nil {
return nil, err
}
if isRoads {
return lines, nil
}
}
return nil, nil // a tile without roads (sea, desert) is an empty one
}
type rawFeature struct {
tags []uint32
geomType uint64
geometry []uint32
}
func decodeLayer(layer []byte) ([]roadLine, bool, error) {
r := pbReader{b: layer}
var name string
var keys []string
var values []string // only string values matter for "class"
var features []rawFeature
extent := uint64(4096)
for !r.done() {
num, wire, val, data, err := r.field()
if err != nil {
return nil, false, err
}
switch num {
case 1:
name = string(data)
case 2:
f, err := decodeFeature(data)
if err != nil {
return nil, false, err
}
features = append(features, f)
case 3:
keys = append(keys, string(data))
case 4:
values = append(values, valueString(data))
case 5:
if wire != 0 || val == 0 {
return nil, false, errBadTile
}
extent = val
}
}
if name != "transportation" {
return nil, false, nil
}
scale := float64(mapExtent) / float64(extent)
var lines []roadLine
for _, f := range features {
if f.geomType != 2 { // LINESTRING
continue
}
class, ok := -1, false
for i := 0; i+1 < len(f.tags); i += 2 {
k, v := int(f.tags[i]), int(f.tags[i+1])
if k >= len(keys) || v >= len(values) {
return nil, false, errBadTile
}
if keys[k] == "class" {
class, ok = roadClasses[values[v]]
if !ok {
class = -1
}
}
}
if class < 0 {
continue
}
parts, err := lineGeometry(f.geometry, scale)
if err != nil {
return nil, false, err
}
for _, p := range parts {
lines = append(lines, roadLine{class: class, points: p})
}
}
return lines, true, nil
}
func decodeFeature(data []byte) (rawFeature, error) {
var f rawFeature
r := pbReader{b: data}
for !r.done() {
num, wire, val, d, err := r.field()
if err != nil {
return f, err
}
switch num {
case 2:
if f.tags, err = packed(wire, val, d, f.tags); err != nil {
return f, err
}
case 3:
f.geomType = val
case 4:
if f.geometry, err = packed(wire, val, d, f.geometry); err != nil {
return f, err
}
}
}
if len(f.tags)%2 != 0 {
return f, errBadTile
}
return f, nil
}
// valueString is a Value's string_value, "" for any other kind.
func valueString(data []byte) string {
r := pbReader{b: data}
for !r.done() {
num, _, _, d, err := r.field()
if err != nil {
return ""
}
if num == 1 {
return string(d)
}
}
return ""
}
// lineGeometry follows the MoveTo/LineTo commands of a (multi)line string.
func lineGeometry(cmds []uint32, scale float64) ([][][2]int, error) {
var parts [][][2]int
var cur [][2]int
x, y := 0, 0
zig := func(v uint32) int { return int(int32(v>>1) ^ -int32(v&1)) }
at := func() [2]int {
return [2]int{int(math.Round(float64(x) * scale)), int(math.Round(float64(y) * scale))}
}
for i := 0; i < len(cmds); {
id, count := cmds[i]&7, int(cmds[i]>>3)
i++
switch id {
case 1, 2: // MoveTo, LineTo
if i+2*count > len(cmds) {
return nil, errBadTile
}
for k := 0; k < count; k++ {
x += zig(cmds[i])
y += zig(cmds[i+1])
i += 2
if id == 1 {
if len(cur) >= 2 {
parts = append(parts, cur)
}
cur = [][2]int{at()}
} else {
if p := at(); len(cur) == 0 || cur[len(cur)-1] != p {
cur = append(cur, p)
}
}
}
case 7: // ClosePath, not in a line; ignored
default:
return nil, errBadTile
}
}
if len(cur) >= 2 {
parts = append(parts, cur)
}
return parts, nil
}