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.
- ucapi_framework/__init__.py +82 -0
- ucapi_framework/config.py +595 -0
- ucapi_framework/device.py +1372 -0
- ucapi_framework/discovery.py +474 -0
- ucapi_framework/driver.py +1716 -0
- ucapi_framework/entity.py +192 -0
- ucapi_framework/helpers.py +153 -0
- ucapi_framework/migration.py +864 -0
- ucapi_framework/setup.py +2253 -0
- ucapi_framework-1.5.0b1.dist-info/METADATA +269 -0
- ucapi_framework-1.5.0b1.dist-info/RECORD +13 -0
- ucapi_framework-1.5.0b1.dist-info/WHEEL +5 -0
- ucapi_framework-1.5.0b1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|