Discovery: alle Geraete finden und stabil ablegen

Ein Lauf mit allen Modulen brachte 54 Geraete und 441 Messwerte - und fuenf
Fehler ans Licht, die vorher niemand sehen konnte, weil clear_tables die
Tabellen bei jedem Lauf geleert hat.

Vier davon sind Schluessel- und Upsert-Fehler derselben Familie:

 * command_parameters war ueber (command_id, url) eindeutig. WLED-Parameter
   haben keine URL - ihr Name ist der Platzhalter in der Kommando-Vorlage -,
   und eine NULL kollidiert in MySQL nie. ON DUPLICATE KEY UPDATE griff
   also nicht, und jeder Lauf legte dieselben Parameter erneut an: aus 18
   wurden nach drei Laeufen 54. Schluessel jetzt (command_id,
   parameter_name).

 * actor_states war ueber (actor_id, url) eindeutig. Umgekehrtes Problem:
   Messwerte, die sich ein Topic teilen, ueberschrieben einander. Der
   go-eCharger schickt sechzehn Werte als JSON-Feld auf einem Topic - von
   denen kam genau einer in der Datenbank an. Schluessel jetzt (actor_id,
   state_name), das bringt 50 verlorene Messwerte zurueck.

 * Kommandos, Messwerte und Parameter aktualisierten ihre URL beim
   Wiederholungslauf nicht - sie stand nicht im UPDATE-Teil. Eine Korrektur
   in einem Modul kam damit nie in einer bestehenden Datenbank an.

 * Der Typ eines Kommando-Parameters wurde als Text in eine int-Spalte
   geschrieben. MariaDB macht daraus stillschweigend 0, und 0 ist "bool" -
   deshalb bot der Editor fuer die WLED-Helligkeit (0..255) ein Ja/Nein an.

Der fuenfte: die Shelly-Gen1-Relais hatten weder eine Kommando-URL noch eine
URL am Zustand. Ohne die weiss niemand, was zu schicken und wo nachzusehen
ist. Gen1 schaltet ueber ?turn=on|off|toggle und meldet sich in "ison".

Damit entfaellt auch die Uebersetzungstabelle im HTTPTransport: die
Zuordnung gehoert ins Geraetemodell, nicht in den Runner.

Zwei Luecken auf der Runner-Seite, die derselbe Lauf gezeigt hat:

 * WLED liess sich gar nicht ansteuern - es gab keinen Transport dafuer.
   Der neue nutzt die JSON-Vorlage aus command_url, fuellt die Platzhalter
   und schickt sie am Stueck an /json/state.

 * Tahoma wurde am Schema "io://" erkannt. Das Schema beschreibt aber die
   Funkart: dieselbe Box liefert rts:// fuer die Dachfenster und
   internal:// fuer die Alarmanlage. Drei von 22 Geraeten fielen durch.
   Erkannt wird jetzt an der Box-Kennung in der URL.

Nachgemessen: zwei Laeufe hintereinander aendern keine einzige Zeile mehr,
die Geraete-IDs bleiben ueber Laeufe stabil (Voraussetzung fuer die
Fremdschluessel der Automatiken), alle 54 Geraete finden genau einen
Transport, und 440 der 441 Messwerte sind tatsaechlich lesbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-31 14:57:29 +02:00
co-authored by Claude Opus 5
parent 0cb1fc635e
commit 631baa7333
6 changed files with 180 additions and 46 deletions
+18 -10
View File
@@ -114,19 +114,27 @@ Fenster gar nichts gelaufen ist — dann ist auch keine Sperre aktiv.
Welcher Weg zum Gerät führt, entscheidet die URL des Aktors in `actors`:
| URL | Messwert (`actor_states.url`) | Kommando |
|-------------|-------------------------------|-------------------------------------|
| `mqtt://…` | vollständiges Topic, abonniert | publiziert auf das Parameter-Topic |
| `http://…` | Feldname in der JSON-Antwort, gepollt | Parameter als Abfrageargumente |
| `io://…` | Tahoma-Statusname, gepollt | `exec/apply` an die Tahoma-Box |
| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | |
| URL | Messwert (`actor_states.url`) | Kommando (`actor_commands.command_url`) |
|---|---|---|
| `mqtt://…` | vollständiges Topic, abonniert | Nutzlast auf das Parameter-Topic |
| `http://…` | Feldname in der JSON-Antwort, gepollt | Abfrageargumente an die Geräte-URL (`turn=on`) |
| `wled://…` | Pfad in `/json/state` (`seg[0].col[0]`), gepollt | JSON-Vorlage mit Platzhaltern, als Ganzes gesendet |
| Tahoma | Statusname (`core:ClosureState`), gepollt | `exec/apply` an die Box |
| `Logic` | gerechnet: Uhrzeit, Datum, Sonne | |
Alle vier stehen in `transports.py`. Eine fünfte Geräteart kommt als weitere
Alle fünf stehen in `transports.py`. Eine sechste Geräteart kommt als weitere
Klasse dazu; sie braucht `passt()`, `zustaende_lesen()` und `senden()`.
Bei HTTP heißen die Schaltbefehle nicht so wie in der Datenbank — dafür gibt es
in `HTTPTransport` eine kleine Übersetzungstabelle (`turn_on``on=true`).
Das ist die Stelle, die beim Anschluss neuer HTTP-Geräte wächst.
Tahoma ist der einzige, der nicht am URL-Schema erkannt wird, sondern an der
**Box-Kennung** in der URL. Das Schema beschreibt dort die Funkart, und
dieselbe Box liefert `io://` für die Jalousien, `rts://` für die Dachfenster
und `internal://` für die Alarmanlage. Ohne `pin` in der `config.ini` ist
niemand zuständig — dann meldet der Runner beim Auslösen „kein Transport",
statt still nichts zu tun.
Bei WLED trägt die Kommando-Vorlage alles: `{"seg":[{"col":[[%red%,%green%,%blue%]]}]}`
wird mit den Parameterwerten gefüllt und am Stück geschickt. Deshalb haben die
Parameter dort keine eigene URL — ihr Name *ist* der Platzhalter.
## Voraussetzungen
+2 -1
View File
@@ -55,7 +55,7 @@ import requests
import paho.mqtt.client as mqtt
from transports import (HTTPTransport, LogicTransport, MQTTTransport,
TahomaTransport)
TahomaTransport, WLEDTransport)
logger = logging.getLogger("autoaction")
@@ -377,6 +377,7 @@ class Runner:
self.mqtt = self._mqtt_verbinden()
self.transporte = [
MQTTTransport(self.mqtt, self.dry_run),
WLEDTransport(requests, dry_run=self.dry_run),
HTTPTransport(requests, dry_run=self.dry_run),
TahomaTransport(requests, config.text("tahoma", "pin"),
config.text("tahoma", "token"),
+133 -18
View File
@@ -8,11 +8,14 @@ URL des Aktors in der Tabelle `actors`:
mqtt://... MQTT-Geraete (Home-Assistant-Discovery)
http://... Shelly und andere HTTP-Geraete
io://... Tahoma
wled://... WLED-Lampen
io://, rts://, internal://, ogp:// Tahoma - das Schema haengt an der
Funkart des Geraets, deshalb wird dort nicht danach
entschieden, sondern an der Box-Kennung in der URL
Logic das gerechnete Geraet "Zeitpunkt" (Uhrzeit, Datum, Sonne)
Alle liegen in einer Datei statt in einem Paket wie bei deviceDiscovery: es
sind vier kurze Klassen, und wer eine fuenfte Geraeteart anschliesst, sieht
sind fuenf kurze Klassen, und wer eine sechste Geraeteart anschliesst, sieht
hier auf einen Blick, was dafuer zu tun ist.
Jeder Transport hat zwei Haelften:
@@ -28,11 +31,19 @@ Jeder Transport hat zwei Haelften:
import json
import logging
import re
import urllib3
from datetime import datetime
from urllib.parse import quote
logger = logging.getLogger("autoaction.transport")
# Die Tahoma-Box hat ein selbst ausgestelltes Zertifikat auf einen Namen, den
# nur das Heimnetz kennt. Die Pruefung ist dort bewusst aus (wie in
# ajax/tahoma.php); ohne diese Zeile warnt urllib3 bei jeder einzelnen
# Abfrage und uebertoent das eigentliche Protokoll.
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class Transport:
"""Gemeinsame Form. Wer nichts zu lesen hat, erbt die leeren Methoden."""
@@ -137,20 +148,15 @@ class HTTPTransport(Transport):
JSON-Antwort ("tC", "a_voltage"). Solche Geraete melden sich nicht von
selbst, sie werden im Poll-Takt gefragt.
Beim Senden haengen die Parameter als Abfrageargumente an die actor_url.
Die Schaltbefehle von Shelly heissen aber nicht so, wie sie in der
Datenbank stehen - dafuer steht unten eine kleine Uebersetzungstabelle.
Wer ein anderes HTTP-Geraet anschliesst, erweitert genau die.
Beim Senden wird die command_url als Abfrageargument an die actor_url
gehaengt ("turn=on") und die Parameter mit ihrem eigenen Namen dazu.
Frueher stand hier eine Uebersetzungstabelle, weil die Shelly-Kommandos
ohne URL in der Datenbank landeten - das ist im Discovery behoben, die
Zuordnung gehoert dorthin und nicht in den Runner.
"""
schema = "http"
SHELLY_BEFEHLE = {
"turn_on": {"on": "true"},
"turn_off": {"on": "false"},
"toggle": {"toggle": "true"},
}
def __init__(self, requests_modul, timeout=5, dry_run=False):
self.requests = requests_modul
self.timeout = timeout
@@ -181,14 +187,17 @@ class HTTPTransport(Transport):
return werte
def senden(self, aktion):
argumente = dict(self.SHELLY_BEFEHLE.get(aktion["command_url"], {}))
argumente = {}
for teil in (aktion["command_url"] or "").split("&"):
if "=" in teil:
schluessel, wert = teil.split("=", 1)
argumente[schluessel] = wert
for p in aktion["params"]:
if p["url"]:
argumente[p["url"]] = p["wert"]
if not argumente and aktion["command_url"]:
# Kein bekannter Schaltbefehl und keine Parameter: das Kommando
# als Argument anhaengen, mehr laesst sich hier nicht ableiten.
argumente[aktion["command_url"]] = "true"
if not argumente:
raise RuntimeError("Kommando ohne URL und ohne Parameter - im "
"Geraetemodell fehlt die Angabe, was zu schicken ist")
if self.dry_run:
logger.info("[dry-run] HTTP %s %s", aktion["actor_url"], argumente)
return
@@ -197,16 +206,119 @@ class HTTPTransport(Transport):
raise RuntimeError("HTTP %d von %s" % (antwort.status_code, aktion["actor_url"]))
# ---------------------------------------------------------------------------
# WLED
# ---------------------------------------------------------------------------
class WLEDTransport(Transport):
"""
WLED-Lampen sprechen ueber eine einzige JSON-Schnittstelle:
GET http://IP/json/state liefert den Zustand, POST dorthin setzt ihn.
Das Geraetemodell nutzt das elegant aus - die command_url ist eine
JSON-Vorlage mit Platzhaltern:
{"bri":%brightness%}
{"seg":[{"col":[[%red%,%green%,%blue%]]}]}
Gesendet wird also nicht Argument fuer Argument, sondern die ausgefuellte
Vorlage am Stueck. Deshalb haben die Parameter hier auch keine eigene URL:
ihr Name ist der Platzhalter.
Die state_url ist ein Pfad in die Antwort ("on", "bri",
"seg[0].col[0]") - dieselbe Schreibweise, die auch in current_value steht.
"""
schema = "wled://"
def __init__(self, requests_modul, timeout=5, dry_run=False):
self.requests = requests_modul
self.timeout = timeout
self.dry_run = dry_run
self.states = []
@staticmethod
def _adresse(actor_url):
return "http://" + actor_url[len("wled://"):].rstrip("/")
def zustaende_anmelden(self, states):
self.states = [s for s in states if s["state_url"]]
logger.info("WLED: %d Messwerte an %d Lampen",
len(self.states), len({s["actor_url"] for s in self.states}))
@staticmethod
def _pfad(daten, pfad):
""""seg[0].col[0]" in der Antwort nachschlagen."""
for teil in pfad.split("."):
treffer = re.match(r"^([^\[]*)((?:\[\d+\])*)$", teil)
if not treffer:
return None
if treffer.group(1):
daten = daten[treffer.group(1)]
for index in re.findall(r"\[(\d+)\]", treffer.group(2)):
daten = daten[int(index)]
return daten
def zustaende_lesen(self):
werte = {}
nach_lampe = {}
for s in self.states:
nach_lampe.setdefault(s["actor_url"], []).append(s)
for actor_url, states in nach_lampe.items():
try:
antwort = self.requests.get(self._adresse(actor_url) + "/json/state",
timeout=self.timeout)
daten = antwort.json()
except Exception as fehler:
logger.debug("WLED %s nicht erreichbar: %s", actor_url, fehler)
continue
for s in states:
try:
werte[s["id"]] = str(self._pfad(daten, s["state_url"]))
except (KeyError, IndexError, TypeError):
logger.debug("WLED %s: Pfad %s nicht gefunden", actor_url, s["state_url"])
return werte
def senden(self, aktion):
vorlage = aktion["command_url"]
if not vorlage:
raise RuntimeError("WLED-Kommando ohne Vorlage")
for p in aktion["params"]:
vorlage = vorlage.replace("%" + p["name"] + "%", str(p["wert"]))
try:
rumpf = json.loads(vorlage)
except ValueError:
# Ein nicht ersetzter Platzhalter oder ein Textwert an einer
# Stelle, wo eine Zahl stehen muss. Lieber hier abbrechen als der
# Lampe etwas Unverstaendliches schicken.
raise RuntimeError("WLED-Vorlage ergibt kein gueltiges JSON: " + vorlage[:120])
if self.dry_run:
logger.info("[dry-run] WLED %s <- %s", aktion["actor_url"],
json.dumps(rumpf, ensure_ascii=False))
return
antwort = self.requests.post(self._adresse(aktion["actor_url"]) + "/json/state",
json=rumpf, timeout=self.timeout)
if antwort.status_code >= 400:
raise RuntimeError("WLED antwortete mit %d" % antwort.status_code)
# ---------------------------------------------------------------------------
# Tahoma
# ---------------------------------------------------------------------------
class TahomaTransport(Transport):
"""
Die actor_url ist die deviceURL (io://...), die state_url ein Statusname
Die actor_url ist die deviceURL, die state_url ein Statusname
("core:ClosureState"), die command_url ein Kommandoname ("setClosure").
Geschickt wird ueber exec/apply - genauso wie in ajax/tahoma.php, nur ohne
die dortige Sonderbehandlung fuer "faehrt gerade".
Zustaendig ist dieser Transport fuer alles, was die Kennung der eigenen
Box in der URL traegt. Am Schema laesst sich das nicht festmachen: es
beschreibt die Funkart, und dieselbe Box liefert io:// fuer die
Jalousien, rts:// fuer die Dachfenster und internal:// fuer die Alarm-
anlage. Ohne PIN in der config.ini ist niemand zustaendig - dann meldet
der Runner beim Ausloesen "kein Transport", statt still nichts zu tun.
"""
schema = "io://"
@@ -219,6 +331,9 @@ class TahomaTransport(Transport):
self.dry_run = dry_run
self.states = []
def passt(self, actor_url):
return bool(self.pin) and ("://" + self.pin + "/") in actor_url
@property
def basis(self):
return "https://gateway-%s:8443/enduser-mobile-web/1/enduserAPI" % self.pin