Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
1f9067a
fix(quota): fix DST-driven reset prediction drift and CET->UTC migration
banter240 Apr 11, 2026
1e38971
chore(release): 🚀 publish version 5.4.1-dev.1
semantic-release-bot Apr 11, 2026
7076c72
fix: fix quota overconsumption, redundancy filter regression, and DE …
banter240 Apr 14, 2026
6c5e90c
chore(release): 🚀 publish version 5.4.1-dev.2
semantic-release-bot Apr 14, 2026
b792ee0
fix(diagnostics): increase serial number redaction suffix to 5 chars
banter240 Apr 15, 2026
f27ad5c
feat(sensors): add zone_mode and home_mode sensors (#86)
banter240 Apr 15, 2026
af8db37
fix(tadox): resolve device-to-zone mapping and pass termination to ma…
banter240 Apr 17, 2026
49fa6e6
chore(release): 🚀 publish version 5.5.0-dev.1
semantic-release-bot Apr 17, 2026
b71181d
Add files via upload
swann05 Apr 18, 2026
dd0ae60
Add files via upload
swann05 Apr 18, 2026
b3a195c
Add files via upload
swann05 Apr 18, 2026
bc6dba7
Add files via upload
swann05 Apr 18, 2026
6f891dd
Add files via upload
swann05 Apr 18, 2026
3833323
Add files via upload
swann05 Apr 19, 2026
beb2153
Add files via upload
swann05 Apr 19, 2026
d0306f3
Add files via upload
swann05 Apr 19, 2026
daa32e3
Update custom_components/tado_hijack/coordinator.py
swann05 Apr 20, 2026
3dec745
Update custom_components/tado_hijack/coordinator.py
swann05 Apr 20, 2026
3991654
Update custom_components/tado_hijack/coordinator.py
swann05 Apr 20, 2026
5c7f2d9
Update custom_components/tado_hijack/definitions.py
swann05 Apr 20, 2026
33d497a
Update definitions.py
swann05 Apr 20, 2026
2ecb8e3
Update coordinator.py
swann05 Apr 21, 2026
0db346a
Update custom_components/tado_hijack/coordinator.py
swann05 Apr 21, 2026
be16ba6
Update custom_components/tado_hijack/coordinator.py
swann05 Apr 21, 2026
50ff9a9
fix(quota): prevent reset-poll runaway loop and sync timer on detecte…
banter240 Apr 21, 2026
82e378b
chore(release): 🚀 publish version 5.5.0-dev.2
semantic-release-bot Apr 21, 2026
bb26e8d
fix(ac): populate mode, fanSpeed and swing for AC power-on overlays
banter240 Apr 30, 2026
60320fc
chore(release): 🚀 publish version 5.5.0-dev.3
semantic-release-bot Apr 30, 2026
16276c2
Merge pull request #1 from banter240/dev
swann05 May 4, 2026
24ecef4
fix(ac): add light field to AC power-on overlay payloads
banter240 May 13, 2026
b28cebe
chore(release): 🚀 publish version 5.5.0-dev.4
semantic-release-bot May 13, 2026
084e8e7
fix(device-linker): guard against non-string manufacturer in device r…
banter240 May 15, 2026
e253ea3
chore(release): 🚀 publish version 5.5.0-dev.5
semantic-release-bot May 15, 2026
c588c35
fix(ac): include required 'light' field for AIR_CONDITIONING overlays…
banter240 May 31, 2026
b93f5b3
chore(release): 🚀 publish version 5.6.1-dev.1
semantic-release-bot May 31, 2026
ca2c86a
feat(tadox): add Tado X hot water support (auto/off only via Hops) wi…
banter240 May 31, 2026
758cc9e
chore(release): 🚀 publish version 5.7.0-dev.1
semantic-release-bot Jun 1, 2026
d0990bb
Merge pull request #2 from banter240/dev-5.5.0
swann05 Jun 11, 2026
b4253b2
Merge branch 'main' into dev
swann05 Jun 11, 2026
2d1937c
Merge pull request #4 from swann05/dev
swann05 Jun 11, 2026
c276042
Merge branch 'dev' into V5.7-DEV1
swann05 Jun 11, 2026
6fc5c7b
Merge pull request #6 from swann05/V5.7-DEV1
swann05 Jun 11, 2026
e3849f7
Merge pull request #8 from swann05/dev
swann05 Jul 1, 2026
90b0558
Merge branch 'dev' into V5.7
swann05 Jul 1, 2026
08f3b35
Merge pull request #12 from swann05/V5.7
swann05 Jul 1, 2026
0b83fe4
chore: target Python 3.14 across the board
banter240 Jul 6, 2026
da7ac93
fix(full_cloud): guard Tado X in TadoAirConditioning for fan/swing an…
banter240 Jul 6, 2026
16deef1
chore(release): 🚀 publish version 5.7.1-dev.1
semantic-release-bot Jul 10, 2026
1d5f1bf
Merge pull request #13 from banter240/dev
swann05 Jul 19, 2026
02208a2
Merge branch 'dev' into V5.7.1
swann05 Jul 26, 2026
a04eb18
Merge pull request #17 from swann05/V5.7.1
swann05 Jul 26, 2026
a246090
Update coordinator.py
swann05 Jul 26, 2026
729863b
Update coordinator.py
swann05 Jul 26, 2026
9f79582
Update coordinator.py
swann05 Jul 26, 2026
01ef73e
Create ruff-autofix.yml
swann05 Jul 26, 2026
efce555
Delete .github/workflows/ruff-autofix.yml
swann05 Jul 26, 2026
aa81296
Update pyproject.toml
swann05 Jul 26, 2026
fd68076
Ruff ignore rule
swann05 Jul 26, 2026
9f375ed
Create ruff-autofix.yml
swann05 Jul 26, 2026
a48e371
style: auto-fix with ruff
swann05 Jul 26, 2026
9ec1a51
Update coordinator.py
swann05 Jul 26, 2026
c48893b
style: auto-fix with ruff
swann05 Jul 26, 2026
cb3f782
Update definitions.py
swann05 Jul 26, 2026
764b2f7
style: auto-fix with ruff
swann05 Jul 26, 2026
15dbd69
Delete .github/workflows/ruff-autofix.yml
swann05 Jul 27, 2026
c25de7b
Update pyproject.toml
swann05 Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions custom_components/tado_hijack/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast

Expand Down Expand Up @@ -277,6 +278,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(
Expand Down Expand Up @@ -380,6 +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
)

from .helpers.discovery import get_bridges

Expand Down Expand Up @@ -1239,6 +1253,107 @@ 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.data_manager.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.

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.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.client.get_active_timetable(zone_id)
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
)
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)."""
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,
)
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
) -> None:
Expand Down
53 changes: 53 additions & 0 deletions custom_components/tado_hijack/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
)


Expand Down Expand Up @@ -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(
Expand All @@ -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,
)


Expand Down Expand Up @@ -1895,4 +1899,53 @@ def _parse_home_zone_mode(c: Any) -> str | None:
optimistic_key="horizontal_swing",
supported_generations={GEN_CLASSIC},
),
create_home_select(
key="timetable_type_all_zones",
value_fn=lambda c: (
next(
iter(c.data_manager.timetable_cache.values()), cast(dict[str, Any], {})
)
.get("type", "ONE_DAY")
.lower()
if c.data_manager.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,
is_supported_fn=lambda c: c.generation == GEN_CLASSIC,
),
create_zone_select(
key="timetable_type",
value_fn=lambda c, zid: (
c.data_manager.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",
),
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",
),
]
1 change: 1 addition & 0 deletions custom_components/tado_hijack/helpers/api_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
)
Expand Down
28 changes: 28 additions & 0 deletions custom_components/tado_hijack/helpers/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
8 changes: 8 additions & 0 deletions custom_components/tado_hijack/helpers/command_merger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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"]))
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions custom_components/tado_hijack/helpers/data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions custom_components/tado_hijack/helpers/tadov3/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
1 change: 1 addition & 0 deletions custom_components/tado_hijack/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
22 changes: 22 additions & 0 deletions custom_components/tado_hijack/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,12 @@
},
"identify_device": {
"name": "Gerät identifizieren"
},
"refresh_timetable": {
"name": "Zeitplan aktualisieren"
},
"refresh_all_timetables": {
"name": "Alle Zeitpläne aktualisieren"
}
},
"number": {
Expand Down Expand Up @@ -334,6 +340,22 @@
"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"
}
},
"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": {
Expand Down
Loading