Phase 10-13: PWA fertig, blockierte Phasen vorbereitet
PWA laeuft: Manifest, Symbole in allen noetigen Groessen und die iOS-Angaben, die Apple statt des Manifests auswertet. Das Startsymbol ist bewusst neutral - die Vier Ringe auf einem Homescreen waeren nach aussen sichtbar und fielen nicht mehr unter die private Nutzung. Vorbereitet, aber nicht ausgefuehrt: Capacitor-Konfiguration, ein Ablage-Adapter fuer Schluesselbund und Keystore (greift defensiv auf die Laufzeit zu, damit die App ohne das Plugin unveraendert weiterlaeuft), die Pfad-Freigabeliste fuer den Reverse Proxy und das Geruest der FMM003-Zuordnungstabelle. Die QR-Seite fuer die Einrichtung war zunaechst ein Fehlgriff: Ich hatte den QR-Erzeuger selbst geschrieben. Der Vergleich gegen eine erprobte Implementierung zeigte 1239 abweichende Module von 3249 - der Code waere unlesbar gewesen. Jetzt liegt eine bewaehrte Bibliothek (MIT, 57 KB) neben der Seite im eigenen Home Assistant. Das erfuellt die eigentliche Anforderung genauso: kein Netzzugriff, der Token verlaesst das eigene Netz nicht. Geprueft ueber einen Umlauf - 233 Zeichen hinein, identisch wieder heraus.
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Capacitor-Konfiguration für die native Hülle.
|
||||||
|
*
|
||||||
|
* **Noch nicht in Betrieb.** Die Pakete sind bewusst nicht installiert; diese
|
||||||
|
* Datei liegt fertig da, damit die Inbetriebnahme nur noch aus den Befehlen in
|
||||||
|
* `../UMSETZUNGSPLAN.md` Phase 10 Stufe 2 besteht:
|
||||||
|
*
|
||||||
|
* npm i -D @capacitor/cli
|
||||||
|
* npm i @capacitor/core @capacitor/ios @capacitor/android
|
||||||
|
* npx cap add ios && npx cap add android
|
||||||
|
* npm run build && npx cap sync
|
||||||
|
* npx cap open ios # in Xcode signieren und aufs Gerät bauen
|
||||||
|
*
|
||||||
|
* Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind
|
||||||
|
* ausschließlich für diese private Installation freigegeben.
|
||||||
|
*
|
||||||
|
* Der Typ ist absichtlich nicht aus @capacitor/cli importiert — das Paket
|
||||||
|
* fehlt ja noch, und ein toter Import würde die Typprüfung brechen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CapacitorKonfiguration {
|
||||||
|
appId: string
|
||||||
|
appName: string
|
||||||
|
webDir: string
|
||||||
|
server?: { androidScheme?: string; iosScheme?: string; cleartext?: boolean }
|
||||||
|
ios?: { contentInset?: string; backgroundColor?: string }
|
||||||
|
android?: { backgroundColor?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const konfiguration: CapacitorKonfiguration = {
|
||||||
|
appId: "app.datametric360",
|
||||||
|
appName: "DataMetric360",
|
||||||
|
webDir: "dist",
|
||||||
|
server: {
|
||||||
|
// https, damit die App als sicherer Ursprung gilt — sonst verweigern
|
||||||
|
// Browser-Bausteine wie die Zwischenablage ihren Dienst.
|
||||||
|
androidScheme: "https",
|
||||||
|
iosScheme: "https",
|
||||||
|
},
|
||||||
|
ios: {
|
||||||
|
// Die App zeichnet bis unter die Statusleiste und hält den sicheren
|
||||||
|
// Bereich selbst über env(safe-area-inset-*) frei.
|
||||||
|
contentInset: "never",
|
||||||
|
backgroundColor: "#161b23",
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
backgroundColor: "#161b23",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default konfiguration
|
||||||
@@ -8,6 +8,15 @@
|
|||||||
/>
|
/>
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<meta name="theme-color" content="#161b23" />
|
<meta name="theme-color" content="#161b23" />
|
||||||
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="./symbol.svg" type="image/svg+xml" />
|
||||||
|
<!-- iOS wertet das Manifest nicht aus und braucht diese beiden Angaben,
|
||||||
|
damit "Zum Home-Bildschirm" ein eigenes Symbol und keine Browserleiste
|
||||||
|
zeigt. -->
|
||||||
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="DataMetric360" />
|
||||||
<title>DataMetric360</title>
|
<title>DataMetric360</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "DataMetric360",
|
||||||
|
"short_name": "DM360",
|
||||||
|
"description": "Fahrzeugdaten, Fahrtenbuch und Tankvorgänge vom eigenen Server.",
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#161b23",
|
||||||
|
"theme_color": "#161b23",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "./symbol.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./symbol-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./symbol-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns='http://www.w3.org/2000/svg' width='512' height='512' viewBox='0 0 512 512'>
|
||||||
|
<rect width='512' height='512' rx='114' fill='#161b23'/>
|
||||||
|
<path d='M150 176h74c48 0 78 31 78 80s-30 80-78 80h-74z' fill='none' stroke='#ffffff' stroke-width='26' stroke-linejoin='round'/>
|
||||||
|
<path d='M330 336V176l46 54 46-54v160' fill='none' stroke='#F50537' stroke-width='26' stroke-linecap='round' stroke-linejoin='round'/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 422 B |
@@ -0,0 +1,88 @@
|
|||||||
|
/* ================================================================
|
||||||
|
Sichere Ablage auf dem Gerät (iOS Keychain / Android Keystore)
|
||||||
|
================================================================
|
||||||
|
Der Zugangstoken hat vollen Zugriff auf die Home-Assistant-Schnittstelle.
|
||||||
|
Im Browser bleibt nur localStorage; in der nativen Hülle gehört er in den
|
||||||
|
Schlüsselbund des Betriebssystems.
|
||||||
|
|
||||||
|
**Noch nicht in Betrieb**: das Plugin ist bewusst keine Abhängigkeit,
|
||||||
|
solange die Capacitor-Hülle nicht gebaut ist. Der Adapter greift deshalb
|
||||||
|
defensiv auf die Laufzeit zu, statt zu importieren — fehlt das Plugin,
|
||||||
|
bleibt es bei der Browser-Variante, und die App läuft trotzdem.
|
||||||
|
|
||||||
|
In Betrieb nehmen (UMSETZUNGSPLAN.md Phase 10 Stufe 2):
|
||||||
|
npm i @aparajita/capacitor-secure-storage
|
||||||
|
Danach ruft main.tsx `nativeAblageEinrichten()` auf. */
|
||||||
|
|
||||||
|
import { BrowserAblage, ablageSetzen, umgebungErkennen, type Ablage } from "./umgebung.ts";
|
||||||
|
|
||||||
|
/** Form des Plugins, so weit die App sie braucht. */
|
||||||
|
interface SicherheitsPlugin {
|
||||||
|
get(optionen: { key: string }): Promise<{ value: string | null }>;
|
||||||
|
set(optionen: { key: string; value: string }): Promise<void>;
|
||||||
|
remove(optionen: { key: string }): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginHolen(): SicherheitsPlugin | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const global = window as unknown as {
|
||||||
|
Capacitor?: { Plugins?: Record<string, unknown> };
|
||||||
|
};
|
||||||
|
const plugin = global.Capacitor?.Plugins?.["SecureStorage"];
|
||||||
|
if (!plugin) return null;
|
||||||
|
const kandidat = plugin as Partial<SicherheitsPlugin>;
|
||||||
|
return typeof kandidat.get === "function" &&
|
||||||
|
typeof kandidat.set === "function" &&
|
||||||
|
typeof kandidat.remove === "function"
|
||||||
|
? (plugin as SicherheitsPlugin)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NativeAblage implements Ablage {
|
||||||
|
readonly #plugin: SicherheitsPlugin;
|
||||||
|
/* Fällt einzeln auf den Browser zurück, wenn ein Aufruf scheitert - besser
|
||||||
|
eine Sitzung ohne Schlüsselbund als eine App, die nicht startet. */
|
||||||
|
readonly #ersatz = new BrowserAblage();
|
||||||
|
|
||||||
|
constructor(plugin: SicherheitsPlugin) {
|
||||||
|
this.#plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lesen(schluessel: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { value } = await this.#plugin.get({ key: schluessel });
|
||||||
|
return value ?? null;
|
||||||
|
} catch {
|
||||||
|
return this.#ersatz.lesen(schluessel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async schreiben(schluessel: string, wert: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.#plugin.set({ key: schluessel, value: wert });
|
||||||
|
} catch {
|
||||||
|
await this.#ersatz.schreiben(schluessel, wert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loeschen(schluessel: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.#plugin.remove({ key: schluessel });
|
||||||
|
} catch {
|
||||||
|
await this.#ersatz.loeschen(schluessel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schaltet auf die sichere Ablage um, sofern die App nativ läuft und das
|
||||||
|
* Plugin vorhanden ist. Gibt zurück, ob umgeschaltet wurde — praktisch für
|
||||||
|
* eine Anzeige in den Einstellungen.
|
||||||
|
*/
|
||||||
|
export function nativeAblageEinrichten(): boolean {
|
||||||
|
if (umgebungErkennen() !== "capacitor") return false;
|
||||||
|
const plugin = pluginHolen();
|
||||||
|
if (!plugin) return false;
|
||||||
|
ablageSetzen(new NativeAblage(plugin));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export * from "./umgebung.ts";
|
|||||||
export * from "./rest.ts";
|
export * from "./rest.ts";
|
||||||
export * from "./live.ts";
|
export * from "./live.ts";
|
||||||
export * from "./warteschlange.ts";
|
export * from "./warteschlange.ts";
|
||||||
|
export * from "./ablageNativ.ts";
|
||||||
|
|
||||||
import { ENTITAETEN } from "./types.ts";
|
import { ENTITAETEN } from "./types.ts";
|
||||||
import type { Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "./types.ts";
|
import type { Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "./types.ts";
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import { createRoot } from "react-dom/client"
|
|||||||
import "@audi-dash/ui/styles.css"
|
import "@audi-dash/ui/styles.css"
|
||||||
import "./stile/grundlage.css"
|
import "./stile/grundlage.css"
|
||||||
import "./stile/audi-schrift.css"
|
import "./stile/audi-schrift.css"
|
||||||
|
import { nativeAblageEinrichten } from "./api"
|
||||||
import { App } from "./App"
|
import { App } from "./App"
|
||||||
|
|
||||||
|
// In der nativen Hülle den Token in den Schlüsselbund legen statt in
|
||||||
|
// localStorage. Ohne Hülle passiert hier nichts.
|
||||||
|
nativeAblageEinrichten()
|
||||||
|
|
||||||
const wurzel = document.getElementById("wurzel")
|
const wurzel = document.getElementById("wurzel")
|
||||||
if (!wurzel) throw new Error("Wurzelelement #wurzel fehlt in index.html")
|
if (!wurzel) throw new Error("Wurzelelement #wurzel fehlt in index.html")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# FMM003 → Home Assistant: Zuordnungstabelle
|
||||||
|
|
||||||
|
**Gerüst, noch nicht ausgefüllt.** Gehört zu Phase 13 des
|
||||||
|
`../UMSETZUNGSPLAN.md`; die Architektur dahinter steht in
|
||||||
|
`../COMPANION_APP_ARCHITECTURE.md` §2b.
|
||||||
|
|
||||||
|
> **Grundregel: nicht raten.** Wie die CAN/FMS-Werte im Codec JSON heißen, ist
|
||||||
|
> unbekannt und je nach Firmware verschieden. Die Tabelle unten wird aus einer
|
||||||
|
> **echten mitgeschnittenen Nachricht** gefüllt, nicht aus der Dokumentation
|
||||||
|
> abgeleitet. Bis dahin bleiben die Felder leer.
|
||||||
|
|
||||||
|
## Datenweg
|
||||||
|
|
||||||
|
```
|
||||||
|
FMM003 (CAN/FMS) ──MQTT über TLS──▶ Mosquitto-Add-on ──▶ MQTT-Integration ──▶ Entitäten
|
||||||
|
```
|
||||||
|
|
||||||
|
Traccar entfällt: Das Gerät liefert bereits JSON, es gibt nichts zu dekodieren.
|
||||||
|
|
||||||
|
## Schritt 1 — Gerät einstellen
|
||||||
|
|
||||||
|
Im Teltonika Configurator:
|
||||||
|
|
||||||
|
| Ort | Einstellung |
|
||||||
|
|---|---|
|
||||||
|
| System → System Settings → Data Protocol | **Codec JSON** |
|
||||||
|
| GPRS → Server Settings | Broker-IP und Port **8883** |
|
||||||
|
| GPRS → Server Settings | Benutzername und Passwort des Brokers |
|
||||||
|
| Security | Wurzelzertifikat, Gerätezertifikat, privater Schlüssel |
|
||||||
|
|
||||||
|
**Firmware-Version hier eintragen:** `________________`
|
||||||
|
|
||||||
|
Codec JSON ist nicht in jeder Firmware enthalten (bei mehreren Modellen erst ab
|
||||||
|
03.28.00). Ohne hochgeladene Zertifikate verweigert das Gerät die
|
||||||
|
MQTT-Verbindung — das ist kein Fehler, sondern Absicht.
|
||||||
|
|
||||||
|
## Schritt 2 — Nachricht mitschneiden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosquitto_sub -h <broker> -p 8883 --cafile ca.crt \
|
||||||
|
-u <benutzer> -P <passwort> -t '#' -v | tee mitschnitt.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Dabei nacheinander: Zündung an, kurz fahren, anhalten, Zündung aus. Danach den
|
||||||
|
Mitschnitt sichten und die Tabelle unten füllen.
|
||||||
|
|
||||||
|
**Nicht ins Repository legen** — der Mitschnitt enthält echte Positionsdaten.
|
||||||
|
|
||||||
|
## Schritt 3 — Zuordnungstabelle
|
||||||
|
|
||||||
|
Aus dem Mitschnitt zu füllen. Die Spalte „Entität" nennt den Namen, unter dem
|
||||||
|
das Backend den Wert erwartet.
|
||||||
|
|
||||||
|
| JSON-Feld | Bedeutung | Einheit | Entität in HA | Gefunden am |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| | Zündung an/aus | 0/1 | `binary_sensor.audi_zuendung` | |
|
||||||
|
| | Geschwindigkeit | km/h | `sensor.audi_geschwindigkeit` | |
|
||||||
|
| | Position Breite | Grad | `device_tracker.audi_position` | |
|
||||||
|
| | Position Länge | Grad | `device_tracker.audi_position` | |
|
||||||
|
| | Kilometerstand | km | `sensor.audi_kilometerstand` | |
|
||||||
|
| | Tankfüllstand | % | `sensor.audi_tankfuellstand` | |
|
||||||
|
| | Drehzahl | min⁻¹ | `sensor.audi_drehzahl` | |
|
||||||
|
| | Kühlmitteltemperatur | °C | `sensor.audi_kuehlmittel` | |
|
||||||
|
| | Bordspannung | V | `sensor.audi_bordspannung` | |
|
||||||
|
| | Zeitstempel | ISO/Epoch | — (Attribut) | |
|
||||||
|
|
||||||
|
Teltonika nennt CAN-Werte oft nur über eine AVL-Kennung (etwa `"12"` für
|
||||||
|
Kraftstoffstand). Deshalb hier immer **beides** notieren: die Kennung aus der
|
||||||
|
Nachricht und ihre Bedeutung aus der AVL-Liste des Geräts.
|
||||||
|
|
||||||
|
## Schritt 4 — MQTT-Integration
|
||||||
|
|
||||||
|
Aus der ausgefüllten Tabelle entstehen die Entitäten. Gerüst für
|
||||||
|
`configuration.yaml`, die Feldnamen in den Vorlagen sind erst danach einsetzbar:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mqtt:
|
||||||
|
binary_sensor:
|
||||||
|
- name: "Audi Zündung"
|
||||||
|
state_topic: "teltonika/<imei>/data"
|
||||||
|
value_template: "{{ 'ON' if value_json.<FELD> | int == 1 else 'OFF' }}"
|
||||||
|
device_class: power
|
||||||
|
|
||||||
|
sensor:
|
||||||
|
- name: "Audi Geschwindigkeit"
|
||||||
|
state_topic: "teltonika/<imei>/data"
|
||||||
|
value_template: "{{ value_json.<FELD> }}"
|
||||||
|
unit_of_measurement: "km/h"
|
||||||
|
state_class: measurement
|
||||||
|
|
||||||
|
device_tracker:
|
||||||
|
- name: "Audi Position"
|
||||||
|
state_topic: "teltonika/<imei>/data"
|
||||||
|
json_attributes_topic: "teltonika/<imei>/data"
|
||||||
|
# latitude/longitude erwartet HA als Attribute
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schritt 5 — Fahrterkennung umstellen
|
||||||
|
|
||||||
|
Neues `pyscript/fahrterkennung_fmm003.py` nach dem Muster von
|
||||||
|
`pyscript/fahrterkennung.py`, mit zwei Unterschieden:
|
||||||
|
|
||||||
|
1. Der Auslöser ist die **Zündungs-Entität**, nicht der WLAN-Sensor.
|
||||||
|
2. Der Startzeitpunkt darf nicht mehr nur im Arbeitsspeicher liegen — beim
|
||||||
|
FMM003 gibt es keinen Grund mehr für diese Einschränkung, weil der Zustand
|
||||||
|
jederzeit vom Gerät neu gelesen werden kann.
|
||||||
|
|
||||||
|
**Unverändert übernehmen:** die Pausentoleranz (`fahrten_pausenzeit_min` aus dem
|
||||||
|
Profil, Standard 15 Minuten) über `task.unique()` und `task.sleep()`. Das ist
|
||||||
|
eine bewusste Produktentscheidung, dreimal durchdacht: Springt der Motor
|
||||||
|
innerhalb der Toleranz wieder an, ist es **eine** Fahrt, nicht zwei. Die Zündung
|
||||||
|
fällt bei Start-Stopp nicht ab, ein zusätzlicher Filter dafür ist also
|
||||||
|
unnötig.
|
||||||
|
|
||||||
|
Vorgehen: erst parallel laufen lassen und in eine Testdatei schreiben, mit der
|
||||||
|
bisherigen Erkennung vergleichen, dann scharf schalten. Danach
|
||||||
|
`WLAN_SENSOR` aus `pyscript/modules/einstellungen.py` entfernen.
|
||||||
|
|
||||||
|
## Schritt 6 — Live-Ansicht einschalten
|
||||||
|
|
||||||
|
In `companion-app/src/funktionen.ts` den Schalter `LIVE_VERFUEGBAR` auf `true`
|
||||||
|
setzen und in `companion-app/src/screens/LiveFahrt.tsx` die Feldnamen in
|
||||||
|
`liveZustandLesen()` an die tatsächlichen Entitäten anpassen.
|
||||||
|
|
||||||
|
Damit werden auch die Fahrtdetails erstmals echte Routen zeigen, statt nur
|
||||||
|
Start- und Zielpunkt.
|
||||||
|
|
||||||
|
## Noch zu entscheiden
|
||||||
|
|
||||||
|
- **Erreichbarkeit des Brokers für das Fahrzeug:** Portfreigabe 8883 direkt auf
|
||||||
|
Mosquitto gegen VPS-Broker mit Mosquitto-Bridge über Tailscale. Ersteres ist
|
||||||
|
einfacher, aber eine echte Freigabe; letzteres öffnet zuhause nichts, kostet
|
||||||
|
aber einen zusätzlichen Server.
|
||||||
|
- **Datenvolumen:** Codec JSON braucht deutlich mehr Bytes als das binäre
|
||||||
|
Codec8E. Bei einem privat genutzten Fahrzeug vernachlässigbar, bei knappem
|
||||||
|
Mobilfunktarif im Blick behalten.
|
||||||
@@ -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
|
||||||
@@ -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