Files
audi-app/custom_components/audi_dashboard/sicherung.py
T
tobias d8b12da36d pyscript-Backend zur echten HA-Integration umgebaut (HACS-fähig)
Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine
normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform
und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf
GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert
homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS.

Fünf Installationsschritte entfallen ersatzlos: der pyscript:-Block, der
panel_custom:-Block, das Kopieren der Oberfläche nach www/, das langlebige
Zugriffstoken (der Verlauf wird direkt über die recorder-API gelesen) und
"pip install pypdf" (steht in manifest.json). Das Fahrzeugprofil legt die
Integration beim ersten Start aus ihrer Vorlage an.

Drei alte Schwächen sind dabei mit erledigt:
- Die Nutzlast landet nicht mehr in der Recorder-Datenbank
  (_unrecorded_attributes - das kann nur eine echte Entität).
- Eine laufende Fahrt überlebt einen Neustart (Store statt Arbeitsspeicher);
  fiel sie während eines Ausfalls ins Ende, schließt
  nach_neustart_fortsetzen() sie beim letzten aufgezeichneten Zeitpunkt.
- Sensor-Zuordnungen wirken sofort - die Zustandsbeobachter werden neu
  gebunden, der Neustart-Hinweis und der Neustart-Dienst sind weg.

Namensvertrag geändert, beide Oberflächen mitgezogen:
pyscript.audi_dashboard_x -> sensor.audi_dashboard_x,
pyscript.audi_dashboard_y -> audi_dashboard.y. Eine Companion-App vom alten
Stand findet nach dem Umstieg nichts mehr und muss neu gebaut werden; das
Panel liegt in der Integration und kann nicht driften.

Der selbstgebaute Updater entfällt - HACS ist die Update-Mechanik, die Home
Assistant kennt. Die Versionierung schrumpft auf eine Quelle: manifest.json.

Geprüft am laufenden Testcontainer (Container byteweise identisch mit dem
Repo): alle 18 Dienste, Panel, Config-Entry neu laden, Historienimport,
echter Shell-Beleg in-process, Neuinstallation im Wegwerf-Container blank mit
automatisch nachinstalliertem pypdf. Companion-App: tsc sauber, 112/112
Tests, beide Rauchtests gegen das laufende Backend grün. Belegparser 8/8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:53:56 +02:00

116 lines
4.3 KiB
Python

"""Sicherung von Fahrzeugprofil, Fahrten, Tankvorgängen und Sensor-Zuordnung.
Manuell über "Backup jetzt erstellen" in den Einstellungen oder automatisch je
nach einstellungen.backup_intervall im Fahrzeugprofil (aus/täglich/
wöchentlich/monatlich) - immer um 04:00 Uhr, wenn eingestellt. Ein einzelner
täglicher Termin genügt für alle drei Intervalle: wöchentlich läuft nur
montags, monatlich nur am Monatsersten, täglich jeden Tag.
Jede Sicherung ist ein eigener Zeitstempel-Unterordner mit Kopien der vier
Bestände, keine ZIP-Datei - eine Sicherung, in die man ohne Werkzeug
hineinschauen und aus der man eine einzelne Datei zurückkopieren kann, ist im
Ernstfall mehr wert als ein paar gesparte Kilobyte.
entitaeten.json gehört ausdrücklich dazu: ohne sie wäre nach einer
Wiederherstellung die komplette Sensor-Zuordnung aus dem Setup-Menü verloren,
und weil sie sonst nirgends abgelegt ist, gäbe es nichts zurückzuholen.
"Backup exportieren"/"importieren" in der Oberfläche laufen dagegen rein über
den Browser (aktuell geladenes Profil + Fahrten + Tankvorgänge als eine
JSON-Datei); wiederherstellen() hier ist das Gegenstück zum Import, das die
Bestände wieder komplett überschreibt.
"""
from __future__ import annotations
import datetime
import json
import logging
import os
import shutil
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
DATEIEN = (
"fahrzeugprofil.json",
"fahrten.jsonl",
"tankvorgaenge.jsonl",
"entitaeten.json",
)
def _kopieren(basis: str, ziel: str) -> str:
os.makedirs(ziel, exist_ok=True)
for name in DATEIEN:
quelle = os.path.join(basis, name)
if os.path.exists(quelle):
shutil.copy(quelle, os.path.join(ziel, name))
return ziel
async def jetzt(k: Koordinator) -> str:
zeitstempel = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
ziel = os.path.join(k.ablage.backup_ordner, zeitstempel)
await k.hass.async_add_executor_job(_kopieren, k.ablage.basis, ziel)
await _vermerken(k)
_LOGGER.info("Sicherung erstellt unter %s", ziel)
return ziel
async def _vermerken(k: Koordinator) -> None:
profil = await k.ablage.profil_lesen()
if profil is None:
return
profil.setdefault("einstellungen", {})["letztes_backup"] = (
datetime.datetime.now().isoformat()
)
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
async def automatisch(k: Koordinator) -> None:
"""Der tägliche 04:00-Termin - prüft selbst, ob heute überhaupt eine
Sicherung fällig ist."""
profil = await k.ablage.profil_lesen()
if profil is None:
return
intervall = profil.get("einstellungen", {}).get("backup_intervall", "aus")
heute = datetime.date.today()
faellig = (
intervall == "taeglich"
or (intervall == "woechentlich" and heute.weekday() == 0)
or (intervall == "monatlich" and heute.day == 1)
)
if not faellig:
return
ziel = await jetzt(k)
_LOGGER.info("Automatische Sicherung (%s) erstellt unter %s", intervall, ziel)
def _als_objekt(wert: object) -> object:
return json.loads(wert) if isinstance(wert, str) else wert
async def wiederherstellen(k: Koordinator, daten: dict) -> None:
"""Überschreibt Profil, Fahrten, Tankvorgänge und die Sensor-Zuordnung
komplett.
entitaeten_json ist optional: ältere Sicherungen, die vor dem Setup-Menü
entstanden sind, enthalten den Abschnitt nicht - dann bleibt die aktuelle
Zuordnung unangetastet, statt sie zu leeren."""
if daten.get("profil_json"):
await k.ablage.profil_schreiben(_als_objekt(daten["profil_json"])) # type: ignore[arg-type]
if daten.get("fahrten_json") is not None:
await k.ablage.fahrten_schreiben(_als_objekt(daten["fahrten_json"])) # type: ignore[arg-type]
if daten.get("tankvorgaenge_json") is not None:
await k.ablage.tankvorgaenge_schreiben(_als_objekt(daten["tankvorgaenge_json"])) # type: ignore[arg-type]
if daten.get("entitaeten_json"):
await k.zuordnung.speichern(_als_objekt(daten["entitaeten_json"])) # type: ignore[arg-type]
await k.trigger_neu_binden()
await k.alles_veroeffentlichen()
_LOGGER.info("Sicherung wiederhergestellt")