Your car's live data, on your car's screen.
A single-page dashboard for a self-hosted TeslaMate, built to be opened in the car's own browser. It reads live vehicle state from Tessalytics Backend over server-sent events and automatically changes dashboard when the car changes state:
- Parked or offline: selectable 7/30-day driving and charging analysis, daily charts, battery health, history, security and vehicle condition.
- Driving: large live speed and trip metrics, the current route, and a power chart that separates energy draw above zero from regenerative braking below it.
- Charging: present electrical readings and an hour-by-hour battery forecast up to the configured charge limit.
All three keep the same vehicle and battery overview at the top, matching the visual and information hierarchy of the iPhone app.
It signs in by scanning a QR code with the iPhone app. Nobody types a 64-character bearer token on a touchscreen in a driver's seat.
Important
This is not a standalone product. It needs Tessalytics Backend deployed beside your TeslaMate — a standalone TeslaMate does not serve this API. The QR sign-in additionally needs the Tessalytics iPhone app, which holds the credential that approves it. There is also a manual token sign-in for desktop use.
Important
Unofficial community software. Not affiliated with, endorsed by, or supported by the TeslaMate project or Tesla, Inc. "TeslaMate" and "Tesla" are the trademarks of their respective owners and are used here only to say what this connects to, in keeping with the TeslaMate trademark policy.
Car browser Tessalytics Backend iPhone app
│ │ │
│ POST /v1/auth/pairing │ │
├────────────────────────────►│ code + QR + poll token │
│◄────────────────────────────┤ │
│ │ │
│ shows the QR code ────────────────── camera ────────────►│ scans it
│ │ │
│ │ POST …/approve (API token) │
│ │◄─────────────────────────────┤ after Face ID
│ GET …/status (poll token) │ │
├────────────────────────────►│ │
│◄──── read-only session ─────┤ │
What the browser ends up holding is deliberately not the server's API token:
- Read-only. Vehicle actions — wake, lock, unlock, climate, charging — require the API token, and the backend
answers a paired session with
403 insufficient_scope. A credential sitting in a car cannot unlock that car. - Expiring, and revocable from the phone at any time (Settings → Paired browsers).
- Not persisted server-side. Sessions live in the backend's memory, so restarting it signs every browser out.
The code is shown on both screens on purpose: compare them before approving. That comparison is what stops someone sending you a QR code of their own.
Node 20 or later.
npm install
npm run mock # a fake backend with generated data, in another terminal
npm run dev # http://localhost:5173npm run mock needs no TeslaMate, no database and no car: it generates a vehicle that drives itself in a circle, and
its pairing flow approves itself after a few seconds so there is nothing to scan. Its API token for the manual sign-in
is printed on the console.
To hold the mock in one dashboard state while working on that layout:
MOCK_STATE=parked npm run mock
MOCK_STATE=driving npm run mock
MOCK_STATE=charging npm run mockThe live connection actively recovers after the Tesla browser is hidden and reopened. Visibility, focus, restored-page and network-online events trigger an immediate state refresh and stream restart; a keep-alive watchdog and bounded backoff continue recovery if the browser delivers no lifecycle event.
Against a real backend:
TESSALYTICS_API=http://192.168.1.2:3022 npm run devThe dev server proxies /v1 and /api to that address, which keeps every request same-origin — the same shape as
production, so CORS is never something that works in one mode and breaks in the other.
If you run the backend from source, build the bundle and let it serve the page at /app: one address, one
origin, and typing http://192.168.1.2:3022 into the car lands on the dashboard. The backend's published image
carries no bundle — it reports capabilities.web_app.enabled as false — so with containers either mount the
dist/ directory into it (WEB_APP_DIR), or just run the image below, which is one command and needs nothing
built.
npm run build
# Then point the backend at the output:
# WEB_APP_DIR=/path/to/tessalytics-web/distThe backend also finds dist/ automatically when this project is checked out as Tessalytics-Web beside it.
A published image serves the page and forwards /v1 and /api to your backend, so the browser still sees one
origin — no CORS, no mixed content, one address to type into the car. Images are built for linux/amd64 and
linux/arm64 by CI, so whatever the TeslaMate box is, this runs on it.
docker run -d --name tessalytics-web \
-e API_UPSTREAM=http://192.168.1.2:3022 \
-p 3023:8080 \
echocool/tessalytics-web:latestThen open http://<this-host>:3023 in the car and scan the code with the app.
If TeslaMate and Tessalytics Backend are already in one
docker-compose.yml, add one service to it — the dashboard needs nothing but the backend's address:
tessalytics-web:
image: echocool/tessalytics-web:latest
restart: always
ports:
- 3023:8080
depends_on:
- tessalytics-backend
environment:
# The backend as *this container* reaches it: the service name and its
# container port, not the port published on the host.
- API_UPSTREAM=http://tessalytics-backend:8080docker compose up -d tessalytics-webThen open http://<that-host>:3023 in the car.
http://tessalytics-backend:8080 resolves because Docker's DNS answers for a service name on a shared network
— which is why this works when both services are in one file, and needs a second look when they are not. If the
backend runs as its own Compose project, its service name belongs to that project; what resolves across the two is
its container_name, which the backend's docker/compose.yaml sets to tessalytics-backend for exactly this
reason. A different name, or no shared network, and the page loads and then reports the backend unreachable.
Point API_UPSTREAM at whatever actually answers.
Don't have the backend in your stack yet? Both services together
The dashboard reads everything through the backend, so it cannot run without one. Added to a TeslaMate stack that
already has database and mosquitto, the pair is:
tessalytics-backend:
image: echocool/tessalytics-backend:latest
restart: always
ports:
- 3022:8080
depends_on:
- database
- mosquitto
environment:
- DATABASE_HOST=database
- DATABASE_USER=teslamate
- DATABASE_PASS=password # the same value as POSTGRES_PASSWORD
- DATABASE_NAME=teslamate
- MQTT_HOST=mosquitto
- API_TOKEN= # openssl rand -hex 32
- TIMEZONE=America/Los_Angeles
tessalytics-web:
image: echocool/tessalytics-web:latest
restart: always
ports:
- 3023:8080
depends_on:
- tessalytics-backend
environment:
- API_UPSTREAM=http://tessalytics-backend:8080API_TOKEN is what the iPhone app uses. The dashboard never gets it: it pairs instead, and the session it receives
is read-only. See the backend's README for the
rest of its settings — MQTT_HOST in particular, without which live lock, sentry, tyre and charge-limit readings
come back as null rather than as values.
Backend on another machine, or a separate stack
Nothing to join and no network wiring — point it at the address the backend answers on:
name: tessalytics-web
services:
tessalytics-web:
image: echocool/tessalytics-web:latest
container_name: tessalytics-web
restart: unless-stopped
environment:
API_UPSTREAM: http://192.168.1.2:3022
ports:
# Localhost only by default: there is no TLS in this container.
- "127.0.0.1:3023:8080"Publish it on 0.0.0.0 — or better, behind a reverse proxy that terminates HTTPS — when the car has to reach it
from elsewhere on the network. The payloads are a complete record of where you drive.
A same-origin direct deployment at a public address such as
http://203.0.113.10:1234 remains compatible: the page and API load, pairing
works, and the screen displays a persistent warning. This is not a secure public
deployment. The read-only session token and vehicle locations are unencrypted
and can be copied by a network observer. Prefer the backend repository's public
Caddy Compose stack, a domain, and HTTPS.
This repository's own docker-compose.yml is the standalone version, ready to docker compose up -d. It joins the network TeslaMate's own Compose project created, so the backend is reachable by service name —
find that network with docker network ls | grep -i teslamate and set TESLAMATE_NETWORK if yours is named
differently.
API_UPSTREAM |
Where the backend is, as this container reaches it. Required. |
PORT |
Host port to publish. Default 3023. |
BIND_ADDRESS |
Host interface. Default 127.0.0.1 — there is no TLS in this container. |
TESLAMATE_NETWORK |
The network TeslaMate's Compose project created, which is how API_UPSTREAM resolves a service name. Default teslamate_default — Compose derives it from the directory TeslaMate was started in, so a directory named tesla-mate yields tesla-mate_default instead. |
TESSALYTICS_WEB_IMAGE |
Override the image: a pinned tag, or ghcr.io/echo-cool/tessalytics-web:latest for GitHub's registry. |
The same image is published to both registries on every push to main:
| Docker Hub | echocool/tessalytics-web |
| GHCR | ghcr.io/echo-cool/tessalytics-web |
Tags: latest follows main, and released versions get 1.2.3 and 1.2 tags. Pin one in production.
GET /healthz answers from nginx alone, so it reports whether the dashboard is serving rather than whether the
backend behind it is up — those are different questions.
There is no TLS in this container, so anything reaching it from outside the LAN goes through one — a tunnel's
origin, Caddy, Traefik, an nginx of your own. That proxy needs the same two settings on /v1/ that this
container's nginx already has, and neither is a default:
location /v1/ {
proxy_pass http://127.0.0.1:3023;
proxy_buffering off; # or every reading is held until a buffer fills
proxy_read_timeout 24h; # nginx's default is 60s
}The live vehicle data is server-sent events over a connection held open for the whole drive. Configuring only this container is the trap: the page loads, the numbers appear once, and then the dashboard quietly stops updating — or updates in bursts — because the outer proxy is buffering the stream or cutting it at its own read timeout. Nothing errors, which is what makes it hard to spot. A one-hour default is long enough to look fine while you test it and short enough to fail on a real drive.
/, /assets/ and /api/ need none of that; they are ordinary short requests.
To build it yourself instead:
API_UPSTREAM=http://tessalytics-backend:8080 \
docker compose -f docker-compose.build.yml up -d --buildThe nginx config disables proxy buffering and read timeouts on /v1/ — without that, the live stream is held in a
buffer and then cut off mid-drive. It also gzips the bundle, which takes it from 240 kB to 75 kB; on a car's
connection that is the difference between a page that appears and one that is still appearing.
npm run build && npx wrangler deploy # or connect the repo to Cloudflare Pagesnpx vercel deploy --prod # vercel.json is committedWarning
A page hosted on the public internet can only talk to a backend that is itself reachable and served over HTTPS.
A backend on your home LAN is neither, and the browser will refuse the request rather than fall back to plaintext —
so a Cloudflare or Vercel deployment needs the backend exposed properly first, with
Cloudflare Tunnel or an
authenticating reverse proxy in front of it. Set VITE_API_BASE_URL to that address at build time and add the
page's origin to the backend's CORS_ORIGINS. If both the car and the backend are on your own network, prefer the
two deployments above; they are simpler and nothing leaves the LAN.
An HTTP page may call an HTTP API for the explicit IP-and-port compatibility
case. A separately hosted public HTTP API additionally requires the build-time
VITE_ALLOW_INSECURE_PUBLIC_HTTP=true; same-origin HTTP needs no flag. HTTPS
pages still cannot call HTTP APIs because browsers enforce mixed-content rules.
| Variable | Where | Meaning |
|---|---|---|
VITE_API_BASE_URL |
build | The API's address as the browser reaches it. Empty (the default) means same origin, which is what you want when the backend or a proxy serves this page. The dashboard never asks for this — see below. |
VITE_ALLOW_INSECURE_PUBLIC_HTTP |
build | Permit a separately hosted public HTTP API. Compatibility only; credentials and location data are unencrypted. |
VITE_CLIENT_LABEL |
build | Optional label shown while pairing, so a phone approving several browsers can tell them apart. |
TESSALYTICS_API |
dev | Where npm run dev proxies the API. |
API_UPSTREAM |
Docker | Where nginx forwards the API. |
Copy .env.example to .env.local (git-ignored) for local values. Never commit a token or a real server address
— .env.example is a template and contains neither.
The backend's address is configuration and nothing else: API_UPSTREAM on the container, or VITE_API_BASE_URL at
build time for a page hosted apart from its backend. There is deliberately no field in the app to type it into — this
runs on a touchscreen in a driver's seat, where a URL and a choice between two ports is the wrong question to ask the
wrong person. When the configured address does not answer, the sign-in screen says which one it tried and what to
check.
- No third-party requests at all. No analytics, no fonts, no CDN, and no map tiles: the route is drawn as a polyline from your own coordinates, so they are never sent to a tile server. It also means the map still draws with no internet.
- No developer-operated anything. The page talks to your backend and nothing else.
- The dashboard keeps its session token in
localStorage, which is what survives the tab reloads a car's browser does on its own. That is acceptable because the token is read-only, expiring and revocable; it would not be acceptable for the API token, which is why the pairing flow exists. Manual API-token sign-in is tab-only by default and is persisted only when the person explicitly checks Remember on this device.
Built for the browser in the car. Tesla's is Chromium, but an old one on MCU2 hardware, so the bundle targets ES2019
and the CSS avoids anything newer than that browser — no flexbox gap, no :has(), no container queries, no
aspect-ratio. Each of those fails silently and takes a layout with it. Modern desktop browsers are of course fine.
Issues and pull requests are welcome. Two conventions worth knowing before you open one:
- A missing reading is never zero. The API answers
nullfor "nobody has reported this", and the UI renders an em dash.?? 0anywhere in the render path is a bug — it turns a cold cache into a flat tyre. - The theme is shared with the iPhone app.
src/theme.cssis a port of the app'sTessalyticsTheme, value for value. Change one and change the other, or the two stop looking like one product.
Run npm run build before pushing; it type-checks, runs the tests, and builds.
.github/workflows/docker.yml publishes to GHCR using the built-in GITHUB_TOKEN, so a fork needs no secrets to
get working images at ghcr.io/<you>/tessalytics-web. Docker Hub is optional and switches on when a token exists:
gh secret set DOCKERHUB_TOKEN # paste a Docker Hub access token
gh variable set DOCKERHUB_USERNAME --body '<your-user>' # only if it is not echocoolWithout the token the Docker Hub steps are skipped and the build still succeeds. The account is a variable rather
than a secret because it is half of every image name anyway. A first GHCR publish may land as a
private package — check Packages → tessalytics-web → Package settings and make it public if you want anonymous
pulls. npm test runs the tests alone —
they cover the two things worth locking down: server-sent-event framing (an event ends with a blank line, and a
parser that drops blank lines discards every reading while reporting itself connected — this has happened) and the
null-versus-zero rule above.
GNU Affero General Public License, version 3 or later — see LICENSE. Use it, self-host it, modify it, share it. The one obligation that matters in practice: if you run a modified version as a service other people can reach, those people are entitled to your modified source. That is the same licence TeslaMate itself uses, which is no accident.
The name and the icon are covered separately by TRADEMARK.md, modelled on TeslaMate's own policy: free for self-hosting, community writing and open-source interoperability; not for commercial products, paid hosting or merchandise trading on the name. The licence governs the code, the trademark policy governs the branding, and neither substitutes for the other.
Earlier commits of this project were published under the MIT licence. Anyone who obtained a copy under those terms keeps them for that copy; everything from this change onward is AGPL-3.0-or-later.
| tessalytics-ios | The iPhone app. TestFlight |
| tessalytics-backend | The API this reads from |
| tessalytics-web | This dashboard |
