Files
ha_config/custom_components/better_thermostat/events/temperature.py
T

561 lines
23 KiB
Python

"""External temperature event handlers for Better Thermostat.
This module includes logic to handle external temperature updates and apply
debounce, anti-flicker, accumulation, and plateau acceptance heuristics used
to make robust decisions about whether the external temperature should be
propagated to the target devices.
"""
import logging
import math
from time import monotonic
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util
from custom_components.better_thermostat.utils.const import CONF_HOMEMATICIP
from custom_components.better_thermostat.utils.helpers import (
convert_to_float_celsius,
is_reasonable_temperature,
)
_LOGGER = logging.getLogger(__name__)
# is ignored for this time window (seconds).
FLICKER_REVERT_WINDOW = 45 # can optionally be made configurable later
# Accept sub-threshold changes if the new value stays stable for this window (seconds)
PLATEAU_ACCEPT_WINDOW = 120
def _update_external_temp_ema(self, temp_q: float) -> float:
"""Update and return EMA-filtered external temperature.
Uses a time-based EMA so varying sensor update intervals behave sensibly.
Tunables (optional attributes on `self`):
- `external_temp_ema_tau_s` (float): time constant in seconds (e.g. 900=15min, 1800=30min)
"""
tau_s = float(self.external_temp_ema_tau_s or 300.0)
if tau_s <= 0:
tau_s = 300.0
now_m = monotonic()
prev_ts = self._external_temp_ema_ts
prev_ema = self.external_temp_ema
if prev_ts is None or prev_ema is None:
ema = float(temp_q)
else:
dt_s = max(0.0, float(now_m) - float(prev_ts))
# alpha = 1 - exp(-dt/tau)
alpha = 1.0 - math.exp(-dt_s / tau_s) if dt_s > 0 else 0.0
ema = float(prev_ema) + alpha * (float(temp_q) - float(prev_ema))
_LOGGER.debug(
"better_thermostat %s: EMA calc: prev=%.3f input=%.3f dt=%.1fs alpha=%.4f -> new=%.3f",
self.device_name,
float(prev_ema),
float(temp_q),
dt_s,
alpha,
ema,
)
self._external_temp_ema_ts = now_m
self.external_temp_ema = ema
# Expose a generic name so consumers don't need to know EMA vs SMA
self.cur_temp_filtered = round(float(ema), 2)
return float(ema)
async def _apply_temperature_update(self, new_temp):
"""Apply the new external temperature and trigger updates."""
_LOGGER.debug(
"better_thermostat %s: _apply_temperature_update called with %.2f",
self.device_name,
new_temp,
)
_cur_q = None if self.cur_temp is None else round(self.cur_temp, 2)
new_temp_q = round(new_temp, 2)
# Remember previous value as stable pre-measure before updating
if _cur_q is not None and _cur_q != new_temp_q:
self.prev_stable_temp = _cur_q
# Richtung merken (nur bei echter Änderung)
if _cur_q is not None:
if new_temp_q > _cur_q:
self.last_change_direction = 1
elif new_temp_q < _cur_q:
self.last_change_direction = -1
self.cur_temp = new_temp_q
self.last_known_external_temp = new_temp_q
# Update EMA (useful if called from timer after delay)
try:
_update_external_temp_ema(self, float(new_temp_q))
except Exception as exc:
_LOGGER.debug(
"better_thermostat %s: EMA update failed (non-critical): %s",
self.device_name,
exc,
)
_ema = self.external_temp_ema
self.last_external_sensor_change = dt_util.now()
# Reset accumulation & pending after accept
self.accum_delta = 0.0
self.accum_dir = 0
self.pending_temp = None
self.pending_since = None
# Cancel any pending plateau timer
if getattr(self, "plateau_timer_cancel", None) is not None:
self.plateau_timer_cancel()
self.plateau_timer_cancel = None
self.async_write_ha_state()
if _ema is not None:
_LOGGER.debug(
"better_thermostat %s: external_temperature filtered (ema_tau_s=%s) raw=%.2f ema=%.2f",
self.device_name,
self.external_temp_ema_tau_s,
float(new_temp_q),
float(_ema),
)
# Schreibe den von BT verwendeten Wert (self.cur_temp) ins TRV
try:
trv_ids = list(self.real_trvs.keys())
if not trv_ids and hasattr(self, "entity_ids"):
trv_ids = list(self.entity_ids or [])
for trv_id in trv_ids:
quirks = (
self.real_trvs.get(trv_id, {}).get("model_quirks")
if hasattr(self, "real_trvs")
else None
)
if quirks and hasattr(quirks, "maybe_set_external_temperature"):
await quirks.maybe_set_external_temperature(self, trv_id, self.cur_temp)
else:
_LOGGER.debug(
"better_thermostat %s: no quirks with maybe_set_external_temperature for %s",
self.device_name,
trv_id,
)
except (AttributeError, KeyError, TypeError, ValueError, RuntimeError):
_LOGGER.debug(
"better_thermostat %s: external_temperature write to TRV failed (non critical)",
self.device_name,
)
# Enqueue control action (skip during valve maintenance to avoid overwriting exercise).
# Still mark that a control cycle is needed after maintenance so we immediately
# resume with the latest temperature.
if self.control_queue_task is not None:
if getattr(self, "in_maintenance", False):
self._control_needed_after_maintenance = True
else:
await self.control_queue_task.put(self)
_LOGGER.debug(
"better_thermostat %s: _apply_temperature_update finished", self.device_name
)
@callback
async def trigger_temperature_change(self, event):
"""Handle temperature changes.
Parameters
----------
self :
self instance of better_thermostat
event :
Event object from the eventbus. Contains the current trigger time.
Returns
-------
None
"""
if self.startup_running:
return
new_state = event.data.get("new_state")
if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
return
_incoming_temperature = convert_to_float_celsius(
str(new_state.state),
self.device_name,
"external_temperature",
unit_of_measurement=new_state.attributes.get("unit_of_measurement"),
)
# Quantisiere auf 2 Dezimalstellen, um FP-Artefakte zu vermeiden
_incoming_temperature_q = (
None if _incoming_temperature is None else round(_incoming_temperature, 2)
)
# Ensure timestamp exists (first run guard)
if self.last_external_sensor_change is None:
# Setze einen alten Zeitpunkt, damit erste Änderung akzeptiert wird
self.last_external_sensor_change = dt_util.now()
# Basis-Debounce (Sekunden) für normale Geräte; durch Anti-Flicker können wir hier auf 5s runter
# gesetzt werden. HomematicIP erhält unten weiterhin ein höheres Intervall (600s).
_time_diff = 5
# Signifikanz-Schwelle: 0.11°C (um 0.1°C Rauschen zu filtern).
# Wir ignorieren die Toleranz-Einstellung hier, um auch bei größerer Regel-Toleranz
# präzise Sensor-Updates zu erhalten.
_sig_threshold = 0.11
try:
for trv in self.all_trvs:
if trv["advanced"][CONF_HOMEMATICIP]:
_time_diff = 600
except (KeyError, TypeError):
pass
if not is_reasonable_temperature(_incoming_temperature_q):
# raise a ha repair notification
_LOGGER.error(
"better_thermostat %s: external_temperature %s is outside the "
"plausible range; ignoring (raw state: %s)",
self.device_name,
_incoming_temperature_q,
new_state.state,
)
# Minimal kompatibler Aufruf (Parameter-Namen angepasst an aktuelle HA API)
ir.async_create_issue(
hass=self.hass,
domain="better_thermostat",
issue_id=f"invalid_external_temperature_{self.device_name}",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="invalid_external_temperature",
translation_placeholders={
"name": self.device_name,
"value": str(new_state.state),
},
)
return
_now = dt_util.now()
try:
_age = (_now - self.last_external_sensor_change).total_seconds()
except (TypeError, AttributeError): # defensiv, sollte nicht auftreten
_age = 999999
# Gerundete Vergleichswerte
_cur_q = None if self.cur_temp is None else round(self.cur_temp, 2)
_diff = None if _cur_q is None else abs(_incoming_temperature_q - _cur_q)
# Quantisierte Differenz zur robusten Schwellenprüfung (vermeidet 0.099999-Fehler)
_diff_q = None if _diff is None else round(_diff, 2)
_sig_threshold_q = round(_sig_threshold, 2)
_is_significant = _cur_q is None or (
_diff_q is not None and _diff_q >= _sig_threshold_q
)
_interval_ok = _age > _time_diff
# Anti-Flicker: Wenn der neue Wert exakt dem vorherigen stabilen Wert entspricht
# (also ein schneller Rücksprung) UND wir kürzlich erst umgestellt haben,
# dann ignorieren wir diesen Rücksprung bis das Fenster abläuft.
if (
False # Anti-flicker disabled
and _cur_q is not None
and self.prev_stable_temp is not None
and _incoming_temperature_q == round(self.prev_stable_temp, 2)
and _incoming_temperature_q != _cur_q
and _age < FLICKER_REVERT_WINDOW
):
# Plane eine Übernahme nach Ablauf des Revert-Fensters, falls der Sensorwert stabil bleibt
try:
remaining = max(0.0, float(FLICKER_REVERT_WINDOW) - float(_age))
except (ValueError, TypeError):
remaining = float(FLICKER_REVERT_WINDOW)
# Merke Kandidatenwert und cancel ggf. vorherige Planung
cancel_cb = self.flicker_unignore_cancel
if callable(cancel_cb):
cancel_cb()
self.flicker_unignore_cancel = None
self.flicker_candidate = _incoming_temperature_q
def _deadline_cb(_now): # executed by HA loop, schedule async body
async def _apply_if_stable():
try:
# Prüfe aktuellen Sensor-Status
sensor_id = self.sensor_entity_id
state = self.hass.states.get(sensor_id) if sensor_id else None
if state is None or state.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
):
return
_val = convert_to_float_celsius(
str(state.state),
self.device_name,
"external_temperature",
unit_of_measurement=state.attributes.get("unit_of_measurement"),
)
_val_q = None if _val is None else round(_val, 2)
cand = self.flicker_candidate
# Übernehme nur, wenn Kandidatwert unverändert und ungleich cur_temp ist
if _val_q is not None and cand is not None and _val_q == cand:
if _val_q != self.cur_temp:
_LOGGER.debug(
"better_thermostat %s: external_temperature flicker revert auto-accepted after %ss (value=%.2f)",
self.device_name,
FLICKER_REVERT_WINDOW,
_val_q,
)
# Akzeptiere Wert wie im normalen Pfad
_prev = self.cur_temp
if _prev is not None and _prev != _val_q:
self.prev_stable_temp = _prev
if _val_q > _prev:
self.last_change_direction = 1
elif _val_q < _prev:
self.last_change_direction = -1
self.cur_temp = _val_q
try:
_update_external_temp_ema(self, float(_val_q))
except Exception:
_LOGGER.debug(
"better_thermostat %s: external_temperature EMA update failed (non critical)",
self.device_name,
)
self.last_external_sensor_change = dt_util.now()
# Reset Anti-Flicker-Akkumulatoren
self.accum_delta = 0.0
self.accum_dir = 0
self.accum_since = dt_util.now()
self.pending_temp = None
self.pending_since = None
self.async_write_ha_state()
# Schreibe TRV-External-Temp über Quirks, falls vorhanden
try:
trv_ids = list(self.real_trvs.keys())
if not trv_ids and hasattr(self, "entity_ids"):
trv_ids = list(self.entity_ids or [])
for trv_id in trv_ids:
quirks = (
self.real_trvs.get(trv_id, {}).get(
"model_quirks"
)
if hasattr(self, "real_trvs")
else None
)
if quirks and hasattr(
quirks, "maybe_set_external_temperature"
):
await quirks.maybe_set_external_temperature(
self, trv_id, self.cur_temp
)
except (
AttributeError,
KeyError,
TypeError,
ValueError,
RuntimeError,
):
_LOGGER.debug(
"better_thermostat %s: external_temperature write to TRV failed (non critical)",
self.device_name,
)
if self.control_queue_task is not None:
await self.control_queue_task.put(self)
finally:
# Aufräumen
self.flicker_unignore_cancel = None
self.flicker_candidate = None
# schedule the async part
self.hass.loop.create_task(_apply_if_stable())
self.flicker_unignore_cancel = async_call_later(
self.hass, remaining + 0.1, _deadline_cb
)
_LOGGER.debug(
"better_thermostat %s: external_temperature flicker revert ignored (current=%.2f revert=%.2f age=%.1fs < %ss)",
self.device_name,
_cur_q,
_incoming_temperature_q,
_age,
FLICKER_REVERT_WINDOW,
)
return
# Richtungswechsel-Schutz: kleine Gegenbewegungen (<= Schwellwert) innerhalb des Flicker-Fensters ignorieren,
# um Ping-Pong um genau 0.10°C zu vermeiden.
_dir_now = 0
if _cur_q is not None:
if _incoming_temperature_q > _cur_q:
_dir_now = 1
elif _incoming_temperature_q < _cur_q:
_dir_now = -1
_last_dir = self.last_change_direction
_block_flip_small = (
False # Anti-flicker disabled # noqa: PLR1714
and _dir_now != 0
and _last_dir != 0
and _dir_now != _last_dir
and _diff_q is not None
and _diff_q <= _sig_threshold_q
and _age < FLICKER_REVERT_WINDOW
)
if _block_flip_small:
_LOGGER.debug(
"better_thermostat %s: external_temperature opposite-direction change ignored "
"(current=%.2f new=%.2f diff=%.2f age=%.1fs <= %ss threshold=%.2f)",
self.device_name,
(_cur_q if _cur_q is not None else float("nan")),
_incoming_temperature_q,
(_diff if _diff is not None else float("nan")),
_age,
FLICKER_REVERT_WINDOW,
_sig_threshold_q,
)
return
# Slope calculation (simple delta per minute)
# Disabled in favor of periodic EMA-based slope calculation in climate.py
# try:
# now_m = monotonic()
# _last_ts = getattr(self, "_slope_last_ts", None)
# if _last_ts is not None and _cur_q is not None:
# dt_min = max(1e-6, (now_m - _last_ts) / 60.0)
# dT = _incoming_temperature_q - _cur_q # K
# inst_slope = dT / dt_min # K/min
# # light smoothing
# if getattr(self, "temp_slope", None) is None:
# self.temp_slope = inst_slope
# else:
# self.temp_slope = 0.7 * self.temp_slope + 0.3 * inst_slope
# setattr(self, "_slope_last_ts", now_m)
# except (AttributeError, TypeError, ZeroDivisionError):
# pass
# Accumulation of small changes in the same direction
_accept_reason = None
if _cur_q is not None:
_signed_delta = round(_incoming_temperature_q - _cur_q, 2)
if _signed_delta != 0:
# set direction from sign
_acc_dir_now = 1 if _signed_delta > 0 else -1
if self.accum_dir in (0, _acc_dir_now):
self.accum_delta = round(self.accum_delta + _signed_delta, 2)
self.accum_dir = _acc_dir_now if self.accum_dir == 0 else self.accum_dir
else:
# direction flipped: reset accumulation to current delta
self.accum_delta = _signed_delta
self.accum_dir = _acc_dir_now
# Plateau tracking
if self.pending_temp != _incoming_temperature_q:
self.pending_temp = _incoming_temperature_q
self.pending_since = dt_util.now()
# Cancel existing timer if pending value changes
if getattr(self, "plateau_timer_cancel", None) is not None:
self.plateau_timer_cancel()
self.plateau_timer_cancel = None
# no change (value back to current): reset pending/timer
elif self.pending_temp is not None:
self.pending_temp = None
self.pending_since = None
if getattr(self, "plateau_timer_cancel", None) is not None:
self.plateau_timer_cancel()
self.plateau_timer_cancel = None
_accum_ok = (
_cur_q is not None
and abs(self.accum_delta) >= _sig_threshold_q
and _interval_ok
)
# Plateau acceptance: sub-threshold change persisted long enough
_plateau_ok = False
if (
not _is_significant
and _cur_q is not None
and self.pending_temp is not None
and self.pending_temp != _cur_q
and self.pending_since is not None
):
_plateau_age = (dt_util.now() - self.pending_since).total_seconds()
_plateau_ok = _plateau_age >= PLATEAU_ACCEPT_WINDOW and _interval_ok
# Schedule timer if not already scheduled
if not _plateau_ok and getattr(self, "plateau_timer_cancel", None) is None:
remaining = max(0.1, PLATEAU_ACCEPT_WINDOW - _plateau_age)
async def _plateau_cb(_now):
self.plateau_timer_cancel = None
# Re-check debounce interval so HomematicIP 600s is respected
_cb_age = (
(dt_util.now() - self.last_external_sensor_change).total_seconds()
if self.last_external_sensor_change is not None
else 999999
)
_cb_interval_ok = _cb_age > _time_diff
if self.pending_temp is not None and _cb_interval_ok:
_LOGGER.debug(
"better_thermostat %s: external_temperature plateau auto-accepted (value=%.2f)",
self.device_name,
self.pending_temp,
)
await _apply_temperature_update(self, self.pending_temp)
self.plateau_timer_cancel = async_call_later(
self.hass, remaining, _plateau_cb
)
if _cur_q is None:
# First reading ever — always accept regardless of interval
_accept_reason = "first_reading"
elif _is_significant and _interval_ok:
_accept_reason = "significant"
elif _accum_ok:
_accept_reason = "accumulated"
elif _plateau_ok:
_accept_reason = "plateau"
if _accept_reason is not None:
# Verarbeite sofort, wenn Intervall abgelaufen ODER Änderung sehr groß
_LOGGER.debug(
"better_thermostat %s: external_temperature update accepted (old=%.2f new=%.2f diff=%.2f "
"age=%.1fs threshold=%.2f interval=%ss reason=%s accum=%.2f dir=%s)",
self.device_name,
(_cur_q if _cur_q is not None else float("nan")),
_incoming_temperature_q,
(_diff_q if _diff_q is not None else float("nan")),
_age,
_sig_threshold_q,
_time_diff,
_accept_reason,
(self.accum_delta if _cur_q is not None else 0.0),
("+" if self.accum_dir > 0 else ("-" if self.accum_dir < 0 else "0")),
)
await _apply_temperature_update(self, _incoming_temperature_q)
else:
_LOGGER.debug(
"better_thermostat %s: external_temperature ignored (old=%.2f new=%.2f diff=%s "
"age=%.1fs sig=%s interval_ok=%s threshold=%.2f accum=%.2f dir=%s pending=%s pending_age=%ss)",
self.device_name,
(_cur_q if _cur_q is not None else float("nan")),
_incoming_temperature_q,
(f"{_diff_q:.2f}" if _diff_q is not None else "None"),
_age,
_is_significant,
_interval_ok,
_sig_threshold_q,
(self.accum_delta if _cur_q is not None else 0.0),
("+" if self.accum_dir > 0 else ("-" if self.accum_dir < 0 else "0")),
(
f"{self.pending_temp:.2f}"
if isinstance(self.pending_temp, (int, float))
else None
),
(
f"{(dt_util.now() - self.pending_since).total_seconds():.1f}"
if self.pending_since is not None
else None
),
)