88
99import aiohttp # For making HTTP requests
1010
11- from .const import LOGGER # Integration-specific logger
12-
13- # Custom exception classes for Scrutiny API interactions.
14- # These allow for more specific error handling in the coordinator and config flow.
11+ from .const import ATTR_METADATA , LOGGER # Integration-specific logger
1512
1613
14+ # Custom exception classes for Scrutiny API interactions.
1715class ScrutinyApiError (Exception ):
1816 """Generic base exception for Scrutiny API errors."""
1917
@@ -29,51 +27,31 @@ class ScrutinyApiConnectionError(ScrutinyApiError):
2927class ScrutinyApiAuthError (ScrutinyApiError ):
3028 """Exception raised for authentication errors with the Scrutiny API."""
3129
32- # Note: The /api/summary endpoint currently does not require authentication.
33- # This is included for completeness or future Scrutiny API changes.
34-
3530
3631class ScrutinyApiResponseError (ScrutinyApiError ):
3732 """
3833 Exception raised for issues with the Scrutiny API's response.
3934
40- This includes unexpected data formats, or if the API itself indicates an error
41- (e.g., `success: false` in the response).
35+ This includes unexpected data formats, or if the API itself indicates an error.
4236 """
4337
4438
4539def _construct_api_exception_message (
4640 base_message : str , url : str | None = None , error : Exception | None = None
4741) -> str :
48- """
49- Construct a standardized and informative message for API-related exceptions.
50-
51- Args:
52- base_message: The core message for the exception.
53- url: The URL that was being accessed, if applicable.
54- error: The original exception that caused this, if applicable.
55-
56- Returns:
57- A formatted exception message string.
58-
59- """
42+ """Construct a standardized and informative message for API-related exceptions."""
6043 message = base_message
6144 if url :
6245 message += f" at { url } "
6346 if error :
64- # Use !s for safe string conversion of the original error.
6547 message += f": { error !s} "
6648 return message
6749
6850
69- # Helper functions to raise specific API errors, primarily for Ruff's TRY301 compliance.
70- # These ensure that 'raise ... from ...' statements are encapsulated.
71-
72-
7351def _raise_scrutiny_api_response_error (
7452 message : str , original_exception : Exception
7553) -> NoReturn :
76- """Construct and raise a ScrutinyApiResponseError, chaining the original excep ."""
54+ """Construct and raise a ScrutinyApiResponseError, chaining the orig exception ."""
7755 raise ScrutinyApiResponseError (message ) from original_exception
7856
7957
@@ -86,15 +64,7 @@ class ScrutinyApiClient:
8664 """Client to interact with the Scrutiny API."""
8765
8866 def __init__ (self , host : str , port : int , session : aiohttp .ClientSession ) -> None :
89- """
90- Initialize the API client.
91-
92- Args:
93- host: The hostname or IP address of the Scrutiny server.
94- port: The port number the Scrutiny server is listening on.
95- session: The aiohttp.ClientSession to use for requests (provided by HA).
96-
97- """
67+ """Initialize the API client."""
9868 self ._host = host
9969 self ._port = port
10070 self ._session = session
@@ -103,170 +73,113 @@ def __init__(self, host: str, port: int, session: aiohttp.ClientSession) -> None
10373 async def _request (
10474 self , method : str , endpoint : str , ** kwargs : Any
10575 ) -> aiohttp .ClientResponse :
106- """
107- Make a generic HTTP request to a Scrutiny API endpoint.
108-
109- Handles common request logic, timeouts, and basic error conversion.
110-
111- Args:
112- method: The HTTP method (e.g., "get", "post").
113- endpoint: The API endpoint path (e.g., "summary").
114- **kwargs: Additional keyword arguments to pass to aiohttp's request method.
115-
116- Returns:
117- The aiohttp.ClientResponse object on success.
118-
119- Raises:
120- ScrutinyApiConnectionError: If a conn. error (timeout, DNS failure) occurs.
121- ScrutinyApiAuthError: If an authentication error (401, 403) occurs.
122- ScrutinyApiResponseError: If the API returns error statuses (4xx, 5xx).
123-
124- """
76+ """Make a generic HTTP request to a Scrutiny API endpoint."""
12577 url = f"{ self ._base_url } /{ endpoint } "
12678 LOGGER .debug ("Requesting Scrutiny data: %s %s" , method , url )
12779
12880 try :
129- # Use a timeout for the request to prevent indefinite blocking.
130- async with asyncio .timeout (10 ): # 10-second timeout
81+ async with asyncio .timeout (10 ):
13182 response = await self ._session .request (
13283 method ,
13384 url ,
134- ssl = False , # Assuming Scrutiny runs on HTTP by default
85+ ssl = False ,
13586 ** kwargs ,
13687 )
137- # Raise an aiohttp.ClientResponseError for bad HTTP codes (4xx or 5xx).
13888 response .raise_for_status ()
13989 return response
14090 except TimeoutError as exc :
141- # Handle request timeout specifically.
14291 msg = _construct_api_exception_message (
14392 "Timeout connecting to Scrutiny" , url
14493 )
14594 raise ScrutinyApiConnectionError (msg ) from exc
14695 except aiohttp .ClientConnectionError as exc :
147- # Handle lower-level errors (DNS resolution failed, connection refused).
14896 msg = _construct_api_exception_message (
14997 "Connection error with Scrutiny" , url
15098 )
15199 raise ScrutinyApiConnectionError (msg ) from exc
152100 except aiohttp .ClientResponseError as exc :
153- # Handle HTTP errors reported by the server (4xx, 5xx status codes).
154101 LOGGER .error (
155102 "HTTP error from Scrutiny API at %s: %s (status: %s)" ,
156103 url ,
157104 exc .message ,
158105 exc .status ,
159106 )
160- if exc .status in (401 , 403 ): # Example for potential future authentication
107+ if exc .status in (401 , 403 ):
161108 auth_msg = _construct_api_exception_message (
162109 f"Authentication error with Scrutiny ({ exc .status } )" , error = exc
163110 )
164111 raise ScrutinyApiAuthError (auth_msg ) from exc
165112
166- # For other HTTP errors, raise a generic response error.
167113 api_err_msg = _construct_api_exception_message (
168114 f"Scrutiny API returned an error ({ exc .status } )" , error = exc
169115 )
170- _raise_scrutiny_api_response_error (api_err_msg , exc ) # Use helper
116+ _raise_scrutiny_api_response_error (api_err_msg , exc )
171117 except aiohttp .ClientError as exc :
172- # Catch other, more generic aiohttp client errors.
173118 generic_msg = _construct_api_exception_message (
174119 "A client error occurred with Scrutiny" , url
175120 )
176121 raise ScrutinyApiConnectionError (generic_msg ) from exc
177122
178123 async def async_get_summary (self ) -> dict [str , dict ]:
179- """
180- Fetch the summary data from the Scrutiny '/api/summary' endpoint.
181-
182- This endpoint provides an overview of all monitored disks.
183-
184- Returns:
185- A dictionary containing the 'summary' data, where keys are disk WWNs
186- and values are objects with 'device' and 'smart' details.
187-
188- Raises:
189- ScrutinyApiResponseError: If the API response is not valid JSON,
190- if `success` is not true, or if the expected
191- data structure ('data.summary') is missing.
192- ScrutinyApiError: For other unexpected processing errors.
193- (Inherits connection errors from _request method)
194-
195- """
196- response : aiohttp .ClientResponse | None = (
197- None # Ensure response is defined for except block
198- )
124+ """Fetch the summary data from the Scrutiny '/api/summary' endpoint."""
125+ response : aiohttp .ClientResponse | None = None
199126 try :
200127 response = await self ._request ("get" , "summary" )
201128 content_type = response .headers .get ("Content-Type" , "" )
202129
203130 if "application/json" not in content_type :
204- # If the content type is not JSON, log the issue and raise an error.
205131 text_response = await response .text ()
206132 LOGGER .error (
207- "Unexpected content type from Scrutiny API: %s. Response: %s" ,
133+ """Unexpected content type from
134+ Scrutiny API (summary): %s. Response: %s""" ,
208135 content_type ,
209- text_response [:200 ], # Log only a snippet of the response
136+ text_response [:200 ],
210137 )
211- msg = f"Expected JSON from Scrutiny, got { content_type } "
212- # This raise is in the try-block, not an except-block.
213- # Ruff TRY301 should not apply here typically, but if it does:
138+ msg = f"Expected JSON from Scrutiny summary, got { content_type } "
214139 raise ScrutinyApiResponseError (msg ) # noqa: TRY301
215140
216- # Attempt to parse the JSON response.
217141 data : dict = await response .json ()
218142
219143 except json .JSONDecodeError as exc :
220- # Handle cases where the response claims to be JSON but isn't valid.
221- raw_response_text = "Could not retrieve raw response."
144+ raw_response_text = "Could not retrieve raw response for summary."
222145 if response :
223146 try :
224147 raw_response_text = await response .text ()
225148 LOGGER .error (
226- "Failed to decode JSON from Scrutiny. Raw response: %s" ,
227- raw_response_text [:500 ], # Log a larger snippet for debugging
149+ "Failed to decode JSON from Scrutiny summary . Raw response: %s" ,
150+ raw_response_text [:500 ],
228151 )
229152 # pylint: disable=broad-except
230- except Exception : # noqa: BLE001 - Catching broad exception during error handling
153+ except Exception : # noqa: BLE001
231154 LOGGER .error (
232- "Failed to decode JSON and also fail to get raw text response."
155+ """Failed to decode JSON for summary
156+ and also failed to get raw text."""
233157 )
234158 else :
235159 LOGGER .error (
236- "Failed to decode JSON, and no response object was available."
160+ """Failed to decode JSON for summary,
161+ no response object was available."""
237162 )
238163
239- msg = "Invalid JSON response received from Scrutiny"
240- _raise_scrutiny_api_response_error (msg , exc ) # Use helper
241-
242- # Re-raise ScrutinyApiError or its subclasses if they were already caught
243- # and processed (e.g., by _request).
164+ msg = "Invalid JSON response received from Scrutiny summary"
165+ _raise_scrutiny_api_response_error (msg , exc )
244166 except ScrutinyApiError :
245167 raise
246-
247- # Catch any other unexpected exceptions during the summary processing.
248168 # pylint: disable=broad-except
249- except Exception as exc : # noqa: BLE001 - Intentional broad catch for API client robustness
169+ except Exception as exc : # noqa: BLE001
250170 LOGGER .exception (
251171 "An unexpected error occurred while processing Scrutiny summary data"
252172 )
253173 msg = "Unexpected error occurred while processing Scrutiny summary"
254- _raise_scrutiny_api_error (msg , exc ) # Use helper
255-
256- # This 'else' block executes only if the 'try' block completes without except.
174+ _raise_scrutiny_api_error (msg , exc )
257175 else :
258- LOGGER .debug (
259- "Scrutiny API response data: %s" , str (data )[:1000 ]
260- ) # Log snippet of data
176+ LOGGER .debug ("Scrutiny API summary response data: %s" , str (data )[:1000 ])
261177
262- # Validate the structure of the successful JSON response.
263178 if not isinstance (data , dict ) or not data .get ("success" ):
264179 err_msg = (
265- """Scrutiny API call not successful or
266- response format is unexpected: """
267- f"{ str (data )[:200 ]} " # Include a snippet of the problematic data
180+ "Scrutiny API summary call not successful or unexpected format: "
181+ f"{ str (data )[:200 ]} "
268182 )
269- # This raise is part of data validation, not an except block.
270183 raise ScrutinyApiResponseError (err_msg )
271184
272185 summary_data = data .get ("data" , {}).get ("summary" )
@@ -278,3 +191,79 @@ async def async_get_summary(self) -> dict[str, dict]:
278191 raise ScrutinyApiResponseError (err_msg )
279192
280193 return summary_data
194+
195+ async def async_get_device_details (
196+ self , wwn : str
197+ ) -> dict [str , Any ]: # Rückgabetyp ist jetzt das "Gesamtobjekt"
198+ """
199+ Fetch detailed information for a specific disk from Scrutiny.
200+
201+ Args:
202+ wwn: The World Wide Name of the disk.
203+
204+ Returns:
205+ A dictionary representing the entire successful JSON response,
206+ which should contain 'data' (with device, smart_results)
207+ and 'metadata' keys.
208+
209+ Raises:
210+ ScrutinyApiResponseError: If API response is not valid JSON,
211+ if `success` is not true.
212+ (Inherits connection errors from _request method)
213+
214+ """
215+ endpoint = f"device/{ wwn } /details"
216+ response : aiohttp .ClientResponse | None = None
217+ LOGGER .debug ("Requesting Scrutiny device details for WWN: %s" , wwn )
218+
219+ try :
220+ response = await self ._request ("get" , endpoint )
221+ content_type = response .headers .get ("Content-Type" , "" )
222+
223+ if "application/json" not in content_type :
224+ msg = f"Expected JSON from Scrutiny device details, got { content_type } "
225+ raise ScrutinyApiResponseError (msg ) # noqa: TRY301
226+
227+ # full_api_response_data ist jetzt die gesamte Antwort,
228+ # z.B. {"data": {...}, "metadata": {...}, "success": true}
229+ full_api_response_data : dict = await response .json ()
230+
231+ except json .JSONDecodeError as exc :
232+ msg = f"""Invalid JSON response received from
233+ Scrutiny device details (WWN: { wwn } )"""
234+ _raise_scrutiny_api_response_error (msg , exc )
235+ except ScrutinyApiError :
236+ raise
237+ except Exception as exc : # noqa: BLE001 pylint: disable=broad-except
238+ msg = f"Unexpected error processing Scrutiny device details (WWN: { wwn } )"
239+ _raise_scrutiny_api_error (msg , exc )
240+ else :
241+ LOGGER .debug (
242+ "Scrutiny API device details FULL response for WWN %s: %s" ,
243+ wwn ,
244+ str (full_api_response_data )[:2000 ],
245+ )
246+
247+ if not isinstance (
248+ full_api_response_data , dict
249+ ) or not full_api_response_data .get ("success" ):
250+ err_msg = (
251+ """Scrutiny API device details call
252+ not successful or unexpected format """
253+ f"(WWN: { wwn } ): { str (full_api_response_data )[:200 ]} "
254+ )
255+ raise ScrutinyApiResponseError (err_msg )
256+
257+ if (
258+ "data" not in full_api_response_data
259+ or ATTR_METADATA not in full_api_response_data
260+ ): # ATTR_METADATA ist "metadata"
261+ err_msg = (
262+ """Scrutiny API device details response
263+ is missing 'data' or 'metadata' key """
264+ f"(WWN: { wwn } ): Keys present: { list (full_api_response_data .keys ())} "
265+ )
266+ LOGGER .error (err_msg )
267+ raise ScrutinyApiResponseError (err_msg )
268+
269+ return full_api_response_data
0 commit comments