ucapi-framework 1.5.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ """
2
+ Base classes and utilities for Unfolded Circle Remote integrations.
3
+
4
+ This module provides reusable base classes for integration drivers, setup flows,
5
+ device management, and device interfaces.
6
+
7
+ Optional Dependencies
8
+ ---------------------
9
+ The discovery module supports optional dependencies for different discovery methods:
10
+ - ssdpy: For SSDP/UPnP discovery (pip install ssdpy)
11
+ - sddp-discovery-protocol: For SDDP discovery (pip install sddp-discovery-protocol)
12
+ - zeroconf: For mDNS/Bonjour discovery (pip install zeroconf)
13
+
14
+ These are only required if you use the corresponding discovery classes.
15
+ See ucapi_framework.discovery module documentation for details.
16
+
17
+ :copyright: (c) 2025 by Jack Powell.
18
+ :license: Mozilla Public License Version 2.0, see LICENSE for more details.
19
+ """
20
+
21
+ from .driver import BaseIntegrationDriver, create_entity_id
22
+ from .setup import BaseSetupFlow, SetupSteps
23
+ from .config import BaseConfigManager, get_config_path
24
+ from .entity import Entity
25
+ from .device import (
26
+ BaseDeviceInterface,
27
+ StatelessHTTPDevice,
28
+ PollingDevice,
29
+ WebSocketDevice,
30
+ WebSocketPollingDevice,
31
+ ExternalClientDevice,
32
+ PersistentConnectionDevice,
33
+ DeviceEvents,
34
+ )
35
+ from .discovery import (
36
+ BaseDiscovery,
37
+ DiscoveredDevice,
38
+ MDNSDiscovery,
39
+ NetworkScanDiscovery,
40
+ SDDPDiscovery,
41
+ SSDPDiscovery,
42
+ )
43
+ from .migration import (
44
+ EntityMigrationMapping,
45
+ MigrationData,
46
+ migrate_entities_on_remote,
47
+ verify_migration,
48
+ )
49
+ from .helpers import (
50
+ find_orphaned_entities,
51
+ )
52
+
53
+ __all__ = [
54
+ "BaseIntegrationDriver",
55
+ "BaseSetupFlow",
56
+ "SetupSteps",
57
+ "BaseConfigManager",
58
+ "get_config_path",
59
+ "Entity",
60
+ "BaseDeviceInterface",
61
+ "StatelessHTTPDevice",
62
+ "PollingDevice",
63
+ "WebSocketDevice",
64
+ "WebSocketPollingDevice",
65
+ "ExternalClientDevice",
66
+ "PersistentConnectionDevice",
67
+ "DeviceEvents",
68
+ "BaseDiscovery",
69
+ "DiscoveredDevice",
70
+ "MDNSDiscovery",
71
+ "NetworkScanDiscovery",
72
+ "SDDPDiscovery",
73
+ "SSDPDiscovery",
74
+ "EntityMigrationMapping",
75
+ "MigrationData",
76
+ "migrate_entities_on_remote",
77
+ "verify_migration",
78
+ "find_orphaned_entities",
79
+ "create_entity_id",
80
+ ]
81
+
82
+ __version__ = "1.4.0b2"
@@ -0,0 +1,595 @@
1
+ """
2
+ Base device configuration manager for Unfolded Circle Remote integrations.
3
+
4
+ Provides reusable device configuration storage and management.
5
+
6
+ :copyright: (c) 2025 by Jack Powell.
7
+ :license: Mozilla Public License Version 2.0, see LICENSE for more details.
8
+ """
9
+
10
+ import dataclasses
11
+ import json
12
+ import logging
13
+ import os
14
+ from typing import Any, Callable, Generic, Iterator, TypeVar, cast, get_args, get_origin
15
+
16
+ _LOG = logging.getLogger(__name__)
17
+
18
+ _CFG_FILENAME = "config.json"
19
+
20
+ # Type variable for device configuration - must be a dataclass
21
+ DeviceT = TypeVar("DeviceT")
22
+
23
+
24
+ def get_config_path(default_path: str) -> str:
25
+ """
26
+ Get the appropriate configuration path for the current environment.
27
+
28
+ Handles three deployment scenarios:
29
+ 1. **Remote Two (production)**: Uses the default path provided by driver.api.config_dir_path
30
+ 2. **Docker container**: Uses UC_CONFIG_HOME environment variable (typically /config)
31
+ 3. **Local development**: Uses {cwd}/config/ as absolute path
32
+
33
+ The detection logic:
34
+ - If UC_CONFIG_HOME is set → use it (Docker environment)
35
+ - If driver.json exists in current directory → local development
36
+ - Otherwise → production (Remote Two)
37
+
38
+ :param default_path: Default path from driver.api.config_dir_path
39
+ :return: Configuration directory path (always absolute)
40
+
41
+ Example usage::
42
+
43
+ driver = MyIntegrationDriver(device_class=MyDevice, ...)
44
+
45
+ config_path = get_config_path(driver.api.config_dir_path)
46
+
47
+ driver.config_manager = BaseConfigManager(
48
+ config_path,
49
+ driver.on_device_added,
50
+ driver.on_device_removed,
51
+ config_class=MyDeviceConfig,
52
+ )
53
+ """
54
+ # Check for Docker environment (UC_CONFIG_HOME is set in Dockerfile)
55
+ if docker_config_home := os.getenv("UC_CONFIG_HOME"):
56
+ _LOG.debug(
57
+ "Docker environment detected, using UC_CONFIG_HOME: %s", docker_config_home
58
+ )
59
+ return docker_config_home
60
+
61
+ # Auto-detect local development: driver.json exists in current directory
62
+ if os.path.exists("driver.json"):
63
+ # Use absolute path based on current working directory
64
+ local_path = os.path.abspath("config")
65
+ _LOG.debug("Local development detected, using config path: %s", local_path)
66
+ return local_path
67
+
68
+ # Production environment (Remote Two) - use default path from API
69
+ _LOG.debug("Production environment, using default path: %s", default_path)
70
+ return default_path
71
+
72
+
73
+ class _EnhancedJSONEncoder(json.JSONEncoder):
74
+ """
75
+ Custom JSON encoder with support for dataclass serialization.
76
+
77
+ The standard json.JSONEncoder doesn't know how to serialize dataclasses.
78
+ This encoder extends it to automatically convert dataclass instances to
79
+ dictionaries using dataclasses.asdict(), enabling seamless JSON persistence
80
+ of device configurations.
81
+
82
+ This is preferred over manual dict conversion because:
83
+ - Automatic serialization of nested dataclasses
84
+ - Type safety maintained through dataclass definitions
85
+ - No need to manually implement to_dict() on every config class
86
+ """
87
+
88
+ def default(self, o: Any) -> Any:
89
+ """
90
+ Override default serialization for unsupported types.
91
+
92
+ :param o: Object to serialize
93
+ :return: JSON-serializable representation
94
+ """
95
+ if dataclasses.is_dataclass(o):
96
+ return dataclasses.asdict(o)
97
+ return super().default(o)
98
+
99
+
100
+ class BaseConfigManager(Generic[DeviceT]):
101
+ """
102
+ Base class for device configuration management.
103
+
104
+ Handles:
105
+ - Loading/storing configuration from/to JSON
106
+ - CRUD operations (add, update, remove, get)
107
+ - Configuration callbacks
108
+ - Optional backup/restore support
109
+
110
+ Type Parameters:
111
+ DeviceT: The device configuration dataclass type
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ data_path: str,
117
+ add_handler: Callable[[DeviceT], None] | None = None,
118
+ remove_handler: Callable[[DeviceT | None], None] | None = None,
119
+ config_class: type[DeviceT] | None = None,
120
+ ):
121
+ """
122
+ Create a configuration instance.
123
+
124
+ :param data_path: Configuration path for the configuration file
125
+ :param add_handler: Optional callback when device is added
126
+ :param remove_handler: Optional callback when device is removed
127
+ :param config_class: The configuration dataclass type (optional, auto-detected from type hints if not provided)
128
+ """
129
+ self._data_path: str = data_path
130
+ self._cfg_file_path: str = os.path.join(data_path, _CFG_FILENAME)
131
+ self._config: list[DeviceT] = []
132
+ self._add_handler = add_handler
133
+ self._remove_handler = remove_handler
134
+ self._config_class = config_class
135
+ self.load()
136
+
137
+ @property
138
+ def data_path(self) -> str:
139
+ """Return the configuration path."""
140
+ return self._data_path
141
+
142
+ def all(self) -> Iterator[DeviceT]:
143
+ """Get an iterator for all device configurations."""
144
+ return iter(self._config)
145
+
146
+ def contains(self, device_id: str) -> bool:
147
+ """
148
+ Check if there's a device with the given device identifier.
149
+
150
+ :param device_id: Device identifier
151
+ :return: True if device exists
152
+ """
153
+ return any(self.get_device_id(item) == device_id for item in self._config)
154
+
155
+ def add_or_update(self, device: DeviceT) -> None:
156
+ """
157
+ Add a new device or update if it already exists.
158
+
159
+ :param device: Device configuration to add or update
160
+ """
161
+ if not self.update(device):
162
+ self._config.append(device)
163
+ self.store()
164
+ if self._add_handler is not None:
165
+ self._add_handler(device)
166
+
167
+ def get(self, device_id: str) -> DeviceT | None:
168
+ """
169
+ Get device configuration for given identifier.
170
+
171
+ :param device_id: Device identifier
172
+ :return: Device configuration or None
173
+ """
174
+ for item in self._config:
175
+ if self.get_device_id(item) == device_id:
176
+ # Return a copy if it's a dataclass
177
+ if dataclasses.is_dataclass(item) and not isinstance(item, type):
178
+ # Cast is safe: we've verified item is a dataclass instance
179
+ return cast(DeviceT, dataclasses.replace(item))
180
+ # Fallback: return the item as-is (shouldn't happen in normal usage)
181
+ _LOG.warning(
182
+ "Device config is not a dataclass, returning original: %s",
183
+ type(item).__name__,
184
+ )
185
+ return item
186
+ return None
187
+
188
+ def update(self, device: DeviceT) -> bool:
189
+ """
190
+ Update a configured device and persist configuration.
191
+
192
+ :param device: Device configuration with updated values
193
+ :return: True if device was updated, False if not found
194
+ """
195
+ device_id = self.get_device_id(device)
196
+ for item in self._config:
197
+ if self.get_device_id(item) == device_id:
198
+ # Update the item in place
199
+ self.update_device_fields(item, device)
200
+ return self.store()
201
+ return False
202
+
203
+ def remove(self, device_id: str) -> bool:
204
+ """
205
+ Remove the given device configuration.
206
+
207
+ :param device_id: Device identifier
208
+ :return: True if device was removed
209
+ """
210
+ device = self.get(device_id)
211
+ if device is None:
212
+ return False
213
+ try:
214
+ # Remove the original object from config
215
+ for item in self._config:
216
+ if self.get_device_id(item) == device_id:
217
+ self._config.remove(item)
218
+ break
219
+
220
+ if self._remove_handler is not None:
221
+ self._remove_handler(device)
222
+ self.store()
223
+ return True
224
+ except ValueError:
225
+ pass
226
+ return False
227
+
228
+ def clear(self) -> None:
229
+ """Remove all configuration."""
230
+ self._config = []
231
+
232
+ if os.path.exists(self._cfg_file_path):
233
+ os.remove(self._cfg_file_path)
234
+
235
+ if self._remove_handler is not None:
236
+ self._remove_handler(None)
237
+
238
+ def store(self) -> bool:
239
+ """
240
+ Store the configuration file.
241
+
242
+ :return: True if the configuration could be saved
243
+ """
244
+ try:
245
+ # Ensure directory exists
246
+ os.makedirs(self._data_path, exist_ok=True)
247
+
248
+ with open(self._cfg_file_path, "w+", encoding="utf-8") as f:
249
+ json.dump(self._config, f, ensure_ascii=False, cls=_EnhancedJSONEncoder)
250
+ return True
251
+ except OSError as err:
252
+ _LOG.error("Cannot write the config file: %s", err)
253
+ return False
254
+
255
+ def load(self) -> bool:
256
+ """
257
+ Load the configuration from file.
258
+
259
+ :return: True if the configuration could be loaded
260
+ """
261
+ if not os.path.exists(self._cfg_file_path):
262
+ _LOG.info(
263
+ "Configuration file not found, starting with empty configuration: %s",
264
+ self._cfg_file_path,
265
+ )
266
+ return False
267
+
268
+ try:
269
+ with open(self._cfg_file_path, "r", encoding="utf-8") as f:
270
+ data = json.load(f)
271
+
272
+ for item in data:
273
+ device = self.deserialize_device(item)
274
+ if device:
275
+ self._config.append(device)
276
+
277
+ _LOG.info("Loaded %d device(s) from configuration", len(self._config))
278
+ return True
279
+ except PermissionError as err:
280
+ _LOG.error(
281
+ "Permission denied reading config file %s: %s", self._cfg_file_path, err
282
+ )
283
+ except OSError as err:
284
+ _LOG.error("Cannot read the config file %s: %s", self._cfg_file_path, err)
285
+ except json.JSONDecodeError as err:
286
+ _LOG.error("Invalid JSON in config file %s: %s", self._cfg_file_path, err)
287
+ except (AttributeError, ValueError, TypeError) as err:
288
+ _LOG.error("Invalid config file format in %s: %s", self._cfg_file_path, err)
289
+
290
+ return False
291
+
292
+ def get_device_id(self, device: DeviceT) -> str:
293
+ """
294
+ Extract device identifier from device configuration.
295
+
296
+ Default implementation: tries common attribute names (identifier, id, device_id).
297
+ Override this if your device config uses a different attribute name.
298
+
299
+ :param device: Device configuration
300
+ :return: Device identifier
301
+ :raises AttributeError: If no valid ID attribute is found
302
+ """
303
+ for attr in ("identifier", "id", "device_id"):
304
+ if hasattr(device, attr):
305
+ value = getattr(device, attr)
306
+ if value:
307
+ return str(value)
308
+
309
+ raise AttributeError(
310
+ f"Device config {type(device).__name__} has no 'identifier', 'id', or 'device_id' attribute. "
311
+ f"Override get_device_id() to specify which attribute to use."
312
+ )
313
+
314
+ def get_backup_json(self) -> str:
315
+ """
316
+ Get configuration as JSON string for backup.
317
+
318
+ :return: JSON string representation of configuration
319
+ """
320
+ try:
321
+ return json.dumps(
322
+ self._config, ensure_ascii=False, indent=2, cls=_EnhancedJSONEncoder
323
+ )
324
+ except (TypeError, ValueError) as err:
325
+ _LOG.error("Failed to serialize configuration: %s", err)
326
+ return "[]"
327
+
328
+ def restore_from_backup_json(self, backup_json: str) -> bool:
329
+ """
330
+ Restore configuration from JSON string.
331
+
332
+ :param backup_json: JSON string containing configuration backup
333
+ :return: True if restore was successful
334
+ """
335
+ try:
336
+ data = json.loads(backup_json)
337
+
338
+ if not isinstance(data, list):
339
+ _LOG.error(
340
+ "Invalid backup format: expected list, got %s", type(data).__name__
341
+ )
342
+ return False
343
+
344
+ # Deserialize and validate all devices first
345
+ new_config: list[DeviceT] = []
346
+ for item in data:
347
+ if not isinstance(item, dict):
348
+ _LOG.warning("Skipping invalid device entry: %s", item)
349
+ continue
350
+
351
+ device = self.deserialize_device(item)
352
+ if device:
353
+ new_config.append(device)
354
+ else:
355
+ _LOG.warning("Failed to deserialize device: %s", item)
356
+
357
+ if not new_config:
358
+ _LOG.error("No valid devices found in backup")
359
+ return False
360
+
361
+ # Replace configuration and persist
362
+ self._config = new_config
363
+ if self.store():
364
+ _LOG.info(
365
+ "Successfully restored %d device(s) from backup", len(self._config)
366
+ )
367
+
368
+ # Notify via add handler for each device
369
+ if self._add_handler is not None:
370
+ for device in self._config:
371
+ self._add_handler(device)
372
+
373
+ return True
374
+ else:
375
+ _LOG.error("Failed to persist restored configuration")
376
+ return False
377
+
378
+ except json.JSONDecodeError as err:
379
+ _LOG.error("Invalid JSON in backup: %s", err)
380
+ return False
381
+ except (AttributeError, ValueError, TypeError) as err:
382
+ _LOG.error("Failed to restore configuration: %s", err)
383
+ return False
384
+
385
+ # ========================================================================
386
+ # Migration Support
387
+ # ========================================================================
388
+
389
+ def migration_required(self) -> bool:
390
+ """
391
+ Check if configuration migration is required.
392
+
393
+ Override this method to implement migration detection logic.
394
+
395
+ :return: True if migration is required
396
+ """
397
+ return False
398
+
399
+ async def migrate(self) -> bool:
400
+ """
401
+ Migrate configuration if required.
402
+
403
+ Override this method to implement migration logic.
404
+
405
+ :return: True if migration was successful
406
+ """
407
+ return True
408
+
409
+ # ========================================================================
410
+ # Helper Methods
411
+ # ========================================================================
412
+
413
+ @staticmethod
414
+ def _deserialize_field(field_value: Any, field_type: Any) -> Any:
415
+ """
416
+ Recursively deserialize a field value based on its type annotation.
417
+
418
+ Handles:
419
+ - Dataclasses (single instances)
420
+ - Lists of dataclasses (e.g., list[LutronLightInfo])
421
+ - Primitive types (passed through)
422
+
423
+ :param field_value: The value to deserialize
424
+ :param field_type: The target type annotation (can be Any or str for forward references)
425
+ :return: Deserialized value
426
+ """
427
+ # Handle None values
428
+ if field_value is None:
429
+ return None
430
+
431
+ # Get the origin type (e.g., list from list[X])
432
+ origin = get_origin(field_type)
433
+
434
+ # Handle list types (e.g., list[SomeDataclass])
435
+ if origin is list:
436
+ args = get_args(field_type)
437
+ if args and isinstance(field_value, list):
438
+ item_type = args[0]
439
+ # If list items are dataclasses, deserialize each one
440
+ if dataclasses.is_dataclass(item_type):
441
+ return [
442
+ item_type(**item) if isinstance(item, dict) else item
443
+ for item in field_value
444
+ ]
445
+ # Not a list of dataclasses, return as-is
446
+ return field_value
447
+
448
+ # Handle single dataclass instances
449
+ if dataclasses.is_dataclass(field_type) and isinstance(field_value, dict):
450
+ return field_type(**field_value)
451
+
452
+ # For all other types (str, int, bool, etc.), return as-is
453
+ return field_value
454
+
455
+ def deserialize_device_auto(
456
+ self, data: dict, device_class: type[DeviceT]
457
+ ) -> DeviceT | None:
458
+ """
459
+ Automatically deserialize device configuration with nested dataclass support.
460
+
461
+ This helper method automatically handles:
462
+ - Nested dataclasses
463
+ - Lists of dataclasses (e.g., list[LutronLightInfo])
464
+ - Primitive types
465
+
466
+ Use this in your deserialize_device() implementation:
467
+
468
+ Example:
469
+ def deserialize_device(self, data: dict) -> MyDeviceConfig | None:
470
+ return self.deserialize_device_auto(data, MyDeviceConfig)
471
+
472
+ For backward compatibility or custom logic, override specific fields:
473
+
474
+ Example:
475
+ def deserialize_device(self, data: dict) -> MyDeviceConfig | None:
476
+ # Let auto-deserialize handle nested dataclasses
477
+ device = self.deserialize_device_auto(data, MyDeviceConfig)
478
+ if device:
479
+ # Add custom migration logic
480
+ if not hasattr(device, 'new_field'):
481
+ device.new_field = "default_value"
482
+ return device
483
+
484
+ :param data: Dictionary with device data
485
+ :param device_class: The device dataclass type
486
+ :return: Device configuration or None if invalid
487
+ """
488
+ try:
489
+ # Get all fields from the dataclass
490
+ field_dict = {}
491
+ for field in dataclasses.fields(device_class):
492
+ field_name = field.name
493
+ if field_name in data:
494
+ # Deserialize the field value based on its type
495
+ field_dict[field_name] = self._deserialize_field(
496
+ data[field_name], field.type
497
+ )
498
+
499
+ # Create the device instance
500
+ return device_class(**field_dict)
501
+
502
+ except (TypeError, ValueError) as err:
503
+ _LOG.error("Failed to deserialize device: %s", err)
504
+ return None
505
+
506
+ # ========================================================================
507
+ # Deserialization (Can be overridden for custom logic)
508
+ # ========================================================================
509
+
510
+ def deserialize_device(self, data: dict) -> DeviceT | None:
511
+ """
512
+ Deserialize device configuration from dictionary.
513
+
514
+ **DEFAULT IMPLEMENTATION**: Uses deserialize_device_auto() with the config class
515
+ provided during initialization or inferred from the Generic type parameter.
516
+
517
+ Most integrations can use the default implementation without overriding:
518
+
519
+ class MyConfigManager(BaseConfigManager[MyDeviceConfig]):
520
+ pass # No override needed!
521
+
522
+ Or explicitly pass the config class:
523
+
524
+ manager = MyConfigManager(data_path, config_class=MyDeviceConfig)
525
+
526
+ **Override only if** you need custom logic:
527
+
528
+ def deserialize_device(self, data: dict) -> MyDeviceConfig | None:
529
+ # Auto-deserialize handles nested dataclasses
530
+ device = self.deserialize_device_auto(data, MyDeviceConfig)
531
+ if device:
532
+ # Custom migration logic
533
+ if 'old_field' in data:
534
+ device.new_field = migrate_value(data['old_field'])
535
+ # Custom post-processing
536
+ for light in device.lights:
537
+ light.name = light.name.replace("_", " ")
538
+ return device
539
+
540
+ :param data: Dictionary with device data
541
+ :return: Device configuration or None if invalid
542
+ """
543
+ # Get config class if not provided during init
544
+ if self._config_class is None:
545
+ # Try to infer from Generic type parameter
546
+ config_class = self._infer_config_class()
547
+ if config_class is None:
548
+ raise TypeError(
549
+ f"{type(self).__name__} must either:\n"
550
+ f"1. Pass config_class to __init__: MyManager(path, config_class=MyConfig)\n"
551
+ f"2. Override deserialize_device() with custom logic\n"
552
+ f"3. Use proper Generic syntax: class MyManager(BaseConfigManager[MyConfig])"
553
+ )
554
+ self._config_class = config_class
555
+
556
+ # Use auto-deserialize with the config class
557
+ return self.deserialize_device_auto(data, self._config_class)
558
+
559
+ def _infer_config_class(self) -> type[DeviceT] | None:
560
+ """
561
+ Infer config class from Generic type parameter.
562
+
563
+ :return: Config class or None if cannot be inferred
564
+ """
565
+ # Get the class's __orig_bases__ which contains Generic[DeviceT] information
566
+ for base in getattr(type(self), "__orig_bases__", []):
567
+ origin = get_origin(base)
568
+ if origin is BaseConfigManager:
569
+ args = get_args(base)
570
+ if args:
571
+ return args[0]
572
+ return None
573
+
574
+ # ========================================================================
575
+ # Optional Override Methods
576
+ # ========================================================================
577
+
578
+ def update_device_fields(self, existing: DeviceT, updated: DeviceT) -> None:
579
+ """
580
+ Update fields of existing device with values from updated device.
581
+
582
+ Default implementation updates all fields. Override for custom behavior.
583
+
584
+ :param existing: Existing device configuration (will be modified)
585
+ :param updated: Updated device configuration (source of new values)
586
+ """
587
+ # Default: update all dataclass fields
588
+ if dataclasses.is_dataclass(existing) and not isinstance(existing, type):
589
+ for field in dataclasses.fields(existing):
590
+ setattr(existing, field.name, getattr(updated, field.name))
591
+ else:
592
+ _LOG.warning(
593
+ "update_device_fields called on non-dataclass: %s",
594
+ type(existing).__name__,
595
+ )