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 <[email protected]>
This commit is contained in:
2026-08-27 23:23:12 +02:00
co-authored by Claude Sonnet 5
parent 80d73e8afd
commit 172db54e7a
12 changed files with 626 additions and 85 deletions
@@ -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 = [