|
| 1 | +"""Custom collection types for OmniLogic equipment management.""" |
| 2 | + |
| 3 | +import logging |
| 4 | +from collections import Counter |
| 5 | +from collections.abc import Iterator |
| 6 | +from typing import Any, Generic, TypeVar, overload |
| 7 | + |
| 8 | +from pyomnilogic_local._base import OmniEquipment |
| 9 | + |
| 10 | +_LOGGER = logging.getLogger(__name__) |
| 11 | + |
| 12 | +# Track which duplicate names we've already warned about to avoid log spam |
| 13 | +_WARNED_DUPLICATE_NAMES: set[str] = set() |
| 14 | + |
| 15 | +# Type variable for equipment types |
| 16 | +T = TypeVar("T", bound=OmniEquipment[Any, Any]) |
| 17 | + |
| 18 | + |
| 19 | +class EquipmentDict(Generic[T]): |
| 20 | + """A dictionary-like collection that supports lookup by both name and system_id. |
| 21 | +
|
| 22 | + This collection allows accessing equipment using either their name (str) or |
| 23 | + system_id (int), providing flexible and intuitive access patterns. |
| 24 | +
|
| 25 | + Type Safety: |
| 26 | + The lookup key type determines the lookup method: |
| 27 | + - str keys lookup by equipment name |
| 28 | + - int keys lookup by equipment system_id |
| 29 | +
|
| 30 | + Examples: |
| 31 | + >>> # Create collection from list of equipment |
| 32 | + >>> bows = EquipmentDict([pool_bow, spa_bow]) |
| 33 | + >>> |
| 34 | + >>> # Access by name (string key) |
| 35 | + >>> pool = bows["Pool"] |
| 36 | + >>> |
| 37 | + >>> # Access by system_id (integer key) |
| 38 | + >>> pool = bows[3] |
| 39 | + >>> |
| 40 | + >>> # Explicit methods for clarity |
| 41 | + >>> pool = bows.get_by_name("Pool") |
| 42 | + >>> pool = bows.get_by_id(3) |
| 43 | + >>> |
| 44 | + >>> # Standard dict operations |
| 45 | + >>> for bow in bows: |
| 46 | + ... print(bow.name) |
| 47 | + >>> len(bows) |
| 48 | + >>> if "Pool" in bows: |
| 49 | + ... print("Pool exists") |
| 50 | +
|
| 51 | + Note: |
| 52 | + If an equipment item has a name that looks like a number (e.g., "123"), |
| 53 | + you must use an actual int type to lookup by system_id, as string keys |
| 54 | + always lookup by name. This type-based differentiation prevents ambiguity. |
| 55 | + """ |
| 56 | + |
| 57 | + def __init__(self, items: list[T] | None = None) -> None: |
| 58 | + """Initialize the equipment collection. |
| 59 | +
|
| 60 | + Args: |
| 61 | + items: Optional list of equipment items to populate the collection. |
| 62 | +
|
| 63 | + Raises: |
| 64 | + ValueError: If any item has neither a system_id nor a name. |
| 65 | + """ |
| 66 | + self._items: list[T] = items if items is not None else [] |
| 67 | + self._validate() |
| 68 | + |
| 69 | + def _validate(self) -> None: |
| 70 | + """Validate the equipment collection. |
| 71 | +
|
| 72 | + Checks for: |
| 73 | + 1. Items without both system_id and name (raises ValueError) |
| 74 | + 2. Duplicate names (logs warning once per unique duplicate) |
| 75 | +
|
| 76 | + Raises: |
| 77 | + ValueError: If any item has neither a system_id nor a name. |
| 78 | + """ |
| 79 | + # Check for items with no system_id AND no name |
| 80 | + if invalid_items := [item for item in self._items if item.system_id is None and item.name is None]: |
| 81 | + raise ValueError( |
| 82 | + f"Equipment collection contains {len(invalid_items)} item(s) " |
| 83 | + "with neither a system_id nor a name. All equipment must have " |
| 84 | + "at least one identifier for addressing." |
| 85 | + ) |
| 86 | + |
| 87 | + # Find duplicate names that we haven't warned about yet |
| 88 | + name_counts = Counter(item.name for item in self._items if item.name is not None) |
| 89 | + duplicate_names = {name for name, count in name_counts.items() if count > 1} |
| 90 | + unwarned_duplicates = duplicate_names.difference(_WARNED_DUPLICATE_NAMES) |
| 91 | + |
| 92 | + # Log warnings for new duplicates |
| 93 | + for name in unwarned_duplicates: |
| 94 | + _LOGGER.warning( |
| 95 | + "Equipment collection contains %d items with the same name '%s'. " |
| 96 | + "Name-based lookups will return the first match. " |
| 97 | + "Consider using system_id-based lookups for reliability " |
| 98 | + "or renaming equipment to avoid duplicates.", |
| 99 | + name_counts[name], |
| 100 | + name, |
| 101 | + ) |
| 102 | + _WARNED_DUPLICATE_NAMES.add(name) |
| 103 | + |
| 104 | + @property |
| 105 | + def _by_name(self) -> dict[str, T]: |
| 106 | + """Dynamically build name-to-equipment mapping.""" |
| 107 | + return {item.name: item for item in self._items if item.name is not None} |
| 108 | + |
| 109 | + @property |
| 110 | + def _by_id(self) -> dict[int, T]: |
| 111 | + """Dynamically build system_id-to-equipment mapping.""" |
| 112 | + return {item.system_id: item for item in self._items if item.system_id is not None} |
| 113 | + |
| 114 | + @overload |
| 115 | + def __getitem__(self, key: str) -> T: ... |
| 116 | + |
| 117 | + @overload |
| 118 | + def __getitem__(self, key: int) -> T: ... |
| 119 | + |
| 120 | + def __getitem__(self, key: str | int) -> T: |
| 121 | + """Get equipment by name (str) or system_id (int). |
| 122 | +
|
| 123 | + Args: |
| 124 | + key: Equipment name (str) or system_id (int) |
| 125 | +
|
| 126 | + Returns: |
| 127 | + The equipment item matching the key |
| 128 | +
|
| 129 | + Raises: |
| 130 | + KeyError: If no equipment matches the key |
| 131 | + TypeError: If key is not str or int |
| 132 | +
|
| 133 | + Examples: |
| 134 | + >>> bows["Pool"] # Lookup by name |
| 135 | + >>> bows[3] # Lookup by system_id |
| 136 | + """ |
| 137 | + if isinstance(key, str): |
| 138 | + return self._by_name[key] |
| 139 | + if isinstance(key, int): |
| 140 | + return self._by_id[key] |
| 141 | + |
| 142 | + raise TypeError(f"Key must be str or int, got {type(key).__name__}") |
| 143 | + |
| 144 | + def __setitem__(self, key: str | int, value: T) -> None: |
| 145 | + """Add or update equipment in the collection. |
| 146 | +
|
| 147 | + The key is only used to determine the operation type (add vs update). |
| 148 | + The actual name and system_id are taken from the equipment object itself. |
| 149 | +
|
| 150 | + Args: |
| 151 | + key: Equipment name (str) or system_id (int) - must match the equipment's values |
| 152 | + value: Equipment item to add or update |
| 153 | +
|
| 154 | + Raises: |
| 155 | + TypeError: If key is not str or int |
| 156 | + ValueError: If key doesn't match the equipment's name or system_id |
| 157 | +
|
| 158 | + Examples: |
| 159 | + >>> # Add by name |
| 160 | + >>> bows["Pool"] = new_pool_bow |
| 161 | + >>> # Add by system_id |
| 162 | + >>> bows[3] = new_pool_bow |
| 163 | + """ |
| 164 | + if isinstance(key, str): |
| 165 | + if value.name != key: |
| 166 | + raise ValueError(f"Equipment name '{value.name}' does not match key '{key}'") |
| 167 | + elif isinstance(key, int): |
| 168 | + if value.system_id != key: |
| 169 | + raise ValueError(f"Equipment system_id {value.system_id} does not match key {key}") |
| 170 | + else: |
| 171 | + raise TypeError(f"Key must be str or int, got {type(key).__name__}") |
| 172 | + |
| 173 | + # Check if we're updating an existing item (prioritize system_id) |
| 174 | + existing_item = None |
| 175 | + if value.system_id and value.system_id in self._by_id: |
| 176 | + existing_item = self._by_id[value.system_id] |
| 177 | + elif value.name and value.name in self._by_name: |
| 178 | + existing_item = self._by_name[value.name] |
| 179 | + |
| 180 | + if existing_item: |
| 181 | + # Replace existing item in place |
| 182 | + idx = self._items.index(existing_item) |
| 183 | + self._items[idx] = value |
| 184 | + else: |
| 185 | + # Add new item |
| 186 | + self._items.append(value) |
| 187 | + |
| 188 | + # Validate after modification |
| 189 | + self._validate() |
| 190 | + |
| 191 | + def __delitem__(self, key: str | int) -> None: |
| 192 | + """Remove equipment from the collection. |
| 193 | +
|
| 194 | + Args: |
| 195 | + key: Equipment name (str) or system_id (int) |
| 196 | +
|
| 197 | + Raises: |
| 198 | + KeyError: If no equipment matches the key |
| 199 | + TypeError: If key is not str or int |
| 200 | +
|
| 201 | + Examples: |
| 202 | + >>> del bows["Pool"] # Remove by name |
| 203 | + >>> del bows[3] # Remove by system_id |
| 204 | + """ |
| 205 | + # First, get the item to remove |
| 206 | + item = self[key] # This will raise KeyError if not found |
| 207 | + |
| 208 | + # Remove from the list (indexes rebuild automatically via properties) |
| 209 | + self._items.remove(item) |
| 210 | + |
| 211 | + def __contains__(self, key: str | int) -> bool: |
| 212 | + """Check if equipment exists by name (str) or system_id (int). |
| 213 | +
|
| 214 | + Args: |
| 215 | + key: Equipment name (str) or system_id (int) |
| 216 | +
|
| 217 | + Returns: |
| 218 | + True if equipment exists, False otherwise |
| 219 | +
|
| 220 | + Examples: |
| 221 | + >>> if "Pool" in bows: |
| 222 | + ... print("Pool exists") |
| 223 | + >>> if 3 in bows: |
| 224 | + ... print("System ID 3 exists") |
| 225 | + """ |
| 226 | + if isinstance(key, str): |
| 227 | + return key in self._by_name |
| 228 | + if isinstance(key, int): |
| 229 | + return key in self._by_id |
| 230 | + |
| 231 | + return False |
| 232 | + |
| 233 | + def __iter__(self) -> Iterator[T]: |
| 234 | + """Iterate over all equipment items in the collection. |
| 235 | +
|
| 236 | + Returns: |
| 237 | + Iterator over equipment items |
| 238 | +
|
| 239 | + Examples: |
| 240 | + >>> for bow in bows: |
| 241 | + ... print(bow.name) |
| 242 | + """ |
| 243 | + return iter(self._items) |
| 244 | + |
| 245 | + def __len__(self) -> int: |
| 246 | + """Get the number of equipment items in the collection. |
| 247 | +
|
| 248 | + Returns: |
| 249 | + Number of items |
| 250 | +
|
| 251 | + Examples: |
| 252 | + >>> len(bows) |
| 253 | + 2 |
| 254 | + """ |
| 255 | + return len(self._items) |
| 256 | + |
| 257 | + def __repr__(self) -> str: |
| 258 | + """Get string representation of the collection. |
| 259 | +
|
| 260 | + Returns: |
| 261 | + String representation showing item count and names |
| 262 | + """ |
| 263 | + names = [f"<ID:{item.system_id},NAME:{item.name}>" for item in self._items] |
| 264 | + # names = [item.name or f"<ID:{item.system_id}>" for item in self._items] |
| 265 | + return f"EquipmentDict({names})" |
| 266 | + |
| 267 | + def append(self, item: T) -> None: |
| 268 | + """Add or update equipment in the collection (list-like interface). |
| 269 | +
|
| 270 | + If equipment with the same system_id or name already exists, it will be |
| 271 | + replaced. System_id is checked first as it's the more reliable unique identifier. |
| 272 | +
|
| 273 | + Args: |
| 274 | + item: Equipment item to add or update |
| 275 | +
|
| 276 | + Examples: |
| 277 | + >>> # Add new equipment |
| 278 | + >>> bows.append(new_pool_bow) |
| 279 | + >>> |
| 280 | + >>> # Update existing equipment (replaces if system_id or name matches) |
| 281 | + >>> bows.append(updated_pool_bow) |
| 282 | + """ |
| 283 | + # Check if we're updating an existing item (prioritize system_id as it's guaranteed unique) |
| 284 | + existing_item = None |
| 285 | + if item.system_id and item.system_id in self._by_id: |
| 286 | + existing_item = self._by_id[item.system_id] |
| 287 | + elif item.name and item.name in self._by_name: |
| 288 | + existing_item = self._by_name[item.name] |
| 289 | + |
| 290 | + if existing_item: |
| 291 | + # Replace existing item in place |
| 292 | + idx = self._items.index(existing_item) |
| 293 | + self._items[idx] = item |
| 294 | + else: |
| 295 | + # Add new item |
| 296 | + self._items.append(item) |
| 297 | + |
| 298 | + # Validate after modification |
| 299 | + self._validate() |
| 300 | + |
| 301 | + def get_by_name(self, name: str) -> T | None: |
| 302 | + """Get equipment by name with explicit method (returns None if not found). |
| 303 | +
|
| 304 | + Args: |
| 305 | + name: Equipment name |
| 306 | +
|
| 307 | + Returns: |
| 308 | + Equipment item or None if not found |
| 309 | +
|
| 310 | + Examples: |
| 311 | + >>> pool = bows.get_by_name("Pool") |
| 312 | + >>> if pool is not None: |
| 313 | + ... await pool.filters[0].turn_on() |
| 314 | + """ |
| 315 | + return self._by_name.get(name) |
| 316 | + |
| 317 | + def get_by_id(self, system_id: int) -> T | None: |
| 318 | + """Get equipment by system_id with explicit method (returns None if not found). |
| 319 | +
|
| 320 | + Args: |
| 321 | + system_id: Equipment system_id |
| 322 | +
|
| 323 | + Returns: |
| 324 | + Equipment item or None if not found |
| 325 | +
|
| 326 | + Examples: |
| 327 | + >>> pool = bows.get_by_id(3) |
| 328 | + >>> if pool is not None: |
| 329 | + ... print(pool.name) |
| 330 | + """ |
| 331 | + return self._by_id.get(system_id) |
| 332 | + |
| 333 | + def get(self, key: str | int, default: T | None = None) -> T | None: |
| 334 | + """Get equipment by name or system_id with optional default. |
| 335 | +
|
| 336 | + Args: |
| 337 | + key: Equipment name (str) or system_id (int) |
| 338 | + default: Default value to return if key not found |
| 339 | +
|
| 340 | + Returns: |
| 341 | + Equipment item or default if not found |
| 342 | +
|
| 343 | + Examples: |
| 344 | + >>> pool = bows.get("Pool") |
| 345 | + >>> pool = bows.get(3) |
| 346 | + >>> pool = bows.get("NonExistent", default=None) |
| 347 | + """ |
| 348 | + try: |
| 349 | + return self[key] |
| 350 | + except KeyError: |
| 351 | + return default |
| 352 | + |
| 353 | + def keys(self) -> list[str]: |
| 354 | + """Get list of all equipment names. |
| 355 | +
|
| 356 | + Returns: |
| 357 | + List of equipment names (excluding items without names) |
| 358 | +
|
| 359 | + Examples: |
| 360 | + >>> bows.keys() |
| 361 | + ['Pool', 'Spa'] |
| 362 | + """ |
| 363 | + return list(self._by_name.keys()) |
| 364 | + |
| 365 | + def values(self) -> list[T]: |
| 366 | + """Get list of all equipment items. |
| 367 | +
|
| 368 | + Returns: |
| 369 | + List of equipment items |
| 370 | +
|
| 371 | + Examples: |
| 372 | + >>> for equipment in bows.values(): |
| 373 | + ... print(equipment.name) |
| 374 | + """ |
| 375 | + return self._items.copy() |
| 376 | + |
| 377 | + def items(self) -> list[tuple[int | None, str | None, T]]: |
| 378 | + """Get list of (system_id, name, equipment) tuples. |
| 379 | +
|
| 380 | + Returns: |
| 381 | + List of (system_id, name, equipment) tuples where both system_id |
| 382 | + and name can be None (though at least one must be set per validation). |
| 383 | +
|
| 384 | + Examples: |
| 385 | + >>> for system_id, name, bow in bows.items(): |
| 386 | + ... print(f"ID: {system_id}, Name: {name}") |
| 387 | + """ |
| 388 | + return [(item.system_id, item.name, item) for item in self._items] |
0 commit comments