Compare commits
19
Commits
car-2.40.3
...
car-2.43.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744d52f322 | ||
|
|
0fe7897340 | ||
|
|
f84e18ef0e | ||
|
|
024e40cd01 | ||
|
|
ee7cf85255 | ||
|
|
1686b791bc | ||
|
|
12752c41e2 | ||
|
|
293464e297 | ||
|
|
adc2c86303 | ||
|
|
1f49cdc410 | ||
|
|
9de9387460 | ||
|
|
f9154c051f | ||
|
|
6563b2c4e8 | ||
|
|
8596951cef | ||
|
|
33e8fe6e05 | ||
|
|
eebe840b6a | ||
|
|
16e6f349da | ||
|
|
60ab3118a1 | ||
|
|
097467f4e0 |
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
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 with state
|
||||||
|
and song (TITLE, ARTIST, ALBUM, ALBUM_ART as a size or ALBUM_ART_URI), and the media browser
|
||||||
|
services by name (2.42.1 on; 2.42.0 also connected to them, which stopped the music). CarPlay's
|
||||||
|
session is `com.allgo.remoteui.mediabrowserservice`, Bluetooth's `com.android.bluetooth`. Ask what was playing
|
||||||
|
when the check ran.
|
||||||
|
|
||||||
|
## Sound checks
|
||||||
|
|
||||||
|
`fetch-logs` also brings `sound-probe-YYYYMMDD-HHMMSS.txt` (Logs tab, "Check sound",
|
||||||
|
`SoundProbe`, 2.43.3 on): permissions, which session plays, then one line a second of the audio
|
||||||
|
analyser's peak, bass, mid, treble and rms, and a result line. Levels only, no sound. The peak has
|
||||||
|
to move for music to count as heard. Ask what was playing when the check ran, and whether the
|
||||||
|
music kept playing and any prompt came up.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Recordings and checks are the car's data: `logs/`, `/signal-recording-*.txt` and
|
||||||
|
`/media-probe-*.txt`, `/sound-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,9 @@ 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
|
||||||
|
/sound-probe-*.txt
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ MG4 head unit (Android 9, AAOS, MT2712)
|
|||||||
- **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, and serves the roads for the Grid's map from
|
app releases from the private forge, and serves the roads for the Grid's map from
|
||||||
OpenStreetMap vector tiles (OpenFreeMap), fetched per tile on demand and cached on a volume. It does **not** talk to ABRP. See `server/README.md`.
|
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`).
|
||||||
@@ -76,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`), 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) |
|
| 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`), Día de Muertos (`MuertosView`, an ofrenda: papel picado, candles, a calavera in a ring of marigolds), Halloween (`HalloweenView`, a jack-o'-lantern, a potion flask, a tombstone on a graveyard hill), both counting with `Tally`; the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire, Día de Muertos, Halloween; 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 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 |
|
| 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 change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
|
| 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 and the active media sessions, CarPlay's and Bluetooth's among them, with their song, and the media browser services by name — never connected to: binding them stopped the music in 2.42.0; saved and sent like a recording), sound check (`SoundProbe`, `SoundLevels`: on a tap, 10 seconds of levels from Android's audio analyser, to learn whether it hears what plays; 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
|
||||||
@@ -110,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 ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,10 @@ object EVHardware {
|
|||||||
// Listening to properties (the app's signal recorder) — read only
|
// 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. */
|
/** A subscription made by [listenToProperties]; [close] ends it. */
|
||||||
class PropertyListening internal constructor(
|
class PropertyListening internal constructor(
|
||||||
/** Properties subscribed to, and those the car service refused. */
|
/** Properties subscribed to, and those the car service refused. */
|
||||||
@@ -342,19 +346,44 @@ object EVHardware {
|
|||||||
* may fill a property from the car network only while someone listens, so a poll alone can
|
* 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
|
* 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
|
* 1 Hz within their limits. Read only: nothing is set. Null when there is no
|
||||||
* CarPropertyManager or no way to subscribe; [PropertyListening.close] unsubscribes.
|
* 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? {
|
fun listenToProperties(admit: (Int) -> Boolean, onEvent: (Int, Int, String) -> Unit): PropertyListening? {
|
||||||
val cpm = sCarPropertyManager ?: return null
|
listenProblem = null
|
||||||
|
val cpm = sCarPropertyManager ?: run {
|
||||||
|
listenProblem = "no car service"
|
||||||
|
return null
|
||||||
|
}
|
||||||
return try {
|
return try {
|
||||||
val register = cpm.javaClass.methods.firstOrNull {
|
val methods = cpm.javaClass.methods
|
||||||
it.name == "registerCallback" && it.parameterCount == 3
|
val register = methods.firstOrNull {
|
||||||
} ?: return null
|
(it.name == "registerListener" || it.name == "registerCallback") && it.parameterCount == 3 &&
|
||||||
val unregister = cpm.javaClass.methods.firstOrNull {
|
it.parameterTypes[1] == Int::class.javaPrimitiveType &&
|
||||||
it.name == "unregisterCallback" && it.parameterCount == 1
|
it.parameterTypes[2] == Float::class.javaPrimitiveType
|
||||||
|
} ?: run {
|
||||||
|
listenProblem = "no way to subscribe"
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
val callbackType = register.parameterTypes[0]
|
val callbackType = register.parameterTypes[0]
|
||||||
if (!callbackType.isInterface) return null
|
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 ->
|
val proxy = Proxy.newProxyInstance(callbackType.classLoader, arrayOf(callbackType)) { self, method, args ->
|
||||||
when (method.name) {
|
when (method.name) {
|
||||||
"onChangeEvent" -> {
|
"onChangeEvent" -> {
|
||||||
@@ -385,7 +414,7 @@ object EVHardware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null
|
val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null
|
||||||
var subscribed = 0
|
val subscribedIds = ArrayList<Int>()
|
||||||
var refused = 0
|
var refused = 0
|
||||||
for (config in configs) {
|
for (config in configs) {
|
||||||
if (config == null) continue
|
if (config == null) continue
|
||||||
@@ -399,21 +428,31 @@ object EVHardware {
|
|||||||
1f.coerceIn(min, maxOf(min, max))
|
1f.coerceIn(min, maxOf(min, max))
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (register.invoke(cpm, proxy, id, rate) == false) refused++ else subscribed++
|
if (register.invoke(cpm, proxy, id, rate) == false) refused++ else subscribedIds.add(id)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
refused++
|
refused++
|
||||||
AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason()))
|
AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppLogger.i(TAG, "listening to $subscribed properties ($refused refused)")
|
AppLogger.i(TAG, "listening to ${subscribedIds.size} properties ($refused refused) via ${register.name}")
|
||||||
PropertyListening(subscribed, refused) {
|
PropertyListening(subscribedIds.size, refused) {
|
||||||
try {
|
for (id in subscribedIds) {
|
||||||
unregister?.invoke(cpm, proxy)
|
try {
|
||||||
} catch (e: Exception) {
|
if (unregister != null) unregister.invoke(cpm, proxy, id)
|
||||||
AppLogger.d(TAG, "listen: unsubscribe failed: ${e.readFailureReason()}")
|
} 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) {
|
} catch (e: Exception) {
|
||||||
|
listenProblem = e.readFailureReason()
|
||||||
AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}")
|
AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ APIs.
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
**Overview styles:** the Overview comes in eighteen looks, from an 80s fluorescent display to a
|
**Overview styles:** the Overview comes in twenty looks, from an 80s fluorescent display to a
|
||||||
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
|
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.
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo
|
|||||||
| **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, listens to the driver-assistance values for every change they report, and lists what changed, to find where the car publishes something; saved to a USB stick too), update log (every install step), upload log |
|
| **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
|
||||||
|
|||||||
@@ -140,6 +140,110 @@ 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-31 — Music checks from the car: the radio publishes a session while it plays (`com.saicmotor.radio`, 29 October, DAB: TITLE the station, ARTIST the programme text, AUTHOR the band), with `isMusicActive` false; whether the radio could be heard then is not known (the owner did not have it playing in the check they looked at), so this says nothing yet about whether the audio analyser hears it. To be tested with the radio audibly playing. CarPlay playing (31 October, 2.43.2) has `isMusicActive` true, like Bluetooth on 28 October. The sound check itself has not run on the car yet: 2.43.3 was not published and those reports came from 2.43.2's music-sources check.
|
||||||
|
- 2026-10-31 — App 2.43.3: **sound check** on the Logs tab (`SoundProbe`, `SoundLevels`), asked for before the Halloween pumpkin's light follows the music (owner: "make the light in the pumpkin react to the equalizer/sound of the music"). One tap: 10 seconds of levels from `Visualizer` on session 0 (peak, bass, mid, treble, rms), saved and sent like a recording; no sound kept, no microphone. New in the car build's manifest: RECORD_AUDIO and MODIFY_AUDIO_SETTINGS. Grounds for trying: Bluetooth played with `isMusicActive` true (28 October check), `libCarPlayPlatformAudio.so` uses AudioTrack and no direct PCM. Whether the analyser hears anything on the car is what this is to find out. Tested: unit tests, lint, emulator (silence says nothing was playing; a pulsing 110 Hz + 1 kHz test tone from another app is heard, peak 0.27–0.91, bass strongest, the tone kept playing, no prompt, no crash). Not tried on the car.
|
||||||
|
- 2026-10-31 — App 2.43.2 published (`car-2.43.2`).
|
||||||
|
- 2026-10-31 — App 2.43.2: Halloween's teeth are triangles hanging from the grin's upper lip, each in the colour of the rib behind it (the right one was the darker orange on the lighter rib; owner: "the color on the right side is off"). Two screenshots redone. Tested: unit tests, lint, emulator with sample values (drawing in panel and full screen, regenerating in full screen) and with no data. Not tried on the car.
|
||||||
|
- 2026-10-31 — App 2.43.1 published (`car-2.43.1`).
|
||||||
|
- 2026-10-31 — App 2.43.1: Halloween's bats can be seen. They were near-black and small on the darkest part of the sky, and the owner did not spot them; now a moonlit purple-grey with a light edge, a third larger. Two screenshots redone. Tested: unit tests, lint, emulator with sample values (panel and full screen) and with no data (no bats). Not tried on the car.
|
||||||
|
- 2026-10-31 — App 2.43.0 published (`car-2.43.0`).
|
||||||
|
- 2026-10-31 — App 2.43.0: Overview styles **Día de Muertos** (`MuertosView`) and **Halloween**
|
||||||
|
(`HalloweenView`; owner: "let's design a 'dia de muertos' and a 'halloween' style"), each with
|
||||||
|
its own palette (the papel picado's pink, the pumpkin's orange). Día de Muertos, an ofrenda and
|
||||||
|
kept a celebration: papel picado = battery (one flag per 10 %), calavera with the speed on its
|
||||||
|
forehead, a ring of marigolds = power (turquoise and anticlockwise regenerating), candles =
|
||||||
|
range (one per 50 km), a path of petals = the last two minutes of power. Halloween: a
|
||||||
|
jack-o'-lantern with the speed in its grin, burning yellow or, regenerating, green; an arch of
|
||||||
|
embers = power; a potion flask = battery; a tombstone and bats = range; the hill = the last two
|
||||||
|
minutes of power. What a value lights is `Tally` (pure, unit-tested: an unread value lights
|
||||||
|
nothing). The static scene is drawn once per size into a bitmap (about 4.5 MB while the view is
|
||||||
|
attached), so a frame only draws the readings; flags, flames, bats, bubbles and the ghost move
|
||||||
|
only while the car moves. Tests and lint pass. Emulator with sample values, panel and full
|
||||||
|
screen: normal, 12 % with regeneration, 40 % (the flask's words over the potion's surface); real
|
||||||
|
build with no data: "—" and nothing lit. No crash of the app. Not tried on the car, so the frame
|
||||||
|
rate on the head unit is unmeasured.
|
||||||
|
- 2026-10-28 — App 2.42.1 published (`car-2.42.1`).
|
||||||
|
- 2026-10-28 — App 2.42.1: **the music-sources check no longer connects to other apps' media
|
||||||
|
services.** In the car, 2.42.0's check (14:26, Bluetooth music playing) made the head unit ask
|
||||||
|
whether to allow access to media (the owner agreed) and **stopped the Bluetooth music**: it
|
||||||
|
connected to every media browser service, and among them the AOSP car radio's
|
||||||
|
(`com.android.car.radio/.RadioService`, which answered "connecting", most likely taking the
|
||||||
|
audio) and the Local Media Player (`com.android.car.media.localmediaplayer`, whose permission
|
||||||
|
request is most likely the question; it may now read media files). My claim that the check only
|
||||||
|
read was wrong: binding another app's service starts it. Now the services are only listed.
|
||||||
|
Nothing lost: the active sessions alone carried everything — **Bluetooth**
|
||||||
|
(`com.android.bluetooth`: title, artist, album, duration, track, cover as a file path, playing)
|
||||||
|
and **CarPlay** (`com.allgo.remoteui.mediabrowserservice`: title, artist, duration, paused) —
|
||||||
|
readable with MEDIA_CONTENT_CONTROL, no connection needed. Services on the car: CarPlay's,
|
||||||
|
Bluetooth A2DP, Local Media Player, the AOSP car radio, SAIC's radio (DAB, "Brillux Radio"),
|
||||||
|
SAIC's USB 1/2 and online media, and SAIC's video browser. Tests and lint pass; not run on the
|
||||||
|
emulator (stopped for low memory; the change only removes code); not tried on the car.
|
||||||
|
- 2026-10-28 — App 2.42.0 published (`car-2.42.0`).
|
||||||
|
- 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
|
- 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.
|
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
|
The head unit's `CarAdvancedAssistedDrivingManager` never polls its 108 `AAD_*` properties: it
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Overview styles
|
# Overview styles
|
||||||
|
|
||||||
The Overview can look eighteen ways: pick one under **Settings → Overview style**. Every style shows
|
The Overview can look twenty 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.
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ 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 (with or without the map) 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, Empire, Día de Muertos and Halloween always do; while one of those is shown, the whole app
|
||||||
(navigation, buttons, switches) takes on its colours too.
|
(navigation, buttons, switches) takes on its colours too.
|
||||||
|
|
||||||
| Style | Look | Colours |
|
| Style | Look | Colours |
|
||||||
@@ -34,6 +34,8 @@ colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire alway
|
|||||||
| [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 |
|
||||||
| [Empire](#empire) | a strategy game's interface | own |
|
| [Empire](#empire) | a strategy game's interface | own |
|
||||||
|
| [Día de Muertos](#día-de-muertos) | an ofrenda with papel picado and a calavera | own |
|
||||||
|
| [Halloween](#halloween) | a jack-o'-lantern on a graveyard hill | own |
|
||||||
|
|
||||||
## Tiles
|
## Tiles
|
||||||
|
|
||||||
@@ -233,3 +235,34 @@ stats, and a diamond minimap with the odometer and the way to the next age. Belo
|
|||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
## Día de Muertos
|
||||||
|
|
||||||
|
After the ofrenda, the altar families in Mexico build to welcome their dead back for a visit: a
|
||||||
|
celebration, so bright and warm rather than frightening. The papel picado across the top is the
|
||||||
|
battery, one cut-paper flag per 10 %, those not lit hanging dark, turning red with "BATTERY LOW"
|
||||||
|
below 20 %. The calavera in the middle, a decorated sugar skull with marigolds (cempasúchil) for
|
||||||
|
eyes, has the speed on its forehead; the ring of marigolds round it is the power, clockwise while
|
||||||
|
drawing, turquoise and anticlockwise while regenerating. The altar's candles are the range, one
|
||||||
|
per 50 km. The path of marigold petals along the bottom, which in the tradition leads the visitors
|
||||||
|
home, is the last two minutes of power. On the right the clock in a tin frame, P R N D as small
|
||||||
|
marigolds, and trip, usage, climate and odometer on the altar cloth. While you drive the flags
|
||||||
|
sway and the flames flicker. The app turns the papel picado's pink with it.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Halloween
|
||||||
|
|
||||||
|
A graveyard hill under a full moon. The jack-o'-lantern has the speed cut into its grin, and its
|
||||||
|
carving burns candle yellow while drawing and witch-fire green while regenerating; the arch of
|
||||||
|
embers over it is the power, from the left while drawing and from the right while regenerating.
|
||||||
|
The potion flask is the battery, red with "BATTERY LOW" blinking below 20 %. The tombstone reads
|
||||||
|
the range ("here lies … km"), and the bats are the range too, one per 50 km; P R N D are small
|
||||||
|
gravestones, the one engaged lit. The moon holds the clock, a sign hanging from a cobweb has trip,
|
||||||
|
usage, climate and odometer, and the hill along the bottom is the last two minutes of power. While
|
||||||
|
you drive the bats fly, the potion bubbles and the ghost floats (where there is room for it, as on
|
||||||
|
this screen). The app turns the pumpkin's orange with it.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 148
|
versionCode 156
|
||||||
versionName "2.40.3"
|
versionName "2.43.3"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||||
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.LinearGradient;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Path;
|
||||||
|
import android.graphics.RadialGradient;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.graphics.Shader;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Halloween Overview style: a graveyard hill under a full moon.
|
||||||
|
*
|
||||||
|
* In the middle a jack-o'-lantern with the speed cut into its grin; its carving burns candle yellow
|
||||||
|
* while drawing and witch-fire green while regenerating, and the arch of embers over it is the
|
||||||
|
* power, left to right while drawing, green and right to left while regenerating. On the left a
|
||||||
|
* potion flask: the battery, red with "BATTERY LOW" blinking below 20 %, under a sign hanging from
|
||||||
|
* a cobweb with trip, usage, climate and odometer. On the right the moon holds the clock, and a
|
||||||
|
* tombstone reads the range ("here lies … km"); P R N D are four small gravestones, the one engaged
|
||||||
|
* lit. The bats are the range too, one per 50 km. The hill along the bottom is the last two minutes
|
||||||
|
* of power, rising with it, in orange or, where it regenerated, green. Unread values show "—" and
|
||||||
|
* light nothing.
|
||||||
|
*
|
||||||
|
* The night sky is drawn once into a bitmap per size; only the rest goes over it. Bats, bubbles and
|
||||||
|
* the ghost move (about 30 frames a second) only while the car moves and the view is on screen.
|
||||||
|
*/
|
||||||
|
final class HalloweenView extends ClusterCanvasView {
|
||||||
|
|
||||||
|
private static final int EMBERS = 25, BATS = 8;
|
||||||
|
private static final int SKY_TOP = 0xFF0B0620, SKY_LOW = 0xFF2A1140, HILL = 0xFF0A0612;
|
||||||
|
private static final int PUMPKIN = 0xFFF2760F, PUMPKIN_DARK = 0xFFB84A06, PUMPKIN_LIT = 0xFFFF9A2E;
|
||||||
|
private static final int CANDLE = 0xFFFFD84A, FIRE = 0xFF6BFF5A, RED = 0xFFFF3B30, STONE = 0xFF6C6A78;
|
||||||
|
private static final int STONE_DARK = 0xFF3E3C4A, MOON = 0xFFF4E9B8, MOON_SHADE = 0xFFD8C98C;
|
||||||
|
private static final int POTION = 0xFF7BF03A, GLASS = 0xFFB9D8D0, BONE = 0xFFF2EAD8, ORANGE = 0xFFFF8C1A;
|
||||||
|
private static final int STEM = 0xFF4E7A2B, WOOD = 0xFF2E1B10, INK = 0xFF140A08;
|
||||||
|
private static final int BAT = 0xFF6F6390, BAT_EDGE = 0xFFD6CCEA;
|
||||||
|
|
||||||
|
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint image = new Paint(Paint.FILTER_BITMAP_FLAG);
|
||||||
|
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Path path = new Path();
|
||||||
|
private final RectF r = new RectF();
|
||||||
|
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
|
||||||
|
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
|
||||||
|
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
|
||||||
|
|
||||||
|
private Bitmap sky;
|
||||||
|
private int skyW, skyH;
|
||||||
|
private float time;
|
||||||
|
private long lastFrame;
|
||||||
|
|
||||||
|
HalloweenView(Context context) {
|
||||||
|
super(context);
|
||||||
|
line.setStyle(Paint.Style.STROKE);
|
||||||
|
line.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
sky = null;
|
||||||
|
skyW = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas c) {
|
||||||
|
super.onDraw(c);
|
||||||
|
int w = getWidth(), h = getHeight();
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
float u = Math.min(h / 640f, w / 1100f);
|
||||||
|
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
|
||||||
|
if (moving) {
|
||||||
|
long now = SystemClock.uptimeMillis();
|
||||||
|
time += lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
|
||||||
|
lastFrame = now;
|
||||||
|
} else {
|
||||||
|
lastFrame = 0;
|
||||||
|
}
|
||||||
|
if (sky == null || skyW != w || skyH != h) buildSky(w, h, u);
|
||||||
|
c.drawBitmap(sky, 0, 0, image);
|
||||||
|
drawBats(c, w, u);
|
||||||
|
drawMoon(c, w, u);
|
||||||
|
drawGhost(c, w, u);
|
||||||
|
drawHill(c, w, h, u);
|
||||||
|
drawEmbers(c, w, u);
|
||||||
|
drawPumpkin(c, w, u);
|
||||||
|
drawSign(c, u);
|
||||||
|
drawFlask(c, u);
|
||||||
|
drawTombstone(c, w, u);
|
||||||
|
drawGears(c, w, u);
|
||||||
|
keepAnimating(moving, 33);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
|
||||||
|
text.setTypeface(tf);
|
||||||
|
text.setTextSize(size);
|
||||||
|
text.setTextAlign(a);
|
||||||
|
text.setColor(colour);
|
||||||
|
c.drawText(s, x, y, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean regen() {
|
||||||
|
return v.powerKw != null && v.powerKw < -0.05f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Sky ----------
|
||||||
|
|
||||||
|
private void buildSky(int w, int h, float u) {
|
||||||
|
sky = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
||||||
|
skyW = w;
|
||||||
|
skyH = h;
|
||||||
|
Canvas c = new Canvas(sky);
|
||||||
|
Paint p = new Paint();
|
||||||
|
p.setShader(new LinearGradient(0, 0, 0, h, new int[] { SKY_TOP, SKY_LOW, 0xFF4A1A3A },
|
||||||
|
new float[] { 0f, 0.7f, 1f }, Shader.TileMode.CLAMP));
|
||||||
|
c.drawRect(0, 0, w, h, p);
|
||||||
|
Paint star = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
star.setColor(0x80FFFFFF);
|
||||||
|
for (int i = 0; i < 90; i++) {
|
||||||
|
float x = ((i * 191 + 17) % 1009) / 1009f * w, y = ((i * 83 + 5) % 421) * u;
|
||||||
|
c.drawCircle(x, y, (i % 4 == 0 ? 1.7f : 0.9f) * u, star);
|
||||||
|
}
|
||||||
|
// Bare trees in the distance.
|
||||||
|
tree(c, w * 0.235f, 590f * u, 150f * u, u);
|
||||||
|
tree(c, w * 0.745f, 590f * u, 118f * u, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bare tree: a trunk and branches that fork twice. */
|
||||||
|
private void tree(Canvas c, float x, float y, float tall, float u) {
|
||||||
|
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
p.setStyle(Paint.Style.STROKE);
|
||||||
|
p.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
p.setColor(0xFF160B20);
|
||||||
|
branch(c, p, x, y, -90f, tall * 0.5f, 7f * u, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void branch(Canvas c, Paint p, float x, float y, float deg, float len, float width, int depth) {
|
||||||
|
double a = Math.toRadians(deg);
|
||||||
|
float nx = x + (float) Math.cos(a) * len, ny = y + (float) Math.sin(a) * len;
|
||||||
|
p.setStrokeWidth(width);
|
||||||
|
c.drawLine(x, y, nx, ny, p);
|
||||||
|
if (depth == 0) return;
|
||||||
|
branch(c, p, nx, ny, deg - 24f - depth * 3f, len * 0.68f, width * 0.65f, depth - 1);
|
||||||
|
branch(c, p, nx, ny, deg + 28f - depth * 2f, len * 0.62f, width * 0.65f, depth - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Moon and clock ----------
|
||||||
|
|
||||||
|
private void drawMoon(Canvas c, int w, float u) {
|
||||||
|
float rad = 74f * u, cx = w - 100f * u - rad * 0.2f, cy = 96f * u;
|
||||||
|
glow.setShader(new RadialGradient(cx, cy, rad * 2.2f, new int[] { 0x55F4E9B8, 0x00F4E9B8 }, null,
|
||||||
|
Shader.TileMode.CLAMP));
|
||||||
|
c.drawCircle(cx, cy, rad * 2.2f, glow);
|
||||||
|
fill.setColor(MOON);
|
||||||
|
c.drawCircle(cx, cy, rad, fill);
|
||||||
|
fill.setColor(MOON_SHADE);
|
||||||
|
c.drawCircle(cx - 42f * u, cy - 34f * u, 12f * u, fill);
|
||||||
|
c.drawCircle(cx + 40f * u, cy + 38f * u, 16f * u, fill);
|
||||||
|
c.drawCircle(cx + 46f * u, cy - 26f * u, 7f * u, fill);
|
||||||
|
c.drawCircle(cx - 30f * u, cy + 44f * u, 8f * u, fill);
|
||||||
|
label(c, v.clock.isEmpty() ? "—" : v.clock, cx, cy + 17f * u, 50f * u, INK, serif, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bats: the range ----------
|
||||||
|
|
||||||
|
private void drawBats(Canvas c, int w, float u) {
|
||||||
|
int lit = Tally.lit(v.rangeKm, 50f, BATS);
|
||||||
|
float cx = w / 2f;
|
||||||
|
for (int i = 0; i < lit; i++) {
|
||||||
|
// Each its own lane high in the sky, clear of the pumpkin's middle.
|
||||||
|
float lane = (40f + (i * 37) % 70) * u;
|
||||||
|
float span = w * 0.92f, speed = (26f + i * 5f) * u;
|
||||||
|
float x = ((i * 0.13f * span + time * speed) % span) + w * 0.04f;
|
||||||
|
float y = lane + (float) Math.sin(time * 2f + i) * 8f * u;
|
||||||
|
if (Math.abs(x - cx) < 60f * u) y -= 10f * u;
|
||||||
|
bat(c, x, y, (i % 2 == 0 ? 20f : 16f) * u, (float) Math.sin(time * 14f + i * 2f), u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bat(Canvas c, float x, float y, float s, float flap, float u) {
|
||||||
|
float tip = -s * 0.5f * flap;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(x, y - s * 0.3f);
|
||||||
|
path.quadTo(x + s * 0.6f, y - s * 0.7f + tip, x + s * 1.5f, y - s * 0.3f + tip);
|
||||||
|
path.quadTo(x + s * 1.1f, y + s * 0.05f, x + s * 0.9f, y + s * 0.35f);
|
||||||
|
path.quadTo(x + s * 0.6f, y + s * 0.05f, x + s * 0.35f, y + s * 0.4f);
|
||||||
|
path.quadTo(x + s * 0.15f, y + s * 0.15f, x, y + s * 0.5f);
|
||||||
|
path.quadTo(x - s * 0.15f, y + s * 0.15f, x - s * 0.35f, y + s * 0.4f);
|
||||||
|
path.quadTo(x - s * 0.6f, y + s * 0.05f, x - s * 0.9f, y + s * 0.35f);
|
||||||
|
path.quadTo(x - s * 1.1f, y + s * 0.05f, x - s * 1.5f, y - s * 0.3f + tip);
|
||||||
|
path.quadTo(x - s * 0.6f, y - s * 0.7f + tip, x, y - s * 0.3f);
|
||||||
|
path.close();
|
||||||
|
// Lit by the moon: black would vanish in the night sky.
|
||||||
|
fill.setColor(BAT);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(BAT_EDGE);
|
||||||
|
line.setStrokeWidth(1.5f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Ghost: only where there is room ----------
|
||||||
|
|
||||||
|
private void drawGhost(Canvas c, int w, float u) {
|
||||||
|
if (w / u < 1400f) return;
|
||||||
|
float x = (w / 2f + 272f * u + w - 290f * u) / 2f, y = 330f * u + (float) Math.sin(time * 1.4f) * 12f * u, s = 34f * u;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(x - s, y + s * 1.5f);
|
||||||
|
path.lineTo(x - s, y);
|
||||||
|
path.cubicTo(x - s, y - s * 1.4f, x + s, y - s * 1.4f, x + s, y);
|
||||||
|
path.lineTo(x + s, y + s * 1.5f);
|
||||||
|
float wob = (float) Math.sin(time * 3f) * s * 0.12f;
|
||||||
|
path.quadTo(x + s * 0.66f, y + s * 1.1f + wob, x + s * 0.33f, y + s * 1.5f);
|
||||||
|
path.quadTo(x, y + s * 1.1f - wob, x - s * 0.33f, y + s * 1.5f);
|
||||||
|
path.quadTo(x - s * 0.66f, y + s * 1.1f + wob, x - s, y + s * 1.5f);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(0xB8F0F4FF);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
fill.setColor(0xFF1A1030);
|
||||||
|
c.drawCircle(x - s * 0.34f, y - s * 0.2f, s * 0.14f, fill);
|
||||||
|
c.drawCircle(x + s * 0.34f, y - s * 0.2f, s * 0.14f, fill);
|
||||||
|
r.set(x - s * 0.14f, y + s * 0.08f, x + s * 0.14f, y + s * 0.44f);
|
||||||
|
c.drawOval(r, fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The hill: the last two minutes of power ----------
|
||||||
|
|
||||||
|
private void drawHill(Canvas c, int w, int h, float u) {
|
||||||
|
float base = h - 44f * u, amp = 36f * u;
|
||||||
|
float[] trail = v.powerTrail;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(0, h);
|
||||||
|
int n = trail == null ? 0 : trail.length;
|
||||||
|
if (n == 0) {
|
||||||
|
path.lineTo(0, base);
|
||||||
|
path.lineTo(w, base);
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
float p = trail[i];
|
||||||
|
float height = Float.isNaN(p) ? 0f : Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f));
|
||||||
|
path.lineTo(i / (n - 1f) * w, base - height * amp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.lineTo(w, h);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(HILL);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
// The ridge line, each stretch in the colour of what the car was doing then.
|
||||||
|
if (n > 1) {
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
for (int i = 1; i < n; i++) {
|
||||||
|
float a = trail[i - 1], b = trail[i];
|
||||||
|
if (Float.isNaN(a) || Float.isNaN(b)) continue;
|
||||||
|
float ha = Math.min(1f, Math.abs(a) / (a < 0 ? 40f : 60f));
|
||||||
|
float hb = Math.min(1f, Math.abs(b) / (b < 0 ? 40f : 60f));
|
||||||
|
line.setColor(b < 0 ? FIRE : ORANGE);
|
||||||
|
c.drawLine((i - 1f) / (n - 1f) * w, base - ha * amp, i / (n - 1f) * w, base - hb * amp, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The arch of embers: the power ----------
|
||||||
|
|
||||||
|
private void drawEmbers(Canvas c, int w, float u) {
|
||||||
|
float cx = w / 2f, cy = 348f * u, radX = 272f * u, radY = 244f * u;
|
||||||
|
int lit = Tally.ofPower(v.powerKw, 150f, 50f, EMBERS);
|
||||||
|
boolean regen = v.powerKw != null && v.powerKw < 0f;
|
||||||
|
for (int i = 0; i < EMBERS; i++) {
|
||||||
|
// Left to right over the top; regeneration comes back from the right.
|
||||||
|
int k = regen ? EMBERS - 1 - i : i;
|
||||||
|
double a = Math.PI + Math.PI * k / (EMBERS - 1);
|
||||||
|
float x = cx + (float) Math.cos(a) * radX, y = cy + (float) Math.sin(a) * radY;
|
||||||
|
boolean on = i < lit;
|
||||||
|
if (on) {
|
||||||
|
fill.setColor(AccentColor.withAlpha(regen ? FIRE : ORANGE, 0x44));
|
||||||
|
c.drawCircle(x, y, 15f * u, fill);
|
||||||
|
}
|
||||||
|
fill.setColor(on ? (regen ? FIRE : CANDLE) : 0x38FFFFFF);
|
||||||
|
c.drawCircle(x, y, 7.5f * u, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The jack-o'-lantern: the speed ----------
|
||||||
|
|
||||||
|
private void drawPumpkin(Canvas c, int w, float u) {
|
||||||
|
float cx = w / 2f, cy = 348f * u, rx = 200f * u, ry = 158f * u;
|
||||||
|
boolean regen = regen();
|
||||||
|
int burn = regen ? FIRE : CANDLE;
|
||||||
|
// The light it throws, stronger the more power.
|
||||||
|
float share = v.powerKw == null ? 0f : Math.min(1f, Math.abs(v.powerKw) / 80f);
|
||||||
|
glow.setShader(new RadialGradient(cx, cy, rx * 1.6f,
|
||||||
|
new int[] { AccentColor.withAlpha(regen ? FIRE : ORANGE, 0x30 + (int) (share * 0x50)),
|
||||||
|
AccentColor.withAlpha(regen ? FIRE : ORANGE, 0) }, null, Shader.TileMode.CLAMP));
|
||||||
|
c.drawCircle(cx, cy, rx * 1.6f, glow);
|
||||||
|
// The stem.
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx - 22f * u, cy - ry + 12f * u);
|
||||||
|
path.quadTo(cx - 14f * u, cy - ry - 26f * u, cx + 14f * u, cy - ry - 44f * u);
|
||||||
|
path.lineTo(cx + 34f * u, cy - ry - 30f * u);
|
||||||
|
path.quadTo(cx + 14f * u, cy - ry - 12f * u, cx + 22f * u, cy - ry + 12f * u);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(STEM);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
// The ribs, outermost first, overlapping.
|
||||||
|
float[][] ribs = { { -0.70f, 0.42f }, { 0.70f, 0.42f }, { -0.40f, 0.48f }, { 0.40f, 0.48f }, { 0f, 0.50f } };
|
||||||
|
for (int i = 0; i < ribs.length; i++) {
|
||||||
|
float rcx = cx + ribs[i][0] * rx, half = ribs[i][1] * rx;
|
||||||
|
r.set(rcx - half, cy - ry * (i < 2 ? 0.86f : 0.98f), rcx + half, cy + ry * (i < 2 ? 0.86f : 0.98f));
|
||||||
|
fill.setColor(i % 2 == 0 ? PUMPKIN : PUMPKIN_LIT);
|
||||||
|
if (i == ribs.length - 1) fill.setColor(PUMPKIN);
|
||||||
|
c.drawOval(r, fill);
|
||||||
|
line.setColor(PUMPKIN_DARK);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
c.drawOval(r, line);
|
||||||
|
}
|
||||||
|
// Eyes and nose: triangles, burning.
|
||||||
|
triangle(c, cx - 88f * u, cy - 92f * u, cx - 34f * u, cy - 28f * u, cx - 128f * u, cy - 28f * u, burn, u);
|
||||||
|
triangle(c, cx + 88f * u, cy - 92f * u, cx + 128f * u, cy - 28f * u, cx + 34f * u, cy - 28f * u, burn, u);
|
||||||
|
triangle(c, cx, cy - 28f * u, cx + 19f * u, cy + 4f * u, cx - 19f * u, cy + 4f * u, burn, u);
|
||||||
|
// The grin, with the speed cut dark into its light.
|
||||||
|
float gt = cy + 8f * u, gb = cy + 122f * u, gw = 156f * u;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx - gw, gt);
|
||||||
|
path.quadTo(cx, gt + 26f * u, cx + gw, gt);
|
||||||
|
path.quadTo(cx + gw * 0.82f, gb, cx, gb);
|
||||||
|
path.quadTo(cx - gw * 0.82f, gb, cx - gw, gt);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(0xFF4A1F04);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
c.save();
|
||||||
|
c.scale(0.965f, 0.93f, cx, (gt + gb) / 2f);
|
||||||
|
fill.setColor(burn);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
c.restore();
|
||||||
|
// Two teeth at the grin's corners, clear of the digits, each the colour of the rib it hangs from.
|
||||||
|
tooth(c, cx, -120f * u, gw, gt, 26f * u, 17f * u, gt + 40f * u, PUMPKIN, u);
|
||||||
|
tooth(c, cx, 120f * u, gw, gt, 26f * u, 17f * u, gt + 40f * u, PUMPKIN_LIT, u);
|
||||||
|
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, gb - 18f * u, 92f * u, INK, serif, Paint.Align.CENTER);
|
||||||
|
label(c, "KM/H", cx + 80f * u, cy + 64f * u, 16f * u, INK, face, Paint.Align.LEFT);
|
||||||
|
// The power under the pumpkin, above the ridge.
|
||||||
|
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
|
||||||
|
Math.abs(v.powerKw));
|
||||||
|
label(c, power, cx, cy + ry + 44f * u, 34f * u, regen ? FIRE : BONE, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void triangle(Canvas c, float x1, float y1, float x2, float y2, float x3, float y3, int burn, float u) {
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(x1, y1);
|
||||||
|
path.lineTo(x2, y2);
|
||||||
|
path.lineTo(x3, y3);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(burn);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(0xFF4A1F04);
|
||||||
|
line.setStrokeWidth(4f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A triangle hanging from the grin's upper lip (a curve sagging by {@code sag}) to {@code tipY}. */
|
||||||
|
private void tooth(Canvas c, float cx, float dx, float gw, float gt, float sag, float halfW, float tipY, int colour, float u) {
|
||||||
|
float left = lip(dx - halfW, gw, gt, sag), right = lip(dx + halfW, gw, gt, sag);
|
||||||
|
path.reset();
|
||||||
|
// A little above the lip, so none of its dark edge shows across the tooth's root.
|
||||||
|
path.moveTo(cx + dx - halfW, left - 1.5f * u);
|
||||||
|
path.lineTo(cx + dx + halfW, right - 1.5f * u);
|
||||||
|
path.lineTo(cx + dx, tipY);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(colour);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(0xFF4A1F04);
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
c.drawLine(cx + dx - halfW, left + 2f * u, cx + dx, tipY, line);
|
||||||
|
c.drawLine(cx + dx + halfW, right + 2f * u, cx + dx, tipY, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The height of the grin's upper lip {@code dx} from the middle. */
|
||||||
|
private static float lip(float dx, float gw, float gt, float sag) {
|
||||||
|
float t = (gw - Math.abs(dx)) / (2f * gw);
|
||||||
|
return gt + 2f * t * (1f - t) * sag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Left: the sign under the cobweb ----------
|
||||||
|
|
||||||
|
private void drawSign(Canvas c, float u) {
|
||||||
|
// The cobweb in the corner.
|
||||||
|
float cs = 150f * u;
|
||||||
|
line.setColor(0x70E8E8F4);
|
||||||
|
line.setStrokeWidth(1.2f * u);
|
||||||
|
for (int i = 0; i <= 5; i++) {
|
||||||
|
double a = Math.PI / 2 * i / 5;
|
||||||
|
c.drawLine(0, 0, (float) Math.cos(a) * cs, (float) Math.sin(a) * cs, line);
|
||||||
|
}
|
||||||
|
for (int ring = 1; ring <= 4; ring++) {
|
||||||
|
float d = cs * ring / 4f;
|
||||||
|
path.reset();
|
||||||
|
for (int i = 0; i <= 5; i++) {
|
||||||
|
double a = Math.PI / 2 * i / 5;
|
||||||
|
float x = (float) Math.cos(a) * d, y = (float) Math.sin(a) * d;
|
||||||
|
if (i == 0) {
|
||||||
|
path.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
double mid = Math.PI / 2 * (i - 0.5) / 5;
|
||||||
|
path.quadTo((float) Math.cos(mid) * d * 0.86f, (float) Math.sin(mid) * d * 0.86f, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.drawPath(path, line);
|
||||||
|
}
|
||||||
|
// The sign, on two threads.
|
||||||
|
float l = 28f * u, rr = l + 300f * u, t = 50f * u, b = 244f * u;
|
||||||
|
line.setColor(0xA0E8E8F4);
|
||||||
|
c.drawLine(l + 60f * u, 0, l + 60f * u, t, line);
|
||||||
|
c.drawLine(rr - 60f * u, 0, rr - 60f * u, t, line);
|
||||||
|
r.set(l, t, rr, b);
|
||||||
|
fill.setColor(WOOD);
|
||||||
|
c.drawRoundRect(r, 8f * u, 8f * u, fill);
|
||||||
|
line.setColor(0xFF6B4226);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
c.drawRoundRect(r, 8f * u, 8f * u, line);
|
||||||
|
String[][] rows = {
|
||||||
|
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
|
||||||
|
{ "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") },
|
||||||
|
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") },
|
||||||
|
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
|
||||||
|
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
|
||||||
|
};
|
||||||
|
for (int i = 0; i < rows.length; i++) {
|
||||||
|
float y = t + (36f + i * 34f) * u;
|
||||||
|
label(c, rows[i][0], l + 16f * u, y, 19f * u, ORANGE, thin, Paint.Align.LEFT);
|
||||||
|
label(c, rows[i][1], rr - 16f * u, y, 23f * u, BONE, face, Paint.Align.RIGHT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The potion flask: the battery ----------
|
||||||
|
|
||||||
|
private void drawFlask(Canvas c, float u) {
|
||||||
|
float rad = 104f * u, cx = 28f * u + 150f * u, cy = 452f * u;
|
||||||
|
boolean low = v.socPercent != null && v.socPercent < 20f;
|
||||||
|
boolean blinkOn = (SystemClock.uptimeMillis() / 500) % 2 == 0;
|
||||||
|
int colour = low ? RED : POTION;
|
||||||
|
float neckW = 34f * u, neckTop = cy - rad - 42f * u;
|
||||||
|
// The glass shape: a round belly and a neck.
|
||||||
|
path.reset();
|
||||||
|
path.addCircle(cx, cy, rad, Path.Direction.CW);
|
||||||
|
r.set(cx - neckW, neckTop, cx + neckW, cy - rad + 16f * u);
|
||||||
|
path.addRect(r, Path.Direction.CW);
|
||||||
|
fill.setColor(0x38B9D8D0);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
// The potion: as high as the battery is full, across the belly only.
|
||||||
|
if (v.socPercent != null) {
|
||||||
|
float share = Math.max(0f, Math.min(1f, v.socPercent / 100f));
|
||||||
|
float level = cy + rad - share * 2f * rad;
|
||||||
|
c.save();
|
||||||
|
c.clipRect(cx - rad, level, cx + rad, cy + rad + 1f);
|
||||||
|
fill.setColor(colour);
|
||||||
|
c.drawCircle(cx, cy, rad - 5f * u, fill);
|
||||||
|
c.restore();
|
||||||
|
// The surface, a brighter line.
|
||||||
|
float half = (float) Math.sqrt(Math.max(0f, (rad - 5f * u) * (rad - 5f * u) - (level - cy) * (level - cy)));
|
||||||
|
line.setColor(0xCCFFFFFF);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
if (half > 0f) c.drawLine(cx - half, level, cx + half, level, line);
|
||||||
|
// Bubbles rise only while driving.
|
||||||
|
fill.setColor(0x99FFFFFF);
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
float span = Math.max(1f, cy + rad - level - 12f * u);
|
||||||
|
float y = cy + rad - 12f * u - ((time * (24f + i * 7f) * u + i * 31f * u) % span);
|
||||||
|
float x = cx + (float) Math.sin(i * 2.1f + time * 1.5f) * rad * 0.5f;
|
||||||
|
c.drawCircle(x, y, (3f + i % 3) * u, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The glass, and its cork.
|
||||||
|
line.setColor(GLASS);
|
||||||
|
line.setStrokeWidth(4f * u);
|
||||||
|
c.drawCircle(cx, cy, rad, line);
|
||||||
|
c.drawLine(cx - neckW, neckTop + 12f * u, cx - neckW, cy - rad + 14f * u, line);
|
||||||
|
c.drawLine(cx + neckW, neckTop + 12f * u, cx + neckW, cy - rad + 14f * u, line);
|
||||||
|
r.set(cx - neckW - 4f * u, neckTop - 8f * u, cx + neckW + 4f * u, neckTop + 16f * u);
|
||||||
|
fill.setColor(0xFF9B6B3A);
|
||||||
|
c.drawRoundRect(r, 5f * u, 5f * u, fill);
|
||||||
|
// Dark where the potion is behind the words; outlined where its surface cuts through them.
|
||||||
|
float level = v.socPercent == null ? cy + rad
|
||||||
|
: cy + rad - Math.max(0f, Math.min(1f, v.socPercent / 100f)) * 2f * rad;
|
||||||
|
boolean numberOn = level <= cy - 18f * u, wordOn = level <= cy + 38f * u;
|
||||||
|
String number = fmt(v.socPercent, "%.0f %%", "—");
|
||||||
|
if (!numberOn && level < cy + 30f * u) {
|
||||||
|
text.setStyle(Paint.Style.STROKE);
|
||||||
|
text.setStrokeWidth(7f * u);
|
||||||
|
label(c, number, cx, cy + 22f * u, 62f * u, INK, serif, Paint.Align.CENTER);
|
||||||
|
text.setStyle(Paint.Style.FILL);
|
||||||
|
}
|
||||||
|
label(c, number, cx, cy + 22f * u, 62f * u, numberOn ? INK : low ? RED : BONE, serif, Paint.Align.CENTER);
|
||||||
|
if (low && blinkOn) {
|
||||||
|
label(c, "BATTERY LOW", cx, cy + 56f * u, 21f * u, BONE, face, Paint.Align.CENTER);
|
||||||
|
} else if (!low) {
|
||||||
|
label(c, "BATTERY", cx, cy + 56f * u, 19f * u, wordOn ? INK : POTION, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The tombstone: the range; small gravestones: the gears ----------
|
||||||
|
|
||||||
|
private void stone(Canvas c, float l, float t, float rr, float b, int colour, int edge, float u) {
|
||||||
|
float mid = (l + rr) / 2f, half = (rr - l) / 2f;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(l, b);
|
||||||
|
path.lineTo(l, t + half);
|
||||||
|
path.cubicTo(l, t + half * 0.1f, mid - half * 0.5f, t, mid, t);
|
||||||
|
path.cubicTo(mid + half * 0.5f, t, rr, t + half * 0.1f, rr, t + half);
|
||||||
|
path.lineTo(rr, b);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(colour);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(edge);
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawTombstone(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 40f * u, l = rr - 250f * u, t = 196f * u, b = 484f * u, mid = (l + rr) / 2f;
|
||||||
|
stone(c, l, t, rr, b, STONE, STONE_DARK, u);
|
||||||
|
label(c, "R.I.P.", mid, t + 70f * u, 36f * u, STONE_DARK, serif, Paint.Align.CENTER);
|
||||||
|
label(c, "HERE LIES", mid, t + 106f * u, 19f * u, 0xFF242230, face, Paint.Align.CENTER);
|
||||||
|
label(c, fmt(v.rangeKm, "%.0f", "—"), mid, t + 186f * u, 86f * u, BONE, serif, Paint.Align.CENTER);
|
||||||
|
label(c, "KM OF RANGE", mid, t + 224f * u, 21f * u, 0xFF242230, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawGears(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 40f * u, l = rr - 250f * u, t = 496f * u, b = 566f * u;
|
||||||
|
String[] gears = { "P", "R", "N", "D" };
|
||||||
|
String now = Gear.label(v.gear);
|
||||||
|
float step = (rr - l) / 4f, half = 24f * u;
|
||||||
|
for (int i = 0; i < gears.length; i++) {
|
||||||
|
float mid = l + (i + 0.5f) * step;
|
||||||
|
boolean on = gears[i].equals(now);
|
||||||
|
if (on) {
|
||||||
|
fill.setColor(0x55FF8C1A);
|
||||||
|
c.drawCircle(mid, (t + b) / 2f, 42f * u, fill);
|
||||||
|
}
|
||||||
|
stone(c, mid - half, t, mid + half, b, on ? ORANGE : STONE_DARK, on ? CANDLE : 0xFF242230, u);
|
||||||
|
label(c, gears[i], mid, b - 18f * u, 32f * u, on ? INK : 0xB0FFFFFF, serif, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -355,7 +355,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
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, gridMapView, 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), new MuertosView(this), new HalloweenView(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.
|
// Grid with map: its map layer goes under it, in the same pane.
|
||||||
@@ -408,6 +408,14 @@ 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();
|
||||||
|
});
|
||||||
|
logPane.findViewById(R.id.sound_probe_button).setOnClickListener(v -> {
|
||||||
|
SoundProbe.start(this);
|
||||||
|
refreshSoundProbe();
|
||||||
|
});
|
||||||
|
|
||||||
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).
|
||||||
@@ -1819,7 +1827,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
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_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),
|
||||||
|
getString(R.string.overview_style_muertos), getString(R.string.overview_style_halloween) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
|
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
|
||||||
@@ -2325,8 +2334,31 @@ 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sound check: its button and its last report. */
|
||||||
|
private void refreshSoundProbe() {
|
||||||
|
MaterialButton button = logPane.findViewById(R.id.sound_probe_button);
|
||||||
|
button.setEnabled(!SoundProbe.running());
|
||||||
|
TextView text = logPane.findViewById(R.id.sound_probe_text);
|
||||||
|
java.util.List<String> status = SoundProbe.status(this);
|
||||||
|
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
|
||||||
|
if (!status.isEmpty()) text.setText(String.join("\n", status));
|
||||||
|
}
|
||||||
|
|
||||||
private void refreshCallLog() {
|
private void refreshCallLog() {
|
||||||
refreshSignalRecorder();
|
refreshSignalRecorder();
|
||||||
|
refreshMediaProbe();
|
||||||
|
refreshSoundProbe();
|
||||||
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,283 @@
|
|||||||
|
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.session.MediaController;
|
||||||
|
import android.media.session.MediaSessionManager;
|
||||||
|
import android.media.session.PlaybackState;
|
||||||
|
import android.service.media.MediaBrowserService;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.TreeSet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, listed but not connected to (2.42.1:
|
||||||
|
* a connection is not harmless, see {@link #browsers}).</li>
|
||||||
|
* </ol>
|
||||||
|
* No other app's service is bound; 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";
|
||||||
|
|
||||||
|
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, listed only. 2.42.0 connected to each, as the car's media
|
||||||
|
* library does; on the car that was not harmless: the Bluetooth music stopped (the AOSP car
|
||||||
|
* radio's service, `com.android.car.radio/.RadioService`, answered "connecting", most likely
|
||||||
|
* taking the audio) and the Local Media Player asked for access to media. The active sessions
|
||||||
|
* above already carry the song of whatever plays, CarPlay's and Bluetooth's included, so no
|
||||||
|
* service is bound any more.
|
||||||
|
*/
|
||||||
|
private static void browsers(Context app, List<String> out) {
|
||||||
|
out.add("Media browser services (" + MediaBrowserService.SERVICE_INTERFACE + "), listed, not connected to:");
|
||||||
|
try {
|
||||||
|
List<ResolveInfo> found = app.getPackageManager()
|
||||||
|
.queryIntentServices(new Intent(MediaBrowserService.SERVICE_INTERFACE), 0);
|
||||||
|
if (found == null || found.isEmpty()) {
|
||||||
|
out.add(" none");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (ResolveInfo ri : found) {
|
||||||
|
if (ri.serviceInfo == null) continue;
|
||||||
|
out.add(" " + new ComponentName(ri.serviceInfo.packageName, ri.serviceInfo.name)
|
||||||
|
.flattenToShortString());
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
out.add(" list not readable: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Path;
|
||||||
|
import android.graphics.RadialGradient;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.graphics.Shader;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Día de Muertos Overview style: an ofrenda at night, the altar families build to welcome
|
||||||
|
* their dead back for a visit. A celebration, so bright and warm, not frightening.
|
||||||
|
*
|
||||||
|
* Across the top the papel picado, cut-paper flags on a string: the battery, one flag per 10 %,
|
||||||
|
* those not lit hanging dark. In the middle a calavera, a decorated sugar skull, with the speed
|
||||||
|
* on its forehead and marigolds (cempasúchil) for eyes; round it a ring of marigolds is the power,
|
||||||
|
* clockwise while drawing, turquoise and anticlockwise while regenerating. On the left the candles
|
||||||
|
* of the altar: the range, one candle per 50 km. Along the bottom the path of marigold petals that
|
||||||
|
* leads the visitors home: the last two minutes of power, higher the more power. On the right the
|
||||||
|
* clock in a tin frame, P R N D as four small marigolds, and trip, usage, climate and odometer on
|
||||||
|
* the altar cloth. Below 20 % the battery turns red and says so. Unread values show "—" and light
|
||||||
|
* nothing.
|
||||||
|
*
|
||||||
|
* The altar is drawn once into a bitmap per size; only the readings go over it. The flags sway and
|
||||||
|
* the flames flicker (about 30 frames a second) only while the car moves and the view is on screen.
|
||||||
|
*/
|
||||||
|
final class MuertosView extends ClusterCanvasView {
|
||||||
|
|
||||||
|
private static final int FLAGS = 10, PETALS = 24, CANDLES = 8;
|
||||||
|
private static final int NIGHT = 0xFF160A26, PLUM = 0xFF2C1242, CLOTH = 0xFF3A0F3E, BONE = 0xFFF8EEDC;
|
||||||
|
private static final int INK = 0xFF2A0E36, MARIGOLD = 0xFFFF9A1F, GOLD = 0xFFFFC94A, TURQUOISE = 0xFF2EE6D0;
|
||||||
|
private static final int PINK = 0xFFFF2E93, RED = 0xFFFF3B47, FLAME = 0xFFFFD36B, WAX = 0xFFF4E6C8;
|
||||||
|
private static final int[] PAPER = { 0xFFFF2E93, 0xFFFF9A1F, 0xFF2EE6D0, 0xFFFFD23F, 0xFF9B5CFF };
|
||||||
|
|
||||||
|
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint image = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
|
||||||
|
private final Path path = new Path();
|
||||||
|
private final Path flag = new Path();
|
||||||
|
private final RectF r = new RectF();
|
||||||
|
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
|
||||||
|
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
|
||||||
|
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
|
||||||
|
|
||||||
|
private Bitmap scene, marigold, turquoise;
|
||||||
|
private int sceneW, sceneH;
|
||||||
|
private float time;
|
||||||
|
private long lastFrame;
|
||||||
|
|
||||||
|
MuertosView(Context context) {
|
||||||
|
super(context);
|
||||||
|
line.setStyle(Paint.Style.STROKE);
|
||||||
|
line.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
scene = null;
|
||||||
|
marigold = null;
|
||||||
|
turquoise = null;
|
||||||
|
sceneW = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas c) {
|
||||||
|
super.onDraw(c);
|
||||||
|
int w = getWidth(), h = getHeight();
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
float u = Math.min(h / 640f, w / 1100f);
|
||||||
|
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
|
||||||
|
if (moving) {
|
||||||
|
long now = SystemClock.uptimeMillis();
|
||||||
|
time += lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
|
||||||
|
lastFrame = now;
|
||||||
|
} else {
|
||||||
|
lastFrame = 0;
|
||||||
|
}
|
||||||
|
if (scene == null || sceneW != w || sceneH != h) buildScene(w, h, u);
|
||||||
|
c.drawBitmap(scene, 0, 0, image);
|
||||||
|
float cx = w / 2f, cy = 345f * u;
|
||||||
|
drawFlags(c, w, u);
|
||||||
|
drawRing(c, cx, cy, u);
|
||||||
|
drawSpeed(c, cx, cy, u);
|
||||||
|
drawBattery(c, u);
|
||||||
|
drawCandles(c, u, moving);
|
||||||
|
drawPetals(c, w, h, u);
|
||||||
|
drawClock(c, w, u);
|
||||||
|
drawGears(c, w, u);
|
||||||
|
drawRows(c, w, h, u);
|
||||||
|
keepAnimating(moving, 33);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
|
||||||
|
text.setTypeface(tf);
|
||||||
|
text.setTextSize(size);
|
||||||
|
text.setTextAlign(a);
|
||||||
|
text.setColor(colour);
|
||||||
|
c.drawText(s, x, y, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The altar, once per size ----------
|
||||||
|
|
||||||
|
private void buildScene(int w, int h, float u) {
|
||||||
|
scene = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
||||||
|
sceneW = w;
|
||||||
|
sceneH = h;
|
||||||
|
marigold = flower(44f * u, MARIGOLD, GOLD, 0xFFE06A00);
|
||||||
|
turquoise = flower(44f * u, TURQUOISE, 0xFFA8FFF4, 0xFF0E9C90);
|
||||||
|
buildFlag(u);
|
||||||
|
Canvas c = new Canvas(scene);
|
||||||
|
c.drawColor(NIGHT);
|
||||||
|
Paint glow = new Paint();
|
||||||
|
glow.setShader(new RadialGradient(w / 2f, 345f * u, Math.max(w, h) * 0.6f,
|
||||||
|
new int[] { 0xFF4A1C5E, PLUM, NIGHT }, new float[] { 0f, 0.45f, 1f }, Shader.TileMode.CLAMP));
|
||||||
|
c.drawRect(0, 0, w, h, glow);
|
||||||
|
// A scatter of stars, always the same.
|
||||||
|
fill.setColor(0x66FFFFFF);
|
||||||
|
for (int i = 0; i < 70; i++) {
|
||||||
|
float x = ((i * 137 + 31) % 997) / 997f * w, y = (110f + (i * 71 % 113) * 3f) * u;
|
||||||
|
c.drawCircle(x, y, (i % 3 == 0 ? 1.8f : 1f) * u, fill);
|
||||||
|
}
|
||||||
|
// The string the flags hang on.
|
||||||
|
line.setColor(0x99FFFFFF);
|
||||||
|
line.setStrokeWidth(1.5f * u);
|
||||||
|
c.drawLine(0, 12f * u, w, 12f * u, line);
|
||||||
|
drawSkull(c, w / 2f, 345f * u, u);
|
||||||
|
drawAltarCloth(c, w, u);
|
||||||
|
drawCandleStands(c, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One flower on a small bitmap: outer petals, inner petals, heart. */
|
||||||
|
private Bitmap flower(float size, int outer, int inner, int shade) {
|
||||||
|
int s = (int) Math.ceil(size);
|
||||||
|
Bitmap b = Bitmap.createBitmap(s, s, Bitmap.Config.ARGB_8888);
|
||||||
|
Canvas c = new Canvas(b);
|
||||||
|
drawFlower(c, s / 2f, s / 2f, s / 2f, outer, inner, shade);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawFlower(Canvas c, float x, float y, float rad, int outer, int inner, int shade) {
|
||||||
|
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
for (int ring = 0; ring < 3; ring++) {
|
||||||
|
int n = 12 - ring * 3;
|
||||||
|
float d = rad * (0.68f - ring * 0.24f), pr = rad * (0.32f - ring * 0.05f);
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
double a = Math.PI * 2 * i / n + ring * 0.3;
|
||||||
|
p.setColor(ring == 0 ? (i % 2 == 0 ? outer : shade) : ring == 1 ? (i % 2 == 0 ? inner : outer) : inner);
|
||||||
|
c.drawCircle(x + (float) Math.cos(a) * d, y + (float) Math.sin(a) * d, pr, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.setColor(shade);
|
||||||
|
c.drawCircle(x, y, rad * 0.14f, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A paper flag with its holes cut out: scalloped below, a flower and diamonds in it. */
|
||||||
|
private void buildFlag(float u) {
|
||||||
|
float w = 100f * u, h = 84f * u;
|
||||||
|
flag.reset();
|
||||||
|
flag.setFillType(Path.FillType.EVEN_ODD);
|
||||||
|
flag.moveTo(0, 0);
|
||||||
|
flag.lineTo(w, 0);
|
||||||
|
flag.lineTo(w, h - 8f * u);
|
||||||
|
int scallops = 6;
|
||||||
|
float sw = w / scallops;
|
||||||
|
for (int i = scallops; i > 0; i--) {
|
||||||
|
float x = i * sw;
|
||||||
|
flag.quadTo(x - sw / 2f, h + 8f * u, x - sw, h - 8f * u);
|
||||||
|
}
|
||||||
|
flag.close();
|
||||||
|
// The middle: a flower of eight petals round a round hole.
|
||||||
|
float cx = w / 2f, cy = h * 0.46f;
|
||||||
|
flag.addCircle(cx, cy, 6f * u, Path.Direction.CW);
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
double a = Math.PI / 4 * i;
|
||||||
|
float px = cx + (float) Math.cos(a) * 17f * u, py = cy + (float) Math.sin(a) * 17f * u;
|
||||||
|
diamond(flag, px, py, 7f * u, 5f * u, a);
|
||||||
|
}
|
||||||
|
// Corners and borders.
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
float x = (i + 0.5f) * w / 6f;
|
||||||
|
diamond(flag, x, 8f * u, 5f * u, 3f * u, 0);
|
||||||
|
if (i == 0 || i == 5) {
|
||||||
|
diamond(flag, x, h * 0.3f, 4f * u, 6f * u, 0);
|
||||||
|
diamond(flag, x, h * 0.62f, 4f * u, 6f * u, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void diamond(Path p, float x, float y, float a, float b, double turn) {
|
||||||
|
float ca = (float) Math.cos(turn), sa = (float) Math.sin(turn);
|
||||||
|
p.moveTo(x + a * ca, y + a * sa);
|
||||||
|
p.lineTo(x - b * sa, y + b * ca);
|
||||||
|
p.lineTo(x - a * ca, y - a * sa);
|
||||||
|
p.lineTo(x + b * sa, y - b * ca);
|
||||||
|
p.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The calavera: the bone, the decoration, the teeth. Its speed and ring go over it. */
|
||||||
|
private void drawSkull(Canvas c, float cx, float cy, float u) {
|
||||||
|
float top = cy - 150f * u, jaw = cy + 150f * u;
|
||||||
|
// Bone.
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx, top);
|
||||||
|
path.cubicTo(cx + 110f * u, top, cx + 148f * u, cy - 70f * u, cx + 142f * u, cy + 8f * u);
|
||||||
|
path.cubicTo(cx + 140f * u, cy + 48f * u, cx + 112f * u, cy + 62f * u, cx + 84f * u, cy + 74f * u);
|
||||||
|
path.lineTo(cx + 78f * u, jaw - 8f * u);
|
||||||
|
path.quadTo(cx, jaw + 6f * u, cx - 78f * u, jaw - 8f * u);
|
||||||
|
path.lineTo(cx - 84f * u, cy + 74f * u);
|
||||||
|
path.cubicTo(cx - 112f * u, cy + 62f * u, cx - 140f * u, cy + 48f * u, cx - 142f * u, cy + 8f * u);
|
||||||
|
path.cubicTo(cx - 148f * u, cy - 70f * u, cx - 110f * u, top, cx, top);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(BONE);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(PINK);
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
// A band of scallops under the forehead, where the speed stands above.
|
||||||
|
line.setColor(PINK);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
for (int i = -4; i < 4; i++) {
|
||||||
|
float x = cx + i * 26f * u;
|
||||||
|
r.set(x, cy - 54f * u, x + 26f * u, cy - 28f * u);
|
||||||
|
c.drawArc(r, 0, 180, false, line);
|
||||||
|
}
|
||||||
|
fill.setColor(TURQUOISE);
|
||||||
|
for (int i = -4; i <= 4; i++) c.drawCircle(cx + i * 26f * u, cy - 41f * u, 3f * u, fill);
|
||||||
|
// Eyes: dark sockets with a marigold each, a ring of dots round them.
|
||||||
|
for (int side = -1; side <= 1; side += 2) {
|
||||||
|
float ex = cx + side * 62f * u, ey = cy + 4f * u;
|
||||||
|
fill.setColor(INK);
|
||||||
|
c.drawCircle(ex, ey, 36f * u, fill);
|
||||||
|
drawFlower(c, ex, ey, 31f * u, MARIGOLD, GOLD, 0xFFE06A00);
|
||||||
|
fill.setColor(PINK);
|
||||||
|
for (int i = 0; i < 14; i++) {
|
||||||
|
double a = Math.PI * 2 * i / 14;
|
||||||
|
c.drawCircle(ex + (float) Math.cos(a) * 43f * u, ey + (float) Math.sin(a) * 43f * u, 2.5f * u, fill);
|
||||||
|
}
|
||||||
|
// A small flower on each cheek.
|
||||||
|
drawFlower(c, cx + side * 92f * u, cy + 56f * u, 10f * u, 0xFF9B5CFF, 0xFFD2B8FF, 0xFF6A32C8);
|
||||||
|
}
|
||||||
|
// Nose: an upside-down heart.
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx, cy + 30f * u);
|
||||||
|
path.cubicTo(cx + 18f * u, cy + 46f * u, cx + 20f * u, cy + 66f * u, cx + 8f * u, cy + 66f * u);
|
||||||
|
path.quadTo(cx + 2f * u, cy + 66f * u, cx, cy + 58f * u);
|
||||||
|
path.quadTo(cx - 2f * u, cy + 66f * u, cx - 8f * u, cy + 66f * u);
|
||||||
|
path.cubicTo(cx - 20f * u, cy + 66f * u, cx - 18f * u, cy + 46f * u, cx, cy + 30f * u);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(INK);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
// Teeth: a smile stitched across.
|
||||||
|
line.setColor(INK);
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
float my = cy + 106f * u;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx - 70f * u, my - 6f * u);
|
||||||
|
path.quadTo(cx, my + 12f * u, cx + 70f * u, my - 6f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
for (int i = -5; i <= 5; i++) {
|
||||||
|
float x = cx + i * 12f * u;
|
||||||
|
float y = my + 3f * u * (1f - (i * i) / 25f);
|
||||||
|
c.drawLine(x, y - 15f * u, x, y + 15f * u, line);
|
||||||
|
}
|
||||||
|
// The chin's little flower.
|
||||||
|
drawFlower(c, cx, jaw - 13f * u, 10f * u, PINK, 0xFFFFA8D4, 0xFFC4186E);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawAltarCloth(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 28f * u, l = rr - 300f * u, t = 340f * u, b = 548f * u;
|
||||||
|
r.set(l, t, rr, b);
|
||||||
|
fill.setColor(CLOTH);
|
||||||
|
c.drawRoundRect(r, 10f * u, 10f * u, fill);
|
||||||
|
// A fringe of paper colours on top.
|
||||||
|
float seg = (rr - l) / 15f;
|
||||||
|
for (int i = 0; i < 15; i++) {
|
||||||
|
fill.setColor(PAPER[i % PAPER.length]);
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(l + i * seg, t);
|
||||||
|
path.lineTo(l + (i + 1) * seg, t);
|
||||||
|
path.lineTo(l + (i + 0.5f) * seg, t + 13f * u);
|
||||||
|
path.close();
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tier the candles stand on. */
|
||||||
|
private void drawCandleStands(Canvas c, float u) {
|
||||||
|
float l = 28f * u, rr = l + 300f * u, b = 548f * u;
|
||||||
|
r.set(l, b - 26f * u, rr, b);
|
||||||
|
fill.setColor(CLOTH);
|
||||||
|
c.drawRoundRect(r, 6f * u, 6f * u, fill);
|
||||||
|
float seg = (rr - l) / 15f;
|
||||||
|
for (int i = 0; i < 15; i++) {
|
||||||
|
fill.setColor(PAPER[i % PAPER.length]);
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(l + i * seg, b - 26f * u);
|
||||||
|
path.lineTo(l + (i + 1) * seg, b - 26f * u);
|
||||||
|
path.lineTo(l + (i + 0.5f) * seg, b - 15f * u);
|
||||||
|
path.close();
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Papel picado: the battery ----------
|
||||||
|
|
||||||
|
private void drawFlags(Canvas c, int w, float u) {
|
||||||
|
boolean low = v.socPercent != null && v.socPercent < 20f;
|
||||||
|
int lit = Tally.lit(v.socPercent, 10f, FLAGS);
|
||||||
|
float fw = 100f * u, gap = (w - FLAGS * fw) / (FLAGS + 1f);
|
||||||
|
fill.setStyle(Paint.Style.FILL);
|
||||||
|
for (int i = 0; i < FLAGS; i++) {
|
||||||
|
float x = gap + i * (fw + gap);
|
||||||
|
c.save();
|
||||||
|
c.translate(x, 12f * u);
|
||||||
|
// The breeze of driving; still when parked.
|
||||||
|
c.skew((float) Math.sin(time * 2.2f + i * 0.8f) * 0.05f, 0);
|
||||||
|
boolean on = i < lit;
|
||||||
|
fill.setColor(!on ? 0x38FFFFFF : low ? RED : PAPER[i % PAPER.length]);
|
||||||
|
c.drawPath(flag, fill);
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawBattery(Canvas c, float u) {
|
||||||
|
boolean low = v.socPercent != null && v.socPercent < 20f;
|
||||||
|
float x = 36f * u;
|
||||||
|
label(c, low ? "BATTERY LOW" : "BATTERY", x, 134f * u, 19f * u, low ? RED : GOLD, face, Paint.Align.LEFT);
|
||||||
|
label(c, fmt(v.socPercent, "%.0f %%", "—"), x, 200f * u, 66f * u, low ? RED : BONE, serif, Paint.Align.LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The marigold ring: the power ----------
|
||||||
|
|
||||||
|
private void drawRing(Canvas c, float cx, float cy, float u) {
|
||||||
|
float rad = 188f * u, half = marigold.getWidth() / 2f;
|
||||||
|
int lit = Tally.ofPower(v.powerKw, 150f, 50f, PETALS);
|
||||||
|
boolean regen = v.powerKw != null && v.powerKw < 0f;
|
||||||
|
for (int i = 0; i < PETALS; i++) {
|
||||||
|
// Clockwise from the top; regeneration runs the other way.
|
||||||
|
int k = regen ? PETALS - i : i;
|
||||||
|
double a = Math.PI * 2 * k / PETALS - Math.PI / 2;
|
||||||
|
float x = cx + (float) Math.cos(a) * rad, y = cy + (float) Math.sin(a) * rad;
|
||||||
|
boolean on = i < lit;
|
||||||
|
image.setAlpha(on ? 255 : 46);
|
||||||
|
c.drawBitmap(on && regen ? turquoise : marigold, x - half, y - half, image);
|
||||||
|
}
|
||||||
|
image.setAlpha(255);
|
||||||
|
// The power's number on a plate at the foot of the ring.
|
||||||
|
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
|
||||||
|
Math.abs(v.powerKw));
|
||||||
|
float py = cy + rad + 2f * u;
|
||||||
|
r.set(cx - 78f * u, py - 22f * u, cx + 78f * u, py + 20f * u);
|
||||||
|
fill.setColor(0xF02C1242);
|
||||||
|
c.drawRoundRect(r, 20f * u, 20f * u, fill);
|
||||||
|
line.setColor(regen ? TURQUOISE : MARIGOLD);
|
||||||
|
line.setStrokeWidth(2f * u);
|
||||||
|
c.drawRoundRect(r, 20f * u, 20f * u, line);
|
||||||
|
label(c, power, cx, py + 10f * u, 28f * u, regen ? TURQUOISE : GOLD, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawSpeed(Canvas c, float cx, float cy, float u) {
|
||||||
|
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, cy - 66f * u, 74f * u, INK, serif, Paint.Align.CENTER);
|
||||||
|
label(c, "KM/H", cx, cy - 128f * u, 14f * u, 0xFFC4186E, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Candles: the range ----------
|
||||||
|
|
||||||
|
private void drawCandles(Canvas c, float u, boolean moving) {
|
||||||
|
float x0 = 36f * u;
|
||||||
|
label(c, "RANGE", x0, 246f * u, 19f * u, GOLD, face, Paint.Align.LEFT);
|
||||||
|
label(c, fmt(v.rangeKm, "%.0f km", "—"), x0, 298f * u, 48f * u, BONE, serif, Paint.Align.LEFT);
|
||||||
|
int lit = Tally.lit(v.rangeKm, 50f, CANDLES);
|
||||||
|
float base = 522f * u, step = 36f * u;
|
||||||
|
for (int i = 0; i < CANDLES; i++) {
|
||||||
|
float x = x0 + 14f * u + i * step;
|
||||||
|
float tall = (130f - (i % 4) * 22f - (i % 3) * 8f) * u;
|
||||||
|
float top = base - tall;
|
||||||
|
r.set(x - 11f * u, top, x + 11f * u, base);
|
||||||
|
boolean on = i < lit;
|
||||||
|
fill.setColor(on ? WAX : 0x40F4E6C8);
|
||||||
|
c.drawRoundRect(r, 3f * u, 3f * u, fill);
|
||||||
|
// A band of colour.
|
||||||
|
r.set(x - 11f * u, top + tall * 0.55f, x + 11f * u, top + tall * 0.55f + 10f * u);
|
||||||
|
fill.setColor(AccentColor.withAlpha(PAPER[i % PAPER.length], on ? 0xFF : 0x40));
|
||||||
|
c.drawRect(r, fill);
|
||||||
|
// The wick.
|
||||||
|
line.setColor(on ? INK : 0x60FFFFFF);
|
||||||
|
line.setStrokeWidth(2f * u);
|
||||||
|
c.drawLine(x, top, x, top - 6f * u, line);
|
||||||
|
if (!on) continue;
|
||||||
|
// The flame, flickering only while driving, with its halo.
|
||||||
|
float flick = moving ? (float) Math.sin(time * 9f + i * 1.7f) * 2.5f * u : 0f;
|
||||||
|
fill.setColor(0x33FFB84A);
|
||||||
|
c.drawCircle(x, top - 18f * u, 19f * u, fill);
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(x, top - 32f * u - Math.abs(flick));
|
||||||
|
path.quadTo(x + 10f * u + flick, top - 12f * u, x, top - 4f * u);
|
||||||
|
path.quadTo(x - 10f * u + flick, top - 12f * u, x, top - 32f * u - Math.abs(flick));
|
||||||
|
path.close();
|
||||||
|
fill.setColor(FLAME);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The petal path: the last two minutes of power ----------
|
||||||
|
|
||||||
|
private void drawPetals(Canvas c, int w, int h, float u) {
|
||||||
|
float base = h - 16f * u, amp = 58f * u;
|
||||||
|
float[] trail = v.powerTrail;
|
||||||
|
// The path itself, faint, whatever the readings.
|
||||||
|
fill.setColor(0x30FF9A1F);
|
||||||
|
r.set(0, base - 4f * u, w, base + 4f * u);
|
||||||
|
c.drawRect(r, fill);
|
||||||
|
if (trail == null || trail.length == 0) return;
|
||||||
|
int n = trail.length;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
float p = trail[i];
|
||||||
|
if (Float.isNaN(p)) continue;
|
||||||
|
float x = (i + 0.5f) / n * w;
|
||||||
|
float share = Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f));
|
||||||
|
// Each stack of petals rises with the power, each petal tilted its own way.
|
||||||
|
int stack = 1 + Math.round(share * 4f);
|
||||||
|
for (int s = 0; s < stack; s++) {
|
||||||
|
float y = base - s * amp / 4f;
|
||||||
|
c.save();
|
||||||
|
c.rotate(((i * 47 + s * 83) % 180) - 90f, x, y);
|
||||||
|
r.set(x - 8f * u, y - 3.5f * u, x + 8f * u, y + 3.5f * u);
|
||||||
|
fill.setColor(p < 0 ? TURQUOISE : s % 2 == 0 ? MARIGOLD : GOLD);
|
||||||
|
c.drawOval(r, fill);
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The right side ----------
|
||||||
|
|
||||||
|
/** The clock in a tin frame. */
|
||||||
|
private void drawClock(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 28f * u, l = rr - 300f * u, t = 118f * u, b = 202f * u;
|
||||||
|
r.set(l, t, rr, b);
|
||||||
|
fill.setColor(0xCC160A26);
|
||||||
|
c.drawRoundRect(r, 12f * u, 12f * u, fill);
|
||||||
|
line.setColor(GOLD);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
c.drawRoundRect(r, 12f * u, 12f * u, line);
|
||||||
|
fill.setColor(GOLD);
|
||||||
|
for (int i = 0; i <= 12; i++) {
|
||||||
|
float x = l + 12f * u + i * (rr - l - 24f * u) / 12f;
|
||||||
|
c.drawCircle(x, t + 8f * u, 2f * u, fill);
|
||||||
|
c.drawCircle(x, b - 8f * u, 2f * u, fill);
|
||||||
|
}
|
||||||
|
label(c, v.clock.isEmpty() ? "—" : v.clock, (l + rr) / 2f, b - 22f * u, 50f * u, BONE, serif, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P R N D as four small marigolds: the one engaged in bloom. */
|
||||||
|
private void drawGears(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 28f * u, l = rr - 300f * u, y = 270f * u;
|
||||||
|
String[] gears = { "P", "R", "N", "D" };
|
||||||
|
String now = Gear.label(v.gear);
|
||||||
|
float step = (rr - l) / 4f, half = marigold.getWidth() / 2f, scale = 58f * u / marigold.getWidth();
|
||||||
|
for (int i = 0; i < gears.length; i++) {
|
||||||
|
float x = l + (i + 0.5f) * step;
|
||||||
|
boolean on = gears[i].equals(now);
|
||||||
|
if (on) {
|
||||||
|
c.save();
|
||||||
|
c.scale(scale, scale, x, y);
|
||||||
|
c.drawBitmap(marigold, x - half, y - half, image);
|
||||||
|
c.restore();
|
||||||
|
} else {
|
||||||
|
fill.setColor(0x40FFFFFF);
|
||||||
|
c.drawCircle(x, y, 22f * u, fill);
|
||||||
|
}
|
||||||
|
label(c, gears[i], x, y + 11f * u, 30f * u, on ? INK : 0xB0FFFFFF, serif, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trip, usage, climate, odometer on the altar cloth. */
|
||||||
|
private void drawRows(Canvas c, int w, int h, float u) {
|
||||||
|
float rr = w - 28f * u, l = rr - 300f * u, top = 378f * u;
|
||||||
|
String[][] rows = {
|
||||||
|
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
|
||||||
|
{ "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") },
|
||||||
|
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") },
|
||||||
|
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
|
||||||
|
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
|
||||||
|
};
|
||||||
|
for (int i = 0; i < rows.length; i++) {
|
||||||
|
float y = top + i * 35f * u;
|
||||||
|
label(c, rows[i][0], l + 18f * u, y, 19f * u, GOLD, thin, Paint.Align.LEFT);
|
||||||
|
label(c, rows[i][1], rr - 18f * u, y, 23f * u, BONE, face, Paint.Align.RIGHT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,9 @@ enum OverviewStyle {
|
|||||||
TIME_CIRCUITS(false, false),
|
TIME_CIRCUITS(false, false),
|
||||||
QUEST(false, false),
|
QUEST(false, false),
|
||||||
VISOR(true, true),
|
VISOR(true, true),
|
||||||
EMPIRE(false, false);
|
EMPIRE(false, false),
|
||||||
|
MUERTOS(false, false),
|
||||||
|
HALLOWEEN(false, false);
|
||||||
|
|
||||||
static final String PREF_KEY = "overview_style";
|
static final String PREF_KEY = "overview_style";
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ import java.util.Map;
|
|||||||
* 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 {
|
||||||
|
|
||||||
@@ -103,7 +103,8 @@ final class SignalRecorder {
|
|||||||
rec.note(System.currentTimeMillis(), "listening failed: " + t.getClass().getSimpleName());
|
rec.note(System.currentTimeMillis(), "listening failed: " + t.getClass().getSimpleName());
|
||||||
}
|
}
|
||||||
rec.note(System.currentTimeMillis(), listening == null
|
rec.note(System.currentTimeMillis(), listening == null
|
||||||
? "listening to the assistance properties: not possible (no car service)"
|
? "listening to the assistance properties: not possible ("
|
||||||
|
+ EVHardware.INSTANCE.getListenProblem() + ")"
|
||||||
: "listening to " + listening.getSubscribed() + " assistance properties ("
|
: "listening to " + listening.getSubscribed() + " assistance properties ("
|
||||||
+ listening.getRefused() + " refused)");
|
+ listening.getRefused() + " refused)");
|
||||||
try {
|
try {
|
||||||
@@ -166,10 +167,19 @@ final class SignalRecorder {
|
|||||||
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 {
|
||||||
@@ -180,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +219,8 @@ final class SignalRecorder {
|
|||||||
} 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(), recording.events(), 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();
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What {@link SoundProbe} makes of Android's audio analyser: how loud a captured waveform is, how
|
||||||
|
* strong a band of its spectrum, and whether a run of those looks like music the analyser can
|
||||||
|
* hear. A failed read is null, never a silent 0. Android-free and unit-tested.
|
||||||
|
*/
|
||||||
|
final class SoundLevels {
|
||||||
|
|
||||||
|
/** Below this the peak counts as silence (the 8-bit waveform's noise is about 1/128). */
|
||||||
|
static final float SILENT = 0.02f;
|
||||||
|
|
||||||
|
private SoundLevels() {}
|
||||||
|
|
||||||
|
/** The waveform's peak from 0 to 1. Its samples are unsigned 8-bit, with silence at 128. */
|
||||||
|
static Float peak(byte[] wave) {
|
||||||
|
if (wave == null || wave.length == 0) return null;
|
||||||
|
int max = 0;
|
||||||
|
for (byte b : wave) max = Math.max(max, Math.abs((b & 0xFF) - 128));
|
||||||
|
return Math.min(1f, max / 128f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mean strength from 0 to 1 of the bins between {@code fromHz} and {@code toHz}. The
|
||||||
|
* analyser's FFT holds the DC and Nyquist values first, then each bin's real and imaginary
|
||||||
|
* part, signed; a bin {@code k} is {@code k × rate / length} Hz.
|
||||||
|
*/
|
||||||
|
static Float band(byte[] fft, int rateHz, float fromHz, float toHz) {
|
||||||
|
if (fft == null || fft.length < 4 || rateHz <= 0) return null;
|
||||||
|
float sum = 0f;
|
||||||
|
int bins = 0;
|
||||||
|
for (int k = 1; k < fft.length / 2; k++) {
|
||||||
|
float hz = k * (float) rateHz / fft.length;
|
||||||
|
if (hz < fromHz || hz >= toHz) continue;
|
||||||
|
sum += (float) Math.hypot(fft[2 * k], fft[2 * k + 1]) / 128f;
|
||||||
|
bins++;
|
||||||
|
}
|
||||||
|
return bins == 0 ? null : Math.min(1f, sum / bins);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Verdict {
|
||||||
|
/** The analyser gave no readings at all. */
|
||||||
|
NO_READING,
|
||||||
|
/** Nothing was playing and the analyser heard nothing: says nothing about the source. */
|
||||||
|
NOTHING_PLAYING,
|
||||||
|
/** Something played and the analyser heard silence: it cannot hear this source. */
|
||||||
|
DEAF,
|
||||||
|
/** The level stays where it is: not music, or not real data. */
|
||||||
|
STEADY,
|
||||||
|
/** The level moves: the pumpkin could follow it. */
|
||||||
|
HEARS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Readings gathered over a time. */
|
||||||
|
static final class Summary {
|
||||||
|
private int samples, readings, bandReadings;
|
||||||
|
private float peakMin = Float.MAX_VALUE, peakMax, bass, mid, treble;
|
||||||
|
private Integer rms;
|
||||||
|
|
||||||
|
/** One round of reads, each null when it failed. */
|
||||||
|
void add(Float peak, Float bassBand, Float midBand, Float trebleBand, Integer rmsMilliBel) {
|
||||||
|
samples++;
|
||||||
|
if (peak != null) {
|
||||||
|
readings++;
|
||||||
|
peakMin = Math.min(peakMin, peak);
|
||||||
|
peakMax = Math.max(peakMax, peak);
|
||||||
|
}
|
||||||
|
if (bassBand != null && midBand != null && trebleBand != null) {
|
||||||
|
bandReadings++;
|
||||||
|
bass += bassBand;
|
||||||
|
mid += midBand;
|
||||||
|
treble += trebleBand;
|
||||||
|
}
|
||||||
|
if (rmsMilliBel != null) rms = rmsMilliBel;
|
||||||
|
}
|
||||||
|
|
||||||
|
Verdict verdict(boolean somethingPlays) {
|
||||||
|
if (readings == 0) return Verdict.NO_READING;
|
||||||
|
if (peakMax < SILENT) return somethingPlays ? Verdict.DEAF : Verdict.NOTHING_PLAYING;
|
||||||
|
return peakMax - peakMin < SILENT ? Verdict.STEADY : Verdict.HEARS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line of the report; what could not be read is "—". */
|
||||||
|
String line() {
|
||||||
|
return "peak " + (readings == 0 ? "—" : String.format(Locale.US, "%.2f–%.2f", peakMin, peakMax))
|
||||||
|
+ ", bass " + mean(bass) + ", mid " + mean(mid) + ", treble " + mean(treble)
|
||||||
|
+ ", rms " + (rms == null ? "—" : rms + " mB")
|
||||||
|
+ " (" + readings + "/" + samples + ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mean(float sum) {
|
||||||
|
return bandReadings == 0 ? "—" : String.format(Locale.US, "%.3f", sum / bandReadings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report's last line. */
|
||||||
|
static String words(Verdict verdict) {
|
||||||
|
switch (verdict) {
|
||||||
|
case HEARS: return "Result: the analyser hears it, and the level moves.";
|
||||||
|
case STEADY: return "Result: the level does not move, so this is not usable music.";
|
||||||
|
case DEAF: return "Result: something plays, but the analyser hears silence.";
|
||||||
|
case NOTHING_PLAYING: return "Result: nothing was playing, so this says nothing. Play something and check again.";
|
||||||
|
default: return "Result: the analyser gave no readings.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.media.AudioManager;
|
||||||
|
import android.media.audiofx.Visualizer;
|
||||||
|
import android.media.session.MediaController;
|
||||||
|
import android.media.session.MediaSessionManager;
|
||||||
|
import android.media.session.PlaybackState;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sound check on the Logs tab: whether Android's audio analyser ({@link Visualizer} on the
|
||||||
|
* output mix, session 0) can hear what the head unit plays, before the Halloween pumpkin's light
|
||||||
|
* follows the music. Only one tap starts it, and it listens for {@value #SECONDS} seconds and lets
|
||||||
|
* go. It records levels only, never the sound, and uses no microphone. The report goes where a
|
||||||
|
* signal recording goes ({@link SignalRecorder#saveCopies}): the app, a USB stick, the server.
|
||||||
|
*/
|
||||||
|
final class SoundProbe {
|
||||||
|
|
||||||
|
static final String FILE = "sound_probe.txt";
|
||||||
|
private static final int SECONDS = 10, READS_PER_SECOND = 10;
|
||||||
|
|
||||||
|
private static volatile boolean running;
|
||||||
|
private static long startedMs;
|
||||||
|
private static List<String> report = new ArrayList<>();
|
||||||
|
private static String result;
|
||||||
|
|
||||||
|
private SoundProbe() {}
|
||||||
|
|
||||||
|
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), "sound-probe").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void probe(Context app) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
out.add("Sound probe, " + SignalRecording.time(startedMs) + ", app " + BuildConfig.VERSION_NAME);
|
||||||
|
String saved;
|
||||||
|
try {
|
||||||
|
permission(app, Manifest.permission.RECORD_AUDIO, out);
|
||||||
|
permission(app, Manifest.permission.MODIFY_AUDIO_SETTINGS, out);
|
||||||
|
boolean plays = sessions(app, out);
|
||||||
|
plays |= musicActive(app, out);
|
||||||
|
SoundLevels.Summary all = listen(out);
|
||||||
|
plays |= musicActive(app, out);
|
||||||
|
out.add("");
|
||||||
|
out.add(SoundLevels.words(all.verdict(plays)));
|
||||||
|
} catch (Throwable t) {
|
||||||
|
out.add("probe stopped: " + t.getClass().getSimpleName());
|
||||||
|
} finally {
|
||||||
|
String name = "sound-probe-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||||
|
.format(new Date(startedMs)) + ".txt";
|
||||||
|
saved = SignalRecorder.saveCopies(app, FILE, name, out);
|
||||||
|
synchronized (SoundProbe.class) {
|
||||||
|
report = out;
|
||||||
|
result = saved;
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void permission(Context app, String name, List<String> out) {
|
||||||
|
boolean granted = app.checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
|
||||||
|
out.add(name.substring(name.lastIndexOf('.') + 1) + ": " + (granted ? "granted" : "not granted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean musicActive(Context app, List<String> out) {
|
||||||
|
try {
|
||||||
|
AudioManager am = (AudioManager) app.getSystemService(Context.AUDIO_SERVICE);
|
||||||
|
boolean active = am != null && am.isMusicActive();
|
||||||
|
out.add("music active: " + active);
|
||||||
|
return active;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
out.add("music active: unreadable (" + e.getClass().getSimpleName() + ")");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which source is playing, for reading the report later; no song. True when one is. */
|
||||||
|
private static boolean sessions(Context app, List<String> out) {
|
||||||
|
boolean playing = false;
|
||||||
|
try {
|
||||||
|
MediaSessionManager msm = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE);
|
||||||
|
List<MediaController> controllers = msm == null ? null : msm.getActiveSessions(null);
|
||||||
|
if (controllers == null || controllers.isEmpty()) out.add("sessions: none");
|
||||||
|
else for (MediaController c : controllers) {
|
||||||
|
PlaybackState s = c.getPlaybackState();
|
||||||
|
out.add("session: " + MediaProbeText.clean(c.getPackageName()) + ", "
|
||||||
|
+ (s == null ? "no state" : MediaProbeText.playbackState(s.getState())));
|
||||||
|
if (s != null && s.getState() == PlaybackState.STATE_PLAYING) playing = true;
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
out.add("sessions: not readable (" + t.getClass().getSimpleName() + ")");
|
||||||
|
}
|
||||||
|
return playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Listens for the whole time, one report line a second; always lets the analyser go. */
|
||||||
|
private static SoundLevels.Summary listen(List<String> out) throws InterruptedException {
|
||||||
|
SoundLevels.Summary all = new SoundLevels.Summary();
|
||||||
|
Visualizer analyser = null;
|
||||||
|
try {
|
||||||
|
analyser = new Visualizer(0);
|
||||||
|
int[] sizes = Visualizer.getCaptureSizeRange();
|
||||||
|
int code = analyser.setCaptureSize(sizes[1]);
|
||||||
|
analyser.setScalingMode(Visualizer.SCALING_MODE_NORMALIZED);
|
||||||
|
analyser.setMeasurementMode(Visualizer.MEASUREMENT_MODE_PEAK_RMS);
|
||||||
|
int enabled = analyser.setEnabled(true);
|
||||||
|
int size = analyser.getCaptureSize(), rateHz = analyser.getSamplingRate() / 1000;
|
||||||
|
out.add("analyser: capture " + size + " at " + rateHz + " Hz, set size " + code + ", enable " + enabled);
|
||||||
|
if (enabled != Visualizer.SUCCESS) return all;
|
||||||
|
byte[] wave = new byte[size], fft = new byte[size];
|
||||||
|
Visualizer.MeasurementPeakRms measure = new Visualizer.MeasurementPeakRms();
|
||||||
|
for (int second = 0; second < SECONDS; second++) {
|
||||||
|
SoundLevels.Summary now = new SoundLevels.Summary();
|
||||||
|
for (int read = 0; read < READS_PER_SECOND; read++) {
|
||||||
|
Thread.sleep(1000 / READS_PER_SECOND);
|
||||||
|
Float peak = analyser.getWaveForm(wave) == Visualizer.SUCCESS ? SoundLevels.peak(wave) : null;
|
||||||
|
boolean spectrum = analyser.getFft(fft) == Visualizer.SUCCESS;
|
||||||
|
Float bass = spectrum ? SoundLevels.band(fft, rateHz, 20f, 250f) : null;
|
||||||
|
Float mid = spectrum ? SoundLevels.band(fft, rateHz, 250f, 2000f) : null;
|
||||||
|
Float treble = spectrum ? SoundLevels.band(fft, rateHz, 2000f, 16000f) : null;
|
||||||
|
Integer rms = analyser.getMeasurementPeakRms(measure) == Visualizer.SUCCESS ? measure.mRms : null;
|
||||||
|
now.add(peak, bass, mid, treble, rms);
|
||||||
|
all.add(peak, bass, mid, treble, rms);
|
||||||
|
}
|
||||||
|
out.add(MediaProbeText.duration(second * 1000L) + " " + now.line());
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
out.add("analyser not available: " + e.getClass().getSimpleName() + " " + MediaProbeText.clean(e.getMessage()));
|
||||||
|
} finally {
|
||||||
|
if (analyser != null) {
|
||||||
|
try {
|
||||||
|
analyser.setEnabled(false);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Released next either way.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
analyser.release();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
out.add("analyser not released: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.sound_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ final class StylePalette {
|
|||||||
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;
|
||||||
case EMPIRE: return R.style.ThemeOverlay_EV_Style_Empire;
|
case EMPIRE: return R.style.ThemeOverlay_EV_Style_Empire;
|
||||||
|
case MUERTOS: return R.style.ThemeOverlay_EV_Style_Muertos;
|
||||||
|
case HALLOWEEN: return R.style.ThemeOverlay_EV_Style_Halloween;
|
||||||
default: return 0;
|
default: return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of a row of things a value lights: flags, candles, petals, embers, bats. An unread
|
||||||
|
* value lights none, never a made-up amount. Android-free and unit-tested.
|
||||||
|
*/
|
||||||
|
final class Tally {
|
||||||
|
|
||||||
|
private Tally() {}
|
||||||
|
|
||||||
|
/** One thing per {@code step} of the value, rounded, from 0 to {@code max}; null or NaN is 0. */
|
||||||
|
static int lit(Float value, float step, int max) {
|
||||||
|
if (value == null || value.isNaN() || step <= 0f || value <= 0f) return 0;
|
||||||
|
return Math.min(max, Math.round(value / step));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Of {@code count} things round a gauge, how many the power lights: all of them at
|
||||||
|
* {@code fullDraw} kW drawn, or at {@code fullRegen} kW regenerated (negative power).
|
||||||
|
* Any real power lights at least one, so the gauge never looks idle while it is not.
|
||||||
|
*/
|
||||||
|
static int ofPower(Float kw, float fullDraw, float fullRegen, int count) {
|
||||||
|
if (kw == null || kw.isNaN() || Math.abs(kw) < 0.1f) return 0;
|
||||||
|
float share = Math.abs(kw) / (kw < 0f ? fullRegen : fullDraw);
|
||||||
|
return Math.max(1, Math.min(count, Math.round(share * count)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,20 @@
|
|||||||
<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>
|
||||||
|
<!-- The sound check (SoundProbe): whether the audio analyser can hear what plays. -->
|
||||||
|
<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_sound_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/sound_probe_hint" android:textAppearance="@style/Text.EV.Secondary" />
|
||||||
|
<com.google.android.material.button.MaterialButton android:id="@+id/sound_probe_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_sound_probe" style="@style/Widget.EV.Button.Outlined" />
|
||||||
|
<TextView android:id="@+id/sound_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" />
|
||||||
|
|||||||
@@ -141,6 +141,8 @@
|
|||||||
<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>
|
||||||
<string name="overview_style_empire">Empire</string>
|
<string name="overview_style_empire">Empire</string>
|
||||||
|
<string name="overview_style_muertos">Día de Muertos</string>
|
||||||
|
<string name="overview_style_halloween">Halloween</string>
|
||||||
<string name="overview_style_nixie">Nixie tubes</string>
|
<string name="overview_style_nixie">Nixie tubes</string>
|
||||||
<string name="overview_style_chronograph">Chronograph</string>
|
<string name="overview_style_chronograph">Chronograph</string>
|
||||||
<string name="cockpit_battery">BATTERY</string>
|
<string name="cockpit_battery">BATTERY</string>
|
||||||
@@ -223,11 +225,22 @@
|
|||||||
<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, listens to the driver-assistance properties for every change they report, and notes the steering-wheel keys; then lists what changed, the rarest first. To find a signal: start it, note the times you switch the feature on and off or change it, and look for the values that changed then. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string>
|
<string name="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, %4$d change events heard. Keys reported, then assistance properties heard, 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$d change events heard, %4$s. Keys reported, then assistance properties heard, 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 and the media sessions apps publish (CarPlay, Bluetooth, radio, USB), each with its song, and the head unit\'s media services by name. Play something first and check once for each source. Like a recording, the result goes to your server and to a USB stick. It only reads and connects to no other app: nothing is played, paused or skipped.</string>
|
||||||
|
<string name="btn_media_probe">Check music sources</string>
|
||||||
|
<string name="label_sound_probe">Sound check</string>
|
||||||
|
<string name="sound_probe_hint">Finds out whether the app can hear the music, for a light that follows it. Park first, play something, then check once each for Bluetooth, CarPlay and radio. It listens for 10 seconds and keeps only the levels, never the sound, and uses no microphone. Like a recording, the result goes to your server and to a USB stick.</string>
|
||||||
|
<string name="btn_sound_probe">Check sound</string>
|
||||||
|
<string name="sound_probe_running">Listening… (10 seconds)</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>
|
||||||
|
|||||||
@@ -104,4 +104,24 @@
|
|||||||
<item name="evAccentStroke">#80344A99</item>
|
<item name="evAccentStroke">#80344A99</item>
|
||||||
<item name="evAccentTrack">#2A3F82</item>
|
<item name="evAccentTrack">#2A3F82</item>
|
||||||
</style>
|
</style>
|
||||||
|
<!-- Día de Muertos: the pink of the papel picado. -->
|
||||||
|
<style name="ThemeOverlay.EV.Style.Muertos" parent="">
|
||||||
|
<item name="evAccent">#FF2E93</item>
|
||||||
|
<item name="evOnAccent">#22041A</item>
|
||||||
|
<item name="evAccentLight">#FFA8D4</item>
|
||||||
|
<item name="evAccentDeep">#4A0E32</item>
|
||||||
|
<item name="evAccentSurface">#2C1242</item>
|
||||||
|
<item name="evAccentStroke">#80993366</item>
|
||||||
|
<item name="evAccentTrack">#7A2558</item>
|
||||||
|
</style>
|
||||||
|
<!-- Halloween: the pumpkin's orange. -->
|
||||||
|
<style name="ThemeOverlay.EV.Style.Halloween" parent="">
|
||||||
|
<item name="evAccent">#FF8C1A</item>
|
||||||
|
<item name="evOnAccent">#1F0E02</item>
|
||||||
|
<item name="evAccentLight">#FFC27A</item>
|
||||||
|
<item name="evAccentDeep">#3D2008</item>
|
||||||
|
<item name="evAccentSurface">#22102E</item>
|
||||||
|
<item name="evAccentStroke">#80804A1E</item>
|
||||||
|
<item name="evAccentTrack">#6A3F18</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -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,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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class SoundLevelsTest {
|
||||||
|
|
||||||
|
private static byte[] wave(int... unsigned) {
|
||||||
|
byte[] out = new byte[unsigned.length];
|
||||||
|
for (int i = 0; i < out.length; i++) out[i] = (byte) unsigned[i];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void silenceIsTheMiddleOfTheWaveform() {
|
||||||
|
assertEquals(0f, SoundLevels.peak(wave(128, 128, 128)), 0f);
|
||||||
|
assertEquals(0.5f, SoundLevels.peak(wave(128, 192, 128)), 0f);
|
||||||
|
assertEquals(0.5f, SoundLevels.peak(wave(128, 64, 128)), 0f);
|
||||||
|
assertEquals(1f, SoundLevels.peak(wave(0, 255)), 0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aFailedReadIsNotSilence() {
|
||||||
|
assertNull(SoundLevels.peak(null));
|
||||||
|
assertNull(SoundLevels.peak(new byte[0]));
|
||||||
|
assertNull(SoundLevels.band(null, 48000, 0f, 250f));
|
||||||
|
assertNull(SoundLevels.band(new byte[8], 0, 0f, 250f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aBandAveragesItsOwnBins() {
|
||||||
|
// Eight values: DC and Nyquist, then bins 1 to 3, here 1000, 2000 and 3000 Hz.
|
||||||
|
byte[] fft = { 99, 99, 64, 0, 0, 32, 3, 4 };
|
||||||
|
assertEquals(0.5f, SoundLevels.band(fft, 8000, 500f, 1500f), 1e-6f);
|
||||||
|
assertEquals(0.25f, SoundLevels.band(fft, 8000, 2000f, 2500f), 1e-6f);
|
||||||
|
assertEquals(5f / 128f, SoundLevels.band(fft, 8000, 3000f, 4000f), 1e-6f);
|
||||||
|
assertEquals((0.5f + 0.25f) / 2f, SoundLevels.band(fft, 8000, 500f, 2500f), 1e-6f);
|
||||||
|
assertNull(SoundLevels.band(fft, 8000, 5000f, 6000f));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theVerdictTellsDeafFromNothingPlaying() {
|
||||||
|
SoundLevels.Summary none = new SoundLevels.Summary();
|
||||||
|
none.add(null, null, null, null, null);
|
||||||
|
assertEquals(SoundLevels.Verdict.NO_READING, none.verdict(true));
|
||||||
|
|
||||||
|
SoundLevels.Summary silent = new SoundLevels.Summary();
|
||||||
|
silent.add(0f, 0f, 0f, 0f, -9600);
|
||||||
|
silent.add(0.01f, 0f, 0f, 0f, -9600);
|
||||||
|
assertEquals(SoundLevels.Verdict.DEAF, silent.verdict(true));
|
||||||
|
assertEquals(SoundLevels.Verdict.NOTHING_PLAYING, silent.verdict(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void onlyAMovingLevelIsHeard() {
|
||||||
|
SoundLevels.Summary steady = new SoundLevels.Summary();
|
||||||
|
steady.add(0.4f, 0.1f, 0.1f, 0.1f, -2000);
|
||||||
|
steady.add(0.41f, 0.1f, 0.1f, 0.1f, -2000);
|
||||||
|
assertEquals(SoundLevels.Verdict.STEADY, steady.verdict(true));
|
||||||
|
|
||||||
|
SoundLevels.Summary music = new SoundLevels.Summary();
|
||||||
|
music.add(0.1f, 0.2f, 0.1f, 0f, -3000);
|
||||||
|
music.add(0.6f, 0.4f, 0.3f, 0.1f, -1500);
|
||||||
|
assertEquals(SoundLevels.Verdict.HEARS, music.verdict(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void theLineAveragesAndMarksWhatFailed() {
|
||||||
|
SoundLevels.Summary s = new SoundLevels.Summary();
|
||||||
|
s.add(0.1f, 0.2f, 0.1f, 0f, -3000);
|
||||||
|
s.add(null, null, null, null, null);
|
||||||
|
s.add(0.6f, 0.4f, 0.3f, 0.1f, -1500);
|
||||||
|
assertEquals("peak 0.10–0.60, bass 0.300, mid 0.200, treble 0.050, rms -1500 mB (2/3)", s.line());
|
||||||
|
assertEquals("peak —, bass —, mid —, treble —, rms — (0/0)", new SoundLevels.Summary().line());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class TallyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anUnreadValueLightsNothing() {
|
||||||
|
assertEquals(0, Tally.lit(null, 10f, 10));
|
||||||
|
assertEquals(0, Tally.lit(Float.NaN, 10f, 10));
|
||||||
|
assertEquals(0, Tally.ofPower(null, 150f, 50f, 24));
|
||||||
|
assertEquals(0, Tally.ofPower(Float.NaN, 150f, 50f, 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void oneThingPerStepUpToTheRow() {
|
||||||
|
assertEquals(8, Tally.lit(76f, 10f, 10));
|
||||||
|
assertEquals(10, Tally.lit(100f, 10f, 10));
|
||||||
|
assertEquals(8, Tally.lit(900f, 50f, 8));
|
||||||
|
assertEquals(6, Tally.lit(312f, 50f, 8));
|
||||||
|
assertEquals(0, Tally.lit(0f, 10f, 10));
|
||||||
|
assertEquals(0, Tally.lit(-5f, 10f, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void powerFillsTheGaugeEachWay() {
|
||||||
|
assertEquals(0, Tally.ofPower(0f, 150f, 50f, 24));
|
||||||
|
assertEquals(24, Tally.ofPower(150f, 150f, 50f, 24));
|
||||||
|
assertEquals(24, Tally.ofPower(400f, 150f, 50f, 24));
|
||||||
|
assertEquals(12, Tally.ofPower(-25f, 150f, 50f, 24));
|
||||||
|
assertEquals(24, Tally.ofPower(-50f, 150f, 50f, 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void realPowerAlwaysShows() {
|
||||||
|
assertEquals(1, Tally.ofPower(0.5f, 150f, 50f, 24));
|
||||||
|
assertEquals(1, Tally.ofPower(-0.2f, 150f, 50f, 24));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 @@
|
|||||||
<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" />
|
||||||
|
|
||||||
|
<!-- The Logs tab's sound check (SoundProbe): Android's audio analyser asks for both. The
|
||||||
|
system user holds them without a prompt; the analyser reports levels only, no sound. -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+27
-2
@@ -16,6 +16,22 @@ spec:
|
|||||||
requests:
|
requests:
|
||||||
storage: 1Gi
|
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:
|
||||||
@@ -47,7 +63,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: server
|
||||||
# Pinned by digest so a re-pushed tag cannot change what runs (version in SPECS.md).
|
# Pinned by digest so a re-pushed tag cannot change what runs (version in SPECS.md).
|
||||||
image: git.stinnesbeck.com/nils/mg4-server@sha256:8b8d94e0562833d196162866b7fdc6910d5dd219f6eb84572d4d5c91b74f7ff1
|
image: git.stinnesbeck.com/nils/mg4-server@sha256:c68837f0e53cfca150644987be535e04d5e999bd4c91064d40973db67235df48
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
@@ -73,12 +89,16 @@ spec:
|
|||||||
value: https://tiles.openfreemap.org/planet
|
value: https://tiles.openfreemap.org/planet
|
||||||
- name: MAP_CACHE_DIR
|
- name: MAP_CACHE_DIR
|
||||||
value: /var/cache/mg4-maps
|
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
|
||||||
@@ -107,10 +127,15 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: maps
|
- name: maps
|
||||||
mountPath: /var/cache/mg4-maps
|
mountPath: /var/cache/mg4-maps
|
||||||
|
- name: logs
|
||||||
|
mountPath: /var/lib/mg4-logs
|
||||||
volumes:
|
volumes:
|
||||||
- name: maps
|
- name: maps
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: mg4-server-maps
|
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: 334 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 441 KiB |
@@ -25,6 +25,8 @@ Environment only; the server refuses to start when a required value is missing.
|
|||||||
| `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_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 |
|
| `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
|
||||||
@@ -43,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.
|
||||||
|
|
||||||
@@ -79,6 +82,26 @@ and is kept, and the car hears 503 with `Retry-After: 10` and gets the tile on i
|
|||||||
404 when maps are off. The log says `map tile fetched` with the size, never the tile.
|
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.
|
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)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
// /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)
|
// 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).
|
||||||
@@ -34,6 +35,8 @@ type API struct {
|
|||||||
Releases *Releases
|
Releases *Releases
|
||||||
// Maps serves the roads for the Overview's map from OpenStreetMap vector tiles (maps.go); nil is off.
|
// Maps serves the roads for the Overview's map from OpenStreetMap vector tiles (maps.go); nil is off.
|
||||||
Maps *Maps
|
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
|
||||||
@@ -54,6 +57,7 @@ func (a *API) Routes() http.Handler {
|
|||||||
a.handoffRoutes(mux)
|
a.handoffRoutes(mux)
|
||||||
a.releaseRoutes(mux)
|
a.releaseRoutes(mux)
|
||||||
a.mapRoutes(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-1
@@ -36,6 +36,8 @@ type config struct {
|
|||||||
forgeToken string
|
forgeToken string
|
||||||
mapTilesURL string
|
mapTilesURL string
|
||||||
mapCacheDir 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
|
||||||
@@ -55,6 +57,8 @@ func loadConfig(getenv func(string) string) (config, error) {
|
|||||||
forgeToken: getenv("FORGE_TOKEN"),
|
forgeToken: getenv("FORGE_TOKEN"),
|
||||||
mapTilesURL: getenv("MAP_TILES_URL"),
|
mapTilesURL: getenv("MAP_TILES_URL"),
|
||||||
mapCacheDir: getenv("MAP_CACHE_DIR"),
|
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"
|
||||||
@@ -89,6 +93,17 @@ func loadConfig(getenv func(string) string) (config, error) {
|
|||||||
return config{}, errors.New("MAP_TILES_URL must be an https URL or \"off\"")
|
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)
|
||||||
}
|
}
|
||||||
@@ -127,6 +142,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
api.Maps = NewMaps(cfg.mapTilesURL, cfg.mapCacheDir, mapUserAgent)
|
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,
|
||||||
@@ -142,7 +164,7 @@ func main() {
|
|||||||
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)
|
"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)
|
||||||
|
|||||||
Reference in New Issue
Block a user