The accent is selectable in Settings → Display: Dynamic Red (default),
Volcano Orange, Holborn Blue, Racing Green, Camden Grey, Monument
Silver, Arctic White and Black Pearl. White, silver and black cannot be
an accent on the black screen as they are, so they become light greys
and a pearl blue-grey.
The accent palette is now theme attributes (attrs_accent.xml) with one
overlay per paint (accents.xml), applied before the activity inflates;
layouts, colour lists, drawables and the views that draw in code use
them, including the red tints that were hard-coded. A change recreates
the activity on the same page. Android 9 crashes on gradients with
theme colours, so the battery card is built from layers.
Logs → "Look for the car colour" lists the head unit's system
properties that mention colour, paint or car model, to find out whether
the paint colour can be read from the car.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
Tyre pressures show in bar by default; Settings → Display offers kPa
and psi as well. Display only: the car reports kPa and the server keeps
storing kPa.
A "Power, 5-min average" card under Power: the time-weighted mean
battery power over the last five minutes, standing included, from the
same samples as the 5-minute range.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
The pages' scroll bars never faded on the car: a ScrollView shows its
bar on every layout, and the live values lay the page out every two
seconds. StableScrollView, the root of every page, now has none.
A new "Energy since start" tile, left of "Usage since start", shows the
net kWh taken from the pack since the trip began, recuperation counted
back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
The ABRP credentials are long to type on the car. "Enter on phone"
(Live data tab, and Settings next to the charging price) shows a QR
code; it opens a form on the server where the phone sends the ABRP API
key and user token, charging price and usable capacity, confirmed with
the approval password. The car polls with its own token and collects
the values once, then saves them through the same checks as the fields
on screen.
Same device-flow shape as pairing. The server keeps the values in memory
only, at most ten minutes, hands them out once with no-store, and never
logs them or puts them back into the page; a paired token only reaches
its own installation's request. Pairing's QR dialog is now shared
(PhoneQrDialog), and the phone pages share one stylesheet.
The config file also takes charging_price and usable_kwh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
The car sends live data to ABRP itself, and the car and the server go
over the same mobile connection: when the car cannot reach ABRP, it
cannot reach the server either, so the server's fallback bought
nothing. The forwarder, its ABRP_* settings and abrpSentUtc are gone;
the server only stores. The app stops sending abrpSentUtc (the server
ignores unknown fields, so 2.11.0 keeps working) and its texts no longer
promise a server fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
ABRP keeps getting live data while the car server is down: with an ABRP
API key and user token set in the app (Live data tab, or abrp_api_key /
abrp_token in the config file), the car sends the newest sample to ABRP
itself at every upload. It tells the server the newest sample ABRP took
(abrpSentUtc), and the server forwards only newer ones, so ABRP gets
each sample once. Without credentials in the app the server forwards
everything, as before. The credentials sit in encrypted storage, go in
the request body, and neither they nor ABRP's answer are logged.
Settings are grouped by what they are for:
- Live data (was Upload): on/off, ABRP credentials, upload frequency.
- Settings (replaces the Wi-Fi tab): full screen and voice key,
usable capacity and manual SoH (were on Consumption), charging price
(was on Charging), Wi-Fi.
Charging and Live data get icons of their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
Net regeneration over the window (or the trip) used to hide both
estimates behind "needs a few minutes of driving". It is a real
reading: the usage now shows as negative kWh/100 km, and the range as
"> 999 km" with "while recuperating" beneath it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
"Range since start" replaces the range at current power: remaining
energy over the consumption since the car was switched on, standing
with the climate on included. A trip starts again after 10 minutes
without samples (the car was off) or when charging.
The kWh/100 km for both estimates moved out of the range tiles into
two cards of their own, directly below them; the overview still fits
the full-screen 1920x720 display without scrolling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
Telemetry no longer loses queued batches. The server drops a bad value,
or a sample without a plausible time, instead of rejecting the batch,
and answers 200 with what it dropped. The app sends only values inside
the server's ranges (speed as a magnitude, so reversing is fine) and no
samples before the head unit's clock is set.
Server:
- Pairing hands out and accepts a token only after the device is saved;
a failed save leaves the request pending.
- A paired token serves only the installation id it was paired with
(403 otherwise); the shared INGEST_TOKEN still serves any.
- Wrong-password lockouts and open pairing requests are limited per
client address (last X-Forwarded-For entry), so a stranger cannot
lock the owner out; a car starting again replaces its own request.
- ABRP forwarding skips samples stamped ahead of the server's clock,
which used to stop it until a restart.
App:
- Settings written to plaintext during a keystore failure move back
into encrypted storage; the stored installation id wins.
- The installation id is derived from ANDROID_ID on first start, so a
reinstall finds its history; a stored or imported id still wins.
- The scanned config file is deleted after import (it holds the token).
- GPS stops while live telemetry is off or the server unconfigured, and
pauses while parked and idle, keeping the fix the car parked with.
- Range estimates use the monotonic clock.
- One HTTP helper (ServerHttp) for telemetry, sync and pairing.
- Last Turkish strings translated; mise logs shows the NilsMG4 tags.
Grafana reads InfluxDB with its own read-only token (new grafana-influx
Secret, created by hand as described in k8s/README.md).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
OTA.md, mise.toml and docs/MG4_PLATFORM_INSTALL.md now use
com.stinnesbeck.nilsmg4(.unstable) and NilsMG4-*.apk. OTA.md says the
updater is switched off and that its publishing steps are upstream's
GitHub process; it also drops the old four-part version example.
CHANGELOG.md keeps the old ID as upstream history.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
On the MG4 the immersive flags hid SAIC's bars but left a black band
where they had been. SYSTEM_UI_FLAG_LAYOUT_STABLE reports the bars'
insets even while they are hidden, and the fitsSystemWindows root
padded itself by them. In full screen the flag is dropped and the root
no longer fits system windows; switching full screen off restores both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
The unstable flavor appended upstream's CI build number as a fourth
part (2.8.0.0-unstable; always .0 without CI). Versions are now the
plain three-part defaultConfig version: 2.8.1-unstable, and build-car
names the APK NilsMG4-unstable-2.8.1.apk. The version code keeps the
x100000 factor (2.8.1 = 6700000): without it the code would fall below
the 6600000 already installed, and the installer refuses downgrades.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
Immersive full screen while the app is in front: the system's top and
bottom bars are hidden, and a swipe from the edge shows them briefly.
Re-applied whenever the window regains focus. A "Full screen" switch in
the Upload tab (default on) turns it off.
On the Android 9 emulator the bars are hidden (system UI flags 0x1706),
stay hidden after leaving and returning, and the switch toggles them
through the UI. Whether SAIC's own bars on the MG4 obey the flags is
only known on the car; if they do not, nothing changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
The car's key report, from the on-screen key log in 2.7.0:
android.intent.extra.hardkey.keycode = 287 (Integer),
android.intent.extra.hardkey.down (Boolean; one report on press, one on
release) and android.intent.extra.hardkey.longpress (Boolean). 2.7.0
only looked for short names like "keyCode", so it never found the code.
SteeringKeyReport now matches extras by the last part of their name,
case-insensitively, and is Android-free; its tests include the exact
double press logged on the car (releases 279 ms apart). Replayed in the
car's format on the emulator: double press opens the app, single and
long presses do not.
The floating button is removed (class, geometry and test, the overlay
permission, setting, switch and strings): it did not show over the MG4
launcher.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
Steering wheel: a double press of the voice key (code 287) opens the
app. The floating button from 2.6.0 does not show over the MG4
launcher, so this is the way back into the app. The head unit reports
keys as the broadcast com.saic.keyevent.hardkey.report, whose extras
are not documented: the receiver tries the usual names (and an
embedded KeyEvent), and counts one press per report whichever way the
up/down field turns out to work; long presses never open the app. The
last 20 reports are listed raw on the Logs tab, so if the guess
misses, a photo of that list gives the real format. A switch in the
Upload tab turns it off. DoublePressDetector (500 ms window) has 5
tests; on the emulator, simulated double presses open the app and
single presses, other keys and long presses do not.
Scrolling: pages jumped back to the top on every 2 s refresh. A stack
trace on the emulator showed why: ViewPager2's RecyclerView lays the
pages out while measuring, at a provisional height, and
ScrollView.onLayout clamps the position to 0. StableScrollView keeps
the driver's position across layouts and is now the root of every
page; the scrolled Charging page stays put across refreshes.
Vehicle tab: all six value tiles, including both range estimates, sit
in one row at the top, so nothing needs scrolling at 1920x720.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
FIRMWARE.md, the mise emulator profile and SPECS.md said 1920x1080.
The MG4's display is 1920x720 at 160 dpi (an app window has been seen
at 1778x720, the car's sidebar taking the rest). The floating button's
geometry test now uses 1920x720.
The app itself assumed no height: checked on an Android 9 emulator at
1920x720, all tabs render (longer ones scroll), the sidebar holds all
seven tabs, and the floating button starts at 1840,312.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
The MG4's launcher does not list third-party apps, so the app could
only be opened by reinstalling it. Now a small round MG-red button
floats over the car's screens: tap opens the app, drag moves it (the
position is kept and clamped to the screen), and it hides while the
app itself is open. A switch in the Upload tab turns it off.
The always-on service owns the overlay, so the button is there from
boot. It needs the overlay permission (SYSTEM_ALERT_WINDOW), which the
car build has through the system UID; without it, or if the window
manager refuses, the button stays away and nothing crashes, and the
Upload tab offers the system permission screen.
Tested on an Android 9 AOSP emulator at the head unit's 1920x1080 /
160 dpi with the car APK (installs as UID 1000): button appears over
the home screen, tap opens the app, hidden while open, drag moves it
without opening the app, position survives a force-stop, the switch
works through the UI. FloatingButtonGeometry has 3 unit tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
The car publishes no battery, cabin or outside temperature and no pack
energy (checked against the fields that reached InfluxDB). Instead of
panels that stay empty:
- Battery temperature tile -> Climate setpoint.
- Outside temperature tile -> Last update ("2 minutes ago").
- Energy in pack = SOC x usable capacity, from the hidden dashboard
constant usableKwh = 50.8 (51 kWh battery).
- Temperatures chart -> Battery voltage and current.
Checked with a simulated drive carrying exactly the car's fields: all
11 panels return data, energy matches 61 % x 50.8 kWh. SPECS.md lists
the missing signals and why.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
- "Where the car was" fits the route again: in Grafana's fit view the
zoom value is the maximum zoom, and 6 held it at country level. Now
15 with 10 % padding. Checked by rendering the Live dashboard with a
simulated 10 km drive.
- Dashboard titles use "-" instead of an em dash: "MG4 - Live",
"MG4 - Consumption", "MG4 - Charging".
- SPECS.md: Grafana keeps its state in SQLite on its PVC, which is why
it runs as one replica with Recreate; high availability needs
PostgreSQL or MySQL first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
- The secrets template moves to k8s/secrets/, and the filled-in copy to
k8s/secrets/secrets.yaml (gitignored). `kubectl apply -f k8s/` does
not recurse, so neither the placeholder template nor the real secrets
can be applied by accident.
- The server image digest is pinned in server.yaml instead of through
kustomize's images override, which `-f` ignores (it would have pulled
a nonexistent :latest).
- README and kustomization.yaml say to use `-k`: plain `-f` never
creates the generated Grafana ConfigMaps.
- The IngressRoutes drop `tls: {}`, like the other routes on this
Traefik: TLS is configured on the websecure entrypoint.
Manifests validated both ways (14 resources rendered with kustomize,
11 as plain files — the difference is the Grafana ConfigMaps).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
Both IngressRoutes (server and Grafana) now use traefik-internal, like
the rest of *.k8s.stinnesbeck.com, instead of the traefik-external
placeholder. README and SPECS updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
The server's host is car.k8s.stinnesbeck.com, not car.stinnesbeck.com:
IngressRoute, PUBLIC_URL (so the pairing QR code points there), the
app's pre-filled server URL and hint, and the docs. Grafana's
placeholder host follows to car-grafana.k8s.stinnesbeck.com, so one
*.k8s.stinnesbeck.com default certificate covers both. App 2.4.1 for the
new default; the server image is unchanged (its URL comes from the
manifest).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
- kustomization pins mg4-server 2.1.0 by digest; server.yaml sets
PUBLIC_URL for the pairing QR code; the secrets template gains
APPROVAL_PASSWORD.
- Filled-in Secrets now go into k8s/secrets.yaml, which is gitignored;
the template stays on placeholders. This follows a leak where real
values typed into the template were committed and pushed (amended and
force-pushed since; both values regenerated).
- SPECS.md records pairing and the incident.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
mg4-server:2.0.0 is on git.stinnesbeck.com and pulls anonymously, so
the Deployment no longer references a registry pull secret. The image
is pinned by digest (sha256:6feea777…) so a re-pushed tag cannot
change what the cluster runs. README and secrets template updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
- k8s/grafana.yaml: Grafana 13.2.3 with a 1Gi PVC, sign-up and
anonymous access off. The InfluxDB datasource (URL and token from the
environment) and three dashboards — Live, Consumption, Charging — are
provisioned from k8s/grafana/ via kustomize-generated ConfigMaps.
- server.yaml for the Go server (port 8080, UID 65532, smaller limits),
image tag 2.0.0. The NetworkPolicy now admits Grafana to InfluxDB.
- ingress.yaml: IngressRoutes for car.stinnesbeck.com and Grafana
(placeholder host car-grafana.stinnesbeck.com) with tls: {}, using
Traefik's default certificate; the cert-manager Certificate is gone.
- secrets.example.yaml: drops the dashboard secrets, adds grafana-admin.
- AGENTS.md loses its Next.js block; SPECS.md describes the new setup.
The removal of web/ (the Next.js app) and of the stale .next/ cache
upstream had committed already landed in 51e6f6f, which was staged
together with the Go server by mistake.
Checked end to end in podman (Go server + InfluxDB 2.7.12 + Grafana
13.2.3): all 25 dashboard queries return the expected data, and the
dashboards were rendered with headless Chromium. Manifests validated
offline with kustomize and kubeconform. Nothing applied to a cluster.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
Dashboard: every label, empty state, chart legend and the login page
are English; numbers and dates use en-GB (24-hour clock, day-month
dates), money is EUR, percentages read 80% instead of %80, durations
"2 h 05 min". html lang is en. Server version 1.1.2; manifests pin it.
Car app: the charging tab's price suffix, validation message and total
cost use € instead of ₺.
Checked by rendering all three pages from the 1.1.2 image against
InfluxDB 2.7.12 in podman.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
Browser title, login heading, hidden password-manager username, session
cookie (nilsmg4_session) and package name. The README keeps the credit
to fatihdonmezdev/FatihsMG4. Server version 1.1.1; the manifests pin it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
- New Server tab: server URL, ingest token and installation id, with
Save, Test connection (health, then a read-only token check) and the
config-file import that upstream removed (app folder / USB stick first,
document picker as fallback). Settings are kept encrypted and read on
every use, so the service picks up a change without a restart. Each
installation generates its own id; the inherited upstream UUID and
the baked-in CONSUMPTION_API_* build fields are gone.
- Live telemetry restored from before ea8032a (TelemetryPayload,
UploadCadence, UploadSettings, GPS) and sent in batches to
POST /v1/telemetry instead of ABRP; the server forwards to ABRP. Adds
ext_temp, voltage and current. Unsent samples queue in memory (about
8 h) while offline.
- Upload tab: the live-telemetry switch and cadence controls work again;
the dead service and autostart switches are removed.
- Manifest: location permissions and foreground service types.
- Unit tests compile again: 114 (stable) / 136 (unstable) pass. Tests
for the deleted AbrpApi and HeadUnitWeather removed; new tests for
ServerConfig and TelemetryQueue. Lint passes.
Not yet tried on the car.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without adb there was no way to see why vehicle values stay blank:
refreshVehicle() swallowed every exception. The Logs tab now shows the
package, version, UID (and whether it is the system UID), signer
SHA-256, firmware, car permission grants, the last snapshot and read
error, and the newest 200 entries of the vehicle library's AppLogger.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- applicationId com.evsuite.abrp -> com.stinnesbeck.nilsmg4 (the car
build is .unstable); the Java namespace is unchanged. Installs next to
upstream's app rather than over it.
- APK names NilsMG4-*.apk, debug builds included; build-car follows.
- OTA install-result action, cache/download names, User-Agent and log
tags follow the new name.
- About credits fatihdonmezdev / FatihsMG4.
- SPECS.md journals what changed and what is still to do, starting
with pointing the About link and QR code at git.stinnesbeck.com/nils/mg4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>