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.
@@ -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))