From 30afab1e588f4ee428a48854ca724ac4d05733d1 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 23 Mar 2026 12:20:49 +0100 Subject: [PATCH 1/2] event --- home-assistant/config.yaml | 9 +- home-assistant/run.sh | 29 +- home-assistant/translations/en.yaml | 4 + home-assistant/translations/nl.yaml | 4 + tado_local/__main__.py | 15 +- tado_local/api.py | 8 +- tado_local/cloud.py | 152 +++++- tado_local/routes.py | 414 ++++++++++++++-- tado_local/state.py | 208 ++++++-- tado_local/static/index.html | 330 ++++++++++++- tado_local/sync.py | 12 +- tests/test_localapi.py | 74 +++ tests/test_routes.py | 719 +++++++++++++++++++++++++++- tests/test_state.py | 96 +++- tests/test_sync.py | 2 +- 15 files changed, 1928 insertions(+), 148 deletions(-) diff --git a/home-assistant/config.yaml b/home-assistant/config.yaml index 401406f..10de3f9 100644 --- a/home-assistant/config.yaml +++ b/home-assistant/config.yaml @@ -1,11 +1,12 @@ name: "TadoLocal Server" description: "TadoLocal Server, based on AmpScm/TadoLocal" -version: "1.1.1" +version: "1.1.2" slug: "tado-local-server" url: "https://github.com/AmpScm/TadoLocal" init: false webui: "http://[HOST]:[PORT:4407]" arch: + - aarch64 - amd64 - armhf - armv7 @@ -19,13 +20,15 @@ options: bridge_ip: "1.2.3.4" bridge_pin: "123-45-678" keep_db_private: true - log_level: info + purge_history: 0 accessories: [] + log_level: info schema: bridge_ip: str bridge_pin: str keep_db_private: bool - log_level: list(debug|info) + purge_history: int accessories: - ip: str pin: str + log_level: list(debug|info) \ No newline at end of file diff --git a/home-assistant/run.sh b/home-assistant/run.sh index 2b77e2f..d0801c1 100644 --- a/home-assistant/run.sh +++ b/home-assistant/run.sh @@ -1,7 +1,9 @@ #!/usr/bin/with-contenv bashio -echo "Tado-local server starting.." +echo "Tado-local server starting..." CONFIG_PSTH=/data/options.json ARGS="" +PRIVATE_DB_PATH=/data/tado-local.db +PUBLIC_DB_PATH=/homeassistant_config/.storage/tado-local.db # Get the variables from HA BRIDGE_IP="$(bashio::config 'bridge_ip')" @@ -21,19 +23,28 @@ fi # Determine where to store the database based on the keep_db_private option LOCAL_DB="$(bashio::config 'keep_db_private')" if [ "$LOCAL_DB" = true ]; then - echo "INFO: keep_db_private is true, using /data/tado-local.db (not accessible outside the container)" - ARGS="${ARGS} --state /data/tado-local.db" + echo "INFO: keep_db_private is true, using ${PRIVATE_DB_PATH} (not accessible outside the container)" + ARGS="${ARGS} --state ${PRIVATE_DB_PATH}" else - echo "INFO: keep_db_private is false, using /homeassistant_config/.storage/tado-local.db (accessible outside the container)" - ARGS="${ARGS} --state /homeassistant_config/.storage/tado-local.db" - if [ -f "/data/tado-local.db" ]; then + echo "INFO: keep_db_private is false, using ${PUBLIC_DB_PATH} (accessible outside the container)" + ARGS="${ARGS} --state ${PUBLIC_DB_PATH}" + if [ -f "${PRIVATE_DB_PATH}" ] && [ ! -f "${PUBLIC_DB_PATH}" ]; then # Forward compatibility: if the old database location exists, move it to the new location and use it. - echo "*** WARNING: DB found at /data/tado-local.db. move to new location and use it. ***" - echo "--- mv /data/tado-local.db /homeassistant_config/.storage/tado-local.db" - mv /data/tado-local.db /homeassistant_config/.storage/tado-local.db + echo "*** WARNING: DB found at ${PRIVATE_DB_PATH}. move to new location and use it. ***" + echo "--- mv ${PRIVATE_DB_PATH} ${PUBLIC_DB_PATH}" + mv $PRIVATE_DB_PATH $PUBLIC_DB_PATH fi fi +# Check if purge_history is set and add it to the arguments +PURGE_DAYS="$(bashio::config 'purge_history')" +if [ -n "$PURGE_DAYS" ] && [[ "$PURGE_DAYS" =~ ^[0-9]+$ ]] && [ "$PURGE_DAYS" -ne 0 ]; then + echo "INFO: purge history after ${PURGE_DAYS} days" + ARGS="${ARGS} --purgehistory ${PURGE_DAYS}" +elif [ -n "$PURGE_DAYS" ] && ! [[ "$PURGE_DAYS" =~ ^[0-9]+$ ]]; then + echo "WARNING: Invalid purge_history value '${PURGE_DAYS}'. It must be a non-negative integer. Ignoring purge_history setting." +fi + # Get the number of accessories configured in HA ACCESSORY_COUNT="$(bashio::config 'accessories|length' || echo 0)" echo "Accessories found: ${ACCESSORY_COUNT}" diff --git a/home-assistant/translations/en.yaml b/home-assistant/translations/en.yaml index 66d617d..69dcbd5 100644 --- a/home-assistant/translations/en.yaml +++ b/home-assistant/translations/en.yaml @@ -19,6 +19,10 @@ configuration: description: "Store the TadoLocal database in the add-on data folder (/data) instead of placing it in the Home Assistant configuration folder (/config/.storage). By placing it in /config/.storage, it becomes accessible or editable via Home Assistant." + purge_history: + name: "Purge history after (days)" + description: "Automatically purge device state history records older than the specified number of days. Set to 0 to disable automatic purging." + accessories: name: "Accessories" description: "List of Tado accessories to pair. (Like Smart AC Control)" diff --git a/home-assistant/translations/nl.yaml b/home-assistant/translations/nl.yaml index da19861..e1223aa 100644 --- a/home-assistant/translations/nl.yaml +++ b/home-assistant/translations/nl.yaml @@ -19,6 +19,10 @@ configuration: description: "Bewaar de TadoLocal database in de datafolder (/data) van de add-on in plaats van deze in de Home Assistant-configuratiemap (/config/.storage) te plaatsen. Door de /config/.storage te plaatsen is deze te benaderen of bewerken via HA." + purge_history: + name: "Geschiedenis automatisch opschonen na (dagen)" + description: "Automatisch geschiedenisrecords van apparaatstatussen ouder dan het opgegeven aantal dagen opschonen. Stel in op 0 om automatisch opschonen uit te schakelen." + accessories: name: "Accessoires" description: "Lijst met Tado-accessoires die gepaired moeten worden. (Zoals Smart AC Control)" diff --git a/tado_local/__main__.py b/tado_local/__main__.py index df68617..5f34052 100644 --- a/tado_local/__main__.py +++ b/tado_local/__main__.py @@ -97,7 +97,7 @@ def handle_signal(signum, frame): tado_api = TadoLocalAPI(str(db_path)) # Initialize Tado Cloud API (always enabled) - cloud_api = TadoCloudAPI(str(db_path), tado_api=tado_api) + cloud_api = TadoCloudAPI(str(db_path), tado_api=tado_api, purge_history_days=args.purgehistory) # Check if already authenticated if not cloud_api.is_authenticated(): @@ -404,6 +404,9 @@ def main(): # Reconnect to a previously paired standalone accessory (no PIN needed) tado-local --bridge-ip 192.168.1.100 --accessory-ip 192.168.1.101 + # Keep database clean by clearing device history after 30 days + tado-local --bridge-ip 192.168.1.100 --accessory-ip 192.168.1.101 --purgehistory 30 + API Endpoints: GET / - API information GET /status - System status @@ -464,6 +467,10 @@ def main(): "--accessory-pin", action="append", default=[], help="HomeKit PIN for a standalone accessory (repeatable, order must match --accessory-ip)" ) + parser.add_argument( + "--purgehistory", type=int, + help="Automatically delete device history records older than specified number of days (e.g., 30)" + ) # Parse CLI arguments args = parser.parse_args() @@ -540,6 +547,12 @@ def main(): logger.error(f"Failed to write PID file: {e}") exit(1) + # Check that --purgehistory value is valid if provided + if args.purgehistory is not None: + if args.purgehistory < 7: + logger.error("Invalid value for --purgehistory: must be a non-negative integer and at least 7 days") + exit(1) + # Run with proper error handling try: asyncio.run(run_server(args)) diff --git a/tado_local/api.py b/tado_local/api.py index f515104..78c7706 100644 --- a/tado_local/api.py +++ b/tado_local/api.py @@ -196,10 +196,10 @@ def _process_raw_accessories(self, raw_accessories): for service in a.get('services', []): # AccessoryInformation service UUID - if service.get('type') == '0000003E-0000-1000-8000-0026BB765291': + if service.get('type').upper() == '0000003E-0000-1000-8000-0026BB765291': for char in service.get('characteristics', []): # SerialNumber characteristic UUID - if char.get('type') == '00000030-0000-1000-8000-0026BB765291': + if char.get('type').upper() == '00000030-0000-1000-8000-0026BB765291': serial_number = char.get('value') break if serial_number: @@ -295,7 +295,7 @@ async def initialize_device_states(self): for pairing_id, items in by_pairing.items(): pairing = self.aid_to_pairing.get(items[0][0], self.pairing) for i in range(0, len(items), batch_size): - batch = items[i : i + batch_size] + batch = items[i: i + batch_size] char_keys = [(aid, iid) for aid, iid, _, _ in batch] try: @@ -931,7 +931,7 @@ async def _poll_characteristics(self, char_list, source="POLLING"): for pairing_id, items in by_pairing.items(): pairing = self.aid_to_pairing.get(items[0][0], self.pairing) for i in range(0, len(items), batch_size): - batch = items[i : i + batch_size] + batch = items[i: i + batch_size] try: results = await pairing.get_characteristics(batch) diff --git a/tado_local/cloud.py b/tado_local/cloud.py index d6eb677..84f5a4a 100644 --- a/tado_local/cloud.py +++ b/tado_local/cloud.py @@ -70,7 +70,7 @@ import asyncio import logging import time -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List from datetime import datetime, timedelta import sqlite3 import json @@ -204,7 +204,7 @@ class TadoCloudAPI: # User-Agent for API identification and communication channel USER_AGENT = f"TadoLocal/{__version__} (+https://github.com/ampscm/TadoLocal)" - def __init__(self, db_path: str, tado_api: TadoLocalAPI): + def __init__(self, db_path: str, tado_api: TadoLocalAPI, purge_history_days: Optional[int] = None): """Initialize Tado Cloud API client. Args: @@ -218,6 +218,7 @@ def __init__(self, db_path: str, tado_api: TadoLocalAPI): self.home_id: Optional[int] = None self._ensure_schema() self._load_tokens() + self.purge_history_days = purge_history_days # Background token refresh task self._refresh_task: Optional[asyncio.Task] = None @@ -640,6 +641,16 @@ async def _background_sync_loop(self): zones = await self.get_zones() last_static_sync = current_time + # Purge old history if configured (once per day is sufficient) + if self.purge_history_days: + purge_result = self.tado_api.state_manager.purge_device_history(self.purge_history_days) + logger.debug( + "History purge during cloud sync: deleted=%s remaining=%s cutoff=%s", + purge_result.get('deleted_rows'), + purge_result.get('remaining_rows'), + purge_result.get('cutoff') + ) + # Sync to database from .sync import TadoCloudSync sync = TadoCloudSync(self.db_path) @@ -942,11 +953,148 @@ async def _fetch_with_cache( logger.error(f"Error fetching {url}: {e}") return None + async def _switch_zones_to_smartschedule( + self, + zones: List[int], + ) -> Optional[Dict[str, Any]]: + """ + Delete zone overlay to return to SMART Schedule via Tado Cloud API. + WARNING: This will cost an API call and should only be used for reseting manual overrides + + Args: + zones: list of Zone IDs + + Returns: + Response data dict or None on error + """ + if aiohttp is None: + logger.error("aiohttp not installed") + return None + + if not self.home_id: + logger.error("Cannot delete overlay: no home_id set") + return None + + if zones is None or len(zones) == 0: + logger.error("No zones provided to enable SMART schedule") + return None + + # Call API + try: + headers = await self.get_headers() + + rooms = ",".join(str(zone) for zone in zones) + url = f"{self.API_BASE_URL}/homes/{self.home_id}/overlay?rooms={rooms}" + + async with aiohttp.ClientSession() as session: + logger.debug(f"Deleting {url} to switch back to SMART schedule") + async with session.delete(url, headers=headers) as resp: + # Update rate limit tracking from response headers + self._update_rate_limit(resp.headers) + + # Success + if resp.status == 204: + logger.debug(f"Switched back to SMART schedule: Deleted {url}") + return {"result": "success"} + + # Rate limit exceeded + elif resp.status == 429: + error_text = await resp.text() + logger.error(f"Rate limit exceeded for {url}: {error_text}") + logger.warning(f"Tado API rate limit: {self.rate_limit.remaining_calls}/{self.rate_limit.granted_calls} calls remaining") + return None + + # Error + else: + error_text = await resp.text() + data = await resp.json() + logger.error(f"Failed to delete {url}: HTTP {resp.status} - {error_text} - {data} ") + return None + + except Exception as e: + logger.error(f"Error switching to SMART schedule {url}: {e}") + return None + + async def _switch_zones_persistant_off( + self, + zones: List[int], + ) -> Optional[Dict[str, Any]]: + """ + Put zone overlay to persistant off mode via Tado Cloud API. + WARNING: This will cost an API call and should be used with care + + Args: + zones: list of Zone IDs + + Returns: + Response data dict or None on error + """ + if aiohttp is None: + logger.error("aiohttp not installed") + return None + + if not self.home_id: + logger.error("Cannot set overlay: no home_id set") + return None + + if zones is None or len(zones) == 0: + logger.error("No zones provided to set persistent off overlay") + return None + + # Call API + try: + headers = await self.get_headers() + + data = {"overlays": []} + for zone in zones: + data["overlays"].append({ + "overlay": { + "setting": { + "power": "OFF", + "type": "HEATING" + }, + "termination": { + "typeSkillBasedApp": "MANUAL"} + }, + "room": zone + }) + + url = f"{self.API_BASE_URL}/homes/{self.home_id}/overlay" + + async with aiohttp.ClientSession() as session: + logger.debug(f"Putting {url} to switch SMART schedule persistant off") + async with session.post(url, headers=headers, json=data) as resp: + # Update rate limit tracking from response headers + self._update_rate_limit(resp.headers) + + # Success + if resp.status == 204: + logger.debug(f"Set persistent OFF overlay via {url}") + return {"result": "success"} + + # Rate limit exceeded + elif resp.status == 429: + error_text = await resp.text() + logger.error(f"Rate limit exceeded for {url}: {error_text}") + logger.warning(f"Tado API rate limit: {self.rate_limit.remaining_calls}/{self.rate_limit.granted_calls} calls remaining") + return None + + # Error + else: + error_text = await resp.text() + logger.error(f"Failed to POST {url}: HTTP {resp.status} - {error_text}") + return None + + except Exception as e: + logger.error(f"Error switching off SMART schedule {url}: {e}") + return None + # ======================================================================== # Tado Cloud API Methods # ======================================================================== async def get_home_info(self, force_refresh: bool = False) -> Optional[Dict[str, Any]]: + """ Get home information from Tado Cloud API. diff --git a/tado_local/routes.py b/tado_local/routes.py index 2a49528..5138fd1 100644 --- a/tado_local/routes.py +++ b/tado_local/routes.py @@ -44,6 +44,12 @@ API_KEYS_RAW = os.environ.get('TADO_API_KEYS', '').strip() API_KEYS = set(key.strip() for key in API_KEYS_RAW.split() if key.strip()) if API_KEYS_RAW else set() +MODE_LABELS = { + 0: "OFF", + 1: "HEAT", + 2: "COOL" +} + def get_api_key(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[str]: """ @@ -324,7 +330,7 @@ async def get_thermostats(api_key: Optional[str] = Depends(get_api_key)): for accessory in accessories: services = accessory.get('services', []) for service in services: - if service.get('type') == '0000004A-0000-1000-8000-0026BB765291': # Thermostat service + if service.get('type').upper() == '0000004A-0000-1000-8000-0026BB765291': # Thermostat service device_id = accessory.get('id') if not device_id: @@ -357,7 +363,7 @@ async def get_thermostats(api_key: Optional[str] = Depends(get_api_key)): 'target_temp_c': target_temp_c, 'target_temp_f': round(target_temp_c * 9/5 + 32, 1) if target_temp_c is not None else None, 'mode': state.get('target_heating_cooling_state', 0), - 'cur_heating': 1 if state.get('current_heating_cooling_state') == 1 else 0, + 'cur_heating': state.get('current_heating_cooling_state'), 'valve_position': state.get('valve_position'), 'battery_low': battery_low, } @@ -390,7 +396,7 @@ async def get_thermostat(thermostat_id: int, api_key: Optional[str] = Depends(ge # Check if it's a thermostat is_thermostat = False for service in accessory.get('services', []): - if service.get('type') == '0000004A-0000-1000-8000-0026BB765291': + if service.get('type').upper() == '0000004A-0000-1000-8000-0026BB765291': is_thermostat = True break @@ -424,7 +430,7 @@ async def get_thermostat(thermostat_id: int, api_key: Optional[str] = Depends(ge 'target_temp_c': target_temp_c, 'target_temp_f': round(target_temp_c * 9/5 + 32, 1) if target_temp_c is not None else None, 'mode': state.get('target_heating_cooling_state', 0), - 'cur_heating': 1 if state.get('current_heating_cooling_state') == 1 else 0, + 'cur_heating': state.get('current_heating_cooling_state'), 'valve_position': state.get('valve_position'), 'battery_low': battery_low, } @@ -441,7 +447,7 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): - Current temperature (°C and °F) - Current humidity (%) - Target temperature (°C and °F) - - Mode (0=Off, 1=Heat) - TargetHeatingCoolingState + - Mode (0=Off, 1=Heat, 2=Cool) - TargetHeatingCoolingState - Currently heating (0=Off, 1=Heating, 2=Cooling) - CurrentHeatingCoolingState Note: Mode values depend on device capabilities. Heating-only devices typically @@ -470,6 +476,7 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): order_id = zone_info['order_id'] leader_serial = zone_info['leader_serial'] leader_type = zone_info['leader_type'] + zone_type = zone_info['zone_type'] is_circuit_driver = zone_info['is_circuit_driver'] tado_zone_id = zone_info['tado_zone_id'] window_open_time = zone_info['window_open_time'] @@ -520,15 +527,15 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): # Circuit driver WITH other devices - use radiator valve heating state (real state) for dev_id in other_devices: dev_state = tado_api.state_manager.get_current_state(dev_id) - if dev_state and dev_state.get('current_heating_cooling_state') == 1: - cur_heating = 1 + if dev_state and dev_state.get('current_heating_cooling_state') in (1, 2): + cur_heating = dev_state.get('current_heating_cooling_state') break else: # Circuit driver ALONE in zone - use its own heating state - cur_heating = 1 if zone_state.get('current_heating_cooling_state') == 1 else 0 + cur_heating = zone_state.get('current_heating_cooling_state') else: # Regular zone leader (not circuit driver) - use its heating state - cur_heating = 1 if zone_state.get('current_heating_cooling_state') == 1 else 0 + cur_heating = zone_state.get('current_heating_cooling_state') # Convert temperatures to Fahrenheit cur_temp_f = round(current_temp * 9/5 + 32, 1) if current_temp is not None else None @@ -563,6 +570,7 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): 'leader_device_id': leader_device_id, 'leader_serial': leader_serial, 'leader_type': leader_type, + 'zone_type': zone_type, 'tado_zone_id': tado_zone_id, 'is_circuit_driver': bool(is_circuit_driver), 'order_id': order_id, @@ -596,6 +604,190 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): 'count': len(zones) } + @app.post("/zones/set", tags=["Zones"]) + async def set_all_zones( + heating_enabled: Optional[bool] = None, + persistant: Optional[bool] = False, + api_key: Optional[str] = Depends(get_api_key) + ): + """ + Set heating mode for all zones, with optional persistent cloud handling. + + Control a zone's heating via its leader device or via Cloud API for persistent control. + + Args: + zone_id: Zone ID to control + heating_enabled: Enable/disable heating mode (true/false) TRUE return to last know heating mode (heat/cool) FALSE turn off heating. + persistant: If true, a Cloud API command will be sent to enable/disable the SMART schedule for unlimited time. + This will cost a cloud API call and requires the cloud API to be enabled and authenticated. + If false, a temporary overlay will be applied that will automatically expire according the Tado App setting. + + Returns: + Success status and applied values + + Notes: + - Commands are sent to the zone's leader device + - The leader propagates changes to other devices as needed + - heating_enabled controls the heat/cool mode (OFF=0, HEAT=1/COOL=2 depending on last active mode) + - if persistant is true, the SMART schedule will be enabled/disabled via Cloud API + for unlimited time until manually changed again. This is useful for scenarios like + "away mode" where you want to ensure the setting persists stop the SMART schedule from re-enabling heating. + heating_enabled=true + persistant=true will enable the SMART schedule again. + If persistant is false, a temporary overlay will be applied that will automatically expire + according the Tado App setting. + """ + + tado_api = get_tado_api() + if not tado_api: + raise HTTPException(status_code=503, detail="API not initialized") + + if heating_enabled is None: + raise HTTPException(status_code=400, detail="At heating mode should be given") + + conn = sqlite3.connect(tado_api.state_manager.db_path) + cursor = conn.execute( + """ + SELECT z.zone_id, z.name, z.tado_zone_id, z.leader_device_id, d.serial_number, z.zone_type + FROM zones z + LEFT JOIN devices d ON z.leader_device_id = d.device_id + ORDER BY z.zone_id + """ + ) + zones = cursor.fetchall() + conn.close() + + if not zones: + return { + 'success': True, + 'count': 0, + 'handling': 'noop', + 'zones': [] + } + + if persistant is True: + cloud_api = getattr(tado_api, 'cloud_api', None) + if not cloud_api: + raise HTTPException(status_code=503, detail="Persistent mode requires cloud API to be enabled") + if not cloud_api.is_authenticated(): + raise HTTPException(status_code=401, detail="Persistent mode requires cloud API authentication") + + missing_tado_zone_ids = [zone_name for _, zone_name, tado_zone_id, _, _, _ in zones if not tado_zone_id] + if missing_tado_zone_ids: + raise HTTPException( + status_code=400, + detail=f"Zones missing tado_zone_id required for cloud control: {', '.join(missing_tado_zone_ids)}" + ) + + tado_zone_ids = [tado_zone_id for zi, n, tado_zone_id, ldi, sn, zt in zones] + + try: + if heating_enabled: + cloud_result = await cloud_api._switch_zones_to_smartschedule(tado_zone_ids) + else: + cloud_result = await cloud_api._switch_zones_persistant_off(tado_zone_ids) + + if cloud_result is None: + raise HTTPException(status_code=502, detail="Cloud API command failed") + + logger.info("All zones: smart-schedule=%s via Cloud API", heating_enabled) + + return { + 'success': True, + 'count': len(zones), + 'handling': 'cloud_call', + 'applied': { + 'target_temperature': None, + 'heating_enabled': heating_enabled + }, + 'zones': [ + { + 'zone_id': zone_id, + 'zone_name': zone_name, + 'tado_zone_id': tado_zone_id + } + for zone_id, zone_name, tado_zone_id, id, sn, zt_ in zones + ] + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed persistent control for all zones: {e}") + raise HTTPException(status_code=500, detail=f"Failed to set persistent cloud control for all zones: {str(e)}") + + if not tado_api.pairing: + raise HTTPException(status_code=503, detail="Bridge not connected") + + char_updates = { + 'target_heating_cooling_state': None # will be set per zone based on heating_enabled and zone type (HEATING vs AIR_CONDITIONING) + } + + conn = sqlite3.connect(tado_api.state_manager.db_path) + results = [] + errors = [] + for zone_id, zone_name, _, leader_device_id, leader_serial, zone_type in zones: + # Fallback: if zone has no explicit leader, use first device in zone + if not leader_device_id: + cur = conn.execute( + "SELECT device_id, serial_number FROM devices WHERE zone_id = ? ORDER BY device_id LIMIT 1", + (zone_id,) + ) + dev = cur.fetchone() + if dev: + leader_device_id, leader_serial = dev + logger.warning( + "Zone %s ('%s') has no leader assigned; falling back to device %s (%s)", + zone_id, zone_name, leader_device_id, leader_serial + ) + else: + logger.warning("Zone %s ('%s') has no devices; skipping", zone_id, zone_name) + errors.append({'zone_id': zone_id, 'zone_name': zone_name, 'error': 'No devices in zone'}) + continue + + if zone_type == 'HEATING' or heating_enabled is False: + # 0 = OFF, 1 = HEAT (when heating_enabled is False always switch to OFF regardless of zone type) + char_updates['target_heating_cooling_state'] = 1 if heating_enabled else 0 + else: + # Zone type can be 'AIR_CONDITIONING' (or other none HEATING) so we need to kwown which mode to return to + # get last mode from device_state_history if available, otherwise default to HEAT (0 = OFF, 1 = HEAT, 2 = COOL) + char_updates['target_heating_cooling_state'] = tado_api.state_manager.get_last_active_heating_mode(leader_device_id) + + # Apply optimistic state for immediate UI feedback + tado_api.state_manager.set_optimistic_state( + leader_device_id, + {'target_heating_cooling_state': char_updates['target_heating_cooling_state']} + ) + + try: + await tado_api.set_device_characteristics(leader_device_id, char_updates) + mode = MODE_LABELS.get(char_updates['target_heating_cooling_state'], f"UNKNOWN({char_updates['target_heating_cooling_state']})") + logger.info("Zone %s (%s): heating mode=%s via local bridge", zone_id, zone_name, mode) + results.append({ + 'success': True, + 'zone_id': zone_id, + 'zone_name': zone_name, + 'leader_device_id': leader_device_id, + 'leader_serial': leader_serial, + 'mode': mode, + 'applied': {'target_temperature': None, 'heating_enabled': heating_enabled} + }) + except Exception as e: + logger.error("Failed to control zone %s (%s): %s", zone_id, zone_name, e) + errors.append({'zone_id': zone_id, 'zone_name': zone_name, 'error': str(e)}) + conn.close() + + return { + 'success': len(errors) == 0, + 'count': len(results), + 'error_count': len(errors), + 'handling': 'local_handling', + 'applied': { + 'target_temperature': None, + 'heating_enabled': heating_enabled + }, + 'zones': results, + 'errors': errors + } + @app.get("/zones/{zone_id}", tags=["Zones"]) async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): """ @@ -635,6 +827,7 @@ async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): order_id = zone_info['order_id'] leader_serial = zone_info['leader_serial'] leader_type = zone_info['leader_type'] + zone_type = zone_info['zone_type'] is_circuit_driver = zone_info['is_circuit_driver'] tado_zone_id = zone_info['tado_zone_id'] window_open_time = zone_info['window_open_time'] @@ -728,6 +921,7 @@ async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): 'leader_device_id': leader_device_id, 'leader_serial': leader_serial, 'leader_type': leader_type, + 'zone_type': zone_type, 'tado_zone_id': tado_zone_id, 'is_circuit_driver': bool(is_circuit_driver), 'order_id': order_id, @@ -835,6 +1029,8 @@ async def set_zone( temperature: Optional[float] = None, heating_enabled: Optional[bool] = None, no_implicit_mode: Optional[bool] = False, + heating_mode: Optional[int] = None, + persistant: Optional[bool] = False, api_key: Optional[str] = Depends(get_api_key) ): """ @@ -843,10 +1039,15 @@ async def set_zone( Args: zone_id: Zone ID to control temperature: Target temperature in °C (-1, 0, or 5-30). - - -1 = resume schedule/auto mode (enable heating without changing target temp) + - -1 = resume schedule/auto mode (to last know heating mode HEAT/COOL without changing target temp) - 0 = disable heating (without changing target temp) - >= 5 = set temperature and enable heating - heating_enabled: Enable/disable heating mode (true/false) + heating_enabled: Enable/disable heating mode (true/false) TRUE return to last know heating mode (heat/cool) false turn off heating. + heating_mode: 0=OFF, 1=HEAT, 2=COOL (if supported by devices in zone). Overrides heating_enabled if given. + no_implicit_mode: If true, disable smart defaults (temperature values won't auto-enable/disable heating) + persistant: If true, only valid for temperature=0 or temperature=-1 else ignored. If true, a Cloud API command will be sent to + enable/disable the SMART schedule for unlimited time. This will cost a cloud API call and requires the cloud API to be enabled and authenticated. + If false, a temporary overlay will be applied that will automatically expire according the Tado App setting. Returns: Success status and applied values @@ -855,18 +1056,18 @@ async def set_zone( - Smart defaults: - temperature = -1 implies heating_enabled=true (resume schedule) - temperature = 0 implies heating_enabled=false (off) - - temperature >= 5°C implies heating_enabled=true + - temperature >= 5°C <= 30°C implies heating_enabled=true - Explicitly set heating_enabled to override smart defaults - Commands are sent to the zone's leader device - The leader propagates changes to other devices as needed - - heating_enabled controls the heat mode (OFF=0, HEAT=1) + - heating_enabled controls the heat/cool mode (OFF=0, HEAT=1/COOL=2 depending on last active mode) - Both temperature=0 and temperature=-1 preserve the stored target temperature - This allows temporary on/off control without affecting your schedule - - temperature=-1 is useful for automation: turn on without changing schedule - - temperature=0 is useful for "away mode": turn off but remember setpoint + - temperature=-1 is useful for automation: turn on without changing schedule (with persistant=true to stop temporaryoverlay stop and SMART schedule) + - temperature=0 is useful for "away mode": turn off but remember setpoint (with persistant=true to set overlay forever until manually turned back on) """ # Log the incoming request - logger.info(f"POST /zones/{zone_id}/set temperature={temperature} heating_enabled={heating_enabled}") + logger.info(f"POST /zones/{zone_id}/set temperature={temperature} heating_enabled={heating_enabled} heating_mode={heating_mode} no_implicit_mode={no_implicit_mode} persistant={persistant}") tado_api = get_tado_api() if not tado_api: @@ -875,26 +1076,42 @@ async def set_zone( if not tado_api.pairing: raise HTTPException(status_code=503, detail="Bridge not connected") - # Apply smart defaults - if temperature is not None and heating_enabled is None: - if temperature == -1: - heating_enabled = True # Resume schedule/enable without changing temp - temperature = None # Don't set temperature - elif temperature == 0: - heating_enabled = False - temperature = None # Don't set temperature - elif temperature >= 5.0 and no_implicit_mode is not True: - heating_enabled = True - elif temperature == -1: - # temperature=-1 always means "don't change temperature, just enable" - temperature = None - if heating_enabled is None: - heating_enabled = True + # Validate temperature range first + if temperature is not None: + if temperature < -1.0 or temperature > 30.0: + raise HTTPException(status_code=400, detail="Temperature must be -1 (resume), 0 (off), or between 5 and 30°C") + if temperature > 0 and temperature < 5.0: + raise HTTPException(status_code=400, detail="Temperature must be -1, 0, or between 5 and 30°C") + + if temperature is None and heating_enabled is None and heating_mode is None: + raise HTTPException(status_code=400, detail="At least the temp or heating mode should be given") + + resolved_temperature = temperature + if heating_mode is None: + resolved_heating_enabled = heating_enabled + else: + resolved_heating_enabled = None + + if temperature == -1: + if heating_enabled is False or heating_mode == 0: + raise HTTPException(status_code=400, detail="Can not switch on and off heating with one call") + resolved_temperature = None + if heating_mode is None: + resolved_heating_enabled = True + elif temperature == 0: + if heating_enabled is True or heating_mode in (1, 2): + raise HTTPException(status_code=400, detail="Can not switch on and off heating with one call") + resolved_temperature = None + if heating_mode is None: + resolved_heating_enabled = False + elif temperature is not None and temperature >= 5.0: + if heating_enabled is None and heating_mode is None and no_implicit_mode is False: + resolved_heating_enabled = True # Get zone info conn = sqlite3.connect(tado_api.state_manager.db_path) cursor = conn.execute(""" - SELECT z.name, z.leader_device_id, d.serial_number + SELECT z.name, z.leader_device_id, d.serial_number, z.tado_zone_id, z.zone_type FROM zones z LEFT JOIN devices d ON z.leader_device_id = d.device_id WHERE z.zone_id = ? @@ -905,7 +1122,7 @@ async def set_zone( if not row: raise HTTPException(status_code=404, detail=f"Zone {zone_id} not found") - zone_name, leader_device_id, leader_serial = row + zone_name, leader_device_id, leader_serial, tado_zone_id, zone_type = row if not leader_device_id: # No explicit leader assigned - fall back to the first device in the zone @@ -933,33 +1150,93 @@ async def set_zone( else: raise HTTPException(status_code=400, detail=f"Zone '{zone_name}' has no leader device assigned") + # Persistent handling only applies to on/off mode changes without temperature updates + use_cloud_persistant_call = ( + persistant is True and + resolved_temperature is None and + resolved_heating_enabled is not None + ) + + if use_cloud_persistant_call: + cloud_api = getattr(tado_api, 'cloud_api', None) + if not cloud_api: + raise HTTPException(status_code=503, detail="Persistent mode requires cloud API to be enabled") + if not cloud_api.is_authenticated(): + raise HTTPException(status_code=401, detail="Persistent mode requires cloud API authentication") + if not tado_zone_id: + raise HTTPException(status_code=400, detail="Zone is missing tado_zone_id required for cloud control") + + try: + if resolved_heating_enabled: + cloud_result = await cloud_api._switch_zones_to_smartschedule([tado_zone_id]) + else: + cloud_result = await cloud_api._switch_zones_persistant_off([tado_zone_id]) + + if cloud_result is None: + raise HTTPException(status_code=502, detail="Cloud API command failed") + + logger.info(f"Zone {zone_id} ({zone_name}): smart-schedule={resolved_heating_enabled} via Cloud API") + + return { + 'success': True, + 'zone_id': zone_id, + 'zone_name': zone_name, + 'leader_device_id': leader_device_id, + 'leader_serial': leader_serial, + 'handling': 'cloud_call', + 'applied': { + 'target_temperature': resolved_temperature, + 'heating_enabled': resolved_heating_enabled, + 'persistant': use_cloud_persistant_call + } + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed cloud control for zone {zone_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to set persistent cloud control: {str(e)}") + # Build characteristic updates char_updates = {} - if temperature is not None: - # Validate temperature range (5-30°C is typical for Tado) - if temperature < 0.0 or temperature > 30.0: - raise HTTPException(status_code=400, detail="Temperature must be -1 (resume), 0 (off), or between 5 and 30°C") - if temperature > 0 and temperature < 5.0: - raise HTTPException(status_code=400, detail="Temperature must be -1, 0, or between 5 and 30°C") - - if temperature > 0: # Only set if not turning off - char_updates['target_temperature'] = temperature + if resolved_temperature is not None: + char_updates['target_temperature'] = resolved_temperature - if heating_enabled is not None: - # 0 = OFF, 1 = HEAT - char_updates['target_heating_cooling_state'] = 1 if heating_enabled else 0 + if heating_mode is not None: + # heating_mode overrides heating_enabled if given + if zone_type == 'HEATING': + if heating_mode in (0, 1): + char_updates['target_heating_cooling_state'] = heating_mode + else: + raise HTTPException(status_code=400, detail="Invalid heating_mode value. Must be 0 (OFF) or 1 (HEAT)") + elif heating_mode in (0, 1, 2): + char_updates['target_heating_cooling_state'] = heating_mode + else: + raise HTTPException(status_code=400, detail="Invalid heating_mode value. Must be 0 (OFF), 1 (HEAT) or 2 (COOL)") + else: + if resolved_heating_enabled is not None: + if zone_type == 'HEATING' or resolved_heating_enabled is False: + # 0 = OFF, 1 = HEAT, when resolved_heating_enabled is False always switch to OFF regardless of zone type + char_updates['target_heating_cooling_state'] = 1 if resolved_heating_enabled else 0 + else: + # Zone type can be 'AIR_CONDITIONING' (other none HEATING) so when need to kwown which mode to return to + # get last mode from device_state_history if available, otherwise default to HEAT (0 = OFF, 1 = HEAT, 2 = COOL) + char_updates['target_heating_cooling_state'] = tado_api.state_manager.get_last_active_heating_mode(leader_device_id) if not char_updates: raise HTTPException(status_code=400, detail="No control parameters provided") # Log what we're changing (single summary line) changes = [] + resolved_mode = None if 'target_temperature' in char_updates: changes.append(f"temperature={char_updates['target_temperature']}°C") if 'target_heating_cooling_state' in char_updates: - mode = "ON" if char_updates['target_heating_cooling_state'] == 1 else "OFF" - changes.append(f"heating={mode}") + resolved_mode = MODE_LABELS.get( + char_updates['target_heating_cooling_state'], + f"UNKNOWN({char_updates['target_heating_cooling_state']})" + ) + changes.append(f"mode={resolved_mode}") logger.info(f"Zone {zone_id} ({zone_name}): {', '.join(changes)}") # Apply optimistic state prediction for immediate UI feedback @@ -987,8 +1264,10 @@ async def set_zone( 'leader_device_id': leader_device_id, 'leader_serial': leader_serial, 'applied': { - 'target_temperature': temperature, - 'heating_enabled': heating_enabled + 'target_temperature': resolved_temperature, + 'heating_enabled': resolved_heating_enabled, + 'heating_mode': resolved_mode, + 'persistant': use_cloud_persistant_call } } @@ -1093,6 +1372,7 @@ async def get_devices(api_key: Optional[str] = Depends(get_api_key)): 'zone_name': device_info.get('zone_name'), 'device_type': device_info.get('device_type'), 'model': device_info.get('model'), + 'name': device_info.get('name'), 'firmware_version': device_info.get('firmware_version'), 'is_zone_leader': device_info.get('is_zone_leader'), 'is_circuit_driver': device_info.get('is_circuit_driver'), @@ -1103,7 +1383,7 @@ async def get_devices(api_key: Optional[str] = Depends(get_api_key)): 'target_temp_c': target_temp_c, 'target_temp_f': round(target_temp_c * 9/5 + 32, 1) if target_temp_c is not None else None, 'mode': state.get('target_heating_cooling_state', 0), - 'cur_heating': 1 if state.get('current_heating_cooling_state') == 1 else 0, + 'cur_heating': state.get('current_heating_cooling_state'), 'valve_position': state.get('valve_position'), 'battery_low': battery_low, } @@ -1152,6 +1432,7 @@ async def get_device(device_id: int, api_key: Optional[str] = Depends(get_api_ke 'zone_name': device_info.get('zone_name'), 'device_type': device_info.get('device_type'), 'model': device_info.get('model'), + 'name': device_info.get('name'), 'firmware_version': device_info.get('firmware_version'), 'is_zone_leader': device_info.get('is_zone_leader'), 'is_circuit_driver': device_info.get('is_circuit_driver'), @@ -1162,7 +1443,7 @@ async def get_device(device_id: int, api_key: Optional[str] = Depends(get_api_ke 'target_temp_c': target_temp_c, 'target_temp_f': round(target_temp_c * 9/5 + 32, 1) if target_temp_c is not None else None, 'mode': state.get('target_heating_cooling_state', 0), - 'cur_heating': 1 if state.get('current_heating_cooling_state') == 1 else 0, + 'cur_heating': state.get('current_heating_cooling_state'), 'valve_position': state.get('valve_position'), 'battery_low': battery_low, } @@ -1501,7 +1782,7 @@ async def get_events(refresh_interval: Optional[int] = None, types: Optional[str State Field Reference: mode: 0=Off, 1=Heat, 2=Cool, 3=Auto (TargetHeatingCoolingState) - cur_heating: 0=not heating, 1=actively heating (CurrentHeatingCoolingState) + cur_heating: 0=not heating, 1=actively heating 2=actively cooling (CurrentHeatingCoolingState) battery_low: true if Cloud API battery_state != "NORMAL" (cached, no DB queries) Temperatures provided in both Celsius (_c) and Fahrenheit (_f) @@ -1794,4 +2075,33 @@ async def refresh_cloud_data(battery_only: bool = False, api_key: Optional[str] logger.error(f"Error refreshing cloud data: {e}") raise HTTPException(status_code=500, detail=f"Failed to refresh cloud data: {str(e)}") + @app.get("/purgehistory/info", tags=["Admin"]) + async def get_history_status(api_key: Optional[str] = Depends(get_api_key)): + """Get history records status, number of records, files size and oldest record.""" + + tado_api = get_tado_api() + return tado_api.state_manager.get_device_history_status_info(tado_api.cloud_api.purge_history_days) + + @app.post("/purgehistory/now", tags=["Admin"]) + async def purge_history_data( + days: Optional[int] = None, + api_key: Optional[str] = Depends(get_api_key) + ): + """Manually clean database from old history records.""" + + tado_api = get_tado_api() + if days is None: + if tado_api.cloud_api.purge_history_days is not None: + days = tado_api.cloud_api.purge_history_days + else: + days = 365 # Default to keeping 1 year of history OR from command line argument + + if not isinstance(days, int): + raise HTTPException(status_code=400, detail="Days must be an integer") + + if days < 7: + raise HTTPException(status_code=400, detail="Days must be greater than or equal to 7") + + return tado_api.state_manager.purge_device_history(days) + return app diff --git a/tado_local/state.py b/tado_local/state.py index 2a9d2c3..9a7b0e5 100644 --- a/tado_local/state.py +++ b/tado_local/state.py @@ -18,6 +18,7 @@ import datetime import logging +import os import sqlite3 import time from typing import Dict, List, Any, Optional @@ -123,17 +124,18 @@ def _load_zone_cache(self): d.serial_number as leader_serial, d.device_type as leader_type, z.tado_zone_id, d.is_circuit_driver, z.uuid, - z.window_open_time, z.window_rest_time + z.window_open_time, z.window_rest_time, z.zone_type FROM zones z LEFT JOIN devices d ON z.leader_device_id = d.device_id ORDER BY z.order_id, z.name """) for zone_id, name, leader_device_id, order_id, leader_serial, leader_type, tado_zone_id, is_circuit_driver, \ - uuid_val, window_open_time, window_rest_time in cursor.fetchall(): + uuid_val, window_open_time, window_rest_time, zone_type in cursor.fetchall(): self.zone_cache[zone_id] = { 'zone_id': zone_id, 'name': name, + 'zone_type': zone_type, 'leader_device_id': leader_device_id, 'order_id': order_id, 'tado_zone_id': tado_zone_id, @@ -211,30 +213,6 @@ def get_device_id_by_aid(self, aid: int) -> Optional[int]: def get_or_create_device(self, serial_number: str, aid: int, accessory_data: dict) -> int: """Get or create device ID for a serial number, updating aid if needed.""" - if serial_number in self.device_id_cache: - device_id = self.device_id_cache[serial_number] - - # Update aid if it's not set or has changed - device_info = self.device_info_cache.get(device_id, {}) - current_aid = device_info.get('aid') - - if current_aid != aid: - logger.info(f"Updating aid for device {device_id} ({serial_number}): {current_aid} -> {aid}") - conn = sqlite3.connect(self.db_path) - conn.execute(""" - UPDATE devices SET aid = ? WHERE device_id = ? - """, (aid, device_id)) - conn.commit() - conn.close() - - # Update caches - if aid: - self.aid_to_device_id[aid] = device_id - if device_info: - device_info['aid'] = aid - - return device_id - # Extract device info from accessory data device_type = "unknown" name = None @@ -243,7 +221,7 @@ def get_or_create_device(self, serial_number: str, aid: int, accessory_data: dic for service in accessory_data.get('services', []): # AccessoryInformation service - if service.get('type') == '0000003e-0000-1000-8000-0026bb765291': + if service.get('type').lower() == '0000003e-0000-1000-8000-0026bb765291': for char in service.get('characteristics', []): char_type = char.get('type', '').lower() value = char.get('value') @@ -257,7 +235,10 @@ def get_or_create_device(self, serial_number: str, aid: int, accessory_data: dic # Determine device type from services service_type = service.get('type', '').lower() if service_type == '0000004a-0000-1000-8000-0026bb765291': - device_type = "thermostat" + if model in ("SU02", "AC02"): + device_type = "smart_ac_control" + else: + device_type = "thermostat" elif service_type == '0000008a-0000-1000-8000-0026bb765291': device_type = "temperature_sensor" elif service_type == '00000082-0000-1000-8000-0026bb765291': @@ -278,6 +259,49 @@ def get_or_create_device(self, serial_number: str, aid: int, accessory_data: dic elif prefix == "SU": device_type = "smart_ac_control" # Smart AC Control V3+ + # Existing device with same serial number Update data if needed + if serial_number in self.device_id_cache: + device_id = self.device_id_cache[serial_number] + + # Update aid if it's not set or has changed + device_info = self.device_info_cache.get(device_id, {}) + current_aid = device_info.get('aid') + + if current_aid != aid: + logger.info(f"Updating aid for device {device_id} ({serial_number}): {current_aid} -> {aid}") + conn = sqlite3.connect(self.db_path) + conn.execute(""" + UPDATE devices SET aid = ? WHERE device_id = ? + """, (aid, device_id)) + conn.commit() + conn.close() + + # Update caches + if aid: + self.aid_to_device_id[aid] = device_id + if device_info: + device_info['aid'] = aid + + # Update name and device type if they are different HomeKit discovery more accurate so leading. + if device_info and (device_info.get('name') != name or device_info.get('device_type') != device_type): + logger.info(f"Updating device info for {device_id} ({serial_number}): " + f"name {device_info.get('name')} -> {name}, " + f"type {device_info.get('device_type')} -> {device_type} " + f"model={model} manufacturer={manufacturer}") + conn = sqlite3.connect(self.db_path) + conn.execute(""" + UPDATE devices SET name = ?, device_type = ?, model = ?, manufacturer = ? WHERE device_id = ? + """, (name, device_type, model, manufacturer, device_id)) + conn.commit() + conn.close() + + # Update cache + if device_info: + device_info['name'] = name + device_info['device_type'] = device_type + + return device_id + # Create device entry conn = sqlite3.connect(self.db_path) cursor = conn.execute(""" @@ -674,3 +698,131 @@ def update_device_window_status(self, device_id: int, window_open: int): self._save_to_history(device_id, time.time()) logger.info(f"Device {device_id} window status updated: {old_status} -> {window_open}") + + def get_last_active_heating_mode(self, device_id: int) -> int: + """ + Get the last known active heating mode (target_heating_cooling_state) for a device. + + Queries the device state history to retrieve the most recent non-zero and non-NULL + target_heating_cooling_state value (i.e., HEAT or COOL, not OFF). This is useful + for resuming a device's heating/cooling mode when switching from OFF mode back to ON. + + Args: + device_id: The device ID to look up + + Returns: + int: The last active heating mode (1=HEAT, 2=COOL), defaults to 1 (HEAT) + if no non-zero mode found in history or device not found + """ + conn = sqlite3.connect(self.db_path) + cursor = conn.execute(""" + SELECT target_heating_cooling_state + FROM device_state_history + WHERE device_id = ? AND target_heating_cooling_state IS NOT NULL AND target_heating_cooling_state != 0 + ORDER BY timestamp_bucket DESC + LIMIT 1 + """, (device_id,)) + + row = cursor.fetchone() + conn.close() + + if row and row[0] is not None: + last_mode = row[0] + logger.debug(f"Device {device_id}: Last active heating mode from history: {last_mode}") + return int(last_mode) + + logger.debug(f"Device {device_id}: No active heating mode history found, defaulting to HEAT (1)") + return 1 + + def purge_device_history(self, days: Optional[int] = None) -> Dict[str, Any]: + """ + Purge old device history records from the database. + + Args: + days: Keep history newer than this many days. If omitted, defaults to 365. (minimal 7 days) + + Returns: + Summary dict with purge statistics. + """ + if days is None: + days = 365 # Default to keeping 1 year of history + + conn = sqlite3.connect(self.db_path) + + modifier = f"-{days} days" + count_cursor = conn.execute( + """ + SELECT COUNT(*) + FROM device_state_history + WHERE updated_at < datetime('now', ?) + """, + (modifier,) + ) + rows_to_delete = count_cursor.fetchone()[0] + + conn.execute( + """ + DELETE FROM device_state_history + WHERE updated_at < datetime('now', ?) + """, + (modifier,) + ) + + remaining_cursor = conn.execute("SELECT COUNT(*) FROM device_state_history") + remaining_rows = remaining_cursor.fetchone()[0] + + conn.commit() + conn.close() + + logger.info( + "Purged %s device history rows older than %s; %s rows remain", + rows_to_delete, + modifier, + remaining_rows + ) + + return { + 'success': True, + 'days': days, + 'deleted_rows': rows_to_delete, + 'remaining_rows': remaining_rows, + 'cutoff': modifier, + } + + def get_device_history_status_info(self, days: Optional[int] = None) -> Dict[str, Any]: + """ + Get overall status information for stored device history. + + Returns: + Summary dict including history record counts, age bounds, device coverage, + and database file size information. + """ + conn = sqlite3.connect(self.db_path) + cursor = conn.execute( + """ + SELECT + COUNT(*) AS record_count, + MIN(updated_at) AS oldest_record + FROM device_state_history + """ + ) + row = cursor.fetchone() + conn.close() + + file_size_bytes = os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0 + file_size_mb = round(file_size_bytes / (1024 * 1024), 3) + + if days is not None: + history_purge = f"{days} days" + else: + history_purge = "never" + + return { + 'success': True, + 'database_path': self.db_path, + 'database_file_size_bytes': file_size_bytes, + 'database_file_size_mb': file_size_mb, + 'history_record_count': row[0] if row and row[0] is not None else 0, + 'oldest_record': row[1] if row else None, + 'history_purge_setting': history_purge, + } diff --git a/tado_local/static/index.html b/tado_local/static/index.html index 543771a..d08270c 100644 --- a/tado_local/static/index.html +++ b/tado_local/static/index.html @@ -33,6 +33,10 @@ --card-medium-end: #ff9642; --card-hot-start: #f44336; --card-hot-end: #e53935; + --card-freeze-start: #b6c6fc; + --card-freeze-end: #96afff; + --card-cooling-start: #5278eb; + --card-cooling-end: #1d53f5; --btn-secondary-bg: #e0e0e0; --btn-secondary-bg-hover: #d0d0d0; --btn-secondary-text: #666; @@ -77,6 +81,10 @@ --card-cold-end: #2e7d32; --card-medium-start: #e69540; --card-medium-end: #cc7a2a; + --card-freeze-start: #b6c6fc; + --card-freeze-end: #96afff; + --card-cooling-start: #5278eb; + --card-cooling-end: #1d53f5; --card-hot-start: #d32f2f; --card-hot-end: #c62828; --btn-secondary-bg: #444; @@ -126,6 +134,10 @@ --card-medium-end: #cc7a2a; --card-hot-start: #d32f2f; --card-hot-end: #c62828; + --card-freeze-start: #b6c6fc; + --card-freeze-end: #96afff; + --card-cooling-start: #5278eb; + --card-cooling-end: #1d53f5; --btn-secondary-bg: #444; --btn-secondary-bg-hover: #555; --btn-secondary-text: #ccc; @@ -167,13 +179,110 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 30px; + margin-bottom: 15px; + flex-wrap: nowrap; + } + + .header-left, + .header-center, + .header-right { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .header-center { + flex: 1; + justify-content: center; + } + + .header-left { + flex: 1; + } + + .header-right { + flex: 1; + justify-content: flex-end; + } + + .schedule-button { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 16px 4px 12px; + background: var(--bg-primary); + border-radius: 8px; + + border: 2px solid var(--border-color); + transition: transform 0.6s cubic-bezier(.25,.8,.25,1); + cursor: pointer; + } + + .schedule-button h4 { + color: var(--text-primary); + white-space: nowrap; + font-weight: normal; + } + + .schedule-button div { + display: flex; + flex-direction: column; + line-height: 1.1; + } + + .schedule-button:hover { + border: none; + background-color: var(--bg-accent-subtle); + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + } + + .schedule-warning { + font-size: 9px; + opacity: 0.8; + color: var(--text-secondary); + } + + @media (max-width: 900px) and (min-width: 650px) { + .schedule-button div, + .schedule-button-label { + display: none; + } + } + + @media (max-width: 650px) { + .header { + flex-wrap: wrap; + } + + .header-left { + order: 1; + flex: 1; + } + + .header-right { + order: 2; + flex: 0; + display: flex; + gap: 8px; + } + + .header-center { + order: 3; + flex: 1 1 100%; + gap: 30px; + display: flex; + justify-content: center; + align-items: center; + margin-top: 12px; + } } h1 { color: var(--text-primary); font-weight: 400; width: 100%; + white-space: nowrap; } .theme-toggle { @@ -184,11 +293,13 @@ cursor: pointer; font-size: 18px; line-height: 1; - transition: border-color 0.2s; + transition: border-color 0.4s; } .theme-toggle:hover { border-color: var(--accent); + transform: translateY(1.5px) translateX(1.5px) scale(0.94); + box-shadow: 0 1px 2px rgba(0,0,0,0.15); } .zones-grid { @@ -231,6 +342,16 @@ color: var(--text-on-accent); } + .zone-card.cooling-medium { + background: linear-gradient(135deg, var(--card-cooling-start) 0%, var(--card-cooling-end) 100%); + color: var(--text-on-accent); + } + + .zone-card.cooling-freeze { + background: linear-gradient(135deg, var(--card-freeze-start) 0%, var(--card-freeze-end) 100%); + color: var(--text-on-accent); + } + .zone-card.off { background: var(--card-off-bg); color: var(--card-off-text); @@ -299,12 +420,15 @@ padding: 6px 10px; cursor: pointer; line-height: 1; - transition: border-color 0.2s; + transition: border-color 0.4s; } .window-toggle:hover { border-color: var(--accent); + transform: translateY(1.5px) translateX(1.5px) scale(0.94); + box-shadow: 0 1px 2px rgba(0,0,0,0.15); } + .window-icon { height: 30px; } @@ -354,7 +478,7 @@ } .modal-header { - margin-bottom: 20px; + margin-bottom: 10px; } .modal-title { @@ -370,7 +494,7 @@ } .control-group { - margin-bottom: 25px; + margin-bottom: 5px; } .control-label { @@ -414,8 +538,7 @@ } .mode-buttons { - display: grid; - grid-template-columns: 1fr 1fr; + display: flex; gap: 10px; } @@ -428,6 +551,7 @@ cursor: pointer; font-size: 14px; transition: all 0.2s; + flex: 1; } .mode-button:hover { @@ -444,7 +568,7 @@ .action-buttons { display: flex; gap: 10px; - margin-top: 25px; + margin-top: 15px; } .btn { @@ -563,7 +687,7 @@ display: flex; gap: 0; border-bottom: 2px solid var(--border-light); - margin: 20px 0; + margin: 0 0 20px 0; } .tab-button { @@ -627,7 +751,7 @@ margin-bottom: 20px; } -
-

Tado Local

- - +
+

Tado Local

+
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+
@@ -1290,14 +1499,14 @@ // Mode is on (heating enabled) // Color by actual current temperature regardless of heating status if (temp !== null && temp !== undefined && temp < 17) { - cardClass += ' heating-cold'; + cardClass += (mode === 2 ? ' cooling-freeze' : ' heating-cold'); } else if (temp !== null && temp !== undefined && temp <= 22) { - cardClass += ' heating-medium'; + cardClass += (mode === 2 ? ' cooling-medium' : ' heating-medium'); } else if (temp !== null && temp !== undefined) { - cardClass += ' heating-hot'; + cardClass += (mode === 2 ? ' cooling-medium' : ' heating-hot'); } else { // No temperature data, default to medium - cardClass += ' heating-medium'; + cardClass += (mode === 2 ? ' cooling-medium' : ' heating-medium'); } // Status text depends on whether actively heating/cooling @@ -1379,7 +1588,13 @@ } tempValue = state.target_temp_c || 20; - modeValue = (state.mode === 0) ? 'off' : 'heat'; + modeValue = (state.mode === 0) ? 'off' : ((state.mode === 2) ? 'cool' : 'heat'); + const coolButton = document.getElementById('mode-cool'); + if (currentZone.zone_type === "AIR_CONDITIONING") { + coolButton.style.display = 'inline-block'; + } else { + coolButton.style.display = 'none'; + } updateTempDisplay(); updateModeButtons(); @@ -1410,15 +1625,39 @@ function updateModeButtons() { document.getElementById('mode-heat').classList.toggle('active', modeValue === 'heat'); + document.getElementById('mode-cool').classList.toggle('active', modeValue === 'cool'); document.getElementById('mode-off').classList.toggle('active', modeValue === 'off'); + document.getElementById('schedule-off').classList.toggle('active', modeValue === 'schedule-off'); + document.getElementById('schedule-on').classList.toggle('active', modeValue === 'schedule-on'); } async function applyChanges() { if (!currentZone) return; const params = new URLSearchParams(); - params.append('temperature', tempValue); - params.append('heating_enabled', modeValue === 'heat' ? 'true' : 'false'); + switch (modeValue) { + case 'schedule-off': + params.append('heating_enabled', 'false'); + params.append('persistant', 'true'); + break; + case 'schedule-on': + params.append('heating_enabled', 'true'); + params.append('persistant', 'true'); + break; + case 'off': + params.append('temperature', tempValue); + params.append('heating_mode', 0); + break; + case 'cool': + params.append('temperature', tempValue); + params.append('heating_mode', 2); + break; + default: + case 'heat': + params.append('temperature', tempValue); + params.append('heating_mode', 1); + break; + } try { const response = await fetch(`/zones/${currentZone.zone_id}/set?${params}`, { @@ -1439,6 +1678,30 @@ } } + async function sendSmartScheduleMode(modeOn) { + if (!confirm("Are you sure to switch the schedule " + (modeOn ? "ON" : "OFF") + " for all zones?\nWARNING: this will cost a Cloud API call")) return; + + const params = new URLSearchParams(); + params.append('heating_enabled', modeOn ? 'true' : 'false' ); + params.append('persistant', 'true'); + + try { + const response = await fetch(`/zones/set?${params}`, { + method: 'POST' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Failed to switch smart schedule'); + } + // Reload zones after a short delay + setTimeout(loadZones, 1000); + } catch (error) { + showError('Failed to switch smart schedule: ' + error.message); + } + } + + function showError(message) { const errorDiv = document.getElementById('error-message'); errorDiv.textContent = message; @@ -1566,11 +1829,34 @@ updateModeButtons(); }); + document.getElementById('mode-cool').addEventListener('click', () => { + modeValue = 'cool'; + updateModeButtons(); + }); + document.getElementById('mode-off').addEventListener('click', () => { modeValue = 'off'; updateModeButtons(); }); + document.getElementById('schedule-off').addEventListener('click', () => { + modeValue = 'schedule-off'; + updateModeButtons(); + }); + + document.getElementById('schedule-on').addEventListener('click', () => { + modeValue = 'schedule-on'; + updateModeButtons(); + }); + + document.getElementById('schedule-all-on').addEventListener('click', () => { + sendSmartScheduleMode(true); + }); + + document.getElementById('schedule-all-off').addEventListener('click', () => { + sendSmartScheduleMode(false); + }); + document.getElementById('btn-cancel').addEventListener('click', closeModal); document.getElementById('btn-apply').addEventListener('click', applyChanges); diff --git a/tado_local/sync.py b/tado_local/sync.py index cde72f5..29a4493 100644 --- a/tado_local/sync.py +++ b/tado_local/sync.py @@ -48,6 +48,7 @@ def normalize_device_type(tado_device_type: str) -> str: "WR01": "wireless_receiver", "WR02": "wireless_receiver", "SU02": "smart_ac_control", + "AC02": "smart_ac_control", } return type_map.get(tado_device_type.upper(), tado_device_type.lower()) @@ -199,12 +200,12 @@ def sync_zones(self, zones_data: List[Dict[str, Any]], home_id: int) -> bool: existing_device = cursor.fetchone() if existing_device: - # Update existing device - don't overwrite name (comes from HomeKit) + # Update existing device - don't overwrite name (comes from HomeKit) and device type (more accurate from HomeKit services) device_id = existing_device[0] cursor.execute( """ UPDATE devices - SET tado_zone_id = ?, zone_id = ?, device_type = ?, + SET tado_zone_id = ?, zone_id = ?, battery_state = ?, firmware_version = ?, is_zone_leader = ?, is_circuit_driver = ?, is_zone_driver = ?, duties = ?, last_seen = CURRENT_TIMESTAMP @@ -213,7 +214,6 @@ def sync_zones(self, zones_data: List[Dict[str, Any]], home_id: int) -> bool: ( tado_zone_id, zone_id, - device_type, battery_state, firmware, is_leader, @@ -256,7 +256,6 @@ def sync_zones(self, zones_data: List[Dict[str, Any]], home_id: int) -> bool: synced_devices += 1 - # Update zone leader if this device is the leader # Update zone leader if this device is the leader if is_leader: try: @@ -387,7 +386,6 @@ def sync_device_list(self, device_list_data: Dict[str, Any], home_id: int) -> bo battery_state = device.get('batteryState') firmware = device.get('currentFwVersion') raw_device_type = device.get('deviceType') - device_type = normalize_device_type(raw_device_type) if raw_device_type else None zone_info = entry.get('zone') or {} tado_zone_id = zone_info.get('discriminator') @@ -406,11 +404,11 @@ def sync_device_list(self, device_list_data: Dict[str, Any], home_id: int) -> bo """ UPDATE devices SET battery_state = ?, firmware_version = ?, - device_type = ?, tado_zone_id = ?, model = ?, + tado_zone_id = ?, model = ?, last_seen = CURRENT_TIMESTAMP WHERE serial_number = ? """, - (battery_state, firmware, device_type, tado_zone_id, raw_device_type, serial), + (battery_state, firmware, tado_zone_id, raw_device_type, serial), ) updated_count += 1 else: diff --git a/tests/test_localapi.py b/tests/test_localapi.py index a28ee11..f042b85 100644 --- a/tests/test_localapi.py +++ b/tests/test_localapi.py @@ -66,6 +66,33 @@ async def test_initialize_with_pairing(self, api_instance, mock_pairing): mock_setup.assert_called_once() assert api_instance.is_initializing is False + @pytest.mark.asyncio + async def test_initialize_with_extra_pairings(self, api_instance, mock_pairing): + """Test that extra pairings are handled during initialization.""" + extra_pairings = AsyncMock() + extra_pairings.list_accessories_and_characteristics = AsyncMock(return_value=[]) + extra_pairings.subscribe = AsyncMock() + extra_pairings.unsubscribe = AsyncMock() + extra_pairings.get_characteristics = AsyncMock(return_value={}) + extra_pairings.put_characteristics = AsyncMock(return_value={}) + extra_pairings.dispatcher_connect = Mock() + + with ( + patch.object(api_instance, 'refresh_accessories', new_callable=AsyncMock) as mock_refresh, + patch.object(api_instance, 'initialize_device_states', new_callable=AsyncMock) as mock_init_states, + patch.object(api_instance, 'setup_event_listeners', new_callable=AsyncMock) as mock_setup, + ): + + await api_instance.initialize(mock_pairing, extra_pairings=extra_pairings) + + # Should still initialize with the main pairing + assert api_instance.pairing == mock_pairing + assert api_instance.extra_pairings == extra_pairings + mock_refresh.assert_called_once() + mock_init_states.assert_called_once() + mock_setup.assert_called_once() + + @pytest.mark.asyncio async def test_cleanup(self, api_instance, mock_pairing): """Test cleanup properly shuts down resources.""" @@ -200,6 +227,53 @@ async def test_refresh_accessories_success(self, api_instance, mock_pairing): assert len(api_instance.accessories_cache) > 0 mock_pairing.list_accessories_and_characteristics.assert_called_once() + @pytest.mark.asyncio + async def test_refresh_accessories_with_extra_pairing_success(self, api_instance, mock_pairing): + """Test successful accessories refresh.""" + mock_accessories = [ + { + 'aid': 1, + 'services': [ + { + 'type': '0000003E-0000-1000-8000-0026BB765291', + 'characteristics': [{'type': '00000030-0000-1000-8000-0026BB765291', 'value': 'SN12345'}], + } + ], + } + ] + mock_accessories_extra = [ + { + 'aid': 1, + 'services': [ + { + 'type': '0000003E-0000-1000-8000-0026BB765291', + 'characteristics': [{'type': '00000030-0000-1000-8000-0026BB765291', 'value': 'SN98765'}], + } + ], + } + ] + + api_instance.pairing = mock_pairing + mock_pairing.list_accessories_and_characteristics.return_value = mock_accessories + + extra_pairings = AsyncMock() + extra_pairings.list_accessories_and_characteristics = AsyncMock(return_value=[]) + extra_pairings.subscribe = AsyncMock() + extra_pairings.unsubscribe = AsyncMock() + extra_pairings.get_characteristics = AsyncMock(return_value={}) + extra_pairings.put_characteristics = AsyncMock(return_value={}) + extra_pairings.dispatcher_connect = Mock() + extra_pairings.list_accessories_and_characteristics.return_value = mock_accessories_extra + api_instance.extra_pairings = [extra_pairings] + + with patch.object(api_instance, '_process_raw_accessories', return_value={'device1': {'aid': 1}}): + await api_instance.refresh_accessories() + + assert api_instance.last_update is not None + assert len(api_instance.accessories_cache) > 0 + mock_pairing.list_accessories_and_characteristics.assert_called_once() + extra_pairings.list_accessories_and_characteristics.assert_called_once() + class TestTadoLocalAPIDeviceStates: @pytest.mark.asyncio diff --git a/tests/test_routes.py b/tests/test_routes.py index 665c0d8..a213159 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -2,9 +2,10 @@ import sqlite3 from pathlib import Path import tempfile -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from fastapi.testclient import TestClient - +from types import SimpleNamespace +from tado_local.routes import create_app, register_routes from tado_local.database import ensure_schema_and_migrate @pytest.fixture @@ -25,11 +26,13 @@ def test_db(): cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id, window_open_time, window_rest_time) VALUES (100, 1, 'Living Room', 'HEATING', 1, 1, 33, 66)") cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id, window_open_time, window_rest_time) VALUES (101, 1, 'Bedroom', 'HOT_WATER', 2, 2, 10, 25)") cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id, window_open_time, window_rest_time) VALUES (102, 1, 'Kitchen', 'HEATING', NULL, 3, 20, 30)") + cursor.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, leader_device_id, order_id, window_open_time, window_rest_time) VALUES (103, 1, 'Office', 'AIR_CONDITIONING', 5, 4, 20, 30)") cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader, is_circuit_driver) VALUES ('SN001', 1, 1, 100, 'thermostat', 'Living Room Thermostat', 'RU01', 'Tado', '1.45', 'NORMAL', 1, 1)") cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader, is_circuit_driver) VALUES ('SN002', 2, 2, 101, 'bridge', 'Bedroom Bridge', 'RU01', 'Tado', '1.45', 'NORMAL', 1, 1)") cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader, is_circuit_driver) VALUES ('SN003', 3, 3, 102, 'thermostat', 'Kitchen Thermostat', 'RB01', 'Tado', '2.10', 'LOW', 0, 1)") cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader, is_circuit_driver) VALUES ('SN004', 4, 1, 100, 'radiator_thermostat', 'Living Room Radiator', 'RV01', 'Tado', '1.20', 'LOW', 0, 0)") + cursor.execute("INSERT INTO devices (serial_number, aid, zone_id, tado_zone_id, device_type, name, model, manufacturer, firmware_version, battery_state, is_zone_leader, is_circuit_driver) VALUES ('SN005', 5, 4, 100, 'smart_ac_control', 'Smart AC Control WR12345678', 'AC02', 'Tado', '118.8', NULL, 1, 0)") cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (1, '20260129100000', 21.5, 20.0, 1, 1, 45, 100, 1)") cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (2, '20260129100500', 19.0, 18.0, 1, 1, 50, 95, 0)") @@ -37,6 +40,7 @@ def test_db(): cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (2, '20260129100700', 19.5, 18.5, 0, 1, 60, 95, 0)") cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (4, '20260129101000', 20.5, 20.0, 1, 0, 45, 60, 1)") cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (2, '20260129110500', 22.0, 21.0, 1, 1, 55, 75, 0)") + cursor.execute("INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) VALUES (5, '20260129110500', 22.0, 21.0, 2, 2, 54, 70, 0)") conn.commit() @@ -426,7 +430,7 @@ def test_get_zones_returns_all_zones(self, client, state_manager): assert response.status_code == 200 data = response.json() assert "zones" in data - assert len(data["zones"]) == 3 + assert len(data["zones"]) == 4 def test_get_zones_includes_zone_metadata(self, client, state_manager): """Test that zone data includes required metadata.""" @@ -458,6 +462,8 @@ def test_get_zones_ordered_by_order_id(self, client, state_manager): assert zones[1]["order_id"] == 2 assert zones[2]["name"] == "Kitchen" assert zones[2]["order_id"] == 3 + assert zones[3]["name"] == "Office" + assert zones[3]["order_id"] == 4 assert zones[2]["home_id"] is None assert zones[2]['window_open_time'] == 20 assert zones[2]['window_rest_time'] == 30 @@ -485,6 +491,7 @@ def test_get_zones_correct_names(self, client, state_manager): assert "Living Room" in zone_names assert "Bedroom" in zone_names assert "Kitchen" in zone_names + assert "Office" in zone_names def test_get_zones_leader_device_ids(self, client, state_manager): """Test that zone leader device IDs are correct from database.""" @@ -509,6 +516,7 @@ def test_get_zones_tado_zone_ids(self, client, state_manager): assert zone_tado_ids["Living Room"] == 100 assert zone_tado_ids["Bedroom"] == 101 assert zone_tado_ids["Kitchen"] == 102 + assert zone_tado_ids["Office"] == 103 def test_get_zones_response_is_json(self, client, state_manager): """Test that response is valid JSON.""" @@ -730,7 +738,7 @@ def test_post_create_zone(self, client, state_manager): assert response.status_code == 200 data = response.json() assert "zone_id" in data - assert data["zone_id"] == 4 + assert data["zone_id"] == 5 assert "name" in data assert data["name"] == "Bathroom" @@ -774,7 +782,7 @@ def test_post_create_zone_no_leader_device_id(self, client, state_manager): assert response.status_code == 200 data = response.json() assert "zone_id" in data - assert data["zone_id"] == 4 + assert data["zone_id"] == 5 assert "name" in data assert data["name"] == "Bathroom" @@ -978,6 +986,8 @@ def test_get_thermostat_non_thermostat_device(self, client, state_manager): response = client.get("/thermostats/2") assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Device 2 is not a thermostat" def test_get_thermostats_response_is_json(self, client, state_manager): """Test that response is valid JSON.""" @@ -999,6 +1009,36 @@ def test_get_thermostats_all_have_device_ids(self, client, state_manager): assert thermostat["device_id"] is not None assert isinstance(thermostat["device_id"], int) + def test_get_thermostats_history(self, client, state_manager): + """Test GET /thermostats/{thermostat_id}/history endpoint.""" + response = client.get("/thermostats/2/history") + + assert response.status_code == 200 + data = response.json() + + assert "count" in data + assert data["count"] == 3 + + assert "device_id" in data + assert data["device_id"] == 2 + + assert "history" in data + assert len(data["history"]) >= 1 + + assert "state" in data["history"][0] + assert "timestamp" in data["history"][0] + + state = data["history"][0]["state"] + assert "cur_temp_c" in state + assert "cur_temp_f" in state + assert "target_temp_c" in state + assert "target_temp_f" in state + assert "mode" in state + assert "cur_heating" in state + assert "hum_perc" in state + assert "battery_low" in state + assert "valve_position" in state + class TestGetDevices: """Test suite for GET /devices endpoint.""" @@ -1010,8 +1050,8 @@ def test_get_devices_returns_all_devices(self, client, state_manager): assert response.status_code == 200 data = response.json() assert "devices" in data - assert len(data["devices"]) == 4 - assert data["count"] == 4 + assert len(data["devices"]) == 5 + assert data["count"] == 5 def test_get_devices_includes_device_metadata(self, client, state_manager): """Test that device data includes required metadata.""" @@ -1308,6 +1348,134 @@ def test_put_device_in_zone(self, client, state_manager): class TestSetZoneBridgeCommands: """Test suite for set_zone route bridge command generation.""" + def test_set_zone_missing_inputs_returns_expected_error(self, client, mock_api): + """At least one of temperature/heating_enabled must be provided.""" + response = client.post("/zones/1/set") + + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "At least the temp or heating mode should be given" + mock_api.set_device_characteristics.assert_not_called() + + def test_set_zone_temp_zero_and_heating_true_returns_conflict(self, client, mock_api): + """temperature=0 conflicts with heating_enabled=true.""" + response = client.post("/zones/1/set?temperature=0&heating_enabled=true") + + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Can not switch on and off heating with one call" + mock_api.set_device_characteristics.assert_not_called() + + def test_set_zone_temp_minus_one_and_heating_false_returns_conflict(self, client, mock_api): + """temperature=-1 conflicts with heating_enabled=false.""" + response = client.post("/zones/1/set?temperature=-1&heating_enabled=false") + + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Can not switch on and off heating with one call" + mock_api.set_device_characteristics.assert_not_called() + + + def test_set_zone_temp_without_implicit_mode_does_not_set_heating(self, client, mock_api): + """When no_implicit_mode=true, temperature alone should not auto-set heating.""" + response = client.post("/zones/1/set?temperature=20&no_implicit_mode=true") + + assert response.status_code == 200 + call_args = mock_api.set_device_characteristics.call_args + chars = call_args[0][1] + + assert 'target_temperature' in chars + assert chars['target_temperature'] == 20 + assert 'target_heating_cooling_state' not in chars + + def test_set_zone_temp_wrong_temperaturer(self, client, mock_api): + """When temperature is illegal return 400 no setting change.""" + response = client.post("/zones/1/set?temperature=4.99") + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Temperature must be -1, 0, or between 5 and 30°C" + mock_api.set_device_characteristics.assert_not_called() + + response = client.post("/zones/1/set?temperature=30.1") + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Temperature must be -1 (resume), 0 (off), or between 5 and 30°C" + mock_api.set_device_characteristics.assert_not_called() + + response = client.post("/zones/1/set?temperature=-1.1") + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Temperature must be -1 (resume), 0 (off), or between 5 and 30°C" + mock_api.set_device_characteristics.assert_not_called() + + + def test_set_zone_persistant_resume_uses_cloud_call(self, client, mock_api): + """temperature=-1 with persistant=true should use cloud API, not bridge write.""" + + class FakeCloudApi: + def is_authenticated(self): + return True + + _switch_zones_to_smartschedule = AsyncMock(return_value={"ok": True}) + _switch_zones_persistant_off = AsyncMock(return_value={"ok": True}) + + mock_api.cloud_api = FakeCloudApi() + + response = client.post("/zones/1/set?temperature=-1&persistant=true") + + assert response.status_code == 200 + data = response.json() + assert data["handling"] == "cloud_call" + assert data["applied"]["target_temperature"] is None + assert data["applied"]["heating_enabled"] is True + + mock_api.set_device_characteristics.assert_not_called() + mock_api.cloud_api._switch_zones_to_smartschedule.assert_called_once_with([100]) + mock_api.cloud_api._switch_zones_persistant_off.assert_not_called() + + def test_set_zone_persistant_off_uses_cloud_call(self, client, mock_api): + """heating_enabled=false with persistant=true should use cloud API off call.""" + + class FakeCloudApi: + def is_authenticated(self): + return True + + _switch_zones_to_smartschedule = AsyncMock(return_value={"ok": True}) + _switch_zones_persistant_off = AsyncMock(return_value={"ok": True}) + + mock_api.cloud_api = FakeCloudApi() + + response = client.post("/zones/1/set?heating_enabled=false&persistant=true") + + assert response.status_code == 200 + data = response.json() + assert data["handling"] == "cloud_call" + assert data["applied"]["target_temperature"] is None + assert data["applied"]["heating_enabled"] is False + + mock_api.set_device_characteristics.assert_not_called() + mock_api.cloud_api._switch_zones_persistant_off.assert_called_once_with([100]) + mock_api.cloud_api._switch_zones_to_smartschedule.assert_not_called() + + def test_set_zone_persistant_with_temperature_still_uses_local_handling(self, client, mock_api): + """persistant=true is ignored when a temperature setpoint is provided.""" + + class FakeCloudApi: + def is_authenticated(self): + return True + + _switch_zones_to_smartschedule = AsyncMock(return_value={"ok": True}) + _switch_zones_persistant_off = AsyncMock(return_value={"ok": True}) + + mock_api.cloud_api = FakeCloudApi() + + response = client.post("/zones/1/set?temperature=20&persistant=true") + + assert response.status_code == 200 + mock_api.set_device_characteristics.assert_called_once() + mock_api.cloud_api._switch_zones_to_smartschedule.assert_not_called() + mock_api.cloud_api._switch_zones_persistant_off.assert_not_called() + def test_set_zone_temperature_calls_bridge(self, client, mock_api): """Test that setting zone temperature sends correct command to bridge.""" response = client.post("/zones/1/set?temperature=22.5") @@ -1498,6 +1666,282 @@ def test_set_zone_temperature_wrong_zone_id(self, client, mock_api): # Verify bridge command was not called mock_api.set_device_characteristics.assert_not_called() + def test_set_zone_temperature_and_mode(self, client, mock_api): + """Test that setting both temperature and heating mode sends both commands.""" + response = client.post("/zones/1/set?temperature=21&heating_mode=1") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify bridge command was called + mock_api.set_device_characteristics.assert_called_once() + call_args = mock_api.set_device_characteristics.call_args + + # Check characteristics passed + chars = call_args[0][1] + assert 'target_temperature' in chars + assert chars['target_temperature'] == 21 + assert 'target_heating_cooling_state' in chars + assert chars['target_heating_cooling_state'] == 1 + + def test_set_heating_mode_off(self, client, mock_api): + """Test that setting heating sends commands.""" + response = client.post("/zones/1/set?heating_mode=0") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify bridge command was called + mock_api.set_device_characteristics.assert_called_once() + call_args = mock_api.set_device_characteristics.call_args + + # Check characteristics passed + chars = call_args[0][1] + assert 'target_temperature' not in chars + assert 'target_heating_cooling_state' in chars + assert chars['target_heating_cooling_state'] == 0 + + def test_set_heating_mode_on_no_temperature(self, client, mock_api): + """Test that setting heating sends commands.""" + response = client.post("/zones/1/set?heating_mode=1") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify bridge command was called + mock_api.set_device_characteristics.assert_called_once() + call_args = mock_api.set_device_characteristics.call_args + + # Check characteristics passed + chars = call_args[0][1] + assert 'target_temperature' not in chars + assert 'target_heating_cooling_state' in chars + assert chars['target_heating_cooling_state'] == 1 + + def test_set_cool_heating_mode_in_heater_device(self, client, mock_api): + """Test that cool mode is not set for heating device.""" + response = client.post("/zones/1/set?heating_mode=2") + + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Invalid heating_mode value. Must be 0 (OFF) or 1 (HEAT)" + + # Verify bridge command was not called + mock_api.set_device_characteristics.assert_not_called() + + def test_set_heating_mode_on_airco_zone(self, client, mock_api): + """Test that setting heating sends commands.""" + response = client.post("/zones/4/set?heating_mode=2") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify bridge command was called + mock_api.set_device_characteristics.assert_called_once() + call_args = mock_api.set_device_characteristics.call_args + + # Check characteristics passed + chars = call_args[0][1] + assert 'target_temperature' not in chars + assert 'target_heating_cooling_state' in chars + assert chars['target_heating_cooling_state'] == 2 + + def test_set_airco_mode_return_to_previous(self, client, mock_api, state_manager): + """Test that setting mode returns to previous mode.""" + response = client.post("/zones/4/set?heating_mode=1") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify bridge command was called + assert mock_api.set_device_characteristics.call_count == 1 + call_args = mock_api.set_device_characteristics.call_args + + # Check characteristics passed, switching to HEAT mode should set heating_cooling_state to 1 + chars = call_args[0][1] + assert 'target_heating_cooling_state' in chars + assert chars['target_heating_cooling_state'] == 1 + + response = client.post("/zones/4/set?heating_mode=0") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify bridge command was called, switching to OFF mode should set heating_cooling_state to 0 + assert mock_api.set_device_characteristics.call_count == 2 + call_args = mock_api.set_device_characteristics.call_args + + # Update device history with mode = 1, fake HomeKit event update in HEAT mode. + conn = sqlite3.connect(state_manager.db_path) + conn.execute( + "INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) " \ + "VALUES (5, '20260229110500', 22.0, 21.0, 1, 1, 54, 70, 0)" + ) + conn.commit() + conn.close() + + # Check characteristics passed + chars = call_args[0][1] + assert 'target_heating_cooling_state' in chars + assert chars['target_heating_cooling_state'] == 0 + + response = client.post("/zones/4/set?heating_enabled=true") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify bridge command was called + assert mock_api.set_device_characteristics.call_count == 3 + call_args = mock_api.set_device_characteristics.call_args + + # Check characteristics passed. switching back to resume schedule should return to previous mode, which is HEAT (1) + chars = call_args[0][1] + assert 'target_heating_cooling_state' in chars + assert chars['target_heating_cooling_state'] == 1 + + def test_set_airco_mode_return_to_previous_when_temp_set(self, client, mock_api, state_manager): + """Test that setting mode returns to previous mode.""" + + response = client.post("/zones/4/set?heating_mode=2") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify bridge command was called + assert mock_api.set_device_characteristics.call_count == 1 + call_args = mock_api.set_device_characteristics.call_args + + # Check characteristics passed + chars = call_args[0][1] + assert 'target_heating_cooling_state' in chars + assert chars['target_heating_cooling_state'] == 2 + + # Update device history with mode = 2, fake HomeKit event update in COOL mode. + conn = sqlite3.connect(state_manager.db_path) + conn.execute( + "INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, target_temperature, current_heating_cooling_state, target_heating_cooling_state, humidity, battery_level, window) " \ + "VALUES (5, '20260229110500', 19.0, 18.0, 2, 2, 54, 70, 0)" + ) + conn.commit() + conn.close() + + response = client.post("/zones/4/set?temperature=22") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + + # Verify bridge command was called + assert mock_api.set_device_characteristics.call_count == 2 + call_args = mock_api.set_device_characteristics.call_args + + # Check characteristics passed + chars = call_args[0][1] + assert 'target_temperature' in chars + assert chars['target_temperature'] == 22 + assert 'target_heating_cooling_state' in chars + assert chars['target_heating_cooling_state'] == 2 + + def test_set_airco_invalid_mode(self, client): + """Test that setting ivalid mode returns .""" + response = client.post("/zones/4/set?heating_mode=3") + + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Invalid heating_mode value. Must be 0 (OFF), 1 (HEAT) or 2 (COOL)" + +class TestSetAllZonesBridgeCommands: + """Test suite for bulk all-zones control.""" + + def test_set_all_zones_local_handling_calls_bridge_for_each_zone(self, client, mock_api): + """Bulk local all-zones control should forward one bridge write per zone.""" + response = client.post("/zones/set?heating_enabled=true") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["count"] == 4 + assert data["handling"] == "local_handling" + assert data["applied"]["heating_enabled"] is True + + assert mock_api.set_device_characteristics.await_count == 4 + called_device_ids = [call.args[0] for call in mock_api.set_device_characteristics.await_args_list] + assert called_device_ids == [1, 2, 3, 5] + + assert data["error_count"] == 0 + assert data["errors"] == [] + + assert len(data["zones"]) == 4 + assert data["zones"][0]["mode"] == "HEAT" + assert data["zones"][1]["mode"] == "HEAT" + assert data["zones"][2]["mode"] == "HEAT" + assert data["zones"][3]["mode"] == "COOL" + + + def test_set_all_zones_persistant_true_uses_single_cloud_call(self, client, mock_api): + """Bulk persistent all-zones control should use one cloud API call.""" + + class FakeCloudApi: + def is_authenticated(self): + return True + + _switch_zones_to_smartschedule = AsyncMock(return_value={"ok": True}) + _switch_zones_persistant_off = AsyncMock(return_value={"ok": True}) + + mock_api.cloud_api = FakeCloudApi() + + response = client.post("/zones/set?heating_enabled=true&persistant=true") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["count"] == 4 + assert data["handling"] == "cloud_call" + assert data["applied"]["target_temperature"] is None + assert data["applied"]["heating_enabled"] is True + + mock_api.set_device_characteristics.assert_not_called() + mock_api.cloud_api._switch_zones_to_smartschedule.assert_called_once_with([100, 101, 102, 103]) + mock_api.cloud_api._switch_zones_persistant_off.assert_not_called() + + def test_set_all_zones_persistant_false_uses_single_cloud_call(self, client, mock_api): + """Bulk persistent off should use the cloud off helper.""" + + class FakeCloudApi: + def is_authenticated(self): + return True + + _switch_zones_to_smartschedule = AsyncMock(return_value={"ok": True}) + _switch_zones_persistant_off = AsyncMock(return_value={"ok": True}) + + mock_api.cloud_api = FakeCloudApi() + + response = client.post("/zones/set?heating_enabled=false&persistant=true") + + assert response.status_code == 200 + data = response.json() + assert data["handling"] == "cloud_call" + assert data["applied"]["heating_enabled"] is False + + mock_api.set_device_characteristics.assert_not_called() + mock_api.cloud_api._switch_zones_persistant_off.assert_called_once_with([100, 101, 102, 103]) + mock_api.cloud_api._switch_zones_to_smartschedule.assert_not_called() + + def test_set_all_zones_requires_heating_enabled(self, client, mock_api): + """Bulk all-zones endpoint requires heating_enabled.""" + response = client.post("/zones/set") + + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "At heating mode should be given" + mock_api.set_device_characteristics.assert_not_called() + class TestSetDeviceBridgeCommands: """Test suite for set_device route bridge command generation.""" @@ -1736,6 +2180,9 @@ def test_set_zone_invalid_temperature_no_bridge_call(self, client, mock_api): assert response.status_code == 400 # Bridge should NOT have been called mock_api.set_device_characteristics.assert_not_called() + data = response.json() + assert data["detail"] == "Temperature must be -1 (resume), 0 (off), or between 5 and 30°C" + def test_set_zone_no_parameters_no_bridge_call(self, client, mock_api): """Test that missing parameters prevent bridge call.""" @@ -1744,6 +2191,8 @@ def test_set_zone_no_parameters_no_bridge_call(self, client, mock_api): assert response.status_code == 400 # Bridge should NOT have been called mock_api.set_device_characteristics.assert_not_called() + data = response.json() + assert data["detail"] == "At least the temp or heating mode should be given" def test_set_zone_nonexistent_zone_no_bridge_call(self, client, mock_api): """Test that nonexistent zone prevents bridge call.""" @@ -2074,12 +2523,268 @@ def test_zone_set_window_timeouts_invalid(self, client, state_manager): """Test that invalid window timeout values are rejected.""" response = client.post("/zones/1/windowtimeouts?window_open_time=-10&window_rest_time=200") assert response.status_code == 400 + data = response.json() + assert data["detail"] == "window_open_time must be between 1 and 480 minutes, or -1 to reset to default" response = client.post("/zones/1/windowtimeouts?window_open_time=100&window_rest_time=-20") assert response.status_code == 400 + data = response.json() + assert data["detail"] == "window_rest_time must be between 1 and 480 minutes, or -1 to reset to default" response = client.post("/zones/1/windowtimeouts?window_open_time=abc&window_rest_time=200") assert response.status_code == 422 + data = response.json() + assert data["detail"][0]["msg"] == "Input should be a valid integer, unable to parse string as an integer" response = client.post("/zones/1/windowtimeouts?window_open_time=100&window_rest_time=xyz") assert response.status_code == 422 + data = response.json() + assert data["detail"][0]["msg"] == "Input should be a valid integer, unable to parse string as an integer" + +class TestPurgeHistoryInfo: + """Test suite for GET /purgehistory/info endpoint.""" + + @pytest.fixture + def purgehistory_info_setup(self): + mock_state_manager = Mock() + mock_state_manager.get_device_history_status_info.return_value = { + "total_records": 5, + "oldest_record": "2026-03-01T00:00:00", + "database_size": 12345, + "purge_history_days": 14, + } + + mock_tado_api = SimpleNamespace( + state_manager=mock_state_manager, + cloud_api=SimpleNamespace(purge_history_days=14), + ) + + app = create_app() + register_routes(app, lambda: mock_tado_api) + + client = TestClient(app) + return client, mock_tado_api, mock_state_manager + + @pytest.fixture + def purgehistory_info_db_setup(self, test_db): + """Setup using real database, routes and DeviceStateManager.""" + from tado_local.routes import create_app, register_routes + from tado_local.state import DeviceStateManager + + state_manager = DeviceStateManager(test_db) + + mock_tado_api = SimpleNamespace( + state_manager=state_manager, + cloud_api=SimpleNamespace(purge_history_days=14), + pairing=None, + ) + + app = create_app() + register_routes(app, lambda: mock_tado_api) + + client = TestClient(app) + return client, mock_tado_api, state_manager + + def test_get_purge_history_info_returns_correct_structure(self, purgehistory_info_setup): + client, mock_tado_api, mock_state_manager = purgehistory_info_setup + + response = client.get("/purgehistory/info") + + assert response.status_code == 200 + assert response.json() == { + "total_records": 5, + "oldest_record": "2026-03-01T00:00:00", + "database_size": 12345, + "purge_history_days": 14, + } + + mock_state_manager.get_device_history_status_info.assert_called_once_with(14) + assert mock_tado_api.cloud_api.purge_history_days == 14 + + def test_get_purge_history_info_passes_configured_purge_days(self, purgehistory_info_setup): + client, mock_tado_api, mock_state_manager = purgehistory_info_setup + mock_tado_api.cloud_api.purge_history_days = 30 + mock_state_manager.get_device_history_status_info.return_value = { + "total_records": 10, + "oldest_record": "2026-02-01T00:00:00", + "database_size": 54321, + "purge_history_days": 30, + } + + response = client.get("/purgehistory/info") + + assert response.status_code == 200 + assert response.json()["purge_history_days"] == 30 + mock_state_manager.get_device_history_status_info.assert_called_once_with(30) + + def test_get_purge_history_info_db_returns_correct_structure(self, purgehistory_info_db_setup): + """Test GET /purgehistory/info returns correct structure using real database.""" + client, mock_tado_api, state_manager = purgehistory_info_db_setup + + conn = sqlite3.connect(state_manager.db_path) + expected_count = conn.execute("SELECT COUNT(*) FROM device_state_history").fetchone()[0] + conn.close() + + response = client.get("/purgehistory/info") + + assert response.status_code == 200 + data = response.json() + + assert "history_record_count" in data + assert "oldest_record" in data + assert "database_file_size_bytes" in data + assert "database_file_size_mb" in data + assert "history_purge_setting" in data + + assert isinstance(data["history_record_count"], int) + assert data["history_record_count"] == expected_count + assert isinstance(data["database_file_size_bytes"], int) + assert data["database_file_size_bytes"] > 0 + assert data["history_purge_setting"] == "14 days" + + + def test_get_purge_history_info_db_oldest_record_is_valid_datetime(self, purgehistory_info_db_setup): + """Test that oldest_record is a valid datetime string.""" + from datetime import datetime + client, mock_tado_api, state_manager = purgehistory_info_db_setup + + response = client.get("/purgehistory/info") + + assert response.status_code == 200 + data = response.json() + + oldest = data["oldest_record"] + assert oldest is not None + # Should be parseable as datetime + parsed = datetime.fromisoformat(oldest) + assert parsed is not None + + def test_get_purge_history_info_db_passes_configured_purge_days(self, purgehistory_info_db_setup): + """Test that purge_history_days in response reflects the configured value.""" + client, mock_tado_api, state_manager = purgehistory_info_db_setup + mock_tado_api.cloud_api.purge_history_days = 30 + + response = client.get("/purgehistory/info") + + assert response.status_code == 200 + data = response.json() + assert data["history_purge_setting"] == "30 days" + + def test_get_purge_history_info_db_passes_never_purge_days(self, purgehistory_info_db_setup): + """Test that purge_history_days in response reflects the configured value.""" + client, mock_tado_api, state_manager = purgehistory_info_db_setup + mock_tado_api.cloud_api.purge_history_days = None + + response = client.get("/purgehistory/info") + + assert response.status_code == 200 + data = response.json() + assert data["history_purge_setting"] == "never" + + +class TestPurgeHistoryNow: + """Test suite for POST /purgehistory/now endpoint.""" + + @pytest.fixture + def purgehistory_now_setup(self, test_db): + from tado_local.routes import create_app, register_routes + from tado_local.state import DeviceStateManager + + state_manager = DeviceStateManager(test_db) + + mock_tado_api = SimpleNamespace( + state_manager=state_manager, + cloud_api=SimpleNamespace(purge_history_days=14), + pairing=None, + ) + + app = create_app() + register_routes(app, lambda: mock_tado_api) + + client = TestClient(app) + return client, mock_tado_api, state_manager + + def test_post_purge_history_now_purge_120_days(self, purgehistory_now_setup): + client, mock_tado_api, state_manager = purgehistory_now_setup + + response = client.post("/purgehistory/now?days=120") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["days"] == 120 + assert "deleted_rows" in data + assert "remaining_rows" in data + assert "cutoff" in data + assert "-120 days" in data["cutoff"] + + def test_post_purge_history_now_uses_configured_days_when_no_param(self, purgehistory_now_setup): + client, mock_tado_api, state_manager = purgehistory_now_setup + + response = client.post("/purgehistory/now") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["days"] == 14 # uses purge_history_days from cloud_api config + + def test_post_purge_history_now_deletes_old_records(self, purgehistory_now_setup): + client, mock_tado_api, state_manager = purgehistory_now_setup + + # Insert an old record that should be purged (updated_at far in the past) + conn = sqlite3.connect(state_manager.db_path) + conn.execute( + "INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, updated_at) " + "VALUES (1, '20200101000000', 20.0, '2020-01-01 00:00:00')" + ) + total_before = conn.execute("SELECT COUNT(*) FROM device_state_history").fetchone()[0] + conn.commit() + conn.close() + + response = client.post("/purgehistory/now?days=8") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["deleted_rows"] >= 1 + assert data["remaining_rows"] == total_before - data["deleted_rows"] + + def test_post_purge_history_now_deletes_based_on_commandline(self, purgehistory_now_setup): + client, mock_tado_api, state_manager = purgehistory_now_setup + + # Insert an old record that should be purged (updated_at far in the past) + conn = sqlite3.connect(state_manager.db_path) + conn.execute( + "INSERT INTO device_state_history (device_id, timestamp_bucket, current_temperature, updated_at) " + "VALUES (1, '20200101000000', 20.0, '2020-01-01 00:00:00')" + ) + total_before = conn.execute("SELECT COUNT(*) FROM device_state_history").fetchone()[0] + conn.commit() + conn.close() + + response = client.post("/purgehistory/now") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["deleted_rows"] >= 1 + assert data["remaining_rows"] == total_before - data["deleted_rows"] + + def test_post_purge_history_now_minimal_days_seven(self, purgehistory_now_setup): + client, mock_tado_api, state_manager = purgehistory_now_setup + response = client.post("/purgehistory/now?days=1") + + assert response.status_code == 400 + data = response.json() + assert data["detail"] == "Days must be greater than or equal to 7" + + def test_post_purge_history_now_uses_deafauly_days_when_no_param(self, purgehistory_now_setup): + client, mock_tado_api, state_manager = purgehistory_now_setup + mock_tado_api.cloud_api.purge_history_days = None + + response = client.post("/purgehistory/now") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["days"] == 365 # uses purge_history_days default of 365 when not set in config diff --git a/tests/test_state.py b/tests/test_state.py index ef92fc6..6f10501 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -52,9 +52,10 @@ def _get_test_timestamp_bucket(timestamp: float) -> str: conn.execute(""" INSERT INTO devices (device_id, serial_number, aid, device_type, name, model, manufacturer, zone_id, is_zone_leader) VALUES - (1, 'RU0208A26ABC123', 221, 'thermostat', 'Living Room Thermostat', 'Smart Thermostat', 'Tado', 1, 1), - (2, 'VA0210A26ABC456', 222, 'radiator_valve', 'Bedroom Radiator Valve', 'Smart Radiator Valve', 'Tado', 2, 0), - (3, 'IB01170626ABC789', 223, 'internet_bridge', 'Internet Bridge', 'Internet Bridge', 'Tado', NULL, 0) + (1, 'RU0208A26ABC123', 221, 'thermostat', 'Living Room Thermostat', 'RU02', 'Tado', 1, 1), + (2, 'VA0210A26ABC456', 222, 'radiator_valve', 'Bedroom Radiator Valve', 'VA02', 'Tado', 2, 0), + (3, 'IB01170626ABC789', 223, 'internet_bridge', 'Internet Bridge', 'IB01', 'Tado', NULL, 0), + (4, 'WR123456789', 224, 'smart_ac_control', 'Smart AC Control WR123456789', 'AC02', 'Tado', 3, 1) """) # Insert mock zones @@ -62,7 +63,8 @@ def _get_test_timestamp_bucket(timestamp: float) -> str: INSERT INTO zones (zone_id, name, leader_device_id, order_id, tado_zone_id) VALUES (1, 'Living Room', 1, 1, 1), - (2, 'Bedroom', 2, 2, 2) + (2, 'Bedroom', 2, 2, 2), + (3, 'Occide', 3, 3, 3) """) # Seed history records (sample database) - 61 seconds apart @@ -82,6 +84,7 @@ def _get_test_timestamp_bucket(timestamp: float) -> str: (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( 1, bucket_0, 20.0, 21.0, 1, 1, 48, 0, None, bucket_0, @@ -89,12 +92,13 @@ def _get_test_timestamp_bucket(timestamp: float) -> str: 1, bucket_2, 23.5, 21.0, 1, 1, 55, 0, None, bucket_2, # latest for device 2 2, bucket_2, 19.0, 20.0, 0, 0, 55, 1, float(base_ts), bucket_2, 3, bucket_0, None, None, None, None, None, None, None, bucket_0, + 4, bucket_1, 19.5, 22.0, 0, 0, 35, 2, float(base_ts), bucket_1, )) # Update devices with zone relationships conn.execute("UPDATE devices SET zone_id = 1 WHERE device_id = 1") conn.execute("UPDATE devices SET zone_id = 2 WHERE device_id = 2") - + conn.execute("UPDATE devices SET zone_id = 3 WHERE device_id = 4") conn.commit() conn.close() @@ -119,6 +123,7 @@ def test_get_device_info_radiator_valve(self, state_manager_with_db_devices): result = state_manager_with_db_devices.get_device_info(2) assert result['device_type'] == 'radiator_valve' + assert result['zone_id'] == 2 assert result['serial_number'] == 'VA0210A26ABC456' def test_get_device_info_internet_bridge(self, state_manager_with_db_devices): @@ -129,6 +134,14 @@ def test_get_device_info_internet_bridge(self, state_manager_with_db_devices): assert result['zone_id'] is None assert result['is_zone_leader'] is False + def test_get_device_info_smart_ac_control(self, state_manager_with_db_devices): + """Test retrieving internet bridge device info.""" + result = state_manager_with_db_devices.get_device_info(4) + + assert result['device_type'] == 'smart_ac_control' + assert result['zone_id'] == 3 + assert result['is_zone_leader'] is True + def test_get_device_info_returns_empty_dict_for_unknown_device(self, state_manager): """Test that unknown device IDs return empty dict.""" result = state_manager.get_device_info(999) @@ -176,6 +189,7 @@ def test_aid_to_device_id_cache_loaded_from_database(self, state_manager_with_db assert state_manager_with_db_devices.get_device_id_by_aid(221) == 1 assert state_manager_with_db_devices.get_device_id_by_aid(222) == 2 assert state_manager_with_db_devices.get_device_id_by_aid(223) == 3 + assert state_manager_with_db_devices.get_device_id_by_aid(224) == 4 def test_zone_info_in_device_cache(self, state_manager_with_db_devices): """Test that zone information is loaded into device cache.""" @@ -198,7 +212,7 @@ def test_get_all_devices_from_database(self, state_manager_with_db_devices): """Test retrieving all devices with database data.""" devices = state_manager_with_db_devices.get_all_devices() - assert len(devices) >= 3 + assert len(devices) == 4 # Verify device 1 device_1 = next((d for d in devices if d['device_id'] == 1), None) @@ -216,6 +230,11 @@ def test_get_all_devices_from_database(self, state_manager_with_db_devices): assert device_3 is not None assert device_3['device_type'] == 'internet_bridge' + # Verify device 4 + device_4 = next((d for d in devices if d['device_id'] == 4), None) + assert device_4 is not None + assert device_4['device_type'] == 'smart_ac_control' + def test_get_all_devices_has_zone_names(self, state_manager_with_db_devices): """Test that all devices include zone information.""" devices = state_manager_with_db_devices.get_all_devices() @@ -857,8 +876,8 @@ def test_creates_new_device_from_accessory_data(self, state_manager): { "type": "0000003e-0000-1000-8000-0026bb765291", # AccessoryInformation "characteristics": [ - {"type": "00000023-0000-1000-8000-0026bb765291", "value": "Living Room Thermostat"}, - {"type": "00000021-0000-1000-8000-0026bb765291", "value": "Smart Thermostat X"}, + {"type": "00000023-0000-1000-8000-0026bb765291", "value": "tado Smart Radiator Thermostat VA12345"}, + {"type": "00000021-0000-1000-8000-0026bb765291", "value": "SRT01"}, {"type": "00000020-0000-1000-8000-0026bb765291", "value": "Tado"}, ], @@ -880,7 +899,7 @@ def test_creates_new_device_from_accessory_data(self, state_manager): info = state_manager.get_device_info(device_id) assert info["serial_number"] == "RU9999TEST123" assert info["aid"] == 555 - assert info["name"] == "Living Room Thermostat" + assert info["name"] == "tado Smart Radiator Thermostat VA12345" assert info["device_type"] == "thermostat" conn = sqlite3.connect(state_manager.db_path) @@ -890,7 +909,50 @@ def test_creates_new_device_from_accessory_data(self, state_manager): ).fetchone() conn.close() - assert row == ("RU9999TEST123", 555, "thermostat", "Living Room Thermostat", "Smart Thermostat X", "Tado") + assert row == ("RU9999TEST123", 555, "thermostat", "tado Smart Radiator Thermostat VA12345", "SRT01", "Tado") + + def test_creates_new_device_from_accessory_data_ac(self, state_manager): + """Creates a new device and parses name/model/manufacturer/type.""" + accessory_data = { + "services": [ + { + "type": "0000003e-0000-1000-8000-0026bb765291", # AccessoryInformation + "characteristics": [ + {"type": "00000023-0000-1000-8000-0026bb765291", "value": "Smart AC Control WR123456"}, + {"type": "00000021-0000-1000-8000-0026bb765291", "value": "AC02"}, + {"type": "00000020-0000-1000-8000-0026bb765291", "value": "Tado"}, + + ], + }, + { + "type": "0000004a-0000-1000-8000-0026bb765291", # Thermostat service + "characteristics": [], + }, + ] + } + + device_id = state_manager.get_or_create_device("WR9999TEST123", 555, accessory_data) + + assert isinstance(device_id, int) + assert device_id > 0 + assert state_manager.device_id_cache["WR9999TEST123"] == device_id + assert state_manager.get_device_id_by_aid(555) == device_id + + info = state_manager.get_device_info(device_id) + assert info["serial_number"] == "WR9999TEST123" + assert info["aid"] == 555 + assert info["name"] == "Smart AC Control WR123456" + assert info["device_type"] == "smart_ac_control" + + conn = sqlite3.connect(state_manager.db_path) + row = conn.execute( + "SELECT serial_number, aid, device_type, name, model, manufacturer FROM devices WHERE device_id = ?", + (device_id,) + ).fetchone() + conn.close() + + assert row == ("WR9999TEST123", 555, "smart_ac_control", "Smart AC Control WR123456", "AC02", "Tado") + def test_detects_device_type_from_serial_prefix_when_service_unknown(self, state_manager): """Falls back to serial prefix mapping when no known service type.""" @@ -903,6 +965,17 @@ def test_detects_device_type_from_serial_prefix_when_service_unknown(self, state info = state_manager.get_device_info(device_id) assert info["device_type"] == "radiator_valve" + def test_detects_device_type_from_serial_prefix_when_service_ac(self, state_manager): + """Falls back to serial prefix mapping when no known service type.""" + device_id = state_manager.get_or_create_device( + "SU0210A26XYZ999", + 777, + {"services": []}, + ) + + info = state_manager.get_device_info(device_id) + assert info["device_type"] == "smart_ac_control" + def test_does_not_add_aid_mapping_when_aid_is_falsy(self, state_manager): """When aid is 0/None, aid_to_device_id should not be populated.""" device_id = state_manager.get_or_create_device( @@ -1305,6 +1378,5 @@ def test_update_device_window_status_does_not_save_when_new_id(self, state_manag with patch.object(state_manager, "_save_to_history") as mock_save: state_manager.update_device_window_status(99, 0) - print(state_manager.current_state) - assert state_manager.current_state[99]["window"] ==0 + assert state_manager.current_state[99]["window"] == 0 mock_save.assert_called_once() diff --git a/tests/test_sync.py b/tests/test_sync.py index 43862eb..342acec 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -179,7 +179,7 @@ def test_sync_zones_with_existing_devices(self, syncer): assert device is not None assert device[0] == "RU001" assert device[1] == "dev" - assert device[2] == "THERMOSTAT" + assert device[2] == "unknown" # should NOT be updated from "unknown" assert leader is not None assert leader[0] is not None From e3ef55018f274f2010f8d639020a5d90e467361b Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 23 Mar 2026 13:50:29 +0100 Subject: [PATCH 2/2] Add changelog on request. (issue #44) --- CHANGELOG.md | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad2d102 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,155 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on https://keepachangelog.com/en/1.1.0/, +and this project adheres to Semantic Versioning. + +--- + +## [1.1.2] – 2026-03-xx +### Added +- Persistent HVAC mode support, the ability to toggle Smart Schedule for heating/cooling (this will trigger an API call). (#43) +- Full Smart AC Control support. +- New `/zones/{id}/set` parameter `heating_mode` (0 = off, 1 = HEAT, 2 = COOL). +- Ability to switch an entire zone on or off with a single API call via `/zones/set/` (as either an overlay or a persistent change). +- Automatic database history purge via CLI and manual purge via API (`/purgehistory/now/`). +- This changelog. (#44) + +### Changed +- Improved synchronization logic between HomeKit state and cloud‑derived zone data (HomeKit device types now take precedence and names are stored correctly). +- `heating_enabled=true` now restores the last known active heating or cooling mode. +- Updated documentation to reflect persistent HVAC behavior and Smart AC Control support. +- Updated `index.html` to support Smart AC Control (COOL modes/colors) and persistent Smart Schedule switching. +- Updated unit tests to cover new and modified features. + +### Fixed +- Corrected case‑sensitivity issues in UUID comparisons. +- Home Assistant add‑on now only moves the database when the public database does not already exist. +- Removed large top‑page spacing in `index.html`. + +--- + +## [1.1.1] – 2026-03-05 +### Added +- Home Assistant add‑on upgrade now supports standalone accessories. +- Containerization support including Dockerfile, entrypoint script, and installation documentation. +- Additional tests for multi‑pairing support and temperature sync. + +### Changed +- Updated version to 1.1.1. +- Improved cloud sync behavior and humidity/temperature handling. +- Polling is now always enabled for Smart AC Control devices. +- General cleanup of duplicated imports and inline code. + +### Fixed +- Fixed accessory ID collision in HomeKit change handler. +- Corrected multiple standalone accessory issues including window detection, NoneType sync, and crash conditions. + +--- + +## [1.1.0] – 2026-02-26 +### Added +- Standalone HomeKit accessory support (e.g., Smart AC Control V3+). +- Open‑window detection logic and corresponding unit tests. +- Dark theme for the UI. +- Additional open‑window detection when AC is active. + +### Changed +- Version updated to 1.1.0. +- UI updates including index.html improvements. +- README and installation documentation updates. + +### Fixed +- Resolved Windows/Linux line‑ending issues in tests. +- Fixed missing zone information (issue #19). +- Improved humidity sync using cloud API. + +--- + +## [1.0.3] – 2026-02-07 +### Added +- Home Assistant Add‑on. +- Additional tests and fixes for project scripts. +- Added pytest‑httpx to development requirements. + +### Changed +- Cleanup of development dependencies and initial packaging support. +- Improved test coverage and ruff cleanup. + +### Fixed +- Fixed broken unit tests. +- Resolved zeroconf test issues. +- Corrected bridge IP display regression. + +--- + +## [1.0.2] – 2025-11-25 +### Fixed +- Corrected zone leader update during synchronization. + +--- + +## [1.0.1] – 2025-11-17 to 2025-11-24 +### Added +- Improved default icons for heating devices. + +### Changed +- Documentation and README updates. + +### Fixed +- Removed invalid argument in code. +- Fixed command processing for non‑thermostat devices. +- Corrected fetching of zones and thermostats by ID. + +--- + +## [1.0.0] – 2025-11-03 to 2025-11-21 +### Added +- Bearer token support. +- User‑Agent header for outgoing requests. +- Domoticz plugin improvements and voice tag enhancements. +- SSE refresh improvements. +- Auto‑setup support for dzga/dzga‑flask. +- Optimistic update handling for integrations. +- Historic data exposure in the UI. +- Minimal web UI for diagnostics and setup. +- REST API consistency improvements. + +### Changed +- Major logging cleanup and improvements. +- Improved shutdown sequence. +- Enhanced thermostat history visualization. +- Updated initial heartbeat and event logging. + +### Fixed +- Multiple Domoticz plugin fixes. +- Device creation fixes. +- Resolved Python version confusion. + +--- + +## [0.9.0] – 2025-10-30 to 2025-11-02 +### Added +- Initial proxy code. +- Polling/eventing system groundwork. +- Zones, storage, and event system. +- Cloud data feeding for improved reporting. +- Ability to set heating per zone via REST. +- Preliminary project roadmap. + +### Changed +- Major README enhancements. +- API cleanup and consistency improvements. +- Improved state reporting. + +### Fixed +- Fixes for local Domoticz integration issues. + +--- + +## [0.1.0] – 2025-10-30 +### Added +- Initial working prototype. +- Basic REST API. +- Early event system. +- Initial project structure.