diff --git a/AGENTS.md b/AGENTS.md index 37cc736..bdbc9ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Project state, review findings, open items, and working rules -**Last updated: 2026-09-05** (Audit-Durchgang, Abschnitt DC) (Tankstelle zweizeilig und antippbar, die Belegkarte sagt bei +**Last updated: 2026-09-06** (Die Batteriehistorie zeichnete stehende Werte als frische Messungen auf - der Vorschlag aus CB war messbar falsch, Abschnitt DI; davor: die ausgelieferte .ipa registriert zwei eigene Plugins nicht, Abschnitt DH). Davor: 2026-09-05, Audit-Durchgang, Abschnitt DC. (Tankstelle zweizeilig und antippbar, die Belegkarte sagt bei fehlender Position die Wahrheit statt einen toten Knopf zu zeigen, der Regler bekommt einen Speichern-Knopf, und die Share-Erweiterung trug eine andere Versionsnummer als die App; dazu die Kopfmarke statt der Firmierung des Betreibers und ein @@ -9802,10 +9802,16 @@ minimum against whether the vehicle was reporting over CAN within plus/minus 5 m days pass, 13 do not** — including 2026-09-03 (12,149 V at 23:56, the desk). These values feed the resting voltage, the SOC, the SOH trend and the fallback shown in "Mein Audi / Zustand". -**Not built, proposed:** require a fresh CAN value near the voltage reading before recording it — -the same mechanism as `wert_in_naehe()` with its 300 s window for the outside temperature. Without a -vehicle, nothing gets recorded. The 13 existing entries are the owner's data; removal is via the -swipe gesture in the measurement list. +**Not built, proposed — ÜBERHOLT am 2026-09-06, siehe Abschnitt DI:** require a fresh CAN value +near the voltage reading before recording it — the same mechanism as `wert_in_naehe()` with its +300 s window for the outside temperature. Without a vehicle, nothing gets recorded. The 13 existing +entries are the owner's data; removal is via the swipe gesture in the measurement list. + +> **Diese Regel wurde gemessen und trifft nicht.** Sie hätte zwei eindeutige Werkbank-Tage +> durchgelassen (CAN 5 Minuten neben dem Minimum) und echte Fahrzeugtage verworfen — am stehenden +> Fahrzeug schweigt CAN, eine CAN-Frist verwirft also ausgerechnet die Ruhespannung. Der wirkliche +> Mechanismus war ein anderer: `pruefen()` zeichnete den zuletzt bekannten Wert alle fünf Minuten +> erneut auf und stempelte ihn mit `jetzt`. Abschnitt DI hat die Rechnung und die Korrektur. ## CC. Tankstelle zweizeilig und antippbar, die Belegkarte sagt die Wahrheit, der Regler bekommt einen Knopf (2026.9.4.5) @@ -12553,3 +12559,131 @@ Am **fertigen Buendel** zu pruefen, nicht am Projekt: Die ersten vier prueft das Skript selbst und bricht sonst ab. Der fuenfte Punkt bleibt Handarbeit: dass eine Klasse in der Liste steht, heisst noch nicht, dass die Bruecke sie zur Laufzeit findet. + +## DI. Die Batteriehistorie zeichnete stehende Werte als frische Messungen auf (2026-09-06) + +Auftrag des Eigentümers: die in Abschnitt CB gemeldete Verunreinigung der +Batteriehistorie durch Werkbank-Messungen abstellen. **Der dort vorgeschlagene +Weg war falsch, und das Messen hat es gezeigt, bevor Code entstanden ist.** + +### Warum der Vorschlag aus CB nicht trägt + +CB schlug vor, eine Messung nur aufzuzeichnen, wenn ein CAN-Wert innerhalb von +300 s vorliegt („ohne Fahrzeug wird nichts erfasst"). Gegen die 19 gespeicherten +Tage und den recorder gerechnet, trifft diese Regel in **beide** Richtungen +daneben: + +* Sie hätte zwei eindeutige Werkbank-Tage **durchgelassen** - am 15.08. und + 23.08. lag eine CAN-Meldung 5 Minuten neben dem Tagesminimum. +* Sie hätte echte Fahrzeugtage **verworfen** - der 30.08. (95 min) und der + 04.09. (8 min) tragen Tagesmaxima von 14,7 bzw. 14,4 V, also Generatorspannung. + Insgesamt hätte sie 12 von 19 Tagen abgelehnt. + +Der Grund ist grundsätzlich: am stehenden Fahrzeug schweigt CAN. Eine +CAN-Frist verwirft damit ausgerechnet die Ruhespannung - die Größe, für die +diese Aufzeichnung überhaupt existiert (siehe den Moduldocstring von +`batterie.py`). + +### Der wirkliche Mechanismus, am Bestand nachgewiesen + +`zustand_oder_none()` liefert den zuletzt bekannten Zustand **unabhängig von +seinem Alter**, und `pruefen()` stempelte ihn mit `jetzt`. Ein schweigender +Dongle „misst" damit alle fünf Minuten weiter seinen letzten Wert. + +Der Beleg steht in den Daten selbst: + +| | | +|---|---| +| **2026-08-16** | Tageseintrag vorhanden (13,024 V @ 08:37:43) | +| | Spannungsmeldungen an diesem Tag: **0** | +| | Abstand zur nächsten echten Meldung: **885 Minuten** | + +Sieben von 19 Einträgen stehen mehr als fünf Minuten von jeder echten Meldung +entfernt; die unstrittigen Fahrzeugtage dagegen exakt bei 0 s. + +Dasselbe zeigt der Sensor selbst, nach Epochen getrennt: bis zum 25.08. meldete +er 2 bis 26 Mal am Tag mit **genau einem einzigen Wert** (13,024 bzw. 13,988 - +eine Spannung, die sich nicht rührt), ab dem 27.08. 35 bis 871 Mal mit 28 bis +487 verschiedenen Werten zwischen 0 und 15,4 V. Die Lücken der ersten Epoche +hat der Livepfad mit Wiederholungen des letzten Werts gefüllt. + +### Was gebaut wurde + +`messzeitpunkt()` in `batterie.py` beantwortet beide Fragen an einer Stelle: +ist das eine neue Messung, und wann wurde sie gemacht. `None` heißt „seit +`MESSUNG_MAX_ALTER_S` nichts Neues - es gibt nichts aufzuzeichnen". + +* **`MESSUNG_MAX_ALTER_S = 600`.** Der Takt liegt bei 5 Minuten + (`TAKT_BATTERIE`); das Fenster muss mindestens eine Taktlänge betragen, sonst + ginge eine Messung verloren, die kurz nach einem Takt eintrifft. Verdoppelt + für einen verspäteten Takt - dieselbe Messung zweimal einzutragen ist + folgenlos, weil sie ihren eigenen Zeitstempel trägt und die Ablage min/max nur + erweitert. +* **Gestempelt wird mit der Gerätezeit**, wenn sie sich über `ZUORDNUNG_MAX_S` + zuordnen lässt, sonst mit der Ankunftszeit. Das ist keine Stilfrage: der + Rückblick stempelt Batteriewerte längst mit Gerätezeit (`verlauf_lesen()` + bekommt in `historienimport.py` die Meldezeit-Entität mit). Ohne dieselbe Wahl + im Livepfad ergäben beide Wege für dieselbe Messung verschiedene Einträge - + genau die Divergenz, die dieses Projekt bei `UNPLAUSIBLE_KMH`, + `MINDESTDAUER_S` und `zaehlerstrecke()` schon dreimal bezahlt hat. +* **Der Tag kommt aus der Messung**, nicht aus `date.today()` - sonst landete + eine Messung von 23:58 im Folgetag. +* **Die Frischeprüfung steht VOR der Plausibilitätsgrenze.** Sonst warnt ein + offline gegangener Dongle alle fünf Minuten über denselben Wert: im laufenden + Testcontainer **77 identische Zeilen** „Batteriespannung 0.0 V unter der + Plausibilitätsgrenze" in 3000 Protokollzeilen, über einen Wert, der sich seit + 5,5 Stunden nicht gerührt hatte. Jetzt warnt jede echte Messung genau einmal. + +**Der Rückblick brauchte keine Änderung** - er iteriert über echte +recorder-Zeilen, von denen jede eine tatsächliche Messung ist, und stempelt sie +mit ihrem eigenen Zeitstempel. Er war die ganze Zeit die richtige Umsetzung; der +Livepfad war der abweichende. + +### Die erste Fassung hatte ein Loch, und nur die Live-Prüfung hat es gezeigt + +Gemessen wurde das Alter zunächst an `last_updated` des Spannungssensors. Nach +dem Ausliefern in den Testcontainer warnte der erste Takt trotzdem - über +denselben seit 15:30 stehenden 0-V-Wert. **Home Assistant setzt beim Hochfahren +jede Entität neu; ihr `last_updated` steht danach auf der Neustartzeit**, auch +wenn der Wert Stunden alt ist. Am 06.09.2026 im laufenden Container abgelesen: + +| Entität | `last_updated` | Wert | +|---|---|---| +| Spannung | 21:12:54 (= Neustart) | 0 | +| Meldezeit | 21:12:54 (= Neustart) | 1788706007 -> **14:46:47** | + +Die Ankunftszeit log also, die **Gerätezeit übersteht den Neustart**: 395 +Minuten. Das Alter wird deshalb an der Messzeit gemessen, nicht an der Ankunft - +`messzeitpunkt()` bestimmt erst die Messzeit und prüft danach das Alter. + +Ohne zugeordnete Meldezeit bleibt nur die Ankunftszeit; dann geht nach einem +Neustart einmalig ein veralteter Wert durch. Das ist bekannt, im Docstring +vermerkt und durch einen eigenen Test festgehalten, statt unbemerkt zu bleiben. + +**Die Lehre ist die alte:** kompiliert und getestet ist nicht verifiziert. Die +16 Tests der ersten Fassung waren grün, 14 davon fielen gegen HEAD - und das +Loch lag trotzdem darin, weil kein Test einen HA-Neustart nachstellte. + +### Geprüft + +18 Tests in `tests/batterie/` (`test_messzeitpunkt.py`, `test_pruefen.py`), +darunter der 16.08.-Fall und der Neustart-Fall mit ihren echten Zahlen. **Gegen +den Stand aus HEAD fallen 16 davon** - die zwei, die in beiden Ständen grün bleiben, sind die +Wächter dafür, dass bestehendes Verhalten unangetastet ist (eine frische +unplausible Messung warnt weiterhin und wird weiterhin nicht erfasst). Alle +109 Backend-Tests grün. + +### Was offen bleibt + +**Werkbank und Fahrzeug lassen sich aus diesen Daten nicht sicher trennen.** Ein +Netzteil bei 12,2 V sieht aus wie ein stehendes Fahrzeug, und die +August-Verunreinigung stammt aus der Testphase, in der der Dongle zwischen +Schreibtisch und Fahrzeugen wanderte. Was jetzt zuverlässig abgestellt ist, sind +die **erfundenen** Einträge - Tage ohne Messung und Zeitstempel, die nicht zur +Messung gehören. Wer die Werkbank wirklich ausschließen will, braucht einen +Schalter, den der Eigentümer beim Abstöpseln umlegt; aus den Sensordaten allein +ist die Frage nicht zu beantworten. + +**Die vorhandenen Einträge bleiben stehen** - das sind die Daten des +Eigentümers. Zu entfernen sind sie über das Wischen in der Messwertliste, so wie +schon in Abschnitt U/CB beschrieben. diff --git a/custom_components/audi_dashboard/batterie.py b/custom_components/audi_dashboard/batterie.py index 4d74e30..223e128 100644 --- a/custom_components/audi_dashboard/batterie.py +++ b/custom_components/audi_dashboard/batterie.py @@ -35,6 +35,22 @@ Liste und im Diagramm landet. Ohne zugeordneten BATTERIE_SENSOR passiert hier bewusst nichts, statt Platzhalter-Nullwerte aufzuzeichnen. +Aufgezeichnet wird nur eine NEUE Messung. zustand_oder_none() liefert den +zuletzt bekannten Zustand unabhängig von seinem Alter - ohne diese Prüfung +"misst" ein schweigender Dongle alle fünf Minuten weiter seinen letzten Wert, +gestempelt mit "jetzt". Am Bestand nachgewiesen (06.09.2026): der 16.08.2026 +trug einen Tageseintrag, obwohl der Spannungssensor an diesem Tag kein einziges +Mal gemeldet hatte - die nächste echte Meldung lag 885 Minuten entfernt; sieben +von 19 Einträgen standen mehr als fünf Minuten von jeder echten Meldung +entfernt, die unstrittigen Fahrzeugtage dagegen exakt bei 0 s. + +Der Zeitstempel ist deshalb auch nicht mehr "jetzt", sondern der der Messung +selbst, und der Tag wird aus ihm abgeleitet - sonst landete eine Messung von +23:58 im Folgetag. Damit verhält sich der Livepfad wie der Rückblick +(historienimport.py's _batterie_importieren), der seit jeher jede Messung mit +ihrem eigenen Zeitstempel einträgt: dieselbe Messung, gleich auf welchem Weg +erfasst, ergibt denselben Eintrag. + Optional wird bei jeder neuen Tagesminimum-Messung auch die aktuelle Außentemperatur (AUSSENTEMP_SENSOR, CAN ambient air temperature) mit aufgezeichnet - für die Messwertliste (Datum, Uhrzeit, Außentemperatur), nicht @@ -49,7 +65,7 @@ import logging from typing import TYPE_CHECKING from .veroeffentlichung import zustand_oder_none -from .verlauf import NEBENWERT_MAX_ABSTAND_S +from .verlauf import NEBENWERT_MAX_ABSTAND_S, ZUORDNUNG_MAX_S, geraetezeit_plausibel if TYPE_CHECKING: from .koordinator import Koordinator @@ -75,6 +91,76 @@ SPANNUNG_MIN_V = 10.0 # Wert. AGM_RUHE_MAX_V = 13.0 +# Wie alt darf eine Messung sein, um noch als neu zu gelten? Der Takt liegt bei +# 5 Minuten (TAKT_BATTERIE in koordinator.py); das Fenster muss deshalb +# mindestens eine Taktlänge betragen, sonst ginge eine Messung verloren, die +# kurz nach einem Takt eintrifft. Verdoppelt, damit auch ein verspäteter Takt +# sie noch fasst - dieselbe Messung zweimal einzutragen ist folgenlos, weil sie +# ihren eigenen Zeitstempel trägt und die Ablage min/max nur erweitert. +MESSUNG_MAX_ALTER_S = 600 + + +def messzeitpunkt( + spannung_zustand, + meldezeit_zustand, + jetzt: datetime.datetime, +) -> datetime.datetime | None: + """Wann wurde diese Spannung gemessen - und ist es überhaupt eine neue Messung? + + `None` heißt: die Messung ist älter als MESSUNG_MAX_ALTER_S, es gibt also + nichts aufzuzeichnen. Siehe Moduldocstring, warum das nötig ist. + + Ohne zugeordnete Meldezeit bleibt nur die Ankunftszeit, und die wird von + einem Neustart zurückgesetzt - dann kann unmittelbar danach einmalig ein + veralteter Wert durchgehen. Mit MELDEZEIT_SENSOR passiert das nicht. + + Gestempelt wird mit der **Gerätezeit**, wenn sie sich zuordnen lässt, sonst + mit der Ankunftszeit. Zugeordnet wird über ZUORDNUNG_MAX_S: die flespi- + Integration setzt die Entitäten eines Datensatzes nacheinander, aber + innerhalb weniger Millisekunden - liegt die Meldezeit weiter weg, gehört sie + zu einem anderen Datensatz und sagt über diese Messung nichts. Der Rückblick + stempelt Batteriewerte ebenfalls mit Gerätezeit (verlauf_lesen() bekommt in + historienimport.py die Meldezeit-Entität mit); ohne dieselbe Wahl hier + ergäben Livepfad und Rückblick für dieselbe Messung verschiedene Einträge. + + Gemessen wird gegen `last_updated`, nicht gegen `last_reported` - und das + ist Absicht: `last_updated` springt nur bei einem echten Wertwechsel, und + genau solche Wechsel schreibt auch der recorder, aus dem der Rückblick + liest. Ein Sensor, der unverändert weitermeldet, gilt hier deshalb als + "nichts Neues" - was richtig ist, denn ein unveränderter Wert erweitert + weder min noch max. Auf `last_reported` umzustellen würde den Livepfad + wieder vom Rückblick trennen. + """ + if spannung_zustand is None: + return None + + gemessen = spannung_zustand.last_updated + if meldezeit_zustand is not None: + abstand_s = abs( + (meldezeit_zustand.last_updated - spannung_zustand.last_updated).total_seconds() + ) + if abstand_s <= ZUORDNUNG_MAX_S: + try: + kandidat = datetime.datetime.fromtimestamp( + float(meldezeit_zustand.state), datetime.UTC + ) + except (TypeError, ValueError, OSError, OverflowError): + kandidat = None + if kandidat is not None and geraetezeit_plausibel(kandidat, jetzt): + gemessen = kandidat + + # Das Alter wird an `gemessen` gemessen, nicht an last_updated - und das ist + # der Unterschied zwischen "funktioniert" und "funktioniert bis zum nächsten + # Neustart". Home Assistant setzt beim Hochfahren jede Entität neu, ihr + # last_updated steht danach auf der Neustartzeit, auch wenn der Wert Stunden + # alt ist. Am 06.09.2026 im Testcontainer gemessen: nach dem Neustart um + # 21:12:54 trug der Spannungssensor last_updated 21:12:54, während die + # Meldezeit desselben Datensatzes 14:46:47 sagte - 395 Minuten alt. Die + # Gerätezeit übersteht den Neustart, die Ankunftszeit nicht. + if (jetzt - gemessen).total_seconds() > MESSUNG_MAX_ALTER_S: + return None + return gemessen + async def pruefen(k: Koordinator) -> None: sensor = k.zuordnung.werte.BATTERIE_SENSOR @@ -87,6 +173,25 @@ async def pruefen(k: Koordinator) -> None: spannung = float(wert) except (TypeError, ValueError): return + # Ist das überhaupt eine neue Messung? Ohne diese Frage zeichnet ein + # schweigender Dongle alle fünf Minuten weiter seinen letzten Wert auf - + # siehe Moduldocstring und messzeitpunkt(). + jetzt = datetime.datetime.now(datetime.UTC) + spannung_zustand = k.hass.states.get(sensor) + meldezeit_sensor = k.zuordnung.werte.MELDEZEIT_SENSOR + meldezeit_zustand = k.hass.states.get(meldezeit_sensor) if meldezeit_sensor else None + gemessen = messzeitpunkt(spannung_zustand, meldezeit_zustand, jetzt) + if gemessen is None: + _LOGGER.debug( + "Batteriespannung %s V ist keine neue Messung (Sensor meldet seit " + "über %s s nichts) - nicht erfasst", + spannung, MESSUNG_MAX_ALTER_S, + ) + return + + # Erst hier, nach der Frischeprüfung: sonst warnt ein offline gegangener + # Dongle alle fünf Minuten über denselben Wert (im Testcontainer 77 Mal in + # 3000 Protokollzeilen gezählt). So warnt jede echte Messung genau einmal. if spannung < SPANNUNG_MIN_V: _LOGGER.warning( "Batteriespannung %s V unter der Plausibilitätsgrenze (%s V) - nicht erfasst", @@ -108,7 +213,6 @@ async def pruefen(k: Koordinator) -> None: temp_sensor = k.zuordnung.werte.AUSSENTEMP_SENSOR temp_wert = zustand_oder_none(k.hass, temp_sensor) if temp_wert is not None: - spannung_zustand = k.hass.states.get(sensor) temp_zustand = k.hass.states.get(temp_sensor) abstand_s = abs((temp_zustand.last_updated - spannung_zustand.last_updated).total_seconds()) if abstand_s <= NEBENWERT_MAX_ABSTAND_S: @@ -122,13 +226,14 @@ async def pruefen(k: Koordinator) -> None: round(abstand_s), ) - jetzt = datetime.datetime.now(datetime.UTC) - heute = datetime.date.today().isoformat() + # Tag und Zeitstempel kommen aus der Messung selbst, nicht aus der Uhr des + # Servers - sonst landete eine Messung von 23:58 im Folgetag. + tag = gemessen.date().isoformat() await k.ablage.batterieverlauf_tageswert_aktualisieren( - heute, jetzt.isoformat(), spannung, aussentemp + tag, gemessen.isoformat(), spannung, aussentemp ) await k.batterieverlauf_veroeffentlichen() - _LOGGER.debug("Batteriespannung erfasst (%s: %s V)", heute, spannung) + _LOGGER.debug("Batteriespannung erfasst (%s: %s V)", tag, spannung) async def eintrag_loeschen(k: Koordinator, datum: str | None) -> None: diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 829f00e..06680cd 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.6.4","sha256":"1d9d562b8215a64e176861d3de52d5f53a17fe590d73814fa065748a7e0e43e9","bytes":378713,"gebaut":"2026-09-06T15:30:15Z"} \ No newline at end of file +{"version":"2026.9.6.5","sha256":"d7373ee38b4b6e97f5d20e5225bca6981d21f60fc34d7b2658b8571650b52726","bytes":378720,"gebaut":"2026-09-06T21:29:15Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 570a143..f1e19e1 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 05234e0..7a7cc1e 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.6.4", + "version": "2026.9.6.5", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": [ diff --git a/tests/batterie/test_messzeitpunkt.py b/tests/batterie/test_messzeitpunkt.py new file mode 100644 index 0000000..1cbdf07 --- /dev/null +++ b/tests/batterie/test_messzeitpunkt.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Nur eine NEUE Messung wird aufgezeichnet - und mit ihrer eigenen Zeit. + +Warum es das gibt (06.09.2026): zustand_oder_none() liefert den zuletzt +bekannten Zustand unabhängig von seinem Alter, und pruefen() stempelte ihn mit +"jetzt". Ein schweigender Dongle "misst" damit alle fünf Minuten weiter seinen +letzten Wert. + +Am Bestand des Testcontainers nachgemessen, nicht vermutet: + + 2026-08-16 Tageseintrag vorhanden (13.024 V @ 08:37:43) + Spannungsmeldungen an diesem Tag: 0 + Abstand zur nächsten echten Meldung: 885 Minuten + +Sieben von 19 Einträgen standen mehr als fünf Minuten von jeder echten Meldung +entfernt; die unstrittigen Fahrzeugtage dagegen exakt bei 0 s. + +Geprüft wird deshalb: dass eine veraltete Messung gar nicht mehr erfasst wird, +dass eine frische ihren eigenen Zeitstempel behält (Gerätezeit, wenn zuordenbar +- so wie der Rückblick es tut), und dass der Tag aus der Messung stammt und +nicht aus der Uhr des Servers. + +Aufruf: python3 tests/batterie/test_messzeitpunkt.py +(braucht das homeassistant-Paket, weil die Integration es importiert - also +z. B. im Test-Container) +""" + +import datetime +import os +import sys +import unittest + +_HIER = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(os.path.dirname(_HIER))) + +from custom_components.audi_dashboard import batterie as b # noqa: E402 + +JETZT = datetime.datetime(2026, 8, 16, 8, 37, 43, tzinfo=datetime.UTC) + + +class Zustand: + """Das Wenige, das messzeitpunkt() von einem HA-State braucht.""" + + def __init__(self, last_updated, state=""): + self.last_updated = last_updated + self.state = state + + +def vor(sekunden): + return JETZT - datetime.timedelta(seconds=sekunden) + + +class NurNeueMessungen(unittest.TestCase): + def test_frische_messung_wird_erfasst(self): + z = Zustand(vor(12)) + self.assertEqual(b.messzeitpunkt(z, None, JETZT), vor(12)) + + def test_veraltete_messung_wird_nicht_erfasst(self): + """Der Fall 16.08.2026: 885 Minuten seit der letzten echten Meldung.""" + z = Zustand(vor(885 * 60)) + self.assertIsNone(b.messzeitpunkt(z, None, JETZT)) + + def test_genau_am_fenster_zaehlt_noch(self): + z = Zustand(vor(b.MESSUNG_MAX_ALTER_S)) + self.assertIsNotNone(b.messzeitpunkt(z, None, JETZT)) + + def test_eine_sekunde_darueber_nicht_mehr(self): + z = Zustand(vor(b.MESSUNG_MAX_ALTER_S + 1)) + self.assertIsNone(b.messzeitpunkt(z, None, JETZT)) + + def test_fenster_deckt_mindestens_einen_takt_ab(self): + """Sonst ginge eine Messung verloren, die kurz nach einem Takt eintrifft.""" + self.assertGreaterEqual(b.MESSUNG_MAX_ALTER_S, 300) + + def test_ohne_zustand_nichts(self): + self.assertIsNone(b.messzeitpunkt(None, None, JETZT)) + + +class GeraetezeitStattAnkunftszeit(unittest.TestCase): + def test_zuordenbare_geraetezeit_gewinnt(self): + ankunft = vor(30) + geraet = vor(200) + mz = Zustand(ankunft, str(geraet.timestamp())) + self.assertEqual(b.messzeitpunkt(Zustand(ankunft), mz, JETZT), geraet) + + def test_zu_weit_entfernte_meldezeit_zaehlt_nicht(self): + """Sie gehört dann zu einem anderen Datensatz - siehe ZUORDNUNG_MAX_S.""" + ankunft = vor(30) + mz = Zustand(vor(90), str(vor(200).timestamp())) + self.assertEqual(b.messzeitpunkt(Zustand(ankunft), mz, JETZT), ankunft) + + def test_unplausible_geraetezeit_zaehlt_nicht(self): + """Eine falsch gestellte Geräteuhr darf die Messung nicht mitreißen.""" + ankunft = vor(30) + zukunft = JETZT + datetime.timedelta(days=3) + mz = Zustand(ankunft, str(zukunft.timestamp())) + self.assertEqual(b.messzeitpunkt(Zustand(ankunft), mz, JETZT), ankunft) + + def test_unlesbare_meldezeit_zaehlt_nicht(self): + ankunft = vor(30) + mz = Zustand(ankunft, "unavailable") + self.assertEqual(b.messzeitpunkt(Zustand(ankunft), mz, JETZT), ankunft) + + +class NachEinemNeustart(unittest.TestCase): + """Am 06.09.2026 im laufenden Testcontainer aufgefallen, nicht am Quelltext. + + Nach dem Neustart um 21:12:54 stand die neue Frischeprüfung im Container - + und der erste Takt um 21:17:54 warnte trotzdem über den seit 15:30 + stehenden 0-V-Wert. Der Grund: Home Assistant setzt beim Hochfahren jede + Entität neu, last_updated steht danach auf der Neustartzeit. Gemessen: + + Spannung last_updated 21:12:54 Wert 0 + Meldezeit last_updated 21:12:54 Wert 1788706007 -> 14:46:47 + + Die Ankunftszeit log also, die Gerätezeit sagte die Wahrheit: 395 Minuten. + """ + + def test_frische_ankunft_aber_alte_geraetezeit_zaehlt_nicht(self): + ankunft = JETZT # der Neustart hat sie gerade gesetzt + geraet = JETZT - datetime.timedelta(minutes=395) + mz = Zustand(ankunft, str(geraet.timestamp())) + self.assertIsNone(b.messzeitpunkt(Zustand(ankunft), mz, JETZT)) + + def test_ohne_meldezeit_bleibt_die_luecke_und_das_ist_dokumentiert(self): + """Ohne MELDEZEIT_SENSOR gibt es nur die Ankunftszeit - dann geht nach + einem Neustart einmalig ein veralteter Wert durch. Der Test hält fest, + dass das bekannt ist und nicht aus Versehen passiert.""" + self.assertIsNotNone(b.messzeitpunkt(Zustand(JETZT), None, JETZT)) + + +class DerTagKommtAusDerMessung(unittest.TestCase): + def test_messung_kurz_vor_mitternacht_bleibt_im_richtigen_tag(self): + """Um 00:03 verarbeitet, um 23:58 gemessen - der Eintrag gehört zum Vortag.""" + jetzt = datetime.datetime(2026, 9, 7, 0, 3, 0, tzinfo=datetime.UTC) + gemessen = datetime.datetime(2026, 9, 6, 23, 58, 0, tzinfo=datetime.UTC) + ergebnis = b.messzeitpunkt(Zustand(gemessen), None, jetzt) + self.assertEqual(ergebnis.date().isoformat(), "2026-09-06") + self.assertNotEqual(ergebnis.date().isoformat(), jetzt.date().isoformat()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/batterie/test_pruefen.py b/tests/batterie/test_pruefen.py new file mode 100644 index 0000000..ee44c91 --- /dev/null +++ b/tests/batterie/test_pruefen.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""pruefen(): was tatsächlich in der Tagesliste landet - und was nicht. + +Ergänzt test_messzeitpunkt.py um den ganzen Weg: der Helfer entscheidet, diese +Tests belegen, dass die Entscheidung auch ankommt. + +Zwei Dinge stehen hier auf dem Prüfstand, beide am Bestand des Testcontainers +gemessen (06.09.2026): + +1. Eine veraltete Messung darf keinen Eintrag erzeugen. Der 16.08.2026 trug + einen Tageseintrag, obwohl der Spannungssensor an diesem Tag kein einziges + Mal gemeldet hatte. + +2. Sie darf auch nicht alle fünf Minuten dieselbe Warnung erzeugen. Im + laufenden Container standen 77 identische Zeilen "Batteriespannung 0.0 V + unter der Plausibilitätsgrenze" in 3000 Protokollzeilen - über einen Wert, + der sich seit 5½ Stunden nicht gerührt hatte. Deshalb kommt die + Frischeprüfung VOR der Plausibilitätsgrenze. + +Aufruf: python3 tests/batterie/test_pruefen.py +(braucht das homeassistant-Paket, weil die Integration es importiert - also +z. B. im Test-Container) +""" + +import asyncio +import datetime +import logging +import os +import sys +import unittest + +_HIER = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(os.path.dirname(_HIER))) + +from custom_components.audi_dashboard import batterie as b # noqa: E402 + +SPANNUNGSSENSOR = "sensor.spannung" + + +class Zustand: + def __init__(self, last_updated, state=""): + self.last_updated = last_updated + self.state = state + + +class Zustaende: + def __init__(self, daten): + self._daten = daten + + def get(self, entity_id): + return self._daten.get(entity_id) + + +class Hass: + def __init__(self, daten): + self.states = Zustaende(daten) + + +class Werte: + BATTERIE_SENSOR = SPANNUNGSSENSOR + AUSSENTEMP_SENSOR = "" + MELDEZEIT_SENSOR = "" + + +class Zuordnung: + def __init__(self): + self.werte = Werte() + + +class Ablage: + def __init__(self): + self.eintraege = [] + + async def batterieverlauf_tageswert_aktualisieren(self, tag, ts, spannung, temp): + self.eintraege.append({"tag": tag, "ts": ts, "spannung": spannung, "temp": temp}) + + +class Koordinator: + def __init__(self, alter_s, spannung): + gemessen = datetime.datetime.now(datetime.UTC) - datetime.timedelta(seconds=alter_s) + self.gemessen = gemessen + self.hass = Hass({SPANNUNGSSENSOR: Zustand(gemessen, str(spannung))}) + self.zuordnung = Zuordnung() + self.ablage = Ablage() + self.veroeffentlicht = 0 + + async def batterieverlauf_veroeffentlichen(self): + self.veroeffentlicht += 1 + + +def lauf(k): + asyncio.run(b.pruefen(k)) + return k + + +class WasErfasstWird(unittest.TestCase): + def test_frische_messung_landet_mit_ihrer_eigenen_zeit(self): + k = lauf(Koordinator(alter_s=12, spannung=12.4)) + self.assertEqual(len(k.ablage.eintraege), 1) + e = k.ablage.eintraege[0] + # Der entscheidende Unterschied zum alten Stand: nicht "jetzt", sondern + # der Zeitpunkt der Messung - und der Tag wird aus ihm abgeleitet. + self.assertEqual(e["ts"], k.gemessen.isoformat()) + self.assertEqual(e["tag"], k.gemessen.date().isoformat()) + self.assertEqual(e["spannung"], 12.4) + self.assertEqual(k.veroeffentlicht, 1) + + def test_veraltete_messung_landet_gar_nicht(self): + """Der Fall 16.08.2026 - Eintrag ohne eine einzige Meldung an dem Tag.""" + k = lauf(Koordinator(alter_s=885 * 60, spannung=13.024)) + self.assertEqual(k.ablage.eintraege, []) + self.assertEqual(k.veroeffentlicht, 0) + + def test_unplausible_messung_landet_nicht(self): + k = lauf(Koordinator(alter_s=12, spannung=0.0)) + self.assertEqual(k.ablage.eintraege, []) + + +class WieOftGewarntWird(unittest.TestCase): + def test_frische_unplausible_messung_warnt(self): + with self.assertLogs("custom_components.audi_dashboard.batterie", level="DEBUG") as p: + lauf(Koordinator(alter_s=12, spannung=0.0)) + self.assertTrue(any(r.levelno == logging.WARNING for r in p.records)) + + def test_veraltete_unplausible_messung_warnt_nicht(self): + """Sonst steht dieselbe Zeile alle fünf Minuten im Protokoll.""" + with self.assertLogs("custom_components.audi_dashboard.batterie", level="DEBUG") as p: + lauf(Koordinator(alter_s=5 * 3600, spannung=0.0)) + self.assertFalse(any(r.levelno >= logging.WARNING for r in p.records)) + + +if __name__ == "__main__": + unittest.main(verbosity=2)