Im develop Branch sind ein paar größere Neuerungen in lib.db und dem database Plugin gelandet. Der letzte Teil behandelt die aktuellen Updates, die ersten Teile gehen nochmal auf schon fertige Änderungen ein, die aber offensichtlich noch nicht alle erreicht haben 🙂

1. Datenlücken statt „Wert unverändert“

Wenn eine Datenquelle zeitweise ausfällt (offline, Verbindung nicht da) oder keine Daten liefert, behält das Item derzeit einfach seinen letzten Wert. Ohne Korrekturen wird dieser Wert für die gesamte Zeitspanne in der Datenbank gespeichert. Neben verfälschten Berechnungen haben auch Plots/Graphen dabei durchgehende Linien, obwohl ggf. keine Daten vorlagen.

Das ist ein altes Thema, über das in den Issues schon viel diskutiert wurde, und es gibt eine Lösung. Genaugenommen – zwei. item.db_mark_invalid() / item.db_mark_valid().

# Verbindung verloren
sh.solar.leistung.db_mark_invalid(caller='solar_plugin', source='connection_lost')

# Verbindung wieder da
sh.solar.leistung.db_mark_valid(caller='solar_plugin', source='connection_restored')
sh.solar.leistung(neuer_wert, 'solar_plugin')

Wichtig dabei: das passiert nicht von allein. Das jeweilige Plugin muss den Ausfall selbst erkennen und db_mark_invalid() aktiv aufrufen. Unterstützt ein Plugin das (noch) nicht, bleibt der letzte Wert wie bisher einfach unverändert stehen. Aktuell macht das noch kein Plugin von Haus aus – Gateway-Plugins wie MQTT (Verbindung zum Broker verloren) oder KNX (Bus-Timeout, knxd nicht erreichbar) wären die ersten Kandidaten, die ich in absehbarer Zeit angehen möchte. Wer Lust hat, das für sein Lieblings-Plugin nachzurüsten: immer her damit.

Neu und ohne Plugin-Unterstützung nutzbar: das Item-Attribut database_invalid_after. Bekommt ein Item über die angegebene Zeitspanne keine Änderung/kein Update, wird automatisch eine Lücke geöffnet:

solar:
    leistung:
        type: num
        database: init
        enforce_updates: true
        database_invalid_after: 10m

enforce_updates muss gesetzt sein, sonst zählt ein unverändertes Update nicht als Lebenszeichen. Und: das ist nur für Items sinnvoll, die ohnehin regelmäßig aktualisiert werden (cycle/crontab o.ä.) – bei unregelmäßig/ereignisgesteuert aktualisierten Items gibt’s sonst nur Fehlalarme.

Sobald wieder ein Wert gesendet wurde, wird automatisch db_mark_valid() aufgerufen und die Werte „zählen“ wieder.

Lückeneinträge werden bei avg/sum/integrate/on/min/max automatisch ausgeschlossen, tauchen bei Rohwertabfragen als NULL (aus der Datenbank) auf (für Lücken in Plots), und lassen sich im Webinterface pro Datensatz auch manuell setzen/zurücknehmen.

Beispiel: KNX-Item und MQTT-Item

Zwei Items, zwei Transportwege, aber beide mit demselben eigentlichen Risiko: die Verbindung zur Außenwelt kann abreißen, ohne dass shng selbst oder das jeweilige Plugin davon groß Notiz nimmt – Telegramme bzw. Nachrichten bleiben einfach aus:

heizung:
    vorlauftemperatur:
        type: num
        database: init
        knx_dpt: 9
        knx_listen: 1/2/3
        enforce_updates: true
        database_invalid_after: 15m

garage:
    aussentemperatur:
        type: num
        database: init
        mqtt_topic_in: garage/aussentemperatur
        enforce_updates: true
        database_invalid_after: 3m

heizung.vorlauftemperatur kommt vom Sensor selbst zyklisch (in ETS am Gerät eingestellt, z.B. alle 5 Minuten senden, unabhängig von einer Wertänderung) – komplett unabhängig von shngs eigenem Scheduler. Fällt knxd aus oder bricht die Verbindung zum Bus ab, bleiben die Telegramme einfach aus. Das KNX-Plugin selbst muss dabei nicht abstürzen oder für dieses eine Item einen auffälligen Fehler werfen – andere Items mit zwischenzeitlich gecachten Werten sehen auf den ersten Blick unauffällig aus. Genau diesen Fall fängt database_invalid_after ab, ohne dass jemand ins Log schauen muss.

garage.aussentemperatur kommt per MQTT rein, mit demselben Prinzip: viele Sensoren (Zigbee2MQTT, Tasmota, …) senden zyklisch, unabhängig davon ob sich der Wert geändert hat – hier alle 60s angenommen, daher 3m als Schwelle. Bleibt der Sensor tot oder der Broker weg, bleibt auch das MQTT-Update aus, ohne dass shng selbst etwas falsch macht.

Ein rein shng-internes cycle+eval-Item wäre dagegen kein gutes Beispiel: solange shng überhaupt läuft, feuert dessen Scheduler garantiert, und stünde der Scheduler wirklich still, würde auch die Prüfung selbst (invalid_check_cycle) nicht mehr laufen – es gäbe also niemanden mehr, der Alarm schlagen könnte.

Der Haken liegt nicht bei MQTT als Transportweg, sondern bei Quellen, die nur bei Zustandsänderung senden – klassisch Tür-/Fensterkontakte, viele Taster. Da ist „seit 30 Minuten kein Update“ der absolute Normalfall, wenn eben nichts passiert ist, kein Ausfall. database_invalid_after ist nur sinnvoll, wenn die Quelle nachweislich regelmäßig sendet – egal ob per MQTT, KNX oder sonstwie -, und die Schwelle muss zu genau diesem Intervall passen, nicht zu einer geschätzten „sollte doch mal was kommen“-Zeit.

Das Risiko bei Fehlkonfiguration – falscher Itemtyp ohne regelmäßige Updates, oder zu kurz gewählte database_invalid_after-Zeit – sind Zeiträume, die in der Datenbank als „nicht vorhanden“ eingetragen sind, obwohl eigentlich ein gültiger Wert vorlag. Das fällt seinerseits nur auf, wenn man es regelmäßig auf Plots überprüft.

2. Aggregierung statt Löschen – die Optionen im Überblick

database_maxage ist ein bekanntes Item-Attribut und löscht alte Werte ersatzlos. Über database_maxage_action lässt sich jetzt stattdessen ein „Ersatzwert“ zu einem Intervall verdichten:

Aktion Bedeutung Typ
delete löschen (Standard) beliebig
avg zeitgewichteter Mittelwert num, bool
sum Summe num, bool
min / max Minimal-/Maximalwert num, bool
integrate diskretes Integral über der Zeit num, bool
duty_cycle (alt: on) zeitgewichtete Einschaltdauer bool
countall Anzahl Rohwerte im Intervall beliebig
first / last ältester/neuester Rohwert, unverändert beliebig, auch str

Das läuft komplett Python-seitig im Plugin, identisch auf SQLite3, MySQL/MariaDB und PostgreSQL. Wer zusätzlich zum Mittelwert auch Min/Max eines Intervalls dauerhaft behalten will, kann dafür die mitgelieferten Structs database.min/database.max nutzen (legen automatisch eigene Kind-Items mit den jeweiligen Werten an).

Beispiel: vier Items, vier verschiedene Konfigurationen

In den Konfigurationen fehlen die eigentlichen Datenquellen, die darf sich jeder dazu denken.

# plugin.yaml
database:
    plugin_name: database
    driver: sqlite3
    default_maxage_interval: 24h
# items/beispiel.yaml
wohnzimmer:
    temperatur:
        type: num
        database: init
        database_maxage: 90
        database_maxage_action: avg
        database_maxage_interval: 1h

pv:
    leistung:
        type: num
        database: init
        database_maxage: 365
        database_maxage_action: integrate
        database_maxage_interval: 1d

haustuer:
    kontakt:
        type: bool
        database: init
        database_maxage: 30
        database_maxage_action: countall

heizung:
    status:
        type: str
        database: init
        database_maxage: 14
        database_maxage_action: last
        database_maxage_interval: 6h
  • wohnzimmer.temperatur: nach 90 Tagen werden die Rohwerte je Stunde zu einem Mittelwert verdichtet – Langzeitverlauf bleibt Jahre erhalten, die minütlichen Rohdaten nicht.
  • pv.leistung: nach einem Jahr wird aus der Momentanleistung je Tag die tatsächlich erzeugte Energie integriert – klassischer Fall für integrate.
  • haustuer.kontakt: setzt kein eigenes database_maxage_interval – fällt auf den Plugin-Parameter default_maxage_interval: 24h zurück. Nach 30 Tagen bleibt pro Tag nur noch die Anzahl der Türöffnungen übrig.
  • heizung.status: ein str-Item – hier funktionieren avg/sum/etc. nicht, aber last schon. Nach 14 Tagen bleibt je 6h-Intervall nur noch der letzte bekannte Status übrig.

Effekt in der Praxis: Die Kompaktierung läuft im selben Zyklus wie das bisherige Löschen (removeold_cycle) und arbeitet sich – genau wie das – in begrenzten Schritten durch alte Daten (max_aggregate_intervals), pro Item. Bei vier Items mit doch recht unterschiedlichem Alter blockiert also keines der anderen den Durchlauf.

Ein Punkt, den man im Hinterkopf behalten sollte: Ändert man später bei einem Item mit countall (hier haustuer.kontakt) nachträglich das database_maxage_interval, werden schon kompaktierte alte Intervalle beim nächsten Durchlauf mit in ein neues, anders breites Intervall gezogen – für avg/sum/min/max/integrate/duty_cycle rechnerisch unproblematisch, bei countall zählt ein bereits kompaktiertes Intervall dann aber nur noch als „1“ statt als die ursprüngliche Anzahl Rohwerte. Kein Bug, nur eine Ungenauigkeit, die man bei countall + nachträglicher Intervalländerung kennen sollte.

3. PostgreSQL+TimescaleDB als neues Backend

Neben SQLite3 und MySQL/MariaDB unterstützt das Plugin jetzt auch PostgreSQL, optional mit der TimescaleDB-Erweiterung. Die Treiber-Auswahl ist jetzt auch komfortabler: driver akzeptiert neben den echten Modulnamen (psycopg2/psycopg) auch sprechende Namen wie postgres, postgresql, timescale, timescaledb (und mysql/mariadb statt pymysql) – welches Python-Modul installiert ist, wird automatisch erkannt.

database_timescale:
    plugin_name: database
    driver: timescaledb
    connect:
    -   host:127.0.0.1
    -   port:5432
    -   user:shng
    -   password:shng_password
    -   database:shng

Was TimescaleDB bringt:

  • Hypertables – die log-Tabelle wird automatisch in Zeitpartitionen (Dateien für einzelne Zeiträume) aufgeteilt, macht zeitbereichsbasierte Abfragen auf großen Tabellen deutlich schneller.
  • Native Kompression (timescale_compress: true) – TimescaleDBs eigene Doku nennt 10-20x (90-95% Speicherplatzersparnis) als üblich für Zeitreihendaten; im eigenen Test gegen einen echten 22-Millionen-Zeilen-Datensatz kamen 17,39x raus.
  • Native Aggregation (timescale_native_aggregation: true) – statt Python-seitiger Aggregierung übernimmt TimescaleDB das über Continuous Aggregates, direkt auf dem Server. Dieselben Item-Attribute (database_maxage_action etc.) steuern das weiterhin.
  • Native Retention (timescale_native_retention: true) – alte Rohdaten-Zeitpartitionen werden automatisch server-seitig fallengelassen statt Item für Item gelöscht.

Und die Nachteile, ehrlich gesagt:

  • Mehr Aufwand als SQLite3 (extra Server, extra Python-Paket psycopg2-binary oder psycopg).
  • Native Retention ist ein globaler Schwellwert für die ganze Tabelle, kein Wert pro Item mehr – auch Items mit database_maxage_action: delete warten dann auf denselben globalen Schwellwert.
  • Wichtig: Items ohne database_maxage sind davon nicht ausgenommen. ALLE Items werden nach der längsten eingestellten Zeit aggregiert – oder gelöscht. Wenn keine default_maxage_action eingestellt ist, werden alle nicht ausdrücklich anders konfigurierten Datenbankwerte der Items gelöscht. Wer native Retention nutzt, sollte default_maxage und default_maxage_action setzen.
  • Der Wechsel von nativer Aggregation zurück zu Plugin-Aggregierung ist nicht vorgesehen (nicht implementiert, da alles andere als trivial) und nicht verlustfrei möglich.
  • Auch die einmal aktivierte Kompression lässt sich über das Plugin nicht mehr rückgängig machen.

Warum gerade TimescaleDB

Es gibt schon ein InfluxDB-Plugin, und Influx gilt seit Langem als die Referenz für Zeitreihen-Datenbanken. Das Problem mit InfluxDB ist dabei zweierlei: zum Einen hat sich die API mit jeder größeren Version komplett geändert, was ständigen Updateaufwand mit sich bringt und für jede Version ein eigenes Plugin erfordern würde. Zum Anderen ist die aktuelle Open-Source-Linie (InfluxDB 3 Core) explizit als Edge-/Echtzeit-Collector positioniert – Compactor und Optimierungen für Langzeit-Speicherung fehlen, und genau die wären für unseren Stats-/Grafik-Anwendungsfall am wichtigsten. Für brauchbare Abfrage-Performance über die Historie braucht es InfluxDB Enterprise. Es gibt zwar eine kostenlose Hobby-Stufe, aber damit hängt man an einer Hersteller-Lizenzstufe statt an einer eigenständigen Open-Source-Installation – für uns als reines Community-Projekt nicht ideal.

TimescaleDB basiert auf PostgreSQL, einer seit Langem gepflegten, gut getesteten und leistungsfähigen Datenbank, die auf den meisten System problemlos verfügbar ist. Die TimescaleDB-Erweiterung bringt mit der nativen Kompression einen großen Platzvorteil mit (siehe oben, 10-20x laut Timescale, 17,39x im eigenen Test), und die nativen Aggregations- und Retentionsfunktionen entlasten SmartHomeNG von Routineaufgaben, die sonst Python-seitig laufen müssten.

Beispiel: dieselben vier Items, jetzt nativ auf TimescaleDB

An den Items selbst ändert sich nichts – dieselbe Konfiguration wie oben. Nur plugin.yaml bekommt zwei zusätzliche Zeilen:

database_timescale:
    plugin_name: database
    driver: timescaledb
    connect:
    -   host:127.0.0.1
    -   port:5432
    -   user:shng
    -   password:shng_password
    -   database:shng
    default_maxage_interval: 24h
    timescale_hypertable: true
    timescale_native_aggregation: true
    timescale_native_retention: true

Was jetzt passiert: TimescaleDB legt ein Continuous Aggregate je tatsächlich genutzter Intervallbreite an, nicht eines je Item. Die Continuous Aggregates werden intern als virtuelle Tabelle über der log-Tabelle eingeblendet. Bei den vier Beispiel-Items sind das genau drei: 1h (wohnzimmer.temperatur), 6h (heizung.status) und 1d (pv.leistung und haustuer.kontakt über den default_maxage_interval-Fallback teilen sich hier eines, weil beide auf dieselbe Intervallbreite kommen). Die Aggregation selbst läuft weiter pro Item korrekt nach dessen eigener Konfiguration.

Der eigentliche Unterschied, konkret an diesem Beispiel: pv.leistung hat mit 365 Tagen das mit Abstand längste database_maxage. Unter nativer Retention wird genau dieser Wert (plus eine Zeitpartition Sicherheitsspanne) zum einzigen Schwellwert für die gesamte Tabelle. Die Rohdaten von haustuer.kontakt – eigentlich für 30 Tage konfiguriert – bleiben deswegen faktisch bis zu ein Jahr lang liegen, weil TimescaleDB pro Zeitpartition löscht und diese Item-übergreifend geteilt werden. Im Plugin-Modus (Abschnitt 2) würde haustuer.kontakt dagegen zuverlässig nach 30 Tagen kompaktiert. Das ist genau der „globaler statt Item-genauer Schwellwert“-Nachteil von oben, nur diesmal mit echten Zahlen dahinter. Durch die Kompression schrumpft der Platzbedarf für die eigentlich nicht erforderlichen Daten so weit, dass der zusätzliche Speicherplatz in der Praxis kein Problem darstellt. Trotzdem lässt sich dieses Feature optimal nutzen, wenn database_maxage bei allen Items in derselben Größenordnung liegt.

Für die meisten Home-Installationen reicht SQLite3 grundsätzlich aus. Da ich aber gehört habe, wie viele Millionen Zeilen viele Leute im Forum in ihren Datenbanken haben, wird TimescaleDB schon wieder interessant – und sei es nur aufgrund der Kompression, selbst ohne native Aggregation/Retention.

Migration von Datenbanken

Wer bestehende Daten zwischen Backends umziehen will (z.B. SQLite3 → TimescaleDB, oder umgekehrt): dafür gibt’s jetzt tools/db_migrate.py als eigenständiges Skript (läuft nur bei gestopptem SmartHomeNG). Migriert Item- und Log-Daten direkt zwischen zwei beliebigen unterstützten Backends, unterstützt Resume und --dry-run. Aber vorsicht – selbst auf einem schnellen Rechner sollte man für 22 Millionen Zeilen schon gute drei Stunden reine Transferzeit einplanen.


0 Kommentare

Schreibe einen Kommentar

Avatar-Platzhalter

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert