20 Commits
Author SHA1 Message Date
nilsandClaude Opus 5.5 e90031d6b0 App 2.40.3: the signal recorder listens to the assistance properties
The ACC recording of 2.32.0 only polled, and the AAD_* properties read
their defaults. The head unit's own assistance manager never polls them:
it subscribes to each for change events, and a vendor HAL may fill a
value only while someone listens. EVHardware gains a read-only
listenToProperties and AssistBlock (global vendor properties 0x21x0A1xx,
all 108 AAD_* ids); the recorder subscribes to that block for its three
minutes and lists the properties heard, those with changing values first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:55:26 +01:00
nilsandClaude Opus 5.5 f7c489858e App 2.40.2: the Grid map moves on the GPU
The map was hardware-rendered, but every frame recomputed every road
point, rebuilt every path and drew the big roads three times, at 20
frames a second, and the car jumped with each GPS fix. Now it is its own
view under the Grid (MapLayerView, a hardware layer): drawn once into a
GPU texture and only redrawn when tiles change or after about 230 m;
each display frame only sets its translation and rotation. The car
glides between fixes, and the Grid over the map redraws only with the
readings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:32:50 +01:00
nilsandClaude Opus 5.5 c3c47f5159 App 2.40.1: an update always reaches the installer screen
The car refuses the installer session, and only a refusal thrown at
once opened the installer screen; one that came back through the
session's result ended in a toast, so the update downloaded and then
nothing seemed to happen. Now any result but success or the driver's
cancel opens the installer screen with the verified APK, as does a
confirmation that cannot be shown, and a session silent for 20 s is
abandoned for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:18:31 +01:00
nilsandClaude Opus 5.5 9ef058cb44 SPECS: app 2.40.0 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:12:47 +01:00
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
nilsandClaude Opus 5.5 7d2c1e8bb4 App 2.38.0: Overview styles Visor and Empire
Visor, after Halo's helmet display: the battery as the shield bar, the
range as the ammunition counter, P R N D as grenade slots, the speed in
the reticle with power round it, and the power trail as blips on a
motion tracker that sweeps while moving. Original blue or the accent.

Empire, after Age of Empires: a wooden resource bar, an isometric world
that moves with the speed, the car as the selected unit, P R N D as
commands, a diamond minimap, and ages by odometer (EmpireAge, tested).
The app takes the player's blue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 21:00:50 +01:00
nilsandClaude Opus 5.5 96b9659bdd App 2.37.0: Overview styles Time circuits and Quest
Time circuits, after Back to the Future: three LED rows (the car as the
destination, the present time, the last departure from P), the speed in
red digits and a flux capacitor that flows with the power; at 88 km/h
it all flashes. Letters on fourteen segments (Segments14, tested).
Quest, after World of Warcraft's interface: player frame, minimap, gold
speed, cast bar, quest tracker, chat, action bar and the odometer as
experience. Both in their own colours, the app with them. In THEMES.md
with screenshots. Emulator only; not tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 18:54:48 +01:00
nilsandClaude Opus 5.5 cba46504a6 App 2.36.0: Overview style Grid, after the Tron films
Neon lines on black over a perspective grid floor that moves with the
speed; the battery as a light wall, power on a disc whose inner ring
turns with it, and the last two minutes of power as a light-cycle trail
of right angles (the Cockpit's PowerTrace, now in ClusterValues). Cyan,
or the accent colour; in cyan the app turns cyan too. In THEMES.md with
two screenshots. Emulator only; not tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 18:33:53 +01:00
nilsandClaude Opus 5.5 1e0fd28eb2 SPECS: server 2.6.2 running; the car updated itself to 2.35.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:53:44 +01:00
nilsandClaude Opus 5.5 784066ba1e k8s: pin server 2.6.2
git.stinnesbeck.com/nils/mg4-server:2.6.2 at
sha256:e4de1e75958d58ece8430a1913f9c1614d8ea1bf566f2e1ec5b449ef51844f77
(update logging, Go 1.27.3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:42:58 +01:00
nilsandClaude Opus 5.5 acb7e1969e Server 2.6.2: log update checks and downloads; Go 1.27.3
The update relay logs every check (car, number of releases, newest
pre-release) and every download (car, file, size, then bytes and time,
or that it was cut short); never the forge's address or token, tested.
Go 1.27.3 for the build image and toolchain: govulncheck had found
GO-2026-6809 in crypto/x509 under 1.26.9; source and binary now clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:40:23 +01:00
nilsandClaude Opus 5.5 76a6213126 SPECS: InfluxDB manifests apply as unchanged (confirmed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:33:44 +01:00
nilsandClaude Opus 5.5 d865906859 k8s: write out the defaulted fields so apply reports InfluxDB unchanged
The NetworkPolicy port gets protocol: TCP, the StatefulSet's volume
claim template apiVersion, kind and volumeMode: Filesystem. Kubernetes
filled these in itself, so every apply -k sent a patch and printed
"configured" although kubectl diff was empty. Validated offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:32:57 +01:00
60 changed files with 5035 additions and 68 deletions
+6 -5
View File
@@ -18,9 +18,10 @@ MG4 head unit (Android 9, AAOS, MT2712)
- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the - **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the
`EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the `EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the
server and to ABRP, and updates itself. server and to ABRP, and updates itself.
- **server/** — Go 1.26 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,12 +76,12 @@ 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; the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie; Retro and Terminal 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, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), 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 and Terminal 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 change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and 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,
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server 15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
@@ -0,0 +1,24 @@
package com.evsuite.hardware
/**
* The block of vendor properties the head unit's driver-assistance manager
* (`CarAdvancedAssistedDrivingManager`, its `AAD_*` ids) lives in: global vendor properties
* `0x21x0A1xx`, of any value type — `0x2140A101` (int) to `0x2160A15B` (float, `AAD_EPTCCTRGTSPD`,
* by its name the cruise control's target speed) and the rest of the same block. Used to choose
* what the signal recorder listens to; nothing here is ever written. Pure, unit-tested.
*/
object AssistBlock {
private const val GROUP_MASK = 0xF0000000.toInt()
private const val GROUP_VENDOR = 0x20000000
private const val AREA_MASK = 0x0F000000
private const val AREA_GLOBAL = 0x01000000
private const val BLOCK_MASK = 0x0000FF00
private const val BLOCK = 0x0000A100
/** Whether [propertyId] is a global vendor property in the assistance block. */
fun contains(propertyId: Int): Boolean =
propertyId and GROUP_MASK == GROUP_VENDOR &&
propertyId and AREA_MASK == AREA_GLOBAL &&
propertyId and BLOCK_MASK == BLOCK
}
@@ -320,6 +320,105 @@ object EVHardware {
ignitionCallbacks.remove(callback) ignitionCallbacks.remove(callback)
} }
// -------------------------------------------------------------------------
// Listening to properties (the app's signal recorder) — read only
// -------------------------------------------------------------------------
/** A subscription made by [listenToProperties]; [close] ends it. */
class PropertyListening internal constructor(
/** Properties subscribed to, and those the car service refused. */
val subscribed: Int,
val refused: Int,
private val stop: () -> Unit,
) : AutoCloseable {
override fun close() = stop()
}
/**
* Subscribes to every CarPropertyManager property whose id [admit] accepts, the way the
* head unit's own managers do (`CarAdvancedAssistedDrivingManager` registers each of its
* `AAD_*` ids for change events and never polls them), and hands each change event to
* [onEvent] as (property id, area, value as text) on the car service's thread. A vendor HAL
* may fill a property from the car network only while someone listens, so a poll alone can
* read its default. ON_CHANGE properties are asked for every change, CONTINUOUS ones for
* 1 Hz within their limits. Read only: nothing is set. Null when there is no
* CarPropertyManager or no way to subscribe; [PropertyListening.close] unsubscribes.
*/
fun listenToProperties(admit: (Int) -> Boolean, onEvent: (Int, Int, String) -> Unit): PropertyListening? {
val cpm = sCarPropertyManager ?: return null
return try {
val register = cpm.javaClass.methods.firstOrNull {
it.name == "registerCallback" && it.parameterCount == 3
} ?: return null
val unregister = cpm.javaClass.methods.firstOrNull {
it.name == "unregisterCallback" && it.parameterCount == 1
}
val callbackType = register.parameterTypes[0]
if (!callbackType.isInterface) return null
val proxy = Proxy.newProxyInstance(callbackType.classLoader, arrayOf(callbackType)) { self, method, args ->
when (method.name) {
"onChangeEvent" -> {
val cpv = args?.getOrNull(0)
if (cpv != null) {
try {
val type = cpv.javaClass
val id = type.getMethod("getPropertyId").invoke(cpv) as Int
val area = type.getMethod("getAreaId").invoke(cpv) as Int
onEvent(id, area, com.evsuite.hardware.probe.VendorSurfaceRules.render(
type.getMethod("getValue").invoke(cpv)))
} catch (e: Exception) {
AppLogger.d(TAG, "listen: event unreadable: ${e.readFailureReason()}")
}
}
null
}
"onErrorEvent" -> {
val id = args?.getOrNull(0) as? Int
val area = args?.getOrNull(1) as? Int
if (id != null) onEvent(id, area ?: AREA_GLOBAL, "error event")
null
}
"equals" -> self === args?.getOrNull(0)
"hashCode" -> System.identityHashCode(self)
"toString" -> "PropertyListening"
else -> null
}
}
val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null
var subscribed = 0
var refused = 0
for (config in configs) {
if (config == null) continue
val type = config.javaClass
val id = runCatching { type.getMethod("getPropertyId").invoke(config) as Int }.getOrNull() ?: continue
if (!admit(id)) continue
val continuous = runCatching { type.getMethod("getChangeMode").invoke(config) as Int }.getOrNull() == 2
val rate = if (!continuous) 0f else {
val min = runCatching { type.getMethod("getMinSampleRate").invoke(config) as Float }.getOrNull() ?: 0f
val max = runCatching { type.getMethod("getMaxSampleRate").invoke(config) as Float }.getOrNull() ?: 1f
1f.coerceIn(min, maxOf(min, max))
}
try {
if (register.invoke(cpm, proxy, id, rate) == false) refused++ else subscribed++
} catch (e: Exception) {
refused++
AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason()))
}
}
AppLogger.i(TAG, "listening to $subscribed properties ($refused refused)")
PropertyListening(subscribed, refused) {
try {
unregister?.invoke(cpm, proxy)
} catch (e: Exception) {
AppLogger.d(TAG, "listen: unsubscribe failed: ${e.readFailureReason()}")
}
}
} catch (e: Exception) {
AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}")
null
}
}
/** Unregisters the CarPropertyManager proxy (call from Service.onDestroy). */ /** Unregisters the CarPropertyManager proxy (call from Service.onDestroy). */
fun unregisterIgnitionPropertyCallback() { fun unregisterIgnitionPropertyCallback() {
val cpm = sCarPropertyManager ?: return val cpm = sCarPropertyManager ?: return
@@ -0,0 +1,29 @@
package com.evsuite.hardware
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AssistBlockTest {
@Test
fun `the assistance manager's properties are in the block`() {
// First and last int, the target speed and the object distance (floats).
for (id in intArrayOf(0x2140A101, 0x2140A171, 0x2160A15B, 0x2160A12C, 0x2160A114)) {
assertTrue("0x%08X".format(id), AssistBlock.contains(id))
}
}
@Test
fun `other properties are not`() {
for (id in intArrayOf(
0x2140A0FF, // the block before
0x2140A201, // the block after
0x11400400, // a standard (system) property
0x2340A101, // not global
0x0140A101, // no group
)) {
assertFalse("0x%08X".format(id), AssistBlock.contains(id))
}
}
}
+11 -2
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
@@ -31,8 +39,9 @@ top. Those are Fatih's reads the car needs and upstream does not have — the ve
ids `0x21401553`–`56`, the vendor mileage `0x21401566` for the odometer, the charging status ids `0x21401553`–`56`, the vendor mileage `0x21401566` for the odometer, the charging status
from `0x2140F409`, SoC, range, power, pack voltage and current on SWI69 as on SWI68, and SWI69 from `0x2140F409`, SoC, range, power, pack voltage and current on SWI69 as on SWI68, and SWI69
speed already in km/h — plus translated comments and, from this fork, the gear's raw code speed already in km/h — plus translated comments and, from this fork, the gear's raw code
(`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`) and a drive mode write without the binder (`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`), a drive mode write without the binder
fallback (`writeDriveModeProperty`). To take upstream again, merge three ways with fallback (`writeDriveModeProperty`), and read-only listening to properties for the signal
recorder (`listenToProperties`, `AssistBlock`). To take upstream again, merge three ways with
`e4e1158` as the base. `e4e1158` as the base.
The car app links EVHardware into its APK, so the **built app** may only be used and passed on The car app links EVHardware into its APK, so the **built app** may only be used and passed on
+7
View File
@@ -110,3 +110,10 @@ That screen needs a `file://` URI on the car build: a read grant on our FileProv
when it comes from the system user (`not exported from UID 1000`), and the installer reports the when it comes from the system user (`not exported from UID 1000`), and the installer reports the
unreadable file as "There was a problem parsing the package". On the emulator the session still unreadable file as "There was a problem parsing the package". On the emulator the session still
installs silently. installs silently.
The refusal does not always come at once: the platform may also answer a moment later, through
the session's result (`OtaInstallResultReceiver`). Since 2.40.1 that answer opens the installer
screen too, as does a confirmation the platform asks for but cannot show; only **Cancel** on that
screen ends the update there. And if the session says nothing at all for 20 s, the app abandons
it and opens the installer screen. Before 2.40.1 a late refusal ended in a short message only:
the update downloaded and then nothing seemed to happen.
+6 -5
View File
@@ -39,8 +39,8 @@ APIs.
## Screenshots ## Screenshots
**Overview styles:** the Overview comes in twelve 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 and a watch face. [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,20 +81,21 @@ 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 and Chronograph ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, and Retro and Terminal 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 |
| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import | | **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
| **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About | | **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About |
| **Logs** | Signal recorder (records every value the head unit offers for 3 minutes and lists what changed, to find where the car publishes something; saved to a USB stick too), update log (every install step), upload log | | **Logs** | Signal recorder (records every value the head unit offers for 3 minutes, listens to the driver-assistance values for every change they report, and lists what changed, to find where the car publishes something; saved to a USB stick too), update log (every install step), upload log |
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the - **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
voice key** on the steering wheel and the app comes to the front (switch on the Settings voice key** on the steering wheel and the app comes to the front (switch on the Settings
+180 -3
View File
@@ -39,8 +39,8 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
### Status ### Status
Current: app 2.35.3, server 2.6.1 (pinned by digest in `k8s/server.yaml`; applied and running Current: app 2.38.0 (installed on the car over the air, 2026-10-26), server 2.6.2 (pinned by digest in
on the cluster 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
without black bands. without black bands.
@@ -78,7 +78,9 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
(`AAD_EPTCCTRGTSPD` `0x2160A15B` 0.0, `AAD_ACC_OBJ_*` 0.0 / -128.0, modes 0). A signal recording (`AAD_EPTCCTRGTSPD` `0x2160A15B` 0.0, `AAD_ACC_OBJ_*` 0.0 / -128.0, modes 0). A signal recording
of all 1,194 values while the set speed was 101, 90 and 80 km/h shows none of those numbers of all 1,194 values while the set speed was 101, 90 and 80 km/h shows none of those numbers
(also tried as m/s, mph and ×2/×10/×16/×64) outside the actual speed, and the ACC buttons send (also tried as m/s, mph and ×2/×10/×16/×64) outside the actual speed, and the ACC buttons send
no key broadcast. Most likely only the instrument cluster has it. no key broadcast. Most likely only the instrument cluster has it. Not yet tried: listening
(subscribing) instead of polling, as the head unit's own manager does — the recorder does it
since 2.40.3; a drive with ACC will tell.
## To do ## To do
@@ -138,6 +140,181 @@ 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.3: **the signal recorder listens** (owner: "is there really nothing we
can do about the readout of the cruise control set point"). The 2.32.0 recording only polled.
The head unit's `CarAdvancedAssistedDrivingManager` never polls its 108 `AAD_*` properties: it
registers a listener for each (`registerListener(…, 0.0f)`, on change), and a vendor HAL may
fill a value from the car network only while someone listens — which would read as the
defaults we saw. EVHardware gains `listenToProperties` (read only: the CarPropertyManager's
`registerCallback` per admitted property, rate 0 for ON_CHANGE, 1 Hz within limits for
CONTINUOUS, one proxy, `unregisterCallback` on close) and `AssistBlock` (global vendor
properties `0x21x0A1xx`, which holds all 108 `AAD_*` ids; tested). The recorder subscribes to
that block for its 3 minutes; `SignalRecording.event` counts every event and puts value changes
on the timeline; the file and the Logs tab list the properties heard, those with changing
values first ("heard …"). Emulator (no car service): the recorder notes that listening is not
possible, records, saves the new section; no crash. The subscription itself is the same
reflection as the ignition listener that works on the car, but untried on the car.
- 2026-10-27 — App 2.40.2: **the map moves on the GPU** (owner: "is the map screen gpu
accelerated? it looks a bit slow on update"). It was hardware-rendered, but every frame (20 a
second) recomputed every road point on the main thread, rebuilt all paths, and drew the big
roads three times for the glow; and the car jumped with each GPS fix (1 Hz). Now the map is
its own view under the Grid (`MapLayerView`, `LAYER_TYPE_HARDWARE`): drawn once into a GPU
texture round an anchor and only redrawn when tiles come or go or the car is ~280 layout units
(≈230 m) from the anchor; each display frame (`postOnAnimation`, only while that style is on
screen and the map shows) sets its translation, pivot and rotation, which the GPU composites.
The car glides towards each fix (eased, snaps beyond 200 m), the heading eases; GridView over
the map no longer animates itself (only the readings redraw it), the "far roads fainter" sort
is a fixed dark gradient towards the top. Layer size on the car about 2500 px square (~25 MB).
Measured on the emulator: with SwiftShader (software GPU) old and new both ~57 ms a frame, and
with the host's Intel GPU (`-gpu host`) both run at the display rate (~18 ms) — the emulator
is too fast to show the car's slowness, so the gain on the head unit is reasoned, not
measured. Not tried on the car.
- 2026-10-27 — App 2.40.1: **an update that downloaded but never reached the installer** (owner:
"sometimes … downloads the update but then doesn't start the installer"). The car refuses the
installer session ("Not allowed to install non-system apps on internal storage"); only a
refusal thrown at once led to the installer screen. A refusal that came back through the
session's result was a toast and nothing else. Now any answer but success, the confirmation and
the driver's own cancel opens the installer screen with the verified APK (path and session id
ride on the result intent; `goAsync` for the copy), a confirmation that cannot be shown does
too, and a session silent for 20 s is abandoned for it. Emulator, with a throwaway build whose
session bytes were damaged (refused later, status 4 `INSTALL_PARSE_FAILED_NOT_APK`) and which
read the update from Downloads: the installer screen opened by itself, Install, now 2.40.1; the
update log shows every step. The 20 s watch is untested (no way to make a session stay silent).
Not tried on the car. OTA.md updated.
- 2026-10-27 — App 2.40.0 published (`car-2.40.0`).
- 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`)
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
rounds, one per 10 km), P R N D as grenade slots, a reticle with the speed and power round it
(green and anticlockwise in regen), a motion tracker whose blips are the power trail (rim 60 kW,
40 kW regen) and whose sweep turns only while moving (~30 fps, on screen only), the clock and an
objective panel; original blue or the accent colour (`hasOriginal`). Empire: a wooden resource
bar (battery, range, power, 5-min usage, the age, the clock), an isometric world whose ground
and trees move with the speed (6 m tiles, ~30 fps only while moving), the car as the selected
unit with the battery as its health bar, the speed large over the world, a carved panel with
P R N D as commands, the unit's portrait and stats, and a diamond minimap with the odometer and
the way to the next age; ages by odometer in `EmpireAge` (pure, tested: Dark, Feudal from
10,000 km, Castle 30,000, Imperial 60,000); its own palette, the app in the player's blue.
`stylePanes` is now built from the canvas list. THEMES.md with two screenshots each. Emulator:
sample values, 15 % at a standstill, with and without the navigation, and no data (dashes); no
crash. Not tried on the car.
- 2026-10-26 — App 2.37.0: Overview styles **Time circuits** (after Back to the Future,
`TimeCircuitsView`) and **Quest** (after World of Warcraft's interface, `QuestView`); both
with their own palette, the app in red and gold with them. Time circuits: three LED rows in a
metal panel with embossed tape — red "DESTINATION" (gear as DRV/REV/NTL/PRK, charge, range,
power/regen lamps, usage), green "PRESENT TIME" (date and time from the head unit's clock),
amber "LAST TIME DEPARTED" (when the car last left P, tracked from the readings, dashes until
seen); the speed in red digits, a flux capacitor whose light runs inwards with the power and
outwards in regen; at 88 km/h the speed burns white, the tape reads "88 KM/H — HOLD ON" and the
capacitor flashes. Letters on fourteen segments (`Segments14`, pure, tested). Quest: player
frame (gear as level, battery as health, power as energy), minimap with the clock, gold speed,
a cast bar (Accelerating, Regenerating, Coasting, Resting in P), a road-trip quest tracker,
system chat lines, P R N D action buttons, the odometer as experience (a level per 1000 km), a
"Low battery!" raid warning below 20 %. Names avoid the trademarks; drawings and fonts are the
app's own. THEMES.md with two screenshots each. Emulator: sample values (also 92 km/h and
15 %), with and without the navigation, and no data (dashes); no crash. Not tried on the car.
- 2026-10-26 — App 2.36.0: Overview style **Grid**, after the Tron films (`GridView`): neon lines
on black, a perspective grid floor that runs towards the driver with the speed (4 m tiles,
~30 fps only while moving and on screen), the speed large and thin, the battery as a
20-segment light wall standing on the floor (orange below 20 %, "LOW ENERGY"), power on a disc
(zero at the top, clockwise drawing, anticlockwise regeneration; an inner ring that turns with
the power), the last two minutes of power as a light-cycle trail of right angles
(`ClusterValues.powerTrail`, the Cockpit's `PowerTrace`; gaps stay gaps), P R N D tiles, the
clock, and five read-outs in cut-corner boxes. Glow from layered strokes (no blur filters).
Original cyan, or the accent colour (`hasOriginal`; low battery then white); in Original the
app turns cyan (`ThemeOverlay.EV.Style.Grid`). Named "Grid", not after the films. THEMES.md has
it with two screenshots. Emulator: sample values (full screen, with navigation, accent, 15 %),
the floor moving between frames, and no data (dashes, nothing lit, still floor); no crash.
Tests and lint. Not tried on the car.
- 2026-10-25 — Server 2.6.2: **the update relay logs what it hands out**, per car (installation
ID, or "shared token"): `update check` with the number of releases and the newest
pre-release, `update download started` with file and size, then `update handed out` with
bytes and time, or `update download cut short`; refusals by the forge name the car too. No
forge address or token in the log (tested: `TestUpdatesAreLogged`). Go 1.27.3 (build image
and `go.mod` toolchain), up from 1.26.9: `govulncheck` had found GO-2026-6809 (compromised
intermediate certificates, `crypto/x509`, reached through the server's TLS); now no
vulnerabilities, in the source and in the built binary. go vet, tests, podman build. Pushed
as `mg4-server:2.6.2` (`sha256:e4de1e75…`, checked in the registry) and pinned in
`k8s/server.yaml`. Applied by the owner, together with the real `FORGE_TOKEN`: the car then
updated itself to 2.35.3 over the air (owner, 2026-10-25).
- 2026-10-25 — `k8s/influxdb.yaml`: the values Kubernetes fills in itself are written out — the
NetworkPolicy port's `protocol: TCP`, and `apiVersion`, `kind` and `volumeMode: Filesystem` on
the StatefulSet's volume claim template. Without them every `kubectl apply -k k8s/` reported
both as "configured" while `kubectl diff -k k8s/` was empty (owner). Applied by the owner:
both now "unchanged". Validated offline (kustomize + kubeconform). Also found: the in-app update
check failed with an IOException because the cluster's `FORGE_TOKEN` was still the template
placeholder — the forge refuses it and the server answers 502; `apply -k` does not include
the Secrets, so a new token needs `kubectl apply -f k8s/secrets/secrets.yaml` and a restart.
- 2026-10-25 — App 2.35.3: a car build with the English EVHardware (comments, log lines, the - 2026-10-25 — App 2.35.3: a car build with the English EVHardware (comments, log lines, the
"Layer" names); no change in behaviour. Emulator: starts, no crash, the log reads "Layer" names); no change in behaviour. Emulator: starts, no crash, the log reads
"Layer1: …" to "Layer5: …". Not tried on the car. "Layer1: …" to "Layer5: …". Not tried on the car.
+93 -3
View File
@@ -1,6 +1,6 @@
# Overview styles # Overview styles
The Overview can look twelve 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,8 +10,8 @@ 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 and Terminal 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 and Nixie tubes 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.
| Style | Look | Colours | | Style | Look | Colours |
@@ -28,6 +28,12 @@ colours, and Sci-fi, Cartoon and Nixie tubes always do; while one of those is sh
| [Terminal](#terminal) | green-screen shell | original or accent | | [Terminal](#terminal) | green-screen shell | original or accent |
| [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 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 |
| [Quest](#quest) | a fantasy game's interface | own |
| [Visor](#visor) | a soldier's helmet display | original or accent |
| [Empire](#empire) | a strategy game's interface | own |
## Tiles ## Tiles
@@ -143,3 +149,87 @@ odometer.
![Chronograph with the navigation](screenshots/themes/chronograph-panel.png) ![Chronograph with the navigation](screenshots/themes/chronograph-panel.png)
![Chronograph, full screen](screenshots/themes/chronograph.png) ![Chronograph, full screen](screenshots/themes/chronograph.png)
## Grid
After the neon world of the Tron films: glowing lines on black and a perspective grid floor that
runs towards you as fast as you drive. The speed sits large in the middle; the battery is a
segmented light wall standing on the floor, turning orange when it runs low; power lights up a
disc whose inner ring turns with it, anticlockwise for regeneration; and the last two minutes of
power run across the floor as a light-cycle trail that only turns at right angles. In its
original cyan (shown, and the app turns cyan with it) or in the accent colour — red gives the
films' other side.
![Grid with the navigation](screenshots/themes/grid-panel.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
After the time machine in Back to the Future: three rows of LED read-outs in a metal panel,
labelled with embossed tape. The red row, "DESTINATION", shows where you can still get to — the
gear as a word (DRV, REV, NTL, PRK), the charge, the range and the usage, with lamps for power and
regeneration; the green row is the present date and time, as in the film; the amber row, "LAST
TIME DEPARTED", is when you last shifted out of P. Beside them the speed in large red digits and
the flux capacitor, whose light runs to its centre with the power and outwards while
regenerating. At 88 km/h the speed burns white and the flux capacitor flashes. The app turns red
with it.
![Time circuits with the navigation](screenshots/themes/time-circuits-panel.png)
![Time circuits, full screen](screenshots/themes/time-circuits.png)
## Quest
After the interface of a fantasy online game like World of Warcraft, with the car as the player
character: a portrait with the gear as its level, the battery as the health bar and power as the
energy bar; a minimap with the clock; the speed in large gold numbers over a cast bar that reads
"Accelerating", "Regenerating", "Coasting" or, in P, "Resting"; a road-trip quest whose objectives
are the trip, the usage and the climate; a chat window with system lines; P R N D as action
buttons, and the odometer as the experience bar, a level per 1000 km. Below 20 % a raid warning
flashes "Low battery!". The app turns gold with it.
![Quest with the navigation](screenshots/themes/quest-panel.png)
![Quest, full screen](screenshots/themes/quest.png)
## Visor
After the helmet display of a science-fiction shooter like Halo: thin light-blue lines and
slanted frames on a dark visor. The shield bar on top is the battery, turning red and blinking
"BATTERY LOW" below 20 %; the ammunition counter is the range, as a number and as rounds, one per
10 km, with P R N D as the grenade slots under it; the reticle in the middle holds the speed and
fills with power round it, green and the other way while regenerating; the motion tracker shows
the last two minutes of power as blips, red while drawing and green while regenerating, and
sweeps while you drive. The objective lists trip, usage, climate and odometer. In its original
blue (shown, and the app turns blue with it) or in the accent colour.
![Visor with the navigation](screenshots/themes/visor-panel.png)
![Visor, full screen](screenshots/themes/visor.png)
## Empire
After the interface of a real-time strategy game like Age of Empires: a wooden resource bar on
top with the battery, range, power and usage, the clock, and the age you have reached by
odometer — Dark Age, then Feudal from 10,000 km, Castle from 30,000 and Imperial from 60,000. In
the isometric world below, the car is the selected unit on a road, its health bar the battery,
while the ground and trees move past as fast as you drive and the speed stands large over the
land. The carved panel at the bottom has P R N D as command buttons, the unit's portrait and
stats, and a diamond minimap with the odometer and the way to the next age. Below 20 % it warns
"Your battery is running low!". The app turns the player's blue with it.
![Empire with the navigation](screenshots/themes/empire-panel.png)
![Empire, full screen](screenshots/themes/empire.png)
+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 138 versionCode 148
versionName "2.35.3" versionName "2.40.3"
// 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.
@@ -21,4 +21,6 @@ final class ClusterValues {
String clock = ""; String clock = "";
/** Tyre pressures formatted in the chosen unit, "—" when unread; never null. */ /** Tyre pressures formatted in the chosen unit, "—" when unread; never null. */
String tyreFl = "—", tyreFr = "—", tyreRl = "—", tyreRr = "—"; String tyreFl = "—", tyreFr = "—", tyreRl = "—", tyreRr = "—";
/** Battery power over the last two minutes, oldest first, NaN where unread; null before any. */
float[] powerTrail;
} }
@@ -0,0 +1,49 @@
package com.evsuite.abrp;
/**
* The Empire style's ages, by odometer: the Dark Age below 10,000 km, the Feudal Age below
* 30,000, the Castle Age below 60,000 and the Imperial Age from there. Pure Java, unit-tested.
*/
final class EmpireAge {
private static final String[] NAMES = { "Dark Age", "Feudal Age", "Castle Age", "Imperial Age" };
/** Where each age after the first begins, in km. */
private static final float[] STARTS = { 10_000f, 30_000f, 60_000f };
private EmpireAge() {}
/** 0 to 3, or -1 when the odometer is unread. */
static int index(Float km) {
if (km == null || Float.isNaN(km)) return -1;
int age = 0;
while (age < STARTS.length && km >= STARTS[age]) age++;
return age;
}
/** The age's name, "—" when unread. */
static String name(Float km) {
int age = index(km);
return age < 0 ? "—" : NAMES[age];
}
/** The next age's name, or null in the last age and when unread. */
static String next(Float km) {
int age = index(km);
return age < 0 || age >= STARTS.length ? null : NAMES[age + 1];
}
/** Kilometres left to the next age, or null in the last age and when unread. */
static Float toNext(Float km) {
int age = index(km);
return age < 0 || age >= STARTS.length ? null : STARTS[age] - km;
}
/** How far through the current age, 0..1; 1 in the last age, 0 when unread. */
static float progress(Float km) {
int age = index(km);
if (age < 0) return 0f;
if (age >= STARTS.length) return 1f;
float from = age == 0 ? 0f : STARTS[age - 1];
return Math.max(0f, Math.min(1f, (km - from) / (STARTS[age] - from)));
}
}
@@ -0,0 +1,454 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Empire Overview style, after the interface of a real-time strategy game like Age of
* Empires: a wooden resource bar on top, an isometric world in the middle and a carved panel
* at the bottom with the commands, the selected unit and a diamond-shaped minimap.
*
* The resource bar holds the battery, the range, the power and the usage over 5 minutes, the
* age (by odometer, {@link EmpireAge}) and the clock. In the world the car is the selected unit
* on a road, with the battery as its health bar; the ground moves past with the speed, which
* stands large over the world. Below 20 % a warning blinks there. At the bottom: P R N D as
* command buttons, the unit's portrait and stats (trip, usage since start, climate), and the
* minimap with the odometer and the way to the next age. Unread values show "—" and fill
* nothing; the world stands still when the speed is unread.
*
* The world animates (about 30 frames a second) only while the car moves and the view is on
* screen.
*/
final class EmpireView extends ClusterCanvasView {
private static final int WOOD = 0xFF3B2414, WOOD_LIGHT = 0xFF5A3A1E, WOOD_DARK = 0xFF24150B,
TRIM = 0xFFC8A05A, PARCHMENT = 0xFFF2E2B8, TEAM = 0xFF3D6BFF, HEALTH = 0xFF3CC23C,
RED = 0xFFE03A2A, GRASS = 0xFF4E7A2E, GRASS_ALT = 0xFF557F33, ROAD = 0xFF8A6A3C, REGEN = 0xFF6FE07A;
/** Metres per tile, as the ground moves past. */
private static final float TILE_M = 6f;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path = new Path();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create(Typeface.SERIF, Typeface.BOLD);
private final Typeface sans = Typeface.create("sans-serif-condensed", Typeface.BOLD);
/** Tiles travelled: the whole part picks the trees, the fraction moves the ground. */
private double travelled;
private long lastFrame;
EmpireView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
advance(moving);
float top = 46f * u, bottom = h - 186f * u;
drawWorld(c, w, top, bottom, u);
drawSpeed(c, w, top, u);
drawResources(c, w, top, u);
drawPanel(c, w, h, bottom, u);
keepAnimating(moving, 33);
}
private void advance(boolean moving) {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (moving) travelled = (travelled + v.speedKmh / 3.6f * dt / TILE_M) % 1_000_000d;
}
// ---------- Shared ----------
private void say(Canvas c, String s, float x, float y, float size, int colour, Typeface face, Paint.Align a, float u) {
text.setTypeface(face);
text.setTextSize(size);
text.setTextAlign(a);
text.setColor(0xCC000000);
c.drawText(s, x + Math.max(1f, size * 0.05f), y + Math.max(1f, size * 0.05f), text);
text.setColor(colour);
c.drawText(s, x, y, text);
}
/** Wood with planks and a gold trim. */
private void wood(Canvas c, float l, float t, float rr, float b, float u) {
fill.setColor(WOOD);
c.drawRect(l, t, rr, b, fill);
line.setColor(WOOD_DARK);
line.setStrokeWidth(2f * u);
for (float y = t + 22f * u; y < b; y += 22f * u) c.drawLine(l, y, rr, y, line);
line.setColor(WOOD_LIGHT);
line.setStrokeWidth(1f * u);
for (float y = t + 23.5f * u; y < b; y += 22f * u) c.drawLine(l, y, rr, y, line);
}
/** A recessed frame with a gold edge. */
private void inset(Canvas c, float l, float t, float rr, float b, int bg, float u) {
r.set(l, t, rr, b);
fill.setColor(bg);
c.drawRect(r, fill);
line.setColor(WOOD_DARK);
line.setStrokeWidth(4f * u);
c.drawRect(r, line);
line.setColor(TRIM);
line.setStrokeWidth(1.5f * u);
c.drawRect(r, line);
}
private void bar(Canvas c, float l, float t, float rr, float b, float frac, int colour, float u) {
fill.setColor(0xFF140C06);
c.drawRect(l, t, rr, b, fill);
if (frac > 0f) {
fill.setColor(colour);
c.drawRect(l, t, l + (rr - l) * Math.min(1f, frac), b, fill);
}
line.setColor(0xFF000000);
line.setStrokeWidth(1.5f * u);
c.drawRect(l, t, rr, b, line);
}
// ---------- World ----------
/** Isometric grass with a road through the middle; the car sits on it, the ground moves. */
private void drawWorld(Canvas c, float w, float top, float bottom, float u) {
c.save();
c.clipRect(0, top, w, bottom);
float tw = 112f * u, th = 56f * u;
float ox = w * 0.36f, oy = top + (bottom - top) * 0.62f;
long whole = (long) Math.floor(travelled);
float phase = (float) (travelled - whole);
// Moving forward along the road (up and to the right) moves the ground down and left.
float sx = -phase * tw / 2f, sy = phase * th / 2f;
int n = (int) Math.ceil((w / tw + (bottom - top) / th)) + 2;
for (int i = -n; i <= n; i++) {
for (int j = -n; j <= n; j++) {
float cx = ox + (i + j) * tw / 2f + sx, cy = oy + (i - j) * th / 2f + sy;
if (cx < -tw || cx > w + tw || cy < top - th || cy > bottom + th) continue;
long cell = j + whole;
boolean road = i == 0;
int colour = road ? ROAD : (hash(i, cell) % 3 == 0 ? GRASS_ALT : GRASS);
diamond(c, cx, cy, tw / 2f, th / 2f, colour);
if (road && (cell & 1) == 0) {
// Wheel ruts.
line.setColor(0x40000000);
line.setStrokeWidth(2f * u);
c.drawLine(cx - tw * 0.18f, cy + th * 0.02f, cx + tw * 0.08f, cy - th * 0.11f, line);
}
}
}
// Trees, drawn back to front so the nearer ones overlap.
for (int i = n; i >= -n; i--) {
for (int j = -n; j <= n; j++) {
if (Math.abs(i) < 2) continue;
long cell = j + whole;
if (hash(i, cell) % 7 != 0) continue;
float cx = ox + (i + j) * tw / 2f + sx, cy = oy + (i - j) * th / 2f + sy;
if (cx < -tw || cx > w + tw || cy < top - th || cy > bottom + 2 * th) continue;
tree(c, cx, cy, u);
}
}
drawUnit(c, ox, oy, u);
c.restore();
}
private static int hash(long i, long j) {
long x = i * 73_856_093L ^ j * 19_349_663L;
x ^= x >>> 13;
x *= 0x5bd1e995L;
x ^= x >>> 15;
return (int) ((x & 0x7fffffffL) % 1_000_003L);
}
private void diamond(Canvas c, float cx, float cy, float hw, float hh, int colour) {
path.reset();
path.moveTo(cx, cy - hh);
path.lineTo(cx + hw, cy);
path.lineTo(cx, cy + hh);
path.lineTo(cx - hw, cy);
path.close();
fill.setColor(colour);
c.drawPath(path, fill);
line.setColor(0x18000000);
line.setStrokeWidth(1f);
c.drawPath(path, line);
}
private void tree(Canvas c, float x, float y, float u) {
fill.setColor(0x40000000);
c.drawOval(x - 20f * u, y - 6f * u, x + 20f * u, y + 6f * u, fill);
fill.setColor(0xFF5A3A1E);
c.drawRect(x - 3f * u, y - 14f * u, x + 3f * u, y, fill);
for (int k = 0; k < 3; k++) {
float base = y - 10f * u - k * 16f * u, half = (22f - k * 5f) * u;
path.reset();
path.moveTo(x - half, base);
path.lineTo(x, base - 28f * u);
path.lineTo(x + half, base);
path.close();
fill.setColor(k == 1 ? 0xFF2F5A23 : 0xFF274D1D);
c.drawPath(path, fill);
}
}
/** The car as the selected unit: a selection ring, the car along the road, its health bar. */
private void drawUnit(Canvas c, float x, float y, float u) {
line.setColor(0xFFFFFFFF);
line.setStrokeWidth(2f * u);
c.drawOval(x - 62f * u, y - 24f * u, x + 62f * u, y + 24f * u, line);
c.save();
c.translate(x, y - 6f * u);
c.rotate(-26.565f);
carProfile(c, 0f, 0f, 54f * u, TEAM);
c.restore();
float frac = v.socPercent == null ? 0f : v.socPercent / 100f;
boolean low = v.socPercent != null && v.socPercent < 20f;
bar(c, x - 40f * u, y - 92f * u, x + 40f * u, y - 84f * u, frac, low ? RED : HEALTH, u);
}
/** A car in profile, facing right, in the player's colour. */
private void carProfile(Canvas c, float cx, float cy, float s, int colour) {
path.reset();
path.moveTo(cx - s, cy + s * 0.15f);
path.lineTo(cx - s * 0.92f, cy - s * 0.15f);
path.quadTo(cx - s * 0.55f, cy - s * 0.22f, cx - s * 0.32f, cy - s * 0.5f);
path.lineTo(cx + s * 0.28f, cy - s * 0.52f);
path.quadTo(cx + s * 0.6f, cy - s * 0.24f, cx + s * 0.95f, cy - s * 0.12f);
path.lineTo(cx + s, cy + s * 0.15f);
path.close();
fill.setColor(colour);
c.drawPath(path, fill);
line.setColor(0xFF0A1440);
line.setStrokeWidth(s * 0.04f);
c.drawPath(path, line);
// Windows.
path.reset();
path.moveTo(cx - s * 0.26f, cy - s * 0.42f);
path.lineTo(cx + s * 0.24f, cy - s * 0.43f);
path.lineTo(cx + s * 0.44f, cy - s * 0.2f);
path.lineTo(cx - s * 0.45f, cy - s * 0.2f);
path.close();
fill.setColor(0xFFBFD8FF);
c.drawPath(path, fill);
fill.setColor(0xFF15100A);
c.drawCircle(cx - s * 0.55f, cy + s * 0.17f, s * 0.21f, fill);
c.drawCircle(cx + s * 0.55f, cy + s * 0.17f, s * 0.21f, fill);
fill.setColor(0xFFB0B0B0);
c.drawCircle(cx - s * 0.55f, cy + s * 0.17f, s * 0.1f, fill);
c.drawCircle(cx + s * 0.55f, cy + s * 0.17f, s * 0.1f, fill);
}
// ---------- Speed ----------
private void drawSpeed(Canvas c, float w, float top, float u) {
float x = w * 0.66f;
say(c, fmt(v.speedKmh, "%.0f", "—"), x, top + 150f * u, 130f * u, PARCHMENT, serif, Paint.Align.CENTER, u);
say(c, "km/h", x, top + 184f * u, 26f * u, PARCHMENT, serif, Paint.Align.CENTER, u);
boolean low = v.socPercent != null && v.socPercent < 20f;
if (low && (SystemClock.uptimeMillis() / 600) % 2 == 0) {
say(c, "Your battery is running low!", x, top + 236f * u, 28f * u, 0xFFFFD040, serif,
Paint.Align.CENTER, u);
}
}
// ---------- Resource bar ----------
private void drawResources(Canvas c, float w, float top, float u) {
wood(c, 0, 0, w, top, u);
line.setColor(TRIM);
line.setStrokeWidth(2f * u);
c.drawLine(0, top - 1f * u, w, top - 1f * u, line);
float y = top / 2f, x = 22f * u, slot = 170f * u;
boolean low = v.socPercent != null && v.socPercent < 20f;
icon(c, 0, x, y, u);
say(c, fmt(v.socPercent, "%.0f %%", "—"), x + 30f * u, y + 9f * u, 26f * u, low ? RED : PARCHMENT, sans,
Paint.Align.LEFT, u);
x += slot;
icon(c, 1, x, y, u);
say(c, fmt(v.rangeKm, "%.0f km", "—"), x + 30f * u, y + 9f * u, 26f * u, PARCHMENT, sans, Paint.Align.LEFT, u);
x += slot;
icon(c, 2, x, y, u);
boolean regen = v.powerKw != null && v.powerKw < -0.05f;
String power = v.powerKw == null ? "—" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
Math.abs(v.powerKw));
say(c, power, x + 30f * u, y + 9f * u, 26f * u, regen ? REGEN : PARCHMENT, sans, Paint.Align.LEFT, u);
x += slot;
icon(c, 3, x, y, u);
say(c, fmt(v.usage5Min, "%.1f kWh/100", "—"), x + 30f * u, y + 9f * u, 26f * u, PARCHMENT, sans,
Paint.Align.LEFT, u);
say(c, v.clock.isEmpty() ? "—" : v.clock, w - 22f * u, y + 9f * u, 26f * u, PARCHMENT, sans,
Paint.Align.RIGHT, u);
say(c, EmpireAge.name(v.odometerKm), w - 130f * u, y + 9f * u, 26f * u, 0xFFFFD873, serif,
Paint.Align.RIGHT, u);
}
/** Small resource pictures: 0 battery, 1 flag, 2 bolt, 3 leaf. */
private void icon(Canvas c, int kind, float x, float y, float u) {
float s = 11f * u;
fill.setColor(0xFF000000);
r.set(x - s - 2f * u, y - s - 2f * u, x + s + 2f * u, y + s + 2f * u);
c.drawRect(r, fill);
line.setColor(TRIM);
line.setStrokeWidth(1.2f * u);
c.drawRect(r, line);
path.reset();
switch (kind) {
case 0:
fill.setColor(HEALTH);
c.drawRect(x - s * 0.5f, y - s * 0.6f, x + s * 0.5f, y + s * 0.85f, fill);
c.drawRect(x - s * 0.22f, y - s * 0.85f, x + s * 0.22f, y - s * 0.6f, fill);
break;
case 1:
fill.setColor(TEAM);
path.moveTo(x - s * 0.5f, y - s * 0.8f);
path.lineTo(x + s * 0.7f, y - s * 0.45f);
path.lineTo(x - s * 0.5f, y - s * 0.1f);
path.close();
c.drawPath(path, fill);
line.setColor(PARCHMENT);
line.setStrokeWidth(1.5f * u);
c.drawLine(x - s * 0.5f, y - s * 0.85f, x - s * 0.5f, y + s * 0.85f, line);
break;
case 2:
fill.setColor(0xFFFFD040);
path.moveTo(x + s * 0.2f, y - s * 0.9f);
path.lineTo(x - s * 0.55f, y + s * 0.1f);
path.lineTo(x, y + s * 0.1f);
path.lineTo(x - s * 0.2f, y + s * 0.9f);
path.lineTo(x + s * 0.55f, y - s * 0.15f);
path.lineTo(x, y - s * 0.15f);
path.close();
c.drawPath(path, fill);
break;
default:
fill.setColor(REGEN);
path.moveTo(x - s * 0.7f, y + s * 0.7f);
path.quadTo(x - s * 0.9f, y - s * 0.9f, x + s * 0.7f, y - s * 0.7f);
path.quadTo(x + s * 0.9f, y + s * 0.8f, x - s * 0.7f, y + s * 0.7f);
c.drawPath(path, fill);
}
}
// ---------- Bottom panel ----------
private void drawPanel(Canvas c, float w, float h, float bottom, float u) {
wood(c, 0, bottom, w, h, u);
line.setColor(TRIM);
line.setStrokeWidth(3f * u);
c.drawLine(0, bottom + 1.5f * u, w, bottom + 1.5f * u, line);
drawCommands(c, bottom, u);
float mapW = 250f * u;
drawUnitInfo(c, 224f * u, bottom, w - mapW - 40f * u, h, u);
drawMinimap(c, w - mapW - 20f * u, bottom, w - 20f * u, h, u);
}
/** P R N D as command buttons, two by two. */
private void drawCommands(Canvas c, float bottom, float u) {
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float s = 74f * u, gap = 8f * u, l = 30f * u, t = bottom + 16f * u;
for (int i = 0; i < gears.length; i++) {
float x = l + (i % 2) * (s + gap), y = t + (i / 2) * (s + gap);
boolean on = gears[i].equals(now);
inset(c, x, y, x + s, y + s, on ? 0xFF22346E : 0xFF1A120A, u);
if (on) {
line.setColor(0xFFFFD873);
line.setStrokeWidth(3f * u);
c.drawRect(x + 3f * u, y + 3f * u, x + s - 3f * u, y + s - 3f * u, line);
}
say(c, gears[i], x + s / 2f, y + s * 0.68f, 40f * u, on ? 0xFFFFFFFF : 0xFF9A8A6A, serif,
Paint.Align.CENTER, u);
}
}
/** The selected unit: portrait, health, name and stats. */
private void drawUnitInfo(Canvas c, float l, float t, float rr, float b, float u) {
inset(c, l, t + 16f * u, rr, b - 14f * u, 0xFF1A120A, u);
float ps = 96f * u, px = l + 16f * u, py = t + 28f * u;
inset(c, px, py, px + ps, py + ps, 0xFF2C3E1E, u);
carProfile(c, px + ps / 2f, py + ps * 0.6f, ps * 0.38f, TEAM);
float frac = v.socPercent == null ? 0f : v.socPercent / 100f;
boolean low = v.socPercent != null && v.socPercent < 20f;
bar(c, px, py + ps + 8f * u, px + ps, py + ps + 22f * u, frac, low ? RED : HEALTH, u);
say(c, fmt(v.socPercent, "%.0f / 100", "—"), px + ps / 2f, py + ps + 40f * u, 16f * u, PARCHMENT, sans,
Paint.Align.CENTER, u);
float x = px + ps + 24f * u;
say(c, "MG4", x, py + 26f * u, 28f * u, 0xFFFFD873, serif, Paint.Align.LEFT, u);
say(c, "Electric Cart", x + 80f * u, py + 26f * u, 20f * u, PARCHMENT, serif, Paint.Align.LEFT, u);
String[][] stats = {
{ "Range", fmt(v.rangeKm, "%.0f km", "—") },
{ "Trip", fmt(v.tripKm, "%.1f km", "—") },
{ "Since start", fmt(v.usageTrip, "%.1f kWh/100 km", "—") },
{ "Climate", fmt(v.climateCelsius, "%.0f °C", "—") },
};
float colW = (rr - x - 16f * u) / 2f;
for (int i = 0; i < stats.length; i++) {
float sx = x + (i % 2) * colW, sy = py + (66f + (i / 2) * 50f) * u;
fill.setColor(TRIM);
path.reset();
path.moveTo(sx + 6f * u, sy - 20f * u);
path.lineTo(sx + 12f * u, sy - 14f * u);
path.lineTo(sx + 6f * u, sy - 8f * u);
path.lineTo(sx, sy - 14f * u);
path.close();
c.drawPath(path, fill);
say(c, stats[i][0], sx + 20f * u, sy - 7f * u, 17f * u, 0xFFBFAE88, sans, Paint.Align.LEFT, u);
say(c, stats[i][1], sx + 20f * u, sy + 18f * u, 23f * u, PARCHMENT, sans, Paint.Align.LEFT, u);
}
}
/** The diamond minimap: the player's land round the car, the road, the odometer under it. */
private void drawMinimap(Canvas c, float l, float t, float rr, float b, float u) {
float cx = (l + rr) / 2f, cy = t + 60f * u, hw = (rr - l) / 2f, hh = 44f * u;
path.reset();
path.moveTo(cx, cy - hh);
path.lineTo(cx + hw, cy);
path.lineTo(cx, cy + hh);
path.lineTo(cx - hw, cy);
path.close();
fill.setColor(0xFF000000);
c.drawPath(path, fill);
c.save();
c.clipPath(path);
fill.setColor(0xFF35561F);
c.drawOval(cx - hw * 0.7f, cy - hh * 0.65f, cx + hw * 0.5f, cy + hh * 0.75f, fill);
fill.setColor(AccentColor.withAlpha(TEAM, 0x70));
c.drawOval(cx - hw * 0.32f, cy - hh * 0.3f, cx + hw * 0.2f, cy + hh * 0.34f, fill);
line.setColor(ROAD);
line.setStrokeWidth(3f * u);
c.drawLine(cx - hw, cy + hh, cx + hw, cy - hh, line);
c.restore();
line.setColor(TRIM);
line.setStrokeWidth(2f * u);
c.drawPath(path, line);
fill.setColor(0xFFFFFFFF);
c.drawCircle(cx, cy, 5f * u, fill);
fill.setColor(TEAM);
c.drawCircle(cx, cy, 3.5f * u, fill);
say(c, fmt(v.odometerKm, "%,.0f km", "—"), cx, cy + hh + 24f * u, 20f * u, PARCHMENT, sans,
Paint.Align.CENTER, u);
String next = EmpireAge.next(v.odometerKm);
Float toNext = EmpireAge.toNext(v.odometerKm);
String towards = next == null || toNext == null ? EmpireAge.name(v.odometerKm)
: String.format(Locale.US, "%s in %,.0f km", next, toNext);
bar(c, l + 12f * u, b - 28f * u, rr - 12f * u, b - 14f * u, EmpireAge.progress(v.odometerKm), 0xFFFFD873, u);
say(c, towards, cx, b - 34f * u, 15f * u, 0xFFBFAE88, sans, Paint.Align.CENTER, u);
}
}
@@ -0,0 +1,534 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
/**
* The Grid Overview style, after the neon world of the Tron films: glowing lines on black, a
* perspective grid floor that runs towards you with the speed, and shapes with cut corners.
*
* Centre: the speed, large and thin. Left: the battery as a segmented light wall standing on
* the floor, with the charge and range beside it. Right: power on a disc — the ring lights from
* the top, clockwise while drawing and anticlockwise while regenerating, and its inner ring
* turns with the power. On the floor, the last two minutes of power as a light-cycle trail,
* which only ever turns at right angles. P R N D as tiles on top, the clock top right, and a
* 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.
*
* 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, fading into the dark towards the top. It is its own view under this one
* ({@link MapLayerView}): drawn once into a hardware layer and moved and turned by the GPU each
* display frame, the car gliding between fixes; 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
* 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
* second) only while the car moves and the view is on screen.
*/
final class GridView extends ClusterCanvasView {
/** Battery segments in the light wall, disc segments, metres per grid tile. */
private static final int WALL = 20, DISC = 60;
private static final float TILE_M = 4f;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint sky = new Paint();
private final Path path = new Path();
private android.graphics.DashPathEffect dash;
private float dashFor;
private final RectF r = new RectF();
private final Typeface thin = Typeface.create("sans-serif-thin", Typeface.NORMAL);
private final Typeface label = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final int accent, accentLight;
/** Null for the plain Grid. */
private final MapRoads map;
/** The map under this view (a hardware layer, moved by the GPU); null for the plain Grid. */
private final MapLayerView mapLayer;
private final Paint fade = new Paint();
private int fadeFor = -1;
private int main, light, dim, alert;
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
private float floorPhase, discTurn;
private long lastFrame;
private int skyFor = -1;
GridView(Context context) {
this(context, null);
}
GridView(Context context, MapRoads map) {
super(context);
this.map = map;
mapLayer = map == null ? null : new MapLayerView(context, map);
accent = AccentColor.resolve(context, R.attr.evAccent);
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.SQUARE);
line.setStrokeJoin(Paint.Join.MITER);
setAccentPalette(false);
}
/** The chosen accent colour instead of the original cyan. */
void setAccentPalette(boolean useAccent) {
main = useAccent ? accent : 0xFF18E6FF;
light = useAccent ? accentLight : 0xFFC8FCFF;
dim = AccentColor.withAlpha(main, 0x40);
alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F;
skyFor = -1;
if (mapLayer != null) mapLayer.setColours(main, light);
invalidate();
}
/** The map layer, for the pane to put under this view; null for the plain Grid. */
MapLayerView mapLayer() {
return mapLayer;
}
@Override
void setValues(ClusterValues values) {
super.setValues(values);
if (mapLayer != null) {
mapLayer.setSpeed(v.speedKmh);
mapLayer.kick();
}
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
advance();
// With a map, the map layer underneath is the whole background (this view stays clear
// there), and the rest stands on dark panels over it.
boolean mapShown = mapLayer != null && mapLayer.isShowing();
float horizon = h * 0.55f;
if (mapShown) {
drawCar(c, w, h, u);
} else {
c.drawColor(0xFF000000);
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);
drawClock(c, w, u);
if (mapShown) drawMapSpeed(c, w, u);
else drawSpeed(c, w, horizon, u);
drawWall(c, horizon, u);
drawDisc(c, w, mapShown, u);
if (!mapShown) drawTrail(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;
// Over the map only the readings change; the map layer moves itself.
keepAnimating(moving && !mapShown, 33);
}
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
private void advance() {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (v.speedKmh != null && v.speedKmh > 0.5f) {
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / TILE_M) % 1f;
}
if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f;
}
// ---------- Floor ----------
private void drawSky(Canvas c, float w, float horizon) {
if (skyFor != (int) horizon) {
sky.setShader(new LinearGradient(0, horizon - horizon * 0.45f, 0, horizon,
0x00000000, AccentColor.withAlpha(main, 0x30), Shader.TileMode.CLAMP));
skyFor = (int) horizon;
}
c.drawRect(0, horizon - horizon * 0.45f, w, horizon, sky);
}
/** Lines converging on the horizon, and cross lines that run towards the viewer. */
private void drawFloor(Canvas c, float w, float h, float horizon, float u) {
float vx = w / 2f, depth = h - horizon;
line.setStrokeWidth(1.2f * u);
for (int i = -14; i <= 14; i++) {
float bottomX = vx + i * w * 0.09f;
line.setColor(AccentColor.withAlpha(main, 0x38));
c.drawLine(vx + (bottomX - vx) * 0.02f, horizon, bottomX, h, line);
}
for (int k = 0; k < 24; k++) {
float d = k + 1f - floorPhase;
if (d <= 0.05f) continue;
float y = horizon + depth / d;
if (y > h || y < horizon + 1f) continue;
float near = (y - horizon) / depth;
line.setColor(AccentColor.withAlpha(main, (int) (0x10 + 0x50 * near)));
c.drawLine(0, y, w, y, line);
}
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
}
// ---------- Map ----------
/** Over the map: the far roads fading into the dark towards the top, and the car. */
private void drawCar(Canvas c, float w, float h, float u) {
float carSx = w / 2f, carSy = h - MapLayerView.CAR_FROM_BOTTOM * u;
if (fadeFor != (int) (carSy + h)) {
fade.setShader(new LinearGradient(0, 0, 0, carSy, 0xB0000000, 0x00000000, Shader.TileMode.CLAMP));
fadeFor = (int) (carSy + h);
}
c.drawRect(0, 0, w, carSy, fade);
// 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);
}
/** 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 ----------
private void drawGears(Canvas c, float w, float u) {
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float tw = 58f * u, th = 40f * u, gap = 12f * u;
float x = w / 2f - (gears.length * tw + (gears.length - 1) * gap) / 2f, top = 22f * u;
text.setTypeface(label);
text.setTextSize(24f * u);
text.setTextAlign(Paint.Align.CENTER);
for (String g : gears) {
boolean on = g.equals(now);
chamfer(x, top, x + tw, top + th, 9f * u);
if (on) {
fill.setColor(main);
c.drawPath(path, fill);
glow(c, path, main, 1.5f * u);
} else {
line.setColor(dim);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
}
text.setColor(on ? 0xFF000000 : dim);
c.drawText(g, x + tw / 2f, top + th * 0.72f, text);
x += tw + gap;
}
}
private void drawClock(Canvas c, float w, float u) {
text.setTypeface(thin);
text.setTextSize(44f * u);
text.setTextAlign(Paint.Align.RIGHT);
glowText(c, v.clock.isEmpty() ? "—" : v.clock, w - 28f * u, 62f * u, light, u);
}
// ---------- Speed ----------
private void drawSpeed(Canvas c, float w, float horizon, float u) {
text.setTypeface(thin);
text.setTextSize(210f * u);
text.setTextAlign(Paint.Align.CENTER);
glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, horizon - 62f * u, light, u);
caption(c, "KM/H", w / 2f, horizon - 22f * u, u, Paint.Align.CENTER);
}
// ---------- Battery wall ----------
private void drawWall(Canvas c, float horizon, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
int colour = low ? alert : main;
float left = 34f * u, right = left + 52f * u, top = 96f * u, bottom = horizon + 70f * u;
float seg = (bottom - top) / WALL;
int lit = v.socPercent == null ? 0 : Math.round(v.socPercent / 100f * WALL);
for (int i = 0; i < WALL; i++) {
float b = bottom - i * seg, t = b - seg + 3f * u;
r.set(left, t, right, b);
if (i < lit) {
fill.setColor(i == lit - 1 ? light : colour);
c.drawRect(r, fill);
} else {
line.setColor(dim);
line.setStrokeWidth(1f * u);
c.drawRect(r, line);
}
}
chamfer(left - 8f * u, top - 8f * u, right + 8f * u, bottom + 8f * u, 10f * u);
glow(c, path, colour, 1.5f * u);
float x = right + 30f * u;
caption(c, low ? "LOW ENERGY" : "ENERGY", x, top + 18f * u, u, Paint.Align.LEFT);
text.setTypeface(thin);
text.setTextSize(96f * u);
text.setTextAlign(Paint.Align.LEFT);
String soc = fmt(v.socPercent, "%.0f", "—");
glowText(c, soc, x, top + 116f * u, low ? alert : light, u);
float socWidth = text.measureText(soc);
text.setTextSize(36f * u);
glowText(c, "%", x + socWidth + 8f * u, top + 116f * u, colour, u);
caption(c, "RANGE", x, top + 168f * u, u, Paint.Align.LEFT);
text.setTypeface(thin);
text.setTextSize(52f * u);
glowText(c, fmt(v.rangeKm, "%.0f km", "—"), x, top + 222f * u, light, u);
}
// ---------- Power disc ----------
/** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */
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;
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.setStrokeWidth(1.5f * u);
c.drawCircle(cx, cy, outer + 10f * u, line);
float sweepPerKw = 270f / 200f;
float power = v.powerKw == null ? 0f : Math.max(-50f, Math.min(150f, v.powerKw));
float segDeg = 270f / DISC;
for (int i = 0; i < DISC; i++) {
// Segment i covers −50 + i·(200/DISC) … one step more, drawn from −135° (top-left).
float kwFrom = -50f + i * 200f / DISC, kwTo = kwFrom + 200f / DISC;
boolean on = v.powerKw != null
&& (power >= 0 ? kwFrom >= 0f && kwFrom < power : kwTo <= 0f && kwTo > power);
float start = -90f + kwFrom * sweepPerKw;
r.set(cx - outer + ring / 2f, cy - outer + ring / 2f, cx + outer - ring / 2f, cy + outer - ring / 2f);
line.setStrokeWidth(ring);
line.setStrokeCap(Paint.Cap.BUTT);
line.setColor(on ? (power < 0 ? light : main) : AccentColor.withAlpha(main, 0x22));
c.drawArc(r, start + 0.6f, segDeg - 1.2f, false, line);
}
line.setStrokeCap(Paint.Cap.SQUARE);
// The zero mark, and an inner ring of dashes that turns with the power.
glowLine(c, cx, cy - outer - 16f * u, cx, cy - outer + ring + 6f * u, light, 2f * u);
float inner = outer - ring - 22f * u;
line.setStrokeWidth(3f * u);
line.setColor(main);
r.set(cx - inner, cy - inner, cx + inner, cy + inner);
for (int k = 0; k < 8; k++) c.drawArc(r, discTurn + k * 45f, 26f, false, line);
line.setColor(dim);
line.setStrokeWidth(1f * u);
c.drawCircle(cx, cy, inner - 12f * u, line);
text.setTypeface(thin);
text.setTextSize(64f * u);
text.setTextAlign(Paint.Align.CENTER);
glowText(c, fmt(v.powerKw, "%.1f", "—"), cx, cy + 18f * u, light, u);
caption(c, v.powerKw != null && v.powerKw < 0f ? "KW · REGEN" : "KW", cx, cy + 50f * u, u,
Paint.Align.CENTER);
caption(c, "POWER", cx, cy - 46f * u, u, Paint.Align.CENTER);
}
// ---------- Light trail ----------
/** Power over the last two minutes as a trail of right angles; NaN breaks it. */
private void drawTrail(Canvas c, float w, float h, float u) {
float[] trail = v.powerTrail;
float left = 300f * u, right = w - 360f * u, top = h * 0.66f, bottom = h * 0.84f;
if (right - left < 200f * u) return;
float max = 10f, min = -10f;
if (trail != null) {
for (float p : trail) {
if (Float.isNaN(p)) continue;
max = Math.max(max, (float) Math.ceil(p / 10f) * 10f);
min = Math.min(min, (float) Math.floor(p / 10f) * 10f);
}
}
float zero = bottom - (0f - min) / (max - min) * (bottom - top);
line.setColor(dim);
line.setStrokeWidth(1f * u);
if (dash == null || dashFor != u) {
dash = new android.graphics.DashPathEffect(new float[] { 6f * u, 8f * u }, 0);
dashFor = u;
}
line.setPathEffect(dash);
c.drawLine(left, zero, right, zero, line);
line.setPathEffect(null);
caption(c, "POWER · LAST 2 MIN", left, top - 10f * u, u, Paint.Align.LEFT);
if (trail == null || trail.length < 2) return;
float step = (right - left) / (trail.length - 1);
path.reset();
boolean open = false;
float lastX = 0, lastY = 0;
for (int i = 0; i < trail.length; i++) {
float p = trail[i];
if (Float.isNaN(p)) {
open = false;
continue;
}
float x = left + i * step, y = bottom - (p - min) / (max - min) * (bottom - top);
if (!open) {
path.moveTo(x, y);
open = true;
} else {
path.lineTo(x, lastY); // along, then a right angle
path.lineTo(x, y);
}
lastX = x;
lastY = y;
}
glow(c, path, main, 2.5f * u);
if (open) {
fill.setColor(light);
c.drawCircle(lastX, lastY, 5f * u, fill);
fill.setColor(AccentColor.withAlpha(main, 0x50));
c.drawCircle(lastX, lastY, 12f * u, fill);
}
}
// ---------- Read-outs ----------
private void drawReadouts(Canvas c, float w, float h, float u) {
String[][] cells = {
{ "USAGE · 5 MIN", fmt(v.usage5Min, "%.1f", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f", "—") },
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
};
float margin = 24f * u, gap = 14f * u, top = h - 70f * u, bottom = h - 14f * u;
float cw = (w - 2 * margin - (cells.length - 1) * gap) / cells.length;
for (int i = 0; i < cells.length; i++) {
float l = margin + i * (cw + gap);
chamfer(l, top, l + cw, bottom, 10f * u);
fill.setColor(0xE6000000);
c.drawPath(path, fill);
line.setColor(dim);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
caption(c, cells[i][0], l + 16f * u, top + 20f * u, u, Paint.Align.LEFT);
text.setTypeface(thin);
text.setTextSize(30f * u);
text.setTextAlign(Paint.Align.RIGHT);
glowText(c, cells[i][1], l + cw - 16f * u, bottom - 10f * u, light, u);
}
}
// ---------- Light ----------
/** A rectangle with its top-left and bottom-right corners cut, into [path]. */
private void chamfer(float l, float t, float rr, float b, float cut) {
path.reset();
path.moveTo(l + cut, t);
path.lineTo(rr, t);
path.lineTo(rr, b - cut);
path.lineTo(rr - cut, b);
path.lineTo(l, b);
path.lineTo(l, t + cut);
path.close();
}
/** A line that glows: two wide faint strokes under a sharp one. */
private void glow(Canvas c, Path p, int colour, float width) {
line.setColor(AccentColor.withAlpha(colour, 0x18));
line.setStrokeWidth(width * 6f);
c.drawPath(p, line);
line.setColor(AccentColor.withAlpha(colour, 0x40));
line.setStrokeWidth(width * 3f);
c.drawPath(p, line);
line.setColor(colour);
line.setStrokeWidth(width);
c.drawPath(p, line);
}
private void glowLine(Canvas c, float x1, float y1, float x2, float y2, int colour, float width) {
path.reset();
path.moveTo(x1, y1);
path.lineTo(x2, y2);
glow(c, path, colour, width);
}
/** Text with a halo: a wide faint outline under the letters. */
private void glowText(Canvas c, String s, float x, float y, int colour, float u) {
text.setStyle(Paint.Style.STROKE);
text.setStrokeWidth(7f * u);
text.setColor(AccentColor.withAlpha(colour, 0x30));
c.drawText(s, x, y, text);
text.setStyle(Paint.Style.FILL);
text.setColor(colour);
c.drawText(s, x, y, text);
}
/** A small label in spaced capitals. */
private void caption(Canvas c, String s, float x, float y, float u, Paint.Align align) {
text.setTypeface(label);
text.setTextSize(15f * u);
text.setLetterSpacing(0.2f);
text.setTextAlign(align);
text.setStyle(Paint.Style.FILL);
text.setColor(main);
c.drawText(s, x, y, text);
text.setLetterSpacing(0f);
}
}
@@ -68,6 +68,13 @@ public class MainActivity extends AppCompatActivity {
/** The styles built on {@link ClusterCanvasView}, fed together. */ /** The styles built on {@link ClusterCanvasView}, fed together. */
private final java.util.List<ClusterCanvasView> canvasStyles = new java.util.ArrayList<>(); private final java.util.List<ClusterCanvasView> canvasStyles = new java.util.ArrayList<>();
private TerminalView terminalView; private TerminalView terminalView;
private GridView gridView;
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;
@@ -342,13 +349,23 @@ public class MainActivity extends AppCompatActivity {
View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView), View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView),
mgPane = drawnPane(octagonView); mgPane = drawnPane(octagonView);
terminalView = new TerminalView(this); terminalView = new TerminalView(this);
gridView = new GridView(this);
mapRoads = new MapRoads(this);
gridMapView = new GridView(this, mapRoads);
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)); new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(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));
stylePanes = new View[] { vehiclePane, gaugesPane, cockpitPane, retroPane, // Grid with map: its map layer goes under it, in the same pane.
futuristicPane, sciFiPane, cartoonPane, mgPane, ((android.widget.FrameLayout) canvasPanes[canvasStyles.indexOf(gridMapView)]).addView(gridMapView.mapLayer(), 0);
canvasPanes[0], canvasPanes[1], canvasPanes[2], canvasPanes[3] }; View[] fixedPanes = { vehiclePane, gaugesPane, cockpitPane, retroPane,
futuristicPane, sciFiPane, cartoonPane, mgPane };
// By OverviewStyle ordinal: the fixed styles, then the canvas styles in their order.
stylePanes = new View[fixedPanes.length + canvasPanes.length];
System.arraycopy(fixedPanes, 0, stylePanes, 0, fixedPanes.length);
System.arraycopy(canvasPanes, 0, stylePanes, fixedPanes.length, canvasPanes.length);
// The Overview page holds every style; Settings → Overview style picks the one shown. // The Overview page holds every style; Settings → Overview style picks the one shown.
android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this); android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this);
overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams( overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams(
@@ -479,6 +496,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
@@ -526,6 +545,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
@@ -1713,11 +1734,60 @@ public class MainActivity extends AppCompatActivity {
private void applyOverviewStyle() { private void applyOverviewStyle() {
OverviewStyle style = overviewStyle(); OverviewStyle style = overviewStyle();
for (View pane : stylePanes) pane.setVisibility(pane == stylePanes[style.ordinal()] ? View.VISIBLE : View.GONE); for (View pane : stylePanes) pane.setVisibility(pane == stylePanes[style.ordinal()] ? View.VISIBLE : View.GONE);
// Retro and Terminal: their original palette or the accent colour, as chosen for each. // Retro, Terminal and Grid: their original palette or the accent colour, as chosen for each.
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));
gridMapView.setAccentPalette(!originalColours(OverviewStyle.GRID_MAP));
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());
}
} }
/** /**
@@ -1746,7 +1816,10 @@ public class MainActivity extends AppCompatActivity {
getString(R.string.overview_style_mg), getString(R.string.overview_style_mg),
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_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_visor), getString(R.string.overview_style_empire) };
} }
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */ /** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
@@ -1812,6 +1885,7 @@ public class MainActivity extends AppCompatActivity {
v.tyreFr = unit.format(s.getTirePressures().getFrontRightKpa()); v.tyreFr = unit.format(s.getTirePressures().getFrontRightKpa());
v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa()); v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa());
v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa()); v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa());
v.powerTrail = cockpitTrace.snapshot();
retroView.setValues(v); retroView.setValues(v);
flowView.setValues(v); flowView.setValues(v);
sciFiView.setValues(v); sciFiView.setValues(v);
@@ -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,223 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.os.SystemClock;
import android.view.View;
/**
* The map under "Grid with map": the roads round the car and a ground grid, straight from
* above, heading up — drawn once into a hardware layer (a GPU texture) and then only moved and
* turned, by the view's own translation and rotation, once per display frame. Nothing is
* redrawn while driving until new tiles arrive or the car nears the layer's edge; then it is
* drawn again round the car. {@link GridView} draws everything else on top.
*
* The layer is a square about twice the distance from the car to the farthest screen corner,
* plus a margin, so it covers the screen at any angle; the car sits {@link #CAR_FROM_BOTTOM}
* layout units above the bottom edge.
*/
final class MapLayerView extends View {
/** Pixels (layout units) per metre, the car's height above the bottom edge, grid spacing. */
static final float MAP_SCALE = 1.2f, CAR_FROM_BOTTOM = 150f;
private static final float GRID_M = 50f;
/** How far the car may move from the layer's centre before it is drawn again, in layout units. */
private static final float MARGIN = 280f;
private final MapRoads map;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path[] roads = new Path[RoadTile.CLASSES];
private final Path grid = new Path();
private int main = 0xFF18E6FF, light = 0xFFC8FCFF;
/** The layer's centre (world tile units) and what it was drawn from. */
private double anchorX = Double.NaN, anchorY;
private int drawnVersion = -1;
private float drawnScale;
/** The car as shown, eased towards where it is (world tile units), and the heading. */
private double shownX = Double.NaN, shownY;
private float heading = Float.NaN;
private boolean showing, ticking;
/** The car's speed, from the readings; NaN when unread. */
private float speedMps = Float.NaN;
MapLayerView(Context context, MapRoads map) {
super(context);
this.map = map;
for (int i = 0; i < roads.length; i++) roads[i] = new Path();
line.setStyle(Paint.Style.STROKE);
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
setLayerType(LAYER_TYPE_HARDWARE, null);
setVisibility(INVISIBLE);
setWillNotDraw(false);
}
void setColours(int main, int light) {
this.main = main;
this.light = light;
invalidate();
}
/** Whether the map is there to be seen (a fix and roads). */
boolean isShowing() {
return showing;
}
/** Layout units, as GridView uses them, for this size. */
private float unit() {
View parent = (View) getParent();
int w = parent == null ? getWidth() : parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight();
int h = parent == null ? getHeight() : parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom();
return Math.min(h / 640f, w / 1100f);
}
@Override
protected void onMeasure(int widthSpec, int heightSpec) {
int w = MeasureSpec.getSize(widthSpec), h = MeasureSpec.getSize(heightSpec);
float u = Math.min(h / 640f, w / 1100f);
int side = (int) (2 * (Math.hypot(w / 2f, h - CAR_FROM_BOTTOM * u) + MARGIN * u));
setMeasuredDimension(Math.max(side, 1), Math.max(side, 1));
}
/** The car's speed in km/h, or null when unread; GridView passes it with every reading. */
void setSpeed(Float kmh) {
speedMps = kmh == null ? Float.NaN : kmh / 3.6f;
}
/** Starts the frame loop if it is not running; GridView calls it with every reading. */
void kick() {
if (!ticking && isAttachedToWindow()) {
ticking = true;
postOnAnimation(this::tick);
}
}
/** One display frame: where the car is, which way, and the layer moved there. */
private void tick() {
View pane = (View) getParent();
if (pane == null || !pane.isShown()) {
ticking = false;
return;
}
MapRoads.Fix fix = map.fix();
boolean ready = fix != null && !map.tiles().isEmpty() && getWidth() > 0;
if (ready != showing) {
showing = ready;
setVisibility(ready ? VISIBLE : INVISIBLE);
}
if (!ready) {
ticking = false;
return;
}
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.12f + 360f) % 360f;
// Between fixes the car runs on with the speed (at most 2.5 s), and what is shown glides
// there rather than jumping with each fix.
double mpu = MapGeometry.metresPerUnit(fix.lat);
float ran = Float.isNaN(fix.bearing) || Float.isNaN(speedMps) ? 0f
: speedMps * 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;
if (Double.isNaN(shownX) || Math.hypot(carX - shownX, carY - shownY) * mpu > 200) {
shownX = carX;
shownY = carY;
} else {
shownX += (carX - shownX) * 0.15;
shownY += (carY - shownY) * 0.15;
}
float u = unit(), px = MAP_SCALE * u;
if (drawnVersion != map.version() || Double.isNaN(anchorX) || drawnScale != px
|| Math.hypot(shownX - anchorX, shownY - anchorY) * mpu * px > MARGIN * u) {
build(shownX, shownY, mpu);
drawnScale = px;
invalidate(); // the only redraw of the layer
}
// The car's spot in the layer goes to its spot on screen, turned round it.
float half = getWidth() / 2f;
float inX = half + (float) ((shownX - anchorX) * mpu) * px;
float inY = half + (float) ((shownY - anchorY) * mpu) * px;
float carSx = pane.getPaddingLeft() + (pane.getWidth() - pane.getPaddingLeft() - pane.getPaddingRight()) / 2f;
float carSy = pane.getHeight() - pane.getPaddingBottom() - CAR_FROM_BOTTOM * u;
setPivotX(inX);
setPivotY(inY);
setRotation(-heading);
setTranslationX(carSx - getLeft() - inX);
setTranslationY(carSy - getTop() - inY);
postOnAnimation(this::tick);
}
/** The roads and the grid in metres east and south of an anchor at the car. */
private void build(double atX, double atY, double mpu) {
anchorX = atX;
anchorY = atY;
drawnVersion = map.version();
float m = (float) mpu;
for (Path p : roads) p.reset();
float u = unit();
double reach = getWidth() / 2f / (MAP_SCALE * u);
double range = reach / mpu;
for (RoadTile tile : map.tiles()) {
double ox = (double) tile.x * MapGeometry.EXTENT - atX, oy = (double) tile.y * MapGeometry.EXTENT - atY;
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;
Path p = roads[tile.classes[i]];
int[] pts = tile.points[i];
p.moveTo((fx + pts[0]) * m, (fy + pts[1]) * m);
for (int k = 2; k < pts.length; k += 2) p.lineTo((fx + pts[k]) * m, (fy + pts[k + 1]) * m);
}
}
// Grid lines on whole multiples of GRID_M east and north, so they stay put on the ground.
grid.reset();
float r = (float) reach;
float offE = (float) ((atX * mpu) % GRID_M), offS = (float) ((atY * mpu) % GRID_M);
int lines = (int) (r / GRID_M) + 1;
for (int k = -lines; k <= lines; k++) {
float x = k * GRID_M - offE, y = k * GRID_M - offS;
grid.moveTo(x, -r);
grid.lineTo(x, r);
grid.moveTo(-r, y);
grid.lineTo(r, y);
}
}
@Override
protected void onDraw(Canvas c) {
if (Double.isNaN(anchorX) || drawnScale <= 0f) return;
float u = unit(), px = drawnScale;
c.save();
c.translate(getWidth() / 2f, getHeight() / 2f);
c.scale(px, px);
stroke(c, grid, AccentColor.withAlpha(main, 0x26), 1f * u / px);
stroke(c, roads[3], AccentColor.withAlpha(main, 0xA0), 1.6f * u / px);
stroke(c, roads[2], AccentColor.withAlpha(main, 0xD8), 2.2f * u / px);
glow(c, roads[1], main, 2.6f * u / px);
glow(c, roads[0], light, 3.4f * u / px);
c.restore();
}
private void stroke(Canvas c, Path p, int colour, float width) {
line.setColor(colour);
line.setStrokeWidth(width);
c.drawPath(p, line);
}
/** As GridView's glow: two wide faint strokes under a sharp one. */
private void glow(Canvas c, Path p, int colour, float width) {
stroke(c, p, AccentColor.withAlpha(colour, 0x18), width * 6f);
stroke(c, p, AccentColor.withAlpha(colour, 0x40), width * 3f);
stroke(c, p, colour, width);
}
}
@@ -0,0 +1,210 @@
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;
/** Goes up whenever a tile arrives or is dropped, so a view knows to rebuild its shapes. */
private volatile int version;
/** 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;
}
int version() {
return version;
}
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);
version++;
}
}
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);
version++;
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);
version++;
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());
}
}
}
@@ -8,7 +8,7 @@ package com.evsuite.abrp;
* colour for it. Styles with only their own fixed palette (the starship console, the cartoon, * colour for it. Styles with only their own fixed palette (the starship console, the cartoon,
* the flight display, the nixie tubes) ignore it and Settings greys it out. * the flight display, the nixie tubes) ignore it and Settings greys it out.
* - {@link #hasOriginal}: has an original palette of its own as well (Retro's turquoise * - {@link #hasOriginal}: has an original palette of its own as well (Retro's turquoise
* display, the terminal's green phosphor); Settings then offers "Original colours" above the * display, the terminal's green phosphor, the Grid's neon cyan, the Visor's blue); Settings then offers "Original colours" above the
* accent colours, remembered per style under {@link #originalKey()}. * accent colours, remembered per style under {@link #originalKey()}.
* *
* Pure Java, unit-tested. * Pure Java, unit-tested.
@@ -25,7 +25,13 @@ enum OverviewStyle {
MINIMAL(true, false), MINIMAL(true, false),
TERMINAL(true, true), TERMINAL(true, true),
NIXIE(false, false), NIXIE(false, false),
CHRONOGRAPH(true, false); CHRONOGRAPH(true, false),
GRID(true, true),
GRID_MAP(true, true),
TIME_CIRCUITS(false, false),
QUEST(false, false),
VISOR(true, true),
EMPIRE(false, false);
static final String PREF_KEY = "overview_style"; static final String PREF_KEY = "overview_style";
@@ -48,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,430 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Quest Overview style, after the interface of a fantasy online game like World of Warcraft:
* gold-trimmed frames over a dusk landscape, the car as the player character.
*
* Top left, the player frame: a round portrait, the gear in the level badge, the battery as the
* health bar and power as the energy bar. Top right, the minimap with the clock. In the middle
* the speed in large gold numbers, and under it the cast bar — "Accelerating" while drawing
* power, "Regenerating" while recovering it, "Coasting" in between and "Resting" in P. Right,
* the quest tracker: a road trip whose objectives are the trip, the usage and the climate.
* Bottom left, a chat window with system lines; bottom, P R N D as action buttons and the
* odometer as the experience bar (a level per 1000 km). Below 20 % a raid warning flashes.
* Unread values show "—" and fill nothing. Drawn per reading; only the raid warning blinks, by
* the readings that redraw it.
*/
final class QuestView extends ClusterCanvasView {
private static final int GOLD = 0xFFC9A24A, GOLD_DARK = 0xFF7A5C24, YELLOW = 0xFFFFD100,
PANEL = 0xE01A130D, HEALTH = 0xFF26B33A, ENERGY = 0xFFF2D21B, REGEN = 0xFF3FBF6F,
XP = 0xFF8E44C9, RED = 0xFFFF3B30, SYSTEM = 0xFFFFE96A;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint skyPaint = new Paint();
private final Path path = new Path();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create(Typeface.SERIF, Typeface.BOLD);
private final Typeface sans = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private int skyFor = -1;
QuestView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
drawLandscape(c, w, h);
drawPlayer(c, u);
drawMinimap(c, w, u);
drawSpeed(c, w, u);
drawCastBar(c, w, u);
drawQuests(c, w, u);
drawChat(c, u);
drawActionBar(c, w, h, u);
drawXp(c, w, h, u);
}
// ---------- World ----------
private void drawLandscape(Canvas c, float w, float h) {
if (skyFor != (int) h) {
skyPaint.setShader(new LinearGradient(0, 0, 0, h, new int[] { 0xFF0B1424, 0xFF1C2A3A, 0xFF2A2418 },
new float[] { 0f, 0.55f, 1f }, Shader.TileMode.CLAMP));
skyFor = (int) h;
}
c.drawRect(0, 0, w, h, skyPaint);
// Two ranges of hills, the near one darker.
hills(c, w, h, h * 0.62f, 0xFF16202A, 0.9f);
hills(c, w, h, h * 0.72f, 0xFF10171D, 1.6f);
}
private void hills(Canvas c, float w, float h, float base, int colour, float wave) {
path.reset();
path.moveTo(0, h);
for (int i = 0; i <= 24; i++) {
float x = w * i / 24f;
float y = base - (float) (Math.sin(i * wave) * 0.5 + Math.sin(i * wave * 0.37 + 1) * 0.5) * h * 0.06f;
path.lineTo(x, y);
}
path.lineTo(w, h);
path.close();
fill.setColor(colour);
c.drawPath(path, fill);
}
// ---------- Frames ----------
/** A dark panel with a gold rim. */
private void panel(Canvas c, float l, float t, float rr, float b, float u) {
r.set(l, t, rr, b);
fill.setColor(PANEL);
c.drawRoundRect(r, 6f * u, 6f * u, fill);
line.setColor(GOLD_DARK);
line.setStrokeWidth(4f * u);
c.drawRoundRect(r, 6f * u, 6f * u, line);
line.setColor(GOLD);
line.setStrokeWidth(1.5f * u);
c.drawRoundRect(r, 6f * u, 6f * u, line);
}
/** Text with a dark outline, as game interfaces write over the world. */
private void outlined(Canvas c, String s, float x, float y, float size, int colour, Typeface face,
Paint.Align align, float u) {
text.setTypeface(face);
text.setTextSize(size);
text.setTextAlign(align);
text.setStyle(Paint.Style.STROKE);
text.setStrokeWidth(Math.max(2f * u, size * 0.08f));
text.setStrokeJoin(Paint.Join.ROUND);
text.setColor(0xFF000000);
c.drawText(s, x, y, text);
text.setStyle(Paint.Style.FILL);
text.setColor(colour);
c.drawText(s, x, y, text);
}
/** A bar with a dark track, a fill of [frac] and centred text. */
private void bar(Canvas c, float l, float t, float rr, float b, float frac, int colour, String label, float u) {
r.set(l, t, rr, b);
fill.setColor(0xFF0A0A0A);
c.drawRect(r, fill);
if (frac > 0f) {
r.set(l, t, l + (rr - l) * Math.min(1f, frac), b);
fill.setColor(colour);
c.drawRect(r, fill);
fill.setColor(0x33FFFFFF);
c.drawRect(r.left, t, r.right, t + (b - t) * 0.35f, fill);
}
line.setColor(GOLD_DARK);
line.setStrokeWidth(2f * u);
c.drawRect(l, t, rr, b, line);
outlined(c, label, (l + rr) / 2f, b - (b - t) * 0.22f, (b - t) * 0.72f, 0xFFFFFFFF, sans,
Paint.Align.CENTER, u);
}
// ---------- Player ----------
private void drawPlayer(Canvas c, float u) {
float px = 86f * u, py = 86f * u, pr = 60f * u;
// Name plate and bars, behind the portrait.
float l = px + 20f * u, rr = px + 380f * u;
panel(c, l, py - 56f * u, rr, py + 46f * u, u);
outlined(c, "MG4", l + 60f * u, py - 28f * u, 24f * u, YELLOW, serif, Paint.Align.LEFT, u);
float soc = v.socPercent == null ? 0f : v.socPercent / 100f;
boolean low = v.socPercent != null && v.socPercent < 20f;
bar(c, l + 56f * u, py - 18f * u, rr - 12f * u, py + 10f * u, soc, low ? RED : HEALTH,
fmt(v.socPercent, "%.0f %%", "—") + " · " + fmt(v.rangeKm, "%.0f km", "—"), u);
boolean regen = v.powerKw != null && v.powerKw < -0.5f;
float energy = v.powerKw == null ? 0f : Math.abs(v.powerKw) / 150f;
bar(c, l + 56f * u, py + 14f * u, rr - 12f * u, py + 36f * u, energy, regen ? REGEN : ENERGY,
v.powerKw == null ? "—" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW", Math.abs(v.powerKw)), u);
// The portrait: a gold ring round the car's name.
fill.setColor(0xFF140F0A);
c.drawCircle(px, py, pr, fill);
line.setColor(GOLD_DARK);
line.setStrokeWidth(10f * u);
c.drawCircle(px, py, pr, line);
line.setColor(GOLD);
line.setStrokeWidth(3f * u);
c.drawCircle(px, py, pr + 3f * u, line);
drawCarGlyph(c, px, py + 6f * u, pr * 0.62f, u);
// The level badge: the gear.
float bx = px - pr * 0.72f, by = py + pr * 0.72f, br = 20f * u;
fill.setColor(0xFF140F0A);
c.drawCircle(bx, by, br, fill);
line.setColor(GOLD);
line.setStrokeWidth(3f * u);
c.drawCircle(bx, by, br, line);
String gear = Gear.label(v.gear);
outlined(c, gear.isEmpty() ? "?" : gear, bx, by + 8f * u, 22f * u, YELLOW, serif, Paint.Align.CENTER, u);
}
/** A small car in profile for the portrait. */
private void drawCarGlyph(Canvas c, float cx, float cy, float s, float u) {
path.reset();
path.moveTo(cx - s, cy + s * 0.15f);
path.lineTo(cx - s * 0.92f, cy - s * 0.12f);
path.quadTo(cx - s * 0.55f, cy - s * 0.2f, cx - s * 0.35f, cy - s * 0.48f);
path.lineTo(cx + s * 0.3f, cy - s * 0.5f);
path.quadTo(cx + s * 0.62f, cy - s * 0.22f, cx + s * 0.95f, cy - s * 0.1f);
path.lineTo(cx + s, cy + s * 0.15f);
path.close();
fill.setColor(GOLD);
c.drawPath(path, fill);
fill.setColor(0xFF140F0A);
c.drawCircle(cx - s * 0.55f, cy + s * 0.18f, s * 0.2f, fill);
c.drawCircle(cx + s * 0.55f, cy + s * 0.18f, s * 0.2f, fill);
fill.setColor(GOLD);
c.drawCircle(cx - s * 0.55f, cy + s * 0.18f, s * 0.11f, fill);
c.drawCircle(cx + s * 0.55f, cy + s * 0.18f, s * 0.11f, fill);
}
// ---------- Minimap ----------
private void drawMinimap(Canvas c, float w, float u) {
float cx = w - 120f * u, cy = 128f * u, mr = 84f * u;
outlined(c, "The Open Road", cx, 30f * u, 20f * u, YELLOW, serif, Paint.Align.CENTER, u);
fill.setColor(0xFF1E3524);
c.drawCircle(cx, cy, mr, fill);
// Roads and a river, clipped to the map.
c.save();
path.reset();
path.addCircle(cx, cy, mr, Path.Direction.CW);
c.clipPath(path);
line.setColor(0xFF8A7650);
line.setStrokeWidth(5f * u);
c.drawLine(cx, cy + mr, cx, cy - mr, line);
c.drawLine(cx - mr, cy - mr * 0.35f, cx + mr, cy + mr * 0.1f, line);
line.setColor(0xFF2E5C8A);
line.setStrokeWidth(7f * u);
path.reset();
path.moveTo(cx - mr, cy + mr * 0.5f);
path.quadTo(cx - mr * 0.2f, cy + mr * 0.2f, cx + mr, cy + mr * 0.7f);
c.drawPath(path, line);
c.restore();
// The player arrow.
path.reset();
path.moveTo(cx, cy - 13f * u);
path.lineTo(cx + 9f * u, cy + 10f * u);
path.lineTo(cx, cy + 5f * u);
path.lineTo(cx - 9f * u, cy + 10f * u);
path.close();
fill.setColor(YELLOW);
c.drawPath(path, fill);
line.setColor(0xFF000000);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
// The stone rim.
line.setColor(0xFF4A4036);
line.setStrokeWidth(12f * u);
c.drawCircle(cx, cy, mr + 5f * u, line);
line.setColor(GOLD);
line.setStrokeWidth(2f * u);
c.drawCircle(cx, cy, mr + 11f * u, line);
c.drawCircle(cx, cy, mr - 1f * u, line);
// The clock under it.
panel(c, cx - 50f * u, cy + mr + 16f * u, cx + 50f * u, cy + mr + 48f * u, u);
outlined(c, v.clock.isEmpty() ? "—" : v.clock, cx, cy + mr + 40f * u, 22f * u, 0xFFFFFFFF, sans,
Paint.Align.CENTER, u);
}
// ---------- Speed and cast bar ----------
private void drawSpeed(Canvas c, float w, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
if (low && (SystemClock.uptimeMillis() / 600) % 2 == 0) {
outlined(c, "Low battery!", w / 2f, 64f * u, 34f * u, RED, serif, Paint.Align.CENTER, u);
}
outlined(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, 236f * u, 150f * u, YELLOW, serif, Paint.Align.CENTER, u);
outlined(c, "km/h", w / 2f, 270f * u, 24f * u, GOLD, serif, Paint.Align.CENTER, u);
}
private void drawCastBar(Canvas c, float w, float u) {
boolean parked = v.gear != null && v.gear == 1;
Float p = v.powerKw;
String spell;
int colour;
float frac;
if (parked) {
spell = "Resting";
colour = 0xFF5B7BD9;
frac = 1f;
} else if (p == null) {
spell = "—";
colour = ENERGY;
frac = 0f;
} else if (p > 0.5f) {
spell = "Accelerating";
colour = 0xFFFFB300;
frac = p / 150f;
} else if (p < -0.5f) {
spell = "Regenerating";
colour = REGEN;
frac = -p / 50f;
} else {
spell = "Coasting";
colour = 0xFF8F8F8F;
frac = 0.02f;
}
float bw = Math.min(520f * u, w * 0.36f), l = (w - bw) / 2f + 18f * u, rr = l + bw, t = 296f * u, b = t + 30f * u;
// The spell icon.
float il = l - 40f * u;
r.set(il, t - 3f * u, il + 36f * u, b + 3f * u);
fill.setColor(0xFF140F0A);
c.drawRect(r, fill);
line.setColor(GOLD);
line.setStrokeWidth(2f * u);
c.drawRect(r, line);
drawSpellIcon(c, r.centerX(), r.centerY(), 13f * u, spell, colour, u);
r.set(l, t, rr, b);
fill.setColor(0xFF0A0A0A);
c.drawRect(r, fill);
r.set(l, t, l + bw * Math.max(0f, Math.min(1f, frac)), b);
fill.setColor(colour);
c.drawRect(r, fill);
fill.setColor(0x33FFFFFF);
c.drawRect(r.left, t, r.right, t + 10f * u, fill);
line.setColor(GOLD);
line.setStrokeWidth(2.5f * u);
c.drawRect(l, t, rr, b, line);
outlined(c, spell, l + 10f * u, b - 8f * u, 20f * u, 0xFFFFFFFF, sans, Paint.Align.LEFT, u);
outlined(c, p == null ? "—" : String.format(Locale.US, "%.1f kW", Math.abs(p)), rr - 10f * u, b - 8f * u,
20f * u, 0xFFFFFFFF, sans, Paint.Align.RIGHT, u);
}
private void drawSpellIcon(Canvas c, float cx, float cy, float s, String spell, int colour, float u) {
fill.setColor(colour);
path.reset();
switch (spell) {
case "Accelerating": // a lightning bolt
path.moveTo(cx + s * 0.2f, cy - s);
path.lineTo(cx - s * 0.6f, cy + s * 0.1f);
path.lineTo(cx, cy + s * 0.1f);
path.lineTo(cx - s * 0.2f, cy + s);
path.lineTo(cx + s * 0.6f, cy - s * 0.15f);
path.lineTo(cx, cy - s * 0.15f);
path.close();
c.drawPath(path, fill);
break;
case "Regenerating": // a leaf
path.moveTo(cx - s * 0.8f, cy + s * 0.8f);
path.quadTo(cx - s, cy - s, cx + s * 0.8f, cy - s * 0.8f);
path.quadTo(cx + s, cy + s * 0.9f, cx - s * 0.8f, cy + s * 0.8f);
c.drawPath(path, fill);
break;
case "Resting":
outlined(c, "Zz", cx, cy + s * 0.5f, s * 1.5f, colour, serif, Paint.Align.CENTER, u);
break;
default:
c.drawCircle(cx, cy, s * 0.5f, fill);
}
}
// ---------- Quest tracker and chat ----------
private void drawQuests(Canvas c, float w, float u) {
float x = w - 400f * u, y = 310f * u;
fill.setColor(YELLOW);
c.drawCircle(x - 18f * u, y - 9f * u, 11f * u, fill);
outlined(c, "!", x - 18f * u, y - 1f * u, 18f * u, 0xFF000000, serif, Paint.Align.CENTER, u);
outlined(c, "Road trip", x, y, 26f * u, YELLOW, serif, Paint.Align.LEFT, u);
String[] objectives = {
"Distance: " + fmt(v.tripKm, "%.1f km", "—"),
"Usage, 5 min: " + fmt(v.usage5Min, "%.1f kWh/100 km", "—"),
"Since start: " + fmt(v.usageTrip, "%.1f kWh/100 km", "—"),
"Climate: " + fmt(v.climateCelsius, "%.0f °C", "—"),
};
for (int i = 0; i < objectives.length; i++) {
outlined(c, "– " + objectives[i], x + 8f * u, y + (34f + i * 30f) * u, 21f * u, 0xFFE8E8E8, sans,
Paint.Align.LEFT, u);
}
}
private void drawChat(Canvas c, float u) {
float l = 22f * u, t = 368f * u, rr = l + 470f * u, b = 520f * u;
r.set(l, t, rr, b);
fill.setColor(0x66000000);
c.drawRoundRect(r, 6f * u, 6f * u, fill);
String state = v.gear == null ? "The car is not answering."
: v.gear == 1 ? "You feel rested."
: v.gear == 2 ? "You are moving backwards."
: "You are on the road.";
String[] lines = {
"Range remaining: " + fmt(v.rangeKm, "%.0f km", "—") + ".",
"Odometer: " + fmt(v.odometerKm, "%,.0f km", "—") + ".",
state,
};
for (int i = 0; i < lines.length; i++) {
outlined(c, lines[i], l + 14f * u, t + (38f + i * 40f) * u, 21f * u, SYSTEM, sans, Paint.Align.LEFT, u);
}
}
// ---------- Action bar and experience ----------
private void drawActionBar(Canvas c, float w, float h, float u) {
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float s = 64f * u, gap = 8f * u, total = gears.length * s + (gears.length - 1) * gap;
float x = (w - total) / 2f, top = h - 104f * u;
panel(c, x - 12f * u, top - 10f * u, x + total + 12f * u, top + s + 10f * u, u);
for (int i = 0; i < gears.length; i++) {
boolean on = gears[i].equals(now);
r.set(x, top, x + s, top + s);
fill.setColor(on ? 0xFF3A2A12 : 0xFF141414);
c.drawRect(r, fill);
line.setColor(on ? YELLOW : GOLD_DARK);
line.setStrokeWidth((on ? 4f : 2f) * u);
c.drawRect(r, line);
outlined(c, gears[i], x + s / 2f, top + s * 0.72f, 36f * u, on ? YELLOW : 0xFF9A9A9A, serif,
Paint.Align.CENTER, u);
outlined(c, String.valueOf(i + 1), x + s - 6f * u, top + 16f * u, 13f * u, 0xFFD0D0D0, sans,
Paint.Align.RIGHT, u);
x += s + gap;
}
}
/** The odometer as experience: a level per 1000 km, the bar the way through this one. */
private void drawXp(Canvas c, float w, float h, float u) {
float l = 22f * u, rr = w - 22f * u, t = h - 26f * u, b = h - 10f * u;
r.set(l, t, rr, b);
fill.setColor(0xFF0A0A0A);
c.drawRect(r, fill);
String label = "—";
if (v.odometerKm != null) {
float into = v.odometerKm % 1000f;
r.set(l, t, l + (rr - l) * into / 1000f, b);
fill.setColor(XP);
c.drawRect(r, fill);
label = String.format(Locale.US, "Level %d · %.0f / 1000 km", (int) (v.odometerKm / 1000f) + 1, into);
}
line.setColor(GOLD_DARK);
line.setStrokeWidth(1.5f * u);
for (int i = 1; i < 20; i++) {
float x = l + (rr - l) * i / 20f;
c.drawLine(x, t, x, b, line);
}
c.drawRect(l, t, rr, b, line);
outlined(c, label, w / 2f, b - 3f * u, 13f * u, 0xFFFFFFFF, sans, Paint.Align.CENTER, u);
}
}
@@ -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;
}
}
}
@@ -0,0 +1,77 @@
package com.evsuite.abrp;
/**
* Fourteen-segment letters, as on the time circuits the Time circuits style is drawn after: the
* months and the gear words. Pure Java so it can be tested off the car.
*
* Segments as bits: the outer seven like {@link SevenSegment} (a top, b upper right, c lower
* right, d bottom, e lower left, f upper left), the middle bar in two halves (g1 left, g2
* right), and the inner six: h from the upper left corner to the centre, i up the middle, j to
* the upper right corner, k to the lower left, l down the middle, m to the lower right.
*/
final class Segments14 {
static final int A = 1, B = 2, C = 4, D = 8, E = 16, F = 32, G1 = 64, G2 = 128,
H = 256, I = 512, J = 1024, K = 2048, L = 4096, M = 8192;
private Segments14() {}
/** The segments a letter, digit or '-' lights; anything else (a space) lights none. */
static int mask(char ch) {
switch (Character.toUpperCase(ch)) {
case 'A': return A | B | C | E | F | G1 | G2;
case 'B': return A | B | C | D | I | L | G2;
case 'C': return A | D | E | F;
case 'D': return A | B | C | D | I | L;
case 'E': return A | D | E | F | G1 | G2;
case 'F': return A | E | F | G1;
case 'G': return A | C | D | E | F | G2;
case 'H': return B | C | E | F | G1 | G2;
case 'I': return A | D | I | L;
case 'J': return B | C | D | E;
case 'K': return E | F | G1 | J | M;
case 'L': return D | E | F;
case 'M': return B | C | E | F | H | J;
case 'N': return B | C | E | F | H | M;
case 'O': return A | B | C | D | E | F;
case 'P': return A | B | E | F | G1 | G2;
case 'R': return A | B | E | F | G1 | G2 | M;
case 'S': return A | C | D | F | G1 | G2;
case 'T': return A | I | L;
case 'U': return B | C | D | E | F;
case 'V': return E | F | J | K;
case 'W': return B | C | E | F | K | M;
case 'Y': return H | J | L;
case '0': return A | B | C | D | E | F | J | K;
case '1': return B | C;
case '2': return A | B | D | E | G1 | G2;
case '3': return A | B | C | D | G2;
case '4': return B | C | F | G1 | G2;
case '5': return A | C | D | F | G1 | G2;
case '6': return A | C | D | E | F | G1 | G2;
case '7': return A | B | C;
case '8': return A | B | C | D | E | F | G1 | G2;
case '9': return A | B | C | D | F | G1 | G2;
case '-': return G1 | G2;
default: return 0;
}
}
/** The three-letter month as the time circuits show it: JAN … DEC; "---" out of range. */
static String month(int month0) {
String[] m = { "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" };
return month0 >= 0 && month0 < 12 ? m[month0] : "---";
}
/** The gear as a three-letter word for the time circuits: PRK, REV, NTL, DRV; "---" unread. */
static String gearWord(Integer gear) {
if (gear == null) return "---";
switch (gear) {
case 1: return "PRK";
case 2: return "REV";
case 3: return "NTL";
case 4: return "DRV";
default: return "---";
}
}
}
@@ -7,6 +7,8 @@ import android.content.IntentFilter;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import com.evsuite.hardware.AssistBlock;
import com.evsuite.hardware.EVHardware;
import com.evsuite.hardware.probe.VendorSurfaceProbe; import com.evsuite.hardware.probe.VendorSurfaceProbe;
import java.io.File; import java.io.File;
@@ -26,7 +28,11 @@ import java.util.Map;
* The signal recorder on the Logs tab: for {@link #DURATION_MS} it reads every value the * The signal recorder on the Logs tab: for {@link #DURATION_MS} it reads every value the
* head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager property and every * head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager property and every
* value getter of the vendor objects, read only) again and again, and every steering-wheel key * value getter of the vendor objects, read only) again and again, and every steering-wheel key
* the head unit reports, into a {@link SignalRecording}. The driver notes the times they do * the head unit reports, into a {@link SignalRecording}. It also listens, as the head unit's own
* driver-assistance manager does, to the properties of the assistance block
* ({@link AssistBlock}): subscribed for change events the whole time, because a value that is
* only filled while someone listens reads as its default when polled (2.40.3, still the
* cruise control). The driver notes the times they do
* what is to be found; the values that change then are the leads. (Built to find the adaptive * what is to be found; the values that change then are the leads. (Built to find the adaptive
* cruise control, 2.32.0: on SWI68 the head unit gets none of it, see SPECS.md.) * cruise control, 2.32.0: on SWI68 the head unit gets none of it, see SPECS.md.)
* *
@@ -85,6 +91,21 @@ final class SignalRecorder {
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
// No key reports: the properties are recorded all the same. // No key reports: the properties are recorded all the same.
} }
EVHardware.PropertyListening listening = null;
try {
listening = EVHardware.INSTANCE.listenToProperties(AssistBlock.INSTANCE::contains,
(id, area, value) -> {
rec.event(System.currentTimeMillis(),
String.format(Locale.US, "CPM 0x%08X/%d", id, area), value);
return kotlin.Unit.INSTANCE;
});
} catch (Throwable t) {
rec.note(System.currentTimeMillis(), "listening failed: " + t.getClass().getSimpleName());
}
rec.note(System.currentTimeMillis(), listening == null
? "listening to the assistance properties: not possible (no car service)"
: "listening to " + listening.getSubscribed() + " assistance properties ("
+ listening.getRefused() + " refused)");
try { try {
long end = startedMs + DURATION_MS; long end = startedMs + DURATION_MS;
while (running && System.currentTimeMillis() < end) { while (running && System.currentTimeMillis() < end) {
@@ -99,6 +120,13 @@ final class SignalRecorder {
} catch (Throwable t) { } catch (Throwable t) {
rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName()); rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName());
} finally { } finally {
if (listening != null) {
try {
listening.close();
} catch (Throwable ignored) {
// the car service is gone
}
}
if (keysRegistered) { if (keysRegistered) {
try { try {
app.unregisterReceiver(keys); app.unregisterReceiver(keys);
@@ -130,7 +158,10 @@ final class SignalRecorder {
text.add("Steering-wheel keys and notes:"); text.add("Steering-wheel keys and notes:");
text.addAll(rec.notes()); text.addAll(rec.notes());
text.add(""); text.add("");
text.add("Changed values, the rarest first:"); text.add("Assistance properties heard (" + rec.events() + " change events), changing ones first:");
text.addAll(rec.heard());
text.add("");
text.add("Changed values (polled), the rarest first:");
text.addAll(rec.summary()); text.addAll(rec.summary());
text.add(""); text.add("");
text.add("Everything, in order:"); text.add("Everything, in order:");
@@ -168,14 +199,17 @@ final class SignalRecorder {
long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs; long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs;
if (running) { if (running) {
out.add(context.getString(R.string.signal_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60, out.add(context.getString(R.string.signal_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60,
recording.captures())); recording.captures(), recording.events()));
} else { } else {
out.add(context.getString(R.string.signal_recorder_done, SignalRecording.time(startedMs), out.add(context.getString(R.string.signal_recorder_done, SignalRecording.time(startedMs),
recording.captures(), result == null ? "…" : result)); recording.captures(), recording.events(), result == null ? "…" : result));
} }
// Steering-wheel keys and the like first: few, and each one a lead. // Steering-wheel keys and the like first: few, and each one a lead.
List<String> notes = recording.notes(); List<String> notes = recording.notes();
out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size())); out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size()));
// Then what the listening heard, changing properties first.
List<String> heard = recording.heard();
for (String line : heard.subList(0, Math.min(12, heard.size()))) out.add("heard " + line);
out.addAll(recording.summary()); out.addAll(recording.summary());
return out; return out;
} }
@@ -16,6 +16,10 @@ import java.util.TreeSet;
* feature on and off, changes a setting). It keeps what changed, when, and per value how * feature on and off, changes a setting). It keeps what changed, when, and per value how
* often and through which values, so the value that follows the action stands out: it * often and through which values, so the value that follows the action stands out: it
* changes a few times, at the moments the driver noted. * changes a few times, at the moments the driver noted.
*
* Besides the captures (polled), it keeps the change events of the properties the recorder
* listens to ({@link #event}): how many per property and through which values. A value the
* head unit only fills while someone listens shows up there and nowhere else.
* Pure Java, unit-tested. * Pure Java, unit-tested.
*/ */
final class SignalRecording { final class SignalRecording {
@@ -31,6 +35,10 @@ final class SignalRecording {
private final List<String> notes = new ArrayList<>(); private final List<String> notes = new ArrayList<>();
private final Map<String, Integer> changes = new HashMap<>(); private final Map<String, Integer> changes = new HashMap<>();
private final Map<String, LinkedHashSet<String>> seen = new HashMap<>(); private final Map<String, LinkedHashSet<String>> seen = new HashMap<>();
private int events;
private final Map<String, String> lastEvent = new HashMap<>();
private final Map<String, Integer> eventCounts = new HashMap<>();
private final Map<String, LinkedHashSet<String>> eventValues = new HashMap<>();
static String time(long ms) { static String time(long ms) {
return new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(ms)); return new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(ms));
@@ -65,6 +73,45 @@ final class SignalRecording {
notes.add(time(ms) + " " + what); notes.add(time(ms) + " " + what);
} }
/**
* A change event from a property listened to. Every one is counted; the timeline gets a
* line only when the value differs from that property's last event.
*/
synchronized void event(long ms, String key, String value) {
events++;
eventCounts.merge(key, 1, Integer::sum);
String before = lastEvent.put(key, value);
if (value.equals(before)) return;
lines.add(time(ms) + " heard " + key + " = " + value);
LinkedHashSet<String> values = eventValues.computeIfAbsent(key, k -> new LinkedHashSet<>());
if (values.size() < VALUES_KEPT) values.add(shorten(value));
}
synchronized int events() {
return events;
}
/**
* Every property that sent events: first those that sent different values, the fewest
* events first (the leads), then those that only ever repeated one value.
*/
synchronized List<String> heard() {
List<String> keys = new ArrayList<>(eventCounts.keySet());
keys.sort((a, b) -> {
boolean va = eventValues.get(a).size() > 1, vb = eventValues.get(b).size() > 1;
if (va != vb) return va ? -1 : 1;
int c = Integer.compare(eventCounts.get(a), eventCounts.get(b));
return c != 0 ? c : a.compareTo(b);
});
List<String> out = new ArrayList<>();
for (String key : keys) {
LinkedHashSet<String> values = eventValues.get(key);
out.add(key + " " + eventCounts.get(key) + " events " + String.join(" → ", values)
+ (values.size() >= VALUES_KEPT ? " …" : ""));
}
return out;
}
/** The notes alone, in order. */ /** The notes alone, in order. */
synchronized List<String> notes() { synchronized List<String> notes() {
return new ArrayList<>(notes); return new ArrayList<>(notes);
@@ -18,6 +18,12 @@ 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:
case GRID_MAP: return R.style.ThemeOverlay_EV_Style_Grid;
case TIME_CIRCUITS: return R.style.ThemeOverlay_EV_Style_TimeCircuits;
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
case EMPIRE: return R.style.ThemeOverlay_EV_Style_Empire;
default: return 0; default: return 0;
} }
} }
@@ -0,0 +1,404 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Calendar;
import java.util.Locale;
/**
* The Time circuits Overview style, after the time machine in Back to the Future: three rows of
* LED read-outs in a metal panel, labelled with embossed tape, a red speed display and a flux
* capacitor.
*
* The rows keep the film's look, each in its colour: red "DESTINATION" — the gear as a word
* (DRV, REV, NTL, PRK), the charge, the range (where you can still get to), power or
* regeneration on the lamps, and the usage over 5 minutes; green "PRESENT TIME" — today's date
* and time; amber "LAST TIME DEPARTED" — when the car last left P, dashes until it has been seen
* to. Right, the speed in large red digits and the flux capacitor, whose light runs to its
* centre faster with more power and outwards while regenerating; at 88 km/h it flashes. Along
* the bottom, trip, usage since start, climate and odometer. Unread values show dashes and light
* nothing. It animates (about 30 frames a second) only while there is power to show or at 88,
* and only on screen.
*/
final class TimeCircuitsView extends ClusterCanvasView {
private static final int RED = 0xFFFF3A26, GREEN = 0xFF3DFF5A, AMBER = 0xFFFFA726, WHITE_HOT = 0xFFFFF4D6;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint metal = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF r = new RectF();
private final Typeface tape = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private int metalFor = -1;
/** When the car last left P, wall clock; 0 until that has been seen. */
private long departedAt;
private Integer lastGear;
private float fluxPhase;
private long lastFrame;
TimeCircuitsView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.ROUND);
}
@Override
void setValues(ClusterValues values) {
// Readings arrive whether or not the style is shown, so the departure is never missed.
Integer gear = values == null ? null : values.gear;
if (gear != null && lastGear != null && lastGear == 1 && gear != 1) {
departedAt = System.currentTimeMillis();
}
if (gear != null) lastGear = gear;
super.setValues(values);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
advance();
c.drawColor(0xFF050505);
float split = w * 0.64f;
drawPanel(c, 12f * u, 10f * u, split - 6f * u, h - 10f * u, u);
float rowLeft = 34f * u, rowRight = split - 24f * u;
drawCarRow(c, rowLeft, rowRight, 22f * u, u);
drawTimeRow(c, rowLeft, rowRight, 170f * u, u, GREEN, "PRESENT TIME", System.currentTimeMillis());
drawTimeRow(c, rowLeft, rowRight, 318f * u, u, AMBER, "LAST TIME DEPARTED", departedAt);
drawBottom(c, rowLeft, rowRight, 470f * u, u);
drawPanel(c, split + 6f * u, 10f * u, w - 12f * u, h - 10f * u, u);
drawSpeed(c, split + 28f * u, w - 28f * u, 26f * u, u);
drawFlux(c, split + 28f * u, w - 28f * u, 262f * u, h - 26f * u, u);
boolean moving = (v.powerKw != null && Math.abs(v.powerKw) > 0.5f) || at88();
keepAnimating(moving, 33);
}
private boolean at88() {
return v.speedKmh != null && v.speedKmh >= 88f;
}
private void advance() {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (v.powerKw != null) fluxPhase = (fluxPhase + v.powerKw * 0.025f * dt + 1000f) % 1f;
}
// ---------- Panels and tape ----------
private void drawPanel(Canvas c, float l, float t, float rr, float b, float u) {
if (metalFor != (int) (b - t)) {
metal.setShader(new LinearGradient(0, t, 0, b, 0xFF2C2D31, 0xFF18191B, Shader.TileMode.CLAMP));
metalFor = (int) (b - t);
}
r.set(l, t, rr, b);
c.drawRoundRect(r, 10f * u, 10f * u, metal);
line.setColor(0xFF45464B);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 10f * u, 10f * u, line);
fill.setColor(0xFF5A5B60);
for (float x : new float[] { l + 14f * u, rr - 14f * u }) {
for (float y : new float[] { t + 14f * u, b - 14f * u }) c.drawCircle(x, y, 4f * u, fill);
}
}
/** Embossed label tape: black, raised white capitals, centred on x. */
private void tape(Canvas c, String s, float x, float y, float u, int tint) {
text.setTypeface(tape);
text.setTextSize(18f * u);
text.setLetterSpacing(0.18f);
text.setTextAlign(Paint.Align.CENTER);
float tw = text.measureText(s);
r.set(x - tw / 2f - 14f * u, y - 18f * u, x + tw / 2f + 14f * u, y + 7f * u);
fill.setColor(tint);
c.drawRoundRect(r, 4f * u, 4f * u, fill);
fill.setColor(0x22FFFFFF);
c.drawRect(r.left + 4f * u, r.top + 3f * u, r.right - 4f * u, r.top + 5f * u, fill);
text.setColor(0xFFF2F2F2);
c.drawText(s, x, y, text);
text.setLetterSpacing(0f);
}
/** A small caption above a field. */
private void caption(Canvas c, String s, float x, float y, float u) {
text.setTypeface(tape);
text.setTextSize(13f * u);
text.setLetterSpacing(0.12f);
text.setTextAlign(Paint.Align.CENTER);
text.setColor(0xFFBFC1C6);
c.drawText(s, x, y, text);
text.setLetterSpacing(0f);
}
// ---------- Rows ----------
/** The cells of one field: 'a' alphanumeric (fourteen segments), 'n' digits, 'l' lamps, ':' colon. */
private static final class Field {
final char kind;
final int cells;
final String caption;
Field(char kind, int cells, String caption) {
this.kind = kind;
this.cells = cells;
this.caption = caption;
}
}
private static final Field[] CAR = {
new Field('a', 3, "GEAR"), new Field('n', 3, "CHARGE %"), new Field('n', 4, "RANGE KM"),
new Field('l', 0, ""), new Field('n', 3, "USAGE"),
};
private static final Field[] TIME = {
new Field('a', 3, "MONTH"), new Field('n', 2, "DAY"), new Field('n', 4, "YEAR"),
new Field('l', 0, ""), new Field('n', 2, "HOUR"), new Field(':', 0, ""), new Field('n', 2, "MIN"),
};
private void drawCarRow(Canvas c, float left, float right, float top, float u) {
String charge = v.socPercent == null ? "---" : String.format(Locale.US, "%3.0f", v.socPercent).replace(' ', '0');
String range = v.rangeKm == null ? "----" : String.format(Locale.US, "%04.0f", Math.min(9999f, v.rangeKm));
String usage = v.usage5Min == null ? "---" : String.format(Locale.US, "%.1f", Math.min(99.9, Math.abs(v.usage5Min)));
boolean regen = v.powerKw != null && v.powerKw < -0.5f, power = v.powerKw != null && v.powerKw > 0.5f;
drawRow(c, left, right, top, u, RED, "DESTINATION", CAR,
new String[] { Segments14.gearWord(v.gear), charge, range, null, usage },
new String[] { "POWER", "REGEN" }, new boolean[] { power, regen });
}
private void drawTimeRow(Canvas c, float left, float right, float top, float u, int colour, String title, long when) {
String[] values;
boolean[] lamps = { false, false };
if (when <= 0) {
values = new String[] { "---", "--", "----", null, "--", null, "--" };
} else {
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(when);
int hour = cal.get(Calendar.HOUR_OF_DAY);
values = new String[] { Segments14.month(cal.get(Calendar.MONTH)),
String.format(Locale.US, "%02d", cal.get(Calendar.DAY_OF_MONTH)),
String.format(Locale.US, "%04d", cal.get(Calendar.YEAR)), null,
String.format(Locale.US, "%02d", hour), null,
String.format(Locale.US, "%02d", cal.get(Calendar.MINUTE)) };
lamps = new boolean[] { hour < 12, hour >= 12 };
}
drawRow(c, left, right, top, u, colour, title, TIME, values, new String[] { "AM", "PM" }, lamps);
}
private void drawRow(Canvas c, float left, float right, float top, float u, int colour, String title,
Field[] fields, String[] values, String[] lampNames, boolean[] lampsOn) {
float boxTop = top + 22f * u, boxH = 78f * u, gap = 14f * u, pad = 8f * u;
float lampW = 74f * u, colonW = 16f * u;
int cells = 0;
float fixed = 0;
for (Field f : fields) {
cells += f.cells;
if (f.kind == 'l') fixed += lampW;
else if (f.kind == ':') fixed += colonW;
else fixed += 2 * pad;
}
fixed += gap * (fields.length - 1);
float cw = Math.min(boxH * 0.58f, (right - left - fixed) / cells);
float total = cells * cw + fixed, x = left + (right - left - total) / 2f;
for (int i = 0; i < fields.length; i++) {
Field f = fields[i];
if (f.kind == 'l') {
drawLamps(c, x, boxTop, lampW, boxH, u, colour, lampNames, lampsOn);
x += lampW + gap;
continue;
}
if (f.kind == ':') {
fill.setColor(colour);
c.drawCircle(x + colonW / 2f, boxTop + boxH * 0.36f, 3.5f * u, fill);
c.drawCircle(x + colonW / 2f, boxTop + boxH * 0.64f, 3.5f * u, fill);
x += colonW + gap;
continue;
}
float bw = f.cells * cw + 2 * pad;
r.set(x, boxTop, x + bw, boxTop + boxH);
fill.setColor(0xFF020202);
c.drawRoundRect(r, 4f * u, 4f * u, fill);
line.setColor(0xFF5C5D62);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 4f * u, 4f * u, line);
caption(c, f.caption, x + bw / 2f, top + 14f * u, u);
drawText(c, values[i], f.kind == 'a', x + pad, boxTop + 8f * u, cw, boxH - 16f * u, f.cells, colour, u);
x += bw + gap;
}
tape(c, title, (left + right) / 2f, boxTop + boxH + 34f * u, u, 0xFF0E0E0F);
}
private void drawLamps(Canvas c, float x, float top, float w, float h, float u, int colour,
String[] names, boolean[] on) {
for (int k = 0; k < 2; k++) {
float cy = top + h * (k == 0 ? 0.3f : 0.72f), cx = x + w - 16f * u;
caption(c, names[k], x + 22f * u, cy + 5f * u, u);
fill.setColor(on[k] ? AccentColor.withAlpha(colour, 0x55) : 0x00000000);
c.drawCircle(cx, cy, 11f * u, fill);
fill.setColor(on[k] ? colour : AccentColor.withAlpha(colour, 0x30));
c.drawCircle(cx, cy, 6f * u, fill);
}
}
// ---------- Segments ----------
/** Right-aligned text in cells; a '.' joins the cell before it. */
private void drawText(Canvas c, String s, boolean alpha, float x, float y, float cw, float ch,
int cells, int colour, float u) {
char[] chars = SevenSegment.cells(s == null ? "" : s, cells);
boolean[] dots = SevenSegment.dots(s == null ? "" : s, cells);
for (int i = 0; i < cells; i++) {
int mask = alpha ? Segments14.mask(chars[i]) : from7(SevenSegment.mask(chars[i]));
drawCell(c, x + i * cw, y, cw, ch, mask, alpha, colour, u);
if (dots[i]) {
fill.setColor(colour);
c.drawCircle(x + (i + 1) * cw - cw * 0.06f, y + ch - cw * 0.1f, cw * 0.07f, fill);
}
}
}
/** Seven-segment bits as fourteen-segment ones (the middle bar is both halves). */
private static int from7(int m) {
int out = m & 63; // a–f share their bits
if ((m & SevenSegment.G) != 0) out |= Segments14.G1 | Segments14.G2;
return out;
}
private void drawCell(Canvas c, float x, float y, float cw, float ch, int mask, boolean alpha, int colour, float u) {
float m = cw * 0.16f, t = cw * 0.12f;
float x0 = x + m, x1 = x + cw - m, xc = (x0 + x1) / 2f, y0 = y + t, y1 = y + ch - t, ym = (y0 + y1) / 2f;
float[][] segs = {
{ x0, y0, x1, y0, Segments14.A }, { x1, y0, x1, ym, Segments14.B }, { x1, ym, x1, y1, Segments14.C },
{ x0, y1, x1, y1, Segments14.D }, { x0, ym, x0, y1, Segments14.E }, { x0, y0, x0, ym, Segments14.F },
{ x0, ym, xc, ym, Segments14.G1 }, { xc, ym, x1, ym, Segments14.G2 },
{ x0, y0, xc, ym, Segments14.H }, { xc, y0, xc, ym, Segments14.I }, { x1, y0, xc, ym, Segments14.J },
{ x0, y1, xc, ym, Segments14.K }, { xc, ym, xc, y1, Segments14.L }, { x1, y1, xc, ym, Segments14.M },
};
for (float[] s : segs) {
int bit = (int) s[4];
if (!alpha && bit >= Segments14.H) continue; // digits have no inner segments
boolean on = (mask & bit) != 0;
float dx = s[2] - s[0], dy = s[3] - s[1], len = (float) Math.hypot(dx, dy);
float g = Math.min(t * 0.8f, len * 0.3f), ux = dx / len * g, uy = dy / len * g;
float ax = s[0] + ux, ay = s[1] + uy, bx = s[2] - ux, by = s[3] - uy;
if (on) {
line.setColor(AccentColor.withAlpha(colour, 0x38));
line.setStrokeWidth(t * 2.4f);
c.drawLine(ax, ay, bx, by, line);
}
line.setColor(on ? colour : AccentColor.withAlpha(colour, 0x1C));
line.setStrokeWidth(bit >= Segments14.H ? t * 0.8f : t);
c.drawLine(ax, ay, bx, by, line);
}
}
// ---------- Bottom read-outs ----------
private void drawBottom(Canvas c, float left, float right, float top, float u) {
String[][] cells = {
{ "TRIP KM", fmt(v.tripKm, "%.1f", "---"), "5" },
{ "SINCE START", fmt(v.usageTrip, "%.1f", "---"), "4" },
{ "CLIMATE °C", fmt(v.climateCelsius, "%.0f", "--"), "3" },
{ "ODOMETER KM", fmt(v.odometerKm, "%.0f", "------"), "6" },
};
float gap = 18f * u, boxH = 62f * u, boxTop = top + 22f * u, pad = 8f * u;
int total = 0;
for (String[] cell : cells) total += Integer.parseInt(cell[2]);
float cw = Math.min(boxH * 0.55f, (right - left - gap * (cells.length - 1) - cells.length * 2 * pad) / total);
float width = total * cw + gap * (cells.length - 1) + cells.length * 2 * pad;
float x = left + (right - left - width) / 2f;
for (String[] cell : cells) {
int n = Integer.parseInt(cell[2]);
float bw = n * cw + 2 * pad;
r.set(x, boxTop, x + bw, boxTop + boxH);
fill.setColor(0xFF020202);
c.drawRoundRect(r, 4f * u, 4f * u, fill);
line.setColor(0xFF5C5D62);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 4f * u, 4f * u, line);
caption(c, cell[0], x + bw / 2f, top + 14f * u, u);
drawText(c, cell[1], false, x + pad, boxTop + 7f * u, cw, boxH - 14f * u, n, AMBER, u);
x += bw + gap;
}
}
// ---------- Speed and flux capacitor ----------
private void drawSpeed(Canvas c, float left, float right, float top, float u) {
float boxH = 168f * u, cells = 3;
float cw = Math.min(boxH * 0.56f, (right - left - 40f * u) / cells);
float bw = cells * cw + 40f * u, x = (left + right - bw) / 2f;
r.set(x, top, x + bw, top + boxH);
fill.setColor(0xFF020202);
c.drawRoundRect(r, 6f * u, 6f * u, fill);
line.setColor(0xFF5C5D62);
line.setStrokeWidth(2.5f * u);
c.drawRoundRect(r, 6f * u, 6f * u, line);
String speed = v.speedKmh == null ? "---" : String.format(Locale.US, "%.0f", Math.min(999f, v.speedKmh));
// At 88 the digits burn white-hot, and flicker.
boolean hot = at88() && (SystemClock.uptimeMillis() / 90) % 3 != 0;
drawText(c, speed, false, x + 20f * u, top + 14f * u, cw, boxH - 28f * u, 3, hot ? WHITE_HOT : RED, u);
tape(c, at88() ? "88 KM/H — HOLD ON" : "KM/H", (left + right) / 2f, top + boxH + 34f * u, u, 0xFF0E0E0F);
}
/** Three glass tubes in a Y, light running to the centre with the power (outwards in regen). */
private void drawFlux(Canvas c, float left, float right, float top, float bottom, float u) {
float labelSpace = 70f * u, boxBottom = bottom - labelSpace;
float size = Math.min(right - left, boxBottom - top);
float cx = (left + right) / 2f, bx = cx - size / 2f;
r.set(bx, top, bx + size, boxBottom);
fill.setColor(0xFF0B0C0E);
c.drawRoundRect(r, 8f * u, 8f * u, fill);
line.setColor(0xFF6A6B70);
line.setStrokeWidth(3f * u);
c.drawRoundRect(r, 8f * u, 8f * u, line);
float cy = top + (boxBottom - top) * 0.52f, inset = size * 0.14f;
float[][] ends = { { bx + inset, top + inset }, { bx + size - inset, top + inset }, { cx, boxBottom - inset } };
boolean flash = at88();
boolean live = v.powerKw != null;
float strength = live ? Math.min(1f, Math.abs(v.powerKw) / 60f) : 0f;
for (float[] e : ends) {
line.setColor(0xFF2A2C30);
line.setStrokeWidth(16f * u);
c.drawLine(e[0], e[1], cx, cy, line);
line.setColor(flash ? WHITE_HOT : AccentColor.withAlpha(WHITE_HOT, live ? 0x40 + (int) (0x60 * strength) : 0x18));
line.setStrokeWidth(5f * u);
c.drawLine(e[0], e[1], cx, cy, line);
if (live && Math.abs(v.powerKw) > 0.5f) {
// Pulses along the tube: phase 0 at the end, 1 at the centre.
for (int k = 0; k < 4; k++) {
float p = (fluxPhase + k / 4f) % 1f;
float px = e[0] + (cx - e[0]) * p, py = e[1] + (cy - e[1]) * p;
fill.setColor(AccentColor.withAlpha(WHITE_HOT, 0x50));
c.drawCircle(px, py, 12f * u, fill);
fill.setColor(WHITE_HOT);
c.drawCircle(px, py, 5f * u, fill);
}
}
fill.setColor(0xFF6A6B70);
c.drawCircle(e[0], e[1], 12f * u, fill);
}
fill.setColor(AccentColor.withAlpha(WHITE_HOT, flash ? 0xA0 : live ? 0x30 + (int) (0x70 * strength) : 0x14));
c.drawCircle(cx, cy, 34f * u, fill);
fill.setColor(flash || (live && strength > 0.05f) ? WHITE_HOT : 0xFF3A3B40);
c.drawCircle(cx, cy, 13f * u, fill);
tape(c, "FLUX CAPACITOR", cx, boxBottom + 30f * u, u, 0xFF0E0E0F);
String power = !live ? "— KILOWATTS"
: v.powerKw < -0.5f ? String.format(Locale.US, "REGEN %.1f KILOWATTS", -v.powerKw)
: String.format(Locale.US, "%.1f KILOWATTS", v.powerKw);
tape(c, power, cx, boxBottom + 62f * u, u, 0xFF5A1410);
}
}
@@ -0,0 +1,322 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RadialGradient;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Visor Overview style, after the helmet display of a science-fiction shooter like Halo:
* thin light-blue lines on a dark visor, slanted panels and a reticle in the middle.
*
* Top centre, the shield bar: the battery, segmented, red and blinking below 20 % with
* "BATTERY LOW". Top right, the ammunition counter: the range as a number and as rounds, one
* per 10 km. Under it, four grenade slots for P R N D, the current one lit. Middle, the
* reticle with the speed inside it and power round it — clockwise while drawing, green and
* anticlockwise while regenerating. Bottom left, the motion tracker: its sweep turns while the
* car moves, and its blips are the last two minutes of power (red drawing, green regenerating), further out the more
* power (the rim is 60 kW, 40 kW regenerating).
* Top left the clock, bottom right the objective with trip, usage, climate and odometer.
* Unread values show "—" and light nothing.
*
* Two palettes: the original visor blue, or the chosen accent colour ({@link #setAccentPalette}).
* The tracker animates (about 30 frames a second) only while the car moves and the view is on
* screen; the low-battery blink rides on the readings that redraw it.
*/
final class VisorView extends ClusterCanvasView {
/** Shield segments, rounds in the counter (10 km each). */
private static final int SHIELD = 24, ROUNDS = 50;
private static final int RED = 0xFFFF3B30, REGEN = 0xFF4CFF7A, ENEMY = 0xFFFF4A3D;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint vignette = new Paint();
private final Path path = new Path();
private final RectF r = new RectF();
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
private final int accent, accentLight;
private int main, light, dim;
private float sweep;
private long lastFrame;
private int vignetteFor = -1;
VisorView(Context context) {
super(context);
accent = AccentColor.resolve(context, R.attr.evAccent);
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
line.setStyle(Paint.Style.STROKE);
line.setStrokeJoin(Paint.Join.MITER);
setAccentPalette(false);
}
/** The chosen accent colour instead of the original visor blue. */
void setAccentPalette(boolean useAccent) {
main = useAccent ? accent : 0xFF5CC8FF;
light = useAccent ? accentLight : 0xFFD2F0FF;
dim = AccentColor.withAlpha(main, 0x40);
vignetteFor = -1;
invalidate();
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
advance(moving);
drawVisor(c, w, h, u);
drawClock(c, u);
drawShield(c, w, u);
drawAmmo(c, w, u);
drawReticle(c, w, h, u);
drawTracker(c, h, u);
drawObjective(c, w, h, u);
keepAnimating(moving, 33);
}
private void advance(boolean moving) {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (moving) sweep = (sweep + dt * 180f) % 360f;
}
// ---------- Visor ----------
/** Dark glass, the helmet's rim darkening the corners, and its edge lines at the top. */
private void drawVisor(Canvas c, float w, float h, float u) {
c.drawColor(0xFF03080D);
if (vignetteFor != (int) (w + h)) {
vignette.setShader(new RadialGradient(w / 2f, h * 0.48f, Math.max(w, h) * 0.62f,
new int[] { AccentColor.withAlpha(main, 0x16), 0x00000000, 0xE0000000 },
new float[] { 0f, 0.62f, 1f }, Shader.TileMode.CLAMP));
vignetteFor = (int) (w + h);
}
c.drawRect(0, 0, w, h, vignette);
line.setStrokeWidth(1.5f * u);
line.setColor(AccentColor.withAlpha(main, 0x50));
path.reset();
path.moveTo(0, 150f * u);
path.quadTo(w * 0.08f, 16f * u, w * 0.3f, 8f * u);
path.moveTo(w, 150f * u);
path.quadTo(w * 0.92f, 16f * u, w * 0.7f, 8f * u);
c.drawPath(path, line);
}
/** A slanted panel, as the display draws its frames. */
private void slant(Canvas c, float l, float t, float rr, float b, float lean, int stroke, int bg, float u) {
path.reset();
path.moveTo(l + lean, t);
path.lineTo(rr, t);
path.lineTo(rr - lean, b);
path.lineTo(l, b);
path.close();
fill.setColor(bg);
c.drawPath(path, fill);
line.setColor(stroke);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
}
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
text.setTypeface(tf);
text.setTextSize(size);
text.setTextAlign(a);
text.setColor(colour);
c.drawText(s, x, y, text);
}
// ---------- Clock ----------
private void drawClock(Canvas c, float u) {
float l = 28f * u, t = 26f * u;
slant(c, l, t, l + 150f * u, t + 44f * u, 12f * u, main, AccentColor.withAlpha(main, 0x18), u);
label(c, v.clock.isEmpty() ? "—" : v.clock, l + 75f * u, t + 33f * u, 30f * u, light, face, Paint.Align.CENTER);
label(c, "LOCAL TIME", l + 12f * u, t + 64f * u, 14f * u, main, face, Paint.Align.LEFT);
}
// ---------- Shield ----------
private void drawShield(Canvas c, float w, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
boolean blinkOn = (SystemClock.uptimeMillis() / 500) % 2 == 0;
int colour = low ? RED : main;
float bw = Math.min(520f * u, w * 0.36f), l = (w - bw) / 2f, t = 28f * u, b = t + 30f * u;
slant(c, l - 14f * u, t - 8f * u, l + bw + 14f * u, b + 8f * u, 14f * u, AccentColor.withAlpha(colour, 0x90),
0x66000000, u);
int lit = v.socPercent == null ? 0 : Math.round(Math.max(0f, Math.min(100f, v.socPercent)) / 100f * SHIELD);
float gap = 3f * u, seg = (bw - (SHIELD - 1) * gap) / SHIELD;
for (int i = 0; i < SHIELD; i++) {
float x = l + i * (seg + gap);
path.reset();
path.moveTo(x + 6f * u, t);
path.lineTo(x + seg + 6f * u, t);
path.lineTo(x + seg, b);
path.lineTo(x, b);
path.close();
boolean on = i < lit && (!low || blinkOn);
fill.setColor(on ? colour : AccentColor.withAlpha(colour, 0x22));
c.drawPath(path, fill);
}
label(c, fmt(v.socPercent, "%.0f %%", "—"), w / 2f, b + 36f * u, 26f * u, low ? RED : light, face,
Paint.Align.CENTER);
if (low && blinkOn) {
label(c, "BATTERY LOW", w / 2f, b + 66f * u, 22f * u, RED, face, Paint.Align.CENTER);
}
}
// ---------- Ammunition and grenades ----------
private void drawAmmo(Canvas c, float w, float u) {
float rr = w - 30f * u, t = 26f * u;
// The count: the range.
label(c, fmt(v.rangeKm, "%.0f", "—"), rr, t + 52f * u, 60f * u, light, face, Paint.Align.RIGHT);
label(c, "KM RANGE", rr, t + 74f * u, 15f * u, main, face, Paint.Align.RIGHT);
// The rounds: two rows of 25, one per 10 km.
int lit = v.rangeKm == null ? 0 : Math.round(Math.max(0f, v.rangeKm) / 10f);
float rw = 5f * u, rh = 20f * u, gap = 3f * u, perRow = ROUNDS / 2f;
float rowW = perRow * rw + (perRow - 1) * gap, l = rr - 150f * u - rowW;
for (int i = 0; i < ROUNDS; i++) {
int row = i / (ROUNDS / 2), col = i % (ROUNDS / 2);
float x = l + col * (rw + gap), y = t + 8f * u + row * (rh + 8f * u);
r.set(x, y, x + rw, y + rh);
fill.setColor(i < lit ? light : AccentColor.withAlpha(main, 0x26));
c.drawRect(r, fill);
}
// The grenade slots: P R N D.
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float gs = 34f * u, gx = l, gy = t + 66f * u;
for (int i = 0; i < gears.length; i++) {
boolean on = gears[i].equals(now);
float x = gx + i * (gs + 6f * u);
path.reset();
path.moveTo(x + gs / 2f, gy);
path.lineTo(x + gs, gy + gs * 0.5f);
path.lineTo(x + gs / 2f, gy + gs);
path.lineTo(x, gy + gs * 0.5f);
path.close();
fill.setColor(on ? main : 0x55000000);
c.drawPath(path, fill);
line.setColor(on ? light : dim);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
label(c, gears[i], x + gs / 2f, gy + gs * 0.5f + 7f * u, 20f * u, on ? 0xFF03080D : main, face,
Paint.Align.CENTER);
}
}
// ---------- Reticle ----------
private void drawReticle(Canvas c, float w, float h, float u) {
float cx = w / 2f, cy = h * 0.53f, rad = 122f * u;
line.setStrokeWidth(2f * u);
line.setColor(AccentColor.withAlpha(main, 0x70));
c.drawCircle(cx, cy, rad, line);
// Four ticks.
line.setColor(main);
line.setStrokeWidth(3f * u);
c.drawLine(cx, cy - rad - 16f * u, cx, cy - rad + 10f * u, line);
c.drawLine(cx, cy + rad - 10f * u, cx, cy + rad + 16f * u, line);
c.drawLine(cx - rad - 16f * u, cy, cx - rad + 10f * u, cy, line);
c.drawLine(cx + rad - 10f * u, cy, cx + rad + 16f * u, cy, line);
// Power round it: 150 kW is a full turn, regeneration 50 kW the other way.
r.set(cx - rad - 10f * u, cy - rad - 10f * u, cx + rad + 10f * u, cy + rad + 10f * u);
line.setStrokeWidth(8f * u);
line.setStrokeCap(Paint.Cap.BUTT);
line.setColor(AccentColor.withAlpha(main, 0x1C));
c.drawArc(r, 0, 360, false, line);
if (v.powerKw != null && Math.abs(v.powerKw) > 0.1f) {
boolean regen = v.powerKw < 0;
float sweepDeg = regen ? Math.max(-360f, v.powerKw / 50f * 360f) : Math.min(360f, v.powerKw / 150f * 360f);
line.setColor(regen ? REGEN : light);
c.drawArc(r, -90f, sweepDeg, false, line);
}
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, cy + 34f * u, 104f * u, light, face, Paint.Align.CENTER);
label(c, "KM/H", cx, cy + 66f * u, 18f * u, main, face, Paint.Align.CENTER);
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, v.powerKw < -0.05f ? "+%.1f kW" : "%.1f kW",
Math.abs(v.powerKw));
label(c, power, cx, cy + rad + 48f * u, 24f * u,
v.powerKw != null && v.powerKw < -0.05f ? REGEN : light, face, Paint.Align.CENTER);
}
// ---------- Motion tracker ----------
/** The tracker: rings, the sweep while moving, and the power trail as blips round the car. */
private void drawTracker(Canvas c, float h, float u) {
float rad = 112f * u, cx = 40f * u + rad, cy = h - 34f * u - rad;
fill.setColor(AccentColor.withAlpha(main, 0x14));
c.drawCircle(cx, cy, rad, fill);
line.setStrokeWidth(1.5f * u);
line.setColor(AccentColor.withAlpha(main, 0x90));
c.drawCircle(cx, cy, rad, line);
line.setColor(AccentColor.withAlpha(main, 0x3A));
c.drawCircle(cx, cy, rad * 0.66f, line);
c.drawCircle(cx, cy, rad * 0.33f, line);
c.drawLine(cx - rad, cy, cx + rad, cy, line);
c.drawLine(cx, cy - rad, cx, cy + rad, line);
if (v.speedKmh != null && v.speedKmh > 0.5f) {
float a = (float) Math.toRadians(sweep - 90f);
line.setColor(AccentColor.withAlpha(light, 0x90));
line.setStrokeWidth(2f * u);
c.drawLine(cx, cy, cx + (float) Math.cos(a) * rad, cy + (float) Math.sin(a) * rad, line);
}
// Blips: the last two minutes, the newest nearest the top, each at the distance its power gives.
float[] trail = v.powerTrail;
if (trail != null && trail.length > 0) {
int step = Math.max(1, trail.length / 24);
for (int i = trail.length - 1, k = 0; i >= 0; i -= step, k++) {
float p = trail[i];
if (Float.isNaN(p) || Math.abs(p) < 1f) continue;
float dist = (0.18f + 0.78f * Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f))) * rad;
float ang = (float) Math.toRadians(-90f + k * 15f);
float bx = cx + (float) Math.cos(ang) * dist, by = cy + (float) Math.sin(ang) * dist;
int colour = p < 0 ? REGEN : ENEMY;
fill.setColor(AccentColor.withAlpha(colour, Math.max(0x40, 0xFF - k * 8)));
c.drawCircle(bx, by, 6f * u, fill);
}
}
// The car in the middle.
path.reset();
path.moveTo(cx, cy - 10f * u);
path.lineTo(cx + 7f * u, cy + 8f * u);
path.lineTo(cx - 7f * u, cy + 8f * u);
path.close();
fill.setColor(light);
c.drawPath(path, fill);
label(c, "60 kW", cx, cy - rad - 8f * u, 13f * u, main, thin, Paint.Align.CENTER);
}
// ---------- Objective ----------
private void drawObjective(Canvas c, float w, float h, float u) {
float rr = w - 30f * u, l = rr - 330f * u, t = h - 214f * u, b = h - 34f * u;
slant(c, l, t, rr, b, 18f * u, AccentColor.withAlpha(main, 0x80), 0x80000000, u);
label(c, "OBJECTIVE", l + 30f * u, t + 30f * u, 18f * u, main, face, Paint.Align.LEFT);
String[][] rows = {
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
};
for (int i = 0; i < rows.length; i++) {
float y = t + (62f + i * 27f) * u, x = l + 26f * u - i * 3f * u;
label(c, rows[i][0], x, y, 17f * u, main, thin, Paint.Align.LEFT);
label(c, rows[i][1], rr - 30f * u - i * 3f * u, y, 19f * u, light, face, Paint.Align.RIGHT);
}
}
}
+9 -3
View File
@@ -135,6 +135,12 @@
<string name="overview_style_minimal">Minimal</string> <string name="overview_style_minimal">Minimal</string>
<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_map">Grid with map</string>
<string name="overview_style_time_circuits">Time circuits</string>
<string name="overview_style_quest">Quest</string>
<string name="overview_style_visor">Visor</string>
<string name="overview_style_empire">Empire</string>
<string name="overview_style_nixie">Nixie tubes</string> <string name="overview_style_nixie">Nixie tubes</string>
<string name="overview_style_chronograph">Chronograph</string> <string name="overview_style_chronograph">Chronograph</string>
<string name="cockpit_battery">BATTERY</string> <string name="cockpit_battery">BATTERY</string>
@@ -217,11 +223,11 @@
<string name="drive_mode_unreadable">Not changed: speed or gear unreadable.</string> <string name="drive_mode_unreadable">Not changed: speed or gear unreadable.</string>
<string name="drive_mode_unknown">an unknown mode (%1$s)</string> <string name="drive_mode_unknown">an unknown mode (%1$s)</string>
<string name="label_signal_recorder">Signal recorder</string> <string name="label_signal_recorder">Signal recorder</string>
<string name="signal_recorder_hint">Records every value the head unit offers for 3 minutes, plus the steering-wheel keys it reports, and lists what changed, the rarest first. To find a signal: start it, note the times you switch the feature on and off or change it, and look for the values that changed then. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string> <string name="signal_recorder_hint">Records every value the head unit offers for 3 minutes, listens to the driver-assistance properties for every change they report, and notes the steering-wheel keys; then lists what changed, the rarest first. To find a signal: start it, note the times you switch the feature on and off or change it, and look for the values that changed then. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string>
<string name="btn_signal_record">Record 3 minutes</string> <string name="btn_signal_record">Record 3 minutes</string>
<string name="btn_signal_stop">Stop recording</string> <string name="btn_signal_stop">Stop recording</string>
<string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures. Keys reported, then values changed so far, the rarest first:</string> <string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures, %4$d change events heard. Keys reported, then assistance properties heard, then values changed so far, the rarest first:</string>
<string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$s. Keys reported, then values changed, the rarest first:</string> <string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$d change events heard, %4$s. Keys reported, then assistance properties heard, then values changed, the rarest first:</string>
<string name="call_log_empty">No upload attempted yet.</string> <string name="call_log_empty">No upload attempted yet.</string>
<string name="update_log_title">Updates, newest first:</string> <string name="update_log_title">Updates, newest first:</string>
<string name="btn_flush_now">Upload now</string> <string name="btn_flush_now">Upload now</string>
@@ -54,4 +54,54 @@
<item name="evAccentStroke">#80774A26</item> <item name="evAccentStroke">#80774A26</item>
<item name="evAccentTrack">#6A4224</item> <item name="evAccentTrack">#6A4224</item>
</style> </style>
<!-- Grid: the neon cyan of its lines. -->
<style name="ThemeOverlay.EV.Style.Grid" parent="">
<item name="evAccent">#18E6FF</item>
<item name="evOnAccent">#00161A</item>
<item name="evAccentLight">#C8FCFF</item>
<item name="evAccentDeep">#06343C</item>
<item name="evAccentSurface">#0A1C20</item>
<item name="evAccentStroke">#80267A86</item>
<item name="evAccentTrack">#1E6672</item>
</style>
<!-- Time circuits: the red of the destination row. -->
<style name="ThemeOverlay.EV.Style.TimeCircuits" parent="">
<item name="evAccent">#FF3A26</item>
<item name="evOnAccent">#1F0603</item>
<item name="evAccentLight">#FF9A8C</item>
<item name="evAccentDeep">#3D0E08</item>
<item name="evAccentSurface">#2A110D</item>
<item name="evAccentStroke">#80773A30</item>
<item name="evAccentTrack">#6A2C24</item>
</style>
<!-- Quest: the gold of the frames. -->
<style name="ThemeOverlay.EV.Style.Quest" parent="">
<item name="evAccent">#C9A24A</item>
<item name="evOnAccent">#1A1408</item>
<item name="evAccentLight">#FFD873</item>
<item name="evAccentDeep">#3A2C10</item>
<item name="evAccentSurface">#241C0E</item>
<item name="evAccentStroke">#806E5628</item>
<item name="evAccentTrack">#6A5426</item>
</style>
<!-- Visor: the blue of the helmet display. -->
<style name="ThemeOverlay.EV.Style.Visor" parent="">
<item name="evAccent">#5CC8FF</item>
<item name="evOnAccent">#03121C</item>
<item name="evAccentLight">#D2F0FF</item>
<item name="evAccentDeep">#0C3048</item>
<item name="evAccentSurface">#0A1A26</item>
<item name="evAccentStroke">#80306A8A</item>
<item name="evAccentTrack">#22587A</item>
</style>
<!-- Empire: the player's blue. -->
<style name="ThemeOverlay.EV.Style.Empire" parent="">
<item name="evAccent">#3D6BFF</item>
<item name="evOnAccent">#FFFFFF</item>
<item name="evAccentLight">#A8BEFF</item>
<item name="evAccentDeep">#101E4A</item>
<item name="evAccentSurface">#121A33</item>
<item name="evAccentStroke">#80344A99</item>
<item name="evAccentTrack">#2A3F82</item>
</style>
</resources> </resources>
@@ -0,0 +1,40 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import org.junit.Test;
/** The Empire style's ages by odometer — pure JVM. */
public class EmpireAgeTest {
@Test
public void agesByOdometer() {
assertEquals("Dark Age", EmpireAge.name(0f));
assertEquals("Dark Age", EmpireAge.name(9_999f));
assertEquals("Feudal Age", EmpireAge.name(10_000f));
assertEquals("Castle Age", EmpireAge.name(45_000f));
assertEquals("Imperial Age", EmpireAge.name(60_000f));
assertEquals("Imperial Age", EmpireAge.name(250_000f));
}
@Test
public void theWayToTheNextAge() {
assertEquals("Castle Age", EmpireAge.next(12_000f));
assertEquals(18_000f, EmpireAge.toNext(12_000f), 0.01f);
assertEquals(0.1f, EmpireAge.progress(12_000f), 0.001f);
assertEquals(0.5f, EmpireAge.progress(5_000f), 0.001f);
assertNull(EmpireAge.next(70_000f));
assertNull(EmpireAge.toNext(70_000f));
assertEquals(1f, EmpireAge.progress(70_000f), 0f);
}
@Test
public void unreadIsNoAge() {
assertEquals("—", EmpireAge.name(null));
assertEquals(-1, EmpireAge.index(Float.NaN));
assertNull(EmpireAge.next(null));
assertNull(EmpireAge.toNext(null));
assertEquals(0f, EmpireAge.progress(null), 0f);
}
}
@@ -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));
} }
} }
@@ -0,0 +1,39 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import org.junit.Test;
/** The time circuits' fourteen-segment letters — pure JVM. */
public class Segments14Test {
@Test
public void everyMonthAndGearWordLightsEveryLetter() {
for (int m = 0; m < 12; m++) {
for (char ch : Segments14.month(m).toCharArray()) assertNotEquals(ch + "", 0, Segments14.mask(ch));
}
for (int g = 1; g <= 4; g++) {
for (char ch : Segments14.gearWord(g).toCharArray()) assertNotEquals(ch + "", 0, Segments14.mask(ch));
}
}
@Test
public void unreadIsDashes() {
assertEquals("---", Segments14.gearWord(null));
assertEquals("---", Segments14.gearWord(9));
assertEquals("---", Segments14.month(12));
assertEquals(Segments14.G1 | Segments14.G2, Segments14.mask('-'));
assertEquals(0, Segments14.mask(' '));
}
@Test
public void wordsAsTheCircuitsShowThem() {
assertEquals("OCT", Segments14.month(9));
assertEquals("DRV", Segments14.gearWord(4));
assertEquals("PRK", Segments14.gearWord(1));
// The letters that need the diagonals do use them.
assertNotEquals(0, Segments14.mask('N') & Segments14.H);
assertNotEquals(0, Segments14.mask('V') & Segments14.K);
}
}
@@ -63,4 +63,22 @@ public class SignalRecordingTest {
assertTrue(r.lines().get(4).endsWith("key 287 down")); assertTrue(r.lines().get(4).endsWith("key 287 down"));
assertEquals(1, r.notes().size()); assertEquals(1, r.notes().size());
} }
@Test
public void heardPropertiesWithChangingValuesComeFirst() {
SignalRecording r = new SignalRecording();
for (int i = 0; i < 5; i++) r.event(i * 1000, "CPM 0x2140A10A/0", "1"); // repeats one value
r.event(1000, "CPM 0x2160A15B/0", "0.0");
r.event(2000, "CPM 0x2160A15B/0", "101.0");
r.event(3000, "CPM 0x2160A15B/0", "101.0");
r.event(4000, "CPM 0x2160A15B/0", "90.0");
List<String> heard = r.heard();
assertEquals(2, heard.size());
assertEquals("CPM 0x2160A15B/0 4 events 0.0 → 101.0 → 90.0", heard.get(0));
assertEquals("CPM 0x2140A10A/0 5 events 1", heard.get(1));
assertEquals(9, r.events());
// The timeline holds only the changes: 0.0, 101.0, 90.0 and the first 1.
long lines = r.lines().stream().filter(l -> l.contains("heard ")).count();
assertEquals(4, lines);
}
} }
@@ -16,10 +16,17 @@ import android.widget.Toast;
* STATUS_PENDING_USER_ACTION is the case worth understanding: the platform is saying it will * STATUS_PENDING_USER_ACTION is the case worth understanding: the platform is saying it will
* install, but wants the driver to confirm. That happens when the app is not granted a silent * install, but wants the driver to confirm. That happens when the app is not granted a silent
* install, and the only correct response is to launch the confirmation activity it hands us. * install, and the only correct response is to launch the confirmation activity it hands us.
*
* Any other failure — on the car the session is refused, "Not allowed to install non-system
* apps on internal storage", and that answer can come here rather than as an exception — opens
* Android's installer screen with the verified APK, as {@link OtaUpdater#install} does for an
* immediate refusal. Only a cancel by the driver ends there.
*/ */
public final class OtaInstallResultReceiver extends BroadcastReceiver { public final class OtaInstallResultReceiver extends BroadcastReceiver {
static final String ACTION_INSTALL_RESULT = "com.stinnesbeck.nilsmg4.OTA_INSTALL_RESULT"; static final String ACTION_INSTALL_RESULT = "com.stinnesbeck.nilsmg4.OTA_INSTALL_RESULT";
static final String EXTRA_APK = "com.stinnesbeck.nilsmg4.extra.APK";
static final String EXTRA_SESSION = "com.stinnesbeck.nilsmg4.extra.SESSION";
private static final String TAG = "EVABRP.Update"; private static final String TAG = "EVABRP.Update";
@@ -31,12 +38,16 @@ public final class OtaInstallResultReceiver extends BroadcastReceiver {
Log.i(TAG, "OTA install result: status=" + status + " msg=" + message); Log.i(TAG, "OTA install result: status=" + status + " msg=" + message);
UpdateLog.record(context, "Install result: status " + status UpdateLog.record(context, "Install result: status " + status
+ (message != null ? ", " + message : "")); + (message != null ? ", " + message : ""));
int session = intent.getIntExtra(EXTRA_SESSION, -1);
if (session >= 0) OtaUpdater.noteAnswer(session);
String apkPath = intent.getStringExtra(EXTRA_APK);
switch (status) { switch (status) {
case PackageInstaller.STATUS_PENDING_USER_ACTION: case PackageInstaller.STATUS_PENDING_USER_ACTION:
Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT); Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT);
if (confirm == null) { if (confirm == null) {
toast(context, context.getString(R.string.ota_install_failed, "no confirm intent")); UpdateLog.record(context, "No confirmation screen offered");
openInstallerScreen(context, apkPath, "no confirm intent");
return; return;
} }
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
@@ -44,23 +55,52 @@ public final class OtaInstallResultReceiver extends BroadcastReceiver {
context.startActivity(confirm); context.startActivity(confirm);
} catch (Throwable t) { } catch (Throwable t) {
Log.e(TAG, "OTA confirm UI failed: " + t.getMessage()); Log.e(TAG, "OTA confirm UI failed: " + t.getMessage());
toast(context, context.getString(R.string.ota_install_failed, UpdateLog.record(context, "Confirmation screen failed: " + t.getClass().getSimpleName());
t.getClass().getSimpleName())); openInstallerScreen(context, apkPath, t.getClass().getSimpleName());
} }
break; break;
case PackageInstaller.STATUS_SUCCESS: case PackageInstaller.STATUS_SUCCESS:
toast(context, context.getString(R.string.ota_install_success)); toast(context, context.getString(R.string.ota_install_success));
break; break;
case PackageInstaller.STATUS_FAILURE_ABORTED:
// The driver cancelled: nothing opens again.
toast(context, context.getString(R.string.ota_install_failed,
message != null ? message : "cancelled"));
break;
default: default:
// The status message is the package manager's own words — INSTALL_FAILED_* // The status message is the package manager's own words — INSTALL_FAILED_*
// and friends. Surfaced verbatim, because guessing at it is what made the // and friends — kept verbatim in the update log, because guessing at it is what
// previous failure unreadable. // made an earlier failure unreadable. Then the installer screen, which the car
toast(context, context.getString(R.string.ota_install_failed, // allows where it refuses the session.
message != null ? message : "status " + status)); openInstallerScreen(context, apkPath, message != null ? message : "status " + status);
break; break;
} }
} }
/**
* Opens Android's installer screen for the APK at [apkPath] off the main thread (it is
* copied to Downloads first); a toast with [why] when there is none or it cannot open.
*/
private void openInstallerScreen(Context context, String apkPath, String why) {
java.io.File apk = apkPath == null ? null : new java.io.File(apkPath);
if (apk == null || !apk.isFile()) {
toast(context, context.getString(R.string.ota_install_failed, why));
return;
}
PendingResult pending = goAsync();
Context app = context.getApplicationContext();
new Thread(() -> {
try {
if (!OtaUpdater.openInstallerScreen(app, apk)) {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() ->
toast(app, app.getString(R.string.ota_install_failed, why)));
}
} finally {
pending.finish();
}
}, "ota-fallback").start();
}
private static void toast(Context context, String text) { private static void toast(Context context, String text) {
Toast.makeText(context.getApplicationContext(), text, Toast.LENGTH_LONG).show(); Toast.makeText(context.getApplicationContext(), text, Toast.LENGTH_LONG).show();
} }
@@ -464,21 +464,57 @@ final class OtaUpdater {
static InstallResult install(Context context, File apk) { static InstallResult install(Context context, File apk) {
if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE; if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE;
try { try {
commitSession(context, apk); int sessionId = commitSession(context, apk);
watchForAnswer(context, sessionId, apk);
return InstallResult.SESSION_STARTED; return InstallResult.SESSION_STARTED;
} catch (Throwable t) { } catch (Throwable t) {
Log.w(TAG, "OTA session install failed; trying system install UI", t); Log.w(TAG, "OTA session install failed; trying system install UI", t);
UpdateLog.record(context, "Installer session failed: " + t); UpdateLog.record(context, "Installer session failed: " + t);
return openInstallerScreen(context, apk) ? InstallResult.INSTALL_UI_STARTED : InstallResult.SESSION_FAILED;
}
}
/** The installer screen for [apk], as the fallback; false if even that could not open. */
static boolean openInstallerScreen(Context context, File apk) {
try { try {
launchInstallerActivity(context, apk); launchInstallerActivity(context, apk);
UpdateLog.record(context, "Opened the system installer instead"); UpdateLog.record(context, "Opened the system installer instead");
return InstallResult.INSTALL_UI_STARTED; return true;
} catch (Throwable fallback) { } catch (Throwable fallback) {
Log.w(TAG, "OTA install UI failed", fallback); Log.w(TAG, "OTA install UI failed", fallback);
UpdateLog.record(context, "System installer failed too: " + fallback); UpdateLog.record(context, "System installer failed too: " + fallback);
return InstallResult.SESSION_FAILED; return false;
} }
} }
/** How long a committed session may stay silent before the installer screen is opened. */
private static final long ANSWER_WAIT_MS = 20_000L;
/** Sessions the platform has answered (any status), so the watch knows to stand down. */
private static final java.util.Set<Integer> answered =
java.util.Collections.synchronizedSet(new java.util.HashSet<>());
/** The platform said something about [sessionId]; called by {@link OtaInstallResultReceiver}. */
static void noteAnswer(int sessionId) {
answered.add(sessionId);
}
/**
* A committed session normally answers within a second or two: installed (the app
* restarts), refused, or "confirm on screen". If it says nothing at all, the update would
* end silently after its download, so the installer screen is opened instead.
*/
private static void watchForAnswer(Context context, int sessionId, File apk) {
Context app = context.getApplicationContext();
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
if (answered.contains(sessionId)) return;
UpdateLog.record(app, "No answer from the installer after " + ANSWER_WAIT_MS / 1000 + " s");
try {
app.getPackageManager().getPackageInstaller().abandonSession(sessionId);
} catch (Throwable ignored) {
// already gone
}
new Thread(() -> openInstallerScreen(app, apk), "ota-fallback").start();
}, ANSWER_WAIT_MS);
} }
/** /**
@@ -510,7 +546,8 @@ final class OtaUpdater {
} }
} }
private static void commitSession(Context context, File apk) throws Exception { /** Commits [apk] to a new installer session and returns its id. */
private static int commitSession(Context context, File apk) throws Exception {
PackageInstaller installer = context.getPackageManager().getPackageInstaller(); PackageInstaller installer = context.getPackageManager().getPackageInstaller();
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams( PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL); PackageInstaller.SessionParams.MODE_FULL_INSTALL);
@@ -533,7 +570,10 @@ final class OtaUpdater {
} }
Intent callback = new Intent(context, OtaInstallResultReceiver.class) Intent callback = new Intent(context, OtaInstallResultReceiver.class)
.setAction(OtaInstallResultReceiver.ACTION_INSTALL_RESULT) .setAction(OtaInstallResultReceiver.ACTION_INSTALL_RESULT)
.setPackage(context.getPackageName()); .setPackage(context.getPackageName())
// Where the verified APK is, for the installer screen if the session fails.
.putExtra(OtaInstallResultReceiver.EXTRA_APK, apk.getAbsolutePath())
.putExtra(OtaInstallResultReceiver.EXTRA_SESSION, sessionId);
// MUTABLE: the platform fills in EXTRA_STATUS and, when it wants the driver to // MUTABLE: the platform fills in EXTRA_STATUS and, when it wants the driver to
// confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty. // confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty.
PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback, PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback,
@@ -543,6 +583,7 @@ final class OtaUpdater {
session.commit(pending.getIntentSender()); session.commit(pending.getIntentSender());
committed = true; committed = true;
Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)"); Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)");
return sessionId;
} finally { } finally {
// Abandon failed staging, but always close our local handle after a commit too. // Abandon failed staging, but always close our local handle after a commit too.
if (!committed) { if (!committed) {
+7 -1
View File
@@ -84,11 +84,16 @@ spec:
volumes: volumes:
- name: config - name: config
emptyDir: {} emptyDir: {}
# apiVersion, kind and volumeMode are the values Kubernetes fills in itself: written out, a
# repeated apply finds nothing to patch (no "configured" for an unchanged StatefulSet).
volumeClaimTemplates: volumeClaimTemplates:
- metadata: - apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data name: data
spec: spec:
accessModes: ["ReadWriteOnce"] accessModes: ["ReadWriteOnce"]
volumeMode: Filesystem
# No storageClassName: the cluster's default class is used. # No storageClassName: the cluster's default class is used.
resources: resources:
requests: requests:
@@ -132,3 +137,4 @@ spec:
app.kubernetes.io/name: grafana app.kubernetes.io/name: grafana
ports: ports:
- port: 8086 - port: 8086
protocol: TCP
+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.1, 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:df1d40e423b7ee365e8379e2eaefb4c0db3a599e6429728a37d46827f83d82cd 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: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
# The car server: one static Go binary on distroless, running as non-root. # The car server: one static Go binary on distroless, running as non-root.
# Configuration comes from the environment at runtime; nothing secret is baked in. # Configuration comes from the environment at runtime; nothing secret is baked in.
FROM docker.io/library/golang:1.26.9-alpine AS build FROM docker.io/library/golang:1.27.3-alpine AS build
WORKDIR /src WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath GOTOOLCHAIN=local ENV CGO_ENABLED=0 GOFLAGS=-trimpath GOTOOLCHAIN=local
COPY go.mod go.sum ./ COPY go.mod go.sum ./
+27
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
@@ -52,6 +54,31 @@ with download URLs pointing back at this server, and their attachments streamed
repository stays private and the forge token never reaches the car; redirects away from the repository stays private and the forge token never reaches the car; redirects away from the
forge are not followed. The car verifies everything itself (`OTA.md`). 404 when unset. forge are not followed. The car verifies everything itself (`OTA.md`). 404 when unset.
Each check and each download is logged with the car's installation ID ("shared token" for the
shared ingest token): `update check` (how many releases, the newest pre-release), `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.
## 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
} }
+1 -1
View File
@@ -2,7 +2,7 @@ module git.stinnesbeck.com/nils/mg4/server
go 1.26.0 go 1.26.0
toolchain go1.26.9 toolchain go1.27.3
require github.com/influxdata/influxdb-client-go/v2 v2.14.0 require github.com/influxdata/influxdb-client-go/v2 v2.14.0
+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
}
+30 -7
View File
@@ -113,8 +113,17 @@ func (a *API) releasesOff(w http.ResponseWriter) bool {
return true return true
} }
func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error { // who names the car in the update log: its installation ID, or the shared ingest token.
func who(c caller) string {
if c.installationID == "" {
return "shared token"
}
return c.installationID
}
func (a *API) appReleases(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
if a.releasesOff(w) { if a.releasesOff(w) {
slog.Info("update check while app updates are off", "installation", who(c))
return nil return nil
} }
resp, err := a.Releases.get(r.Context(), "/api/v1/repos/"+a.Releases.Repo+"/releases?limit=20") resp, err := a.Releases.get(r.Context(), "/api/v1/repos/"+a.Releases.Repo+"/releases?limit=20")
@@ -123,7 +132,7 @@ func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
slog.Warn("forge refused the release list", "status", resp.StatusCode) slog.Warn("forge refused the release list", "installation", who(c), "status", resp.StatusCode)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil return nil
} }
@@ -133,8 +142,13 @@ func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []
} }
base := a.publicURL(r) + "/v1/app/assets/" base := a.publicURL(r) + "/v1/app/assets/"
out := make([]carRelease, 0, len(releases)) out := make([]carRelease, 0, len(releases))
newest := "none"
for _, rel := range releases { for _, rel := range releases {
cr := carRelease{TagName: rel.TagName, Prerelease: rel.Prerelease, Draft: rel.Draft, Assets: []carAsset{}} cr := carRelease{TagName: rel.TagName, Prerelease: rel.Prerelease, Draft: rel.Draft, Assets: []carAsset{}}
// The forge lists the newest first; the car takes pre-releases only.
if newest == "none" && rel.Prerelease && !rel.Draft {
newest = rel.TagName
}
for _, as := range rel.Assets { for _, as := range rel.Assets {
cr.Assets = append(cr.Assets, carAsset{Name: as.Name, Size: as.Size, cr.Assets = append(cr.Assets, carAsset{Name: as.Name, Size: as.Size,
BrowserDownloadURL: base + strconv.FormatInt(rel.ID, 10) + "/" + strconv.FormatInt(as.ID, 10) + BrowserDownloadURL: base + strconv.FormatInt(rel.ID, 10) + "/" + strconv.FormatInt(as.ID, 10) +
@@ -142,13 +156,14 @@ func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []
} }
out = append(out, cr) out = append(out, cr)
} }
slog.Info("update check", "installation", who(c), "releases", len(out), "newest", newest)
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil return nil
} }
// appAsset streams one release attachment. The forge's own metadata names the file, so a car // appAsset streams one release attachment. The forge's own metadata names the file, so a car
// cannot ask for anything but an attachment of this repository's releases. // cannot ask for anything but an attachment of this repository's releases.
func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error { func (a *API) appAsset(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
if a.releasesOff(w) { if a.releasesOff(w) {
return nil return nil
} }
@@ -173,7 +188,7 @@ func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byt
return nil return nil
} }
if meta.StatusCode != http.StatusOK || decodeErr != nil || asset.UUID == "" { if meta.StatusCode != http.StatusOK || decodeErr != nil || asset.UUID == "" {
slog.Warn("forge refused the asset", "status", meta.StatusCode) slog.Warn("forge refused the asset", "installation", who(c), "status", meta.StatusCode)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil return nil
} }
@@ -187,7 +202,7 @@ func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byt
} }
defer file.Body.Close() defer file.Body.Close()
if file.StatusCode != http.StatusOK { if file.StatusCode != http.StatusOK {
slog.Warn("forge refused the download", "status", file.StatusCode) slog.Warn("forge refused the download", "installation", who(c), "asset", asset.Name, "status", file.StatusCode)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"}) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil return nil
} }
@@ -201,9 +216,17 @@ func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byt
if file.ContentLength > 0 && file.ContentLength <= maxAssetBytes { if file.ContentLength > 0 && file.ContentLength <= maxAssetBytes {
h.Set("Content-Length", strconv.FormatInt(file.ContentLength, 10)) h.Set("Content-Length", strconv.FormatInt(file.ContentLength, 10))
} }
slog.Info("update download started", "installation", who(c), "asset", asset.Name,
"bytes", file.ContentLength)
started := time.Now()
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes)); err != nil { sent, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes))
slog.Warn("asset transfer cut short", "asset", asset.Name, "error", err) took := time.Since(started).Round(time.Millisecond)
if err != nil {
slog.Warn("update download cut short", "installation", who(c), "asset", asset.Name,
"sent", sent, "took", took, "error", err)
return nil
} }
slog.Info("update handed out", "installation", who(c), "asset", asset.Name, "sent", sent, "took", took)
return nil return nil
} }
+35
View File
@@ -1,7 +1,9 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -113,3 +115,36 @@ func TestForgeURLMustBeHTTPS(t *testing.T) {
t.Fatal("plain http forge accepted") t.Fatal("plain http forge accepted")
} }
} }
// The server's log says who checked for and fetched an update, and how it went — the car's
// installation (here the shared token), the newest pre-release, the file and the bytes — and
// never the forge's token or address.
func TestUpdatesAreLogged(t *testing.T) {
var logged bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
defer slog.SetDefault(previous)
forge := fakeForge(t)
defer forge.Close()
h := releasesAPI(t, forge)
if rec := do(t, h, "GET", "/v1/app/releases", testToken, ""); rec.Code != 200 {
t.Fatalf("%d", rec.Code)
}
if rec := do(t, h, "GET", "/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk", testToken, ""); rec.Code != 200 {
t.Fatalf("%d", rec.Code)
}
out := logged.String()
for _, want := range []string{
`msg="update check" installation="shared token" releases=1 newest=car-2.16.0`,
`msg="update download started" installation="shared token" asset=NilsMG4-unstable-2.16.0.apk`,
`msg="update handed out" installation="shared token" asset=NilsMG4-unstable-2.16.0.apk sent=4`,
} {
if !strings.Contains(out, want) {
t.Errorf("log lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, testForgeToken) || strings.Contains(out, forge.URL) {
t.Fatal("the forge or its token leaked into the log")
}
}