Skip to content

Commit 3286ce6

Browse files
committed
SMART Attribute added
1 parent 90c1d89 commit 3286ce6

4 files changed

Lines changed: 719 additions & 409 deletions

File tree

custom_components/scrutiny/api.py

Lines changed: 108 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@
88

99
import 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.
1715
class ScrutinyApiError(Exception):
1816
"""Generic base exception for Scrutiny API errors."""
1917

@@ -29,51 +27,31 @@ class ScrutinyApiConnectionError(ScrutinyApiError):
2927
class 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

3631
class 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

4539
def _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-
7351
def _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

Comments
 (0)