diff --git a/tado_local/__main__.py b/tado_local/__main__.py index 9cd1484..c9d5fe6 100644 --- a/tado_local/__main__.py +++ b/tado_local/__main__.py @@ -44,6 +44,7 @@ server: Optional[uvicorn.Server] = None shutdown_event: Optional[asyncio.Event] = None + async def run_server(args): """Run the Tado Local server.""" global bridge_pairing, tado_api, server, shutdown_event @@ -136,6 +137,7 @@ def handle_signal(signum, frame): logger.debug("Attempting to import zeroconf_register for mDNS registration") from .zeroconf_register import register_service_async logger.info("mDNS registration helper loaded") + async def _schedule_mdns(): # Register a single HTTP service so basic clients can discover the API. # We intentionally publish only the HTTP service to avoid duplicate @@ -179,10 +181,9 @@ def _mdns_done(fut: 'asyncio.Future'): else: logger.info("mDNS registration disabled by --no-mdns flag") - server_ip = get_primary_ipv4() or "0.0.0.0" - logger.info( "*** Tado Local ready! ***") + logger.info("*** Tado Local ready! ***") logger.info(f"Bridge IP: {bridge_ip}") logger.info(f"API Server: http://{server_ip}:{args.port}") logger.info(f"Documentation: http://{server_ip}:{args.port}/docs") @@ -342,6 +343,7 @@ def _mdns_done(fut: 'asyncio.Future'): # Forced exit after 3 seconds to avoid lingering connections (especially for browsers) import threading import sys + def force_exit(): logger.warning("Forcing process exit after 3 seconds to avoid lingering SSE connections.") os._exit(0) @@ -352,6 +354,7 @@ def force_exit(): threading.Timer(3.0, force_exit).start() logger.info("Shutdown complete. Process will exit in 3 seconds.") + def main(): """Main entry point for the CLI.""" parser = argparse.ArgumentParser( @@ -394,26 +397,46 @@ def main(): POST /refresh - Manually refresh data """ ) - parser.add_argument("--state", default="~/.tado-local.db", - help="Path to state database (default: ~/.tado-local.db)") - parser.add_argument("--no-mdns", action="store_true", - help="Disable mDNS/Avahi/zeroconf service registration at startup") - parser.add_argument("--bridge-ip", - help="IP of the Tado bridge (e.g., 192.168.1.100). If not provided, will auto-discover from existing pairings.") - parser.add_argument("--pin", - help="HomeKit PIN for initial pairing (XXX-XX-XXX format)") - parser.add_argument("--port", type=int, default=4407, - help="Port for REST API server (default: 4407)") - parser.add_argument("--clear-pairings", action="store_true", - help="Clear all existing pairings from database before starting") - parser.add_argument("--verbose", action="store_true", - help="Enable verbose logging (DEBUG level)") - parser.add_argument("--daemon", action="store_true", - help="Run in daemon mode (structured logging for syslog, auto-enables --pid-file)") - parser.add_argument("--syslog", - help="Send logs to syslog instead of stdout (e.g., /dev/log, localhost:514, or remote.server:514)") - parser.add_argument("--pid-file", - help="Write process ID to specified file (useful for daemon mode)") + parser.add_argument( + "--state", default="~/.tado-local.db", + help="Path to state database (default: ~/.tado-local.db)" + ) + parser.add_argument( + "--no-mdns", action="store_true", + help="Disable mDNS/Avahi/zeroconf service registration at startup" + ) + parser.add_argument( + "--bridge-ip", + help="IP of the Tado bridge (e.g., 192.168.1.100). If not provided, will auto-discover from existing pairings." + ) + parser.add_argument( + "--pin", + help="HomeKit PIN for initial pairing (XXX-XX-XXX format)" + ) + parser.add_argument( + "--port", type=int, default=4407, + help="Port for REST API server (default: 4407)" + ) + parser.add_argument( + "--clear-pairings", action="store_true", + help="Clear all existing pairings from database before starting" + ) + parser.add_argument( + "--verbose", action="store_true", + help="Enable verbose logging (DEBUG level)" + ) + parser.add_argument( + "--daemon", action="store_true", + help="Run in daemon mode (structured logging for syslog, auto-enables --pid-file)" + ) + parser.add_argument( + "--syslog", + help="Send logs to syslog instead of stdout (e.g., /dev/log, localhost:514, or remote.server:514)" + ) + parser.add_argument( + "--pid-file", + help="Write process ID to specified file (useful for daemon mode)" + ) # Parse CLI arguments args = parser.parse_args() @@ -499,5 +522,6 @@ def main(): logger.error(f"ERROR: {e}") exit(1) + if __name__ == "__main__": main() diff --git a/tado_local/__version__.py b/tado_local/__version__.py index fbc064a..e868d08 100644 --- a/tado_local/__version__.py +++ b/tado_local/__version__.py @@ -32,4 +32,4 @@ # - 1.1.1: Bug fix update # - 2.0.0: Major breaking change -__version__ = "1.0.0-alpha1" +__version__ = "1.1.0" diff --git a/tado_local/api.py b/tado_local/api.py index 3c918f3..ed4eb46 100644 --- a/tado_local/api.py +++ b/tado_local/api.py @@ -35,12 +35,12 @@ class TadoLocalAPI: """Tado Local that leverages HomeKit for real-time data without cloud dependency.""" - accessories_cache : List[Any] - accessories_dict : Dict[str, Any] - accessories_id : Dict[int, str] - characteristic_map : Dict[tuple[int, int], str] - characteristic_iid_map : Dict[tuple[int, str], int] - device_to_characteristics : Dict[int, List[tuple[int, int, str]]] # device_id -> [(aid, iid, char_type)] + accessories_cache: List[Any] + accessories_dict: Dict[str, Any] + accessories_id: Dict[int, str] + characteristic_map: Dict[tuple[int, int], str] + characteristic_iid_map: Dict[tuple[int, str], int] + device_to_characteristics: Dict[int, List[tuple[int, int, str]]] # device_id -> [(aid, iid, char_type)] def __init__(self, db_path: str): self.pairing: Optional[IpPairing] = None @@ -61,6 +61,7 @@ def __init__(self, db_path: str): # Cleanup tracking self.subscribed_characteristics: List[tuple[int, int]] = [] self.background_tasks: List[asyncio.Task] = [] + self.window_close_timers: Dict[int, asyncio.Task] = {} self.is_shutting_down = False async def initialize(self, pairing: IpPairing): @@ -78,6 +79,15 @@ async def cleanup(self): logger.info("Starting cleanup...") self.is_shutting_down = True + # Cancel window recheck tasks + if self.window_close_timers: + logger.info(f"Cancelling {len(self.window_close_timers)} window closing timers") + for task in self.window_close_timers.values(): + if not task.done(): + task.cancel() + await asyncio.gather(*self.window_close_timers.values(), return_exceptions=True) + self.window_close_timers.clear() + # Cancel all background tasks if self.background_tasks: logger.info(f"Cancelling {len(self.background_tasks)} background tasks") @@ -141,7 +151,7 @@ async def refresh_accessories(self): raise HTTPException(status_code=503, detail=f"Failed to refresh accessories: {e}") def _process_raw_accessories(self, raw_accessories): - accessories={} + accessories = {} for a in raw_accessories: aid = a.get('aid') @@ -266,7 +276,6 @@ async def initialize_device_states(self): logger.info(f"Device state initialization complete - baseline established for {len(self.device_to_characteristics)} devices") - async def setup_event_listeners(self): """Setup unified change detection with events + polling comparison.""" if not self.pairing: @@ -322,7 +331,7 @@ async def setup_persistent_events(self): logger.info("Setting up persistent event system...") # Register unified change handler for events - def event_callback(update_data : dict[tuple[int, int], dict]): + def event_callback(update_data: dict[tuple[int, int], dict]): """Handle ALL HomeKit characteristic updates.""" logger.debug(f"Event callback received update: {update_data}") for k, v in update_data.items(): @@ -413,7 +422,6 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"): if not char_name: char_name = f"{aid}.{iid}" - # Check if this is actually a change last_value = self.change_tracker['last_values'].get(char_key) if last_value == value: @@ -467,6 +475,8 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"): # Format log message: show zone name, only add device detail if not zone leader if is_zone_leader: + # Check for window open/close based on leader updates + self._handle_window_open_detection(device_id, device_info, char_type) # Zone leader - just show zone name logger.info(f"[{src}] {zone_name} | {char_name}: {last_value} -> {value}") else: @@ -477,13 +487,138 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"): # Don't send raw characteristic events anymore - we'll send aggregated state changes # Broadcast aggregated state change for relevant characteristics - if char_name in ['TargetTemperature', 'CurrentTemperature', 'TargetHeatingCoolingState', - 'CurrentHeatingCoolingState', 'CurrentRelativeHumidity', 'ValvePosition']: + if char_name in [ + 'TargetTemperature', 'CurrentTemperature', 'TargetHeatingCoolingState', + 'CurrentHeatingCoolingState', 'CurrentRelativeHumidity', 'ValvePosition' + ]: await self.broadcast_state_change(device_id, zone_name) except Exception as e: logger.error(f"Error handling unified change: {e}") + def _handle_window_open_detection(self, device_id, device_info, char_type): + """Detect window open/close based on leader device temperature update.""" + try: + # Only check for window open/close if the characteristic is relevant (temperature changes in leader) + if char_type.lower() in [DeviceStateManager.CHAR_CURRENT_TEMPERATURE]: + # Get current state for leader device + leader_state = self.state_manager.get_current_state(device_id) + if not leader_state: + # No state info available + return + + zone_name = device_info.get('zone_name', 'No Zone') + + # Simple heuristic: if temperature drops significantly within time threshold, a window open is asumed + # temp_change_threshold = 2.0 # degrees Celsius + # temp_drop_time_threshold = 10 # minutes + temp_change_threshold = 1.0 # degrees Celsius + temp_change_time_threshold = 20 # minutes + + # Get old values from database of the last temp_drop_time_threshold minutes to compare trends + history = self.state_manager.get_device_history_info(device_id, age=temp_change_time_threshold) + + # history structure: history_count, earliest_entry (temp, window, window_lastupdate), latest_entry (temp, window, window_lastupdate) + if not history or history['history_count'] < 1: + # No data, just ignore + return + + # calculate time difference from latest entry to see how long the window has been open/closed/rest + time_diff = (time.time() - int(history['latest_entry'][2])) // 60 + current_window_state = leader_state.get('window') + + # If window is currently open (1) and has been open for longer than the open time threshold, set it to rest (2) + if current_window_state == 1 and time_diff > device_info.get('window_open_time', 15): + logger.info(f"[Window] {zone_name} | Window set to close again, being open over {time_diff:.0f} mins") + # Consider it closed again -> put in rest (2) state to avoid rapid open/close detection + self.state_manager.update_device_window_status(device_id, 2) + self._cancel_window_close_timer(device_id) + return + + if history['history_count'] < 2: + # Temperature drop is to slow to call it an open window + logger.info(f"[Window] {zone_name} | Not enough readings (only {history['history_count']} " + + f"entry in last {temp_change_time_threshold} minutes)") + return + + mode = leader_state.get('cur_heating', 0) # (0=Off, 1=Heating, 2=Cooling) + if mode == 2: + # In cooling mode, calculate temp _rise_ (lastest - earlier) as temp_change + temp_change = history['latest_entry'][0] - history['earliest_entry'][0] + logger.info(f"[Window] {zone_name} | cooling | Window status {current_window_state} " + + f"for {time_diff:.0f} mins | Temp rise {temp_change:.1f}") + else: + # In heating mode, calculate temp _drop_ (earlier - lastest) as temp_change + temp_change = history['earliest_entry'][0] - history['latest_entry'][0] + logger.info(f"[Window] {zone_name} | heating | Window status {current_window_state} " + + f"for {time_diff:.0f} mins | Temp drop {temp_change:.1f}") + + # check if window is currently closed (0) or in rest (2) long enough to consider it closed again + if current_window_state == 0 or (current_window_state == 2 and time_diff > device_info.get('window_rest_time', 15)): + # A significant temp _rise_ is likely to be caused by an open window + window_status = 1 if temp_change >= temp_change_threshold else 0 + + # Update state manager with window status + self.state_manager.update_device_window_status(device_id, window_status) + + # If window is set open (1), schedule a timer to close it again after 'window_open_time' minutes + if window_status == 1: + window_close_delay = device_info.get('window_open_time', 30) + self._schedule_window_close_timer(device_id, window_close_delay, device_info) + + except Exception as e: + logger.error(f"Error in window open detection: {e}") + import traceback + print(traceback.format_exc()) + + def _schedule_window_close_timer(self, device_id: int, window_close_delay: int, device_info: Dict[str, Any]): + """" Schedule a timer to set the window status back to closed after a delay.""" + if self.is_shutting_down: + return + + existing_task = self.window_close_timers.get(device_id) + if existing_task and not existing_task.done(): + existing_task.cancel() + + task = asyncio.create_task(self._window_close_handler(device_id, device_info, window_close_delay)) + self.window_close_timers[device_id] = task + task.add_done_callback(lambda t: self._window_close_timer_stop(device_id, t)) + + def _cancel_window_close_timer(self, device_id: int): + """ Cancel any existing window close timer for the device.""" + task = self.window_close_timers.pop(device_id, None) + if task and not task.done(): + task.cancel() + + def _window_close_timer_stop(self, device_id: int, task: asyncio.Task): + """ Callback to clean up after window close timer finishes or is cancelled.""" + if self.window_close_timers.get(device_id) is task: + del self.window_close_timers[device_id] + + async def _window_close_handler(self, device_id: int, device_info: Dict[str, Any], closing_delay: int): + """ Wait for the specified delay and then set the window status back to closed if it's still open.""" + interval = closing_delay * 60 + + try: + await asyncio.sleep(interval) + if self.is_shutting_down: + return + + current_state = self.state_manager.get_current_state(device_id) + if not current_state or current_state.get('window') != 1: + return + + zone_name = device_info.get('zone_name', 'No Zone') + logger.info(f"[Window] {zone_name} | Window set to close again") + # Consider it closed again -> put in rest state (2) to avoid rapid open/close detection + self.state_manager.update_device_window_status(device_id, 2) + + except asyncio.CancelledError: + return + except Exception as e: + logger.error(f"Window closing error for device {device_id}: {e}") + return + async def broadcast_event(self, event_data): """Broadcast change event to all connected SSE clients.""" try: @@ -545,6 +680,7 @@ def _build_device_state(self, device_id: int) -> dict: 'cur_heating': 1 if state.get('current_heating_cooling_state') == 1 else 0, 'valve_position': state.get('valve_position'), 'battery_low': battery_low, + 'window': state.get('window'), } async def broadcast_state_change(self, device_id: int, zone_name: str): @@ -595,14 +731,17 @@ async def broadcast_state_change(self, device_id: int, zone_name: str): 'target_temp_c': leader_state['target_temp_c'], 'target_temp_f': leader_state['target_temp_f'], 'mode': 0, - 'cur_heating': 0 + 'cur_heating': 0, + 'window_open': leader_state['window'] == 1, } # Apply circuit driver logic for heating states (using cache) if is_circuit_driver: # Circuit driver - check radiator valves in zone (from cache) - other_devices = [dev_id for dev_id, dev_info in self.state_manager.device_info_cache.items() - if dev_info.get('zone_id') == zone_id and not dev_info.get('is_circuit_driver')] + other_devices = [ + dev_id for dev_id, dev_info in self.state_manager.device_info_cache.items() + if dev_info.get('zone_id') == zone_id and not dev_info.get('is_circuit_driver') + ] if other_devices: for valve_id in other_devices: diff --git a/tado_local/database.py b/tado_local/database.py index 2dda2a3..c6c02cd 100644 --- a/tado_local/database.py +++ b/tado_local/database.py @@ -59,6 +59,8 @@ zone_type TEXT, leader_device_id INTEGER, order_id INTEGER, + window_open_time INTEGER, + window_rest_time INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (tado_home_id) REFERENCES tado_homes(tado_home_id) ON DELETE CASCADE, @@ -110,6 +112,8 @@ target_humidity REAL, active_state INTEGER, valve_position INTEGER, + window INTEGER, + window_lastupdate TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (device_id, timestamp_bucket), FOREIGN KEY (device_id) REFERENCES devices(device_id) ON DELETE CASCADE @@ -168,7 +172,7 @@ def ensure_schema_and_migrate(db_path: str): # Supported schema version for this codebase. If the database reports a # higher user_version we should refuse to start to avoid silent data loss # or incompatible assumptions. - SUPPORTED_SCHEMA_VERSION = 2 + SUPPORTED_SCHEMA_VERSION = 3 # Open connection and check current schema version before applying changes conn = sqlite3.connect(db_path) @@ -230,6 +234,33 @@ def _apply_script_tolerant(conn, script: str): except Exception: pass raise + + # Migration to version 3: add window detection columns to zones for new window detection features; + # these can be added without data backfill so we can just add and move on + if current_version < 3: + try: + # Use explicit transaction to ensure atomic migration + conn.execute("BEGIN IMMEDIATE") + try: + # SQLite throws "duplicate column name" if it already exists, so we can just attempt to add and ignore if it fails + conn.execute("ALTER TABLE 'device_state_history' ADD COLUMN 'window' INTEGER NOT NULL DEFAULT 0") + conn.execute("ALTER TABLE 'device_state_history' ADD COLUMN 'window_lastupdate' TIMESTAMP") + conn.execute("ALTER TABLE 'zones' ADD COLUMN 'window_open_time' INTEGER NOT NULL DEFAULT 30") + conn.execute("ALTER TABLE 'zones' ADD COLUMN 'window_rest_time' INTEGER NOT NULL DEFAULT 15") + except Exception: + # Column may already exist depending on prior runs + pass + + conn.execute("PRAGMA user_version = 3") + current_version = 3 + conn.commit() + except Exception: + # Rollback any partial changes on error + try: + conn.rollback() + except Exception: + pass + raise finally: conn.close() else: diff --git a/tado_local/homekit_uuids.py b/tado_local/homekit_uuids.py index e787e0c..b1af732 100644 --- a/tado_local/homekit_uuids.py +++ b/tado_local/homekit_uuids.py @@ -280,6 +280,7 @@ "E44673A0-247B-4360-8A76-DB9DA69C0101": "TadoProprietaryControl", } + def get_service_name(uuid: str) -> str: """Convert HomeKit service UUID to human-readable name.""" # Normalize UUID to uppercase for lookup @@ -290,6 +291,7 @@ def get_service_name(uuid: str) -> str: return TADO_SERVICES[uuid_upper] return HOMEKIT_SERVICES.get(uuid_upper, uuid) + def get_characteristic_name(uuid: str) -> str: """Convert HomeKit characteristic UUID to human-readable name.""" # Normalize UUID to uppercase for lookup @@ -300,12 +302,14 @@ def get_characteristic_name(uuid: str) -> str: return TADO_CHARACTERISTICS[uuid_upper] return HOMEKIT_CHARACTERISTICS.get(uuid_upper, uuid) + def get_characteristic_value_name(characteristic_name: str, value) -> str: """Convert HomeKit characteristic value to human-readable name.""" if characteristic_name in HOMEKIT_VALUES and value in HOMEKIT_VALUES[characteristic_name]: return HOMEKIT_VALUES[characteristic_name][value] return str(value) + def enhance_accessory_data(accessories): """ Enhance raw HomeKit accessories data with human-readable names. @@ -373,6 +377,7 @@ def enhance_accessory_data(accessories): return enhanced + def add_tado_specific_info(enhanced_char, char_name, value): """Add Tado-specific interpretations for characteristics.""" diff --git a/tado_local/routes.py b/tado_local/routes.py index 7c54b70..2a49528 100644 --- a/tado_local/routes.py +++ b/tado_local/routes.py @@ -26,7 +26,7 @@ from typing import Optional from fastapi import FastAPI, HTTPException, Depends, status -from fastapi.responses import StreamingResponse, FileResponse +from fastapi.responses import StreamingResponse, FileResponse, PlainTextResponse from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.staticfiles import StaticFiles @@ -44,6 +44,7 @@ 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() + def get_api_key(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)) -> Optional[str]: """ Validate API key from Authorization header. @@ -149,8 +150,8 @@ async def robots(): if robots_file.exists(): return FileResponse(robots_file, media_type="text/plain") else: - # Fallback if file not found - return "User-agent: *\nDisallow: /\n", {"Content-Type": "text/plain"} + # Fallback if file not found (make sure is sent as plain text not json) + return PlainTextResponse("User-agent: *\nDisallow: /\n") @app.get("/.well-known/{path:path}", include_in_schema=False) async def well_known(path: str): @@ -460,8 +461,10 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): # Use cached zone info (no DB query) # Sort by order_id (treating None as 999, but 0 is valid), then by name - for zone_id, zone_info in sorted(tado_api.state_manager.zone_cache.items(), - key=lambda x: (999 if x[1].get('order_id') is None else x[1].get('order_id'), x[1].get('name'))): + for zone_id, zone_info in sorted( + tado_api.state_manager.zone_cache.items(), + key=lambda x: (999 if x[1].get('order_id') is None else x[1].get('order_id'), x[1].get('name')) + ): name = zone_info['name'] leader_device_id = zone_info['leader_device_id'] order_id = zone_info['order_id'] @@ -469,10 +472,14 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): leader_type = zone_info['leader_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'] + window_rest_time = zone_info['window_rest_time'] # Get device count for this zone (quick loop through device cache) - device_count = sum(1 for dev_info in tado_api.state_manager.device_info_cache.values() - if dev_info.get('zone_id') == zone_id) + device_count = sum( + 1 for dev_info in tado_api.state_manager.device_info_cache.values() + if dev_info.get('zone_id') == zone_id + ) # Get zone state from leader (with optimistic updates for UI responsiveness) # Note: Individual devices always show real state. Only zone aggregation uses optimistic state. @@ -495,6 +502,7 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): humidity = zone_state.get('humidity') target_temp = zone_state.get('target_temperature') target_heating_cooling_state = zone_state.get('target_heating_cooling_state', 0) + window_open = (zone_state.get('window') == 1) # Mode: Always from zone leader's target_heating_cooling_state (with optimistic updates) mode = target_heating_cooling_state @@ -503,8 +511,10 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): cur_heating = 0 if is_circuit_driver: # Circuit driver - check if there are other devices (radiator valves) in zone - other_devices = [dev_id for dev_id, dev_info in tado_api.state_manager.device_info_cache.items() - if dev_info.get('zone_id') == zone_id and not dev_info.get('is_circuit_driver')] + other_devices = [ + dev_id for dev_id, dev_info in tado_api.state_manager.device_info_cache.items() + if dev_info.get('zone_id') == zone_id and not dev_info.get('is_circuit_driver') + ] if other_devices: # Circuit driver WITH other devices - use radiator valve heating state (real state) @@ -532,6 +542,7 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): 'target_temp_f': target_temp_f, 'mode': mode, 'cur_heating': cur_heating, + 'window_open': window_open, } else: state_summary = { @@ -542,6 +553,7 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): 'target_temp_f': None, 'mode': 0, 'cur_heating': 0, + 'window_open': None, } zones.append({ @@ -555,6 +567,8 @@ async def get_zones(api_key: Optional[str] = Depends(get_api_key)): 'is_circuit_driver': bool(is_circuit_driver), 'order_id': order_id, 'device_count': device_count, + 'window_open_time': window_open_time, + 'window_rest_time': window_rest_time, 'state': state_summary }) @@ -623,10 +637,14 @@ async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): leader_type = zone_info['leader_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'] + window_rest_time = zone_info['window_rest_time'] # Get device count for this zone (quick loop through device cache) - device_count = sum(1 for dev_info in tado_api.state_manager.device_info_cache.values() - if dev_info.get('zone_id') == zone_id) + device_count = sum( + 1 for dev_info in tado_api.state_manager.device_info_cache.values() + if dev_info.get('zone_id') == zone_id + ) # Get zone state from leader (with optimistic updates for UI responsiveness) # Note: Individual devices always show real state. Only zone aggregation uses optimistic state. @@ -649,6 +667,7 @@ async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): humidity = zone_state.get('humidity') target_temp = zone_state.get('target_temperature') target_heating_cooling_state = zone_state.get('target_heating_cooling_state', 0) + window_open = (zone_state.get('window') == 1) # Mode: Always from zone leader's target_heating_cooling_state (with optimistic updates) mode = target_heating_cooling_state @@ -657,8 +676,10 @@ async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): cur_heating = 0 if is_circuit_driver: # Circuit driver - check if there are other devices (radiator valves) in zone - other_devices = [dev_id for dev_id, dev_info in tado_api.state_manager.device_info_cache.items() - if dev_info.get('zone_id') == zone_id and not dev_info.get('is_circuit_driver')] + other_devices = [ + dev_id for dev_id, dev_info in tado_api.state_manager.device_info_cache.items() + if dev_info.get('zone_id') == zone_id and not dev_info.get('is_circuit_driver') + ] if other_devices: # Circuit driver WITH other devices - use radiator valve heating state (real state) @@ -686,6 +707,7 @@ async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): 'target_temp_f': target_temp_f, 'mode': mode, 'cur_heating': cur_heating, + 'window_open': window_open } else: state_summary = { @@ -696,6 +718,7 @@ async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): 'target_temp_f': None, 'mode': 0, 'cur_heating': 0, + 'window_open': None, } zone = { @@ -709,6 +732,8 @@ async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): 'is_circuit_driver': bool(is_circuit_driver), 'order_id': order_id, 'device_count': device_count, + 'window_open_time': window_open_time, + 'window_rest_time': window_rest_time, 'state': state_summary } @@ -736,7 +761,12 @@ async def get_zone(zone_id: int, api_key: Optional[str] = Depends(get_api_key)): } @app.post("/zones", tags=["Zones"]) - async def create_zone(name: str, leader_device_id: Optional[int] = None, order_id: Optional[int] = None, api_key: Optional[str] = Depends(get_api_key)): + async def create_zone( + name: str, + leader_device_id: Optional[int] = None, + order_id: Optional[int] = None, + api_key: Optional[str] = Depends(get_api_key) + ): """Create a new zone.""" tado_api = get_tado_api() if not tado_api: @@ -757,14 +787,18 @@ async def create_zone(name: str, leader_device_id: Optional[int] = None, order_i return {'zone_id': zone_id, 'name': name} @app.put("/zones/{zone_id}", tags=["Zones"]) - async def update_zone(zone_id: int, name: Optional[str] = None, leader_device_id: Optional[int] = None, order_id: Optional[int] = None, api_key: Optional[str] = Depends(get_api_key)): + async def update_zone( + zone_id: int, + name: Optional[str] = None, + leader_device_id: Optional[int] = None, + order_id: Optional[int] = None, + api_key: Optional[str] = Depends(get_api_key) + ): """Update a zone.""" tado_api = get_tado_api() if not tado_api: raise HTTPException(status_code=503, detail="API not initialized") - conn = sqlite3.connect(tado_api.state_manager.db_path) - updates = [] params = [] if name is not None: @@ -780,24 +814,29 @@ async def update_zone(zone_id: int, name: Optional[str] = None, leader_device_id if not updates: raise HTTPException(status_code=400, detail="No updates provided") + conn = sqlite3.connect(tado_api.state_manager.db_path) params.append(zone_id) conn.execute(f"UPDATE zones SET {', '.join(updates)} WHERE zone_id = ?", params) + updated = (conn.total_changes > 0) conn.commit() conn.close() + if not updated: + raise HTTPException(status_code=404, detail=f"Zone {zone_id} not found or no changes made") + # Reload device cache tado_api.state_manager._load_device_cache() - return {'zone_id': zone_id, 'updated': True} + return {'zone_id': zone_id, 'updated': updated} @app.post("/zones/{zone_id}/set", tags=["Zones"]) async def set_zone( - zone_id: int, - temperature: Optional[float] = None, - heating_enabled: Optional[bool] = None, - no_implicit_mode: Optional[bool] = False, - api_key: Optional[str] = Depends(get_api_key) - ): + zone_id: int, + temperature: Optional[float] = None, + heating_enabled: Optional[bool] = None, + no_implicit_mode: Optional[bool] = False, + api_key: Optional[str] = Depends(get_api_key) + ): """ Control a zone's heating via its leader device. @@ -957,6 +996,66 @@ async def set_zone( logger.error(f"Failed to control zone {zone_id}: {e}") raise HTTPException(status_code=500, detail=f"Failed to set zone control: {str(e)}") + @app.post("/zones/{zone_id}/windowtimeouts", tags=["Zones"]) + async def set_zone_window_timeouts( + zone_id: int, + window_open_time: Optional[int] = None, + window_rest_time: Optional[int] = None, + api_key: Optional[str] = Depends(get_api_key) + ): + """ + Set the open window timeout for a specific zone. + Args: + window_open_time: window open timeout in minutes (1-480, or -1 to reset to default: 30) + window_rest_time: window rest timeout in minutes (1-480, or -1 to reset to default: 15) + + Returns: + Success status and applied values + """ + # Log the incoming request + logger.info(f"POST /zones/{zone_id}/set window_open_time={window_open_time} window_rest_time={window_rest_time}") + + tado_api = get_tado_api() + if not tado_api: + raise HTTPException(status_code=503, detail="API not initialized") + + if not tado_api.pairing: + raise HTTPException(status_code=503, detail="Bridge not connected") + + if zone_id not in tado_api.state_manager.zone_cache: + raise HTTPException(status_code=404, detail=f"Zone {zone_id} not found") + + # Apply smart defaults + if window_open_time is None and window_rest_time is None: + raise HTTPException(status_code=400, detail="No control parameters provided") + + updates = [] + params = [] + + if window_open_time is not None: + if window_open_time < -1 or window_open_time > 480: + raise HTTPException(status_code=400, detail="window_open_time must be between 1 and 480 minutes, or -1 to reset to default") + updates.append("window_open_time = ?") + params.append(int(window_open_time) if window_open_time > 0 else 30) + + if window_rest_time is not None: + if window_rest_time < -1 or window_rest_time > 480: + raise HTTPException(status_code=400, detail="window_rest_time must be between 1 and 480 minutes, or -1 to reset to default") + updates.append("window_rest_time = ?") + params.append(int(window_rest_time) if window_rest_time > 0 else 15) + + # Update the zone's window timeout settings in the database + params.append(zone_id) + conn = sqlite3.connect(tado_api.state_manager.db_path) + conn.execute(f"UPDATE zones SET {', '.join(updates)} WHERE zone_id = ?", params) + conn.commit() + conn.close() + + # Reload zone cache + tado_api.state_manager._load_zone_cache() + + return {'zone_id': zone_id, 'updated': True} + @app.get("/devices", tags=["Devices"]) async def get_devices(api_key: Optional[str] = Depends(get_api_key)): """ @@ -1648,11 +1747,13 @@ async def refresh_cloud_data(battery_only: bool = False, api_key: Optional[str] logger.info(f"Synced {len(devices)} devices (battery status)") # Sync to database - await sync.sync_all(cloud_api, - home_data=False, # Skip - zones_data=False, # Skip - zone_states_data=zone_states, - devices_data=devices) + await sync.sync_all( + cloud_api, + home_data=False, # Skip + zones_data=False, # Skip + zone_states_data=zone_states, + devices_data=devices + ) result['refreshed'] = ['battery_status', 'device_status'] else: @@ -1671,11 +1772,13 @@ async def refresh_cloud_data(battery_only: bool = False, api_key: Optional[str] result['devices_synced'] = len(devices) # Sync to database - await sync.sync_all(cloud_api, - home_data=home_info, - zones_data=zones, - zone_states_data=zone_states, - devices_data=devices) + await sync.sync_all( + cloud_api, + home_data=home_info, + zones_data=zones, + zone_states_data=zone_states, + devices_data=devices + ) result['refreshed'] = ['home_info', 'zones', 'battery_status', 'device_status'] diff --git a/tado_local/state.py b/tado_local/state.py index 9a8957a..283f556 100644 --- a/tado_local/state.py +++ b/tado_local/state.py @@ -24,6 +24,7 @@ logger = logging.getLogger(__name__) + class DeviceStateManager: """Manages device state tracking, history, and change detection.""" @@ -88,11 +89,12 @@ def _load_device_cache(self): cursor = conn.execute(""" SELECT d.device_id, d.serial_number, d.aid, d.name, d.device_type, d.zone_id, z.name as zone_name, d.is_zone_leader, d.is_circuit_driver, d.battery_state, - z.tado_zone_id + z.tado_zone_id, z.window_open_time, z.window_rest_time FROM devices d LEFT JOIN zones z ON d.zone_id = z.zone_id """) - for device_id, serial_number, aid, name, device_type, zone_id, zone_name, is_zone_leader, is_circuit_driver, battery_state, tado_zone_id in cursor.fetchall(): + for device_id, serial_number, aid, name, device_type, zone_id, zone_name, is_zone_leader, is_circuit_driver, \ + battery_state, tado_zone_id, window_open_time, window_rest_time in cursor.fetchall(): self.device_id_cache[serial_number] = device_id if aid: self.aid_to_device_id[aid] = device_id @@ -106,7 +108,9 @@ def _load_device_cache(self): 'tado_zone_id': tado_zone_id, 'is_zone_leader': bool(is_zone_leader), 'is_circuit_driver': bool(is_circuit_driver), - 'battery_state': battery_state # From Cloud API: "NORMAL", "LOW", etc. + 'battery_state': battery_state, # From Cloud API: "NORMAL", "LOW", etc. + 'window_open_time': window_open_time, # minutes to consider window open after detection + 'window_rest_time': window_rest_time, # minutes to wait after window closes before resuming normal operation } conn.close() logger.info(f"Loaded {len(self.device_id_cache)} devices from cache") @@ -118,13 +122,15 @@ def _load_zone_cache(self): SELECT z.zone_id, z.name, z.leader_device_id, z.order_id, d.serial_number as leader_serial, d.device_type as leader_type, z.tado_zone_id, - d.is_circuit_driver, z.uuid + d.is_circuit_driver, z.uuid, + z.window_open_time, z.window_rest_time 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 in cursor.fetchall(): + 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(): self.zone_cache[zone_id] = { 'zone_id': zone_id, 'name': name, @@ -134,7 +140,9 @@ def _load_zone_cache(self): 'leader_serial': leader_serial, 'leader_type': leader_type, 'is_circuit_driver': bool(is_circuit_driver), - 'uuid': uuid_val + 'uuid': uuid_val, + 'window_open_time': window_open_time, + 'window_rest_time': window_rest_time } conn.close() @@ -151,7 +159,8 @@ def _load_latest_state_from_db(self): current_heating_cooling_state, target_heating_cooling_state, heating_threshold_temperature, cooling_threshold_temperature, temperature_display_units, battery_level, status_low_battery, - humidity, target_humidity, active_state, valve_position + humidity, target_humidity, active_state, valve_position, + window, window_lastupdate FROM device_state_history WHERE (device_id, timestamp_bucket) IN ( SELECT device_id, MAX(timestamp_bucket) @@ -179,6 +188,8 @@ def _load_latest_state_from_db(self): 'target_humidity': row[12], 'active_state': row[13], 'valve_position': row[14], + 'window': row[15], + 'window_lastupdate': row[16], } # Set the last saved bucket @@ -362,7 +373,8 @@ def _has_state_changed(self, device_id: int) -> bool: 'current_heating_cooling_state', 'target_heating_cooling_state', 'heating_threshold_temperature', 'cooling_threshold_temperature', 'temperature_display_units', 'battery_level', 'status_low_battery', - 'humidity', 'target_humidity', 'active_state', 'valve_position' + 'humidity', 'target_humidity', 'active_state', 'valve_position', + 'window' ] for field in data_fields: @@ -394,8 +406,9 @@ def _save_to_history(self, device_id: int, timestamp: float): current_heating_cooling_state, target_heating_cooling_state, heating_threshold_temperature, cooling_threshold_temperature, temperature_display_units, battery_level, status_low_battery, - humidity, target_humidity, active_state, valve_position - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + humidity, target_humidity, active_state, valve_position, + window, window_lastupdate + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id, timestamp_bucket) DO UPDATE SET current_temperature = COALESCE(excluded.current_temperature, current_temperature), target_temperature = COALESCE(excluded.target_temperature, target_temperature), @@ -410,6 +423,8 @@ def _save_to_history(self, device_id: int, timestamp: float): target_humidity = COALESCE(excluded.target_humidity, target_humidity), active_state = COALESCE(excluded.active_state, active_state), valve_position = COALESCE(excluded.valve_position, valve_position), + window = COALESCE(excluded.window, window), + window_lastupdate = COALESCE(excluded.window_lastupdate, window_lastupdate), updated_at = CURRENT_TIMESTAMP """, ( device_id, bucket, @@ -425,7 +440,9 @@ def _save_to_history(self, device_id: int, timestamp: float): state.get('humidity'), state.get('target_humidity'), state.get('active_state'), - state.get('valve_position') + state.get('valve_position'), + state.get('window'), + state.get('window_lastupdate') )) conn.commit() conn.close() @@ -446,6 +463,8 @@ def _save_to_history(self, device_id: int, timestamp: float): 'target_humidity': state.get('target_humidity'), 'active_state': state.get('active_state'), 'valve_position': state.get('valve_position'), + 'window': state.get('window'), + 'window_lastupdate': state.get('window_lastupdate'), } logger.debug(f"Saved device {device_id} state to history bucket {bucket}") @@ -582,10 +601,10 @@ def get_all_devices(self) -> List[Dict]: conn = sqlite3.connect(self.db_path) cursor = conn.execute(""" - SELECT d.device_id, d.serial_number, d.aid, d.device_type, d.name, - d.model, d.manufacturer, d.firmware_version, d.zone_id, - z.name as zone_name, d.is_zone_leader, d.is_circuit_driver, - d.battery_state, d.first_seen, d.last_seen + SELECT d.device_id, d.serial_number, d.aid, d.device_type, d.name, + d.model, d.manufacturer, d.firmware_version, d.zone_id, + z.name as zone_name, d.is_zone_leader, d.is_circuit_driver, + d.battery_state, d.first_seen, d.last_seen FROM devices d LEFT JOIN zones z ON d.zone_id = z.zone_id ORDER BY device_id @@ -606,3 +625,50 @@ def get_all_devices(self) -> List[Dict]: conn.close() return devices + + def get_device_history_info(self, device_id: int, age: int) -> Dict[str, Any]: + """Get metadata about a device's history (earliest and latest timestamps).""" + + conn = sqlite3.connect(self.db_path) + cursor = conn.execute(""" + SELECT current_temperature, window, window_lastupdate + FROM device_state_history + WHERE device_id = ? + AND updated_at > datetime('now', ?) + ORDER BY updated_at DESC + """, (device_id, f"-{age} minutes")) + history = cursor.fetchall() + conn.close() + + if history and len(history) > 0: + return { + 'history_count': len(history), + 'latest_entry': history[0], + 'earliest_entry': history[-1], + } + + return { + 'history_count': 0, + 'latest_entry': None, + 'earliest_entry': None, + } + + def update_device_window_status(self, device_id: int, window_open: int): + """ + Update the window open/closed status for a device. + + Args: + device_id: The device ID to update. + window_open: 1 if window is open, 0 if closed, 2 if in rest. + """ + if device_id not in self.current_state: + self.current_state[device_id] = {} + + old_status = self.current_state[device_id].get('window') + # Optionally, persist this change to history if status changed + if old_status != window_open: + self.current_state[device_id]['window'] = window_open + self.current_state[device_id]['window_lastupdate'] = time.time() + + self._save_to_history(device_id, time.time()) + logger.info(f"Device {device_id} window status updated: {old_status} -> {window_open}") diff --git a/tado_local/static/index.html b/tado_local/static/index.html index caaaaa4..543771a 100644 --- a/tado_local/static/index.html +++ b/tado_local/static/index.html @@ -173,13 +173,14 @@ h1 { color: var(--text-primary); font-weight: 400; + width: 100%; } .theme-toggle { background: none; border: 2px solid var(--border-color); border-radius: 8px; - padding: 6px 10px; + padding: 10px 10px; cursor: pointer; font-size: 18px; line-height: 1; @@ -287,16 +288,40 @@ } .flame-icon { - position: absolute; - top: 15px; - right: 15px; font-size: 24px; - animation: flicker 1.5s infinite alternate; + animation: flicker 2s infinite alternate; + } + + .window-toggle { + background: none; + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 6px 10px; + cursor: pointer; + line-height: 1; + transition: border-color 0.2s; + } + + .window-toggle:hover { + border-color: var(--accent); + } + .window-icon { + height: 30px; + } + + .zone-activity-icons { + display: flex; + position: absolute; + top: 16px; + flex-direction: column; + align-items: center; + align-self: flex-end; } @keyframes flicker { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.8; } + 0% { opacity: 0.7; transform: scale(1); } + 50% { opacity: 1; transform: scale(0.7); } + 100% { opacity: 1; transform: scale(1); } } /* Modal */ @@ -473,8 +498,9 @@ color: var(--battery-low); font-size: 12px; margin-top: 5px; + font-weight: 600; } - + .status-bar { background: var(--bg-card); border-radius: 8px; @@ -601,11 +627,82 @@ margin-bottom: 20px; } + +

Tado Local

+
@@ -707,6 +804,7 @@ let historyChart = null; let lastCloudAuthState = null; let statusPollInterval = null; + let showWindowStatus = "open-only"; // "show", "hide", "open-only" // Lazy load Chart.js and date adapter when history tab is first opened async function loadChartJs() { @@ -1167,6 +1265,11 @@ const mode = state.mode || 0; const curHeating = state.cur_heating || 0; const batteryLow = state.battery_low; + + let windowStatus = "hide"; + if (state.window_open !== null && state.window_open !== undefined) { + windowStatus = state.window_open ? "open" : "closed"; + } // Format temperature with one decimal, split for styling let tempHtml = '--'; @@ -1210,10 +1313,21 @@ // Show icon based on cur_heating state // 0 = off (no icon), 1 = heating (flame), 2 = cooling (frost/snowflake) let activityIcon = ''; + + // Show icon based on window open status if enabled + if (windowStatus != "hide") { + let showWindowOpenStatus = windowStatus != "hide" && + ((showWindowStatus == "show") || (showWindowStatus == "open-only" && windowStatus == "open")); + activityIcon += '