diff --git a/DESIGN.md b/DESIGN.md index 973bc85..fdb6c3d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -111,9 +111,10 @@ tado-local/ **Purpose**: Command-line interface and application bootstrap **Key Responsibilities**: -- Parse command-line arguments (`--bridge-ip`, `--pin`, `--port`, `--state`) +- Parse command-line arguments (`--bridge-ip`, `--pin`, `--port`, `--state`, `--accessory-ip`, `--accessory-pin`) - Initialize database and create API instance - Manage bridge pairing (load existing or create new) +- Optionally pair standalone HomeKit accessories (e.g. Smart AC Control V3+) - Start OAuth2 device flow for cloud authentication (browser-based) - Configure and start uvicorn web server - Handle graceful shutdown and cleanup @@ -126,7 +127,8 @@ main() │ ├── TadoLocalAPI.__init__(db_path) │ ├── create_app() + register_routes() │ ├── TadoBridge.pair_or_load() - │ ├── TadoLocalAPI.initialize(pairing) + │ ├── TadoBridge.pair_or_load_accessory() # for each --accessory-ip + │ ├── TadoLocalAPI.initialize(pairing, extra_pairings=...) │ └── uvicorn.Server.serve() └── cleanup() ``` diff --git a/README.md b/README.md index 320afab..1dbfa8b 100644 --- a/README.md +++ b/README.md @@ -345,8 +345,22 @@ Options: --pin XXX-XX-XXX HomeKit PIN for initial pairing --port PORT API server port (default: 4407) --clear-pairings Clear all existing pairings before starting + --accessory-ip IP IP of a standalone HomeKit accessory (repeatable) + --accessory-pin PIN HomeKit PIN for a standalone accessory (repeatable) ``` +#### Standalone Accessories + +Some Tado products (e.g. Smart AC Control V3+) are standalone HomeKit accessories +that pair independently from the bridge. You can include them alongside the bridge: + +```bash +tado-local --bridge-ip 192.168.1.100 --accessory-ip 192.168.1.101 --accessory-pin 987-65-432 +``` + +Multiple accessories can be added by repeating the flags. Their zones will appear +in the same API alongside the bridge zones. + ### Optional Authentication You can optionally secure the REST API with one or more Bearer tokens. This is useful for: @@ -620,6 +634,8 @@ Options: --pin XXX-XX-XXX HomeKit PIN for initial pairing --port PORT API server port (default: 4407) --clear-pairings Clear all existing pairings before starting + --accessory-ip IP IP of a standalone HomeKit accessory (repeatable) + --accessory-pin PIN HomeKit PIN for a standalone accessory (repeatable) ``` --- diff --git a/tado_local/__main__.py b/tado_local/__main__.py index c9d5fe6..df68617 100644 --- a/tado_local/__main__.py +++ b/tado_local/__main__.py @@ -128,8 +128,21 @@ def handle_signal(signum, frame): args.bridge_ip, args.pin, db_path, args.clear_pairings ) - # Initialize the API with the pairing - await tado_api.initialize(bridge_pairing) + # Pair / load standalone accessories (e.g. Smart AC Control V3+) + accessory_pairings = [] + for idx, acc_ip in enumerate(args.accessory_ip): + acc_pin = args.accessory_pin[idx] if idx < len(args.accessory_pin) else None + try: + pairing, _ = await TadoBridge.pair_or_load_accessory( + acc_ip, acc_pin, db_path + ) + accessory_pairings.append(pairing) + logger.info(f"Standalone accessory {acc_ip}: ready") + except Exception as e: + logger.error(f"Standalone accessory {acc_ip}: failed ({e})") + + # Initialize the API with the bridge pairing + any standalone accessories + await tado_api.initialize(bridge_pairing, extra_pairings=accessory_pairings or None) # Register mDNS service asynchronously (Avahi via DBus preferred, fall back to zeroconf) if not args.no_mdns: @@ -385,6 +398,12 @@ def main(): # Debug mode with verbose logging tado-local --bridge-ip 192.168.1.100 --verbose + # Pair a standalone Smart AC Control alongside the bridge + tado-local --bridge-ip 192.168.1.100 --accessory-ip 192.168.1.101 --accessory-pin 987-65-432 + + # Reconnect to a previously paired standalone accessory (no PIN needed) + tado-local --bridge-ip 192.168.1.100 --accessory-ip 192.168.1.101 + API Endpoints: GET / - API information GET /status - System status @@ -437,6 +456,14 @@ def main(): "--pid-file", help="Write process ID to specified file (useful for daemon mode)" ) + parser.add_argument( + "--accessory-ip", action="append", default=[], + help="IP of a standalone HomeKit accessory such as Tado Smart AC Control V3+ (repeatable)" + ) + parser.add_argument( + "--accessory-pin", action="append", default=[], + help="HomeKit PIN for a standalone accessory (repeatable, order must match --accessory-ip)" + ) # Parse CLI arguments args = parser.parse_args() diff --git a/tado_local/__version__.py b/tado_local/__version__.py index e868d08..c482383 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.1.0" +__version__ = "1.1.1" diff --git a/tado_local/api.py b/tado_local/api.py index ed4eb46..f515104 100644 --- a/tado_local/api.py +++ b/tado_local/api.py @@ -35,6 +35,7 @@ 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] @@ -44,6 +45,8 @@ class TadoLocalAPI: def __init__(self, db_path: str): self.pairing: Optional[IpPairing] = None + self.extra_pairings: List[IpPairing] = [] + self.aid_to_pairing: Dict[int, IpPairing] = {} self.accessories_cache = [] self.accessories_dict = {} self.accessories_id = {} @@ -60,19 +63,22 @@ def __init__(self, db_path: str): # Cleanup tracking self.subscribed_characteristics: List[tuple[int, int]] = [] + self.pairing_subscriptions: Dict[int, 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): - """Initialize the API with a HomeKit pairing.""" + async def initialize(self, pairing: IpPairing, *, extra_pairings: Optional[List[IpPairing]] = None): + """Initialize the API with a HomeKit pairing and optional standalone accessories.""" self.pairing = pairing - self.is_initializing = True # Suppress change logging during init + self.extra_pairings = extra_pairings or [] + self.is_initializing = True await self.refresh_accessories() await self.initialize_device_states() - self.is_initializing = False # Re-enable change logging + self.is_initializing = False await self.setup_event_listeners() - logger.info("Tado Local initialized successfully") + n = 1 + len(self.extra_pairings) + logger.info(f"Tado Local initialized successfully ({n} pairing{'s' if n > 1 else ''})") async def cleanup(self): """Clean up resources and unsubscribe from events.""" @@ -99,15 +105,27 @@ async def cleanup(self): await asyncio.gather(*self.background_tasks, return_exceptions=True) logger.info("Background tasks cancelled") - # Unsubscribe from all event characteristics - if self.pairing and self.subscribed_characteristics: + # Unsubscribe from event characteristics on each pairing + all_pairings = [self.pairing] + self.extra_pairings + for pairing in all_pairings: + if not pairing: + continue + chars = self.pairing_subscriptions.get(id(pairing), []) + if not chars: + continue try: - logger.info(f"Unsubscribing from {len(self.subscribed_characteristics)} event characteristics") - await self.pairing.unsubscribe(self.subscribed_characteristics) - logger.info("Successfully unsubscribed from events") + logger.info(f"Unsubscribing {len(chars)} events from pairing {id(pairing)}") + await pairing.unsubscribe(chars) except Exception as e: logger.warning(f"Error during unsubscribe: {e}") + # Close standalone accessory pairings + for extra in self.extra_pairings: + try: + await extra.close() + except Exception as e: + logger.warning(f"Error closing standalone pairing: {e}") + # Close all event listener queues if self.event_listeners: logger.info(f"Closing {len(self.event_listeners)} event listener queues") @@ -142,9 +160,27 @@ async def refresh_accessories(self): try: raw_accessories = await self.pairing.list_accessories_and_characteristics() self.accessories_dict = self._process_raw_accessories(raw_accessories) + for a in raw_accessories: + aid = a.get('aid') + if aid is not None: + self.aid_to_pairing[aid] = self.pairing + + for extra in self.extra_pairings: + try: + extra_raw = await extra.list_accessories_and_characteristics() + extra_dict = self._process_raw_accessories(extra_raw) + self.accessories_dict.update(extra_dict) + for a in extra_raw: + aid = a.get('aid') + if aid is not None: + self.aid_to_pairing[aid] = extra + logger.info(f"Standalone accessory contributed {len(extra_dict)} device(s)") + except Exception as e: + logger.error(f"Failed to refresh standalone accessory: {e}") + self.accessories_cache = list(self.accessories_dict.values()) self.last_update = time.time() - logger.info(f"Refreshed {len(self.accessories_cache)} accessories") + logger.info(f"Refreshed {len(self.accessories_cache)} accessories total") return self.accessories_cache except Exception as e: logger.error(f"Failed to refresh accessories: {e}") @@ -207,7 +243,7 @@ def _process_raw_accessories(self, raw_accessories): accessories[key] = { 'id': device_id, # Primary key for API - 'aid': aid, # HomeKit accessory ID + 'aid': aid, # HomeKit accessory ID 'serial_number': serial_number, } | a @@ -247,32 +283,36 @@ async def initialize_device_states(self): logger.info(f"Polling {len(chars_to_poll)} characteristics for initial state...") - # Poll in batches to avoid overwhelming the device + # Group by pairing so each is polled via its own connection + by_pairing: Dict[int, List] = defaultdict(list) + for item in chars_to_poll: + aid = item[0] + by_pairing[id(self.aid_to_pairing.get(aid, self.pairing))].append(item) + batch_size = 10 timestamp = time.time() - for i in range(0, len(chars_to_poll), batch_size): - batch = chars_to_poll[i:i+batch_size] - char_keys = [(aid, iid) for aid, iid, _, _ in batch] + 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] + char_keys = [(aid, iid) for aid, iid, _, _ in batch] - try: - results = await self.pairing.get_characteristics(char_keys) + try: + results = await pairing.get_characteristics(char_keys) - for (aid, iid, device_id, char_type) in batch: - if (aid, iid) in results: - char_data = results[(aid, iid)] - value = char_data.get('value') + for aid, iid, device_id, char_type in batch: + if (aid, iid) in results: + char_data = results[(aid, iid)] + value = char_data.get('value') - if value is not None: - # Update device state - field_name, old_val, new_val = self.state_manager.update_device_characteristic( - device_id, char_type, value, timestamp - ) - if field_name: - logger.debug(f"Initialized device {device_id} {field_name}: {value}") + if value is not None: + field_name, old_val, new_val = self.state_manager.update_device_characteristic(device_id, char_type, value, timestamp) + if field_name: + logger.debug(f"Initialized device {device_id} {field_name}: {value}") - except Exception as e: - logger.error(f"Error polling batch during initialization: {e}") + except Exception as e: + logger.error(f"Error polling batch during initialization: {e}") logger.info(f"Device state initialization complete - baseline established for {len(self.device_to_characteristics)} devices") @@ -315,58 +355,64 @@ async def setup_event_listeners(self): logger.info(f"Initialized change tracker with {len(self.change_tracker['last_values'])} known values from database") - # Try to set up persistent event system + # Try to set up persistent event system for all pairings events_active = await self.setup_persistent_events() - # Only set up polling if events failed (no point polling if events work - it just hits cache) if not events_active: logger.warning("Events not available, falling back to polling") - await self.setup_polling_system() - else: - logger.info("Events active, skipping polling (would just hit 3-hour cache)") + + # Always enable polling as a safety net — standalone accessories + # (e.g. Smart AC Control) may not fire HomeKit temperature events + # even when subscribed, relying on cloud push instead. + await self.setup_polling_system() + if events_active: + logger.info("Events active; polling enabled as safety net for silent devices") async def setup_persistent_events(self): - """Set up persistent event subscriptions to all event characteristics.""" + """Set up persistent event subscriptions across all pairings (bridge + standalone).""" try: logger.info("Setting up persistent event system...") - # Register unified change handler for events - 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(): - asyncio.create_task(self.handle_change(k[0], k[1], v, source="EVENT")) + def _make_event_callback(source_label: str): + def event_callback(update_data: dict[tuple[int, int], dict]): + logger.debug(f"Event ({source_label}) received: {update_data}") + for k, v in update_data.items(): + asyncio.create_task(self.handle_change(k[0], k[1], v, source="EVENT")) - # Register the callback with the pairing's dispatcher - self.pairing.dispatcher_connect(event_callback) - logger.info("Event callback registered with dispatcher") + return event_callback - # Collect ALL event-capable characteristics from ALL accessories - all_event_characteristics = [] + all_pairings = [self.pairing] + self.extra_pairings + total_subscribed = 0 - for accessory in self.accessories_cache: - aid = accessory.get('aid') - for service in accessory.get('services', []): - for char in service.get('characteristics', []): - perms = char.get('perms', []) - if 'ev' in perms: # Event notification supported - iid = char.get('iid') - char_type = char.get('type', '').lower() - - # Track what this characteristic is - all_event_characteristics.append((aid, iid)) - self.characteristic_map[(aid, iid)] = get_characteristic_name(char_type) - self.characteristic_iid_map[(aid, get_characteristic_name(char_type))] = iid - self.change_tracker['event_characteristics'].add((aid, iid)) - - if all_event_characteristics: - # Subscribe to ALL event characteristics at once - this is critical! - await self.pairing.subscribe(all_event_characteristics) - # Track subscriptions for cleanup - self.subscribed_characteristics = all_event_characteristics.copy() - logger.info(f"Subscribed to {len(all_event_characteristics)} event characteristics") - logger.debug(f"Characteristic map: {self.characteristic_map}") + for idx, pairing in enumerate(all_pairings): + label = "bridge" if idx == 0 else f"accessory-{idx}" + pairing.dispatcher_connect(_make_event_callback(label)) + chars_for_pairing = [] + for accessory in self.accessories_cache: + aid = accessory.get('aid') + if self.aid_to_pairing.get(aid) is not pairing: + continue + for service in accessory.get('services', []): + for char in service.get('characteristics', []): + perms = char.get('perms', []) + if 'ev' in perms: + iid = char.get('iid') + char_type = char.get('type', '').lower() + chars_for_pairing.append((aid, iid)) + self.characteristic_map[(aid, iid)] = get_characteristic_name(char_type) + self.characteristic_iid_map[(aid, get_characteristic_name(char_type))] = iid + self.change_tracker['event_characteristics'].add((aid, iid)) + + if chars_for_pairing: + await pairing.subscribe(chars_for_pairing) + self.pairing_subscriptions[id(pairing)] = chars_for_pairing + self.subscribed_characteristics.extend(chars_for_pairing) + total_subscribed += len(chars_for_pairing) + logger.info(f"Subscribed to {len(chars_for_pairing)} events on {label}") + + if total_subscribed: + logger.info(f"Total event subscriptions: {total_subscribed}") return True else: logger.warning("No event-capable characteristics found") @@ -439,14 +485,20 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"): # Update device state manager if device_id: - # Find the accessory by aid (since events come with aid, not device_id) + # Find the accessory matching BOTH aid and device_id to avoid + # collisions when bridge and standalone share the same aid. accessory = None for acc in self.accessories_cache: - if acc.get('aid') == aid: + if acc.get('aid') == aid and acc.get('id') == device_id: accessory = acc break + if not accessory: + for acc in self.accessories_cache: + if acc.get('aid') == aid: + accessory = acc + break - if accessory and accessory.get('id'): + if accessory: # Find the characteristic type for this aid/iid char_type = None for service in accessory.get('services', []): @@ -458,11 +510,9 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"): break if char_type: - field_name, old_val, new_val = self.state_manager.update_device_characteristic( - accessory['id'], char_type, value, timestamp - ) + field_name, old_val, new_val = self.state_manager.update_device_characteristic(device_id, char_type, value, timestamp) if field_name: - logger.debug(f"Updated device {accessory['id']} {field_name}: {old_val} -> {new_val}") + logger.debug(f"Updated device {device_id} {field_name}: {old_val} -> {new_val}") # Skip logging during initialization if not self.is_initializing: @@ -488,9 +538,13 @@ async def handle_change(self, aid, iid, update_data, source="UNKNOWN"): # Broadcast aggregated state change for relevant characteristics if char_name in [ - 'TargetTemperature', 'CurrentTemperature', 'TargetHeatingCoolingState', - 'CurrentHeatingCoolingState', 'CurrentRelativeHumidity', 'ValvePosition' - ]: + 'TargetTemperature', + 'CurrentTemperature', + 'TargetHeatingCoolingState', + 'CurrentHeatingCoolingState', + 'CurrentRelativeHumidity', + 'ValvePosition', + ]: await self.broadcast_state_change(device_id, zone_name) except Exception as e: @@ -500,7 +554,7 @@ 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]: + if char_type and 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: @@ -524,7 +578,10 @@ def _handle_window_open_detection(self, device_id, device_info, char_type): 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 + latest_window_ts = history['latest_entry'][2] + if latest_window_ts is None: + return + time_diff = (time.time() - int(latest_window_ts)) // 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) @@ -537,21 +594,27 @@ def _handle_window_open_detection(self, device_id, device_info, char_type): 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)") + 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) + 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}") + 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}") + 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)): @@ -569,10 +632,11 @@ def _handle_window_open_detection(self, device_id, device_info, char_type): 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.""" + """ " Schedule a timer to set the window status back to closed after a delay.""" if self.is_shutting_down: return @@ -585,18 +649,18 @@ def _schedule_window_close_timer(self, device_id: int, window_close_delay: int, 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.""" + """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.""" + """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.""" + """Wait for the specified delay and then set the window status back to closed if it's still open.""" interval = closing_delay * 60 try: @@ -656,7 +720,7 @@ def _celsius_to_fahrenheit(self, celsius: float) -> float: """Convert Celsius to Fahrenheit.""" if celsius is None: return None - return round(celsius * 9/5 + 32, 1) + return round(celsius * 9 / 5 + 32, 1) def _build_device_state(self, device_id: int) -> dict: """Build a standardized device state dictionary.""" @@ -708,7 +772,7 @@ async def broadcast_state_change(self, device_id: int, zone_name: str): 'serial': serial, 'zone_name': zone_name, 'state': device_state, - 'timestamp': time.time() + 'timestamp': time.time(), } await self.broadcast_event(device_event) @@ -739,9 +803,10 @@ async def broadcast_state_change(self, device_id: int, zone_name: str): 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') - ] + 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: @@ -765,13 +830,7 @@ async def broadcast_state_change(self, device_id: int, zone_name: str): self.last_zone_states[zone_id] = zone_state.copy() # Broadcast zone state change - zone_event = { - 'type': 'zone', - 'zone_id': zone_id, - 'zone_name': zone_name, - 'state': zone_state, - 'timestamp': time.time() - } + zone_event = {'type': 'zone', 'zone_id': zone_id, 'zone_name': zone_name, 'state': zone_state, 'timestamp': time.time()} await self.broadcast_event(zone_event) except Exception as e: @@ -863,30 +922,29 @@ async def background_polling_loop(self): async def _poll_characteristics(self, char_list, source="POLLING"): """Poll a list of characteristics and process changes.""" - # Poll in batches to avoid overwhelming the device - batch_size = 15 - - for i in range(0, len(char_list), batch_size): - batch = char_list[i:i+batch_size] + by_pairing: Dict[int, List] = defaultdict(list) + for item in char_list: + by_pairing[id(self.aid_to_pairing.get(item[0], self.pairing))].append(item) - try: - results = await self.pairing.get_characteristics(batch) + batch_size = 15 - for aid, iid in batch: - if (aid, iid) in results: - char_data = results[(aid, iid)] - value = char_data.get('value') + 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] - # Create proper update_data format for unified change handler - update_data = { - 'value': value - } + try: + results = await pairing.get_characteristics(batch) - # Use the unified change handler - await self.handle_change(aid, iid, update_data, source) + for aid, iid in batch: + if (aid, iid) in results: + char_data = results[(aid, iid)] + value = char_data.get('value') + update_data = {'value': value} + await self.handle_change(aid, iid, update_data, source) - except Exception as e: - logger.error(f"Error polling batch: {e}") + except Exception as e: + logger.error(f"Error polling batch: {e}") async def handle_homekit_event(self, event_data): """Handle incoming HomeKit events and update device states.""" @@ -897,10 +955,7 @@ async def handle_homekit_event(self, event_data): value = event_data.get('value') if aid and iid and value is not None: - self.device_states[str(aid)][str(iid)] = { - 'value': value, - 'timestamp': time.time() - } + self.device_states[str(aid)][str(iid)] = {'value': value, 'timestamp': time.time()} # Notify event listeners (for SSE) for queue in self.event_listeners: @@ -979,7 +1034,8 @@ async def set_device_characteristics(self, device_id: int, char_updates: Dict[st if not characteristics_to_set: raise ValueError("No valid characteristics to set") - # Set the characteristics + # Set the characteristics via the correct pairing + target_pairing = self.aid_to_pairing.get(aid, self.pairing) logger.debug(f"Sending to HomeKit: {characteristics_to_set}") - await self.pairing.put_characteristics(characteristics_to_set) + await target_pairing.put_characteristics(characteristics_to_set) return True diff --git a/tado_local/bridge.py b/tado_local/bridge.py index 0a38041..e1e4dee 100644 --- a/tado_local/bridge.py +++ b/tado_local/bridge.py @@ -41,6 +41,8 @@ logger = logging.getLogger(__name__) +HOMEKIT_DEFAULT_PORT = 80 + class TadoBridge: """Manages HomeKit bridge pairing and connection.""" @@ -51,6 +53,7 @@ async def get_or_create_controller_identity(db_path: str): # Ensure DB schema and run migrations before using DB from .database import ensure_schema_and_migrate + ensure_schema_and_migrate(db_path) conn = sqlite3.connect(db_path) @@ -77,19 +80,14 @@ async def get_or_create_controller_identity(db_path: str): # Serialize keys for storage private_key_bytes = private_key.private_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - ) - public_key_bytes = public_key.public_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PublicFormat.SubjectPublicKeyInfo + encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) + public_key_bytes = public_key.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo) # Store in database conn.execute( "INSERT INTO controller_identity (controller_id, private_key, public_key) VALUES (?, ?, ?)", - (controller_id, private_key_bytes, public_key_bytes) + (controller_id, private_key_bytes, public_key_bytes), ) conn.commit() conn.close() @@ -108,7 +106,7 @@ async def save_pairing_session(db_path: str, bridge_ip: str, controller_id: str, # Save new session conn.execute( "INSERT INTO pairing_sessions (bridge_ip, controller_id, session_state, part1_salt, part1_public_key) VALUES (?, ?, ?, ?, ?)", - (bridge_ip, controller_id, "part1_complete", salt, public_key) + (bridge_ip, controller_id, "part1_complete", salt, public_key), ) conn.commit() conn.close() @@ -120,7 +118,7 @@ async def get_pairing_session(db_path: str, bridge_ip: str): conn = sqlite3.connect(db_path) cursor = conn.execute( "SELECT controller_id, part1_salt, part1_public_key FROM pairing_sessions WHERE bridge_ip = ? AND session_state = 'part1_complete'", - (bridge_ip,) + (bridge_ip,), ) row = cursor.fetchone() conn.close() @@ -138,6 +136,20 @@ async def clear_pairing_session(db_path: str, bridge_ip: str): conn.commit() conn.close() + @staticmethod + async def _connect_pairing(pairing_data: dict, db_path: str) -> tuple: + """Create an IpPairing from stored data, connect, and list accessories. + + Returns: + (IpPairing, list of accessories) + """ + char_cache = CharacteristicCacheSQLite(db_path) + controller = IpController(char_cache=char_cache, zeroconf_instance=None) + pairing = IpPairing(controller, pairing_data) + await pairing._ensure_connected() + accessories = await pairing.list_accessories_and_characteristics() + return pairing, accessories + @staticmethod async def perform_pairing(host: str, port: int, pin: str, db_path: str = None): """Perform pairing with persistent controller identity management.""" @@ -380,9 +392,7 @@ async def pair_or_load(bridge_ip: Optional[str], pin: Optional[str], db_path: Pa if bridge_ip: # User specified a bridge IP, try to find that specific pairing - row = conn.execute( - "SELECT pairing_data FROM pairings WHERE bridge_ip = ?", (bridge_ip,) - ).fetchone() + row = conn.execute("SELECT pairing_data FROM pairings WHERE bridge_ip = ?", (bridge_ip,)).fetchone() if row: pairing_data = json.loads(row[0]) selected_bridge_ip = bridge_ip @@ -407,25 +417,8 @@ async def pair_or_load(bridge_ip: Optional[str], pin: Optional[str], db_path: Pa if pairing_data is not None: logger.info(f"=> Testing existing pairing for {selected_bridge_ip}...") - # Create a controller with proper async context try: - if False: - zeroconf_instance = AsyncZeroconf() - else: - zeroconf_instance = None - - # Create SQLite-backed characteristic cache - char_cache = CharacteristicCacheSQLite(str(db_path)) - - # Create controller with proper dependencies - controller = IpController(char_cache=char_cache, zeroconf_instance=zeroconf_instance) - - # Create pairing with controller instance - pairing = IpPairing(controller, pairing_data) - - # Test connection - await pairing._ensure_connected() - accessories = await pairing.list_accessories_and_characteristics() + pairing, accessories = await TadoBridge._connect_pairing(pairing_data, str(db_path)) logger.info(f"Successfully connected to {selected_bridge_ip}!") logger.info(f"Found {len(accessories)} accessories") @@ -450,16 +443,8 @@ async def pair_or_load(bridge_ip: Optional[str], pin: Optional[str], db_path: Pa logger.info(f"Starting fresh pairing with {bridge_ip} using PIN {pin}...") try: - if False: - zeroconf_instance = AsyncZeroconf() - else: - zeroconf_instance = None - - # Perform pairing using enhanced protocol with persistent controller identity - # Use the pairing data as returned by the protocol - pairing_data = await TadoBridge.perform_pairing_with_controller(bridge_ip, 80, pin, str(db_path)) + pairing_data = await TadoBridge.perform_pairing_with_controller(bridge_ip, HOMEKIT_DEFAULT_PORT, pin, str(db_path)) - # Save to DB conn.execute( "INSERT OR REPLACE INTO pairings (bridge_ip, pairing_data) VALUES (?, ?)", (bridge_ip, json.dumps(pairing_data)), @@ -467,14 +452,7 @@ async def pair_or_load(bridge_ip: Optional[str], pin: Optional[str], db_path: Pa conn.commit() logger.info("Pairing successful and saved to database!") - # Create pairing instance with the new data - # Create a controller instance for the pairing - - char_cache = CharacteristicCacheSQLite(str(db_path)) - controller = IpController(char_cache=char_cache, zeroconf_instance=zeroconf_instance) - pairing = IpPairing(controller, pairing_data) - await pairing._ensure_connected() - await pairing.list_accessories_and_characteristics() + pairing, _ = await TadoBridge._connect_pairing(pairing_data, str(db_path)) logger.info("Connected and fetched accessories!") return pairing, bridge_ip @@ -484,9 +462,9 @@ async def pair_or_load(bridge_ip: Optional[str], pin: Optional[str], db_path: Pa # Provide enhanced error messages based on Home Assistant's approach if "UnavailableError" in str(type(e)) or "Unavailable" in str(e): - logger.error("" + "="*60) + logger.error("" + "=" * 60) logger.error("DEVICE REPORTS 'UNAVAILABLE' FOR PAIRING") - logger.error("="*60) + logger.error("=" * 60) logger.error("Based on Home Assistant's approach, this typically means:") logger.error("1. Device is already paired to another HomeKit controller") logger.error("2. Device needs to be reset to clear existing pairings") @@ -512,7 +490,7 @@ async def pair_or_load(bridge_ip: Optional[str], pin: Optional[str], db_path: Pa logger.error(" - Check device status flags in mDNS browser") logger.error(" - Look for 'sf=1' (unpaired) vs 'sf=0' (paired)") logger.error(" - Verify device is actually advertising for pairing") - logger.error("="*60 + "") + logger.error("=" * 60 + "") elif "Already" in str(e): logger.error("Device appears to already be paired.") elif "Authentication" in str(type(e)) or "Authentication" in str(e): @@ -524,6 +502,68 @@ async def pair_or_load(bridge_ip: Optional[str], pin: Optional[str], db_path: Pa raise RuntimeError("No pairing data found and no PIN provided. Provide --pin to pair first.") + @staticmethod + async def pair_or_load_accessory(accessory_ip: str, pin: Optional[str], db_path: Path): + """Load existing pairing or perform new pairing for a standalone accessory. + + Unlike pair_or_load(), this targets a specific IP without auto-select + logic and stores the pairing in the same ``pairings`` table. The + accessory is expected to be a standalone HomeKit device (e.g. Tado + Smart AC Control V3+) that is **not** behind the Internet Bridge. + """ + db_str = str(db_path) + conn = sqlite3.connect(db_str) + conn.executescript(DB_SCHEMA) + conn.commit() + + row = conn.execute( + "SELECT pairing_data FROM pairings WHERE bridge_ip = ?", + (accessory_ip,), + ).fetchone() + + if row: + pairing_data = json.loads(row[0]) + logger.info(f"Found existing accessory pairing for {accessory_ip}") + + try: + pairing, accessories = await TadoBridge._connect_pairing(pairing_data, db_str) + logger.info(f"Accessory {accessory_ip}: connected, {len(accessories)} accessory/ies") + conn.close() + return pairing, accessory_ip + except Exception as e: + logger.error(f"Accessory {accessory_ip}: connection failed ({e})") + conn.close() + raise RuntimeError(f"Failed to connect to accessory {accessory_ip}: {e}") + + # No existing pairing — need a PIN + if not pin: + conn.close() + raise RuntimeError( + f"No existing pairing for accessory {accessory_ip} and no " + f"--accessory-pin provided. Supply the HomeKit PIN for " + f"initial pairing." + ) + + logger.info(f"Starting fresh pairing with accessory {accessory_ip}...") + try: + pairing_data = await TadoBridge.perform_pairing_with_controller(accessory_ip, HOMEKIT_DEFAULT_PORT, pin, db_str) + conn.execute( + "INSERT OR REPLACE INTO pairings (bridge_ip, pairing_data) VALUES (?, ?)", + (accessory_ip, json.dumps(pairing_data)), + ) + conn.commit() + logger.info(f"Accessory {accessory_ip}: pairing saved") + + pairing, _ = await TadoBridge._connect_pairing(pairing_data, db_str) + logger.info(f"Accessory {accessory_ip}: connected after fresh pairing") + + conn.close() + return pairing, accessory_ip + except Exception as e: + conn.close() + logger.error(f"Accessory {accessory_ip}: pairing failed ({e})") + raise + @staticmethod async def perform_pairing_with_controller(host: str, port: int, hap_pin: str, db_path: str): """ diff --git a/tado_local/state.py b/tado_local/state.py index 283f556..2a9d2c3 100644 --- a/tado_local/state.py +++ b/tado_local/state.py @@ -275,6 +275,8 @@ def get_or_create_device(self, serial_number: str, aid: int, accessory_data: dic device_type = "radiator_valve" # Smart Radiator Thermostat elif prefix == "WR": device_type = "wireless_receiver" # Extension Kit + elif prefix == "SU": + device_type = "smart_ac_control" # Smart AC Control V3+ # Create device entry conn = sqlite3.connect(self.db_path) @@ -441,7 +443,7 @@ def _save_to_history(self, device_id: int, timestamp: float): state.get('target_humidity'), state.get('active_state'), state.get('valve_position'), - state.get('window'), + state.get('window', 0), state.get('window_lastupdate') )) conn.commit() diff --git a/tado_local/sync.py b/tado_local/sync.py index c658ec4..cde72f5 100644 --- a/tado_local/sync.py +++ b/tado_local/sync.py @@ -83,11 +83,14 @@ def sync_home(self, home_data: Dict[str, Any]) -> bool: timezone = home_data.get('dateTimeZone') temp_unit = home_data.get('temperatureUnit') - conn.execute(""" + conn.execute( + """ INSERT OR REPLACE INTO tado_homes (tado_home_id, name, timezone, temperature_unit, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) - """, (home_id, name, timezone, temp_unit)) + """, + (home_id, name, timezone, temp_unit), + ) conn.commit() conn.close() @@ -133,30 +136,40 @@ def sync_zones(self, zones_data: List[Dict[str, Any]], home_id: int) -> bool: continue # Check if zone already exists - cursor.execute(""" + cursor.execute( + """ SELECT zone_id FROM zones WHERE tado_home_id = ? AND tado_zone_id = ? - """, (home_id, tado_zone_id)) + """, + (home_id, tado_zone_id), + ) existing = cursor.fetchone() if existing: # Update existing zone zone_id = existing[0] - cursor.execute(""" + cursor.execute( + """ UPDATE zones SET name = ?, zone_type = ?, order_id = ?, updated_at = CURRENT_TIMESTAMP WHERE zone_id = ? - """, (zone_name, zone_type, order_index, zone_id)) + """, + (zone_name, zone_type, order_index, zone_id), + ) logger.debug(f"Updated zone {zone_id}: {zone_name} (Tado ID: {tado_zone_id}, order: {order_index})") else: # Insert new zone with stable uuid import uuid as _uuid + new_uuid = str(_uuid.uuid4()) - cursor.execute(""" + cursor.execute( + """ INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, order_id, uuid, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """, (tado_zone_id, home_id, zone_name, zone_type, order_index, new_uuid)) + """, + (tado_zone_id, home_id, zone_name, zone_type, order_index, new_uuid), + ) zone_id = cursor.lastrowid logger.info(f"Created zone {zone_id}: {zone_name} (Tado ID: {tado_zone_id}, order: {order_index})") @@ -177,39 +190,67 @@ def sync_zones(self, zones_data: List[Dict[str, Any]], home_id: int) -> bool: duties_str = ','.join(duties) if duties else None # Check if device exists - cursor.execute(""" + cursor.execute( + """ SELECT device_id FROM devices WHERE serial_number = ? - """, (serial,)) + """, + (serial,), + ) existing_device = cursor.fetchone() if existing_device: # Update existing device - don't overwrite name (comes from HomeKit) device_id = existing_device[0] - cursor.execute(""" + cursor.execute( + """ UPDATE devices SET tado_zone_id = ?, zone_id = ?, device_type = ?, battery_state = ?, firmware_version = ?, is_zone_leader = ?, is_circuit_driver = ?, is_zone_driver = ?, duties = ?, last_seen = CURRENT_TIMESTAMP WHERE device_id = ? - """, (tado_zone_id, zone_id, device_type, battery_state, - firmware, is_leader, is_circuit_driver, is_zone_driver, - duties_str, device_id)) + """, + ( + tado_zone_id, + zone_id, + device_type, + battery_state, + firmware, + is_leader, + is_circuit_driver, + is_zone_driver, + duties_str, + device_id, + ), + ) logger.debug(f"Updated device {serial} in zone {zone_name}") else: # Insert new device - use device type + serial as placeholder name # (will be updated with proper name from HomeKit later) device_name = f"{device_type}_{serial[-6:]}" - cursor.execute(""" + cursor.execute( + """ INSERT INTO devices (serial_number, tado_zone_id, zone_id, device_type, name, battery_state, firmware_version, is_zone_leader, is_circuit_driver, is_zone_driver, duties, first_seen, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """, (serial, tado_zone_id, zone_id, device_type, device_name, - battery_state, firmware, is_leader, is_circuit_driver, - is_zone_driver, duties_str)) + """, + ( + serial, + tado_zone_id, + zone_id, + device_type, + device_name, + battery_state, + firmware, + is_leader, + is_circuit_driver, + is_zone_driver, + duties_str, + ), + ) device_id = cursor.lastrowid logger.info(f"Created device {serial} ({device_type}) in zone {zone_name}") @@ -219,9 +260,12 @@ def sync_zones(self, zones_data: List[Dict[str, Any]], home_id: int) -> bool: # Update zone leader if this device is the leader if is_leader: try: - cursor.execute(""" + cursor.execute( + """ UPDATE zones SET leader_device_id = ? WHERE zone_id = ? - """, (device_id, zone_id)) + """, + (device_id, zone_id), + ) logger.debug(f"Set leader device {device_id} for zone {zone_name}") except Exception as e: logger.debug(f"Failed to set leader device for zone {zone_name}: {e}") @@ -251,8 +295,11 @@ def sync_zone_states_data(self, zone_states_data: List[Dict[str, Any]], home_id: """ Sync zone states from Tado Cloud API to update humidity. - The zoneStates_data endpoint provides additional device information including - link status, schedule information, geolocation that may not be available via HomeKit. + Tado devices only fire HomeKit humidity events when the delta exceeds + ~5%, so cloud values fill the gap between those infrequent updates. + Temperature is intentionally excluded here -- always-on HomeKit polling + (every 60-120s) provides timely local reads without the precision + mismatch that cloud values would introduce. Args: zone_states_data: Zone state response from Tado Cloud API @@ -274,30 +321,31 @@ def sync_zone_states_data(self, zone_states_data: List[Dict[str, Any]], home_id: settings = zone_state.get('setting', {}) if not settings or settings.get('type') == 'HOT_WATER': - continue # Do not process HOT_WATER zones for now + continue - sensoDataPoints = zone_state.get('sensorDataPoints', {}) - humidity = str(sensoDataPoints.get('humidity', {}).get('percentage')) + sensor_data = zone_state.get('sensorDataPoints', {}) + humidity = sensor_data.get('humidity', {}).get('percentage') - if humidity != "None": - logger.debug(f"Get all aids for zone: {zone_id}") - # Get all devices of this zone to generate update humidity - cursor.execute(f"SELECT aid FROM devices WHERE tado_zone_id = '{zone_id}'") + if humidity is None: + continue - for device in cursor.fetchall(): - # Get iid for this specific device(may be multiple devices in one zone with different iids) - iid = tado_api.get_iid_from_characteristics(device[0], "CurrentRelativeHumidity") if device else None + logger.debug(f"Cloud humidity for zone {zone_id}: {humidity}%") + cursor.execute("SELECT aid FROM devices WHERE tado_zone_id = ?", (str(zone_id),)) - if device and iid: - # Create an update event for humidity - asyncio.create_task(tado_api.handle_change(device[0], iid, {'value': humidity}, source="POLLING")) - logger.debug(f"Humidity change, generate event: ({device[0]}, {iid}) >> value = {humidity}") - humidity_updates += 1 + for device in cursor.fetchall(): + aid = device[0] + if not aid: + continue + + iid = tado_api.get_iid_from_characteristics(aid, "CurrentRelativeHumidity") + if iid: + asyncio.create_task(tado_api.handle_change(aid, iid, {'value': humidity}, source="POLLING")) + humidity_updates += 1 conn.commit() conn.close() - logger.info(f"Updated {humidity_updates} devices from zone states data") + logger.info(f"Cloud zone states: {humidity_updates} humidity updates applied") return True except Exception as e: @@ -324,8 +372,10 @@ def sync_device_list(self, device_list_data: Dict[str, Any], home_id: int) -> bo updated_count = 0 - entries = device_list_data.get('entries', []) + entries = device_list_data.get('entries', []) if isinstance(device_list_data, dict) else device_list_data or [] for entry in entries: + if not entry or not isinstance(entry, dict): + continue device = entry.get('device') if not device: continue @@ -338,24 +388,30 @@ def sync_device_list(self, device_list_data: Dict[str, Any], home_id: int) -> bo 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', {}) + zone_info = entry.get('zone') or {} tado_zone_id = zone_info.get('discriminator') # Check if device exists - cursor.execute(""" + cursor.execute( + """ SELECT device_id FROM devices WHERE serial_number = ? - """, (serial,)) + """, + (serial,), + ) existing = cursor.fetchone() if existing: # Update existing device - cursor.execute(""" + cursor.execute( + """ UPDATE devices SET battery_state = ?, firmware_version = ?, device_type = ?, 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, device_type, tado_zone_id, raw_device_type, serial), + ) updated_count += 1 else: # Device not yet in database - will be added during zone sync @@ -371,11 +427,7 @@ def sync_device_list(self, device_list_data: Dict[str, Any], home_id: int) -> bo logger.error(f"Failed to sync device list: {e}", exc_info=True) return False - async def sync_all(self, cloud_api, - home_data=None, - zones_data=None, - zone_states_data=None, - devices_data=None) -> bool: + async def sync_all(self, cloud_api, home_data=None, zones_data=None, zone_states_data=None, devices_data=None) -> bool: """ Sync all data from Tado Cloud API to database. diff --git a/tests/test_localapi.py b/tests/test_localapi.py index c0bc72c..a28ee11 100644 --- a/tests/test_localapi.py +++ b/tests/test_localapi.py @@ -9,6 +9,7 @@ from tado_local.api import TadoLocalAPI from tado_local.state import DeviceStateManager + @pytest.fixture def api_instance(tmp_path): """Fixture to create a TadoLocalAPI instance.""" @@ -28,6 +29,7 @@ def mock_pairing(): pairing.dispatcher_connect = Mock() return pairing + class TestTadoLocalAPI: @pytest.mark.asyncio async def test_api_initialization(self, api_instance): @@ -47,13 +49,14 @@ async def test_api_initialization(self, api_instance): assert api_instance.is_shutting_down is False assert api_instance.state_manager is not None - @pytest.mark.asyncio async def test_initialize_with_pairing(self, api_instance, mock_pairing): """Test API initialization with pairing.""" - 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: + 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) @@ -68,6 +71,7 @@ async def test_cleanup(self, api_instance, mock_pairing): """Test cleanup properly shuts down resources.""" api_instance.pairing = mock_pairing api_instance.subscribed_characteristics = [(1, 1), (1, 2)] + api_instance.pairing_subscriptions = {id(mock_pairing): [(1, 1), (1, 2)]} # Create actual asyncio tasks that can be cancelled and gathered async def dummy_task(): @@ -111,7 +115,6 @@ async def test_event_listeners_management(self, api_instance): assert queue1 in api_instance.event_listeners assert queue2 in api_instance.zone_event_listeners - @pytest.mark.asyncio async def test_initialization_flag(self, api_instance): """Test initialization flag prevents logging during init.""" @@ -123,7 +126,6 @@ async def test_initialization_flag(self, api_instance): api_instance.is_initializing = False assert api_instance.is_initializing is False - @pytest.mark.asyncio async def test_shutdown_flag(self, api_instance): """Test shutdown flag.""" @@ -137,17 +139,7 @@ class TestTadoLocalAPIRefreshAccessories: @pytest.mark.asyncio async def test_process_raw_accessories(self, api_instance): """Test raw accessories processing.""" - raw_accessories = [ - { - 'aid': 1, - 'services': [ - { - 'type': '0000003E-0000-1000-8000-0026BB765291', - 'characteristics': [] - } - ] - } - ] + raw_accessories = [{'aid': 1, 'services': [{'type': '0000003E-0000-1000-8000-0026BB765291', 'characteristics': []}]}] with patch.object(api_instance.state_manager, 'get_or_create_device', return_value=1): result = api_instance._process_raw_accessories(raw_accessories) @@ -163,10 +155,10 @@ def test_process_raw_accessories_with_serial_number(self, api_instance): 'type': '0000003E-0000-1000-8000-0026BB765291', 'characteristics': [ {'type': '00000030-0000-1000-8000-0026BB765291', 'value': 'SN12345'}, - {'type': '00000011-0000-1000-8000-0026BB765291', 'value': '21.3'} - ] + {'type': '00000011-0000-1000-8000-0026BB765291', 'value': '21.3'}, + ], } - ] + ], } ] @@ -192,11 +184,9 @@ async def test_refresh_accessories_success(self, api_instance, mock_pairing): 'services': [ { 'type': '0000003E-0000-1000-8000-0026BB765291', - 'characteristics': [ - {'type': '00000030-0000-1000-8000-0026BB765291', 'value': 'SN12345'} - ] + 'characteristics': [{'type': '00000030-0000-1000-8000-0026BB765291', 'value': 'SN12345'}], } - ] + ], } ] @@ -223,70 +213,33 @@ async def test_initialize_device_states_without_pairing(self, api_instance): # No calls should be made assert api_instance.pairing is None - @pytest.mark.asyncio async def test_initialize_device_states_no_char_to_poll(self, api_instance, mock_pairing): """Test initialize_device_states when no readable characteristics exist.""" api_instance.pairing = mock_pairing - api_instance.device_to_characteristics = { - 1: [(1, 10, 'temperature')] - } + api_instance.device_to_characteristics = {1: [(1, 10, 'temperature')]} # Setup accessories with non-readable characteristic - api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [ - { - 'characteristics': [ - { - 'iid': 10, - 'perms': ['pw'] # Write-only, not readable - } - ] - } - ] - } - ] + api_instance.accessories_cache = [{'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'perms': ['pw']}]}]}] # Write-only, not readable await api_instance.initialize_device_states() # Should not call get_characteristics mock_pairing.get_characteristics.assert_not_called() - @pytest.mark.asyncio async def test_initialize_device_states_single_readable_char(self, api_instance, mock_pairing): """Test initialize_device_states with a single readable characteristic.""" api_instance.pairing = mock_pairing - api_instance.device_to_characteristics = { - 1: [(1, 10, 'temperature')] - } + api_instance.device_to_characteristics = {1: [(1, 10, 'temperature')]} # Setup accessories with readable characteristic - api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [ - { - 'characteristics': [ - { - 'iid': 10, - 'perms': ['pr', 'ev'] # Readable - } - ] - } - ] - } - ] + api_instance.accessories_cache = [{'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'perms': ['pr', 'ev']}]}]}] # Readable - mock_pairing.get_characteristics.return_value = { - (1, 10): {'value': 21.5} - } + mock_pairing.get_characteristics.return_value = {(1, 10): {'value': 21.5}} - with patch.object(api_instance.state_manager, 'update_device_characteristic', - return_value=('temperature', None, 21.5)) as mock_update: + with patch.object(api_instance.state_manager, 'update_device_characteristic', return_value=('temperature', None, 21.5)) as mock_update: await api_instance.initialize_device_states() mock_pairing.get_characteristics.assert_called_once_with([(1, 10)]) @@ -296,56 +249,27 @@ async def test_initialize_device_states_single_readable_char(self, api_instance, assert call_args[1] == 'temperature' # char_type assert call_args[2] == 21.5 # value - @pytest.mark.asyncio async def test_initialize_device_states_multiple_char(self, api_instance, mock_pairing): """Test initialize_device_states with multiple readable characteristics.""" api_instance.pairing = mock_pairing - api_instance.device_to_characteristics = { - 1: [(1, 10, 'temperature'), (1, 11, 'humidity')], - 2: [(2, 20, 'temperature')] - } + api_instance.device_to_characteristics = {1: [(1, 10, 'temperature'), (1, 11, 'humidity')], 2: [(2, 20, 'temperature')]} # Setup accessories api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [ - { - 'characteristics': [ - {'iid': 10, 'perms': ['pr']}, - {'iid': 11, 'perms': ['pr', 'ev']} - ] - } - ] - }, - { - 'aid': 2, - 'services': [ - { - 'characteristics': [ - {'iid': 20, 'perms': ['pr']} - ] - } - ] - } + {'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'perms': ['pr']}, {'iid': 11, 'perms': ['pr', 'ev']}]}]}, + {'aid': 2, 'services': [{'characteristics': [{'iid': 20, 'perms': ['pr']}]}]}, ] - mock_pairing.get_characteristics.return_value = { - (1, 10): {'value': 21.5}, - (1, 11): {'value': 55}, - (2, 20): {'value': 22.0} - } + mock_pairing.get_characteristics.return_value = {(1, 10): {'value': 21.5}, (1, 11): {'value': 55}, (2, 20): {'value': 22.0}} - with patch.object(api_instance.state_manager, 'update_device_characteristic', - return_value=('field', None, 'value')) as mock_update: + with patch.object(api_instance.state_manager, 'update_device_characteristic', return_value=('field', None, 'value')) as mock_update: await api_instance.initialize_device_states() mock_pairing.get_characteristics.assert_called_once() assert mock_update.call_count == 3 - @pytest.mark.asyncio async def test_initialize_device_states_batch_processing(self, api_instance, mock_pairing): """Test that characteristics are polled in batches of 10.""" @@ -360,28 +284,21 @@ async def test_initialize_device_states_batch_processing(self, api_instance, moc iid = 10 device_to_chars[aid] = [(aid, iid, 'temperature')] - accessories.append({ - 'aid': aid, - 'services': [{ - 'characteristics': [{'iid': iid, 'perms': ['pr']}] - }] - }) + accessories.append({'aid': aid, 'services': [{'characteristics': [{'iid': iid, 'perms': ['pr']}]}]}) api_instance.device_to_characteristics = device_to_chars api_instance.accessories_cache = accessories # Mock responses for all characteristics - mock_results = {(i+1, 10): {'value': 20.0 + i} for i in range(25)} + mock_results = {(i + 1, 10): {'value': 20.0 + i} for i in range(25)} mock_pairing.get_characteristics.return_value = mock_results - with patch.object(api_instance.state_manager, 'update_device_characteristic', - return_value=('temperature', None, 20.0)): + with patch.object(api_instance.state_manager, 'update_device_characteristic', return_value=('temperature', None, 20.0)): await api_instance.initialize_device_states() # Should be called 3 times (batch_size=10: 10, 10, 5) assert mock_pairing.get_characteristics.call_count == 3 - @pytest.mark.asyncio async def test_initialize_device_states_handles_batch_errors(self, api_instance, mock_pairing): """Test that errors in one batch don't prevent other batches.""" @@ -396,52 +313,31 @@ async def test_initialize_device_states_handles_batch_errors(self, api_instance, iid = 10 device_to_chars[aid] = [(aid, iid, 'temperature')] - accessories.append({ - 'aid': aid, - 'services': [{ - 'characteristics': [{'iid': iid, 'perms': ['pr']}] - }] - }) + accessories.append({'aid': aid, 'services': [{'characteristics': [{'iid': iid, 'perms': ['pr']}]}]}) api_instance.device_to_characteristics = device_to_chars api_instance.accessories_cache = accessories # First batch fails, second succeeds - mock_pairing.get_characteristics.side_effect = [ - Exception("Connection error"), - {(i, 10): {'value': 20.0} for i in range(11, 16)} - ] + mock_pairing.get_characteristics.side_effect = [Exception("Connection error"), {(i, 10): {'value': 20.0} for i in range(11, 16)}] - with patch.object(api_instance.state_manager, 'update_device_characteristic', - return_value=('temperature', None, 20.0)): + with patch.object(api_instance.state_manager, 'update_device_characteristic', return_value=('temperature', None, 20.0)): await api_instance.initialize_device_states() # Should attempt both batches despite first one failing assert mock_pairing.get_characteristics.call_count == 2 - @pytest.mark.asyncio async def test_initialize_device_states_skips_none_values(self, api_instance, mock_pairing): """Test that None values are skipped during initialization.""" api_instance.pairing = mock_pairing - api_instance.device_to_characteristics = { - 1: [(1, 10, 'temperature')] - } + api_instance.device_to_characteristics = {1: [(1, 10, 'temperature')]} - api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [{'iid': 10, 'perms': ['pr']}] - }] - } - ] + api_instance.accessories_cache = [{'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'perms': ['pr']}]}]}] # Return None value - mock_pairing.get_characteristics.return_value = { - (1, 10): {'value': None} - } + mock_pairing.get_characteristics.return_value = {(1, 10): {'value': None}} with patch.object(api_instance.state_manager, 'update_device_characteristic') as mock_update: await api_instance.initialize_device_states() @@ -449,27 +345,14 @@ async def test_initialize_device_states_skips_none_values(self, api_instance, mo # Should not call update_device_characteristic for None values mock_update.assert_not_called() - @pytest.mark.asyncio async def test_initialize_device_states_missing_characteristic_data(self, api_instance, mock_pairing): """Test handling when characteristic is not in results.""" api_instance.pairing = mock_pairing - api_instance.device_to_characteristics = { - 1: [(1, 10, 'temperature'), (1, 11, 'humidity')] - } + api_instance.device_to_characteristics = {1: [(1, 10, 'temperature'), (1, 11, 'humidity')]} - api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'perms': ['pr']}, - {'iid': 11, 'perms': ['pr']} - ] - }] - } - ] + api_instance.accessories_cache = [{'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'perms': ['pr']}, {'iid': 11, 'perms': ['pr']}]}]}] # Only return one characteristic mock_pairing.get_characteristics.return_value = { @@ -477,43 +360,37 @@ async def test_initialize_device_states_missing_characteristic_data(self, api_in # (1, 11) missing } - with patch.object(api_instance.state_manager, 'update_device_characteristic', - return_value=('temperature', None, 21.5)) as mock_update: + with patch.object(api_instance.state_manager, 'update_device_characteristic', return_value=('temperature', None, 21.5)) as mock_update: await api_instance.initialize_device_states() # Should only update the one present assert mock_update.call_count == 1 - @pytest.mark.asyncio async def test_initialize_device_states_mixed_permissions(self, api_instance, mock_pairing): """Test that only readable characteristics are polled.""" api_instance.pairing = mock_pairing - api_instance.device_to_characteristics = { - 1: [(1, 10, 'temperature'), (1, 11, 'target_temp'), (1, 12, 'mode')] - } + api_instance.device_to_characteristics = {1: [(1, 10, 'temperature'), (1, 11, 'target_temp'), (1, 12, 'mode')]} api_instance.accessories_cache = [ { 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'perms': ['pr']}, # Readable - {'iid': 11, 'perms': ['pw']}, # Write-only - {'iid': 12, 'perms': ['pr', 'pw']} # Read-write - ] - }] + 'services': [ + { + 'characteristics': [ + {'iid': 10, 'perms': ['pr']}, # Readable + {'iid': 11, 'perms': ['pw']}, # Write-only + {'iid': 12, 'perms': ['pr', 'pw']}, # Read-write + ] + } + ], } ] - mock_pairing.get_characteristics.return_value = { - (1, 10): {'value': 21.5}, - (1, 12): {'value': 1} - } + mock_pairing.get_characteristics.return_value = {(1, 10): {'value': 21.5}, (1, 12): {'value': 1}} - with patch.object(api_instance.state_manager, 'update_device_characteristic', - return_value=('field', None, 'value')): + with patch.object(api_instance.state_manager, 'update_device_characteristic', return_value=('field', None, 'value')): await api_instance.initialize_device_states() # Should only poll characteristics with 'pr' permission @@ -523,32 +400,21 @@ async def test_initialize_device_states_mixed_permissions(self, api_instance, mo assert (1, 12) in call_args assert (1, 11) not in call_args - @pytest.mark.asyncio async def test_initialize_device_states_uses_timestamp(self, api_instance, mock_pairing): """Test that timestamp is passed to state manager.""" api_instance.pairing = mock_pairing - api_instance.device_to_characteristics = { - 1: [(1, 10, 'temperature')] - } + api_instance.device_to_characteristics = {1: [(1, 10, 'temperature')]} - api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [{'iid': 10, 'perms': ['pr']}] - }] - } - ] + api_instance.accessories_cache = [{'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'perms': ['pr']}]}]}] - mock_pairing.get_characteristics.return_value = { - (1, 10): {'value': 21.5} - } + mock_pairing.get_characteristics.return_value = {(1, 10): {'value': 21.5}} - with patch.object(api_instance.state_manager, 'update_device_characteristic', - return_value=('temperature', None, 21.5)) as mock_update, \ - patch('time.time', return_value=1234567890.0): + with ( + patch.object(api_instance.state_manager, 'update_device_characteristic', return_value=('temperature', None, 21.5)) as mock_update, + patch('time.time', return_value=1234567890.0), + ): await api_instance.initialize_device_states() @@ -556,31 +422,18 @@ async def test_initialize_device_states_uses_timestamp(self, api_instance, mock_ call_args = mock_update.call_args[0] assert call_args[3] == 1234567890.0 # timestamp argument - @pytest.mark.asyncio async def test_initialize_device_states_logs_initialization(self, api_instance, mock_pairing): """Test that initialization is properly logged.""" api_instance.pairing = mock_pairing - api_instance.device_to_characteristics = { - 1: [(1, 10, 'temperature')] - } + api_instance.device_to_characteristics = {1: [(1, 10, 'temperature')]} - api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [{'iid': 10, 'perms': ['pr']}] - }] - } - ] + api_instance.accessories_cache = [{'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'perms': ['pr']}]}]}] - mock_pairing.get_characteristics.return_value = { - (1, 10): {'value': 21.5} - } + mock_pairing.get_characteristics.return_value = {(1, 10): {'value': 21.5}} - with patch.object(api_instance.state_manager, 'update_device_characteristic', - return_value=('temperature', None, 21.5)): + with patch.object(api_instance.state_manager, 'update_device_characteristic', return_value=('temperature', None, 21.5)): await api_instance.initialize_device_states() # Method should complete without errors @@ -598,7 +451,6 @@ async def test_setup_event_listeners_without_pairing(self, api_instance): # Should return without setting up change_tracker assert not hasattr(api_instance, 'change_tracker') - @pytest.mark.asyncio async def test_setup_event_listeners_initializes_change_tracker(self, api_instance, mock_pairing): """Test that change_tracker is properly initialized.""" @@ -615,28 +467,22 @@ async def test_setup_event_listeners_initializes_change_tracker(self, api_instan assert isinstance(api_instance.change_tracker['last_values'], dict) assert isinstance(api_instance.change_tracker['event_characteristics'], set) - @pytest.mark.asyncio async def test_setup_event_listeners_populates_last_values(self, api_instance, mock_pairing): """Test that last_values is populated from current device states.""" api_instance.pairing = mock_pairing api_instance.device_to_characteristics = { - 1: [ - (1, 10, DeviceStateManager.CHAR_CURRENT_TEMPERATURE), - (1, 11, DeviceStateManager.CHAR_TARGET_TEMPERATURE) - ] + 1: [(1, 10, DeviceStateManager.CHAR_CURRENT_TEMPERATURE), (1, 11, DeviceStateManager.CHAR_TARGET_TEMPERATURE)] } api_instance.accessories_cache = [] # Mock current state - mock_state = { - 'current_temperature': 21.5, - 'target_temperature': 22.0, - 'humidity': 55 - } + mock_state = {'current_temperature': 21.5, 'target_temperature': 22.0, 'humidity': 55} - with patch.object(api_instance.state_manager, 'get_current_state', return_value=mock_state), \ - patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True): + with ( + patch.object(api_instance.state_manager, 'get_current_state', return_value=mock_state), + patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True), + ): await api_instance.setup_event_listeners() @@ -646,7 +492,6 @@ async def test_setup_event_listeners_populates_last_values(self, api_instance, m assert (1, 11) in api_instance.change_tracker['last_values'] assert api_instance.change_tracker['last_values'][(1, 11)] == 22.0 - @pytest.mark.asyncio async def test_setup_event_listeners_calls_persistent_events(self, api_instance, mock_pairing): """Test that setup_persistent_events is called.""" @@ -654,13 +499,11 @@ async def test_setup_event_listeners_calls_persistent_events(self, api_instance, api_instance.device_to_characteristics = {} api_instance.accessories_cache = [] - with patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True) \ - as mock_setup: + with patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True) as mock_setup: await api_instance.setup_event_listeners() mock_setup.assert_called_once() - @pytest.mark.asyncio async def test_setup_event_listeners_fallback_to_polling(self, api_instance, mock_pairing): """Test fallback to polling when events are not available.""" @@ -668,27 +511,30 @@ async def test_setup_event_listeners_fallback_to_polling(self, api_instance, moc api_instance.device_to_characteristics = {} api_instance.accessories_cache = [] - with patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=False), \ - patch.object(api_instance, 'setup_polling_system', new_callable=AsyncMock) as mock_polling: + with ( + patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=False), + patch.object(api_instance, 'setup_polling_system', new_callable=AsyncMock) as mock_polling, + ): await api_instance.setup_event_listeners() mock_polling.assert_called_once() - @pytest.mark.asyncio - async def test_setup_event_listeners_skips_polling_when_events_active(self, api_instance, mock_pairing): - """Test that polling is skipped when events are active.""" + async def test_setup_event_listeners_always_enables_polling(self, api_instance, mock_pairing): + """Test that polling is always enabled as safety net for standalone accessories.""" api_instance.pairing = mock_pairing api_instance.device_to_characteristics = {} api_instance.accessories_cache = [] - with patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True), \ - patch.object(api_instance, 'setup_polling_system', new_callable=AsyncMock) as mock_polling: + with ( + patch.object(api_instance, 'setup_persistent_events', new_callable=AsyncMock, return_value=True), + patch.object(api_instance, 'setup_polling_system', new_callable=AsyncMock) as mock_polling, + ): await api_instance.setup_event_listeners() - mock_polling.assert_not_called() + mock_polling.assert_called_once() class TestTadoLocalAPISetupPersistentEvents: @@ -697,53 +543,34 @@ async def test_setup_persistent_events_no_event_char(self, api_instance, mock_pa """Test setup_persistent_events when no event characteristics exist.""" api_instance.pairing = mock_pairing api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['pr']} # No 'ev' - ] - }] - } + {'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['pr']}]}]} # No 'ev' ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} result = await api_instance.setup_persistent_events() assert result is False mock_pairing.subscribe.assert_not_called() - @pytest.mark.asyncio async def test_setup_persistent_events_success(self, api_instance, mock_pairing): """Test successful setup of persistent events.""" api_instance.pairing = mock_pairing + api_instance.aid_to_pairing = {1: mock_pairing} api_instance.accessories_cache = [ { 'aid': 1, - 'services': [{ - 'characteristics': [ - { - 'iid': 10, - 'type': '00000011-0000-1000-8000-0026BB765291', - 'perms': ['pr', 'ev'] # Event notification supported - }, - { - 'iid': 11, - 'type': '00000035-0000-1000-8000-0026BB765291', - 'perms': ['pr', 'pw', 'ev'] - } - ] - }] + 'services': [ + { + 'characteristics': [ + {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['pr', 'ev']}, # Event notification supported + {'iid': 11, 'type': '00000035-0000-1000-8000-0026BB765291', 'perms': ['pr', 'pw', 'ev']}, + ] + } + ], } ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} result = await api_instance.setup_persistent_events() @@ -756,77 +583,56 @@ async def test_setup_persistent_events_success(self, api_instance, mock_pairing) assert (1, 11) in call_args assert len(api_instance.subscribed_characteristics) == 2 - @pytest.mark.asyncio async def test_setup_persistent_events_registers_dispatcher(self, api_instance, mock_pairing): """Test that event callback is registered with dispatcher.""" api_instance.pairing = mock_pairing + api_instance.aid_to_pairing = {1: mock_pairing} api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']} - ] - }] - } + {'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}]}]} ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} await api_instance.setup_persistent_events() # Check that dispatcher_connect was called mock_pairing.dispatcher_connect.assert_called_once() - @pytest.mark.asyncio async def test_setup_persistent_events_populates_char_maps(self, api_instance, mock_pairing): """Test that characteristic maps are populated.""" mock_pairing.dispatcher_connect = Mock() # ensure sync api_instance.pairing = mock_pairing + api_instance.aid_to_pairing = {1: mock_pairing} api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']} - ] - }] - } + {'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}]}]} ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} await api_instance.setup_persistent_events() # Check characteristic_map was populated assert (1, 10) in api_instance.characteristic_map - @pytest.mark.asyncio async def test_setup_persistent_events_tracks_event_char(self, api_instance, mock_pairing): """Test that event characteristics are tracked.""" api_instance.pairing = mock_pairing + api_instance.aid_to_pairing = {1: mock_pairing} api_instance.accessories_cache = [ { 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}, - {'iid': 11, 'type': '00000035-0000-1000-8000-0026BB765291', 'perms': ['pr']} # No 'ev' - ] - }] + 'services': [ + { + 'characteristics': [ + {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}, + {'iid': 11, 'type': '00000035-0000-1000-8000-0026BB765291', 'perms': ['pr']}, # No 'ev' + ] + } + ], } ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} await api_instance.setup_persistent_events() @@ -834,33 +640,16 @@ async def test_setup_persistent_events_tracks_event_char(self, api_instance, moc assert (1, 10) in api_instance.change_tracker['event_characteristics'] assert (1, 11) not in api_instance.change_tracker['event_characteristics'] - @pytest.mark.asyncio async def test_setup_persistent_events_handles_multiple_acc(self, api_instance, mock_pairing): """Test setup with multiple accessories.""" api_instance.pairing = mock_pairing + api_instance.aid_to_pairing = {1: mock_pairing, 2: mock_pairing} api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']} - ] - }] - }, - { - 'aid': 2, - 'services': [{ - 'characteristics': [ - {'iid': 20, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']} - ] - }] - } + {'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}]}]}, + {'aid': 2, 'services': [{'characteristics': [{'iid': 20, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}]}]}, ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} result = await api_instance.setup_persistent_events() @@ -869,25 +658,15 @@ async def test_setup_persistent_events_handles_multiple_acc(self, api_instance, assert (1, 10) in call_args assert (2, 20) in call_args - @pytest.mark.asyncio async def test_setup_persistent_events_handles_subscript_err(self, api_instance, mock_pairing): """Test handling of subscription errors.""" api_instance.pairing = mock_pairing + api_instance.aid_to_pairing = {1: mock_pairing} api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']} - ] - }] - } + {'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}]}]} ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} mock_pairing.subscribe.side_effect = Exception("Subscription failed") @@ -895,25 +674,15 @@ async def test_setup_persistent_events_handles_subscript_err(self, api_instance, assert result is False - @pytest.mark.asyncio async def test_setup_persistent_events_callback_creates_task(self, api_instance, mock_pairing): """Test that event callback is registered and callable.""" api_instance.pairing = mock_pairing + api_instance.aid_to_pairing = {1: mock_pairing} api_instance.accessories_cache = [ - { - 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']} - ] - }] - } + {'aid': 1, 'services': [{'characteristics': [{'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291', 'perms': ['ev']}]}]} ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} await api_instance.setup_persistent_events() @@ -924,44 +693,39 @@ async def test_setup_persistent_events_callback_creates_task(self, api_instance, callback = mock_pairing.dispatcher_connect.call_args[0][0] assert callable(callback) - @pytest.mark.asyncio async def test_setup_persistent_events_empty_accessories_cache(self, api_instance, mock_pairing): """Test setup with empty accessories cache.""" api_instance.pairing = mock_pairing api_instance.accessories_cache = [] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} result = await api_instance.setup_persistent_events() assert result is False mock_pairing.subscribe.assert_not_called() - @pytest.mark.asyncio async def test_setup_persistent_events_mixed_permissions(self, api_instance, mock_pairing): """Test that only characteristics with 'ev' permission are subscribed.""" api_instance.pairing = mock_pairing + api_instance.aid_to_pairing = {1: mock_pairing} api_instance.accessories_cache = [ { 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'type': 'type1', 'perms': ['pr', 'ev']}, # Event - {'iid': 11, 'type': 'type2', 'perms': ['pr']}, # No event - {'iid': 12, 'type': 'type3', 'perms': ['pw', 'ev']}, # Event - {'iid': 13, 'type': 'type4', 'perms': ['pw']} # No event - ] - }] + 'services': [ + { + 'characteristics': [ + {'iid': 10, 'type': 'type1', 'perms': ['pr', 'ev']}, # Event + {'iid': 11, 'type': 'type2', 'perms': ['pr']}, # No event + {'iid': 12, 'type': 'type3', 'perms': ['pw', 'ev']}, # Event + {'iid': 13, 'type': 'type4', 'perms': ['pw']}, # No event + ] + } + ], } ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} await api_instance.setup_persistent_events() @@ -971,26 +735,18 @@ async def test_setup_persistent_events_mixed_permissions(self, api_instance, moc assert (1, 12) in call_args assert (1, 13) not in call_args - @pytest.mark.asyncio async def test_setup_persistent_events_stores_subscribed_char(self, api_instance, mock_pairing): """Test that subscribed_characteristics is stored for cleanup.""" api_instance.pairing = mock_pairing + api_instance.aid_to_pairing = {1: mock_pairing} api_instance.accessories_cache = [ { 'aid': 1, - 'services': [{ - 'characteristics': [ - {'iid': 10, 'type': 'type1', 'perms': ['ev']}, - {'iid': 11, 'type': 'type2', 'perms': ['ev']} - ] - }] + 'services': [{'characteristics': [{'iid': 10, 'type': 'type1', 'perms': ['ev']}, {'iid': 11, 'type': 'type2', 'perms': ['ev']}]}], } ] - api_instance.change_tracker = { - 'event_characteristics': set(), - 'last_values': {} - } + api_instance.change_tracker = {'event_characteristics': set(), 'last_values': {}} await api_instance.setup_persistent_events() @@ -1059,20 +815,15 @@ def _setup_handle_change(self, api_instance): { 'aid': 1, 'id': "dev-1", - 'services': [{ - 'characteristics': [ # CurrentTemperature - {'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291'} - ] - }] + 'services': [{'characteristics': [{'iid': 10, 'type': '00000011-0000-1000-8000-0026BB765291'}]}], # CurrentTemperature } ] api_instance.state_manager.get_device_info.return_value = { 'zone_name': 'Living', 'name': 'Thermostat', - 'is_zone_leader': False # avoid window detection path + 'is_zone_leader': False, # avoid window detection path } - api_instance.state_manager.update_device_characteristic.return_value \ - = ("current_temperature", 20.0, 22.5) + api_instance.state_manager.update_device_characteristic.return_value = ("current_temperature", 20.0, 22.5) api_instance.broadcast_state_change = AsyncMock() api_instance._handle_window_open_detection = Mock() @@ -1177,21 +928,16 @@ async def test_handle_change_calls_window_detection(self, api_instance): """Test that window open detection is triggered for temperature changes.""" self._setup_handle_change(api_instance) api_instance.characteristic_map[(1, 10)] = "CurrentTemperature" - api_instance.state_manager.get_device_info.return_value = { - 'zone_name': 'Living', - 'name': 'Thermostat', - 'is_zone_leader': True - } + api_instance.state_manager.get_device_info.return_value = {'zone_name': 'Living', 'name': 'Thermostat', 'is_zone_leader': True} await api_instance.handle_change(1, 10, {"value": 22.5}) assert api_instance.change_tracker['polling_changes'] == 1 api_instance._handle_window_open_detection.assert_called_once_with( - 'dev-1', - {'zone_name': 'Living', 'name': 'Thermostat', 'is_zone_leader': True}, - '00000011-0000-1000-8000-0026bb765291' + 'dev-1', {'zone_name': 'Living', 'name': 'Thermostat', 'is_zone_leader': True}, '00000011-0000-1000-8000-0026bb765291' ) + class TestTadoLocalAPIHandleWindowOpenDetection: def _setup_window_detection(self, api_instance): api_instance.state_manager = Mock(spec=DeviceStateManager) @@ -1252,8 +998,7 @@ def test_window_detection_closes_open_window_after_timeout(self, api_instance): "latest_entry": (21.0, 1, 1000), } - with patch.object(api_instance, "_cancel_window_close_timer") as mock_cancel, \ - patch("time.time", return_value=2000): + with patch.object(api_instance, "_cancel_window_close_timer") as mock_cancel, patch("time.time", return_value=2000): api_instance._handle_window_open_detection(device_id, device_info, char_type) api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 2) @@ -1268,8 +1013,7 @@ def test_window_detection_opens_window_on_heating_temp_drop(self, api_instance): "latest_entry": (21.0, 0, 1990), # drop = 1.5 } - with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \ - patch("time.time", return_value=2000): + with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, patch("time.time", return_value=2000): api_instance._handle_window_open_detection(device_id, device_info, char_type) api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 1) @@ -1284,8 +1028,7 @@ def test_window_detection_keeps_window_closed_on_small_drop(self, api_instance): "latest_entry": (21.3, 0, 1990), # drop = 0.7 } - with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \ - patch("time.time", return_value=2000): + with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, patch("time.time", return_value=2000): api_instance._handle_window_open_detection(device_id, device_info, char_type) api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 0) @@ -1299,11 +1042,10 @@ def test_window_detection_cooling_mode_sets_open_and_schedules_timer(self, api_i api_instance.state_manager.get_device_history_info.return_value = { "history_count": 2, "earliest_entry": (21.0, 0, 1940), - "latest_entry": (22.5, 0, 1990), # temp_change = 1.5 + "latest_entry": (22.5, 0, 1990), # temp_change = 1.5 } - with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \ - patch("time.time", return_value=2000): + with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, patch("time.time", return_value=2000): api_instance._handle_window_open_detection(device_id, device_info, char_type) api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 1) @@ -1317,11 +1059,10 @@ def test_window_detection_cooling_mode_small_rise_keeps_closed(self, api_instanc api_instance.state_manager.get_device_history_info.return_value = { "history_count": 2, "earliest_entry": (21.0, 0, 1940), - "latest_entry": (21.3, 0, 1990), # temp_change = 0.3 + "latest_entry": (21.3, 0, 1990), # temp_change = 0.3 } - with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \ - patch("time.time", return_value=2000): + with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, patch("time.time", return_value=2000): api_instance._handle_window_open_detection(device_id, device_info, char_type) api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 0) @@ -1341,8 +1082,7 @@ def test_window_opens_after_timer_expires(self, api_instance): "latest_entry": (21.0, 0, 1990), # drop = 1.5 } - with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, \ - patch("time.time", return_value=2000): + with patch.object(api_instance, "_schedule_window_close_timer") as mock_schedule, patch("time.time", return_value=2000): api_instance._handle_window_open_detection(device_id, device_info, char_type) api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 1) @@ -1374,6 +1114,7 @@ async def test_window_closes_after_timer_expiry(self, api_instance): # Window should be set to rest state (2) api_instance.state_manager.update_device_window_status.assert_called_once_with(device_id, 2) + class TestTadoLocalAPIScheduleWindowCloseTimer: @pytest.mark.asyncio async def test_schedule_window_close_timer_creates_and_stores_task(self, api_instance): @@ -1436,8 +1177,7 @@ async def test_schedule_window_close_timer_adds_done_callback(self, api_instance device_id = "dev-1" delay = 1 # 1 minute (in real use, but we'll mock the handler) - with patch.object(api_instance, - "_window_close_handler", new_callable=AsyncMock) as mock_handler: + with patch.object(api_instance, "_window_close_handler", new_callable=AsyncMock) as mock_handler: mock_handler.return_value = None api_instance._schedule_window_close_timer(device_id, delay, {"zone_name": "Living"}) @@ -1450,8 +1190,7 @@ async def test_schedule_window_close_timer_adds_done_callback(self, api_instance pass # Task should be cleaned up after completion - assert device_id not in api_instance.window_close_timers \ - or api_instance.window_close_timers[device_id].done() + assert device_id not in api_instance.window_close_timers or api_instance.window_close_timers[device_id].done() @pytest.mark.asyncio async def test_schedule_window_close_timer_multiple_devices(self, api_instance): @@ -1470,6 +1209,7 @@ async def test_schedule_window_close_timer_multiple_devices(self, api_instance): assert device_id in api_instance.window_close_timers assert asyncio.isfuture(api_instance.window_close_timers[device_id]) + class TestTadoLocalAPICancelWindowCloseTimer: @pytest.mark.asyncio async def test_cancel_window_close_timer_removes_and_cancels_task(self, api_instance): @@ -1587,8 +1327,7 @@ async def test_window_close_handler_waits_and_closes_window(self, api_instance): api_instance.state_manager.get_current_state.return_value = {"window": 1} - with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, \ - patch("time.time", return_value=1000): + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, patch("time.time", return_value=1000): await api_instance._window_close_handler(device_id, device_info, closing_delay) # Should sleep for 60 seconds (1 minute * 60) @@ -1783,6 +1522,7 @@ async def dummy_handler(): # Task should be cleaned up assert device_id not in api_instance.window_close_timers + class TestTadoLocalAPIBroadcastEvent: @pytest.mark.asyncio async def test_broadcast_event_sends_to_all_listeners(self, api_instance): @@ -1910,12 +1650,7 @@ async def test_broadcast_event_json_serialization(self, api_instance): mock_listener = AsyncMock() api_instance.event_listeners = [mock_listener] - event_data = { - "type": "device", - "device": "dev-1", - "value": 22.5, - "timestamp": 1234567890 - } + event_data = {"type": "device", "device": "dev-1", "value": 22.5, "timestamp": 1234567890} await api_instance.broadcast_event(event_data) @@ -1977,6 +1712,7 @@ def test_celsius_to_fahrenheit_rounding(self, api_instance): result = api_instance._celsius_to_fahrenheit(20.123) assert result == 68.2 # 20.123 * 9/5 + 32 = 68.2214, rounded to 68.2 + class TestTadoLocalAPIBuildDeviceState: def test_build_device_state_with_valid_data(self, api_instance): """Test building device state with valid temperature and state data.""" @@ -1988,11 +1724,9 @@ def test_build_device_state_with_valid_data(self, api_instance): 'target_heating_cooling_state': 1, 'current_heating_cooling_state': 1, 'valve_position': 75, - 'window': 0 - } - api_instance.state_manager.device_info_cache = { - 'dev-1': {'battery_state': 'NORMAL'} + 'window': 0, } + api_instance.state_manager.device_info_cache = {'dev-1': {'battery_state': 'NORMAL'}} result = api_instance._build_device_state('dev-1') @@ -2014,9 +1748,7 @@ def test_build_device_state_battery_low(self, api_instance): 'current_temperature': 20.0, 'target_temperature': 21.5, } - api_instance.state_manager.device_info_cache = { - 'dev-1': {'battery_state': 'LOW'} - } + api_instance.state_manager.device_info_cache = {'dev-1': {'battery_state': 'LOW'}} result = api_instance._build_device_state('dev-1') @@ -2029,9 +1761,7 @@ def test_build_device_state_battery_unknown(self, api_instance): 'current_temperature': 20.0, 'target_temperature': 21.5, } - api_instance.state_manager.device_info_cache = { - 'dev-1': {'battery_state': 'UNKNOWN'} - } + api_instance.state_manager.device_info_cache = {'dev-1': {'battery_state': 'UNKNOWN'}} result = api_instance._build_device_state('dev-1') @@ -2044,9 +1774,7 @@ def test_build_device_state_no_battery_info(self, api_instance): 'current_temperature': 20.0, 'target_temperature': 21.5, } - api_instance.state_manager.device_info_cache = { - 'dev-1': {} - } + api_instance.state_manager.device_info_cache = {'dev-1': {}} result = api_instance._build_device_state('dev-1') @@ -2247,11 +1975,11 @@ async def test_broadcast_state_change_circuit_driver_logic(self, api_instance): 'target_heating_cooling_state': 1, 'current_heating_cooling_state': 1, 'window': 0, - } + }, ] api_instance.state_manager.device_info_cache = { 'dev-1': {'zone_id': 'zone-1', 'is_circuit_driver': True}, - 'dev-2': {'zone_id': 'zone-1', 'is_circuit_driver': False} + 'dev-2': {'zone_id': 'zone-1', 'is_circuit_driver': False}, } api_instance.state_manager.zone_cache = { 'zone-1': { @@ -2295,11 +2023,9 @@ async def test_broadcast_state_change_circuit_driver_logic_other(self, api_insta 'target_heating_cooling_state': 1, 'current_heating_cooling_state': 0, 'window': 0, - } + }, ] - api_instance.state_manager.device_info_cache = { - 'dev-1': {'zone_id': 'zone-1', 'is_circuit_driver': True} - } + api_instance.state_manager.device_info_cache = {'dev-1': {'zone_id': 'zone-1', 'is_circuit_driver': True}} api_instance.state_manager.zone_cache = { 'zone-1': { 'name': 'Living', @@ -2318,7 +2044,6 @@ async def test_broadcast_state_change_circuit_driver_logic_other(self, api_insta # Zone cur_heating should be 0 (from circuit driver) assert zone_call['state']['cur_heating'] == 0 - @pytest.mark.asyncio async def test_broadcast_state_change_handles_exception(self, api_instance): """Test that exception during broadcast is handled gracefully.""" @@ -2349,6 +2074,7 @@ async def test_broadcast_state_change_timestamp_included(self, api_instance): call_args = api_instance.broadcast_event.call_args[0][0] assert call_args['timestamp'] == 1234567890.0 + class TestTadoLocalAPIPollingSystem: @pytest.mark.asyncio async def test_setup_polling_system_collects_chars_and_starts_task(self, api_instance): @@ -2356,11 +2082,13 @@ async def test_setup_polling_system_collects_chars_and_starts_task(self, api_ins { "aid": 1, "services": [ - {"characteristics": [ - {"iid": 10, "perms": ["ev", "pr"]}, # include - {"iid": 11, "perms": ["pr"]}, # skip - {"iid": 12, "perms": ["ev", "pr"]}, # include - ]} + { + "characteristics": [ + {"iid": 10, "perms": ["ev", "pr"]}, # include + {"iid": 11, "perms": ["pr"]}, # skip + {"iid": 12, "perms": ["ev", "pr"]}, # include + ] + } ], } ] @@ -2416,15 +2144,14 @@ async def test_background_polling_loop_runs_fast_and_slow_poll(self, api_instanc api_instance.is_shutting_down = False api_instance.pairing = True api_instance.monitored_characteristics = [(1, 10), (1, 11)] - api_instance.characteristic_map[(1, 10)] = "CurrentHumidity" # priority + api_instance.characteristic_map[(1, 10)] = "CurrentHumidity" # priority api_instance.characteristic_map[(1, 11)] = "CurrentTemperature" api_instance._poll_characteristics = AsyncMock() async def sleep_once(_seconds): api_instance.is_shutting_down = True - with patch("asyncio.sleep", side_effect=sleep_once), \ - patch("time.time", return_value=130): + with patch("asyncio.sleep", side_effect=sleep_once), patch("time.time", return_value=130): await api_instance.background_polling_loop() # FAST-POLL should be called for humidity char @@ -2443,8 +2170,7 @@ async def test_background_polling_loop_skips_when_not_paired(self, api_instance) async def sleep_once(_seconds): api_instance.is_shutting_down = True - with patch("asyncio.sleep", side_effect=sleep_once), \ - patch("time.time", return_value=130): + with patch("asyncio.sleep", side_effect=sleep_once), patch("time.time", return_value=130): await api_instance.background_polling_loop() api_instance._poll_characteristics.assert_not_called() @@ -2465,13 +2191,13 @@ async def sleep_side_effect(seconds): if seconds == 5: api_instance.is_shutting_down = True - with patch("asyncio.sleep", side_effect=sleep_side_effect), \ - patch("time.time", return_value=130): + with patch("asyncio.sleep", side_effect=sleep_side_effect), patch("time.time", return_value=130): await api_instance.background_polling_loop() assert 10 in sleep_calls assert 5 in sleep_calls + class TestTadoLocalAPIPollCharacteristics: @pytest.mark.asyncio async def test_poll_characteristics_batches_and_calls_handle_change(self, api_instance): @@ -2498,9 +2224,7 @@ async def test_poll_characteristics_skips_missing_results(self, api_instance): char_list = [(1, 1), (1, 2), (1, 3)] api_instance.pairing = Mock() - api_instance.pairing.get_characteristics = AsyncMock( - return_value={(1, 1): {"value": 10}, (1, 3): {"value": 30}} - ) + api_instance.pairing.get_characteristics = AsyncMock(return_value={(1, 1): {"value": 10}, (1, 3): {"value": 30}}) api_instance.handle_change = AsyncMock() await api_instance._poll_characteristics(char_list) @@ -2516,9 +2240,7 @@ async def test_poll_characteristics_continues_after_batch_error(self, api_instan second_batch = {(1, i): {"value": i} for i in range(16, 21)} api_instance.pairing = Mock() - api_instance.pairing.get_characteristics = AsyncMock( - side_effect=[Exception("batch fail"), second_batch] - ) + api_instance.pairing.get_characteristics = AsyncMock(side_effect=[Exception("batch fail"), second_batch]) api_instance.handle_change = AsyncMock() await api_instance._poll_characteristics(char_list) @@ -2616,9 +2338,7 @@ async def test_set_device_char_raises_when_no_valid_chars(self, api_instance): api_instance.pairing.put_characteristics = AsyncMock() api_instance.state_manager = Mock(spec=DeviceStateManager) api_instance.state_manager.get_device_info.return_value = {"id": 1, "aid": 100} - api_instance.accessories_cache = [ - {"aid": 100, "services": [{"characteristics": [{"iid": 10, "type": "some-other-type"}]}]} - ] + api_instance.accessories_cache = [{"aid": 100, "services": [{"characteristics": [{"iid": 10, "type": "some-other-type"}]}]}] with pytest.raises(ValueError, match="No valid characteristics to set"): await api_instance.set_device_characteristics(1, {"target_temperature": 21.0}) @@ -2636,11 +2356,7 @@ async def test_set_device_char_success_single_characteristic(self, api_instance) api_instance.accessories_cache = [ { "aid": 100, - "services": [ - {"characteristics": [ - {"iid": 10, "type": DeviceStateManager.CHAR_TARGET_TEMPERATURE} - ]} - ], + "services": [{"characteristics": [{"iid": 10, "type": DeviceStateManager.CHAR_TARGET_TEMPERATURE}]}], } ] @@ -2661,10 +2377,12 @@ async def test_set_device_char_success_multiple_characteristics(self, api_instan { "aid": 100, "services": [ - {"characteristics": [ - {"iid": 10, "type": DeviceStateManager.CHAR_TARGET_TEMPERATURE}, - {"iid": 11, "type": DeviceStateManager.CHAR_TARGET_HEATING_COOLING}, - ]} + { + "characteristics": [ + {"iid": 10, "type": DeviceStateManager.CHAR_TARGET_TEMPERATURE}, + {"iid": 11, "type": DeviceStateManager.CHAR_TARGET_HEATING_COOLING}, + ] + } ], } ] @@ -2678,9 +2396,7 @@ async def test_set_device_char_success_multiple_characteristics(self, api_instan ) assert ok is True - api_instance.pairing.put_characteristics.assert_awaited_once_with( - [(100, 10, 20.5), (100, 11, 1)] - ) + api_instance.pairing.put_characteristics.assert_awaited_once_with([(100, 10, 20.5), (100, 11, 1)]) @pytest.mark.asyncio async def test_set_device_char_ignores_unknown_char_and_sets_valid(self, api_instance): @@ -2693,11 +2409,7 @@ async def test_set_device_char_ignores_unknown_char_and_sets_valid(self, api_ins api_instance.accessories_cache = [ { "aid": 100, - "services": [ - {"characteristics": [ - {"iid": 10, "type": DeviceStateManager.CHAR_TARGET_TEMPERATURE} - ]} - ], + "services": [{"characteristics": [{"iid": 10, "type": DeviceStateManager.CHAR_TARGET_TEMPERATURE}]}], } ] diff --git a/tests/test_sync.py b/tests/test_sync.py index 5f7ad88..43862eb 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -20,7 +20,7 @@ def syncer(temp_db): class TestSyncHome: def test_normalize_device_type(self, syncer): - """ Test that device types are normalized correctly. """ + """Test that device types are normalized correctly.""" from tado_local.sync import normalize_device_type type = normalize_device_type("VA02") @@ -43,7 +43,6 @@ def test_normalize_device_type(self, syncer): pytest.fail(f"normalize_device_type('None') raised unexpectedly: {exc}") assert device_type == "unknown" - def test_sync_home_inserts_row(self, syncer): home_data = { "id": 123, @@ -119,14 +118,8 @@ def test_sync_zones_creates_zone_devices_and_skips_hot_water(self, syncer): def test_sync_zones_updates_existing_and_removes_stale(self, syncer): conn = sqlite3.connect(syncer.db_path) - conn.execute( - "INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, order_id, uuid) " \ - "VALUES (1, 7, 'Old', 'HEATING', 9, 'u1')" - ) - conn.execute( - "INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, order_id, uuid) " \ - "VALUES (99, 7, 'Stale', 'HEATING', 1, 'u2')" - ) + conn.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, order_id, uuid) " "VALUES (1, 7, 'Old', 'HEATING', 9, 'u1')") + conn.execute("INSERT INTO zones (tado_zone_id, tado_home_id, name, zone_type, order_id, uuid) " "VALUES (99, 7, 'Stale', 'HEATING', 1, 'u2')") conn.commit() conn.close() @@ -149,9 +142,7 @@ def test_sync_zones_updates_existing_and_removes_stale(self, syncer): def test_sync_zones_with_existing_devices(self, syncer): conn = sqlite3.connect(syncer.db_path) - conn.execute( - "INSERT INTO devices (serial_number, name, device_type) VALUES ('RU001', 'dev', 'unknown')" - ) + conn.execute("INSERT INTO devices (serial_number, name, device_type) VALUES ('RU001', 'dev', 'unknown')") conn.commit() conn.close() @@ -196,10 +187,7 @@ def test_sync_zones_with_existing_devices(self, syncer): class TestSyncZoneStatesData: def test_sync_zone_states_data_creates_humidity_tasks(self, syncer): conn = sqlite3.connect(syncer.db_path) - conn.execute( - "INSERT INTO devices (serial_number, aid, tado_zone_id, name)" \ - "VALUES ('RU001', 11, '1', 'dev1')" - ) + conn.execute("INSERT INTO devices (serial_number, aid, tado_zone_id, name)" "VALUES ('RU001', 11, '1', 'dev1')") conn.commit() conn.close() @@ -218,9 +206,79 @@ def test_sync_zone_states_data_creates_humidity_tasks(self, syncer): with patch("tado_local.sync.asyncio.create_task") as create_task: assert syncer.sync_zone_states_data(zone_states_data, home_id=1, tado_api=tado_api) is True - tado_api.get_iid_from_characteristics.assert_called_once_with(11, "CurrentRelativeHumidity") + tado_api.get_iid_from_characteristics.assert_any_call(11, "CurrentRelativeHumidity") + assert create_task.call_count >= 1 + + def test_sync_zone_states_data_ignores_temperature_only(self, syncer): + """Temperature-only zones should be skipped -- temp is handled by HomeKit polling.""" + conn = sqlite3.connect(syncer.db_path) + conn.execute("INSERT INTO devices (serial_number, aid, tado_zone_id, name)" "VALUES ('SU001', 22, '5', 'ac_ctrl')") + conn.commit() + conn.close() + + zone_states_data = { + "zoneStates": { + "5": { + "setting": {"type": "AIR_CONDITIONING"}, + "sensorDataPoints": { + "insideTemperature": {"celsius": 23.4, "fahrenheit": 74.1}, + }, + } + } + } + + tado_api = MagicMock() + + with patch("tado_local.sync.asyncio.create_task") as create_task: + assert syncer.sync_zone_states_data(zone_states_data, home_id=1, tado_api=tado_api) is True + + create_task.assert_not_called() + + def test_sync_zone_states_data_syncs_only_humidity_when_both_present(self, syncer): + """When both temp and humidity are present, only humidity should be synced.""" + conn = sqlite3.connect(syncer.db_path) + conn.execute("INSERT INTO devices (serial_number, aid, tado_zone_id, name)" "VALUES ('RU002', 33, '7', 'thermostat')") + conn.commit() + conn.close() + + zone_states_data = { + "zoneStates": { + "7": { + "setting": {"type": "HEATING"}, + "sensorDataPoints": { + "insideTemperature": {"celsius": 21.0}, + "humidity": {"percentage": 48}, + }, + } + } + } + + tado_api = MagicMock() + tado_api.get_iid_from_characteristics.return_value = 100 + + with patch("tado_local.sync.asyncio.create_task") as create_task: + assert syncer.sync_zone_states_data(zone_states_data, home_id=1, tado_api=tado_api) is True + + tado_api.get_iid_from_characteristics.assert_called_once_with(33, "CurrentRelativeHumidity") create_task.assert_called_once() + def test_sync_zone_states_data_skips_when_no_sensor_data(self, syncer): + zone_states_data = { + "zoneStates": { + "1": { + "setting": {"type": "HEATING"}, + "sensorDataPoints": {}, + } + } + } + + tado_api = MagicMock() + + with patch("tado_local.sync.asyncio.create_task") as create_task: + assert syncer.sync_zone_states_data(zone_states_data, home_id=1, tado_api=tado_api) is True + + create_task.assert_not_called() + def test_sync_zone_states_data_skips_hot_water(self, syncer): zone_states_data = { "zoneStates": { @@ -242,10 +300,7 @@ def test_sync_zone_states_data_skips_hot_water(self, syncer): class TestSyncDeviceList: def test_sync_device_list_updates_existing_device(self, syncer): conn = sqlite3.connect(syncer.db_path) - conn.execute( - "INSERT INTO devices (serial_number, name, device_type) " + - "VALUES ('RU001', 'dev', 'unknown')" - ) + conn.execute("INSERT INTO devices (serial_number, name, device_type) " + "VALUES ('RU001', 'dev', 'unknown')") conn.commit() conn.close() @@ -266,10 +321,7 @@ def test_sync_device_list_updates_existing_device(self, syncer): assert syncer.sync_device_list(payload, home_id=1) is True conn = sqlite3.connect(syncer.db_path) - row = conn.execute( - "SELECT battery_state, firmware_version, tado_zone_id, model " + - "FROM devices WHERE serial_number = 'RU001'" - ).fetchone() + row = conn.execute("SELECT battery_state, firmware_version, tado_zone_id, model " + "FROM devices WHERE serial_number = 'RU001'").fetchone() conn.close() assert row[0] == "GOOD" @@ -298,9 +350,11 @@ async def test_sync_all_with_prefetched_data(self, syncer): cloud_api.home_id = 100 cloud_api.tado_api = MagicMock() - with patch.object(syncer, "sync_home", return_value=True) as p_home, \ - patch.object(syncer, "sync_zones", return_value=True) as p_zones, \ - patch.object(syncer, "sync_device_list", return_value=True) as p_devices: + with ( + patch.object(syncer, "sync_home", return_value=True) as p_home, + patch.object(syncer, "sync_zones", return_value=True) as p_zones, + patch.object(syncer, "sync_device_list", return_value=True) as p_devices, + ): ok = await syncer.sync_all( cloud_api, home_data={"id": 100, "name": "H"}, @@ -313,5 +367,3 @@ async def test_sync_all_with_prefetched_data(self, syncer): p_home.assert_called_once() p_zones.assert_called_once() p_devices.assert_called_once() - -