22Legacy device gateway for Gen1 Shelly devices.
33"""
44
5+ from __future__ import annotations
6+
57import logging
68import time
79from datetime import datetime
8- from typing import Any
10+ from typing import TYPE_CHECKING , Any
911
1012from ...domain .entities .device_status import DeviceStatus
1113from ...domain .entities .discovered_device import DiscoveredDevice
14+ from ...domain .entities .exceptions import DeviceAuthenticationError
1215from ...domain .enums .enums import Status
1316from ...domain .value_objects .action_result import ActionResult
17+ from ...utils .validation import normalize_mac
1418from ..network .legacy_http_client import LegacyHttpClient
1519from .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+
1725logger = 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 ,
0 commit comments