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:
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) : "—"}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user