Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ This project is licensed under the MIT License.

## 📌 Changelog

### v1.1.2
- **New**: Automatic detection and configuration for TadoLocalServer (zero‑config)
*Known limitation: `localhost` is not discoverable*
- **New**: Home Assistant notification when re‑authorization is required
- **New**: Air conditioning support
- **New**: Persistent Home/Away SMART Schedule handling (requires an API call)
- **Improved**: Sensitive information is now hidden in downloaded diagnostic data
- **Improved**: Home Assistant services now include an option to set SMART Schedule persistence (requires an API call)

### v1.1.0
- **New**:Home Assistant services (stop all zones from heating, resume heating in all zones).
- **New**:Server info panel.
Expand Down
23 changes: 23 additions & 0 deletions custom_components/tado_local/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.components.persistent_notification import async_create

from .services import async_setup_services
from .const import DOMAIN, CONF_IP_ADDRESS, CONF_PORT, CONF_UPDATE_INTERVAL, PLATFORMS
Expand Down Expand Up @@ -52,6 +53,28 @@ async def async_get_data():
raise UpdateFailed(f"Errore API Status: {resp_status.status}")
status_info = await resp_status.json()

cloud_api = status_info.get("cloud_api", None)
if cloud_api:
if cloud_api.get("enabled", False):
if not cloud_api.get("authenticated", False):
if cloud_api.get("authentication_required", False):
_LOGGER.info(f"Cloud needs authentication: {cloud_api.get('message', '')}")
if cloud_api.get('user_code'):
async_create(
hass,
f"{cloud_api.get('message', '')}\nUsercode: {cloud_api.get('user_code')}, Expires in: {cloud_api.get('auth_expires_in')} seconds.",
title="Tado Local: Cloud Authentication Required",
notification_id="tado_local_cloud_auth",
)
else:
async_create(
hass,
"Look in TadoLocal Sever log for details.",
title="Tado Local: Cloud Authentication Required",
notification_id="tado_local_cloud_auth",
)


zones_list = zones_json.get("zones", zones_json) if isinstance(zones_json, dict) else zones_json
devices_list = devices_json.get("devices", devices_json) if isinstance(devices_json, dict) else devices_json

Expand Down
47 changes: 46 additions & 1 deletion custom_components/tado_local/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from urllib.parse import urlparse
from homeassistant.core import HomeAssistant
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
Expand All @@ -8,6 +9,7 @@
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.network import get_url

from .const import DOMAIN, MANUFACTURER, format_model, MASTER_DEVICE

Expand All @@ -19,12 +21,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
coordinator = data["coordinator"]
base_url = data["base_url"]

# if the base_url contains localhost, replace it with the actual HA host to ensure connectivity from other devices
if "localhost" in base_url:
ha_host = urlparse(get_url(hass)).hostname
base_url = base_url.replace("localhost", ha_host)

entities = []

zones = coordinator.data.get("zones", [])
for zone in zones:
entities.append(TadoZoneHeating(coordinator, zone))
entities.append(TadoZoneOpenWindow(coordinator, zone))
if zone.get("zone_type", "HEATING") == "AIR_CONDITIONING":
entities.append(TadoZoneCooling(coordinator, zone))

devices = coordinator.data.get("devices", [])
for device in devices:
Expand Down Expand Up @@ -74,6 +83,40 @@ def is_on(self):
# cur_heating can be 0=off, 1=heating, 2=cooling
return val == 1
return False

class TadoZoneCooling(CoordinatorEntity, BinarySensorEntity):
_attr_has_entity_name = True
_attr_icon = "mdi:air-conditioner"
_attr_translation_key = "cooling_active"

def __init__(self, coordinator, zone_data):
super().__init__(coordinator)
self._zone_id = zone_data.get("zone_id") or zone_data.get("id")
self._zone_name = zone_data.get("name")
self._attr_unique_id = f"tado_local_cooling_{self._zone_id}"
self._tado_zone_id = zone_data.get("tado_zone_id", "")

@property
def device_info(self):
return {
"configuration_url": f"https://app.tado.com/en/main/home/zoneV2/{self._tado_zone_id}/",
"identifiers": {(DOMAIN, "zone", self._zone_id)},
"name": self._zone_name,
"manufacturer": MANUFACTURER,
"model": format_model("zone_control"),
}

@property
def is_on(self):
zones = self.coordinator.data.get("zones", [])
for zone in zones:
zid = zone.get("zone_id") or zone.get("id")
if zid == self._zone_id:
state = zone.get("state", zone)
val = state.get("cur_heating", 0)
# cur_heating can be 0=off, 1=heating, 2=cooling
return val == 2
return False


class TadoZoneOpenWindow(CoordinatorEntity, BinarySensorEntity):
Expand Down Expand Up @@ -129,7 +172,9 @@ def __init__(self, coordinator, device_data):
via_device = (DOMAIN, "zone", zone_id)

raw_model = device_data.get("device_type", "Device")
raw_model += (" " + device_data.get("model")) or ""
model = device_data.get("model")
if model:
raw_model += " " + model
sw_version = device_data.get("firmware_version", None)

self._device_info_data = {
Expand Down
96 changes: 84 additions & 12 deletions custom_components/tado_local/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
from homeassistant.components.climate.const import (
HVACMode,
ClimateEntityFeature,
PRESET_AWAY,
PRESET_HOME,
PRESET_NONE,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
Expand Down Expand Up @@ -47,9 +50,10 @@ class TadoLocalClimate(CoordinatorEntity, ClimateEntity):
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.PRESET_MODE
)

_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO]
_attr_preset_modes = [PRESET_HOME, PRESET_AWAY, PRESET_NONE]

def __init__(self, coordinator, initial_data, base_url):
super().__init__(coordinator)
Expand All @@ -58,6 +62,14 @@ def __init__(self, coordinator, initial_data, base_url):
self._device_name = initial_data.get("name", f"Zone {self._zone_id}")
self._attr_unique_id = f"tado_local_zone_{self._zone_id}"
self._base_url = base_url
self._attr_preset_mode = PRESET_NONE
self._update_preset_count = 0

self._can_cool = (initial_data.get("zone_type", "HEATING") == "AIR_CONDITIONING")
if self._can_cool:
self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF, HVACMode.AUTO]
else:
self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO]

@property
def device_info(self):
Expand Down Expand Up @@ -90,34 +102,94 @@ def target_temperature(self):
@property
def hvac_mode(self) -> HVACMode:
mode = self._zone_data.get("mode")
# It takes a while for Tado to update the mode after a preset change,
# so we need wait a few updates before adjusting the preset mode
if self._update_preset_count > 0:
self._update_preset_count -= 1

if mode == 0:
if self._attr_preset_mode == PRESET_HOME and self._update_preset_count == 0:
self._attr_preset_mode = PRESET_NONE
return HVACMode.OFF
return HVACMode.HEAT

if self._attr_preset_mode == PRESET_HOME:
return HVACMode.AUTO

if self._attr_preset_mode == PRESET_AWAY and self._update_preset_count == 0:
self._attr_preset_mode = PRESET_NONE
return HVACMode.COOL if (mode == 2 and self._can_cool) else HVACMode.HEAT

async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.AUTO:
self._attr_preset_mode = PRESET_NONE
await self._async_send_zone_update(temperature=-1) # Tado will decide the mode based on previous mode
return

mode = None
temp_param = None
if hvac_mode == HVACMode.OFF:
mode = 0 # TadoLocal will igrore the temperature 0 param when heating mode is supported in newer API
temp_param = 0
elif hvac_mode == HVACMode.AUTO:
temp_param = -1
elif hvac_mode == HVACMode.HEAT:
target = self.target_temperature
if target is None or target < 5:
target = 21
temp_param = target
mode = 1
temp_param = -1 # TadoLocal will igrore the temperature -1 param when heating mode is supported in newer API
elif hvac_mode == HVACMode.COOL and self._can_cool:
mode = 2
else:
_LOGGER.debug("Errore invalid mode")
return

# For backwards API compatibility we need to send temperature param for mode change, will be ignored by Tado if heating_mode is supported
await self._async_send_zone_update(temperature=temp_param, mode=mode)

async def async_set_preset_mode(self, preset_mode: str) -> None:
self._attr_preset_mode = preset_mode

if preset_mode == PRESET_NONE:
return

# It takes a while for Tado to update the mode after a preset change,
# so we need to track the last preset and reset it after a few updates
# This is a workaround to avoid sending a preset mode again pending the previous one
# wasting API calls
if self._update_preset_count > 0:
return
self._update_preset_count = 4

url = f"{self._base_url}/zones/{self._zone_id}/set"
params = {
"persistant": "true",
"heating_enabled": "true" if preset_mode == PRESET_HOME else "false"
}

if temp_param is not None:
await self._async_send_zone_update(temp_param)
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params) as response:
if response.status != 200:
_LOGGER.error("Errore update Tado: %s", await response.text())
else:
await self.coordinator.async_request_refresh()
except Exception as err:
_LOGGER.error("Errore connessione update: %s", err)

_LOGGER.debug("Preset: %s ", self._attr_preset_mode)
self._attr_preset_mode = preset_mode

async def async_set_temperature(self, **kwargs) -> None:
temp = kwargs.get(ATTR_TEMPERATURE)
if temp is None:
return
await self._async_send_zone_update(temp)

async def _async_send_zone_update(self, temperature):
async def _async_send_zone_update(self, temperature=None, mode=None):
url = f"{self._base_url}/zones/{self._zone_id}/set"
params = {"temperature": str(temperature)}
params = {}
if temperature is not None:
params["temperature"] = str(temperature)
if mode is not None:
params["heating_mode"] = str(mode)
if len(params) == 0:
return

async with aiohttp.ClientSession() as session:
try:
Expand Down
79 changes: 78 additions & 1 deletion custom_components/tado_local/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ async def validate_input(hass: HomeAssistant, data: Dict[str, Any]) -> None:
async with session.get(url) as response:
if response.status != 200:
raise Exception("Status code not 200")
js = await response.json()
if js.get("service") != "Tado Local":
raise Exception("not_tado_local")


class TadoLocalConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Gestisce il flusso di configurazione per Tado Local."""
Expand All @@ -42,6 +46,9 @@ def async_get_options_flow(config_entry):
# Passiamo la config_entry al costruttore
return TadoLocalOptionsFlowHandler(config_entry)

# -------------------------------------------------------------------------
# USER STEP (manual setup)
# -------------------------------------------------------------------------
async def async_step_user(
self, user_input: Optional[Dict[str, Any]] = None
) -> FlowResult:
Expand All @@ -54,8 +61,11 @@ async def async_step_user(
except Exception:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id("tado_local_server")
self._abort_if_unique_id_configured()

return self.async_create_entry(
title=f"Tado Local ({user_input[CONF_IP_ADDRESS]})",
title=f"Tado Local ({user_input[CONF_IP_ADDRESS]})",
data=user_input
)

Expand All @@ -69,6 +79,73 @@ async def async_step_user(
step_id="user", data_schema=data_schema, errors=errors
)

# -------------------------------------------------------------------------
# ZEROCONF DISCOVERY STEP
# -------------------------------------------------------------------------
async def async_step_zeroconf(self, discovery_info) -> FlowResult:
"""Handle mDNS discovery of tado-local."""
host = discovery_info.host
port = discovery_info.port or 4407

# Validate the discovered server
try:
url = f"http://{host}:{port}/api"
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=5) as resp:
if resp.status != 200:
return self.async_abort(reason="cannot_connect")
data = await resp.json()
if data.get("service") != "Tado Local":
return self.async_abort(reason="not_tado_local")
except Exception:
return self.async_abort(reason="cannot_connect")

# Prevent duplicates
await self.async_set_unique_id("tado_local_server")
self._abort_if_unique_id_configured()

# Store discovery info for confirm step
self._discovered_host = host
self._discovered_port = port

return await self.async_step_confirm()

# -------------------------------------------------------------------------
# CONFIRMATION STEP
# -------------------------------------------------------------------------
async def async_step_confirm(self, user_input=None) -> FlowResult:
def _is_local_address(ip: str) -> bool:
import socket
try:
host_ips = socket.gethostbyname_ex(socket.gethostname())[2]
return ip in host_ips
except Exception:
return False

display_ip = "localhost" if _is_local_address(self._discovered_host) else self._discovered_host

"""Ask user to confirm adding the discovered server."""
if user_input is not None:
return self.async_create_entry(
title=f"Tado Local ({display_ip})",
data={
CONF_IP_ADDRESS: self._discovered_host, # keep real IP internally
CONF_PORT: self._discovered_port,
CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
}
)

return self.async_show_form(
step_id="confirm",
description_placeholders={
"ip": display_ip,
"port": self._discovered_port,
}
)

# -----------------------------------------------------------------------------
# OPTIONS FLOW
# -----------------------------------------------------------------------------
class TadoLocalOptionsFlowHandler(config_entries.OptionsFlow):
"""Gestisce la riconfigurazione delle opzioni."""

Expand Down
Loading