Skip to content

Commit 53a27f6

Browse files
committed
refactor: Give capture the same per-generation adapter as restore
export_bulk_config keeps the export envelope and the per-device loop; how one generation's component configs are captured (Gen2 GetConfig/GetCode/Schedule.List vs the Gen1 mapped-config copy plus raw /settings fetch) moves unchanged into ComponentCaptureStrategy implementations under use_cases/capture_strategies. LEGACY_SETTINGS_KEY moves to the device_backup entity: it names part of the snapshot format, which capture and restore now both consume from the same home.
1 parent 64ec337 commit 53a27f6

8 files changed

Lines changed: 177 additions & 119 deletions

File tree

packages/core/src/core/domain/entities/device_backup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55

66
from core.utils.validation import normalize_mac
77

8+
# The raw Gen1 /settings entry captured alongside the mapped components in a
9+
# snapshot. It is the data source a Gen1 restore replays, never a restore
10+
# target itself.
11+
LEGACY_SETTINGS_KEY = "legacy_settings"
12+
813

914
@dataclass
1015
class DeviceBackup:

packages/core/src/core/use_cases/bulk_operations.py

Lines changed: 13 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
from ..domain.value_objects.action_result import ActionResult
88
from ..domain.value_objects.generation import Generation
99
from ..gateways.device import DeviceGateway
10+
from .capture_strategies import ComponentCaptureStrategy
11+
from .capture_strategies.gen1 import Gen1CaptureStrategy
12+
from .capture_strategies.gen2 import Gen2CaptureStrategy
1013

1114
logger = logging.getLogger(__name__)
1215

@@ -18,6 +21,8 @@ def __init__(
1821
device_gateway: DeviceGateway,
1922
):
2023
self._device_gateway = device_gateway
24+
self._gen1_capture = Gen1CaptureStrategy(device_gateway)
25+
self._gen2_capture = Gen2CaptureStrategy(device_gateway)
2126

2227
async def execute_bulk_update(
2328
self, device_ips: list[str], channel: str = "stable"
@@ -136,6 +141,7 @@ async def export_bulk_config(
136141
if not device_status:
137142
continue
138143

144+
strategy = self._capture_strategy(device_status)
139145
device_data: dict[str, Any] = {
140146
"device_info": {
141147
"device_name": device_status.device_name,
@@ -144,120 +150,19 @@ async def export_bulk_config(
144150
"mac_address": device_status.mac_address,
145151
"app_name": device_status.app_name,
146152
},
147-
"components": {},
153+
"components": await strategy.capture_components(
154+
device_ip, device_status, component_types
155+
),
148156
}
149157

150-
# Gen1 has no /rpc: GetConfig and Schedule.List 404, so capture from
151-
# the configs already mapped onto the DeviceStatus instead.
152-
if Generation.from_device_gen(device_status.gen) is Generation.GEN1:
153-
await self._export_gen1_config(
154-
device_ip, device_status, component_types, device_data
155-
)
156-
result["devices"][device_ip] = device_data
157-
continue
158-
159-
for component in device_status.components:
160-
if component.component_type in component_types:
161-
162-
config_result = await self._device_gateway.execute_component_action(
163-
device_ip, component.key, "GetConfig", {}
164-
)
165-
166-
component_export = {
167-
"type": component.component_type,
168-
"success": config_result.success,
169-
"config": config_result.data if config_result.success else None,
170-
"error": (
171-
config_result.error if not config_result.success else None
172-
),
173-
}
174-
175-
if component.component_type == "script" and config_result.success:
176-
code_data = await self._fetch_script_code(
177-
device_ip, component.key
178-
)
179-
if code_data is not None:
180-
component_export["code"] = code_data
181-
182-
device_data["components"][component.key] = component_export
183-
184-
if "schedules" in component_types:
185-
schedules = await self._fetch_schedules(device_ip)
186-
device_data["components"].update(schedules)
187-
188158
result["devices"][device_ip] = device_data
189159

190160
return result
191161

192-
async def _export_gen1_config(
193-
self,
194-
device_ip: str,
195-
device_status: DeviceStatus,
196-
component_types: list[str],
197-
device_data: dict[str, Any],
198-
) -> None:
199-
"""Capture Gen1 component configs plus the raw ``/settings``.
200-
201-
The ``legacy_settings`` entry is the source of truth a Gen1 restore
202-
replays; it is omitted when the raw fetch fails, and the mapped configs
203-
alone still make the backup valid.
204-
"""
205-
for component in device_status.components:
206-
if component.component_type in component_types:
207-
device_data["components"][component.key] = {
208-
"type": component.component_type,
209-
"success": True,
210-
"config": component.config,
211-
"error": None,
212-
}
213-
214-
legacy_settings = await self._device_gateway.get_legacy_settings(device_ip)
215-
if legacy_settings is not None:
216-
device_data["components"]["legacy_settings"] = {
217-
"type": "legacy_settings",
218-
"success": True,
219-
"config": legacy_settings,
220-
"error": None,
221-
}
222-
223-
async def _fetch_script_code(
224-
self, device_ip: str, component_key: str
225-
) -> dict[str, Any] | None:
226-
try:
227-
script_id = int(component_key.split(":")[1])
228-
code_result = await self._device_gateway.execute_component_action(
229-
device_ip, component_key, "GetCode", {"id": script_id}
230-
)
231-
if code_result.success and code_result.data:
232-
return code_result.data
233-
except (ValueError, IndexError, AttributeError):
234-
pass
235-
236-
return None
237-
238-
async def _fetch_schedules(self, device_ip: str) -> dict[str, Any]:
239-
schedule_export = {}
240-
241-
list_result = await self._device_gateway.execute_component_action(
242-
device_ip, "schedule", "List", {}
243-
)
244-
schedule_data = list_result.data
245-
if list_result.success and schedule_data:
246-
schedule_export["schedules"] = {
247-
"type": "schedule",
248-
"success": True,
249-
"config": schedule_data,
250-
"error": None,
251-
}
252-
elif not list_result.success:
253-
schedule_export["schedules"] = {
254-
"type": "schedule",
255-
"success": False,
256-
"config": None,
257-
"error": list_result.error,
258-
}
259-
260-
return schedule_export
162+
def _capture_strategy(self, status: DeviceStatus) -> ComponentCaptureStrategy:
163+
if Generation.from_device_gen(status.gen) is Generation.GEN1:
164+
return self._gen1_capture
165+
return self._gen2_capture
261166

262167
async def apply_bulk_config(
263168
self,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Per-generation capture strategies.
2+
3+
The capture mirror of :mod:`core.use_cases.restore_strategies`:
4+
``BulkOperationsUseCase.export_bulk_config`` owns the export envelope and the
5+
per-device loop; how one generation's component configs are captured lives
6+
behind ``ComponentCaptureStrategy``.
7+
"""
8+
9+
from typing import Any, Protocol
10+
11+
from core.domain.entities.device_status import DeviceStatus
12+
13+
14+
class ComponentCaptureStrategy(Protocol):
15+
"""One device generation's side of a config capture."""
16+
17+
async def capture_components(
18+
self, device_ip: str, status: DeviceStatus, component_types: list[str]
19+
) -> dict[str, Any]:
20+
"""Captured component entries keyed by component key, in the snapshot
21+
shape (``{"type", "success", "config", "error"}`` plus per-type
22+
extras)."""
23+
...
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Capture strategy for Gen1 (legacy HTTP) devices."""
2+
3+
from typing import Any
4+
5+
from core.domain.entities.device_backup import LEGACY_SETTINGS_KEY
6+
from core.domain.entities.device_status import DeviceStatus
7+
from core.gateways.device import DeviceGateway
8+
9+
10+
class Gen1CaptureStrategy:
11+
"""Capture Gen1 component configs plus the raw ``/settings``.
12+
13+
Gen1 has no /rpc: GetConfig and Schedule.List 404, so the mapped configs
14+
already on the ``DeviceStatus`` are captured instead. The
15+
``legacy_settings`` entry is the source of truth a Gen1 restore replays;
16+
it is omitted when the raw fetch fails, and the mapped configs alone still
17+
make the backup valid.
18+
"""
19+
20+
def __init__(self, device_gateway: DeviceGateway):
21+
self._device_gateway = device_gateway
22+
23+
async def capture_components(
24+
self, device_ip: str, status: DeviceStatus, component_types: list[str]
25+
) -> dict[str, Any]:
26+
components: dict[str, Any] = {}
27+
28+
for component in status.components:
29+
if component.component_type in component_types:
30+
components[component.key] = {
31+
"type": component.component_type,
32+
"success": True,
33+
"config": component.config,
34+
"error": None,
35+
}
36+
37+
legacy_settings = await self._device_gateway.get_legacy_settings(device_ip)
38+
if legacy_settings is not None:
39+
components[LEGACY_SETTINGS_KEY] = {
40+
"type": LEGACY_SETTINGS_KEY,
41+
"success": True,
42+
"config": legacy_settings,
43+
"error": None,
44+
}
45+
46+
return components
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Capture strategy for Gen2+ (RPC) devices."""
2+
3+
from typing import Any
4+
5+
from core.domain.entities.device_status import DeviceStatus
6+
from core.gateways.device import DeviceGateway
7+
8+
9+
class Gen2CaptureStrategy:
10+
"""Capture component configs over the Gen2+ RPC surface."""
11+
12+
def __init__(self, device_gateway: DeviceGateway):
13+
self._device_gateway = device_gateway
14+
15+
async def capture_components(
16+
self, device_ip: str, status: DeviceStatus, component_types: list[str]
17+
) -> dict[str, Any]:
18+
components: dict[str, Any] = {}
19+
20+
for component in status.components:
21+
if component.component_type in component_types:
22+
23+
config_result = await self._device_gateway.execute_component_action(
24+
device_ip, component.key, "GetConfig", {}
25+
)
26+
27+
component_export = {
28+
"type": component.component_type,
29+
"success": config_result.success,
30+
"config": config_result.data if config_result.success else None,
31+
"error": (
32+
config_result.error if not config_result.success else None
33+
),
34+
}
35+
36+
if component.component_type == "script" and config_result.success:
37+
code_data = await self._fetch_script_code(device_ip, component.key)
38+
if code_data is not None:
39+
component_export["code"] = code_data
40+
41+
components[component.key] = component_export
42+
43+
if "schedules" in component_types:
44+
schedules = await self._fetch_schedules(device_ip)
45+
components.update(schedules)
46+
47+
return components
48+
49+
async def _fetch_script_code(
50+
self, device_ip: str, component_key: str
51+
) -> dict[str, Any] | None:
52+
try:
53+
script_id = int(component_key.split(":")[1])
54+
code_result = await self._device_gateway.execute_component_action(
55+
device_ip, component_key, "GetCode", {"id": script_id}
56+
)
57+
if code_result.success and code_result.data:
58+
return code_result.data
59+
except (ValueError, IndexError, AttributeError):
60+
pass
61+
62+
return None
63+
64+
async def _fetch_schedules(self, device_ip: str) -> dict[str, Any]:
65+
schedule_export = {}
66+
67+
list_result = await self._device_gateway.execute_component_action(
68+
device_ip, "schedule", "List", {}
69+
)
70+
schedule_data = list_result.data
71+
if list_result.success and schedule_data:
72+
schedule_export["schedules"] = {
73+
"type": "schedule",
74+
"success": True,
75+
"config": schedule_data,
76+
"error": None,
77+
}
78+
elif not list_result.success:
79+
schedule_export["schedules"] = {
80+
"type": "schedule",
81+
"success": False,
82+
"config": None,
83+
"error": list_result.error,
84+
}
85+
86+
return schedule_export

packages/core/src/core/use_cases/restore_device_config.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from contextlib import AbstractAsyncContextManager
66
from typing import Any
77

8-
from core.domain.entities.device_backup import DeviceBackup
8+
from core.domain.entities.device_backup import LEGACY_SETTINGS_KEY, DeviceBackup
99
from core.domain.entities.exceptions import DeviceNotFoundError
1010
from core.domain.value_objects.generation import Generation
1111
from core.domain.value_objects.restore_result import (
@@ -15,10 +15,7 @@
1515
from core.gateways.device import DeviceGateway
1616
from core.repositories.backup_repository import BackupRepository
1717
from core.use_cases.backup_device_config import BackupNotFoundError
18-
from core.use_cases.restore_strategies import (
19-
LEGACY_SETTINGS_KEY,
20-
ComponentRestoreStrategy,
21-
)
18+
from core.use_cases.restore_strategies import ComponentRestoreStrategy
2219
from core.use_cases.restore_strategies.gen1 import Gen1RestoreStrategy
2320
from core.use_cases.restore_strategies.gen2 import Gen2RestoreStrategy
2421
from core.utils.validation import normalize_mac

packages/core/src/core/use_cases/restore_strategies/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@
1212
from core.domain.entities.device_status import DeviceStatus
1313
from core.domain.value_objects.restore_result import ComponentRestoreResult
1414

15-
# The raw Gen1 /settings entry captured alongside the mapped components. It is the
16-
# data source a Gen1 restore replays, never a restore target itself.
17-
LEGACY_SETTINGS_KEY = "legacy_settings"
18-
1915

2016
@dataclass
2117
class PrepareOutcome:

packages/core/src/core/use_cases/restore_strategies/gen1.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@
99
import asyncio
1010
from typing import Any
1111

12-
from core.domain.entities.device_backup import DeviceBackup
12+
from core.domain.entities.device_backup import LEGACY_SETTINGS_KEY, DeviceBackup
1313
from core.domain.entities.device_status import DeviceStatus
1414
from core.domain.services.gen1_settings_translation import (
1515
restorable_params,
1616
wifi_subresources,
1717
)
1818
from core.domain.value_objects.restore_result import ComponentRestoreResult
1919
from core.gateways.device import DeviceGateway
20-
from core.use_cases.restore_strategies import LEGACY_SETTINGS_KEY, PrepareOutcome
20+
from core.use_cases.restore_strategies import PrepareOutcome
2121

2222

2323
class Gen1RestoreStrategy:

0 commit comments

Comments
 (0)