Skip to content

Commit 92a571c

Browse files
committed
Merge branch 'feat/gen1-authentication'
2 parents b25334e + cf7744e commit 92a571c

8 files changed

Lines changed: 519 additions & 18 deletions

File tree

packages/api/src/api/dependencies/container.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def get_credentials_use_case(self) -> ManageCredentialsUseCase:
9393

9494
def on_credential_changed(mac: str) -> None:
9595
self.get_rpc_client().invalidate_credential_cache(mac)
96+
self.get_device_gateway().invalidate_legacy_credential_cache(mac)
9697

9798
self._credentials_use_case = ManageCredentialsUseCase(
9899
repository_factory=self.create_credentials_repository,

packages/core/src/core/dependencies/container_base.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
"""Shared container base providing common gateway and interactor factories (no cast)."""
22

3-
from typing import Any
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any
6+
7+
if TYPE_CHECKING:
8+
from core.services.authentication_service import AuthenticationService
49

510
from core.gateways.device import LegacyDeviceGateway
611
from core.gateways.device.ap_device_detector import APDeviceDetector
@@ -45,6 +50,8 @@ def get_device_gateway(self) -> ShellyDeviceGateway:
4550
legacy_gateway = LegacyDeviceGateway(
4651
http_client=legacy_http_client,
4752
component_mapper=legacy_component_mapper,
53+
authentication_service=self._get_authentication_service_optional(),
54+
auth_state_cache=self.get_auth_state_cache(),
4855
)
4956

5057
self._device_gateway = ShellyDeviceGateway(
@@ -53,6 +60,13 @@ def get_device_gateway(self) -> ShellyDeviceGateway:
5360
)
5461
return self._device_gateway
5562

63+
def _get_authentication_service_optional(self) -> AuthenticationService | None:
64+
"""Return AuthenticationService if available, None otherwise."""
65+
if hasattr(self, "get_authentication_service"):
66+
service: AuthenticationService = self.get_authentication_service()
67+
return service
68+
return None
69+
5670
def get_mdns_client(self) -> MDNSGateway:
5771
if self._mdns_client is None:
5872
self._mdns_client = ZeroconfMDNSClient()

packages/core/src/core/gateways/device/legacy_device_gateway.py

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,26 @@
22
Legacy device gateway for Gen1 Shelly devices.
33
"""
44

5+
from __future__ import annotations
6+
57
import logging
68
import time
79
from datetime import datetime
8-
from typing import Any
10+
from typing import TYPE_CHECKING, Any
911

1012
from ...domain.entities.device_status import DeviceStatus
1113
from ...domain.entities.discovered_device import DiscoveredDevice
14+
from ...domain.entities.exceptions import DeviceAuthenticationError
1215
from ...domain.enums.enums import Status
1316
from ...domain.value_objects.action_result import ActionResult
17+
from ...utils.validation import normalize_mac
1418
from ..network.legacy_http_client import LegacyHttpClient
1519
from .legacy_component_mapper import LegacyComponentMapper
1620

21+
if TYPE_CHECKING:
22+
from ...services.auth_state_cache import AuthStateCache
23+
from ...services.authentication_service import AuthenticationService
24+
1725
logger = logging.getLogger(__name__)
1826

1927

@@ -24,9 +32,55 @@ def __init__(
2432
self,
2533
http_client: LegacyHttpClient,
2634
component_mapper: LegacyComponentMapper,
35+
authentication_service: AuthenticationService | None = None,
36+
auth_state_cache: AuthStateCache | None = None,
2737
) -> None:
2838
self._http_client = http_client
2939
self._component_mapper = component_mapper
40+
self._authentication_service = authentication_service
41+
self._auth_state_cache = auth_state_cache
42+
self._ip_to_mac: dict[str, str] = {}
43+
self._basic_auth_cache: dict[str, tuple[str, str]] = {}
44+
45+
async def _ensure_mac(self, ip: str) -> str | None:
46+
"""Get MAC address for an IP, fetching from /shelly if not cached."""
47+
normalized_ip = normalize_mac(ip)
48+
if normalized_ip in self._ip_to_mac:
49+
return self._ip_to_mac[normalized_ip]
50+
try:
51+
shelly_data = await self._http_client.fetch_json(ip, "shelly")
52+
mac = shelly_data.get("mac")
53+
if mac:
54+
normalized_mac = normalize_mac(mac)
55+
self._ip_to_mac[normalized_ip] = normalized_mac
56+
return normalized_mac
57+
except Exception:
58+
pass
59+
return None
60+
61+
async def _resolve_auth(self, ip: str) -> tuple[str, str] | None:
62+
"""Resolve Basic Auth credentials for a device by IP."""
63+
if not self._authentication_service:
64+
return None
65+
66+
mac = await self._ensure_mac(ip)
67+
if not mac:
68+
return None
69+
70+
if mac in self._basic_auth_cache:
71+
return self._basic_auth_cache[mac]
72+
73+
credential = await self._authentication_service.resolve_credentials(mac)
74+
if credential:
75+
auth_tuple = (credential.username, credential.password)
76+
self._basic_auth_cache[mac] = auth_tuple
77+
return auth_tuple
78+
return None
79+
80+
def invalidate_credential_cache(self, mac: str) -> None:
81+
"""Clear cached credentials for a device."""
82+
normalized_mac = normalize_mac(mac)
83+
self._basic_auth_cache.pop(normalized_mac, None)
3084

3185
async def discover_device(self, ip: str) -> DiscoveredDevice | None:
3286
"""Discover a legacy Gen1 Shelly device.
@@ -42,8 +96,23 @@ async def discover_device(self, ip: str) -> DiscoveredDevice | None:
4296
device_info = await self._http_client.fetch_json(ip, "shelly")
4397
response_time = time.perf_counter() - start_time
4498

45-
status_data = await self._http_client.fetch_json_optional(ip, "status")
46-
settings_data = await self._http_client.fetch_json_optional(ip, "settings")
99+
# Detect auth requirement from /shelly response
100+
auth_enabled = device_info.get("auth", False)
101+
mac = device_info.get("mac")
102+
auth: tuple[str, str] | None = None
103+
104+
if auth_enabled and mac and self._auth_state_cache:
105+
normalized_mac = normalize_mac(mac)
106+
self._ip_to_mac[normalize_mac(ip)] = normalized_mac
107+
self._auth_state_cache.mark_auth_required(normalized_mac)
108+
auth = await self._resolve_auth(ip)
109+
110+
status_data = await self._http_client.fetch_json_optional(
111+
ip, "status", auth=auth
112+
)
113+
settings_data = await self._http_client.fetch_json_optional(
114+
ip, "settings", auth=auth
115+
)
47116

48117
device_name = self._derive_device_name(device_info, settings_data)
49118
firmware_version = (
@@ -74,6 +143,7 @@ async def discover_device(self, ip: str) -> DiscoveredDevice | None:
74143
response_time=response_time,
75144
last_seen=datetime.now(),
76145
has_update=has_update_value,
146+
auth_required=auth_enabled,
77147
)
78148
except Exception as e:
79149
logger.debug(
@@ -95,8 +165,21 @@ async def get_device_status(self, ip: str) -> DeviceStatus | None:
95165
"""
96166
try:
97167
device_info = await self._http_client.fetch_json(ip, "shelly")
98-
status_data = await self._http_client.fetch_json(ip, "status")
99-
settings_data = await self._http_client.fetch_json_optional(ip, "settings")
168+
169+
auth: tuple[str, str] | None = None
170+
if device_info.get("auth", False):
171+
mac = device_info.get("mac")
172+
if mac:
173+
normalized_mac = normalize_mac(mac)
174+
self._ip_to_mac[normalize_mac(ip)] = normalized_mac
175+
auth = await self._resolve_auth(ip)
176+
177+
status_data = await self._http_client.fetch_json(ip, "status", auth=auth)
178+
settings_data = await self._http_client.fetch_json_optional(
179+
ip, "settings", auth=auth
180+
)
181+
except DeviceAuthenticationError:
182+
raise
100183
except Exception as e:
101184
logger.debug(
102185
"Failed to fetch legacy data for %s: %s",
@@ -174,8 +257,11 @@ async def execute_action(
174257
)
175258

176259
try:
260+
auth = (
261+
await self._resolve_auth(ip) if self._authentication_service else None
262+
)
177263
response = await self._http_client.get_with_params(
178-
ip, command["endpoint"], command["params"]
264+
ip, command["endpoint"], command["params"], auth=auth
179265
)
180266
return ActionResult(
181267
device_ip=ip,

packages/core/src/core/gateways/device/shelly_device_gateway.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ def __init__(
3939
self.timeout = timeout
4040
self._legacy_gateway = legacy_gateway
4141

42+
def invalidate_legacy_credential_cache(self, mac: str) -> None:
43+
if self._legacy_gateway:
44+
self._legacy_gateway.invalidate_credential_cache(mac)
45+
4246
async def discover_device(self, ip: str) -> DiscoveredDevice | None:
4347
"""
4448
Discover basic device information (original get_device_status logic).

packages/core/src/core/gateways/network/legacy_http_client.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import requests
55

6+
from core.domain.entities.exceptions import DeviceAuthenticationError
7+
68

79
class LegacyHttpClient:
810
"""HTTP client for legacy Gen1 Shelly devices using simple HTTP GET requests."""
@@ -13,28 +15,42 @@ def __init__(
1315
self.timeout = timeout
1416
self._session = session or requests.Session()
1517

16-
async def fetch_json(self, ip: str, endpoint: str) -> dict[str, Any]:
18+
async def fetch_json(
19+
self, ip: str, endpoint: str, auth: tuple[str, str] | None = None
20+
) -> dict[str, Any]:
1721
loop = asyncio.get_event_loop()
18-
return await loop.run_in_executor(None, self._sync_fetch_json, ip, endpoint)
22+
return await loop.run_in_executor(
23+
None, self._sync_fetch_json, ip, endpoint, auth
24+
)
1925

20-
async def fetch_json_optional(self, ip: str, endpoint: str) -> dict[str, Any]:
26+
async def fetch_json_optional(
27+
self, ip: str, endpoint: str, auth: tuple[str, str] | None = None
28+
) -> dict[str, Any]:
2129
try:
22-
return await self.fetch_json(ip, endpoint)
30+
return await self.fetch_json(ip, endpoint, auth=auth)
2331
except Exception:
2432
return {}
2533

2634
async def get_with_params(
27-
self, ip: str, endpoint: str, params: dict[str, Any]
35+
self,
36+
ip: str,
37+
endpoint: str,
38+
params: dict[str, Any],
39+
auth: tuple[str, str] | None = None,
2840
) -> dict[str, Any]:
2941
loop = asyncio.get_event_loop()
3042

3143
return await loop.run_in_executor(
32-
None, self._sync_get_with_params, ip, endpoint, params
44+
None, self._sync_get_with_params, ip, endpoint, params, auth
3345
)
3446

35-
def _sync_fetch_json(self, ip: str, endpoint: str) -> dict[str, Any]:
47+
def _sync_fetch_json(
48+
self, ip: str, endpoint: str, auth: tuple[str, str] | None = None
49+
) -> dict[str, Any]:
3650
url = f"http://{ip}/{endpoint.lstrip('/')}"
37-
response = self._session.get(url, timeout=self.timeout)
51+
response = self._session.get(url, timeout=self.timeout, auth=auth)
52+
if response.status_code == 401:
53+
raise DeviceAuthenticationError(ip)
3854
response.raise_for_status()
3955
data = response.json()
4056

@@ -44,10 +60,18 @@ def _sync_fetch_json(self, ip: str, endpoint: str) -> dict[str, Any]:
4460
return cast(dict[str, Any], data)
4561

4662
def _sync_get_with_params(
47-
self, ip: str, endpoint: str, params: dict[str, Any]
63+
self,
64+
ip: str,
65+
endpoint: str,
66+
params: dict[str, Any],
67+
auth: tuple[str, str] | None = None,
4868
) -> dict[str, Any]:
4969
url = f"http://{ip}/{endpoint.lstrip('/')}"
50-
response = self._session.get(url, params=params, timeout=self.timeout)
70+
response = self._session.get(
71+
url, params=params, timeout=self.timeout, auth=auth
72+
)
73+
if response.status_code == 401:
74+
raise DeviceAuthenticationError(ip)
5175
response.raise_for_status()
5276

5377
try:

packages/core/tests/unit/gateways/device/test_legacy_device_gateway.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ async def test_it_executes_legacy_action_successfully(
140140
assert result.success is True
141141
assert result.data == {"ison": True}
142142
mock_http_client.get_with_params.assert_called_once_with(
143-
"192.168.1.100", "relay/0", {"turn": "on"}
143+
"192.168.1.100", "relay/0", {"turn": "on"}, auth=None
144144
)
145145

146146
async def test_it_handles_unsupported_legacy_action(self, gateway):

0 commit comments

Comments
 (0)