Tahoma: verlorene Funkbefehle erkennen und nachsenden

Der Funk von der Box zum Motor ist unbestaetigt. Die Box nimmt einen Befehl
an und meldet Erfolg, auch wenn er den Motor nie erreicht - dann faehrt der
Behang gar nicht. Erkennbar ist das allein daran, dass er hinterher nicht
dort steht, wo er stehen soll. Genau das ist jetzt die Bedingung fuer den
zweiten Versuch.

Der Ansatz stand schon in der Datei, konnte aber nicht wirken:

Die Endstellung wurde gegen Neigung 0 geprueft, obwohl gerade die
gewuenschte Neigung geschickt worden war. Damit lieferte die Wartefunktion
immer "nicht erreicht" - verlorener Befehl und geglueckte Fahrt sahen gleich
aus, jedes Kommando lief zweimal 120 Sekunden und ging zweimal raus.

aktion["actor_name"] gibt es im Auftrag nicht; der Name steht eine Ebene
hoeher. Der KeyError flog nach der Vorstufe, also nachdem "Neigung 0" schon
draussen war: der Behang fuhr auf Position und liess die Lamellen offen. 23
Mal im Protokoll, zuletzt am 21.09. um 18:49 an acht Jalousien.

Jetzt:

  _positionsZiel() leitet das Ziel auch fuer "up" und "down" ab (0 bzw. 100
  Prozent Schliessung). Ohne das haette ausgerechnet der haeufigste Befehl
  kein pruefbares Ziel und der Wiederholversuch liefe fuer ihn leer.

  _zieleErreicht() vergleicht mit zwei Prozentpunkten Spielraum: io-Motoren
  melden fuer befohlene 100 gern 99 oder 101. Ohne Toleranz gaelte eine
  geglueckte Fahrt als verloren. None heisst "ist egal" - damit funktionieren
  auch Rollladen ohne Lamellen und reine Neigungsbefehle.

  _fahren() haelt die Schleife an einer Stelle. Vor der Wiederholung wird
  noch einmal nachgesehen, weil der Stand nur alle zwei Sekunden gelesen wird
  und der Behang in der letzten Sekunde angekommen sein kann. Ohne pruefbares
  Ziel (stop, my, wink) wird einmal geschickt und nicht gewartet.

Wiederholt wird ausdruecklich immer, wenn das Ziel nicht erreicht ist - auch
wenn der Behang unterwegs war und woanders stehengeblieben ist. Die
Unterscheidung waere ueber core:MovingState moeglich und ist bewusst nicht
gewollt.

Geprueft ohne Schaltbefehle: Ziel erreicht -> einmal gesendet; Befehl
verloren -> zweimal; erster verloren, zweiter kommt an -> zweimal, Erfolg;
99 statt 100 -> einmal; Rollladen ohne Lamellen, stop und reine Neigung
jeweils richtig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 19:47:39 +02:00
co-authored by Claude Opus 5
parent b4e3531800
commit 813af4e8a2
2 changed files with 109 additions and 24 deletions
+106 -24
View File
@@ -455,6 +455,14 @@ class TahomaTransport(Transport):
# nach einem Kommando steht dort noch der alte Wert.
JALOUSIE_VORLAUF_SEKUNDEN = 8
# So oft wird ein Kommando hoechstens geschickt, wenn der Behang danach
# nicht auf seinem Ziel steht. Zwei Versuche, weil ein dritter bei einem
# wirklich stummen Motor nur Zeit kostet - siehe _fahren().
JALOUSIE_VERSUCHE = 2
# Wie weit der gemeldete Stand vom Ziel abweichen darf, in Prozentpunkten.
JALOUSIE_TOLERANZ = 2
def __init__(self, requests_modul, pin, token, timeout=10, dry_run=False):
self.requests = requests_modul
self.pin = pin
@@ -608,20 +616,78 @@ class TahomaTransport(Transport):
" (zuerst %s, dann warten)" % vorstufe if umweg else "")
return
if umweg:
self._apply(aktion["actor_url"], befehl, vorstufe)
if(not self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 1.Versuch", 0,
None if position_index is None else int(parameter[position_index]))):
self._apply(aktion["actor_url"], befehl, vorstufe)
self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 2.Versuch", 0,
None if position_index is None else int(parameter[position_index]))
self._apply(aktion["actor_url"], befehl, parameter)
if(not self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 1.VersuchEndPos", 0,
None if position_index is None else int(parameter[position_index]))):
self._apply(aktion["actor_url"], befehl, parameter)
self._warteAufJalousie(aktion["actor_url"], aktion["actor_name"]+" 2.VersuchEndPos", 0,
None if position_index is None else int(parameter[position_index]))
position_ziel = self._positionsZiel(befehl, parameter, position_index)
neigung_ziel = (int(parameter[neigung_index])
if neigung_index is not None else None)
if umweg:
# Dieselbe Position, aber Neigung 0 - das ist der ganze Sinn der
# Vorstufe.
self._fahren(aktion, befehl, vorstufe, 0, position_ziel, "Vorstufe")
self._fahren(aktion, befehl, parameter, neigung_ziel, position_ziel, "Endstellung")
def _positionsZiel(self, befehl, parameter, position_index):
"""
Wo der Behang danach stehen soll - oder None, wenn das niemand weiss.
Bei setClosure und setClosureAndOrientation steht es im Parameter.
"up" und "down" tragen keinen, meinen aber null bzw. hundert Prozent
Schliessung; ohne diese Zuordnung haette ausgerechnet der haeufigste
Befehl kein pruefbares Ziel, und der Wiederholversuch liefe fuer ihn
leer.
Bei "my", "stop" und "wink" gibt es wirklich keines. Dort wird nichts
geprueft und nichts wiederholt - siehe _fahren().
"""
if position_index is not None:
return int(parameter[position_index])
return {"up": 0, "down": 100}.get(befehl)
def _fahren(self, aktion, befehl, parameter, neigung_ziel, position_ziel, schritt):
"""
Ein Kommando schicken und wiederholen, bis der Behang sein Ziel zeigt.
Der Funk von der Box zum Motor ist unbestaetigt: die Box nimmt den
Befehl an und meldet Erfolg, auch wenn er den Motor nie erreicht -
dann faehrt der Behang gar nicht. Erkennbar ist das allein daran,
dass er hinterher nicht dort steht, wo er stehen soll. Genau das ist
die Bedingung fuer den zweiten Versuch.
Wiederholt wird in jedem Fall, in dem das Ziel nicht erreicht ist.
Ob der Behang unterwegs war, liesse sich an core:MovingState ablesen
und koennte einen Abbruch begruenden (Hindernis, Wandschalter) - das
ist hier bewusst nicht gewollt: ein Behang, der nicht dort steht, wo
er stehen soll, soll es noch einmal versuchen.
Ohne pruefbares Ziel wird einmal geschickt und nicht gewartet. Bei
"stop" auf das Ende einer Fahrt zu warten waere ein Widerspruch in
sich, und zu pruefen gaebe es nichts.
"""
name = aktion.get("actor_name") or aktion["actor_url"]
if neigung_ziel is None and position_ziel is None:
self._apply(aktion["actor_url"], befehl, parameter)
return True
for versuch in range(1, self.JALOUSIE_VERSUCHE + 1):
self._apply(aktion["actor_url"], befehl, parameter)
if self._warteAufJalousie(aktion["actor_url"],
"%s, %s, Versuch %d" % (name, schritt, versuch),
neigung_ziel, position_ziel):
return True
if versuch >= self.JALOUSIE_VERSUCHE:
break
# Vor der zweiten Fahrt noch einmal hinsehen. Der Stand wird alle
# zwei Sekunden gelesen und von der Box traege gemeldet - er kann
# in der letzten Sekunde des Fensters angekommen sein, und dann
# waere die Wiederholung eine Fahrt zuviel.
if self._zieleErreicht(self._zustaende(aktion["actor_url"]),
neigung_ziel, position_ziel):
return True
logger.info("%s (%s): nicht auf dem Ziel, %d. Versuch",
name, schritt, versuch + 1)
logger.warning("%s (%s): Ziel nach %d Versuchen nicht erreicht",
name, schritt, self.JALOUSIE_VERSUCHE)
return False
def _kannKombi(self, actor_url):
"""
Hat das Geraet ein Kommando fuer Position und Neigung zusammen?
@@ -640,6 +706,29 @@ class TahomaTransport(Transport):
raise RuntimeError("Tahoma antwortete mit %d: %s"
% (antwort.status_code, antwort.text[:120]))
def _zieleErreicht(self, zustaende, neigung_ziel, schliessung_ziel):
"""
Zeigt das Geraet die gewuenschten Werte? None heisst "ist egal".
Verglichen wird mit Spielraum: io-Motoren melden fuer befohlene 100 %
gern 99 oder 101. Ohne Toleranz gaelte eine geglueckte Fahrt als
verloren, und der Behang fuehre ein zweites Mal - genau das, was der
Wiederholversuch verhindern soll.
Ein fehlendes Feld gilt nie als erreicht. Als 0 durchgehen zu lassen
waere ausgerechnet beim Ziel 0 ein falsches Erfolgssignal.
"""
if zustaende is None:
return False
def passt(wert, ziel):
if ziel is None:
return True
return wert is not None and abs(int(wert) - ziel) <= self.JALOUSIE_TOLERANZ
return (passt(zustaende.get("core:SlateOrientationState"), neigung_ziel)
and passt(zustaende.get("core:ClosureState"), schliessung_ziel))
def _warteAufJalousie(self, actor_url, actor_name, neigung_ziel, schliessung_ziel=None):
"""
Wartet, bis die Jalousie ihre Fahrt beendet hat und die Ziele zeigt.
@@ -662,20 +751,13 @@ class TahomaTransport(Transport):
if z.get("core:MovingState") is True:
gestartet = True
continue
neigung = z.get("core:SlateOrientationState")
schliessung = z.get("core:ClosureState")
# Ein fehlendes Feld darf nicht als 0 durchgehen - das waere
# ausgerechnet beim Ziel 0 ein falsches Erfolgssignal.
neigung_ok = neigung is not None and int(neigung) == neigung_ziel
schliessung_ok = (schliessung_ziel is None
or (schliessung is not None and int(schliessung) == schliessung_ziel))
if neigung_ok and schliessung_ok and (
if self._zieleErreicht(z, neigung_ziel, schliessung_ziel) and (
gestartet or time.time() - start >= self.JALOUSIE_VORLAUF_SEKUNDEN):
return True
logger.warning("%s(%s) hat Neigung %s%% nicht innerhalb von %d s erreicht",
actor_url,actor_name, neigung_ziel, self.JALOUSIE_WARTE_SEKUNDEN)
logger.warning("%s (%s) steht nach %d s nicht auf Neigung %s / Position %s",
actor_url, actor_name, self.JALOUSIE_WARTE_SEKUNDEN,
neigung_ziel, schliessung_ziel)
return False
@staticmethod
def _zahl(wert):
"""Tahoma erwartet Zahlen als Zahlen, Text als Text."""