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>
@@ -0,0 +1,136 @@
|
||||
"""Audi Dashboard - Fahrtenbuch, Tankstatistik und Fahrzeugzustand als eigene
|
||||
Home-Assistant-Integration.
|
||||
|
||||
Diese Datei ist der Lebenszyklus: einrichten, entladen, neu laden. Die Fach-
|
||||
logik liegt in den Modulen daneben, der laufende Zustand im Koordinator.
|
||||
|
||||
WAS DIE INTEGRATION MITBRINGT, WAS FRÜHER VON HAND EINGERICHTET WERDEN MUSSTE
|
||||
-----------------------------------------------------------------------------
|
||||
Die pyscript-Fassung brauchte fünf getrennte Handgriffe in der Installation:
|
||||
einen `pyscript:`-Block mit allow_all_imports und hass_is_global, einen
|
||||
`panel_custom:`-Block, das Kopieren der Oberflächen-Dateien nach /config/www/,
|
||||
ein langlebiges Zugriffstoken in einer Textdatei und ein `pip install pypdf`
|
||||
im Container. Jeder davon konnte vergessen werden, und mehrere scheiterten
|
||||
still.
|
||||
|
||||
Alle fünf sind hier weg:
|
||||
- Das Panel meldet die Integration selbst an (kein YAML, kein Neustart).
|
||||
- Die Oberflächen-Dateien liegen in frontend/ und werden von hier unter
|
||||
einem eigenen URL-Pfad ausgeliefert (kein Kopieren nach www/).
|
||||
- Den Verlauf liest die Integration direkt über die recorder-API (kein
|
||||
Token, siehe verlauf.py).
|
||||
- pypdf steht über manifest.json bereit (kein pip-Aufruf, siehe belege.py).
|
||||
- Eingerichtet wird über die Oberfläche (config_flow), nicht über YAML.
|
||||
|
||||
Was NICHT hierher gehört und deshalb weiterhin von Hand in die
|
||||
configuration.yaml kommt: der `recorder:`-Block mit der verlängerten
|
||||
Aufbewahrung (recorder_snippet.yaml). Der betrifft die ganze Instanz und ihre
|
||||
Datenbankgröße - das ist eine Entscheidung des Betreibers, keine, die eine
|
||||
einzelne Integration für ihn treffen darf.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.components import frontend, panel_custom
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from . import dienste
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
PANEL_ICON,
|
||||
PANEL_KOMPONENTE,
|
||||
PANEL_TITEL,
|
||||
PANEL_URL,
|
||||
STATIK_ORDNER,
|
||||
STATIK_URL,
|
||||
)
|
||||
from .koordinator import Koordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATTFORMEN = [Platform.SENSOR]
|
||||
|
||||
type AudiConfigEntry = ConfigEntry[Koordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AudiConfigEntry) -> bool:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
version = str(integration.version)
|
||||
|
||||
koordinator = Koordinator(hass, entry, version)
|
||||
entry.runtime_data = koordinator
|
||||
|
||||
await _statik_anmelden(hass)
|
||||
await koordinator.starten()
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATTFORMEN)
|
||||
await _panel_anmelden(hass, version)
|
||||
dienste.registrieren(hass, koordinator)
|
||||
|
||||
# Ändert sich die Einrichtung, wird die Integration neu geladen statt
|
||||
# halb angepasst - das ist die einzige Variante, bei der nichts von der
|
||||
# alten Einrichtung übrig bleiben kann.
|
||||
entry.async_on_unload(entry.add_update_listener(_neu_laden))
|
||||
|
||||
_LOGGER.info("Audi Dashboard %s eingerichtet", version)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AudiConfigEntry) -> bool:
|
||||
geladen = await hass.config_entries.async_unload_platforms(entry, PLATTFORMEN)
|
||||
if not geladen:
|
||||
return False
|
||||
dienste.abmelden(hass)
|
||||
frontend.async_remove_panel(hass, PANEL_URL)
|
||||
await entry.runtime_data.beenden()
|
||||
return True
|
||||
|
||||
|
||||
async def _neu_laden(hass: HomeAssistant, entry: AudiConfigEntry) -> None:
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def _statik_anmelden(hass: HomeAssistant) -> None:
|
||||
"""Liefert frontend/ unter STATIK_URL aus.
|
||||
|
||||
Nur einmal je Home-Assistant-Lauf: aiohttp-Routen lassen sich nicht wieder
|
||||
entfernen, ein zweiter Versuch nach einem Neu-Laden der Integration würde
|
||||
scheitern. Das ist unkritisch - der Pfad zeigt auf einen Ordner, dessen
|
||||
Inhalt sich beim Neu-Laden nicht ändert.
|
||||
|
||||
cache_headers=False ist Absicht: die Dateien werden von HACS ersetzt, und
|
||||
ein Browser, der eine alte Fassung 31 Tage lang aus dem Cache bedient
|
||||
(die HA-Vorgabe für statische Pfade), wäre genau die stille Veraltung, die
|
||||
das Versions-Verfahren verhindern soll."""
|
||||
if hass.data.get(DOMAIN, {}).get("statik"):
|
||||
return
|
||||
ordner = os.path.join(os.path.dirname(__file__), STATIK_ORDNER)
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(STATIK_URL, ordner, False)]
|
||||
)
|
||||
hass.data.setdefault(DOMAIN, {})["statik"] = True
|
||||
|
||||
|
||||
async def _panel_anmelden(hass: HomeAssistant, version: str) -> None:
|
||||
"""Meldet den Sidebar-Eintrag an.
|
||||
|
||||
Die Version hängt als Query-Parameter an der Modul-URL: sie ändert sich
|
||||
genau dann, wenn eine neue Fassung installiert wurde, und ist damit der
|
||||
passende Cache-Brecher für den Lade-Stub."""
|
||||
await panel_custom.async_register_panel(
|
||||
hass,
|
||||
frontend_url_path=PANEL_URL,
|
||||
webcomponent_name=PANEL_KOMPONENTE,
|
||||
sidebar_title=PANEL_TITEL,
|
||||
sidebar_icon=PANEL_ICON,
|
||||
module_url=f"{STATIK_URL}/audi-dashboard-panel.js?v={version}",
|
||||
embed_iframe=False,
|
||||
require_admin=False,
|
||||
config={"version": version, "statik": STATIK_URL},
|
||||
)
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Datenzugriff für Fahrzeugprofil, Fahrten, Tankvorgänge und Batterieverlauf.
|
||||
|
||||
Drei getrennte Bestände, wie in SPECIFICATION.md §6.1 festgelegt:
|
||||
- Fahrzeugprofil: eine JSON-Datei, alles Fahrzeugspezifische
|
||||
- Fahrten: JSON Lines, eine Zeile je Fahrt
|
||||
- Tankvorgänge: JSON Lines
|
||||
- Batterieverlauf: JSON Lines, ein Eintrag je Tag
|
||||
|
||||
Dateiformat und Ordnername sind identisch mit der pyscript-Fassung
|
||||
(/config/audi_dashboard/). Eine bestehende Installation läuft damit nach dem
|
||||
Umstieg einfach weiter - es gibt keine Datenmigration, und deshalb auch
|
||||
keinen Weg, bei der Migration etwas zu verlieren.
|
||||
|
||||
ZWEI DINGE, DIE HIER ANDERS SIND ALS IN DER PYSCRIPT-FASSUNG
|
||||
------------------------------------------------------------
|
||||
1. Ganz normales `open()`. Die dortige Verrenkung `task.executor(io.open, ...)`
|
||||
war eine reine pyscript-Eigenheit (das eingebaute open() existiert dort
|
||||
nicht). Hier läuft jede Dateioperation stattdessen über
|
||||
`hass.async_add_executor_job` - ein Datei-Zugriff hat im Event-Loop nichts
|
||||
verloren, und diese Klasse ist die einzige Stelle, die das kapselt: alle
|
||||
öffentlichen Methoden sind `async` und schalten selbst in den Executor.
|
||||
|
||||
2. Eine Sperre um die verändernden Zugriffe. Fast jede Änderung ist ein
|
||||
Lesen-Ändern-Schreiben über den kompletten Bestand (eine Fahrt ändern
|
||||
heißt: alle Fahrten lesen, eine anfassen, alle zurückschreiben). Liefen
|
||||
zwei davon verschränkt, gewänne die zuletzt schreibende und die andere
|
||||
Änderung wäre spurlos weg. In der pyscript-Fassung war das eine offene
|
||||
Flanke; hier kostet die Absicherung eine `asyncio.Lock`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Ablage:
|
||||
"""Alle Dateizugriffe der App an einer Stelle."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, basis: str) -> None:
|
||||
self._hass = hass
|
||||
self.basis = basis
|
||||
self.profil_pfad = os.path.join(basis, "fahrzeugprofil.json")
|
||||
self.fahrten_pfad = os.path.join(basis, "fahrten.jsonl")
|
||||
self.tankvorgaenge_pfad = os.path.join(basis, "tankvorgaenge.jsonl")
|
||||
self.batterieverlauf_pfad = os.path.join(basis, "batteriespannung.jsonl")
|
||||
self.zuordnung_pfad = os.path.join(basis, "entitaeten.json")
|
||||
self.belege_ordner = os.path.join(basis, "belege")
|
||||
self.backup_ordner = os.path.join(basis, "backups")
|
||||
self._sperre = asyncio.Lock()
|
||||
|
||||
async def _im_executor(self, funktion: Callable, *args: Any) -> Any:
|
||||
return await self._hass.async_add_executor_job(funktion, *args)
|
||||
|
||||
# ------------------------------------------------------------- Ordner
|
||||
|
||||
def _ordner_anlegen(self) -> None:
|
||||
os.makedirs(self.basis, exist_ok=True)
|
||||
os.makedirs(self.belege_ordner, exist_ok=True)
|
||||
|
||||
async def ordner_sicherstellen(self) -> None:
|
||||
await self._im_executor(self._ordner_anlegen)
|
||||
|
||||
def _vorlage_anlegen(self, vorlage_pfad: str) -> bool:
|
||||
if os.path.exists(self.profil_pfad):
|
||||
return False
|
||||
with open(vorlage_pfad, encoding="utf-8") as datei:
|
||||
inhalt = datei.read()
|
||||
self._text_schreiben(self.profil_pfad, inhalt)
|
||||
return True
|
||||
|
||||
async def vorlage_anlegen(self, vorlage_pfad: str) -> bool:
|
||||
"""Legt bei der allerersten Einrichtung ein Fahrzeugprofil aus der
|
||||
mitgelieferten Vorlage an. True, wenn dabei wirklich etwas entstanden
|
||||
ist.
|
||||
|
||||
Vorher war das ein eigener Installationsschritt ("die Datei aus data/
|
||||
nach /config/audi_dashboard/ kopieren"). Wurde er vergessen, startete
|
||||
die App in einen Zustand, den man von einem Fehler nicht unterscheiden
|
||||
konnte: das Panel blieb leer, im Protokoll stand nur eine Zeile über
|
||||
eine fehlende Datei.
|
||||
|
||||
Ein bereits vorhandenes Profil wird nie angefasst - deshalb die
|
||||
Existenzprüfung im selben Executor-Aufruf wie das Schreiben, nicht
|
||||
davor: sonst läge zwischen Prüfen und Schreiben ein Zeitfenster."""
|
||||
return await self._im_executor(self._vorlage_anlegen, vorlage_pfad)
|
||||
|
||||
# ------------------------------------------------------ Rohe Dateiarbeit
|
||||
|
||||
@staticmethod
|
||||
def _text_lesen(pfad: str) -> str | None:
|
||||
if not os.path.exists(pfad):
|
||||
return None
|
||||
with open(pfad, encoding="utf-8") as datei:
|
||||
return datei.read()
|
||||
|
||||
@staticmethod
|
||||
def _text_schreiben(pfad: str, text: str) -> None:
|
||||
"""Atomar: erst in eine .tmp-Datei, dann umbenennen.
|
||||
|
||||
os.replace ist auf allen unterstützten Systemen atomar. Damit kann ein
|
||||
Absturz mitten im Schreiben keine halb geschriebene Datei hinterlassen
|
||||
- der alte Stand bleibt vollständig, bis der neue vollständig da ist."""
|
||||
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||
tmp = pfad + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as datei:
|
||||
datei.write(text)
|
||||
os.replace(tmp, pfad)
|
||||
|
||||
@staticmethod
|
||||
def _zeilen_aus_text(text: str | None, pfad: str) -> list[dict]:
|
||||
if not text:
|
||||
return []
|
||||
datensaetze: list[dict] = []
|
||||
for nummer, zeile in enumerate(text.splitlines(), start=1):
|
||||
zeile = zeile.strip()
|
||||
if not zeile:
|
||||
continue
|
||||
try:
|
||||
datensaetze.append(json.loads(zeile))
|
||||
except ValueError as fehler:
|
||||
# Eine kaputte Zeile darf nicht den ganzen Bestand mitreißen:
|
||||
# der Rest ist gültig und die App bleibt benutzbar.
|
||||
_LOGGER.error(
|
||||
"%s Zeile %s ist kein gültiges JSON (%s) - Zeile übersprungen",
|
||||
pfad, nummer, fehler,
|
||||
)
|
||||
return datensaetze
|
||||
|
||||
@staticmethod
|
||||
def _text_aus_zeilen(datensaetze: list[dict]) -> str:
|
||||
zeilen = [json.dumps(d, ensure_ascii=False) for d in datensaetze]
|
||||
return "\n".join(zeilen) + ("\n" if zeilen else "")
|
||||
|
||||
# ------------------------------------------------------- Fahrzeugprofil
|
||||
|
||||
def _profil_lesen(self) -> dict | None:
|
||||
text = self._text_lesen(self.profil_pfad)
|
||||
if text is None:
|
||||
_LOGGER.error(
|
||||
"%s fehlt - bis dahin bleiben alle Funktionen aus, die das Profil brauchen",
|
||||
self.profil_pfad,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError as fehler:
|
||||
_LOGGER.error(
|
||||
"%s ist kein gültiges JSON (%s). Letztes Backup aus %s zurückspielen.",
|
||||
self.profil_pfad, fehler, self.backup_ordner,
|
||||
)
|
||||
return None
|
||||
|
||||
async def profil_lesen(self) -> dict | None:
|
||||
"""Das Fahrzeugprofil, oder None wenn es fehlt bzw. beschädigt ist.
|
||||
|
||||
Jeder Aufrufer muss den None-Fall abfangen: ohne diese Prüfung reißt
|
||||
eine fehlende Datei jeden Zeittakt und jeden Dienst mit, der das
|
||||
Profil braucht - bei laufenden Zeittriggern also im Minutentakt."""
|
||||
return await self._im_executor(self._profil_lesen)
|
||||
|
||||
async def profil_schreiben(self, profil: dict) -> None:
|
||||
async with self._sperre:
|
||||
await self._im_executor(
|
||||
self._text_schreiben,
|
||||
self.profil_pfad,
|
||||
json.dumps(profil, ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------- Fahrten
|
||||
|
||||
async def fahrten_lesen(self) -> list[dict]:
|
||||
text = await self._im_executor(self._text_lesen, self.fahrten_pfad)
|
||||
return self._zeilen_aus_text(text, self.fahrten_pfad)
|
||||
|
||||
async def fahrten_schreiben(self, fahrten: list[dict]) -> None:
|
||||
await self._im_executor(
|
||||
self._text_schreiben, self.fahrten_pfad, self._text_aus_zeilen(fahrten)
|
||||
)
|
||||
|
||||
async def fahrt_anhaengen(self, fahrt: dict) -> None:
|
||||
async with self._sperre:
|
||||
fahrten = await self.fahrten_lesen()
|
||||
fahrten.append(fahrt)
|
||||
await self.fahrten_schreiben(fahrten)
|
||||
|
||||
async def fahrten_ergaenzen(self, neue: list[dict]) -> None:
|
||||
"""Mehrere Fahrten in einem Rutsch, nach Startzeit sortiert.
|
||||
|
||||
Für den Historienimport: bei einem Jahr Verlauf wären das sonst
|
||||
hunderte einzelne Schreibvorgänge."""
|
||||
if not neue:
|
||||
return
|
||||
async with self._sperre:
|
||||
alle = await self.fahrten_lesen()
|
||||
alle.extend(neue)
|
||||
alle.sort(key=lambda f: f.get("ts_start") or "")
|
||||
await self.fahrten_schreiben(alle)
|
||||
|
||||
@staticmethod
|
||||
def _datensatz_aendern(
|
||||
zeilen: list[dict], id_feld: str, id_wert: str, aenderungen: dict, schutz: bool
|
||||
) -> bool:
|
||||
"""Ersetzt ausgewählte Felder eines Datensatzes anhand seiner ID.
|
||||
|
||||
Manuell geänderte Felder (edited_fields) werden dabei nie
|
||||
überschrieben. schutz=False hebt genau diese Sperre auf - nötig für
|
||||
Eingaben aus der Oberfläche: edited_fields schützt gegen die
|
||||
automatische Ergänzung, nicht gegen den Menschen, der das Feld gerade
|
||||
selbst korrigiert."""
|
||||
for d in zeilen:
|
||||
if d.get(id_feld) != id_wert:
|
||||
continue
|
||||
geschuetzt = set(d.get("edited_fields", [])) if schutz else set()
|
||||
for feld, wert in aenderungen.items():
|
||||
if feld not in geschuetzt:
|
||||
d[feld] = wert
|
||||
return True
|
||||
return False
|
||||
|
||||
async def fahrt_aktualisieren(self, trip_id: str, aenderungen: dict) -> bool:
|
||||
async with self._sperre:
|
||||
fahrten = await self.fahrten_lesen()
|
||||
if not self._datensatz_aendern(fahrten, "trip_id", trip_id, aenderungen, True):
|
||||
return False
|
||||
await self.fahrten_schreiben(fahrten)
|
||||
return True
|
||||
|
||||
async def fahrt_bearbeiten(self, trip_id: str, aenderungen: dict) -> bool:
|
||||
"""Wie fahrt_aktualisieren(), aber für Eingaben aus der Oberfläche:
|
||||
eine von Hand gesetzte Angabe sticht auch dann, wenn dasselbe Feld
|
||||
schon einmal von Hand gesetzt wurde."""
|
||||
async with self._sperre:
|
||||
fahrten = await self.fahrten_lesen()
|
||||
if not self._datensatz_aendern(fahrten, "trip_id", trip_id, aenderungen, False):
|
||||
return False
|
||||
await self.fahrten_schreiben(fahrten)
|
||||
return True
|
||||
|
||||
async def fahrt_loeschen(self, trip_id: str) -> bool:
|
||||
async with self._sperre:
|
||||
fahrten = await self.fahrten_lesen()
|
||||
uebrig = [f for f in fahrten if f.get("trip_id") != trip_id]
|
||||
if len(uebrig) == len(fahrten):
|
||||
return False
|
||||
await self.fahrten_schreiben(uebrig)
|
||||
return True
|
||||
|
||||
# --------------------------------------------------------- Tankvorgänge
|
||||
|
||||
async def tankvorgaenge_lesen(self) -> list[dict]:
|
||||
text = await self._im_executor(self._text_lesen, self.tankvorgaenge_pfad)
|
||||
return self._zeilen_aus_text(text, self.tankvorgaenge_pfad)
|
||||
|
||||
async def tankvorgaenge_schreiben(self, tankvorgaenge: list[dict]) -> None:
|
||||
await self._im_executor(
|
||||
self._text_schreiben,
|
||||
self.tankvorgaenge_pfad,
|
||||
self._text_aus_zeilen(tankvorgaenge),
|
||||
)
|
||||
|
||||
async def tankvorgang_anhaengen(self, tankvorgang: dict) -> None:
|
||||
async with self._sperre:
|
||||
alle = await self.tankvorgaenge_lesen()
|
||||
alle.append(tankvorgang)
|
||||
await self.tankvorgaenge_schreiben(alle)
|
||||
|
||||
async def tankvorgaenge_ergaenzen(self, neue: list[dict]) -> None:
|
||||
if not neue:
|
||||
return
|
||||
async with self._sperre:
|
||||
alle = await self.tankvorgaenge_lesen()
|
||||
alle.extend(neue)
|
||||
alle.sort(key=lambda t: t.get("ts") or "")
|
||||
await self.tankvorgaenge_schreiben(alle)
|
||||
|
||||
async def tankvorgang_aktualisieren(self, tank_id: str, aenderungen: dict) -> bool:
|
||||
async with self._sperre:
|
||||
alle = await self.tankvorgaenge_lesen()
|
||||
if not self._datensatz_aendern(alle, "tank_id", tank_id, aenderungen, True):
|
||||
return False
|
||||
await self.tankvorgaenge_schreiben(alle)
|
||||
return True
|
||||
|
||||
async def tankvorgang_loeschen(self, tank_id: str) -> bool:
|
||||
async with self._sperre:
|
||||
alle = await self.tankvorgaenge_lesen()
|
||||
uebrig = [t for t in alle if t.get("tank_id") != tank_id]
|
||||
if len(uebrig) == len(alle):
|
||||
return False
|
||||
await self.tankvorgaenge_schreiben(uebrig)
|
||||
return True
|
||||
|
||||
async def tankvorgang_nach_id(self, tank_id: str) -> dict | None:
|
||||
for t in await self.tankvorgaenge_lesen():
|
||||
if t.get("tank_id") == tank_id:
|
||||
return t
|
||||
return None
|
||||
|
||||
async def tankvorgang_nach_receipt_key(self, receipt_key: str) -> dict | None:
|
||||
"""Derselbe Beleg (receipt_key, minutengenau) darf keinen zweiten
|
||||
Datensatz erzeugen (§7.7 Regel 3)."""
|
||||
for t in await self.tankvorgaenge_lesen():
|
||||
if t.get("receipt_key") == receipt_key:
|
||||
return t
|
||||
return None
|
||||
|
||||
async def distanz_seit_letzter_tankung(self, aktueller_km: float | None) -> float | None:
|
||||
"""Gefahrene Distanz seit dem vorherigen Tankvorgang, als Vorschlag
|
||||
für das gleichnamige Formularfeld - frei überschreibbar, genau wie
|
||||
odometer_km selbst. None, wenn kein Kilometerstand oder kein
|
||||
vorheriger Tankvorgang vorliegt (erster Eintrag überhaupt)."""
|
||||
if aktueller_km is None:
|
||||
return None
|
||||
alle = await self.tankvorgaenge_lesen()
|
||||
if not alle:
|
||||
return None
|
||||
letzter = max(alle, key=lambda t: t.get("ts") or "")
|
||||
if letzter.get("odometer_km") is None:
|
||||
return None
|
||||
return round(aktueller_km - letzter["odometer_km"], 1)
|
||||
|
||||
# ------------------------------------------------------ Batteriespannung
|
||||
|
||||
async def batterieverlauf_lesen(self) -> list[dict]:
|
||||
"""Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}), älteste
|
||||
zuerst. min_ts/max_ts sind die Zeitstempel (ISO, UTC) der jeweiligen
|
||||
Einzelmessung, für die Datum/Uhrzeit-Anzeige beim Antippen des
|
||||
Diagrammpunkts - der Punkt selbst zeigt nur den Minimalwert."""
|
||||
text = await self._im_executor(self._text_lesen, self.batterieverlauf_pfad)
|
||||
return self._zeilen_aus_text(text, self.batterieverlauf_pfad)
|
||||
|
||||
async def batterieverlauf_tageswert_aktualisieren(
|
||||
self, datum: str, ts: str, spannung: float
|
||||
) -> None:
|
||||
"""Trägt eine neue Messung in den Tageseintrag für `datum` ein: legt
|
||||
ihn beim ersten Wert des Tages an, erweitert sonst nur min/max samt
|
||||
dem Zeitstempel der jeweils neuen Extremmessung."""
|
||||
async with self._sperre:
|
||||
verlauf = await self.batterieverlauf_lesen()
|
||||
for eintrag in verlauf:
|
||||
if eintrag.get("datum") != datum:
|
||||
continue
|
||||
if spannung < eintrag["min"]:
|
||||
eintrag["min"] = spannung
|
||||
eintrag["min_ts"] = ts
|
||||
if spannung > eintrag["max"]:
|
||||
eintrag["max"] = spannung
|
||||
eintrag["max_ts"] = ts
|
||||
break
|
||||
else:
|
||||
verlauf.append(
|
||||
{"datum": datum, "min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts}
|
||||
)
|
||||
# Nach Datum sortiert schreiben, nicht in Einfügereihenfolge.
|
||||
# Solange nur die Live-Aufzeichnung schrieb, war beides dasselbe
|
||||
# (sie trägt immer den heutigen Tag ein). Der nachträgliche Import
|
||||
# trägt dagegen vergangene Tage ein - ohne diese Zeile stünden sie
|
||||
# hinter den neueren, und das Diagramm im Frontend, das die Datei
|
||||
# in Dateireihenfolge zeichnet, liefe zeitlich rückwärts.
|
||||
verlauf.sort(key=lambda e: e.get("datum") or "")
|
||||
await self._im_executor(
|
||||
self._text_schreiben,
|
||||
self.batterieverlauf_pfad,
|
||||
self._text_aus_zeilen(verlauf),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------- Sensor-Zuordnung
|
||||
|
||||
async def zuordnung_lesen(self) -> dict:
|
||||
"""Die im Setup-Menü gespeicherten Zuordnungen, oder {} wenn die
|
||||
Datei fehlt bzw. beschädigt ist.
|
||||
|
||||
Der Fehlerfall ist nicht Vorsicht um ihrer selbst willen: das Anwenden
|
||||
läuft beim Start VOR der ersten Veröffentlichung. Ohne die Absicherung
|
||||
reißt eine einzige unlesbare Zeile den gesamten Startvorgang mit - das
|
||||
Panel bliebe komplett leer, ohne dass irgendetwas auf die Ursache
|
||||
hindeutet."""
|
||||
text = await self._im_executor(self._text_lesen, self.zuordnung_pfad)
|
||||
if not text or not text.strip():
|
||||
return {}
|
||||
try:
|
||||
gelesen = json.loads(text)
|
||||
except ValueError as fehler:
|
||||
_LOGGER.error(
|
||||
"%s ist kein gültiges JSON (%s). Die eingebauten Standardwerte gelten "
|
||||
"weiter; die Zuordnung lässt sich im Setup-Menü neu speichern.",
|
||||
self.zuordnung_pfad, fehler,
|
||||
)
|
||||
return {}
|
||||
if not isinstance(gelesen, dict):
|
||||
_LOGGER.error("%s enthält kein Objekt - wird ignoriert.", self.zuordnung_pfad)
|
||||
return {}
|
||||
return gelesen
|
||||
|
||||
async def zuordnung_schreiben(self, mapping: dict) -> None:
|
||||
await self._im_executor(
|
||||
self._text_schreiben,
|
||||
self.zuordnung_pfad,
|
||||
json.dumps(mapping, ensure_ascii=False, indent=2),
|
||||
)
|
||||
|
||||
|
||||
def neue_id(praefix: str) -> str:
|
||||
return f"{praefix}-{uuid.uuid4().hex[:12]}"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Langfristige Aufzeichnung der 12V-Batteriespannung (Mein Audi -> Zustand
|
||||
-> Batteriespannung antippen).
|
||||
|
||||
Eine über die Jahre absinkende Spannung deutet auf eine alternde Batterie hin
|
||||
- dafür braucht es einen Verlauf, nicht nur den aktuellen Wert.
|
||||
|
||||
Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts}) reicht dafür und hält
|
||||
die Datei auch über viele Jahre klein, selbst wenn der Sensor mehrfach pro
|
||||
Stunde meldet (aktive Fahrt: Motor an/aus, Ladezustand des Alternators
|
||||
wechselt laufend). Die Prüfung läuft deshalb alle 5 Minuten und trägt jede
|
||||
Messung in den Tageseintrag ein; die Ablage erweitert dabei nur min/max samt
|
||||
dem genauen Zeitstempel der jeweiligen Extremmessung - robust gegen Neustarts
|
||||
und schwankende Erreichbarkeit.
|
||||
|
||||
Der Tagesminimalwert ist die für die Entladung aussagekräftige Größe: er fällt
|
||||
typischerweise auf den Moment kurz nach dem Start, bevor der Alternator die
|
||||
Ladespannung hochfährt - also am nächsten an die "echte" Ruhespannung heran,
|
||||
ohne 2+ Stunden Standzeit abwarten zu müssen. Deshalb verwendet auch die
|
||||
Ladezustands-/Gesundheitsauswertung im Frontend (AGM_RUHE_MAX_V) den
|
||||
Tagesminimalwert, nicht den Maximalwert.
|
||||
|
||||
Es wird bewusst NICHT gefiltert, welche Messwerte gespeichert werden - min UND
|
||||
max jedes Tages werden roh aufgezeichnet. Die AGM-Ruhespannungs-Grenze wird
|
||||
erst beim Anzeigen angewendet: Tage, an denen selbst der Minimalwert über der
|
||||
Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet, z. B. bei einer sehr
|
||||
langen Fahrt), tauchen dort nicht in der Statistik auf.
|
||||
|
||||
Ohne zugeordneten BATTERIE_SENSOR passiert hier bewusst nichts, statt
|
||||
Platzhalter-Nullwerte aufzuzeichnen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .veroeffentlichung import zustand_oder_none
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def pruefen(k: Koordinator) -> None:
|
||||
sensor = k.zuordnung.werte.BATTERIE_SENSOR
|
||||
if not sensor:
|
||||
return
|
||||
wert = zustand_oder_none(k.hass, sensor)
|
||||
if wert is None:
|
||||
return
|
||||
try:
|
||||
spannung = float(wert)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
|
||||
jetzt = datetime.datetime.now(datetime.UTC)
|
||||
heute = datetime.date.today().isoformat()
|
||||
await k.ablage.batterieverlauf_tageswert_aktualisieren(
|
||||
heute, jetzt.isoformat(), spannung
|
||||
)
|
||||
await k.batterieverlauf_veroeffentlichen()
|
||||
_LOGGER.debug("Batteriespannung erfasst (%s: %s V)", heute, spannung)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Belegverarbeitung für Tankbelege (SPECIFICATION.md §7.7).
|
||||
|
||||
Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff.
|
||||
Das eigentliche Lesen der PDF macht shell_beleg_parser.py daneben; hier steht,
|
||||
was mit dem Ergebnis passiert.
|
||||
|
||||
ZWEI REGELN, DIE DEN AUFBAU ERKLÄREN
|
||||
------------------------------------
|
||||
1. Liter, Kosten und Ersparnis kommen vom Beleg, Kilometerstand und gefahrene
|
||||
Distanz kommen immer vom Fahrzeug (§7.7 Regel 2). Ein Beleg-Nachtrag rührt
|
||||
odometer_km/distance_km deshalb nicht an.
|
||||
2. price_per_l wird grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie
|
||||
vom Beleg übernommen. fuel_total_eur ist dabei immer der reale, bereits
|
||||
rabattierte Betrag; die SmartDeal-Ersparnis steht separat in discount.
|
||||
|
||||
Mit tank_id trägt der Upload die Belegdaten in einen bestehenden Tankvorgang
|
||||
nach - egal ob der ursprünglich manuell oder automatisch angelegt wurde. Passt
|
||||
das Belegdatum nicht zum erfassten Tankvorgang (anderer Kalendertag), wird
|
||||
nichts übernommen, sondern ein Fehler veröffentlicht: sonst landet in der
|
||||
Oberfläche versehentlich ein falscher Beleg an einem bestehenden Eintrag.
|
||||
|
||||
Ohne tank_id legt der Upload KEINEN eigenständigen Tankvorgang an, sondern
|
||||
parst nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte
|
||||
in den gerade offenen Entwurf und speichert erst auf einen expliziten Klick.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from . import shell_beleg_parser
|
||||
from .tankerkennung import leerer_tankvorgang
|
||||
from .veroeffentlichung import zustand_oder_none
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def preis_je_liter(fuel_total_eur: float | None, liters: float | None) -> float | None:
|
||||
if not liters or fuel_total_eur is None:
|
||||
return None
|
||||
return round(fuel_total_eur / liters, 3)
|
||||
|
||||
|
||||
def _speichern_und_lesen(ordner: str, dateiname: str, rohdaten: bytes) -> tuple[str, dict]:
|
||||
"""Läuft komplett im Executor: Datei schreiben und PDF lesen sind beides
|
||||
blockierende Arbeit."""
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
pfad = os.path.join(ordner, dateiname)
|
||||
with open(pfad, "wb") as datei:
|
||||
datei.write(rohdaten)
|
||||
return pfad, shell_beleg_parser.beleg_lesen(pfad)
|
||||
|
||||
|
||||
async def hochladen(k: Koordinator, daten: dict) -> None:
|
||||
pdf_base64 = daten.get("pdf_base64")
|
||||
tank_id = daten.get("tank_id")
|
||||
if not pdf_base64:
|
||||
_LOGGER.warning("Beleg-Upload ohne pdf_base64 aufgerufen")
|
||||
return
|
||||
|
||||
dateiname = daten.get("dateiname") or (
|
||||
f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
|
||||
)
|
||||
# Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
|
||||
# Ausnahme hier (kaputtes Base64, volle Festplatte, unlesbares PDF) wäre
|
||||
# für die Oberfläche unsichtbar - ein abgelehnter Dienstaufruf landet dort
|
||||
# nur in der Browser-Konsole, angezeigt wird nichts.
|
||||
try:
|
||||
rohdaten = base64.b64decode(pdf_base64)
|
||||
pdf_pfad, geparst = await k.hass.async_add_executor_job(
|
||||
_speichern_und_lesen, k.ablage.belege_ordner, dateiname, rohdaten
|
||||
)
|
||||
except Exception as fehler: # noqa: BLE001 - jede Ursache endet hier gleich
|
||||
_LOGGER.error("Beleg konnte nicht verarbeitet werden: %s", fehler)
|
||||
await k.beleg_ergebnis_veroeffentlichen(
|
||||
{"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}
|
||||
)
|
||||
return
|
||||
|
||||
receipt_key = geparst.get("receipt_key")
|
||||
if not receipt_key:
|
||||
_LOGGER.error("Parser lieferte keinen receipt_key")
|
||||
await k.beleg_ergebnis_veroeffentlichen(
|
||||
{"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}
|
||||
)
|
||||
return
|
||||
|
||||
# §7.7 Regel 3: derselbe Beleg (receipt_key, minutengenau) erzeugt keinen
|
||||
# zweiten Datensatz.
|
||||
vorhandener = await k.ablage.tankvorgang_nach_receipt_key(receipt_key)
|
||||
if vorhandener is not None and vorhandener.get("tank_id") != tank_id:
|
||||
_LOGGER.info("Beleg %s bereits vorhanden, übersprungen", receipt_key)
|
||||
await k.beleg_ergebnis_veroeffentlichen(
|
||||
{"fehler": "Beleg bereits erfasst", "tank_id": tank_id}
|
||||
)
|
||||
return
|
||||
|
||||
beleg_felder = _beleg_felder(geparst, pdf_pfad)
|
||||
|
||||
if not tank_id:
|
||||
await k.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None})
|
||||
_LOGGER.info("Beleg %s geparst, wartet auf Speichern in der Oberfläche", receipt_key)
|
||||
return
|
||||
|
||||
await _nachtragen(k, tank_id, geparst, beleg_felder, receipt_key)
|
||||
|
||||
|
||||
def _beleg_felder(geparst: dict, pdf_pfad: str) -> dict:
|
||||
liters = geparst.get("liters")
|
||||
fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert
|
||||
return {
|
||||
"receipt_key": geparst.get("receipt_key"),
|
||||
"receipt_no": geparst.get("receipt_no"),
|
||||
"tse_beleg_nr": geparst.get("tse_beleg_nr"),
|
||||
"ts": geparst.get("ts"),
|
||||
"ts_payment": geparst.get("ts_payment"),
|
||||
"ts_tse": geparst.get("ts_tse"),
|
||||
"station_id": geparst.get("station_id"),
|
||||
"station_name": geparst.get("station_name"),
|
||||
"station_address": geparst.get("station_address"),
|
||||
"article_no": geparst.get("article_no"),
|
||||
"product_name": geparst.get("product_name"),
|
||||
"fuel_type": geparst.get("fuel_type"),
|
||||
"liters": liters,
|
||||
"fuel_total_eur": fuel_total_eur,
|
||||
"price_per_l": preis_je_liter(fuel_total_eur, liters),
|
||||
"discount": geparst.get("discount"), # SmartDeal-Ersparnis
|
||||
"discount_per_l": geparst.get("discount_per_l"),
|
||||
"list_price_per_l": geparst.get("list_price_per_l"),
|
||||
"receipt_total_eur": geparst.get("receipt_total_eur"),
|
||||
"net_eur": geparst.get("net_eur"),
|
||||
"vat_eur": geparst.get("vat_eur"),
|
||||
"receipt_file": pdf_pfad,
|
||||
}
|
||||
|
||||
|
||||
async def _nachtragen(
|
||||
k: Koordinator, tank_id: str, geparst: dict, beleg_felder: dict, receipt_key: str
|
||||
) -> None:
|
||||
vorhanden = await k.ablage.tankvorgang_nach_id(tank_id)
|
||||
if not vorhanden:
|
||||
_LOGGER.warning("Tankvorgang %s für Beleg-Nachtrag nicht gefunden", tank_id)
|
||||
await k.beleg_ergebnis_veroeffentlichen(
|
||||
{"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}
|
||||
)
|
||||
return
|
||||
|
||||
vorhandenes_datum = (vorhanden.get("ts") or "")[:10]
|
||||
belegdatum = (geparst.get("ts") or "")[:10]
|
||||
if vorhandenes_datum and belegdatum and vorhandenes_datum != belegdatum:
|
||||
_LOGGER.warning(
|
||||
"Belegdatum %s passt nicht zu Tankvorgang %s (Datum %s)",
|
||||
belegdatum, tank_id, vorhandenes_datum,
|
||||
)
|
||||
await k.beleg_ergebnis_veroeffentlichen(
|
||||
{"fehler": "Falsches Belegdatum", "tank_id": tank_id}
|
||||
)
|
||||
return
|
||||
|
||||
aenderungen = dict(beleg_felder)
|
||||
aenderungen["status"] = "vollständig"
|
||||
if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen):
|
||||
await k.tankvorgaenge_veroeffentlichen()
|
||||
await k.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id})
|
||||
_LOGGER.info("Beleg %s in Tankvorgang %s nachgetragen", receipt_key, tank_id)
|
||||
else:
|
||||
await k.beleg_ergebnis_veroeffentlichen(
|
||||
{"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------ Tankvorgänge aus dem Formular
|
||||
|
||||
async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None:
|
||||
"""Erfassung aus dem Formular "Neuer Tankvorgang".
|
||||
|
||||
receipt_key/receipt_file sind optional: kommen sie mit (weil zuvor ein
|
||||
Beleg ohne tank_id in den Entwurf übernommen wurde), zählt der Tankvorgang
|
||||
als per Beleg erfasst, sonst als manuell."""
|
||||
liter = daten.get("liter")
|
||||
kosten = daten.get("kosten")
|
||||
if liter is None or kosten is None:
|
||||
_LOGGER.warning("liter und kosten sind Pflichtfelder")
|
||||
return
|
||||
|
||||
km = daten.get("km")
|
||||
odometer_km = km if km is not None else _km_aus_sensor(k)
|
||||
distanz = daten.get("distanz")
|
||||
if distanz is None:
|
||||
# Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie
|
||||
# odometer_km selbst frei überschreibbar - ein übergebener Wert hat
|
||||
# deshalb immer Vorrang vor der Berechnung.
|
||||
distanz = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
|
||||
|
||||
receipt_key = daten.get("receipt_key")
|
||||
tankvorgang = leerer_tankvorgang(
|
||||
daten.get("ts") or datetime.datetime.now(datetime.UTC).isoformat(),
|
||||
"beleg" if receipt_key else "manual",
|
||||
)
|
||||
tankvorgang.update({
|
||||
"receipt_key": receipt_key,
|
||||
"liters": liter,
|
||||
"fuel_total_eur": kosten,
|
||||
"price_per_l": preis_je_liter(kosten, liter),
|
||||
"discount": daten.get("ersparnis"),
|
||||
"station_name": daten.get("station"),
|
||||
"odometer_km": odometer_km,
|
||||
"distance_km": distanz,
|
||||
"fuel_type": daten.get("kraftstoff"),
|
||||
"status": "vollständig",
|
||||
"receipt_file": daten.get("receipt_file"),
|
||||
})
|
||||
|
||||
await k.ablage.tankvorgang_anhaengen(tankvorgang)
|
||||
await k.tankvorgaenge_veroeffentlichen()
|
||||
_LOGGER.info("Tankvorgang %s angelegt", tankvorgang["tank_id"])
|
||||
|
||||
|
||||
def _km_aus_sensor(k: Koordinator) -> float | None:
|
||||
wert = zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR)
|
||||
try:
|
||||
return float(wert) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None:
|
||||
"""Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob
|
||||
ursprünglich per Beleg oder manuell angelegt)."""
|
||||
tank_id = daten.get("tank_id")
|
||||
if not tank_id:
|
||||
_LOGGER.warning("tankvorgang_aktualisieren ohne tank_id aufgerufen")
|
||||
return
|
||||
|
||||
liter = daten.get("liter")
|
||||
kosten = daten.get("kosten")
|
||||
aenderungen = {
|
||||
"ts": daten.get("ts"),
|
||||
"liters": liter,
|
||||
"fuel_total_eur": kosten,
|
||||
"price_per_l": preis_je_liter(kosten, liter),
|
||||
"odometer_km": daten.get("km"),
|
||||
"discount": daten.get("ersparnis"),
|
||||
"station_name": daten.get("station"),
|
||||
"distance_km": daten.get("distanz"),
|
||||
"fuel_type": daten.get("kraftstoff"),
|
||||
}
|
||||
if daten.get("receipt_key"):
|
||||
aenderungen["receipt_key"] = daten["receipt_key"]
|
||||
aenderungen["receipt_file"] = daten.get("receipt_file")
|
||||
aenderungen["source"] = "beleg"
|
||||
|
||||
if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen):
|
||||
await k.tankvorgaenge_veroeffentlichen()
|
||||
_LOGGER.info("Tankvorgang %s aktualisiert", tank_id)
|
||||
else:
|
||||
_LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id)
|
||||
|
||||
|
||||
async def tankvorgang_loeschen(k: Koordinator, tank_id: str | None) -> None:
|
||||
if not tank_id:
|
||||
_LOGGER.warning("tankvorgang_loeschen ohne tank_id aufgerufen")
|
||||
return
|
||||
if await k.ablage.tankvorgang_loeschen(tank_id):
|
||||
await k.tankvorgaenge_veroeffentlichen()
|
||||
_LOGGER.info("Tankvorgang %s gelöscht", tank_id)
|
||||
else:
|
||||
_LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Fahrzeugbilder direkt aus der Oberfläche hochladen und löschen
|
||||
(Einstellungen -> Fahrzeugbilder).
|
||||
|
||||
Nur die festen, im Frontend fest verdrahteten Dateinamen sind erlaubt - eine
|
||||
Positivliste, damit ein manipulierter Dateiname aus dem Frontend niemals
|
||||
außerhalb von www/bilder/ schreiben kann (kein Pfad-Traversal über "../" oder
|
||||
absolute Pfade).
|
||||
|
||||
Die Bytes werden unverändert unter dem erwarteten Namen abgelegt, unabhängig
|
||||
vom tatsächlichen Bildformat des Originalfotos (z. B. JPEG von einem iPhone
|
||||
unter einem ".webp"-Namen) - Browser stellen <img> nach den tatsächlichen
|
||||
Bilddaten dar, nicht nach der Dateiendung. Eine Größenbeschränkung gibt es
|
||||
bewusst nicht: jede Fotogröße muss hochladbar sein.
|
||||
|
||||
WARUM DIESE DATEIEN NICHT IM INTEGRATIONSORDNER LIEGEN: sie sind Nutzerdaten,
|
||||
kein Auslieferbestandteil. Lägen sie unter custom_components/audi_dashboard/,
|
||||
würde das nächste HACS-Update sie mitlöschen. /config/www/bilder/ gehört
|
||||
dagegen dem Nutzer und wird von keinem Update angefasst; ausgeliefert wird es
|
||||
weiterhin unter /local/bilder/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ERLAUBTE_DATEINAMEN = frozenset({
|
||||
"seitenansicht.webp",
|
||||
"seitenansicht-winter.webp",
|
||||
"front-schraeg.webp",
|
||||
"frontansicht.webp",
|
||||
"heckansicht.webp",
|
||||
"cockpit.webp",
|
||||
"scheinwerfer.webp",
|
||||
"sitze.webp",
|
||||
"rad-sommer.webp",
|
||||
"rad-winter.webp",
|
||||
"draufsicht.webp",
|
||||
})
|
||||
|
||||
|
||||
def _pfad(ordner: str, dateiname: str | None) -> str | None:
|
||||
if not dateiname or dateiname not in ERLAUBTE_DATEINAMEN:
|
||||
return None
|
||||
return os.path.join(ordner, dateiname)
|
||||
|
||||
|
||||
def _schreiben(pfad: str, rohdaten: bytes) -> None:
|
||||
os.makedirs(os.path.dirname(pfad), exist_ok=True)
|
||||
tmp = pfad + ".tmp"
|
||||
with open(tmp, "wb") as datei:
|
||||
datei.write(rohdaten)
|
||||
os.replace(tmp, pfad)
|
||||
|
||||
|
||||
def _loeschen(pfad: str) -> bool:
|
||||
if not os.path.exists(pfad):
|
||||
return False
|
||||
os.remove(pfad)
|
||||
return True
|
||||
|
||||
|
||||
async def hochladen(
|
||||
hass: HomeAssistant, ordner: str, dateiname: str | None, daten_base64: str | None
|
||||
) -> None:
|
||||
pfad = _pfad(ordner, dateiname)
|
||||
if not pfad or not daten_base64:
|
||||
_LOGGER.warning("Bild-Upload abgelehnt (dateiname=%s)", dateiname)
|
||||
return
|
||||
try:
|
||||
rohdaten = base64.b64decode(daten_base64)
|
||||
except (binascii.Error, ValueError) as fehler:
|
||||
_LOGGER.error("Bild-Upload: Daten nicht lesbar (%s)", fehler)
|
||||
return
|
||||
await hass.async_add_executor_job(_schreiben, pfad, rohdaten)
|
||||
_LOGGER.info("Fahrzeugbild gespeichert (%s, %s Bytes)", dateiname, len(rohdaten))
|
||||
|
||||
|
||||
async def loeschen(hass: HomeAssistant, ordner: str, dateiname: str | None) -> None:
|
||||
pfad = _pfad(ordner, dateiname)
|
||||
if not pfad:
|
||||
_LOGGER.warning("Bild-Löschen abgelehnt (dateiname=%s)", dateiname)
|
||||
return
|
||||
if await hass.async_add_executor_job(_loeschen, pfad):
|
||||
_LOGGER.info("Fahrzeugbild gelöscht (%s)", dateiname)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Einrichtung über die Oberfläche (Einstellungen -> Geräte & Dienste ->
|
||||
Integration hinzufügen -> Audi Dashboard).
|
||||
|
||||
Bewusst ohne Eingabefelder. Es gäbe genau eine Sache zu fragen - welche
|
||||
Entität welche Rolle im Fahrzeug spielt -, und die gehört nicht hierher: die
|
||||
Zuordnung wird im Setup-Menü der App selbst vorgenommen, wo neben jedem Feld
|
||||
steht, wofür es gebraucht wird und was passiert, wenn es leer bleibt. Sie
|
||||
hier abzufragen hieße, dieselbe Auswahl an zwei Stellen zu pflegen - und die
|
||||
schlechtere von beiden zuerst zu zeigen.
|
||||
|
||||
Eine einzige Instanz, erzwungen über single_config_entry in manifest.json:
|
||||
die App gehört zu genau einem Fahrzeug, und alle Bestände liegen unter einem
|
||||
festen Pfad.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AudiDashboardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Ein Bestätigungsschritt, mehr braucht es nicht."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
return self.async_create_entry(title="Audi Dashboard", data={})
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Feste Namen und Pfade der Integration.
|
||||
|
||||
Die Entity-IDs und Dienstnamen hier sind ein VERTRAG mit beiden Oberflächen
|
||||
(homeassistant/www/audi-dashboard-app.js und companion-app/): dort stehen
|
||||
dieselben Namen in je einer zentralen Tabelle. Wer hier umbenennt, muss beide
|
||||
mitziehen - sonst liest die App ins Leere, ohne dass irgendetwas abstürzt.
|
||||
|
||||
Warum die Namen sich gegenüber der pyscript-Fassung überhaupt geändert haben:
|
||||
dort hießen sie `pyscript.audi_dashboard_*`, weil pyscript der Besitzer der
|
||||
Domain war. Ohne pyscript wäre dieser Präfix eine Lüge - die Entitäten
|
||||
gehören jetzt dieser Integration. Aus `pyscript.audi_dashboard_x` wird
|
||||
deshalb `sensor.audi_dashboard_x`, aus dem Dienst `pyscript.audi_dashboard_y`
|
||||
wird `audi_dashboard.y`.
|
||||
"""
|
||||
|
||||
DOMAIN = "audi_dashboard"
|
||||
|
||||
# Unterordner im HA-Konfigurationsverzeichnis für alle Nutzerdaten
|
||||
# (Fahrzeugprofil, Fahrten, Tankvorgänge, Belege, Sicherungen). Bewusst
|
||||
# derselbe Name und dasselbe Dateiformat wie in der pyscript-Fassung: eine
|
||||
# bestehende Installation wird dadurch einfach weiterbenutzt, es gibt keine
|
||||
# Datenmigration und damit auch keinen Weg, dabei etwas zu verlieren.
|
||||
BASIS_ORDNER = "audi_dashboard"
|
||||
|
||||
# Panel: Sidebar-Eintrag und URL. Früher aus configuration.yaml
|
||||
# (panel_custom:), jetzt von der Integration selbst registriert - deshalb
|
||||
# entfällt der YAML-Block und mit ihm der Neustart beim ersten Einbau.
|
||||
PANEL_URL = "audi-dashboard"
|
||||
PANEL_TITEL = "Mein Audi"
|
||||
PANEL_ICON = "mdi:car-sports"
|
||||
PANEL_KOMPONENTE = "audi-dashboard-panel"
|
||||
|
||||
# Statischer Pfad, unter dem die Oberflächen-Dateien aus frontend/
|
||||
# ausgeliefert werden. Ersetzt /local/ (also /config/www/): die Dateien
|
||||
# gehören jetzt zum Installationspaket der Integration und werden von HACS
|
||||
# mitgeliefert, statt separat nach www/ kopiert zu werden.
|
||||
STATIK_URL = "/audi_dashboard_static"
|
||||
STATIK_ORDNER = "frontend"
|
||||
|
||||
# Vom Nutzer hochgeladene Fahrzeugfotos. Die bleiben in /config/www/bilder,
|
||||
# also unter /local/bilder/: sie sind NUTZERDATEN, kein Auslieferbestandteil.
|
||||
# Lägen sie im Integrationsordner, würde das nächste HACS-Update sie
|
||||
# mitlöschen - genau das darf nicht passieren.
|
||||
BILDER_ORDNER = "www/bilder"
|
||||
|
||||
# --------------------------------------------------------------- Entitäten
|
||||
# Schlüssel -> Objekt-ID. Die vollständige Entity-ID ist
|
||||
# sensor.audi_dashboard_<objekt-id>; sensor.py legt sie so an.
|
||||
E_PROFIL = "profil"
|
||||
E_FAHRTEN = "fahrten"
|
||||
E_TANKVORGAENGE = "tankvorgaenge"
|
||||
E_FAHRZEUGSTATUS = "fahrzeugstatus"
|
||||
E_BATTERIEVERLAUF = "batterieverlauf"
|
||||
E_ZUORDNUNG = "entitaeten"
|
||||
E_BELEG_ERGEBNIS = "beleg_ergebnis"
|
||||
E_IMPORT_STATUS = "import_status"
|
||||
E_APP_VERSION = "app_version"
|
||||
E_REIFEN_SOMMER = "reifen_sommer_km"
|
||||
E_REIFEN_WINTER = "reifen_winter_km"
|
||||
E_REIFEN_AKTIV = "reifen_aktiver_satz"
|
||||
|
||||
# Signal für den Weg Koordinator -> Entität (async_dispatcher_send).
|
||||
SIGNAL_AKTUALISIERT = f"{DOMAIN}_aktualisiert"
|
||||
|
||||
# ----------------------------------------------------------------- Dienste
|
||||
D_JETZT_AKTUALISIEREN = "jetzt_aktualisieren"
|
||||
D_PROFIL_SCHREIBEN = "profil_schreiben"
|
||||
D_ENTITAETEN_SCHREIBEN = "entitaeten_schreiben"
|
||||
D_FAHRT_MANUELL_ANLEGEN = "fahrt_manuell_anlegen"
|
||||
D_FAHRT_AKTUALISIEREN = "fahrt_aktualisieren"
|
||||
D_FAHRT_LOESCHEN = "fahrt_loeschen"
|
||||
D_SCREENING_JETZT = "screening_jetzt"
|
||||
D_BELEG_HOCHLADEN = "beleg_hochladen"
|
||||
D_TANKVORGANG_MANUELL = "tankvorgang_manuell"
|
||||
D_TANKVORGANG_AKTUALISIEREN = "tankvorgang_aktualisieren"
|
||||
D_TANKVORGANG_LOESCHEN = "tankvorgang_loeschen"
|
||||
D_BILD_HOCHLADEN = "bild_hochladen"
|
||||
D_BILD_LOESCHEN = "bild_loeschen"
|
||||
D_REIFEN_WECHSELN = "reifen_wechseln"
|
||||
D_REIFEN_KM_SETZEN = "reifen_km_setzen"
|
||||
D_BACKUP_JETZT = "backup_jetzt"
|
||||
D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen"
|
||||
D_HISTORIE_IMPORTIEREN = "historie_importieren"
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Die Dienste, die beide Oberflächen aufrufen.
|
||||
|
||||
Aus `pyscript.audi_dashboard_<name>` ist `audi_dashboard.<name>` geworden -
|
||||
der doppelte Präfix war nur nötig, weil alle pyscript-Dienste sich eine Domain
|
||||
teilten. Die Namen selbst und die Feldnamen sind unverändert; beide
|
||||
Oberflächen führen dieselbe Liste in je einer zentralen Tabelle.
|
||||
|
||||
Die Schemata sind bewusst großzügig: die Oberfläche schickt Zahlenfelder je
|
||||
nach Formularzustand mal als Zahl, mal gar nicht. Ein zu enges Schema würde
|
||||
einen Dienstaufruf ablehnen, und ein abgelehnter Aufruf ist in der Oberfläche
|
||||
unsichtbar - er landet nur in der Browser-Konsole. Was inhaltlich nicht passt,
|
||||
weisen deshalb die Fachfunktionen mit einer verständlichen Protokollzeile ab,
|
||||
nicht die Schema-Prüfung.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from . import belege, bilder, fahrterkennung, historienimport, reifen, screening, sicherung
|
||||
from .const import (
|
||||
BILDER_ORDNER,
|
||||
D_BACKUP_JETZT,
|
||||
D_BACKUP_WIEDERHERSTELLEN,
|
||||
D_BELEG_HOCHLADEN,
|
||||
D_BILD_HOCHLADEN,
|
||||
D_BILD_LOESCHEN,
|
||||
D_ENTITAETEN_SCHREIBEN,
|
||||
D_FAHRT_AKTUALISIEREN,
|
||||
D_FAHRT_LOESCHEN,
|
||||
D_FAHRT_MANUELL_ANLEGEN,
|
||||
D_HISTORIE_IMPORTIEREN,
|
||||
D_JETZT_AKTUALISIEREN,
|
||||
D_PROFIL_SCHREIBEN,
|
||||
D_REIFEN_KM_SETZEN,
|
||||
D_REIFEN_WECHSELN,
|
||||
D_SCREENING_JETZT,
|
||||
D_TANKVORGANG_AKTUALISIEREN,
|
||||
D_TANKVORGANG_LOESCHEN,
|
||||
D_TANKVORGANG_MANUELL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .koordinator import Koordinator
|
||||
|
||||
# Ein JSON-Feld darf als Zeichenkette oder schon ausgepackt ankommen: das
|
||||
# Panel schickt JSON.stringify(...), ein Aufruf aus einer Automation heraus
|
||||
# reicht dagegen naheliegenderweise die Struktur selbst durch.
|
||||
_JSON = vol.Any(cv.string, dict, list)
|
||||
_ZAHL = vol.Any(vol.Coerce(float), None)
|
||||
_TEXT = vol.Any(cv.string, None)
|
||||
|
||||
_FAHRT_FELDER = {
|
||||
vol.Optional("art"): _TEXT,
|
||||
vol.Optional("start_ort"): _TEXT,
|
||||
vol.Optional("ziel_ort"): _TEXT,
|
||||
vol.Optional("odo_start"): _ZAHL,
|
||||
vol.Optional("odo_end"): _ZAHL,
|
||||
vol.Optional("distanz"): _ZAHL,
|
||||
}
|
||||
|
||||
_TANK_FELDER = {
|
||||
vol.Optional("ts"): _TEXT,
|
||||
vol.Optional("liter"): _ZAHL,
|
||||
vol.Optional("kosten"): _ZAHL,
|
||||
vol.Optional("km"): _ZAHL,
|
||||
vol.Optional("ersparnis"): _ZAHL,
|
||||
vol.Optional("station"): _TEXT,
|
||||
vol.Optional("distanz"): _ZAHL,
|
||||
vol.Optional("kraftstoff"): _TEXT,
|
||||
vol.Optional("receipt_key"): _TEXT,
|
||||
vol.Optional("receipt_file"): _TEXT,
|
||||
}
|
||||
|
||||
SCHEMATA: dict[str, vol.Schema] = {
|
||||
D_JETZT_AKTUALISIEREN: vol.Schema({}),
|
||||
D_SCREENING_JETZT: vol.Schema({}),
|
||||
D_BACKUP_JETZT: vol.Schema({}),
|
||||
D_PROFIL_SCHREIBEN: vol.Schema({vol.Required("profil_json"): _JSON}),
|
||||
D_ENTITAETEN_SCHREIBEN: vol.Schema({vol.Required("zuordnung_json"): _JSON}),
|
||||
D_FAHRT_MANUELL_ANLEGEN: vol.Schema({
|
||||
vol.Required("ts_start"): cv.string,
|
||||
vol.Required("ts_end"): cv.string,
|
||||
**_FAHRT_FELDER,
|
||||
}),
|
||||
D_FAHRT_AKTUALISIEREN: vol.Schema({
|
||||
vol.Required("trip_id"): cv.string,
|
||||
vol.Optional("ts_start"): _TEXT,
|
||||
vol.Optional("ts_end"): _TEXT,
|
||||
**_FAHRT_FELDER,
|
||||
}),
|
||||
D_FAHRT_LOESCHEN: vol.Schema({vol.Required("trip_id"): cv.string}),
|
||||
D_BELEG_HOCHLADEN: vol.Schema({
|
||||
vol.Required("pdf_base64"): cv.string,
|
||||
vol.Optional("dateiname"): _TEXT,
|
||||
vol.Optional("tank_id"): _TEXT,
|
||||
}),
|
||||
D_TANKVORGANG_MANUELL: vol.Schema(dict(_TANK_FELDER)),
|
||||
D_TANKVORGANG_AKTUALISIEREN: vol.Schema({
|
||||
vol.Required("tank_id"): cv.string, **_TANK_FELDER
|
||||
}),
|
||||
D_TANKVORGANG_LOESCHEN: vol.Schema({vol.Required("tank_id"): cv.string}),
|
||||
D_BILD_HOCHLADEN: vol.Schema({
|
||||
vol.Required("dateiname"): cv.string,
|
||||
vol.Required("daten_base64"): cv.string,
|
||||
}),
|
||||
D_BILD_LOESCHEN: vol.Schema({vol.Required("dateiname"): cv.string}),
|
||||
D_REIFEN_WECHSELN: vol.Schema({vol.Required("satz"): vol.In(reifen.SAETZE)}),
|
||||
D_REIFEN_KM_SETZEN: vol.Schema({
|
||||
vol.Required("satz"): vol.In(reifen.SAETZE),
|
||||
vol.Required("km"): vol.Coerce(float),
|
||||
}),
|
||||
D_BACKUP_WIEDERHERSTELLEN: vol.Schema({
|
||||
vol.Optional("profil_json"): _JSON,
|
||||
vol.Optional("fahrten_json"): _JSON,
|
||||
vol.Optional("tankvorgaenge_json"): _JSON,
|
||||
vol.Optional("entitaeten_json"): _JSON,
|
||||
}),
|
||||
D_HISTORIE_IMPORTIEREN: vol.Schema({
|
||||
vol.Required("start"): cv.string,
|
||||
vol.Required("ende"): cv.string,
|
||||
}),
|
||||
}
|
||||
|
||||
Fabrik = Callable[[ServiceCall], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
def _als_dienst(fabrik: Fabrik) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]:
|
||||
"""Macht aus einer Funktion, die eine Koroutine zurückgibt, eine echte
|
||||
Koroutinen-Funktion.
|
||||
|
||||
Home Assistant prüft mit iscoroutinefunction(), ob es einen Dienst
|
||||
erwarten muss. Ein Lambda, das eine Koroutine nur zurückgibt, besteht
|
||||
diese Prüfung nicht - der Aufruf würde stillschweigend nichts tun."""
|
||||
|
||||
async def behandler(aufruf: ServiceCall) -> None:
|
||||
await fabrik(aufruf)
|
||||
|
||||
return behandler
|
||||
|
||||
|
||||
def _objekt(wert: object) -> Any:
|
||||
return json.loads(wert) if isinstance(wert, str) else wert
|
||||
|
||||
|
||||
def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
|
||||
"""Meldet alle Dienste an. Gegenstück: abmelden()."""
|
||||
|
||||
async def jetzt_aktualisieren(_aufruf: ServiceCall) -> None:
|
||||
"""Gegenstück zum "Ziehen zum Aktualisieren": stößt den von der
|
||||
Datenquelle bereitgestellten Refresh-Knopf an, statt auf den nächsten
|
||||
Takt zu warten. Ohne eingetragenen Knopf nur die direkte
|
||||
Neuveröffentlichung - besser als gar nichts, auch wenn dann kein
|
||||
echter neuer Abruf beim Fahrzeug passiert."""
|
||||
knopf = k.zuordnung.werte.REFRESH_BUTTON
|
||||
if knopf:
|
||||
await hass.services.async_call(
|
||||
"button", "press", {"entity_id": knopf}, blocking=True
|
||||
)
|
||||
await k.fahrzeugstatus_veroeffentlichen()
|
||||
|
||||
async def profil_schreiben(aufruf: ServiceCall) -> None:
|
||||
"""Überschreibt das komplette Fahrzeugprofil.
|
||||
|
||||
Die Oberfläche hält Profil, Fahrzeugdaten und Einstellungen ohnehin
|
||||
als einen zusammenhängenden Stand im Speicher und schickt ihn nach
|
||||
jeder Änderung komplett zurück - deshalb ein einziger Dienst statt
|
||||
eines eigenen je Feld."""
|
||||
await k.ablage.profil_schreiben(_objekt(aufruf.data["profil_json"]))
|
||||
await k.profil_veroeffentlichen()
|
||||
|
||||
async def entitaeten_schreiben(aufruf: ServiceCall) -> None:
|
||||
"""Speichert die im Setup-Menü gewählte Sensor-Zuordnung, wendet sie
|
||||
sofort an, bindet die Zustandsbeobachter neu und veröffentlicht
|
||||
Katalog und Status neu.
|
||||
|
||||
Das Neubinden ist der Grund, warum der frühere Hinweis "wirkt erst
|
||||
nach einem Neustart" und der Neustart-Knopf daneben entfallen konnten:
|
||||
auch Zündung, Kilometerstand und Tankfüllstand wirken jetzt sofort."""
|
||||
await k.zuordnung.speichern(_objekt(aufruf.data["zuordnung_json"]))
|
||||
await k.trigger_neu_binden()
|
||||
await k.zuordnung_veroeffentlichen()
|
||||
await k.fahrzeugstatus_veroeffentlichen()
|
||||
|
||||
async def historie_importieren(aufruf: ServiceCall) -> None:
|
||||
"""Startet den Import und kehrt sofort zurück.
|
||||
|
||||
Der Import kann bei einem Jahr Verlauf Minuten dauern. Die Oberfläche
|
||||
wartet deshalb nicht auf den Dienstaufruf, sondern verfolgt den
|
||||
Fortschritt über sensor.audi_dashboard_import_status - genau so, wie
|
||||
sie es schon bei der pyscript-Fassung tat, deren Dienste ebenfalls
|
||||
sofort zurückkehrten."""
|
||||
k.entry.async_create_background_task(
|
||||
hass,
|
||||
historienimport.importieren(k, aufruf.data["start"], aufruf.data["ende"]),
|
||||
"audi_dashboard_historienimport",
|
||||
)
|
||||
|
||||
bilder_ordner = hass.config.path(BILDER_ORDNER)
|
||||
|
||||
behandler: dict[str, Callable[[ServiceCall], Coroutine[Any, Any, None]]] = {
|
||||
D_JETZT_AKTUALISIEREN: jetzt_aktualisieren,
|
||||
D_PROFIL_SCHREIBEN: profil_schreiben,
|
||||
D_ENTITAETEN_SCHREIBEN: entitaeten_schreiben,
|
||||
D_HISTORIE_IMPORTIEREN: historie_importieren,
|
||||
D_SCREENING_JETZT: _als_dienst(lambda _a: screening.durchfuehren(k)),
|
||||
D_FAHRT_MANUELL_ANLEGEN: _als_dienst(
|
||||
lambda a: fahrterkennung.manuell_anlegen(k, dict(a.data))
|
||||
),
|
||||
D_FAHRT_AKTUALISIEREN: _als_dienst(
|
||||
lambda a: fahrterkennung.aktualisieren(k, dict(a.data))
|
||||
),
|
||||
D_FAHRT_LOESCHEN: _als_dienst(
|
||||
lambda a: fahrterkennung.loeschen(k, a.data["trip_id"])
|
||||
),
|
||||
D_BELEG_HOCHLADEN: _als_dienst(lambda a: belege.hochladen(k, dict(a.data))),
|
||||
D_TANKVORGANG_MANUELL: _als_dienst(
|
||||
lambda a: belege.tankvorgang_manuell(k, dict(a.data))
|
||||
),
|
||||
D_TANKVORGANG_AKTUALISIEREN: _als_dienst(
|
||||
lambda a: belege.tankvorgang_aktualisieren(k, dict(a.data))
|
||||
),
|
||||
D_TANKVORGANG_LOESCHEN: _als_dienst(
|
||||
lambda a: belege.tankvorgang_loeschen(k, a.data["tank_id"])
|
||||
),
|
||||
D_BILD_HOCHLADEN: _als_dienst(
|
||||
lambda a: bilder.hochladen(
|
||||
hass, bilder_ordner, a.data["dateiname"], a.data["daten_base64"]
|
||||
)
|
||||
),
|
||||
D_BILD_LOESCHEN: _als_dienst(
|
||||
lambda a: bilder.loeschen(hass, bilder_ordner, a.data["dateiname"])
|
||||
),
|
||||
D_REIFEN_WECHSELN: _als_dienst(lambda a: reifen.wechseln(k, a.data["satz"])),
|
||||
D_REIFEN_KM_SETZEN: _als_dienst(
|
||||
lambda a: reifen.km_setzen(k, a.data["satz"], a.data["km"])
|
||||
),
|
||||
D_BACKUP_JETZT: _als_dienst(lambda _a: sicherung.jetzt(k)),
|
||||
D_BACKUP_WIEDERHERSTELLEN: _als_dienst(
|
||||
lambda a: sicherung.wiederherstellen(k, dict(a.data))
|
||||
),
|
||||
}
|
||||
|
||||
for name, funktion in behandler.items():
|
||||
hass.services.async_register(DOMAIN, name, funktion, schema=SCHEMATA[name])
|
||||
|
||||
|
||||
def abmelden(hass: HomeAssistant) -> None:
|
||||
for name in SCHEMATA:
|
||||
hass.services.async_remove(DOMAIN, name)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Welche Home-Assistant-Entität welche Rolle im Fahrzeug spielt.
|
||||
|
||||
Das ist die einzige technische Konfiguration der App. Alles andere (Pausen-
|
||||
zeit, Reifendaten, Versicherung, ...) gehört ins Fahrzeugprofil und ist über
|
||||
die Oberfläche änderbar.
|
||||
|
||||
**Im Auslieferstand ist hier alles leer.** Das ist Absicht, kein unfertiger
|
||||
Zustand: welche Entity-IDs richtig sind, hängt an der jeweiligen Instanz und
|
||||
ihren Integrationen. Zugeordnet wird nach der Installation im Setup-Menü der
|
||||
App (Einstellungen -> Fahrzeug einrichten -> Setup), das die Zuordnung nach
|
||||
audi_dashboard/entitaeten.json schreibt (siehe zuordnung.py).
|
||||
|
||||
Ein leeres Feld ist der sichere Zustand: die betroffene Kachel zeigt
|
||||
"unbekannt" statt eines falschen Werts (siehe zustand_oder_none() in
|
||||
veroeffentlichung.py), und die trigger-gebundenen Felder registrieren gar
|
||||
keinen Zustandsbeobachter, statt einen gegen eine nicht existierende Entität
|
||||
zu registrieren. Eine gesetzte, aber falsche Entity-ID ist deshalb schlechter
|
||||
als eine leere - genau das ist 2026-08-23 einmal passiert, als hier noch die
|
||||
Entity-IDs einer längst abgeräumten Testinstanz standen.
|
||||
|
||||
Zwei typische Quellen auf dieser Instanz: der Teltonika FMM003 (GPS-Tracker
|
||||
mit CAN-Anbindung, über flespi angebunden) und eine EU-Data-Act-Integration
|
||||
des Herstellers. Welche davon welche Rolle bedient, entscheidet das
|
||||
Setup-Menü - nicht diese Datei.
|
||||
|
||||
UNTERSCHIED ZUR PYSCRIPT-FASSUNG: dort waren das Modul-Variablen, die
|
||||
entitaeten.py zur Laufzeit per setattr() auf dem importierten Modul-Objekt
|
||||
überschrieb. Das funktionierte, war aber ein globaler, veränderlicher Zustand
|
||||
quer durch alle Dateien. Hier ist es ein ganz normales Objekt, das der
|
||||
Koordinator hält und weiterreicht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, fields
|
||||
|
||||
# Vier gleich aufgebaute Positionen für Türen und Fenster.
|
||||
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sensorzuordnung:
|
||||
"""Die wirksame Zuordnung. Feldnamen = Schlüssel in entitaeten.json."""
|
||||
|
||||
# Fahrterkennung: Start/Ende einer Fahrt über den Zündungs-/ACC-Status
|
||||
# (on = Fahrt läuft). Dieselbe Entität speist auch die Anzeige
|
||||
# "fährt/steht" - damit können Erfassung und Anzeige nicht auseinander-
|
||||
# laufen.
|
||||
ZUENDUNG_SENSOR: str = ""
|
||||
|
||||
# Kilometerstand - für Fahrtabschluss-Screening, Reifenzähler und
|
||||
# Ölwechsel-Prognose.
|
||||
#
|
||||
# Beim FMM003 hier NICHT den selbst berechneten Gesamtkilometerstand
|
||||
# (*_total_calculated_mileage) zuordnen: der beruht auf GPS-Strecken-
|
||||
# rechnung statt auf dem Tacho und damit auf einer anderen Zählbasis als
|
||||
# der echte Fahrzeug-Kilometerstand. Wer ihn einträgt, verfälscht alle
|
||||
# drei genannten Auswertungen mit einem inkonsistenten Basiswert. Der vom
|
||||
# CAN gelesene Wert (*_total_vehicle_mileage_read_from_can) bzw. der
|
||||
# Kilometerstand der EU-Data-Act-Integration ist der richtige.
|
||||
KM_SENSOR: str = ""
|
||||
|
||||
# Tankfüllstand in Prozent - für die automatische Tankerkennung.
|
||||
TANK_SENSOR: str = ""
|
||||
|
||||
# Reichweite (Übersicht).
|
||||
RANGE_SENSOR: str = ""
|
||||
|
||||
# 12V-Batteriespannung. Beim FMM003 ist das external_power_voltage - die
|
||||
# vom Gerät gemessene Bordnetzspannung des Fahrzeugs -, NICHT
|
||||
# battery_voltage (das ist die interne Pufferbatterie des Trackers selbst
|
||||
# und hat mit der Fahrzeugbatterie nichts zu tun).
|
||||
BATTERIE_SENSOR: str = ""
|
||||
|
||||
# Knopf für eine sofortige Neuabfrage beim Fahrzeug.
|
||||
REFRESH_BUTTON: str = ""
|
||||
|
||||
# Türen/Fenster (je vier Positionen, siehe POSITIONEN).
|
||||
TUER_SENSOREN: list[str] = field(default_factory=list)
|
||||
FENSTER_SENSOREN: list[str] = field(default_factory=list)
|
||||
HECKKLAPPE_SENSOR: str = ""
|
||||
HAUBE_SENSOR: str = ""
|
||||
|
||||
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
|
||||
# App-eigene, aus dem Servicebuch berechnete Prognose).
|
||||
NAECHSTER_OELWECHSEL_SENSOR: str = ""
|
||||
OELWECHSEL_STRECKE_SENSOR: str = ""
|
||||
NAECHSTE_INSPEKTION_SENSOR: str = ""
|
||||
INSPEKTION_STRECKE_SENSOR: str = ""
|
||||
|
||||
# Live-GPS-Position (Übersicht -> Standort-Kachel). Breiten-/Längengrad
|
||||
# als zwei eigene sensor-Entities: flespi liefert Koordinaten so, nicht
|
||||
# als Attribute einer device_tracker-Entity.
|
||||
STANDORT_LAT_SENSOR: str = ""
|
||||
STANDORT_LON_SENSOR: str = ""
|
||||
|
||||
|
||||
def _standardwerte() -> dict[str, object]:
|
||||
"""Die eingebauten Werte, einmal eingefroren.
|
||||
|
||||
Gebraucht für "Zurücksetzen" im Setup-Menü: die Zuordnung wird bei jedem
|
||||
Anwenden vollständig neu gesetzt, ein weggelassener Override fällt damit
|
||||
auf genau diesen Wert zurück."""
|
||||
leer = Sensorzuordnung()
|
||||
werte: dict[str, object] = {}
|
||||
for f in fields(leer):
|
||||
wert = getattr(leer, f.name)
|
||||
werte[f.name] = list(wert) if isinstance(wert, list) else wert
|
||||
return werte
|
||||
|
||||
|
||||
STANDARDWERTE: dict[str, object] = _standardwerte()
|
||||
|
||||
SCHLUESSEL: set[str] = set(STANDARDWERTE)
|
||||
|
||||
|
||||
# Katalog aller Rollen fürs Setup-Menü im Frontend. Reihenfolge =
|
||||
# Anzeige-Reihenfolge innerhalb jeder Gruppe. domains/device_classes/units
|
||||
# sind Erwartungen fürs Frontend-seitige Filtern und Vorschlagen (Schalter
|
||||
# "Nur passende Sensoren anzeigen") - eine fehlende oder leere Liste bedeutet
|
||||
# "keine Einschränkung" bzw. "diese Rolle hat üblicherweise keine Einheit".
|
||||
FELDER: list[dict] = [
|
||||
{"key": "ZUENDUNG_SENSOR", "label": "Zündung/ACC-Status", "gruppe": "fahrterkennung",
|
||||
"hinweis": "on = Fahrt läuft, erkennt Fahrtbeginn/-ende.",
|
||||
"domains": ["binary_sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": True,
|
||||
"stichworte": ["zündung", "ignition", "acc", "motor", "engine"]},
|
||||
{"key": "KM_SENSOR", "label": "Kilometerstand", "gruppe": "fahrterkennung",
|
||||
"hinweis": "Gesamt-Kilometerstand des Fahrzeugs - für Fahrtabschluss-Screening, Reifenzähler und Ölwechsel-Prognose.",
|
||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["kilometer", "km", "mileage", "odometer", "tacho", "laufleistung"]},
|
||||
{"key": "TANK_SENSOR", "label": "Tankfüllstand", "gruppe": "fahrterkennung",
|
||||
"hinweis": "Füllstand in Prozent - für die automatische Tankerkennung.",
|
||||
"domains": ["sensor"], "device_classes": [], "units": ["%"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["tank", "fuel", "kraftstoff", "füllstand", "level"]},
|
||||
{"key": "RANGE_SENSOR", "label": "Reichweite", "gruppe": "uebersicht",
|
||||
"hinweis": "Für die Übersicht - bleibt sie leer, zeigt die Oberfläche \"unbekannt\".",
|
||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["reichweite", "range"]},
|
||||
{"key": "BATTERIE_SENSOR", "label": "12V-Batteriespannung", "gruppe": "uebersicht",
|
||||
"hinweis": "Nicht jede Integration liefert diesen Wert - bleibt er leer, zeigt \"Mein Audi\" \"unbekannt\".",
|
||||
"domains": ["sensor"], "device_classes": ["voltage"], "units": ["V"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["batterie", "battery", "spannung", "volt", "12v"]},
|
||||
{"key": "REFRESH_BUTTON", "label": "Sofort-Aktualisierung (Knopf)", "gruppe": "uebersicht",
|
||||
"hinweis": "Der von der Integration bereitgestellte Knopf für \"Ziehen zum Aktualisieren\".",
|
||||
"domains": ["button"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["refresh", "aktualisieren", "update", "abrufen", "poll"]},
|
||||
{"key": "TUER_SENSOREN", "label": "Türen", "gruppe": "sicherheit",
|
||||
"hinweis": "\"aus\"/off = zu. Für \"Sicher abgestellt\" wichtig.",
|
||||
"domains": ["binary_sensor"], "device_classes": ["door"], "units": [], "liste": True, "positionen": POSITIONEN,
|
||||
"pflicht": False, "stichworte": ["tür", "door"]},
|
||||
{"key": "FENSTER_SENSOREN", "label": "Fenster", "gruppe": "sicherheit",
|
||||
"hinweis": "\"aus\"/off = zu.",
|
||||
"domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN,
|
||||
"pflicht": False, "stichworte": ["fenster", "window"]},
|
||||
{"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit",
|
||||
"hinweis": "\"aus\"/off = zu.",
|
||||
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["heckklappe", "tailgate", "kofferraum", "trunk"]},
|
||||
{"key": "HAUBE_SENSOR", "label": "Motorhaube", "gruppe": "sicherheit",
|
||||
"hinweis": "\"aus\"/off = zu.",
|
||||
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
|
||||
{"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht",
|
||||
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.",
|
||||
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["ölwechsel", "oil", "service", "change"]},
|
||||
{"key": "OELWECHSEL_STRECKE_SENSOR", "label": "Nächster Ölwechsel (Restkilometer)", "gruppe": "uebersicht",
|
||||
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
|
||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["ölwechsel", "oil", "distance", "strecke"]},
|
||||
{"key": "NAECHSTE_INSPEKTION_SENSOR", "label": "Nächste Inspektion (Datum)", "gruppe": "uebersicht",
|
||||
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit.",
|
||||
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["inspektion", "inspection", "service"]},
|
||||
{"key": "INSPEKTION_STRECKE_SENSOR", "label": "Nächste Inspektion (Restkilometer)", "gruppe": "uebersicht",
|
||||
"hinweis": "Meldet die Integration oft als negativen Countdown - die App zeigt nur den Betrag.",
|
||||
"domains": ["sensor"], "device_classes": ["distance"], "units": ["km", "mi"], "liste": False, "pflicht": False,
|
||||
"stichworte": ["inspektion", "inspection", "distance", "strecke"]},
|
||||
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
|
||||
"hinweis": "Breitengrad als eigener Sensor (z. B. flespi).",
|
||||
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["breitengrad", "latitude", "lat", "standort", "gps", "position", "ortung"]},
|
||||
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
|
||||
"hinweis": "Längengrad als eigener Sensor (z. B. flespi).",
|
||||
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
|
||||
"stichworte": ["längengrad", "longitude", "lon", "lng", "standort", "gps", "position", "ortung"]},
|
||||
]
|
||||
|
||||
# In der pyscript-Fassung stand hier eine Liste TRIGGER_GEBUNDEN: die drei
|
||||
# Felder ZUENDUNG/KM/TANK_SENSOR waren beim Modul-Laden einmalig in die
|
||||
# @state_trigger-Registrierung eingebacken, eine Änderung wirkte deshalb erst
|
||||
# nach einem HA-Neustart, und das Setup-Menü musste genau davor warnen.
|
||||
# Diese Integration bindet ihre Zustandsbeobachter bei jeder Änderung neu
|
||||
# (koordinator.trigger_neu_binden) - die Einschränkung, der Warnhinweis und
|
||||
# der eigens dafür gebaute Neustart-Dienst sind damit ersatzlos weg.
|
||||
for _feld in FELDER:
|
||||
_feld["trigger_gebunden"] = False
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Fahrterkennung über den Zündungs-/ACC-Status (SPECIFICATION.md §7.1).
|
||||
|
||||
Start: die Zündungs-Entität wechselt auf "on". Ende: sie wechselt auf "off"
|
||||
und kommt binnen der Pausenzeit nicht zurück.
|
||||
|
||||
Die Pausenregel läuft über eine abbrechbare Aufgabe: jede neue Änderung der
|
||||
Zündung bricht eine noch wartende Ende-Bestätigung ab. Kommt die Zündung
|
||||
während der Wartezeit zurück, wird die Fahrt dadurch nicht beendet - genau
|
||||
die Pausenregel aus §7.1. (In der pyscript-Fassung leisteten task.unique() und
|
||||
task.sleep() dasselbe.)
|
||||
|
||||
WAS HIER GEGENÜBER DER PYSCRIPT-FASSUNG BESSER IST: dort lebte der Beginn
|
||||
einer laufenden Fahrt nur im Arbeitsspeicher. Ein HA-Neustart mitten in einer
|
||||
Fahrt oder während der Wartezeit verlor diesen Zwischenstand - die Fahrt wurde
|
||||
dann gar nicht aufgezeichnet. Das war als bekannte Schwäche vermerkt und ist
|
||||
hier behoben: der Zwischenstand liegt in einem Store (siehe koordinator.py),
|
||||
überlebt einen Neustart und wird beim Hochfahren wieder aufgenommen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from . import screening
|
||||
from .ablage import neue_id
|
||||
from .veroeffentlichung import zustand_oder_none
|
||||
from .verlauf import verlauf_lesen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STANDARD_PAUSENZEIT_MIN = 15
|
||||
|
||||
|
||||
def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle: str) -> dict:
|
||||
"""Das vollständige Fahrt-Schema mit allem, was noch nicht bekannt ist,
|
||||
auf None. Bewusst alle Felder ausgeschrieben: die Oberfläche darf sich auf
|
||||
ihre Existenz verlassen, und ein fehlendes Feld wäre schwerer zu finden
|
||||
als ein leeres."""
|
||||
return {
|
||||
"trip_id": neue_id("t"),
|
||||
"ts_start": start_ts.isoformat(),
|
||||
"ts_end": ende_ts.isoformat(),
|
||||
"duration_s": int((ende_ts - start_ts).total_seconds()),
|
||||
"distance_km": None,
|
||||
"km_quelle": None,
|
||||
"odo_start": None,
|
||||
"odo_end": None,
|
||||
"avg_speed_kmh": None,
|
||||
"start_lat": None,
|
||||
"start_lon": None,
|
||||
"end_lat": None,
|
||||
"end_lon": None,
|
||||
"start_address": None,
|
||||
"end_address": None,
|
||||
"art": "privat",
|
||||
"route": None,
|
||||
"pausen": [],
|
||||
"source": quelle,
|
||||
"status": "offen",
|
||||
"edited_fields": [],
|
||||
}
|
||||
|
||||
|
||||
async def pausenzeit_sekunden(k: Koordinator) -> int:
|
||||
profil = await k.ablage.profil_lesen()
|
||||
if profil is None:
|
||||
return STANDARD_PAUSENZEIT_MIN * 60
|
||||
minuten = profil.get("einstellungen", {}).get(
|
||||
"fahrten_pausenzeit_min", STANDARD_PAUSENZEIT_MIN
|
||||
)
|
||||
return int(minuten) * 60
|
||||
|
||||
|
||||
async def zuendung_geaendert(k: Koordinator, neu: str | None, alt: str | None) -> None:
|
||||
"""Reagiert auf jede Zustandsänderung der Zündungs-Entität."""
|
||||
# Eine noch wartende Ende-Bestätigung aus einer vorherigen Änderung
|
||||
# abbrechen - das ist der Mechanismus hinter der Pausenregel.
|
||||
k.warte_ende_ab_abbrechen()
|
||||
|
||||
an_jetzt = neu == "on"
|
||||
an_vorher = alt == "on"
|
||||
|
||||
if an_jetzt and not an_vorher:
|
||||
if k.fahrt_start_ts is None:
|
||||
await k.fahrt_start_setzen(datetime.datetime.now(datetime.UTC))
|
||||
_LOGGER.info("Fahrt gestartet um %s", k.fahrt_start_ts)
|
||||
# Die Anzeige "fährt/steht" hängt an derselben Entität - sofort neu
|
||||
# veröffentlichen, statt bis zum nächsten 20-Sekunden-Takt zu warten.
|
||||
await k.fahrzeugstatus_veroeffentlichen()
|
||||
return
|
||||
|
||||
if an_vorher and not an_jetzt and k.fahrt_start_ts is not None:
|
||||
start_ts = k.fahrt_start_ts
|
||||
abbruch_ts = datetime.datetime.now(datetime.UTC)
|
||||
wartezeit = await pausenzeit_sekunden(k)
|
||||
k.warte_ende_ab(_ende_bestaetigen(k, start_ts, abbruch_ts, wartezeit))
|
||||
|
||||
await k.fahrzeugstatus_veroeffentlichen()
|
||||
|
||||
|
||||
async def _ende_bestaetigen(
|
||||
k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime, wartezeit: int
|
||||
) -> None:
|
||||
"""Wartet die Pausenzeit ab und beendet die Fahrt dann.
|
||||
|
||||
Kommt die Zündung vorher zurück, bricht zuendung_geaendert() genau diese
|
||||
Aufgabe ab und der Code hinter dem sleep läuft nie."""
|
||||
try:
|
||||
await asyncio.sleep(wartezeit)
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("Fahrtende verworfen - Zündung kam innerhalb der Pausenzeit zurück")
|
||||
raise
|
||||
await fahrt_beenden(k, start_ts, ende_ts)
|
||||
|
||||
|
||||
async def nach_neustart_fortsetzen(k: Koordinator) -> None:
|
||||
"""Schließt eine Fahrt ab, die während eines HA-Ausfalls zu Ende ging.
|
||||
|
||||
Der Zwischenstand einer laufenden Fahrt überlebt jetzt einen Neustart
|
||||
(Store im Koordinator). Damit entsteht aber ein neuer Fall, den es vorher
|
||||
nicht gab: ging die Zündung aus, WÄHREND Home Assistant unten war, hat
|
||||
niemand die Ende-Bestätigung gesehen - der Beobachter bekommt nach dem
|
||||
Hochfahren keinen Wechsel mehr, weil der längst passiert ist. Ohne diese
|
||||
Prüfung bliebe die Fahrt für immer offen, und die nächste echte Fahrt
|
||||
würde an ihren Beginn angehängt.
|
||||
|
||||
Läuft bewusst erst, wenn Home Assistant vollständig gestartet ist: mitten
|
||||
im Einrichten hat die Datenquelle ihren Zustand oft noch nicht gemeldet,
|
||||
und ein "noch nicht da" ließe sich nicht von "aus" unterscheiden."""
|
||||
if k.fahrt_start_ts is None:
|
||||
return
|
||||
|
||||
sensor = k.zuordnung.werte.ZUENDUNG_SENSOR
|
||||
if zustand_oder_none(k.hass, sensor) == "on":
|
||||
return # fährt noch - der Beobachter übernimmt wie sonst auch
|
||||
|
||||
ende_ts = await _letztes_lebenszeichen(k, sensor, k.fahrt_start_ts)
|
||||
_LOGGER.info(
|
||||
"Fahrt seit %s wurde während eines Ausfalls beendet - wird jetzt mit "
|
||||
"Ende %s abgeschlossen", k.fahrt_start_ts.isoformat(), ende_ts.isoformat(),
|
||||
)
|
||||
await fahrt_beenden(k, k.fahrt_start_ts, ende_ts)
|
||||
|
||||
|
||||
async def _letztes_lebenszeichen(
|
||||
k: Koordinator, sensor: str, seit: datetime.datetime
|
||||
) -> datetime.datetime:
|
||||
"""Der zuletzt aufgezeichnete Zeitpunkt der Zündungs-Entität seit `seit`.
|
||||
|
||||
Das ist der ehrlichste verfügbare Endzeitpunkt: bis dahin wissen wir, dass
|
||||
das Fahrzeug beobachtet wurde. "Jetzt" zu nehmen wäre falsch, wenn Home
|
||||
Assistant tagelang unten war - daraus würde eine Fahrt über Tage. Gibt es
|
||||
gar keine Aufzeichnung (Sensor nicht zugeordnet, recorder zu kurz), bleibt
|
||||
nur "jetzt"; das Screening trägt die Kilometerstände ohnehin nach."""
|
||||
jetzt = datetime.datetime.now(datetime.UTC)
|
||||
punkte = await verlauf_lesen(k.hass, sensor, seit, jetzt)
|
||||
return punkte[-1][0] if punkte else jetzt
|
||||
|
||||
|
||||
async def fahrt_beenden(
|
||||
k: Koordinator, start_ts: datetime.datetime, ende_ts: datetime.datetime
|
||||
) -> None:
|
||||
fahrt = leere_fahrt(start_ts, ende_ts, "ha")
|
||||
await k.ablage.fahrt_anhaengen(fahrt)
|
||||
await k.fahrt_start_setzen(None)
|
||||
await k.fahrten_veroeffentlichen()
|
||||
_LOGGER.info(
|
||||
"Fahrt %s beendet, Status 'offen', Screening angestoßen", fahrt["trip_id"]
|
||||
)
|
||||
await screening.durchfuehren(k)
|
||||
|
||||
|
||||
# ------------------------------------------------------- Aus der Oberfläche
|
||||
|
||||
HANDFELDER = {"start_address", "end_address", "odo_start", "odo_end", "distance_km"}
|
||||
|
||||
|
||||
def _handfelder(
|
||||
start_ort: str | None,
|
||||
ziel_ort: str | None,
|
||||
odo_start: float | None,
|
||||
odo_end: float | None,
|
||||
distanz: float | None,
|
||||
) -> dict:
|
||||
"""Die von Hand befüllbaren Felder auf ihre Datensatz-Namen abgebildet,
|
||||
ohne die leer gelassenen. Der Aufrufer schreibt die Schlüssel zusätzlich
|
||||
nach edited_fields - das schützt sie vor dem automatischen
|
||||
Kilometerstand-Screening, das sonst später darüberschreiben würde."""
|
||||
werte = {
|
||||
"start_address": start_ort or None,
|
||||
"end_address": ziel_ort or None,
|
||||
"odo_start": odo_start,
|
||||
"odo_end": odo_end,
|
||||
"distance_km": distanz,
|
||||
}
|
||||
felder = {k: v for k, v in werte.items() if v is not None}
|
||||
# Fehlt die Distanz, ergibt sie sich aus den beiden Kilometerständen -
|
||||
# dieselbe Rechnung wie im Screening.
|
||||
if "distance_km" not in felder and "odo_start" in felder and "odo_end" in felder:
|
||||
felder["distance_km"] = round(felder["odo_end"] - felder["odo_start"], 1)
|
||||
return felder
|
||||
|
||||
|
||||
async def manuell_anlegen(k: Koordinator, daten: dict) -> None:
|
||||
"""Manuelle Fahrterfassung aus der Oberfläche (§5.3), unabhängig von der
|
||||
automatischen Zündungs-Erkennung. Alles außer Start- und Endzeitpunkt ist
|
||||
optional und bleibt leer, wenn es das Screening später selbst ergänzen
|
||||
soll."""
|
||||
start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
|
||||
ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
|
||||
hand = _handfelder(
|
||||
daten.get("start_ort"), daten.get("ziel_ort"),
|
||||
daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"),
|
||||
)
|
||||
|
||||
fahrt = leere_fahrt(start_ts, ende_ts, "manual")
|
||||
fahrt["art"] = daten.get("art") or "privat"
|
||||
fahrt.update(hand)
|
||||
fahrt["edited_fields"] = sorted(hand)
|
||||
if fahrt["distance_km"] is not None:
|
||||
fahrt["km_quelle"] = "manuell"
|
||||
fahrt["status"] = "vollständig"
|
||||
|
||||
await k.ablage.fahrt_anhaengen(fahrt)
|
||||
await k.fahrten_veroeffentlichen()
|
||||
await screening.durchfuehren(k)
|
||||
_LOGGER.info("Fahrt %s von Hand angelegt", fahrt["trip_id"])
|
||||
|
||||
|
||||
async def aktualisieren(k: Koordinator, daten: dict) -> None:
|
||||
"""Bearbeitet eine bestehende Fahrt (Einzelfahrt-Seite), egal ob sie
|
||||
automatisch erkannt oder von Hand angelegt wurde."""
|
||||
trip_id = daten.get("trip_id")
|
||||
if not trip_id:
|
||||
_LOGGER.warning("fahrt_aktualisieren ohne trip_id aufgerufen")
|
||||
return
|
||||
|
||||
aenderungen = _handfelder(
|
||||
daten.get("start_ort"), daten.get("ziel_ort"),
|
||||
daten.get("odo_start"), daten.get("odo_end"), daten.get("distanz"),
|
||||
)
|
||||
if daten.get("art"):
|
||||
aenderungen["art"] = daten["art"]
|
||||
if daten.get("ts_start") and daten.get("ts_end"):
|
||||
start_ts = datetime.datetime.fromisoformat(daten["ts_start"])
|
||||
ende_ts = datetime.datetime.fromisoformat(daten["ts_end"])
|
||||
aenderungen["ts_start"] = start_ts.isoformat()
|
||||
aenderungen["ts_end"] = ende_ts.isoformat()
|
||||
aenderungen["duration_s"] = int((ende_ts - start_ts).total_seconds())
|
||||
if aenderungen.get("distance_km") is not None:
|
||||
aenderungen["km_quelle"] = "manuell"
|
||||
aenderungen["status"] = "vollständig"
|
||||
|
||||
# Von Hand gesetzte Felder bleiben von Hand gesetzt: die Liste wächst mit,
|
||||
# damit ein späteres Screening sie nicht wieder überschreibt.
|
||||
passend = [f for f in await k.ablage.fahrten_lesen() if f.get("trip_id") == trip_id]
|
||||
bisher = set(passend[0].get("edited_fields", [])) if passend else set()
|
||||
aenderungen["edited_fields"] = sorted(bisher | (HANDFELDER & set(aenderungen)))
|
||||
|
||||
if await k.ablage.fahrt_bearbeiten(trip_id, aenderungen):
|
||||
await k.fahrten_veroeffentlichen()
|
||||
_LOGGER.info("Fahrt %s aktualisiert", trip_id)
|
||||
else:
|
||||
_LOGGER.warning("Fahrt %s nicht gefunden", trip_id)
|
||||
|
||||
|
||||
async def loeschen(k: Koordinator, trip_id: str | None) -> None:
|
||||
if not trip_id:
|
||||
_LOGGER.warning("fahrt_loeschen ohne trip_id aufgerufen")
|
||||
return
|
||||
if await k.ablage.fahrt_loeschen(trip_id):
|
||||
await k.fahrten_veroeffentlichen()
|
||||
_LOGGER.info("Fahrt %s gelöscht", trip_id)
|
||||
else:
|
||||
_LOGGER.warning("Fahrt %s nicht gefunden", trip_id)
|
||||
@@ -0,0 +1,344 @@
|
||||
/* ==================================================================
|
||||
iOS-Auflage über audi-dashboard.css — Audi CI (Rot F50537, Audi Type,
|
||||
dunkle Fläche) in der Formensprache einer modernen iOS-App:
|
||||
Große Titel, gruppierte Listen mit 44-px-Zeilen, transluente Tab-Leiste,
|
||||
iOS-Schalter und -Segmente, gefüllte Aktionsknöpfe.
|
||||
Reine Auflage: audi-dashboard.css und die App-Logik bleiben unberührt.
|
||||
================================================================== */
|
||||
|
||||
:host{
|
||||
display:block;height:100%;container-type:inline-size;
|
||||
font-family:"Audi Type",Helvetica,Arial,sans-serif;
|
||||
--ios-tint:#F50537;
|
||||
--ios-sep:rgba(255,255,255,.13);
|
||||
--ios-grau:#8E8E93;
|
||||
--ios-fill:rgba(120,120,128,.24);
|
||||
--r-tile:16px;
|
||||
/* deckende Kachelfarbe fuer Flaechen, die etwas verdecken muessen
|
||||
(Wischzeile ueber dem Loeschen-Feld, Kartenfeld) */
|
||||
--tile-deckend:#171B21;
|
||||
}
|
||||
:host([data-theme="nacht"]){
|
||||
--canvas:#0C1014;
|
||||
--tile:rgba(255,255,255,.055);
|
||||
--tile-2:rgba(255,255,255,.10);
|
||||
--line:rgba(255,255,255,.13);
|
||||
--line-strong:rgba(255,255,255,.24);
|
||||
--fg:#FFFFFF; --fg2:#BFC4CC; --fg3:#8E8E93;
|
||||
--ok:#30D158; --warn:#FFD60A; --bad:#FF453A;
|
||||
/* Ohne diese Angabe rendert der Browser native Steuerelemente (Dropdown-
|
||||
Aufklapp-Liste bei <select>, Scrollbars) in seiner Standard-Hellansicht,
|
||||
egal wie dunkel die App selbst ist - das liess z. B. "Modell" beim
|
||||
Aufklappen grell-weiss aufblitzen. Betrifft alle <select>-Felder. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
:host([data-theme="tag"]){
|
||||
--canvas:#F2F2F7;
|
||||
--tile:#FFFFFF;
|
||||
--tile-2:#EFEFF4;
|
||||
--line:rgba(60,60,67,.13);
|
||||
--line-strong:rgba(60,60,67,.24);
|
||||
--fg:#000000; --fg2:#3C3C43; --fg3:#8E8E93;
|
||||
--ok:#34C759; --warn:#FF9F0A; --bad:#FF3B30;
|
||||
--ios-sep:rgba(60,60,67,.16);
|
||||
--ios-fill:rgba(120,120,128,.16);
|
||||
--tile-deckend:#FFFFFF;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body{padding:0;background:var(--canvas)}
|
||||
html,body{height:100%}
|
||||
/* Der von _aufbauen() eingefuegte Wrapper zwischen Host und .phone. Die
|
||||
412px-Deckelung aus audi-dashboard.css (fuer die alte Einzelgroesse
|
||||
gedacht) wird hier unbedingt aufgehoben, nicht nur ab 860px: auf
|
||||
schmalen Bildschirmen macht der Viewport selbst die Breite eng genug,
|
||||
:host-Selektoren innerhalb @container werden von diesem Browser aber
|
||||
offenbar verworfen - deshalb unbedingt statt bedingt.
|
||||
Die Hoehe bleibt dagegen viewport-verankert (100dvh statt 100%): das ist
|
||||
der Grund, warum audi-dashboard.css das ueberhaupt so baute - HA gibt der
|
||||
panel_custom-Kette keine verlaessliche Hoehe weiter, height:100% liess den
|
||||
Hauptinhalt hier auf 0px kollabieren (Tabbar direkt unter dem Header,
|
||||
main#view unsichtbar). Erst ab 860px (siehe @container unten) darf die
|
||||
880px-Deckelung fallen, damit die App auf grossen Bildschirmen mehr als
|
||||
eine schwebende Telefonkarte ist. */
|
||||
:host > div{height:min(880px, calc(100dvh - 24px));max-width:none}
|
||||
.phone{width:100%;max-width:none;height:100%;border-radius:0;
|
||||
font-family:"Audi Type",Helvetica,Arial,sans-serif;
|
||||
-webkit-font-smoothing:antialiased;letter-spacing:0}
|
||||
|
||||
/* ---------------- Navigationsleiste ---------------- */
|
||||
/* Sichere Bereiche des Geräterahmens: Statusleiste oben, Home-Indikator unten */
|
||||
/* Linker Innenabstand = --seitenrand von main#view (16px), damit der Titel
|
||||
exakt auf der Kante der Kacheln darunter sitzt. */
|
||||
.topbar{align-items:center;gap:4px;padding:56px 20px 4px 16px}
|
||||
.head{display:flex;flex-direction:column}
|
||||
.eyebrow{display:none}
|
||||
.title{font-size:22px;font-weight:400;letter-spacing:-.01em;line-height:1.2}
|
||||
.back{width:34px;height:34px;margin-left:-8px;justify-content:center}
|
||||
/* Design-Audit 2026-08-13, auf allen Breiten nachgezogen 2026-08-17: .back ist
|
||||
auch inaktiv (opacity:0) noch ein Flex-Element plus Gap - auf Wurzelseiten
|
||||
ohne Zurueck-Pfeil schob das die Kopfzeile (.head/.title) sichtbar weiter
|
||||
nach rechts, als main#view darunter beginnt. Das negative margin-right hebt
|
||||
zusaetzlich den Gap auf, sonst blieben 4px Versatz. Auf Detailseiten
|
||||
(.back.on) bleibt der Platz bewusst reserviert, das ist dort ein echter,
|
||||
bedienbarer Knopf. */
|
||||
.back:not(.on){width:0;min-width:0;margin:0 -4px 0 0;overflow:hidden}
|
||||
/* Rot bleibt Akzent/destruktiv, nicht Navigationsfarbe (dieselbe Regel wie
|
||||
bei den Aktions-Buttons und dem aktiven Menü-Icon) - derselbe neutrale Ton
|
||||
wie die Kopfzeile selbst. */
|
||||
.back svg{width:22px;height:22px;color:var(--fg)}
|
||||
.sync{margin-top:3px;font-size:12.5px;color:var(--fg3)}
|
||||
.sync svg{width:13px;height:13px}
|
||||
.themebtn{width:36px;height:36px;border:0;background:var(--ios-fill);color:var(--fg2)}
|
||||
.themebtn:hover{background:var(--tile-2);color:var(--fg)}
|
||||
/* Telefonbreite: die Ringe stehen oben rechts und oeffnen die Einstellungen
|
||||
(Stand nach dem Design-Audit). Das Zahnrad entfaellt dort, damit oben rechts
|
||||
nur ein Element sitzt. */
|
||||
.rings{order:9;margin-left:8px;margin-right:-10px;cursor:pointer;
|
||||
min-width:44px;min-height:44px;height:44px;justify-content:flex-end;padding:0 10px}
|
||||
.rings svg{height:17px}
|
||||
.profilbtn{display:none}
|
||||
/* Unterseite: kompakter, zentrierter Titel wie in einer iOS-Navigationsleiste */
|
||||
.phone:has(.back.on) .topbar{padding:54px 16px 6px;gap:8px}
|
||||
.phone:has(.back.on) .head{align-items:center}
|
||||
.phone:has(.back.on) .title{font-size:17px;font-weight:400;letter-spacing:-.01em;text-align:center}
|
||||
.phone:has(.back.on) .sync{margin-top:2px;font-size:11.5px}
|
||||
|
||||
main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smooth}
|
||||
|
||||
/* ---------------- Karten und Listen ---------------- */
|
||||
.tile{padding:16px 18px;margin-bottom:14px;border-radius:var(--r-tile)}
|
||||
.tile.flat{padding:0}
|
||||
.tilebtn:hover{background:var(--tile-2)}
|
||||
/* top/margin-top NICHT von der Basis uebernehmen: alle .go-Kacheln
|
||||
(Fahrzeug/Service/Versicherung/Reifen/Schutzbrief) sind mehrzeilige
|
||||
Kacheln mit .label oben plus einer ganzen dl.rows-Liste darunter - eine
|
||||
vertikale 50%-Zentrierung landet dann mitten in der Zeilenliste statt
|
||||
neben dem Titel und ueberlappt umgebrochenen Zeilentext (Design-Audit
|
||||
2026-08-13). Bleibt bei top:var(--sp-5) aus der Basis, nur rechter
|
||||
Abstand/Farbe werden fuer die iOS-Optik angepasst. */
|
||||
.tilebtn .go{right:18px;stroke:var(--ios-grau);stroke-width:1.6}
|
||||
.label{font-size:13px;letter-spacing:.01em;text-transform:none;color:var(--fg3)}
|
||||
.row{min-height:44px;padding:11px 0;border-bottom:1px solid var(--ios-sep)}
|
||||
.row dt{font-size:16px;color:var(--fg)}
|
||||
.row dd{font-size:16px;color:var(--fg2)}
|
||||
.row dd small{font-size:12.5px;margin-top:2px}
|
||||
.row-fussnote{font-size:12.5px}
|
||||
.rows-fest dt{flex:0 0 150px}
|
||||
.chev{stroke:var(--ios-grau);stroke-width:1.6}
|
||||
|
||||
.acc-h{padding:13px 0;border-bottom:1px solid var(--ios-sep)}
|
||||
.acc-h .k{font-size:16px}
|
||||
.acc-h .v{font-size:12.5px}
|
||||
.leaf{padding:12px 0 12px 40px;border-bottom:1px solid var(--ios-sep)}
|
||||
.leaf .k{font-size:16px}
|
||||
.leaf .v{font-size:15px}
|
||||
.leaf .k small,.leaf .v small{font-size:12.5px}
|
||||
.mark::before,.mark::after{background:var(--ios-grau)}
|
||||
|
||||
/* ---------------- Bedienelemente ---------------- */
|
||||
.seg{gap:0;padding:2px;background:var(--ios-fill);border-radius:9px;margin-top:14px}
|
||||
.seg button{border:0;border-radius:7px;padding:7px 8px;font-size:13px;font-weight:400;
|
||||
letter-spacing:0;text-transform:none;color:var(--fg2)}
|
||||
.seg button.on{background:var(--tile-2);color:var(--fg);box-shadow:0 1px 3px rgba(0,0,0,.25)}
|
||||
|
||||
/* Aktive/Primär-Buttons sind grau, nicht rot (Nutzerwunsch 2026-08-16): Rot
|
||||
bleibt nur noch für destruktive Aktionen (.loeschen). .aktion allein ist
|
||||
die neutrale Sekundäraktion, .primaer die grau gefüllte Hauptaktion -
|
||||
dieselbe Fläche wie die Grundauflage (var(--fg)/var(--canvas)). */
|
||||
.aktion{margin-top:18px;padding:15px;border:0;border-radius:14px;
|
||||
background:var(--ios-fill);color:var(--fg);font-size:16px;font-weight:400;
|
||||
letter-spacing:0;text-transform:none}
|
||||
.aktion:hover{background:var(--tile-2)}
|
||||
.aktion.primaer{background:var(--fg);color:var(--canvas)}
|
||||
.aktion.primaer:hover{filter:brightness(1.08)}
|
||||
.aktion.loeschen{background:var(--ios-fill);color:var(--bad);border:0}
|
||||
.aktion.loeschen:hover{background:var(--bad);color:#fff;filter:none}
|
||||
|
||||
.feld{min-height:44px;padding:9px 0;border-bottom:1px solid var(--ios-sep)}
|
||||
.feld label{font-size:16px;color:var(--fg)}
|
||||
.feld input,.feld select{background:transparent;border:0;border-radius:8px;font-size:16px;
|
||||
padding:8px 4px;color:var(--fg2)}
|
||||
/* Design-Audit 2026-08-13/16: die graue Flaeche ist das einheitliche Signal
|
||||
"das hier kann ich aendern". Sie trugen bisher nur die Felder mit Einheit
|
||||
(.mitEinheit input, z.B. "Pause bis [15] Minuten"); alles andere - <select>
|
||||
wie "10.000 km"/"1 Jahr", aber auch FIN oder Kennzeichen - stand ohne Kasten
|
||||
da und war von reinem Anzeigetext nicht zu unterscheiden. Jetzt bekommt
|
||||
jedes editierbare Feld denselben Kasten. Ausgenommen sind Eingaben mit
|
||||
eigener Optik (Schalter, Auswahlkaestchen, Regler, Dateiauswahl) - die
|
||||
behalten die transparente Grundregel darueber.
|
||||
.mitEinheit input braucht deshalb keine eigene Regel mehr; Breite und
|
||||
Rechtsbuendigkeit dort kommen weiterhin aus audi-dashboard.css. */
|
||||
.feld input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),
|
||||
.feld select{background:var(--ios-fill);padding:8px 10px}
|
||||
.feld input:focus,.feld select:focus{color:var(--fg)}
|
||||
/* Pull-down-Button-Optik (Apple HIG): ein einzelner Abwaerts-Chevron statt
|
||||
des (durch appearance:none in audi-dashboard.css bereits entfernten)
|
||||
nativen Browser-Pfeils - macht die Auswahlfelder als solche erkennbar,
|
||||
ohne ein eigenes Menue-Widget nachzubauen. Farbe fix auf --ios-grau, das
|
||||
ist bereits die Farbe aller anderen Chevrons (.chev) in beiden Themes. */
|
||||
.feld select{
|
||||
padding-right:28px;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5l3.5 3.5 3.5-3.5' fill='none' stroke='%238E8E93' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat:no-repeat;
|
||||
background-position:right 9px center;
|
||||
background-size:10px 10px;
|
||||
}
|
||||
.notiz{background:var(--ios-fill);border:0;border-radius:12px;font-size:16px}
|
||||
|
||||
.switch{width:51px;height:31px}
|
||||
.switch .track{background:var(--ios-fill);border:0}
|
||||
.switch .track::before{left:2px;top:2px;width:27px;height:27px;box-shadow:0 2px 6px rgba(0,0,0,.3)}
|
||||
/* Grün statt Rot: Schalter-Ein-Zustand ist kein destruktiver/Primär-Akzent,
|
||||
sondern ein neutrales "an" - deckt sich mit dem echten iOS-Systemgrün, das
|
||||
--ok hier ohnehin schon trägt (Design-Review §B). */
|
||||
.switch input:checked+.track{background:var(--ok)}
|
||||
.switch input:checked+.track::before{transform:translateX(20px)}
|
||||
|
||||
.pill{font-size:12px;letter-spacing:.01em;text-transform:none;padding:5px 11px;
|
||||
border:0;background:var(--ios-fill);color:var(--fg2)}
|
||||
|
||||
.status{padding:14px 0 2px}
|
||||
.status .t{font-size:16px}
|
||||
.dot{width:10px;height:10px}
|
||||
|
||||
/* ---------------- Zahlen (Audi CI bleibt führend) ---------------- */
|
||||
.fig{letter-spacing:-.03em}
|
||||
.unit{color:var(--fg3)}
|
||||
.balken{height:6px;background:var(--ios-fill)}
|
||||
.balken i{background:var(--ok)}
|
||||
.quad .l{font-size:11px;letter-spacing:.01em;text-transform:none}
|
||||
.legend span{font-size:12.5px}
|
||||
|
||||
/* ---------------- Menüs und Popups ---------------- */
|
||||
.bildmenu,.sdpopup{border:0;border-radius:14px;background:rgba(40,44,52,.92);
|
||||
-webkit-backdrop-filter:saturate(180%) blur(22px);backdrop-filter:saturate(180%) blur(22px);
|
||||
box-shadow:0 12px 40px rgba(0,0,0,.5)}
|
||||
:host([data-theme="tag"]) .bildmenu,:host([data-theme="tag"]) .sdpopup{background:rgba(250,250,252,.92)}
|
||||
.bildmenu button{padding:13px 16px;font-size:16px;border-bottom:1px solid var(--ios-sep)}
|
||||
|
||||
/* ---------------- Tab-Leiste ---------------- */
|
||||
.tabbar{border-top:1px solid var(--ios-sep);padding-bottom:26px;
|
||||
background:rgba(12,16,20,.72);
|
||||
-webkit-backdrop-filter:saturate(180%) blur(24px);backdrop-filter:saturate(180%) blur(24px)}
|
||||
:host([data-theme="tag"]) .tabbar{background:rgba(242,242,247,.78)}
|
||||
.tab{border-top:0;margin-top:0;padding:7px 2px 6px;gap:3px;
|
||||
font-size:10px;letter-spacing:0;text-transform:none;color:var(--ios-grau)}
|
||||
/* Beschriftung folgt weiter dem Schalter in den Einstellungen.
|
||||
:not(.tabpille) ist Pflicht: die Icon-Huelle ist ebenfalls ein <span> (span
|
||||
class="tabpille"), ein blosses ".tab span" traf also nicht nur das Label
|
||||
sondern auch die Pille selbst - das Icon verschwand mit, nicht nur der
|
||||
Text. Genau das war der gemeldete "ganze Menueleiste verschwindet"-Bug. */
|
||||
.tabbar.ohne .tab span:not(.tabpille){display:none}
|
||||
.tab svg{width:26px;height:26px;stroke-width:1.5}
|
||||
/* myAudi-Menueleiste: das aktive Symbol sitzt hell in einer gefuellten Pille,
|
||||
die uebrigen bleiben grau - kein farbiger Tint, kein Oberkantenstrich. */
|
||||
.tabpille{width:63px;height:39px;border-radius:20px}
|
||||
.tab.on{color:var(--fg);border-top-color:transparent}
|
||||
.tab.on .tabpille{background:rgba(255,255,255,.14)}
|
||||
:host([data-theme="tag"]) .tab.on .tabpille{background:rgba(0,0,0,.07)}
|
||||
/* Ist die Beschriftung sichtbar (nicht .tabbar.ohne), soll die graue Flaeche
|
||||
Icon UND Text umschliessen statt nur die enge Pille ums Icon - die Flaeche
|
||||
wandert dafuer vom Icon-Symbol auf den ganzen Tab-Knopf. Im reinen
|
||||
Icon-Modus bleibt die enge Pille wie zuvor. */
|
||||
.tabbar:not(.ohne) .tab.on{background:rgba(255,255,255,.14);border-radius:14px}
|
||||
:host([data-theme="tag"]) .tabbar:not(.ohne) .tab.on{background:rgba(0,0,0,.07)}
|
||||
.tabbar:not(.ohne) .tab.on .tabpille{background:none}
|
||||
.tabbar.ohne .tab{padding:11px 2px 10px}
|
||||
|
||||
:focus-visible{outline:2px solid var(--ios-tint);outline-offset:2px}
|
||||
|
||||
/* Die Wischzeile muss das darunterliegende Loeschen-Feld verdecken - das
|
||||
halbtransparente --tile der Auflage liess es durchscheinen. */
|
||||
.swipe-wrap .swipe-content{background:var(--tile-deckend)}
|
||||
.mapbox,.mapbox .leaflet-container{background:var(--tile-deckend)}
|
||||
|
||||
/* ==================================================================
|
||||
Große Bildschirme (Tablet, Desktop): dieselbe App, aber die Tab-Leiste
|
||||
wird zur seitlichen Navigation und der Inhalt bekommt eine ruhige,
|
||||
begrenzte Spalte. Container-Abfrage statt @media, weil die App in einem
|
||||
eigenen Rahmen sitzt und nicht die Fensterbreite messen darf. Schwelle 860 px.
|
||||
================================================================== */
|
||||
/* Klon der Ringe in der Menueleiste - nur im Spaltenlayout sichtbar */
|
||||
.navmarke{display:none}
|
||||
|
||||
@container (min-width:860px){
|
||||
/* Ab hier keine schwebende Telefonkarte mehr - die 880px-Deckelung faellt,
|
||||
die App fuellt die tatsaechliche (weiterhin viewport-verankerte) Hoehe. */
|
||||
:host > div{height:calc(100dvh - 24px)}
|
||||
.phone{display:grid;grid-template-columns:264px minmax(0,1fr);
|
||||
grid-template-rows:auto auto minmax(0,1fr)}
|
||||
/* max-width:860px muss zu main#view passen, sonst treibt der Flex-Inhalt
|
||||
(.head{flex:1} schiebt .profilbtn nach rechts) das Zahnrad bis an den
|
||||
Rand der vollen Grid-Spalte statt an den Rand der Inhaltsspalte darunter -
|
||||
auf breiten Bildschirmen klaffte da eine sichtbare Luecke. */
|
||||
.topbar{grid-column:2;grid-row:1;padding:38px 44px 8px;max-width:860px}
|
||||
.phone:has(.back.on) .topbar{grid-column:2;grid-row:1;padding:34px 44px 8px;justify-content:flex-start;max-width:860px}
|
||||
.phone:has(.back.on) .head{align-items:flex-start}
|
||||
.phone:has(.back.on) .title{font-size:26px;text-align:left}
|
||||
.title{font-size:34px}
|
||||
.ptr{grid-column:2;grid-row:2;max-width:860px}
|
||||
main#view{--seitenrand:44px;grid-column:2;grid-row:3;padding:8px var(--seitenrand) 48px;max-width:860px}
|
||||
|
||||
.tabbar{grid-column:1;grid-row:1 / 4;display:flex;flex-direction:column;gap:4px;
|
||||
align-content:start;width:264px;padding:38px 14px 20px;
|
||||
border-top:0;border-right:1px solid var(--ios-sep);background:none;
|
||||
-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||
.tab{flex-direction:row;justify-content:flex-start;align-items:center;gap:14px;
|
||||
padding:12px 14px;border-radius:12px;font-size:15px;letter-spacing:0}
|
||||
.tab span{display:block}
|
||||
.tab svg{width:22px;height:22px}
|
||||
.tabpille{width:auto;height:auto;border-radius:0;background:none}
|
||||
.tab.on .tabpille{background:none}
|
||||
.tab:hover{background:var(--tile)}
|
||||
/* Aktives Symbol: dieselbe Farbe wie die Kopfzeile (--fg), nicht mehr Rot -
|
||||
Fläche bleibt neutral hinterlegt statt rosa (Design-Review §B). */
|
||||
.tab.on{background:var(--ios-fill);color:var(--fg)}
|
||||
.tabbar.ohne .tab span{display:block}
|
||||
.tabbar.ohne .tab{padding:12px 14px;gap:14px}
|
||||
.tabbar.ohne .tab svg{width:22px;height:22px}
|
||||
/* Marke am Kopf der Seitenspalte, dafuer keine Ringe in der Kopfzeile
|
||||
(Design-Audit: die Marke soll nur einmal im Bild stehen). */
|
||||
.navmarke{display:block;margin:2px 0 26px 14px;color:var(--fg)}
|
||||
.navmarke svg{width:54px;height:auto;display:block}
|
||||
/* Grosser Bildschirm: Marke am Kopf der Spalte, Zahnrad wieder oben rechts */
|
||||
.profilbtn{display:inline-flex}
|
||||
/* Die Kopf-Ringe setzt die App per Inline-Stil (display:inline-flex),
|
||||
deshalb hier mit !important. */
|
||||
.rings{display:none !important}
|
||||
|
||||
/* Nur die Box selbst hoeher setzen, nicht jedes Element mit .carfix: die
|
||||
Klasse haengt auch am <img> und am Platzhalter-<div> (siehe
|
||||
bildMitPlatzhalter()). Der Platzhalter ist position:absolute;inset:0 -
|
||||
eine feste Hoehe schlaegt dort das bottom:0 und machte ihn 260px hoch in
|
||||
einer 132px hohen, overflow:hidden-Box (leerer Zustand), also sichtbar
|
||||
abgeschnitten. Bild und Platzhalter fuellen die Box ohnehin per 100%. */
|
||||
.bildbox.carfix{height:260px}
|
||||
.bildbox.carfix.klein{height:220px}
|
||||
.bildgrid{grid-template-columns:repeat(4,1fr)}
|
||||
.quad{gap:14px}
|
||||
|
||||
/* Design-Audit 2026-08-13: .setup-popup/.sdpopup/.sheet/.standortmenu sind
|
||||
position:absolute relativ zu .phone (der vollen Rasterbreite, nicht nur
|
||||
der Inhaltsspalte) mit left/right auf einen festen Abstand - auf breiten
|
||||
Bildschirmen ergab das einen fast bildschirmbreiten Dialog statt einer
|
||||
ruhigen Karte. max-width + margin:auto zentriert sie innerhalb des
|
||||
bestehenden left/right-Abstands, ohne die transform-basierte
|
||||
Einblend-Animation dieser Elemente zu beruehren. */
|
||||
.setup-popup,.sdpopup,.sheet,.beleg-popup{max-width:560px;margin-left:auto;margin-right:auto}
|
||||
.standortmenu{max-width:640px;margin-left:auto;margin-right:auto}
|
||||
}
|
||||
|
||||
/* Fahrzeugbild randlos wie in einer Fahrzeug-App: der Platzhalter bleibt
|
||||
sichtbar, aber ohne technischen Strichrahmen. */
|
||||
.szene{margin-bottom:20px}
|
||||
/* --tile statt --ios-fill waere hier naheliegend, faellt im Tag-Theme aber
|
||||
auf reines Weiss (#FFFFFF) - ununterscheidbar von Canvas und den meisten
|
||||
Kacheln drumherum, die Box verschwand. --ios-fill ist in beiden Themes ein
|
||||
deutlich sichtbares transluzentes Grau, dieselbe Konvention wie
|
||||
.feld select/.aktion ("das hier ist eine Flaeche"). */
|
||||
.bildbox.bild-fehlt .platzhalter{border:0;background:var(--ios-fill);border-radius:var(--r-tile)}
|
||||
.platzhalter-datei{display:none}
|
||||
|
||||
/* Zeilen mit Wert plus Statuspunkt rechts (Sicherheitscheck) ruhiger setzen */
|
||||
.row dd .dot{width:8px;height:8px}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* ================================================================
|
||||
Audi-Dashboard — Lade-Stub für das Panel
|
||||
================================================================
|
||||
Home Assistant lädt diese Datei als Modul des Sidebar-Eintrags. Ihre
|
||||
einzige Aufgabe ist, den eigentlichen Code nachzuladen.
|
||||
|
||||
Der Cache-Brecher steht in der eigenen URL: die Integration hängt beim
|
||||
Anmelden des Panels die installierte Version als ?v=… an (siehe
|
||||
_panel_anmelden in __init__.py). Diese Datei reicht sie unverändert an
|
||||
audi-dashboard-app.js weiter. Jede neue Version ist damit aus Sicht des
|
||||
Browsers eine neue URL und wird nie aus einem alten Cache bedient - und
|
||||
umgekehrt lädt eine unveränderte Installation nichts unnötig neu.
|
||||
|
||||
Das ersetzt die frühere Lösung, bei der diese Datei erst
|
||||
audi-dashboard-version.json mit cache:"no-store" holen musste, um an eine
|
||||
Zahl zu kommen. Die brauchte es, weil /local/ von Home Assistant 31 Tage
|
||||
lang gecacht wird und die Datei sonst nie wieder aktualisiert worden wäre.
|
||||
Als Integration liefert die App ihre Dateien selbst aus - mit
|
||||
cache_headers=False und mit der Version schon in der URL. Eine zusätzliche
|
||||
Netzwerkanfrage bei jedem Seitenaufruf ist dafür nicht mehr nötig.
|
||||
|
||||
Bekannte HA-Eigenheit (an der Testinstanz reproduziert, nicht vermutet):
|
||||
ha-panel-custom cached geladene Skripte nur für den alten js_url-Typ,
|
||||
nicht für module_url. Bei einem direkten/harten Seitenaufruf kann das
|
||||
dazu führen, dass dieses Skript zweimal als <script type=module> eingefügt
|
||||
wird, bevor das erste fertig geladen hat - beobachtet als zwei
|
||||
Netzwerk-Requests und ein hängenbleibendes "Lädt …" ohne Fehler. Der
|
||||
Cache unten macht das Starten robust dagegen: der zweite Aufruf wartet auf
|
||||
das Ergebnis des ersten, statt selbst nochmal zu importieren. */
|
||||
window.__audiDashboardStarten =
|
||||
window.__audiDashboardStarten ||
|
||||
(async () => {
|
||||
const eigene = new URL(import.meta.url);
|
||||
const version = eigene.searchParams.get("v") || Date.now();
|
||||
await import(new URL(`audi-dashboard-app.js?v=${version}`, eigene).href);
|
||||
})();
|
||||
await window.__audiDashboardStarten;
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-02ab3f2e-9065-406b-b3a2-96f948fc1e4e" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3d56eca2-3609-4ab9-a4bc-ffc6d9c94e2a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M426.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-3cc43eeb-ac3a-4285-9c6d-681d610de30a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path id="uuid-b98eb56c-eb39-485f-8e3f-36f1bc8fa622" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-887f53b2-a25f-4c1a-b207-be4bc0e6cad6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path id="uuid-2c6229e3-766a-4640-9e66-076dd5bbca77" d="M408.4969,59l16.0201-44h-24.75l-80.2974,39.2816-6.4489,17.7184h74.25l-4.7316,13h16.5s4.7316-13,4.7316-13h16.4977l4.7316-13h-16.5031ZM392.0022,59h-53.6829l64.966-31h0l-11.2831,31Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bb9d6aae-5c61-449e-a3f7-9dc3eca29a10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-78d414ea-ca1f-4f07-a6dd-815839547cb5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 427 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M324.3052,54l14.1948-39h88.5l-4.7316,13h-72l-4.7316,13h55.5c16.5,0,23.2243,9,18.4927,22-5.8003,15.9362-9.6585,22-34.4519,22h-47.5555c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h47.5555c9.4358,0,11.1533-3.3211,13.2203-9,2.3539-6.4674-.3962-9-6.7243-9h-72Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-da9a622c-841b-4217-8357-91e7b2ea0fa7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-eab43470-9ed9-4ef3-88a5-73d45fc6a05a" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 436 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M434.7542,37.5c4.6956-12.9009-4.4207-22.5-18.3107-22.5h-51.5045c-26.0165,0-28.7311,6.1298-33.8705,20.25l-9.5543,26.25c-3.3206,9.1232-.1075,23.5,17.9468,23.5h48.5c25.7614,0,32.1276-7.2191,37.2344-21.25,5.6993-15.6587-5.968-21.25-18.7656-21.25h-51.5c-3.8594,0-7.5554.4423-10.3879,1.0658l3.0266-8.3158c1.6863-4.6331,2.6389-7.25,12.6389-7.25h51.5046c4.0569,0,8.7417,3.4572,6.5422,9.5h16.5ZM350.198,55.5h51.5c6.2949,0,9.3513,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-51.5001c-8.4935,0-7.6956-6.3314-6.1783-10.5,2.1838-6,5.1783-6,12.1838-6Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-441d88e6-447f-439d-bb01-ecd138048170" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8742-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z" style="fill:#fff;"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon id="uuid-04405ac0-17bc-4ab9-8f1b-d9c06ed1387e" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-e2ec85d2-1c31-42a4-badb-6dc585c3e159" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M165.478,15h-70l-25.478,70h16.5l9.4633-26h59.5518c4.7471,0,6.8294,4.4041,4.2185,25.9165h16.9999v-8.9007c0-5.2722,0-17.0158-5.796-23.4287,6.8741-3.3009,10.0555-7.4061,12.8643-15.1234,4.6956-12.9009-4.4339-22.4637-18.3239-22.4637ZM100.6949,46l6.5515-18h53.5c4.0569,0,8.7549,3.4209,6.5555,9.4637-2.1993,6.0427-5.3154,8.5363-13.1069,8.5363h-53.5Z"/><path d="M279.9435,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon id="uuid-140489d1-1cc4-4640-9981-d5f2efde7a30" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-6be63f45-3fa3-45ba-97e7-17573e0bd3f7" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-bcf865bd-1403-4135-bc2a-39f0ea8693ef" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 312 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><path d="M310.9422,35.25c4.1375-11.3676-4.1296-20.25-20.1296-20.25h-48.5c-26.0164,0-28.4479,5.3519-34.6605,22.5h16.4711c2.8264-7.7654,3.4577-9.5,13.4577-9.5h48.5c4.0569,0,10.4989,1.3767,8.3612,7.25-1.9243,5.287-4.4488,7.25-12.6388,7.25h-43.5l-4.7316,13h48.5c6.2949,0,9.3512,1.7826,6.9973,8.25-1.7214,4.7295-3.9965,8.25-13.0027,8.25h-54.5c-8.4936,0-7.6956-6.3314-6.1783-10.5h-16.5c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c23.4238,0,27.7979-3.537,34.239-21.25,2.3006-6.3267.3341-11.4665-4.0894-14.8846,5.5286-2.8196,7.1832-7.3654,9.458-13.6154Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcc2e61f-be44-4609-aa28-1b6186c8cb58" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z" style="fill:#fff;"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815" style="fill:#fff;"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z" style="fill:#fff;"/><polygon id="uuid-4eb1f8b9-3e2f-414b-927c-5027877a6367" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15" style="fill:#fff;"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-dcb70d1d-da3e-4814-a4c9-ff91d6217f07" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 430 100"><polygon points="140 0 103.603 100 0 100 36.397 0 140 0" style="fill:#f50537;"/><path d="M163.5209,15c13.89,0,23.0062,9.5991,18.3107,22.5h-16.5c2.1994-6.0428-2.4854-9.5-6.5423-9.5h-48.5c-10,0-10.9525,2.617-12.6388,7.25-1.327,3.646-1.046,7.25,7.3612,7.25h48.5c12.7976,0,24.4649,5.5913,18.7656,21.25-5.1069,14.031-8.473,21.25-34.2344,21.25h-48.5c-22.238,0-20.8261-15.589-17.9467-23.5h16.5c-1.5172,4.1686-2.3153,10.5,6.1783,10.5h48.5c9.0063,0,11.2814-3.5205,13.0028-8.25,2.3539-6.4674-.7024-8.25-6.9972-8.25h-48.5c-16.0991,0-23.0423-9.5-19.1296-20.25,5.1394-14.1202,7.854-20.25,33.8704-20.25h48.5Z"/><polygon points="276.2715 46.0815 254.8152 46.0815 296.6135 85 318.0698 85 276.2715 46.0815"/><path d="M312.8315,37.5c4.6956-12.9009-4.4207-22.5-18.3105-22.5h-54.5c-26.0165,0-28.731,6.1298-33.8704,20.25l-9.5543,26.25c-2.8794,7.911-4.2913,23.5,17.9467,23.5h54.5c8.5029,0,14.5557-.7916,19.0469-2.3499l-11.4382-10.6501h-57.377c-8.4937,0-7.6956-6.3314-6.1783-10.5l9.5542-26.25c1.6863-4.633,2.6388-7.25,12.6388-7.25h54.5c4.0569,0,8.7417,3.4573,6.5422,9.5l-5.8916,16.1871,12.3236,11.4745,10.068-27.6617Z"/><polygon id="uuid-76d49135-5520-455b-8e33-d0d14e5c227c" points="430 15 331 15 326.2684 28 408.7684 28 330.2721 85 355.0221 85 421.8096 37.5 430 15"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg id="Shell_x5F_2012_x5F_PECTEN_x5F_RGB" version="1.1" viewBox="0 0 24 24" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="-12.1" y="-103.7" width="141.7" height="141.7" fill="none"/>
|
||||
<g transform="matrix(.22054 0 0 .22054 -.94555 -.53756)">
|
||||
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4 19.6 0 35.4 15.9 35.4 35.4 0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1z" fill="none" stroke="#fff" stroke-miterlimit="40" stroke-width="7"/>
|
||||
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4 19.6 0 35.4 15.9 35.4 35.4 0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1z" fill="#fbce07"/>
|
||||
<path d="m54.6 87.8h-18l-1.6-13.3-11-7.9c-0.5-2.3-0.7-4.7-0.7-7.1 0-19.6 15.9-35.4 35.4-35.4s35.4 15.9 35.4 35.4c0 2.4-0.2 4.8-0.7 7.1l-10.9 7.9-1.7 13.3h-18l-1 0.8c-0.9 0.7-2 1-3.1 1s-2.2-0.4-3.1-1zm0.6-6.2h-13.1l-1.3-10.6-10.8-7.8c-0.3-1.2-0.5-2.5-0.5-3.8 0-2.1 0.4-4.2 1.3-6.1l19.1 19.3-18.5-22.9c0.7-3 2.3-5.6 4.6-7.7l16.6 28.7-14.7-31.3c1.9-2.5 4.6-4.4 7.6-5.3l10.2 35.6-7.4-37.1c2.3-1.3 4.8-1.9 7.4-1.9 0.5 0 1.1 0 1.6 0.1l1.5 38.5 1.5-38.5c0.5-0.1 1.1-0.1 1.6-0.1 2.6 0 5.1 0.7 7.4 1.9l-7.4 37.1 10.2-35.7c3 0.9 5.7 2.8 7.6 5.3l-14.8 31.4 16.6-28.7c2.3 2 3.9 4.7 4.6 7.7l-18.5 22.9 19.1-19.3c0.9 1.9 1.3 4 1.3 6.1 0 1.3-0.2 2.5-0.5 3.8l-10.8 7.8-1.3 10.6h-13l-2 1.4c-0.5 0.4-1.1 0.5-1.7 0.5s-1.2-0.2-1.7-0.5z" fill="#dd1d21" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,375 @@
|
||||
"""Nachträglicher Import vergangener Zeiträume aus dem Home-Assistant-Verlauf
|
||||
(Einstellungen -> "Daten importieren aus Home Assistant").
|
||||
|
||||
WOZU
|
||||
----
|
||||
Fahrterkennung, Tankerkennung und Batterieverlauf arbeiten alle nur ab dem
|
||||
Moment, in dem sie laufen. Alles, was das Fahrzeug gemeldet hat, BEVOR die App
|
||||
lief (oder während Home Assistant aus war, oder bevor ein Sensor überhaupt
|
||||
zugeordnet war), taucht in den Beständen der App deshalb nie auf - obwohl der
|
||||
recorder es längst aufgezeichnet hat. Dieser Import schließt genau diese
|
||||
Lücke: er liest denselben Verlauf, den die Live-Erkennung sonst in Echtzeit
|
||||
sieht, und leitet daraus rückwirkend dieselben Datensätze ab.
|
||||
|
||||
GRENZE, DIE MAN KENNEN MUSS
|
||||
---------------------------
|
||||
Weiter zurück als der recorder aufbewahrt, geht es nicht - was dort gelöscht
|
||||
ist, ist endgültig weg. Home Assistant löscht standardmäßig nach 10 Tagen;
|
||||
recorder_snippet.yaml hebt das auf ein Jahr an. Der Import meldet deshalb im
|
||||
Ergebnis mit, ab wann im gewählten Zeitraum überhaupt Daten vorlagen
|
||||
(`ab_wann_daten`), damit ein leeres Ergebnis nicht wie ein Fehler aussieht.
|
||||
|
||||
DOPPELTE DATENSÄTZE
|
||||
-------------------
|
||||
Der Import ist absichtlich mehrfach ausführbar (überlappende Zeiträume,
|
||||
zweiter Versuch nach einem Abbruch): jede erzeugte Fahrt wird gegen die
|
||||
bereits vorhandenen geprüft und übersprungen, wenn sich ihr Zeitraum mit einer
|
||||
bestehenden Fahrt überschneidet - egal ob die live erkannt, von Hand angelegt
|
||||
oder aus einem früheren Import stammt. Tankvorgänge werden über ein
|
||||
Zeitfenster (TANK_DUBLETTE_MIN) entdoppelt, Batteriewerte über den Tag als
|
||||
Schlüssel (dort führt die Ablage min/max ohnehin zusammen, statt Zeilen zu
|
||||
vervielfachen).
|
||||
|
||||
Erzeugte Datensätze tragen `source: "import"` - dieselbe Rolle wie "ha" (live
|
||||
erkannt), "manual" (von Hand) und "auto" (Tankerkennung), damit später
|
||||
nachvollziehbar bleibt, woher ein Eintrag stammt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
|
||||
from .tankerkennung import leerer_tankvorgang, schwelle_prozent
|
||||
from .verlauf import Verlaufspunkt, verlauf_lesen, wert_bei, zahl
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Zwei Tankvorgänge innerhalb dieser Spanne gelten als derselbe - schützt
|
||||
# gegen Dubletten, wenn derselbe Zeitraum zweimal importiert wird oder sich
|
||||
# Import und Live-Erkennung am Rand überschneiden.
|
||||
TANK_DUBLETTE_MIN = 90
|
||||
|
||||
# Fahrten, die kürzer sind, sind Zündung-an-ohne-Fahrt (Radio, Tür öffnen mit
|
||||
# Zündung, Diagnose) - die Live-Erkennung legt sie zwar an, im Rückblick
|
||||
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
|
||||
MINDESTDAUER_S = 60
|
||||
|
||||
|
||||
def als_zeit(wert: object) -> datetime.datetime | None:
|
||||
"""Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
|
||||
|
||||
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt
|
||||
im Formular schließlich Ortszeit, keine UTC."""
|
||||
if not wert:
|
||||
return None
|
||||
ts = datetime.datetime.fromisoformat(str(wert))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.astimezone()
|
||||
return ts.astimezone(datetime.UTC)
|
||||
|
||||
|
||||
def _fahrtfenster(
|
||||
zuendung_verlauf: list[Verlaufspunkt], pausenzeit_s: int
|
||||
) -> list[tuple[datetime.datetime, datetime.datetime]]:
|
||||
"""Aus dem Zündungsverlauf die Zeiträume, in denen gefahren wurde.
|
||||
|
||||
Zwei Schritte, die zusammen die Pausenregel aus §7.1 nachbilden: erst
|
||||
jeden zusammenhängenden "on"-Abschnitt sammeln, dann benachbarte
|
||||
Abschnitte verschmelzen, deren Lücke kürzer als die Pausenzeit ist. Genau
|
||||
das tut die Live-Erkennung über die abbrechbare Wartezeit, nur eben im
|
||||
Nachhinein und ohne Warten."""
|
||||
roh: list[tuple[datetime.datetime, datetime.datetime]] = []
|
||||
offen: datetime.datetime | None = None
|
||||
for ts, wert in zuendung_verlauf:
|
||||
an = str(wert).lower() in ("on", "true", "1")
|
||||
if an and offen is None:
|
||||
offen = ts
|
||||
elif not an and offen is not None:
|
||||
roh.append((offen, ts))
|
||||
offen = None
|
||||
# Ein am Ende des Zeitraums noch offener Abschnitt wird verworfen: die
|
||||
# Fahrt ist zu diesem Zeitpunkt noch nicht beendet, ihr Ende läge hinter
|
||||
# dem gewählten Fenster. Sie beim Fensterende abzuschneiden würde eine
|
||||
# Fahrt mit erfundener Endzeit erzeugen.
|
||||
|
||||
if not roh:
|
||||
return []
|
||||
|
||||
verschmolzen = [roh[0]]
|
||||
for start, ende in roh[1:]:
|
||||
vorheriger_start, vorheriges_ende = verschmolzen[-1]
|
||||
if (start - vorheriges_ende).total_seconds() < pausenzeit_s:
|
||||
verschmolzen[-1] = (vorheriger_start, ende)
|
||||
else:
|
||||
verschmolzen.append((start, ende))
|
||||
return verschmolzen
|
||||
|
||||
|
||||
def _ueberschneidet(
|
||||
start: datetime.datetime, ende: datetime.datetime, bestehende: list[dict]
|
||||
) -> bool:
|
||||
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
|
||||
überschneidet. Verhindert Dubletten beim wiederholten Import."""
|
||||
for f in bestehende:
|
||||
try:
|
||||
f_start = datetime.datetime.fromisoformat(f.get("ts_start")) # type: ignore[arg-type]
|
||||
f_ende = datetime.datetime.fromisoformat(f.get("ts_end")) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if f_start.tzinfo is None or f_ende.tzinfo is None:
|
||||
continue
|
||||
if start < f_ende and f_start < ende:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||
"""Fahrten aus dem Zündungsverlauf, mit Kilometerstand und Start-/
|
||||
Zielkoordinaten aus den übrigen Verläufen ergänzt."""
|
||||
fenster = _fahrtfenster(verlaeufe["zuendung"], await pausenzeit_sekunden(k))
|
||||
if not fenster:
|
||||
return {"angelegt": 0, "uebersprungen": 0, "zu_kurz": 0}
|
||||
|
||||
bestehende = await k.ablage.fahrten_lesen()
|
||||
km_verlauf = verlaeufe["km"]
|
||||
lat_verlauf = verlaeufe["lat"]
|
||||
lon_verlauf = verlaeufe["lon"]
|
||||
|
||||
angelegt = uebersprungen = zu_kurz = 0
|
||||
neue: list[dict] = []
|
||||
|
||||
for f_start, f_ende in fenster:
|
||||
dauer_s = int((f_ende - f_start).total_seconds())
|
||||
if dauer_s < MINDESTDAUER_S:
|
||||
zu_kurz += 1
|
||||
continue
|
||||
if _ueberschneidet(f_start, f_ende, bestehende):
|
||||
uebersprungen += 1
|
||||
continue
|
||||
|
||||
odo_start = wert_bei(km_verlauf, f_start)
|
||||
odo_end = wert_bei(km_verlauf, f_ende)
|
||||
distanz = None
|
||||
if odo_start is not None and odo_end is not None and odo_end >= odo_start:
|
||||
distanz = round(odo_end - odo_start, 1)
|
||||
|
||||
durchschnitt = None
|
||||
if distanz is not None and dauer_s > 0:
|
||||
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
|
||||
|
||||
fahrt = leere_fahrt(f_start, f_ende, "import")
|
||||
fahrt.update({
|
||||
"distance_km": distanz,
|
||||
"km_quelle": "sensor" if distanz is not None else None,
|
||||
"odo_start": odo_start,
|
||||
"odo_end": odo_end,
|
||||
"avg_speed_kmh": durchschnitt,
|
||||
"start_lat": wert_bei(lat_verlauf, f_start),
|
||||
"start_lon": wert_bei(lon_verlauf, f_start),
|
||||
"end_lat": wert_bei(lat_verlauf, f_ende),
|
||||
"end_lon": wert_bei(lon_verlauf, f_ende),
|
||||
"status": "vollständig" if distanz is not None else "offen",
|
||||
})
|
||||
neue.append(fahrt)
|
||||
bestehende.append(fahrt)
|
||||
angelegt += 1
|
||||
|
||||
await k.ablage.fahrten_ergaenzen(neue)
|
||||
return {"angelegt": angelegt, "uebersprungen": uebersprungen, "zu_kurz": zu_kurz}
|
||||
|
||||
|
||||
async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||
"""Tankvorgänge aus dem Füllstandsverlauf - dieselbe Tiefststand-Logik wie
|
||||
in der Live-Erkennung: jeder Anstieg über die Schwelle gegen den zuletzt
|
||||
gesehenen Tiefststand ist ein Tankvorgang, nicht jeder Anstieg gegen den
|
||||
unmittelbar vorherigen Wert."""
|
||||
verlauf = verlaeufe["tank"]
|
||||
if not verlauf:
|
||||
return {"angelegt": 0, "uebersprungen": 0}
|
||||
|
||||
schwelle = schwelle_prozent(await k.ablage.profil_lesen())
|
||||
km_verlauf = verlaeufe["km"]
|
||||
bestehende = await k.ablage.tankvorgaenge_lesen()
|
||||
fenster_s = TANK_DUBLETTE_MIN * 60
|
||||
|
||||
bekannte_zeiten: list[datetime.datetime] = []
|
||||
for t in bestehende:
|
||||
try:
|
||||
ts = datetime.datetime.fromisoformat(t.get("ts")) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if ts.tzinfo is not None:
|
||||
bekannte_zeiten.append(ts)
|
||||
|
||||
angelegt = uebersprungen = 0
|
||||
neue: list[dict] = []
|
||||
tiefststand: float | None = None
|
||||
|
||||
for ts, wert in verlauf:
|
||||
aktuell = zahl(wert)
|
||||
if aktuell is None:
|
||||
continue
|
||||
if tiefststand is None or aktuell <= tiefststand:
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
if aktuell - tiefststand < schwelle:
|
||||
continue
|
||||
|
||||
if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
|
||||
uebersprungen += 1
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
|
||||
odometer_km = wert_bei(km_verlauf, ts)
|
||||
tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
|
||||
tankvorgang["odometer_km"] = odometer_km
|
||||
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
|
||||
ts.isoformat(), odometer_km, neue + bestehende
|
||||
)
|
||||
neue.append(tankvorgang)
|
||||
bekannte_zeiten.append(ts)
|
||||
angelegt += 1
|
||||
tiefststand = aktuell
|
||||
|
||||
await k.ablage.tankvorgaenge_ergaenzen(neue)
|
||||
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
|
||||
|
||||
|
||||
def _distanz_zum_vorherigen(
|
||||
eigene_ts: str, odometer_km: float | None, kandidaten: list[dict]
|
||||
) -> float | None:
|
||||
""""Gefahren seit der letzten Tankung" heißt: seit der letzten Tankung VOR
|
||||
dieser hier - nicht seit der zeitlich jüngsten überhaupt.
|
||||
|
||||
Beim Import eines vergangenen Zeitraums liegen im Bestand regelmäßig
|
||||
bereits neuere Tankvorgänge; die als Bezug zu nehmen ergäbe eine negative
|
||||
Strecke (und damit, nach der Prüfung unten, gar keine)."""
|
||||
if odometer_km is None:
|
||||
return None
|
||||
vorheriger: tuple[str, float] | None = None
|
||||
for t in kandidaten:
|
||||
t_ts = t.get("ts") or ""
|
||||
if t.get("odometer_km") is None or not t_ts or t_ts >= eigene_ts:
|
||||
continue
|
||||
if vorheriger is None or t_ts > vorheriger[0]:
|
||||
vorheriger = (t_ts, t["odometer_km"])
|
||||
if vorheriger is None:
|
||||
return None
|
||||
distanz = round(odometer_km - vorheriger[1], 1)
|
||||
return None if distanz < 0 else distanz
|
||||
|
||||
|
||||
async def _batterie_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||
"""Tagesminimum/-maximum der 12V-Spannung je Tag des Zeitraums.
|
||||
|
||||
Die Ablage führt bestehende und neue Werte pro Tag zusammen (min bleibt
|
||||
min, max bleibt max), deshalb braucht es hier keine eigene
|
||||
Dubletten-Prüfung: ein zweiter Import desselben Zeitraums verändert die
|
||||
Einträge nicht mehr."""
|
||||
verlauf = verlaeufe["batterie"]
|
||||
if not verlauf:
|
||||
return {"tage": 0}
|
||||
|
||||
tage: dict[str, dict] = {}
|
||||
for ts, wert in verlauf:
|
||||
spannung = zahl(wert)
|
||||
if spannung is None:
|
||||
continue
|
||||
tag = ts.date().isoformat()
|
||||
eintrag = tage.get(tag)
|
||||
if eintrag is None:
|
||||
tage[tag] = {"min": spannung, "min_ts": ts, "max": spannung, "max_ts": ts}
|
||||
continue
|
||||
if spannung < eintrag["min"]:
|
||||
eintrag["min"] = spannung
|
||||
eintrag["min_ts"] = ts
|
||||
if spannung > eintrag["max"]:
|
||||
eintrag["max"] = spannung
|
||||
eintrag["max_ts"] = ts
|
||||
|
||||
for tag in sorted(tage):
|
||||
werte = tage[tag]
|
||||
await k.ablage.batterieverlauf_tageswert_aktualisieren(
|
||||
tag, werte["min_ts"].isoformat(), werte["min"]
|
||||
)
|
||||
await k.ablage.batterieverlauf_tageswert_aktualisieren(
|
||||
tag, werte["max_ts"].isoformat(), werte["max"]
|
||||
)
|
||||
|
||||
return {"tage": len(tage)}
|
||||
|
||||
|
||||
async def importieren(k: Koordinator, start: object, ende: object) -> None:
|
||||
"""Liest den Verlauf im gewählten Zeitraum und leitet daraus Fahrten,
|
||||
Tankvorgänge und Batteriewerte ab.
|
||||
|
||||
Der Fortschritt geht über die Entität sensor.audi_dashboard_import_status
|
||||
an die Oberfläche - ein Dienstaufruf kehrt sofort zurück und kann kein
|
||||
Ergebnis mitbringen."""
|
||||
await k.ablage.ordner_sicherstellen()
|
||||
|
||||
try:
|
||||
von = als_zeit(start)
|
||||
bis = als_zeit(ende)
|
||||
except ValueError as fehler:
|
||||
_LOGGER.error("Import mit unlesbarem Zeitraum aufgerufen (%s)", fehler)
|
||||
await k.import_status_veroeffentlichen("fehler", {"meldung": "Zeitraum nicht lesbar"})
|
||||
return
|
||||
|
||||
if von is None or bis is None or von >= bis:
|
||||
_LOGGER.warning("Import ohne gültigen Zeitraum aufgerufen")
|
||||
await k.import_status_veroeffentlichen("fehler", {
|
||||
"meldung": "Bitte einen Zeitraum wählen, dessen Ende nach dem Start liegt."
|
||||
})
|
||||
return
|
||||
|
||||
await k.import_status_veroeffentlichen(
|
||||
"laeuft", {"von": von.isoformat(), "bis": bis.isoformat()}
|
||||
)
|
||||
_LOGGER.info("Import gestartet für %s bis %s", von.isoformat(), bis.isoformat())
|
||||
|
||||
werte = k.zuordnung.werte
|
||||
verlaeufe = {
|
||||
"zuendung": await verlauf_lesen(k.hass, werte.ZUENDUNG_SENSOR, von, bis),
|
||||
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis),
|
||||
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis),
|
||||
"batterie": await verlauf_lesen(k.hass, werte.BATTERIE_SENSOR, von, bis),
|
||||
"lat": await verlauf_lesen(k.hass, werte.STANDORT_LAT_SENSOR, von, bis),
|
||||
"lon": await verlauf_lesen(k.hass, werte.STANDORT_LON_SENSOR, von, bis),
|
||||
}
|
||||
|
||||
# Frühester Zeitpunkt, zu dem im gewählten Fenster überhaupt etwas
|
||||
# aufgezeichnet war - damit ein leeres Ergebnis erklärbar wird
|
||||
# ("recorder reicht nur bis ...") statt wie ein Fehler auszusehen.
|
||||
frueheste = None
|
||||
for punkte in verlaeufe.values():
|
||||
if punkte and (frueheste is None or punkte[0][0] < frueheste):
|
||||
frueheste = punkte[0][0]
|
||||
|
||||
fahrten = await _fahrten_importieren(k, verlaeufe)
|
||||
tank = await _tankvorgaenge_importieren(k, verlaeufe)
|
||||
batterie = await _batterie_importieren(k, verlaeufe)
|
||||
|
||||
await k.fahrten_veroeffentlichen()
|
||||
await k.tankvorgaenge_veroeffentlichen()
|
||||
await k.batterieverlauf_veroeffentlichen()
|
||||
|
||||
ergebnis = {
|
||||
"von": von.isoformat(),
|
||||
"bis": bis.isoformat(),
|
||||
"ab_wann_daten": frueheste.isoformat() if frueheste else None,
|
||||
"fahrten_angelegt": fahrten["angelegt"],
|
||||
"fahrten_uebersprungen": fahrten["uebersprungen"],
|
||||
"fahrten_zu_kurz": fahrten["zu_kurz"],
|
||||
"tankvorgaenge_angelegt": tank["angelegt"],
|
||||
"tankvorgaenge_uebersprungen": tank["uebersprungen"],
|
||||
"batterie_tage": batterie["tage"],
|
||||
}
|
||||
await k.import_status_veroeffentlichen("fertig", ergebnis)
|
||||
_LOGGER.info("Import abgeschlossen - %s", ergebnis)
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Der Koordinator: hält den Zustand der App und verdrahtet alles miteinander.
|
||||
|
||||
Was hier zusammenkommt, lag in der pyscript-Fassung über die Dateien verteilt,
|
||||
weil es dort keinen Ort dafür gab: die Dekoratoren @service, @state_trigger und
|
||||
@time_trigger banden ihre Registrierung direkt an die Funktion daneben. Eine
|
||||
Integration hat einen Lebenszyklus (Einrichten, Entladen, Neu-Laden), und
|
||||
genau deshalb braucht es eine Stelle, die weiß, was sie angemeldet hat und es
|
||||
wieder abmelden kann.
|
||||
|
||||
DREI DINGE, DIE DIESER AUFBAU MÖGLICH MACHT UND DIE ES VORHER NICHT GAB
|
||||
-----------------------------------------------------------------------
|
||||
1. Zustandsbeobachter lassen sich zur Laufzeit neu binden. Früher backte
|
||||
@state_trigger die Entity-ID einmalig beim Modul-Laden ein - ein im
|
||||
Setup-Menü geänderter Zündungs-, Kilometer- oder Tanksensor wirkte erst
|
||||
nach einem HA-Neustart, und die Oberfläche musste einen eigenen
|
||||
Neustart-Knopf dafür anbieten. trigger_neu_binden() ersetzt beides.
|
||||
2. Der Zwischenstand einer laufenden Fahrt überlebt einen Neustart. Er liegt
|
||||
in einem Store statt nur im Arbeitsspeicher; dasselbe gilt für den
|
||||
Tank-Tiefststand. Beides war als bekannte Schwäche vermerkt.
|
||||
3. Die Nutzlast landet nicht mehr in der Recorder-Datenbank (siehe sensor.py).
|
||||
|
||||
Der Weg von hier zur Oberfläche: `setzen()` legt Zustand und Nutzlast ab und
|
||||
schickt ein Signal; die Entität in sensor.py hört darauf und schreibt sich
|
||||
neu. Die Entitäten holen sich ihren Wert beim Anlegen selbst aus `werte` -
|
||||
deshalb darf schon veröffentlicht werden, bevor die Plattform überhaupt
|
||||
eingerichtet ist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, EventStateChangedData, HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_state_change_event,
|
||||
async_track_time_change,
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from . import batterie, fahrterkennung, reifen, screening, sicherung, tankerkennung
|
||||
from .ablage import Ablage
|
||||
from .const import (
|
||||
E_APP_VERSION,
|
||||
E_BATTERIEVERLAUF,
|
||||
E_BELEG_ERGEBNIS,
|
||||
E_FAHRTEN,
|
||||
E_FAHRZEUGSTATUS,
|
||||
E_IMPORT_STATUS,
|
||||
E_PROFIL,
|
||||
E_REIFEN_AKTIV,
|
||||
E_REIFEN_SOMMER,
|
||||
E_REIFEN_WINTER,
|
||||
E_TANKVORGAENGE,
|
||||
E_ZUORDNUNG,
|
||||
SIGNAL_AKTUALISIERT,
|
||||
)
|
||||
from .veroeffentlichung import fahrzeugstatus
|
||||
from .zuordnung import Zuordnung
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Wie oft der Live-Fahrzeugstatus neu aus den Quellsensoren gelesen wird.
|
||||
TAKT_STATUS = datetime.timedelta(seconds=20)
|
||||
# Profil/Fahrten/Tankvorgänge ändern sich normalerweise nur durch Aktionen,
|
||||
# die ohnehin sofort selbst veröffentlichen. Dieser Takt ist nur das
|
||||
# Sicherheitsnetz für Änderungen direkt im Dateisystem.
|
||||
TAKT_DATEN = datetime.timedelta(seconds=60)
|
||||
TAKT_BATTERIE = datetime.timedelta(minutes=5)
|
||||
|
||||
STORE_VERSION = 1
|
||||
|
||||
|
||||
class Koordinator:
|
||||
"""Zustand, Zeittakte und Zustandsbeobachter der Integration."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, version: str) -> None:
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.version = version
|
||||
self.ablage = Ablage(hass, hass.config.path("audi_dashboard"))
|
||||
self.zuordnung = Zuordnung(self.ablage)
|
||||
|
||||
# Was die Entitäten anzeigen: Schlüssel -> (Zustand, Nutzlast).
|
||||
self.werte: dict[str, tuple[str, dict | list | None]] = {}
|
||||
|
||||
self._store = Store[dict](hass, STORE_VERSION, f"{entry.entry_id}_laufzeit")
|
||||
self.fahrt_start_ts: datetime.datetime | None = None
|
||||
self.tiefststand_pct: float | None = None
|
||||
|
||||
self._takte: list[CALLBACK_TYPE] = []
|
||||
self._beobachter: list[CALLBACK_TYPE] = []
|
||||
self._ende_aufgabe: asyncio.Task | None = None
|
||||
|
||||
# ------------------------------------------------------------ Lebenszyklus
|
||||
|
||||
async def starten(self) -> None:
|
||||
await self.ablage.ordner_sicherstellen()
|
||||
if await self.ablage.vorlage_anlegen(
|
||||
os.path.join(os.path.dirname(__file__), "vorlage", "fahrzeugprofil.json")
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Erste Einrichtung: Fahrzeugprofil aus der Vorlage angelegt. "
|
||||
"Die Fahrzeugdaten werden in der App unter Einstellungen -> "
|
||||
"Fahrzeug einrichten eingetragen."
|
||||
)
|
||||
await self.zuordnung.anwenden()
|
||||
await self._laufzeit_laden()
|
||||
await self.alles_veroeffentlichen()
|
||||
# Der Reifenzähler schreibt beim Start einmal fort: Kilometer, die
|
||||
# während eines HA-Ausfalls gefahren wurden, gehören noch dem damals
|
||||
# aktiven Satz (siehe reifen.py).
|
||||
await reifen.fortschreiben(self, reifen.aktueller_km(self))
|
||||
self._takte_anmelden()
|
||||
await self.trigger_neu_binden()
|
||||
# Erst wenn Home Assistant vollständig oben ist: bis dahin haben die
|
||||
# Datenquellen ihre Zustände oft noch nicht gemeldet, und eine noch
|
||||
# nicht gemeldete Zündung ließe sich nicht von "aus" unterscheiden
|
||||
# (siehe fahrterkennung.nach_neustart_fortsetzen).
|
||||
self._takte.append(
|
||||
async_at_started(self.hass, self._nach_start)
|
||||
)
|
||||
|
||||
async def _nach_start(self, _hass: HomeAssistant) -> None:
|
||||
await fahrterkennung.nach_neustart_fortsetzen(self)
|
||||
|
||||
async def beenden(self) -> None:
|
||||
for abmelden in (*self._takte, *self._beobachter):
|
||||
abmelden()
|
||||
self._takte.clear()
|
||||
self._beobachter.clear()
|
||||
self.warte_ende_ab_abbrechen()
|
||||
|
||||
# ------------------------------------------------------------- Zeittakte
|
||||
|
||||
def _takte_anmelden(self) -> None:
|
||||
self._takte.append(
|
||||
async_track_time_interval(self.hass, self._takt_status, TAKT_STATUS)
|
||||
)
|
||||
self._takte.append(
|
||||
async_track_time_interval(self.hass, self._takt_daten, TAKT_DATEN)
|
||||
)
|
||||
self._takte.append(
|
||||
async_track_time_interval(self.hass, self._takt_batterie, TAKT_BATTERIE)
|
||||
)
|
||||
# Ein einzelner täglicher Termin genügt für alle Sicherungs-Intervalle
|
||||
# - welche heute fällig ist, entscheidet sicherung.automatisch().
|
||||
self._takte.append(
|
||||
async_track_time_change(self.hass, self._takt_sicherung, hour=4, minute=0, second=0)
|
||||
)
|
||||
|
||||
async def _takt_status(self, _jetzt: datetime.datetime) -> None:
|
||||
await self.fahrzeugstatus_veroeffentlichen()
|
||||
|
||||
async def _takt_daten(self, _jetzt: datetime.datetime) -> None:
|
||||
await self.profil_veroeffentlichen()
|
||||
await self.fahrten_veroeffentlichen()
|
||||
await self.tankvorgaenge_veroeffentlichen()
|
||||
|
||||
async def _takt_batterie(self, _jetzt: datetime.datetime) -> None:
|
||||
await batterie.pruefen(self)
|
||||
|
||||
async def _takt_sicherung(self, _jetzt: datetime.datetime) -> None:
|
||||
await sicherung.automatisch(self)
|
||||
|
||||
# -------------------------------------------------- Zustandsbeobachter
|
||||
|
||||
async def trigger_neu_binden(self) -> None:
|
||||
"""Meldet die Beobachter für Zündung, Kilometerstand und Tankfüllstand
|
||||
neu an - nach jeder Änderung der Sensor-Zuordnung.
|
||||
|
||||
Ohne zugeordnete Entität wird bewusst gar kein Beobachter angemeldet,
|
||||
statt einen gegen eine nicht existierende Entity-ID zu registrieren."""
|
||||
for abmelden in self._beobachter:
|
||||
abmelden()
|
||||
self._beobachter.clear()
|
||||
|
||||
werte = self.zuordnung.werte
|
||||
self._beobachten(werte.ZUENDUNG_SENSOR, self._zuendung_geaendert)
|
||||
self._beobachten(werte.KM_SENSOR, self._kilometerstand_geaendert)
|
||||
self._beobachten(werte.TANK_SENSOR, self._tankfuellstand_geaendert)
|
||||
|
||||
def _beobachten(
|
||||
self, entity_id: str, rueckruf: Callable[[Event[EventStateChangedData]], Coroutine[Any, Any, None]]
|
||||
) -> None:
|
||||
if not entity_id:
|
||||
return
|
||||
self._beobachter.append(
|
||||
async_track_state_change_event(self.hass, [entity_id], rueckruf)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _zustaende(ereignis: Event[EventStateChangedData]) -> tuple[str | None, str | None]:
|
||||
alt = ereignis.data["old_state"]
|
||||
neu = ereignis.data["new_state"]
|
||||
return (neu.state if neu else None, alt.state if alt else None)
|
||||
|
||||
async def _zuendung_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
|
||||
neu, alt = self._zustaende(ereignis)
|
||||
await fahrterkennung.zuendung_geaendert(self, neu, alt)
|
||||
|
||||
async def _kilometerstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
|
||||
neu, alt = self._zustaende(ereignis)
|
||||
if neu is None or alt is None:
|
||||
return
|
||||
await reifen.fortschreiben(self, reifen.aktueller_km(self))
|
||||
await screening.durchfuehren(self)
|
||||
|
||||
async def _tankfuellstand_geaendert(self, ereignis: Event[EventStateChangedData]) -> None:
|
||||
neu, _alt = self._zustaende(ereignis)
|
||||
await tankerkennung.fuellstand_geaendert(self, neu)
|
||||
|
||||
# ------------------------------------------------- Laufende Fahrt merken
|
||||
|
||||
async def _laufzeit_laden(self) -> None:
|
||||
gespeichert = await self._store.async_load() or {}
|
||||
roh = gespeichert.get("fahrt_start_ts")
|
||||
if roh:
|
||||
try:
|
||||
self.fahrt_start_ts = datetime.datetime.fromisoformat(roh)
|
||||
_LOGGER.info(
|
||||
"Laufende Fahrt seit %s aus dem letzten Lauf übernommen", roh
|
||||
)
|
||||
except ValueError:
|
||||
self.fahrt_start_ts = None
|
||||
self.tiefststand_pct = gespeichert.get("tiefststand_pct")
|
||||
|
||||
async def _laufzeit_sichern(self) -> None:
|
||||
await self._store.async_save({
|
||||
"fahrt_start_ts": self.fahrt_start_ts.isoformat() if self.fahrt_start_ts else None,
|
||||
"tiefststand_pct": self.tiefststand_pct,
|
||||
})
|
||||
|
||||
async def fahrt_start_setzen(self, ts: datetime.datetime | None) -> None:
|
||||
self.fahrt_start_ts = ts
|
||||
await self._laufzeit_sichern()
|
||||
|
||||
async def tiefststand_setzen(self, wert: float | None) -> None:
|
||||
self.tiefststand_pct = wert
|
||||
await self._laufzeit_sichern()
|
||||
|
||||
def warte_ende_ab(self, koroutine: Coroutine[Any, Any, None]) -> None:
|
||||
self.warte_ende_ab_abbrechen()
|
||||
self._ende_aufgabe = self.entry.async_create_background_task(
|
||||
self.hass, koroutine, "audi_dashboard_fahrtende"
|
||||
)
|
||||
|
||||
def warte_ende_ab_abbrechen(self) -> None:
|
||||
if self._ende_aufgabe is not None and not self._ende_aufgabe.done():
|
||||
self._ende_aufgabe.cancel()
|
||||
self._ende_aufgabe = None
|
||||
|
||||
# ---------------------------------------------------------- Veröffentlichen
|
||||
|
||||
def setzen(
|
||||
self, schluessel: str, zustand: str | float | None, daten: dict | list | None = None
|
||||
) -> None:
|
||||
self.werte[schluessel] = (zustand, daten)
|
||||
async_dispatcher_send(self.hass, f"{SIGNAL_AKTUALISIERT}_{schluessel}")
|
||||
|
||||
async def profil_veroeffentlichen(self, profil: dict | None = None) -> None:
|
||||
if profil is None:
|
||||
profil = await self.ablage.profil_lesen()
|
||||
if profil is None:
|
||||
return
|
||||
self.setzen(E_PROFIL, "aktuell", profil)
|
||||
# Die drei Reifen-Kontrollwerte hängen am selben Profil - zusammen
|
||||
# veröffentlichen, damit sie nicht auseinanderlaufen können.
|
||||
# Die beiden km-Stände bleiben Zahlen (die Entitäten tragen die
|
||||
# Einheit km); None heißt "noch nie gezählt" und wird von Home
|
||||
# Assistant als "unknown" angezeigt - richtiger als eine erfundene 0.
|
||||
stand = reifen.zustaende(profil)
|
||||
self.setzen(E_REIFEN_SOMMER, stand["sommer"])
|
||||
self.setzen(E_REIFEN_WINTER, stand["winter"])
|
||||
self.setzen(E_REIFEN_AKTIV, str(stand["aktiv"]))
|
||||
|
||||
async def fahrten_veroeffentlichen(self) -> None:
|
||||
self.setzen(E_FAHRTEN, "aktuell", await self.ablage.fahrten_lesen())
|
||||
|
||||
async def tankvorgaenge_veroeffentlichen(self) -> None:
|
||||
self.setzen(E_TANKVORGAENGE, "aktuell", await self.ablage.tankvorgaenge_lesen())
|
||||
|
||||
async def batterieverlauf_veroeffentlichen(self) -> None:
|
||||
self.setzen(E_BATTERIEVERLAUF, "aktuell", await self.ablage.batterieverlauf_lesen())
|
||||
|
||||
async def zuordnung_veroeffentlichen(self) -> None:
|
||||
self.setzen(E_ZUORDNUNG, "aktuell", self.zuordnung.aktueller_stand())
|
||||
|
||||
async def fahrzeugstatus_veroeffentlichen(self) -> None:
|
||||
self.setzen(E_FAHRZEUGSTATUS, "aktuell", fahrzeugstatus(self.hass, self.zuordnung.werte))
|
||||
|
||||
async def beleg_ergebnis_veroeffentlichen(self, daten: dict) -> None:
|
||||
self.setzen(E_BELEG_ERGEBNIS, "aktuell", daten)
|
||||
|
||||
async def import_status_veroeffentlichen(self, zustand: str, daten: dict) -> None:
|
||||
self.setzen(E_IMPORT_STATUS, zustand, daten)
|
||||
|
||||
async def alles_veroeffentlichen(self) -> None:
|
||||
self.setzen(E_APP_VERSION, self.version, {"app": self.version})
|
||||
await self.profil_veroeffentlichen()
|
||||
await self.fahrten_veroeffentlichen()
|
||||
await self.tankvorgaenge_veroeffentlichen()
|
||||
await self.fahrzeugstatus_veroeffentlichen()
|
||||
await self.batterieverlauf_veroeffentlichen()
|
||||
await self.zuordnung_veroeffentlichen()
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.8.23.2",
|
||||
"documentation": "https://github.com/paulnothaft/audi-app/blob/main/README.md",
|
||||
"issue_tracker": "https://github.com/paulnothaft/audi-app/issues",
|
||||
"codeowners": ["@paulnothaft"],
|
||||
"config_flow": true,
|
||||
"integration_type": "service",
|
||||
"single_config_entry": true,
|
||||
"iot_class": "local_polling",
|
||||
"dependencies": ["http", "frontend", "panel_custom", "recorder"],
|
||||
"requirements": ["pypdf>=4.0.0"]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Reifenzähler: jeder gefahrene Kilometer wird dem Satz zugerechnet, der zum
|
||||
Zeitpunkt der Fahrt aktiv war.
|
||||
|
||||
Früher war der Zähler = aktueller Kilometerstand minus fester Startwert je
|
||||
Satz. Das war falsch, sobald einmal zwischen Sommer und Winter umgeschaltet
|
||||
wurde: die Differenz zum Startwert zählt dann alle seither gefahrenen
|
||||
Kilometer mit, auch die auf dem jeweils anderen Satz. Jetzt führt
|
||||
reifen.saetze.<satz>.km selbst den fortlaufenden Gesamtstand, und
|
||||
reifen.referenz_odo_km hält den zuletzt verarbeiteten Kilometerstand fest,
|
||||
gegen den die nächste Änderung als Delta gemessen wird.
|
||||
|
||||
Keine Abhängigkeit von total_increasing oder utility_meter: die Fortschreibung
|
||||
braucht nur den zuletzt bekannten Absolutwert des Kilometerstand-Sensors. Ein
|
||||
zeitweise "unavailable" Quellsensor verliert dadurch nichts - referenz_odo_km
|
||||
bleibt einfach stehen, bis wieder ein gültiger Wert kommt, und das nächste
|
||||
Delta wird dann gegen diesen alten Stand berechnet. Auch Kilometer, die
|
||||
während eines HA-Ausfalls gefahren wurden, werden so noch dem damals aktiven
|
||||
Satz zugerechnet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .veroeffentlichung import zustand_oder_none
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SAETZE = ("sommer", "winter")
|
||||
|
||||
|
||||
def aktueller_km(k: Koordinator) -> float | None:
|
||||
try:
|
||||
return float(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR)) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def fortschreiben(k: Koordinator, neuer_km: float | None, neuer_aktiv: str | None = None) -> None:
|
||||
"""Rechnet zuerst mit dem bisher aktiven Satz ab (Delta seit
|
||||
referenz_odo_km), trägt danach optional einen Satzwechsel ein - in dieser
|
||||
Reihenfolge, damit beim Wechseln selbst keine Kilometer dem neuen Satz
|
||||
zugerechnet werden, die eigentlich noch auf dem alten gefahren wurden."""
|
||||
profil = await k.ablage.profil_lesen()
|
||||
if profil is None:
|
||||
return
|
||||
|
||||
reifen = profil.setdefault("reifen", {})
|
||||
saetze = reifen.setdefault("saetze", {})
|
||||
aktiv = reifen.get("aktiv", "sommer")
|
||||
referenz = reifen.get("referenz_odo_km")
|
||||
|
||||
if referenz is not None and neuer_km is not None:
|
||||
delta = round(neuer_km - referenz, 1)
|
||||
if delta > 0:
|
||||
satz = saetze.setdefault(aktiv, {})
|
||||
satz["km"] = round((satz.get("km") or 0) + delta, 1)
|
||||
if neuer_km is not None:
|
||||
reifen["referenz_odo_km"] = neuer_km
|
||||
if neuer_aktiv is not None:
|
||||
reifen["aktiv"] = neuer_aktiv
|
||||
|
||||
await k.ablage.profil_schreiben(profil)
|
||||
await k.profil_veroeffentlichen(profil)
|
||||
|
||||
|
||||
async def wechseln(k: Koordinator, satz: str | None) -> None:
|
||||
if satz not in SAETZE:
|
||||
_LOGGER.warning("Ungültiger Reifensatz '%s'", satz)
|
||||
return
|
||||
await fortschreiben(k, aktueller_km(k), neuer_aktiv=satz)
|
||||
_LOGGER.info("Aktiver Reifensatz auf '%s' umgeschaltet", satz)
|
||||
|
||||
|
||||
async def km_setzen(k: Koordinator, satz: str | None, km: object) -> None:
|
||||
"""Manuelle Korrektur des gefahrenen Kilometerstands eines Reifensatzes.
|
||||
|
||||
Überschreibt nur den gespeicherten Zähler selbst - referenz_odo_km (der
|
||||
zuletzt verarbeitete Fahrzeug-Kilometerstand) bleibt unverändert, damit
|
||||
die nächste Fortschreibung weiterhin korrekt von hier aus weiterzählt,
|
||||
statt auf den neuen Wert erneut ein Delta seit dem alten Stand
|
||||
aufzuschlagen."""
|
||||
if satz not in SAETZE:
|
||||
_LOGGER.warning("Ungültiger Reifensatz '%s'", satz)
|
||||
return
|
||||
try:
|
||||
wert = round(float(km), 1) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
_LOGGER.warning("Ungültiger km-Wert '%s'", km)
|
||||
return
|
||||
if wert < 0:
|
||||
_LOGGER.warning("Negativer km-Wert '%s' abgelehnt", wert)
|
||||
return
|
||||
|
||||
profil = await k.ablage.profil_lesen()
|
||||
if profil is None:
|
||||
return
|
||||
reifen = profil.setdefault("reifen", {})
|
||||
reifen.setdefault("saetze", {}).setdefault(satz, {})["km"] = wert
|
||||
await k.ablage.profil_schreiben(profil)
|
||||
await k.profil_veroeffentlichen(profil)
|
||||
_LOGGER.info("km-Stand '%s' manuell auf %s gesetzt", satz, wert)
|
||||
|
||||
|
||||
def zustaende(profil: dict | None) -> dict[str, object]:
|
||||
"""Die drei Kontrollwerte für die eigenen Reifen-Entitäten.
|
||||
|
||||
Das Frontend liest die Zahlen direkt aus dem veröffentlichten Profil -
|
||||
diese Entitäten sind nur zur Kontrolle in Entwicklerwerkzeuge -> Zustände
|
||||
und für eigene Automationen des Nutzers da."""
|
||||
reifen = (profil or {}).get("reifen", {})
|
||||
saetze = reifen.get("saetze", {})
|
||||
return {
|
||||
"sommer": saetze.get("sommer", {}).get("km"),
|
||||
"winter": saetze.get("winter", {}).get("km"),
|
||||
"aktiv": reifen.get("aktiv") or "unbekannt",
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Zweistufiger Fahrtabschluss: offene Fahrten nachträglich mit Start- und
|
||||
Endkilometerstand vervollständigen (SPECIFICATION.md §7.2).
|
||||
|
||||
Der Kilometerstand kommt laut Datenquelle nicht sicher mit Fahrtende, sondern
|
||||
teils erst mit Beginn oder während der nächsten Fahrt. Statt auf einen festen
|
||||
Zeitpunkt zu warten, wird deshalb der aufgezeichnete Verlauf des
|
||||
Kilometerstand-Sensors nach dem Wert durchsucht, dessen Zeitstempel am
|
||||
nächsten am Fahrtbeginn bzw. -ende liegt.
|
||||
|
||||
Läuft nach jedem Fahrtende und zusätzlich bei jeder Änderung des
|
||||
Kilometerstand-Sensors - unabhängig vom Fahrtende-Ereignis selbst, eben weil
|
||||
der Wert auch erst mit der nächsten Fahrt eintreffen kann.
|
||||
|
||||
VORBEHALT: Der GPS-Fallback (km_quelle="gps") ist weiterhin nicht umgesetzt,
|
||||
da die Adressauflösung ein eigenes Arbeitspaket ist. Fahrten ohne passenden
|
||||
Kilometerstand bleiben "offen" statt auf GPS-Distanz auszuweichen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .verlauf import naechster_wert, verlauf_lesen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Wie weit vor/zurück gesucht wird, ausgehend von Fahrtbeginn bzw. -ende.
|
||||
# 48h deckt auch länger stehende Fahrzeuge ab, ohne bei jedem Screening die
|
||||
# komplette Historie zu laden.
|
||||
SUCHFENSTER = datetime.timedelta(hours=48)
|
||||
|
||||
|
||||
def _als_zeit(wert: str | None) -> datetime.datetime | None:
|
||||
if not wert:
|
||||
return None
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(wert)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict:
|
||||
"""Ergänzt Strecke, Quelle und Status, sobald beide Kilometerstände da
|
||||
sind. Eine Fahrt ist genau dann "vollständig", wenn ihre Strecke feststeht
|
||||
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch"."""
|
||||
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
|
||||
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
|
||||
aenderungen["km_quelle"] = "odometer"
|
||||
aenderungen["status"] = "vollständig"
|
||||
return aenderungen
|
||||
|
||||
|
||||
async def durchfuehren(k: Koordinator) -> None:
|
||||
"""Versucht, alle offenen Fahrten zu vervollständigen."""
|
||||
km_sensor = k.zuordnung.werte.KM_SENSOR
|
||||
fahrten = await k.ablage.fahrten_lesen()
|
||||
offene = [f for f in fahrten if f.get("status") == "offen"]
|
||||
if not offene:
|
||||
return
|
||||
offene.sort(key=lambda f: f.get("ts_start", ""))
|
||||
|
||||
if km_sensor:
|
||||
for fahrt in offene:
|
||||
await _fahrt_screenen(k, km_sensor, fahrt)
|
||||
|
||||
await _lueckenlos_verketten(k)
|
||||
await k.fahrten_veroeffentlichen()
|
||||
|
||||
|
||||
async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None:
|
||||
start = _als_zeit(fahrt.get("ts_start"))
|
||||
ende = _als_zeit(fahrt.get("ts_end"))
|
||||
if start is None or ende is None:
|
||||
_LOGGER.warning(
|
||||
"Fahrt %s hat keinen lesbaren Zeitraum - Screening übersprungen",
|
||||
fahrt.get("trip_id"),
|
||||
)
|
||||
return
|
||||
|
||||
# Ein Abruf für beide Zeitpunkte statt zwei: die Fenster überlappen sich
|
||||
# bei einer normalen Fahrt ohnehin fast vollständig.
|
||||
punkte = await verlauf_lesen(k.hass, km_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
|
||||
if not punkte:
|
||||
return
|
||||
|
||||
geaendert = False
|
||||
if fahrt.get("odo_end") is None:
|
||||
wert = naechster_wert(ende, punkte)
|
||||
if wert is not None:
|
||||
fahrt["odo_end"] = wert
|
||||
geaendert = True
|
||||
if fahrt.get("odo_start") is None:
|
||||
wert = naechster_wert(start, punkte)
|
||||
if wert is not None:
|
||||
fahrt["odo_start"] = wert
|
||||
geaendert = True
|
||||
|
||||
if geaendert:
|
||||
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
|
||||
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
|
||||
|
||||
|
||||
async def _lueckenlos_verketten(k: Koordinator) -> None:
|
||||
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
|
||||
wurde.
|
||||
|
||||
Zwischen zwei in der Liste direkt aufeinanderfolgenden Fahrten steht das
|
||||
Fahrzeug nur - der Kilometerstand ändert sich dabei nicht, unabhängig
|
||||
davon, wie lange die Standzeit dauert. Ein bestätigter Wert wird deshalb
|
||||
an den direkten Nachbarn weitergereicht, auch wenn dessen eigenes
|
||||
Screening noch keinen Treffer hatte.
|
||||
|
||||
Annahme: die Fahrten-Liste enthält keine Lücken durch verpasste
|
||||
Ereignisse. Das ist der Normalfall, aber keine Garantie."""
|
||||
fahrten = sorted(await k.ablage.fahrten_lesen(), key=lambda f: f.get("ts_start", ""))
|
||||
geaendert: set[str] = set()
|
||||
|
||||
for aktuelle, naechste in zip(fahrten, fahrten[1:]):
|
||||
if aktuelle.get("odo_end") is not None and naechste.get("odo_start") is None:
|
||||
naechste["odo_start"] = aktuelle["odo_end"]
|
||||
geaendert.add(naechste["trip_id"])
|
||||
elif naechste.get("odo_start") is not None and aktuelle.get("odo_end") is None:
|
||||
aktuelle["odo_end"] = naechste["odo_start"]
|
||||
geaendert.add(aktuelle["trip_id"])
|
||||
|
||||
for fahrt in fahrten:
|
||||
if fahrt["trip_id"] not in geaendert:
|
||||
continue
|
||||
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
|
||||
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Die Entitäten, über die beide Oberflächen die Daten der App lesen.
|
||||
|
||||
Das Frontend liest hass.states["sensor.audi_dashboard_profil"] usw. direkt und
|
||||
holt sich die Nutzlast aus dem Attribut `daten` - kein Dienstaufruf mit
|
||||
Rückgabewert, dessen Verhalten aus Sicht des Browsers nicht durchgängig
|
||||
dokumentiert ist.
|
||||
|
||||
WARUM ECHTE ENTITÄTEN UND NICHT NUR EINTRÄGE IN DER ZUSTANDSMASCHINE
|
||||
--------------------------------------------------------------------
|
||||
Wegen einer einzigen Zeile: `_unrecorded_attributes`. Die Nutzlast dieser
|
||||
Entitäten ist groß - das Fahrtenarchiv wächst über die Jahre auf Hunderte
|
||||
Kilobyte. Home Assistant zeichnet Zustands-Attribute standardmäßig bei jeder
|
||||
Änderung in der Recorder-Datenbank auf und warnt ab ~16 KB, dass genau das
|
||||
Datenbankprobleme macht. In der pyscript-Fassung war das eine offene Flanke:
|
||||
jede Veröffentlichung schrieb den kompletten Bestand erneut in die Datenbank,
|
||||
alle 60 Sekunden.
|
||||
|
||||
Nur eine Entität, die zu einer Integration gehört, kann Attribute von der
|
||||
Aufzeichnung ausnehmen. Ein roher Eintrag in der Zustandsmaschine (was
|
||||
pyscripts state.set() macht) kann das nicht. Die Nutzlast geht damit
|
||||
weiterhin an jede Oberfläche, landet aber nie in der Datenbank.
|
||||
|
||||
Die Zustände selbst sind bewusst schlicht ("aktuell"): der Inhalt steckt im
|
||||
Attribut, der Zustand sagt nur, dass etwas da ist. Bei den drei
|
||||
Reifen-Entitäten und der App-Version ist der Zustand dagegen der eigentliche
|
||||
Wert - die sind zur Kontrolle in Entwicklerwerkzeuge -> Zustände und für
|
||||
eigene Automationen des Nutzers gedacht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfLength
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
E_APP_VERSION,
|
||||
E_BATTERIEVERLAUF,
|
||||
E_BELEG_ERGEBNIS,
|
||||
E_FAHRTEN,
|
||||
E_FAHRZEUGSTATUS,
|
||||
E_IMPORT_STATUS,
|
||||
E_PROFIL,
|
||||
E_REIFEN_AKTIV,
|
||||
E_REIFEN_SOMMER,
|
||||
E_REIFEN_WINTER,
|
||||
E_TANKVORGAENGE,
|
||||
E_ZUORDNUNG,
|
||||
SIGNAL_AKTUALISIERT,
|
||||
)
|
||||
from .koordinator import Koordinator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Beschreibung:
|
||||
schluessel: str
|
||||
name: str
|
||||
icon: str
|
||||
einheit: str | None = None
|
||||
kategorie: EntityCategory | None = None
|
||||
|
||||
|
||||
BESCHREIBUNGEN: tuple[Beschreibung, ...] = (
|
||||
Beschreibung(E_PROFIL, "Audi Dashboard Profil", "mdi:car-info"),
|
||||
Beschreibung(E_FAHRTEN, "Audi Dashboard Fahrten", "mdi:road-variant"),
|
||||
Beschreibung(E_TANKVORGAENGE, "Audi Dashboard Tankvorgänge", "mdi:gas-station"),
|
||||
Beschreibung(E_FAHRZEUGSTATUS, "Audi Dashboard Fahrzeugstatus", "mdi:car-connected"),
|
||||
Beschreibung(E_BATTERIEVERLAUF, "Audi Dashboard Batterieverlauf", "mdi:car-battery"),
|
||||
Beschreibung(E_REIFEN_SOMMER, "Audi Dashboard Reifen Sommer km", "mdi:tire", UnitOfLength.KILOMETERS),
|
||||
Beschreibung(E_REIFEN_WINTER, "Audi Dashboard Reifen Winter km", "mdi:snowflake", UnitOfLength.KILOMETERS),
|
||||
Beschreibung(E_REIFEN_AKTIV, "Audi Dashboard Reifen aktiver Satz", "mdi:tire"),
|
||||
Beschreibung(E_ZUORDNUNG, "Audi Dashboard Entitäten", "mdi:link-variant", None, EntityCategory.DIAGNOSTIC),
|
||||
Beschreibung(E_BELEG_ERGEBNIS, "Audi Dashboard Beleg Ergebnis", "mdi:receipt-text", None, EntityCategory.DIAGNOSTIC),
|
||||
Beschreibung(E_IMPORT_STATUS, "Audi Dashboard Import Status", "mdi:database-import", None, EntityCategory.DIAGNOSTIC),
|
||||
Beschreibung(E_APP_VERSION, "Audi Dashboard App Version", "mdi:tag-outline", None, EntityCategory.DIAGNOSTIC),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
koordinator: Koordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
AudiEntitaet(koordinator, beschreibung) for beschreibung in BESCHREIBUNGEN
|
||||
)
|
||||
|
||||
|
||||
class AudiEntitaet(SensorEntity):
|
||||
"""Eine Entität je Datenbestand. Zustand kurz, Inhalt im Attribut `daten`."""
|
||||
|
||||
# Der eigentliche Grund für echte Entitäten - siehe Kopfkommentar.
|
||||
_unrecorded_attributes = frozenset({"daten"})
|
||||
|
||||
# Bewusst KEIN has_entity_name: die Entity-IDs (sensor.audi_dashboard_*)
|
||||
# sind ein Vertrag mit beiden Oberflächen (siehe const.py). Mit
|
||||
# has_entity_name würde Home Assistant den Gerätenamen davorsetzen und die
|
||||
# IDs hingen am - vom Nutzer änderbaren - Gerätenamen.
|
||||
_attr_has_entity_name = False
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, koordinator: Koordinator, beschreibung: Beschreibung) -> None:
|
||||
self._koordinator = koordinator
|
||||
self._beschreibung = beschreibung
|
||||
self._attr_name = beschreibung.name
|
||||
self._attr_icon = beschreibung.icon
|
||||
self._attr_native_unit_of_measurement = beschreibung.einheit
|
||||
self._attr_entity_category = beschreibung.kategorie
|
||||
self._attr_unique_id = f"{koordinator.entry.entry_id}_{beschreibung.schluessel}"
|
||||
# Die gewünschte Objekt-ID ausdrücklich vorgeben, statt sie aus dem
|
||||
# Namen ableiten zu lassen: aus dem Namen käme zwar dasselbe heraus,
|
||||
# aber nur solange niemand den Namen anfasst.
|
||||
self.internal_integration_suggested_object_id = (
|
||||
f"audi_dashboard_{beschreibung.schluessel}"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, koordinator.entry.entry_id)},
|
||||
name="Audi Dashboard",
|
||||
manufacturer="Audi Dashboard",
|
||||
sw_version=koordinator.version,
|
||||
entry_type=None,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
f"{SIGNAL_AKTUALISIERT}_{self._beschreibung.schluessel}",
|
||||
self._neu_zeichnen,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _neu_zeichnen(self) -> None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _wert(self) -> tuple[str, object]:
|
||||
return self._koordinator.werte.get(self._beschreibung.schluessel, ("unbekannt", None))
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Bis zur ersten Veröffentlichung gibt es nichts zu zeigen.
|
||||
|
||||
"unavailable" ist hier ehrlicher als ein Platzhalterwert: die
|
||||
Oberfläche unterscheidet ausdrücklich zwischen "kein Wert" und "Wert
|
||||
unbekannt" und darf sich darauf verlassen."""
|
||||
return self._beschreibung.schluessel in self._koordinator.werte
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
return self._wert()[0]
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, object]:
|
||||
return {"daten": self._wert()[1]}
|
||||
@@ -0,0 +1,257 @@
|
||||
# Felder und Eingabehilfen für Entwicklerwerkzeuge -> Aktionen.
|
||||
# Namen und Beschreibungen stehen in strings.json bzw. translations/, nicht
|
||||
# hier - so steht jeder Text genau einmal.
|
||||
#
|
||||
# Beide Oberflächen rufen diese Dienste über die WebSocket- bzw. REST-API auf
|
||||
# und brauchen diese Datei nicht. Sie ist dafür da, dass die Dienste in Home
|
||||
# Assistant selbst bedienbar und nachvollziehbar sind - zum Nachschauen, zum
|
||||
# Ausprobieren und für eigene Automationen des Nutzers.
|
||||
|
||||
jetzt_aktualisieren:
|
||||
screening_jetzt:
|
||||
backup_jetzt:
|
||||
|
||||
profil_schreiben:
|
||||
fields:
|
||||
profil_json:
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
entitaeten_schreiben:
|
||||
fields:
|
||||
zuordnung_json:
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
fahrt_manuell_anlegen:
|
||||
fields:
|
||||
ts_start:
|
||||
required: true
|
||||
example: "2026-08-23T07:30:00+02:00"
|
||||
selector:
|
||||
text:
|
||||
ts_end:
|
||||
required: true
|
||||
example: "2026-08-23T08:05:00+02:00"
|
||||
selector:
|
||||
text:
|
||||
art:
|
||||
selector:
|
||||
select:
|
||||
options: ["privat", "arbeitsweg"]
|
||||
start_ort:
|
||||
selector:
|
||||
text:
|
||||
ziel_ort:
|
||||
selector:
|
||||
text:
|
||||
odo_start:
|
||||
selector:
|
||||
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
odo_end:
|
||||
selector:
|
||||
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
distanz:
|
||||
selector:
|
||||
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
|
||||
fahrt_aktualisieren:
|
||||
fields:
|
||||
trip_id:
|
||||
required: true
|
||||
example: t-4f2a91c30b7e
|
||||
selector:
|
||||
text:
|
||||
ts_start:
|
||||
selector:
|
||||
text:
|
||||
ts_end:
|
||||
selector:
|
||||
text:
|
||||
art:
|
||||
selector:
|
||||
select:
|
||||
options: ["privat", "arbeitsweg"]
|
||||
start_ort:
|
||||
selector:
|
||||
text:
|
||||
ziel_ort:
|
||||
selector:
|
||||
text:
|
||||
odo_start:
|
||||
selector:
|
||||
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
odo_end:
|
||||
selector:
|
||||
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
distanz:
|
||||
selector:
|
||||
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
|
||||
fahrt_loeschen:
|
||||
fields:
|
||||
trip_id:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
beleg_hochladen:
|
||||
fields:
|
||||
pdf_base64:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
dateiname:
|
||||
selector:
|
||||
text:
|
||||
tank_id:
|
||||
selector:
|
||||
text:
|
||||
|
||||
tankvorgang_manuell:
|
||||
fields:
|
||||
ts:
|
||||
selector:
|
||||
text:
|
||||
liter:
|
||||
selector:
|
||||
number: { min: 0, max: 200, step: 0.01, mode: box, unit_of_measurement: L }
|
||||
kosten:
|
||||
selector:
|
||||
number: { min: 0, max: 2000, step: 0.01, mode: box, unit_of_measurement: EUR }
|
||||
km:
|
||||
selector:
|
||||
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
ersparnis:
|
||||
selector:
|
||||
number: { min: 0, max: 500, step: 0.01, mode: box, unit_of_measurement: EUR }
|
||||
station:
|
||||
selector:
|
||||
text:
|
||||
distanz:
|
||||
selector:
|
||||
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
kraftstoff:
|
||||
selector:
|
||||
text:
|
||||
receipt_key:
|
||||
selector:
|
||||
text:
|
||||
receipt_file:
|
||||
selector:
|
||||
text:
|
||||
|
||||
tankvorgang_aktualisieren:
|
||||
fields:
|
||||
tank_id:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
ts:
|
||||
selector:
|
||||
text:
|
||||
liter:
|
||||
selector:
|
||||
number: { min: 0, max: 200, step: 0.01, mode: box, unit_of_measurement: L }
|
||||
kosten:
|
||||
selector:
|
||||
number: { min: 0, max: 2000, step: 0.01, mode: box, unit_of_measurement: EUR }
|
||||
km:
|
||||
selector:
|
||||
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
ersparnis:
|
||||
selector:
|
||||
number: { min: 0, max: 500, step: 0.01, mode: box, unit_of_measurement: EUR }
|
||||
station:
|
||||
selector:
|
||||
text:
|
||||
distanz:
|
||||
selector:
|
||||
number: { min: 0, max: 10000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
kraftstoff:
|
||||
selector:
|
||||
text:
|
||||
receipt_key:
|
||||
selector:
|
||||
text:
|
||||
receipt_file:
|
||||
selector:
|
||||
text:
|
||||
|
||||
tankvorgang_loeschen:
|
||||
fields:
|
||||
tank_id:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
bild_hochladen:
|
||||
fields:
|
||||
dateiname:
|
||||
required: true
|
||||
example: seitenansicht.webp
|
||||
selector:
|
||||
text:
|
||||
daten_base64:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
|
||||
bild_loeschen:
|
||||
fields:
|
||||
dateiname:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
reifen_wechseln:
|
||||
fields:
|
||||
satz:
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options: ["sommer", "winter"]
|
||||
|
||||
reifen_km_setzen:
|
||||
fields:
|
||||
satz:
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options: ["sommer", "winter"]
|
||||
km:
|
||||
required: true
|
||||
selector:
|
||||
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
|
||||
|
||||
backup_wiederherstellen:
|
||||
fields:
|
||||
profil_json:
|
||||
selector:
|
||||
object:
|
||||
fahrten_json:
|
||||
selector:
|
||||
object:
|
||||
tankvorgaenge_json:
|
||||
selector:
|
||||
object:
|
||||
entitaeten_json:
|
||||
selector:
|
||||
object:
|
||||
|
||||
historie_importieren:
|
||||
fields:
|
||||
start:
|
||||
required: true
|
||||
example: "2026-01-01T00:00:00"
|
||||
selector:
|
||||
text:
|
||||
ende:
|
||||
required: true
|
||||
example: "2026-08-23T00:00:00"
|
||||
selector:
|
||||
text:
|
||||
@@ -0,0 +1,466 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parser für Shell-Tankbelege (PDF), aufgerufen von belegverarbeitung.py.
|
||||
|
||||
Diese Datei fehlte beim Bau im Projektordner (siehe Kopfkommentar in
|
||||
pyscript/belegverarbeitung.py und README.md, Abschnitt "Bekannte Lücken").
|
||||
Sie ist hier gegen echte Shell-eReceipts neu geschrieben und gegen deren
|
||||
tatsächlich von pypdf extrahierten Text geprüft - nicht gegen eine
|
||||
angenommene Struktur. Regressionstest mit zehn realen Belegen aus vier
|
||||
Stationen liegt unter tests/test_shell_beleg_parser.py (Aufruf: python3
|
||||
tests/test_shell_beleg_parser.py aus diesem Ordner). Die Belege
|
||||
unterscheiden sich u. a. in der Markenzeile ("SHELL STATION" vs.
|
||||
"Shell Station" vs. "Shell-Station") und der Rabattbezeichnung
|
||||
("V-Power Smart Deal" vs. "ClubSmartRabatt") - deshalb ist die
|
||||
Stations- und Rabatterkennung unten bewusst nicht an deren genauen
|
||||
Wortlaut gebunden, sondern an feste Beleg-Struktur-Marker (Obj.-Nr.,
|
||||
Betragsmuster vor "EUR #A").
|
||||
|
||||
Abhängigkeit: pypdf (reine Textextraktion, der Beleg ist ein normales
|
||||
Text-PDF, kein Scan/Bild - kein OCR nötig). Läuft als eigener Prozess über
|
||||
`python3 shell_beleg_parser.py <pfad>` (system-python3 im Container, nicht
|
||||
die pyscript-Sandbox - siehe _parser_aufrufen() in belegverarbeitung.py),
|
||||
deshalb reicht `pip install pypdf` einmalig im selben Python, das
|
||||
`python3` in configuration.yaml/Docker auch sonst benutzt.
|
||||
|
||||
Kommandozeilen-Schnittstelle wie von belegverarbeitung.py angenommen:
|
||||
Ein Pfad zu einer PDF-Datei als einziges Argument, JSON-Objekt auf stdout
|
||||
bei Erfolg, Exit-Code ungleich 0 und Fehlertext auf stderr bei Misserfolg
|
||||
(z. B. wenn der Beleg keiner der erkannten Vorlagen entspricht).
|
||||
|
||||
Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix):
|
||||
SHELL STATION
|
||||
<Betreibername, z.B. "A. Zrenner GmbH">
|
||||
<Straße>
|
||||
<PLZ Ort>
|
||||
Obj.-Nr.: <Stations-ID> Tel. ...
|
||||
Beleg-Nr. <receipt_no> <TT.MM.JJ> <HH:MM>
|
||||
*<article_no> <Produktname> <Listenbetrag> EUR #A*
|
||||
*Zp <Zapfsäule> <Liter> l <Listenpreis/l> EUR/l # *
|
||||
[<Rabattname> -<Rabattbetrag> EUR #A -- optional, nur mit SmartDeal
|
||||
<Rabatt/l> EUR/Liter -- optional]
|
||||
Gesamtbetrag <Endbetrag> EUR
|
||||
Typ Netto Mwst Brutto
|
||||
A:<Mwst-Satz>% <Netto> <Mwst> <Brutto>
|
||||
...
|
||||
Mobile Payment
|
||||
<TT.MM.JJJJ> <HH:MM:SS> -- Zahlungszeitstempel
|
||||
...
|
||||
(Seite 2) POS:...-...-... TSE-Beleg-Nr: <tse_beleg_nr>
|
||||
Start/Ende: <TT.MM.JJJJ HH:MM:SS> / ...
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import pypdf
|
||||
except ImportError:
|
||||
print(
|
||||
"pypdf fehlt. Einmalig installieren mit: pip install pypdf "
|
||||
"(im selben Python, das diesen Parser ausführt, siehe Kopfkommentar).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _text_aus_pdf(pdf_pfad):
|
||||
leser = pypdf.PdfReader(pdf_pfad)
|
||||
return "\n".join(seite.extract_text() or "" for seite in leser.pages)
|
||||
|
||||
|
||||
def _de_zahl(text):
|
||||
""""103,06" -> 103.06 / "2,399" -> 2.399. Kein Tausenderpunkt bei
|
||||
Tankbeträgen dieser Größenordnung zu erwarten, trotzdem robust: Punkte
|
||||
vor dem Umwandeln entfernen, nicht nur das Komma ersetzen."""
|
||||
if text is None:
|
||||
return None
|
||||
return float(text.strip().replace(".", "").replace(",", "."))
|
||||
|
||||
|
||||
def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
|
||||
jahr = int(jahr)
|
||||
if jahr < 100:
|
||||
jahr += 2000
|
||||
return datetime.datetime(
|
||||
jahr, int(monat), int(tag), int(stunde), int(minute), int(sekunde)
|
||||
).isoformat()
|
||||
|
||||
|
||||
_ZAHL = r"[\d.,]+"
|
||||
|
||||
# Echte Geldangabe: Nachkommastellen sind Pflicht, damit im stationsunabhängigen
|
||||
# Parser (_parsen_generisch) keine Artikel-/Belegnummer als Betrag durchgeht.
|
||||
_GELD = r"\d{1,3}(?:[ .]\d{3})*,\d{2}"
|
||||
_GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME")
|
||||
# Steuer- und Nettozeilen tragen dieselben Schlüsselwörter ("MWST GESAMT"),
|
||||
# nennen aber nie den bezahlten Rechnungsbetrag.
|
||||
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
|
||||
|
||||
# Kraftstoffmarken, wie sie im Belegkopf stehen. Bewusst nur dort gesucht (die
|
||||
# ersten Zeilen), nicht im ganzen Text: "Total" wäre sonst z. B. auch als
|
||||
# Summenzeile ein Treffer. Längere Namen zuerst, damit "TotalEnergies" nicht
|
||||
# als "Total" endet. Die Schreibweise hier ist die, die angezeigt wird - auf
|
||||
# Bons steht die Marke meist in Großbuchstaben ("SHELL STATION").
|
||||
_MARKEN = (
|
||||
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
|
||||
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
|
||||
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
|
||||
"bft", "BP", "Q1",
|
||||
)
|
||||
_STRASSE_WOERTER = r"(?:stra(?:ss|ß)e|str\.|gasse|weg|platz|allee|ring|damm|chaussee)"
|
||||
|
||||
|
||||
def _marke(zeilen):
|
||||
"""Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn
|
||||
keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen."""
|
||||
kopf = "\n".join(zeilen[:8])
|
||||
for marke in _MARKEN:
|
||||
if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE):
|
||||
return marke
|
||||
return None
|
||||
|
||||
|
||||
def _ist_strasse(zeile):
|
||||
"""Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der
|
||||
Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht."""
|
||||
if not zeile:
|
||||
return False
|
||||
return bool(re.search(r"\d", zeile) or re.search(_STRASSE_WOERTER, zeile, re.IGNORECASE))
|
||||
|
||||
|
||||
def _tankstelle(marke, strasse, ort, ersatzname=None):
|
||||
"""Anzeigename der Tankstelle im Format "Marke, Straße, Ort" (§ Anzeige im
|
||||
Frontend, Feld station_name). Fehlende Teile fallen weg statt als leere
|
||||
Kommastelle stehen zu bleiben - nicht jeder Beleg nennt eine Straße. Ohne
|
||||
erkennbare Marke tritt der Betreibername an ihre Stelle."""
|
||||
teile = [t for t in (marke or ersatzname, strasse, ort) if t]
|
||||
return ", ".join(teile) if teile else None
|
||||
|
||||
|
||||
def _parsen(text):
|
||||
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
|
||||
treffer = re.search(muster, text, flags)
|
||||
if treffer is None and pflicht:
|
||||
raise ValueError(f"Muster nicht gefunden: {muster!r}")
|
||||
return treffer
|
||||
|
||||
# ---- Station ---------------------------------------------------------
|
||||
# Nicht auf den Wortlaut der Markenzeile selbst verankert ("SHELL
|
||||
# STATION" vs. "Shell-STATION" - beide an echten Belegen gesehen,
|
||||
# offenbar je nach Pächter/Kassensystem unterschiedlich geschrieben).
|
||||
# Stattdessen einfach die drei Zeilen direkt vor "Obj.-Nr." nehmen - die
|
||||
# Reihenfolge Betreiber/Straße/Ort direkt davor ist auf beiden echten
|
||||
# Belegen gleich.
|
||||
station = suchen(
|
||||
r"(?P<name>.+?)\s*\n\s*(?P<strasse>.+?)\s*\n\s*(?P<ort>.+?)\s*\n\s*Obj\.-Nr\."
|
||||
)
|
||||
station_id = suchen(r"Obj\.-Nr\.:\s*(\d+)").group(1)
|
||||
|
||||
# ---- Beleg-Kopf (Beleg-Nr., Datum/Zeit auf dem Kassenbon) ----------
|
||||
kopf = suchen(
|
||||
r"Beleg-Nr\.\s*(?P<receipt_no>[\d/]+)\s+(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{2})\s+(?P<stunde>\d{2}):(?P<minute>\d{2})"
|
||||
)
|
||||
receipt_no = kopf.group("receipt_no")
|
||||
ts = _iso(kopf.group("tag"), kopf.group("monat"), kopf.group("jahr"),
|
||||
kopf.group("stunde"), kopf.group("minute"))
|
||||
|
||||
# ---- Kraftstoff-Position --------------------------------------------
|
||||
position = suchen(
|
||||
r"\*(?P<article_no>\d+)\s+(?P<produkt>.+?)\s+" + _ZAHL + r"\s*EUR\s*#A\*"
|
||||
)
|
||||
article_no = position.group("article_no")
|
||||
product_name = position.group("produkt").strip()
|
||||
|
||||
zapfsaeule = suchen(
|
||||
r"\*Zp\s*\d+\s+(?P<liter>" + _ZAHL + r")\s*l\s+(?P<preis>" + _ZAHL + r")\s*EUR/l"
|
||||
)
|
||||
liters = _de_zahl(zapfsaeule.group("liter"))
|
||||
list_price_per_l = _de_zahl(zapfsaeule.group("preis"))
|
||||
|
||||
# ---- SmartDeal-Rabatt (optional - nicht jeder Tankvorgang hat einen) --
|
||||
rabatt_treffer = suchen(r"-(" + _ZAHL + r")\s*EUR\s*#A\s*$", pflicht=False)
|
||||
discount = _de_zahl(rabatt_treffer.group(1)) if rabatt_treffer else None
|
||||
rabatt_l_treffer = suchen(r"(" + _ZAHL + r")\s*EUR/Liter", pflicht=False)
|
||||
discount_per_l = _de_zahl(rabatt_l_treffer.group(1)) if rabatt_l_treffer else None
|
||||
|
||||
# ---- Beträge ----------------------------------------------------------
|
||||
fuel_total_eur = _de_zahl(suchen(r"Gesamtbetrag\s+(" + _ZAHL + r")\s*EUR").group(1))
|
||||
steuerzeile = suchen(
|
||||
r"A:" + _ZAHL + r"%\s+(?P<netto>" + _ZAHL + r")\s+(?P<mwst>" + _ZAHL + r")\s+(?P<brutto>" + _ZAHL + r")"
|
||||
)
|
||||
net_eur = _de_zahl(steuerzeile.group("netto"))
|
||||
vat_eur = _de_zahl(steuerzeile.group("mwst"))
|
||||
receipt_total_eur = _de_zahl(steuerzeile.group("brutto"))
|
||||
|
||||
# ---- Zahlungszeitstempel (Mobile Payment) - optional, falls anderes
|
||||
# Zahlungsverfahren auf dem Beleg steht ---------------------------------
|
||||
zahlung = suchen(
|
||||
r"Mobile Payment\s*\n+\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
|
||||
pflicht=False,
|
||||
)
|
||||
ts_payment = (
|
||||
_iso(zahlung.group("tag"), zahlung.group("monat"), zahlung.group("jahr"),
|
||||
zahlung.group("stunde"), zahlung.group("minute"), zahlung.group("sekunde"))
|
||||
if zahlung else None
|
||||
)
|
||||
|
||||
# ---- TSE-Signatur (Seite 2) - optional -------------------------------
|
||||
tse_nr = suchen(r"TSE-Beleg-Nr:\s*(\d+)", pflicht=False)
|
||||
tse_beleg_nr = tse_nr.group(1) if tse_nr else None
|
||||
tse_zeit = suchen(
|
||||
r"Start/Ende:\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
|
||||
pflicht=False,
|
||||
)
|
||||
ts_tse = (
|
||||
_iso(tse_zeit.group("tag"), tse_zeit.group("monat"), tse_zeit.group("jahr"),
|
||||
tse_zeit.group("stunde"), tse_zeit.group("minute"), tse_zeit.group("sekunde"))
|
||||
if tse_zeit else None
|
||||
)
|
||||
|
||||
# receipt_key: minutengenauer Deduplizierungs-Schlüssel (§6.4/§7.7 Regel 3)
|
||||
receipt_key = f"{station_id}_{receipt_no.replace('/', '-')}_{ts[:16]}"
|
||||
|
||||
return {
|
||||
"receipt_key": receipt_key,
|
||||
"receipt_no": receipt_no,
|
||||
"tse_beleg_nr": tse_beleg_nr,
|
||||
"ts": ts,
|
||||
"ts_payment": ts_payment,
|
||||
"ts_tse": ts_tse,
|
||||
"station_id": station_id,
|
||||
# Anzeigename "Marke, Straße, Ort" (siehe _tankstelle). Der Ort steht
|
||||
# auf dem Bon als "85057 Ingolstadt" - die PLZ gehört nur in die
|
||||
# ausführliche station_address darunter, nicht in den Anzeigenamen.
|
||||
"station_name": _tankstelle(
|
||||
_marke(text.splitlines()),
|
||||
station.group("strasse").strip(),
|
||||
re.sub(r"^\d{4,5}\s+", "", station.group("ort").strip()),
|
||||
ersatzname=station.group("name").strip(),
|
||||
),
|
||||
"station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}",
|
||||
"article_no": article_no,
|
||||
"product_name": product_name,
|
||||
"fuel_type": product_name,
|
||||
"liters": liters,
|
||||
"list_price_per_l": list_price_per_l,
|
||||
"discount": discount,
|
||||
"discount_per_l": discount_per_l,
|
||||
"fuel_total_eur": fuel_total_eur,
|
||||
"receipt_total_eur": receipt_total_eur,
|
||||
"net_eur": net_eur,
|
||||
"vat_eur": vat_eur,
|
||||
}
|
||||
|
||||
|
||||
def _parsen_generisch(text):
|
||||
"""Stationsunabhängiger Fallback für Belege, die keinem der oben fest an
|
||||
Shells Beleg-Struktur verankerten Muster entsprechen (_parsen() wirft in
|
||||
dem Fall ValueError - siehe main()). Arbeitet nach demselben Verfahren,
|
||||
das an einem echten Beleg einer bislang unbekannten Tankstelle von Hand
|
||||
nachvollzogen wurde: Adresse suchen, Gesamtbetrag suchen ("Gesamt.../
|
||||
Absolut..."), Literangabe suchen ("Menge"/"Amount" oder eine an "l"/
|
||||
"Liter" hängende Zahl), daraus den tatsächlich bezahlten Preis/Liter
|
||||
errechnen (Gesamtbetrag / Liter). Steht daneben auch ein aufgedruckter
|
||||
Preis/Liter auf dem Beleg und weicht er vom errechneten ab, wurde ein
|
||||
Rabatt gewährt - auf dem Beleg meist als eigener Betrag mit einem Minus
|
||||
davor erkennbar, danach wird zuerst gesucht, sonst aus der Preisdifferenz
|
||||
hergeleitet. Ob ein so gefundener Rabatt als "SmartDeal" behandelt wird,
|
||||
entscheidet einzig station_name (Shell-Logo/-Label im Frontend, siehe
|
||||
frontend_veroeffentlichung.py) - kein Sonderfall hier nötig, die Frontend-
|
||||
Anzeige hängt nur an einem gesetzten discount, nicht an einem eigenen
|
||||
Kennzeichen.
|
||||
|
||||
Liefert deutlich weniger Felder als _parsen() (kein Bon-Kleingedrucktes
|
||||
wie TSE-Nummer/Zahlungszeitstempel) - unproblematisch, denn
|
||||
belegverarbeitung.py liest nur receipt_key als echtes Pflichtfeld,
|
||||
alles andere per .get()."""
|
||||
|
||||
def suchen(muster, flags=re.IGNORECASE | re.MULTILINE):
|
||||
return re.search(muster, text, flags)
|
||||
|
||||
zeilen = [z.rstrip() for z in text.splitlines()]
|
||||
|
||||
# ---- Adresse -----------------------------------------------------------
|
||||
# Die PLZ-Ort-Angabe steht nicht zuverlässig auf einer eigenen Zeile (ein
|
||||
# österreichischer Beleg führt sie als "SHELL TANKSTELLE, 6450 SÖLDEN"
|
||||
# zusammen mit der Filialbezeichnung) und PLZ sind vier- *oder*
|
||||
# fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
|
||||
# verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
|
||||
# Name ist die erste Textzeile darüber.
|
||||
plz_treffer = None
|
||||
plz_zeile = None
|
||||
for i, zeile in enumerate(zeilen[:12]):
|
||||
treffer = re.search(r"\b\d{4,5}\s+([A-Za-zÄÖÜäöüß][^\n]*)$", zeile)
|
||||
if treffer:
|
||||
plz_treffer, plz_zeile = treffer, i
|
||||
break
|
||||
|
||||
ort = None
|
||||
strasse = None
|
||||
betreiber = None
|
||||
station_address = None
|
||||
if plz_treffer is not None:
|
||||
ort = plz_treffer.group(1).strip(" ,.-")
|
||||
station_address = zeilen[plz_zeile].strip()
|
||||
# Die Straße steht entweder vor der PLZ auf derselben Zeile
|
||||
# ("Musterstr. 5, 80331 München") oder auf der Zeile darüber.
|
||||
vor_plz = zeilen[plz_zeile][: plz_treffer.start()].strip(" ,.-")
|
||||
if _ist_strasse(vor_plz):
|
||||
strasse = vor_plz
|
||||
for zeile in reversed(zeilen[:plz_zeile]):
|
||||
if not zeile.strip():
|
||||
continue
|
||||
if strasse is None and _ist_strasse(zeile.strip()):
|
||||
strasse = zeile.strip()
|
||||
continue
|
||||
betreiber = zeile.strip()
|
||||
break
|
||||
if betreiber is None:
|
||||
for zeile in zeilen[:5]:
|
||||
if zeile.strip():
|
||||
betreiber = zeile.strip()
|
||||
break
|
||||
if station_address and strasse and strasse not in station_address:
|
||||
station_address = f"{strasse}, {station_address}"
|
||||
station_name = _tankstelle(_marke(zeilen), strasse, ort, ersatzname=betreiber)
|
||||
|
||||
# ---- Gesamtbetrag ------------------------------------------------------
|
||||
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
|
||||
# einem echten Beleg aufgefallen sind: Bondrucker sperren Überschriften
|
||||
# gern buchstabenweise ("G E S A M T BETRAG EUR: 92,60"), und ein frei
|
||||
# laufendes Muster fand vorher die Spaltenüberschrift "SUMME-EUR" und
|
||||
# las die Artikelnummer der Folgezeile als Betrag. Deshalb: Schlüsselwort
|
||||
# auf der leerzeichenfreien Zeile prüfen, Betrag nur als echte Geldangabe
|
||||
# (mit Nachkommastellen) akzeptieren und Steuerzeilen ausschließen.
|
||||
kandidaten = []
|
||||
for zeile in zeilen:
|
||||
kompakt = re.sub(r"\s+", "", zeile).upper()
|
||||
if not any(wort in kompakt for wort in _GESAMT_WOERTER):
|
||||
continue
|
||||
if any(wort in kompakt for wort in _STEUER_WOERTER):
|
||||
continue
|
||||
betraege = re.findall(_GELD, zeile)
|
||||
if betraege:
|
||||
kandidaten.append(_de_zahl(betraege[-1]))
|
||||
if not kandidaten:
|
||||
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
|
||||
# Mehrere Treffer (z. B. Summenzeile und Kartenzahlungszeile): der
|
||||
# Rechnungsbetrag ist der größte - Teilbeträge sind nie größer.
|
||||
fuel_total_eur = max(kandidaten)
|
||||
|
||||
# ---- Menge in Litern ----------------------------------------------------
|
||||
menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)")
|
||||
if not menge:
|
||||
menge = suchen(r"(" + _ZAHL + r")\s*(?:Liter|Ltr\.?|l)\b")
|
||||
if not menge:
|
||||
raise ValueError("Keine Literangabe gefunden (weder 'Menge'/'Amount' noch 'X l')")
|
||||
liters = _de_zahl(menge.group(1))
|
||||
if not liters:
|
||||
raise ValueError("Literangabe ist 0 oder unlesbar")
|
||||
|
||||
berechneter_preis_l = round(fuel_total_eur / liters, 3)
|
||||
|
||||
# ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt -------
|
||||
# "Preis/L" (ohne ausgeschriebenes "Liter") kommt auf echten Belegen vor,
|
||||
# deshalb ist alles nach dem L optional.
|
||||
aufgedruckt = suchen(
|
||||
r"(?:Preis\s*/?\s*(?:je\s*)?L(?:iter|tr\.?)?|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
|
||||
)
|
||||
list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l
|
||||
|
||||
discount = None
|
||||
discount_per_l = None
|
||||
if aufgedruckt and abs(list_price_per_l - berechneter_preis_l) > 0.005:
|
||||
minus = suchen(r"-\s*(" + _ZAHL + r")\s*(?:€|EUR)")
|
||||
if minus:
|
||||
discount = _de_zahl(minus.group(1))
|
||||
discount_per_l = round(discount / liters, 3)
|
||||
else:
|
||||
discount_per_l = round(list_price_per_l - berechneter_preis_l, 3)
|
||||
discount = round(discount_per_l * liters, 2)
|
||||
|
||||
# ---- Zeitstempel: irgendein Datum+Uhrzeit auf dem Beleg ----------------
|
||||
zeit = suchen(r"(\d{2})\.(\d{2})\.(\d{2,4})\D{0,10}?(\d{2}):(\d{2})")
|
||||
ts = _iso(zeit.group(1), zeit.group(2), zeit.group(3), zeit.group(4), zeit.group(5)) if zeit else None
|
||||
|
||||
# ---- Belegnummer, sonst deterministischer Ersatzschlüssel --------------
|
||||
# Wichtig: kein eingebautes hash() (pro Prozess zufällig gesalzen, siehe
|
||||
# PYTHONHASHSEED) - der Parser läuft für jeden Beleg als eigener Prozess
|
||||
# (Kopfkommentar oben), ein instabiler Schlüssel würde den §7.7-Regel-3-
|
||||
# Dedup-Vergleich in belegverarbeitung.py unbrauchbar machen.
|
||||
beleg_nr = suchen(
|
||||
r"(?:Beleg-?Nr\.?|Bon-?Nr\.?|Rechnungs-?Nr\.?|Receipt\s*No\.?|Trans(?:aktions)?-?Nr\.?)\D{0,5}?([\dA-Za-z/\-]+)"
|
||||
)
|
||||
receipt_no = beleg_nr.group(1) if beleg_nr else None
|
||||
schluessel_basis = "|".join(
|
||||
str(teil) for teil in (receipt_no, ts, station_address, fuel_total_eur, liters) if teil
|
||||
) or text[:80]
|
||||
receipt_key = "generisch_" + hashlib.sha1(schluessel_basis.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
return {
|
||||
"receipt_key": receipt_key,
|
||||
"receipt_no": receipt_no,
|
||||
"tse_beleg_nr": None,
|
||||
"ts": ts,
|
||||
"ts_payment": None,
|
||||
"ts_tse": None,
|
||||
"station_id": None,
|
||||
"station_name": station_name,
|
||||
"station_address": station_address,
|
||||
"article_no": None,
|
||||
"product_name": None,
|
||||
"fuel_type": None,
|
||||
"liters": liters,
|
||||
"list_price_per_l": list_price_per_l,
|
||||
"discount": discount,
|
||||
"discount_per_l": discount_per_l,
|
||||
"fuel_total_eur": fuel_total_eur,
|
||||
"receipt_total_eur": fuel_total_eur,
|
||||
"net_eur": None,
|
||||
"vat_eur": None,
|
||||
}
|
||||
|
||||
|
||||
def beleg_lesen(pdf_pfad):
|
||||
"""Liest einen Tankbeleg und gibt die Felder als dict zurück.
|
||||
|
||||
Der eine Einstiegspunkt für Aufrufer im selben Prozess. Die Integration
|
||||
(belege.py) ruft genau diese Funktion im Executor auf - früher lief der
|
||||
Parser als eigener Unterprozess über main() unten, weil pyscript keine
|
||||
fremden Pakete laden konnte und pypdf deshalb von Hand ins Container-
|
||||
Python installiert werden musste. Als echte Integration steht pypdf über
|
||||
manifest.json bereit; der Unterprozess und der Handinstallations-Schritt
|
||||
entfallen damit ersatzlos.
|
||||
|
||||
Wirft eine Exception, wenn weder das Shell-Format noch der
|
||||
stationsunabhängige Rückfall greifen - der Aufrufer entscheidet, was er
|
||||
dem Nutzer davon zeigt."""
|
||||
text = _text_aus_pdf(pdf_pfad)
|
||||
try:
|
||||
return _parsen(text)
|
||||
except Exception:
|
||||
# Kein Shell-Beleg im bekannten Format - Versuch mit dem
|
||||
# stationsunabhängigen Fallback (_parsen_generisch() oben), bevor
|
||||
# endgültig aufgegeben wird.
|
||||
return _parsen_generisch(text)
|
||||
|
||||
|
||||
def main():
|
||||
"""Kommandozeilen-Aufruf, weiterhin nutzbar zum Nachschauen von Hand:
|
||||
`python3 shell_beleg_parser.py <pfad-zur-pdf>`."""
|
||||
if len(sys.argv) != 2:
|
||||
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
ergebnis = beleg_lesen(sys.argv[1])
|
||||
except Exception as exc:
|
||||
print(f"Beleg konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(json.dumps(ergebnis, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,115 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Audi Dashboard",
|
||||
"description": "Richtet das Panel \"Mein Audi\" ein. Welcher Sensor welche Rolle im Fahrzeug spielt, wird danach im Setup-Menü der App selbst zugeordnet (Einstellungen → Fahrzeug einrichten → Setup)."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"jetzt_aktualisieren": {
|
||||
"name": "Jetzt aktualisieren",
|
||||
"description": "Stößt eine sofortige Neuabfrage beim Fahrzeug an und veröffentlicht den Fahrzeugstatus neu."
|
||||
},
|
||||
"screening_jetzt": {
|
||||
"name": "Screening jetzt",
|
||||
"description": "Versucht sofort, offene Fahrten mit Start- und Endkilometerstand zu vervollständigen."
|
||||
},
|
||||
"backup_jetzt": {
|
||||
"name": "Sicherung jetzt",
|
||||
"description": "Legt sofort eine Sicherung von Profil, Fahrten, Tankvorgängen und Sensor-Zuordnung an."
|
||||
},
|
||||
"profil_schreiben": {
|
||||
"name": "Profil schreiben",
|
||||
"description": "Überschreibt das komplette Fahrzeugprofil.",
|
||||
"fields": {
|
||||
"profil_json": { "name": "Profil", "description": "Das vollständige Fahrzeugprofil als JSON." }
|
||||
}
|
||||
},
|
||||
"entitaeten_schreiben": {
|
||||
"name": "Sensor-Zuordnung schreiben",
|
||||
"description": "Speichert die Zuordnung von Home-Assistant-Entitäten zu den Rollen der App und wendet sie sofort an.",
|
||||
"fields": {
|
||||
"zuordnung_json": { "name": "Zuordnung", "description": "Feldname zu Entity-ID als JSON." }
|
||||
}
|
||||
},
|
||||
"fahrt_manuell_anlegen": {
|
||||
"name": "Fahrt anlegen",
|
||||
"description": "Legt eine Fahrt von Hand an, unabhängig von der automatischen Zündungs-Erkennung.",
|
||||
"fields": {
|
||||
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
|
||||
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
|
||||
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
|
||||
"start_ort": { "name": "Startort", "description": "Freitext." },
|
||||
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
|
||||
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
|
||||
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
|
||||
"distanz": { "name": "Strecke", "description": "Nur nötig, wenn keine Kilometerstände vorliegen." }
|
||||
}
|
||||
},
|
||||
"fahrt_aktualisieren": {
|
||||
"name": "Fahrt bearbeiten",
|
||||
"description": "Ändert eine bestehende Fahrt.",
|
||||
"fields": {
|
||||
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." },
|
||||
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
|
||||
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
|
||||
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
|
||||
"start_ort": { "name": "Startort", "description": "Freitext." },
|
||||
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
|
||||
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
|
||||
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
|
||||
"distanz": { "name": "Strecke", "description": "Von Hand gesetzt." }
|
||||
}
|
||||
},
|
||||
"fahrt_loeschen": {
|
||||
"name": "Fahrt löschen",
|
||||
"description": "Löscht eine Fahrt unwiderruflich.",
|
||||
"fields": {
|
||||
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." }
|
||||
}
|
||||
},
|
||||
"beleg_hochladen": {
|
||||
"name": "Beleg hochladen",
|
||||
"description": "Liest einen Tankbeleg (PDF) und trägt ihn in einen bestehenden Tankvorgang nach oder gibt die Werte an die Oberfläche zurück.",
|
||||
"fields": {
|
||||
"pdf_base64": { "name": "PDF", "description": "Der Beleg als Base64." },
|
||||
"dateiname": { "name": "Dateiname", "description": "Unter diesem Namen wird der Beleg abgelegt." },
|
||||
"tank_id": { "name": "Tankvorgang", "description": "Leer lassen, um nur zu lesen, ohne etwas zu speichern." }
|
||||
}
|
||||
},
|
||||
"tankvorgang_manuell": {
|
||||
"name": "Tankvorgang anlegen",
|
||||
"description": "Erfasst einen Tankvorgang von Hand.",
|
||||
"fields": {
|
||||
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel; leer = jetzt." },
|
||||
"liter": { "name": "Liter", "description": "Getankte Menge." },
|
||||
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
|
||||
"km": { "name": "Kilometerstand", "description": "Leer = aus dem zugeordneten Sensor." },
|
||||
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
|
||||
"station": { "name": "Station", "description": "Name der Tankstelle." },
|
||||
"distanz": { "name": "Gefahrene Strecke", "description": "Leer = aus der Differenz zum letzten Tankvorgang." },
|
||||
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
|
||||
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
|
||||
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
|
||||
}
|
||||
},
|
||||
"tankvorgang_aktualisieren": {
|
||||
"name": "Tankvorgang bearbeiten",
|
||||
"description": "Ändert einen bestehenden Tankvorgang.",
|
||||
"fields": {
|
||||
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." },
|
||||
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel." },
|
||||
"liter": { "name": "Liter", "description": "Getankte Menge." },
|
||||
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
|
||||
"km": { "name": "Kilometerstand", "description": "Kilometerstand beim Tanken." },
|
||||
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
|
||||
"station": { "name": "Station", "description": "Name der Tankstelle." },
|
||||
"distanz": { "name": "Gefahrene Strecke", "description": "Seit dem letzten Tankvorgang." },
|
||||
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
|
||||
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
|
||||
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
|
||||
}
|
||||
},
|
||||
"tankvorgang_loeschen": {
|
||||
"name": "Tankvorgang löschen",
|
||||
"description": "Löscht einen Tankvorgang unwiderruflich.",
|
||||
"fields": {
|
||||
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." }
|
||||
}
|
||||
},
|
||||
"bild_hochladen": {
|
||||
"name": "Fahrzeugbild hochladen",
|
||||
"description": "Legt ein Fahrzeugfoto unter einem der erwarteten Namen ab.",
|
||||
"fields": {
|
||||
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." },
|
||||
"daten_base64": { "name": "Bild", "description": "Die Bilddaten als Base64." }
|
||||
}
|
||||
},
|
||||
"bild_loeschen": {
|
||||
"name": "Fahrzeugbild löschen",
|
||||
"description": "Entfernt ein zuvor hochgeladenes Fahrzeugfoto.",
|
||||
"fields": {
|
||||
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." }
|
||||
}
|
||||
},
|
||||
"reifen_wechseln": {
|
||||
"name": "Reifensatz wechseln",
|
||||
"description": "Schaltet den aktiven Reifensatz um.",
|
||||
"fields": {
|
||||
"satz": { "name": "Satz", "description": "sommer oder winter." }
|
||||
}
|
||||
},
|
||||
"reifen_km_setzen": {
|
||||
"name": "Reifen-Kilometerstand setzen",
|
||||
"description": "Korrigiert den gezählten Kilometerstand eines Reifensatzes von Hand.",
|
||||
"fields": {
|
||||
"satz": { "name": "Satz", "description": "sommer oder winter." },
|
||||
"km": { "name": "Kilometer", "description": "Der neue Zählerstand." }
|
||||
}
|
||||
},
|
||||
"backup_wiederherstellen": {
|
||||
"name": "Sicherung wiederherstellen",
|
||||
"description": "Überschreibt Profil, Fahrten, Tankvorgänge und Sensor-Zuordnung mit einer zuvor exportierten Sicherung.",
|
||||
"fields": {
|
||||
"profil_json": { "name": "Profil", "description": "Fahrzeugprofil als JSON." },
|
||||
"fahrten_json": { "name": "Fahrten", "description": "Fahrten als JSON-Liste." },
|
||||
"tankvorgaenge_json": { "name": "Tankvorgänge", "description": "Tankvorgänge als JSON-Liste." },
|
||||
"entitaeten_json": { "name": "Sensor-Zuordnung", "description": "Optional; fehlt sie, bleibt die aktuelle Zuordnung unangetastet." }
|
||||
}
|
||||
},
|
||||
"historie_importieren": {
|
||||
"name": "Daten aus dem Verlauf importieren",
|
||||
"description": "Leitet Fahrten, Tankvorgänge und Batteriewerte rückwirkend aus dem aufgezeichneten Verlauf ab.",
|
||||
"fields": {
|
||||
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
|
||||
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Automatische Tankerkennung über den Füllstandssensor.
|
||||
|
||||
Beobachtung: der Füllstand (in Prozent) springt beim Fahren nie nach oben, er
|
||||
sinkt nur - jeder Anstieg ist also ein Tankvorgang. Schwelle nach Vorgabe: ab
|
||||
+5 Liter ODER +9 Prozentpunkten Anstieg gilt als nachgetankt. Die
|
||||
5-Liter-Vorgabe wird über fahrzeug.tankvolumen_liter aus dem Fahrzeugprofil in
|
||||
Prozentpunkte umgerechnet, damit beide Angaben auf derselben Einheit
|
||||
verglichen werden können; es gilt jeweils die empfindlichere (kleinere) der
|
||||
beiden Schwellen.
|
||||
|
||||
Tiefststand-Tracking statt einfachem Vorher/Nachher-Vergleich: die Datenquelle
|
||||
liefert einen Tankvorgang oft in mehreren kleinen Schritten (40% -> 45% ->
|
||||
60%), von denen keiner allein die Schwelle überschreiten muss. Deshalb wird
|
||||
der zuletzt bekannte Tiefststand gespeichert und der Anstieg dagegen gemessen.
|
||||
Nach Anlage eines Tankvorgangs wird der Tiefststand auf den aktuellen Wert
|
||||
zurückgesetzt, damit derselbe Vorgang nicht mehrfach Datensätze erzeugt.
|
||||
|
||||
Der so angelegte Tankvorgang ist bewusst ein unvollständiger Platzhalter
|
||||
(status "unvollständig", ohne Liter/Kosten/Station): erwartet wird ein
|
||||
automatisch erkannter Tankvorgang mit Zeitstempel, Kilometerstand und
|
||||
gefahrener Distanz, den der Nutzer später per Beleg-Nachtrag vervollständigt.
|
||||
|
||||
Auch hier gilt die Härtung aus fahrterkennung.py: der Tiefststand lebt nicht
|
||||
mehr nur im Arbeitsspeicher, sondern im Store des Koordinators und überlebt
|
||||
damit einen Neustart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .ablage import neue_id
|
||||
from .veroeffentlichung import zustand_oder_none
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .koordinator import Koordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
LITER_SCHWELLE = 5
|
||||
PROZENT_SCHWELLE = 9
|
||||
STANDARD_TANKVOLUMEN_LITER = 58 # Rückfall, falls im Profil nicht hinterlegt
|
||||
|
||||
|
||||
def _als_zahl(wert: object) -> float | None:
|
||||
try:
|
||||
return float(wert) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def schwelle_prozent(profil: dict | None) -> float:
|
||||
"""Die empfindlichere der beiden Schwellen, in Prozentpunkten."""
|
||||
if profil is None:
|
||||
tankvolumen = STANDARD_TANKVOLUMEN_LITER
|
||||
else:
|
||||
tankvolumen = (
|
||||
profil.get("fahrzeug", {}).get("tankvolumen_liter") or STANDARD_TANKVOLUMEN_LITER
|
||||
)
|
||||
return min((LITER_SCHWELLE / tankvolumen) * 100, PROZENT_SCHWELLE)
|
||||
|
||||
|
||||
def leerer_tankvorgang(ts: str, quelle: str) -> dict:
|
||||
"""Das vollständige Tankvorgang-Schema, alles Unbekannte auf None."""
|
||||
return {
|
||||
"tank_id": neue_id("f"),
|
||||
"receipt_key": None,
|
||||
"ts": ts,
|
||||
"liters": None,
|
||||
"fuel_total_eur": None,
|
||||
"price_per_l": None,
|
||||
"discount": None,
|
||||
"station_name": None,
|
||||
"odometer_km": None,
|
||||
"distance_km": None,
|
||||
"fuel_type": None,
|
||||
"source": quelle,
|
||||
"status": "unvollständig",
|
||||
"receipt_file": None,
|
||||
"edited_fields": [],
|
||||
}
|
||||
|
||||
|
||||
async def fuellstand_geaendert(k: Koordinator, neu: str | None) -> None:
|
||||
aktuell = _als_zahl(neu)
|
||||
if aktuell is None:
|
||||
return
|
||||
|
||||
tiefststand = k.tiefststand_pct
|
||||
if tiefststand is None or aktuell <= tiefststand:
|
||||
await k.tiefststand_setzen(aktuell)
|
||||
return
|
||||
|
||||
anstieg = aktuell - tiefststand
|
||||
if anstieg < schwelle_prozent(await k.ablage.profil_lesen()):
|
||||
return
|
||||
|
||||
await _automatisch_anlegen(k, anstieg)
|
||||
await k.tiefststand_setzen(aktuell)
|
||||
|
||||
|
||||
async def _automatisch_anlegen(k: Koordinator, anstieg_pct: float) -> None:
|
||||
odometer_km = _als_zahl(zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR))
|
||||
tankvorgang = leerer_tankvorgang(
|
||||
datetime.datetime.now(datetime.UTC).isoformat(), "auto"
|
||||
)
|
||||
tankvorgang["odometer_km"] = odometer_km
|
||||
tankvorgang["distance_km"] = await k.ablage.distanz_seit_letzter_tankung(odometer_km)
|
||||
|
||||
await k.ablage.tankvorgang_anhaengen(tankvorgang)
|
||||
await k.tankvorgaenge_veroeffentlichen()
|
||||
_LOGGER.info(
|
||||
"Tankvorgang %s automatisch erkannt (Füllstandsanstieg %s Prozentpunkte)",
|
||||
tankvorgang["tank_id"], round(anstieg_pct, 1),
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Audi Dashboard",
|
||||
"description": "Richtet das Panel \"Mein Audi\" ein. Welcher Sensor welche Rolle im Fahrzeug spielt, wird danach im Setup-Menü der App selbst zugeordnet (Einstellungen → Fahrzeug einrichten → Setup)."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "Audi Dashboard ist bereits eingerichtet."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"jetzt_aktualisieren": {
|
||||
"name": "Jetzt aktualisieren",
|
||||
"description": "Stößt eine sofortige Neuabfrage beim Fahrzeug an und veröffentlicht den Fahrzeugstatus neu."
|
||||
},
|
||||
"screening_jetzt": {
|
||||
"name": "Screening jetzt",
|
||||
"description": "Versucht sofort, offene Fahrten mit Start- und Endkilometerstand zu vervollständigen."
|
||||
},
|
||||
"backup_jetzt": {
|
||||
"name": "Sicherung jetzt",
|
||||
"description": "Legt sofort eine Sicherung von Profil, Fahrten, Tankvorgängen und Sensor-Zuordnung an."
|
||||
},
|
||||
"profil_schreiben": {
|
||||
"name": "Profil schreiben",
|
||||
"description": "Überschreibt das komplette Fahrzeugprofil.",
|
||||
"fields": {
|
||||
"profil_json": { "name": "Profil", "description": "Das vollständige Fahrzeugprofil als JSON." }
|
||||
}
|
||||
},
|
||||
"entitaeten_schreiben": {
|
||||
"name": "Sensor-Zuordnung schreiben",
|
||||
"description": "Speichert die Zuordnung von Home-Assistant-Entitäten zu den Rollen der App und wendet sie sofort an.",
|
||||
"fields": {
|
||||
"zuordnung_json": { "name": "Zuordnung", "description": "Feldname zu Entity-ID als JSON." }
|
||||
}
|
||||
},
|
||||
"fahrt_manuell_anlegen": {
|
||||
"name": "Fahrt anlegen",
|
||||
"description": "Legt eine Fahrt von Hand an, unabhängig von der automatischen Zündungs-Erkennung.",
|
||||
"fields": {
|
||||
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
|
||||
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
|
||||
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
|
||||
"start_ort": { "name": "Startort", "description": "Freitext." },
|
||||
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
|
||||
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
|
||||
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Leer lassen, wenn das Screening ihn ergänzen soll." },
|
||||
"distanz": { "name": "Strecke", "description": "Nur nötig, wenn keine Kilometerstände vorliegen." }
|
||||
}
|
||||
},
|
||||
"fahrt_aktualisieren": {
|
||||
"name": "Fahrt bearbeiten",
|
||||
"description": "Ändert eine bestehende Fahrt.",
|
||||
"fields": {
|
||||
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." },
|
||||
"ts_start": { "name": "Beginn", "description": "Zeitpunkt des Fahrtbeginns (ISO)." },
|
||||
"ts_end": { "name": "Ende", "description": "Zeitpunkt des Fahrtendes (ISO)." },
|
||||
"art": { "name": "Art", "description": "privat oder arbeitsweg." },
|
||||
"start_ort": { "name": "Startort", "description": "Freitext." },
|
||||
"ziel_ort": { "name": "Zielort", "description": "Freitext." },
|
||||
"odo_start": { "name": "Kilometerstand bei Beginn", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
|
||||
"odo_end": { "name": "Kilometerstand bei Ende", "description": "Von Hand gesetzt, wird vom Screening nicht mehr überschrieben." },
|
||||
"distanz": { "name": "Strecke", "description": "Von Hand gesetzt." }
|
||||
}
|
||||
},
|
||||
"fahrt_loeschen": {
|
||||
"name": "Fahrt löschen",
|
||||
"description": "Löscht eine Fahrt unwiderruflich.",
|
||||
"fields": {
|
||||
"trip_id": { "name": "Fahrt", "description": "trip_id der Fahrt." }
|
||||
}
|
||||
},
|
||||
"beleg_hochladen": {
|
||||
"name": "Beleg hochladen",
|
||||
"description": "Liest einen Tankbeleg (PDF) und trägt ihn in einen bestehenden Tankvorgang nach oder gibt die Werte an die Oberfläche zurück.",
|
||||
"fields": {
|
||||
"pdf_base64": { "name": "PDF", "description": "Der Beleg als Base64." },
|
||||
"dateiname": { "name": "Dateiname", "description": "Unter diesem Namen wird der Beleg abgelegt." },
|
||||
"tank_id": { "name": "Tankvorgang", "description": "Leer lassen, um nur zu lesen, ohne etwas zu speichern." }
|
||||
}
|
||||
},
|
||||
"tankvorgang_manuell": {
|
||||
"name": "Tankvorgang anlegen",
|
||||
"description": "Erfasst einen Tankvorgang von Hand.",
|
||||
"fields": {
|
||||
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel; leer = jetzt." },
|
||||
"liter": { "name": "Liter", "description": "Getankte Menge." },
|
||||
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
|
||||
"km": { "name": "Kilometerstand", "description": "Leer = aus dem zugeordneten Sensor." },
|
||||
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
|
||||
"station": { "name": "Station", "description": "Name der Tankstelle." },
|
||||
"distanz": { "name": "Gefahrene Strecke", "description": "Leer = aus der Differenz zum letzten Tankvorgang." },
|
||||
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
|
||||
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
|
||||
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
|
||||
}
|
||||
},
|
||||
"tankvorgang_aktualisieren": {
|
||||
"name": "Tankvorgang bearbeiten",
|
||||
"description": "Ändert einen bestehenden Tankvorgang.",
|
||||
"fields": {
|
||||
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." },
|
||||
"ts": { "name": "Zeitpunkt", "description": "ISO-Zeitstempel." },
|
||||
"liter": { "name": "Liter", "description": "Getankte Menge." },
|
||||
"kosten": { "name": "Betrag", "description": "Der real bezahlte, bereits rabattierte Betrag." },
|
||||
"km": { "name": "Kilometerstand", "description": "Kilometerstand beim Tanken." },
|
||||
"ersparnis": { "name": "Ersparnis", "description": "Rabatt in Euro." },
|
||||
"station": { "name": "Station", "description": "Name der Tankstelle." },
|
||||
"distanz": { "name": "Gefahrene Strecke", "description": "Seit dem letzten Tankvorgang." },
|
||||
"kraftstoff": { "name": "Kraftstoff", "description": "Sorte." },
|
||||
"receipt_key": { "name": "Beleg-Schlüssel", "description": "Aus einem zuvor gelesenen Beleg." },
|
||||
"receipt_file": { "name": "Belegdatei", "description": "Pfad zur abgelegten PDF." }
|
||||
}
|
||||
},
|
||||
"tankvorgang_loeschen": {
|
||||
"name": "Tankvorgang löschen",
|
||||
"description": "Löscht einen Tankvorgang unwiderruflich.",
|
||||
"fields": {
|
||||
"tank_id": { "name": "Tankvorgang", "description": "tank_id des Vorgangs." }
|
||||
}
|
||||
},
|
||||
"bild_hochladen": {
|
||||
"name": "Fahrzeugbild hochladen",
|
||||
"description": "Legt ein Fahrzeugfoto unter einem der erwarteten Namen ab.",
|
||||
"fields": {
|
||||
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." },
|
||||
"daten_base64": { "name": "Bild", "description": "Die Bilddaten als Base64." }
|
||||
}
|
||||
},
|
||||
"bild_loeschen": {
|
||||
"name": "Fahrzeugbild löschen",
|
||||
"description": "Entfernt ein zuvor hochgeladenes Fahrzeugfoto.",
|
||||
"fields": {
|
||||
"dateiname": { "name": "Dateiname", "description": "Einer der von der App erwarteten Namen." }
|
||||
}
|
||||
},
|
||||
"reifen_wechseln": {
|
||||
"name": "Reifensatz wechseln",
|
||||
"description": "Schaltet den aktiven Reifensatz um.",
|
||||
"fields": {
|
||||
"satz": { "name": "Satz", "description": "sommer oder winter." }
|
||||
}
|
||||
},
|
||||
"reifen_km_setzen": {
|
||||
"name": "Reifen-Kilometerstand setzen",
|
||||
"description": "Korrigiert den gezählten Kilometerstand eines Reifensatzes von Hand.",
|
||||
"fields": {
|
||||
"satz": { "name": "Satz", "description": "sommer oder winter." },
|
||||
"km": { "name": "Kilometer", "description": "Der neue Zählerstand." }
|
||||
}
|
||||
},
|
||||
"backup_wiederherstellen": {
|
||||
"name": "Sicherung wiederherstellen",
|
||||
"description": "Überschreibt Profil, Fahrten, Tankvorgänge und Sensor-Zuordnung mit einer zuvor exportierten Sicherung.",
|
||||
"fields": {
|
||||
"profil_json": { "name": "Profil", "description": "Fahrzeugprofil als JSON." },
|
||||
"fahrten_json": { "name": "Fahrten", "description": "Fahrten als JSON-Liste." },
|
||||
"tankvorgaenge_json": { "name": "Tankvorgänge", "description": "Tankvorgänge als JSON-Liste." },
|
||||
"entitaeten_json": { "name": "Sensor-Zuordnung", "description": "Optional; fehlt sie, bleibt die aktuelle Zuordnung unangetastet." }
|
||||
}
|
||||
},
|
||||
"historie_importieren": {
|
||||
"name": "Daten aus dem Verlauf importieren",
|
||||
"description": "Leitet Fahrten, Tankvorgänge und Batteriewerte rückwirkend aus dem aufgezeichneten Verlauf ab.",
|
||||
"fields": {
|
||||
"start": { "name": "Von", "description": "Beginn des Zeitraums (ISO, Ortszeit erlaubt)." },
|
||||
"ende": { "name": "Bis", "description": "Ende des Zeitraums (ISO, Ortszeit erlaubt)." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Lesezugriff auf den aufgezeichneten Zustandsverlauf (recorder).
|
||||
|
||||
Über Home Assistants eigene recorder-API
|
||||
(`homeassistant.components.recorder.history.get_significant_states`), nicht
|
||||
über direkten SQL-Zugriff auf home-assistant_v2.db: das Datenbankschema des
|
||||
recorders ist HA-intern und ändert sich zwischen Versionen, die Funktion
|
||||
dagegen ist die von HA selbst benutzte und stabile Schnittstelle.
|
||||
|
||||
`significant_changes_only=False` ist wichtig: bei numerischen Sensoren
|
||||
(Kilometerstand, Tankfüllstand) liefert der Standardmodus nur "auffällige"
|
||||
Änderungen und verschluckt genau die kleinen Schritte, aus denen sich
|
||||
Fahrstrecke und Tankvorgänge zusammensetzen.
|
||||
|
||||
WAS HIER GEGENÜBER DER PYSCRIPT-FASSUNG WEGFÄLLT: das Fahrtabschluss-
|
||||
Screening las den Verlauf früher über die HTTP-REST-API (/api/history/period)
|
||||
und brauchte dafür ein langlebiges Zugriffstoken in
|
||||
audi_dashboard/ha_token.txt - ein eigener Installationsschritt, der bei jeder
|
||||
Neuinstallation vergessen werden konnte und dessen Fehlen sich nur als
|
||||
Warnung im Protokoll zeigte (Fahrten blieben dann stumm ohne Strecke). Als
|
||||
echte Integration liest die App den Verlauf direkt; Token und
|
||||
Installationsschritt entfallen ersatzlos.
|
||||
|
||||
Die recorder-Abfrage läuft über den Executor: ein Datenbankzugriff hat im
|
||||
Event-Loop nichts verloren.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from homeassistant.components.recorder import get_instance, history
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
Verlaufspunkt = tuple[datetime.datetime, str]
|
||||
|
||||
|
||||
def _rohverlauf(
|
||||
hass: HomeAssistant, entity_id: str, start: datetime.datetime, ende: datetime.datetime
|
||||
) -> list[Verlaufspunkt]:
|
||||
roh = history.get_significant_states(
|
||||
hass, start, ende, [entity_id], None, True, False
|
||||
)
|
||||
ergebnis: list[Verlaufspunkt] = []
|
||||
for zustand in roh.get(entity_id) or []:
|
||||
# "unknown"/"unavailable" bedeuten "keine Meldung", nicht "Wert 0" -
|
||||
# würden sie durchgereicht, ergäbe ein Ausfall der Datenquelle eine
|
||||
# Fahrt mit absurder Kilometerdifferenz.
|
||||
if zustand.state in ("unknown", "unavailable", None, ""):
|
||||
continue
|
||||
ergebnis.append((zustand.last_updated, zustand.state))
|
||||
ergebnis.sort(key=lambda p: p[0])
|
||||
return ergebnis
|
||||
|
||||
|
||||
async def verlauf_lesen(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str | None,
|
||||
start: datetime.datetime,
|
||||
ende: datetime.datetime,
|
||||
) -> list[Verlaufspunkt]:
|
||||
"""Zustandsverlauf einer Entität als aufsteigende Liste von
|
||||
(Zeitpunkt, Rohwert). Leere Liste, wenn die Entität nicht zugeordnet ist,
|
||||
im Zeitraum nichts vorliegt oder der recorder nicht erreichbar ist."""
|
||||
if not entity_id:
|
||||
return []
|
||||
try:
|
||||
return await get_instance(hass).async_add_executor_job(
|
||||
_rohverlauf, hass, entity_id, start, ende
|
||||
)
|
||||
except Exception: # noqa: BLE001 - der Verlauf ist Beiwerk, nie der Kern
|
||||
# Ohne Verlauf bleibt eine Fahrt ohne Strecke bzw. der Import leer -
|
||||
# beides ist verkraftbar. Den aufrufenden Ablauf deswegen abzubrechen
|
||||
# wäre es nicht.
|
||||
_LOGGER.warning("Verlauf von %s nicht lesbar", entity_id, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def zahl(wert: object) -> float | None:
|
||||
try:
|
||||
return float(wert) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def wert_bei(
|
||||
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
|
||||
) -> float | None:
|
||||
"""Der zuletzt vor `zeitpunkt` gemeldete Zahlenwert, sonst der erste
|
||||
danach, sonst None.
|
||||
|
||||
"Zuletzt davor" ist die richtige Wahl für einen Zählerstand: der
|
||||
Kilometerstand bei Fahrtbeginn ist der, der zuletzt gemeldet wurde, nicht
|
||||
der nächste (der schon Strecke enthält)."""
|
||||
davor = None
|
||||
for ts, wert in verlauf:
|
||||
gezahlt = zahl(wert)
|
||||
if gezahlt is None:
|
||||
continue
|
||||
if ts <= zeitpunkt:
|
||||
davor = gezahlt
|
||||
else:
|
||||
return davor if davor is not None else gezahlt
|
||||
return davor
|
||||
|
||||
|
||||
def naechster_wert(
|
||||
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt]
|
||||
) -> float | None:
|
||||
"""Der Wert, dessen Zeitstempel am nächsten an `zielzeit` liegt.
|
||||
|
||||
Anders als wert_bei() ist hier bewusst egal, ob der Wert davor oder danach
|
||||
liegt: der Kilometerstand kommt laut Datenquelle nicht sicher mit
|
||||
Fahrtende, sondern teils erst mit Beginn der nächsten Fahrt."""
|
||||
bester_wert = None
|
||||
beste_diff = None
|
||||
for ts, wert in verlauf:
|
||||
gezahlt = zahl(wert)
|
||||
if gezahlt is None:
|
||||
continue
|
||||
diff = abs((ts - zielzeit).total_seconds())
|
||||
if beste_diff is None or diff < beste_diff:
|
||||
bester_wert = gezahlt
|
||||
beste_diff = diff
|
||||
return bester_wert
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Baut die Nutzlasten, die als Entitäts-Attribut `daten` an die Oberfläche
|
||||
gehen.
|
||||
|
||||
Das Frontend liest die Werte über das ganz normale hass.states statt über
|
||||
einen Dienst mit Rückgabewert: Zustände lesen ist ein seit Jahren stabiler,
|
||||
einfacher Weg, während das Verhalten response-fähiger Dienste aus Sicht des
|
||||
Browsers nicht durchgängig dokumentiert ist.
|
||||
|
||||
Hier stehen nur die reinen Rechenfunktionen - wer sie wann veröffentlicht,
|
||||
entscheidet der Koordinator (koordinator.py).
|
||||
|
||||
GRÖSSENGRENZE, DIE JETZT KEINE MEHR IST: Zustands-Attribute sind in Home
|
||||
Assistant nicht für beliebig große Datenmengen gedacht; ab ~16 KB warnt der
|
||||
Recorder und schreibt sie trotzdem in die Datenbank. Die Fahrten- und
|
||||
Tankvorgänge-Archive überschreiten das nach ein paar Jahren zwangsläufig. In
|
||||
der pyscript-Fassung war das eine bekannte, offene Flanke. Diese Integration
|
||||
markiert das Attribut `daten` als nicht aufzuzeichnen (siehe
|
||||
_unrecorded_attributes in sensor.py) - die Nutzlast geht weiterhin an jede
|
||||
Oberfläche, landet aber nie in der Datenbank.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .einstellungen import POSITIONEN, Sensorzuordnung
|
||||
|
||||
|
||||
def zustand_oder_none(hass: HomeAssistant, entity_id: str | None) -> str | None:
|
||||
"""Sicherer Zustandszugriff.
|
||||
|
||||
Eine nicht (mehr) existierende Entität ist hier der Normalfall, solange
|
||||
Rollen im Setup-Menü unbelegt sind, und bleibt auch danach relevant: fällt
|
||||
eine Datenquelle aus, soll die Oberfläche das zeigen, nicht an einem
|
||||
Fehler hängen bleiben."""
|
||||
if not entity_id:
|
||||
return None
|
||||
zustand = hass.states.get(entity_id)
|
||||
if zustand is None or zustand.state in (None, "", "unknown", "unavailable"):
|
||||
return None
|
||||
return zustand.state
|
||||
|
||||
|
||||
def _zu_zahl(wert: object) -> float | None:
|
||||
try:
|
||||
return float(wert) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _abs_zahl(wert: object) -> float | None:
|
||||
zahl = _zu_zahl(wert)
|
||||
return None if zahl is None else abs(zahl)
|
||||
|
||||
|
||||
def _zu_bool(wert: object) -> bool | None:
|
||||
"""binary_sensor-Zustand als echtes True/False, None bei fehlender Meldung.
|
||||
|
||||
None ist hier ausdrücklich kein "nein": ohne zugeordneten Sensor weiß die
|
||||
App schlicht nicht, ob gefahren wird - und muss das anzeigen dürfen, statt
|
||||
"steht" zu behaupten."""
|
||||
if wert is None:
|
||||
return None
|
||||
return str(wert).lower() in ("on", "true", "1", "open", "yes")
|
||||
|
||||
|
||||
def _standort(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
|
||||
"""Live-GPS-Position - liest STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR, zwei
|
||||
eigene sensor-Entities für Breiten-/Längengrad (flespi liefert Koordinaten
|
||||
so, nicht als Attribute einer einzelnen device_tracker-Entity).
|
||||
|
||||
Fehlt eine der beiden Entity-IDs oder ist der Zustand (noch) nicht
|
||||
verfügbar, liefert diese Funktion durchgehend None statt eines geratenen
|
||||
Werts."""
|
||||
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
|
||||
|
||||
lat_id, lon_id = werte.STANDORT_LAT_SENSOR, werte.STANDORT_LON_SENSOR
|
||||
if not lat_id or not lon_id:
|
||||
return leer
|
||||
lat_zustand = hass.states.get(lat_id)
|
||||
lon_zustand = hass.states.get(lon_id)
|
||||
if lat_zustand is None or lon_zustand is None:
|
||||
return leer
|
||||
lat = _zu_zahl(zustand_oder_none(hass, lat_id))
|
||||
lon = _zu_zahl(zustand_oder_none(hass, lon_id))
|
||||
if lat is None or lon is None:
|
||||
return leer
|
||||
return {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"genauigkeit_m": None,
|
||||
"zeit": lat_zustand.last_updated.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _sicherheitscheck(hass: HomeAssistant, werte: Sensorzuordnung) -> list[dict]:
|
||||
"""Die einzeln geprüften Punkte hinter "Sicher abgestellt", fürs Frontend
|
||||
(Klick auf den Status öffnet diese Liste mit einem grünen/roten/grauen
|
||||
Punkt je Zeile).
|
||||
|
||||
"ok" ist None, wenn der Sensor fehlt oder nicht verfügbar ist - genau
|
||||
daraus leitet sich auch die zusammengefasste Kennzahl unten ab, damit
|
||||
beide nie auseinanderlaufen können."""
|
||||
eintraege: list[dict] = []
|
||||
for pos, sensor in zip(POSITIONEN, werte.TUER_SENSOREN):
|
||||
w = zustand_oder_none(hass, sensor)
|
||||
eintraege.append({"label": f"Tür {pos}", "ok": None if w is None else w == "off"})
|
||||
for pos, sensor in zip(POSITIONEN, werte.FENSTER_SENSOREN):
|
||||
w = zustand_oder_none(hass, sensor)
|
||||
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
|
||||
for label, sensor in (
|
||||
("Heckklappe", werte.HECKKLAPPE_SENSOR),
|
||||
("Motorhaube", werte.HAUBE_SENSOR),
|
||||
):
|
||||
w = zustand_oder_none(hass, sensor)
|
||||
eintraege.append({"label": label, "ok": None if w is None else w == "off"})
|
||||
return eintraege
|
||||
|
||||
|
||||
def fahrzeugstatus(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
|
||||
"""Bündelt die live aus Home Assistant gelesenen Fahrzeugwerte
|
||||
frontend-freundlich, damit die Oberfläche keine Entity-IDs kennen muss."""
|
||||
sicherheitscheck = _sicherheitscheck(hass, werte)
|
||||
# "Sicher abgestellt": erst wenn WIRKLICH jeder einzeln geprüfte Punkt
|
||||
# zu/verriegelt ist, gilt das Fahrzeug als gesichert; fehlt auch nur eine
|
||||
# Meldung, ist der Status unbekannt statt geraten.
|
||||
if any(e["ok"] is None for e in sicherheitscheck):
|
||||
gesichert = None
|
||||
else:
|
||||
gesichert = all(e["ok"] for e in sicherheitscheck)
|
||||
|
||||
standort = _standort(hass, werte)
|
||||
|
||||
return {
|
||||
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),
|
||||
"tankprozent": _zu_zahl(zustand_oder_none(hass, werte.TANK_SENSOR)),
|
||||
"reichweite_km": _zu_zahl(zustand_oder_none(hass, werte.RANGE_SENSOR)),
|
||||
"batteriespannung": _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR)),
|
||||
"gesichert": gesichert,
|
||||
"sicherheitscheck": sicherheitscheck,
|
||||
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die
|
||||
# auch die Fahrterkennung als maßgebliches Signal für Fahrtbeginn und
|
||||
# -ende nimmt - damit sagen Anzeige und Erfassung zwangsläufig
|
||||
# dasselbe. Vorher wurde dieser Zustand im Frontend aus dem Status der
|
||||
# Fahrten abgeleitet ("offen" = fährt). Das war falsch: "offen" heißt
|
||||
# unvollständige Daten, nicht "unterwegs" - eine Fahrt ohne
|
||||
# Kilometerstand blieb dauerhaft "offen" und das Fahrzeug damit
|
||||
# dauerhaft "fahrend". None bedeutet: kein Zündungssensor zugeordnet.
|
||||
"zuendung": _zu_bool(zustand_oder_none(hass, werte.ZUENDUNG_SENSOR)),
|
||||
"standort_lat": standort["lat"],
|
||||
"standort_lon": standort["lon"],
|
||||
"standort_genauigkeit_m": standort["genauigkeit_m"],
|
||||
"standort_zeit": standort["zeit"],
|
||||
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die
|
||||
# App-eigene Servicebuch-Berechnung) - die Streckensensoren liefern
|
||||
# negative Restkilometer-Werte, hier deshalb der Betrag.
|
||||
"oelwechsel_faellig_ts": zustand_oder_none(hass, werte.NAECHSTER_OELWECHSEL_SENSOR),
|
||||
"oelwechsel_faellig_km": _abs_zahl(zustand_oder_none(hass, werte.OELWECHSEL_STRECKE_SENSOR)),
|
||||
"inspektion_faellig_ts": zustand_oder_none(hass, werte.NAECHSTE_INSPEKTION_SENSOR),
|
||||
"inspektion_faellig_km": _abs_zahl(zustand_oder_none(hass, werte.INSPEKTION_STRECKE_SENSOR)),
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Die Integration legt daraus bei der ersten Einrichtung /config/audi_dashboard/fahrzeugprofil.json an und fasst sie danach nie wieder an. Die Platzhalter (VIN, Kennzeichen, Erstzulassung, HU-Termin, Versicherung, Werkstatt, Servicebuch) werden in der App unter Einstellungen -> Fahrzeug einrichten ersetzt, nicht hier.",
|
||||
|
||||
"fahrzeug": {
|
||||
"fin": "",
|
||||
"kennzeichen": "",
|
||||
"titel": "RS 4 Avant competition",
|
||||
"zusatz": "Avant competition",
|
||||
"untertitel": "quattro · 331 kW",
|
||||
"details": "2.9 TFSI quattro competition · 331 kW · 450 PS",
|
||||
"erstzulassung": "",
|
||||
"hauptuntersuchung_faellig": "",
|
||||
"tankvolumen_liter": 58,
|
||||
"ausfuehrung": "competition"
|
||||
},
|
||||
|
||||
"einstellungen": {
|
||||
"nacht_von": "19:00",
|
||||
"nacht_bis": "06:00",
|
||||
"fahrten_pausenzeit_min": 15,
|
||||
"uebersichtsbild": "Frontansicht",
|
||||
"kraftstoffanbieter": "Shell",
|
||||
"smartdeal": { "aktiv": false, "laeuft_ab": "" },
|
||||
"oelwechsel_intervall": {
|
||||
"modus": "hersteller",
|
||||
"km": 15000,
|
||||
"monate": 12,
|
||||
"hersteller_km": 30000,
|
||||
"hersteller_monate": 24
|
||||
},
|
||||
"backup_intervall": "aus",
|
||||
"letztes_backup": null
|
||||
},
|
||||
|
||||
"versicherung": {
|
||||
"gesellschaft": "",
|
||||
"vertrag": "",
|
||||
"nummer": "",
|
||||
"service": "",
|
||||
"notfall": "",
|
||||
"beitrag": 0,
|
||||
"steueranteil": 0,
|
||||
"steuersatz": "19 %",
|
||||
"gueltigAb": "",
|
||||
"zahlweise": "jährlich",
|
||||
"teile": [
|
||||
{ "bez": "Haftpflicht", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
|
||||
{ "bez": "Vollkasko inklusive Teilkasko", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
|
||||
{ "bez": "Schutzbrief Mobilität", "betrag": 0, "sf": null, "sfAlt": null, "satz": null, "satzAlt": null }
|
||||
],
|
||||
"selbst": [["Vollkasko", "0 €"], ["Teilkasko", "0 €"]],
|
||||
"schutzbrief": {
|
||||
"stand": "",
|
||||
"gilt": "",
|
||||
"versichert": "",
|
||||
"ausloeser": [],
|
||||
"entweder": [],
|
||||
"gruppen": [],
|
||||
"nicht": [],
|
||||
"pflichten": [],
|
||||
"rang": ""
|
||||
},
|
||||
"leistungen": []
|
||||
},
|
||||
|
||||
"steuer": {
|
||||
"betrag": 0,
|
||||
"zeitraum": "jährlich",
|
||||
"faellig": null,
|
||||
"grundlage": "Hubraum und CO₂-Ausstoß",
|
||||
"lastschrift": true,
|
||||
"finanzamt": ""
|
||||
},
|
||||
|
||||
"reifen": {
|
||||
"aktiv": "sommer",
|
||||
"anzugsmoment_nm": 120,
|
||||
"wechsel": { "datum": "", "vereinbart": false },
|
||||
"referenz_odo_km": null,
|
||||
"saetze": {
|
||||
"sommer": {
|
||||
"km": 0,
|
||||
"bild": "/local/bilder/rad-sommer.webp",
|
||||
"marke": "",
|
||||
"modell": "",
|
||||
"dot": "",
|
||||
"mass": "275/30 R20 97Y XL",
|
||||
"druck_vorne": "2,5",
|
||||
"druck_hinten": "2,4",
|
||||
"kommentar": ""
|
||||
},
|
||||
"winter": {
|
||||
"km": 0,
|
||||
"bild": "/local/bilder/rad-winter.webp",
|
||||
"marke": "",
|
||||
"modell": "",
|
||||
"dot": "",
|
||||
"mass": "265/35 R19",
|
||||
"druck_vorne": "2,6",
|
||||
"druck_hinten": "2,5",
|
||||
"kommentar": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"service": {
|
||||
"vereinbart": {},
|
||||
"autohaus": {
|
||||
"name": "",
|
||||
"adresse": "",
|
||||
"tel": "",
|
||||
"mail": "",
|
||||
"pos": null
|
||||
},
|
||||
"buch": []
|
||||
},
|
||||
|
||||
"technik": [
|
||||
{ "gruppe": "Motor", "posten": [
|
||||
["Bauart", "V6 Ottomotor, Direkteinspritzung, Biturbo"],
|
||||
["Hubraum", "2 894 cm³"],
|
||||
["Leistung", "331 kW / 450 PS bei 5 700–6 700 min⁻¹"],
|
||||
["Maximales Drehmoment", "600 Nm bei 2 000–5 000 min⁻¹"],
|
||||
["Abgasreinigung", "Katalysator, Lambdasonde, Ottopartikelfilter"],
|
||||
["Abgasnorm", "EU6 AP"],
|
||||
["Motormanagement", "vollelektronisch"]
|
||||
]},
|
||||
{ "gruppe": "Kraftübertragung", "posten": [
|
||||
["Getriebe", "8-Gang tiptronic"],
|
||||
["Antrieb", "quattro, permanent, selbstsperrendes Mittendifferenzial"],
|
||||
["Kupplung", "hydraulischer Drehmomentwandler mit Überbrückung"]
|
||||
]},
|
||||
{ "gruppe": "Fahrleistungen", "posten": [
|
||||
["0 auf 100 km/h", "3,9 s"],
|
||||
["Höchstgeschwindigkeit", "290 km/h"],
|
||||
["Effizienzklasse", "E"],
|
||||
["Fahrgeräusch", "70 dB(A)"]
|
||||
]},
|
||||
{ "gruppe": "Fahrwerk, Lenkung, Bremsen", "posten": [
|
||||
["Fahrwerk", "RS-Sportfahrwerk"],
|
||||
["Vorderachse", "5-Lenker, Rohrstabilisator"],
|
||||
["Hinterachse", "5-Lenker, Rohrstabilisator"],
|
||||
["Lenkung", "elektromechanisch, geschwindigkeitsabhängig"],
|
||||
["Wendekreis", "ca. 11,7 m"],
|
||||
["Bremsanlage", "RS-Stahlbremse, Sättel rot glänzend"],
|
||||
["Vorn", "Faustsattel, Scheibe innenbelüftet"],
|
||||
["Hinten", "Faustsattel mit elektrischer Parkbremse, innenbelüftet"]
|
||||
]},
|
||||
{ "gruppe": "Räder und Reifen", "posten": [
|
||||
["Räder", "Leichtmetall 9J × 20, Audi Sport"],
|
||||
["Reifen", "275/30 R20 97Y XL"],
|
||||
["Pannenhilfe", "Reifenreparaturset, kein Ersatzrad"],
|
||||
["Radschrauben", "Standard"],
|
||||
["Anzugsmoment", "120 Nm"]
|
||||
]},
|
||||
{ "gruppe": "Maße, Gewichte, Volumen", "posten": [
|
||||
["Leergewicht", "1 820 kg"],
|
||||
["Zulässiges Gesamtgewicht", "2 350 kg"],
|
||||
["Dachlast / Stützlast", "90 / 80 kg"],
|
||||
["Anhängelast gebremst", "2 100 kg bei 8 %, 1 900 kg bei 12 %"],
|
||||
["Anhängelast ungebremst", "750 kg"],
|
||||
["Gepäckraum", "495 / 1 495 l"],
|
||||
["Tankinhalt", "58 l, erhöhtes Füllvolumen für quattro"]
|
||||
]},
|
||||
{ "gruppe": "Bordnetz", "posten": [
|
||||
["Batterie", "420 A, 75 Ah"],
|
||||
["Generator", "100–150 A"],
|
||||
["Kältemittel", "R1234yf"]
|
||||
]},
|
||||
{ "gruppe": "Wartung", "posten": [
|
||||
["Serviceanzeige", "30 000 km oder 2 Jahre, variabel"],
|
||||
["Garantie", "Anschlussgarantie 3 Jahre, max. 100 000 km"]
|
||||
]}
|
||||
],
|
||||
|
||||
"ausstattung": [
|
||||
{ "gruppe": "RS-Paket", "sonder": ["RS competition", "RS-Designpaket rot", "RS-Komfortpaket", "RS-Sportabgasanlage plus", "RS-Sportfahrwerk", "RS-Stahlbremsanlage, Sättel rot glänzend", "Bremssättel rot lackiert", "Anhebung der Höchstgeschwindigkeit auf 290 km/h", "Sicherheitsgurte schwarz mit rotem Rand", "RS-Einstiegsleisten"] },
|
||||
{ "gruppe": "Außen", "sonder": ["Optikpaket schwarz plus", "Optikpaket schwarz glänzend", "Panorama-Glasdach", "Dachreling schwarz", "Anhängevorrichtung", "Gepäckraumklappe elektrisch öffnend und schließend", "Matrix-LED-Scheinwerfer mit dynamischer Lichtinszenierung", "LED-Heckleuchten mit dynamischer Lichtinszenierung", "Außenspiegel elektrisch, beheiz- und anklappbar, abblendend, mit Memory", "Außenspiegelgehäuse in Wagenfarbe", "Sonnenschutzverglasung abgedunkelt"] },
|
||||
{ "gruppe": "Innen", "sonder": ["Mikrofaser Dinamica/Leder mit Wabensteppung und RS-Stickung", "Sportsitze plus vorn", "Vordersitze elektrisch mit Memory für den Fahrersitz", "Sitzheizung vorn und hinten", "Lendenwirbelstütze mit Massagefunktion", "Dekoreinlagen Carbon Köper matt", "Ambiente-Lichtpaket plus", "3-Zonen-Komfortklimaautomatik", "Lederlenkrad 3-Speichen mit Lenkradheizung", "Innenspiegel automatisch abblendend, rahmenlos", "USB-Ladeschnittstellen im Fond", "Sonnenschutzrollo hinten", "Fußmatten RS-Designpaket"] },
|
||||
{ "gruppe": "Infotainment", "sonder": ["MMI Navigation plus mit MMI touch", "Audi virtual cockpit plus", "Head-up-Display", "Bang & Olufsen Premium Sound System mit 3D-Klang", "Audi phone box", "LTE-Unterstützung für Audi phone box", "Audi connect Navigation & Infotainment"] },
|
||||
{ "gruppe": "Assistenz", "sonder": ["Assistenzpaket Tour", "Assistenzpaket Stadt mit Parken", "Audi adaptive cruise control", "Audi active lane assist", "Halteassistent", "Parkassistent mit Einparkhilfe plus", "Umgebungskameras", "Kamerabasierte Verkehrszeichenerkennung", "Audi pre sense rear und basic", "Garagentoröffner", "Komfortschlüssel mit sensorgesteuerter Heckklappe"] },
|
||||
{ "gruppe": "Serie, ausgewählt", "sonder": ["Audi drive select", "Audi pre sense city", "Spurwechselwarnung", "Reifendruck-Kontrollanzeige", "Start-Stop-System", "Audi connect Notruf & Service mit Remote & Control", "Smartphone-Interface", "Kraftstoffbehälter mit erhöhtem Füllvolumen", "Pedalerie und Fußstütze aus Edelstahl"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Sensor-zu-Entität-Zuordnung fürs Setup-Menü (Einstellungen -> Fahrzeug
|
||||
einrichten -> Setup).
|
||||
|
||||
audi_dashboard/entitaeten.json enthält nur die vom Nutzer über das Setup-Menü
|
||||
geänderten Zuordnungen, keyed nach den Feldnamen aus einstellungen.py.
|
||||
anwenden() legt sie über die eingebauten Standardwerte.
|
||||
|
||||
Wichtig ist, dass JEDES bekannte Feld bei jedem Durchlauf gesetzt wird, nicht
|
||||
nur die belegten. Eine frühere Fassung übersprang leere Werte - weil die
|
||||
Zuordnung aber ein dauerhaftes Objekt ist, blieb ein einmal gesetzter Wert
|
||||
danach für immer stehen. "Zurücksetzen" im Setup-Menü hatte bei allen Feldern
|
||||
mit leerem Standardwert (15 von 17) schlicht keine Wirkung, und die Oberfläche
|
||||
zeigte beim nächsten Zeichnen wieder den alten Wert, als sei das Speichern
|
||||
fehlgeschlagen. Umgekehrt wurde eine Liste aus leeren Einträgen gesetzt statt
|
||||
übersprungen, was den Sicherheitscheck mit zwölf "unbekannt"-Zeilen füllte und
|
||||
"Sicher abgestellt" dauerhaft grau werden ließ.
|
||||
|
||||
Jedes Feld bei jedem Durchlauf zu schreiben ist damit nicht nur korrekt,
|
||||
sondern auch idempotent: der Zustand hängt allein an entitaeten.json, nicht an
|
||||
der Reihenfolge vorheriger Aufrufe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import einstellungen
|
||||
from .ablage import Ablage
|
||||
from .einstellungen import FELDER, SCHLUESSEL, STANDARDWERTE, Sensorzuordnung
|
||||
|
||||
|
||||
def _ist_leer(wert: object) -> bool:
|
||||
"""Leer heißt: keine Zuordnung. Auch eine Liste, die nur leere Einträge
|
||||
enthält - das Setup-Menü schickt für unbelegte Positionen ["","",...], und
|
||||
das ist inhaltlich dasselbe wie gar keine Liste."""
|
||||
if wert in (None, ""):
|
||||
return True
|
||||
if isinstance(wert, list):
|
||||
return not any(eintrag for eintrag in wert)
|
||||
return False
|
||||
|
||||
|
||||
class Zuordnung:
|
||||
"""Hält die wirksame Sensorzuordnung und pflegt sie aus entitaeten.json."""
|
||||
|
||||
def __init__(self, ablage: Ablage) -> None:
|
||||
self._ablage = ablage
|
||||
self.werte = Sensorzuordnung()
|
||||
|
||||
async def anwenden(self) -> None:
|
||||
"""Setzt für jedes bekannte Feld den wirksamen Wert - den Override,
|
||||
wenn einer hinterlegt ist, sonst den eingebauten Standardwert."""
|
||||
overrides = await self._ablage.zuordnung_lesen()
|
||||
for key in SCHLUESSEL:
|
||||
wert = overrides.get(key)
|
||||
standard = STANDARDWERTE[key]
|
||||
gesetzt = standard if _ist_leer(wert) else wert
|
||||
setattr(self.werte, key, list(gesetzt) if isinstance(gesetzt, list) else gesetzt)
|
||||
|
||||
async def speichern(self, mapping: dict) -> None:
|
||||
"""Schreibt nur bekannte Schlüssel; unbekannte werden stillschweigend
|
||||
verworfen, damit ein späterer Katalog-Umbau keine Datenleichen in
|
||||
entitaeten.json hinterlässt."""
|
||||
bereinigt = {k: v for k, v in mapping.items() if k in SCHLUESSEL}
|
||||
await self._ablage.zuordnung_schreiben(bereinigt)
|
||||
await self.anwenden()
|
||||
|
||||
def aktueller_stand(self) -> dict:
|
||||
"""Katalog + aktuell wirksamer Wert je Feld, Grundlage für die Entität
|
||||
sensor.audi_dashboard_entitaeten. Die Werte kommen aus dem
|
||||
Zuordnungsobjekt selbst, nicht direkt aus der JSON-Datei - damit zeigt
|
||||
die Oberfläche bereits angewendete Overrides und eingebaute
|
||||
Standardwerte einheitlich."""
|
||||
werte = {feld["key"]: getattr(self.werte, feld["key"], None) for feld in FELDER}
|
||||
return {"katalog": FELDER, "werte": werte, "standardwerte": STANDARDWERTE}
|
||||
|
||||
|
||||
__all__ = ["Zuordnung", "einstellungen"]
|
||||