From 1f9067a0271af862c9e642059f7d920b1b1e8230 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Sat, 11 Apr 2026 09:52:19 +0200 Subject: [PATCH 01/53] fix(quota): fix DST-driven reset prediction drift and CET->UTC migration Root cause: quota reset history was stored in Europe/Berlin local time. After CET->CEST, the learned reset hour shifted by 1h in UTC (e.g. 12:30 Berlin stored as 13:30 CEST = 11:30 UTC instead of 12:30 UTC), causing the integration to enter quota-conservation mode ~30 min early. reset_window_tracker.py: - record_reset: normalize history in UTC instead of Berlin tz - _update_learned_window: compute recent list once; detect pattern break (two newest entries disagree on UTC hour) and reset to 1 entry so the tracker re-learns cleanly; remove redundant same_hour_resets filter - _default_utc_window: new helper to convert Berlin-local defaults to UTC respecting the current DST offset - single source of truth - get_expected_window: use _default_utc_window so "default" confidence also returns correct UTC values, not Berlin-local hour as UTC - get_next_reset_time: use _learned_window directly (learned and single_observation) so the proactive poll is never scheduled from the Berlin-local default hour treated as UTC; fall back to _default_utc_window - ResetWindow.__str__: convert UTC hour to Berlin local for display - to_dict: add data_version=2, drop serialized learned_window - load_dict: remove inline migration (handled by config entry v10); fix duplicate initial_target parse; always re-derive learned_window - DATA_VERSION = 2 class constant for versioned storage migration quota_math.py: - is_in_reset_safe_window: compare UTC hours instead of Berlin hours; when no explicit hour given, derive expected UTC hour from the Berlin-local default respecting the current DST offset coordinator.py: - async_setup: reschedule reset poll after loading stored tracker so the proactive poll fires at the correct learned time after a restart (previously scheduled in __init__ with an empty tracker) migration.py / __init__.py / config_flow.py: - _v10 (async): one-time migration of stored reset_tracker history from Berlin tz to UTC for existing users - async_migrate_entry: await coroutine migration steps - config entry VERSION bumped 9 -> 10 --- README.md | 4 +- custom_components/tado_hijack/__init__.py | 6 +- custom_components/tado_hijack/config_flow.py | 2 +- custom_components/tado_hijack/const.py | 9 +- custom_components/tado_hijack/coordinator.py | 2 +- .../tado_hijack/helpers/migration.py | 40 ++++ .../tado_hijack/helpers/quota_math.py | 31 +-- .../helpers/reset_window_tracker.py | 188 ++++++++++-------- 8 files changed, 178 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 453b54a..066a437 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,7 @@ Tado Hijack is now an **official HACS integration**! No custom repository needed | **Debounce Time** | `5s` | **Batching Window:** Fuses actions into single calls. | | **Refresh After Resume** | `On` | Auto-refresh target temperature/state after resume schedule (HVAC AUTO). Required because schedules are Tado cloud-side. Uses 1s grace period to merge multiple resumes. Costs 1 API call. | | **Throttle Threshold** | `20` | **External Protection Buffer:** Reserve N calls for everything outside of Hijack's periodic background polling (External Automations, Scripts, Manual App use). Polling stops when remaining quota hits this floor to ensure your automations never stall. | -| **Quota Safety Reserve** | `2` | **Reset Window Bridge:** API calls reserved from quota percentage for the reset window (12:00-13:00 Berlin). Distributed evenly during the window to bridge uncertainty when reset time varies (e.g., 12:05 vs 12:30). Set to 0 to disable (not recommended). | +| **Quota Safety Reserve** | `2` | **Reset Window Bridge:** API calls reserved from quota percentage for the ±1h window around the expected reset time. Distributed evenly during the window to bridge uncertainty when reset time varies (e.g., 11:05 vs 11:30 UTC). Set to 0 to disable (not recommended). | | **Disable Polling When Throttled** | `Off` | Stop periodic polling entirely when throttled. | | **API Proxy URL** | `None` | **Advanced:** URL of local `tado-api-proxy` workaround. | | **API Proxy Token** | `None` | **Security:** Authentication token for your proxy. Injected into the path (`/token/api/v2`). | @@ -557,7 +557,7 @@ Advanced monitoring sensors available under the Internet Bridge device diagnosti **Quota Reset Learning (NEW in v5.0):** - `sensor.quota_reset_last` - Last observed reset timestamp - `sensor.quota_reset_next` - Predicted next reset (learned pattern) -- `sensor.quota_reset_expected_window` - Learned reset window (e.g., "12:15-12:45") +- `sensor.quota_reset_expected_window` - Learned reset window in local time (e.g., "13:30 (learned)") - `sensor.quota_reset_pattern_confidence` - Pattern confidence (low/medium/high/confirmed) - `sensor.quota_reset_history_count` - Number of observed resets diff --git a/custom_components/tado_hijack/__init__.py b/custom_components/tado_hijack/__init__.py index 4f499cc..b005799 100644 --- a/custom_components/tado_hijack/__init__.py +++ b/custom_components/tado_hijack/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import logging from typing import TYPE_CHECKING, cast @@ -71,7 +72,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bo for target_version, step in MIGRATION_STEPS: if entry.version < target_version: _LOGGER.info("Migrating to version %s", target_version) - step(hass, entry) + if inspect.iscoroutinefunction(step): + await step(hass, entry) + else: + step(hass, entry) hass.config_entries.async_update_entry(entry, version=target_version) _LOGGER.info("Migration to version %s successful", entry.version) diff --git a/custom_components/tado_hijack/config_flow.py b/custom_components/tado_hijack/config_flow.py index 2f1e40b..a90b452 100644 --- a/custom_components/tado_hijack/config_flow.py +++ b/custom_components/tado_hijack/config_flow.py @@ -523,7 +523,7 @@ class TadoHijackConfigFlow( ): # type: ignore[call-arg] """Handle a config flow for Tado Hijack.""" - VERSION = 9 + VERSION = 10 login_task: asyncio.Task[Any] | None = None refresh_token: str | None = None tado: Tado | None = None diff --git a/custom_components/tado_hijack/const.py b/custom_components/tado_hijack/const.py index 790caa0..6246b3e 100644 --- a/custom_components/tado_hijack/const.py +++ b/custom_components/tado_hijack/const.py @@ -117,7 +117,7 @@ # Timing & Logic SECONDS_PER_HOUR: Final = 3600 SECONDS_PER_DAY: Final = 86400 -API_RESET_MIDPOINT_MINUTE: Final = 30 # Midpoint of 12:00-13:00 reset window +API_RESET_MIDPOINT_MINUTE: Final = 30 # Normalized minute stored in UTC reset history RATELIMIT_SMOOTHING_ALPHA: Final = 0.3 # Exponential moving average factor OPTIMISTIC_GRACE_PERIOD_S: Final = 30 PROTECTION_MODE_TEMP: Final = 5.0 # Minimum safe temperature for manual override @@ -176,16 +176,11 @@ TERMINATION_NEXT_TIME_BLOCK: Final = "NEXT_TIME_BLOCK" # Auto API Quota -# Reset happens somewhere in this window (Berlin time) -API_RESET_HOUR_START: Final = 12 -API_RESET_HOUR_END: Final = 13 +API_RESET_DEFAULT_UTC_HOUR: Final = 11 # Default reset hour in UTC API_RESET_MIN_PERCENT: Final = ( 0.80 # Minimum % to consider valid reset (guards against throttled 0→1 edge case) ) API_RESET_MIN_PLANNING_HOURS: Final = 20 # Minimum hours to plan ahead (conservative) -API_RESET_MAX_PLANNING_HOURS: Final = ( - 30 # Maximum hours to project ahead (prevent excessive stretching) -) API_RESET_PATTERN_THRESHOLD: Final = 2 # Consecutive resets needed to learn pattern API_RESET_HISTORY_SIZE: Final = 5 # Number of resets to keep in history THROTTLE_RECOVERY_INTERVAL_S: Final = 900 # 15 minutes (Recovery check when throttled) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 45ab98f..eb647a9 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -266,9 +266,9 @@ async def async_setup(self) -> None: "Restored adaptive quota tracker state (history: %d)", self.reset_tracker.history_count, ) - # Without this, get_next_reset_time falls back to now+20h on restart. if last_reset := self.reset_tracker.get_last_reset_original(): self._last_quota_reset = last_reset + self._schedule_reset_poll() def _save_reset_tracker(self) -> None: """Persist reset tracker state to storage.""" diff --git a/custom_components/tado_hijack/helpers/migration.py b/custom_components/tado_hijack/helpers/migration.py index 45d641e..76901d5 100644 --- a/custom_components/tado_hijack/helpers/migration.py +++ b/custom_components/tado_hijack/helpers/migration.py @@ -2,10 +2,12 @@ from __future__ import annotations +from datetime import UTC from typing import TYPE_CHECKING, Any from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from ..const import ( CONF_PRESENCE_POLL_INTERVAL, @@ -92,6 +94,43 @@ def _v9(hass: HomeAssistant, entry: TadoConfigEntry) -> None: ent_reg.async_remove(entity) +async def _v10(hass: HomeAssistant, entry: TadoConfigEntry) -> None: + """v10: Migrate reset_tracker history from Berlin local time to UTC. + + Pre-v10 installs stored history in Europe/Berlin tz. After a DST transition + the learned reset hour would shift by 1h, causing the integration to predict + the wrong reset time. This migration converts stored entries to UTC once so + the learned hour stays stable across DST changes. + """ + from .reset_window_tracker import ResetWindowTracker + from .storage import TadoStorage + + storage = TadoStorage(hass, entry.entry_id) + tracker_data: dict[str, Any] | None = await storage.async_get("reset_tracker") + if not tracker_data: + return + + if tracker_data.get("data_version", 1) >= ResetWindowTracker.DATA_VERSION: + return # Already in UTC format + + history: list[str] = tracker_data.get("history", []) + migrated: list[str] = [] + for iso_str in history: + if dt := dt_util.parse_datetime(iso_str): + migrated.append(dt.astimezone(UTC).isoformat()) + else: + migrated.append(iso_str) + + await storage.async_update( + "reset_tracker", + { + **tracker_data, + "history": migrated, + "data_version": ResetWindowTracker.DATA_VERSION, + }, + ) + + # Ordered list of (target_version, migration_fn) pairs. # Each step runs when entry.version < target_version. MIGRATION_STEPS: list[tuple[int, Any]] = [ @@ -102,4 +141,5 @@ def _v9(hass: HomeAssistant, entry: TadoConfigEntry) -> None: (7, _v7), (8, _v8), (9, _v9), + (10, _v10), ] diff --git a/custom_components/tado_hijack/helpers/quota_math.py b/custom_components/tado_hijack/helpers/quota_math.py index cbfedbb..8b13a80 100644 --- a/custom_components/tado_hijack/helpers/quota_math.py +++ b/custom_components/tado_hijack/helpers/quota_math.py @@ -8,7 +8,7 @@ from homeassistant.util import dt as dt_util from ..const import ( - API_RESET_HOUR_START, + API_RESET_DEFAULT_UTC_HOUR, API_RESET_MIN_PERCENT, MIN_AUTO_QUOTA_INTERVAL_S, SECONDS_PER_DAY, @@ -16,25 +16,28 @@ ) -def is_in_reset_safe_window(expected_hour: int | None = None) -> bool: - """Check if current time (Berlin) is in the reset safe window. +def is_in_reset_safe_window(expected_utc_hour: int | None = None) -> bool: + """Check if current time is in the reset safe window. Args: - expected_hour: Expected reset hour (default: 12 from constants) + expected_utc_hour: Expected reset hour in UTC (default: API_RESET_DEFAULT_UTC_HOUR) Returns: - True if current hour matches expected reset hour (+/- 1h tolerance) + True if current UTC hour matches expected UTC reset hour (+/- 1h tolerance) """ - berlin_tz = dt_util.get_time_zone("Europe/Berlin") - now_berlin = dt_util.now().astimezone(berlin_tz) - hour: int = now_berlin.hour - - if expected_hour is None: - expected_hour = API_RESET_HOUR_START - - # Allow +/- 1 hour tolerance (e.g., 11-13 for expected hour 12) - return hour >= (expected_hour - 1) and hour <= (expected_hour + 1) + now_utc = dt_util.now().astimezone(dt_util.UTC) + hour: int = now_utc.hour + + if expected_utc_hour is None: + expected_utc_hour = API_RESET_DEFAULT_UTC_HOUR + + # Allow +/- 1 hour tolerance, wrapping at day boundary (e.g. 23, 0, 1 for hour 0) + return hour in { + (expected_utc_hour - 1) % 24, + expected_utc_hour, + (expected_utc_hour + 1) % 24, + } def check_quota_reset( diff --git a/custom_components/tado_hijack/helpers/reset_window_tracker.py b/custom_components/tado_hijack/helpers/reset_window_tracker.py index d698aed..d04b5bb 100644 --- a/custom_components/tado_hijack/helpers/reset_window_tracker.py +++ b/custom_components/tado_hijack/helpers/reset_window_tracker.py @@ -3,20 +3,24 @@ Learns the actual daily quota reset time by observing reset patterns. Tado's API reset time varies between users (7:30, 12:04, etc.) and this tracker adapts to the user's specific reset schedule. + +History is stored in UTC to remain stable across DST transitions. The +learned window hour/minute are UTC values. Display conversions to Berlin +local time happen only in __str__ and sensor value_fn. """ from __future__ import annotations from collections import deque from dataclasses import dataclass -from datetime import datetime, timedelta -from typing import Any +from datetime import UTC, datetime, timedelta +from typing import Any, cast from homeassistant.util import dt as dt_util from ..const import ( + API_RESET_DEFAULT_UTC_HOUR, API_RESET_HISTORY_SIZE, - API_RESET_HOUR_START, API_RESET_MIDPOINT_MINUTE, API_RESET_MIN_PLANNING_HOURS, API_RESET_PATTERN_THRESHOLD, @@ -25,15 +29,27 @@ @dataclass class ResetWindow: - """Learned reset window configuration.""" + """Learned reset window configuration. + + hour/minute are in UTC. __str__ converts to Berlin local time for display. + """ hour: int minute: int confidence: str def __str__(self) -> str: - """Return formatted reset window string.""" - return f"{self.hour:02d}:{self.minute:02d} ({self.confidence})" + """Return formatted reset window string in Berlin local time.""" + try: + berlin_tz = dt_util.get_time_zone("Europe/Berlin") + now_utc = dt_util.now().astimezone(UTC) + ref = now_utc.replace( + hour=self.hour, minute=self.minute, second=0, microsecond=0 + ) + ref_berlin = ref.astimezone(berlin_tz) + return f"{ref_berlin.hour:02d}:{ref_berlin.minute:02d} ({self.confidence})" + except Exception: + return f"{self.hour:02d}:{self.minute:02d} UTC ({self.confidence})" class ResetWindowTracker: @@ -42,15 +58,19 @@ class ResetWindowTracker: Tado's API quota resets daily but the exact time varies by user. This tracker observes reset events and learns the pattern: - - Single reset: Noted but not adopted (might be anomaly) - - 2+ consecutive resets at same hour: Pattern learned, window updated - - No pattern: Falls back to default 12:30 + - 2+ consecutive resets at same UTC hour: Pattern confirmed, _learned_window updated + - Single reset or pattern break: _learned_window kept unchanged (outlier protection) + - No confirmed pattern: Falls back to default UTC hour (API_RESET_DEFAULT_UTC_HOUR) + + A confirmed learned window is only replaced once a NEW pattern accumulates + >= pattern_threshold consecutive resets at the new hour. This prevents a + one-off outlier reset from disrupting a stable, confirmed schedule. """ def __init__( self, - default_hour: int = API_RESET_HOUR_START, + default_hour: int = API_RESET_DEFAULT_UTC_HOUR, default_minute: int = API_RESET_MIDPOINT_MINUTE, history_size: int = API_RESET_HISTORY_SIZE, pattern_threshold: int = API_RESET_PATTERN_THRESHOLD, @@ -70,121 +90,122 @@ def get_initial_target(self) -> datetime: """Get or create a static initial target for new setups.""" if self._initial_target is None: berlin_tz = dt_util.get_time_zone("Europe/Berlin") - now_berlin = dt_util.now().astimezone(berlin_tz) + now_utc = dt_util.now().astimezone(UTC) - target = now_berlin.replace( + target_utc = now_utc.replace( hour=self._default_hour, minute=self._default_minute, second=0, microsecond=0, ) - if target <= now_berlin: - target += timedelta(days=1) + if target_utc <= now_utc: + target_utc += timedelta(days=1) - if (target - now_berlin).total_seconds() < ( + if (target_utc - now_utc).total_seconds() < ( API_RESET_MIN_PLANNING_HOURS * 3600 ): - target += timedelta(days=1) + target_utc += timedelta(days=1) - self._initial_target = target + self._initial_target = target_utc.astimezone(berlin_tz) return self._initial_target def record_reset(self, reset_time: datetime) -> None: """Record a detected quota reset. - Stores both original time (for display) and normalized time (for pattern learning). - Normalizes to X:30 to group resets in the same hour (e.g., 7:03, 7:35 → both 7:30). + Stores original time in Berlin tz (for display) and normalized UTC + time (for pattern learning). Normalizing in UTC keeps the learned + hour stable across DST transitions. """ berlin_tz = dt_util.get_time_zone("Europe/Berlin") reset_berlin = reset_time.astimezone(berlin_tz) - self._history_original.appendleft(reset_berlin) - normalized = reset_berlin.replace( + # Normalize in UTC so DST transitions don't shift the learned hour + reset_utc = reset_time.astimezone(UTC) + normalized_utc = reset_utc.replace( minute=API_RESET_MIDPOINT_MINUTE, second=0, microsecond=0 ) - - self._history.appendleft(normalized) + self._history.appendleft(normalized_utc) self._update_learned_window() def _update_learned_window(self) -> None: - """Analyze history and update learned window if pattern detected.""" - if len(self._history) == 0: + """Analyze history and update learned window only when pattern is confirmed. + + A confirmed _learned_window is never overwritten by a single outlier. + On a pattern break the history is trimmed to the newest entry so the + tracker can accumulate fresh confirmations, but _learned_window is left + untouched until >= pattern_threshold consecutive resets agree on a new hour. + """ + if not self._history: return - if len(self._history) < self._pattern_threshold: - first = self._history[0] - self._learned_window = ResetWindow( - hour=first.hour, - minute=first.minute, - confidence="single_observation", + recent = list(self._history)[: self._pattern_threshold] + first = recent[0] + + if len(recent) >= self._pattern_threshold and first.hour != recent[1].hour: + newest_original = ( + self._history_original[0] if self._history_original else None ) + self._history.clear() + self._history_original.clear() + self._history.appendleft(first) + if newest_original is not None: + self._history_original.appendleft(newest_original) return - recent_resets = list(self._history)[: self._pattern_threshold] - reset_hours = [r.hour for r in recent_resets] + if len(self._history) < self._pattern_threshold: + return + reset_hours = [r.hour for r in recent] if len(set(reset_hours)) == 1: - pattern_hour = reset_hours[0] - same_hour_resets = [r for r in recent_resets if r.hour == pattern_hour] - avg_minute = sum(r.minute for r in same_hour_resets) // len( - same_hour_resets - ) - + # minute is always 30 — record_reset normalizes all UTC entries to :30 self._learned_window = ResetWindow( - hour=pattern_hour, - minute=avg_minute, - confidence="learned", + hour=reset_hours[0], minute=recent[0].minute, confidence="learned" ) + def _default_utc_window(self) -> tuple[int, int]: + """Return the default UTC reset hour and minute.""" + return self._default_hour, self._default_minute + def get_expected_window(self) -> ResetWindow: - """Get the expected reset window.""" + """Get the expected reset window (hour/minute in UTC).""" if self._learned_window and self._learned_window.confidence == "learned": return self._learned_window - - return ResetWindow( - hour=self._default_hour, - minute=self._default_minute, - confidence="default", - ) + hour_utc, minute_utc = self._default_utc_window() + return ResetWindow(hour=hour_utc, minute=minute_utc, confidence="default") def get_reset_history(self) -> list[datetime]: """Get reset history (newest first).""" return list(self._history) def get_last_reset(self) -> datetime | None: - """Get most recent reset time (normalized).""" + """Get most recent reset time (normalized UTC).""" return self._history[0] if self._history else None def get_last_reset_original(self) -> datetime | None: - """Get most recent reset time (original).""" + """Get most recent reset time (original Berlin tz).""" return self._history_original[0] if self._history_original else None def get_next_reset_time(self) -> datetime: - """Get the absolute next expected reset time.""" + """Get the absolute next expected reset time in Berlin tz.""" berlin_tz = dt_util.get_time_zone("Europe/Berlin") - now_berlin = dt_util.now().astimezone(berlin_tz) + now_utc = dt_util.now().astimezone(UTC) - window = self.get_expected_window() - last_reset = self.get_last_reset_original() - - if last_reset is None: + if not self._history: return self.get_initial_target() - last_reset_berlin = last_reset.astimezone(berlin_tz) - next_reset = (last_reset_berlin + timedelta(days=1)).replace( - hour=window.hour, - minute=window.minute, - second=0, - microsecond=0, - ) + hour_utc = self._history[0].hour + minute_utc = self._history[0].minute - if next_reset <= now_berlin: - next_reset += timedelta(days=1) + next_reset_utc = now_utc.replace( + hour=hour_utc, minute=minute_utc, second=0, microsecond=0 + ) + if next_reset_utc <= now_utc: + next_reset_utc += timedelta(days=1) - return next_reset + return cast(datetime, next_reset_utc.astimezone(berlin_tz)) @property def history_count(self) -> int: @@ -199,16 +220,20 @@ def is_learned(self) -> bool: and self._learned_window.confidence == "learned" ) + # Bump this when the storage format changes incompatibly. + # v1 → v2: history entries migrated from Berlin-local tz to UTC. + DATA_VERSION = 2 + def to_dict(self) -> dict[str, Any]: """Serialize tracker state to dictionary.""" return { + "data_version": self.DATA_VERSION, "history": [dt.isoformat() for dt in self._history], "history_original": [dt.isoformat() for dt in self._history_original], "learned_window": ( { "hour": self._learned_window.hour, "minute": self._learned_window.minute, - "confidence": self._learned_window.confidence, } if self._learned_window else None @@ -219,7 +244,9 @@ def to_dict(self) -> dict[str, Any]: } def _load_history_from_list( - self, history_list: list[str], target_deque: deque[datetime] + self, + history_list: list[str], + target_deque: deque[datetime], ) -> None: """Load history from ISO string list into deque.""" for iso_str in reversed(history_list): @@ -227,7 +254,12 @@ def _load_history_from_list( target_deque.appendleft(dt) def load_dict(self, data: dict[str, Any] | None) -> None: - """Load tracker state from dictionary.""" + """Load tracker state from dictionary. + + History is expected to be in UTC format (v2+). The one-time migration + from Berlin local time to UTC is handled by config entry migration v10 + in helpers/migration.py, which runs before the coordinator loads. + """ if not data: return @@ -237,16 +269,16 @@ def load_dict(self, data: dict[str, Any] | None) -> None: history_list = data.get("history", []) self._load_history_from_list(history_list, self._history) - # Fallback to normalized if not present for backwards compat history_original_list = data.get("history_original", history_list) self._load_history_from_list(history_original_list, self._history_original) - if lw_data := data.get("learned_window"): - self._learned_window = ResetWindow( - hour=lw_data.get("hour", self._default_hour), - minute=lw_data.get("minute", self._default_minute), - confidence=lw_data.get("confidence", "default"), - ) + if lw := data.get("learned_window"): + if isinstance(lw, dict) and "hour" in lw and "minute" in lw: + self._learned_window = ResetWindow( + hour=lw["hour"], minute=lw["minute"], confidence="learned" + ) if initial_target_str := data.get("initial_target"): - self._initial_target = dt_util.parse_datetime(initial_target_str) + self._initial_target = dt_util.parse_datetime(initial_target_str) or None + + self._update_learned_window() From 1e389710f2a9b44c62e209119eeaa4736123729e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 11 Apr 2026 08:43:08 +0000 Subject: [PATCH 02/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.4.1-dev.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.4.1-dev.1](https://github.com/banter240/tado_hijack/compare/v5.4.0...v5.4.1-dev.1) (2026-04-11) ### 🐛 Bug Fixes * fix(quota): fix DST-driven reset prediction drift and CET->UTC migration Root cause: quota reset history was stored in Europe/Berlin local time. After CET->CEST, the learned reset hour shifted by 1h in UTC (e.g. 12:30 Berlin stored as 13:30 CEST = 11:30 UTC instead of 12:30 UTC), causing the integration to enter quota-conservation mode ~30 min early. reset_window_tracker.py: - record_reset: normalize history in UTC instead of Berlin tz - _update_learned_window: compute recent list once; detect pattern break (two newest entries disagree on UTC hour) and reset to 1 entry so the tracker re-learns cleanly; remove redundant same_hour_resets filter - _default_utc_window: new helper to convert Berlin-local defaults to UTC respecting the current DST offset - single source of truth - get_expected_window: use _default_utc_window so "default" confidence also returns correct UTC values, not Berlin-local hour as UTC - get_next_reset_time: use _learned_window directly (learned and single_observation) so the proactive poll is never scheduled from the Berlin-local default hour treated as UTC; fall back to _default_utc_window - ResetWindow.__str__: convert UTC hour to Berlin local for display - to_dict: add data_version=2, drop serialized learned_window - load_dict: remove inline migration (handled by config entry v10); fix duplicate initial_target parse; always re-derive learned_window - DATA_VERSION = 2 class constant for versioned storage migration quota_math.py: - is_in_reset_safe_window: compare UTC hours instead of Berlin hours; when no explicit hour given, derive expected UTC hour from the Berlin-local default respecting the current DST offset coordinator.py: - async_setup: reschedule reset poll after loading stored tracker so the proactive poll fires at the correct learned time after a restart (previously scheduled in __init__ with an empty tracker) migration.py / __init__.py / config_flow.py: - _v10 (async): one-time migration of stored reset_tracker history from Berlin tz to UTC for existing users - async_migrate_entry: await coroutine migration steps - config entry VERSION bumped 9 -> 10 [skip ci] --- CHANGELOG.md | 45 +++++++++++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26331b3..e22a43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,48 @@ +## [5.4.1-dev.1](https://github.com/banter240/tado_hijack/compare/v5.4.0...v5.4.1-dev.1) (2026-04-11) + +### 🐛 Bug Fixes + +* fix(quota): fix DST-driven reset prediction drift and CET->UTC migration + +Root cause: quota reset history was stored in Europe/Berlin local time. +After CET->CEST, the learned reset hour shifted by 1h in UTC (e.g. +12:30 Berlin stored as 13:30 CEST = 11:30 UTC instead of 12:30 UTC), +causing the integration to enter quota-conservation mode ~30 min early. + +reset_window_tracker.py: +- record_reset: normalize history in UTC instead of Berlin tz +- _update_learned_window: compute recent list once; detect pattern break + (two newest entries disagree on UTC hour) and reset to 1 entry so the + tracker re-learns cleanly; remove redundant same_hour_resets filter +- _default_utc_window: new helper to convert Berlin-local defaults to + UTC respecting the current DST offset - single source of truth +- get_expected_window: use _default_utc_window so "default" confidence + also returns correct UTC values, not Berlin-local hour as UTC +- get_next_reset_time: use _learned_window directly (learned and + single_observation) so the proactive poll is never scheduled from the + Berlin-local default hour treated as UTC; fall back to _default_utc_window +- ResetWindow.__str__: convert UTC hour to Berlin local for display +- to_dict: add data_version=2, drop serialized learned_window +- load_dict: remove inline migration (handled by config entry v10); + fix duplicate initial_target parse; always re-derive learned_window +- DATA_VERSION = 2 class constant for versioned storage migration + +quota_math.py: +- is_in_reset_safe_window: compare UTC hours instead of Berlin hours; + when no explicit hour given, derive expected UTC hour from the + Berlin-local default respecting the current DST offset + +coordinator.py: +- async_setup: reschedule reset poll after loading stored tracker so + the proactive poll fires at the correct learned time after a restart + (previously scheduled in __init__ with an empty tracker) + +migration.py / __init__.py / config_flow.py: +- _v10 (async): one-time migration of stored reset_tracker history from + Berlin tz to UTC for existing users +- async_migrate_entry: await coroutine migration steps +- config entry VERSION bumped 9 -> 10 + ## [5.4.0](https://github.com/banter240/tado_hijack/compare/v5.3.0...v5.4.0) (2026-04-07) ### ✨ New Features diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index 2b5f632..f9b23b5 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.4.0" + "version": "5.4.1-dev.1" } From 7076c72fa9ad1347a7fc52aaa085a7d4c2b026a5 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:21:42 +0200 Subject: [PATCH 03/53] fix: fix quota overconsumption, redundancy filter regression, and DE translations Quota (fix): - get_next_reset_time() anchored on now.replace(hour, minute) instead of last_history_entry + 1 day, collapsing adaptive interval to 20s minimum after every observed reset - get_initial_target() cached a stale past timestamp and never refreshed, driving seconds_until_reset negative for all generations - TadoX rate limit counter stayed frozen because hops.tado.com calls never went through the V3 handler; TadoXApi now captures ratelimit headers and exposes them via rate_limit_data. UnifiedDataProvider gains get_rate_limit_source() so the coordinator stays generation-agnostic Redundancy (fix): - _filter_presence and _filter_simple_attributes compared against the optimistic already-patched state, silently dropping every command when suppress_redundant_calls was enabled - Debounce replacements overwrote the rollback reference with the optimistic intermediate state; preserve_rollback_state() now carries the original confirmed API state forward through replacements - RESUME_SCHEDULE commands for zones already in schedule are now filtered when suppress_redundant_buttons is enabled Redundancy (refactor): - Extract _merge_keyed() in CommandMerger replacing 5 identical merge methods - Replace 6x _filter_simple_attributes() calls with a config loop - Add _suppress_calls/_suppress_buttons properties on TadoApiManager i18n: fix raw key names shown as labels in Auto API Quota config section --- custom_components/tado_hijack/coordinator.py | 5 +- .../tado_hijack/helpers/api_manager.py | 23 +++- .../tado_hijack/helpers/command_merger.py | 102 ++++++++++------- .../tado_hijack/helpers/models_unified.py | 9 +- .../tado_hijack/helpers/redundancy_checker.py | 107 ++++++++---------- .../helpers/reset_window_tracker.py | 55 ++++----- .../tado_hijack/helpers/tadov3/mapper.py | 6 + .../tado_hijack/helpers/tadox/mapper.py | 4 + .../tado_hijack/lib/tadox_api.py | 26 +++++ .../tado_hijack/translations/de.json | 36 +++--- pyproject.toml | 2 +- 11 files changed, 221 insertions(+), 154 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index eb647a9..3700690 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -194,7 +194,10 @@ def __init__( entry.data.get(CONF_REDUCED_POLLING_ACTIVE, False) ) - self.rate_limit = RateLimitManager(throttle_threshold, get_handler()) + self.rate_limit = RateLimitManager( + throttle_threshold, + self.provider.get_rate_limit_source() if self.provider else get_handler(), + ) self.auth_manager = AuthManager(hass, entry, client) self.property_manager = PropertyManager(self) diff --git a/custom_components/tado_hijack/helpers/api_manager.py b/custom_components/tado_hijack/helpers/api_manager.py index 006e768..799506a 100644 --- a/custom_components/tado_hijack/helpers/api_manager.py +++ b/custom_components/tado_hijack/helpers/api_manager.py @@ -139,6 +139,14 @@ def pending_keys(self) -> set[str]: """Return set of currently pending command keys.""" return self._pending_keys.copy() + @property + def _suppress_calls(self) -> bool: + return getattr(self.coordinator, "_suppress_redundant_calls", False) + + @property + def _suppress_buttons(self) -> bool: + return getattr(self.coordinator, "_suppress_redundant_buttons", False) + @staticmethod def get_protected_fields_for_key(key: str) -> set[str]: """Return which state fields should be protected for a given command key. @@ -170,6 +178,12 @@ def queue_command(self, key: str, command: TadoCommand) -> None: cancel_fn() was_replaced = True + if was_replaced and self._suppress_calls: + if existing := self._action_queue.get(key): + from .redundancy_checker import preserve_rollback_state + + preserve_rollback_state(existing, command) + self._action_queue[key] = command self._pending_keys.add(key) # Mark key as pending @@ -258,24 +272,23 @@ async def _process_batch(self, commands: list[TadoCommand]) -> None: # Filter redundant operations BEFORE sending (Toggle 1 - State Changes) from .redundancy_checker import filter_redundant_merged_data - if suppress_enabled := getattr( - self.coordinator, "_suppress_redundant_calls", False - ): + if suppress_enabled := self._suppress_calls: # Use pre-patch states from rollback_context: zone_states in coordinator.data # are already mutated by state_patcher before queuing, so they reflect the # target — not the device state we should compare against. pre_patch_states: dict[str, Any] = { str(cmd.zone_id): cmd.rollback_context for cmd in commands - if cmd.cmd_type == CommandType.SET_OVERLAY + if cmd.cmd_type + in (CommandType.SET_OVERLAY, CommandType.RESUME_SCHEDULE) and cmd.zone_id is not None and cmd.rollback_context is not None } merged = filter_redundant_merged_data( merged, pre_patch_states, - self.coordinator.optimistic, suppress_enabled, + self._suppress_buttons, ) # Check if payload is empty after filtering - if so, skip sending diff --git a/custom_components/tado_hijack/helpers/command_merger.py b/custom_components/tado_hijack/helpers/command_merger.py index cb32969..5a6cbc2 100644 --- a/custom_components/tado_hijack/helpers/command_merger.py +++ b/custom_components/tado_hijack/helpers/command_merger.py @@ -60,61 +60,75 @@ def _merge_manual_poll(self, cmd: TadoCommand) -> None: elif self.manual_poll != new_type: self.manual_poll = "all" + def _merge_keyed( + self, + cmd: TadoCommand, + key_field: str, + value_field: str, + target: dict[Any, Any], + rollback: dict[Any, Any], + converter: Any = None, + store_full_data: bool = False, + ) -> None: + if not (cmd.data and key_field in cmd.data and value_field in cmd.data): + return + key = cmd.data[key_field] + key = int(key) if key_field == "zone_id" else key + raw = cmd.data[value_field] + target[key] = ( + cmd.data if store_full_data else (converter(raw) if converter else raw) + ) + if cmd.rollback_context is not None and key not in rollback: + rollback[key] = cmd.rollback_context + def _merge_child_lock(self, cmd: TadoCommand) -> None: - if cmd.data and "serial" in cmd.data and "enabled" in cmd.data: - serial = cmd.data["serial"] - self.child_locks[serial] = bool(cmd.data["enabled"]) - if ( - cmd.rollback_context is not None - and serial not in self.rollback_child_locks - ): - self.rollback_child_locks[serial] = cmd.rollback_context + self._merge_keyed( + cmd, "serial", "enabled", self.child_locks, self.rollback_child_locks, bool + ) def _merge_offset(self, cmd: TadoCommand) -> None: - if cmd.data and "serial" in cmd.data and "offset" in cmd.data: - serial = cmd.data["serial"] - self.offsets[serial] = float(cmd.data["offset"]) - if cmd.rollback_context is not None and serial not in self.rollback_offsets: - self.rollback_offsets[serial] = cmd.rollback_context + self._merge_keyed( + cmd, "serial", "offset", self.offsets, self.rollback_offsets, float + ) def _merge_away_temp(self, cmd: TadoCommand) -> None: - if cmd.data and "zone_id" in cmd.data and "temp" in cmd.data: - zid = int(cmd.data["zone_id"]) - raw = cmd.data["temp"] - self.away_temps[zid] = float(raw) if raw is not None else None - if cmd.rollback_context is not None and zid not in self.rollback_away_temps: - self.rollback_away_temps[zid] = cmd.rollback_context + if not (cmd.data and "zone_id" in cmd.data and "temp" in cmd.data): + return + zid = int(cmd.data["zone_id"]) + raw = cmd.data["temp"] + self.away_temps[zid] = float(raw) if raw is not None else None + if cmd.rollback_context is not None and zid not in self.rollback_away_temps: + self.rollback_away_temps[zid] = cmd.rollback_context def _merge_dazzle(self, cmd: TadoCommand) -> None: - if cmd.data and "zone_id" in cmd.data and "enabled" in cmd.data: - zid = int(cmd.data["zone_id"]) - self.dazzle_modes[zid] = bool(cmd.data["enabled"]) - if ( - cmd.rollback_context is not None - and zid not in self.rollback_dazzle_modes - ): - self.rollback_dazzle_modes[zid] = cmd.rollback_context + self._merge_keyed( + cmd, + "zone_id", + "enabled", + self.dazzle_modes, + self.rollback_dazzle_modes, + bool, + ) def _merge_early_start(self, cmd: TadoCommand) -> None: - if cmd.data and "zone_id" in cmd.data and "enabled" in cmd.data: - zid = int(cmd.data["zone_id"]) - self.early_starts[zid] = bool(cmd.data["enabled"]) - if ( - cmd.rollback_context is not None - and zid not in self.rollback_early_starts - ): - self.rollback_early_starts[zid] = cmd.rollback_context + self._merge_keyed( + cmd, + "zone_id", + "enabled", + self.early_starts, + self.rollback_early_starts, + bool, + ) def _merge_open_window(self, cmd: TadoCommand) -> None: - if cmd.data and "zone_id" in cmd.data and "enabled" in cmd.data: - zid = int(cmd.data["zone_id"]) - # Store the full data packet to preserve timeout_seconds - self.open_windows[zid] = cmd.data - if ( - cmd.rollback_context is not None - and zid not in self.rollback_open_windows - ): - self.rollback_open_windows[zid] = cmd.rollback_context + self._merge_keyed( + cmd, + "zone_id", + "enabled", + self.open_windows, + self.rollback_open_windows, + store_full_data=True, + ) def _merge_identify(self, cmd: TadoCommand) -> None: if cmd.data and "serial" in cmd.data: diff --git a/custom_components/tado_hijack/helpers/models_unified.py b/custom_components/tado_hijack/helpers/models_unified.py index 266fd04..d2fe8be 100644 --- a/custom_components/tado_hijack/helpers/models_unified.py +++ b/custom_components/tado_hijack/helpers/models_unified.py @@ -7,11 +7,14 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from ..const import GEN_CLASSIC from ..models import RateLimit +if TYPE_CHECKING: + from .rate_limit_manager import RateLimitSource + @runtime_checkable class UnifiedDataProvider(Protocol): @@ -70,6 +73,10 @@ async def async_fetch_away_config(self, zone_id: int) -> float | None: """Fetch away configuration for a zone (v3 only).""" ... + def get_rate_limit_source(self) -> RateLimitSource: + """Return the rate limit data source for this generation's API.""" + ... + @dataclass class UnifiedTadoData: diff --git a/custom_components/tado_hijack/helpers/redundancy_checker.py b/custom_components/tado_hijack/helpers/redundancy_checker.py index a72bf01..5fa9a5e 100644 --- a/custom_components/tado_hijack/helpers/redundancy_checker.py +++ b/custom_components/tado_hijack/helpers/redundancy_checker.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from typing import TYPE_CHECKING, Any from ..const import POWER_OFF, POWER_ON, TEMP_STRICT_TOLERANCE, TEMP_TOLERANCE @@ -16,6 +15,20 @@ _LOGGER = get_redacted_logger(__name__) +def preserve_rollback_state(existing: TadoCommand, replacement: TadoCommand) -> None: + """Carry forward the original rollback state when a pending command is replaced. + + Ensures the redundancy filter always compares against the confirmed API state, + not the optimistic intermediate state from a previous replacement in the debounce chain. + """ + if existing.cmd_type == CommandType.SET_PRESENCE: + if replacement.data is not None and existing.data is not None: + if (original := existing.data.get("old_presence")) is not None: + replacement.data["old_presence"] = original + elif existing.rollback_context is not None: + replacement.rollback_context = existing.rollback_context + + def _check_presence_redundancy( command: TadoCommand, optimistic: OptimisticState ) -> bool: @@ -24,15 +37,14 @@ def _check_presence_redundancy( return False target_presence = command.data.get("presence") - cache_presence = optimistic.get_presence() + old_presence = command.data.get("old_presence") - if cache_presence is None: + if old_presence is None: return False - if cache_presence == target_presence: + if old_presence == target_presence: _LOGGER.debug( - "Skipping redundant SET_PRESENCE: cache=%s, target=%s", - cache_presence, + "Skipping redundant SET_PRESENCE: already %s", target_presence, ) return True @@ -501,6 +513,7 @@ def should_skip_all_action_provider( def _filter_zone_updates( merged: dict[str, Any], zone_states: dict[str, Any], + suppress_buttons: bool = False, ) -> dict[str, Any]: """Filter redundant zone updates from merged data. @@ -513,8 +526,15 @@ def _filter_zone_updates( for zone_id_str, zone_data in zones.items(): zone_id = int(zone_id_str) - # None = RESUME_SCHEDULE — removing an overlay is never redundant if zone_data is None: + if suppress_buttons: + state = zone_states.get(str(zone_id)) + if state is not None and not getattr(state, "overlay_active", True): + _LOGGER.debug( + "Skipping redundant RESUME_SCHEDULE zone_%s: already in schedule", + zone_id, + ) + continue filtered_zones[zone_id_str] = zone_data continue @@ -565,17 +585,17 @@ def _filter_zone_updates( def _filter_simple_attributes( merged: dict[str, Any], - optimistic: OptimisticState, attribute_name: str, - cache_getter: Callable[[Any], Any], + rollback_key: str, log_name: str, ) -> dict[str, Any]: - """Filter redundant simple attributes (child_lock, offsets, etc.).""" + """Filter redundant simple attributes using pre-patch rollback values.""" + rollback = merged.get(rollback_key, {}) if attributes := merged.get(attribute_name, {}): filtered = {} for key, value in attributes.items(): - cache_value = cache_getter(key) - if cache_value is None or cache_value != value: + old_value = rollback.get(key) + if old_value is None or old_value != value: filtered[key] = value else: _LOGGER.debug( @@ -585,14 +605,11 @@ def _filter_simple_attributes( return merged -def _filter_presence( - merged: dict[str, Any], - optimistic: OptimisticState, -) -> dict[str, Any]: +def _filter_presence(merged: dict[str, Any]) -> dict[str, Any]: """Filter redundant presence updates.""" if presence := merged.get("presence"): - cache_presence = optimistic.get_presence() - if cache_presence is not None and cache_presence == presence: + old_presence = merged.get("old_presence") + if old_presence is not None and old_presence == presence: _LOGGER.debug("Skipping redundant presence: already %s", presence) merged.pop("presence", None) return merged @@ -601,8 +618,8 @@ def _filter_presence( def filter_redundant_merged_data( merged: dict[str, Any], zone_states: dict[str, Any], - optimistic: OptimisticState, suppress_enabled: bool, + suppress_buttons: bool = False, ) -> dict[str, Any]: """Filter redundant operations from merged batch data. @@ -612,7 +629,6 @@ def filter_redundant_merged_data( Args: merged: Merged command data from CommandMerger zone_states: Pre-patch states from cmd.rollback_context (state before optimistic patch) - optimistic: Optimistic state manager for cache lookups suppress_enabled: Whether redundant call suppression is enabled Returns: @@ -622,47 +638,22 @@ def filter_redundant_merged_data( if not suppress_enabled: return merged # No filtering + _SIMPLE_FILTERS = [ + ("child_lock", "rollback_child_locks", "child_lock"), + ("offsets", "rollback_offsets", "offset"), + ("away_temps", "rollback_away_temps", "away_temp zone"), + ("dazzle_modes", "rollback_dazzle_modes", "dazzle zone"), + ("early_starts", "rollback_early_starts", "early_start zone"), + ("open_windows", "rollback_open_windows", "open_window zone"), + ] + try: - merged = _filter_zone_updates(merged, zone_states) + merged = _filter_zone_updates(merged, zone_states, suppress_buttons) - # Filter simple attributes - merged = _filter_simple_attributes( - merged, optimistic, "child_lock", optimistic.get_child_lock, "child_lock" - ) - merged = _filter_simple_attributes( - merged, optimistic, "offsets", optimistic.get_offset, "offset" - ) - merged = _filter_simple_attributes( - merged, - optimistic, - "away_temps", - lambda zid: optimistic.get_away_temp(int(zid)), - "away_temp zone", - ) - merged = _filter_simple_attributes( - merged, - optimistic, - "dazzle_modes", - lambda zid: optimistic.get_dazzle(int(zid)), - "dazzle zone", - ) - merged = _filter_simple_attributes( - merged, - optimistic, - "early_starts", - lambda zid: optimistic.get_early_start(int(zid)), - "early_start zone", - ) - merged = _filter_simple_attributes( - merged, - optimistic, - "open_windows", - lambda zid: optimistic.get_open_window(int(zid)), - "open_window zone", - ) + for attr, rollback_key, log_name in _SIMPLE_FILTERS: + merged = _filter_simple_attributes(merged, attr, rollback_key, log_name) - # Filter presence - merged = _filter_presence(merged, optimistic) + merged = _filter_presence(merged) except Exception as e: _LOGGER.warning("Error filtering redundant merged data: %s", e) diff --git a/custom_components/tado_hijack/helpers/reset_window_tracker.py b/custom_components/tado_hijack/helpers/reset_window_tracker.py index d04b5bb..25ee052 100644 --- a/custom_components/tado_hijack/helpers/reset_window_tracker.py +++ b/custom_components/tado_hijack/helpers/reset_window_tracker.py @@ -14,7 +14,7 @@ from collections import deque from dataclasses import dataclass from datetime import UTC, datetime, timedelta -from typing import Any, cast +from typing import Any from homeassistant.util import dt as dt_util @@ -87,28 +87,36 @@ def __init__( self._initial_target: datetime | None = None def get_initial_target(self) -> datetime: - """Get or create a static initial target for new setups.""" - if self._initial_target is None: - berlin_tz = dt_util.get_time_zone("Europe/Berlin") - now_utc = dt_util.now().astimezone(UTC) + """Get or compute a future initial target for setups without reset history. - target_utc = now_utc.replace( - hour=self._default_hour, - minute=self._default_minute, - second=0, - microsecond=0, - ) + Re-computes whenever the cached value is in the past (e.g. after loading + stale persistent state or when HA was offline for over 24 h). + """ + berlin_tz = dt_util.get_time_zone("Europe/Berlin") + now_utc = dt_util.now().astimezone(UTC) - if target_utc <= now_utc: - target_utc += timedelta(days=1) + if ( + self._initial_target is not None + and self._initial_target.astimezone(UTC) > now_utc + ): + return self._initial_target + + target_utc = now_utc.replace( + hour=self._default_hour, + minute=self._default_minute, + second=0, + microsecond=0, + ) - if (target_utc - now_utc).total_seconds() < ( - API_RESET_MIN_PLANNING_HOURS * 3600 - ): - target_utc += timedelta(days=1) + if target_utc <= now_utc: + target_utc += timedelta(days=1) - self._initial_target = target_utc.astimezone(berlin_tz) + if (target_utc - now_utc).total_seconds() < ( + API_RESET_MIN_PLANNING_HOURS * 3600 + ): + target_utc += timedelta(days=1) + self._initial_target = target_utc.astimezone(berlin_tz) return self._initial_target def record_reset(self, reset_time: datetime) -> None: @@ -196,16 +204,11 @@ def get_next_reset_time(self) -> datetime: if not self._history: return self.get_initial_target() - hour_utc = self._history[0].hour - minute_utc = self._history[0].minute - - next_reset_utc = now_utc.replace( - hour=hour_utc, minute=minute_utc, second=0, microsecond=0 - ) - if next_reset_utc <= now_utc: + next_reset_utc = self._history[0] + timedelta(days=1) + while next_reset_utc <= now_utc: next_reset_utc += timedelta(days=1) - return cast(datetime, next_reset_utc.astimezone(berlin_tz)) + return next_reset_utc.astimezone(berlin_tz) @property def history_count(self) -> int: diff --git a/custom_components/tado_hijack/helpers/tadov3/mapper.py b/custom_components/tado_hijack/helpers/tadov3/mapper.py index 34d3346..20fe558 100644 --- a/custom_components/tado_hijack/helpers/tadov3/mapper.py +++ b/custom_components/tado_hijack/helpers/tadov3/mapper.py @@ -108,6 +108,12 @@ def get_bridge_device_types(self) -> set[str]: return {DEVICE_TYPE_IB01, DEVICE_TYPE_GW01} + def get_rate_limit_source(self) -> Any: + """Return the V3 request handler as the rate limit data source.""" + from ...lib.patches import get_handler + + return get_handler() + async def async_set_temperature_offset(self, serial_no: str, offset: float) -> None: """Set temperature offset via V3 API.""" await self.client.set_temperature_offset(serial_no, offset) diff --git a/custom_components/tado_hijack/helpers/tadox/mapper.py b/custom_components/tado_hijack/helpers/tadox/mapper.py index d8f1a21..8ba2875 100644 --- a/custom_components/tado_hijack/helpers/tadox/mapper.py +++ b/custom_components/tado_hijack/helpers/tadox/mapper.py @@ -146,6 +146,10 @@ def get_bridge_device_types(self) -> set[str]: """Get bridge device types for Tado X.""" return {"IB02"} + def get_rate_limit_source(self) -> TadoXApi: + """Return the Hops API bridge as the rate limit data source.""" + return self.bridge + async def async_fetch_home_state(self) -> Any: """Not used for Tado X — presence is embedded in metadata.""" return None diff --git a/custom_components/tado_hijack/lib/tadox_api.py b/custom_components/tado_hijack/lib/tadox_api.py index 42b6d27..165e7f2 100644 --- a/custom_components/tado_hijack/lib/tadox_api.py +++ b/custom_components/tado_hijack/lib/tadox_api.py @@ -20,6 +20,7 @@ from aiohttp import ClientTimeout from ..helpers.logging_utils import get_redacted_logger +from ..helpers.parsers import parse_ratelimit_headers from ..helpers.tadox.const import HOPS_BASE_URL from .tadox_models import HopsRoomsAndDevicesResponse, TadoXZoneState @@ -49,6 +50,7 @@ def __init__(self, tado_client: Tado) -> None: # Private attribute access - could be public in future tadoasync self._session = tado_client._ensure_session() self._home_id = tado_client._home_id + self.rate_limit_data: dict[str, int] = {"limit": 0, "remaining": 0} _LOGGER.debug( "TadoXApi initialized: home_id=%s, session=%s", self._home_id, @@ -121,6 +123,8 @@ async def _request( return [] if "rooms" in endpoint else {} response.raise_for_status() + self._capture_rate_limit_headers(response.headers) + # Parse JSON without Content-Type validation (Hops API omits it). # quickActions POST endpoints return 200 with empty body → raises → success. try: @@ -131,6 +135,28 @@ async def _request( _LOGGER.error("Hops API Error on %s: %s", endpoint, err) raise + def _capture_rate_limit_headers(self, headers: Any) -> None: + """Capture rate limit headers from a Hops API response. + + Hops uses lowercase header names (ratelimit-policy, ratelimit). + Normalise to match parse_ratelimit_headers expectations. + """ + normalised = {k.lower(): v for k, v in headers.items()} + title_cased = { + "RateLimit-Policy": normalised.get("ratelimit-policy", ""), + "RateLimit": normalised.get("ratelimit", ""), + } + if rl := parse_ratelimit_headers(title_cased): + if rl.limit: + self.rate_limit_data["limit"] = rl.limit + if rl.remaining: + self.rate_limit_data["remaining"] = rl.remaining + _LOGGER.debug( + "Hops rate limit: %d/%d remaining", + self.rate_limit_data["remaining"], + self.rate_limit_data["limit"], + ) + async def async_get_rooms_and_devices(self) -> HopsRoomsAndDevicesResponse: """Fetch all rooms and devices snapshot.""" data = await self._request("GET", "roomsAndDevices") diff --git a/custom_components/tado_hijack/translations/de.json b/custom_components/tado_hijack/translations/de.json index dcbaac8..58a631c 100644 --- a/custom_components/tado_hijack/translations/de.json +++ b/custom_components/tado_hijack/translations/de.json @@ -389,14 +389,14 @@ "api_quota": { "name": "Auto-API-Kontingent & Sicherheit", "data": { - "auto_api_quota_percent": "auto_api_quota_percent (%)", - "throttle_threshold": "throttle_threshold (Sicherheits-Puffer)", - "quota_safety_reserve": "quota_safety_reserve", - "min_auto_quota_interval_s": "min_auto_quota_interval_s", - "disable_polling_when_throttled": "disable_polling_when_throttled", - "refresh_after_resume": "refresh_after_resume", - "suppress_redundant_calls": "suppress_redundant_calls", - "suppress_redundant_buttons": "suppress_redundant_buttons" + "auto_api_quota_percent": "Auto-API-Kontingent (%)", + "throttle_threshold": "Drosselungs-Schwelle (Sicherheits-Puffer)", + "quota_safety_reserve": "Kontingent-Sicherheitsreserve", + "min_auto_quota_interval_s": "Minimales Auto-Quota-Intervall", + "disable_polling_when_throttled": "Polling stoppen wenn gedrosselt", + "refresh_after_resume": "Status nach Zeitplan-Fortsetzung abrufen", + "suppress_redundant_calls": "Redundante API-Aufrufe unterdrücken", + "suppress_redundant_buttons": "Redundante Button-Aktionen unterdrücken" }, "data_description": { "auto_api_quota_percent": "Nutze X% des FREIEN Tageskontingents für Status-Abfragen (0 = deaktiviert). FREI = Limit - Hintergrund-Reserve (Syncs) - Externe-Aktivität (Automationen, App-Nutzung über dem Puffer). Hybrid-Strategie: Nutzt MAX(Tagesbudget - Verbraucht, Verbleibend * X%) → System fragt IMMER weiter ab, auch wenn Tagesbudget überschritten.", @@ -406,7 +406,7 @@ "disable_polling_when_throttled": "Stoppt die periodische Abfrage vollständig, wenn das verbleibende Kontingent den Puffer (throttle_threshold) erreicht.", "refresh_after_resume": "Automatisch Zieltemperatur/Status abrufen nach 'resume_schedule' (HVAC AUTO). Notwendig, da Zeitpläne Tado-Cloud-seitig verwaltet werden. Nutzt 1s Karenzzeit (Grace-Period), um mehrere Fortsetzungen zusammenzufassen. Kostet 1 API-Aufruf.", "suppress_redundant_calls": "Überspringe API-Aufrufe wenn Zielzustand dem Cache entspricht (Temperatur, Modus, Anwesenheit, Power). Spart Kontingent bei versehentlichem Doppelklick oder UI-Interaktionen. Sendet nur bei tatsächlicher Änderung. Werte die nicht im Cache sind werden immer gesendet.", - "suppress_redundant_buttons": "Überspringe auch Button-Aktionen (resume_all, boost_all, turn_off_all, set_mode_all) wenn alle Zonen bereits im Zielzustand. Benötigt 'suppress_redundant_calls'. Einzelne explizite Aktionen und Synchronisierungs-Buttons werden immer gesendet." + "suppress_redundant_buttons": "Überspringe auch Button-Aktionen (resume_all, boost_all, turn_off_all, set_mode_all) wenn alle Zonen bereits im Zielzustand. Benötigt 'Redundante API-Aufrufe unterdrücken'. Einzelne explizite Aktionen und Synchronisierungs-Buttons werden immer gesendet." } }, "reduced_polling": { @@ -496,14 +496,14 @@ "api_quota": { "name": "Auto-API-Kontingent & Sicherheit", "data": { - "auto_api_quota_percent": "auto_api_quota_percent (%)", - "throttle_threshold": "throttle_threshold (Sicherheits-Puffer)", - "quota_safety_reserve": "quota_safety_reserve", - "min_auto_quota_interval_s": "min_auto_quota_interval_s", - "disable_polling_when_throttled": "disable_polling_when_throttled", - "refresh_after_resume": "refresh_after_resume", - "suppress_redundant_calls": "suppress_redundant_calls", - "suppress_redundant_buttons": "suppress_redundant_buttons" + "auto_api_quota_percent": "Auto-API-Kontingent (%)", + "throttle_threshold": "Drosselungs-Schwelle (Sicherheits-Puffer)", + "quota_safety_reserve": "Kontingent-Sicherheitsreserve", + "min_auto_quota_interval_s": "Minimales Auto-Quota-Intervall", + "disable_polling_when_throttled": "Polling stoppen wenn gedrosselt", + "refresh_after_resume": "Status nach Zeitplan-Fortsetzung abrufen", + "suppress_redundant_calls": "Redundante API-Aufrufe unterdrücken", + "suppress_redundant_buttons": "Redundante Button-Aktionen unterdrücken" }, "data_description": { "auto_api_quota_percent": "Nutze X% des FREIEN Tageskontingents für Status-Abfragen (0 = deaktiviert). FREI = Limit - Hintergrund-Reserve (Syncs) - Externe-Aktivität (Automationen, App-Nutzung über dem Puffer). Hybrid-Strategie: Nutzt MAX(Tagesbudget - Verbraucht, Verbleibend * X%) → System fragt IMMER weiter ab, auch wenn Tagesbudget überschritten.", @@ -513,7 +513,7 @@ "disable_polling_when_throttled": "Stoppt die periodische Abfrage vollständig, wenn das verbleibende Kontingent den Puffer (throttle_threshold) erreicht.", "refresh_after_resume": "Automatisch Zieltemperatur/Status abrufen nach 'resume_schedule' (HVAC AUTO). Notwendig, da Zeitpläne Tado-Cloud-seitig verwaltet werden. Nutzt 1s Karenzzeit (Grace-Period), um mehrere Fortsetzungen zusammenzufassen. Kostet 1 API-Aufruf.", "suppress_redundant_calls": "Überspringe API-Aufrufe wenn Zielzustand dem Cache entspricht (Temperatur, Modus, Anwesenheit, Power). Spart Kontingent bei versehentlichem Doppelklick oder UI-Interaktionen. Sendet nur bei tatsächlicher Änderung. Werte die nicht im Cache sind werden immer gesendet.", - "suppress_redundant_buttons": "Überspringe auch Button-Aktionen (resume_all, boost_all, turn_off_all, set_mode_all) wenn alle Zonen bereits im Zielzustand. Benötigt 'suppress_redundant_calls'. Einzelne explizite Aktionen und Synchronisierungs-Buttons werden immer gesendet." + "suppress_redundant_buttons": "Überspringe auch Button-Aktionen (resume_all, boost_all, turn_off_all, set_mode_all) wenn alle Zonen bereits im Zielzustand. Benötigt 'Redundante API-Aufrufe unterdrücken'. Einzelne explizite Aktionen und Synchronisierungs-Buttons werden immer gesendet." } }, "reduced_polling": { diff --git a/pyproject.toml b/pyproject.toml index 54a7f7b..cb8c712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ warn_redundant_casts = true warn_return_any = true warn_unused_configs = true warn_unused_ignores = true -exclude = ["testing_config/"] +exclude = ["testing_config/", "dev/"] [tool.ruff] target-version = "py313" From 6c5e90cb31e19208acb5f238faa5f14835217eda Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 14 Apr 2026 16:22:51 +0000 Subject: [PATCH 04/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.4.1-dev.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.4.1-dev.2](https://github.com/banter240/tado_hijack/compare/v5.4.1-dev.1...v5.4.1-dev.2) (2026-04-14) ### 🐛 Bug Fixes * fix: fix quota overconsumption, redundancy filter regression, and DE translations Quota (fix): - get_next_reset_time() anchored on now.replace(hour, minute) instead of last_history_entry + 1 day, collapsing adaptive interval to 20s minimum after every observed reset - get_initial_target() cached a stale past timestamp and never refreshed, driving seconds_until_reset negative for all generations - TadoX rate limit counter stayed frozen because hops.tado.com calls never went through the V3 handler; TadoXApi now captures ratelimit headers and exposes them via rate_limit_data. UnifiedDataProvider gains get_rate_limit_source() so the coordinator stays generation-agnostic Redundancy (fix): - _filter_presence and _filter_simple_attributes compared against the optimistic already-patched state, silently dropping every command when suppress_redundant_calls was enabled - Debounce replacements overwrote the rollback reference with the optimistic intermediate state; preserve_rollback_state() now carries the original confirmed API state forward through replacements - RESUME_SCHEDULE commands for zones already in schedule are now filtered when suppress_redundant_buttons is enabled Redundancy (refactor): - Extract _merge_keyed() in CommandMerger replacing 5 identical merge methods - Replace 6x _filter_simple_attributes() calls with a config loop - Add _suppress_calls/_suppress_buttons properties on TadoApiManager i18n: fix raw key names shown as labels in Auto API Quota config section [skip ci] --- CHANGELOG.md | 34 +++++++++++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e22a43c..d8b1124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +## [5.4.1-dev.2](https://github.com/banter240/tado_hijack/compare/v5.4.1-dev.1...v5.4.1-dev.2) (2026-04-14) + +### 🐛 Bug Fixes + +* fix: fix quota overconsumption, redundancy filter regression, and DE translations + +Quota (fix): +- get_next_reset_time() anchored on now.replace(hour, minute) instead of + last_history_entry + 1 day, collapsing adaptive interval to 20s minimum + after every observed reset +- get_initial_target() cached a stale past timestamp and never refreshed, + driving seconds_until_reset negative for all generations +- TadoX rate limit counter stayed frozen because hops.tado.com calls never + went through the V3 handler; TadoXApi now captures ratelimit headers and + exposes them via rate_limit_data. UnifiedDataProvider gains + get_rate_limit_source() so the coordinator stays generation-agnostic + +Redundancy (fix): +- _filter_presence and _filter_simple_attributes compared against the + optimistic already-patched state, silently dropping every command when + suppress_redundant_calls was enabled +- Debounce replacements overwrote the rollback reference with the optimistic + intermediate state; preserve_rollback_state() now carries the original + confirmed API state forward through replacements +- RESUME_SCHEDULE commands for zones already in schedule are now filtered + when suppress_redundant_buttons is enabled + +Redundancy (refactor): +- Extract _merge_keyed() in CommandMerger replacing 5 identical merge methods +- Replace 6x _filter_simple_attributes() calls with a config loop +- Add _suppress_calls/_suppress_buttons properties on TadoApiManager + +i18n: fix raw key names shown as labels in Auto API Quota config section + ## [5.4.1-dev.1](https://github.com/banter240/tado_hijack/compare/v5.4.0...v5.4.1-dev.1) (2026-04-11) ### 🐛 Bug Fixes diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index f9b23b5..ec75cf5 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.4.1-dev.1" + "version": "5.4.1-dev.2" } From b792ee07930745c5f9f6df6c339602a54bf6214f Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:08:06 +0200 Subject: [PATCH 05/53] fix(diagnostics): increase serial number redaction suffix to 5 chars Prevents false duplicate-device appearance in diagnostics output when two serials both end in the same 4 digits (e.g. both ending in "1234"). --- custom_components/tado_hijack/helpers/logging_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tado_hijack/helpers/logging_utils.py b/custom_components/tado_hijack/helpers/logging_utils.py index 7bfd25c..f0d5be8 100644 --- a/custom_components/tado_hijack/helpers/logging_utils.py +++ b/custom_components/tado_hijack/helpers/logging_utils.py @@ -67,7 +67,7 @@ def partial_redact_sn(m: re.Match[str]) -> str: if sn.startswith("_"): prefix = "_" sn = sn[1:] - return f"{prefix}{sn[:2]}...{sn[-4:]}" + return f"{prefix}{sn[:2]}...{sn[-5:]}" data = re.sub( r"(?:\b|_|^)[A-Z]{2,3}[A-Z0-9]{8,12}(?=\b|_|$)", partial_redact_sn, data From f27ad5cb6563d3fcba87b1614b2a3322f966df32 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:38:25 +0200 Subject: [PATCH 06/53] feat(sensors): add zone_mode and home_mode sensors (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-zone operating mode sensor (schedule/off/boost/manual) for both Tado Classic and Tado X generations, and a home-level aggregate sensor that returns "mixed" when zones are in different modes. No additional API calls - reads from already-fetched zone_states. Boost detection for Classic uses the existing 25°C temperature heuristic consistent with action_provider.py. Co-authored-by: laurensdehoorne --- README.md | 2 + custom_components/tado_hijack/const.py | 7 +++ custom_components/tado_hijack/definitions.py | 56 +++++++++++++++++++ .../tado_hijack/helpers/parsers.py | 23 ++++++++ .../tado_hijack/helpers/tadov3/parsers.py | 18 ++++++ .../tado_hijack/helpers/tadox/parsers.py | 12 ++++ .../tado_hijack/translations/de.json | 19 +++++++ .../tado_hijack/translations/en.json | 19 +++++++ 8 files changed, 156 insertions(+) diff --git a/README.md b/README.md index 066a437..3e031fa 100644 --- a/README.md +++ b/README.md @@ -543,6 +543,7 @@ Global controls and elite transparency for your home. _Linked to your Internet B | `sensor.tado_{home}_api_limit` | Sensor | Total daily API quota limit (1000 standard, 3000 with proxy). | | `sensor.tado_{home}_api_remaining` | Sensor | **API Gold:** Your remaining daily call budget. | | `sensor.tado_{home}_api_status` | Sensor | Real-time health (`connected`, `throttled`, `rate_limited`). | +| `sensor.tado_{home}_home_mode` | Sensor | Aggregate zone mode across all heating/AC zones: `schedule`, `manual`, `boost`, `off`, or `mixed` (zones differ). Useful for automations — e.g. trigger "resume schedule" when `mixed`. |
@@ -615,6 +616,7 @@ Cloud-only features that HomeKit does not support. | `select.fan_speed` | Select | **v3 AC Only:** Full fan speed control. | | `select.vertical_swing` | Select | **v3 AC Only:** Vertical swing control (ON/OFF or position modes). | | `select.horizontal_swing` | Select | **v3 AC Only:** Horizontal swing control (ON/OFF or position modes). | +| `sensor.zone_mode` | Sensor | **Mode:** Current operating mode: `schedule`, `manual`, `boost`, `off`. Classic: boost detected via 25°C setpoint. Tado X: native boost field. Heating and AC zones only. | | `sensor.heating_power` | Sensor | **Insight:** Valve opening % or Boiler Load %. | | `sensor.humidity` | Sensor | Zone humidity (faster than HomeKit). | | `sensor.dew_point` | Sensor | **Climate:** Dew point temperature (°C) via Magnus formula. Sources: linked `zone_temp_source` → zone state (v3) → unavailable (Tado X). Enabled via _Dew Point Sensor_ feature flag. | diff --git a/custom_components/tado_hijack/const.py b/custom_components/tado_hijack/const.py index 6246b3e..bb3ee34 100644 --- a/custom_components/tado_hijack/const.py +++ b/custom_components/tado_hijack/const.py @@ -140,6 +140,13 @@ ZONE_TYPE_HOT_WATER: Final = "HOT_WATER" ZONE_TYPE_AIR_CONDITIONING: Final = "AIR_CONDITIONING" +# Zone Mode States +ZONE_MODE_SCHEDULE: Final = "schedule" +ZONE_MODE_OFF: Final = "off" +ZONE_MODE_BOOST: Final = "boost" +ZONE_MODE_MANUAL: Final = "manual" +ZONE_MODE_MIXED: Final = "mixed" + # Power States POWER_ON: Final = "ON" POWER_OFF: Final = "OFF" diff --git a/custom_components/tado_hijack/definitions.py b/custom_components/tado_hijack/definitions.py index 9ab280b..e97f387 100644 --- a/custom_components/tado_hijack/definitions.py +++ b/custom_components/tado_hijack/definitions.py @@ -70,6 +70,7 @@ TEMP_MAX_HOT_WATER_OVERRIDE, TEMP_MIN_AC, TEMP_MIN_HOT_WATER, + ZONE_MODE_MIXED, ZONE_TYPE_AIR_CONDITIONING, ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER, @@ -910,12 +911,45 @@ def create_zone_sensor( ) +def _parse_home_zone_mode(c: Any) -> str | None: + """Return the combined zone mode across all heating/AC zones.""" + zone_states = c.data.zone_states + if not zone_states: + return None + + parse_fn = ( + tadox_parsers.parse_zone_mode + if c.generation == GEN_X + else v3_parsers.parse_zone_mode + ) + + relevant_ids = [ + zid + for zid, zmeta in c.zones_meta.items() + if getattr(zmeta, "type", ZONE_TYPE_HEATING) + in {ZONE_TYPE_HEATING, ZONE_TYPE_AIR_CONDITIONING} + ] + if not relevant_ids: + return None + + if modes := {parse_fn(zone_states.get(str(zid))) for zid in relevant_ids} - {None}: + return next(iter(modes)) if len(modes) == 1 else ZONE_MODE_MIXED + else: + return None + + ENTITY_DEFINITIONS: Final[list[TadoEntityDefinition]] = [ create_diagnostic_sensor( key="api_status", value_fn=lambda c: str(c.data.api_status), device_class=SensorDeviceClass.ENUM, ), + create_home_sensor( + key="home_mode", + value_fn=_parse_home_zone_mode, + device_class=SensorDeviceClass.ENUM, + icon="mdi:home-thermometer", + ), create_diagnostic_sensor( key="tado_generation", value_fn=lambda c: "Tado X" if c.generation == GEN_X else "Classic", @@ -1154,6 +1188,28 @@ def create_zone_sensor( state_class=SensorStateClass.MEASUREMENT, unique_id_suffix="pwr", ), + create_zone_sensor( + key="zone_mode", + supported_generations={GEN_CLASSIC}, + value_fn=lambda c, zid: v3_parsers.parse_zone_mode( + c.data.zone_states.get(str(zid)) + ), + device_class=SensorDeviceClass.ENUM, + icon="mdi:thermostat", + supported_zone_types={ZONE_TYPE_HEATING, ZONE_TYPE_AIR_CONDITIONING}, + unique_id_suffix="mode", + ), + create_zone_sensor( + key="zone_mode", + supported_generations={GEN_X}, + value_fn=lambda c, zid: tadox_parsers.parse_zone_mode( + c.data.zone_states.get(str(zid)) + ), + device_class=SensorDeviceClass.ENUM, + icon="mdi:thermostat", + supported_zone_types={ZONE_TYPE_HEATING, ZONE_TYPE_AIR_CONDITIONING}, + unique_id_suffix="mode", + ), create_zone_sensor( key="humidity", value_fn=lambda c, zid: _get_zone_sensor_data(c, zid, "humidity"), diff --git a/custom_components/tado_hijack/helpers/parsers.py b/custom_components/tado_hijack/helpers/parsers.py index 7a2bf6d..448b5c7 100644 --- a/custom_components/tado_hijack/helpers/parsers.py +++ b/custom_components/tado_hijack/helpers/parsers.py @@ -5,6 +5,13 @@ import re from typing import TYPE_CHECKING, Any +from ..const import ( + POWER_OFF, + ZONE_MODE_BOOST, + ZONE_MODE_MANUAL, + ZONE_MODE_OFF, + ZONE_MODE_SCHEDULE, +) from ..models import RateLimit if TYPE_CHECKING: @@ -60,6 +67,22 @@ def get_ac_capabilities(capabilities: Capabilities) -> dict[str, set[str]]: } +def resolve_zone_mode(overlay_active: bool, power: str, is_boost: bool) -> str: + """Resolve zone operating mode from overlay state. + + Args: + overlay_active: Whether a manual overlay is active. + power: Zone power state ("ON" or "OFF"). + is_boost: Whether the zone is currently in boost mode. + + """ + if not overlay_active: + return ZONE_MODE_SCHEDULE + if power == POWER_OFF: + return ZONE_MODE_OFF + return ZONE_MODE_BOOST if is_boost else ZONE_MODE_MANUAL + + def parse_schedule_temperature(state: Any) -> float | None: """Extract the target temperature from the active schedule in zone state. diff --git a/custom_components/tado_hijack/helpers/tadov3/parsers.py b/custom_components/tado_hijack/helpers/tadov3/parsers.py index bd32508..c84301c 100644 --- a/custom_components/tado_hijack/helpers/tadov3/parsers.py +++ b/custom_components/tado_hijack/helpers/tadov3/parsers.py @@ -7,6 +7,7 @@ import homeassistant.util.dt as dt_util +from ...const import BOOST_MODE_TEMP, TEMP_TOLERANCE from ..climate_physics import ( VENTILATION_AH_THRESHOLD as _DEFAULT_VENTILATION_AH_THRESHOLD, ) @@ -18,6 +19,7 @@ from ..climate_physics import ( compute_dew_point as _compute_dew_point, ) +from ..parsers import resolve_zone_mode # Re-export for callers that import it directly (e.g. definitions.py uses # compute_absolute_humidity via this module). @@ -184,3 +186,19 @@ def parse_mold_risk_level(state: Any) -> str | None: return None temp, rh = values return compute_mold_risk_level(temp, rh) + + +def parse_zone_mode(state: Any) -> str | None: + """Return the current operating mode of a v3 zone.""" + if not state: + return None + setting = getattr(state, "setting", None) + power = getattr(setting, "power", "OFF") if setting else "OFF" + temp_obj = getattr(setting, "temperature", None) if setting else None + celsius = getattr(temp_obj, "celsius", None) if temp_obj else None + is_boost = celsius is not None and abs(celsius - BOOST_MODE_TEMP) <= TEMP_TOLERANCE + return resolve_zone_mode( + overlay_active=getattr(state, "overlay_active", False), + power=power, + is_boost=is_boost, + ) diff --git a/custom_components/tado_hijack/helpers/tadox/parsers.py b/custom_components/tado_hijack/helpers/tadox/parsers.py index 0b02d03..33a4976 100644 --- a/custom_components/tado_hijack/helpers/tadox/parsers.py +++ b/custom_components/tado_hijack/helpers/tadox/parsers.py @@ -17,6 +17,7 @@ from ..climate_physics import ( compute_dew_point as _compute_dew_point, ) +from ..parsers import resolve_zone_mode if TYPE_CHECKING: from datetime import datetime @@ -163,3 +164,14 @@ def parse_ventilation_recommended( indoor_ah = compute_absolute_humidity(temp_celsius, rh) outdoor_ah = compute_absolute_humidity(outdoor_temp, outdoor_rh) return compute_ventilation_beneficial(indoor_ah, outdoor_ah, threshold) + + +def parse_zone_mode(state: TadoXZoneState | None) -> str | None: + """Return the current operating mode of a Tado X zone.""" + if not state: + return None + return resolve_zone_mode( + overlay_active=state.overlay_active, + power=state.setting.power, + is_boost=state.boost_mode is not None, + ) diff --git a/custom_components/tado_hijack/translations/de.json b/custom_components/tado_hijack/translations/de.json index 58a631c..c9ac84d 100644 --- a/custom_components/tado_hijack/translations/de.json +++ b/custom_components/tado_hijack/translations/de.json @@ -142,6 +142,25 @@ }, "scan_interval": { "name": "Abfrageintervall" + }, + "zone_mode": { + "name": "Zonenmodus", + "state": { + "schedule": "Zeitplan", + "off": "Aus", + "boost": "Boost", + "manual": "Manuell" + } + }, + "home_mode": { + "name": "Home-Modus", + "state": { + "schedule": "Zeitplan", + "off": "Aus", + "boost": "Boost", + "manual": "Manuell", + "mixed": "Gemischt" + } } }, "binary_sensor": { diff --git a/custom_components/tado_hijack/translations/en.json b/custom_components/tado_hijack/translations/en.json index 69ba5b7..7b8ee23 100644 --- a/custom_components/tado_hijack/translations/en.json +++ b/custom_components/tado_hijack/translations/en.json @@ -142,6 +142,25 @@ }, "scan_interval": { "name": "Scan Interval" + }, + "zone_mode": { + "name": "Zone Mode", + "state": { + "schedule": "Schedule", + "off": "Off", + "boost": "Boost", + "manual": "Manual" + } + }, + "home_mode": { + "name": "Home Mode", + "state": { + "schedule": "Schedule", + "off": "Off", + "boost": "Boost", + "manual": "Manual", + "mixed": "Mixed" + } } }, "binary_sensor": { From af8db37924578a9235ba0850429b500743b687e7 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:43:37 +0200 Subject: [PATCH 07/53] fix(tadox): resolve device-to-zone mapping and pass termination to manual control (#89) Fix _resolve_device_to_zone() using zone.id which doesn't exist on HopsRoomSnapshot (Tado X zones use room_id). Iterate zones_meta.items() and use the dict key as zone_id instead. Fix TadoXExecutor ignoring the termination dict from the merged overlay data. Extract termination_type and duration_seconds and pass them to async_set_manual_control so TIMER overlays created via set_mode duration parameter actually expire as intended. --- .../tado_hijack/helpers/entity_resolver.py | 6 +++--- .../tado_hijack/helpers/tadox/executor.py | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/custom_components/tado_hijack/helpers/entity_resolver.py b/custom_components/tado_hijack/helpers/entity_resolver.py index 62085d5..9ec767f 100644 --- a/custom_components/tado_hijack/helpers/entity_resolver.py +++ b/custom_components/tado_hijack/helpers/entity_resolver.py @@ -110,16 +110,16 @@ def _resolve_device_to_zone(self, unique_id: str) -> int | None: if len(parts) >= 3: # noqa: PLR2004 serial_no = parts[-1] - for zone in self.coordinator.zones_meta.values(): + for zone_id, zone in self.coordinator.zones_meta.items(): for device in zone.devices: if device.serial_no == serial_no: _LOGGER.debug( "Resolved device %s to zone %d via serial %s", unique_id, - zone.id, + zone_id, serial_no, ) - return zone.id + return zone_id except (ValueError, IndexError, AttributeError): pass return None diff --git a/custom_components/tado_hijack/helpers/tadox/executor.py b/custom_components/tado_hijack/helpers/tadox/executor.py index c1f3b91..f5cf8e9 100644 --- a/custom_components/tado_hijack/helpers/tadox/executor.py +++ b/custom_components/tado_hijack/helpers/tadox/executor.py @@ -185,16 +185,29 @@ def _clear_all_optimistic() -> None: # Manual Control - rebuild overlay from merged data setting = data.get("setting", {}) - # Support both v3 format (celsius) and Tado X format (value) temp_dict = setting.get("temperature", {}) - temp = temp_dict.get("value") or temp_dict.get("celsius") + temp = temp_dict.get("celsius") # Magic number mapping: temp=-1 → power=OFF (last call wins) temp, power = map_magic_temp_to_power(temp) + termination = data.get("termination", {}) + termination_type = ( + termination.get("typeSkillBasedApp") + or termination.get("type") + or "MANUAL" + ) + duration_seconds = termination.get("durationInSeconds") + await self._safe_execute( f"overlay_{zone_id}", - self.bridge.async_set_manual_control(zone_id, temp, power=power), + self.bridge.async_set_manual_control( + zone_id, + temp, + power=power, + termination_type=termination_type, + duration_seconds=duration_seconds, + ), rollback_fn=self._create_zones_rollback([zone_id], rollback_zones), success_fn=lambda zid=zone_id, pwr=power, t=temp: ( self.coordinator.optimistic.apply_zone_state( From 49fa6e6de192a7d0ef67c5c09df6452ab0afc45d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 17 Apr 2026 18:51:36 +0000 Subject: [PATCH 08/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.5.0-dev.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.5.0-dev.1](https://github.com/banter240/tado_hijack/compare/v5.4.1-dev.2...v5.5.0-dev.1) (2026-04-17) ### ✨ New Features * feat(sensors): add zone_mode and home_mode sensors (#86) Adds per-zone operating mode sensor (schedule/off/boost/manual) for both Tado Classic and Tado X generations, and a home-level aggregate sensor that returns "mixed" when zones are in different modes. No additional API calls - reads from already-fetched zone_states. Boost detection for Classic uses the existing 25°C temperature heuristic consistent with action_provider.py. Co-authored-by: laurensdehoorne ### 🐛 Bug Fixes * fix(diagnostics): increase serial number redaction suffix to 5 chars Prevents false duplicate-device appearance in diagnostics output when two serials both end in the same 4 digits (e.g. both ending in "1234"). * fix(tadox): resolve device-to-zone mapping and pass termination to manual control (#89) Fix _resolve_device_to_zone() using zone.id which doesn't exist on HopsRoomSnapshot (Tado X zones use room_id). Iterate zones_meta.items() and use the dict key as zone_id instead. Fix TadoXExecutor ignoring the termination dict from the merged overlay data. Extract termination_type and duration_seconds and pass them to async_set_manual_control so TIMER overlays created via set_mode duration parameter actually expire as intended. [skip ci] --- CHANGELOG.md | 35 +++++++++++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b1124..e0ca3f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## [5.5.0-dev.1](https://github.com/banter240/tado_hijack/compare/v5.4.1-dev.2...v5.5.0-dev.1) (2026-04-17) + +### ✨ New Features + +* feat(sensors): add zone_mode and home_mode sensors (#86) + +Adds per-zone operating mode sensor (schedule/off/boost/manual) for +both Tado Classic and Tado X generations, and a home-level aggregate +sensor that returns "mixed" when zones are in different modes. + +No additional API calls - reads from already-fetched zone_states. +Boost detection for Classic uses the existing 25°C temperature heuristic +consistent with action_provider.py. + +Co-authored-by: laurensdehoorne + + +### 🐛 Bug Fixes + +* fix(diagnostics): increase serial number redaction suffix to 5 chars + +Prevents false duplicate-device appearance in diagnostics output when +two serials both end in the same 4 digits (e.g. both ending in "1234"). + +* fix(tadox): resolve device-to-zone mapping and pass termination to manual control (#89) + +Fix _resolve_device_to_zone() using zone.id which doesn't exist on +HopsRoomSnapshot (Tado X zones use room_id). Iterate zones_meta.items() +and use the dict key as zone_id instead. + +Fix TadoXExecutor ignoring the termination dict from the merged overlay +data. Extract termination_type and duration_seconds and pass them to +async_set_manual_control so TIMER overlays created via set_mode duration +parameter actually expire as intended. + ## [5.4.1-dev.2](https://github.com/banter240/tado_hijack/compare/v5.4.1-dev.1...v5.4.1-dev.2) (2026-04-14) ### 🐛 Bug Fixes diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index ec75cf5..b667520 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.4.1-dev.2" + "version": "5.5.0-dev.1" } From b71181d387cea875b82fb570a64939dd87ad2e6f Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:08:09 +0200 Subject: [PATCH 09/53] Add files via upload --- custom_components/tado_hijack/coordinator.py | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 3700690..27fbb3a 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -376,6 +376,7 @@ async def _async_update_data(self) -> TadoData: self.zones_meta = self.data_manager.zones_meta self.devices_meta = self.data_manager.devices_meta + self.timetable_cache: dict[int, dict] = self.data_manager.timetable_cache from .helpers.discovery import get_bridges @@ -1106,6 +1107,45 @@ async def async_set_early_start(self, zone_id: int, enabled: bool) -> None: rollback_context=old_val, ) + async def async_set_timetable(self, zone_id: int, timetable_type: str) -> None: + """Set the active timetable schedule type for a zone. + + timetable_type: "ONE_DAY" | "THREE_DAY" | "SEVEN_DAY" + Goes through the command queue (1 API call, debounced & merged). + """ + type_to_id = {"ONE_DAY": 0, "THREE_DAY": 1, "SEVEN_DAY": 2} + timetable_id = type_to_id.get(timetable_type) + if timetable_id is None: + _LOGGER.error("Invalid timetable type: %s", timetable_type) + return + + # Optimistically update local cache for immediate UI feedback + entry = {"id": timetable_id, "type": timetable_type} + self.timetable_cache[zone_id] = entry + self.data_manager.timetable_cache[zone_id] = entry + self.async_update_listeners() + + self.api_manager.queue_command( + f"set_timetable_{zone_id}", + TadoCommand( + CommandType.SET_TIMETABLE, + zone_id=zone_id, + data={"zone_id": zone_id, "timetable_id": timetable_id}, + ), + ) + + async def async_refresh_timetable(self, zone_id: int) -> None: + """Refresh the active timetable for a zone from the API.""" + _LOGGER.info("Refreshing timetable for zone %s", zone_id) + try: + entry = await self._tado.get_active_timetable(zone_id) + self.timetable_cache[zone_id] = entry + self.data_manager.timetable_cache[zone_id] = entry + self.async_update_listeners() + _LOGGER.debug("Timetable refreshed for zone %s: %s", zone_id, entry) + except Exception as err: + _LOGGER.error("Failed to refresh timetable for zone %s: %s", zone_id, err) + async def async_set_open_window_detection( self, zone_id: int, enabled: bool, timeout_seconds: int | None = None ) -> None: From dd0ae60b1b63edc47b67125adeffd98a3b9c22ae Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:10:34 +0200 Subject: [PATCH 10/53] Add files via upload --- custom_components/tado_hijack/definitions.py | 26 ++++++++++++++++++++ custom_components/tado_hijack/models.py | 1 + 2 files changed, 27 insertions(+) diff --git a/custom_components/tado_hijack/definitions.py b/custom_components/tado_hijack/definitions.py index e97f387..1569247 100644 --- a/custom_components/tado_hijack/definitions.py +++ b/custom_components/tado_hijack/definitions.py @@ -1895,4 +1895,30 @@ def _parse_home_zone_mode(c: Any) -> str | None: optimistic_key="horizontal_swing", supported_generations={GEN_CLASSIC}, ), + create_zone_button( + key="refresh_timetable", + press_fn=lambda c, zid: c.async_refresh_timetable(zid), + icon="mdi:calendar-refresh", + entity_category=EntityCategory.CONFIG, + supported_zone_types={ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER}, + supported_generations={GEN_CLASSIC}, + unique_id_suffix="refresh_timetable", + ), + create_zone_select( + key="timetable_type", + value_fn=lambda c, zid: ( + c.timetable_cache.get(zid, {}).get("type", "ONE_DAY").lower() + if hasattr(c, "timetable_cache") + else None + ), + options_fn=lambda c, zid: ["one_day", "three_day", "seven_day"], + select_option_fn=lambda c, zid, val: c.async_set_timetable( + zid, val.upper() + ), + icon="mdi:calendar-week", + entity_category=EntityCategory.CONFIG, + supported_zone_types={ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER}, + supported_generations={GEN_CLASSIC}, + unique_id_suffix="timetable_type", + ), ] diff --git a/custom_components/tado_hijack/models.py b/custom_components/tado_hijack/models.py index ba029f1..a39b07c 100644 --- a/custom_components/tado_hijack/models.py +++ b/custom_components/tado_hijack/models.py @@ -61,6 +61,7 @@ class CommandType(StrEnum): SET_DAZZLE = "set_dazzle" SET_EARLY_START = "set_early_start" SET_OPEN_WINDOW = "set_open_window" + SET_TIMETABLE = "set_timetable" IDENTIFY = "identify" From b3a195c0f26b84f716a632fff3390279639e6247 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:11:37 +0200 Subject: [PATCH 11/53] Add files via upload --- custom_components/tado_hijack/translations/de.json | 11 +++++++++++ custom_components/tado_hijack/translations/en.json | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/custom_components/tado_hijack/translations/de.json b/custom_components/tado_hijack/translations/de.json index c9ac84d..9fbca29 100644 --- a/custom_components/tado_hijack/translations/de.json +++ b/custom_components/tado_hijack/translations/de.json @@ -279,6 +279,9 @@ }, "identify_device": { "name": "Gerät identifizieren" + }, + "refresh_timetable": { + "name": "Zeitplan aktualisieren" } }, "number": { @@ -334,6 +337,14 @@ "off": "Aus", "auto": "Automatisch" } + }, + "timetable_type": { + "name": "Wochenplan-Modus", + "state": { + "one_day": "Mo-So (gleich jeden Tag)", + "three_day": "Mo-Fr / Sa / So", + "seven_day": "Pro Wochentag" + } } }, "climate": { diff --git a/custom_components/tado_hijack/translations/en.json b/custom_components/tado_hijack/translations/en.json index 7b8ee23..608bd7c 100644 --- a/custom_components/tado_hijack/translations/en.json +++ b/custom_components/tado_hijack/translations/en.json @@ -279,6 +279,9 @@ }, "identify_device": { "name": "Identify Device" + }, + "refresh_timetable": { + "name": "Refresh timetable" } }, "number": { @@ -334,6 +337,14 @@ "off": "Off", "auto": "Auto" } + }, + "timetable_type": { + "name": "Schedule Mode", + "state": { + "one_day": "Mon-Sun (same every day)", + "three_day": "Mon-Fri / Sat / Sun", + "seven_day": "Per day of the week" + } } }, "climate": { From bc6dba720a70694acd1e4c8508dc6e52ebe91edb Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:13:08 +0200 Subject: [PATCH 12/53] Add files via upload --- .../tado_hijack/helpers/tadov3/executor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/custom_components/tado_hijack/helpers/tadov3/executor.py b/custom_components/tado_hijack/helpers/tadov3/executor.py index 2862800..e9b6568 100644 --- a/custom_components/tado_hijack/helpers/tadov3/executor.py +++ b/custom_components/tado_hijack/helpers/tadov3/executor.py @@ -169,6 +169,16 @@ async def _execute_zone_properties(self, merged: dict[str, Any]) -> None: }, ) + for zid, timetable_id in merged.get("timetables", {}).items(): + if self._should_skip_zone(zid): # [DUMMY_HOOK] + continue + + await self._safe_execute( + f"timetable_{zid}", + self.client.set_active_timetable(zid, timetable_id), + context={"zone_id": zid, "timetable_id": timetable_id}, + ) + async def _execute_zone_actions(self, merged: dict[str, Any]) -> None: """Execute overlays and resumes using v3 bulk endpoints.""" zones = merged["zones"] From 6f891ddbfbb458cd95172ad75e9a84b391a6cffa Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:15:12 +0200 Subject: [PATCH 13/53] Add files via upload --- .../tado_hijack/helpers/api_manager.py | 1 + .../tado_hijack/helpers/client.py | 28 +++++++++++++++++++ .../tado_hijack/helpers/command_merger.py | 8 ++++++ .../tado_hijack/helpers/data_manager.py | 1 + 4 files changed, 38 insertions(+) diff --git a/custom_components/tado_hijack/helpers/api_manager.py b/custom_components/tado_hijack/helpers/api_manager.py index 799506a..bc84fb6 100644 --- a/custom_components/tado_hijack/helpers/api_manager.py +++ b/custom_components/tado_hijack/helpers/api_manager.py @@ -302,6 +302,7 @@ async def _process_batch(self, commands: list[TadoCommand]) -> None: merged.get("dazzle_modes"), merged.get("early_starts"), merged.get("open_windows"), + merged.get("timetables"), merged.get("identifies"), ] ) diff --git a/custom_components/tado_hijack/helpers/client.py b/custom_components/tado_hijack/helpers/client.py index c88378b..9ca04f1 100644 --- a/custom_components/tado_hijack/helpers/client.py +++ b/custom_components/tado_hijack/helpers/client.py @@ -156,3 +156,31 @@ async def identify_device(self, serial_no: str) -> None: f"devices/{serial_no}/identify", method=HttpMethod.POST, ) + + async def get_active_timetable(self, zone_id: int) -> dict[str, Any]: + """Get the active timetable type for a zone. + + Returns a dict with 'id' (int) and 'type' (str) fields. + Possible types: + - ONE_DAY : same schedule every day (Mon-Sun) + - THREE_DAY : Mon-Fri / Sat / Sun + - SEVEN_DAY : one schedule per day of the week + """ + response = await self._request( + f"homes/{self._home_id}/zones/{zone_id}/schedule/activeTimetable" + ) + return cast(dict[str, Any], orjson.loads(response)) + + async def set_active_timetable(self, zone_id: int, timetable_id: int) -> None: + """Set the active timetable for a zone. + + timetable_id values: + 0 = ONE_DAY (Mon-Sun) + 1 = THREE_DAY (Mon-Fri / Sat / Sun) + 2 = SEVEN_DAY (one per day) + """ + await self._request( + f"homes/{self._home_id}/zones/{zone_id}/schedule/activeTimetable", + data={"id": timetable_id}, + method=HttpMethod.PUT, + ) diff --git a/custom_components/tado_hijack/helpers/command_merger.py b/custom_components/tado_hijack/helpers/command_merger.py index 5a6cbc2..066de3c 100644 --- a/custom_components/tado_hijack/helpers/command_merger.py +++ b/custom_components/tado_hijack/helpers/command_merger.py @@ -23,6 +23,7 @@ def __init__(self, zones_meta: dict[int, Zone]) -> None: self.dazzle_modes: dict[int, bool] = {} self.early_starts: dict[int, bool] = {} self.open_windows: dict[int, Any] = {} + self.timetables: dict[int, int] = {} # zone_id -> timetable_id self.identifies: set[str] = set() self.presence: str | None = None self.old_presence: str | None = None @@ -46,6 +47,7 @@ def add(self, cmd: TadoCommand) -> None: CommandType.SET_DAZZLE: self._merge_dazzle, CommandType.SET_EARLY_START: self._merge_early_start, CommandType.SET_OPEN_WINDOW: self._merge_open_window, + CommandType.SET_TIMETABLE: self._merge_timetable, CommandType.IDENTIFY: self._merge_identify, CommandType.SET_PRESENCE: self._merge_presence, CommandType.RESUME_SCHEDULE: self._merge_resume, @@ -130,6 +132,11 @@ def _merge_open_window(self, cmd: TadoCommand) -> None: store_full_data=True, ) + def _merge_timetable(self, cmd: TadoCommand) -> None: + """Merge timetable command — last write wins per zone.""" + if cmd.data and "zone_id" in cmd.data and "timetable_id" in cmd.data: + self.timetables[int(cmd.data["zone_id"])] = int(cmd.data["timetable_id"]) + def _merge_identify(self, cmd: TadoCommand) -> None: if cmd.data and "serial" in cmd.data: self.identifies.add(str(cmd.data["serial"])) @@ -192,6 +199,7 @@ def result(self) -> dict[str, Any]: "dazzle_modes": self.dazzle_modes, "early_starts": self.early_starts, "open_windows": self.open_windows, + "timetables": self.timetables, "identifies": self.identifies, "presence": self.presence, "old_presence": self.old_presence, diff --git a/custom_components/tado_hijack/helpers/data_manager.py b/custom_components/tado_hijack/helpers/data_manager.py index 4c34f58..be33c32 100644 --- a/custom_components/tado_hijack/helpers/data_manager.py +++ b/custom_components/tado_hijack/helpers/data_manager.py @@ -66,6 +66,7 @@ def __init__( self.capabilities_cache: dict[int, Any] = {} self.offsets_cache: dict[str, TemperatureOffset] = {} self.away_cache: dict[int, float] = {} + self.timetable_cache: dict[int, dict[str, Any]] = {} # zone_id -> {id, type} self._capability_locks: dict[int, asyncio.Lock] = {} self._last_slow_poll: float = 0 self._last_offset_poll: float = 0 From 383332338f389c8ee72f3dbe3a920526596d37b7 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:31:55 +0200 Subject: [PATCH 14/53] Add files via upload --- custom_components/tado_hijack/coordinator.py | 68 ++++++++++++++++++++ custom_components/tado_hijack/definitions.py | 37 +++++++++-- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 27fbb3a..1627033 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -273,6 +273,16 @@ async def async_setup(self) -> None: self._last_quota_reset = last_reset self._schedule_reset_poll() + timetable_data = await self.storage.async_get("timetable_cache") + if timetable_data: + restored = {int(k): v for k, v in timetable_data.items()} + self.data_manager.timetable_cache.update(restored) + _LOGGER.debug( + "Restored timetable cache for %d zone(s): %s", + len(restored), + restored, + ) + def _save_reset_tracker(self) -> None: """Persist reset tracker state to storage.""" self.hass.async_create_task( @@ -1107,6 +1117,15 @@ async def async_set_early_start(self, zone_id: int, enabled: bool) -> None: rollback_context=old_val, ) + def _save_timetable_cache(self) -> None: + """Persist timetable cache to storage.""" + self.hass.async_create_task( + self.storage.async_update( + "timetable_cache", + {str(k): v for k, v in self.timetable_cache.items()}, + ) + ) + async def async_set_timetable(self, zone_id: int, timetable_type: str) -> None: """Set the active timetable schedule type for a zone. @@ -1124,6 +1143,7 @@ async def async_set_timetable(self, zone_id: int, timetable_type: str) -> None: self.timetable_cache[zone_id] = entry self.data_manager.timetable_cache[zone_id] = entry self.async_update_listeners() + self._save_timetable_cache() self.api_manager.queue_command( f"set_timetable_{zone_id}", @@ -1142,10 +1162,58 @@ async def async_refresh_timetable(self, zone_id: int) -> None: self.timetable_cache[zone_id] = entry self.data_manager.timetable_cache[zone_id] = entry self.async_update_listeners() + self._save_timetable_cache() _LOGGER.debug("Timetable refreshed for zone %s: %s", zone_id, entry) except Exception as err: _LOGGER.error("Failed to refresh timetable for zone %s: %s", zone_id, err) + async def async_refresh_all_timetables(self) -> None: + """Refresh the active timetable for all compatible zones (HEATING + HOT_WATER, GEN_CLASSIC only).""" + if self.generation != GEN_CLASSIC: + _LOGGER.debug("async_refresh_all_timetables: skipped (not GEN_CLASSIC)") + return + + from .helpers.zone_utils import get_zone_type + + zone_ids = [ + zone_id + for zone_id, zone in self.zones_meta.items() + if get_zone_type(zone) in (ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER) + ] + + if not zone_ids: + _LOGGER.debug("async_refresh_all_timetables: no compatible zones found") + return + + _LOGGER.info("Refreshing timetables for %d zone(s): %s", len(zone_ids), zone_ids) + for zone_id in zone_ids: + await self.async_refresh_timetable(zone_id) + + async def async_set_timetable_all_zones(self, timetable_type: str) -> None: + """Set the timetable type for all compatible zones (HEATING + HOT_WATER, GEN_CLASSIC only).""" + if self.generation != GEN_CLASSIC: + _LOGGER.debug("async_set_timetable_all_zones: skipped (not GEN_CLASSIC)") + return + + from .helpers.zone_utils import get_zone_type + + zone_ids = [ + zone_id + for zone_id, zone in self.zones_meta.items() + if get_zone_type(zone) in (ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER) + ] + + if not zone_ids: + _LOGGER.debug("async_set_timetable_all_zones: no compatible zones found") + return + + _LOGGER.info( + "Setting timetable type '%s' for %d zone(s): %s", + timetable_type, len(zone_ids), zone_ids, + ) + for zone_id in zone_ids: + await self.async_set_timetable(zone_id, timetable_type) + async def async_set_open_window_detection( self, zone_id: int, enabled: bool, timeout_seconds: int | None = None ) -> None: diff --git a/custom_components/tado_hijack/definitions.py b/custom_components/tado_hijack/definitions.py index 1569247..2b85e50 100644 --- a/custom_components/tado_hijack/definitions.py +++ b/custom_components/tado_hijack/definitions.py @@ -760,6 +760,7 @@ def create_home_button( entity_category: EntityCategory | None = None, translation_key: str | None = None, unique_id_suffix: str | None = None, + is_supported_fn: Any | None = None, ) -> TadoEntityDefinition: """Create a button for the Tado Home.""" return _create_definition( @@ -772,6 +773,7 @@ def create_home_button( entity_category=entity_category, translation_key=translation_key, unique_id_suffix=unique_id_suffix, + is_supported_fn=is_supported_fn, ) @@ -835,6 +837,7 @@ def create_home_select( icon: str | None = None, entity_category: EntityCategory | None = None, unique_id_suffix: str | None = None, + is_supported_fn: Any | None = None, ) -> TadoEntityDefinition: """Create a select entity for the Tado Home.""" return _create_definition( @@ -847,6 +850,7 @@ def create_home_select( icon=icon, entity_category=entity_category, unique_id_suffix=unique_id_suffix, + is_supported_fn=is_supported_fn, ) @@ -1895,14 +1899,26 @@ def _parse_home_zone_mode(c: Any) -> str | None: optimistic_key="horizontal_swing", supported_generations={GEN_CLASSIC}, ), - create_zone_button( - key="refresh_timetable", - press_fn=lambda c, zid: c.async_refresh_timetable(zid), + create_home_select( + key="timetable_type_all_zones", + value_fn=lambda c: ( + next(iter(c.timetable_cache.values()), {}).get("type", "ONE_DAY").lower() + if hasattr(c, "timetable_cache") and c.timetable_cache + else "one_day" + ), + options=["one_day", "three_day", "seven_day"], + select_option_fn=lambda c, val: c.async_set_timetable_all_zones(val.upper()), + icon="mdi:calendar-week", + entity_category=EntityCategory.CONFIG, + unique_id_suffix="timetable_type_all", + is_supported_fn=lambda c: c.generation == GEN_CLASSIC, + ), + create_home_button( + key="refresh_all_timetables", + press_fn=lambda c: c.async_refresh_all_timetables(), icon="mdi:calendar-refresh", entity_category=EntityCategory.CONFIG, - supported_zone_types={ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER}, - supported_generations={GEN_CLASSIC}, - unique_id_suffix="refresh_timetable", + is_supported_fn=lambda c: c.generation == GEN_CLASSIC, ), create_zone_select( key="timetable_type", @@ -1921,4 +1937,13 @@ def _parse_home_zone_mode(c: Any) -> str | None: supported_generations={GEN_CLASSIC}, unique_id_suffix="timetable_type", ), + create_zone_button( + key="refresh_timetable", + press_fn=lambda c, zid: c.async_refresh_timetable(zid), + icon="mdi:calendar-refresh", + entity_category=EntityCategory.CONFIG, + supported_zone_types={ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER}, + supported_generations={GEN_CLASSIC}, + unique_id_suffix="refresh_timetable", + ), ] From beb215373483e4c89a96731c99974f77bfd4e02c Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:32:55 +0200 Subject: [PATCH 15/53] Add files via upload --- custom_components/tado_hijack/translations/de.json | 11 +++++++++++ custom_components/tado_hijack/translations/en.json | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/custom_components/tado_hijack/translations/de.json b/custom_components/tado_hijack/translations/de.json index 9fbca29..558f82f 100644 --- a/custom_components/tado_hijack/translations/de.json +++ b/custom_components/tado_hijack/translations/de.json @@ -282,6 +282,9 @@ }, "refresh_timetable": { "name": "Zeitplan aktualisieren" + }, + "refresh_all_timetables": { + "name": "Alle Zeitpläne aktualisieren" } }, "number": { @@ -345,6 +348,14 @@ "three_day": "Mo-Fr / Sa / So", "seven_day": "Pro Wochentag" } + }, + "timetable_type_all_zones": { + "name": "Zeitplantyp (alle Zonen)", + "state": { + "one_day": "Mo-So (gleich jeden Tag)", + "three_day": "Mo-Fr / Sa / So", + "seven_day": "Pro Wochentag" + } } }, "climate": { diff --git a/custom_components/tado_hijack/translations/en.json b/custom_components/tado_hijack/translations/en.json index 608bd7c..33a7edc 100644 --- a/custom_components/tado_hijack/translations/en.json +++ b/custom_components/tado_hijack/translations/en.json @@ -282,6 +282,9 @@ }, "refresh_timetable": { "name": "Refresh timetable" + }, + "refresh_all_timetables": { + "name": "Refresh all timetables" } }, "number": { @@ -345,6 +348,14 @@ "three_day": "Mon-Fri / Sat / Sun", "seven_day": "Per day of the week" } + }, + "timetable_type_all_zones": { + "name": "Timetable type (all zones)", + "state": { + "one_day": "Mon-Sun (same every day)", + "three_day": "Mon-Fri / Sat / Sun", + "seven_day": "Per day of the week" + } } }, "climate": { From d0306f3d42f02f2767c429d659d4ad8f3adb34fe Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 19 Apr 2026 09:47:13 +0200 Subject: [PATCH 16/53] Add files via upload --- custom_components/tado_hijack/coordinator.py | 153 ++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 45ab98f..75d1739 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -194,7 +194,10 @@ def __init__( entry.data.get(CONF_REDUCED_POLLING_ACTIVE, False) ) - self.rate_limit = RateLimitManager(throttle_threshold, get_handler()) + self.rate_limit = RateLimitManager( + throttle_threshold, + self.provider.get_rate_limit_source() if self.provider else get_handler(), + ) self.auth_manager = AuthManager(hass, entry, client) self.property_manager = PropertyManager(self) @@ -266,9 +269,22 @@ async def async_setup(self) -> None: "Restored adaptive quota tracker state (history: %d)", self.reset_tracker.history_count, ) - # Without this, get_next_reset_time falls back to now+20h on restart. if last_reset := self.reset_tracker.get_last_reset_original(): self._last_quota_reset = last_reset + self._schedule_reset_poll() + + timetable_data = await self.storage.async_get("timetable_cache") + if timetable_data: + restored = {int(k): v for k, v in timetable_data.items()} + self.data_manager.timetable_cache.update(restored) + self._timetable_cache_initialized = True + _LOGGER.debug( + "Restored timetable cache for %d zone(s): %s", + len(restored), + restored, + ) + else: + self._timetable_cache_initialized = False def _save_reset_tracker(self) -> None: """Persist reset tracker state to storage.""" @@ -373,6 +389,10 @@ async def _async_update_data(self) -> TadoData: self.zones_meta = self.data_manager.zones_meta self.devices_meta = self.data_manager.devices_meta + self.timetable_cache: dict[int, dict] = self.data_manager.timetable_cache + + if self.generation == GEN_CLASSIC and not self._timetable_cache_initialized: + await self._async_init_timetable_cache() from .helpers.discovery import get_bridges @@ -1103,6 +1123,135 @@ async def async_set_early_start(self, zone_id: int, enabled: bool) -> None: rollback_context=old_val, ) + def _save_timetable_cache(self) -> None: + """Persist timetable cache to storage.""" + self.hass.async_create_task( + self.storage.async_update( + "timetable_cache", + {str(k): v for k, v in self.timetable_cache.items()}, + ) + ) + + async def _async_init_timetable_cache(self) -> None: + """Fetch timetable type from API for all compatible zones on first boot.""" + from .helpers.zone_utils import get_zone_type + + zone_ids = [ + zone_id + for zone_id, zone in self.zones_meta.items() + if get_zone_type(zone) in (ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER) + ] + + if not zone_ids: + self._timetable_cache_initialized = True + return + + _LOGGER.info( + "Initializing timetable cache from API for %d zone(s): %s", + len(zone_ids), zone_ids, + ) + for zone_id in zone_ids: + try: + entry = await self._tado.get_active_timetable(zone_id) + self.timetable_cache[zone_id] = entry + self.data_manager.timetable_cache[zone_id] = entry + _LOGGER.debug("Timetable initialized for zone %s: %s", zone_id, entry) + except Exception as err: + _LOGGER.warning( + "Could not fetch timetable for zone %s: %s", zone_id, err + ) + + self._timetable_cache_initialized = True + self._save_timetable_cache() + + async def async_set_timetable(self, zone_id: int, timetable_type: str) -> None: + """Set the active timetable schedule type for a zone. + + timetable_type: "ONE_DAY" | "THREE_DAY" | "SEVEN_DAY" + Goes through the command queue (1 API call, debounced & merged). + """ + type_to_id = {"ONE_DAY": 0, "THREE_DAY": 1, "SEVEN_DAY": 2} + timetable_id = type_to_id.get(timetable_type) + if timetable_id is None: + _LOGGER.error("Invalid timetable type: %s", timetable_type) + return + + # Optimistically update local cache for immediate UI feedback + entry = {"id": timetable_id, "type": timetable_type} + self.timetable_cache[zone_id] = entry + self.data_manager.timetable_cache[zone_id] = entry + self.async_update_listeners() + self._save_timetable_cache() + + self.api_manager.queue_command( + f"set_timetable_{zone_id}", + TadoCommand( + CommandType.SET_TIMETABLE, + zone_id=zone_id, + data={"zone_id": zone_id, "timetable_id": timetable_id}, + ), + ) + + async def async_refresh_timetable(self, zone_id: int) -> None: + """Refresh the active timetable for a zone from the API.""" + _LOGGER.info("Refreshing timetable for zone %s", zone_id) + try: + entry = await self._tado.get_active_timetable(zone_id) + self.timetable_cache[zone_id] = entry + self.data_manager.timetable_cache[zone_id] = entry + self.async_update_listeners() + self._save_timetable_cache() + _LOGGER.debug("Timetable refreshed for zone %s: %s", zone_id, entry) + except Exception as err: + _LOGGER.error("Failed to refresh timetable for zone %s: %s", zone_id, err) + + async def async_refresh_all_timetables(self) -> None: + """Refresh the active timetable for all compatible zones (HEATING + HOT_WATER, GEN_CLASSIC only).""" + if self.generation != GEN_CLASSIC: + _LOGGER.debug("async_refresh_all_timetables: skipped (not GEN_CLASSIC)") + return + + from .helpers.zone_utils import get_zone_type + + zone_ids = [ + zone_id + for zone_id, zone in self.zones_meta.items() + if get_zone_type(zone) in (ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER) + ] + + if not zone_ids: + _LOGGER.debug("async_refresh_all_timetables: no compatible zones found") + return + + _LOGGER.info("Refreshing timetables for %d zone(s): %s", len(zone_ids), zone_ids) + for zone_id in zone_ids: + await self.async_refresh_timetable(zone_id) + + async def async_set_timetable_all_zones(self, timetable_type: str) -> None: + """Set the timetable type for all compatible zones (HEATING + HOT_WATER, GEN_CLASSIC only).""" + if self.generation != GEN_CLASSIC: + _LOGGER.debug("async_set_timetable_all_zones: skipped (not GEN_CLASSIC)") + return + + from .helpers.zone_utils import get_zone_type + + zone_ids = [ + zone_id + for zone_id, zone in self.zones_meta.items() + if get_zone_type(zone) in (ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER) + ] + + if not zone_ids: + _LOGGER.debug("async_set_timetable_all_zones: no compatible zones found") + return + + _LOGGER.info( + "Setting timetable type '%s' for %d zone(s): %s", + timetable_type, len(zone_ids), zone_ids, + ) + for zone_id in zone_ids: + await self.async_set_timetable(zone_id, timetable_type) + async def async_set_open_window_detection( self, zone_id: int, enabled: bool, timeout_seconds: int | None = None ) -> None: From daa32e38dc29a099d63b73d2d4ae87d4f274b426 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:48:53 +0200 Subject: [PATCH 17/53] Update custom_components/tado_hijack/coordinator.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/tado_hijack/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 1627033..69560c9 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1122,7 +1122,7 @@ def _save_timetable_cache(self) -> None: self.hass.async_create_task( self.storage.async_update( "timetable_cache", - {str(k): v for k, v in self.timetable_cache.items()}, + {str(k): v for k, v in self.data_manager.timetable_cache.items()}, ) ) From 3dec745e008c6aaff2625a1c0dc3ae9aa635e41c Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:50:21 +0200 Subject: [PATCH 18/53] Update custom_components/tado_hijack/coordinator.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/tado_hijack/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 69560c9..927160c 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1138,9 +1138,9 @@ async def async_set_timetable(self, zone_id: int, timetable_type: str) -> None: _LOGGER.error("Invalid timetable type: %s", timetable_type) return + # Optimistically update local cache for immediate UI feedback # Optimistically update local cache for immediate UI feedback entry = {"id": timetable_id, "type": timetable_type} - self.timetable_cache[zone_id] = entry self.data_manager.timetable_cache[zone_id] = entry self.async_update_listeners() self._save_timetable_cache() From 39916546a5c60dc4019b6653e0b713beccb895b4 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:50:43 +0200 Subject: [PATCH 19/53] Update custom_components/tado_hijack/coordinator.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/tado_hijack/coordinator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 927160c..5d95cf2 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1159,7 +1159,6 @@ async def async_refresh_timetable(self, zone_id: int) -> None: _LOGGER.info("Refreshing timetable for zone %s", zone_id) try: entry = await self._tado.get_active_timetable(zone_id) - self.timetable_cache[zone_id] = entry self.data_manager.timetable_cache[zone_id] = entry self.async_update_listeners() self._save_timetable_cache() From 5c7f2d9b9ccdced7cc4020a513115ce8f2138834 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:54:34 +0200 Subject: [PATCH 20/53] Update custom_components/tado_hijack/definitions.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/tado_hijack/definitions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/tado_hijack/definitions.py b/custom_components/tado_hijack/definitions.py index 2b85e50..468a277 100644 --- a/custom_components/tado_hijack/definitions.py +++ b/custom_components/tado_hijack/definitions.py @@ -1902,8 +1902,8 @@ def _parse_home_zone_mode(c: Any) -> str | None: create_home_select( key="timetable_type_all_zones", value_fn=lambda c: ( - next(iter(c.timetable_cache.values()), {}).get("type", "ONE_DAY").lower() - if hasattr(c, "timetable_cache") and c.timetable_cache + next(iter(c.data_manager.timetable_cache.values()), {}).get("type", "ONE_DAY").lower() + if c.data_manager.timetable_cache else "one_day" ), options=["one_day", "three_day", "seven_day"], From 33d497ab0ee016c5bd2b4392e700ee9ad994f9a6 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:16:49 +0200 Subject: [PATCH 21/53] Update definitions.py line 1925 add data_manager --- custom_components/tado_hijack/definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tado_hijack/definitions.py b/custom_components/tado_hijack/definitions.py index 468a277..68b59e4 100644 --- a/custom_components/tado_hijack/definitions.py +++ b/custom_components/tado_hijack/definitions.py @@ -1923,7 +1923,7 @@ def _parse_home_zone_mode(c: Any) -> str | None: create_zone_select( key="timetable_type", value_fn=lambda c, zid: ( - c.timetable_cache.get(zid, {}).get("type", "ONE_DAY").lower() + c.data_manager.timetable_cache.get(zid, {}).get("type", "ONE_DAY").lower() if hasattr(c, "timetable_cache") else None ), From 2ecb8e384f072c56987070ef407d3767d2a64eba Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:36:34 +0200 Subject: [PATCH 22/53] Update coordinator.py DELETED DUPLICATE COMMENT LINE 1141 --- custom_components/tado_hijack/coordinator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 5d95cf2..3c5e5c0 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1138,7 +1138,6 @@ async def async_set_timetable(self, zone_id: int, timetable_type: str) -> None: _LOGGER.error("Invalid timetable type: %s", timetable_type) return - # Optimistically update local cache for immediate UI feedback # Optimistically update local cache for immediate UI feedback entry = {"id": timetable_id, "type": timetable_type} self.data_manager.timetable_cache[zone_id] = entry From 0db346add103cadd2deac07557285d285e7b093c Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:57:12 +0200 Subject: [PATCH 23/53] Update custom_components/tado_hijack/coordinator.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/tado_hijack/coordinator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 3c5e5c0..ea8ce58 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1184,8 +1184,9 @@ async def async_refresh_all_timetables(self) -> None: return _LOGGER.info("Refreshing timetables for %d zone(s): %s", len(zone_ids), zone_ids) - for zone_id in zone_ids: - await self.async_refresh_timetable(zone_id) + await asyncio.gather( + *(self.async_refresh_timetable(zone_id) for zone_id in zone_ids) + ) async def async_set_timetable_all_zones(self, timetable_type: str) -> None: """Set the timetable type for all compatible zones (HEATING + HOT_WATER, GEN_CLASSIC only).""" From be16ba6e494edfc22a2275c000e149f557c79cbf Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:57:24 +0200 Subject: [PATCH 24/53] Update custom_components/tado_hijack/coordinator.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- custom_components/tado_hijack/coordinator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index ea8ce58..fd0b9ad 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1210,8 +1210,9 @@ async def async_set_timetable_all_zones(self, timetable_type: str) -> None: "Setting timetable type '%s' for %d zone(s): %s", timetable_type, len(zone_ids), zone_ids, ) - for zone_id in zone_ids: - await self.async_set_timetable(zone_id, timetable_type) + await asyncio.gather( + *(self.async_set_timetable(zone_id, timetable_type) for zone_id in zone_ids) + ) async def async_set_open_window_detection( self, zone_id: int, enabled: bool, timeout_seconds: int | None = None From 50ff9a9cf0cbc4ed2841bc69ceaafa3b98271cba Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:52:54 +0200 Subject: [PATCH 25/53] fix(quota): prevent reset-poll runaway loop and sync timer on detected reset Replace max(1s, delay) in schedule_reset_poll with an explicit 24h fallback when delay is invalid, preventing a tight API-quota-burning loop on stale initial_target. Reschedule the reset poll immediately when a quota reset is detected via regular polling so the timer stays in sync without waiting for the next scheduled fire. Also refactor zone overlay/resume redundancy checks into dedicated helpers for clarity. --- custom_components/tado_hijack/coordinator.py | 1 + .../tado_hijack/helpers/poll_scheduler.py | 8 +- .../tado_hijack/helpers/redundancy_checker.py | 125 ++++++++++-------- 3 files changed, 77 insertions(+), 57 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 3700690..8956034 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -642,6 +642,7 @@ def _detect_quota_reset(self) -> None: self.reset_tracker.record_reset(reset_time) self._save_reset_tracker() + self._schedule_reset_poll() expected = self.reset_tracker.get_expected_window() _LOGGER.info( diff --git a/custom_components/tado_hijack/helpers/poll_scheduler.py b/custom_components/tado_hijack/helpers/poll_scheduler.py index c973e4d..a869d9a 100644 --- a/custom_components/tado_hijack/helpers/poll_scheduler.py +++ b/custom_components/tado_hijack/helpers/poll_scheduler.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant +from ..const import SECONDS_PER_DAY from .logging_utils import get_redacted_logger _LOGGER = get_redacted_logger(__name__) @@ -82,8 +83,13 @@ def schedule_reset_poll(self, delay_s: float, callback: AsyncCallback) -> None: """Schedule a one-shot timer that fires *callback* after *delay_s* seconds.""" if self._reset_poll_unsub: self._reset_poll_unsub.cancel() + if delay_s <= 0: + _LOGGER.warning( + "Reset poll delay invalid (%.1fs), falling back to 24h", delay_s + ) + delay_s = float(SECONDS_PER_DAY) self._reset_poll_unsub = self._hass.loop.call_later( - max(1.0, delay_s), + delay_s, lambda: self._hass.async_create_task(callback()), ) diff --git a/custom_components/tado_hijack/helpers/redundancy_checker.py b/custom_components/tado_hijack/helpers/redundancy_checker.py index 5fa9a5e..ec59f41 100644 --- a/custom_components/tado_hijack/helpers/redundancy_checker.py +++ b/custom_components/tado_hijack/helpers/redundancy_checker.py @@ -510,6 +510,47 @@ def should_skip_all_action_provider( return False +def _is_resume_redundant( + zone_id: int, zone_states: dict[str, Any], suppress_buttons: bool +) -> bool: + """Return True if a RESUME_SCHEDULE command is redundant.""" + if not suppress_buttons: + return False + state = zone_states.get(str(zone_id)) + return state is not None and not getattr(state, "overlay_active", True) + + +def _is_overlay_redundant( + zone_id: int, zone_data: dict[str, Any], zone_states: dict[str, Any] +) -> bool: + """Return True if a SET_OVERLAY command matches current device state.""" + state = zone_states.get(str(zone_id)) + if state is None or not getattr(state, "overlay_active", False): + return False + + setting = zone_data.get("setting", {}) + target_power = setting.get("power") + + api_setting = getattr(state, "setting", None) + cache_power = getattr(api_setting, "power", None) if api_setting else None + + if cache_power is None or target_power != cache_power: + return False + + if target_power == POWER_OFF: + return True + + target_temp = (setting.get("temperature") or {}).get("celsius") + if target_temp is None: + return False + + cache_temp_obj = getattr(api_setting, "temperature", None) + cache_temp = ( + getattr(cache_temp_obj, "celsius", None) if cache_temp_obj is not None else None + ) + return cache_temp is not None and abs(cache_temp - target_temp) < TEMP_TOLERANCE + + def _filter_zone_updates( merged: dict[str, Any], zone_states: dict[str, Any], @@ -521,65 +562,37 @@ def _filter_zone_updates( optimistic patching. coordinator.data.zone_states is mutated in-place by state_patcher before queuing, so it already reflects the target by batch time. """ - if zones := merged.get("zones", {}): - filtered_zones: dict[str, Any] = {} - for zone_id_str, zone_data in zones.items(): - zone_id = int(zone_id_str) - - if zone_data is None: - if suppress_buttons: - state = zone_states.get(str(zone_id)) - if state is not None and not getattr(state, "overlay_active", True): - _LOGGER.debug( - "Skipping redundant RESUME_SCHEDULE zone_%s: already in schedule", - zone_id, - ) - continue - filtered_zones[zone_id_str] = zone_data - continue + if not (zones := merged.get("zones", {})): + return merged - setting = zone_data.get("setting", {}) - target_power = setting.get("power") - target_temp = (setting.get("temperature") or {}).get("celsius") - - state = zone_states.get(str(zone_id)) - if state is None or not getattr(state, "overlay_active", False): - filtered_zones[zone_id_str] = zone_data - continue - - api_setting = getattr(state, "setting", None) - cache_power = getattr(api_setting, "power", None) if api_setting else None - - if cache_power is None or target_power != cache_power: - filtered_zones[zone_id_str] = zone_data - continue - - if target_power == POWER_OFF: - _LOGGER.debug("Skipping redundant zone_%s overlay: both OFF", zone_id) - continue - - if target_temp is not None: - cache_temp_obj = getattr(api_setting, "temperature", None) - cache_temp = ( - getattr(cache_temp_obj, "celsius", None) - if cache_temp_obj is not None - else None - ) - if ( - cache_temp is not None - and abs(cache_temp - target_temp) < TEMP_TOLERANCE - ): - _LOGGER.debug( - "Skipping redundant zone_%s overlay: power=%s, temp=%s", - zone_id, - target_power, - target_temp, - ) - continue + filtered_zones: dict[str, Any] = {} + for zone_id_str, zone_data in zones.items(): + zone_id = int(zone_id_str) + if zone_data is None and _is_resume_redundant( + zone_id, zone_states, suppress_buttons + ): + _LOGGER.debug( + "Skipping redundant RESUME_SCHEDULE zone_%s: already in schedule", + zone_id, + ) + elif ( + zone_data is None + and not _is_resume_redundant(zone_id, zone_states, suppress_buttons) + ) or ( + zone_data is not None + and not _is_overlay_redundant(zone_id, zone_data, zone_states) + ): filtered_zones[zone_id_str] = zone_data - - merged["zones"] = filtered_zones + else: + setting = zone_data.get("setting", {}) + _LOGGER.debug( + "Skipping redundant zone_%s overlay: power=%s, temp=%s", + zone_id, + setting.get("power"), + (setting.get("temperature") or {}).get("celsius"), + ) + merged["zones"] = filtered_zones return merged From 82e378b717ba3d2324173781afcc5b0b55998ffc Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 21 Apr 2026 15:54:32 +0000 Subject: [PATCH 26/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.5.0-dev.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.5.0-dev.2](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.1...v5.5.0-dev.2) (2026-04-21) ### 🐛 Bug Fixes * fix(quota): prevent reset-poll runaway loop and sync timer on detected reset Replace max(1s, delay) in schedule_reset_poll with an explicit 24h fallback when delay is invalid, preventing a tight API-quota-burning loop on stale initial_target. Reschedule the reset poll immediately when a quota reset is detected via regular polling so the timer stays in sync without waiting for the next scheduled fire. Also refactor zone overlay/resume redundancy checks into dedicated helpers for clarity. [skip ci] --- CHANGELOG.md | 13 +++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ca3f9..2328f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [5.5.0-dev.2](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.1...v5.5.0-dev.2) (2026-04-21) + +### 🐛 Bug Fixes + +* fix(quota): prevent reset-poll runaway loop and sync timer on detected reset + +Replace max(1s, delay) in schedule_reset_poll with an explicit 24h fallback +when delay is invalid, preventing a tight API-quota-burning loop on stale +initial_target. Reschedule the reset poll immediately when a quota reset is +detected via regular polling so the timer stays in sync without waiting for +the next scheduled fire. Also refactor zone overlay/resume redundancy checks +into dedicated helpers for clarity. + ## [5.5.0-dev.1](https://github.com/banter240/tado_hijack/compare/v5.4.1-dev.2...v5.5.0-dev.1) (2026-04-17) ### ✨ New Features diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index b667520..15ec6da 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.5.0-dev.1" + "version": "5.5.0-dev.2" } From bb26e8d0573a2e1ca144d62f6263bb5049912536 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:41:27 +0200 Subject: [PATCH 27/53] fix(ac): populate mode, fanSpeed and swing for AC power-on overlays - add _ensure_ac_setting_fields coordinator helper: fetches cached capabilities and auto-populates fanSpeed/Level and swing fields when additional_setting_fields is not explicitly provided; called from async_set_zone_overlay and per-zone from async_set_multiple_zone_overlays - fix _execute_set_mode in services.py: map operation_mode to ac_mode (HEAT/COOL/DRY/FAN) and forward to async_set_multiple_zone_overlays; mode field was never set for this service path - single-toggle swing fallback: use current state, last-seen cache, then "OFF" (tadoasync does not expose swings in mode capabilities) Resolves 422 errors for AC zones in standby that were missing mode, fanSpeed and swing fields in overlay payloads. Closes #98 --- custom_components/tado_hijack/coordinator.py | 89 ++++++++++++++++++-- custom_components/tado_hijack/services.py | 2 + 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 8956034..beb437b 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -101,6 +101,7 @@ from .helpers.state_patcher import patch_zone_overlay, patch_zone_resume from .helpers.storage import TadoStorage from .helpers.utils import apply_jitter +from .helpers.zone_utils import get_zone_type from .lib.patches import get_handler from .models import CommandType, RateLimit, TadoCommand, TadoData @@ -250,6 +251,7 @@ def __init__( self._last_quota_reset: datetime | None = None self._last_remaining: int | None = None self._force_next_update: bool = False + self._last_ac_swing: dict[int, str] = {} # Adaptive quota reset window learning self.reset_tracker = ResetWindowTracker() @@ -821,7 +823,6 @@ async def async_set_zone_auto( self._execute_resume_command(zone_id) # Trigger refresh only for AC and Hot Water zones (TRVs are excluded) - from .helpers.zone_utils import get_zone_type zone = self.zones_meta.get(zone_id) ztype = get_zone_type(zone, None) @@ -1187,8 +1188,6 @@ def supports_temperature(self, zone_id: int) -> bool: if capabilities.temperatures exists. If the API later rejects with 422, that's a real error that should be logged. """ - from .helpers.zone_utils import get_zone_type - zone = self.zones_meta.get(zone_id) ztype = get_zone_type(zone) @@ -1219,8 +1218,6 @@ def _resolve_zone_temperature( return None # Fallback to zone-type defaults for power ON - from .helpers.zone_utils import get_zone_type - zone = self.zones_meta.get(zone_id) ztype = get_zone_type(zone) @@ -1230,6 +1227,73 @@ def _resolve_zone_temperature( return TEMP_DEFAULT_AC return TEMP_DEFAULT_HEATING + async def _ensure_ac_setting_fields( + self, zone_id: int, ac_mode: str | None, power: str + ) -> dict[str, Any]: + """Return fan/swing fields required for an AC power-on overlay. + + tadoasync does not expose single-toggle swing in mode capabilities, + so for single-swing zones in standby we fall back to the last observed + value or "OFF" as a safe default. + """ + if power != POWER_ON: + return {} + + if get_zone_type(self.zones_meta.get(zone_id)) != ZONE_TYPE_AIR_CONDITIONING: + return {} + + capabilities = await self.async_get_capabilities(zone_id) + if not capabilities: + return {} + + mode_key = (ac_mode or "HEAT").lower() + mode_caps = getattr(capabilities, mode_key, None) + if not mode_caps: + return {} + + state = self.data.zone_states.get(str(zone_id)) + setting = getattr(state, "setting", None) if state else None + + # Cache any swing value seen in current state + if (swing_now := getattr(setting, "swing", None)) is not None: + self._last_ac_swing[zone_id] = swing_now + + fields: dict[str, Any] = {} + + if fan_speeds := getattr(mode_caps, "fan_speeds", None): + current = getattr(setting, "fan_speed", None) + value = current if current in fan_speeds else fan_speeds[0] + fields["fanSpeed"] = str(value).upper() + elif fan_levels := getattr(mode_caps, "fan_level", None): + current = getattr(setting, "fan_level", None) + value = current if current in fan_levels else fan_levels[0] + fields["fanLevel"] = str(value).upper() + + has_axis_swing = False + for cap_attr, api_key, state_attr in [ + ("vertical_swing", "verticalSwing", "vertical_swing"), + ("horizontal_swing", "horizontalSwing", "horizontal_swing"), + ]: + if swing_caps := getattr(mode_caps, cap_attr, None): + has_axis_swing = True + current = getattr(setting, state_attr, None) + value = ( + current + if current in swing_caps + else ("OFF" if "OFF" in swing_caps else swing_caps[0]) + ) + fields[api_key] = str(value).upper() + + if not has_axis_swing: + # tadoasync does not expose single-toggle swing in mode capabilities; + # use cached/current value, or "OFF" for standby zones with no prior state. + swing_val = getattr(setting, "swing", None) or self._last_ac_swing.get( + zone_id + ) + fields["swing"] = str(swing_val).upper() if swing_val else "OFF" + + return fields + async def async_set_zone_overlay( self, zone_id: int, @@ -1246,6 +1310,11 @@ async def async_set_zone_overlay( """Set a manual overlay with timer/duration support.""" final_temp = self._resolve_zone_temperature(zone_id, temperature, power) + if additional_setting_fields is None: + ac_fields = await self._ensure_ac_setting_fields(zone_id, ac_mode, power) + if ac_fields: + additional_setting_fields = ac_fields + data = build_overlay_data( zone_id=zone_id, zones_meta=self.zones_meta, @@ -1317,6 +1386,14 @@ async def async_set_multiple_zone_overlays( for zone_id in zone_ids: zone_temp = self._resolve_zone_temperature(zone_id, temperature, power) + zone_additional = additional_setting_fields + if zone_additional is None: + ac_fields = await self._ensure_ac_setting_fields( + zone_id, ac_mode, power + ) + if ac_fields: + zone_additional = ac_fields + data = build_overlay_data( zone_id=zone_id, zones_meta=self.zones_meta, @@ -1327,7 +1404,7 @@ async def async_set_multiple_zone_overlays( overlay_type=overlay_type, ac_mode=ac_mode, supports_temp=self.supports_temperature(zone_id), - additional_setting_fields=additional_setting_fields, + additional_setting_fields=zone_additional, ) old_state = patch_zone_overlay( diff --git a/custom_components/tado_hijack/services.py b/custom_components/tado_hijack/services.py index 6f440d2..ff71638 100644 --- a/custom_components/tado_hijack/services.py +++ b/custom_components/tado_hijack/services.py @@ -344,6 +344,7 @@ async def _execute_set_mode( return power = POWER_OFF if operation_mode == "off" else POWER_ON + ac_mode = operation_mode.upper() if operation_mode not in ("off", "auto") else None if not overlay_mode and not duration: overlay_mode = OVERLAY_MANUAL @@ -355,6 +356,7 @@ async def _execute_set_mode( duration=duration, overlay_mode=overlay_mode, overlay_type=overlay_type, + ac_mode=ac_mode, refresh_after=refresh_after, ) From 60320fcd6b88142e7273fdf13a0a34da5f3a62eb Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 30 Apr 2026 14:24:19 +0000 Subject: [PATCH 28/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.5.0-dev.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.5.0-dev.3](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.2...v5.5.0-dev.3) (2026-04-30) ### 🐛 Bug Fixes * fix(ac): populate mode, fanSpeed and swing for AC power-on overlays - add _ensure_ac_setting_fields coordinator helper: fetches cached capabilities and auto-populates fanSpeed/Level and swing fields when additional_setting_fields is not explicitly provided; called from async_set_zone_overlay and per-zone from async_set_multiple_zone_overlays - fix _execute_set_mode in services.py: map operation_mode to ac_mode (HEAT/COOL/DRY/FAN) and forward to async_set_multiple_zone_overlays; mode field was never set for this service path - single-toggle swing fallback: use current state, last-seen cache, then "OFF" (tadoasync does not expose swings in mode capabilities) Resolves 422 errors for AC zones in standby that were missing mode, fanSpeed and swing fields in overlay payloads. [skip ci] --- CHANGELOG.md | 21 +++++++++++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2328f97..d2cc9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## [5.5.0-dev.3](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.2...v5.5.0-dev.3) (2026-04-30) + +### 🐛 Bug Fixes + +* fix(ac): populate mode, fanSpeed and swing for AC power-on overlays + +- add _ensure_ac_setting_fields coordinator helper: fetches cached + capabilities and auto-populates fanSpeed/Level and swing fields + when additional_setting_fields is not explicitly provided; called + from async_set_zone_overlay and per-zone from async_set_multiple_zone_overlays + +- fix _execute_set_mode in services.py: map operation_mode to ac_mode + (HEAT/COOL/DRY/FAN) and forward to async_set_multiple_zone_overlays; + mode field was never set for this service path + +- single-toggle swing fallback: use current state, last-seen cache, + then "OFF" (tadoasync does not expose swings in mode capabilities) + +Resolves 422 errors for AC zones in standby that were missing mode, +fanSpeed and swing fields in overlay payloads. + ## [5.5.0-dev.2](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.1...v5.5.0-dev.2) (2026-04-21) ### 🐛 Bug Fixes diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index 15ec6da..eda07f2 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.5.0-dev.2" + "version": "5.5.0-dev.3" } From 24ecef43c2044fed50cace955b0c2a29d1fc1228 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Wed, 13 May 2026 16:45:04 +0200 Subject: [PATCH 29/53] fix(ac): add light field to AC power-on overlay payloads - extract _build_ac_fan_fields, _build_ac_swing_fields and _build_ac_light_fields from _ensure_ac_setting_fields to reduce function complexity and satisfy Sourcery quality check - _build_ac_light_fields: include light field when the mode capability exposes it; defaults to "OFF" if present in the allowed values, otherwise first listed value Resolves 422 errors for AC zones whose capabilities require a light field in the overlay setting (e.g. "light not in supported light [OFF, ON]"). Refs #98 --- custom_components/tado_hijack/coordinator.py | 94 ++++++++++++-------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index beb437b..b8c6af2 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1227,48 +1227,29 @@ def _resolve_zone_temperature( return TEMP_DEFAULT_AC return TEMP_DEFAULT_HEATING - async def _ensure_ac_setting_fields( - self, zone_id: int, ac_mode: str | None, power: str - ) -> dict[str, Any]: - """Return fan/swing fields required for an AC power-on overlay. - - tadoasync does not expose single-toggle swing in mode capabilities, - so for single-swing zones in standby we fall back to the last observed - value or "OFF" as a safe default. - """ - if power != POWER_ON: - return {} - - if get_zone_type(self.zones_meta.get(zone_id)) != ZONE_TYPE_AIR_CONDITIONING: - return {} - - capabilities = await self.async_get_capabilities(zone_id) - if not capabilities: - return {} - - mode_key = (ac_mode or "HEAT").lower() - mode_caps = getattr(capabilities, mode_key, None) - if not mode_caps: - return {} - - state = self.data.zone_states.get(str(zone_id)) - setting = getattr(state, "setting", None) if state else None - - # Cache any swing value seen in current state - if (swing_now := getattr(setting, "swing", None)) is not None: - self._last_ac_swing[zone_id] = swing_now - - fields: dict[str, Any] = {} - + @staticmethod + def _build_ac_fan_fields(mode_caps: Any, setting: Any) -> dict[str, Any]: + """Return fanSpeed or fanLevel field for an AC overlay.""" if fan_speeds := getattr(mode_caps, "fan_speeds", None): current = getattr(setting, "fan_speed", None) value = current if current in fan_speeds else fan_speeds[0] - fields["fanSpeed"] = str(value).upper() - elif fan_levels := getattr(mode_caps, "fan_level", None): + return {"fanSpeed": str(value).upper()} + if fan_levels := getattr(mode_caps, "fan_level", None): current = getattr(setting, "fan_level", None) value = current if current in fan_levels else fan_levels[0] - fields["fanLevel"] = str(value).upper() + return {"fanLevel": str(value).upper()} + return {} + def _build_ac_swing_fields( + self, zone_id: int, mode_caps: Any, setting: Any + ) -> dict[str, Any]: + """Return swing fields for an AC overlay. + + Prefers axis swing (verticalSwing/horizontalSwing) when exposed by capabilities. + Falls back to single-toggle swing — using the last cached value or "OFF" — + because tadoasync does not parse single-toggle swing from mode capabilities. + """ + fields: dict[str, Any] = {} has_axis_swing = False for cap_attr, api_key, state_attr in [ ("vertical_swing", "verticalSwing", "vertical_swing"), @@ -1285,8 +1266,6 @@ async def _ensure_ac_setting_fields( fields[api_key] = str(value).upper() if not has_axis_swing: - # tadoasync does not expose single-toggle swing in mode capabilities; - # use cached/current value, or "OFF" for standby zones with no prior state. swing_val = getattr(setting, "swing", None) or self._last_ac_swing.get( zone_id ) @@ -1294,6 +1273,45 @@ async def _ensure_ac_setting_fields( return fields + @staticmethod + def _build_ac_light_fields(mode_caps: Any) -> dict[str, Any]: + """Return light field for an AC overlay, defaulting to OFF.""" + if light_caps := getattr(mode_caps, "light", None): + return { + "light": "OFF" if "OFF" in light_caps else str(light_caps[0]).upper() + } + return {} + + async def _ensure_ac_setting_fields( + self, zone_id: int, ac_mode: str | None, power: str + ) -> dict[str, Any]: + """Return all AC-required fields for a power-on overlay.""" + if power != POWER_ON: + return {} + if get_zone_type(self.zones_meta.get(zone_id)) != ZONE_TYPE_AIR_CONDITIONING: + return {} + + capabilities = await self.async_get_capabilities(zone_id) + if not capabilities: + return {} + + mode_key = (ac_mode or "HEAT").lower() + mode_caps = getattr(capabilities, mode_key, None) + if not mode_caps: + return {} + + state = self.data.zone_states.get(str(zone_id)) + setting = getattr(state, "setting", None) if state else None + + if (swing_now := getattr(setting, "swing", None)) is not None: + self._last_ac_swing[zone_id] = swing_now + + return ( + self._build_ac_fan_fields(mode_caps, setting) + | self._build_ac_swing_fields(zone_id, mode_caps, setting) + | self._build_ac_light_fields(mode_caps) + ) + async def async_set_zone_overlay( self, zone_id: int, From b28cebea56da3ba81bf78988acbf268eaa1df284 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 13 May 2026 14:46:19 +0000 Subject: [PATCH 30/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.5.0-dev.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.5.0-dev.4](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.3...v5.5.0-dev.4) (2026-05-13) ### 🐛 Bug Fixes * fix(ac): add light field to AC power-on overlay payloads - extract _build_ac_fan_fields, _build_ac_swing_fields and _build_ac_light_fields from _ensure_ac_setting_fields to reduce function complexity and satisfy Sourcery quality check - _build_ac_light_fields: include light field when the mode capability exposes it; defaults to "OFF" if present in the allowed values, otherwise first listed value Resolves 422 errors for AC zones whose capabilities require a light field in the overlay setting (e.g. "light not in supported light [OFF, ON]"). [skip ci] --- CHANGELOG.md | 18 ++++++++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2cc9ec..fdb5b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## [5.5.0-dev.4](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.3...v5.5.0-dev.4) (2026-05-13) + +### 🐛 Bug Fixes + +* fix(ac): add light field to AC power-on overlay payloads + +- extract _build_ac_fan_fields, _build_ac_swing_fields and + _build_ac_light_fields from _ensure_ac_setting_fields to + reduce function complexity and satisfy Sourcery quality check + +- _build_ac_light_fields: include light field when the mode + capability exposes it; defaults to "OFF" if present in the + allowed values, otherwise first listed value + +Resolves 422 errors for AC zones whose capabilities require a +light field in the overlay setting (e.g. "light not in supported +light [OFF, ON]"). + ## [5.5.0-dev.3](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.2...v5.5.0-dev.3) (2026-04-30) ### 🐛 Bug Fixes diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index eda07f2..a38078c 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.5.0-dev.3" + "version": "5.5.0-dev.4" } From 084e8e723d77ba59344674679ef3e10d97e618c4 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Fri, 15 May 2026 19:29:07 +0200 Subject: [PATCH 31/53] fix(device-linker): guard against non-string manufacturer in device registry - add _is_tado_device() helper that checks isinstance before .lower(), centralising the Tado manufacturer check (DRY) - use _is_tado_device() in _build_device_cache() and get_climate_entity_id() replacing the bare device.manufacturer.lower() calls that raised AttributeError when HA stores manufacturer as int - fix .gitignore to match dev/local as symlinks (trailing slash only matches real directories) Supersedes #103 Closes #102 --- .gitignore | 4 ++-- .../tado_hijack/helpers/device_linker.py | 19 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index d4f4cfe..b891472 100755 --- a/.gitignore +++ b/.gitignore @@ -181,5 +181,5 @@ GEMINI.md testing_config/ # AI Workspace (Vibe/Claude/Gemini) -dev/ -local/ +dev +local diff --git a/custom_components/tado_hijack/helpers/device_linker.py b/custom_components/tado_hijack/helpers/device_linker.py index 2dd3f40..1702627 100644 --- a/custom_components/tado_hijack/helpers/device_linker.py +++ b/custom_components/tado_hijack/helpers/device_linker.py @@ -17,6 +17,13 @@ _cache_built = False +def _is_tado_device(device: dr.DeviceEntry) -> bool: + """Return True if device.manufacturer identifies a Tado device.""" + return ( + isinstance(device.manufacturer, str) and "tado" in device.manufacturer.lower() + ) + + def invalidate_cache() -> None: """Invalidate the device cache, forcing rebuild on next access.""" global _cache_built @@ -35,11 +42,7 @@ def _build_device_cache(hass: HomeAssistant, force: bool = False) -> None: _device_cache.clear() for device in registry.devices.values(): - if ( - device.manufacturer - and "tado" in device.manufacturer.lower() - and device.serial_number - ): + if _is_tado_device(device) and device.serial_number: _device_cache[device.serial_number] = cast( set[tuple[str, str]], device.identifiers ) @@ -86,11 +89,7 @@ def get_climate_entity_id(hass: HomeAssistant, serial_no: str) -> str | None: ( device for device in d_registry.devices.values() - if ( - device.manufacturer - and "tado" in device.manufacturer.lower() - and device.serial_number == serial_no - ) + if _is_tado_device(device) and device.serial_number == serial_no ), None, ) From e253ea3212890466edf3556d5fd83255021cc916 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 15 May 2026 17:30:19 +0000 Subject: [PATCH 32/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.5.0-dev.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.5.0-dev.5](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.4...v5.5.0-dev.5) (2026-05-15) ### 🐛 Bug Fixes * fix(device-linker): guard against non-string manufacturer in device registry - add _is_tado_device() helper that checks isinstance before .lower(), centralising the Tado manufacturer check (DRY) - use _is_tado_device() in _build_device_cache() and get_climate_entity_id() replacing the bare device.manufacturer.lower() calls that raised AttributeError when HA stores manufacturer as int - fix .gitignore to match dev/local as symlinks (trailing slash only matches real directories) [skip ci] --- CHANGELOG.md | 14 ++++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb5b6b..06b0cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [5.5.0-dev.5](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.4...v5.5.0-dev.5) (2026-05-15) + +### 🐛 Bug Fixes + +* fix(device-linker): guard against non-string manufacturer in device registry + +- add _is_tado_device() helper that checks isinstance before .lower(), + centralising the Tado manufacturer check (DRY) +- use _is_tado_device() in _build_device_cache() and get_climate_entity_id() + replacing the bare device.manufacturer.lower() calls that raised + AttributeError when HA stores manufacturer as int +- fix .gitignore to match dev/local as symlinks (trailing slash only + matches real directories) + ## [5.5.0-dev.4](https://github.com/banter240/tado_hijack/compare/v5.5.0-dev.3...v5.5.0-dev.4) (2026-05-13) ### 🐛 Bug Fixes diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index a38078c..0c95631 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.5.0-dev.4" + "version": "5.5.0-dev.5" } From c588c3575b989a0553b17716f1ac82f1edcfefa4 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Sun, 31 May 2026 16:26:39 +0200 Subject: [PATCH 33/53] fix(ac): include required 'light' field for AIR_CONDITIONING overlays on supported devices Some V3 AC units declare a "light" capability per operating mode. Sending overlays without this field resulted in 422 "setting.notSupported" errors from the Tado API when changing fan speed, swing or mode. We now populate the light field from capabilities (when present) in TadoV3ActionProvider, following the same approach used for fan and swing fields. The FAN mode path in the climate entity is updated accordingly. Fixes #105. --- custom_components/tado_hijack/climate_entity.py | 10 ++++++++++ .../helpers/tadov3/action_provider.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/custom_components/tado_hijack/climate_entity.py b/custom_components/tado_hijack/climate_entity.py index cbbcf71..96f9fd2 100644 --- a/custom_components/tado_hijack/climate_entity.py +++ b/custom_components/tado_hijack/climate_entity.py @@ -553,6 +553,16 @@ async def _async_set_fan_only_mode(self) -> None: ) additional_fields[api_key] = str(swing_value).upper() + # Build light setting when supported (carry over from current state) + if light_caps := getattr(fan_mode_caps, "light", None): + current = getattr(state.setting, "light", None) if state else None + if current and current in light_caps: + additional_fields["light"] = str(current).upper() + else: + additional_fields["light"] = ( + "OFF" if "OFF" in light_caps else str(light_caps[0]).upper() + ) + await self.tado_coordinator.async_set_zone_overlay( zone_id=self._zone_id, power=POWER_ON, diff --git a/custom_components/tado_hijack/helpers/tadov3/action_provider.py b/custom_components/tado_hijack/helpers/tadov3/action_provider.py index 5977c46..7dcf70c 100644 --- a/custom_components/tado_hijack/helpers/tadov3/action_provider.py +++ b/custom_components/tado_hijack/helpers/tadov3/action_provider.py @@ -181,6 +181,7 @@ async def async_set_ac_setting(self, zone_id: int, key: str, value: str) -> None additional_fields |= self._build_ac_swing_settings( mode_caps, key, value, state, zone_id ) + additional_fields |= self._build_ac_light_settings(mode_caps, state) data = build_overlay_data( zone_id, @@ -293,6 +294,21 @@ def _build_ac_swing_settings( return fields + def _build_ac_light_settings(self, mode_caps: Any, state: Any) -> dict[str, str]: + """Extract AC light setting based on capabilities and current state.""" + if light_caps := getattr(mode_caps, "light", None): + current = ( + getattr(state.setting, "light", None) + if getattr(state, "setting", None) + else None + ) + if current and current in light_caps: + val = str(current).upper() + else: + val = "OFF" if "OFF" in light_caps else str(light_caps[0]).upper() + return {"light": val} + return {} + async def async_set_temperature_offset(self, serial_no: str, offset: float) -> None: """Set temperature offset for a v3 device.""" old_val = self.coordinator.data_manager.offsets_cache.get(serial_no) From b93f5b392ffbe39f18d5a5ffe44ba398e2f876b6 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 31 May 2026 14:38:23 +0000 Subject: [PATCH 34/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.6.1-dev.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.6.1-dev.1](https://github.com/banter240/tado_hijack/compare/v5.6.0...v5.6.1-dev.1) (2026-05-31) ### 🐛 Bug Fixes * fix(ac): include required 'light' field for AIR_CONDITIONING overlays on supported devices Some V3 AC units declare a "light" capability per operating mode. Sending overlays without this field resulted in 422 "setting.notSupported" errors from the Tado API when changing fan speed, swing or mode. We now populate the light field from capabilities (when present) in TadoV3ActionProvider, following the same approach used for fan and swing fields. The FAN mode path in the climate entity is updated accordingly. Fixes #105. [skip ci] --- CHANGELOG.md | 16 ++++++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3fcbb..9725e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## [5.6.1-dev.1](https://github.com/banter240/tado_hijack/compare/v5.6.0...v5.6.1-dev.1) (2026-05-31) + +### 🐛 Bug Fixes + +* fix(ac): include required 'light' field for AIR_CONDITIONING overlays on supported devices + +Some V3 AC units declare a "light" capability per operating mode. Sending +overlays without this field resulted in 422 "setting.notSupported" errors +from the Tado API when changing fan speed, swing or mode. + +We now populate the light field from capabilities (when present) in +TadoV3ActionProvider, following the same approach used for fan and swing +fields. The FAN mode path in the climate entity is updated accordingly. + +Fixes #105. + ## [5.6.0](https://github.com/banter240/tado_hijack/compare/v5.5.0...v5.6.0) (2026-05-19) ### ✨ New Features diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index aa5496b..cd6fbb1 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.6.0" + "version": "5.6.1-dev.1" } From ca2c86a76a1995d1bc79a8f1335509e4b6cd2776 Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Sun, 31 May 2026 14:38:23 +0000 Subject: [PATCH 35/53] feat(tadox): add Tado X hot water support (auto/off only via Hops) with central guards Adds support for the domesticHotWater programmer on Tado X devices using the Hops endpoints (resumeSchedule + boost for forced off). - Hot water exposed as virtual zone with reserved high ID (9001) - TadoHotWaterX entity limited to auto/off (no temperature control) - Safe fetching with 404 caching - Dedicated coordinator paths with optimistic updates - All set hot water operations correctly detect and route TadoX (9001 + test dummy 9997) - Central redundancy_checker for hot water (v3 + TadoX through same guards) - Central overlay_validator for TadoX hot water Hops calls - _is_tadox_hot_water_zone helper for consistent routing Hops programmer paths are fundamentally different from v3 overlays, so all operations go through the dedicated endpoints and central guard infrastructure. --- .../tado_hijack/climate_entity.py | 7 +- custom_components/tado_hijack/const.py | 16 +- custom_components/tado_hijack/coordinator.py | 146 +++++++++++++++++- custom_components/tado_hijack/dummy/const.py | 8 - .../tado_hijack/dummy/dummy_handler.py | 72 ++++++++- .../tado_hijack/helpers/overlay_validator.py | 13 ++ .../tado_hijack/helpers/redundancy_checker.py | 26 ++++ .../tado_hijack/helpers/tadox/mapper.py | 36 ++++- .../tado_hijack/lib/tadox_api.py | 38 ++++- .../tado_hijack/lib/tadox_models.py | 23 +++ custom_components/tado_hijack/services.py | 23 +++ .../tado_hijack/translations/cs.json | 2 +- custom_components/tado_hijack/water_heater.py | 79 +++++++++- 13 files changed, 455 insertions(+), 34 deletions(-) delete mode 100644 custom_components/tado_hijack/dummy/const.py diff --git a/custom_components/tado_hijack/climate_entity.py b/custom_components/tado_hijack/climate_entity.py index 96f9fd2..7965283 100644 --- a/custom_components/tado_hijack/climate_entity.py +++ b/custom_components/tado_hijack/climate_entity.py @@ -14,6 +14,8 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from .const import ( + DUMMY_ZONE_ID_AC, + DUMMY_ZONE_ID_HOT_WATER, GEN_X, OVERLAY_MANUAL, POWER_OFF, @@ -23,7 +25,6 @@ TEMP_MIN_AC, TEMP_STEP_AC, ) -from .dummy.const import DUMMY_ZONE_ID_AC, DUMMY_ZONE_ID_HOT_WATER from .entity import TadoOptimisticMixin, TadoStateMemoryMixin, TadoZoneEntity from .helpers.logging_utils import get_redacted_logger from .helpers.parsers import ( @@ -219,7 +220,7 @@ def current_temperature(self) -> float | None: temp = getattr(temp_obj, "celsius", getattr(temp_obj, "value", None)) if temp is not None: result = float(temp) - # Only log for real zones, skip dummy zones (998, 999) + # Only log for real zones, skip dummy zones if self._zone_id not in (DUMMY_ZONE_ID_AC, DUMMY_ZONE_ID_HOT_WATER): _LOGGER.debug( "Zone %d current_temperature: %s (from inside_temperature)", @@ -249,7 +250,7 @@ def target_temperature(self) -> float | None: if state and state.setting and state.setting.temperature: if temp := getattr(state.setting.temperature, "celsius", None): result = float(temp) - # Only log for real zones, skip dummy zones (998, 999) + # Only log for real zones, skip dummy zones if self._zone_id not in (DUMMY_ZONE_ID_AC, DUMMY_ZONE_ID_HOT_WATER): _LOGGER.debug( "Zone %d target_temperature: %s (min=%s, max=%s, step=%s)", diff --git a/custom_components/tado_hijack/const.py b/custom_components/tado_hijack/const.py index bb3ee34..6701b4d 100644 --- a/custom_components/tado_hijack/const.py +++ b/custom_components/tado_hijack/const.py @@ -140,6 +140,21 @@ ZONE_TYPE_HOT_WATER: Final = "HOT_WATER" ZONE_TYPE_AIR_CONDITIONING: Final = "AIR_CONDITIONING" +# Reserved IDs for things that aren't normal Tado rooms/zones. +# +# Tado X synthetic IDs (real hardware, just not exposed as zones by the API): +# 9000-9099 single-instance features (hot water programmer etc.) +# 9100-9199 multi-instance features (future ACs etc. if the API ever supports it) +TADOX_VIRTUAL_HOT_WATER_ZONE_ID: Final = 9001 + +# Pure test dummies. No real hardware. +DUMMY_HOME_ID: Final = "DUMMY_HOME" +DUMMY_ZONE_ID_HOT_WATER: Final = 9999 +DUMMY_ZONE_ID_AC: Final = 9998 +DUMMY_ZONE_ID_TADOX_HOT_WATER: Final = ( + 9997 # Dedicated test dummy for Tado X hot water (Hops behavior) +) + # Zone Mode States ZONE_MODE_SCHEDULE: Final = "schedule" ZONE_MODE_OFF: Final = "off" @@ -207,7 +222,6 @@ CAPABILITY_INSIDE_TEMP: Final = "INSIDE_TEMPERATURE_MEASUREMENT" TEMP_OFFSET_ATTR: Final = "temperatureOffset" -# Device Type Mapping (Single Source of Truth) DEVICE_TYPE_MAP: Final[dict[str, str]] = { "GW": "Gateway (V2)", "IB01": "Internet Bridge", diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index b8c6af2..76abe4a 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -59,6 +59,7 @@ DEFAULT_SUPPRESS_REDUNDANT_CALLS, DEFAULT_THROTTLE_THRESHOLD, DOMAIN, + DUMMY_ZONE_ID_TADOX_HOT_WATER, GEN_CLASSIC, GEN_X, MIN_AUTO_QUOTA_INTERVAL_S, @@ -68,6 +69,7 @@ POWER_ON, RESUME_REFRESH_DELAY_S, SECONDS_PER_HOUR, + TADOX_VIRTUAL_HOT_WATER_ZONE_ID, TEMP_DEFAULT_AC, TEMP_DEFAULT_HEATING, TEMP_DEFAULT_HOT_WATER, @@ -873,6 +875,10 @@ async def async_set_hot_water_auto( ignore_global_config: bool = False, ) -> None: """Set hot water zone to auto mode (resume schedule).""" + if self._is_tadox_hot_water_zone(zone_id): + await self._async_set_hot_water_tadox_resume() + return + self._execute_resume_command(zone_id, operation_mode="auto") if refresh_after or (self._refresh_after_resume and not ignore_global_config): @@ -882,6 +888,10 @@ async def async_set_hot_water_off( self, zone_id: int, refresh_after: bool = False ) -> None: """Set hot water zone to off (manual overlay).""" + if self._is_tadox_hot_water_zone(zone_id): + await self._async_set_hot_water_tadox_off() + return + data = build_overlay_data( zone_id, self.zones_meta, @@ -894,10 +904,131 @@ async def async_set_hot_water_off( if refresh_after: self._schedule_queued_refresh() + def _get_tadox_hot_water_zid(self) -> int: + """Return the effective zone ID for TadoX hot water (real 9001 or test dummy 9997).""" + if self.dummy_handler and self.dummy_handler.is_tadox_hot_water_test_dummy( + DUMMY_ZONE_ID_TADOX_HOT_WATER + ): + return DUMMY_ZONE_ID_TADOX_HOT_WATER + return TADOX_VIRTUAL_HOT_WATER_ZONE_ID + + def _is_tadox_hot_water_zone(self, zone_id: int) -> bool: + """True for TadoX domesticHotWater programmer (real 9001 or test dummy 9997).""" + if self.generation == GEN_X and zone_id == TADOX_VIRTUAL_HOT_WATER_ZONE_ID: + return True + return bool( + self.dummy_handler + and ( + self.dummy_handler.is_tadox_hot_water_dummy(zone_id) + or self.dummy_handler.is_tadox_hot_water_test_dummy(zone_id) + ) + ) + + async def _async_set_hot_water_tadox_resume(self) -> None: + from .helpers.overlay_validator import validate_tadox_hot_water_resume + from .helpers.redundancy_checker import should_skip_hot_water_resume + + zid = self._get_tadox_hot_water_zid() + + is_valid, error = validate_tadox_hot_water_resume() + if not is_valid: + _LOGGER.error("TadoX hot water resumeSchedule validation failed: %s", error) + return + + if should_skip_hot_water_resume( + zid, self.data.zone_states, self._suppress_redundant_buttons + ): + _LOGGER.debug( + "Skipping TadoX hot water resumeSchedule for zone %s: already on schedule", + zid, + ) + return + + if self.dummy_handler and ( + self.dummy_handler.is_tadox_hot_water_dummy(zid) + or self.dummy_handler.is_tadox_hot_water_test_dummy(zid) + ): + self.dummy_handler.set_tadox_hot_water_auto(zid) + self.optimistic.apply_zone_state(zid, overlay=False, grace_period=10.0) + self.async_update_listeners() + _LOGGER.debug( + "TadoX hot water dummy: resumeSchedule handled for zone %s", zid + ) + self._schedule_queued_refresh() + return + + self.optimistic.apply_zone_state(zid, overlay=False, grace_period=10.0) + self.async_update_listeners() + + _LOGGER.debug("Sending TadoX hot water resumeSchedule for zone %s", zid) + try: + await self.tadox_bridge.async_resume_hot_water_schedule() + except Exception as e: + _LOGGER.error("Failed to resume Tado X hot water schedule: %s", e) + self.optimistic.clear_zone(zid) + self._schedule_queued_refresh() + + async def _async_set_hot_water_tadox_off(self) -> None: + from .helpers.overlay_validator import validate_tadox_hot_water_boost_off + from .helpers.redundancy_checker import should_skip_hot_water_off + + zid = self._get_tadox_hot_water_zid() + + is_valid, error = validate_tadox_hot_water_boost_off() + if not is_valid: + _LOGGER.error("TadoX hot water boost OFF validation failed: %s", error) + return + + if should_skip_hot_water_off( + zid, self.data.zone_states, self._suppress_redundant_buttons + ): + _LOGGER.debug( + "Skipping TadoX hot water boost OFF for zone %s: already forced off", + zid, + ) + return + + if self.dummy_handler and ( + self.dummy_handler.is_tadox_hot_water_dummy(zid) + or self.dummy_handler.is_tadox_hot_water_test_dummy(zid) + ): + self.dummy_handler.set_tadox_hot_water_off(zid) + self.optimistic.apply_zone_state( + zid, overlay=True, power="OFF", grace_period=10.0 + ) + self.async_update_listeners() + _LOGGER.debug("TadoX hot water dummy: boost OFF handled for zone %s", zid) + self._schedule_queued_refresh() + return + + self.optimistic.apply_zone_state( + zid, overlay=True, power="OFF", grace_period=10.0 + ) + self.async_update_listeners() + + _LOGGER.debug("Sending TadoX hot water boost OFF for zone %s", zid) + try: + await self.tadox_bridge.async_set_hot_water_off() + except Exception as e: + _LOGGER.error("Failed to set Tado X hot water OFF: %s", e) + self.optimistic.clear_zone(zid) + self._schedule_queued_refresh() + async def async_set_hot_water_heat( self, zone_id: int, temperature: float | None = None ) -> None: """Set hot water zone to heat mode (manual overlay).""" + if self._is_tadox_hot_water_zone(zone_id): + # Tado X hot water programmer only supports auto/off via Hops. + # Run through central validator for consistency (Hops path is different). + from .helpers.overlay_validator import validate_tadox_hot_water_boost_off + + is_valid, error = validate_tadox_hot_water_boost_off() + if not is_valid: + _LOGGER.error("TadoX hot water heat validation failed: %s", error) + _LOGGER.warning("Hot water 'heat' mode is not supported on Tado X") + return + state = self.data.zone_states.get(str(zone_id)) temp = temperature or TEMP_DEFAULT_HOT_WATER @@ -1182,12 +1313,17 @@ def _handle_overlay_side_effects( self._schedule_queued_refresh() def supports_temperature(self, zone_id: int) -> bool: - """Check if a zone supports temperature control in overlays. + """Check if a zone supports temperature control in overlays.""" + if ( + self.generation == GEN_X and zone_id == TADOX_VIRTUAL_HOT_WATER_ZONE_ID + ) or ( + self.dummy_handler + and self.dummy_handler.is_tadox_hot_water_test_dummy(zone_id) + ): + # Tado X hot water programmer has no temperature control + # (also for the dedicated TadoX test dummy) + return False - Uses capabilities as source of truth. For HOT_WATER zones, we check - if capabilities.temperatures exists. If the API later rejects with 422, - that's a real error that should be logged. - """ zone = self.zones_meta.get(zone_id) ztype = get_zone_type(zone) diff --git a/custom_components/tado_hijack/dummy/const.py b/custom_components/tado_hijack/dummy/const.py deleted file mode 100644 index 8bd26d8..0000000 --- a/custom_components/tado_hijack/dummy/const.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Constants for Tado Hijack Dummy Environment.""" - -from typing import Final - -DUMMY_ZONE_ID_HOT_WATER: Final = 999 -DUMMY_ZONE_ID_AC: Final = 998 - -DUMMY_HOME_ID: Final = "DUMMY_HOME" diff --git a/custom_components/tado_hijack/dummy/dummy_handler.py b/custom_components/tado_hijack/dummy/dummy_handler.py index 34e746d..67319a7 100644 --- a/custom_components/tado_hijack/dummy/dummy_handler.py +++ b/custom_components/tado_hijack/dummy/dummy_handler.py @@ -8,12 +8,16 @@ from ..const import ( DEVICE_TYPE_RU01, DEVICE_TYPE_VA01, + DUMMY_ZONE_ID_AC, + DUMMY_ZONE_ID_HOT_WATER, + DUMMY_ZONE_ID_TADOX_HOT_WATER, + TADOX_VIRTUAL_HOT_WATER_ZONE_ID, ZONE_TYPE_AIR_CONDITIONING, ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER, ) from ..helpers.logging_utils import get_redacted_logger -from .const import DUMMY_ZONE_ID_AC, DUMMY_ZONE_ID_HOT_WATER +from ..lib.tadox_models import TadoXHotWaterState if TYPE_CHECKING: from ..coordinator import TadoDataUpdateCoordinator @@ -42,7 +46,7 @@ def __init__(self, coordinator: TadoDataUpdateCoordinator) -> None: def _init_dummy_states(self) -> None: """Initialize internal dummy state objects.""" - # 1. Hot Water Dummy + # 1. Classic v3 Hot Water Dummy (original test entity) self._states[DUMMY_ZONE_ID_HOT_WATER] = RobustNamespace( setting=RobustNamespace( type=ZONE_TYPE_HOT_WATER, @@ -51,7 +55,6 @@ def _init_dummy_states(self) -> None: ), overlay=None, overlay_active=False, - # Classic HW doesn't have current_temperature at zone level, use RobustNamespace for safe access current_temperature=RobustNamespace(celsius=45.0, fahrenheit=113.0), sensor_data_points=RobustNamespace( inside_temperature=RobustNamespace(celsius=45.0, fahrenheit=113.0) @@ -68,7 +71,19 @@ def _init_dummy_states(self) -> None: ), ) - # 2. AC Dummy + # 2. Tado X Hot Water dummy (real Hops behavior, for testing) + self._states[TADOX_VIRTUAL_HOT_WATER_ZONE_ID] = TadoXHotWaterState( + state="SCHEDULE_ON", + nextStateChange="2026-02-01T06:00:00Z", + ) + + # 3. Dedicated Tado X Hot Water test dummy (separate ID from real 9001) + self._states[DUMMY_ZONE_ID_TADOX_HOT_WATER] = TadoXHotWaterState( + state="SCHEDULE_ON", + nextStateChange="2026-02-01T06:00:00Z", + ) + + # 3. AC Dummy self._states[DUMMY_ZONE_ID_AC] = RobustNamespace( setting=RobustNamespace( type=ZONE_TYPE_AIR_CONDITIONING, @@ -101,7 +116,29 @@ def _init_dummy_states(self) -> None: def is_dummy_zone(self, zone_id: int) -> bool: """Check if a zone ID belongs to a dummy zone.""" - return zone_id in (DUMMY_ZONE_ID_AC, DUMMY_ZONE_ID_HOT_WATER) + return zone_id in ( + DUMMY_ZONE_ID_AC, + DUMMY_ZONE_ID_HOT_WATER, + DUMMY_ZONE_ID_TADOX_HOT_WATER, + TADOX_VIRTUAL_HOT_WATER_ZONE_ID, + ) + + def is_tadox_hot_water_dummy(self, zone_id: int) -> bool: + """Check if this is the Tado X hot water dummy (real hardware simulation).""" + return zone_id == TADOX_VIRTUAL_HOT_WATER_ZONE_ID + + def is_tadox_style_hot_water(self, zone_id: int) -> bool: + """Check if the hot water at this zone_id uses Tado X style state (for dummy testing of TadoHotWaterX).""" + state = self._states.get(zone_id) + return bool( + state + and hasattr(state, "state") + and not hasattr(state, "current_temperature") + ) + + def is_tadox_hot_water_test_dummy(self, zone_id: int) -> bool: + """Dedicated test dummy for Tado X hot water behavior (separate from real 9001).""" + return zone_id == DUMMY_ZONE_ID_TADOX_HOT_WATER def split_zones(self, zone_ids: list[int]) -> tuple[list[int], list[int]]: """Split a list of zone IDs into real and dummy buckets.""" @@ -150,7 +187,7 @@ def inject_metadata( capabilities: dict[int, Any], ) -> None: """Inject dummy zone metadata into real data.""" - _LOGGER.debug("Injecting dummy zones (998=AC, 999=HW)") + _LOGGER.debug("Injecting dummy zones (998=AC, 999=HW, 9997=TadoX HW test)") # Inject Hot Water Zone zones[DUMMY_ZONE_ID_HOT_WATER] = self._create_hw_metadata() capabilities[DUMMY_ZONE_ID_HOT_WATER] = self._create_hw_capabilities() @@ -231,6 +268,26 @@ def intercept_command( return True + # --- Tado X Hot Water Dummy (real hardware behavior) --- + + def set_tadox_hot_water_off(self, zone_id: int | None = None) -> None: + """Simulate the Hops boost OFF for hot water (forced off).""" + zid = zone_id or TADOX_VIRTUAL_HOT_WATER_ZONE_ID + if current := self._states.get(zid): + self._states[zid] = TadoXHotWaterState( + state="BOOST_OFF", + nextStateChange=getattr(current, "next_state_change", None), + ) + + def set_tadox_hot_water_auto(self, zone_id: int | None = None) -> None: + """Simulate resuming the schedule (SCHEDULE_ON).""" + zid = zone_id or TADOX_VIRTUAL_HOT_WATER_ZONE_ID + if current := self._states.get(zid): + self._states[zid] = TadoXHotWaterState( + state="SCHEDULE_ON", + nextStateChange=getattr(current, "next_state_change", None), + ) + def get_away_configuration(self, zone_id: int) -> dict[str, Any]: """Return a mock away configuration.""" return { @@ -243,6 +300,9 @@ def get_capabilities(self, zone_id: int) -> Any: """Return mock capabilities for a zone.""" if zone_id == DUMMY_ZONE_ID_HOT_WATER: return self._create_hw_capabilities() + if zone_id == DUMMY_ZONE_ID_TADOX_HOT_WATER: + # Tado X hot water has no temperature control (like real Hops behavior) + return RobustNamespace(type=ZONE_TYPE_HOT_WATER, temperatures=None) return self._create_ac_capabilities() if zone_id == DUMMY_ZONE_ID_AC else None def _update_activity(self, zone_id: int, state: Any) -> None: diff --git a/custom_components/tado_hijack/helpers/overlay_validator.py b/custom_components/tado_hijack/helpers/overlay_validator.py index 7ea2159..59abfa7 100644 --- a/custom_components/tado_hijack/helpers/overlay_validator.py +++ b/custom_components/tado_hijack/helpers/overlay_validator.py @@ -69,3 +69,16 @@ def validate_overlay_payload( ) return True, None + + +def validate_tadox_hot_water_resume() -> tuple[bool, str | None]: + """Validate Tado X domesticHotWater resumeSchedule (Hops programmer endpoint).""" + # No request body - always structurally valid for current Hops API. + return True, None + + +def validate_tadox_hot_water_boost_off() -> tuple[bool, str | None]: + """Validate Tado X domesticHotWater boost OFF payload (Hops).""" + # Fixed payload {"boost": "OFF"} for the current supported operation. + # Extend here when the programmer API gains more options or stricter rules. + return True, None diff --git a/custom_components/tado_hijack/helpers/redundancy_checker.py b/custom_components/tado_hijack/helpers/redundancy_checker.py index ec59f41..a7c51a8 100644 --- a/custom_components/tado_hijack/helpers/redundancy_checker.py +++ b/custom_components/tado_hijack/helpers/redundancy_checker.py @@ -551,6 +551,32 @@ def _is_overlay_redundant( return cache_temp is not None and abs(cache_temp - target_temp) < TEMP_TOLERANCE +def _is_hot_water_off_redundant( + zone_id: int, zone_states: dict[str, Any], suppress_buttons: bool +) -> bool: + """Return True if hot water off (manual overlay) is redundant.""" + if not suppress_buttons: + return False + state = zone_states.get(str(zone_id)) + if state is None: + return False + return bool(getattr(state, "overlay_active", False)) + + +def should_skip_hot_water_resume( + zone_id: int, zone_states: dict[str, Any], suppress_buttons: bool +) -> bool: + """Return True if hot water resume to schedule is redundant (v3 + TadoX).""" + return _is_resume_redundant(zone_id, zone_states, suppress_buttons) + + +def should_skip_hot_water_off( + zone_id: int, zone_states: dict[str, Any], suppress_buttons: bool +) -> bool: + """Return True if hot water off is redundant (TadoX Hops + v3 HW).""" + return _is_hot_water_off_redundant(zone_id, zone_states, suppress_buttons) + + def _filter_zone_updates( merged: dict[str, Any], zone_states: dict[str, Any], diff --git a/custom_components/tado_hijack/helpers/tadox/mapper.py b/custom_components/tado_hijack/helpers/tadox/mapper.py index 8ba2875..aed8105 100644 --- a/custom_components/tado_hijack/helpers/tadox/mapper.py +++ b/custom_components/tado_hijack/helpers/tadox/mapper.py @@ -4,8 +4,9 @@ from typing import Any -from ...const import GEN_X +from ...const import GEN_X, TADOX_VIRTUAL_HOT_WATER_ZONE_ID from ...lib.tadox_api import TadoXApi +from ...lib.tadox_models import TadoXHotWaterState from ..logging_utils import get_redacted_logger from ..models_unified import UnifiedTadoData @@ -22,6 +23,8 @@ def __init__(self, bridge: TadoXApi) -> None: """Initialize the Tado X mapper.""" self.bridge = bridge self._last_presence: str = "HOME" + # None = not probed; True = installed; False = not installed (skip future calls) + self._hot_water_available: bool | None = None async def async_fetch_unified_data(self) -> UnifiedTadoData: """Fetch all relevant Tado X data and return a UnifiedTadoData container.""" @@ -70,6 +73,8 @@ async def async_fetch_unified_data(self) -> UnifiedTadoData: for state in room_states: unified_data.zone_states[str(state.room_id)] = state + await self._augment_with_hot_water(unified_data.zone_states) + # 4. Map Devices (Hardware Metadata) all_hops_devices = other_devices + [ dev for room in rooms for dev in room.devices @@ -91,7 +96,9 @@ async def async_fetch_zones(self) -> dict[str, Any]: exc_info=True, ) return {} - return {str(state.room_id): state for state in room_states} + result: dict[str, Any] = {str(state.room_id): state for state in room_states} + await self._augment_with_hot_water(result) + return result async def async_fetch_metadata(self) -> tuple[dict[int, Any], dict[str, Any]]: """Fetch Tado X metadata (slow poll): rooms, devices, and presence.""" @@ -165,3 +172,28 @@ async def async_fetch_away_config(self, zone_id: int) -> float | None: async def async_set_temperature_offset(self, serial_no: str, offset: float) -> None: """Set temperature offset via Hops API.""" await self.bridge.async_set_temperature_offset(serial_no, offset) + + async def _fetch_hot_water_state_safe(self) -> TadoXHotWaterState | None: + """Fetch hot water state with caching for 'not installed'.""" + if self._hot_water_available is False: + return None + + try: + result = await self.bridge.async_get_hot_water_state() + except Exception as e: + _LOGGER.debug("Tado X hot water state fetch failed (transient): %s", e) + return None + + if result is None: + if self._hot_water_available is None: + _LOGGER.debug("Tado X hot water programmer not detected (no hardware)") + self._hot_water_available = False + return None + + self._hot_water_available = True + return result + + async def _augment_with_hot_water(self, zones: dict[str, Any]) -> None: + """Inject Tado X hot water state from Hops (real hardware, synthetic ID).""" + if (hw := await self._fetch_hot_water_state_safe()) is not None: + zones[str(TADOX_VIRTUAL_HOT_WATER_ZONE_ID)] = hw diff --git a/custom_components/tado_hijack/lib/tadox_api.py b/custom_components/tado_hijack/lib/tadox_api.py index 165e7f2..06d46ec 100644 --- a/custom_components/tado_hijack/lib/tadox_api.py +++ b/custom_components/tado_hijack/lib/tadox_api.py @@ -19,10 +19,15 @@ from aiohttp import ClientTimeout +from ..const import HTTP_BAD_REQUEST from ..helpers.logging_utils import get_redacted_logger from ..helpers.parsers import parse_ratelimit_headers from ..helpers.tadox.const import HOPS_BASE_URL -from .tadox_models import HopsRoomsAndDevicesResponse, TadoXZoneState +from .tadox_models import ( + HopsRoomsAndDevicesResponse, + TadoXHotWaterState, + TadoXZoneState, +) if TYPE_CHECKING: from tadoasync import Tado @@ -121,7 +126,17 @@ async def _request( if "roomsAndDevices" in endpoint: return {"rooms": [], "devices": []} return [] if "rooms" in endpoint else {} - response.raise_for_status() + + if response.status >= HTTP_BAD_REQUEST: + body = await response.text() + _LOGGER.error( + "Hops API Error %d: %s. Response: %s", + response.status, + endpoint, + body, + ) + # Re-raise with proper status (matches behavior of v2 error handler) + response.raise_for_status() self._capture_rate_limit_headers(response.headers) @@ -228,6 +243,25 @@ async def async_turn_off_all_zones(self) -> Any: """Turn off all rooms (frost protection mode).""" return await self._request("POST", "quickActions/allOff") + async def async_get_hot_water_state(self) -> TadoXHotWaterState | None: + """Fetch current hot water programmer state.""" + data = await self._request("GET", "programmer/domesticHotWater/state") + if not data: + return None # 404 handled by _request as {} + return cast(TadoXHotWaterState, TadoXHotWaterState.model_validate(data)) + + async def async_resume_hot_water_schedule(self) -> Any: + """Resume hot water schedule (clear boost override).""" + return await self._request("POST", "programmer/domesticHotWater/resumeSchedule") + + async def async_set_hot_water_off(self) -> Any: + """Force hot water OFF via boost override.""" + return await self._request( + "POST", + "programmer/domesticHotWater/boost", + json_data={"boost": "OFF"}, + ) + async def async_set_open_window_detection(self, room_id: int, enabled: bool) -> Any: """Enable or disable open window detection.""" if enabled: diff --git a/custom_components/tado_hijack/lib/tadox_models.py b/custom_components/tado_hijack/lib/tadox_models.py index ba7f707..6e041e9 100644 --- a/custom_components/tado_hijack/lib/tadox_models.py +++ b/custom_components/tado_hijack/lib/tadox_models.py @@ -227,3 +227,26 @@ class HopsRoomsAndDevicesResponse(BaseModel): rooms: list[HopsRoomSnapshot] other_devices: list[TadoXDevice] = Field(alias="otherDevices") home: HomePresence | None = None # Presence information + + +class _HotWaterSetting: + def __init__(self, power: str) -> None: + self.power = power + + +class TadoXHotWaterState(BaseModel): + """State for the Tado X domestic hot water programmer (Hops).""" + + state: str + next_state_change: str | None = Field(None, alias="nextStateChange") + setpoint: Any | None = None + setpoint_constraints: Any | None = Field(None, alias="setpointConstraints") + + @property + def overlay_active(self) -> bool: + return not self.state.startswith("SCHEDULE_") + + @property + def setting(self) -> _HotWaterSetting: + power = "OFF" if self.overlay_active else "ON" + return _HotWaterSetting(power) diff --git a/custom_components/tado_hijack/services.py b/custom_components/tado_hijack/services.py index ff71638..0baa6e8 100644 --- a/custom_components/tado_hijack/services.py +++ b/custom_components/tado_hijack/services.py @@ -9,6 +9,7 @@ from .const import ( DOMAIN, + GEN_X, OVERLAY_AUTO, OVERLAY_MANUAL, OVERLAY_NEXT_BLOCK, @@ -24,6 +25,7 @@ SERVICE_SET_MODE_ALL, SERVICE_SET_WATER_HEATER_MODE, SERVICE_TURN_OFF_ALL_ZONES, + TADOX_VIRTUAL_HOT_WATER_ZONE_ID, ZONE_TYPE_HOT_WATER, ) from .helpers.logging_utils import get_redacted_logger @@ -257,6 +259,27 @@ async def handle_set_water_heater_mode(call: ServiceCall) -> None: for coord, zone_ids in coord_map.items(): for zone_id in zone_ids: + # Tado X hot water uses dedicated Hops paths (no zone overlay) + if ( + coord.generation == GEN_X + and zone_id == TADOX_VIRTUAL_HOT_WATER_ZONE_ID + ): + if operation_mode == "auto": + await coord.async_set_hot_water_auto( + zone_id, + refresh_after=refresh_after, + ignore_global_config=True, + ) + elif operation_mode == "off": + await coord.async_set_hot_water_off( + zone_id, refresh_after=refresh_after + ) + else: + _LOGGER.warning( + "Hot water 'heat' mode is not supported on Tado X" + ) + continue + if operation_mode == "auto": await coord.async_set_hot_water_auto( zone_id, refresh_after=refresh_after, ignore_global_config=True diff --git a/custom_components/tado_hijack/translations/cs.json b/custom_components/tado_hijack/translations/cs.json index fe7a7c5..99f6f96 100644 --- a/custom_components/tado_hijack/translations/cs.json +++ b/custom_components/tado_hijack/translations/cs.json @@ -696,4 +696,4 @@ } } } -} \ No newline at end of file +} diff --git a/custom_components/tado_hijack/water_heater.py b/custom_components/tado_hijack/water_heater.py index 82c2625..2aededf 100644 --- a/custom_components/tado_hijack/water_heater.py +++ b/custom_components/tado_hijack/water_heater.py @@ -12,7 +12,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + DUMMY_ZONE_ID_TADOX_HOT_WATER, GEN_X, + TADOX_VIRTUAL_HOT_WATER_ZONE_ID, TEMP_MAX_HOT_WATER, TEMP_MIN_HOT_WATER, TEMP_STEP_HOT_WATER, @@ -35,13 +37,19 @@ OPERATION_MODE_OFF = "off" OPERATION_MODES = [OPERATION_MODE_AUTO, OPERATION_MODE_HEAT, OPERATION_MODE_OFF] +OPERATION_MODES_TADOX = [OPERATION_MODE_AUTO, OPERATION_MODE_OFF] def _setup_water_heater_entities_tadox( coordinator: TadoDataUpdateCoordinator, ) -> list[TadoHotWater]: """Set up hot water entities for Tado X.""" - return [] # [TADO_X] Not yet supported + if coordinator.data.zone_states.get(str(TADOX_VIRTUAL_HOT_WATER_ZONE_ID)) is None: + _LOGGER.debug( + "No Tado X hot water found (programmer/domesticHotWater/state unavailable)" + ) + return [] + return [TadoHotWaterX(coordinator, TADOX_VIRTUAL_HOT_WATER_ZONE_ID, "Hot Water")] def _setup_water_heater_entities_v3( @@ -65,11 +73,27 @@ async def async_setup_entry( """Set up Tado hot water based on a config entry.""" coordinator: TadoDataUpdateCoordinator = entry.runtime_data - entities = ( - _setup_water_heater_entities_tadox(coordinator) - if coordinator.generation == GEN_X - else _setup_water_heater_entities_v3(coordinator) - ) + if coordinator.generation == GEN_X: + entities = _setup_water_heater_entities_tadox(coordinator) + else: + entities = _setup_water_heater_entities_v3(coordinator) + + # Dedicated Tado X Hot Water test dummy (separate ID 9997) + # This lets you test the full TadoX Hops hot water paths locally in HA + # without touching the real 9001 synthetic zone. + if ( + coordinator.dummy_handler + and coordinator.dummy_handler.is_tadox_hot_water_test_dummy( + DUMMY_ZONE_ID_TADOX_HOT_WATER + ) + ): + entities.append( + TadoHotWaterX( + coordinator, + DUMMY_ZONE_ID_TADOX_HOT_WATER, + "DUMMY Tado X Hot Water", + ) + ) async_add_entities(entities) @@ -258,3 +282,46 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temperature=rounded_temp, overlay_type="HOT_WATER", ) + + +class TadoHotWaterX(TadoHotWater): + """Tado X hot water via Hops (synthetic ID, real hardware).""" + + _attr_operation_list = OPERATION_MODES_TADOX + _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE + + @property + def target_temperature(self) -> float | None: + return None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + state = self.coordinator.data.zone_states.get(str(self._zone_id)) + if state and (nsc := getattr(state, "next_state_change", None)): + return {"next_state_change": nsc} + return {} + + def _get_actual_value(self) -> str: + state = self.coordinator.data.zone_states.get(str(self._zone_id)) + if state is None: + return OPERATION_MODE_AUTO + return ( + OPERATION_MODE_OFF + if getattr(state, "overlay_active", False) + else OPERATION_MODE_AUTO + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + pass + + async def async_set_operation_mode(self, operation_mode: str) -> None: + if operation_mode == OPERATION_MODE_OFF: + await self.tado_coordinator.async_set_hot_water_off(self._zone_id) + elif operation_mode == OPERATION_MODE_AUTO: + await self.tado_coordinator.async_set_hot_water_auto(self._zone_id) + else: + _LOGGER.warning( + "Tado X hot water: unsupported operation mode '%s' (supported: %s)", + operation_mode, + OPERATION_MODES_TADOX, + ) From 758cc9e17b4a353bf39de4984418672292d03f2f Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 1 Jun 2026 13:11:25 +0000 Subject: [PATCH 36/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.7.0-dev.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.7.0-dev.1](https://github.com/banter240/tado_hijack/compare/v5.6.1-dev.1...v5.7.0-dev.1) (2026-06-01) ### ✨ New Features * feat(tadox): add Tado X hot water support (auto/off only via Hops) with central guards Adds support for the domesticHotWater programmer on Tado X devices using the Hops endpoints (resumeSchedule + boost for forced off). - Hot water exposed as virtual zone with reserved high ID (9001) - TadoHotWaterX entity limited to auto/off (no temperature control) - Safe fetching with 404 caching - Dedicated coordinator paths with optimistic updates - All set hot water operations correctly detect and route TadoX (9001 + test dummy 9997) - Central redundancy_checker for hot water (v3 + TadoX through same guards) - Central overlay_validator for TadoX hot water Hops calls - _is_tadox_hot_water_zone helper for consistent routing Hops programmer paths are fundamentally different from v3 overlays, so all operations go through the dedicated endpoints and central guard infrastructure. [skip ci] --- CHANGELOG.md | 19 +++++++++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9725e6f..107ff65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [5.7.0-dev.1](https://github.com/banter240/tado_hijack/compare/v5.6.1-dev.1...v5.7.0-dev.1) (2026-06-01) + +### ✨ New Features + +* feat(tadox): add Tado X hot water support (auto/off only via Hops) with central guards + +Adds support for the domesticHotWater programmer on Tado X devices using the Hops endpoints (resumeSchedule + boost for forced off). + +- Hot water exposed as virtual zone with reserved high ID (9001) +- TadoHotWaterX entity limited to auto/off (no temperature control) +- Safe fetching with 404 caching +- Dedicated coordinator paths with optimistic updates +- All set hot water operations correctly detect and route TadoX (9001 + test dummy 9997) +- Central redundancy_checker for hot water (v3 + TadoX through same guards) +- Central overlay_validator for TadoX hot water Hops calls +- _is_tadox_hot_water_zone helper for consistent routing + +Hops programmer paths are fundamentally different from v3 overlays, so all operations go through the dedicated endpoints and central guard infrastructure. + ## [5.6.1-dev.1](https://github.com/banter240/tado_hijack/compare/v5.6.0...v5.6.1-dev.1) (2026-05-31) ### 🐛 Bug Fixes diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index cd6fbb1..49afff1 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.6.1-dev.1" + "version": "5.7.0-dev.1" } From 0b83fe4bb2c4236ff6a20c872b6c14ccfa01c9fe Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:46:29 +0200 Subject: [PATCH 37/53] chore: target Python 3.14 across the board Align the integration with Home Assistant's current Python 3.14 requirement. - pyproject.toml: python = "^3.14", mypy python_version = "3.14", ruff target-version = "py314" - .github/workflows/lint.yml: python-version 3.14 - Updated and pinned dev tooling: mypy==2.1.0 (native PEP 695 support, no more --enable-incomplete-feature), ruff==0.15.20, pre-commit==4.6.0, updated pytest stack - .pre-commit-config.yaml: updated hook revisions, mypy hook now pins exact mypy==2.1.0 + tadoasync==0.2.2; removed --disable-error-code and --no-warn-unused-ignores - hacs.json + pyproject: minimum homeassistant pinned to "2026.3" - requirements.txt synced with dev dependencies - Replaced remaining # type: ignore[method-assign] / [union-attr] etc. with cast(Any, ...) for monkey-patches (lib/patches.py) and dynamic attributes (coordinator.py + related) - Minor ruff-driven cleanups (logging, exception syntax, etc.) All local on ai/dev. --- .github/workflows/lint.yml | 2 +- .pre-commit-config.yaml | 6 +++--- custom_components/tado_hijack/coordinator.py | 2 +- custom_components/tado_hijack/definitions.py | 4 ++-- .../tado_hijack/helpers/entity_resolver.py | 4 ++-- .../helpers/reset_window_tracker.py | 1 + .../helpers/tadov3/action_provider.py | 11 +++++++--- custom_components/tado_hijack/lib/patches.py | 8 +++++--- custom_components/tado_hijack/services.py | 18 ++++++++++++++--- hacs.json | 2 +- pyproject.toml | 20 +++++++++---------- requirements.txt | 6 +++--- 12 files changed, 52 insertions(+), 32 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6fd0701..cf76b43 100755 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,7 +20,7 @@ jobs: - name: "Set up Python" uses: "actions/setup-python@v5" with: - python-version: "3.13" + python-version: "3.14" cache: "pip" - name: "Install requirements" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59c5c31..f7173e8 100755 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: # Ruff hook for fast linting and formatting. - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.8 + rev: v0.15.20 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -37,7 +37,7 @@ repos: types: [python] args: [--ignore-missing-imports, --explicit-package-bases] additional_dependencies: - - "mypy>=1.8" + - "mypy==2.1.0" - "tadoasync==0.2.2" # Sourcery hook for AI-powered refactoring. @@ -50,7 +50,7 @@ repos: # Gitleaks hook for detecting secrets and sensitive information. - repo: https://github.com/gitleaks/gitleaks - rev: v8.30.0 # Use the latest stable version + rev: v8.30.1 # Use the latest stable version hooks: - id: gitleaks diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 76abe4a..08de6e0 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1228,7 +1228,7 @@ async def async_set_early_start(self, zone_id: int, enabled: bool) -> None: if zone := self.zones_meta.get(zone_id): old_val = getattr(zone, "early_start_enabled", None) # tadoasync Zone model misses this field, so we set it dynamically - zone.early_start_enabled = enabled # type: ignore[union-attr] + cast(Any, zone).early_start_enabled = enabled await self.property_manager.async_set_zone_property( zone_id, diff --git a/custom_components/tado_hijack/definitions.py b/custom_components/tado_hijack/definitions.py index e97f387..eedff2f 100644 --- a/custom_components/tado_hijack/definitions.py +++ b/custom_components/tado_hijack/definitions.py @@ -141,11 +141,11 @@ def _read_climate_or_sensor_value( if val is not None: try: return float(val) - except (ValueError, TypeError): + except ValueError, TypeError: pass try: return float(state.state) - except (ValueError, TypeError): + except ValueError, TypeError: pass return None diff --git a/custom_components/tado_hijack/helpers/entity_resolver.py b/custom_components/tado_hijack/helpers/entity_resolver.py index 9ec767f..1bfd25c 100644 --- a/custom_components/tado_hijack/helpers/entity_resolver.py +++ b/custom_components/tado_hijack/helpers/entity_resolver.py @@ -95,7 +95,7 @@ def parse_unique_id(self, unique_id: str) -> int | None: for i, part in enumerate(parts): if part == "zone" and i + 1 < len(parts) and parts[i + 1].isdigit(): return int(parts[i + 1]) - except (ValueError, IndexError, AttributeError): + except ValueError, IndexError, AttributeError: pass return None @@ -120,7 +120,7 @@ def _resolve_device_to_zone(self, unique_id: str) -> int | None: serial_no, ) return zone_id - except (ValueError, IndexError, AttributeError): + except ValueError, IndexError, AttributeError: pass return None diff --git a/custom_components/tado_hijack/helpers/reset_window_tracker.py b/custom_components/tado_hijack/helpers/reset_window_tracker.py index 25ee052..f1c457a 100644 --- a/custom_components/tado_hijack/helpers/reset_window_tracker.py +++ b/custom_components/tado_hijack/helpers/reset_window_tracker.py @@ -117,6 +117,7 @@ def get_initial_target(self) -> datetime: target_utc += timedelta(days=1) self._initial_target = target_utc.astimezone(berlin_tz) + assert self._initial_target is not None return self._initial_target def record_reset(self, reset_time: datetime) -> None: diff --git a/custom_components/tado_hijack/helpers/tadov3/action_provider.py b/custom_components/tado_hijack/helpers/tadov3/action_provider.py index 7dcf70c..fd95038 100644 --- a/custom_components/tado_hijack/helpers/tadov3/action_provider.py +++ b/custom_components/tado_hijack/helpers/tadov3/action_provider.py @@ -33,7 +33,7 @@ async def async_resume_all_schedules(self) -> None: active_zones = self.get_active_zone_ids(include_heating=True, include_ac=True) if not active_zones: - _LOGGER.warning("No active zones to resume") + _LOGGER.warning("No active zones to resume (service: resume_all_schedules)") return _LOGGER.info("Queued resume schedules for %d active zones", len(active_zones)) @@ -82,7 +82,9 @@ def _apply_bulk_zone_overlay( zone_ids = self.get_active_zone_ids(include_heating=True, include_ac=True) if not zone_ids: - _LOGGER.warning("No active zones to %s", action_name) + _LOGGER.warning( + "No active zones to %s (service: %s)", action_name, command_key + ) return _LOGGER.info("Queued %s for %d active zones", action_name, len(zone_ids)) @@ -157,7 +159,10 @@ async def async_set_ac_setting(self, zone_id: int, key: str, value: str) -> None """Set an AC specific setting (v3) respecting hardware capabilities.""" state = self.coordinator.data.zone_states.get(str(zone_id)) if not state or not getattr(state, "setting", None): - _LOGGER.error("Cannot set AC setting: No state for zone %d", zone_id) + _LOGGER.error( + "Cannot set AC setting: No state for zone %d (from AC service or entity)", + zone_id, + ) return from .parsers import get_overlay_type, resolve_ac_mode diff --git a/custom_components/tado_hijack/lib/patches.py b/custom_components/tado_hijack/lib/patches.py index b047b03..2225201 100644 --- a/custom_components/tado_hijack/lib/patches.py +++ b/custom_components/tado_hijack/lib/patches.py @@ -12,7 +12,7 @@ import sys from datetime import datetime -from typing import Any +from typing import Any, cast from ..const import TADO_VERSION_PATCH from ..helpers.logging_utils import get_redacted_logger @@ -96,7 +96,9 @@ async def patched_set_meter_readings( f"Error setting meter reading: {data['message']}" ) - tadoasync.tadoasync.Tado.set_meter_readings = patched_set_meter_readings # type: ignore[method-assign] + cast( + Any, tadoasync.tadoasync.Tado + ).set_meter_readings = patched_set_meter_readings _LOGGER.debug("Successfully patched tadoasync Tado.set_meter_readings") except Exception as e: _LOGGER.error("Failed to patch set_meter_readings: %s", e) @@ -173,7 +175,7 @@ def patched_pre_deserialize(cls: Any, d: dict[str, Any]) -> dict[str, Any]: return d - tadoasync.models.ZoneState.__pre_deserialize__ = classmethod( # type: ignore[method-assign, assignment] + cast(Any, tadoasync.models.ZoneState).__pre_deserialize__ = classmethod( patched_pre_deserialize ) _LOGGER.debug("Successfully patched ZoneState.__pre_deserialize__") diff --git a/custom_components/tado_hijack/services.py b/custom_components/tado_hijack/services.py index 0baa6e8..274adcd 100644 --- a/custom_components/tado_hijack/services.py +++ b/custom_components/tado_hijack/services.py @@ -157,7 +157,10 @@ async def handle_manual_poll(call: ServiceCall) -> None: ) await coord.async_targeted_fetch(refresh_type, entity_id) return - _LOGGER.warning("Could not resolve Tado zone for entity %s", entity_id) + _LOGGER.warning( + "Could not resolve Tado zone for entity %s (service: manual_poll)", + entity_id, + ) else: _LOGGER.debug("Service call: manual_poll (type: %s)", refresh_type) for coord in _get_coordinators_for_call(hass, call): @@ -183,6 +186,7 @@ async def handle_boost_all(call: ServiceCall) -> None: async def handle_set_mode(call: ServiceCall) -> None: """Service to set a manual mode (batched).""" + _LOGGER.debug("Service call: set_mode") entity_ids = call.data.get("entity_id") if not entity_ids: return @@ -203,7 +207,10 @@ async def handle_set_mode(call: ServiceCall) -> None: resolved = True break if not resolved: - _LOGGER.warning("Could not resolve Tado zone for entity %s", entity_id) + _LOGGER.warning( + "Could not resolve Tado zone for entity %s (service: set_mode)", + entity_id, + ) for coord, zone_ids in coord_map.items(): if zone_ids: @@ -211,6 +218,7 @@ async def handle_set_mode(call: ServiceCall) -> None: async def handle_set_mode_all(call: ServiceCall) -> None: """Service to set a manual overlay for all heating/AC zones (batched).""" + _LOGGER.debug("Service call: set_mode_all_zones") params = _parse_service_call_data(call) for coord in _get_coordinators_for_call(hass, call): zone_ids = coord.get_active_zones( @@ -226,6 +234,7 @@ async def handle_set_mode_all(call: ServiceCall) -> None: async def handle_set_water_heater_mode(call: ServiceCall) -> None: """Service to set a mode for water heater entities.""" + _LOGGER.debug("Service call: set_water_heater_mode") entity_ids = call.data.get("entity_id") if not entity_ids: return @@ -255,7 +264,10 @@ async def handle_set_water_heater_mode(call: ServiceCall) -> None: resolved = True break if not resolved: - _LOGGER.warning("Could not resolve Tado zone for entity %s", entity_id) + _LOGGER.warning( + "Could not resolve Tado zone for entity %s (service: set_water_heater_mode)", + entity_id, + ) for coord, zone_ids in coord_map.items(): for zone_id in zone_ids: diff --git a/hacs.json b/hacs.json index 61a69e2..38654d6 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "Tado Hijack", - "homeassistant": "2025.12.0", + "homeassistant": "2026.3", "render_readme": true } diff --git a/pyproject.toml b/pyproject.toml index cb8c712..11b048d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.mypy] platform = "linux" -python_version = "3.13" +python_version = "3.14" follow_imports = "normal" ignore_missing_imports = true explicit_package_bases = true @@ -22,7 +22,7 @@ warn_unused_ignores = true exclude = ["testing_config/", "dev/"] [tool.ruff] -target-version = "py313" +target-version = "py314" line-length = 88 [tool.ruff.lint] @@ -66,18 +66,18 @@ license = "GPL-3.0" readme = "README.md" [tool.poetry.dependencies] -python = "^3.13" -homeassistant = ">=2026.3.4" +python = "^3.14" +homeassistant = ">=2026.3" [tool.poetry.group.dev.dependencies] -pytest = "9.0.0" -pytest-cov = "7.0.0" -pytest-homeassistant-custom-component = "0.13.320" +pytest = "9.1.1" +pytest-cov = "7.1.0" +pytest-homeassistant-custom-component = "0.13.345" # Recommended development tools: -ruff = "0.15.8" -mypy = "1.19.1" -pre-commit = "4.5.1" +ruff = "0.15.20" +mypy = "2.1.0" +pre-commit = "4.6.0" yamllint = "1.38.0" [build-system] diff --git a/requirements.txt b/requirements.txt index e78e80e..25747dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ tadoasync==0.2.2 # CI/CD & Dev Tools -ruff==0.15.8 -pre-commit==4.5.1 -mypy==1.19.1 +ruff==0.15.20 +pre-commit==4.6.0 +mypy==2.1.0 yamllint==1.38.0 From da7ac93c13b0c0ecce2d802ff8f878820044f26b Mon Sep 17 00:00:00 2001 From: Banter240 <199655869+banter240@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:17:16 +0200 Subject: [PATCH 38/53] fix(full_cloud): guard Tado X in TadoAirConditioning for fan/swing and hvac_mode In Full Cloud Mode on Tado X, TadoAirConditioning is the unified entity for all zones. - fan_modes/swing_modes return None for GEN_X (no capabilities endpoint) - _get_active_hvac_mode returns HEAT for GEN_X (all zones are heating) - supported_features limited for GEN_X (no FAN/SWING) - hvac_modes set to [OFF, HEAT, AUTO] for GEN_X (matching TadoHeating) Prevents event loop crash during setup and incorrect 'cooling' action on heating zones. Also improves service call diagnostics: - Added debug logs when entering service handlers (manual_poll, set_mode, set_mode_all_zones, set_water_heater_mode). - Warnings now include the service name (e.g. "(service: manual_poll)"). - Better messages in async_add_meter_reading for Tado X and general failures (permission/subscription hints). See https://github.com/banter240/tado_hijack/discussions/113 Fixes #111 Fixes #112 Incorporate work from reported issues. --- custom_components/tado_hijack/climate.py | 4 +-- .../tado_hijack/climate_entity.py | 35 ++++++++++--------- custom_components/tado_hijack/coordinator.py | 22 +++++++----- .../tado_hijack/helpers/entity_setup.py | 3 +- .../tado_hijack/helpers/executor_unified.py | 3 +- custom_components/tado_hijack/sensor.py | 3 +- 6 files changed, 39 insertions(+), 31 deletions(-) diff --git a/custom_components/tado_hijack/climate.py b/custom_components/tado_hijack/climate.py index d47eba0..350e2c1 100644 --- a/custom_components/tado_hijack/climate.py +++ b/custom_components/tado_hijack/climate.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from .climate_entity import TadoAirConditioning, TadoHeating -from .const import ZONE_TYPE_AIR_CONDITIONING, ZONE_TYPE_HEATING +from .const import GEN_X, ZONE_TYPE_AIR_CONDITIONING, ZONE_TYPE_HEATING from .helpers.discovery import yield_zones if TYPE_CHECKING: @@ -25,7 +25,7 @@ async def async_setup_entry( coordinator: TadoDataUpdateCoordinator = entry.runtime_data entities: list[TadoHeating | TadoAirConditioning] = [] - if coordinator.generation == "x": + if coordinator.generation == GEN_X: if coordinator.full_cloud_mode: entities.extend( TadoAirConditioning(coordinator, zone.id, zone.name) diff --git a/custom_components/tado_hijack/climate_entity.py b/custom_components/tado_hijack/climate_entity.py index 7965283..95d3e18 100644 --- a/custom_components/tado_hijack/climate_entity.py +++ b/custom_components/tado_hijack/climate_entity.py @@ -84,18 +84,14 @@ async def async_added_to_hass(self) -> None: async def _async_update_capabilities(self) -> None: """Fetch and refresh capabilities.""" - if self.tado_coordinator.generation == "x": - # Tado X (Static Defaults) + if self.tado_coordinator.generation == GEN_X: self._attr_min_temp = 5.0 self._attr_max_temp = 30.0 self._attr_target_temperature_step = 0.5 if isinstance(self, TadoAirConditioning): self._attr_hvac_modes = [ HVACMode.OFF, - HVACMode.COOL, HVACMode.HEAT, - HVACMode.DRY, - HVACMode.FAN_ONLY, HVACMode.AUTO, ] self.async_write_ha_state() @@ -380,11 +376,17 @@ def __init__( self, coordinator: TadoDataUpdateCoordinator, zone_id: int, zone_name: str ) -> None: """Initialize air conditioning climate entity.""" - # [TADO_X] Use heating-compatible defaults for Tado X (Unified entity) if coordinator.generation == GEN_X: default_temp, min_temp = self._get_defaults_tadox() + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.AUTO] else: default_temp, min_temp = self._get_defaults_v3() + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.AUTO] super().__init__( coordinator, @@ -397,14 +399,11 @@ def __init__( self._attr_unique_id = ( f"{coordinator.config_entry.entry_id}_climate_ac_{zone_id}" ) - self._attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.AUTO] self._store_last_state("vertical_swing", "OFF") self._store_last_state("horizontal_swing", "OFF") def _get_defaults_tadox(self) -> tuple[float, float]: """Get defaults for Tado X (Heating-compatible).""" - # Tado X zones act as heating zones by default (min 5.0) - # We use this entity for ALL Tado X rooms return 21.0, 5.0 def _get_defaults_v3(self) -> tuple[float, float]: @@ -413,18 +412,18 @@ def _get_defaults_v3(self) -> tuple[float, float]: def _get_active_hvac_mode(self) -> HVACMode: """Return hvac mode when power is ON based on current state.""" + if self.tado_coordinator.generation == GEN_X: + return HVACMode.HEAT if ( opt_mode := self.tado_coordinator.optimistic.get_zone_ac_mode(self._zone_id) ) is not None: if hvac_mode := _ac_mode_to_hvac(opt_mode): return hvac_mode - # v3 Classic: mode exists in Setting (Tado X does not expose it here) - if self.tado_coordinator.generation != GEN_X: - state = self._current_state - if state and state.setting: - if hvac_mode := _ac_mode_to_hvac(str(state.setting.mode)): - return hvac_mode + state = self._current_state + if state and state.setting: + if hvac_mode := _ac_mode_to_hvac(str(state.setting.mode)): + return hvac_mode return HVACMode.COOL @@ -498,10 +497,10 @@ def fan_mode(self) -> str | None: @property def fan_modes(self) -> list[str] | None: """Return supported fan modes (cached).""" + if self.tado_coordinator.generation == GEN_X: + return None capabilities = self.tado_coordinator.data.capabilities.get(self._zone_id) if not capabilities: - # We trigger an async update but return None for now - # HA will call this again when we call async_write_ha_state self.hass.async_create_task(self._async_update_capabilities()) return None @@ -605,6 +604,8 @@ def swing_mode(self) -> str | None: @property def swing_modes(self) -> list[str] | None: """Return supported swing modes (cached).""" + if self.tado_coordinator.generation == GEN_X: + return None capabilities = self.tado_coordinator.data.capabilities.get(self._zone_id) if not capabilities: self.hass.async_create_task(self._async_update_capabilities()) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 08de6e0..902d312 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -128,7 +128,7 @@ def __init__( # Migration: old values if self.generation in ("v2", "v3", "v2_v3", "classic"): self.generation = GEN_CLASSIC - elif self.generation == "x": + elif self.generation == GEN_X: self.generation = GEN_X self.full_cloud_mode = entry.data.get(CONF_FULL_CLOUD_MODE, False) @@ -1281,15 +1281,19 @@ async def async_add_meter_reading(self, reading: int) -> None: """Add a meter reading to Tado Energy IQ.""" if self.generation == GEN_X: _LOGGER.warning( - "Meter readings are not currently supported via Hops API in this integration" + "Meter readings via 'add_meter_reading' service are not supported for Tado X. " + "Tado X does not provide meter reading support via the Hops API used by this integration." ) - # Tado X does not support set_meter_readings via the current Hops Api wrapper we have, - # or it requires EIQ API. We will just pass for now or throw error if user tries. - else: - try: - await self.client.set_meter_readings(reading=reading) - except Exception as e: - _LOGGER.error("Failed to add meter reading: %s", e) + return + try: + await self.client.set_meter_readings(reading=reading) + except Exception as e: + _LOGGER.error( + "Failed to add meter reading via 'add_meter_reading' service. " + "This feature typically requires a tado° Auto-Assist subscription and Energy IQ enabled in your Tado account. " + "The integration's token may not have permission to write meter readings." + ) + _LOGGER.debug("Detailed Tado API error: %s", e) async def async_set_ac_setting(self, zone_id: int, key: str, value: str) -> None: """Set an AC specific setting (fan speed, swing, temperature, etc.).""" diff --git a/custom_components/tado_hijack/helpers/entity_setup.py b/custom_components/tado_hijack/helpers/entity_setup.py index 06c2466..26e66b9 100644 --- a/custom_components/tado_hijack/helpers/entity_setup.py +++ b/custom_components/tado_hijack/helpers/entity_setup.py @@ -8,6 +8,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from ..const import ( + GEN_X, ZONE_TYPE_AIR_CONDITIONING, ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER, @@ -102,7 +103,7 @@ def _process_zone_scope( # Zone types not yet supported on Tado X supported_types = definition.get("supported_zone_types") if ( - coordinator.generation == "x" + coordinator.generation == GEN_X and supported_types and supported_types.issubset(_TADOX_UNSUPPORTED_ZONE_TYPES) ): diff --git a/custom_components/tado_hijack/helpers/executor_unified.py b/custom_components/tado_hijack/helpers/executor_unified.py index 542bbe6..39875ab 100644 --- a/custom_components/tado_hijack/helpers/executor_unified.py +++ b/custom_components/tado_hijack/helpers/executor_unified.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING, Any +from ..const import GEN_X from .tadov3.executor import TadoV3Executor from .tadox.executor import TadoXExecutor @@ -27,7 +28,7 @@ def __init__(self, coordinator: TadoDataUpdateCoordinator) -> None: async def execute_batch(self, merged_data: dict[str, Any]) -> None: """Execute command batch using appropriate executor.""" - if self.coordinator.generation == "x": + if self.coordinator.generation == GEN_X: if self._x_executor: await self._x_executor.execute_batch(merged_data) else: diff --git a/custom_components/tado_hijack/sensor.py b/custom_components/tado_hijack/sensor.py index 8258eea..69ec5ef 100644 --- a/custom_components/tado_hijack/sensor.py +++ b/custom_components/tado_hijack/sensor.py @@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + GEN_X, ZONE_TYPE_HOT_WATER, ) from .entity import ( @@ -91,7 +92,7 @@ def __init__( trans_key = cast(str, definition["translation_key"]) # Special handling for heating_power label (v3 only) - if definition["key"] == "heating_power" and coordinator.generation != "x": + if definition["key"] == "heating_power" and coordinator.generation != GEN_X: zone = coordinator.zones_meta.get(zone_id) if zone and zone.type == ZONE_TYPE_HOT_WATER: trans_key = "hot_water_power" From 16deef1242f0c0d190ee852b5156c88bb81c2dbe Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 10 Jul 2026 14:37:39 +0000 Subject: [PATCH 39/53] =?UTF-8?q?chore(release):=20=F0=9F=9A=80=20publish?= =?UTF-8?q?=20version=205.7.1-dev.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [5.7.1-dev.1](https://github.com/banter240/tado_hijack/compare/v5.7.0...v5.7.1-dev.1) (2026-07-10) * fix(full_cloud): guard Tado X in TadoAirConditioning for fan/swing and hvac_mode In Full Cloud Mode on Tado X, TadoAirConditioning is the unified entity for all zones. - fan_modes/swing_modes return None for GEN_X (no capabilities endpoint) - _get_active_hvac_mode returns HEAT for GEN_X (all zones are heating) - supported_features limited for GEN_X (no FAN/SWING) - hvac_modes set to [OFF, HEAT, AUTO] for GEN_X (matching TadoHeating) Prevents event loop crash during setup and incorrect 'cooling' action on heating zones. Also improves service call diagnostics: - Added debug logs when entering service handlers (manual_poll, set_mode, set_mode_all_zones, set_water_heater_mode). - Warnings now include the service name (e.g. "(service: manual_poll)"). - Better messages in async_add_meter_reading for Tado X and general failures (permission/subscription hints). See https://github.com/banter240/tado_hijack/discussions/113 * chore: target Python 3.14 across the board Align the integration with Home Assistant's current Python 3.14 requirement. - pyproject.toml: python = "^3.14", mypy python_version = "3.14", ruff target-version = "py314" - .github/workflows/lint.yml: python-version 3.14 - Updated and pinned dev tooling: mypy==2.1.0 (native PEP 695 support, no more --enable-incomplete-feature), ruff==0.15.20, pre-commit==4.6.0, updated pytest stack - .pre-commit-config.yaml: updated hook revisions, mypy hook now pins exact mypy==2.1.0 + tadoasync==0.2.2; removed --disable-error-code and --no-warn-unused-ignores - hacs.json + pyproject: minimum homeassistant pinned to "2026.3" - requirements.txt synced with dev dependencies - Replaced remaining # type: ignore[method-assign] / [union-attr] etc. with cast(Any, ...) for monkey-patches (lib/patches.py) and dynamic attributes (coordinator.py + related) - Minor ruff-driven cleanups (logging, exception syntax, etc.) All local on ai/dev. [skip ci] --- CHANGELOG.md | 34 +++++++++++++++++++++ custom_components/tado_hijack/manifest.json | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a74d5a3..8fed5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +## [5.7.1-dev.1](https://github.com/banter240/tado_hijack/compare/v5.7.0...v5.7.1-dev.1) (2026-07-10) +* fix(full_cloud): guard Tado X in TadoAirConditioning for fan/swing and hvac_mode + +In Full Cloud Mode on Tado X, TadoAirConditioning is the unified entity for all zones. + +- fan_modes/swing_modes return None for GEN_X (no capabilities endpoint) +- _get_active_hvac_mode returns HEAT for GEN_X (all zones are heating) +- supported_features limited for GEN_X (no FAN/SWING) +- hvac_modes set to [OFF, HEAT, AUTO] for GEN_X (matching TadoHeating) + +Prevents event loop crash during setup and incorrect 'cooling' action on heating zones. + +Also improves service call diagnostics: +- Added debug logs when entering service handlers (manual_poll, set_mode, set_mode_all_zones, set_water_heater_mode). +- Warnings now include the service name (e.g. "(service: manual_poll)"). +- Better messages in async_add_meter_reading for Tado X and general failures (permission/subscription hints). + +See https://github.com/banter240/tado_hijack/discussions/113 + +* chore: target Python 3.14 across the board + +Align the integration with Home Assistant's current Python 3.14 requirement. + +- pyproject.toml: python = "^3.14", mypy python_version = "3.14", ruff target-version = "py314" +- .github/workflows/lint.yml: python-version 3.14 +- Updated and pinned dev tooling: mypy==2.1.0 (native PEP 695 support, no more --enable-incomplete-feature), ruff==0.15.20, pre-commit==4.6.0, updated pytest stack +- .pre-commit-config.yaml: updated hook revisions, mypy hook now pins exact mypy==2.1.0 + tadoasync==0.2.2; removed --disable-error-code and --no-warn-unused-ignores +- hacs.json + pyproject: minimum homeassistant pinned to "2026.3" +- requirements.txt synced with dev dependencies +- Replaced remaining # type: ignore[method-assign] / [union-attr] etc. with cast(Any, ...) for monkey-patches (lib/patches.py) and dynamic attributes (coordinator.py + related) +- Minor ruff-driven cleanups (logging, exception syntax, etc.) + +All local on ai/dev. + ## [5.7.0](https://github.com/banter240/tado_hijack/compare/v5.6.0...v5.7.0) (2026-06-30) * feat(tadox): add Tado X hot water support (auto/off only via Hops) with central guards diff --git a/custom_components/tado_hijack/manifest.json b/custom_components/tado_hijack/manifest.json index b2d6137..3321c01 100644 --- a/custom_components/tado_hijack/manifest.json +++ b/custom_components/tado_hijack/manifest.json @@ -12,5 +12,5 @@ "requirements": [ "tadoasync==0.2.2" ], - "version": "5.7.0" + "version": "5.7.1-dev.1" } From a24609090ecc6fcf20dde0c85a6b77bafd474330 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:01:00 +0200 Subject: [PATCH 40/53] Update coordinator.py --- custom_components/tado_hijack/coordinator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 8855ec6..85c3a66 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1,5 +1,7 @@ """Data Update Coordinator for Tado Hijack.""" +import asyncio + from __future__ import annotations from datetime import datetime, timedelta From 729863b473da43a7be39656d52f792a5ff946ecb Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:11:21 +0200 Subject: [PATCH 41/53] Update coordinator.py --- custom_components/tado_hijack/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 85c3a66..026f9e1 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1,12 +1,11 @@ """Data Update Coordinator for Tado Hijack.""" -import asyncio - from __future__ import annotations from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, cast +import asyncio import aiohttp from homeassistant.core import ( HomeAssistant, From 9f79582bc9b85570fef40a85c0eba62e2ead2d05 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:14:24 +0200 Subject: [PATCH 42/53] Update coordinator.py --- custom_components/tado_hijack/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 026f9e1..a068b2e 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -2,10 +2,10 @@ from __future__ import annotations +import asyncio from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, cast -import asyncio import aiohttp from homeassistant.core import ( HomeAssistant, From 01ef73ef141fa47b68dc156282a3d84422fcb730 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:25:17 +0200 Subject: [PATCH 43/53] Create ruff-autofix.yml --- .github/workflows/ruff-autofix.yml | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/ruff-autofix.yml diff --git a/.github/workflows/ruff-autofix.yml b/.github/workflows/ruff-autofix.yml new file mode 100644 index 0000000..cd07757 --- /dev/null +++ b/.github/workflows/ruff-autofix.yml @@ -0,0 +1,35 @@ +name: Ruff Autofix + +on: + pull_request: + push: + branches: + - dev + +jobs: + autofix: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install ruff + run: pip install ruff + + - name: Fix lint issues + run: ruff check --fix . + + - name: Format code + run: ruff format . + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "style: auto-fix with ruff" From efce555ff70debe80fe9125be438949cb090ecf7 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:30:08 +0200 Subject: [PATCH 44/53] Delete .github/workflows/ruff-autofix.yml --- .github/workflows/ruff-autofix.yml | 35 ------------------------------ 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/ruff-autofix.yml diff --git a/.github/workflows/ruff-autofix.yml b/.github/workflows/ruff-autofix.yml deleted file mode 100644 index cd07757..0000000 --- a/.github/workflows/ruff-autofix.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Ruff Autofix - -on: - pull_request: - push: - branches: - - dev - -jobs: - autofix: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Install ruff - run: pip install ruff - - - name: Fix lint issues - run: ruff check --fix . - - - name: Format code - run: ruff format . - - - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: "style: auto-fix with ruff" From aa81296fcc7a70efa5a0d11ebb8a71589769fd96 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:35:59 +0200 Subject: [PATCH 45/53] Update pyproject.toml --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 11b048d..ce94345 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ ignore = [ "PLR0915", # Too many statements (complex setup/service functions) "PLW0603", # Using the global statement (module-level flags for patches and log level) "TID252", # Relative imports are HA integration convention (..const, ..models etc.) + "PLR0917", # too-many-positional-arguments — acceptable pour les factory functions ] [tool.ruff.lint.per-file-ignores] From 9f375ed992d2ba813cd81b35c10b888baaf86489 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:40:57 +0200 Subject: [PATCH 46/53] Create ruff-autofix.yml --- .github/workflows/ruff-autofix.yml | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/ruff-autofix.yml diff --git a/.github/workflows/ruff-autofix.yml b/.github/workflows/ruff-autofix.yml new file mode 100644 index 0000000..cd07757 --- /dev/null +++ b/.github/workflows/ruff-autofix.yml @@ -0,0 +1,35 @@ +name: Ruff Autofix + +on: + pull_request: + push: + branches: + - dev + +jobs: + autofix: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install ruff + run: pip install ruff + + - name: Fix lint issues + run: ruff check --fix . + + - name: Format code + run: ruff format . + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "style: auto-fix with ruff" From a48e371190ba2b42af65d9d60100953ca708f02d Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:41:12 +0000 Subject: [PATCH 47/53] style: auto-fix with ruff --- custom_components/tado_hijack/coordinator.py | 8 ++++++-- custom_components/tado_hijack/definitions.py | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index a068b2e..beaf19f 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -1317,7 +1317,9 @@ async def async_refresh_all_timetables(self) -> None: _LOGGER.debug("async_refresh_all_timetables: no compatible zones found") return - _LOGGER.info("Refreshing timetables for %d zone(s): %s", len(zone_ids), zone_ids) + _LOGGER.info( + "Refreshing timetables for %d zone(s): %s", len(zone_ids), zone_ids + ) await asyncio.gather( *(self.async_refresh_timetable(zone_id) for zone_id in zone_ids) ) @@ -1342,7 +1344,9 @@ async def async_set_timetable_all_zones(self, timetable_type: str) -> None: _LOGGER.info( "Setting timetable type '%s' for %d zone(s): %s", - timetable_type, len(zone_ids), zone_ids, + timetable_type, + len(zone_ids), + zone_ids, ) await asyncio.gather( *(self.async_set_timetable(zone_id, timetable_type) for zone_id in zone_ids) diff --git a/custom_components/tado_hijack/definitions.py b/custom_components/tado_hijack/definitions.py index 7771454..c342d3d 100644 --- a/custom_components/tado_hijack/definitions.py +++ b/custom_components/tado_hijack/definitions.py @@ -1902,7 +1902,9 @@ def _parse_home_zone_mode(c: Any) -> str | None: create_home_select( key="timetable_type_all_zones", value_fn=lambda c: ( - next(iter(c.data_manager.timetable_cache.values()), {}).get("type", "ONE_DAY").lower() + next(iter(c.data_manager.timetable_cache.values()), {}) + .get("type", "ONE_DAY") + .lower() if c.data_manager.timetable_cache else "one_day" ), @@ -1928,9 +1930,7 @@ def _parse_home_zone_mode(c: Any) -> str | None: else None ), options_fn=lambda c, zid: ["one_day", "three_day", "seven_day"], - select_option_fn=lambda c, zid, val: c.async_set_timetable( - zid, val.upper() - ), + select_option_fn=lambda c, zid, val: c.async_set_timetable(zid, val.upper()), icon="mdi:calendar-week", entity_category=EntityCategory.CONFIG, supported_zone_types={ZONE_TYPE_HEATING, ZONE_TYPE_HOT_WATER}, From 9ec1a5181f92c352e2ff31724153c7b78a27dd32 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:04:47 +0200 Subject: [PATCH 48/53] Update coordinator.py line 1294 OLD entry = await self._tado.get_active_timetable(zone_id) NEW entry = await self.client.get_active_timetable(zone_id) line 394 OLD self.timetable_cache: dict[int, dict] = self.data_manager.timetable_cache NEW self.timetable_cache: dict[int, dict[str, Any]] = self.data_manager.timetable_cache --- custom_components/tado_hijack/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index beaf19f..2472af9 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -391,7 +391,7 @@ async def _async_update_data(self) -> TadoData: self.zones_meta = self.data_manager.zones_meta self.devices_meta = self.data_manager.devices_meta - self.timetable_cache: dict[int, dict] = self.data_manager.timetable_cache + self.timetable_cache: dict[int, dict[str, Any]] = self.data_manager.timetable_cache from .helpers.discovery import get_bridges @@ -1291,7 +1291,7 @@ async def async_refresh_timetable(self, zone_id: int) -> None: """Refresh the active timetable for a zone from the API.""" _LOGGER.info("Refreshing timetable for zone %s", zone_id) try: - entry = await self._tado.get_active_timetable(zone_id) + entry = await self.client.get_active_timetable(zone_id) self.data_manager.timetable_cache[zone_id] = entry self.async_update_listeners() self._save_timetable_cache() From c48893b46d1d61bb998ef5b4b394f26a2010d963 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:05:00 +0000 Subject: [PATCH 49/53] style: auto-fix with ruff --- custom_components/tado_hijack/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/tado_hijack/coordinator.py b/custom_components/tado_hijack/coordinator.py index 2472af9..662c418 100644 --- a/custom_components/tado_hijack/coordinator.py +++ b/custom_components/tado_hijack/coordinator.py @@ -391,7 +391,9 @@ async def _async_update_data(self) -> TadoData: self.zones_meta = self.data_manager.zones_meta self.devices_meta = self.data_manager.devices_meta - self.timetable_cache: dict[int, dict[str, Any]] = self.data_manager.timetable_cache + self.timetable_cache: dict[int, dict[str, Any]] = ( + self.data_manager.timetable_cache + ) from .helpers.discovery import get_bridges From cb3f78247d8f6cff855ba479c3b6b9920a390ecd Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:08:47 +0200 Subject: [PATCH 50/53] Update definitions.py LINE 394 OLD next(iter(c.data_manager.timetable_cache.values()), {}) NEW next(iter(c.data_manager.timetable_cache.values()), cast(dict[str, Any], {})) --- custom_components/tado_hijack/definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/tado_hijack/definitions.py b/custom_components/tado_hijack/definitions.py index c342d3d..4eb01a6 100644 --- a/custom_components/tado_hijack/definitions.py +++ b/custom_components/tado_hijack/definitions.py @@ -1902,7 +1902,7 @@ def _parse_home_zone_mode(c: Any) -> str | None: create_home_select( key="timetable_type_all_zones", value_fn=lambda c: ( - next(iter(c.data_manager.timetable_cache.values()), {}) + next(iter(c.data_manager.timetable_cache.values()), cast(dict[str, Any], {})) .get("type", "ONE_DAY") .lower() if c.data_manager.timetable_cache From 764b2f774390d8a3e08976dcb9dfe2a9eb3ad83e Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:08:56 +0000 Subject: [PATCH 51/53] style: auto-fix with ruff --- custom_components/tado_hijack/definitions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/tado_hijack/definitions.py b/custom_components/tado_hijack/definitions.py index 4eb01a6..46d4fc8 100644 --- a/custom_components/tado_hijack/definitions.py +++ b/custom_components/tado_hijack/definitions.py @@ -1902,7 +1902,9 @@ def _parse_home_zone_mode(c: Any) -> str | None: create_home_select( key="timetable_type_all_zones", value_fn=lambda c: ( - next(iter(c.data_manager.timetable_cache.values()), cast(dict[str, Any], {})) + next( + iter(c.data_manager.timetable_cache.values()), cast(dict[str, Any], {}) + ) .get("type", "ONE_DAY") .lower() if c.data_manager.timetable_cache From 15dbd6932643b71a37548c4d16cf8e799a7aef22 Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:25:23 +0200 Subject: [PATCH 52/53] Delete .github/workflows/ruff-autofix.yml --- .github/workflows/ruff-autofix.yml | 35 ------------------------------ 1 file changed, 35 deletions(-) delete mode 100644 .github/workflows/ruff-autofix.yml diff --git a/.github/workflows/ruff-autofix.yml b/.github/workflows/ruff-autofix.yml deleted file mode 100644 index cd07757..0000000 --- a/.github/workflows/ruff-autofix.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Ruff Autofix - -on: - pull_request: - push: - branches: - - dev - -jobs: - autofix: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - - name: Install ruff - run: pip install ruff - - - name: Fix lint issues - run: ruff check --fix . - - - name: Format code - run: ruff format . - - - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: "style: auto-fix with ruff" From c25de7b9996a79b7ad6f57f51e68dc9b67eeef0e Mon Sep 17 00:00:00 2001 From: swann05 <121623695+swann05@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:25:49 +0200 Subject: [PATCH 53/53] Update pyproject.toml --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ce94345..11b048d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,6 @@ ignore = [ "PLR0915", # Too many statements (complex setup/service functions) "PLW0603", # Using the global statement (module-level flags for patches and log level) "TID252", # Relative imports are HA integration convention (..const, ..models etc.) - "PLR0917", # too-many-positional-arguments — acceptable pour les factory functions ] [tool.ruff.lint.per-file-ignores]