Das ist das selbst hostbare Community Aiways Backend
  • TypeScript 95.1%
  • Shell 4.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Timo Erdbrügger 56304ea41f
All checks were successful
CI / build-and-test (push) Successful in 24s
CI / gate (push) Successful in 0s
CI / docker (push) Successful in 3s
CI / release (push) Successful in 22s
CI / deploy (push) Successful in 15s
release: tbox-key-Bundle liefert RSA-Komponenten (n/e/d/p/q) fuer die BLE-Auth
Die AiwaysApp braucht den User-Key als RsaKey (Dezimalstrings) fuer die A001-BLE-
Signatur; das Backend rechnet sie aus dem Private-Key (JWK) statt die App PEM parsen
zu lassen. Feld 'rsa' im /me/tbox-key- und Register-Provisioning-Paket.
2026-09-01 16:56:06 +02:00
.github/workflows release: Release-Tags zaehlen automatisch hoch (#7) 2026-08-27 22:40:47 +02:00
scripts proxmox-lxc.sh: Runner-Setup auf verifizierten Forgejo-v16-Weg (nutzt register-runner.sh) 2026-08-27 15:42:50 +02:00
src release: tbox-key-Bundle liefert RSA-Komponenten (n/e/d/p/q) fuer die BLE-Auth 2026-09-01 16:56:06 +02:00
.env.example release: Registrierung schliessen, Admin-Bootstrap, API-Keys (#2) 2026-08-27 20:53:36 +02:00
.gitignore Forgejo-Migration: CI-Fix, Repo-URLs, .env.example + .gitignore 2026-08-27 12:00:24 +02:00
docker-compose.yml release: Registrierung schliessen, Admin-Bootstrap, API-Keys (#2) 2026-08-27 20:53:36 +02:00
Dockerfile Import AiwaysSelfHostBackend v0.1 (from GitHub main @eef4c5e) 2026-08-27 11:27:36 +02:00
LICENSE Import AiwaysSelfHostBackend v0.1 (from GitHub main @eef4c5e) 2026-08-27 11:27:36 +02:00
package.json release: Position aus dem MQTT-Pfad, Reifendruck skaliert, VIN nachtragbar (#9) 2026-08-30 09:01:04 +02:00
README.md release: erneuter Sweep-Upload rechnet abgeleitete Werte neu (#9) 2026-08-30 09:05:30 +02:00
tsconfig.json Import AiwaysSelfHostBackend v0.1 (from GitHub main @eef4c5e) 2026-08-27 11:27:36 +02:00

Aiways U5 Telemetry Backend (OpenAiways)

Self-hosted Backend (Node/TypeScript + Postgres) für den Aiways U5. Nimmt die im Fahrzeug erzeugte cache_data (GB/T-32960-Realtime-Records, Klartext) auf, parst sie und stellt sie über eine JSON-API + Admin-Dashboard bereit — und puffert Steuerbefehle für den ESP-Kurier.

Teil des OpenAiways-Projekts — Wiederherstellung der App-Funktionalität für U5-Fahrzeuge, nachdem der Hersteller den europäischen Cloud-Dienst abgeschaltet hat. Right-to-Repair am eigenen Fahrzeug.

Features (V1)

  • Telemetrie-Sink: dbcvpage-Ingest (TBox Port 50000 — der primäre Lesepfad, ~1000 Signale je Sweep), cache_data-Ingest (roh), .inx-Ingest (Recorder-Snapshots mit GPS-Spur, Zellspannungen und Zelltemperaturen) + JSON-Ingest für reiche, bereits dekodierte Records (Reichweite, Zellspannungen, Temperaturen, GPS).
  • Details & Diagnose: die volle Signalfläche eines Sweeps — Messwerte mit Rohwert und physikalischem Wert, Fault-Flags getrennt davon (siehe unten).
  • Command-Queue: User reihen Steuerbefehle ein; der ESP-Kurier pullt offene und bestätigt (ack) sie — „eventually consistent" Steuerung.
  • Auth & Nutzer: Login (Token), rollenbasiert. Beim ersten Start entsteht automatisch ein Admin-Konto; Selbstregistrierung ist standardmäßig aus — weitere Konten legt der Admin im Dashboard an.
  • API-Keys: Zugang für Geräte und Integrationen unabhängig vom Benutzerkonto — einzeln widerrufbar, optional mit Ablaufdatum.
  • Admin-Dashboard: Web-UI (Fahrzeuge, Live-Status, Telemetrie-Chart, Details, Diagnose, Command-Queue, Nutzerverwaltung, API-Sandbox) unter /. Die Telemetrie-Tabelle ist pro Spalte sortier- und filterbar (siehe unten).

Datenfluss

TBox (/mnt/sdcard/ACQB/cache_data)  --FTP-->  Bridge/Uploader  --HTTP POST-->  dieses Backend  -->  Postgres  -->  API/Frontend
                       cache_info (index_w)                      X-Index-W

Die gesamte Parse-Logik liegt serverseitig — die spätere MCU-Bridge muss nur Datei ziehen und roh POSTen.

Was der Parser liefert (verifiziert an echten Daten)

Aus dem 0x01-Block jedes 1048-Byte-Records: Zeitstempel, SoC, Packspannung, Packstrom, Speed, Odometer, Gang. GB32960-Invalid-Marker (0xFF/0xFFFF) werden zu null.

Noch nicht enthalten (Quelle steht noch nicht final): Reichweite (VCUDrivingrange). Kommt über den Store-Getter der Bridge; Schema/Parser sind dafür vorbereitet.

.inx-Snapshots (INTEST-Recorder)

Die TBox schreibt zusätzlich zu cache_data einen vollen Fahrzeug-Recorder nach /mnt/sdcard/data/*.inx (per FTP abholbar). Diese Dateien liefern das, was der cache_data-Pfad nicht hergibt — GPS-Spur, Zellspannungen und Zelltemperaturen.

Container: 56-Byte-Header (INTEST 2.1), danach längen-präfixierte zlib-Blöcke — erst ein Meta-Block mit der Signalanzahl, dann das Schema (Records à 114 Byte, GBK-Signalnamen), dann die Sample-Blöcke (bei den Referenzdateien 1005 Signale, 2447 Byte je Sample, ~1 Sample/s).

Pro Sample dekodiert (verifiziert an 5 echten Aufzeichnungen):

Feld Quelle im Sample Prüfung
Zeitstempel f32 DDMMYY + f32 HHMMSS deckt sich mit dem Dateinamen
lat / lon f64 NMEA ddmm.mmmm Spur liegt auf der gefahrenen Route
altitude, heading f32 Kurs vs. GPS-Bahn: 0,9° Median-Abweichung
speed f32 km/h vs. aus GPS-Fixes gerechnet: 1,5 km/h Median-Abweichung
odometer f64 km monoton steigend, +16 km über eine Fahrt
cells 96 f32 Summe deckt sich mit der Packspannung
temps 24 f32 (Invalid = 215) 1820 °C
packVoltage f32 ×0,1 V folgt der Zellsumme (r = 0,99)

SoC bleibt null. Die Reihenfolge der Werte im Sample ist nicht 1:1 zur Schema-Reihenfolge, und aus dem Schema allein lässt sich nicht ableiten, welches Signal welchen Slot bekommt. Zellen/Temperaturen/Packspannung werden deshalb pro Datei über ihre physikalische Signatur lokalisiert; für den SoC gibt es keine solche Signatur — er wird bewusst leer gelassen statt geraten.

Abgeschnittene Dateien (Recorder mittendrin gestoppt) sind kein Fehler: der Parser liefert die lesbaren Records und setzt truncated: true.

dbcvpage — der primäre Lesepfad (TBox Port 50000)

Die TBox beantwortet auf Port 50000 das Kommando dbcvpage <n> mit einer Seite ihrer Live-Signalfläche. Ein Sweep über alle Seiten liefert rund 1000 benannte Signale — deutlich mehr als die MQTT-IPC, der u. a. HV-Packspannung/-strom, die 96 Zellspannungen, Zelltemperaturen und der Odometer fehlen. Deshalb ist das jetzt der primäre Lesepfad; cache_data, .inx und der JSON-Ingest bleiben unverändert daneben bestehen.

Ein Sweep füllt drei Töpfe:

Ziel Inhalt Warum
telemetry die Kerngrößen (SoC, Packspannung/-strom, Odometer, Reichweite, Speed, 96 Zellspannungen, 24 Zelltemperaturen) Charts, /latest, Reichweiten-Schätzung und die App funktionieren unverändert weiter
vehicle_details alle übrigen Messwerte, ein Signal je Zeile, mit Rohwert und physikalischem Wert Skalierung ist nicht bei jedem Signal bestätigt — der Rohwert bleibt nachrechenbar
vehicle_diagnostics die Fault-/Warnflags (Tier H), active = raw != 0 wird ganz anders abgefragt („was ist gerade aktiv?") und wächst anders

Housekeeping-Signale (Rolling-Counter, Checksummen, Gültigkeitsbits) werden verworfen; ihre Zahl steht im Snapshot-Kopfsatz. Timos Referenz-Sweep vom 29.08.2026 mit 930 Signalen teilt sich so auf: 490 Messwerte, 236 Fault-Flags (davon 16 aktiv), 204 verworfen.

Warum schmal statt 1000 Spalten: die Signalliste ist nicht stabil — ein späterer Sweep bringt Seiten dazu. Schmal kostet das kein Schema-Update, und weggelassene Signale hinterlassen keine toten Spalten.

Einordnung und Skalierung kommen aus src/ingest/dbcvpageCatalog.tsgeneriert aus Timos Sweep-Auswertung (991 Signale mit Seite, Domäne, Tier, Kategorie, Einheit, Skala/Offset). Signale, die ein späterer Sweep dazubringt, ordnet eine Heuristik über den Namen grob ein (Fault/Housekeeping/sonstiges); eine Einheit behauptet sie nicht. Die Antwort von /ingest/dbcvpage listet solche Signale unter unknown auf — dann gehört der Katalog nachgezogen.

Nicht in die Kernspalten übernommen: gear, charge_state und status führen dort GB32960-Codes aus cache_data. VCUShftstt/BMSACChgConnectStt zählen anders — beide Kodierungen in einer Spalte wären stiller Datenmüll, sie stehen nur in vehicle_details. Eine Position führt dbcvpage nicht — die kommt aus dem .inx oder aus dem MQTT-Pfad (siehe unten).

Reifendruck ist am Fahrzeug gegengeprüft: der Sweep las roh 96/96/97/98, im Auto standen rund 2,52,6 bar. Der Rohwert ist damit weder kPa noch bar, sondern 2,75 kPa je Schritt (= 0,4 psi, genau das Doppelte der 1,373 kPa, mit denen die Kopfeinheit ihren TPMS-Druck rechnet). /vehicles/:id/latest liefert die vier Räder deshalb fertig als tires — Druck in kPa und bar plus Reifentemperatur.

Sweep aufnehmen:

# Sweep auf dem Laptop im TBox-WLAN ziehen (read-only)
python aichi_dbcvpage_sweep.py --max 65

# Erst nur prüfen, was drinsteckt (schreibt nichts):
curl -X POST "http://localhost:3000/ingest/dbcvpage?dry=1" \
  -H "Content-Type: text/plain" \
  -H "X-Device-Token: $DEVICE_TOKEN" \
  --data-binary @aichi_dbcvpage_1788038278.txt
# -> {"ok":true,"dryRun":true,"ts":"2026-08-29T21:17:58.000Z",
#     "details":490,"diagnostics":236,"activeFaults":16,"dropped":204,"unknown":[]}

Den Zeitstempel zieht der Parser aus dem Dateinamen im Log-Kopf (aichi_dbcvpage_<unixzeit>.txt) — das ist der einzige, den die Datei mitbringt. Fehlt er, muss ?ts= mitkommen. Eine Bridge, die live gegen Port 50000 spricht, schickt stattdessen JSON: {"vin":"L…","ts":"…","signals":{"BMSStateOfCharge":30, …}}.

Der Ingest ist idempotent (Primärschlüssel vehicle_id, ts, signal, source) — derselbe Sweep zweimal hochgeladen ändert nichts. Er schreibt die Zeilen dabei neu, statt sie stehen zu lassen: value und unit sind aus dem Katalog abgeleitet, und eine später am Fahrzeug gegengeprüfte Skalierung (etwa der Reifendruck) zieht sich so durch erneutes Hochladen desselben Sweeps nach. Die Antwort nennt unter refreshed, wie viele Zeilen schon da waren und neu gerechnet wurden.

Position aus dem MQTT-Pfad (/ingest/mqtt)

dbcvpage kennt keine Koordinaten. Die Position steht im zweiten Pfad des Autos: die TBox beantwortet über ihren Broker (admups) ein getNetInfoInd mit getNetInfoRsp, und dessen Body trägt neben Funkzelle und Pegel latitude/longitudeNMEA ddmm.mmmm, wie im .inx (5302.289 = 53°02.289 = 53.03815).

# Ein TBox-Log am Stück; ?year= gibt das Jahr, das die Logzeitstempel nicht mitbringen
curl -X POST "http://localhost:3000/ingest/mqtt?year=2026&dry=1" \
  -H "Content-Type: text/plain" -H "X-Device-Token: $DEVICE_TOKEN" \
  --data-binary @sysi.log.0
# -> {"ok":true,"dryRun":true,"fixes":7,"seen":7,"skipped":0,"duplicates":0,"year":2026, …}

Jeder Fix steht im Log zweimal: einmal als C-Logzeile mit voller Genauigkeit und gleich darauf im JSON der abgehenden MQTT-Nachricht, dort aber auf zwei Nachkommastellen gerundet (rund 20 m gröber). Gelesen wird nur die C-Zeile — sonst zählte jeder Fix doppelt, mit der schlechteren Variante obendrauf.

Eine Bridge, die live am Broker hängt, schickt stattdessen JSON: {"vin":"L…","messages":[{"ts":"…","msgBody":{"latitude":5302.289,"longitude":839.34}}]}. Rechnet sie selbst schon in Dezimalgrad um, muss "format":"deg" dabeistehen — automatisch unterscheiden lässt sich beides nicht (53.038 ist als NMEA lesbar und liegt dann 3000 km daneben).

Gegengeprüft: ein Fix vom 19.08.2026 06:15:00 aus dem Log deckt sich auf ~4 m mit dem .inx-Track derselben Sekunde. Beide Quellen benutzen dieselbe Uhr — die Log-Zeitstempel gehen also ohne Umrechnung in dieselbe Zeitachse wie die übrige Telemetrie.

Schnellstart

A) Docker Compose (empfohlen)

git clone https://forgejo.timo-erdbruegger.de/aiways/Backend.git
cd Backend
cp .env.example .env         # DEVICE_TOKEN + POSTGRES_PASSWORD setzen!
docker compose up -d --build
# Migration läuft automatisch beim App-Start
curl localhost:3000/health   # -> {"ok":true}

B) Proxmox LXC (Homelab)

Das Repo ist öffentlich — kein Token nötig. Auf dem Proxmox-Host als root:

curl -fsSL https://forgejo.timo-erdbruegger.de/aiways/Backend/raw/branch/main/scripts/proxmox-lxc.sh -o proxmox-lxc.sh
bash proxmox-lxc.sh

Geführter Dialog (CTID/Hostname/Ressourcen, optional Auto-Deploy-Runner). Legt einen Debian-12-LXC an, installiert Docker + das Backend, startet Compose und zeigt die URL. Details zu Auto-Deploy siehe Abschnitt CI / CD weiter unten.

C) Lokal / Bare-Metal

npm install
cp .env.example .env
# Postgres bereitstellen (z.B. docker run ... postgres:16), DATABASE_URL setzen
npm run migrate
npm run dev

Testdaten laden (ohne Auto)

Mit lokalen Dumps aus einem TBox-FTP-Pull:

npm run ingest -- ./cache_data ./cache_info <VIN?>

Oder per HTTP, so wie es später die Bridge macht:

INDEX_W=$(node -e "const b=require('fs').readFileSync('cache_info');console.log(b.readUInt32LE(4))")
curl -X POST "http://localhost:3000/ingest/cache-data?index_w=$INDEX_W" \
  -H "Content-Type: application/octet-stream" \
  -H "X-Device-Token: $DEVICE_TOKEN" \
  -H "X-VIN: LXXXXXXXXXXXXXXXX" \
  --data-binary @cache_data

.inx-Snapshots genauso — lokal oder per HTTP:

npm run ingest -- --inx ./trip.inx <VIN?>

# Erst nur prüfen, was in der Datei steckt (schreibt nichts):
curl -X POST "http://localhost:3000/ingest/inx?dry=1" \
  -H "Content-Type: application/octet-stream" \
  -H "X-Device-Token: $DEVICE_TOKEN" \
  --data-binary @trip.inx
# -> {"ok":true,"dryRun":true,"version":"INTEST 2.1","signals":1005,
#     "sampleSize":2447,"parsed":2785,"truncated":false,"inserted":0}

API

Auth = Authorization: Bearer <token> (aus Login/Registrierung). Geräte = X-Device-Token. Vollständige, interaktive Übersicht im Dashboard-Tab Sandbox bzw. unter GET /sandbox (Dev).

Methode Pfad Auth Zweck
GET / Admin-Web-Dashboard
POST /auth/register Selbstregistrierung — standardmäßig 403, nur mit ALLOW_REGISTRATION=true
POST /auth/login Login → {user,token}
GET /auth/me Login Aktueller Nutzer
POST /auth/password Login Eigenes Passwort ändern {currentPassword,newPassword}
GET /vehicles Bearer/API-Key Fahrzeuge auflisten
GET /vehicles/:id/owners Bearer/API-Key Halter-Historie (wer das Fahrzeug wann besass)
POST /vehicles/:id/owner Admin Halterwechsel eintragen. Body {userId, at?}at = Übernahmezeitpunkt (Default jetzt)
PATCH /vehicles/:id Admin VIN oder Name korrigieren {vin?, name?} — Telemetrie, Sweeps und Halter-Historie bleiben an der Fahrzeug-ID
GET /vehicles/:id/latest Bearer/API-Key Zuletzt bekannter Zustand: je Messgröße ihr letzter Wert mit eigenem Zeitstempel. ?flat=1 = alte Form (ein Record). :id = Fahrzeug-ID oder VIN
GET /vehicles/:id/timeseries?from&to&f_<spalte>&sort&dir&every&limit&offset Bearer/API-Key Zeitreihe. :id = Fahrzeug-ID oder VIN. Filtern/Sortieren serverseitig per SQL; filterbar sind u. a. soc, speed, odometer, gear, range_km, lat, lon, altitude, heading sowie die Kategorie-Spalten source, charge_state, status; limit = Seitengröße (Default 100, max 50000), offset blättert, every = jede N-te Zeile. Trefferzahl im Header X-Total-Count
POST /commands Bearer/API-Key Steuerbefehl einreihen {vehicleId|vin,type,payload?,expiresInSec?}
GET /commands Bearer/API-Key Befehle auflisten (?vehicle_id=&status=&limit=)
GET /commands/pull?vehicle_id= Device/API-Key ESP-Kurier: offene Befehle holen (→ sent)
POST /commands/:id/ack Device/API-Key ESP-Kurier: bestätigen {status,result?}
GET /users, POST /users Admin-Login Nutzerverwaltung
GET /api-keys Admin-Login API-Keys auflisten (nie mit Geheimnis)
POST /api-keys Admin-Login API-Key anlegen {name,role?,expiresInDays?} → Klartext nur hier
DELETE /api-keys/:id Admin-Login API-Key widerrufen
POST /ingest/cache-data Device Roh-cache_data (?index_w= oder X-Index-W)
POST /ingest/inx Device Roh-.inx-Snapshot (INTEST-Recorder), ?dry=1 parst nur
POST /ingest/telemetry Device JSON-Ingest reicher Records (Reichweite/Zellen/Temps/GPS)
POST /ingest/dbcvpage Device dbcvpage-Sweep (TBox Port 50000) — Sweep-Log als text/plain oder JSON, ?dry=1 parst nur
POST /ingest/mqtt Device Position aus dem MQTT-Pfad (getNetInfoRsp) — TBox-Log als text/plain (?year=) oder JSON einer Live-Bridge, ?dry=1 parst nur
GET /vehicles/:id/snapshots Bearer/API-Key Liste der Sweeps (Zeitpunkt, Zahl der Messwerte/Flags)
GET /vehicles/:id/details Bearer/API-Key Messwerte eines Sweeps, ein Signal je Zeile. ?ts=&domain=&tier=&category=&signal=&sort=&dir=&limit=&offset=, ?history=1&from=&to= für den Verlauf
GET /vehicles/:id/diagnostics Bearer/API-Key Fault-Flags eines Sweeps — per Default nur die aktiven, ?all=1 auch die stillen
GET /health Health-Check

Konfiguration (.env)

Variable Default Zweck
DATABASE_URL postgres://openaiways:changeme@localhost:5432/aiways Postgres-Verbindung
PORT 3000 HTTP-Port
DEVICE_TOKEN Shared Secret für Ingest + Command-Pull/Ack (X-Device-Token)
AUTH_SECRET (unsicherer Dev-Default) Signatur der Login-Tokens; in Produktion setzen
ALLOW_REGISTRATION true Registrierung offen/gesperrt (erster Nutzer immer erlaubt)
NODE_ENV production schaltet die Dev-Sandbox (/sandbox) ab

Entwicklung

npm run build      # TypeScript -> dist/
npm test           # build + Selbsttests (Parser + Auth, ohne DB)

Konten & Zugänge

Es gibt keine offene Selbstregistrierung. POST /auth/register antwortet mit 403, solange ALLOW_REGISTRATION=true nicht ausdrücklich gesetzt ist (gedacht für lokale Entwicklung). Ein Registrierungsprozess mit Verifizierung kommt später.

Admin-Bootstrap. Startet das Backend mit leerer users-Tabelle, legt es genau ein Admin-Konto an:

  • ADMIN_USERNAME (Default admin), ADMIN_PASSWORD, optional ADMIN_EMAIL.
  • Ohne ADMIN_PASSWORD wird ein Zufallspasswort erzeugt und einmalig ins Server-Log geschrieben (docker compose logs backend) — danach bitte über POST /auth/password ändern. Ist ein ADMIN_PASSWORD gesetzt, taucht es nie im Log auf.
  • Der Schritt ist idempotent: existiert schon ein Nutzer, passiert nichts.

Alle weiteren Konten legt der Admin im Dashboard-Tab Nutzer an.

API-Keys

API-Keys hängen an keinem Benutzerkonto — gedacht für den ESP-Kurier, die Upload-Bridge und andere Integrationen. Anlegen im Dashboard-Tab API-Keys oder per POST /api-keys; der Klartext (oaw_…) ist genau einmal sichtbar, in der Datenbank liegt nur sein SHA-256-Hash.

curl "$BASE/vehicles" -H "X-API-Key: oaw_..."
curl "$BASE/vehicles" -H "Authorization: Bearer oaw_..."      # geht auch
curl -X POST "$BASE/ingest/telemetry" -H "X-API-Key: oaw_..." -d '{...}'
  • Wo sie gelten: Query-API, Command-Queue und alle Ingest-Endpunkte (dort als Alternative zum gemeinsamen DEVICE_TOKEN).
  • Wo nicht: Kontoverwaltung (/auth/me, /auth/password, /users, /api-keys). Dafür braucht es einen echten Login — auch ein Key mit Rolle admin kommt dort nicht durch und kann sich so keine weiteren Schlüssel oder Konten ausstellen.
  • Rolle user (Default) oder admin steuert den Zugriff auf rollengeschützte Datenendpunkte; expiresInDays setzt optional ein Ablaufdatum.
  • Widerruf wirkt sofort. Schlüssel werden nicht gelöscht, sondern als widerrufen markiert, damit die Historie nachvollziehbar bleibt.

Telemetrie-Tab (Dashboard)

Zeitfenster (Von/Bis), Downsampling (every) und Limit bestimmen, was geladen wird; Sortierung und Spaltenfilter arbeiten danach im Browser auf dem geladenen Satz.

  • Sortieren: Spaltenkopf klicken (erneut klicken kehrt die Richtung um, ▲/▼ zeigt sie an). Leere Werte stehen immer am Ende.
  • Filtern: Eingabefeld unter jedem Spaltenkopf. Alle Filter wirken zusammen (UND).
    • Zahlenspalten: 80, >80, >=80, <80, <=80, 10..50 (Bereich, inklusive) oder leer für Nullwerte. Ein unvollständiger Ausdruck wird rot markiert und ignoriert.
    • Textspalten (Zeit, Gang): Teilstring, Groß-/Kleinschreibung egal — z. B. 2026-08-27.
  • Der Chart zeigt immer die gefilterte Auswahl in Zeitreihenfolge; die Fußzeile nennt „X von Y Zeilen". Filter zurücksetzen leert alle Spaltenfilter.

Projektstruktur

src/
  server.ts              Fastify-Bootstrap (registriert alle Routen)
  routes/
    api.ts               Ingest- (cache_data/.inx/JSON/dbcvpage) + Query-Endpunkte
    commands.ts          Command-Queue (User-Seite + ESP-Pull/Ack)
    users.ts             Nutzerverwaltung (Admin)
    apikeys.ts           API-Key-Verwaltung (Admin)
    dashboard.ts         Admin-Web-Dashboard (inline SPA)
    sandbox.ts           Dev-API-Übersicht (nur NODE_ENV!=production)
    selftest.ts          Dashboard-Filter-Tests (CI)
  auth/
    crypto.ts            scrypt-Hash + HMAC-Token (zero-dep)
    middleware.ts        requireUser / requireAdmin / *Session / machineOk
    apiKeys.ts           API-Key-DB-Zugriff (nur Hashes)
    bootstrap.ts         legt beim ersten Start das Admin-Konto an
    routes.ts            register/login/me/password/config
    selftest.ts          Auth-Tests (CI)
  vehicles/
    signalQuery.ts       Filter/Sortierung fuer /details und /diagnostics
  users/store.ts         Nutzer-DB-Zugriff
  commands/store.ts      Command-Queue-DB-Zugriff
  ingest/
    cacheDataParser.ts   Record-Parser (Kern, gegen echte Daten verifiziert)
    inxParser.ts         .inx-Recorder-Parser (INTEST-Container, GPS/Zellen/Temps)
    store.ts             DB-Insert (idempotent, inkl. cells/temps/gps)
    cli.ts               lokaler Datei-Ingest (cache_data und --inx)
    selftest.ts          Parser-Tests (CI)
    inxSelftest.ts       .inx-Parser-Tests (synthetischer Container, CI)
    dbcvpage.ts          dbcvpage-Sweep: Parser, Einordnung, Skalierung, Aufteilung
    dbcvpageCatalog.ts   GENERIERT: 991 Signale (Seite/Domaene/Tier/Kategorie/Einheit/Skala)
    dbcvpageSelftest.ts  dbcvpage-Tests (CI)
    snapshotStore.ts     DB-Insert der Snapshots (Kopf + Details + Diagnose, eine Transaktion)
  db/
    schema.sql           Postgres-Schema (Migration, idempotent)
    migrate.ts           Migration-Runner
    pool.ts              pg-Pool
scripts/
  proxmox-lxc.sh         Homelab-Installer (LXC + Backend + Auto-Deploy-Runner)
  register-runner.sh     Forgejo-Actions-Runner registrieren

CI / CD

Die Workflow (.github/workflows/main.yml, läuft auf Forgejo Actions) hat fünf Jobs:

Job Wann Zweck
build-and-test jeder Push/PR npm install + build + Parser-Selbsttest
docker nach Tests Docker-Smoke-Build (überspringt sauber, wenn der Runner keinen Docker-Daemon hat)
gate jeder Push prüft, ob die Commit-Message das ganze Wort release enthält
release wenn gate = true baut ein tar.gz-Artefakt und legt via Forgejo-API ein Repo-Release an (v<version>-<sha>)
deploy nach release, wenn aktiviert rollt das Backend auf dem eigenen LXC aus (rsync/opt/openaiwaysdocker compose up)

Auto-Deploy auf ein eigenes LXC (self-hosted Runner)

Der deploy-Job läuft auf einem self-hosted Forgejo-Runner mit dem Label deploy, der auf dem Ziel-LXC selbst läuft (Host-Mode). Er ist dormant, bis beides gilt: ein solcher Runner ist registriert und die Repo-Variable DEPLOY_ENABLED=true ist gesetzt — sonst wird der Job übersprungen (keine hängenden Runs).

Einmaliges Setup (auf dem Proxmox-Host als root):

Das Repo ist öffentlich — für die Installation werden keine Zugangsdaten benötigt. Nur die Runner-Registrierung braucht einen Token (s. u.).

  1. Runner-Werte holen: Repo → Settings → Actions → Runners → „Create new Runner" → dort stehen UUID und Token (Forgejo v16; beide werden gebraucht, nur einmalig sichtbar).
  2. LXC + Backend + Runner provisionieren. Interaktiv (geführter Dialog wie bei den Proxmox-VE-Helper-Scripts — Default vs. Erweitert, eigene CTID/Hostname/Ressourcen; Auto-Deploy ja/nein + UUID+Token werden im Dialog abgefragt):
    curl -fsSL https://forgejo.timo-erdbruegger.de/aiways/Backend/raw/branch/main/scripts/proxmox-lxc.sh -o proxmox-lxc.sh
    bash proxmox-lxc.sh
    
    Oder unbeaufsichtigt (NON_INTERACTIVE=1 erzwingt den Dialog-losen Weg):
    NON_INTERACTIVE=1 FORGEJO_RUNNER_UUID=<uuid> FORGEJO_RUNNER_TOKEN=<token> bash proxmox-lxc.sh
    
    Das legt den LXC an, installiert Docker + Backend (/opt/openaiways, mit .env), und registriert per scripts/register-runner.sh einen act_runner (Label deploy, Host-Mode) als systemd-Dienst. Runner nachträglich (falls beim Install übersprungen): im LXC cd /opt/openaiways && UUID=<uuid> TOKEN=<token> bash scripts/register-runner.sh.
  3. Auto-Deploy scharfschalten: Repo → Settings → Actions → VariablesDEPLOY_ENABLED = true.

Ab dann: Ein Commit mit dem Wort releaserelease-Job baut + veröffentlicht → deploy-Job spiegelt den Stand nach /opt/openaiways und startet Compose neu; die .env (Secrets) am Zielort bleibt unangetastet, pgdata als Named Volume erhalten.

Status: Die Deploy-Seite der Workflow ist aktiv, aber das LXC-/Runner-Setup ist noch nicht end-to-end an echter Hardware verifiziert (bisher existiert kein Ziel-LXC). Der Ablauf ist so aufgebaut, dass ohne Runner/Variable nichts passiert.

Lizenz

MIT

Fahrzeug-Eigentum und Sichtbarkeit

Telemetrie hängt nicht nur an der VIN, sondern an Halter + Zeitraum (Issue #5). vehicle_owners führt je Fahrzeug eine Kette halboffener Intervalle [from_ts, to_ts).

Ein Nutzer sieht einen Telemetrie-Record genau dann, wenn dessen ts in eines seiner Intervalle fällt. Beim Halterwechsel wandert deshalb nicht die Historie mit: der Vorbesitzer behält seine Daten, der neue Halter fängt bei seinem Übernahmezeitpunkt an.

# Halterwechsel zum 01.07. eintragen (nur Admin)
curl -X POST "$BASE/vehicles/1/owner" -H "Authorization: Bearer $TOKEN" \
     -H 'Content-Type: application/json' -d '{"userId":3,"at":"2026-07-01T00:00:00Z"}'

Wer wie viel sieht:

Zugang Sichtbarkeit
Login-Token, Rolle admin alles
Login-Token, Rolle user die eigenen Fahrzeuge, jeweils nur die eigenen Besitz-Zeiträume
API-Key, Rolle admin alles
API-Key, Rolle user wie sein Inhaber (userId bei der Erstellung, sonst der ausstellende Admin)

Das gilt für /vehicles, /vehicles/:id/latest, /vehicles/:id/timeseries, die Halter-Historie und die Command-Queue. Fremde Fahrzeuge und Befehle liefern 404 — nicht 403, damit die blosse Existenz einer ID nichts verrät.

Ein API-Key für ein Steuergerät legt man deshalb am besten für den Halter an:

curl -X POST "$BASE/api-keys" -H "Authorization: Bearer $ADMIN_TOKEN" \
     -H 'Content-Type: application/json' -d '{"name":"esp-alice","role":"user","userId":2}'

Neu per Ingest angelegte Fahrzeuge bekommen automatisch einen Halter: den Inhaber des verwendeten API-Keys, beim gemeinsamen DEVICE_TOKEN den dienstältesten Admin. Bestandsfahrzeuge sind bei der Migration rückwirkend (-infinity) dem dienstältesten Admin zugeordnet worden, damit durch die Umstellung keine vorhandene Telemetrie unsichtbar wird.

/vehicles/:id/latest — zuletzt bekannter Zustand

Ein einzelner Telemetrie-Record hat fast immer Löcher: .inx führt keinen SoC, cache_data keine Zellspannungen. Der neueste Record ist deshalb selten der vollständigste. /latest sucht daher je Messgröße den letzten Record, in dem sie überhaupt gesetzt war — jede bringt ihren eigenen Zeitstempel mit:

{
  "soc":          { "ts": "2026-08-20T10:00:00.000Z", "value": 70 },
  "pack_voltage": { "ts": "2026-08-10T10:00:00.000Z", "value": 373.6 },
  "odometer":     { "ts": "2026-08-25T10:00:00.000Z", "value": 1500 },
  "gps":          { "ts": "2026-08-10T10:00:00.000Z", "lat": 51.5, "lon": 7.4 },
  "cells":        { "ts": "2026-08-01T10:00:00.000Z", "value": [3.7, 3.8] },
  "source":       { "ts": "2026-08-25T10:00:00.000Z", "value": "json" },
  "tires":        { "frontLeft": { "ts": "2026-08-29T21:17:58.000Z",
                                   "pressureKpa": 264, "pressureBar": 2.64, "tempC": 20 } }
}

Skalare liefern {ts, value}, GPS {ts, lat, lon} (lat und lon stammen garantiert aus demselben Record). Messgrößen, für die es überhaupt keinen Wert gibt, fehlen im Objekt. Zeitstempel sind ISO 8601 in UTC, wie überall sonst in der API.

Nach einem dbcvpage-Sweep kommen zwei Blöcke dazu, die nicht aus telemetry stammen: snapshot (Kopfsatz des jüngsten Sweeps) und tires — Druck je Rad in kPa und bar plus Reifentemperatur, aus vehicle_details gezogen. Das spart Dashboard und App den Umweg über /details samt Wissen um die Signalnamen. Räder ohne Messwert fehlen.

Je Feld läuft eine Unterabfrage mit ORDER BY ts DESC LIMIT 1 über den Index (vehicle_id, ts DESC) — sie bricht beim ersten Treffer ab, und alle gehen als ein UNION ALL in einem Roundtrip raus. Die Eigentums-Zeiträume aus dem Abschnitt oben gelten dabei pro Feld: ein Wert aus der Zeit vor der Übernahme ist für den neuen Halter nicht sichtbar.

?flat=1 liefert weiterhin die alte Form (ein einzelner Record) für Clients, die darauf gebaut sind.

Release-Versionen

Tags folgen v<major>.<minor>.<build>:

  • major.minor kommt aus package.json — die steuerst du von Hand.
  • build ist git rev-list --count HEAD, zählt also mit jedem Commit von allein hoch.
  • Der Commit-SHA steht in der Release-Beschreibung, nicht im Tag.

Damit ist jeder Tag nicht nur eindeutig, sondern auch größer als der vorherige — mit dem alten v1.0.0-<sha> liess sich nicht erkennen, welcher Build neuer war. Die Patch-Stelle in package.json wird ignoriert; sie wird durch build ersetzt.

package.json 1.0.0, 31 Commits  ->  v1.0.31
package.json 1.1.0, 31 Commits  ->  v1.1.31