and touching the same field (day.km) caused MongoDB
to throw a 500, holding all cached chunks back. Seed the whole day
subdocument instead.
Co-Authored-By: Claude <noreply@anthropic.com>
- HTML <input type=date> in a GET form, zero client JS (same server-component model)
- selected day detail card with tiles + rows above the period summary
- day-by-day list entries link to /consumption?date=YYYY-MM-DD and open+highlight the selected one
- 'Temizle' clears the filter
Co-Authored-By: Claude <noreply@anthropic.com>
- daily PUT: → for incremental 30-min delta chunks (day totals accumulate)
- new GET /v1/consumption/history/[installationId]: ingest-token-authenticated read-back
so the head-unit app can display cloud history without the dashboard password
Co-Authored-By: Claude <noreply@anthropic.com>
The published APK carries the Vercel origin and the write-only ingest token.
Verified against the signed artifact: the origin and token are present, and no
MongoDB URI, host or password appears anywhere in the dex.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A lone password field is usually ignored: managers key a saved entry on
(origin, username), and with no username there is nothing to key on. There is
only ever one account here, so the hidden field carries a fixed value and the
server never reads it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign-out answered GET behind a <Link>, and Next prefetches links that are in the
viewport. The button sits in every page header, so the browser called it as soon
as a page rendered and cleared the session cookie before the reader had clicked
anything. The first page loaded; the next navigation landed back on the password
screen, which read as the session not persisting.
It is a POST form now, and the route answers POST only — a prefetch of it gets
405 and changes nothing. The rule this broke is the general one: an endpoint
with a side effect must be unreachable by a prefetch, a crawler, or a browser
preloading what it thinks you will click.
Both session cookies are now set on the returned response rather than through
the cookies() store, which a freshly constructed redirect does not reliably
carry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A file named .env.vercel or .env.production would have been committed by the
old two-line list, which is exactly the file someone reaches for when they need
somewhere to paste real credentials.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The standalone service in server/ was never deployed anywhere, and keeping it
alongside a Vercel app would mean maintaining two implementations of the same
validation rules. It is deleted; web/ is the only ingest path now. Its routes
live at /v1/... rather than under /api, so the Android client's URL building is
unchanged and CONSUMPTION_API_URL stays a bare origin.
Credentials are split in two. INGEST_TOKEN is baked into the published APK and
must be assumed extractable — anyone can unzip an APK and read its strings — so
it opens the PUT endpoints and nothing else. Reading the history needs the
dashboard password, which is never compiled into anything and reaches the
browser as an HMAC-signed httpOnly cookie. The MongoDB URI stays a server-side
environment variable; the car has never held one and still does not.
The dashboard is a phone-shaped reader for the same data: totals for energy
taken and paid, a daily energy chart, an efficiency trend, and every DC session
with its power/SOC curve. It reuses the head unit's OLED palette so the two read
as one app. Every page is a server component and the charts are hand-rolled SVG,
so the whole thing renders without shipping client JavaScript.
MongoClient is cached on globalThis: a fresh client per request means a fresh
pool per request, and Atlas starts refusing connections long before the traffic
would justify it. Index creation moved to the first write of each warm lambda,
since a serverless deployment has no boot step to hang it on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The charging session meter used the head unit's wall clock, which jumps when it
picks up GPS or NTP time. One forward jump mid-charge invented both kWh and
hours. The consumption counters already integrate on elapsedRealtime for exactly
this reason; the session meter now does too, and keeps wall-clock values only as
the start and end stamps a reader needs to place a session on a timeline.
Intervals longer than five minutes are no longer bridged. A head unit that slept
mid-charge came back and billed the whole gap at the last power reading, and a
session interrupted overnight reported the night as charging time. Reported
duration is metered charging time now, not the span from start to end.
Sessions are metered on DC alone, at or above 10 kW. AC charging on this car is
an overnight affair the head unit sleeps through, so its curve, duration and
energy were guesses either way. The charging screen says so rather than letting
the previous DC session's figures be read as the current AC charge.
Also:
- A daily consumption record uploaded after an offline spell carries the
lifetime total as it stood at the close of that day. Every backlogged day used
to be stamped with the current lifetime, which plots as a flat line and one
cliff.
- The offline session queue is capped at 50. It is a single SharedPreferences
string read into memory in full, and nothing drained it while the cloud
endpoint was unconfigured.
- A long charge keeps the start of its curve: at 720 points the sample interval
doubles and the curve is thinned, rather than dropping the oldest point.
- The cloud sync throttle runs on the monotonic clock, and catch-up sends up to
10 days and 10 sessions per cycle rather than one and three.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2.2.3 was published with the broken installer, so it is recorded as released
rather than rewritten, and 2.2.4 is the build that can actually install itself.
2.2.4 must be sideloaded once — the fix cannot deliver itself over the path it
fixes. OTA should carry 2.2.5 onward.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two byte-identical copies of the platform key live on this machine, under
LUMINA/DriveHub_Kamera/tools and MG/DriveHub_Kamera/tools. OTA.md named the
LUMINA one; the MG one sits inside this workspace and is the copy actually
reached for.
Both work today only because someone keeps them in sync by hand. A private
signing key duplicated across two directories, with a checked-in document
pointing at one of them, is worth collapsing to a single location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The refresh button found 2.2.3, downloaded it, and reported
"Kurulum reddedildi (imza uyuşmuyor?)" for a build signed with exactly the same
platform certificate as the one already running. The signature was never the
problem.
install() shelled out to `pm install -r` with a path inside the app's private
cache. pm does not read the file — it hands the path to the package manager
service, which opens it from its own process and SELinux context, where that
directory is not readable. The install failed for a reason nobody could see:
install() returned a bare boolean, and UpdateHook mapped every false to the one
message it had. The pm output that would have named the real error was captured
into a StringBuilder and discarded.
A PackageInstaller session takes no path. We open the archive ourselves, as the
app, and stream it into the session, so no second process ever has to reach our
cache. Approach taken from merthankaraman/DriveHub_Dort, which hit the same wall
on the same head unit.
Consequences worth knowing when reading a future failure:
- Committing a session is asynchronous, so the refresh button can only say an
install started. OtaInstallResultReceiver has the verdict, and surfaces the
package manager's own INSTALL_FAILED_* text rather than guessing.
- STATUS_PENDING_USER_ACTION is not an error. The receiver launches the
confirmation activity the platform supplies.
- The PendingIntent must be MUTABLE or it arrives with no status extras.
install() now returns an enum, so a certificate mismatch, an unparseable or
wrong-package archive, and a failed session each say what they are.
Also checks the archive's package name against ours, which was a genuine gap:
the platform key signs every app on this unit, so a certificate match alone
never meant the APK was this app.
The `pm install -r` exit-code test goes with the shell-out it covered. Nothing
in the session path is exercisable off-device.
REQUEST_INSTALL_PACKAGES and the receiver are declared in the unstable manifest
only — the stable channel has no updater at all, and its merged manifest was
checked to confirm it gains neither.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The window switch sat on the Upload Service page, which implied a relationship
that does not exist: closing windows needs no ABRP token, no GPS, no upload
interval, and runs in its own service whether or not uploading is on. It is now
a sixth page of the pager, between Araç and Wi-Fi.
Splitting it in two while it moved, because the new page has room the card did
not: the switch and status stay together, and the event log gets its own card,
monospaced and no longer capped at four lines.
setOffscreenPageLimit switches from a hardcoded 4 to panes.length - 1. It was
already one short of the page count and would have gone stale again here.
refreshWindowClose runs on the two-second tick and dereferences views on a page
that may not be on screen, so the pager must not be free to detach them.
Six tabs share the bar where five did. Widget.EV.NavTab already autosizes down
to a 12sp floor, so the labels shrink rather than truncate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports WinClose's SWI69 window control as an opt-in foreground service that is
independent of ABRP: no token, no GPS, no upload cadence. The driver opening a
door while the car is in P at a readable 0 km/h pulses UP at all four areas for
five seconds, then STOP.
Only the parts that serve that one behaviour are taken. WinClose's speed and
time arming, configurable delay, warning beep, per-window modes and position
reads are all absent — the target car is a Comfort trim whose motors carry no
position sensor, so a close can be neither verified nor cancelled.
The pulse, the door edge detector and the eligibility policy are pure classes
with unit tests. STOP is attempted from a finally block on every exit, including
an interrupted sleep, a refused write and a sender that throws, because a pulse
that stops arriving leaves the motors wherever they were.
Guards worth naming, several of them learned from reading WinClose rather than
from the port itself:
- A wake lock spans the pulse. This fires as the driver leaves, which is exactly
when the head unit suspends; without it the glass can stop halfway with no
STOP sent.
- A fresh listener registration replays the door's current state. That replay
seeds the edge detector instead of triggering it, or a binder reconnect while
the door stands open would close the windows on someone halfway out.
- WinClose's 60-second cooldown, so reaching back into the car does not run a
second pulse.
- The service stops itself on a non-SWI69 generation, and the switch is disabled
unless the process holds system uid — only the unstable flavor declares
android.uid.system, so a stable build could otherwise offer a toggle whose
every command would fail.
This is the project's first write to the vehicle. README, SECURITY, AGENTS,
CONTRIBUTING and DISCLAIMER all claimed the app never writes; that claim is
retracted and scoped rather than quietly left standing.
Untested on a car. The feature is off by default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The signing key and the pre-release tick are the two things that silently
make an update invisible, and neither is guessable from the code.
Co-Authored-By: Claude <noreply@anthropic.com>
OtaUpdater was complete and unreachable: upstream's UpdateHook returned false
and had an empty checkInBackground, so nothing ever called the downloader or
the installer. That seam was suspended for an audit of the published suite;
this fork ships its own builds from its own repository, signed with the
platform key the head unit already trusts, so it is live here.
The release URL and the About link now point at this repository. An APK from
anywhere else is signed with a different key, and OtaUpdater.install checks
the archive's certificate against the running app's before calling
pm install -r — a mismatch is deleted rather than installed.
The refresh button sits outside the tab row, next to About, so it cannot read
as a sixth page. It disables itself for the duration: the check, the download
and the install are one background pass, and a second press would start a
second download of the same APK. The result goes to a toast because the check
is reachable from every page and no one page owns the answer.
The stable flavour gains a matching no-op overload. It still contains no
updater at all — the class is absent from that APK rather than disabled — and
the overload exists only so the shared UI compiles in both flavours.
Co-Authored-By: Claude <noreply@anthropic.com>
A fork of malys/EVABRPUploader (itself a fork of Leon Kernan's ABRP_Uploader)
carrying the changes that make it work on this car. History starts here: the
tree is what matters, and the upstream projects are where the earlier work
belongs.
EVHardware is vendored rather than tracked as a submodule, so one clone
builds.
Telemetry. SOC, battery power and range read as unavailable on SWI69 because
the vendor fallbacks were gated to SWI68 alone, while the property ids they
use are the ones this car publishes — the gates now admit SWI69. Tyre
pressure, the odometer and charge status come from the vendor block too,
since the generic properties for all three are declared here and never
publish. Tyre pressure is confirmed on the vehicle; the other two are not
yet.
Vehicle page. Every signal the car answers, in a table. One that it does not
publish shows a dash: an unimplemented VHAL property returns zero, and
"0 °C" would read as a measurement.
Wi-Fi page. Joins a network the unit already has saved, so a phone hotspot
does not need a trip into Settings on every drive. Tried on the upload tick
and from a button, skipped while already associated. This leans on the unit
being API 28 and the build being platform-signed — getConfiguredNetworks and
enableNetwork are closed to ordinary apps from API 29 — and fails quietly
where that does not hold.
The OTA updater points at this repository. An APK from upstream is signed
with a different key, which Android will not install over a platform-signed
build.
Co-Authored-By: Claude <noreply@anthropic.com>