Files
tobias 90fff7b305 Vollaudit: sechs echte Fehler in Backend, Panel und companion-app behoben
- companion-app Batterie.tsx: Generatorspannung zeigte sich faelschlich als
  Ruhespannung/verzerrte den SOH-Trend, Panel-Filter (AGM_RUHE_MAX_V) fehlte
- Manuell angelegte Fahrten/Tankvorgaenge trugen naive Zeitstempel und wurden
  von der Import-Dublettenpruefung stillschweigend uebersprungen; neue
  gemeinsame zeit_normalisiert() in verlauf.py schliesst die Luecke
- Batteriespannungsverlauf fehlte im Backup (sicherung.py)
- Tankvorgangs-Import verlor stillschweigend aeltere Tankvorgaenge vor Beginn
  der Litersensor-Historie; laeuft jetzt zweigleisig (Prozent + Liter)
- Panel-Statistikseite behauptete faelschlich, der Verbrauch je Fahrt komme
  vom Fahrzeug (OBD) - ist eine Naeherung aus dem Tankfuellstand
- companion-app Statistik.tsx: Arbeitsweg-Segment war noch rot statt neutral

Version 2026.8.27.19, OTA-Buendel neu gebaut, im Testcontainer verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 00:05:58 +02:00

123 lines
4.6 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 fünf
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.
batteriespannung.jsonl gehört ausdrücklich dazu: batterie.py führt sie
bewusst als mehrjährigen Verlauf, um eine über die Zeit absinkende
Ruhespannung erkennen zu können - ohne Sicherung wäre dieser Verlauf bei
einem Festplattenausfall unwiederbringlich weg, anders als Profil/Fahrten/
Tankvorgänge, die dieselbe Sicherung bereits abdeckte.
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",
"batteriespannung.jsonl",
)
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")