Statistik-Icon-Groesse, Batteriediagramm-Ueberarbeitung, Temperatur-Kopplung mit 300s-Fenster, letzte gueltige Spannung als Fallback

- Statistik-Tab-Icon per zentrierter Stauchung an die Fuellflaeche der
  anderen Tab-Icons angeglichen (Panel + companion-app), auf Nutzerwunsch
  anschliessend 10% groesser.
- Batteriediagramm neu gezeichnet: dynamische viewBox (1 Einheit = 1 Pixel,
  kein preserveAspectRatio-Verzerren mehr), ein Punkt je Ruhespannungsmessung
  statt Datumsbeschriftung, Tageshoechstwert nur noch im Tooltip. Generator-
  spannungen (> AGM_RUHE_MAX_V = 13,0 V, vom Nutzer festgelegt) bleiben aus
  dem Diagramm aussen vor; der SOC-Kurve-Eckwert "100% / voll" bleibt bei den
  ursprünglich recherchierten 12,8 V. companion-app auf dieselbe lineare
  SOC-Interpolation umgestellt (vorher eine abweichende Stufenkurve).
- Außentemperatur wird jetzt auch beim Batterieverlauf-Import nachgetragen
  (vorher nur live) - Kopplung an die naechstgelegene Messung innerhalb
  NEBENWERT_MAX_ABSTAND_S (verlauf.py), vom Nutzer nach Live-Daten-Analyse
  auf 300s festgelegt. Merge-Logik in ablage.py ergaenzt: ein erneuter Import
  kann eine zuvor fehlende Temperatur jetzt tatsaechlich nachtragen, auch
  wenn sich der Tagesminimalwert selbst nicht aendert.
- "Mein Audi"/Zustand zeigt bei unplausibler Live-Messung (z. B. Dongle
  offline) die zuletzt gemessene Spannung statt "unbekannt".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 23:23:12 +02:00
parent 80d73e8afd
commit 172db54e7a
12 changed files with 626 additions and 85 deletions
+13 -1
View File
@@ -349,7 +349,17 @@ class Ablage:
ihn beim ersten Wert des Tages an, erweitert sonst nur min/max samt
dem Zeitstempel der jeweils neuen Extremmessung. min_temp_c wird nur
mitgeschrieben, wenn diese Messung auch den Minimalwert setzt - sie
gilt für genau diesen Zeitpunkt, nicht für den Tag insgesamt."""
gilt für genau diesen Zeitpunkt, nicht für den Tag insgesamt.
Sonderfall, ohne den ein erneuter Import nichts nachliefern könnte:
meldet ein späterer Aufruf exakt denselben (bereits gespeicherten)
Minimalwert erneut, aber mit einer jetzt verfügbaren Temperatur, wo
vorher keine lag - z. B. weil NEBENWERT_MAX_ABSTAND_S seither
aufgeweitet wurde und die damals zu weit entfernte Temperaturmessung
jetzt innerhalb der Grenze liegt -, wird min_temp_c nachträglich
gefüllt. `spannung < eintrag["min"]` allein hätte das nie erlaubt:
derselbe Wert ist nie *kleiner* als sich selbst, also griff die
Aktualisierung nur beim allerersten Mal."""
async with self._sperre:
verlauf = await self.batterieverlauf_lesen()
for eintrag in verlauf:
@@ -359,6 +369,8 @@ class Ablage:
eintrag["min"] = spannung
eintrag["min_ts"] = ts
eintrag["min_temp_c"] = aussentemp
elif spannung == eintrag["min"] and eintrag.get("min_temp_c") is None and aussentemp is not None:
eintrag["min_temp_c"] = aussentemp
if spannung > eintrag["max"]:
eintrag["max"] = spannung
eintrag["max_ts"] = ts
+26 -5
View File
@@ -49,6 +49,7 @@ import logging
from typing import TYPE_CHECKING
from .veroeffentlichung import zustand_oder_none
from .verlauf import NEBENWERT_MAX_ABSTAND_S
if TYPE_CHECKING:
from .koordinator import Koordinator
@@ -79,13 +80,33 @@ async def pruefen(k: Koordinator) -> None:
)
return
# Nicht nur "gibt es einen Temperaturwert", sondern "ist er auch aktuell":
# zustand_oder_none() liefert unabhängig vom Alter den zuletzt bekannten
# Zustand - meldet der Temperatursensor seltener als die Spannung (im
# Testbestand teils erst nach 30+ Stunden erneut), würde sonst ein
# beliebig alter Temperaturwert einer frischen Spannungsmessung
# zugeschlagen. Verglichen wird gegen den Zeitpunkt der Spannungsmessung
# selbst (last_updated des Spannungssensors), nicht gegen "jetzt" - beide
# Sensoren werden aus dem aktuellen hass.states-Stand gelesen, können aber
# unterschiedlich alt sein. Grenze und Begründung siehe
# NEBENWERT_MAX_ABSTAND_S (verlauf.py) - vom Nutzer festgelegt.
aussentemp = None
temp_wert = zustand_oder_none(k.hass, k.zuordnung.werte.AUSSENTEMP_SENSOR)
temp_sensor = k.zuordnung.werte.AUSSENTEMP_SENSOR
temp_wert = zustand_oder_none(k.hass, temp_sensor)
if temp_wert is not None:
try:
aussentemp = float(temp_wert)
except (TypeError, ValueError):
pass
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:
try:
aussentemp = float(temp_wert)
except (TypeError, ValueError):
pass
else:
_LOGGER.debug(
"Außentemperatur %s s von der Spannungsmessung entfernt - nicht mit erfasst",
round(abstand_s),
)
jetzt = datetime.datetime.now(datetime.UTC)
heute = datetime.date.today().isoformat()
@@ -1 +1 @@
{"version":"2026.8.27.7","sha256":"4631011c09be09efcdf787ba86ad1bd1fb40006f0d247e8b99b29b0a196f2da3","bytes":235579,"gebaut":"2026-08-27T17:32:23Z"}
{"version":"2026.8.27.17","sha256":"9ccedff2ae407751703713cc91b37bb51124f6fb5b98014ef2ae49659e51de2a","bytes":236026,"gebaut":"2026-08-27T21:03:47Z"}
@@ -1672,19 +1672,46 @@ function vAudi() {
Ziehen mit einem Finger, Antippen wählt den nächstgelegenen Messpunkt.
bvZeichnen() aktualisiert nur die SVG/Tooltip-Inhalte (kein render()), damit
Zoom/Pan flüssig bleiben. */
/* bvPunkte enthält nur Ruhespannungs-Messwerte (<= AGM_RUHE_MAX_V) - sowohl
Grundlage für Ladezustand/SOH-Trend als auch für das Diagramm selbst.
Generatorspannungen (> AGM_RUHE_MAX_V, z. B. 13,01 V) tauchen dort bewusst
nicht auf (Nutzerwunsch: "13,01 shall be generator und not part of the
diagram") - anders als in einer Zwischenfassung, die kurzzeitig jede
Messung zeichnete; die Messwertliste (vBatterieliste()) zeigt weiterhin
ausnahmslos alle Tage samt "Generatorspannung"-Kennzeichnung, sie liest
direkt aus BATTERIEVERLAUF, nicht aus bvPunkte. */
let bvVollDomain = null, bvDomain = null, bvPunkte = [], bvAusgewaehlt = null, bvYDomain = null;
const bvZeiger = new Map(); // pointerId -> {x,y}, für Pinch (2 Finger) vs. Pan (1 Finger)
let bvGestStart = null; // {type:"pan"|"pinch", ...Ausgangswerte der Geste}
let bvTapKandidat = null, bvBewegtSeit = false;
const BV_W = 320, BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 22;
/* BV_W ist nur der Startwert: bvZeichnen() setzt die viewBox auf die
tatsächlich gerenderte Pixelbreite (bvBreite), damit eine Einheit im
Koordinatensystem genau einem Bildschirmpixel entspricht. Vorher stand hier
eine feste 320er-viewBox mit preserveAspectRatio="none" - auf der 736px
breiten Desktop-Spalte wurde dadurch alles um Faktor 2,3 in die Breite
gezogen, sichtbar an den unnatürlich breit gelaufenen Achsenzahlen und an
überlappenden Beschriftungen. BV_MB ist klein, seit die Datumsbeschriftung
unter dem Diagramm entfällt (Datum steht beim Antippen im Tooltip). */
const BV_H = 170, BV_ML = 40, BV_MR = 10, BV_MT = 10, BV_MB = 12;
const BV_W = 320;
let bvBreite = BV_W;
const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" });
/* Referenzwerte für AGM-Starterbatterien (12V), recherchiert u. a. bei
aboutdarwin.com/agm-battery-voltage-charts und brsbattery.com - eine volle
AGM-Batterie liegt in Ruhe (min. 2h ohne Last/Ladung) bei 12,8-13,0 V,
50 % Ladezustand bei 12,2-12,3 V, unter 11,8 V gilt sie als kritisch
entladen. Der Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V -
deutlich über dem Ruhespannungs-Maximum.
/* Referenzwerte für AGM-Starterbatterien (12V). Zwei unterschiedliche Zahlen,
nicht eine: AGM_RUHE_MAX_V ist die Klassifizierungsgrenze "gilt diese
Messung überhaupt noch als Batterie(ruhe)spannung, nicht schon
Generatorspannung" (Diagramm/SOH-Trend) - vom Nutzer direkt auf 13,0 V
festgelegt ("max voltage for AGM Battery is 13V, everything above is the
generator"). BV_SOC_TABELLE (unten) ist davon unabhängig die
Ladezustands-Kurve selbst - deren "100 % / voll"-Eckwert bleibt bei den
ursprünglich recherchierten 12,8 V (aboutdarwin.com/agm-battery-voltage-
charts, brsbattery.com: volle Ruhespannung), vom Nutzer ausdrücklich
bestätigt ("12,8v voll was already right"). Eine Messung zwischen 12,8 und
13,0 V zählt also als gültige Batteriemessung (Diagrammpunkt, fließt in
SOH ein), zeigt aber weiterhin 100 % SOC - dieselbe Klemmung, die die
Tabelle für jeden Wert >= ihrem letzten Eckwert ohnehin schon anwendet. Der
Ladealternator liefert im Betrieb üblicherweise 13,5-14,8 V - deutlich über
beiden Grenzen, die Trennung zur Generatorspannung bleibt also eindeutig.
Das Backend (batterieverlauf.py) zeichnet pro Tag min UND max roh auf,
ohne Filterung - der Sensor meldet künftig mehrfach pro Stunde (aktive
@@ -1693,12 +1720,8 @@ const kurzdat = (d) => d.toLocaleDateString("de-DE", { day: "2-digit", month: "2
Moment kurz nach dem Start, bevor der Alternator hochfährt, und ist damit
die für den Ladezustand aussagekräftige Größe. Tage, an denen selbst das
Minimum über der Grenze liegt (Fahrzeug nie im Ruhezustand beobachtet),
tauchen entsprechend nicht in der Ladezustands-Statistik auf. Die Grenze
liegt bei 12,8 V (voller Ruhezustand laut obiger Recherche) statt z. B.
13,2 V, weil alles darüber bereits vom Generator stammt, nicht von der
Batterie selbst - solche Werte würden den Ladezustand künstlich zu gut
aussehen lassen. */
const AGM_RUHE_MAX_V = 12.8;
tauchen entsprechend nicht in der Ladezustands-Statistik auf. */
const AGM_RUHE_MAX_V = 13.0;
const BV_SOC_TABELLE = [
[10.5, 0], [11.8, 10], [11.9, 20], [12.0, 30], [12.1, 40],
[12.2, 50], [12.4, 60], [12.5, 70], [12.6, 80], [12.7, 90], [12.8, 100],
@@ -1769,12 +1792,17 @@ function bvSohBewertung() {
}
function vBatterieverlauf() {
// Der Diagrammpunkt und die Kurve bestehen ausschließlich aus dem
// Tagesminimalwert (siehe AGM_RUHE_MAX_V-Kommentar oben) - min_ts ist der
// Ein Punkt je Tag mit einer echten Ruhespannungsmessung (<= AGM_RUHE_MAX_V,
// z. B. 13,0 V), gezeichnet wird der Tagesminimalwert - min_ts ist der
// echte Zeitstempel genau dieser Einzelmessung, nicht ein fiktiver
// Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann.
bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min <= AGM_RUHE_MAX_V)
.map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum }))
// Tagesmittelpunkt, damit Antippen Datum UND Uhrzeit zeigen kann. Der
// Tageshöchstwert bekommt bewusst KEINEN eigenen Punkt, er steht beim
// Antippen im Tooltip. Tage, deren Minimum über der Grenze liegt (nie
// wirklich in Ruhe beobachtet, sondern nur Generatorspannung), tauchen im
// Diagramm nicht auf (Nutzerwunsch) - die Messwertliste zeigt sie weiterhin
// alle, mit "Generatorspannung"-Kennzeichnung.
bvPunkte = BATTERIEVERLAUF.filter((e) => e.min != null && e.min_ts && e.min <= AGM_RUHE_MAX_V)
.map((e) => ({ t: new Date(e.min_ts).getTime(), v: e.min, vMin: e.min, vMax: e.max, minTs: e.min_ts, datum: e.datum, temp: e.min_temp_c }))
.sort((a, b) => a.t - b.t);
bvAusgewaehlt = null; bvZeiger.clear(); bvGestStart = null;
@@ -1790,7 +1818,9 @@ function vBatterieverlauf() {
bvYDomain = genug ? [10, 15] : null;
const stufen = bvFarbstufen();
const letzter = bvPunkte.length ? bvPunkte[bvPunkte.length - 1] : null;
// Ladezustand nur aus der letzten echten Ruhespannung - eine
// Generatorspannung ergäbe sonst dauerhaft "100 % · Sehr gut".
const letzter = bvRuhePunkte.length ? bvRuhePunkte[bvRuhePunkte.length - 1] : null;
const soc = letzter ? bvSocProzent(letzter.v) : null;
const soh = bvSohBewertung();
const punktFarbe = (status) => (status === "ok" ? "var(--ok)" : status === "warn" ? "var(--warn)" : status === "bad" ? "var(--bad)" : "var(--fg3)");
@@ -1806,7 +1836,7 @@ function vBatterieverlauf() {
</div>
${genug ? `
<div id="bvChart" style="margin-top:14px;touch-action:none;position:relative">
<svg id="bvSvg" viewBox="0 0 ${BV_W} ${BV_H}" preserveAspectRatio="none" style="width:100%;height:${BV_H}px;display:block"></svg>
<svg id="bvSvg" viewBox="0 0 ${BV_W} ${BV_H}" style="width:100%;height:${BV_H}px;display:block"></svg>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px">
<span class="label" id="bvRange" style="margin-top:0"></span>
@@ -1871,7 +1901,7 @@ function vBatterieliste() {
}).join("")}</div>`;
}
function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (BV_W - BV_ML - BV_MR); }
function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (bvBreite - BV_ML - BV_MR); }
/* Auf [0,1] geklemmter Anteil statt roher Extrapolation - ein Punkt außerhalb
von yMin/yMax (z. B. ein Ausreißer, der die Plausibilitätsgrenze auf einer
noch nicht aktualisierten Instanz umgangen hat) zeichnet sich sonst weit
@@ -1890,8 +1920,10 @@ function bvDomainKlemmen(domain) {
if (b > bvVollDomain[1]) { b = bvVollDomain[1]; a = b - breite; }
return [a, b];
}
/* Eine Koordinateneinheit ist seit der dynamischen viewBox genau ein Pixel
(siehe bvBreite), die Ränder gelten also unskaliert. */
function bvPlotRahmen(rect) {
return { linksPx: rect.left + rect.width * (BV_ML / BV_W), breitePx: rect.width * ((BV_W - BV_ML - BV_MR) / BV_W) };
return { linksPx: rect.left + BV_ML, breitePx: Math.max(1, rect.width - BV_ML - BV_MR) };
}
function bvZeichnen() {
@@ -1902,6 +1934,19 @@ function bvZeichnen() {
// nicht mehr "streckt".
const [yMin, yMax] = bvYDomain;
/* Koordinatensystem an die echte Rendergröße koppeln: eine Einheit = ein
Pixel. Ohne das zog die feste 320er-viewBox mit preserveAspectRatio="none"
auf breiten Spalten alles in die Breite, Achsenzahlen inklusive.
Ein ResizeObserver hält das beim Fensterwechsel nach (die viewBox selbst
ändert die Elementgröße nicht, deshalb keine Endlosschleife). */
const gemessen = svg.getBoundingClientRect().width;
if (gemessen > 0) bvBreite = gemessen;
svg.setAttribute("viewBox", `0 0 ${bvBreite.toFixed(1)} ${BV_H}`);
if (!svg._bvBeobachtet && typeof ResizeObserver !== "undefined") {
svg._bvBeobachtet = true;
new ResizeObserver(() => bvZeichnen()).observe(svg);
}
// etwas über den sichtbaren Bereich hinaus zeichnen, damit die Linie am
// Rand nicht mitten in der Luft abbricht, sondern zum nächsten (Rand-)Punkt
// weiterläuft
@@ -1911,15 +1956,12 @@ function bvZeichnen() {
const gitter = [0, 1, 2, 3].map((i) => {
const v = yMin + ((yMax - yMin) * i) / 3, y = bvSkalaY(v, yMin, yMax);
return `<line x1="${BV_ML}" y1="${y.toFixed(1)}" x2="${BV_W - BV_MR}" y2="${y.toFixed(1)}" stroke="var(--line)" stroke-width="0.5"/>
return `<line x1="${BV_ML}" y1="${y.toFixed(1)}" x2="${(bvBreite - BV_MR).toFixed(1)}" y2="${y.toFixed(1)}" stroke="var(--line)" stroke-width="0.5"/>
<text x="${BV_ML - 6}" y="${(y + 3).toFixed(1)}" font-size="11" fill="var(--fg3)" text-anchor="end">${de(v, 1)}</text>`;
}).join("");
const xLabels = [0, 0.5, 1].map((f) => {
const x = BV_ML + f * (BV_W - BV_ML - BV_MR);
const t = bvDomain[0] + f * spanne;
return `<text x="${x.toFixed(1)}" y="${BV_H - 6}" font-size="11" fill="var(--fg3)"
text-anchor="${f === 0 ? "start" : f === 1 ? "end" : "middle"}">${kurzdat(new Date(t))}</text>`;
}).join("");
/* Keine Datumsbeschriftung unter dem Diagramm (Nutzerwunsch): der Zeitraum
steht als Text unter der Zeichenfläche (#bvRange), das Datum einer
einzelnen Messung erscheint beim Antippen im Tooltip. */
const stufen = bvFarbstufen();
const kreise = rahmen.map((p) => {
const idx = bvPunkte.indexOf(p);
@@ -1928,8 +1970,20 @@ function bvZeichnen() {
r="${gewaehlt ? 4.5 : 2.4}" fill="${bvFarbe(p.v, stufen)}"
${gewaehlt ? 'stroke="var(--fg)" stroke-width="1"' : ""}/>`;
}).join("");
/* Sanfte Flaechenfuellung unter der Linie (wie iOS Health/Aktie-Charts) -
macht aus der duennen Linie auf der jetzt vollen Spaltenbreite ein
"fertiges" Diagramm statt eines leeren Bandes. Rein dekorativ, keine
neue Information; niedrige Deckkraft, damit das Gitter durchscheint. */
const flaeche = rahmen.length >= 2
? `<path d="M${bvSkalaX(rahmen[0].t).toFixed(1)},${(BV_H - BV_MB).toFixed(1)} L${linie.split(" ").join(" L")} L${bvSkalaX(rahmen[rahmen.length - 1].t).toFixed(1)},${(BV_H - BV_MB).toFixed(1)} Z"
fill="url(#bvGrad)" stroke="none"/>`
: "";
svg.innerHTML = `${gitter}<polyline points="${linie}" fill="none" stroke="var(--fg)" stroke-width="1.5"/>${kreise}${xLabels}`;
svg.innerHTML = `<defs><linearGradient id="bvGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="var(--fg)" stop-opacity="0.16"/>
<stop offset="1" stop-color="var(--fg)" stop-opacity="0"/>
</linearGradient></defs>${gitter}${flaeche}<polyline points="${linie}" fill="none" stroke="var(--fg)"
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>${kreise}`;
const rangeEl = $("#bvRange");
if (rangeEl) rangeEl.textContent = `${kurzdat(new Date(bvDomain[0]))} ${kurzdat(new Date(bvDomain[1]))}`;
@@ -1939,7 +1993,15 @@ function bvZeichnen() {
const p = bvPunkte[bvAusgewaehlt];
const zeit = new Date(p.minTs);
tip.style.display = "block";
tip.textContent = `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr · Min ${de(p.vMin, 1)} V · Max ${de(p.vMax, 1)} V`;
/* Der Höchstwert des Tages hat bewusst keinen eigenen Punkt im Diagramm
(Nutzerwunsch) - hier ist die Stelle, an der er sichtbar wird. */
const teile = [
`${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr`,
`Min ${de(p.vMin, 2)} V`,
];
if (p.vMax != null) teile.push(`Max ${de(p.vMax, 2)} V`);
if (p.temp != null) teile.push(`${de(p.temp, 1)} °C`);
tip.textContent = teile.join(" · ");
} else {
tip.style.display = "none";
}
@@ -3622,8 +3684,17 @@ const ICONS = {
audi: '<path d="M5.46 4.05a80 80 0 0 1 13.08 0l.24.02.14.2A55 55 0 0 1 21.9 9.2l2.1 2.1v8.2c0 .83-.67 1.5-1.5 1.5h-2a1.5 1.5 0 0 1-1.5-1.5V19l-14-.04v.54c0 .83-.67 1.5-1.5 1.5h-2A1.5 1.5 0 0 1 0 19.5v-8.2l2.1-2.1a55 55 0 0 1 2.98-4.93l.14-.2zM2.7 10 1 11.7v.3h4v1H1v6.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5v-1.55L20 18v1.5c0 .28.22.5.5.5h2a.5.5 0 0 0 .5-.5V13h-4v-1h4v-.3L21.3 10zm17.94-1a45 45 0 0 0-2.43-3.98 78 78 0 0 0-12.44 0L4.51 7c-.3.5-.7 1.16-1.16 2.01h17.3ZM7.3 12h9.42l1.36 1.36-.1.3c-.31 1-.6 1.78-.88 2.31-.13.27-.27.5-.41.67-.13.15-.36.36-.68.36H8a.9.9 0 0 1-.68-.36c-.14-.17-.28-.4-.41-.67a17 17 0 0 1-.89-2.32l-.09-.29zm.42 1-.64.64c.28.84.52 1.47.73 1.89a2.4 2.4 0 0 0 .3.47h7.8l.02-.01c.07-.08.16-.23.28-.46.21-.42.45-1.05.73-1.9L16.3 13H7.71Z"/>',
trips: '<circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="M8 7.5c4 1.5 4.5 3 3 5s-1 3.5 4.5 5"/>',
/* CI-Symbol polls-s statt der vorherigen handgezeichneten Balken - dieselbe
Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). */
stat: CI.pollsS,
Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). Anders
als die handgezeichneten Tab-Icons (die im 24er-Raster deutlich Rand
lassen, ~32-47% Fuellflaeche laut Live-Messung per getBBox()) ist das
CI-Symbol randlos gezeichnet (~92% Fuellflaeche) und wirkte dadurch neben
"Fahrten"/"Tanken" sichtbar zu gross. Zentrierte Stauchung auf das
gemessene Grössenverhältnis (Faktor 0.77, um (12,12) - auf Nutzerwunsch
10% größer als die erste, auf ~45% Fuellflaeche gerechnete Fassung)
statt einer eigenen Icon-Groesse: bleibt so bei jeder Breakpoint-Groesse
(.tab svg 26px/22px) automatisch im richtigen Verhältnis, ohne zwei
Stellen pflegen zu müssen. */
stat: `<g transform="translate(2.76,2.76) scale(0.77)">${CI.pollsS}</g>`,
fuel: '<path d="M4.5 20V6.2c0-.9.8-1.7 1.7-1.7h5.6c1 0 1.7.8 1.7 1.7V20"/><path d="M3 20h12.5"/><path d="M4.5 11h10.5"/><path d="M17 20v-8.5a2 2 0 0 0-2-2"/><path d="M17 11.5V7.8l-2-2.3"/>',
};
const TABS = [
@@ -52,6 +52,7 @@ from .verlauf import (
verbrauch_aus_literstaenden,
verlauf_lesen,
wert_bei,
wert_in_naehe,
zahl,
)
@@ -307,10 +308,26 @@ async def _batterie_importieren(k: Koordinator, verlaeufe: dict) -> dict:
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."""
Einträge nicht mehr - das gilt auch für die Außentemperatur (siehe unten),
ein erneuter Import über einen bereits importierten Zeitraum ergänzt sie
dort nachträglich, ohne etwas doppelt zu schreiben.
Trägt bei jedem Tagesminimum auch die Außentemperatur nach, sofern
AUSSENTEMP_SENSOR zugeordnet ist - Parität zur Live-Erfassung
(batterie.py's pruefen()), die das seit ihrer Einführung bereits tut. Bis
zu dieser Korrektur fehlte das hier: verlaeufe["aussentemp"] wurde gar
nicht erst gelesen, ein Import füllte min_temp_c also nie, selbst wenn die
Temperaturhistorie im recorder für denselben Zeitraum längst vorlag.
wert_in_naehe() statt naechster_wert(): der Temperatursensor dieses
Testbestands meldet teils erst nach 30+ Stunden erneut - ohne Zeitgrenze
würde ein beliebig alter Temperaturwert der frischen Spannungsmessung
zugeschlagen. Siehe NEBENWERT_MAX_ABSTAND_S (verlauf.py) für die vom
Nutzer festgelegte Grenze."""
verlauf = verlaeufe["batterie"]
if not verlauf:
return {"tage": 0}
aussentemp_verlauf = verlaeufe.get("aussentemp") or []
tage: dict[str, dict] = {}
for ts, wert in verlauf:
@@ -336,8 +353,11 @@ async def _batterie_importieren(k: Koordinator, verlaeufe: dict) -> dict:
for tag in sorted(tage):
werte = tage[tag]
aussentemp = (
wert_in_naehe(werte["min_ts"], aussentemp_verlauf) if aussentemp_verlauf else None
)
await k.ablage.batterieverlauf_tageswert_aktualisieren(
tag, werte["min_ts"].isoformat(), werte["min"]
tag, werte["min_ts"].isoformat(), werte["min"], aussentemp
)
await k.ablage.batterieverlauf_tageswert_aktualisieren(
tag, werte["max_ts"].isoformat(), werte["max"]
@@ -382,6 +402,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis),
"tank_liter": await verlauf_lesen(k.hass, werte.TANK_LITER_SENSOR, von, bis),
"batterie": await verlauf_lesen(k.hass, werte.BATTERIE_SENSOR, von, bis),
"aussentemp": await verlauf_lesen(k.hass, werte.AUSSENTEMP_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),
}
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.27.8",
"version": "2026.8.27.17",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],
@@ -192,3 +192,43 @@ def naechster_wert(
bester_wert = gezahlt
beste_diff = diff
return bester_wert
# Wie lange eine Nebenmessung (z. B. Außentemperatur) höchstens von der
# Hauptmessung entfernt liegen darf, um noch als "zur selben Zeit" zu gelten -
# vom Nutzer auf 300s (5 Minuten) festgelegt, nach ursprünglich 60s. Der
# Wechsel ist an echten FMM003-Daten begründet, nicht geschätzt: das Gerät
# sendet Außentemperatur deutlich seltener als Spannung (an einem
# beobachteten Fahrtfenster 851 Spannungs- gegen 187 Temperaturmeldungen in
# 48h) - selbst bei laufender Fahrt und ohne jede Verbindungsstörung wurden
# reale Lücken bis 593s zwischen zwei Temperaturmeldungen gemessen. 60s hätte
# genau diesen völlig normalen Fall verworfen. Physikalisch unproblematisch:
# die Außentemperatur ändert sich über wenige Minuten kaum, anders als z. B.
# eine GPS-Position oder die Spannung selbst - und die Live-Prüfung
# (batterie.py's pruefen()) läuft ohnehin nur alle 5 Minuten, verlangt hier
# also keine feinere Genauigkeit, als sie selbst liefert. Ein Verbindungs-
# abbruch des Geräts (beobachtet: 45 Minuten, beide Sensoren gleichzeitig
# stumm) bleibt mit 300s weiterhin sicher außerhalb der Grenze und liefert
# bewusst KEINEN Wert statt eines beliebig alten.
NEBENWERT_MAX_ABSTAND_S = 300
def wert_in_naehe(
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt], max_abstand_s: float = NEBENWERT_MAX_ABSTAND_S
) -> float | None:
"""Wie naechster_wert(), aber None statt eines beliebig weit entfernten
Werts, wenn selbst der zeitlich nächste mehr als max_abstand_s von
zielzeit entfernt liegt."""
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
if beste_diff is None or beste_diff > max_abstand_s:
return None
return bester_wert
@@ -25,6 +25,16 @@ from homeassistant.core import HomeAssistant
from .einstellungen import POSITIONEN, Sensorzuordnung
# Letzte plausible Batteriespannung, für die "Zustand"-Kachel, wenn die
# aktuelle Live-Messung gerade unplausibel ist (Sensor kurzzeitig offline,
# Dongle getrennt o.ä.) - der Nutzer will dort die zuletzt gemessene Spannung
# sehen statt "unbekannt". RAM-only wie andere Laufzeitzustände dieses
# Projekts (siehe die bekannten Lücken zu Fahrtstart/Tankstand): kein Store,
# bewusst - ein Neustart zeigt kurz wieder "unbekannt", bis die nächste echte
# Messung eintrifft, statt einen möglicherweise sehr alten Wert aus einer
# Datei wiederzubeleben.
_letzte_gueltige_spannung: dict[str, float | None] = {"wert": None}
def zustand_oder_none(hass: HomeAssistant, entity_id: str | None) -> str | None:
"""Sicherer Zustandszugriff.
@@ -162,6 +172,14 @@ def fahrzeugstatus(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
# sonst zeigt die "Zustand"-Kachel einen Wert, der im Messwertverlauf
# nie auftaucht, weil er dort schon verworfen wird.
spannung = None
if spannung is not None:
_letzte_gueltige_spannung["wert"] = spannung
else:
# Nutzerwunsch: statt "unbekannt" die zuletzt gemessene Spannung
# zeigen, wenn der Sensor gerade nichts Plausibles liefert (z. B.
# Dongle offline, meldet dann oft 0 V) - None bleibt nur, solange
# noch nie ein gültiger Wert beobachtet wurde.
spannung = _letzte_gueltige_spannung["wert"]
return {
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),