Installationsskript: eine Zeile statt dreissig Minuten Handarbeit

homeassistant/install.sh richtet das Panel samt Backend auf einer
Home-Assistant-Instanz ein und verdrahtet dabei die Selbstaktualisierung, so
dass danach alles Weitere ueber die Weboberflaeche laeuft: Sensoren zuordnen,
nach Updates suchen, einspielen.

Gedacht fuer das Add-on "Terminal & SSH" direkt auf der Instanz:
  bash <(curl -fsSL <ROH-URL>/homeassistant/install.sh)
Alternativ von einem Rechner aus mit --ziel gegen ein eingebundenes
config-Verzeichnis, oder mit --von gegen eine lokale Arbeitskopie.

Was es tut: pyscript installieren (neueste Version von GitHub, falls nicht
vorhanden), pyscript/ und www/ einspielen, den Belegleser mitliefern (er
liegt in data/, ist aber Code), fehlende Datenbestaende aus der Vorlage
anlegen, den Abschnitt in die configuration.yaml eintragen und
UPDATE_REPO_URL setzen.

Drei Eigenschaften, auf die es dabei ankommt:

- Mehrfach ausfuehrbar. Der Abschnitt in der configuration.yaml steht
  zwischen Markern und wird beim zweiten Lauf ersetzt statt angehaengt.
  Stehen pyscript oder panel_custom bereits ausserhalb dieses Abschnitts,
  weist das Skript darauf hin, statt einen doppelten Schluessel zu erzeugen.
- Vorhandene Daten bleiben unangetastet. Fahrzeugprofil, Fahrten,
  Tankvorgaenge und ein bereits hinterlegtes Token werden nie ueberschrieben,
  nur fehlende Dateien angelegt.
- Die configuration.yaml wird vor jeder Aenderung mit Zeitstempel gesichert.

Geprueft gegen eine frische Home-Assistant-Instanz im Container: HA startet
ohne Konfigurationsfehler, alle neun pyscript-Entitaeten werden
veroeffentlicht, das Panel ist als "Mein Audi" mit mdi:car-sports unter
/audi-dashboard-panel registriert und rendert im Browser mit fuenf Tabs. Ein
zweiter Durchlauf laesst Profil und Token unveraendert und den
Konfigurationsabschnitt genau einmal stehen.

Ausserdem den blockierenden Verlaufsabruf in fahrtabschluss_logik.py
uebernommen: urlopen lief direkt statt ueber task.executor, was Home
Assistant seit 2026.8 abbricht - jede Fahrt waere ohne Strecke geblieben.
Der Fix lag bisher nur im Branch umsetzung-datametric360; eine frische
Installation haette den Fehler sonst mitgebracht.

INSTALL.md nennt den schnellen Weg jetzt vor der ausfuehrlichen Anleitung.
This commit is contained in:
Paul Nothaft
2026-08-13 16:20:34 +02:00
parent 78d70fa4ca
commit c11c492d86
3 changed files with 348 additions and 7 deletions
+39
View File
@@ -34,6 +34,45 @@ Zeitaufwand: ca. 3040 Minuten, größtenteils Warten auf Neustarts.
--- ---
## Der schnelle Weg: install.sh
Für eine neue Instanz gibt es ein Skript, das die Schritte 1 bis 3 und 5
zusammen erledigt. Am einfachsten direkt auf der HA-Instanz im Add-on
**Terminal & SSH**:
```bash
bash <(curl -fsSL https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/homeassistant/install.sh)
```
Liegt das Repository privat (Standard), braucht der Aufruf ein Zugangstoken:
```bash
curl -fsSL -H "Authorization: token <DEIN-GITEA-TOKEN>" \
https://gitea.nothaft.cloud/paul/audi-app/raw/branch/main/homeassistant/install.sh -o install.sh
bash install.sh --repo https://<nutzer>:<token>@gitea.nothaft.cloud/paul/audi-app.git
```
Alternativ von einem Rechner aus gegen ein eingebundenes config-Verzeichnis:
```bash
./install.sh --ziel /Volumes/config --von ~/Development/audi-app
```
Das Skript installiert pyscript, spielt Backend und Oberfläche ein, legt das
Fahrzeugprofil aus der Vorlage an (**vorhandene Daten bleiben unangetastet**),
trägt den Abschnitt in die `configuration.yaml` ein — mit Sicherungskopie und
so, dass ein zweiter Lauf ihn ersetzt statt anhängt — und verdrahtet die
Selbstaktualisierung, damit „Update suchen" in der App funktioniert.
`--hilfe` zeigt alle Optionen.
Danach bleiben nur noch drei Dinge, alle in der Weboberfläche: neu starten,
Sensoren zuordnen (Schritt 4 unten), Fahrzeugdaten eintragen.
Wer lieber jeden Schritt selbst nachvollzieht, folgt der ausführlichen
Anleitung ab hier.
## Schritt 1 — pyscript installieren ## Schritt 1 — pyscript installieren
1. In Home Assistant: **HACS → Integrationen → Explore & Download Repositories** 1. In Home Assistant: **HACS → Integrationen → Explore & Download Repositories**
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env bash
#
# DataMetric360 — Installation in Home Assistant
#
# Richtet das Panel samt Backend auf einer Home-Assistant-Instanz ein und
# verdrahtet die Selbstaktualisierung, damit danach alles Weitere über die
# Weboberfläche läuft: Sensoren zuordnen, nach Updates suchen, einspielen.
#
# Wo ausführen:
# Am einfachsten direkt auf der HA-Instanz, im Add-on "Terminal & SSH":
# bash <(curl -fsSL <ROH-URL dieser Datei>)
# Alternativ von einem Rechner aus gegen ein eingebundenes config-Verzeichnis:
# ./install.sh --ziel /Volumes/config
#
# Mehrfach ausführbar: bereits vorhandene Fahrzeugdaten werden nie
# überschrieben, und der Abschnitt in der configuration.yaml wird beim
# erneuten Lauf ersetzt statt ein zweites Mal angehängt.
#
set -euo pipefail
# ------------------------------------------------------------ Voreinstellungen
REPO_STANDARD="https://gitea.nothaft.cloud/paul/audi-app.git"
BRANCH="main"
ZIEL="/config"
QUELLE=""
TOKEN=""
REPO="$REPO_STANDARD"
PYSCRIPT_INSTALLIEREN="ja"
INTERAKTIV="ja"
MARKE_START="# >>> DataMetric360 (von install.sh verwaltet) >>>"
MARKE_ENDE="# <<< DataMetric360 <<<"
# ------------------------------------------------------------------- Ausgabe
rot() { printf '\033[31m%s\033[0m\n' "$*"; }
gruen(){ printf '\033[32m%s\033[0m\n' "$*"; }
grau() { printf '\033[90m%s\033[0m\n' "$*"; }
schritt(){ printf '\n\033[1m==> %s\033[0m\n' "$*"; }
fehler(){ rot "FEHLER: $*"; exit 1; }
hilfe() {
cat <<'ENDE'
DataMetric360 — Installation in Home Assistant
--ziel <pfad> config-Verzeichnis von Home Assistant (Standard: /config)
--von <pfad> lokale Arbeitskopie benutzen statt zu klonen
--repo <url> Repository zum Klonen und für die Selbstaktualisierung
--branch <name> Branch (Standard: main)
--token <wert> langlebiges Zugriffstoken für die Kilometerstand-Historie
--ohne-pyscript pyscript nicht mitinstallieren (wenn schon vorhanden)
--still keine Rückfragen stellen (für unbeaufsichtigte Läufe)
--hilfe diese Übersicht
Beispiele
bash install.sh # auf der HA-Instanz, klont selbst
bash install.sh --von ~/audi-app # aus einer lokalen Arbeitskopie
bash install.sh --ziel /Volumes/config # gegen ein eingebundenes Laufwerk
ENDE
}
while [ $# -gt 0 ]; do
case "$1" in
--ziel) ZIEL="${2:-}"; shift 2 ;;
--von) QUELLE="${2:-}"; shift 2 ;;
--repo) REPO="${2:-}"; shift 2 ;;
--branch) BRANCH="${2:-}"; shift 2 ;;
--token) TOKEN="${2:-}"; shift 2 ;;
--ohne-pyscript) PYSCRIPT_INSTALLIEREN="nein"; shift ;;
--still) INTERAKTIV="nein"; shift ;;
--hilfe|-h) hilfe; exit 0 ;;
*) fehler "Unbekannte Option: $1 (--hilfe zeigt alle)" ;;
esac
done
# --------------------------------------------------------------- Vorprüfungen
schritt "Umgebung prüfen"
for werkzeug in python3 curl unzip; do
command -v "$werkzeug" >/dev/null 2>&1 || fehler "$werkzeug wird gebraucht, ist aber nicht vorhanden."
done
[ -d "$ZIEL" ] || fehler "Das Zielverzeichnis $ZIEL gibt es nicht. Mit --ziel den richtigen Pfad angeben."
[ -w "$ZIEL" ] || fehler "Keine Schreibrechte auf $ZIEL."
if [ ! -f "$ZIEL/configuration.yaml" ]; then
rot "In $ZIEL liegt keine configuration.yaml."
fehler "Das sieht nicht nach einem Home-Assistant-Verzeichnis aus. Pfad mit --ziel prüfen."
fi
grau " Ziel: $ZIEL"
# ------------------------------------------------------------ Quelle besorgen
AUFRAEUMEN=""
saeubern() { [ -n "$AUFRAEUMEN" ] && rm -rf "$AUFRAEUMEN" || true; }
trap saeubern EXIT
if [ -n "$QUELLE" ]; then
schritt "Lokale Arbeitskopie benutzen"
[ -d "$QUELLE/homeassistant" ] || fehler "In $QUELLE gibt es keinen Ordner homeassistant/."
HA_QUELLE="$QUELLE/homeassistant"
grau " $HA_QUELLE"
else
schritt "Projekt herunterladen"
command -v git >/dev/null 2>&1 || fehler "git wird zum Klonen gebraucht (oder --von <pfad> benutzen)."
AUFRAEUMEN="$(mktemp -d)"
if ! git clone --depth 1 --branch "$BRANCH" "$REPO" "$AUFRAEUMEN/quelle" >/dev/null 2>&1; then
rot "Klonen von $REPO fehlgeschlagen."
grau " Bei einem privaten Repository ein Zugangstoken in die URL aufnehmen, etwa:"
grau " --repo https://<nutzer>:<token>@gitea.example/paul/audi-app.git"
exit 1
fi
HA_QUELLE="$AUFRAEUMEN/quelle/homeassistant"
grau " $REPO ($BRANCH)"
fi
[ -d "$HA_QUELLE/pyscript" ] || fehler "Im Projekt fehlt homeassistant/pyscript/."
# ------------------------------------------------------------------- pyscript
if [ "$PYSCRIPT_INSTALLIEREN" = "ja" ]; then
if [ -d "$ZIEL/custom_components/pyscript" ]; then
schritt "pyscript ist bereits vorhanden"
grau " $ZIEL/custom_components/pyscript"
else
schritt "pyscript installieren"
VERSION="$(curl -fsSL https://api.github.com/repos/custom-components/pyscript/releases/latest \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])' 2>/dev/null || true)"
[ -n "$VERSION" ] || fehler "Konnte die neueste pyscript-Version nicht ermitteln (kein Internet?)."
TMPPY="$(mktemp -d)"
curl -fsSL -o "$TMPPY/pyscript.zip" \
"https://github.com/custom-components/pyscript/archive/refs/tags/${VERSION}.zip"
unzip -oq "$TMPPY/pyscript.zip" -d "$TMPPY"
mkdir -p "$ZIEL/custom_components"
cp -r "$TMPPY/pyscript-${VERSION}/custom_components/pyscript" "$ZIEL/custom_components/"
rm -rf "$TMPPY"
gruen " pyscript $VERSION installiert"
fi
fi
# --------------------------------------------------------- Dateien einspielen
schritt "Backend und Oberfläche einspielen"
mkdir -p "$ZIEL/pyscript" "$ZIEL/www" "$ZIEL/audi_dashboard"
# pyscript und www sind Code - vollständig ersetzen.
cp -r "$HA_QUELLE/pyscript/." "$ZIEL/pyscript/"
cp -r "$HA_QUELLE/www/." "$ZIEL/www/"
grau " pyscript/ und www/ aktualisiert"
# Der Belegleser liegt in data/, ist aber Code (belegverarbeitung.py ruft ihn
# als Unterprozess auf) - deshalb hier mit, anders als die Datenbestände.
cp "$HA_QUELLE/data/shell_beleg_parser.py" "$ZIEL/audi_dashboard/shell_beleg_parser.py"
# Datenbestände: nur anlegen, was fehlt. Vorhandenes nie überschreiben.
if [ ! -f "$ZIEL/audi_dashboard/fahrzeugprofil.json" ]; then
cp "$HA_QUELLE/data/fahrzeugprofil.example.json" "$ZIEL/audi_dashboard/fahrzeugprofil.json"
gruen " Fahrzeugprofil aus der Vorlage angelegt"
else
grau " Fahrzeugprofil vorhanden - unangetastet"
fi
for datei in fahrten.jsonl tankvorgaenge.jsonl; do
[ -f "$ZIEL/audi_dashboard/$datei" ] || : > "$ZIEL/audi_dashboard/$datei"
done
mkdir -p "$ZIEL/audi_dashboard/belege"
# --------------------------------------------------- Selbstaktualisierung
schritt "Selbstaktualisierung verdrahten"
# UPDATE_REPO_URL im ausgelieferten einstellungen.py setzen, damit
# "Update suchen" in den Einstellungen der App funktioniert.
python3 - "$ZIEL/pyscript/modules/einstellungen.py" "$REPO" "$BRANCH" <<'PYENDE'
import re, sys
pfad, repo, branch = sys.argv[1], sys.argv[2], sys.argv[3]
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
inhalt = re.sub(r'^UPDATE_REPO_URL\s*=.*$', f'UPDATE_REPO_URL = "{repo}"', inhalt, count=1, flags=re.M)
inhalt = re.sub(r'^UPDATE_BRANCH\s*=.*$', f'UPDATE_BRANCH = "{branch}"', inhalt, count=1, flags=re.M)
with open(pfad, "w", encoding="utf-8") as f:
f.write(inhalt)
PYENDE
grau " Updatequelle: $REPO ($BRANCH)"
grau " Danach in der App unter Einstellungen → Version → \"Update suchen\""
# ---------------------------------------------------------- configuration.yaml
schritt "configuration.yaml ergänzen"
KONFIG="$ZIEL/configuration.yaml"
SICHERUNG="$KONFIG.vor-datametric360-$(date +%Y%m%d_%H%M%S)"
cp "$KONFIG" "$SICHERUNG"
grau " Sicherung: $(basename "$SICHERUNG")"
python3 - "$KONFIG" "$MARKE_START" "$MARKE_ENDE" <<'PYENDE'
import re, sys
pfad, start, ende = sys.argv[1], sys.argv[2], sys.argv[3]
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
block = f"""{start}
# Von install.sh eingefügt. Aenderungen innerhalb dieses Abschnitts werden bei
# der naechsten Installation ersetzt - Eigenes bitte ausserhalb notieren.
pyscript:
allow_all_imports: true
panel_custom:
- name: audi-dashboard-panel
sidebar_title: Mein Audi
sidebar_icon: mdi:car-sports
module_url: /local/audi-dashboard-panel.js?v=1
embed_iframe: false
trust_external_script: false
{ende}"""
muster = re.compile(re.escape(start) + r".*?" + re.escape(ende), re.S)
if muster.search(inhalt):
inhalt = muster.sub(block, inhalt)
print(" Abschnitt ersetzt")
else:
for schluessel in ("pyscript:", "panel_custom:"):
if re.search(rf"^{schluessel}", inhalt, re.M):
print(f" ACHTUNG: '{schluessel}' steht bereits ausserhalb des verwalteten Abschnitts.")
print(" Der Abschnitt wird trotzdem angehaengt - Home Assistant meldet dann")
print(" einen doppelten Schluessel. Bitte den alten Eintrag von Hand entfernen.")
if not inhalt.endswith("\n"):
inhalt += "\n"
inhalt += "\n" + block + "\n"
print(" Abschnitt angehaengt")
with open(pfad, "w", encoding="utf-8") as f:
f.write(inhalt)
PYENDE
# ------------------------------------------------------------------- Token
schritt "Zugriffstoken für die Kilometerstand-Historie"
TOKENDATEI="$ZIEL/audi_dashboard/ha_token.txt"
if [ -n "$TOKEN" ]; then
printf '%s' "$TOKEN" > "$TOKENDATEI"
chmod 600 "$TOKENDATEI" 2>/dev/null || true
gruen " Token gespeichert"
elif [ -s "$TOKENDATEI" ]; then
grau " Vorhandenes Token bleibt unangetastet"
elif [ "$INTERAKTIV" = "ja" ] && [ -t 0 ]; then
echo " Der Fahrtabschluss liest den Kilometerstand aus dem Verlauf und braucht dafür"
echo " ein langlebiges Zugriffstoken (Profil → Sicherheit → Langlebige Zugriffstoken)."
echo " Leer lassen und später nachtragen ist möglich - Fahrten bleiben dann ohne Strecke."
printf " Token (Eingabe bleibt verborgen): "
read -rs EINGABE || EINGABE=""
echo
if [ -n "$EINGABE" ]; then
printf '%s' "$EINGABE" > "$TOKENDATEI"
chmod 600 "$TOKENDATEI" 2>/dev/null || true
gruen " Token gespeichert"
else
rot " Übersprungen - später in $TOKENDATEI ablegen."
fi
else
rot " Kein Token angegeben. Später in $TOKENDATEI ablegen,"
rot " sonst bleiben Fahrten ohne Streckenangabe."
fi
# ------------------------------------------------------------------ Abschluss
schritt "Fertig"
cat <<ENDE
Noch zu tun, alles in der Weboberfläche:
1. Home Assistant neu starten
Einstellungen → System → oben rechts → Neu starten
2. Sensoren zuordnen
Mein Audi → Einstellungen → Fahrzeug einrichten → Einrichten
→ "Setup — Sensoren zuordnen"
Pflicht ist nur die Zündung; alles Weitere ist optional.
3. Fahrzeugdaten eintragen
Im selben Menü unter "Einrichten": Kennzeichen, FIN, Versicherung, …
Ab dann läuft alles über die Oberfläche - auch Updates:
Mein Audi → Einstellungen → Version → "Update suchen"
Sicherung der configuration.yaml:
$SICHERUNG
ENDE
gruen "Installation abgeschlossen."
@@ -51,10 +51,19 @@ def _token_lesen():
def historie_abfragen(entity_id, start_iso, end_iso): def historie_abfragen(entity_id, start_iso, end_iso):
"""urllib.request.urlopen läuft direkt, nicht über task.executor - anders """Fragt die Verlaufsdaten des Kilometerstand-Sensors über die REST-API ab.
als ein bloßes open() ist das ein modulqualifizierter Aufruf einer echten
externen Funktion, der an der Testinstanz ohne Fehler durchlief. Blockiert Beide blockierenden Schritte laufen über task.executor. Ein früherer Stand
kurz, läuft nur beim Fahrtabschluss-Screening, nicht bei jedem Tick.""" rief urlopen() direkt auf - das lief jahrelang durch, bis Home Assistant
anfing, blockierende Aufrufe im Ereignisschleifen-Thread zu erkennen und
abzubrechen (real beobachtet ab 2026.8: "Caught blocking call to
putrequest ... inside the event loop"). Seitdem schlug jedes Screening
fehl, und manuell angelegte Fahrten blieben ohne Strecke - sichtbar nur
als Warnung im Protokoll, weil der Aufrufer den Fehler bewusst abfängt.
task.executor braucht echte externe Funktionen; urllib.request.urlopen und
die read-Methode der Antwort sind genau das (dieselbe Einschränkung wie bei
io.open in profil.py)."""
token = _token_lesen() token = _token_lesen()
start_enc = urllib.parse.quote(start_iso) start_enc = urllib.parse.quote(start_iso)
end_enc = urllib.parse.quote(end_iso) end_enc = urllib.parse.quote(end_iso)
@@ -63,10 +72,10 @@ def historie_abfragen(entity_id, start_iso, end_iso):
f"?filter_entity_id={entity_id}&end_time={end_enc}" f"?filter_entity_id={entity_id}&end_time={end_enc}"
) )
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
resp = urllib.request.urlopen(req, timeout=10) resp = task.executor(urllib.request.urlopen, req, timeout=10)
rohtext = resp.read().decode("utf-8") rohdaten = task.executor(resp.read)
resp.close() resp.close()
daten = json.loads(rohtext) daten = json.loads(rohdaten.decode("utf-8"))
return daten[0] if daten else [] return daten[0] if daten else []