ucapi-framework 0.1.0__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 +35 -0
- ucapi_framework/config.py +361 -0
- ucapi_framework/device.py +487 -0
- ucapi_framework/discovery.py +319 -0
- ucapi_framework/driver.py +575 -0
- ucapi_framework/setup.py +937 -0
- ucapi_framework-0.1.0.dist-info/METADATA +194 -0
- ucapi_framework-0.1.0.dist-info/RECORD +10 -0
- ucapi_framework-0.1.0.dist-info/WHEEL +5 -0
- ucapi_framework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
:copyright: (c) 2025 by Jack Powell.
|
|
8
|
+
:license: Mozilla Public License Version 2.0, see LICENSE for more details.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .driver import BaseIntegrationDriver
|
|
12
|
+
from .setup import BaseSetupFlow, SetupSteps
|
|
13
|
+
from .config import BaseDeviceManager
|
|
14
|
+
from .device import (
|
|
15
|
+
BaseDeviceInterface,
|
|
16
|
+
StatelessHTTPDevice,
|
|
17
|
+
PollingDevice,
|
|
18
|
+
WebSocketDevice,
|
|
19
|
+
)
|
|
20
|
+
from .discovery import BaseDiscovery, DiscoveredDevice
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BaseIntegrationDriver",
|
|
24
|
+
"BaseSetupFlow",
|
|
25
|
+
"SetupSteps",
|
|
26
|
+
"BaseDeviceManager",
|
|
27
|
+
"BaseDeviceInterface",
|
|
28
|
+
"StatelessHTTPDevice",
|
|
29
|
+
"PollingDevice",
|
|
30
|
+
"WebSocketDevice",
|
|
31
|
+
"BaseDiscovery",
|
|
32
|
+
"DiscoveredDevice",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,361 @@
|
|
|
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 abc import ABC, abstractmethod
|
|
15
|
+
from asyncio import Lock
|
|
16
|
+
from typing import Callable, Generic, Iterator, TypeVar
|
|
17
|
+
|
|
18
|
+
_LOG = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_CFG_FILENAME = "config.json"
|
|
21
|
+
|
|
22
|
+
# Type variable for device configuration
|
|
23
|
+
DeviceT = TypeVar("DeviceT")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _EnhancedJSONEncoder(json.JSONEncoder):
|
|
27
|
+
"""Python dataclass json encoder."""
|
|
28
|
+
|
|
29
|
+
def default(self, o):
|
|
30
|
+
if dataclasses.is_dataclass(o):
|
|
31
|
+
return dataclasses.asdict(o)
|
|
32
|
+
return super().default(o)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BaseDeviceManager(ABC, Generic[DeviceT]):
|
|
36
|
+
"""
|
|
37
|
+
Base class for device configuration management.
|
|
38
|
+
|
|
39
|
+
Handles:
|
|
40
|
+
- Loading/storing configuration from/to JSON
|
|
41
|
+
- CRUD operations (add, update, remove, get)
|
|
42
|
+
- Configuration callbacks
|
|
43
|
+
- Optional backup/restore support
|
|
44
|
+
|
|
45
|
+
Type Parameters:
|
|
46
|
+
DeviceT: The device configuration dataclass type
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
data_path: str,
|
|
52
|
+
add_handler: Callable[[DeviceT], None] | None = None,
|
|
53
|
+
remove_handler: Callable[[DeviceT | None], None] | None = None,
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Create a configuration instance.
|
|
57
|
+
|
|
58
|
+
:param data_path: Configuration path for the configuration file
|
|
59
|
+
:param add_handler: Optional callback when device is added
|
|
60
|
+
:param remove_handler: Optional callback when device is removed
|
|
61
|
+
"""
|
|
62
|
+
self._data_path: str = data_path
|
|
63
|
+
self._cfg_file_path: str = os.path.join(data_path, _CFG_FILENAME)
|
|
64
|
+
self._config: list[DeviceT] = []
|
|
65
|
+
self._add_handler = add_handler
|
|
66
|
+
self._remove_handler = remove_handler
|
|
67
|
+
self._config_lock = Lock()
|
|
68
|
+
self.load()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def data_path(self) -> str:
|
|
72
|
+
"""Return the configuration path."""
|
|
73
|
+
return self._data_path
|
|
74
|
+
|
|
75
|
+
def all(self) -> Iterator[DeviceT]:
|
|
76
|
+
"""Get an iterator for all device configurations."""
|
|
77
|
+
return iter(self._config)
|
|
78
|
+
|
|
79
|
+
def contains(self, device_id: str) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Check if there's a device with the given device identifier.
|
|
82
|
+
|
|
83
|
+
:param device_id: Device identifier
|
|
84
|
+
:return: True if device exists
|
|
85
|
+
"""
|
|
86
|
+
return any(self.get_device_id(item) == device_id for item in self._config)
|
|
87
|
+
|
|
88
|
+
def add_or_update(self, device: DeviceT) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Add a new device or update if it already exists.
|
|
91
|
+
|
|
92
|
+
:param device: Device configuration to add or update
|
|
93
|
+
"""
|
|
94
|
+
if not self.update(device):
|
|
95
|
+
self._config.append(device)
|
|
96
|
+
self.store()
|
|
97
|
+
if self._add_handler is not None:
|
|
98
|
+
self._add_handler(device)
|
|
99
|
+
|
|
100
|
+
def get(self, device_id: str) -> DeviceT | None:
|
|
101
|
+
"""
|
|
102
|
+
Get device configuration for given identifier.
|
|
103
|
+
|
|
104
|
+
:param device_id: Device identifier
|
|
105
|
+
:return: Device configuration or None
|
|
106
|
+
"""
|
|
107
|
+
for item in self._config:
|
|
108
|
+
if self.get_device_id(item) == device_id:
|
|
109
|
+
# Return a copy
|
|
110
|
+
return dataclasses.replace(item)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def update(self, device: DeviceT) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Update a configured device and persist configuration.
|
|
116
|
+
|
|
117
|
+
:param device: Device configuration with updated values
|
|
118
|
+
:return: True if device was updated, False if not found
|
|
119
|
+
"""
|
|
120
|
+
device_id = self.get_device_id(device)
|
|
121
|
+
for item in self._config:
|
|
122
|
+
if self.get_device_id(item) == device_id:
|
|
123
|
+
# Update the item in place
|
|
124
|
+
self.update_device_fields(item, device)
|
|
125
|
+
return self.store()
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
def remove(self, device_id: str) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Remove the given device configuration.
|
|
131
|
+
|
|
132
|
+
:param device_id: Device identifier
|
|
133
|
+
:return: True if device was removed
|
|
134
|
+
"""
|
|
135
|
+
device = self.get(device_id)
|
|
136
|
+
if device is None:
|
|
137
|
+
return False
|
|
138
|
+
try:
|
|
139
|
+
# Remove the original object from config
|
|
140
|
+
for item in self._config:
|
|
141
|
+
if self.get_device_id(item) == device_id:
|
|
142
|
+
self._config.remove(item)
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
if self._remove_handler is not None:
|
|
146
|
+
self._remove_handler(device)
|
|
147
|
+
self.store()
|
|
148
|
+
return True
|
|
149
|
+
except ValueError:
|
|
150
|
+
pass
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
def clear(self) -> None:
|
|
154
|
+
"""Remove all configuration."""
|
|
155
|
+
self._config = []
|
|
156
|
+
|
|
157
|
+
if os.path.exists(self._cfg_file_path):
|
|
158
|
+
os.remove(self._cfg_file_path)
|
|
159
|
+
|
|
160
|
+
if self._remove_handler is not None:
|
|
161
|
+
self._remove_handler(None)
|
|
162
|
+
|
|
163
|
+
def store(self) -> bool:
|
|
164
|
+
"""
|
|
165
|
+
Store the configuration file.
|
|
166
|
+
|
|
167
|
+
:return: True if the configuration could be saved
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
with open(self._cfg_file_path, "w+", encoding="utf-8") as f:
|
|
171
|
+
json.dump(self._config, f, ensure_ascii=False, cls=_EnhancedJSONEncoder)
|
|
172
|
+
return True
|
|
173
|
+
except OSError as err:
|
|
174
|
+
_LOG.error("Cannot write the config file: %s", err)
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
def load(self) -> bool:
|
|
178
|
+
"""
|
|
179
|
+
Load the configuration from file.
|
|
180
|
+
|
|
181
|
+
:return: True if the configuration could be loaded
|
|
182
|
+
"""
|
|
183
|
+
if not os.path.exists(self._cfg_file_path):
|
|
184
|
+
_LOG.info(
|
|
185
|
+
"Configuration file not found, starting with empty configuration: %s",
|
|
186
|
+
self._cfg_file_path,
|
|
187
|
+
)
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
with open(self._cfg_file_path, "r", encoding="utf-8") as f:
|
|
192
|
+
data = json.load(f)
|
|
193
|
+
|
|
194
|
+
for item in data:
|
|
195
|
+
device = self.deserialize_device(item)
|
|
196
|
+
if device:
|
|
197
|
+
self._config.append(device)
|
|
198
|
+
|
|
199
|
+
_LOG.info("Loaded %d device(s) from configuration", len(self._config))
|
|
200
|
+
return True
|
|
201
|
+
except PermissionError as err:
|
|
202
|
+
_LOG.error(
|
|
203
|
+
"Permission denied reading config file %s: %s", self._cfg_file_path, err
|
|
204
|
+
)
|
|
205
|
+
except OSError as err:
|
|
206
|
+
_LOG.error("Cannot read the config file %s: %s", self._cfg_file_path, err)
|
|
207
|
+
except json.JSONDecodeError as err:
|
|
208
|
+
_LOG.error("Invalid JSON in config file %s: %s", self._cfg_file_path, err)
|
|
209
|
+
except (AttributeError, ValueError, TypeError) as err:
|
|
210
|
+
_LOG.error("Invalid config file format in %s: %s", self._cfg_file_path, err)
|
|
211
|
+
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
def get_device_id(self, device: DeviceT) -> str:
|
|
215
|
+
"""
|
|
216
|
+
Extract device identifier from device configuration.
|
|
217
|
+
|
|
218
|
+
Default implementation: tries common attribute names (identifier, id, device_id).
|
|
219
|
+
Override this if your device config uses a different attribute name.
|
|
220
|
+
|
|
221
|
+
:param device: Device configuration
|
|
222
|
+
:return: Device identifier
|
|
223
|
+
:raises AttributeError: If no valid ID attribute is found
|
|
224
|
+
"""
|
|
225
|
+
for attr in ("identifier", "id", "device_id"):
|
|
226
|
+
if hasattr(device, attr):
|
|
227
|
+
value = getattr(device, attr)
|
|
228
|
+
if value:
|
|
229
|
+
return str(value)
|
|
230
|
+
|
|
231
|
+
raise AttributeError(
|
|
232
|
+
f"Device config {type(device).__name__} has no 'identifier', 'id', or 'device_id' attribute. "
|
|
233
|
+
f"Override get_device_id() to specify which attribute to use."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def get_backup_json(self) -> str:
|
|
237
|
+
"""
|
|
238
|
+
Get configuration as JSON string for backup.
|
|
239
|
+
|
|
240
|
+
:return: JSON string representation of configuration
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
return json.dumps(
|
|
244
|
+
self._config, ensure_ascii=False, indent=2, cls=_EnhancedJSONEncoder
|
|
245
|
+
)
|
|
246
|
+
except (TypeError, ValueError) as err:
|
|
247
|
+
_LOG.error("Failed to serialize configuration: %s", err)
|
|
248
|
+
return "[]"
|
|
249
|
+
|
|
250
|
+
def restore_from_backup_json(self, backup_json: str) -> bool:
|
|
251
|
+
"""
|
|
252
|
+
Restore configuration from JSON string.
|
|
253
|
+
|
|
254
|
+
:param backup_json: JSON string containing configuration backup
|
|
255
|
+
:return: True if restore was successful
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
data = json.loads(backup_json)
|
|
259
|
+
|
|
260
|
+
if not isinstance(data, list):
|
|
261
|
+
_LOG.error(
|
|
262
|
+
"Invalid backup format: expected list, got %s", type(data).__name__
|
|
263
|
+
)
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
# Deserialize and validate all devices first
|
|
267
|
+
new_config: list[DeviceT] = []
|
|
268
|
+
for item in data:
|
|
269
|
+
if not isinstance(item, dict):
|
|
270
|
+
_LOG.warning("Skipping invalid device entry: %s", item)
|
|
271
|
+
continue
|
|
272
|
+
|
|
273
|
+
device = self.deserialize_device(item)
|
|
274
|
+
if device:
|
|
275
|
+
new_config.append(device)
|
|
276
|
+
else:
|
|
277
|
+
_LOG.warning("Failed to deserialize device: %s", item)
|
|
278
|
+
|
|
279
|
+
if not new_config:
|
|
280
|
+
_LOG.error("No valid devices found in backup")
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
# Replace configuration and persist
|
|
284
|
+
self._config = new_config
|
|
285
|
+
if self.store():
|
|
286
|
+
_LOG.info(
|
|
287
|
+
"Successfully restored %d device(s) from backup", len(self._config)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Notify via add handler for each device
|
|
291
|
+
if self._add_handler is not None:
|
|
292
|
+
for device in self._config:
|
|
293
|
+
self._add_handler(device)
|
|
294
|
+
|
|
295
|
+
return True
|
|
296
|
+
else:
|
|
297
|
+
_LOG.error("Failed to persist restored configuration")
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
except json.JSONDecodeError as err:
|
|
301
|
+
_LOG.error("Invalid JSON in backup: %s", err)
|
|
302
|
+
return False
|
|
303
|
+
except (AttributeError, ValueError, TypeError) as err:
|
|
304
|
+
_LOG.error("Failed to restore configuration: %s", err)
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
# ========================================================================
|
|
308
|
+
# Migration Support
|
|
309
|
+
# ========================================================================
|
|
310
|
+
|
|
311
|
+
def migration_required(self) -> bool:
|
|
312
|
+
"""
|
|
313
|
+
Check if configuration migration is required.
|
|
314
|
+
|
|
315
|
+
Override this method to implement migration detection logic.
|
|
316
|
+
|
|
317
|
+
:return: True if migration is required
|
|
318
|
+
"""
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
async def migrate(self) -> bool:
|
|
322
|
+
"""
|
|
323
|
+
Migrate configuration if required.
|
|
324
|
+
|
|
325
|
+
Override this method to implement migration logic.
|
|
326
|
+
|
|
327
|
+
:return: True if migration was successful
|
|
328
|
+
"""
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
# ========================================================================
|
|
332
|
+
# Abstract Methods (Must be implemented by subclasses)
|
|
333
|
+
# ========================================================================
|
|
334
|
+
|
|
335
|
+
@abstractmethod
|
|
336
|
+
def deserialize_device(self, data: dict) -> DeviceT | None:
|
|
337
|
+
"""
|
|
338
|
+
Deserialize device configuration from dictionary.
|
|
339
|
+
|
|
340
|
+
This should handle missing fields and provide defaults for backward compatibility.
|
|
341
|
+
|
|
342
|
+
:param data: Dictionary with device data
|
|
343
|
+
:return: Device configuration or None if invalid
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
# ========================================================================
|
|
347
|
+
# Optional Override Methods
|
|
348
|
+
# ========================================================================
|
|
349
|
+
|
|
350
|
+
def update_device_fields(self, existing: DeviceT, updated: DeviceT) -> None:
|
|
351
|
+
"""
|
|
352
|
+
Update fields of existing device with values from updated device.
|
|
353
|
+
|
|
354
|
+
Default implementation updates all fields. Override for custom behavior.
|
|
355
|
+
|
|
356
|
+
:param existing: Existing device configuration (will be modified)
|
|
357
|
+
:param updated: Updated device configuration (source of new values)
|
|
358
|
+
"""
|
|
359
|
+
# Default: update all dataclass fields
|
|
360
|
+
for field in dataclasses.fields(existing):
|
|
361
|
+
setattr(existing, field.name, getattr(updated, field.name))
|