- TypeScript 95.1%
- Shell 4.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .github/workflows | ||
| scripts | ||
| src | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
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) |
18–20 °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.ts — generiert 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,5–2,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/longitude — NMEA 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(Defaultadmin),ADMIN_PASSWORD, optionalADMIN_EMAIL.- Ohne
ADMIN_PASSWORDwird ein Zufallspasswort erzeugt und einmalig ins Server-Log geschrieben (docker compose logs backend) — danach bitte überPOST /auth/passwordändern. Ist einADMIN_PASSWORDgesetzt, 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 Rolleadminkommt dort nicht durch und kann sich so keine weiteren Schlüssel oder Konten ausstellen. - Rolle
user(Default) oderadminsteuert den Zugriff auf rollengeschützte Datenendpunkte;expiresInDayssetzt 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) oderleerfür Nullwerte. Ein unvollständiger Ausdruck wird rot markiert und ignoriert. - Textspalten (Zeit, Gang): Teilstring, Groß-/Kleinschreibung egal — z. B.
2026-08-27.
- Zahlenspalten:
- 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/openaiways → docker 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.).
- Runner-Werte holen: Repo → Settings → Actions → Runners → „Create new Runner" → dort stehen UUID und Token (Forgejo v16; beide werden gebraucht, nur einmalig sichtbar).
- 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):
Oder unbeaufsichtigt (curl -fsSL https://forgejo.timo-erdbruegger.de/aiways/Backend/raw/branch/main/scripts/proxmox-lxc.sh -o proxmox-lxc.sh bash proxmox-lxc.shNON_INTERACTIVE=1erzwingt den Dialog-losen Weg):
Das legt den LXC an, installiert Docker + Backend (NON_INTERACTIVE=1 FORGEJO_RUNNER_UUID=<uuid> FORGEJO_RUNNER_TOKEN=<token> bash proxmox-lxc.sh/opt/openaiways, mit.env), und registriert perscripts/register-runner.sheinenact_runner(Labeldeploy, Host-Mode) als systemd-Dienst. Runner nachträglich (falls beim Install übersprungen): im LXCcd /opt/openaiways && UUID=<uuid> TOKEN=<token> bash scripts/register-runner.sh. - Auto-Deploy scharfschalten: Repo → Settings → Actions → Variables →
DEPLOY_ENABLED = true.
Ab dann: Ein Commit mit dem Wort release → release-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.minorkommt auspackage.json— die steuerst du von Hand.buildistgit 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