Skip to content

Repository files navigation

Tessalytics logo

Tessalytics Web

Your car's live data, on your car's screen.

CI Docker React 19 TypeScript License: AGPL v3 No tracking

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.

How signing in works

   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.

Quick start

Node 20 or later.

npm install
npm run mock     # a fake backend with generated data, in another terminal
npm run dev      # http://localhost:5173

npm 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 mock

The 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 dev

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

Deploying

Served by the backend (no second container)

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/dist

The backend also finds dist/ automatically when this project is checked out as Tessalytics-Web beside it.

Docker (nothing to build)

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:latest

Then open http://<this-host>:3023 in the car and scan the code with the app.

Compose, beside your TeslaMate stack

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:8080
docker compose up -d tessalytics-web

Then 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:8080

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

Putting your own reverse proxy in front

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 --build

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

Cloudflare Workers / Pages

npm run build && npx wrangler deploy      # or connect the repo to Cloudflare Pages

Vercel

npx vercel deploy --prod                  # vercel.json is committed

Warning

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.

Configuration

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.

Privacy

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

Browser support

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.

Contributing

Issues and pull requests are welcome. Two conventions worth knowing before you open one:

  1. A missing reading is never zero. The API answers null for "nobody has reported this", and the UI renders an em dash. ?? 0 anywhere in the render path is a bug — it turns a cold cache into a flat tyre.
  2. The theme is shared with the iPhone app. src/theme.css is a port of the app's TessalyticsTheme, 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.

Publishing images from a fork

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

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

Licence

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.

Related projects

tessalytics-ios The iPhone app. TestFlight
tessalytics-backend The API this reads from
tessalytics-web This dashboard

About

In-car dashboard for a self-hosted TeslaMate: live vehicle data on the car's own screen, signed in by scanning a QR code with the Tessalytics iPhone app.

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages