Batteriespannungs-Messwertliste, Wisch-Löschen, Reifensatz-Archiv, CI-Icons

Fünf vom Nutzer angeforderte Erweiterungen in einer Runde:

- Batteriespannungs-Kachel bekommt eine Messwertliste (Datum, Uhrzeit,
  Spannung, optional Außentemperatur über den neuen AUSSENTEMP_SENSOR),
  erreichbar über ein neues list-s-Symbol in der Diagramm-Kachel. Werte
  über AGM_RUHE_MAX_V (12,8 V) sind keine Ruhespannung, sondern
  Lichtmaschinenspannung - die Zeile markiert das jetzt mit
  "Generatorspannung" statt es unkommentiert als Messwert auszugeben.
- Jede Zeile der neuen Liste wischbar zum Löschen (neuer Dienst
  batterieverlauf_loeschen), über dieselbe Wisch-Mechanik wie Fahrten/
  Tankvorgänge in beiden Oberflächen.
- Batteriespannungs-Diagramm im Panel war auf großen Bildschirmen (≥860px,
  Spaltenlayout) stark in die Breite gezogen (preserveAspectRatio="none"
  bei fester Höhe) - gedeckelt wie die dort bereits vorhandenen Popups.
- Statistik-Tab-Symbol in beiden Oberflächen durch das Audi-CI-Symbol
  polls-s ersetzt.
- Neue Möglichkeit, einen Reifensatz zu archivieren ("Neue Räder
  anlegen"): der aktuelle Stand (km, Marke, Modell, DOT, Maße, Solldruck,
  Kommentar) wandert in ein Archiv, der laufende Satz beginnt bei 0 km neu.
  Archiv als aufklappbare Übersicht mit voller Bearbeitungsmöglichkeit,
  über drei neue, bewusst nicht über profilSchreiben laufende Dienste
  (reifen_archivieren/-aktualisieren/-loeschen) - aus demselben Grund wie
  reifen_wechseln: kein im Browser gehaltener Stand darf einen
  zwischenzeitlich fortgeschriebenen km-Wert überschreiben.

Live im Testcontainer geprüft (erstmals über :18123 statt :8123 erreichbar)
und dabei zwei echte Layout-Fehler gefunden, die kein Compile-/Testlauf
sehen konnte: die Archiv-Eingabefelder waren durch ein fälschlich
verwendetes .mitEinheit (feste 96px-Breite, eigentlich für Zahl+Einheit
gedacht) abgeschnitten ("Continenta" statt "Continental"), und die
Kilometerzahl in der eingeklappten Archiv-Zeile war klein an das Datum
gequetscht statt wie der Satzname lesbar. Beides behoben, live erneut
bestätigt.

Backend: py_compile clean, services.yaml ergänzt. Panel: node --check
clean. companion-app: tsc/Testsuite (146/146)/Build/OTA-Bündel alle grün.
Manifest 2026.8.25.2 → .8, jeder Neustart im Testcontainer sauber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:44:24 +02:00
parent 70253e83a5
commit 17149937a6
23 changed files with 1025 additions and 31 deletions
+144
View File
@@ -2942,6 +2942,150 @@ confirmed mechanism the pending Sicherheit plan already documented. `screening.p
`TankDetail.tsx` were deliberately left untouched: the estimate flows through existing display/edit paths
without needing either to change.
## R. Five owner-requested features in one round: battery measurement list, swipe-delete, diagram-width fix,
CI icon substitutions, wheel-set archiving (2026.8.25.3/.4)
**1. Battery measurement list.** The battery-voltage tile only ever showed a hand-drawn chart. Added a
"list" corner button (CI `list-s`) that opens a new list view — one row per day (the existing daily
min/max data, unchanged storage shape) showing date, time, voltage, and now also outside air temperature.
Backend: new optional `AUSSENTEMP_SENSOR` role (`einstellungen.py`); `batterie.py`'s 5-minute check now also
reads it and passes it to `ablage.batterieverlauf_tageswert_aktualisieren()`, which gained an `aussentemp`
parameter — stored as `min_temp_c`, **only updated when a measurement also sets that day's minimum voltage**
(the temperature is tied to that specific reading, not a daily average). Older entries and setups without
the sensor simply carry `min_temp_c: None` — no backfill, no migration. New screens: companion-app
`BatterieListe.tsx` (route `battliste`, reached from an `IconButton`/`SymbolListe` in `Batterie.tsx`'s
header) and panel `vBatterieliste()` (route `battliste`, reached via a new `.iconbtn`/`ciSVG(CI.listS)`
button in `vBatterieverlauf()`'s tile header).
**2. Swipe-to-delete for the new list, per entry.** Reused the existing pattern in both codebases rather
than inventing a new one: companion-app's `Zeilenmenue` (wipe + accessible menu button, see its own
docstring) and the panel's `swipeZeile()`/`eintragLoeschen()`/`LOESCH_TEXT`/`LOESCH_HINWEIS` machinery
already used for trips/tank vorgaenge, extended with a `"batt"` art. New backend service
`batterieverlauf_loeschen` (field: `datum`) → `ablage.batterieverlauf_eintrag_loeschen()` (new) →
`batterie.eintrag_loeschen()` (new, wired in `dienste.py`).
**3. Battery diagram "still stretched" on a large monitor — panel only (confirmed with the owner; the
diagram in question doesn't exist as a separate wide layout in companion-app).** Root cause found in
`audi-dashboard-ios.css`: past 860px viewport width the whole app switches to a wide two-column layout
(`@container (min-width:860px)`) where `main#view`'s content column is 772px wide (860 2×44px side
padding). The battery chart's SVG (`#bvSvg`, `audi-dashboard-app.js`) is drawn on a fixed `viewBox="0 0 320
170"` with `preserveAspectRatio="none"` and `width:100%` — at 772px wide against a native 320-unit design,
that's a ~2.4× horizontal stretch with the height staying fixed at 170px, turning the chart into an
unnaturally wide, flat band. Fixed the same way the existing popups in that file already solve the identical
problem one section up: `#bvChart{max-width:400px;margin-left:auto;margin-right:auto}` inside the same
`@container` block, no changes to the SVG's own drawing logic (`BV_W`/`BV_H`/`bvZeichnen()`), which section
O of this file already spent real effort getting right for a *different* stretch bug (the Y-axis
rescaling one) — deliberately left untouched here.
**4. CI icon substitutions — `list-l/-s`, `polls-l/-s`, `package-l/-s`, all requested by the owner
(SVG source supplied directly from their local Audi icon export folder, same as every other CI icon in this
codebase — see the recurring "vom Nutzer als SVG geliefert, verbatim uebernommen" comment convention).**
Added to the `CI` catalog in `audi-dashboard-app.js`. `polls-s` replaces the Statistik tab's hand-drawn bar
icon (`ICONS.stat`) in the panel's tab bar — needed adding `"stat"` to `ICONS_VOLL` (the existing mechanism
for filled-vs-outline icon rendering; CI paths carry their own `fill="currentColor"` but would otherwise
also inherit the tab bar's `stroke:currentColor` from `.tab svg`, doubling the line weight around the
shape). Companion-app's `symbole.tsx` had no filled-icon mechanism at all yet (its `SymbolAudi` etc. are
plain stroked outlines, unlike the panel's CI-icon-based tab bar) — added a `voll` prop to the shared
`Rahmen` wrapper (`fill`/`stroke` swap) as the equivalent mechanism, then `SymbolStatistik` (parity with
the panel), plus `SymbolListe`/`SymbolPaket` for the other two icon uses below. `list-s`/`package-s` are the
corner/inline buttons described in points 1 and 5; the `-l` (48-grid) variants are stored for parity with
every other CI icon pair in the catalog but aren't wired to a render path yet, same as the pre-existing
`editL`/`oilChangeL`/etc.
**5. Wheel-set archiving ("Neue Räder anlegen").** A `package-s` button sits between the wheel photo and
the editable Hersteller/Modell fields on the Reifen page in both codebases (panel: inline always-editable
`data-rf` inputs, matching that page's existing convention; companion-app: read-only `Wertzeile`s with a
separate edit form, matching *its* existing convention — no new interaction pattern introduced in either).
Pressing it asks "Neue Räder anlegen?" (panel: the existing `bestaetigen()` action sheet; companion-app: the
existing `window.confirm`-based `bestaetigen()` already used by `wechseln()` on the same page) before doing
anything. On confirmation, `reifen.py`'s new `archivieren(k, satz)`: snapshots the season's *entire* current
record (km, marke, modell, dot, mass, druck_vorne/hinten, kommentar, plus a generated id and today's date as
`ersetzt_am`) into `reifen.archiv` (newest first), then resets the live record — **km to 0 (explicit owner
decision) and marke/modell/dot/kommentar to blank** (a replacement tire hasn't been characterized yet),
**but mass/druck_vorne/druck_hinten/bild carried over** (size and target pressure don't usually change for
a same-fitment replacement — a judgment call, not something the owner specified; flag if wrong). Three new
services (`reifen_archivieren`, `reifen_archiv_aktualisieren`, `reifen_archiv_loeschen`), all deliberately
**bypassing `profilSchreiben`/`profilSpeichern`** for the same reason `reifen_wechseln`/`reifen_km_setzen`
already do (see `reifenzaehler.py`'s own docstring) — a full-profile round trip from a stale browser copy
could clobber a km value the backend fortgeschrieben in the meantime.
Overview UI: an "Archiv" tile at the bottom of the Reifen page, one row per archived set. Companion-app uses
the design system's `Accordion` (title = "Sommerräder"/"Winterräder", summary = "Ersetzt am ... · N km"),
expanding to a read-only `Werteliste` with a "Bearbeiten" button that swaps in an edit form (mirrors
`TankFormular`'s style) — plus a destructive "Löschen" button. The panel reuses its own existing
`acc-h`/`acc-b`/`data-acc` accordion mechanism (same as the trips/tankvorgaenge year/month lists) with
always-inline-editable fields (`data-rfa="id|feld"`, parallel to `data-rf`) that call
`reifen_archiv_aktualisieren` directly on change, plus a `data-reifenarchivloeschen` button.
`profilAdapter.ts` gained a `ReifenArchivEintrag` type and `Fahrzeug.reifen.archiv`; `profilZuFahrzeug()`
reads it straight off the profile (`?? []`), and `zusammenfuehren()` needed no change — it clones the raw
profile first and only ever touches `aktiv`/`anzugsmoment_nm`/`wechsel`/`saetze`, so `archiv` survives
untouched exactly like it already did for every other field this function doesn't know about.
Verified: `py_compile` clean on all six touched backend files (`einstellungen.py`, `ablage.py`,
`batterie.py`, `const.py`, `dienste.py`, `reifen.py`) plus `services.yaml` entries added for all four new
services; `node --check` clean on the panel JS; companion-app `tsc --noEmit` clean and full suite green at
**146/146** (one new test — the existing "every registered page renders with sample data" smoke test picked
up `battliste` automatically); `npm run build` and `npm run ota` both succeeded (OTA bundle rebuilt at
`2026.8.25.4`, sha256 `9d5ebc2a62da4bbddcd766a462726bb51a213ea4095e367e88a8e5d321a9f8e4`). Manifest bumped
`2026.8.25.2``.3` (backend) → `.4` (frontend + OTA), both restarts in `audi_ha_test` confirmed clean via
`"Audi Dashboard ... eingerichtet"` with no tracebacks, including the periodic battery-check log line
firing without error against the new `AUSSENTEMP_SENSOR` code path. **Not verified live in a browser**
the setup-menu entry for `AUSSENTEMP_SENSOR`, the new list screens, the icon substitutions' actual pixel
rendering, and the archive accordion/edit flow were none of them clicked through in this round (a
browser-tooling networking issue blocked reaching the test instance's UI, same blocker noted in section Q).
This entire round rests on static analysis, compiler/test-suite checks, and clean service-registration logs
— not on having seen any of it render.
## S. Live browser access to the test instance, a "Generatorspannung" remark, and two real bugs section R's
static-only verification missed (2026.8.25.5.8)
**The owner logged this session into the test instance's browser at `http://localhost:18123` (not the
`:8123` used earlier - a different, working port for this environment).** This unblocked live verification
for the first time since the FMM003 investigation's `/profile/security` refusal (section P) — worth noting
for future sessions: try `:18123` before assuming the browser tooling can't reach this project's test
container.
**Generator-voltage remark (2026.8.25.5):** `AGM_RUHE_MAX_V = 12.8` (the resting-vs-alternator-voltage
threshold, already used by the diagram's own point filter and the SOC estimate) was not applied to the new
measurement list from section R — a day whose minimum was never actually measured at rest still displayed
next to a date/time as if it were one. Companion-app: exported the constant from `Batterie.tsx`, added a
`Pill` reading "Generatorspannung" in `BatterieListe.tsx` when `min > AGM_RUHE_MAX_V`. Panel: same threshold
already in scope in `vBatterieliste()`, added a `.pill`-styled badge under the date in the `.v` column.
**Live verification then immediately found two real defects that every static check in section R had
missed** — `py_compile`/`tsc`/`node --check`/the test suite all passed because none of them render CSS:
1. **Archive edit fields visually truncated ("Continenta", "SportConta", "275/30 R2").** `vReifenArchiv()`'s
`feld()` helper unconditionally wrapped every input in `<span class="mitEinheit">` — a class meant for
short number+unit pairs (km, Nm, bar) that hard-codes `input{width:96px}`. None of the fields that
actually used this helper (marke/modell/mass/dot/kommentar) needed a unit at all, so the wrapper was pure
accident, not a deliberate width choice. Fixed by dropping `mitEinheit` from that helper entirely,
matching the plain `<input>` the live (non-archived) season fields already use one screen up. Confirmed
live: all three previously-clipped fields now render in full.
2. **Owner follow-up, also confirmed live before and after:** the archive accordion's collapsed summary
line squeezed `4.542 km` into a `<small>` immediately after the "Ersetzt am" date, and separately asked
for the Kommentar/Notiz field specifically to get more room. Both fixed the same way in both codebases:
Kommentar/Notiz now renders label-above-input at full width (`.feld--breit` in the panel CSS; a
bypassed-`Feld` block in companion-app) instead of sharing a row with its label like every other short
field — and the collapsed summary now shows the km figure at title size/color (`.acc-h .k`/
`.ads-acc-h__k`-equivalent styling) with the date dropped to its own smaller line below, instead of the
two being run together.
**Lesson for this project going forward, worth remembering deliberately:** a clean compile/test pass is not
evidence a layout is correct — none of the tooling in this repo renders CSS or checks visual width. Section
R shipped a real, user-visible truncation bug behind a fully green test suite. When browser access is
available (now confirmed reachable at `:18123`), prefer at least one live screenshot of any new or changed
input-heavy screen over relying on compiler output alone.
Verified: panel `node --check` clean at every step; companion-app `tsc --noEmit` clean, suite green at
146/146, `npm run build`/`npm run ota` succeeded. Manifest `2026.8.25.4``.5` (Generatorspannung) → `.6`
(mitEinheit fix) → `.7` (Kommentar width + archive header restyle, panel) → `.8` (same two, companion-app
parity + final OTA rebuild, sha256 `464c294c6c07dcb636a8ea3bd93c7e525b21722defb41f573e2b31fefade49ea`).
Every restart in `audi_ha_test` confirmed clean via log, **and this time also confirmed live in the browser**
at each step from `.6` onward — screenshots taken of the battery list (Generatorspannung pill rendering
correctly, including a real entry below the threshold correctly *not* tagged), the Statistik tab's `polls-s`
icon, and the Reifen archive section before and after both fixes.
---
## Working conventions (observed — keep them)
+53
View File
@@ -44,6 +44,20 @@ export interface TankvorgangFelder {
fahrt_manuell_anlegen/fahrt_aktualisieren).
Alles außer den Zeitpunkten ist optional: leer bleibt es liegen, bis das
Kilometerstand-Screening (§7.2) oder eine spätere Bearbeitung es füllt. */
/** Eingabefelder für einen archivierten Reifensatz - Namen wie beim Backend
(reifen.py: reifen_archiv_aktualisieren). */
export interface ReifenArchivFelder {
ersetzt_am?: string;
km?: number | null;
marke?: string | null;
modell?: string | null;
dot?: string | null;
mass?: string | null;
druck_vorne?: string | null;
druck_hinten?: string | null;
kommentar?: string | null;
}
export interface FahrtFelder {
ts_start?: string;
ts_end?: string;
@@ -174,6 +188,45 @@ export class DataMetricApi {
);
}
/** Löscht einen Tageseintrag aus der Batteriespannungs-Messwertliste. */
batterieverlaufEintragLoeschen(datum: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"batterieverlauf_loeschen",
{ datum },
"Messwert löschen",
);
}
/** "Neue Räder anlegen": archiviert den aktuellen Stand von `satz` und legt
einen frischen (km 0) an. */
reifenArchivieren(satz: "sommer" | "winter"): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_archivieren",
{ satz },
"Neue Räder anlegen",
);
}
reifenArchivAktualisieren(id: string, felder: ReifenArchivFelder): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_archiv_aktualisieren",
{ id, ...felder },
"Archivierten Reifensatz ändern",
);
}
reifenArchivLoeschen(id: string): Promise<unknown> {
return this.warteschlange.einreihen(
DIENST_DOMAIN,
"reifen_archiv_loeschen",
{ id },
"Archivierten Reifensatz löschen",
);
}
/** Stößt eine sofortige Aktualisierung im Backend an (Pull-to-refresh). */
jetztAktualisieren(): Promise<unknown> {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren");
+19
View File
@@ -36,6 +36,22 @@ export interface Reifensatz {
[weitere: string]: unknown
}
/** Ein per "Neue Räder anlegen" archivierter Reifensatz (reifen.py
archivieren()) - der vollständige Stand zum Zeitpunkt des Austauschs. */
export interface ReifenArchivEintrag {
id: string
satz: "sommer" | "winter"
ersetzt_am: string
km: number | null
marke: string | null
modell: string | null
dot: string | null
mass: string | null
druck_vorne: string | null
druck_hinten: string | null
kommentar: string | null
}
export interface Fahrzeug {
fin: string
details: string
@@ -74,6 +90,8 @@ export interface Fahrzeug {
wechsel: Record<string, unknown>
sommer: Reifensatz
winter: Reifensatz
/** Neueste zuerst - schon so im Profil (reifen.py archivieren()). */
archiv: ReifenArchivEintrag[]
}
service: Record<string, unknown>
technik: Record<string, unknown>
@@ -179,6 +197,7 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
wechsel: (reifen["wechsel"] ?? {}) as Record<string, unknown>,
sommer: saetze["sommer"] ?? {},
winter: saetze["winter"] ?? {},
archiv: (reifen["archiv"] as ReifenArchivEintrag[] | undefined) ?? [],
},
service: abschnitt(profil, "service"),
technik: abschnitt(profil, "technik"),
+1 -1
View File
@@ -37,7 +37,7 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
oel: { modus, km: 15000, monate: 12, herstellerKm: 30000, herstellerMonate: 24 },
versicherung: {},
steuer: { faellig: "" },
reifen: { aktiv: "Sommer", anzugsmoment: null, wechsel: {}, sommer: {}, winter: {} },
reifen: { aktiv: "Sommer", anzugsmoment: null, wechsel: {}, sommer: {}, winter: {}, archiv: [] },
service: {},
technik: {},
ausstattung: [],
+3
View File
@@ -17,6 +17,7 @@ export type SeitenName =
// Unterseiten von "Mein Audi"
| "ident"
| "battverlauf"
| "battliste"
| "service"
| "werkstatt"
| "sbuch"
@@ -54,6 +55,7 @@ export const ZURUECK: Partial<Record<SeitenName, SeitenName>> = {
reifen: "audi",
ident: "audi",
battverlauf: "audi",
battliste: "battverlauf",
vers: "audi",
beitrag: "vers",
vertrag: "vers",
@@ -78,6 +80,7 @@ export const TITEL: Record<SeitenName, string> = {
fuel: "Tanken",
ident: "Fahrzeugdaten",
battverlauf: "Batterie",
battliste: "Messwerte",
service: "Service",
werkstatt: "Werkstatt",
sbuch: "Servicebuch",
+14 -5
View File
@@ -13,11 +13,13 @@
import { useEffect, useState } from "react"
import { Tile } from "@audi-dash/ui"
import { IconButton, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, de } from "../format"
import type { SeitenName } from "../navigation"
import { SymbolListe } from "../symbole"
import { Leerzustand, Wertzeile, Werteliste } from "./bausteine"
interface Tageswert {
@@ -29,8 +31,10 @@ interface Tageswert {
}
/** Ab hier gilt eine Messung als nicht mehr in Ruhe aufgenommen - alles
darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. */
const AGM_RUHE_MAX_V = 12.8
darüber ist schon Lichtmaschinenspannung, nicht mehr Ruhespannung. Auch
von BatterieListe.tsx verwendet, für die "Generatorspannung"-Kennzeichnung
dort - eine einzige Schwelle für beide Ansichten. */
export const AGM_RUHE_MAX_V = 12.8
/** Spannung → Ladezustand für eine AGM-Batterie in Ruhe. */
const LADEKURVE: [number, number][] = [
@@ -49,7 +53,7 @@ function ladezustand(spannung: number): number | null {
return 0
}
export function Batterie() {
export function Batterie({ geheZu }: { geheZu: (name: SeitenName) => void }) {
const { api, fahrzeug } = useDaten()
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
const [fehler, setzeFehler] = useState(false)
@@ -97,7 +101,12 @@ export function Batterie() {
return (
<>
<Tile>
<span className="ads-eyebrow">Ruhespannung</span>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="ads-eyebrow">Ruhespannung</span>
<IconButton aria-label="Messwertliste" onClick={() => geheZu("battliste")}>
<SymbolListe groesse={18} />
</IconButton>
</div>
<div className="dm-detail__kopf">
<div className="dm-detail__zahl">
{letzter.min != null ? de(letzter.min, 2) : "—"}
+103
View File
@@ -0,0 +1,103 @@
/**
* Batteriespannungs-Messwertliste — ein Eintrag pro Tag (dieselben Daten wie
* das Diagramm in Batterie.tsx), aber als Liste: Datum, Uhrzeit, Spannung,
* Außentemperatur. Erreichbar über das Listen-Symbol oben rechts im
* Diagramm.
*
* Jede Zeile lässt sich wie Fahrten/Tankvorgänge nach links wegwischen, um
* sie zu löschen (Zeilenmenue - Wischgeste plus sichtbares Menü, siehe dort).
*
* Liegt der Tagesminimalwert über AGM_RUHE_MAX_V (Batterie.tsx), wurde er nie
* in Ruhe gemessen (das Fahrzeug stand an diesem Tag nie lange genug still) -
* die Zeile zeigt dann "Generatorspannung" statt so zu tun, als sei es ein
* Ruhewert.
*/
import { useEffect, useState } from "react"
import { Pill, Tile } from "@audi-dash/ui"
import { ENTITAETEN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import { datum, datumZeit, de } from "../format"
import { Leerzustand } from "./bausteine"
import { AGM_RUHE_MAX_V } from "./Batterie"
import { Zeilenmenue } from "./Zeilenmenue"
interface Tageswert {
datum: string
min: number | null
max: number | null
min_ts?: string
min_temp_c?: number | null
}
export function BatterieListe() {
const { api } = useDaten()
const [verlauf, setzeVerlauf] = useState<Tageswert[] | null>(null)
const [fehler, setzeFehler] = useState(false)
const neuLaden = () => {
void api.rest
.datenLesen<Tageswert[]>(ENTITAETEN.batterieverlauf)
.then((daten) => setzeVerlauf(Array.isArray(daten) ? daten : []))
.catch(() => setzeFehler(true))
}
useEffect(neuLaden, [api])
if (fehler) {
return (
<Leerzustand
titel="Liste nicht abrufbar"
text="Der Server hat die Messwertliste nicht geliefert. Beim nächsten Aufruf klappt es vielleicht wieder."
/>
)
}
if (!verlauf) return <div className="dm-laden">Liste wird geladen </div>
const messwerte = verlauf.filter((t) => t.min != null).slice().reverse()
if (messwerte.length === 0) {
return (
<Leerzustand
titel="Keine Messwerte"
text="Für dieses Fahrzeug liefert die Anbindung derzeit keinen Spannungssensor."
/>
)
}
const loeschen = async (eintrag: Tageswert) => {
await api.batterieverlaufEintragLoeschen(eintrag.datum)
neuLaden()
}
return (
<Tile>
{messwerte.map((t) => (
<Zeilenmenue
key={t.datum}
loeschFrage={`Messwert vom ${datum(t.datum)} löschen?`}
beiLoeschen={() => void loeschen(t)}
kinder={
<div className="dm-listenzeile">
<span className="dm-listenzeile__haupt">
<span className="dm-listenzeile__titel">
{de(t.min!, 2)} V
<span className="dm-listenzeile__neben">
{t.min_temp_c != null ? `${de(t.min_temp_c, 1)} °C` : "—"}
</span>
</span>
<span className="dm-listenzeile__unter">
{t.min_ts ? datumZeit(t.min_ts) : datum(t.datum)}
</span>
</span>
{t.min! > AGM_RUHE_MAX_V && <Pill>Generatorspannung</Pill>}
</div>
}
/>
))}
</Tile>
)
}
+208 -2
View File
@@ -9,12 +9,13 @@
import { useState } from "react"
import { ActionButton, Feld, Seg, Tile } from "@audi-dash/ui"
import { Accordion, ActionButton, Feld, IconButton, Seg, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN } from "../api"
import { useDaten } from "../daten/DatenKontext"
import type { Reifensatz } from "../daten/profilAdapter"
import type { Reifensatz, ReifenArchivEintrag } from "../daten/profilAdapter"
import { datum, de, deOderStrich } from "../format"
import { SymbolPaket } from "../symbole"
import { Wertzeile, Werteliste, bestaetigen } from "./bausteine"
import { Bild } from "./Bild"
import { kalenderDateiLaden } from "./kalender"
@@ -45,6 +46,19 @@ export function Reifen() {
}
}
const archivieren = async () => {
if (!bestaetigen("Neue Räder anlegen?")) return
setzeLaeuft(true)
try {
// Eigener Dienst wie reifen_wechseln: archiviert den bisherigen Stand
// und setzt den laufenden Satz zurück (km 0, Marke/Modell/DOT/Kommentar
// leer - siehe reifen.py archivieren()).
await api.reifenArchivieren(anzeige === "Winter" ? "winter" : "sommer")
} finally {
setzeLaeuft(false)
}
}
const wechseldatumSetzen = async (neuesDatum: string) => {
await profilSpeichern({
fahrzeug: {
@@ -125,6 +139,11 @@ export function Reifen() {
beschreibung={`${anzeige}rad`}
name={`${anzeige}rad`}
/>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: -6 }}>
<IconButton aria-label="Neue Räder anlegen" onClick={() => void archivieren()} disabled={laeuft}>
<SymbolPaket groesse={18} />
</IconButton>
</div>
<Werteliste
kinder={
<>
@@ -153,6 +172,193 @@ export function Reifen() {
rechnet sie immer dem gerade montierten Satz zu.
</p>
</Tile>
{fahrzeug.reifen.archiv.length > 0 && (
<Tile>
<span className="ads-eyebrow">Archiv</span>
{fahrzeug.reifen.archiv.map((eintrag) => (
<ArchivZeile key={eintrag.id} eintrag={eintrag} />
))}
</Tile>
)}
</>
)
}
function ArchivZeile({ eintrag }: { eintrag: ReifenArchivEintrag }) {
const { api } = useDaten()
const [bearbeiten, setzeBearbeiten] = useState(false)
const [laeuft, setzeLaeuft] = useState(false)
const [form, setzeForm] = useState(() => ({
ersetzt_am: eintrag.ersetzt_am,
km: String(eintrag.km ?? ""),
marke: eintrag.marke ?? "",
modell: eintrag.modell ?? "",
dot: eintrag.dot ?? "",
mass: eintrag.mass ?? "",
druck_vorne: eintrag.druck_vorne ?? "",
druck_hinten: eintrag.druck_hinten ?? "",
kommentar: eintrag.kommentar ?? "",
}))
const speichern = async () => {
setzeLaeuft(true)
try {
const kmZahl = Number(form.km.replace(",", "."))
await api.reifenArchivAktualisieren(eintrag.id, {
ersetzt_am: form.ersetzt_am,
km: Number.isFinite(kmZahl) ? kmZahl : null,
marke: form.marke || null,
modell: form.modell || null,
dot: form.dot || null,
mass: form.mass || null,
druck_vorne: form.druck_vorne || null,
druck_hinten: form.druck_hinten || null,
kommentar: form.kommentar || null,
})
setzeBearbeiten(false)
} finally {
setzeLaeuft(false)
}
}
const loeschen = async () => {
if (!bestaetigen("Diesen archivierten Reifensatz löschen?")) return
setzeLaeuft(true)
try {
await api.reifenArchivLoeschen(eintrag.id)
} finally {
setzeLaeuft(false)
}
}
return (
<Accordion
title={eintrag.satz === "winter" ? "Winterräder" : "Sommerräder"}
summary={
// km in derselben Größe/Farbe wie der Titel (nicht als angehängte
// .ads-acc-h__v-Kleinschrift ans Datum gequetscht - live beobachtet,
// dass das schwer lesbar war), Datum als eigene, kleinere Zeile
// darunter. Parität zum Panel (dieselbe Umstellung dort).
<span style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 3 }}>
<span style={{ color: "var(--fg)", fontSize: 15 }}>{deOderStrich(eintrag.km)} km</span>
<small>Ersetzt am {datum(eintrag.ersetzt_am)}</small>
</span>
}
>
{bearbeiten ? (
<>
<Feld label="Ersetzt am">
<input
className="dm-eingabe"
type="date"
value={form.ersetzt_am}
onChange={(e) => setzeForm({ ...form, ersetzt_am: e.target.value })}
/>
</Feld>
<Feld label="Gefahrene Strecke" unit="km">
<input
className="dm-eingabe"
inputMode="decimal"
value={form.km}
onChange={(e) => setzeForm({ ...form, km: e.target.value })}
/>
</Feld>
<Feld label="Marke">
<input
className="dm-eingabe"
value={form.marke}
onChange={(e) => setzeForm({ ...form, marke: e.target.value })}
/>
</Feld>
<Feld label="Modell">
<input
className="dm-eingabe"
value={form.modell}
onChange={(e) => setzeForm({ ...form, modell: e.target.value })}
/>
</Feld>
<Feld label="DOT">
<input
className="dm-eingabe"
value={form.dot}
onChange={(e) => setzeForm({ ...form, dot: e.target.value })}
/>
</Feld>
<Feld label="Größe">
<input
className="dm-eingabe"
value={form.mass}
onChange={(e) => setzeForm({ ...form, mass: e.target.value })}
/>
</Feld>
<Feld label="Luftdruck vorne" unit="bar">
<input
className="dm-eingabe"
value={form.druck_vorne}
onChange={(e) => setzeForm({ ...form, druck_vorne: e.target.value })}
/>
</Feld>
<Feld label="Luftdruck hinten" unit="bar">
<input
className="dm-eingabe"
value={form.druck_hinten}
onChange={(e) => setzeForm({ ...form, druck_hinten: e.target.value })}
/>
</Feld>
{/* Kein <Feld> hier (Label und Eingabe teilen sich sonst eine Zeile,
die Eingabe bleibt auf ihrer intrinsischen Breite - für eine
Notiz wie "Profiltiefe 5,1 mm bei der letzten Kontrolle" zu
wenig Platz, live beobachtet). Eingabe über die volle Breite,
Label darüber - Parität zum Panel (.feld--breit). */}
<div className="ads-feld" style={{ display: "block", borderBottom: 0 }}>
<label style={{ display: "block", marginBottom: 8 }}>Notiz</label>
<input
className="dm-eingabe"
style={{ width: "100%", textAlign: "left" }}
value={form.kommentar}
onChange={(e) => setzeForm({ ...form, kommentar: e.target.value })}
/>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 14 }}>
<ActionButton onClick={() => void speichern()} disabled={laeuft}>
{laeuft ? "Speichere …" : "Speichern"}
</ActionButton>
<ActionButton onClick={() => setzeBearbeiten(false)} disabled={laeuft}>
Abbrechen
</ActionButton>
</div>
<div style={{ marginTop: 10 }}>
<ActionButton variant="destructive" onClick={() => void loeschen()} disabled={laeuft}>
Löschen
</ActionButton>
</div>
</>
) : (
<>
<Werteliste
kinder={
<>
<Wertzeile label="Größe" wert={eintrag.mass || "—"} />
<Wertzeile
label="Marke und Modell"
wert={[eintrag.marke, eintrag.modell].filter(Boolean).join(" ") || "—"}
/>
<Wertzeile label="DOT" wert={eintrag.dot || "—"} />
<Wertzeile
label="Luftdruck"
wert={`${eintrag.druck_vorne || "—"} / ${eintrag.druck_hinten || "—"} bar`}
zusatz="vorne / hinten"
/>
{Boolean(eintrag.kommentar) && <Wertzeile label="Notiz" wert={eintrag.kommentar!} />}
</>
}
/>
<div style={{ marginTop: 14 }}>
<ActionButton onClick={() => setzeBearbeiten(true)}>Bearbeiten</ActionButton>
</div>
</>
)}
</Accordion>
)
}
+4 -1
View File
@@ -6,6 +6,7 @@
import { useDaten } from "../daten/DatenKontext"
import type { Route, SeitenName } from "../navigation"
import { Batterie } from "./Batterie"
import { BatterieListe } from "./BatterieListe"
import { Einstellungen } from "./Einstellungen"
import { FahrtDetail } from "./FahrtDetail"
import { Fahrten } from "./Fahrten"
@@ -69,7 +70,9 @@ export function SeiteFuer(props: SeitenProps) {
case "ident":
return <Fahrzeugdaten />
case "battverlauf":
return <Batterie />
return <Batterie geheZu={geheZu} />
case "battliste":
return <BatterieListe />
case "service":
return <Service geheZu={geheZu} />
case "werkstatt":
+46 -4
View File
@@ -13,14 +13,22 @@ interface SymbolProps {
groesse?: number | undefined
}
function Rahmen({ groesse = 24, kinder }: SymbolProps & { kinder: ReactElement }) {
/** `voll`: Fuellflaeche statt Kontur, fuer Symbole aus dem Audi-CI-Icon-Satz,
deren Pfade ihr eigenes fill="currentColor" mitbringen - ohne stroke="none"
hier wuerde der geerbte Kontur-Stroke trotzdem um jede Flaeche gezeichnet.
Entspricht der .voll-Klasse im Panel (audi-dashboard-app.js). */
function Rahmen({
groesse = 24,
voll = false,
kinder,
}: SymbolProps & { voll?: boolean; kinder: ReactElement }) {
return (
<svg
width={groesse}
height={groesse}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
fill={voll ? "currentColor" : "none"}
stroke={voll ? "none" : "currentColor"}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
@@ -70,8 +78,42 @@ export function SymbolFahrten({ groesse }: SymbolProps) {
)
}
// CI-Symbol polls-s statt der vorherigen handgezeichneten Balken (Parität
// zum Panel, siehe ICONS.stat dort).
export function SymbolStatistik({ groesse }: SymbolProps) {
return <Rahmen groesse={groesse} kinder={<path d="M4 20V9.5M10 20V4.5M16 20v-8M22 20H2" />} />
return (
<Rahmen
groesse={groesse}
voll
kinder={
<path d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z" />
}
/>
)
}
// CI-Symbol list-s: Sprung vom Diagramm zur Werteliste (Batteriespannung).
export function SymbolListe({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
voll
kinder={<path d="M5 8H3V7h2zm2-1h14v1H7zm-4 4h2v1H3zm4 0h9v1H7zm-4 4h2v1H3zm4 0h5v1H7z" />}
/>
)
}
// CI-Symbol package-s: "Neue Räder anlegen" (Reifensatz archivieren).
export function SymbolPaket({ groesse }: SymbolProps) {
return (
<Rahmen
groesse={groesse}
voll
kinder={
<path d="m12 .96 11 4.71v12.66l-11 4.71-11-4.71V5.67zM2 6.76v10.91l9.5 4.07V10.83zm10.5 4.07v10.91l9.5-4.07V6.76zM21.23 6 12 2.04 8.41 3.58l9.2 3.97zM7.15 4.12 2.77 6 12 9.96l4.34-1.86-9.2-3.98Z" />
}
/>
)
}
export function SymbolTanken({ groesse }: SymbolProps) {
+29 -9
View File
@@ -332,19 +332,24 @@ class Ablage:
# ------------------------------------------------------ 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."""
"""Ein Eintrag pro Tag ({datum, min, min_ts, max, max_ts,
min_temp_c}), ä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. min_temp_c ist die Außentemperatur zum Zeitpunkt genau
dieser Minimalmessung (None ohne zugeordneten Temperatursensor oder
bei älteren, vor dessen Einführung geschriebenen Einträgen)."""
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
self, datum: str, ts: str, spannung: float, aussentemp: float | None = None
) -> 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."""
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."""
async with self._sperre:
verlauf = await self.batterieverlauf_lesen()
for eintrag in verlauf:
@@ -353,14 +358,16 @@ class Ablage:
if spannung < eintrag["min"]:
eintrag["min"] = spannung
eintrag["min_ts"] = ts
eintrag["min_temp_c"] = aussentemp
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}
)
verlauf.append({
"datum": datum, "min": spannung, "min_ts": ts,
"max": spannung, "max_ts": ts, "min_temp_c": aussentemp,
})
# 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
@@ -374,6 +381,19 @@ class Ablage:
self._text_aus_zeilen(verlauf),
)
async def batterieverlauf_eintrag_loeschen(self, datum: str) -> bool:
async with self._sperre:
verlauf = await self.batterieverlauf_lesen()
uebrig = [e for e in verlauf if e.get("datum") != datum]
if len(uebrig) == len(verlauf):
return False
await self._im_executor(
self._text_schreiben,
self.batterieverlauf_pfad,
self._text_aus_zeilen(uebrig),
)
return True
# ---------------------------------------------------- Sensor-Zuordnung
async def zuordnung_lesen(self) -> dict:
+30 -1
View File
@@ -27,6 +27,12 @@ langen Fahrt), tauchen dort nicht in der Statistik auf.
Ohne zugeordneten BATTERIE_SENSOR passiert hier bewusst nichts, statt
Platzhalter-Nullwerte aufzuzeichnen.
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
für die Ladezustandsauswertung selbst. Ohne zugeordneten Temperatursensor
bleibt das Feld None, genau wie beim Batteriesensor.
"""
from __future__ import annotations
@@ -55,10 +61,33 @@ async def pruefen(k: Koordinator) -> None:
except (TypeError, ValueError):
return
aussentemp = None
temp_wert = zustand_oder_none(k.hass, k.zuordnung.werte.AUSSENTEMP_SENSOR)
if temp_wert is not None:
try:
aussentemp = float(temp_wert)
except (TypeError, ValueError):
pass
jetzt = datetime.datetime.now(datetime.UTC)
heute = datetime.date.today().isoformat()
await k.ablage.batterieverlauf_tageswert_aktualisieren(
heute, jetzt.isoformat(), spannung
heute, jetzt.isoformat(), spannung, aussentemp
)
await k.batterieverlauf_veroeffentlichen()
_LOGGER.debug("Batteriespannung erfasst (%s: %s V)", heute, spannung)
async def eintrag_loeschen(k: Koordinator, datum: str | None) -> None:
"""Löscht einen Tageseintrag aus der Messwertliste (Wischen zum Löschen).
Entfernt nur diesen einen Tag - min/max anderer Tage bleiben unberührt,
das Diagramm zeichnet beim nächsten Aufruf einfach ohne diesen Punkt."""
if not datum:
_LOGGER.warning("batterieverlauf_loeschen ohne datum aufgerufen")
return
if await k.ablage.batterieverlauf_eintrag_loeschen(datum):
await k.batterieverlauf_veroeffentlichen()
_LOGGER.info("Batteriespannungs-Tageseintrag %s gelöscht", datum)
else:
_LOGGER.warning("Batteriespannungs-Tageseintrag %s nicht gefunden", datum)
@@ -84,8 +84,12 @@ D_TANKVORGANG_AKTUALISIEREN = "tankvorgang_aktualisieren"
D_TANKVORGANG_LOESCHEN = "tankvorgang_loeschen"
D_BILD_HOCHLADEN = "bild_hochladen"
D_BILD_LOESCHEN = "bild_loeschen"
D_BATTERIEVERLAUF_LOESCHEN = "batterieverlauf_loeschen"
D_REIFEN_WECHSELN = "reifen_wechseln"
D_REIFEN_KM_SETZEN = "reifen_km_setzen"
D_REIFEN_ARCHIVIEREN = "reifen_archivieren"
D_REIFEN_ARCHIV_AKTUALISIEREN = "reifen_archiv_aktualisieren"
D_REIFEN_ARCHIV_LOESCHEN = "reifen_archiv_loeschen"
D_BACKUP_JETZT = "backup_jetzt"
D_BACKUP_WIEDERHERSTELLEN = "backup_wiederherstellen"
D_HISTORIE_IMPORTIEREN = "historie_importieren"
@@ -28,6 +28,7 @@ from homeassistant.helpers import config_validation as cv
from . import (
aktualisierung,
batterie,
belege,
bilder,
fahrterkennung,
@@ -41,6 +42,7 @@ from .const import (
CONF_GITEA_TOKEN,
D_BACKUP_JETZT,
D_BACKUP_WIEDERHERSTELLEN,
D_BATTERIEVERLAUF_LOESCHEN,
D_BELEG_HOCHLADEN,
D_BILD_HOCHLADEN,
D_BILD_LOESCHEN,
@@ -51,6 +53,9 @@ from .const import (
D_HISTORIE_IMPORTIEREN,
D_JETZT_AKTUALISIEREN,
D_PROFIL_SCHREIBEN,
D_REIFEN_ARCHIV_AKTUALISIEREN,
D_REIFEN_ARCHIV_LOESCHEN,
D_REIFEN_ARCHIVIEREN,
D_REIFEN_KM_SETZEN,
D_REIFEN_WECHSELN,
D_SCREENING_JETZT,
@@ -94,6 +99,18 @@ _TANK_FELDER = {
vol.Optional("receipt_file"): _TEXT,
}
_REIFEN_ARCHIV_FELDER = {
vol.Optional("ersetzt_am"): _TEXT,
vol.Optional("km"): _ZAHL,
vol.Optional("marke"): _TEXT,
vol.Optional("modell"): _TEXT,
vol.Optional("dot"): _TEXT,
vol.Optional("mass"): _TEXT,
vol.Optional("druck_vorne"): _TEXT,
vol.Optional("druck_hinten"): _TEXT,
vol.Optional("kommentar"): _TEXT,
}
SCHEMATA: dict[str, vol.Schema] = {
D_JETZT_AKTUALISIEREN: vol.Schema({}),
D_SCREENING_JETZT: vol.Schema({}),
@@ -132,6 +149,12 @@ SCHEMATA: dict[str, vol.Schema] = {
vol.Required("satz"): vol.In(reifen.SAETZE),
vol.Required("km"): vol.Coerce(float),
}),
D_REIFEN_ARCHIVIEREN: vol.Schema({vol.Required("satz"): vol.In(reifen.SAETZE)}),
D_REIFEN_ARCHIV_AKTUALISIEREN: vol.Schema({
vol.Required("id"): cv.string, **_REIFEN_ARCHIV_FELDER
}),
D_REIFEN_ARCHIV_LOESCHEN: vol.Schema({vol.Required("id"): cv.string}),
D_BATTERIEVERLAUF_LOESCHEN: vol.Schema({vol.Required("datum"): cv.string}),
D_BACKUP_WIEDERHERSTELLEN: vol.Schema({
vol.Optional("profil_json"): _JSON,
vol.Optional("fahrten_json"): _JSON,
@@ -320,6 +343,16 @@ def registrieren(hass: HomeAssistant, k: Koordinator) -> None:
D_REIFEN_KM_SETZEN: _als_dienst(
lambda a: reifen.km_setzen(k, a.data["satz"], a.data["km"])
),
D_REIFEN_ARCHIVIEREN: _als_dienst(lambda a: reifen.archivieren(k, a.data["satz"])),
D_REIFEN_ARCHIV_AKTUALISIEREN: _als_dienst(
lambda a: reifen.archiv_aktualisieren(k, a.data["id"], dict(a.data))
),
D_REIFEN_ARCHIV_LOESCHEN: _als_dienst(
lambda a: reifen.archiv_loeschen(k, a.data["id"])
),
D_BATTERIEVERLAUF_LOESCHEN: _als_dienst(
lambda a: batterie.eintrag_loeschen(k, a.data["datum"])
),
D_BACKUP_JETZT: _als_dienst(lambda _a: sicherung.jetzt(k)),
D_BACKUP_WIEDERHERSTELLEN: _als_dienst(
lambda a: sicherung.wiederherstellen(k, dict(a.data))
@@ -81,6 +81,11 @@ class Sensorzuordnung:
# und hat mit der Fahrzeugbatterie nichts zu tun).
BATTERIE_SENSOR: str = ""
# Außentemperatur (CAN ambient air temperature) - optional, für die
# Batteriespannungs-Messwertliste: zusammen mit dem Tagesminimum
# aufgezeichnet, siehe batterie.py.
AUSSENTEMP_SENSOR: str = ""
# Knopf für eine sofortige Neuabfrage beim Fahrzeug.
REFRESH_BUTTON: str = ""
@@ -166,6 +171,11 @@ FELDER: list[dict] = [
"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": "AUSSENTEMP_SENSOR", "label": "Außentemperatur", "gruppe": "uebersicht",
"hinweis": "CAN-Außentemperatur - optional, ergänzt die Batteriespannungs-Messwertliste um den "
"Temperaturwert je Tag.",
"domains": ["sensor"], "device_classes": ["temperature"], "units": ["°C", "°F"], "liste": False,
"pflicht": False, "stichworte": ["außentemperatur", "aussentemperatur", "ambient", "temperatur", "temperature"]},
{"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,
@@ -1 +1 @@
{"version":"2026.8.24.11","sha256":"c1a85f9a15b434121f4cd19e2729cfbe60ca89757163954f07152483b8b209af","bytes":233245,"gebaut":"2026-08-24T14:58:21Z"}
{"version":"2026.8.25.8","sha256":"464c294c6c07dcb636a8ea3bd93c7e525b21722defb41f573e2b31fefade49ea","bytes":235525,"gebaut":"2026-08-27T09:40:45Z"}
@@ -176,6 +176,12 @@ function profilZuCar(p, status) {
wechsel: p.reifen.wechsel,
sommer: reifenSatz("sommer"),
winter: reifenSatz("winter"),
// Neueste zuerst, schon so im Profil (reifen.py archivieren()). Nur
// gelesen - bearbeitet/gelöscht wird über eigene Dienste
// (reifen_archiv_aktualisieren/-loeschen), nicht über profilSpeichern,
// aus demselben Grund wie km oben: kein Stand im Browser darf einen
// zwischenzeitlichen Servereintrag überschreiben.
archiv: p.reifen.archiv || [],
},
service: p.service,
technik: p.technik,
@@ -1044,6 +1050,16 @@ const CI = {
verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */
battery12vL: '<path fill="currentColor" d="M11 11h7v4h13v-4h7v4h6v23H5V15h6zm1 4h5v-3h-5zm-6 1v21h37V16zm31-1v-3h-5v3zm-3 11v4.5h-1V26h-4.5v-1H33v-4.5h1V25h4.5v1zm-15.5 0h-8v-1h8z"/>',
battery12vS: '<path fill="currentColor" d="M5 5h4v2h6V5h4v2h3v12H2V7h3zm1 2h2V6H6zM3 8v10h18V8zm15-1V6h-2v1zm-1 6v2h-1v-2h-2v-1h2v-2h1v2h2v1zm-8 0H5v-1h4z"/>',
/* list-l/-s, polls-l/-s, package-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer
als SVG geliefert, verbatim uebernommen) - l im 48er-, s im 24er-Grid wie
poi-l/poi-s. list: Sprung vom Diagramm zur Werteliste (Batteriespannung).
polls: Statistik-Tab in der Tableiste. package: "Neue Räder anlegen". */
listL: '<path fill="currentColor" d="M14 19h-2v-1h2zm2-1h18v1H16zm-4 6h2v1h-2zm4 0h12v1H16zm-4 6h2v1h-2zm4 0h5v1h-5z"/>',
listS: '<path fill="currentColor" d="M5 8H3V7h2zm2-1h14v1H7zm-4 4h2v1H3zm4 0h9v1H7zm-4 4h2v1H3zm4 0h5v1H7z"/>',
pollsL: '<path fill="currentColor" d="M19 3h10v37H19zm1 1v35h8V4zm13 8h10v28H33zm1 1v26h8V13zM5 19h10v21H5zm1 1v19h8V20zM5 44h38v1H5z"/>',
pollsS: '<path fill="currentColor" d="M9 0h6v21H9zm1 1v19h4V1zm7 4h6v16h-6zm1 1v14h4V6zM1 9h6v12H1zm1 1v10h4V10zM1 23h22v1H1z"/>',
packageL: '<path fill="currentColor" d="m24 2.97 20.99 9v24.07l-20.99 9-20.99-9V11.96l20.99-9ZM16.11 7.4l19.18 8.29 7.97-3.41L24 4.03l-7.89 3.38Zm27.9 5.62L24.5 21.39v22.37L44 35.39V13.03Zm-20.5 30.73V21.39L4 13.03v22.36l19.52 8.37ZM4.74 12.29 24 20.54l10.06-4.31-19.19-8.29L4.74 12.3Z"/>',
packageS: '<path fill="currentColor" d="m12 .96 11 4.71v12.66l-11 4.71-11-4.71V5.67zM2 6.76v10.91l9.5 4.07V10.83zm10.5 4.07v10.91l9.5-4.07V6.76zM21.23 6 12 2.04 8.41 3.58l9.2 3.97zM7.15 4.12 2.77 6 12 9.96l4.34-1.86-9.2-3.98Z"/>',
/* edit-l/-s aus dem Audi-CI-Icon-Satz (vom Nutzer als SVG geliefert,
verbatim uebernommen) - l im 48er-, s im 24er-Grid wie poi-l/poi-s. */
editL: '<path fill="currentColor" d="M28.99 3.6c.34-.1.73-.11 1.1.04h.02l3.61 1.81c.35.18.58.5.68.84s.11.73-.04 1.1v.01l-2.01 4.06 4.02 2.02-6.72 13.44-.9-.44 6.28-12.56-3.13-1.56-13.6 27.47-6.3 4.66v-7.8L28.15 4.27c.18-.35.5-.58.84-.68Zm2.46 7.42 2-4.02a.65.65 0 0 0 0-.41c-.05-.14-.12-.22-.17-.24l-3.58-1.8a.65.65 0 0 0-.41 0c-.14.05-.22.12-.24.17L27.02 8.8zm-4.88-1.33-13.4 26.89 4.5 2.25L31.01 11.9l-4.44-2.2ZM13 37.61v4.9l3.95-2.93z"/>',
@@ -1782,7 +1798,10 @@ function vBatterieverlauf() {
return `
<div class="tile">
<span class="label">Verlauf</span>
<div style="display:flex;justify-content:space-between;align-items:center">
<span class="label">Verlauf</span>
<button class="iconbtn" aria-label="Messwertliste" data-go="battliste:x">${ciSVG(CI.listS, 18)}</button>
</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:170px;display:block"></svg>
@@ -1825,6 +1844,31 @@ function vBatterieverlauf() {
</div>`;
}
/* Messwertliste zur Batteriespannungs-Kachel: ein Eintrag pro Tag (dieselben
Daten wie das Diagramm), aber als Liste mit Datum, Uhrzeit, Spannung und
Außentemperatur. Jede Zeile wischbar zum Löschen wie Fahrten/Tankvorgänge
(art "batt" in eintragLoeschen()/LOESCH_TEXT/LOESCH_HINWEIS) - kein
data-go/Chevron, es gibt keine Detailseite für einen einzelnen Messwert.
Liegt der Tagesminimalwert über AGM_RUHE_MAX_V, wurde er nie in Ruhe
gemessen (an dem Tag stand das Fahrzeug nie lange genug still) - die Zeile
markiert das mit "Generatorspannung" statt so zu tun, als sei es ein
Ruhewert (dieselbe Schwelle wie im Diagramm selbst, siehe bvPunkte oben). */
function vBatterieliste() {
const messwerte = BATTERIEVERLAUF.filter((e) => e.min != null).slice().reverse();
if (messwerte.length === 0) {
return `<div class="tile"><span class="label" style="color:var(--fg3)">Keine Messwerte</span></div>`;
}
return `<div class="tile">${messwerte.map((e) => {
const zeit = e.min_ts ? new Date(e.min_ts) : null;
const wann = zeit ? `${dedat(zeit)} · ${zeit.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })} Uhr` : dedat(new Date(e.datum));
return swipeZeile(`batt:${e.datum}`, `
<div class="leaf">
<span class="k">${de(e.min, 2)} V<small>${e.min_temp_c != null ? de(e.min_temp_c, 1) + " °C" : "—"}</small></span>
<span class="v">${wann}${e.min > AGM_RUHE_MAX_V ? `<span class="pill" style="margin-top:4px;padding:3px 9px;font-size:11px">Generatorspannung</span>` : ""}</span>
</div>`);
}).join("")}</div>`;
}
function bvSkalaX(t) { return BV_ML + ((t - bvDomain[0]) / (bvDomain[1] - bvDomain[0])) * (BV_W - BV_ML - BV_MR); }
function bvSkalaY(v, yMin, yMax) { return BV_H - BV_MB - ((v - yMin) / (yMax - yMin)) * (BV_H - BV_MT - BV_MB); }
function bvDomainKlemmen(domain) {
@@ -2843,7 +2887,10 @@ function vReifen() {
<button type="button" class="loeschen" data-bildloeschen="${radDatei}">Löschen</button>
</div>` : ""}
<button data-satz="${k === "sommer" ? "Sommer" : "Winter"}" class="montiert ${aktiv ? "on" : ""}">Montiert</button>
<div style="margin-top:22px">
<div style="display:flex;justify-content:flex-end;margin-top:10px">
<button class="iconbtn" data-reifenarchivieren="${k}" aria-label="Neue Räder anlegen">${ciSVG(CI.packageS, 18)}</button>
</div>
<div style="margin-top:12px">
${feld(k, "marke", "Hersteller")}
${feld(k, "modell", "Modell")}
${feld(k, "mass", "Größe")}
@@ -2856,7 +2903,8 @@ function vReifen() {
<span>bar</span>
</span>
</div>
${feld(k, "kommentar", "Kommentar")}
<div class="feld feld--breit"><label>Kommentar</label>
<input value="${esc(t.kommentar ?? "")}" data-rf="${k}|kommentar" placeholder="noch offen"></div>
</div>
</div>`;
};
@@ -2883,7 +2931,55 @@ function vReifen() {
<button class="aktion" data-ics="reifen">In den Kalender übernehmen</button>
</div>
${satz("sommer")}
${satz("winter")}`;
${satz("winter")}
${vReifenArchiv()}`;
}
/* Übersicht der per "Neue Räder anlegen" archivierten Sätze - jede Zeile
aufklappbar (dasselbe acc-h/acc-b-Muster wie die Tankvorgänge-Liste), mit
inline editierbaren Feldern wie bei den laufenden Sätzen oben, nur über
eigene Dienste statt profilSpeichern (siehe profilZuCar()). */
function vReifenArchiv() {
const archiv = CAR.reifen.archiv || [];
if (archiv.length === 0) return "";
// Kein .mitEinheit hier (das erzwingt eine feste 96px-Eingabebreite fürs
// Zahl+Einheit-Muster, z. B. km/Nm - für freien Text wie Hersteller/Modell/
// Kommentar riss das den Wert ab, siehe live beobachtet: "Continenta",
// "SportConta"). Genau wie feld() in satz() oben: einfaches <input>, das
// sich über den .feld-Flex den ganzen Rest der Zeile nimmt.
const feld = (a, schluessel, bez) => `<div class="feld"><label>${bez}</label>
<input value="${esc(a[schluessel] ?? "")}" data-rfa="${a.id}|${schluessel}" placeholder=""></div>`;
return `<div class="tile"><span class="label">Archiv</span>
${archiv.map((a, i) => {
const satzLabel = a.satz === "winter" ? "Winterräder" : "Sommerräder";
return `<button class="acc-h" aria-expanded="false" data-acc="rfarch${i}"><span class="mark"></span>
<span class="k">${satzLabel}</span>
<span class="v" style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
<span style="color:var(--fg);font-size:15px">${a.km != null ? de(a.km) + " km" : ""}</span>
<small>Ersetzt am ${dedat(new Date(a.ersetzt_am))}</small>
</span></button>
<div class="acc-b" id="rfarch${i}">
<div class="feld"><label>Ersetzt am</label><input type="date" value="${esc(a.ersetzt_am ?? "")}" data-rfa="${a.id}|ersetzt_am"></div>
<div class="feld"><label>Gefahrene Strecke</label>
<span class="mitEinheit"><input type="number" step="0.1" min="0" value="${a.km ?? ""}" data-rfa="${a.id}|km"><span>km</span></span></div>
${feld(a, "marke", "Hersteller")}
${feld(a, "modell", "Modell")}
${feld(a, "mass", "Größe")}
${feld(a, "dot", "DOT")}
<div class="feld"><label>Solldruck vorn / hinten</label>
<span class="mitEinheit">
<input class="druckfeld" value="${esc(a.druck_vorne ?? "")}" data-rfa="${a.id}|druck_vorne" placeholder="">
<span>/</span>
<input class="druckfeld" value="${esc(a.druck_hinten ?? "")}" data-rfa="${a.id}|druck_hinten" placeholder="">
<span>bar</span>
</span>
</div>
<div class="feld feld--breit"><label>Kommentar</label>
<input value="${esc(a.kommentar ?? "")}" data-rfa="${a.id}|kommentar" placeholder=""></div>
<button class="aktion loeschen" style="margin-top:10px" data-reifenarchivloeschen="${a.id}">Löschen</button>
</div>`;
}).join("")}
</div>`;
}
/* Gemeinsames Formular für "Neue Fahrt" und das Bearbeiten einer bestehenden
@@ -3250,12 +3346,14 @@ const LOESCH_TEXT = {
fill: "Tankvorgang löschen?",
sbuch: "Servicebuch-Eintrag löschen?",
bild: "Foto löschen?",
batt: "Messwert löschen?",
};
const LOESCH_HINWEIS = {
trip: "Die Fahrt wird aus dem Fahrtenbuch entfernt. Das lässt sich nicht widerrufen.",
fill: "Der Tankvorgang und der zugehörige Beleg werden entfernt. Das lässt sich nicht widerrufen.",
sbuch: "Der Eintrag wird aus dem Servicebuch entfernt. Das lässt sich nicht widerrufen.",
bild: "Das Foto wird vom Server gelöscht. Der Platz bleibt bestehen und kann neu belegt werden.",
batt: "Der Tageseintrag wird aus der Messwertliste entfernt. Das lässt sich nicht widerrufen.",
};
function eintragLoeschen(art, id) {
if (art === "trip") {
@@ -3267,6 +3365,9 @@ function eintragLoeschen(art, id) {
} else if (art === "sbuch") {
CAR.service.buch.splice(parseInt(id, 10), 1);
profilSpeichern();
} else if (art === "batt") {
serviceRufen("batterieverlauf_loeschen", { datum: id });
BATTERIEVERLAUF = BATTERIEVERLAUF.filter((e) => e.datum !== id);
}
}
@@ -3511,7 +3612,9 @@ const ICONS = {
assets/icons/car.svg). Flaeche statt Kontur, deshalb die Klasse "voll". */
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"/>',
stat: '<path d="M4 20V9.5M10 20V4.5M16 20v-8M22 20H2"/>',
/* CI-Symbol polls-s statt der vorherigen handgezeichneten Balken - dieselbe
Fuellflaeche-statt-Kontur-Behandlung wie "audi" (siehe ICONS_VOLL). */
stat: CI.pollsS,
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 = [
@@ -3522,7 +3625,7 @@ const TABS = [
{ id: "fuel", label: "Tanken", eyebrow: "", title: "Tanken" },
];
/* Symbole, die als Flaeche gezeichnet sind statt als Kontur */
const ICONS_VOLL = new Set(["audi"]);
const ICONS_VOLL = new Set(["audi", "stat"]);
let route = { name: "home" };
const ZURUECK = {
trip: "trips", fill: "fuel", service: "audi", reifen: "audi", ident: "audi",
@@ -3530,6 +3633,7 @@ const ZURUECK = {
vertrag: "vers",
steuer: "vers", notruf: "vers", werkstatt: "service", sbuch: "service", einst: "home",
battverlauf: "audi",
battliste: "battverlauf",
/* Fehlte: die Sicherheitsansicht war eine Sackgasse ohne sichtbaren
Zurück-Pfeil. */
sicherheit: "home",
@@ -3889,6 +3993,7 @@ function render() {
else if (route.name === "schutz") { head = ["Im Ernstfall", "Schutzbrief"]; v.innerHTML = vSchutz(); }
else if (route.name === "werkstatt") { head = ["Service", "Autohaus"]; v.innerHTML = vWerkstatt(); }
else if (route.name === "battverlauf") { head = ["Fahrzeug", "Batteriespannung"]; v.innerHTML = vBatterieverlauf(); bvZeichnen(); }
else if (route.name === "battliste") { head = ["Batteriespannung", "Messwerte"]; v.innerHTML = vBatterieliste(); }
else if (route.name === "sbuch") { head = ["Servicebuch", "Eintrag"]; v.innerHTML = vSbuch(); }
else if (route.name === "standort") { head = ["Fahrzeug", "Standort"]; v.innerHTML = vStandort(); }
else { const t = TABS.find((x) => x.id === route.name); head = [t.eyebrow, t.id === "audi" ? "Mein Audi" : t.title];
@@ -4724,6 +4829,25 @@ function ereignisseVerdrahten() {
}
const satz = e.target.closest("[data-satz]");
if (satz) { serviceRufen("reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; }
const rfArch = e.target.closest("[data-reifenarchivieren]");
if (rfArch) {
const k = rfArch.dataset.reifenarchivieren;
bestaetigen("Neue Räder anlegen?",
"Der aktuelle Stand wird ins Archiv gelegt, der laufende Satz beginnt neu bei 0 km.",
"Anlegen", () => { serviceRufen("reifen_archivieren", { satz: k }); }, false);
return;
}
const rfArchDel = e.target.closest("[data-reifenarchivloeschen]");
if (rfArchDel) {
const id = rfArchDel.dataset.reifenarchivloeschen;
bestaetigen("Archivierten Reifensatz löschen?",
"Das lässt sich nicht widerrufen.", "Löschen", () => {
serviceRufen("reifen_archiv_loeschen", { id });
CAR.reifen.archiv = CAR.reifen.archiv.filter((a) => a.id !== id);
render();
}, true);
return;
}
const k = e.target.closest("[data-ics]");
if (k) {
if (k.dataset.ics === "service") {
@@ -4912,6 +5036,18 @@ function ereignisseVerdrahten() {
CAR.reifen[k][schluessel] = wert;
profilSpeichern(); render(); return;
}
const rfa = e.target.dataset.rfa;
if (rfa) {
const [id, schluessel] = rfa.split("|");
const wert = schluessel === "km" ? parseFloat(e.target.value) || 0 : e.target.value;
const eintrag = CAR.reifen.archiv.find((a) => a.id === id);
if (eintrag) eintrag[schluessel] = wert;
// Eigener Dienst statt profilSpeichern (siehe profilZuCar()) - ändert
// nur diesen einen archivierten Satz, statt das ganze Profil (samt
// eines womöglich seither fortgeschriebenen km-Stands) zu überschreiben.
serviceRufen("reifen_archiv_aktualisieren", { id, [schluessel]: wert });
return;
}
if (e.target.dataset.schrift !== undefined) {
beschriftung = e.target.checked;
try { localStorage.setItem(SCHRIFT_KEY, beschriftung ? "an" : "aus"); } catch (err) { /* Privatmodus */ }
@@ -334,6 +334,14 @@ main#view{--seitenrand:16px;padding:0 var(--seitenrand) 30px;scroll-behavior:smo
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}
/* Batteriespannungs-Diagramm ist auf BV_W=320 SVG-Einheiten gezeichnet
(audi-dashboard-app.js) und haelt seine Hoehe fest bei 170px - bei voller
Spaltenbreite (772px Inhaltsbreite: 860 - 2*44px Seitenrand) zog
preserveAspectRatio="none" das Diagramm zu einem extrem breiten, flachen
Band auseinander. Gedeckelt wie die Popups oben, statt die feste Hoehe
oder die BV_*-Zeichen-Logik anzufassen. */
#bvChart{max-width:400px;margin-left:auto;margin-right:auto}
}
/* Fahrzeugbild randlos wie in einer Fahrzeug-App: der Platzhalter bleibt
@@ -171,6 +171,17 @@
.themebtn:hover { background: var(--tile-2); color: var(--fg); }
.themebtn:active { background: var(--tile-2); color: var(--fg); transform: scale(.92); }
.themebtn svg { fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; }
/* Wie .themebtn (44px Trefferflaeche), aber ohne die Kontur-Stroke-Regel -
fuer CI-Symbole (ciSVG()), deren Pfade ihr eigenes fill="currentColor"
mitbringen (z. B. list-s im Kopf der Batteriespannungs-Kachel). */
.iconbtn {
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 44px; border-radius: 50%;
border: none; background: none; color: var(--fg3);
cursor: pointer; padding: 0; flex: 0 0 auto;
}
.iconbtn:hover { background: var(--tile-2); color: var(--fg); }
.iconbtn:active { background: var(--tile-2); color: var(--fg); transform: scale(.92); }
/* D4 war urspruenglich: die Ringe sind Marke, kein Knopf. Seit die
iOS-Auflage in Telefonbreite das Zahnrad ausblendet, sind sie dort aber der
einzige Zugang zu den Einstellungen - und als reines div mit
@@ -665,6 +676,15 @@ button.sicherheitszeile:active { background: var(--tile-2); }
appearance: none;
}
.feld input:focus, .feld select:focus, .notiz:focus { outline: none; border-color: var(--red); }
/* Label und Eingabe untereinander statt nebeneinander, die Eingabe über die
volle Zeilenbreite - für Felder, deren Wert typischerweise länger ist als
ein Kurztext (Kommentar bei Sommer-/Winterrädern, live wie archiviert).
Sonst gilt .feld input oben: die Eingabe wächst nie über ihre intrinsische
Breite, nur das Label tut das (flex:1 1 auto) - für einen Reifenkommentar
("Profiltiefe 5,1 mm bei der letzten Montage") reichte das nicht. */
.feld--breit { display: block; }
.feld--breit label { display: block; margin-bottom: 8px; }
.feld--breit input { width: 100%; text-align: left; }
/* Das AUFGEKLAPPTE <select>-Popup ist Browser-/OS-Chrome, kein Teil des
Seiten-DOMs - color-scheme (siehe :host weiter oben) beeinflusst es nur
teilweise, der Rest bleibt hell. Einzige zuverlaessige Stellschraube:
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.25.2",
"version": "2026.8.25.8",
"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"],
@@ -20,9 +20,11 @@ Satz zugerechnet.
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:
@@ -32,6 +34,11 @@ _LOGGER = logging.getLogger(__name__)
SAETZE = ("sommer", "winter")
# Felder, die bei "Neue Räder anlegen" mit auf den frischen Satz übernommen
# werden - Größe und Solldruck ändern sich bei einem gleich montierten Ersatz
# typischerweise nicht, anders als Marke/Modell/DOT (siehe archivieren()).
_UEBERNOMMENE_FELDER = ("bild", "mass", "druck_vorne", "druck_hinten")
def aktueller_km(k: Koordinator) -> float | None:
try:
@@ -106,6 +113,95 @@ async def km_setzen(k: Koordinator, satz: str | None, km: object) -> None:
_LOGGER.info("km-Stand '%s' manuell auf %s gesetzt", satz, wert)
async def archivieren(k: Koordinator, satz: str | None) -> None:
""""Neue Räder anlegen": legt den aktuellen Stand von `satz` (km, Marke,
Modell, DOT, Kommentar) als Übersichtszeile ins Archiv und setzt den
laufenden Satz auf einen frischen Stand zurück - km auf 0, Marke/Modell/
DOT/Kommentar leer (galten für den alten, jetzt ersetzten Reifen), Größe
und Solldruck unverändert übernommen (siehe _UEBERNOMMENE_FELDER)."""
if satz not in SAETZE:
_LOGGER.warning("Ungültiger Reifensatz '%s'", satz)
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.setdefault("reifen", {})
saetze = reifen.setdefault("saetze", {})
alt = dict(saetze.get(satz, {}))
archiv = reifen.setdefault("archiv", [])
archiv.insert(0, {
"id": neue_id("r"),
"satz": satz,
"ersetzt_am": datetime.date.today().isoformat(),
"km": alt.get("km") or 0,
"marke": alt.get("marke") or "",
"modell": alt.get("modell") or "",
"dot": alt.get("dot") or "",
"mass": alt.get("mass") or "",
"druck_vorne": alt.get("druck_vorne") or "",
"druck_hinten": alt.get("druck_hinten") or "",
"kommentar": alt.get("kommentar") or "",
})
saetze[satz] = {
"km": 0, "marke": "", "modell": "", "dot": "", "kommentar": "",
**{feld: alt.get(feld) or "" for feld in _UEBERNOMMENE_FELDER},
}
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Reifensatz '%s' archiviert (%s km), neuer Satz angelegt", satz, archiv[0]["km"])
async def archiv_aktualisieren(k: Koordinator, id_: str | None, daten: dict) -> None:
"""Bearbeitet einen archivierten Reifensatz-Eintrag (Übersichtszeile auf
der Reifen-Seite, aufgeklappt -> "Bearbeiten")."""
if not id_:
_LOGGER.warning("reifen_archiv_aktualisieren ohne id aufgerufen")
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
archiv = profil.setdefault("reifen", {}).setdefault("archiv", [])
eintrag = next((e for e in archiv if e.get("id") == id_), None)
if eintrag is None:
_LOGGER.warning("Archivierter Reifensatz '%s' nicht gefunden", id_)
return
erlaubte_felder = (
"ersetzt_am", "km", "marke", "modell", "dot", "mass",
"druck_vorne", "druck_hinten", "kommentar",
)
for feld in erlaubte_felder:
if feld in daten and daten[feld] is not None:
eintrag[feld] = daten[feld]
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Archivierter Reifensatz '%s' aktualisiert", id_)
async def archiv_loeschen(k: Koordinator, id_: str | None) -> None:
if not id_:
_LOGGER.warning("reifen_archiv_loeschen ohne id aufgerufen")
return
profil = await k.ablage.profil_lesen()
if profil is None:
return
reifen = profil.setdefault("reifen", {})
archiv = reifen.get("archiv", [])
uebrig = [e for e in archiv if e.get("id") != id_]
if len(uebrig) == len(archiv):
_LOGGER.warning("Archivierter Reifensatz '%s' nicht gefunden", id_)
return
reifen["archiv"] = uebrig
await k.ablage.profil_schreiben(profil)
await k.profil_veroeffentlichen(profil)
_LOGGER.info("Archivierter Reifensatz '%s' gelöscht", id_)
def zustaende(profil: dict | None) -> dict[str, object]:
"""Die drei Kontrollwerte für die eigenen Reifen-Entitäten.
@@ -230,6 +230,62 @@ reifen_km_setzen:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
reifen_archivieren:
fields:
satz:
required: true
selector:
select:
options: ["sommer", "winter"]
reifen_archiv_aktualisieren:
fields:
id:
required: true
selector:
text:
ersetzt_am:
selector:
text:
km:
selector:
number: { min: 0, max: 2000000, step: 0.1, mode: box, unit_of_measurement: km }
marke:
selector:
text:
modell:
selector:
text:
dot:
selector:
text:
mass:
selector:
text:
druck_vorne:
selector:
text:
druck_hinten:
selector:
text:
kommentar:
selector:
text:
reifen_archiv_loeschen:
fields:
id:
required: true
selector:
text:
batterieverlauf_loeschen:
fields:
datum:
required: true
selector:
text:
backup_wiederherstellen:
fields:
profil_json: