Compare commits
19
Commits
car-2.38.0
...
car-2.42.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6563b2c4e8 | ||
|
|
8596951cef | ||
|
|
33e8fe6e05 | ||
|
|
eebe840b6a | ||
|
|
16e6f349da | ||
|
|
60ab3118a1 | ||
|
|
097467f4e0 | ||
|
|
e90031d6b0 | ||
|
|
f7c489858e | ||
|
|
c3c47f5159 | ||
|
|
9ef058cb44 | ||
|
|
69b1adee2b | ||
|
|
90c2c75fc6 | ||
|
|
2d7b324899 | ||
|
|
09cbd21634 | ||
|
|
6be2ba5193 | ||
|
|
d9c7c5ea9a | ||
|
|
65dc1ba847 | ||
|
|
776a11b182 |
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: car-logs
|
||||||
|
description: Download the signal recordings the MG4 sent to the car server (fetch-logs) and read them. Use when the owner says a recording is on the server, asks to get, download or look at the car's logs or recordings, or after a test drive with the signal recorder.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Fetch and read the car's recordings
|
||||||
|
|
||||||
|
The app's signal recorder (Logs tab, `SignalRecorder`) sends each finished recording to the
|
||||||
|
car server (`LogOutbox`, `PUT /v1/logs/{name}`); the server keeps the newest 200
|
||||||
|
(`server/logs.go`). `fetch-logs` in the dev shell downloads the ones not yet in `logs/`.
|
||||||
|
|
||||||
|
## Fetch
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix develop -c fetch-logs
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints each new file (`logs/signal-recording-YYYYMMDD-HHMMSS.txt`) and `fetch-logs: N new`.
|
||||||
|
The token comes from `~/.config/nilsmg4/logs-token` (the server's `LOGS_TOKEN`) and goes only in
|
||||||
|
a header.
|
||||||
|
|
||||||
|
- **Never print, cat or echo the token**, and never pass it on a command line you show. Do not
|
||||||
|
read the token file.
|
||||||
|
- `no token`: ask the owner to put the server's `LOGS_TOKEN` in `~/.config/nilsmg4/logs-token`
|
||||||
|
(and in `k8s/secrets/secrets.yaml`, then apply it). Do not create or fill it yourself.
|
||||||
|
- HTTP 401: the file and the server's Secret differ. HTTP 404: reading is off (no
|
||||||
|
`LOGS_TOKEN` on the server) or the server is older than 2.9.0. The owner applies manifests and
|
||||||
|
Secrets; never run `kubectl` against the cluster, not even read-only.
|
||||||
|
- `0 new`: nothing arrived. In the car, the Logs tab says "Sent to the server." or how many wait
|
||||||
|
and why (no server set up, server not reached, an HTTP status); the car retries every 2
|
||||||
|
minutes while the service runs.
|
||||||
|
|
||||||
|
## Read
|
||||||
|
|
||||||
|
The files are plain text but `file` may call them data; search with `rg -a`. Newest first:
|
||||||
|
`ls -t logs/`. If the owner said what they did and when (e.g. "ACC on at 08:50, set speed
|
||||||
|
100→90 at 08:51"), line those times up with the timeline.
|
||||||
|
|
||||||
|
A recording has four parts, in this order:
|
||||||
|
|
||||||
|
1. Header: captures, start time, app version.
|
||||||
|
2. **Steering-wheel keys and notes.** The first note says whether listening worked:
|
||||||
|
`listening to N assistance properties (M refused)`, or `not possible (<reason>)`.
|
||||||
|
3. **Assistance properties heard** (`EVHardware.listenToProperties` on `AssistBlock`, the
|
||||||
|
`0x21x0A1xx` block): change events, properties whose value changed first. This is where a
|
||||||
|
value shows up that the head unit fills only while someone listens.
|
||||||
|
4. **Changed values (polled)**, rarest first, then **Everything, in order**: every capture
|
||||||
|
(about every 5 s) with what changed since the last.
|
||||||
|
|
||||||
|
Useful cuts:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rg -a 'listening to' logs/<file> # did listening work
|
||||||
|
awk '/^Assistance properties heard/,/^$/' logs/<file> # what listening heard
|
||||||
|
rg -a 'A15B|A15A' logs/<file> # one property's history
|
||||||
|
rg -a 'getLatestSpeed' logs/<file> | head # the car's speed over time
|
||||||
|
```
|
||||||
|
|
||||||
|
Known leads (SPECS.md, 2026-10-27): `0x2160A15B` is `AAD_EPTCCTRGTSPD`, by its name the cruise
|
||||||
|
control's target speed (0.0 when only polled); `0x2140A15A` moved between 3 and 7;
|
||||||
|
`0x2160A1A3`/`0x2160A1A4` counted up by 0.1. A value that tracks what the owner did at the
|
||||||
|
times they name is a lead; say how sure it is, and that it is not confirmed until a second
|
||||||
|
drive shows the same.
|
||||||
|
|
||||||
|
## Music-sources checks
|
||||||
|
|
||||||
|
`fetch-logs` also brings `media-probe-YYYYMMDD-HHMMSS.txt` (Logs tab, "Check music sources",
|
||||||
|
`MediaProbe`). Three parts: audio players (use, app, state), active media sessions, and every
|
||||||
|
media browser service with `connected` / `refused or failed` / `no answer`, then its state and
|
||||||
|
song (TITLE, ARTIST, ALBUM, ALBUM_ART as a size). CarPlay's is
|
||||||
|
`com.allgo.remoteui.mediabrowserservice/...RemoteUIMediaBrowserService`. Ask what was playing
|
||||||
|
when the check ran.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Recordings and checks are the car's data: `logs/`, `/signal-recording-*.txt` and
|
||||||
|
`/media-probe-*.txt` are gitignored. Never
|
||||||
|
commit, publish or upload them anywhere.
|
||||||
|
- Findings that change what the project knows go into a `SPECS.md` done-log entry, as usual.
|
||||||
@@ -41,3 +41,8 @@ mise.local.toml
|
|||||||
|
|
||||||
# CarPlay diagnostic exports: vendor software, never committed
|
# CarPlay diagnostic exports: vendor software, never committed
|
||||||
/dump/
|
/dump/
|
||||||
|
|
||||||
|
# The car's own recordings (fetch-logs, or copied off a USB stick): its data, not the code's.
|
||||||
|
/logs/
|
||||||
|
/signal-recording-*.txt
|
||||||
|
/media-probe-*.txt
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ MG4 head unit (Android 9, AAOS, MT2712)
|
|||||||
server and to ABRP, and updates itself.
|
server and to ABRP, and updates itself.
|
||||||
- **server/** — Go 1.27 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
|
- **server/** — Go 1.27 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
|
||||||
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
|
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
|
||||||
app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`.
|
app releases from the private forge, and serves the roads for the Grid's map from
|
||||||
|
OpenStreetMap vector tiles (OpenFreeMap), fetched per tile on demand and cached on a volume, and keeps the signal recordings the car sends
|
||||||
|
(read with their own token, `fetch-logs`). 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 +77,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, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, 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, Terminal, Grid and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
|
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
|
||||||
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
|
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus 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, a USB stick and the server, through `LogOutbox`, which sends it again later when the car is offline; how to find where the car publishes something), music-sources check (`MediaProbe`: audio players, media sessions and every media browser service, CarPlay's among them, with their song; read only, saved and sent like a recording), 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
|
||||||
@@ -109,6 +111,7 @@ nix develop # JDK 21, Android SDK, Go
|
|||||||
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
|
./gradlew testStableDebugUnitTest testUnstableDebugUnitTest lintStableDebug
|
||||||
build-car # build/car/NilsMG4-unstable-<version>.apk + .sha256
|
build-car # build/car/NilsMG4-unstable-<version>.apk + .sha256
|
||||||
publish-car # pre-release car-<version> on the forge (needs a token)
|
publish-car # pre-release car-<version> on the forge (needs a token)
|
||||||
|
fetch-logs # the car's recordings from the server into logs/ (needs LOGS_TOKEN; skill car-logs)
|
||||||
cd server && go vet ./... && go test ./... && govulncheck ./...
|
cd server && go vet ./... && go test ./... && govulncheck ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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,144 @@ object EVHardware {
|
|||||||
ignitionCallbacks.remove(callback)
|
ignitionCallbacks.remove(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Listening to properties (the app's signal recorder) — read only
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Why the last [listenToProperties] returned null, in a few words; null after a success. */
|
||||||
|
@Volatile var listenProblem: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** 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, with the reason in [listenProblem];
|
||||||
|
* [PropertyListening.close] unsubscribes.
|
||||||
|
*
|
||||||
|
* Android 9's car library (the MG4's) names the calls `registerListener(CarPropertyEventListener,
|
||||||
|
* int, float)` and `unregisterListener`; Android 10 renamed them `registerCallback` and
|
||||||
|
* `unregisterCallback`. Both are looked for. Unsubscribing goes property by property: the
|
||||||
|
* one-argument `unregisterListener` of Android 9 removes entries from the list it walks and so
|
||||||
|
* leaves every other property subscribed.
|
||||||
|
*/
|
||||||
|
fun listenToProperties(admit: (Int) -> Boolean, onEvent: (Int, Int, String) -> Unit): PropertyListening? {
|
||||||
|
listenProblem = null
|
||||||
|
val cpm = sCarPropertyManager ?: run {
|
||||||
|
listenProblem = "no car service"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val methods = cpm.javaClass.methods
|
||||||
|
val register = methods.firstOrNull {
|
||||||
|
(it.name == "registerListener" || it.name == "registerCallback") && it.parameterCount == 3 &&
|
||||||
|
it.parameterTypes[1] == Int::class.javaPrimitiveType &&
|
||||||
|
it.parameterTypes[2] == Float::class.javaPrimitiveType
|
||||||
|
} ?: run {
|
||||||
|
listenProblem = "no way to subscribe"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val callbackType = register.parameterTypes[0]
|
||||||
|
val unregister = methods.firstOrNull {
|
||||||
|
it.name == register.name.replaceFirst("register", "unregister") && it.parameterCount == 2 &&
|
||||||
|
it.parameterTypes[0] == callbackType && it.parameterTypes[1] == Int::class.javaPrimitiveType
|
||||||
|
}
|
||||||
|
val unregisterAll = methods.firstOrNull {
|
||||||
|
it.name == register.name.replaceFirst("register", "unregister") && it.parameterCount == 1 &&
|
||||||
|
it.parameterTypes[0] == callbackType
|
||||||
|
}
|
||||||
|
if (!callbackType.isInterface) {
|
||||||
|
listenProblem = "unexpected listener type ${callbackType.simpleName}"
|
||||||
|
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
|
||||||
|
val subscribedIds = ArrayList<Int>()
|
||||||
|
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 subscribedIds.add(id)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
refused++
|
||||||
|
AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppLogger.i(TAG, "listening to ${subscribedIds.size} properties ($refused refused) via ${register.name}")
|
||||||
|
PropertyListening(subscribedIds.size, refused) {
|
||||||
|
for (id in subscribedIds) {
|
||||||
|
try {
|
||||||
|
if (unregister != null) unregister.invoke(cpm, proxy, id)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AppLogger.d(TAG, "listen: unsubscribe 0x%08X failed: %s".format(id, e.readFailureReason()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unregister == null) {
|
||||||
|
try {
|
||||||
|
unregisterAll?.invoke(cpm, proxy)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AppLogger.d(TAG, "listen: unsubscribe failed: ${e.readFailureReason()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
listenProblem = e.readFailureReason()
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.evsuite.hardware
|
||||||
|
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PropertyListeningTest {
|
||||||
|
|
||||||
|
class Config(val propertyId: Int, val changeMode: Int, val minSampleRate: Float, val maxSampleRate: Float)
|
||||||
|
class Value(val propertyId: Int, val areaId: Int, val value: Any)
|
||||||
|
|
||||||
|
/** Shaped like Android 9's CarPropertyManager, the car's: registerListener, and an
|
||||||
|
* unregisterListener(listener) that skips every other property as the car's does. */
|
||||||
|
class Android9Manager(private val configs: List<Config>) {
|
||||||
|
interface CarPropertyEventListener {
|
||||||
|
fun onChangeEvent(value: Any?)
|
||||||
|
fun onErrorEvent(propertyId: Int, areaId: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
val active = LinkedHashMap<Int, MutableList<CarPropertyEventListener>>()
|
||||||
|
val rates = HashMap<Int, Float>()
|
||||||
|
|
||||||
|
fun getPropertyList(): List<Config> = configs
|
||||||
|
|
||||||
|
fun registerListener(listener: CarPropertyEventListener, propertyId: Int, rate: Float): Boolean {
|
||||||
|
active.getOrPut(propertyId) { ArrayList() }.add(listener)
|
||||||
|
rates[propertyId] = rate
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregisterListener(listener: CarPropertyEventListener) {
|
||||||
|
val ids = active.keys.toMutableList()
|
||||||
|
var i = 0
|
||||||
|
while (i < ids.size) {
|
||||||
|
unregisterListener(listener, ids[i])
|
||||||
|
if (active[ids[i]] == null) ids.removeAt(i) // as SparseArray.remove under keyAt(i)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregisterListener(listener: CarPropertyEventListener, propertyId: Int) {
|
||||||
|
val list = active[propertyId] ?: return
|
||||||
|
list.remove(listener)
|
||||||
|
if (list.isEmpty()) active.remove(propertyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fire(propertyId: Int, value: Any) {
|
||||||
|
active[propertyId]?.toList()?.forEach { it.onChangeEvent(Value(propertyId, 0x01000000, value)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoListeners {
|
||||||
|
fun getPropertyList(): List<Config> = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val field = EVHardware::class.java.getDeclaredField("sCarPropertyManager").apply { isAccessible = true }
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun reset() = field.set(null, null)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `subscribes through Android 9's registerListener and unsubscribes every property`() {
|
||||||
|
val manager = Android9Manager(listOf(
|
||||||
|
Config(0x2160A15B, 1, 0f, 0f), // in the block, on change
|
||||||
|
Config(0x2140A15A, 1, 0f, 0f),
|
||||||
|
Config(0x2160A1A3, 2, 0f, 10f), // continuous
|
||||||
|
Config(0x2140A101, 1, 0f, 0f),
|
||||||
|
Config(0x11400400, 1, 0f, 0f), // outside the block
|
||||||
|
))
|
||||||
|
field.set(null, manager)
|
||||||
|
val heard = ArrayList<String>()
|
||||||
|
|
||||||
|
val listening = EVHardware.listenToProperties(AssistBlock::contains) { id, area, value ->
|
||||||
|
heard += "%08X/%d=%s".format(id, area, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotNull(EVHardware.listenProblem, listening)
|
||||||
|
assertEquals(4, listening!!.subscribed)
|
||||||
|
assertEquals(0, listening.refused)
|
||||||
|
assertEquals(setOf(0x2160A15B, 0x2140A15A, 0x2160A1A3, 0x2140A101), manager.active.keys)
|
||||||
|
assertEquals(0f, manager.rates[0x2160A15B])
|
||||||
|
assertEquals(1f, manager.rates[0x2160A1A3])
|
||||||
|
|
||||||
|
manager.fire(0x2160A15B, 90f)
|
||||||
|
assertEquals(1, heard.size)
|
||||||
|
assertTrue(heard[0], heard[0].startsWith("2160A15B/16777216="))
|
||||||
|
|
||||||
|
listening.close()
|
||||||
|
assertTrue(manager.active.toString(), manager.active.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `says why when there is no way to subscribe`() {
|
||||||
|
field.set(null, NoListeners())
|
||||||
|
assertNull(EVHardware.listenToProperties(AssistBlock::contains) { _, _, _ -> })
|
||||||
|
assertEquals("no way to subscribe", EVHardware.listenProblem)
|
||||||
|
|
||||||
|
field.set(null, null)
|
||||||
|
assertNull(EVHardware.listenToProperties(AssistBlock::contains) { _, _, _ -> })
|
||||||
|
assertEquals("no car service", EVHardware.listenProblem)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-2
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ APIs.
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
**Overview styles:** the Overview comes in seventeen looks, from an 80s fluorescent display to a
|
**Overview styles:** the Overview comes in eighteen looks, from an 80s fluorescent display to a
|
||||||
starship console, a watch face, a neon grid, a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the
|
starship console, a watch face, a neon grid (also over a map of the roads round the car), a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the
|
||||||
navigation, and what inspired it.
|
navigation, and what inspired it.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -81,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, Chronograph, Grid, Time circuits, Quest, Visor and Empire ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, and Retro, Terminal, Grid and Visor with "Original") colours the whole app to match, e.g. turquoise for Retro. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
|
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, lease allowance, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro, Futuristic, Sci-fi, Cartoon, MG, Minimal, Terminal, Nixie tubes, Chronograph, Grid, Grid with map, Time circuits, Quest, Visor and Empire ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, and Retro, Terminal, Grid and Visor with "Original") colours the whole app to match, e.g. turquoise for Retro. Grid with map lays the roads round the car on the Grid's floor: the app asks the car's server for the map tile it is in (not its position), the server fetches only that tile from OpenFreeMap's OpenStreetMap vector tiles and keeps it, and the GPS runs for it only while that style is on screen. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
|
||||||
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
|
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
|
||||||
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
|
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
|
||||||
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
||||||
| **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; sent to your server, and saved to a USB stick too), music-sources check (where the head unit tells what is playing, CarPlay included; read only), 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
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
|
|||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
Current: app 2.35.3 (installed on the car over the air), server 2.6.2 (pinned by digest in
|
Current: app 2.38.0 (installed on the car over the air, 2026-10-26), server 2.6.2 (pinned by digest in
|
||||||
`k8s/server.yaml`; applied and running since 2026-10-25).
|
`k8s/server.yaml`; applied and running since 2026-10-25).
|
||||||
|
|
||||||
- [x] On the car: QR pairing, values on the Overview, voice-key double press, full screen
|
- [x] On the car: QR pairing, values on the Overview, voice-key double press, full screen
|
||||||
@@ -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,183 @@ 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-28 — App 2.42.0: **music-sources check** on the Logs tab (`MediaProbe`,
|
||||||
|
`MediaProbeText`; owner: "is it possible to show playing music in our dashboard as well?", then
|
||||||
|
"yes start the probe"). Before a now-playing display, it finds where the head unit tells what is
|
||||||
|
playing, read only: the players Android's audio service reports (use, app, state), the active
|
||||||
|
media sessions (`MediaSessionManager.getActiveSessions`, MEDIA_CONTENT_CONTROL, which the system
|
||||||
|
user has) with their song, and every media browser service, each connected to, its session read
|
||||||
|
and disconnected. CarPlay's is tried even when not listed:
|
||||||
|
`com.allgo.remoteui.mediabrowserservice/.RemoteUIMediaBrowserService`, which the car's own media
|
||||||
|
library (`com.saicmotor.sdk.media…MediaPlayControlManager` in Allgo's APK) reads CarPlay's title,
|
||||||
|
artist, album and cover from through a plain `MediaBrowser`. Nothing of Allgo's is changed and
|
||||||
|
nothing is played, paused or skipped. The report (`media-probe-YYYYMMDD-HHMMSS.txt`) goes where
|
||||||
|
a recording goes, through the shared `SignalRecorder.saveCopies`: the app, a USB stick, the
|
||||||
|
server (`fetch-logs`). Unit tests for the text; tests and lint pass. Emulator (Android 9): the
|
||||||
|
players are read with their app and state (the system APIs answer the system user), the
|
||||||
|
sessions are readable (none there, no SecurityException), the AOSP music app's browser service
|
||||||
|
connects and is read, CarPlay's (not installed there) is "refused or failed", and the report
|
||||||
|
lands in the outbox. **Not tried on the car.** Unknown until then: whether CarPlay's service
|
||||||
|
lets the app connect, and whether the radio, Bluetooth and USB sources publish a session.
|
||||||
|
- 2026-10-28 — First recordings over the server (14:19 and 14:23, app 2.41.0, ACC on, the set
|
||||||
|
speed raised a few times): the server path works, and listening works in the car (249
|
||||||
|
assistance properties, 0 refused, 76 and 81 change events). `0x2160A15B` (`AAD_EPTCCTRGTSPD`)
|
||||||
|
stays 0.0 even while listened to: the car does not fill it. `0x2140A15A` (3/7, a few 0s) and
|
||||||
|
`0x2160A1A3`/`A1A4` (always equal, 1.4–5.3, now and then 0.1–0.2 down) do not move with the
|
||||||
|
key presses. New lead: steering-wheel keys **297** (2× at 14:20:09, 2× at 14:20:12) and **300**
|
||||||
|
(14:20:05, 2× at 14:20:40), the car speeding up 27 → 49 km/h after them; key 3 is Home. The
|
||||||
|
14:23 recording has no key at all. Not confirmed: which buttons these are. Also seen:
|
||||||
|
`0x21401553`–`1556` ≈ 256 are tyre pressures and `0x2140155B`–`155E` (18–24) tyre
|
||||||
|
temperatures, not the set speed.
|
||||||
|
- 2026-10-27 — Skill `car-logs` (`.claude/skills/car-logs/SKILL.md`; owner: "can we write a skill for downloading the logs?"): fetch with `fetch-logs` without ever showing the token, what its errors mean, how a recording is laid out, the cuts that find a lead, the leads so far, and that recordings are never committed. Its commands tried on the 08:52 recording.
|
||||||
|
- 2026-10-27 — Server 2.9.0 pushed (`sha256:c68837f0e53cfca150644987be535e04d5e999bd4c91064d40973db67235df48`, pinned in `k8s/server.yaml`); app 2.41.0 published (`car-2.41.0`).
|
||||||
|
- 2026-10-27 — App 2.41.0, server 2.9.0: **recordings reach the server** (owner: "can we make
|
||||||
|
it so that we can upload the logs to an endpoint in the server, and you can get the logs from
|
||||||
|
there? that way i don't have to carry a usb stick"). A finished signal recording goes to
|
||||||
|
`files/log-outbox` (`LogOutbox`, at most 10 waiting) and is sent at once, gzipped, with the car's
|
||||||
|
token: `PUT /v1/logs/{name}`. Offline, 401 (not paired yet), 404 (a server without logs), 408,
|
||||||
|
429 or 5xx keep it, and the service's tick sends it again, at most every 2 minutes; any other
|
||||||
|
refusal drops it. The Logs tab says "Sent to the server." or how many wait and why. Server
|
||||||
|
(`logs.go`): `LOGS_DIR` on a new 1Gi volume `mg4-server-logs`, the newest 200 files and none
|
||||||
|
older than 180 days, names as the app's only, up to 2 MB sent and 8 MB unpacked, UTF-8 only; the
|
||||||
|
same name replaces itself. Reading (`GET /v1/logs`, `GET /v1/logs/{name}`) takes `LOGS_TOKEN`, a
|
||||||
|
new optional Secret value that must differ from `INGEST_TOKEN`; the car's token cannot read, the
|
||||||
|
read token cannot write. `fetch-logs` in the dev shell fetches the new ones into `logs/`
|
||||||
|
(gitignored, as are the recordings copied into the checkout's top folder), token from
|
||||||
|
`~/.config/nilsmg4/logs-token`. Tested: server unit tests (both tokens, refusals, pruning,
|
||||||
|
config); the real server code in a harness on this machine took the 08:52 recording gzipped as
|
||||||
|
the app sends it (204), refused the car's token for reading (401), and `fetch-logs` brought it
|
||||||
|
back byte for byte, once. The app's own sending could not be pointed at that harness (the app
|
||||||
|
takes https only); on the emulator the recording lands in the outbox and the Logs tab says it
|
||||||
|
waits. Not tried on the car.
|
||||||
|
- 2026-10-27 — App 2.40.4 published (`car-2.40.4`).
|
||||||
|
- 2026-10-27 — App 2.40.4: **the recorder's listening works on Android 9**. Both recordings
|
||||||
|
from the car (08:49 and 08:52, 2.40.3) said "listening to the assistance properties: not
|
||||||
|
possible": `listenToProperties` looked only for `registerCallback`, Android 10's name; the car's
|
||||||
|
car library (Android 9) has `registerListener(CarPropertyEventListener, int, float)` and
|
||||||
|
`unregisterListener`, as the app's `CarPropertyAdapter` already knew. It now takes either, and
|
||||||
|
unsubscribes property by property (Android 9's one-argument `unregisterListener` removes
|
||||||
|
entries from the list it walks and would leave every other property subscribed). A failure now
|
||||||
|
says why (`EVHardware.listenProblem`: no car service, no way to subscribe, or the exception).
|
||||||
|
Tested with a stand-in manager of Android 9's shape (`PropertyListeningTest`). Correction to
|
||||||
|
2.40.3: the ignition listener it was compared with also looks only for `registerCallback`, so
|
||||||
|
it most likely never subscribes on the car either (it reads the state once at start); left as
|
||||||
|
it is. What the polled part of the two recordings shows of the block: `0x2160A15B` (by name
|
||||||
|
the target speed) 0.0 throughout; `0x2140A15A` between 3 and 7 every few captures;
|
||||||
|
`0x2160A1A3`/`0x2160A1A4` counting up by 0.1; speeds 8–33 km/h. Not tried on the car.
|
||||||
|
- 2026-10-27 — App 2.40.3 published (`car-2.40.3`; also carries 2.40.1 and 2.40.2).
|
||||||
|
- 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`)
|
- 2026-10-26 — App 2.38.0: Overview styles **Visor** (after Halo's helmet display, `VisorView`)
|
||||||
and **Empire** (after Age of Empires' interface, `EmpireView`). Visor: shield bar = battery (24
|
and **Empire** (after Age of Empires' interface, `EmpireView`). Visor: shield bar = battery (24
|
||||||
segments, red and blinking "BATTERY LOW" below 20 %), ammunition counter = range (number and
|
segments, red and blinking "BATTERY LOW" below 20 %), ammunition counter = range (number and
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Overview styles
|
# Overview styles
|
||||||
|
|
||||||
The Overview can look seventeen ways: pick one under **Settings → Overview style**. Every style shows
|
The Overview can look eighteen ways: pick one under **Settings → Overview style**. Every style shows
|
||||||
the same readings (battery, range, power, speed, gear, usage, trip, climate, odometer, time) and
|
the same readings (battery, range, power, speed, gear, usage, trip, climate, odometer, time) and
|
||||||
shows "—" for anything the car did not answer, never a made-up zero.
|
shows "—" for anything the car did not answer, never a made-up zero.
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ emulator at the car's resolution with **sample values** (87 km/h in D, 76 %, 18.
|
|||||||
emulator has no car; on the car the values are live.
|
emulator has no car; on the car the values are live.
|
||||||
|
|
||||||
**Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings →
|
**Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings →
|
||||||
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid and Visor can also show their original
|
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid (with or without the map) and Visor can also show their original
|
||||||
colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire always do; while one of those is shown, the whole app
|
colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire always do; while one of those is shown, the whole app
|
||||||
(navigation, buttons, switches) takes on its colours too.
|
(navigation, buttons, switches) takes on its colours too.
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire alway
|
|||||||
| [Nixie tubes](#nixie-tubes) | glowing glass tubes | own |
|
| [Nixie tubes](#nixie-tubes) | glowing glass tubes | own |
|
||||||
| [Chronograph](#chronograph) | a watch face | accent |
|
| [Chronograph](#chronograph) | a watch face | accent |
|
||||||
| [Grid](#grid) | neon lines on a glowing grid floor | original or accent |
|
| [Grid](#grid) | neon lines on a glowing grid floor | original or accent |
|
||||||
|
| [Grid with map](#grid-with-map) | the Grid, with the real roads round the car on its floor | original or accent |
|
||||||
| [Time circuits](#time-circuits) | a time machine's LED displays | own |
|
| [Time circuits](#time-circuits) | a time machine's LED displays | own |
|
||||||
| [Quest](#quest) | a fantasy game's interface | own |
|
| [Quest](#quest) | a fantasy game's interface | own |
|
||||||
| [Visor](#visor) | a soldier's helmet display | original or accent |
|
| [Visor](#visor) | a soldier's helmet display | original or accent |
|
||||||
@@ -162,6 +163,22 @@ films' other side.
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## Time circuits
|
## Time circuits
|
||||||
|
|
||||||
After the time machine in Back to the Future: three rows of LED read-outs in a metal panel,
|
After the time machine in Back to the Future: three rows of LED read-outs in a metal panel,
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 141
|
versionCode 151
|
||||||
versionName "2.38.0"
|
versionName "2.42.0"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||||
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ public class AbrpUploadService extends Service {
|
|||||||
consumptionTracker.sample(vehicle);
|
consumptionTracker.sample(vehicle);
|
||||||
chargeSessionTracker.sample(vehicle);
|
chargeSessionTracker.sample(vehicle);
|
||||||
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
|
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
|
||||||
|
LogOutbox.sendIfDue(this); // recordings the car could not send when they ended
|
||||||
|
|
||||||
UploadSettings settings = UploadSettings.from(prefs);
|
UploadSettings settings = UploadSettings.from(prefs);
|
||||||
// Live data goes to the server, to ABRP directly, or both; with neither set up there is
|
// Live data goes to the server, to ABRP directly, or both; with neither set up there is
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ import android.os.SystemClock;
|
|||||||
* row of read-outs along the bottom. Unread values show "—", light nothing and leave gaps in
|
* row of read-outs along the bottom. Unread values show "—", light nothing and leave gaps in
|
||||||
* the trail; the floor stands still when the speed is unread.
|
* the trail; the floor stands still when the speed is unread.
|
||||||
*
|
*
|
||||||
|
* With a map ("Grid with map", {@link MapRoads}): the roads round the car from OpenStreetMap lie
|
||||||
|
* under everything, heading up, the bigger ones glowing brighter, with the car as a light
|
||||||
|
* marker low on the screen; between GPS fixes the car's position runs on
|
||||||
|
* with the speed. The map is then the whole background, straight from above (about
|
||||||
|
* 400 m ahead, 450 m to each side), over a ground grid (a line every 50 m) that turns and slides
|
||||||
|
* with the roads, 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
|
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
|
||||||
* low battery is orange in the original (the films' other side) and white in the accent one,
|
* low battery is orange in the original (the films' other side) and white in the accent one,
|
||||||
* so it never looks like the accent itself. The floor and the disc animate (about 30 frames a
|
* so it never looks like the accent itself. The floor and the disc animate (about 30 frames a
|
||||||
@@ -44,6 +55,12 @@ final class GridView extends ClusterCanvasView {
|
|||||||
private final Typeface thin = Typeface.create("sans-serif-thin", Typeface.NORMAL);
|
private final Typeface thin = Typeface.create("sans-serif-thin", Typeface.NORMAL);
|
||||||
private final Typeface label = Typeface.create("sans-serif-condensed", Typeface.BOLD);
|
private final Typeface label = Typeface.create("sans-serif-condensed", Typeface.BOLD);
|
||||||
private final int accent, accentLight;
|
private final int accent, accentLight;
|
||||||
|
/** Null for the plain Grid. */
|
||||||
|
private final MapRoads map;
|
||||||
|
/** 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;
|
private int main, light, dim, alert;
|
||||||
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
|
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
|
||||||
@@ -52,7 +69,13 @@ final class GridView extends ClusterCanvasView {
|
|||||||
private int skyFor = -1;
|
private int skyFor = -1;
|
||||||
|
|
||||||
GridView(Context context) {
|
GridView(Context context) {
|
||||||
|
this(context, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
GridView(Context context, MapRoads map) {
|
||||||
super(context);
|
super(context);
|
||||||
|
this.map = map;
|
||||||
|
mapLayer = map == null ? null : new MapLayerView(context, map);
|
||||||
accent = AccentColor.resolve(context, R.attr.evAccent);
|
accent = AccentColor.resolve(context, R.attr.evAccent);
|
||||||
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
|
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||||
line.setStyle(Paint.Style.STROKE);
|
line.setStyle(Paint.Style.STROKE);
|
||||||
@@ -68,9 +91,24 @@ final class GridView extends ClusterCanvasView {
|
|||||||
dim = AccentColor.withAlpha(main, 0x40);
|
dim = AccentColor.withAlpha(main, 0x40);
|
||||||
alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F;
|
alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F;
|
||||||
skyFor = -1;
|
skyFor = -1;
|
||||||
|
if (mapLayer != null) mapLayer.setColours(main, light);
|
||||||
invalidate();
|
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
|
@Override
|
||||||
protected void onDraw(Canvas c) {
|
protected void onDraw(Canvas c) {
|
||||||
super.onDraw(c);
|
super.onDraw(c);
|
||||||
@@ -78,21 +116,32 @@ final class GridView extends ClusterCanvasView {
|
|||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
float u = Math.min(h / 640f, w / 1100f);
|
float u = Math.min(h / 640f, w / 1100f);
|
||||||
advance();
|
advance();
|
||||||
c.drawColor(0xFF000000);
|
// 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;
|
float horizon = h * 0.55f;
|
||||||
|
if (mapShown) {
|
||||||
|
drawCar(c, w, h, u);
|
||||||
|
} else {
|
||||||
|
c.drawColor(0xFF000000);
|
||||||
drawSky(c, w, horizon);
|
drawSky(c, w, horizon);
|
||||||
drawFloor(c, w, h, horizon, u);
|
drawFloor(c, w, h, horizon, u);
|
||||||
|
}
|
||||||
|
if (map != null && !mapShown) drawMapState(c, w, h, horizon, u);
|
||||||
|
if (mapShown) drawPanels(c, w, horizon, u);
|
||||||
drawGears(c, w, u);
|
drawGears(c, w, u);
|
||||||
drawClock(c, w, u);
|
drawClock(c, w, u);
|
||||||
drawSpeed(c, w, horizon, u);
|
if (mapShown) drawMapSpeed(c, w, u);
|
||||||
|
else drawSpeed(c, w, horizon, u);
|
||||||
drawWall(c, horizon, u);
|
drawWall(c, horizon, u);
|
||||||
drawDisc(c, w, u);
|
drawDisc(c, w, mapShown, u);
|
||||||
drawTrail(c, w, h, u);
|
if (!mapShown) drawTrail(c, w, h, u);
|
||||||
drawReadouts(c, w, h, u);
|
drawReadouts(c, w, h, u);
|
||||||
|
if (mapShown) drawAttribution(c, w, h, u);
|
||||||
|
|
||||||
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
|
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
|
||||||
keepAnimating(moving, 33);
|
// 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. */
|
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
|
||||||
@@ -138,6 +187,75 @@ final class GridView extends ClusterCanvasView {
|
|||||||
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
|
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Map ----------
|
||||||
|
|
||||||
|
/** 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 ----------
|
// ---------- Top ----------
|
||||||
|
|
||||||
private void drawGears(Canvas c, float w, float u) {
|
private void drawGears(Canvas c, float w, float u) {
|
||||||
@@ -225,8 +343,13 @@ final class GridView extends ClusterCanvasView {
|
|||||||
// ---------- Power disc ----------
|
// ---------- Power disc ----------
|
||||||
|
|
||||||
/** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */
|
/** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */
|
||||||
private void drawDisc(Canvas c, float w, float u) {
|
private void drawDisc(Canvas c, float w, boolean overMap, float u) {
|
||||||
float cx = w - 170f * u, cy = 238f * u, outer = 132f * u, ring = 22f * u;
|
float cx = w - 170f * u, cy = 238f * u, outer = 132f * u, ring = 22f * u;
|
||||||
|
if (overMap) {
|
||||||
|
// The map runs under the disc: a dark backing keeps it readable.
|
||||||
|
fill.setColor(0xD0000000);
|
||||||
|
c.drawCircle(cx, cy, outer + 16f * u, fill);
|
||||||
|
}
|
||||||
line.setColor(dim);
|
line.setColor(dim);
|
||||||
line.setStrokeWidth(1.5f * u);
|
line.setStrokeWidth(1.5f * u);
|
||||||
c.drawCircle(cx, cy, outer + 10f * u, line);
|
c.drawCircle(cx, cy, outer + 10f * u, line);
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.zip.GZIPOutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files from the Logs tab (signal recordings) on their way to the car server, so they reach the
|
||||||
|
* owner without a USB stick: kept in {@code files/log-outbox} until the server has them
|
||||||
|
* ({@code PUT /v1/logs/{name}}, gzip, the ingest token in the header), sent at once and, when
|
||||||
|
* that fails, again from the service's tick, at most every {@link #RETRY_MS}. At most
|
||||||
|
* {@link #MAX_FILES} wait; the oldest goes first. Names are the app's own and say nothing about
|
||||||
|
* where the car is.
|
||||||
|
*/
|
||||||
|
final class LogOutbox {
|
||||||
|
|
||||||
|
static final String DIR = "log-outbox";
|
||||||
|
static final int MAX_FILES = 10;
|
||||||
|
static final long RETRY_MS = 2 * 60_000L;
|
||||||
|
private static final String TAG = "EVABRP.LogOutbox";
|
||||||
|
/** As the server accepts them. */
|
||||||
|
private static final Pattern NAME = Pattern.compile("[a-z0-9][a-z0-9-]{0,79}\\.txt");
|
||||||
|
|
||||||
|
/** What to do with a file after the server's answer. */
|
||||||
|
enum Outcome { SENT, DROP, KEEP }
|
||||||
|
|
||||||
|
private static long lastFailureMs;
|
||||||
|
private static String lastResult;
|
||||||
|
|
||||||
|
private LogOutbox() {}
|
||||||
|
|
||||||
|
/** SENT for a 2xx; KEEP for what may pass later (no answer, 401 before pairing, 408, 429,
|
||||||
|
* 5xx, and a 404 from a server without logs yet); DROP for any other refusal. */
|
||||||
|
static Outcome outcome(int status) {
|
||||||
|
if (status >= 200 && status < 300) return Outcome.SENT;
|
||||||
|
if (status <= 0 || status == 401 || status == 404 || status == 408 || status == 429 || status >= 500) {
|
||||||
|
return Outcome.KEEP;
|
||||||
|
}
|
||||||
|
return Outcome.DROP;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean validName(String name) {
|
||||||
|
return name != null && NAME.matcher(name).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The names to remove so that at most [max] remain: the oldest (by name, which starts
|
||||||
|
* with what they are and ends in the time) beyond the newest [max]. */
|
||||||
|
static List<String> surplus(List<String> names, int max) {
|
||||||
|
List<String> sorted = new ArrayList<>(names);
|
||||||
|
Collections.sort(sorted);
|
||||||
|
return sorted.size() <= max ? Collections.emptyList() : sorted.subList(0, sorted.size() - max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts [text] in the outbox as [name]; false when it could not be written. */
|
||||||
|
static synchronized boolean add(Context context, String name, List<String> text) {
|
||||||
|
if (!validName(name)) return false;
|
||||||
|
File dir = new File(context.getFilesDir(), DIR);
|
||||||
|
if (!dir.isDirectory() && !dir.mkdirs()) return false;
|
||||||
|
File tmp = new File(dir, name + ".tmp");
|
||||||
|
try (Writer out = new OutputStreamWriter(new FileOutputStream(tmp), StandardCharsets.UTF_8)) {
|
||||||
|
for (String line : text) out.write(line + "\n");
|
||||||
|
} catch (Exception e) {
|
||||||
|
tmp.delete();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!tmp.renameTo(new File(dir, name))) {
|
||||||
|
tmp.delete();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String old : surplus(waiting(dir), MAX_FILES)) new File(dir, old).delete();
|
||||||
|
lastFailureMs = 0; // something new: try at once
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends what waits, unless the last attempt failed less than {@link #RETRY_MS} ago. Blocking:
|
||||||
|
* call it off the main thread. */
|
||||||
|
static void sendIfDue(Context context) {
|
||||||
|
synchronized (LogOutbox.class) {
|
||||||
|
if (lastFailureMs != 0 && System.currentTimeMillis() - lastFailureMs < RETRY_MS) return;
|
||||||
|
}
|
||||||
|
send(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends every waiting file, oldest first, until one fails. Blocking. */
|
||||||
|
static synchronized void send(Context context) {
|
||||||
|
File dir = new File(context.getFilesDir(), DIR);
|
||||||
|
List<String> names = waiting(dir);
|
||||||
|
if (names.isEmpty()) return;
|
||||||
|
ServerConfig config = ServerConfig.get(context);
|
||||||
|
if (!config.isConfigured()) {
|
||||||
|
lastResult = "no server set up";
|
||||||
|
lastFailureMs = System.currentTimeMillis();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Collections.sort(names);
|
||||||
|
for (String name : names) {
|
||||||
|
File file = new File(dir, name);
|
||||||
|
int status;
|
||||||
|
try {
|
||||||
|
byte[] body = gzip(Files.readAllBytes(file.toPath()));
|
||||||
|
status = ServerHttp.send("PUT", config.url + "/v1/logs/" + name, "Bearer " + config.token,
|
||||||
|
"text/plain; charset=utf-8", "gzip", body).status;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "Log not sent: " + e.getClass().getSimpleName());
|
||||||
|
status = 0;
|
||||||
|
}
|
||||||
|
Outcome outcome = outcome(status);
|
||||||
|
if (outcome == Outcome.KEEP) {
|
||||||
|
lastResult = status == 0 ? "server not reached" : "server answered " + status;
|
||||||
|
lastFailureMs = System.currentTimeMillis();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
file.delete();
|
||||||
|
lastResult = outcome == Outcome.SENT ? "sent" : "refused by the server (" + status + ")";
|
||||||
|
Log.i(TAG, "Log " + name + ": " + lastResult);
|
||||||
|
}
|
||||||
|
lastFailureMs = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the Logs tab: whether the last file reached the server, or why not yet. */
|
||||||
|
static synchronized String status(Context context) {
|
||||||
|
int waiting = waiting(new File(context.getFilesDir(), DIR)).size();
|
||||||
|
if (waiting == 0) return lastResult == null ? null : "sent".equals(lastResult)
|
||||||
|
? context.getString(R.string.log_outbox_sent) : lastResult;
|
||||||
|
return context.getString(R.string.log_outbox_waiting, waiting, lastResult == null ? "…" : lastResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> waiting(File dir) {
|
||||||
|
String[] names = dir.list();
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
if (names == null) return out;
|
||||||
|
for (String n : Arrays.asList(names)) if (validName(n)) out.add(n);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] gzip(byte[] data) throws java.io.IOException {
|
||||||
|
ByteArrayOutputStream buf = new ByteArrayOutputStream(data.length / 4 + 64);
|
||||||
|
try (GZIPOutputStream z = new GZIPOutputStream(buf)) {
|
||||||
|
z.write(data);
|
||||||
|
}
|
||||||
|
return buf.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,11 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private TerminalView terminalView;
|
private TerminalView terminalView;
|
||||||
private GridView gridView;
|
private GridView gridView;
|
||||||
private VisorView visorView;
|
private VisorView visorView;
|
||||||
|
/** "Grid with map": the same style over the roads round the car (MapRoads, fed by GPS here). */
|
||||||
|
private GridView gridMapView;
|
||||||
|
private MapRoads mapRoads;
|
||||||
|
private android.location.LocationListener mapListener;
|
||||||
|
private boolean inFront;
|
||||||
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
||||||
private View[] stylePanes;
|
private View[] stylePanes;
|
||||||
private View consumptionPane;
|
private View consumptionPane;
|
||||||
@@ -345,12 +350,16 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
mgPane = drawnPane(octagonView);
|
mgPane = drawnPane(octagonView);
|
||||||
terminalView = new TerminalView(this);
|
terminalView = new TerminalView(this);
|
||||||
gridView = new GridView(this);
|
gridView = new GridView(this);
|
||||||
|
mapRoads = new MapRoads(this);
|
||||||
|
gridMapView = new GridView(this, mapRoads);
|
||||||
visorView = new VisorView(this);
|
visorView = new VisorView(this);
|
||||||
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
||||||
new NixieView(this), new ChronographView(this), gridView, new TimeCircuitsView(this),
|
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
||||||
new QuestView(this), visorView, new EmpireView(this));
|
new QuestView(this), visorView, new EmpireView(this));
|
||||||
View[] canvasPanes = new View[canvasStyles.size()];
|
View[] canvasPanes = new View[canvasStyles.size()];
|
||||||
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
||||||
|
// Grid with map: its map layer goes under it, in the same pane.
|
||||||
|
((android.widget.FrameLayout) canvasPanes[canvasStyles.indexOf(gridMapView)]).addView(gridMapView.mapLayer(), 0);
|
||||||
View[] fixedPanes = { vehiclePane, gaugesPane, cockpitPane, retroPane,
|
View[] fixedPanes = { vehiclePane, gaugesPane, cockpitPane, retroPane,
|
||||||
futuristicPane, sciFiPane, cartoonPane, mgPane };
|
futuristicPane, sciFiPane, cartoonPane, mgPane };
|
||||||
// By OverviewStyle ordinal: the fixed styles, then the canvas styles in their order.
|
// By OverviewStyle ordinal: the fixed styles, then the canvas styles in their order.
|
||||||
@@ -399,6 +408,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
else SignalRecorder.start(this);
|
else SignalRecorder.start(this);
|
||||||
refreshSignalRecorder();
|
refreshSignalRecorder();
|
||||||
});
|
});
|
||||||
|
logPane.findViewById(R.id.media_probe_button).setOnClickListener(v -> {
|
||||||
|
MediaProbe.start(this);
|
||||||
|
refreshMediaProbe();
|
||||||
|
});
|
||||||
|
|
||||||
setUpPager();
|
setUpPager();
|
||||||
// Back on the page that was open when the activity was recreated (accent colour change).
|
// Back on the page that was open when the activity was recreated (accent colour change).
|
||||||
@@ -487,6 +500,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// Poll state and log only while the screen is up; onPause cancels it.
|
// Poll state and log only while the screen is up; onPause cancels it.
|
||||||
uiHandler.post(uiRefresh);
|
uiHandler.post(uiRefresh);
|
||||||
uiHandler.post(overviewRefresh);
|
uiHandler.post(overviewRefresh);
|
||||||
|
inFront = true;
|
||||||
|
updateMapLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -534,6 +549,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
// Stop polling off-screen: this activity has no reason to spend cycles then.
|
||||||
uiHandler.removeCallbacks(uiRefresh);
|
uiHandler.removeCallbacks(uiRefresh);
|
||||||
uiHandler.removeCallbacks(overviewRefresh);
|
uiHandler.removeCallbacks(overviewRefresh);
|
||||||
|
inFront = false;
|
||||||
|
updateMapLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -1725,9 +1742,56 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
|
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
|
||||||
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
|
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
|
||||||
gridView.setAccentPalette(!originalColours(OverviewStyle.GRID));
|
gridView.setAccentPalette(!originalColours(OverviewStyle.GRID));
|
||||||
|
gridMapView.setAccentPalette(!originalColours(OverviewStyle.GRID_MAP));
|
||||||
visorView.setAccentPalette(!originalColours(OverviewStyle.VISOR));
|
visorView.setAccentPalette(!originalColours(OverviewStyle.VISOR));
|
||||||
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
|
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
|
||||||
if (settingsPane != null) bindAccentColor();
|
if (settingsPane != null) bindAccentColor();
|
||||||
|
updateMapLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPS for the Grid's map, only while "Grid with map" is the style and the app is in front;
|
||||||
|
* every fix goes to {@link MapRoads}. Without the location permission there is no map (the
|
||||||
|
* floor says it waits for GPS), never a crash.
|
||||||
|
*/
|
||||||
|
private void updateMapLocation() {
|
||||||
|
android.location.LocationManager lm =
|
||||||
|
(android.location.LocationManager) getSystemService(LOCATION_SERVICE);
|
||||||
|
if (lm == null || mapRoads == null) return;
|
||||||
|
boolean wanted = inFront && overviewStyle() == OverviewStyle.GRID_MAP;
|
||||||
|
if (!wanted) {
|
||||||
|
if (mapListener != null) {
|
||||||
|
try {
|
||||||
|
lm.removeUpdates(mapListener);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
mapListener = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mapListener != null) return;
|
||||||
|
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
android.location.LocationListener listener = new android.location.LocationListener() {
|
||||||
|
@Override public void onLocationChanged(android.location.Location location) { mapRoads.onFix(location); }
|
||||||
|
@Override public void onStatusChanged(String provider, int status, android.os.Bundle extras) { }
|
||||||
|
@Override public void onProviderEnabled(String provider) { }
|
||||||
|
@Override public void onProviderDisabled(String provider) { }
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
lm.requestLocationUpdates(android.location.LocationManager.GPS_PROVIDER, 1000L, 0f, listener);
|
||||||
|
mapListener = listener;
|
||||||
|
android.location.Location last = lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER);
|
||||||
|
if (last != null && android.os.SystemClock.elapsedRealtimeNanos() - last.getElapsedRealtimeNanos()
|
||||||
|
< MapRoads.FIX_FRESH_MS * 1_000_000L) {
|
||||||
|
mapRoads.onFix(last);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
android.util.Log.w("MainActivity", "No GPS for the map: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1757,6 +1821,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
|
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
|
||||||
getString(R.string.overview_style_nixie),
|
getString(R.string.overview_style_nixie),
|
||||||
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
|
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
|
||||||
|
getString(R.string.overview_style_grid_map),
|
||||||
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
|
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
|
||||||
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire) };
|
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire) };
|
||||||
}
|
}
|
||||||
@@ -2264,8 +2329,20 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
if (!status.isEmpty()) text.setText(String.join("\n", status));
|
if (!status.isEmpty()) text.setText(String.join("\n", status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The music-sources check: its button and its last report. */
|
||||||
|
private void refreshMediaProbe() {
|
||||||
|
MaterialButton button = logPane.findViewById(R.id.media_probe_button);
|
||||||
|
button.setEnabled(!MediaProbe.running());
|
||||||
|
TextView text = logPane.findViewById(R.id.media_probe_text);
|
||||||
|
java.util.List<String> status = MediaProbe.status(this);
|
||||||
|
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
|
||||||
|
if (status.size() > 120) status = status.subList(0, 120);
|
||||||
|
if (!status.isEmpty()) text.setText(String.join("\n", status));
|
||||||
|
}
|
||||||
|
|
||||||
private void refreshCallLog() {
|
private void refreshCallLog() {
|
||||||
refreshSignalRecorder();
|
refreshSignalRecorder();
|
||||||
|
refreshMediaProbe();
|
||||||
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
||||||
// Update steps first: after a failed install, they are what the driver came here for.
|
// Update steps first: after a failed install, they are what the driver came here for.
|
||||||
java.util.List<String> updates = UpdateLog.lines(this);
|
java.util.List<String> updates = UpdateLog.lines(this);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.ComponentName;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.content.pm.ResolveInfo;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.media.AudioPlaybackConfiguration;
|
||||||
|
import android.media.MediaMetadata;
|
||||||
|
import android.media.browse.MediaBrowser;
|
||||||
|
import android.media.session.MediaController;
|
||||||
|
import android.media.session.MediaSessionManager;
|
||||||
|
import android.media.session.PlaybackState;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.service.media.MediaBrowserService;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.TreeSet;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The music-sources check on the Logs tab: where the head unit tells what is playing, before
|
||||||
|
* the Overview shows it. Three places, all read only:
|
||||||
|
* <ol>
|
||||||
|
* <li>the playback Android's audio service reports (which app plays, for what use);</li>
|
||||||
|
* <li>the active media sessions ({@code MediaSessionManager.getActiveSessions}, which needs
|
||||||
|
* MEDIA_CONTENT_CONTROL: the car build has it as the system user), with their song and
|
||||||
|
* state;</li>
|
||||||
|
* <li>every media browser service on the head unit, CarPlay's
|
||||||
|
* ({@link #CARPLAY}, where the car's own media library reads CarPlay's song) among them:
|
||||||
|
* connected to as the car's media library does, its session's song and state read, and
|
||||||
|
* disconnected.</li>
|
||||||
|
* </ol>
|
||||||
|
* Nothing is played, paused or skipped. The report goes where a signal recording goes
|
||||||
|
* ({@link SignalRecorder#saveCopies}): the app, a USB stick, the server.
|
||||||
|
*/
|
||||||
|
final class MediaProbe {
|
||||||
|
|
||||||
|
static final String FILE = "media_probe.txt";
|
||||||
|
/** CarPlay's song, as {@code com.saicmotor.sdk.media…MediaPlayControlManager} reads it. */
|
||||||
|
static final ComponentName CARPLAY = new ComponentName("com.allgo.remoteui.mediabrowserservice",
|
||||||
|
"com.allgo.remoteui.mediabrowserservice.RemoteUIMediaBrowserService");
|
||||||
|
static final long CONNECT_TIMEOUT_MS = 8_000L;
|
||||||
|
/** More browser services than this are listed but not connected to. */
|
||||||
|
static final int MAX_BROWSERS = 12;
|
||||||
|
|
||||||
|
private static volatile boolean running;
|
||||||
|
private static long startedMs;
|
||||||
|
private static List<String> report = new ArrayList<>();
|
||||||
|
private static String result;
|
||||||
|
|
||||||
|
private MediaProbe() {}
|
||||||
|
|
||||||
|
static synchronized boolean running() {
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
static synchronized void start(Context context) {
|
||||||
|
if (running) return;
|
||||||
|
Context app = context.getApplicationContext();
|
||||||
|
running = true;
|
||||||
|
startedMs = System.currentTimeMillis();
|
||||||
|
result = null;
|
||||||
|
report = new ArrayList<>();
|
||||||
|
new Thread(() -> probe(app), "media-probe").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void probe(Context app) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
out.add("Media probe, " + SignalRecording.time(startedMs) + ", app " + BuildConfig.VERSION_NAME);
|
||||||
|
String saved;
|
||||||
|
try {
|
||||||
|
out.add("");
|
||||||
|
audio(app, out);
|
||||||
|
out.add("");
|
||||||
|
sessions(app, out);
|
||||||
|
out.add("");
|
||||||
|
browsers(app, out);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
out.add("probe stopped: " + t.getClass().getSimpleName());
|
||||||
|
} finally {
|
||||||
|
String name = "media-probe-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||||
|
.format(new Date(startedMs)) + ".txt";
|
||||||
|
saved = SignalRecorder.saveCopies(app, FILE, name, out);
|
||||||
|
synchronized (MediaProbe.class) {
|
||||||
|
report = out;
|
||||||
|
result = saved;
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who plays audio, as Android's audio service sees it. */
|
||||||
|
private static void audio(Context app, List<String> out) {
|
||||||
|
out.add("Audio playing (AudioManager):");
|
||||||
|
AudioManager am = (AudioManager) app.getSystemService(Context.AUDIO_SERVICE);
|
||||||
|
if (am == null) {
|
||||||
|
out.add(" no audio service");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
out.add(" music active: " + am.isMusicActive() + ", mode " + am.getMode());
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
out.add(" music active: unreadable (" + e.getClass().getSimpleName() + ")");
|
||||||
|
}
|
||||||
|
List<AudioPlaybackConfiguration> players;
|
||||||
|
try {
|
||||||
|
players = am.getActivePlaybackConfigurations();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
out.add(" players: unreadable (" + e.getClass().getSimpleName() + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (players == null || players.isEmpty()) {
|
||||||
|
out.add(" players: none");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PackageManager pm = app.getPackageManager();
|
||||||
|
for (AudioPlaybackConfiguration p : players) {
|
||||||
|
StringBuilder line = new StringBuilder(" player: ");
|
||||||
|
try {
|
||||||
|
line.append(MediaProbeText.usage(p.getAudioAttributes().getUsage()));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
line.append("usage ?");
|
||||||
|
}
|
||||||
|
// The app and state are system APIs: read when the platform lets the system user.
|
||||||
|
Integer uid = hiddenInt(p, "getClientUid");
|
||||||
|
if (uid != null) line.append(", uid ").append(uid).append(' ').append(packages(pm, uid));
|
||||||
|
Integer state = hiddenInt(p, "getPlayerState");
|
||||||
|
if (state != null) line.append(", ").append(MediaProbeText.playerState(state));
|
||||||
|
if (uid == null && state == null) line.append(" — ").append(MediaProbeText.clean(p.toString()));
|
||||||
|
out.add(line.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer hiddenInt(Object target, String method) {
|
||||||
|
try {
|
||||||
|
Object v = target.getClass().getMethod(method).invoke(target);
|
||||||
|
return v instanceof Integer ? (Integer) v : null;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String packages(PackageManager pm, int uid) {
|
||||||
|
try {
|
||||||
|
String[] names = pm.getPackagesForUid(uid);
|
||||||
|
return names == null ? "(no package)" : String.join(", ", names);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return "(package unreadable)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The media sessions apps publish, newest first, with their song. */
|
||||||
|
private static void sessions(Context app, List<String> out) {
|
||||||
|
out.add("Active media sessions (MediaSessionManager):");
|
||||||
|
List<MediaController> controllers;
|
||||||
|
try {
|
||||||
|
MediaSessionManager msm = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE);
|
||||||
|
controllers = msm == null ? null : msm.getActiveSessions(null);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
out.add(" not readable: " + t.getClass().getSimpleName() + " " + MediaProbeText.clean(t.getMessage()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (controllers == null || controllers.isEmpty()) {
|
||||||
|
out.add(" none");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (MediaController c : controllers) {
|
||||||
|
out.add(" session of " + safe(c::getPackageName));
|
||||||
|
describe(c, out, " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every media browser service, each connected to, read and disconnected. */
|
||||||
|
private static void browsers(Context app, List<String> out) {
|
||||||
|
out.add("Media browser services (" + MediaBrowserService.SERVICE_INTERFACE + "):");
|
||||||
|
Set<ComponentName> services = new LinkedHashSet<>();
|
||||||
|
try {
|
||||||
|
List<ResolveInfo> found = app.getPackageManager()
|
||||||
|
.queryIntentServices(new Intent(MediaBrowserService.SERVICE_INTERFACE), 0);
|
||||||
|
for (ResolveInfo ri : found == null ? new ArrayList<ResolveInfo>() : found) {
|
||||||
|
if (ri.serviceInfo != null) {
|
||||||
|
services.add(new ComponentName(ri.serviceInfo.packageName, ri.serviceInfo.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
out.add(" list not readable: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
if (!services.contains(CARPLAY)) out.add(" CarPlay's service is not listed; tried all the same");
|
||||||
|
services.add(CARPLAY);
|
||||||
|
HandlerThread thread = new HandlerThread("media-probe-browser");
|
||||||
|
thread.start();
|
||||||
|
try {
|
||||||
|
Handler handler = new Handler(thread.getLooper());
|
||||||
|
int n = 0;
|
||||||
|
for (ComponentName service : services) {
|
||||||
|
if (++n > MAX_BROWSERS) {
|
||||||
|
out.add(" " + service.flattenToShortString() + ": not tried (limit)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
browse(app, handler, service, out);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
thread.quitSafely();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connects to one browser service on {@code handler}'s thread and reads its session. */
|
||||||
|
private static void browse(Context app, Handler handler, ComponentName service, List<String> out) {
|
||||||
|
out.add(" " + service.flattenToShortString() + ":");
|
||||||
|
CountDownLatch answered = new CountDownLatch(1);
|
||||||
|
AtomicReference<String> outcome = new AtomicReference<>();
|
||||||
|
AtomicReference<MediaBrowser> browser = new AtomicReference<>();
|
||||||
|
boolean posted = handler.post(() -> {
|
||||||
|
try {
|
||||||
|
MediaBrowser b = new MediaBrowser(app, service, new MediaBrowser.ConnectionCallback() {
|
||||||
|
@Override public void onConnected() {
|
||||||
|
outcome.compareAndSet(null, "connected");
|
||||||
|
answered.countDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onConnectionFailed() {
|
||||||
|
outcome.compareAndSet(null, "refused or failed");
|
||||||
|
answered.countDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onConnectionSuspended() {
|
||||||
|
outcome.compareAndSet(null, "suspended");
|
||||||
|
answered.countDown();
|
||||||
|
}
|
||||||
|
}, null);
|
||||||
|
browser.set(b);
|
||||||
|
b.connect();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
outcome.compareAndSet(null, "could not try: " + t.getClass().getSimpleName());
|
||||||
|
answered.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (!posted || !answered.await(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
||||||
|
out.add(" no answer in " + CONNECT_TIMEOUT_MS / 1000 + " s");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.add(" " + outcome.get());
|
||||||
|
MediaBrowser b = browser.get();
|
||||||
|
if (!"connected".equals(outcome.get()) || b == null) return;
|
||||||
|
try {
|
||||||
|
out.add(" root: " + MediaProbeText.clean(b.getRoot()));
|
||||||
|
describe(new MediaController(app, b.getSessionToken()), out, " ");
|
||||||
|
} catch (Throwable t) {
|
||||||
|
out.add(" session not readable: " + t.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
out.add(" interrupted");
|
||||||
|
} finally {
|
||||||
|
handler.post(() -> {
|
||||||
|
try {
|
||||||
|
MediaBrowser b = browser.get();
|
||||||
|
if (b != null) b.disconnect();
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session's state and everything its song's metadata holds. */
|
||||||
|
private static void describe(MediaController c, List<String> out, String indent) {
|
||||||
|
try {
|
||||||
|
PlaybackState s = c.getPlaybackState();
|
||||||
|
out.add(indent + "state: " + (s == null ? "(none)"
|
||||||
|
: MediaProbeText.playbackState(s.getState()) + ", at "
|
||||||
|
+ MediaProbeText.duration(s.getPosition()) + ", actions 0x"
|
||||||
|
+ Long.toHexString(s.getActions())));
|
||||||
|
} catch (Throwable t) {
|
||||||
|
out.add(indent + "state: unreadable (" + t.getClass().getSimpleName() + ")");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
MediaController.PlaybackInfo info = c.getPlaybackInfo();
|
||||||
|
if (info != null) {
|
||||||
|
out.add(indent + "plays " + (info.getPlaybackType() == MediaController.PlaybackInfo.PLAYBACK_TYPE_REMOTE
|
||||||
|
? "remotely" : "locally") + ", for "
|
||||||
|
+ MediaProbeText.usage(info.getAudioAttributes().getUsage()));
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
// Not every session says.
|
||||||
|
}
|
||||||
|
MediaMetadata m;
|
||||||
|
try {
|
||||||
|
m = c.getMetadata();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
out.add(indent + "song: unreadable (" + t.getClass().getSimpleName() + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (m == null) {
|
||||||
|
out.add(indent + "song: (no metadata)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String key : new TreeSet<>(m.keySet())) {
|
||||||
|
String shortKey = key.startsWith("android.media.metadata.")
|
||||||
|
? key.substring("android.media.metadata.".length()) : key;
|
||||||
|
out.add(indent + shortKey + ": " + value(m, key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One metadata value: a picture by its size, text cleaned, anything else as a number. */
|
||||||
|
private static String value(MediaMetadata m, String key) {
|
||||||
|
try {
|
||||||
|
Bitmap bitmap = m.getBitmap(key);
|
||||||
|
if (bitmap != null) return "picture " + bitmap.getWidth() + "×" + bitmap.getHeight();
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
// not a picture
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CharSequence text = m.getText(key);
|
||||||
|
if (text != null) return MediaProbeText.clean(text);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
// not text
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (key.equals(MediaMetadata.METADATA_KEY_DURATION)) {
|
||||||
|
return MediaProbeText.duration(m.getLong(key));
|
||||||
|
}
|
||||||
|
if (m.getRating(key) != null) return "rating";
|
||||||
|
return Long.toString(m.getLong(key));
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(java.util.concurrent.Callable<String> read) {
|
||||||
|
try {
|
||||||
|
return MediaProbeText.clean(read.call());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the Logs tab shows: the state, then the report. */
|
||||||
|
static synchronized List<String> status(Context context) {
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
if (startedMs == 0) return lines;
|
||||||
|
if (running) {
|
||||||
|
lines.add(context.getString(R.string.media_probe_running));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
lines.add(context.getString(R.string.media_probe_done, SignalRecording.time(startedMs),
|
||||||
|
result == null ? "…" : result));
|
||||||
|
String sent = LogOutbox.status(context);
|
||||||
|
if (sent != null) lines.add(sent);
|
||||||
|
lines.addAll(report.subList(Math.min(1, report.size()), report.size()));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The words of {@link MediaProbe}'s report: Android's numbers for playback and audio states as
|
||||||
|
* names, and text from other apps made safe for one line. Android-free and unit-tested.
|
||||||
|
*/
|
||||||
|
final class MediaProbeText {
|
||||||
|
|
||||||
|
/** Longest text kept from another app (a title, an artist). */
|
||||||
|
static final int MAX_TEXT = 120;
|
||||||
|
|
||||||
|
private MediaProbeText() {}
|
||||||
|
|
||||||
|
/** Another app's text on one line: control characters out, at most {@link #MAX_TEXT}. */
|
||||||
|
static String clean(CharSequence text) {
|
||||||
|
if (text == null) return "(none)";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < text.length() && sb.length() < MAX_TEXT; i++) {
|
||||||
|
char c = text.charAt(i);
|
||||||
|
sb.append(Character.isISOControl(c) ? ' ' : c);
|
||||||
|
}
|
||||||
|
String s = sb.toString().trim();
|
||||||
|
if (text.length() > MAX_TEXT) s += "…";
|
||||||
|
return s.isEmpty() ? "(empty)" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code android.media.session.PlaybackState} STATE_*. */
|
||||||
|
static String playbackState(int state) {
|
||||||
|
switch (state) {
|
||||||
|
case 0: return "none";
|
||||||
|
case 1: return "stopped";
|
||||||
|
case 2: return "paused";
|
||||||
|
case 3: return "playing";
|
||||||
|
case 4: return "fast forwarding";
|
||||||
|
case 5: return "rewinding";
|
||||||
|
case 6: return "buffering";
|
||||||
|
case 7: return "error";
|
||||||
|
case 8: return "connecting";
|
||||||
|
case 9: return "skipping to previous";
|
||||||
|
case 10: return "skipping to next";
|
||||||
|
case 11: return "skipping to queue item";
|
||||||
|
default: return "state " + state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code android.media.AudioAttributes} USAGE_*. */
|
||||||
|
static String usage(int usage) {
|
||||||
|
switch (usage) {
|
||||||
|
case 0: return "unknown";
|
||||||
|
case 1: return "media";
|
||||||
|
case 2: return "voice call";
|
||||||
|
case 3: return "call signalling";
|
||||||
|
case 4: return "alarm";
|
||||||
|
case 5: return "notification";
|
||||||
|
case 6: return "ringtone";
|
||||||
|
case 7: case 8: case 9: case 10: return "notification (" + usage + ")";
|
||||||
|
case 11: return "accessibility";
|
||||||
|
case 12: return "navigation guidance";
|
||||||
|
case 13: return "sonification";
|
||||||
|
case 14: return "game";
|
||||||
|
case 15: return "virtual source";
|
||||||
|
case 16: return "assistant";
|
||||||
|
default: return "usage " + usage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code android.media.AudioPlaybackConfiguration} PLAYER_STATE_*. */
|
||||||
|
static String playerState(int state) {
|
||||||
|
switch (state) {
|
||||||
|
case -1: return "unknown";
|
||||||
|
case 0: return "released";
|
||||||
|
case 1: return "idle";
|
||||||
|
case 2: return "started";
|
||||||
|
case 3: return "paused";
|
||||||
|
case 4: return "stopped";
|
||||||
|
default: return "state " + state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A position or duration in milliseconds as m:ss; negative (unknown) as "?". */
|
||||||
|
static String duration(long ms) {
|
||||||
|
if (ms < 0) return "?";
|
||||||
|
long s = ms / 1000;
|
||||||
|
return String.format(Locale.US, "%d:%02d", s / 60, s % 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ enum OverviewStyle {
|
|||||||
NIXIE(false, false),
|
NIXIE(false, false),
|
||||||
CHRONOGRAPH(true, false),
|
CHRONOGRAPH(true, false),
|
||||||
GRID(true, true),
|
GRID(true, true),
|
||||||
|
GRID_MAP(true, true),
|
||||||
TIME_CIRCUITS(false, false),
|
TIME_CIRCUITS(false, false),
|
||||||
QUEST(false, false),
|
QUEST(false, false),
|
||||||
VISOR(true, true),
|
VISOR(true, true),
|
||||||
@@ -53,7 +54,8 @@ enum OverviewStyle {
|
|||||||
|
|
||||||
/** Where the choice between the original palette and the accent colour is kept. */
|
/** Where the choice between the original palette and the accent colour is kept. */
|
||||||
String originalKey() {
|
String originalKey() {
|
||||||
return "original_colours_" + name();
|
// Grid with map is the Grid over a map: one choice for both.
|
||||||
|
return "original_colours_" + (this == GRID_MAP ? GRID : this).name();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roads of one map tile as the server sends them (server/maps.go): per road its class
|
||||||
|
* (0 motorway and trunk, 1 primary and secondary, 2 tertiary and unclassified, 3 residential)
|
||||||
|
* and its points in tile units, plus each road's bounding box for skipping far ones quickly.
|
||||||
|
* {@link #parse} fails closed: anything unexpected is no tile at all. Pure Java, unit-tested.
|
||||||
|
*/
|
||||||
|
final class RoadTile {
|
||||||
|
|
||||||
|
static final int CLASSES = 4;
|
||||||
|
/** Points may lie a little beyond the tile (a road is kept to its first point outside). */
|
||||||
|
private static final int SLACK = MapGeometry.EXTENT;
|
||||||
|
private static final int MAX_ROADS = 20_000;
|
||||||
|
|
||||||
|
final int x, y;
|
||||||
|
final int[] classes;
|
||||||
|
/** Per road: x0, y0, x1, y1, … in tile units. */
|
||||||
|
final int[][] points;
|
||||||
|
/** Per road: minX, minY, maxX, maxY. */
|
||||||
|
final int[][] bounds;
|
||||||
|
|
||||||
|
private RoadTile(int x, int y, int[] classes, int[][] points, int[][] bounds) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.classes = classes;
|
||||||
|
this.points = points;
|
||||||
|
this.bounds = bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tile in {@code json}, which must be the one asked for; null when it is not usable. */
|
||||||
|
static RoadTile parse(String json, int expectX, int expectY) {
|
||||||
|
try {
|
||||||
|
JSONObject o = new JSONObject(json);
|
||||||
|
if (o.getInt("z") != MapGeometry.ZOOM || o.getInt("x") != expectX || o.getInt("y") != expectY
|
||||||
|
|| o.getInt("extent") != MapGeometry.EXTENT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JSONArray ways = o.getJSONArray("ways");
|
||||||
|
if (ways.length() > MAX_ROADS) return null;
|
||||||
|
int n = ways.length();
|
||||||
|
int[] classes = new int[n];
|
||||||
|
int[][] points = new int[n][];
|
||||||
|
int[][] bounds = new int[n][];
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
JSONObject way = ways.getJSONObject(i);
|
||||||
|
int c = way.getInt("c");
|
||||||
|
JSONArray p = way.getJSONArray("p");
|
||||||
|
if (c < 0 || c >= CLASSES || p.length() < 4 || p.length() % 2 != 0) return null;
|
||||||
|
int[] pts = new int[p.length()];
|
||||||
|
int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE;
|
||||||
|
for (int k = 0; k < pts.length; k++) {
|
||||||
|
int value = p.getInt(k);
|
||||||
|
if (value < -SLACK || value > MapGeometry.EXTENT + SLACK) return null;
|
||||||
|
pts[k] = value;
|
||||||
|
if (k % 2 == 0) {
|
||||||
|
minX = Math.min(minX, value);
|
||||||
|
maxX = Math.max(maxX, value);
|
||||||
|
} else {
|
||||||
|
minY = Math.min(minY, value);
|
||||||
|
maxY = Math.max(maxY, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
classes[i] = c;
|
||||||
|
points[i] = pts;
|
||||||
|
bounds[i] = new int[] { minX, minY, maxX, maxY };
|
||||||
|
}
|
||||||
|
return new RoadTile(expectX, expectY, classes, points, bounds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,12 @@ final class ServerHttp {
|
|||||||
*/
|
*/
|
||||||
static Response send(String method, String url, String authorization, String contentType, byte[] body)
|
static Response send(String method, String url, String authorization, String contentType, byte[] body)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
|
return send(method, url, authorization, contentType, null, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As above, with a {@code Content-Encoding} ({@code "gzip"}) for the body, or null. */
|
||||||
|
static Response send(String method, String url, String authorization, String contentType,
|
||||||
|
String contentEncoding, byte[] body) throws Exception {
|
||||||
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
|
||||||
try {
|
try {
|
||||||
connection.setRequestMethod(method);
|
connection.setRequestMethod(method);
|
||||||
@@ -69,6 +75,7 @@ final class ServerHttp {
|
|||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.setFixedLengthStreamingMode(body.length);
|
connection.setFixedLengthStreamingMode(body.length);
|
||||||
connection.setRequestProperty("Content-Type", contentType);
|
connection.setRequestProperty("Content-Type", contentType);
|
||||||
|
if (contentEncoding != null) connection.setRequestProperty("Content-Encoding", contentEncoding);
|
||||||
try (OutputStream out = connection.getOutputStream()) { out.write(body); }
|
try (OutputStream out = connection.getOutputStream()) { out.write(body); }
|
||||||
}
|
}
|
||||||
int status = connection.getResponseCode();
|
int status = connection.getResponseCode();
|
||||||
|
|||||||
@@ -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,15 +28,19 @@ 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.)
|
||||||
*
|
*
|
||||||
* A full capture is far heavier than the Overview's reading, so the next one waits at least
|
* A full capture is far heavier than the Overview's reading, so the next one waits at least
|
||||||
* {@link #MIN_INTERVAL_MS} and at least as long as the last one took: the recorder uses at most
|
* {@link #MIN_INTERVAL_MS} and at least as long as the last one took: the recorder uses at most
|
||||||
* half the time. It runs on its own thread, on after the app leaves the screen (the service
|
* half the time. It runs on its own thread, on after the app leaves the screen (the service
|
||||||
* keeps the process), and stops by itself. At the end the recording goes to the app's storage
|
* keeps the process), and stops by itself. At the end the recording goes to the app's storage,
|
||||||
* and to the app's folder on every USB stick plugged in.
|
* to the app's folder on every USB stick plugged in, and to the car server ({@link LogOutbox}).
|
||||||
*/
|
*/
|
||||||
final class SignalRecorder {
|
final class SignalRecorder {
|
||||||
|
|
||||||
@@ -85,6 +91,22 @@ 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 ("
|
||||||
|
+ EVHardware.INSTANCE.getListenProblem() + ")"
|
||||||
|
: "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 +121,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,15 +159,27 @@ 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:");
|
||||||
text.addAll(rec.lines());
|
text.addAll(rec.lines());
|
||||||
List<String> places = new ArrayList<>();
|
|
||||||
if (write(new File(app.getFilesDir(), FILE), text)) places.add("app");
|
|
||||||
String name = "signal-recording-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
String name = "signal-recording-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||||
.format(new Date(startedMs)) + ".txt";
|
.format(new Date(startedMs)) + ".txt";
|
||||||
|
return saveCopies(app, FILE, name, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a report to the app's storage ({@code appFile}), to the app's folder on every USB
|
||||||
|
* stick plugged in ({@code name}) and to the car server; says where it went. Shared with
|
||||||
|
* {@link MediaProbe}.
|
||||||
|
*/
|
||||||
|
static String saveCopies(Context app, String appFile, String name, List<String> text) {
|
||||||
|
List<String> places = new ArrayList<>();
|
||||||
|
if (write(new File(app.getFilesDir(), appFile), text)) places.add("app");
|
||||||
File[] dirs = app.getExternalFilesDirs(null);
|
File[] dirs = app.getExternalFilesDirs(null);
|
||||||
for (File dir : dirs == null ? new File[0] : dirs) {
|
for (File dir : dirs == null ? new File[0] : dirs) {
|
||||||
try {
|
try {
|
||||||
@@ -149,6 +190,12 @@ final class SignalRecorder {
|
|||||||
// A volume that cannot say what it is gets nothing.
|
// A volume that cannot say what it is gets nothing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// And to the server, so it reaches the owner without a stick; the service sends it again
|
||||||
|
// later when the car is offline now.
|
||||||
|
if (LogOutbox.add(app, name, text)) {
|
||||||
|
LogOutbox.send(app);
|
||||||
|
places.add("for the server");
|
||||||
|
}
|
||||||
return places.isEmpty() ? "not saved" : "saved: " + String.join(", ", places);
|
return places.isEmpty() ? "not saved" : "saved: " + String.join(", ", places);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,14 +215,19 @@ 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));
|
||||||
|
String sent = LogOutbox.status(context);
|
||||||
|
if (sent != null) out.add(sent);
|
||||||
}
|
}
|
||||||
// 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,7 +18,8 @@ final class StylePalette {
|
|||||||
case SCIFI: return R.style.ThemeOverlay_EV_Style_SciFi;
|
case SCIFI: return R.style.ThemeOverlay_EV_Style_SciFi;
|
||||||
case CARTOON: return R.style.ThemeOverlay_EV_Style_Cartoon;
|
case CARTOON: return R.style.ThemeOverlay_EV_Style_Cartoon;
|
||||||
case NIXIE: return R.style.ThemeOverlay_EV_Style_Nixie;
|
case NIXIE: return R.style.ThemeOverlay_EV_Style_Nixie;
|
||||||
case GRID: return R.style.ThemeOverlay_EV_Style_Grid;
|
case GRID:
|
||||||
|
case GRID_MAP: return R.style.ThemeOverlay_EV_Style_Grid;
|
||||||
case TIME_CIRCUITS: return R.style.ThemeOverlay_EV_Style_TimeCircuits;
|
case TIME_CIRCUITS: return R.style.ThemeOverlay_EV_Style_TimeCircuits;
|
||||||
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
|
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
|
||||||
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
|
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
<com.google.android.material.button.MaterialButton android:id="@+id/signal_record_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_signal_record" style="@style/Widget.EV.Button.Outlined" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/signal_record_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_signal_record" style="@style/Widget.EV.Button.Outlined" />
|
||||||
<TextView android:id="@+id/signal_record_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
|
<TextView android:id="@+id/signal_record_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
<!-- The music-sources check (MediaProbe): where the head unit tells what is playing. -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:layout_marginBottom="16dp" android:background="@drawable/bg_glass">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_media_probe" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/media_probe_hint" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
|
<com.google.android.material.button.MaterialButton android:id="@+id/media_probe_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_media_probe" style="@style/Widget.EV.Button.Outlined" />
|
||||||
|
<TextView android:id="@+id/media_probe_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||||
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" />
|
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" />
|
||||||
|
|||||||
@@ -136,6 +136,7 @@
|
|||||||
<string name="overview_style_terminal">Terminal</string>
|
<string name="overview_style_terminal">Terminal</string>
|
||||||
<string name="accent_original">Original</string>
|
<string name="accent_original">Original</string>
|
||||||
<string name="overview_style_grid">Grid</string>
|
<string name="overview_style_grid">Grid</string>
|
||||||
|
<string name="overview_style_grid_map">Grid with map</string>
|
||||||
<string name="overview_style_time_circuits">Time circuits</string>
|
<string name="overview_style_time_circuits">Time circuits</string>
|
||||||
<string name="overview_style_quest">Quest</string>
|
<string name="overview_style_quest">Quest</string>
|
||||||
<string name="overview_style_visor">Visor</string>
|
<string name="overview_style_visor">Visor</string>
|
||||||
@@ -222,11 +223,18 @@
|
|||||||
<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. The whole recording goes to your server (later, if the car is offline), and with a USB stick plugged in it 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="label_media_probe">Music sources</string>
|
||||||
|
<string name="media_probe_hint">Finds where the head unit tells what is playing: the playback Android reports, the media sessions apps publish, and every media service of the head unit, CarPlay\'s among them, each with its song. Play something first (CarPlay, then the radio, Bluetooth or USB) and check once for each. Like a recording, the result goes to your server and to a USB stick. It only reads: nothing is played, paused or skipped.</string>
|
||||||
|
<string name="btn_media_probe">Check music sources</string>
|
||||||
|
<string name="media_probe_running">Checking… (up to a minute)</string>
|
||||||
|
<string name="media_probe_done">Check from %1$s, %2$s:</string>
|
||||||
|
<string name="log_outbox_sent">Sent to the server.</string>
|
||||||
|
<string name="log_outbox_waiting">Not on the server yet (%1$d waiting: %2$s); it is sent again every 2 minutes.</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>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
public class LogOutboxTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void whatTheServerSaysDecidesTheFile() {
|
||||||
|
assertEquals(LogOutbox.Outcome.SENT, LogOutbox.outcome(204));
|
||||||
|
assertEquals(LogOutbox.Outcome.SENT, LogOutbox.outcome(200));
|
||||||
|
// Worth another try: no answer, not paired yet, a server without logs yet, busy, broken.
|
||||||
|
for (int status : new int[] {0, 401, 404, 408, 429, 500, 502, 503}) {
|
||||||
|
assertEquals(String.valueOf(status), LogOutbox.Outcome.KEEP, LogOutbox.outcome(status));
|
||||||
|
}
|
||||||
|
// Refused for good: it would never pass.
|
||||||
|
for (int status : new int[] {400, 403, 413, 415}) {
|
||||||
|
assertEquals(String.valueOf(status), LogOutbox.Outcome.DROP, LogOutbox.outcome(status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void namesAreTheServersKind() {
|
||||||
|
assertTrue(LogOutbox.validName("signal-recording-20261027-085240.txt"));
|
||||||
|
assertFalse(LogOutbox.validName("signal-recording-20261027-085240.txt.tmp"));
|
||||||
|
assertFalse(LogOutbox.validName("../escape.txt"));
|
||||||
|
assertFalse(LogOutbox.validName("Upper.txt"));
|
||||||
|
assertFalse(LogOutbox.validName(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theOldestGoBeyondTheLimit() {
|
||||||
|
assertEquals(Arrays.asList("signal-recording-20261027-084921.txt"),
|
||||||
|
LogOutbox.surplus(Arrays.asList("signal-recording-20261027-085240.txt",
|
||||||
|
"signal-recording-20261027-084921.txt", "signal-recording-20261027-090000.txt"), 2));
|
||||||
|
assertEquals(Collections.emptyList(),
|
||||||
|
LogOutbox.surplus(Collections.singletonList("signal-recording-20261027-085240.txt"), 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class MediaProbeTextTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anotherAppsTextStaysOnOneLine() {
|
||||||
|
assertEquals("Song Title", MediaProbeText.clean("Song\nTitle"));
|
||||||
|
assertEquals("(none)", MediaProbeText.clean(null));
|
||||||
|
assertEquals("(empty)", MediaProbeText.clean(" \t"));
|
||||||
|
String longText = new String(new char[500]).replace('\0', 'a');
|
||||||
|
String cleaned = MediaProbeText.clean(longText);
|
||||||
|
assertEquals(MediaProbeText.MAX_TEXT + 1, cleaned.length());
|
||||||
|
assertTrue(cleaned.endsWith("…"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void androidsNumbersHaveNames() {
|
||||||
|
assertEquals("playing", MediaProbeText.playbackState(3));
|
||||||
|
assertEquals("paused", MediaProbeText.playbackState(2));
|
||||||
|
assertEquals("state 99", MediaProbeText.playbackState(99));
|
||||||
|
assertEquals("media", MediaProbeText.usage(1));
|
||||||
|
assertEquals("navigation guidance", MediaProbeText.usage(12));
|
||||||
|
assertEquals("assistant", MediaProbeText.usage(16));
|
||||||
|
assertEquals("started", MediaProbeText.playerState(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void durationsAsMinutes() {
|
||||||
|
assertEquals("3:25", MediaProbeText.duration(205_400));
|
||||||
|
assertEquals("0:00", MediaProbeText.duration(0));
|
||||||
|
assertEquals("?", MediaProbeText.duration(-1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<!-- Unstable channel only. The stable build has no self-update path, so neither the install
|
<!-- Unstable channel only. The stable build has no self-update path, so neither the install
|
||||||
permission nor the session result receiver belongs in the main manifest. -->
|
permission nor the session result receiver belongs in the main manifest. -->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:sharedUserId="android.uid.system">
|
android:sharedUserId="android.uid.system">
|
||||||
|
|
||||||
<!-- Needed for the PackageInstaller session that replaced `pm install -r`. On this
|
<!-- Needed for the PackageInstaller session that replaced `pm install -r`. On this
|
||||||
@@ -11,6 +12,11 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
|
<!-- Lists other apps' media sessions (MediaProbe, the Logs tab's music-sources check).
|
||||||
|
A platform permission: the system user has it; elsewhere the check says it cannot. -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MEDIA_CONTENT_CONTROL"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -161,10 +161,46 @@
|
|||||||
echo "Published $tag ($(basename "$apk") and .sha256) at $forge/$repo/releases/tag/$tag"
|
echo "Published $tag ($(basename "$apk") and .sha256) at $forge/$repo/releases/tag/$tag"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Fetches the files the car sent from its Logs tab (signal recordings) from the car
|
||||||
|
# server into logs/ (gitignored), those not there yet. Needs the server's LOGS_TOKEN,
|
||||||
|
# from $LOGS_TOKEN or ~/.config/nilsmg4/logs-token; sent in a header, never printed.
|
||||||
|
fetchLogs = pkgs.writeShellApplication {
|
||||||
|
name = "fetch-logs";
|
||||||
|
runtimeInputs = [ pkgs.curl pkgs.jq pkgs.coreutils pkgs.git ];
|
||||||
|
text = ''
|
||||||
|
server=''${SERVER_URL:-https://car.k8s.stinnesbeck.com}
|
||||||
|
token=''${LOGS_TOKEN:-}
|
||||||
|
if [ -z "$token" ] && [ -f "$HOME/.config/nilsmg4/logs-token" ]; then
|
||||||
|
token=$(tr -d '[:space:]' < "$HOME/.config/nilsmg4/logs-token")
|
||||||
|
fi
|
||||||
|
if [ -z "$token" ]; then
|
||||||
|
echo "fetch-logs: no token. Put the server's LOGS_TOKEN in ~/.config/nilsmg4/logs-token (or \$LOGS_TOKEN)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
root=$(git rev-parse --show-toplevel)
|
||||||
|
mkdir -p "$root/logs"
|
||||||
|
auth=(-H "Authorization: Bearer $token")
|
||||||
|
fetched=0
|
||||||
|
while IFS= read -r name; do
|
||||||
|
# The server only stores such names; anything else is not a path to write to.
|
||||||
|
if ! [[ "$name" =~ ^[a-z0-9][a-z0-9-]{0,79}\.txt$ ]]; then
|
||||||
|
echo "fetch-logs: skipping an unexpected name" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
[ -f "$root/logs/$name" ] && continue
|
||||||
|
curl -sSf "''${auth[@]}" -o "$root/logs/$name.part" "$server/v1/logs/$name"
|
||||||
|
mv "$root/logs/$name.part" "$root/logs/$name"
|
||||||
|
echo "logs/$name"
|
||||||
|
fetched=$((fetched + 1))
|
||||||
|
done < <(curl -sSf "''${auth[@]}" "$server/v1/logs" | jq -r '.logs[].name')
|
||||||
|
echo "fetch-logs: $fetched new"
|
||||||
|
'';
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
packages = [ jdk android.androidsdk buildCar publishCar pkgs.go pkgs.gopls pkgs.govulncheck pkgs.influxdb2-server pkgs.influxdb2-cli ];
|
packages = [ jdk android.androidsdk buildCar publishCar fetchLogs pkgs.go pkgs.gopls pkgs.govulncheck pkgs.influxdb2-server pkgs.influxdb2-cli ];
|
||||||
|
|
||||||
ANDROID_HOME = sdk;
|
ANDROID_HOME = sdk;
|
||||||
ANDROID_SDK_ROOT = sdk;
|
ANDROID_SDK_ROOT = sdk;
|
||||||
@@ -179,6 +215,7 @@
|
|||||||
echo " ./gradlew assembleUnstableDebug # build that can update itself (OTA)"
|
echo " ./gradlew assembleUnstableDebug # build that can update itself (OTA)"
|
||||||
echo " build-car # unstable build signed for the car"
|
echo " build-car # unstable build signed for the car"
|
||||||
echo " publish-car # publish it for the in-app updater"
|
echo " publish-car # publish it for the in-app updater"
|
||||||
|
echo " fetch-logs # the car's recordings from the server, into logs/"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-2
@@ -7,7 +7,7 @@ is applied automatically. Apply them yourself.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `namespace.yaml` | Namespace `mg4` |
|
| `namespace.yaml` | Namespace `mg4` |
|
||||||
| `influxdb.yaml` | InfluxDB 2.7.12 StatefulSet (10Gi PVC, default storage class), cluster-internal Service, NetworkPolicy admitting only the server and Grafana |
|
| `influxdb.yaml` | InfluxDB 2.7.12 StatefulSet (10Gi PVC, default storage class), cluster-internal Service, NetworkPolicy admitting only the server and Grafana |
|
||||||
| `server.yaml` | Go server Deployment (1 replica, non-root, read-only root filesystem) and Service |
|
| `server.yaml` | Go server Deployment (1 replica, non-root, read-only root filesystem), Service, and two 1Gi PVCs: the map tile cache and the logs the car sends |
|
||||||
| `grafana.yaml` | Grafana 13.2.3 Deployment (1Gi PVC), Service; datasource and dashboards provisioned |
|
| `grafana.yaml` | Grafana 13.2.3 Deployment (1Gi PVC), Service; datasource and dashboards provisioned |
|
||||||
| `grafana/` | The provisioned InfluxDB datasource and the MG4 dashboards (Live, Consumption, Charging) |
|
| `grafana/` | The provisioned InfluxDB datasource and the MG4 dashboards (Live, Consumption, Charging) |
|
||||||
| `ingress.yaml` | Traefik `IngressRoute`s for car.k8s.stinnesbeck.com and Grafana, TLS from Traefik's default certificate |
|
| `ingress.yaml` | Traefik `IngressRoute`s for car.k8s.stinnesbeck.com and Grafana, TLS from Traefik's default certificate |
|
||||||
@@ -22,7 +22,8 @@ is applied automatically. Apply them yourself.
|
|||||||
covered by Traefik's default certificate.
|
covered by Traefik's default certificate.
|
||||||
2. **Create the Secrets.** `cp k8s/secrets/secrets.example.yaml k8s/secrets/secrets.yaml` (gitignored), replace
|
2. **Create the Secrets.** `cp k8s/secrets/secrets.example.yaml k8s/secrets/secrets.yaml` (gitignored), replace
|
||||||
every `CHANGE-ME` there (`openssl rand -hex 32`): `influxdb-auth`, `mg4-server` (ingest token,
|
every `CHANGE-ME` there (`openssl rand -hex 32`): `influxdb-auth`, `mg4-server` (ingest token,
|
||||||
approval password for pairing, the forge token for app updates) and `grafana-admin`.
|
approval password for pairing, the forge token for app updates, the token that reads the
|
||||||
|
car's logs) and `grafana-admin`.
|
||||||
Keep the template itself on placeholders only.
|
Keep the template itself on placeholders only.
|
||||||
3. **Registry access.** The `mg4-server` package on git.stinnesbeck.com is public, so no pull
|
3. **Registry access.** The `mg4-server` package on git.stinnesbeck.com is public, so no pull
|
||||||
secret is needed. If you make it private, add an `imagePullSecrets` entry to `server.yaml`.
|
secret is needed. If you make it private, add an `imagePullSecrets` entry to `server.yaml`.
|
||||||
@@ -54,6 +55,14 @@ the forge that can only **read** the `nils/mg4` repository, put it there, apply
|
|||||||
restart the server. Without it, updates are off (the car's updater gets a 404). `FORGE_URL`
|
restart the server. Without it, updates are off (the car's updater gets a 404). `FORGE_URL`
|
||||||
and `FORGE_REPO` are set in `server.yaml`.
|
and `FORGE_REPO` are set in `server.yaml`.
|
||||||
|
|
||||||
|
## Logs from the car
|
||||||
|
|
||||||
|
The car sends its signal recordings to the server (`server/README.md`, "Logs from the car"),
|
||||||
|
which keeps them on the `mg4-server-logs` volume at `LOGS_DIR`. Reading them needs `LOGS_TOKEN`
|
||||||
|
from the `mg4-server` Secret: `openssl rand -hex 32`, put it there (it must differ from
|
||||||
|
`INGEST_TOKEN`), apply the Secret and restart the server. For `fetch-logs` on your machine, put
|
||||||
|
the same value in `~/.config/nilsmg4/logs-token`. Without it the car still sends; nobody can read.
|
||||||
|
|
||||||
## Agent access
|
## Agent access
|
||||||
|
|
||||||
[`agent-access/`](agent-access/README.md) creates a service account limited to the `mg4`
|
[`agent-access/`](agent-access/README.md) creates a service account limited to the `mg4`
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ stringData:
|
|||||||
# relays the releases to the car; k8s/README.md, "App updates"). Leave it empty to switch
|
# relays the releases to the car; k8s/README.md, "App updates"). Leave it empty to switch
|
||||||
# updates off.
|
# updates off.
|
||||||
FORGE_TOKEN: CHANGE-ME-read-only-forge-token
|
FORGE_TOKEN: CHANGE-ME-read-only-forge-token
|
||||||
|
# Reads the files the car sends from its Logs tab (GET /v1/logs). At least 24 characters and
|
||||||
|
# not the INGEST_TOKEN. Leave it empty to switch reading off; the car can still send.
|
||||||
|
LOGS_TOKEN: CHANGE-ME-openssl-rand-hex-32
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
|
|||||||
+58
-3
@@ -1,5 +1,37 @@
|
|||||||
# 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
|
||||||
|
---
|
||||||
|
# The files the car sends from its Logs tab (signal recordings), read with LOGS_TOKEN. Unlike
|
||||||
|
# the map cache these are not fetched again, so they get a volume of their own.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: mg4-server-logs
|
||||||
|
namespace: mg4
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mg4-server
|
||||||
|
app.kubernetes.io/part-of: mg4
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
@@ -25,12 +57,13 @@ spec:
|
|||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
runAsUser: 65532
|
runAsUser: 65532
|
||||||
runAsGroup: 65532
|
runAsGroup: 65532
|
||||||
|
fsGroup: 65532
|
||||||
seccompProfile:
|
seccompProfile:
|
||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: server
|
||||||
# 2.6.2, pinned by digest so a re-pushed tag cannot change what runs.
|
# Pinned by digest so a re-pushed tag cannot change what runs (version in SPECS.md).
|
||||||
image: git.stinnesbeck.com/nils/mg4-server@sha256:e4de1e75958d58ece8430a1913f9c1614d8ea1bf566f2e1ec5b449ef51844f77
|
image: git.stinnesbeck.com/nils/mg4-server@sha256:c68837f0e53cfca150644987be535e04d5e999bd4c91064d40973db67235df48
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
@@ -50,12 +83,22 @@ 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
|
||||||
|
# Files from the car's Logs tab, kept on the volume below (the newest 200, at most
|
||||||
|
# 180 days). Reading them needs LOGS_TOKEN from the Secret; empty switches reading off.
|
||||||
|
- name: LOGS_DIR
|
||||||
|
value: /var/lib/mg4-logs
|
||||||
- name: INFLUX_TOKEN
|
- name: INFLUX_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: influxdb-auth
|
name: influxdb-auth
|
||||||
key: admin-token
|
key: admin-token
|
||||||
# INGEST_TOKEN, APPROVAL_PASSWORD, FORGE_TOKEN.
|
# INGEST_TOKEN, APPROVAL_PASSWORD, FORGE_TOKEN, LOGS_TOKEN.
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: mg4-server
|
name: mg4-server
|
||||||
@@ -81,6 +124,18 @@ spec:
|
|||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
capabilities:
|
capabilities:
|
||||||
drop: ["ALL"]
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: maps
|
||||||
|
mountPath: /var/cache/mg4-maps
|
||||||
|
- name: logs
|
||||||
|
mountPath: /var/lib/mg4-logs
|
||||||
|
volumes:
|
||||||
|
- name: maps
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: mg4-server-maps
|
||||||
|
- name: logs
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: mg4-server-logs
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -23,6 +23,10 @@ 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 |
|
||||||
|
| `LOGS_DIR` | where the files the car sends from its Logs tab are kept (the manifest mounts a volume there); empty switches logs off |
|
||||||
|
| `LOGS_TOKEN` | optional, at least 24 characters and not the `INGEST_TOKEN`; reads those files (below). Empty: the car can send, nobody can read |
|
||||||
| `LISTEN_ADDR` | default `:8080` |
|
| `LISTEN_ADDR` | default `:8080` |
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
@@ -41,6 +45,7 @@ with (403 otherwise); the shared token works for any.
|
|||||||
- `PUT /v1/charging-sessions/{sessionId}` — a finished charging session (AC or DC, as far as the car was awake) with its curve.
|
- `PUT /v1/charging-sessions/{sessionId}` — a finished charging session (AC or DC, as far as the car was awake) with its curve.
|
||||||
- `GET /v1/consumption/history/{installationId}` — the car reads its own totals back.
|
- `GET /v1/consumption/history/{installationId}` — the car reads its own totals back.
|
||||||
- `GET /v1/health` — `{"status":"ok"}`.
|
- `GET /v1/health` — `{"status":"ok"}`.
|
||||||
|
- `PUT /v1/logs/{name}` — a file from the car's Logs tab (below).
|
||||||
|
|
||||||
A malformed request is a 400 naming the field; a database failure is a 500 without detail.
|
A malformed request is a 400 naming the field; a database failure is a 500 without detail.
|
||||||
|
|
||||||
@@ -57,6 +62,46 @@ shared ingest token): `update check` (how many releases, the newest pre-release)
|
|||||||
download started` (file, size), then `update handed out` (bytes sent, time taken) or `update
|
download started` (file, size), then `update handed out` (bytes sent, time taken) or `update
|
||||||
download cut short`. The forge's address and token never appear in the log.
|
download cut short`. The forge's address and token never appear in the log.
|
||||||
|
|
||||||
|
## Map roads
|
||||||
|
|
||||||
|
`POST /v1/map/roads` `{z, x, y}`, with the car's token: the drivable roads (motorway to minor
|
||||||
|
streets) of one web-mercator tile at zoom 14, for the app's "Grid with map" style. The car names
|
||||||
|
the tile, not its position, and in the body, so it stays out of access logs. The answer is
|
||||||
|
`{z, x, y, extent: 4096, ways: [{c, p: [x, y, …]}]}`: per road its class (0 motorway and trunk,
|
||||||
|
1 primary and secondary, 2 tertiary, 3 minor streets) and its points in tile units, simplified to
|
||||||
|
about 1.5 m; gzipped when the car accepts it (10–15 KB a tile in a city before that).
|
||||||
|
|
||||||
|
The roads are fetched **only for the tiles a car asks for**: the OpenStreetMap vector tile
|
||||||
|
(OpenMapTiles schema) of that square from the TileJSON at `MAP_TILES_URL` (by default
|
||||||
|
OpenFreeMap: free, no key, no request limits; its template moves with each weekly build and is
|
||||||
|
looked up every 6 hours), decoded by a small reader of our own (`mvt.go`, the `transportation`
|
||||||
|
layer only). Each tile is fetched once and kept for 30 days in `MAP_CACHE_DIR` (and the last 300
|
||||||
|
in memory); at most two fetches run at once. A request waits up to 6 s; a slower fetch goes on
|
||||||
|
and is kept, and the car hears 503 with `Retry-After: 10` and gets the tile on its next try.
|
||||||
|
400 for anything but zoom 14 or a tile outside it, 503 when the tile host fails (not cached),
|
||||||
|
404 when maps are off. The log says `map tile fetched` with the size, never the tile.
|
||||||
|
Map data © OpenStreetMap contributors (ODbL), tiles © OpenMapTiles, served by OpenFreeMap.
|
||||||
|
|
||||||
|
## Logs from the car
|
||||||
|
|
||||||
|
So a signal recording reaches the owner without a USB stick. The car sends each one when it ends
|
||||||
|
(again later when it is offline) with its own token: `PUT /v1/logs/{name}`, plain UTF-8 text,
|
||||||
|
gzipped (`Content-Encoding: gzip`) or not; at most 2 MB as sent and 8 MB unpacked. The name is
|
||||||
|
the app's (`signal-recording-20261027-085240.txt`: lower-case letters, digits and dashes,
|
||||||
|
`.txt`); the same name again replaces the file, so a retry is harmless. 204 when stored. The
|
||||||
|
newest 200 files are kept, none older than 180 days.
|
||||||
|
|
||||||
|
Reading takes `LOGS_TOKEN`, which can do nothing else, and the car's token cannot read:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Authorization: Bearer $LOGS_TOKEN" https://car.k8s.stinnesbeck.com/v1/logs # {logs: [{name, bytes, received}]}, newest first
|
||||||
|
curl -H "Authorization: Bearer $LOGS_TOKEN" https://car.k8s.stinnesbeck.com/v1/logs/<name> # the text
|
||||||
|
```
|
||||||
|
|
||||||
|
`fetch-logs` in the dev shell fetches the new ones into `logs/` (token from
|
||||||
|
`~/.config/nilsmg4/logs-token`). 404 when reading is off. The log says `log received` with the
|
||||||
|
name and size, never the contents.
|
||||||
|
|
||||||
## Settings from a phone
|
## Settings from a phone
|
||||||
|
|
||||||
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
|
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ 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)
|
||||||
|
// /v1/logs/… files from the Logs tab, read with LOGS_TOKEN (logs.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 +33,10 @@ 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
|
||||||
|
// Logs keeps the files the car sends from its Logs tab (logs.go); nil is off.
|
||||||
|
Logs *Logs
|
||||||
// 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 +56,8 @@ 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)
|
||||||
|
a.logRoutes(mux)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Logs keeps the text files the car sends from its Logs tab (signal recordings) on a volume,
|
||||||
|
// so they reach the owner without a USB stick. The car sends with its ingest token; reading
|
||||||
|
// needs LOGS_TOKEN, a token of its own, so neither the car's token can read them nor this one
|
||||||
|
// write anything.
|
||||||
|
//
|
||||||
|
// PUT /v1/logs/{name} one file from the car (ingest token); the same name again replaces it
|
||||||
|
// GET /v1/logs the files, newest first (LOGS_TOKEN)
|
||||||
|
// GET /v1/logs/{name} one file (LOGS_TOKEN)
|
||||||
|
//
|
||||||
|
// The newest maxLogFiles files younger than maxLogAge are kept. A file's contents never go to
|
||||||
|
// the server log, only its name and size.
|
||||||
|
type Logs struct {
|
||||||
|
Dir string
|
||||||
|
// ReadToken is LOGS_TOKEN; empty switches reading off (the car can still send).
|
||||||
|
ReadToken string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxLogWireBytes = 2 << 20 // as sent, gzip or not
|
||||||
|
maxLogBytes = 8 << 20 // as stored
|
||||||
|
maxLogFiles = 200
|
||||||
|
maxLogAge = 180 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// logName is what a file may be called: the app's own names, e.g. signal-recording-20261027-085240.txt.
|
||||||
|
var logName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,79}\.txt$`)
|
||||||
|
|
||||||
|
// NewLogs keeps the files in dir, which must exist.
|
||||||
|
func NewLogs(dir, readToken string) *Logs {
|
||||||
|
return &Logs{Dir: dir, ReadToken: readToken, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) logRoutes(mux *http.ServeMux) {
|
||||||
|
if a.Logs == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux.HandleFunc("PUT /v1/logs/{name}", a.putLog)
|
||||||
|
mux.HandleFunc("GET /v1/logs", a.Logs.list)
|
||||||
|
mux.HandleFunc("GET /v1/logs/{name}", a.Logs.get)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) putLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := a.authenticate(r); !ok {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if !logName.MatchString(name) {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be lower-case letters, digits and dashes, ending in .txt"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text, status, msg := readLogBody(r)
|
||||||
|
if status != 0 {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Logs.save(name, text); err != nil {
|
||||||
|
slog.Error("log not stored", "name", name, "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("log received", "name", name, "bytes", len(text))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLogBody is the request's text, un-gzipped when it says so; a status and message when it
|
||||||
|
// is too large, not gzip after all, or not UTF-8 text.
|
||||||
|
func readLogBody(r *http.Request) ([]byte, int, string) {
|
||||||
|
wire, err := io.ReadAll(io.LimitReader(r.Body, maxLogWireBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, http.StatusBadRequest, "body could not be read"
|
||||||
|
}
|
||||||
|
if len(wire) > maxLogWireBytes {
|
||||||
|
return nil, http.StatusRequestEntityTooLarge, "body is too large"
|
||||||
|
}
|
||||||
|
text := wire
|
||||||
|
switch strings.ToLower(r.Header.Get("Content-Encoding")) {
|
||||||
|
case "", "identity":
|
||||||
|
case "gzip":
|
||||||
|
zr, err := gzip.NewReader(bytes.NewReader(wire))
|
||||||
|
if err != nil {
|
||||||
|
return nil, http.StatusBadRequest, "body is not gzip"
|
||||||
|
}
|
||||||
|
text, err = io.ReadAll(io.LimitReader(zr, maxLogBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, http.StatusBadRequest, "body is not gzip"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, http.StatusUnsupportedMediaType, "only gzip or no encoding"
|
||||||
|
}
|
||||||
|
if len(text) > maxLogBytes {
|
||||||
|
return nil, http.StatusRequestEntityTooLarge, "body is too large"
|
||||||
|
}
|
||||||
|
if !utf8.Valid(text) {
|
||||||
|
return nil, http.StatusBadRequest, "body is not UTF-8 text"
|
||||||
|
}
|
||||||
|
return text, 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// save writes the file whole (a temporary file renamed into place), then prunes.
|
||||||
|
func (l *Logs) save(name string, text []byte) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
tmp, err := os.CreateTemp(l.Dir, ".incoming-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name()) // gone after the rename; cleans up after a failure
|
||||||
|
if _, err := tmp.Write(text); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp.Name(), filepath.Join(l.Dir, name)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
l.prune()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type logEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Received time.Time `json:"received"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entries are the stored files, newest first.
|
||||||
|
func (l *Logs) entries() ([]logEntry, error) {
|
||||||
|
dir, err := os.ReadDir(l.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []logEntry
|
||||||
|
for _, e := range dir {
|
||||||
|
if !e.Type().IsRegular() || !logName.MatchString(e.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, logEntry{Name: e.Name(), Bytes: info.Size(), Received: info.ModTime().UTC()})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if !out[i].Received.Equal(out[j].Received) {
|
||||||
|
return out[i].Received.After(out[j].Received)
|
||||||
|
}
|
||||||
|
return out[i].Name > out[j].Name
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prune removes all but the newest maxLogFiles and anything older than maxLogAge.
|
||||||
|
func (l *Logs) prune() {
|
||||||
|
entries, err := l.entries()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cutoff := l.now().Add(-maxLogAge)
|
||||||
|
for i, e := range entries {
|
||||||
|
if i >= maxLogFiles || e.Received.Before(cutoff) {
|
||||||
|
if err := os.Remove(filepath.Join(l.Dir, e.Name)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
slog.Warn("old log not removed", "name", e.Name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mayRead accepts LOGS_TOKEN only, compared in constant time; main refuses a short one.
|
||||||
|
func (l *Logs) mayRead(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if l.ReadToken == "" {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "reading logs is off (no LOGS_TOKEN)"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
supplied, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !ok || subtle.ConstantTimeCompare([]byte(supplied), []byte(l.ReadToken)) != 1 {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logs) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !l.mayRead(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
entries, err := l.entries()
|
||||||
|
l.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("logs not listed", "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"logs": append([]logEntry{}, entries...)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logs) get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !l.mayRead(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if !logName.MatchString(name) {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such log"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
text, err := os.ReadFile(filepath.Join(l.Dir, name))
|
||||||
|
l.mu.Unlock()
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such log"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("log not read", "name", name, "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testLogsToken = "logs-read-token-0123456789abcdef"
|
||||||
|
|
||||||
|
func newLogsAPI(t *testing.T) (*API, string) {
|
||||||
|
t.Helper()
|
||||||
|
api, _ := newTestAPI()
|
||||||
|
dir := t.TempDir()
|
||||||
|
api.Logs = NewLogs(dir, testLogsToken)
|
||||||
|
return api, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func gzipped(t *testing.T, text string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var b bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&b)
|
||||||
|
if _, err := zw.Write([]byte(text)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func putLog(t *testing.T, h http.Handler, name, token string, body []byte, encoding string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest("PUT", "/v1/logs/"+name, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
if encoding != "" {
|
||||||
|
req.Header.Set("Content-Encoding", encoding)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCarSendsALogAndTheOwnerReadsIt(t *testing.T) {
|
||||||
|
api, _ := newLogsAPI(t)
|
||||||
|
h := api.Routes()
|
||||||
|
const name = "signal-recording-20261027-085240.txt"
|
||||||
|
text := "Signal recording, 37 captures\nlistening to 108 assistance properties\n"
|
||||||
|
|
||||||
|
if rec := putLog(t, h, name, testToken, gzipped(t, text), "gzip"); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("put: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := do(t, h, "GET", "/v1/logs", testLogsToken, "")
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("list: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var list struct {
|
||||||
|
Logs []logEntry `json:"logs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list.Logs) != 1 || list.Logs[0].Name != name ||
|
||||||
|
list.Logs[0].Bytes != int64(len(text)) {
|
||||||
|
t.Fatalf("list: %v %s", err, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = do(t, h, "GET", "/v1/logs/"+name, testLogsToken, "")
|
||||||
|
if rec.Code != 200 || rec.Body.String() != text || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
|
||||||
|
t.Fatalf("get: %d %q", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent again (a retry), it replaces itself.
|
||||||
|
if rec := putLog(t, h, name, testToken, []byte("again\n"), ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("put again: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do(t, h, "GET", "/v1/logs/"+name, testLogsToken, ""); rec.Body.String() != "again\n" {
|
||||||
|
t.Fatalf("replaced: %q", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogTokensDoNotCross(t *testing.T) {
|
||||||
|
api, _ := newLogsAPI(t)
|
||||||
|
h := api.Routes()
|
||||||
|
name := "signal-recording-1.txt"
|
||||||
|
if rec := putLog(t, h, name, testLogsToken, []byte("x"), ""); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("the read token wrote: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := putLog(t, h, name, testToken, []byte("x"), ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("put: %d", rec.Code)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/v1/logs", "/v1/logs/" + name} {
|
||||||
|
for _, token := range []string{"", testToken, testLogsToken + "x"} {
|
||||||
|
if rec := do(t, h, "GET", path, token, ""); rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("GET %s with %q: %d", path, token, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadingIsOffWithoutALogsToken(t *testing.T) {
|
||||||
|
api, _ := newLogsAPI(t)
|
||||||
|
api.Logs.ReadToken = ""
|
||||||
|
h := api.Routes()
|
||||||
|
if rec := putLog(t, h, "a.txt", testToken, []byte("x"), ""); rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("put: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do(t, h, "GET", "/v1/logs", "", ""); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("list: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadLogsAreRefused(t *testing.T) {
|
||||||
|
api, dir := newLogsAPI(t)
|
||||||
|
h := api.Routes()
|
||||||
|
for _, c := range []struct {
|
||||||
|
name, encoding string
|
||||||
|
body []byte
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"..%2Fescape.txt", "", []byte("x"), http.StatusBadRequest},
|
||||||
|
{"Upper.txt", "", []byte("x"), http.StatusBadRequest},
|
||||||
|
{"no-suffix", "", []byte("x"), http.StatusBadRequest},
|
||||||
|
{".hidden.txt", "", []byte("x"), http.StatusBadRequest},
|
||||||
|
{"a.txt", "gzip", []byte("not gzip"), http.StatusBadRequest},
|
||||||
|
{"a.txt", "br", []byte("x"), http.StatusUnsupportedMediaType},
|
||||||
|
{"a.txt", "", []byte{0xff, 0xfe, 0x00}, http.StatusBadRequest},
|
||||||
|
{"a.txt", "", make([]byte, maxLogWireBytes+1), http.StatusRequestEntityTooLarge},
|
||||||
|
{"a.txt", "gzip", gzipped(t, strings.Repeat("a", maxLogBytes+1)), http.StatusRequestEntityTooLarge},
|
||||||
|
} {
|
||||||
|
if rec := putLog(t, h, c.name, testToken, c.body, c.encoding); rec.Code != c.want {
|
||||||
|
t.Errorf("%s %s: %d, want %d (%s)", c.name, c.encoding, rec.Code, c.want, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files, _ := os.ReadDir(dir); len(files) != 0 {
|
||||||
|
t.Fatalf("stored: %v", files)
|
||||||
|
}
|
||||||
|
if rec := do(t, h, "GET", "/v1/logs/missing.txt", testLogsToken, ""); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("missing: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOldAndSurplusLogsArePruned(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
l := NewLogs(dir, testLogsToken)
|
||||||
|
// The new file gets the real time as its own, so the others are placed before it.
|
||||||
|
now := time.Now()
|
||||||
|
old := filepath.Join(dir, "old.txt")
|
||||||
|
if err := os.WriteFile(old, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chtimes(old, now.Add(-maxLogAge-time.Hour), now.Add(-maxLogAge-time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < maxLogFiles+5; i++ {
|
||||||
|
p := filepath.Join(dir, fmt.Sprintf("f-%03d.txt", i))
|
||||||
|
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
at := now.Add(-time.Duration(maxLogFiles+5-i) * time.Minute)
|
||||||
|
if err := os.Chtimes(p, at, at); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := l.save("new.txt", []byte("y")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries, _ := l.entries()
|
||||||
|
if len(entries) != maxLogFiles || entries[0].Name != "new.txt" {
|
||||||
|
t.Fatalf("%d kept, first %s", len(entries), entries[0].Name)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(old); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("the old one is still there")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "f-000.txt")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("the oldest surplus one is still there")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogsConfig(t *testing.T) {
|
||||||
|
base := map[string]string{"INFLUX_URL": "http://i", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o", "INGEST_TOKEN": testToken}
|
||||||
|
env := func(extra map[string]string) func(string) string {
|
||||||
|
return func(k string) string {
|
||||||
|
if v, ok := extra[k]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return base[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
extra map[string]string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{map[string]string{}, true},
|
||||||
|
{map[string]string{"LOGS_DIR": "/l"}, true},
|
||||||
|
{map[string]string{"LOGS_DIR": "/l", "LOGS_TOKEN": testLogsToken}, true},
|
||||||
|
{map[string]string{"LOGS_TOKEN": testLogsToken}, false},
|
||||||
|
{map[string]string{"LOGS_DIR": "/l", "LOGS_TOKEN": "short"}, false},
|
||||||
|
{map[string]string{"LOGS_DIR": "/l", "LOGS_TOKEN": testToken}, false},
|
||||||
|
} {
|
||||||
|
if _, err := loadConfig(env(c.extra)); (err == nil) != c.ok {
|
||||||
|
t.Errorf("%v: %v", c.extra, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-1
@@ -34,6 +34,10 @@ type config struct {
|
|||||||
forgeURL string
|
forgeURL string
|
||||||
forgeRepo string
|
forgeRepo string
|
||||||
forgeToken string
|
forgeToken string
|
||||||
|
mapTilesURL string
|
||||||
|
mapCacheDir string
|
||||||
|
logsDir string
|
||||||
|
logsToken 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 +55,10 @@ 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"),
|
||||||
|
logsDir: getenv("LOGS_DIR"),
|
||||||
|
logsToken: getenv("LOGS_TOKEN"),
|
||||||
}
|
}
|
||||||
if c.listenAddr == "" {
|
if c.listenAddr == "" {
|
||||||
c.listenAddr = ":8080"
|
c.listenAddr = ":8080"
|
||||||
@@ -73,6 +81,29 @@ 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\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Logs from the car are optional (LOGS_DIR); reading them needs a token of their own, as
|
||||||
|
// long as the ingest token, and never the ingest token itself.
|
||||||
|
if c.logsToken != "" && c.logsDir == "" {
|
||||||
|
return config{}, errors.New("LOGS_TOKEN is set but LOGS_DIR is not")
|
||||||
|
}
|
||||||
|
if c.logsToken != "" && len(c.logsToken) < minIngestTokenLength {
|
||||||
|
return config{}, fmt.Errorf("LOGS_TOKEN must be at least %d characters", minIngestTokenLength)
|
||||||
|
}
|
||||||
|
if c.logsToken != "" && c.logsToken == c.ingestToken {
|
||||||
|
return config{}, errors.New("LOGS_TOKEN must differ from INGEST_TOKEN")
|
||||||
|
}
|
||||||
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 +133,22 @@ 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)
|
||||||
|
}
|
||||||
|
if cfg.logsDir != "" {
|
||||||
|
if err := os.MkdirAll(cfg.logsDir, 0o700); err != nil {
|
||||||
|
slog.Error("logs directory not usable", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
api.Logs = NewLogs(cfg.logsDir, cfg.logsToken)
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.listenAddr,
|
Addr: cfg.listenAddr,
|
||||||
@@ -116,7 +163,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, "logs", api.Logs != nil, "logsReadable", api.Logs != nil && cfg.logsToken != "")
|
||||||
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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user