Merge branch 'umsetzung-datametric360': Companion-App-Phasen 1-10 uebernehmen
Bringt die vollstaendig gebaute DataMetric360-App zusammen (React/Vite, 21 Screens, Audi-Assets, PWA + native Capacitor-Huelle, 90+9 Tests) sowie die profil_lesen()-Haertung gegen fehlende/kaputte Profildatei zusammen. Konfliktaufloesung: - AGENTS.md, INSTALL.md, README.md: main-Fassung war jeweils die chronologisch neuere, uebernommen und um die durch den Merge tatsaechlich erledigten Punkte ergaenzt (profil_lesen()-Haertung, Audit-Reste-Entscheidung). - fahrterkennung.py: toten WLAN-Zweig vom Branch verworfen, Zuendungs- basierte Erkennung von main behalten. - homeassistant/FMM003_MAPPING.md (MQTT/Mosquitto-Ansatz vom 2026-08-11, vor der Umstellung auf flespi) bewusst nicht uebernommen - main nutzt seit 2026-08-12 flespi als alleinigen FMM003-Datenweg. UMSETZUNGSPLAN.md Phase 13 entsprechend als ueberholt markiert, verweist auf AGENTS.md als massgeblich. - REVIEW_main_2026-08-13.md, ha_install.md (add/add): main-Fassung war die spaetere Revision derselben Dokumente, uebernommen. Die von REVIEW_main_2026-08-13.md befuerchtete Merge-Falle (profil_lesen() gibt jetzt None zurueck, main-seitige Aufrufer pruefen das nicht) wurde verifiziert als bereits entschaerft: alle 5 Aufrufstellen im gemergten Stand (backup.py x2, fahrterkennung.py, frontend_veroeffentlichung.py, reifenzaehler.py, tankerkennung.py) sind None-sicher. installationspaket/ nicht Teil dieses Commits (gitignored, wird bei Bedarf neu zusammengestellt).
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# Reverse-Proxy: Pfad-Freigabeliste für DataMetric360
|
||||
|
||||
**Vorbereitung, noch nicht in Betrieb.** Gehört zu Phase 12 des
|
||||
`../UMSETZUNGSPLAN.md`; die Entscheidung dahinter steht in
|
||||
`../COMPANION_APP_ARCHITECTURE.md` §4.
|
||||
|
||||
Ziel: Die App erreicht Home Assistant von unterwegs, **ohne dass Home Assistant
|
||||
selbst im Internet steht**. Dazu ein Cloudflare-Tunnel (baut nur nach außen auf,
|
||||
kein Port am Router) auf einen Reverse Proxy, der ausschließlich die unten
|
||||
aufgeführten Pfade durchlässt.
|
||||
|
||||
## Was die App wirklich braucht
|
||||
|
||||
Aus dem tatsächlichen Code der Datenschicht (`companion-app/src/api/`)
|
||||
abgeleitet, nicht geschätzt:
|
||||
|
||||
| Methode | Pfad | Wofür |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/` | Verbindungsprüfung bei der Ersteinrichtung |
|
||||
| `GET` | `/api/states/pyscript.audi_dashboard_*` | Profil, Fahrten, Tankvorgänge, Status, Batterieverlauf, Belegergebnis, Updatestatus |
|
||||
| `GET` | `/api/states/pyscript.reifen_*` | Reifensatz-Kilometerstände |
|
||||
| `POST` | `/api/services/pyscript/audi_dashboard_*` | alle schreibenden Vorgänge |
|
||||
| `GET` (Upgrade) | `/api/websocket` | Live-Aktualisierung |
|
||||
|
||||
Alles andere wird geblockt, insbesondere `/auth/*`, `/lovelace*`, `/config*`,
|
||||
`/api/config`, `/api/history*`, `/developer-tools*` und die Oberfläche selbst.
|
||||
|
||||
## Ehrliche Einschränkung
|
||||
|
||||
**Der WebSocket lässt sich nicht pfadgenau beschneiden.** Nach `auth_ok` kann
|
||||
über `/api/websocket` grundsätzlich jeder Zustand gelesen werden, nicht nur die
|
||||
`pyscript.*`-Entitäten. Die Freigabeliste ist an dieser Stelle also grobkörniger
|
||||
als beim REST-Zugriff.
|
||||
|
||||
Was bleibt: Ohne gültigen Token kommt gar keine Verbindung zustande, und ein
|
||||
verlorenes Gerät wird durch Zurückziehen genau seines Tokens ausgesperrt. Das
|
||||
ist die im Architekturdokument bewusst akzeptierte Abwägung — sie sollte nur
|
||||
nicht in Vergessenheit geraten.
|
||||
|
||||
Wer sie nicht eingehen will, hat eine Alternative: den WebSocket weglassen und
|
||||
die App auf regelmäßiges Abfragen umstellen. Kostet Akku und Datenvolumen,
|
||||
verkleinert die Angriffsfläche aber auf die exakt aufgeführten REST-Pfade.
|
||||
|
||||
## Nginx Proxy Manager
|
||||
|
||||
Im Add-on unter *Hosts → Proxy Hosts → Edit → Advanced* eintragen. Ziel ist der
|
||||
interne Name der Home-Assistant-Instanz (im Supervisor-Netz `homeassistant:8123`).
|
||||
|
||||
```nginx
|
||||
# Reihenfolge zählt: die erlaubenden Blöcke stehen vor dem pauschalen Verbot.
|
||||
|
||||
location = /api/ {
|
||||
proxy_pass http://homeassistant:8123;
|
||||
include conf.d/include/proxy.conf;
|
||||
}
|
||||
|
||||
location ~ ^/api/states/pyscript\.(audi_dashboard_[a-z_]+|reifen_[a-z_]+)$ {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://homeassistant:8123;
|
||||
include conf.d/include/proxy.conf;
|
||||
}
|
||||
|
||||
location ~ ^/api/services/pyscript/audi_dashboard_[a-z_]+$ {
|
||||
limit_except POST { deny all; }
|
||||
proxy_pass http://homeassistant:8123;
|
||||
include conf.d/include/proxy.conf;
|
||||
}
|
||||
|
||||
location = /api/websocket {
|
||||
proxy_pass http://homeassistant:8123;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 3600s;
|
||||
include conf.d/include/proxy.conf;
|
||||
}
|
||||
|
||||
# Die Fotos der Fahrzeuge, die die App anzeigt. Nur Lesen, nur Bilder.
|
||||
location ~ ^/local/bilder/[a-z0-9_-]+\.(webp|png|jpg|svg)$ {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://homeassistant:8123;
|
||||
include conf.d/include/proxy.conf;
|
||||
}
|
||||
|
||||
# Alles Übrige: nicht durchlassen.
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
```
|
||||
|
||||
Wird die App selbst unter derselben Adresse ausgeliefert, braucht sie einen
|
||||
eigenen Block auf ihr Verzeichnis — sauberer ist ein getrennter Hostname
|
||||
(`datametric360.app` für die App, `api.datametric360.app` für die
|
||||
Schnittstelle), dann bleibt diese Liste unverändert.
|
||||
|
||||
## Nach der Einrichtung prüfen
|
||||
|
||||
Von einem Netz ohne VPN, etwa über Mobilfunk:
|
||||
|
||||
```bash
|
||||
API=https://api.datametric360.app
|
||||
T=<Zugriffstoken>
|
||||
|
||||
# muss gehen
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \
|
||||
"$API/api/states/pyscript.audi_dashboard_profil"
|
||||
|
||||
# muss 404 oder 403 liefern
|
||||
curl -s -o /dev/null -w '%{http_code}\n' "$API/auth/authorize"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/config"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \
|
||||
"$API/api/states/sensor.audi_rs_4_avant_mileage"
|
||||
|
||||
# ohne Token: 401, nicht 200
|
||||
curl -s -o /dev/null -w '%{http_code}\n' "$API/api/states/pyscript.audi_dashboard_profil"
|
||||
```
|
||||
|
||||
Zusätzlich: Port 8123 darf von außen **gar nicht** antworten.
|
||||
|
||||
## Offen bis zur Einrichtung
|
||||
|
||||
- Nameserver von `datametric360.app` auf Cloudflare umstellen („Full setup")
|
||||
- Entscheidung Nginx Proxy Manager gegen Traefik (die Liste oben ist für NPM
|
||||
geschrieben und für Traefik sinngemäß zu übertragen)
|
||||
- Endgültige Hostnamen festlegen und hier eintragen
|
||||
@@ -53,6 +53,8 @@ def _backup_jetzt():
|
||||
|
||||
def _letztes_backup_vermerken():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return
|
||||
p.setdefault("einstellungen", {})["letztes_backup"] = datetime.datetime.now().isoformat()
|
||||
profil.profil_schreiben(p)
|
||||
frontend_veroeffentlichung.profil_veroeffentlichen()
|
||||
@@ -69,6 +71,8 @@ def audi_dashboard_backup_jetzt():
|
||||
@time_trigger("cron(0 4 * * *)")
|
||||
def _automatisches_backup():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return
|
||||
intervall = p.get("einstellungen", {}).get("backup_intervall", "aus")
|
||||
heute = datetime.date.today()
|
||||
faellig = (
|
||||
|
||||
@@ -38,7 +38,7 @@ import einstellungen
|
||||
import frontend_veroeffentlichung
|
||||
import profil
|
||||
|
||||
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py" # TODO: Datei ablegen
|
||||
PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- Hilfsfunktionen
|
||||
|
||||
@@ -42,6 +42,8 @@ _fahrt_start_ts = None
|
||||
|
||||
def _pausenzeit_sekunden():
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return 15 * 60
|
||||
return p.get("einstellungen", {}).get("fahrten_pausenzeit_min", 15) * 60
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ import profil
|
||||
|
||||
|
||||
def profil_veroeffentlichen():
|
||||
state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": profil.profil_lesen()})
|
||||
daten = profil.profil_lesen()
|
||||
if daten is None:
|
||||
return
|
||||
state.set("pyscript.audi_dashboard_profil", "aktuell", new_attributes={"daten": daten})
|
||||
|
||||
|
||||
def fahrten_veroeffentlichen():
|
||||
|
||||
@@ -49,10 +49,29 @@ def ordner_sicherstellen():
|
||||
# ----------------------------------------------------------- Fahrzeugprofil
|
||||
|
||||
def profil_lesen():
|
||||
"""Liest das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
|
||||
|
||||
Ohne diese Prüfung reißt eine fehlende Datei (Installation unvollständig,
|
||||
siehe INSTALL.md Schritt 2) jeden Trigger und jeden Service mit, der das
|
||||
Profil braucht — bei laufenden Zeittriggern also im Minutentakt. Jeder
|
||||
Aufrufer muss den None-Fall abfangen."""
|
||||
if not os.path.exists(PROFIL_PFAD):
|
||||
log.error(
|
||||
f"audi_dashboard: {PROFIL_PFAD} fehlt. Siehe INSTALL.md Schritt 2 — "
|
||||
"bis dahin bleiben alle Funktionen aus, die das Profil brauchen."
|
||||
)
|
||||
return None
|
||||
f = task.executor(io.open, PROFIL_PFAD, "r")
|
||||
inhalt = f.read()
|
||||
f.close()
|
||||
return json.loads(inhalt)
|
||||
try:
|
||||
return json.loads(inhalt)
|
||||
except ValueError as fehler:
|
||||
log.error(
|
||||
f"audi_dashboard: {PROFIL_PFAD} ist kein gültiges JSON ({fehler}). "
|
||||
"Letztes Backup aus audi_dashboard/backups/ zurückspielen."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def profil_schreiben(profil):
|
||||
|
||||
@@ -60,6 +60,8 @@ def _fortschreiben_und_veroeffentlichen(neuer_km, neuer_aktiv=None):
|
||||
Reihenfolge, damit beim Wechseln selbst keine km dem neuen Satz
|
||||
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
|
||||
p = profil.profil_lesen()
|
||||
if p is None:
|
||||
return
|
||||
reifen = p.setdefault("reifen", {})
|
||||
saetze = reifen.setdefault("saetze", {})
|
||||
aktiv = reifen.get("aktiv", "sommer")
|
||||
|
||||
@@ -57,7 +57,10 @@ def _als_zahl(wert):
|
||||
|
||||
def _schwelle_prozent():
|
||||
p = profil.profil_lesen()
|
||||
tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
|
||||
if p is None:
|
||||
tankvolumen = STANDARD_TANKVOLUMEN_LITER
|
||||
else:
|
||||
tankvolumen = p.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
|
||||
liter_als_prozent = (LITER_SCHWELLE / tankvolumen) * 100
|
||||
return min(liter_als_prozent, PROZENT_SCHWELLE)
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
DOM-Anbindung (Shadow DOM statt document, siehe $ weiter unten).
|
||||
|
||||
Bekannte Lücken in diesem Stand:
|
||||
- Statistik-Seite (vStat/ST) zeigt weiterhin Beispielzahlen, keine echte
|
||||
Auswertung aus TRIPS/FILLS - eigenes Arbeitspaket.
|
||||
- Kein Bild-Ordner vorhanden; Bilder fallen auf die im Prototyp bereits
|
||||
vorgesehenen Platzhalter-Maße zurück (§7a), erscheinen aber als
|
||||
gebrochene Bilder, bis bilder/ tatsächlich befüllt ist.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DataMetric360 — Gerät verbinden</title>
|
||||
<!--
|
||||
Erzeugt den Einrichtungs-QR-Code für die App, vollständig auf dem Gerät.
|
||||
|
||||
Der Punkt dabei: Ein QR-Generator im Internet bekäme den Zugangstoken zu
|
||||
sehen. Diese Seite lädt ihre Bibliothek aus dem eigenen Home Assistant
|
||||
(dm360-qrcode-lib.js daneben) und stellt keine einzige Netzanfrage — der
|
||||
Token verlässt das eigene Netz nicht.
|
||||
|
||||
Ablegen unter /config/www/, aufrufbar als /local/dm360-qr.html.
|
||||
Gegenstück in der App: UMSETZUNGSPLAN.md Phase 10 Stufe 2.
|
||||
-->
|
||||
<script src="./dm360-qrcode-lib.js"></script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
||||
background: #161b23; color: #fff;
|
||||
font: 400 15px/1.55 Helvetica, Arial, sans-serif;
|
||||
}
|
||||
main { width: min(420px, 92vw); padding: 24px; }
|
||||
h1 { font-size: 22px; font-weight: 300; margin: 0 0 6px; }
|
||||
p { color: #9aa1ad; font-size: 13.5px; margin: 0 0 18px; text-wrap: pretty; }
|
||||
label { display: block; font-size: 10px; letter-spacing: .2em;
|
||||
text-transform: uppercase; color: #8a94a3; margin: 14px 0 6px; }
|
||||
input {
|
||||
width: 100%; box-sizing: border-box; padding: 11px 12px; font-size: 16px;
|
||||
background: #1f2733; color: #fff; border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 8px; outline: none;
|
||||
}
|
||||
input:focus-visible { outline: 2px solid #F50537; outline-offset: 2px; }
|
||||
button {
|
||||
margin-top: 18px; width: 100%; padding: 13px; font-size: 15px;
|
||||
background: #fff; color: #000; border: 0; border-radius: 999px; cursor: pointer;
|
||||
}
|
||||
#ausgabe { margin-top: 22px; display: none; text-align: center; }
|
||||
#ausgabe.sichtbar { display: block; }
|
||||
#ausgabe img { width: 100%; max-width: 320px; height: auto;
|
||||
image-rendering: pixelated; background: #fff;
|
||||
padding: 12px; border-radius: 12px; box-sizing: border-box; }
|
||||
.warnung { color: #ffaa00; font-size: 12px; margin-top: 14px; }
|
||||
.fehler { color: #fd2c4e; font-size: 13px; margin-top: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Gerät verbinden</h1>
|
||||
<p>
|
||||
Trag die von außen erreichbare Adresse und einen langlebigen Zugriffstoken ein.
|
||||
Der Code entsteht hier auf dem Gerät; nichts davon wird irgendwohin gesendet.
|
||||
</p>
|
||||
|
||||
<label for="adresse">Server-Adresse</label>
|
||||
<input id="adresse" type="url" placeholder="https://api.datametric360.app" />
|
||||
|
||||
<label for="token">Zugriffstoken</label>
|
||||
<input id="token" type="password" placeholder="in Home Assistant unter Profil → Sicherheit" />
|
||||
|
||||
<button id="erzeugen" type="button">Code anzeigen</button>
|
||||
|
||||
<div id="ausgabe"></div>
|
||||
<p id="meldung" class="fehler" hidden></p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const ausgabe = document.getElementById("ausgabe")
|
||||
const meldung = document.getElementById("meldung")
|
||||
|
||||
function melde(text) {
|
||||
meldung.textContent = text
|
||||
meldung.hidden = !text
|
||||
}
|
||||
|
||||
document.getElementById("erzeugen").addEventListener("click", () => {
|
||||
melde("")
|
||||
ausgabe.classList.remove("sichtbar")
|
||||
|
||||
const url = document.getElementById("adresse").value.trim().replace(/\/+$/, "")
|
||||
const token = document.getElementById("token").value.trim()
|
||||
if (!url || !token) {
|
||||
melde("Bitte Adresse und Token eintragen.")
|
||||
return
|
||||
}
|
||||
|
||||
const inhalt = JSON.stringify({ url, token })
|
||||
try {
|
||||
// Version 0 heißt: kleinstmögliche Version selbst wählen.
|
||||
// Fehlerkorrektur L, weil der Code direkt vom Bildschirm abgelesen
|
||||
// wird — es gibt keinen zerkratzten Aufkleber zu retten.
|
||||
const qr = qrcode(0, "L")
|
||||
qr.addData(inhalt)
|
||||
qr.make()
|
||||
ausgabe.innerHTML = qr.createImgTag(6, 0, "Einrichtungs-Code")
|
||||
ausgabe.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
'<p class="warnung">Wer diesen Code abfotografiert, hat vollen Zugriff auf die ' +
|
||||
"Schnittstelle. Nach dem Einrichten die Seite schließen.</p>",
|
||||
)
|
||||
ausgabe.classList.add("sichtbar")
|
||||
} catch (fehler) {
|
||||
melde(
|
||||
"Der Code konnte nicht erzeugt werden: " +
|
||||
String((fehler && fehler.message) || fehler) +
|
||||
". Ist der Token vollständig eingefügt?",
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user