From 219dbba9780efd8d1f7e4654d9769ab0eddbb0da Mon Sep 17 00:00:00 2001 From: Johan Zander Date: Wed, 3 Jun 2026 16:54:39 +0200 Subject: [PATCH 1/9] Add async support using httpx with coroutine-passthrough pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces requests with httpx and adds async counterparts for all API classes (AsyncGrowattApi, AsyncOpenApiV1, AsyncMin, AsyncSph). Uses a shared base class pattern where regular def methods return self._request(...) — producing a coroutine in async context — so ~60 methods are shared with zero duplication. Only methods that chain async calls need explicit async overrides (6 total). Closes #149 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + docs/README.md | 23 +- docs/architecture.md | 143 +++ docs/openapiv1.md | 43 +- docs/shinephone.md | 17 +- examples/async_min_example.py | 61 ++ examples/min_example.py | 4 +- examples/min_example_dashboard.py | 4 +- examples/sph_example.py | 4 +- growattServer/__init__.py | 4 + growattServer/async_base_api.py | 216 ++++ growattServer/base_api.py | 928 ++++++++---------- growattServer/exceptions.py | 18 +- growattServer/open_api_v1/__init__.py | 231 +++-- .../open_api_v1/async_open_api_v1.py | 45 + growattServer/open_api_v1/devices/__init__.py | 2 + .../open_api_v1/devices/abstract_device.py | 3 +- .../open_api_v1/devices/async_min.py | 61 ++ .../open_api_v1/devices/async_sph.py | 110 +++ growattServer/open_api_v1/devices/min.py | 95 +- growattServer/open_api_v1/devices/sph.py | 92 +- setup.py | 2 +- 22 files changed, 1361 insertions(+), 748 deletions(-) create mode 100644 docs/architecture.md create mode 100644 examples/async_min_example.py create mode 100644 growattServer/async_base_api.py create mode 100644 growattServer/open_api_v1/async_open_api_v1.py create mode 100644 growattServer/open_api_v1/devices/async_min.py create mode 100644 growattServer/open_api_v1/devices/async_sph.py diff --git a/.gitignore b/.gitignore index 21010f5..6b49697 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,8 @@ share/python-wheels/ *.egg MANIFEST +# Virtual environments +.venv/ + # Symlink examples/growattServer diff --git a/docs/README.md b/docs/README.md index b3e378f..793fd12 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,9 +16,28 @@ Please refer to the docs for [ShinePhone/legacy](./shinephone.md) for it's usage This follows Growatt's OpenAPI V1. Please refer to the docs for [OpenAPI V1](./openapiv1.md) for it's usage and available methods. -## Note +### Breaking Change: `requests` -> `httpx` -This is based on the endpoints used on the mobile app and could be changed without notice. +This version replaces the `requests` library with `httpx` for both sync and async HTTP. +If your code catches `requests.exceptions.RequestException`, update it to catch +`httpx.HTTPError` instead. See the [exceptions module](../growattServer/exceptions.py) +for details on exception handling. + +### Sync/Async Support + +The library supports both synchronous and asynchronous usage. Every API class has an async +counterpart — use whichever fits your application: + +| Sync | Async | +|:-----|:------| +| `GrowattApi` | `AsyncGrowattApi` | +| `OpenApiV1` | `AsyncOpenApiV1` | + +The async classes accept an optional `session` parameter (an `httpx.AsyncClient`) so you can +share a session across your application (useful in frameworks like Home Assistant). + +For details on how the sync/async class hierarchy works internally, see +[Architecture: Sync/Async Design](./architecture.md). ## Examples diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..11ee8d9 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,143 @@ +# Architecture: Sync/Async Design + +The Growatt server has two separate APIs: + +1. **Legacy API** (ShinePhone mobile app endpoints) — `plant_list`, `inverter_data`, `mix_info`, etc. +2. **V1 API** (OpenAPI) — `plant/list`, `plant/details`, `device/min/detail`, etc. + +Different base URLs, different auth, different response formats. That's why there are two layers. + +## Layer 1: Legacy API + +``` +_GrowattApiBase — 45 methods that define WHAT to call + e.g. plant_list() returns self._request("GET", .../PlantListAPI.do) + No HTTP code here. Just URLs, params, and extract logic. + +GrowattApi — HOW to make the HTTP call (sync) + _request() uses httpx.Client + 3 overrides: login, device_list, update_plant_settings + +AsyncGrowattApi — HOW to make the HTTP call (async) + _request() uses await httpx.AsyncClient + Same 3 overrides, with await +``` + +The 3 overrides exist in both `GrowattApi` and `AsyncGrowattApi` because those methods need to +do multiple HTTP calls in sequence — they can't use the simple `_request` helper. + +## Layer 2: V1 API + +``` +_OpenApiV1Base — 27 methods that define WHAT to call on the V1 API + e.g. plant_list() returns self.v1_request("GET", "plant/list") + Also overrides get_url() to point at the V1 base URL. + +OpenApiV1 — inherits from BOTH _OpenApiV1Base AND GrowattApi + v1_request() uses httpx.Client (sync) + +AsyncOpenApiV1 — inherits from BOTH _OpenApiV1Base AND AsyncGrowattApi + v1_request() uses await httpx.AsyncClient (async) +``` + +So `OpenApiV1` gets the 43 legacy methods from `GrowattApi` *plus* the 27 V1 methods +from `_OpenApiV1Base`. Same for the async variant. + +## Device Classes + +``` +AbstractDevice — Base class with device_sn and validation helpers + +Min / Sph — Device-specific methods (detail, energy, settings, etc.) + Use self.api.v1_request() which returns a coroutine + in async context (passthrough pattern) + +AsyncMin / AsyncSph — Only override methods that chain async calls: + AsyncMin: read_time_segments + AsyncSph: read_ac_charge_times, read_ac_discharge_times +``` + +## How It Works: Coroutine Passthrough + +The key insight is that a regular `def` method can return a coroutine +without awaiting it. The caller (user code) is responsible for awaiting. + +```python +# In the base class (regular def, NOT async def): +class _OpenApiV1Base: + def plant_details(self, plant_id): + return self.v1_request("GET", "plant/details", ...) + +# Sync subclass: +class OpenApiV1(_OpenApiV1Base, GrowattApi): + def v1_request(self, ...): # returns dict + response = self.session.request(...) + return self.process_response(response.json(), ...) + +# Async subclass: +class AsyncOpenApiV1(_OpenApiV1Base, AsyncGrowattApi): + async def v1_request(self, ...): # returns coroutine + response = await self.session.request(...) + return self.process_response(response.json(), ...) +``` + +When user code calls `api.plant_details(123)`: +- **Sync**: `v1_request()` executes, returns `dict` -> `plant_details()` returns `dict` +- **Async**: `v1_request()` is `async def`, calling it returns a `coroutine` + -> `plant_details()` returns that `coroutine` -> user does `await api.plant_details(123)` + +This eliminates the need to duplicate every method as both `def` and `async def`. + +## When Passthrough Doesn't Work + +Methods that **chain** async calls cannot use passthrough because they +need to process an intermediate result: + +```python +# This CANNOT be shared -- self.detail() returns a coroutine in async context, +# and you can't call _parse_ac_charge_settings() on a coroutine. +def read_ac_charge_times(self, settings_data=None): + if settings_data is None: + settings_data = self.detail() # coroutine in async! + return self._parse_ac_charge_settings(settings_data) +``` + +These methods need explicit `async def` overrides: + +```python +class AsyncSph(Sph): + async def read_ac_charge_times(self, settings_data=None): + if settings_data is None: + settings_data = await self.detail() # await the coroutine + return self._parse_ac_charge_settings(settings_data) +``` + +### Methods Requiring Async Overrides + +| Layer | Class | Method | Reason | +|:------|:------|:-------|:-------| +| Base API | `AsyncGrowattApi` | `login` | Post-processes response dict | +| Base API | `AsyncGrowattApi` | `device_list` | Chains `plant_info()` then `_get_all_devices()` | +| Base API | `AsyncGrowattApi` | `update_plant_settings` | Conditionally calls `plant_settings()` | +| Device | `AsyncMin` | `read_time_segments` | Conditionally calls `self.settings()` | +| Device | `AsyncSph` | `read_ac_charge_times` | Conditionally calls `self.detail()` | +| Device | `AsyncSph` | `read_ac_discharge_times` | Conditionally calls `self.detail()` | + +All other methods (~60) are shared via base classes with zero duplication. + +## Usage + +```python +# Sync +from growattServer import OpenApiV1 + +api = OpenApiV1(token="...") +plants = api.plant_list() + +# Async +from growattServer import AsyncOpenApiV1 + +async def main(): + async with AsyncOpenApiV1(token="...", session=my_httpx_client) as api: + plants = await api.plant_list() +``` diff --git a/docs/openapiv1.md b/docs/openapiv1.md index 866302d..4c7aa46 100644 --- a/docs/openapiv1.md +++ b/docs/openapiv1.md @@ -6,17 +6,39 @@ It extends our ["Legacy" ShinePhone](./shinephone.md) so methods from [there](./ ## Usage -The public v1 API requires token-based authentication +The public v1 API requires token-based authentication. + +### Sync ```python import growattServer api = growattServer.OpenApiV1(token="YOUR_API_TOKEN") -# Get a list of growatt plants. -plants = api.plant_list_v1() +plants = api.plant_list() print(plants) ``` +### Async + +```python +import asyncio +import growattServer + +async def main(): + async with growattServer.AsyncOpenApiV1(token="YOUR_API_TOKEN") as api: + plants = await api.plant_list() + print(plants) + +asyncio.run(main()) +``` + +You can also pass an existing `httpx.AsyncClient` session: + +```python +async with growattServer.AsyncOpenApiV1(token="YOUR_API_TOKEN", session=my_httpx_client) as api: + plants = await api.plant_list() +``` + ## Methods and Variables ### Methods @@ -38,6 +60,7 @@ Methods that work across all device types. Devices offer a generic way to interact with your device using the V1 API without needing to provide your S/N every time. And can be used instead of the more specific device methods in the API class. ```python +# Sync import growattServer from growattServer.open_api_v1.devices import Sph, Min @@ -46,9 +69,17 @@ api = growattServer.OpenApiV1(token="YOUR_API_TOKEN") my_inverter = Sph(api, 'YOUR_DEVICE_SERIAL_NUMBER') # or Min(api, 'YOUR_DEVICE_SERIAL_NUMBER') my_inverter.detail() my_inverter.energy() -my_inverter.energy_history() -my_inverter.read_parameter() -my_inverter.write_parameter() +``` + +```python +# Async +import growattServer +from growattServer.open_api_v1.devices import AsyncSph, AsyncMin + +async with growattServer.AsyncOpenApiV1(token="YOUR_API_TOKEN") as api: + my_inverter = AsyncSph(api, 'YOUR_DEVICE_SERIAL_NUMBER') # or AsyncMin(...) + await my_inverter.detail() + await my_inverter.energy() ``` | Method | Arguments | Description | diff --git a/docs/shinephone.md b/docs/shinephone.md index d2084f4..713a523 100644 --- a/docs/shinephone.md +++ b/docs/shinephone.md @@ -7,7 +7,7 @@ At the time of writing this "Legacy API" is still the most used method. ## Getting started -Using username/password basic authentication +### Sync ```python import growattServer @@ -18,6 +18,21 @@ login_response = api.login(, ) print(api.plant_list(login_response['user']['id'])) ``` +### Async + +```python +import asyncio +import growattServer + +async def main(): + async with growattServer.AsyncGrowattApi() as api: + login_response = await api.login(, ) + # Get a list of growatt plants. + print(await api.plant_list(login_response['user']['id'])) + +asyncio.run(main()) +``` + ## Methods and Variables ### Methods diff --git a/examples/async_min_example.py b/examples/async_min_example.py new file mode 100644 index 0000000..b491a9c --- /dev/null +++ b/examples/async_min_example.py @@ -0,0 +1,61 @@ +""" +Async example script for MIN/TLX devices using the OpenAPI V1. + +This is the async equivalent of min_example.py. All API calls use await +and the client is used as an async context manager. + +You can obtain an API token from the Growatt API documentation or developer portal. +""" + +import asyncio +import json + +import httpx + +import growattServer + +# test token from official API docs https://www.showdoc.com.cn/262556420217021/1494053950115877 +api_token = "6eb6f069523055a339d71e5b1f6c88cc" # gitleaks:allow + + +async def main(): + try: + async with growattServer.AsyncOpenApiV1(token=api_token) as api: + # Plant info + plants = await api.plant_list() + print(f"Plants: Found {plants['count']} plants") + plant_id = plants["plants"][0]["plant_id"] + + # Devices + devices = await api.device_list(plant_id) + + for device in devices["devices"]: + if device["type"] == 7: # (MIN/TLX) + inverter_sn = device["device_sn"] + print(f"Processing inverter: {inverter_sn}") + + # Get device details + inverter_data = await api.min_detail(inverter_sn) + print(json.dumps(inverter_data, indent=4, sort_keys=True)) + + # Get energy data + energy_data = await api.min_energy(device_sn=inverter_sn) + print(json.dumps(energy_data, indent=4, sort_keys=True)) + + # Get settings + settings_data = await api.min_settings(device_sn=inverter_sn) + print(json.dumps(settings_data, indent=4, sort_keys=True)) + + # Read time segments + tou = await api.min_read_time_segments(inverter_sn, settings_data) + print(json.dumps(tou, indent=4)) + + except growattServer.GrowattV1ApiError as e: + print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") + except growattServer.GrowattParameterError as e: + print(f"Parameter Error: {e}") + except httpx.HTTPError as e: + print(f"Network Error: {e}") + + +asyncio.run(main()) diff --git a/examples/min_example.py b/examples/min_example.py index b1fee17..04d4c9d 100644 --- a/examples/min_example.py +++ b/examples/min_example.py @@ -1,6 +1,6 @@ import json -import requests +import httpx import growattServer @@ -88,7 +88,7 @@ print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") except growattServer.GrowattParameterError as e: print(f"Parameter Error: {e}") -except requests.exceptions.RequestException as e: +except httpx.HTTPError as e: print(f"Network Error: {e}") except Exception as e: print(f"Unexpected error: {e}") diff --git a/examples/min_example_dashboard.py b/examples/min_example_dashboard.py index 8f479ff..b7da7e1 100644 --- a/examples/min_example_dashboard.py +++ b/examples/min_example_dashboard.py @@ -1,6 +1,6 @@ import json -import requests +import httpx import growattServer @@ -91,7 +91,7 @@ print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") except growattServer.GrowattParameterError as e: print(f"Parameter Error: {e}") -except requests.exceptions.RequestException as e: +except httpx.HTTPError as e: print(f"Network Error: {e}") except Exception as e: print(f"Unexpected error: {e}") diff --git a/examples/sph_example.py b/examples/sph_example.py index 9f110a0..f467554 100644 --- a/examples/sph_example.py +++ b/examples/sph_example.py @@ -9,7 +9,7 @@ import json import os -import requests +import httpx import growattServer @@ -146,7 +146,7 @@ print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") except growattServer.GrowattParameterError as e: print(f"Parameter Error: {e}") -except requests.exceptions.RequestException as e: +except httpx.HTTPError as e: print(f"Network Error: {e}") except Exception as e: # noqa: BLE001 print(f"Unexpected error: {e}") diff --git a/growattServer/__init__.py b/growattServer/__init__.py index e6914e8..0b4c6e6 100755 --- a/growattServer/__init__.py +++ b/growattServer/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations +from .async_base_api import AsyncGrowattApi from .base_api import GrowattApi, Timespan, hash_password from .exceptions import ( GrowattError, @@ -11,11 +12,14 @@ GrowattV1ApiErrorCode, ) from .open_api_v1 import DeviceType, OpenApiV1 +from .open_api_v1.async_open_api_v1 import AsyncOpenApiV1 # Package name name = "growattServer" __all__ = [ + "AsyncGrowattApi", + "AsyncOpenApiV1", "DeviceType", "GrowattApi", "GrowattError", diff --git a/growattServer/async_base_api.py b/growattServer/async_base_api.py new file mode 100644 index 0000000..73cb243 --- /dev/null +++ b/growattServer/async_base_api.py @@ -0,0 +1,216 @@ +"""Async Growatt API client.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from typing import Self + +import httpx + +from .base_api import _GrowattApiBase, hash_password + + +async def _async_raise_for_status(response): + response.raise_for_status() + + +class AsyncGrowattApi(_GrowattApiBase): + """ + Async client for Growatt API endpoints. + + All methods inherited from ``_GrowattApiBase`` work transparently in async + context because they return ``self._request(...)`` or chain to another + base method, both of which yield coroutines when ``_request`` is async. + """ + + def __init__(self, add_random_user_id: bool = False, agent_identifier: str | None = None, session: httpx.AsyncClient | None = None) -> None: + """ + Initialize the Growatt API client. + + Args: + add_random_user_id: Append a short random suffix to the user-agent. + agent_identifier: Optional override for the user-agent string. + session: Optional httpx.AsyncClient to reuse. + + """ + super().__init__(add_random_user_id, agent_identifier) + + if session is not None: + self.session = session + self._owns_session = False + else: + self.session = httpx.AsyncClient( + headers={"User-Agent": self.agent_identifier}, + follow_redirects=True, + timeout=None, # noqa: S113 + event_hooks={"response": [_async_raise_for_status]}, + ) + self._owns_session = True + + async def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Any = None, text: bool = False) -> Any: + """Make an async HTTP request and return the JSON response (or text if text=True).""" + kwargs = {} + if params is not None: + kwargs["params"] = params + if data is not None: + kwargs["data"] = data + if follow_redirects is not None: + kwargs["follow_redirects"] = follow_redirects + response = await self.session.request(method, url, **kwargs) + result = response.text if text else response.json() + return extract(result) if extract is not None else result + + async def aclose(self) -> None: + """Close the underlying HTTP session if we own it.""" + if self._owns_session: + await self.session.aclose() + + async def __aenter__(self) -> Self: + """Enter the async context manager.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Exit the async context manager.""" + await self.aclose() + + # Methods that need direct session access or chain async calls + + async def login(self, username: str, password: str, is_password_hashed: bool = False) -> dict[str, Any]: + """ + Log the user in. + + Returns + ------- + 'data' -- A List containing Objects containing the folowing + 'plantName' -- Friendly name of the plant + 'plantId' -- The ID of the plant + 'service' + 'quality' + 'isOpenSmartFamily' + 'totalData' -- An Object + 'success' -- True or False + 'msg' + 'app_code' + 'user' -- An Object containing a lot of user information + 'uid' + 'userLanguage' + 'inverterGroup' -- A List + 'timeZone' -- A Number + 'lat' + 'lng' + 'dataAcqList' -- A List + 'type' + 'accountName' -- The username + 'password' -- The password hash of the user + 'isValiPhone' + 'kind' + 'mailNotice' -- True or False + 'id' + 'lasLoginIp' + 'lastLoginTime' + 'userDeviceType' + 'phoneNum' + 'approved' -- True or False + 'area' -- Continent of the user + 'smsNotice' -- True or False + 'isAgent' + 'token' + 'nickName' + 'parentUserId' + 'customerCode' + 'country' + 'isPhoneNumReg' + 'createDate' + 'rightlevel' + 'appType' + 'serverUrl' + 'roleId' + 'enabled' -- True or False + 'agentCode' + 'inverterList' -- A list + 'email' + 'company' + 'activeName' + 'codeIndex' + 'appAlias' + 'isBigCustomer' + 'noticeType' + + """ + if not is_password_hashed: + password = hash_password(password) + + response = await self.session.post(self.get_url("newTwoLoginAPI.do"), data={ + "userName": username, + "password": password + }) + + data = response.json()["back"] + if data["success"]: + data.update({ + "userId": data["user"]["id"], + "userLevel": data["user"]["rightlevel"] + }) + return data + + async def device_list(self, plant_id: str) -> list[dict[str, Any]]: + """Get a list of all devices connected to plant.""" + device_list = (await self.plant_info(plant_id)).get("deviceList", []) + + if not device_list: + # for tlx systems, the device_list in plant is empty, so use _get_all_devices() instead + device_list = await self._get_all_devices(plant_id) + + return device_list + + async def update_plant_settings(self, plant_id: str, changed_settings: dict[str, Any], current_settings: dict[str, Any] | None = None) -> dict[str, Any]: + """ + Update plant settings. + + Args: + plant_id: Plant identifier. + changed_settings: Dict of settings to change. + current_settings: Current settings dict or None. + + Returns: + dict: Server response indicating success or failure. + + """ + # If no existing settings have been provided then get them from the growatt server + if current_settings is None: + current_settings = await self.plant_settings(plant_id) + + # These are the parameters that the form requires, without these an error is thrown. Pre-populate their values with the current values + form_settings = { + "plantCoal": (None, str(current_settings["formulaCoal"])), + "plantSo2": (None, str(current_settings["formulaSo2"])), + "accountName": (None, str(current_settings["userAccount"])), + "plantID": (None, str(current_settings["id"])), + # Hardcoded to 0 as I can't work out what value it should have + "plantFirm": (None, "0"), + "plantCountry": (None, str(current_settings["country"])), + "plantType": (None, str(current_settings["plantType"])), + "plantIncome": (None, str(current_settings["formulaMoneyStr"])), + "plantAddress": (None, str(current_settings["plantAddress"])), + "plantTimezone": (None, str(current_settings["timezone"])), + "plantLng": (None, str(current_settings["plant_lng"])), + "plantCity": (None, str(current_settings["city"])), + "plantCo2": (None, str(current_settings["formulaCo2"])), + "plantMoney": (None, str(current_settings["formulaMoneyUnitId"])), + "plantPower": (None, str(current_settings["nominalPower"])), + "plantLat": (None, str(current_settings["plant_lat"])), + "plantDate": (None, str(current_settings["createDateText"])), + "plantName": (None, str(current_settings["plantName"])), + } + + # Overwrite the current value of the setting with the new value + for setting, value in changed_settings.items(): + form_settings[setting] = (None, str(value)) + + response = await self.session.post(self.get_url( + "newTwoPlantAPI.do?op=updatePlant"), files=form_settings) + + return response.json() + diff --git a/growattServer/base_api.py b/growattServer/base_api.py index fba830a..efe9996 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -11,9 +11,12 @@ import secrets import warnings from enum import IntEnum -from typing import Any +from typing import TYPE_CHECKING, Any -import requests +if TYPE_CHECKING: + from typing import Self + +import httpx from .exceptions import GrowattError @@ -46,42 +49,33 @@ class Timespan(IntEnum): month = 2 -class GrowattApi: - """Base client for Growatt API endpoints.""" +def _raise_for_status(response): + response.raise_for_status() + + +class _GrowattApiBase: + """ + Shared base for sync and async Growatt API clients. + + Contains non-HTTP logic (URL building, date formatting, user-agent setup) + and all shared API methods. Methods are defined as regular ``def`` (not + ``async def``) and work transparently with both sync and async subclasses + because they return ``self._request(...)`` — which produces a coroutine + in async context that the caller can ``await``. + """ server_url = "https://openapi.growatt.com/" agent_identifier = "Dalvik/2.1.0 (Linux; U; Android 12; https://github.com/indykoning/PyPi_GrowattServer)" def __init__(self, add_random_user_id: bool = False, agent_identifier: str | None = None) -> None: - """ - Initialize the Growatt API client. - - Args: - add_random_user_id: Append a short random suffix to the user-agent. - agent_identifier: Optional override for the user-agent string. - - """ if agent_identifier is not None: self.agent_identifier = agent_identifier - # If a random user id is required, generate a 5 digit number and add it to the user agent if add_random_user_id: random_number = "".join(str(secrets.randbelow(10)) for _ in range(5)) self.agent_identifier += " - " + random_number - self.session = requests.Session() - - def _raise_for_status(response, *args: object, **kwargs: object) -> None: - _ = args - _ = kwargs - response.raise_for_status() - - self.session.hooks = {"response": [_raise_for_status]} - - headers = {"User-Agent": self.agent_identifier} - self.session.headers.update(headers) - - def __get_date_string(self, timespan: Timespan | None = None, date: datetime.datetime | None = None) -> str: + def _get_date_string(self, timespan: Timespan | None = None, date: datetime.datetime | None = None) -> str: if timespan is not None and not isinstance(timespan, Timespan): raise ValueError("timespan must be a Timespan enum value") @@ -100,84 +94,6 @@ def get_url(self, page: str) -> str: """Return the page URL.""" return self.server_url + page - def login(self, username: str, password: str, is_password_hashed: bool = False) -> dict[str, Any]: - """ - Log the user in. - - Returns - ------- - 'data' -- A List containing Objects containing the folowing - 'plantName' -- Friendly name of the plant - 'plantId' -- The ID of the plant - 'service' - 'quality' - 'isOpenSmartFamily' - 'totalData' -- An Object - 'success' -- True or False - 'msg' - 'app_code' - 'user' -- An Object containing a lot of user information - 'uid' - 'userLanguage' - 'inverterGroup' -- A List - 'timeZone' -- A Number - 'lat' - 'lng' - 'dataAcqList' -- A List - 'type' - 'accountName' -- The username - 'password' -- The password hash of the user - 'isValiPhone' - 'kind' - 'mailNotice' -- True or False - 'id' - 'lasLoginIp' - 'lastLoginTime' - 'userDeviceType' - 'phoneNum' - 'approved' -- True or False - 'area' -- Continent of the user - 'smsNotice' -- True or False - 'isAgent' - 'token' - 'nickName' - 'parentUserId' - 'customerCode' - 'country' - 'isPhoneNumReg' - 'createDate' - 'rightlevel' - 'appType' - 'serverUrl' - 'roleId' - 'enabled' -- True or False - 'agentCode' - 'inverterList' -- A list - 'email' - 'company' - 'activeName' - 'codeIndex' - 'appAlias' - 'isBigCustomer' - 'noticeType' - - """ - if not is_password_hashed: - password = hash_password(password) - - response = self.session.post(self.get_url("newTwoLoginAPI.do"), data={ - "userName": username, - "password": password - }) - - data = response.json()["back"] - if data["success"]: - data.update({ - "userId": data["user"]["id"], - "userLevel": data["user"]["rightlevel"] - }) - return data - def plant_list(self, user_id: str) -> list[dict[str, Any]]: """ Get a list of plants connected to this account. @@ -192,14 +108,13 @@ def plant_list(self, user_id: str) -> list[dict[str, Any]]: Exception: If the request to the server fails. """ - response = self.session.get( - self.get_url("PlantListAPI.do"), + return self._request( + "GET", self.get_url("PlantListAPI.do"), params={"userId": user_id}, - allow_redirects=False + follow_redirects=False, + extract=lambda r: r.get("back", []), ) - return response.json().get("back", []) - def plant_detail(self, plant_id: str, timespan: Timespan, date: datetime.datetime | None = None) -> dict[str, Any]: """ Get plant details for specified timespan. @@ -216,15 +131,13 @@ def plant_detail(self, plant_id: str, timespan: Timespan, date: datetime.datetim Exception: If the request to the server fails. """ - date_str = self.__get_date_string(timespan, date) - - response = self.session.get(self.get_url("PlantDetailAPI.do"), params={ - "plantId": plant_id, - "type": timespan.value, - "date": date_str - }) + date_str = self._get_date_string(timespan, date) - return response.json().get("back", {}) + return self._request( + "GET", self.get_url("PlantDetailAPI.do"), + params={"plantId": plant_id, "type": timespan.value, "date": date_str}, + extract=lambda r: r.get("back", {}), + ) def plant_list_two(self) -> list[dict[str, Any]]: """ @@ -234,8 +147,8 @@ def plant_list_two(self) -> list[dict[str, Any]]: list: A list of plants with detailed information. """ - response = self.session.post( - self.get_url("newTwoPlantAPI.do"), + return self._request( + "POST", self.get_url("newTwoPlantAPI.do"), params={"op": "getAllPlantListTwo"}, data={ "language": "1", @@ -244,12 +157,11 @@ def plant_list_two(self) -> list[dict[str, Any]]: "pageSize": "15", "plantName": "", "plantStatus": "", - "toPageNum": "1" - } + "toPageNum": "1", + }, + extract=lambda r: r.get("PlantList", []), ) - return response.json().get("PlantList", []) - def inverter_data(self, inverter_id: str, date: datetime.datetime | None = None) -> dict[str, Any]: """ Get inverter data for specified date or today. @@ -265,16 +177,11 @@ def inverter_data(self, inverter_id: str, date: datetime.datetime | None = None) Exception: If the request to the server fails. """ - date_str = self.__get_date_string(date=date) - params: dict[str, str | int] = { - "op": "getInverterData", - "id": inverter_id, - "type": 1, - "date": date_str, - } - response = self.session.get(self.get_url("newInverterAPI.do"), params=params) - - return response.json() + date_str = self._get_date_string(date=date) + return self._request( + "GET", self.get_url("newInverterAPI.do"), + params={"op": "getInverterData", "id": inverter_id, "type": 1, "date": date_str}, + ) def inverter_detail(self, inverter_id: str) -> dict[str, Any]: """ @@ -290,12 +197,10 @@ def inverter_detail(self, inverter_id: str) -> dict[str, Any]: Exception: If the request to the server fails. """ - response = self.session.get(self.get_url("newInverterAPI.do"), params={ - "op": "getInverterDetailData", - "inverterId": inverter_id - }) - - return response.json() + return self._request( + "GET", self.get_url("newInverterAPI.do"), + params={"op": "getInverterDetailData", "inverterId": inverter_id}, + ) def inverter_detail_two(self, inverter_id: str) -> dict[str, Any]: """ @@ -311,12 +216,10 @@ def inverter_detail_two(self, inverter_id: str) -> dict[str, Any]: Exception: If the request to the server fails. """ - response = self.session.get(self.get_url("newInverterAPI.do"), params={ - "op": "getInverterDetailData_two", - "inverterId": inverter_id - }) - - return response.json() + return self._request( + "GET", self.get_url("newInverterAPI.do"), + params={"op": "getInverterDetailData_two", "inverterId": inverter_id}, + ) def tlx_system_status(self, plant_id: str, tlx_id: str) -> dict[str, Any]: """ @@ -333,15 +236,13 @@ def tlx_system_status(self, plant_id: str, tlx_id: str) -> dict[str, Any]: Exception: If the request to the server fails. """ - response = self.session.post( - self.get_url("newTlxApi.do"), + return self._request( + "POST", self.get_url("newTlxApi.do"), params={"op": "getSystemStatus_KW"}, - data={"plantId": plant_id, - "id": tlx_id} + data={"plantId": plant_id, "id": tlx_id}, + extract=lambda r: r.get("obj", {}), ) - return response.json().get("obj", {}) - def tlx_energy_overview(self, plant_id: str, tlx_id: str) -> dict[str, Any]: """ Get energy overview. @@ -357,15 +258,13 @@ def tlx_energy_overview(self, plant_id: str, tlx_id: str) -> dict[str, Any]: Exception: If the request to the server fails. """ - response = self.session.post( - self.get_url("newTlxApi.do"), + return self._request( + "POST", self.get_url("newTlxApi.do"), params={"op": "getEnergyOverview"}, - data={"plantId": plant_id, - "id": tlx_id} + data={"plantId": plant_id, "id": tlx_id}, + extract=lambda r: r.get("obj", {}), ) - return response.json().get("obj", {}) - def tlx_energy_prod_cons(self, plant_id: str, tlx_id: str, timespan: Timespan = Timespan.hour, date: datetime.datetime | None = None) -> dict[str, Any]: """ Get energy production and consumption (kW). @@ -383,20 +282,16 @@ def tlx_energy_prod_cons(self, plant_id: str, tlx_id: str, timespan: Timespan = Exception: If the request to the server fails. """ - date_str = self.__get_date_string(timespan, date) + date_str = self._get_date_string(timespan, date) - response = self.session.post( - self.get_url("newTlxApi.do"), + return self._request( + "POST", self.get_url("newTlxApi.do"), params={"op": "getEnergyProdAndCons_KW"}, - data={"date": date_str, - "plantId": plant_id, - "language": "1", - "id": tlx_id, - "type": timespan.value} + data={"date": date_str, "plantId": plant_id, "language": "1", + "id": tlx_id, "type": timespan.value}, + extract=lambda r: r.get("obj", {}), ) - return response.json().get("obj", {}) - def tlx_data(self, tlx_id: str, date: datetime.datetime | None = None) -> dict[str, Any]: """ Get TLX inverter data for specified date or today. @@ -412,16 +307,11 @@ def tlx_data(self, tlx_id: str, date: datetime.datetime | None = None) -> dict[s Exception: If the request to the server fails. """ - date_str = self.__get_date_string(date=date) - params: dict[str, str | int] = { - "op": "getTlxData", - "id": tlx_id, - "type": 1, - "date": date_str, - } - response = self.session.get(self.get_url("newTlxApi.do"), params=params) - - return response.json() + date_str = self._get_date_string(date=date) + return self._request( + "GET", self.get_url("newTlxApi.do"), + params={"op": "getTlxData", "id": tlx_id, "type": 1, "date": date_str}, + ) def tlx_detail(self, tlx_id: str) -> dict[str, Any]: """ @@ -437,12 +327,10 @@ def tlx_detail(self, tlx_id: str) -> dict[str, Any]: Exception: If the request to the server fails. """ - response = self.session.get(self.get_url("newTlxApi.do"), params={ - "op": "getTlxDetailData", - "id": tlx_id - }) - - return response.json() + return self._request( + "GET", self.get_url("newTlxApi.do"), + params={"op": "getTlxDetailData", "id": tlx_id}, + ) def tlx_params(self, tlx_id: str) -> dict[str, Any]: """ @@ -458,12 +346,10 @@ def tlx_params(self, tlx_id: str) -> dict[str, Any]: Exception: If the request to the server fails. """ - response = self.session.get(self.get_url("newTlxApi.do"), params={ - "op": "getTlxParams", - "id": tlx_id - }) - - return response.json() + return self._request( + "GET", self.get_url("newTlxApi.do"), + params={"op": "getTlxParams", "id": tlx_id}, + ) def tlx_all_settings(self, tlx_id: str) -> dict[str, Any] | None: """ @@ -479,13 +365,12 @@ def tlx_all_settings(self, tlx_id: str) -> dict[str, Any] | None: Exception: If the request to the server fails. """ - response = self.session.post(self.get_url("newTlxApi.do"), params={ - "op": "getTlxSetData" - }, data={ - "serialNum": tlx_id - }) - - return response.json().get("obj", {}).get("tlxSetBean") + return self._request( + "POST", self.get_url("newTlxApi.do"), + params={"op": "getTlxSetData"}, + data={"serialNum": tlx_id}, + extract=lambda r: r.get("obj", {}).get("tlxSetBean"), + ) def tlx_enabled_settings(self, tlx_id: str) -> dict[str, Any]: """ @@ -502,14 +387,13 @@ def tlx_enabled_settings(self, tlx_id: str) -> dict[str, Any]: """ string_time = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d") - response = self.session.post( - self.get_url("newLoginAPI.do"), + return self._request( + "POST", self.get_url("newLoginAPI.do"), params={"op": "getSetPass"}, - data={"deviceSn": tlx_id, "stringTime": string_time, "type": "5"} + data={"deviceSn": tlx_id, "stringTime": string_time, "type": "5"}, + extract=lambda r: r.get("obj", {}), ) - return response.json().get("obj", {}) - def tlx_battery_info(self, serial_num: str) -> dict[str, Any]: """ Get battery information. @@ -524,14 +408,13 @@ def tlx_battery_info(self, serial_num: str) -> dict[str, Any]: Exception: If the request to the server fails. """ - response = self.session.post( - self.get_url("newTlxApi.do"), + return self._request( + "POST", self.get_url("newTlxApi.do"), params={"op": "getBatInfo"}, - data={"lan": 1, "serialNum": serial_num} + data={"lan": 1, "serialNum": serial_num}, + extract=lambda r: r.get("obj", {}), ) - return response.json().get("obj", {}) - def tlx_battery_info_detailed(self, plant_id: str, serial_num: str) -> dict[str, Any]: """ Get detailed battery information. @@ -547,14 +430,12 @@ def tlx_battery_info_detailed(self, plant_id: str, serial_num: str) -> dict[str, Exception: If the request to the server fails. """ - response = self.session.post( - self.get_url("newTlxApi.do"), + return self._request( + "POST", self.get_url("newTlxApi.do"), params={"op": "getBatDetailData"}, - data={"lan": 1, "plantId": plant_id, "id": serial_num} + data={"lan": 1, "plantId": plant_id, "id": serial_num}, ) - return response.json() - def mix_info(self, mix_id: str, plant_id: str | None = None) -> dict[str, Any]: """ Get high-level values from a Mix device. @@ -596,10 +477,11 @@ def mix_info(self, mix_id: str, plant_id: str | None = None) -> dict[str, Any]: if (plant_id): request_params["plantId"] = plant_id - response = self.session.get(self.get_url( - "newMixApi.do"), params=request_params) - - return response.json().get("obj", {}) + return self._request( + "GET", self.get_url("newMixApi.do"), + params=request_params, + extract=lambda r: r.get("obj", {}), + ) def mix_totals(self, mix_id: str, plant_id: str) -> dict[str, Any]: """ @@ -626,13 +508,11 @@ def mix_totals(self, mix_id: str, plant_id: str) -> dict[str, Any]: 'unit' -- Unit of currency for 'Revenue' """ - response = self.session.post(self.get_url("newMixApi.do"), params={ - "op": "getEnergyOverview", - "mixId": mix_id, - "plantId": plant_id - }) - - return response.json().get("obj", {}) + return self._request( + "POST", self.get_url("newMixApi.do"), + params={"op": "getEnergyOverview", "mixId": mix_id, "plantId": plant_id}, + extract=lambda r: r.get("obj", {}), + ) def mix_system_status(self, mix_id: str, plant_id: str) -> dict[str, Any]: """ @@ -670,13 +550,11 @@ def mix_system_status(self, mix_id: str, plant_id: str) -> dict[str, Any]: 'wBatteryType' -- ??? 1 """ - response = self.session.post(self.get_url("newMixApi.do"), params={ - "op": "getSystemStatus_KW", - "mixId": mix_id, - "plantId": plant_id - }) - - return response.json().get("obj", {}) + return self._request( + "POST", self.get_url("newMixApi.do"), + params={"op": "getSystemStatus_KW", "mixId": mix_id, "plantId": plant_id}, + extract=lambda r: r.get("obj", {}), + ) def mix_detail(self, mix_id: str, plant_id: str, timespan: Timespan = Timespan.hour, date: datetime.datetime | None = None) -> dict[str, Any]: """ @@ -729,10 +607,10 @@ def mix_detail(self, mix_id: str, plant_id: str, timespan: Timespan = Timespan.h epvToday (from mix_info) - eAcCharge - eChargeToday """ - date_str = self.__get_date_string(timespan, date) + date_str = self._get_date_string(timespan, date) - response = self.session.post( - self.get_url("newMixApi.do"), + return self._request( + "POST", self.get_url("newMixApi.do"), params={ "op": "getEnergyProdAndCons_KW", "plantId": plant_id, @@ -740,10 +618,9 @@ def mix_detail(self, mix_id: str, plant_id: str, timespan: Timespan = Timespan.h "type": timespan.value, "date": date_str, }, + extract=lambda r: r.get("obj", {}), ) - return response.json().get("obj", {}) - def get_mix_inverter_settings(self, serial_number: str) -> dict[str, Any]: """ Get the inverter settings related to battery modes. @@ -755,13 +632,10 @@ def get_mix_inverter_settings(self, serial_number: str) -> dict[str, Any]: dict: A dictionary of settings. """ - default_params: dict[str, str | int] = { - "op": "getMixSetParams", - "serialNum": serial_number, - "kind": 0, - } - response = self.session.get(self.get_url("newMixApi.do"), params=default_params) - return response.json() + return self._request( + "GET", self.get_url("newMixApi.do"), + params={"op": "getMixSetParams", "serialNum": serial_number, "kind": 0}, + ) def dashboard_data(self, plant_id: str, timespan: Timespan = Timespan.hour, date: datetime.datetime | None = None) -> dict[str, Any]: """ @@ -809,15 +683,17 @@ def dashboard_data(self, plant_id: str, timespan: Timespan = Timespan.hour, date NOTE: Does not return any data for a tlx system. Use plant_energy_data() instead. """ - date_str = self.__get_date_string(timespan, date) + date_str = self._get_date_string(timespan, date) - response = self.session.post(self.get_url("newPlantAPI.do"), params={ - "action": "getEnergyStorageData", - "date": date_str, - "type": timespan.value, - "plantId": plant_id, - }) - return response.json() + return self._request( + "POST", self.get_url("newPlantAPI.do"), + params={ + "action": "getEnergyStorageData", + "date": date_str, + "type": timespan.value, + "plantId": plant_id, + }, + ) def plant_settings(self, plant_id: str) -> dict[str, Any]: """ @@ -830,39 +706,32 @@ def plant_settings(self, plant_id: str) -> dict[str, Any]: dict: A python dictionary containing the settings for the specified plant. """ - response = self.session.get(self.get_url("newPlantAPI.do"), params={ - "op": "getPlant", - "plantId": plant_id - }) - - return response.json() + return self._request( + "GET", self.get_url("newPlantAPI.do"), + params={"op": "getPlant", "plantId": plant_id}, + ) def storage_detail(self, storage_id: str) -> dict[str, Any]: """Get "All parameters" from battery storage.""" - response = self.session.get(self.get_url("newStorageAPI.do"), params={ - "op": "getStorageInfo_sacolar", - "storageId": storage_id - }) - - return response.json() + return self._request( + "GET", self.get_url("newStorageAPI.do"), + params={"op": "getStorageInfo_sacolar", "storageId": storage_id}, + ) def storage_params(self, storage_id: str) -> dict[str, Any]: """Get much more detail from battery storage.""" - response = self.session.get(self.get_url("newStorageAPI.do"), params={ - "op": "getStorageParams_sacolar", - "storageId": storage_id - }) - - return response.json() + return self._request( + "GET", self.get_url("newStorageAPI.do"), + params={"op": "getStorageParams_sacolar", "storageId": storage_id}, + ) def storage_energy_overview(self, plant_id: str, storage_id: str) -> dict[str, Any]: """Get some energy/generation overview data.""" - response = self.session.post(self.get_url("newStorageAPI.do?op=getEnergyOverviewData_sacolar"), params={ - "plantId": plant_id, - "storageSn": storage_id - }) - - return response.json().get("obj", {}) + return self._request( + "POST", self.get_url("newStorageAPI.do?op=getEnergyOverviewData_sacolar"), + params={"plantId": plant_id, "storageSn": storage_id}, + extract=lambda r: r.get("obj", {}), + ) def inverter_list(self, plant_id: str) -> list[dict[str, Any]]: """Use device_list, it's more descriptive since the list contains more than inverters.""" @@ -870,48 +739,28 @@ def inverter_list(self, plant_id: str) -> list[dict[str, Any]]: "This function may be deprecated in the future because naming is not correct, use device_list instead", DeprecationWarning, stacklevel=2) return self.device_list(plant_id) - def __get_all_devices(self, plant_id: str) -> dict[str, Any]: + def _get_all_devices(self, plant_id: str) -> dict[str, Any]: """Get basic plant information with device list.""" - params: dict[str, str | int] = { - "op": "getAllDeviceList", - "plantId": plant_id, - "language": 1, - } - response = self.session.get(self.get_url("newTwoPlantAPI.do"), params=params) - - return response.json().get("deviceList", {}) - - def device_list(self, plant_id: str) -> list[dict[str, Any]]: - """Get a list of all devices connected to plant.""" - device_list = self.plant_info(plant_id).get("deviceList", []) - - if not device_list: - # for tlx systems, the device_list in plant is empty, so use __get_all_devices() instead - device_list = self.__get_all_devices(plant_id) - - return device_list + return self._request( + "GET", self.get_url("newTwoPlantAPI.do"), + params={"op": "getAllDeviceList", "plantId": plant_id, "language": 1}, + extract=lambda r: r.get("deviceList", {}), + ) def plant_info(self, plant_id: str) -> dict[str, Any]: """Get basic plant information with device list.""" - params: dict[str, str | int] = { - "op": "getAllDeviceListTwo", - "plantId": plant_id, - "pageNum": 1, - "pageSize": 1, - } - response = self.session.get(self.get_url("newTwoPlantAPI.do"), params=params) - - return response.json() + return self._request( + "GET", self.get_url("newTwoPlantAPI.do"), + params={"op": "getAllDeviceListTwo", "plantId": plant_id, "pageNum": 1, "pageSize": 1}, + ) def plant_energy_data(self, plant_id: str) -> dict[str, Any]: """Get the energy data used in the 'Plant' tab in the phone.""" - response = self.session.post(self.get_url("newTwoPlantAPI.do"), - params={ - "op": "getUserCenterEnertyDataByPlantid"}, - data={"language": 1, - "plantId": plant_id}) - - return response.json() + return self._request( + "POST", self.get_url("newTwoPlantAPI.do"), + params={"op": "getUserCenterEnertyDataByPlantid"}, + data={"language": 1, "plantId": plant_id}, + ) def is_plant_noah_system(self, plant_id: str) -> dict[str, Any]: """ @@ -932,10 +781,10 @@ def is_plant_noah_system(self, plant_id: str) -> dict[str, Any]: 'plantName' -- Friendly name of the plant """ - response = self.session.post(self.get_url("noahDeviceApi/noah/isPlantNoahSystem"), data={ - "plantId": plant_id - }) - return response.json() + return self._request( + "POST", self.get_url("noahDeviceApi/noah/isPlantNoahSystem"), + data={"plantId": plant_id}, + ) def noah_system_status(self, serial_number: str) -> dict[str, Any]: """ @@ -963,14 +812,14 @@ def noah_system_status(self, serial_number: str) -> dict[str, Any]: 'ppv' -- Solar generation in watt e.g. '200Watt' 'alias' -- Friendly name of the noah device 'profitTotal' -- Total generated profit through noah device - 'moneyUnit' -- Unit of currency e.g. '€' + 'moneyUnit' -- Unit of currency e.g. '\u20ac' 'status' -- Is the noah device online (True or False) """ - response = self.session.post(self.get_url("noahDeviceApi/noah/getSystemStatus"), data={ - "deviceSn": serial_number - }) - return response.json() + return self._request( + "POST", self.get_url("noahDeviceApi/noah/getSystemStatus"), + data={"deviceSn": serial_number}, + ) def noah_info(self, serial_number: str) -> dict[str, Any]: """ @@ -1014,59 +863,10 @@ def noah_info(self, serial_number: str) -> dict[str, Any]: 'plantName' -- Friendly name of the plant """ - response = self.session.post(self.get_url("noahDeviceApi/noah/getNoahInfoBySn"), data={ - "deviceSn": serial_number - }) - return response.json() - - def update_plant_settings(self, plant_id: str, changed_settings: dict[str, Any], current_settings: dict[str, Any] | None = None) -> dict[str, Any]: - """ - Update plant settings. - - Args: - plant_id: Plant identifier. - changed_settings: Dict of settings to change. - current_settings: Current settings dict or None. - - Returns: - dict: Server response indicating success or failure. - - """ - # If no existing settings have been provided then get them from the growatt server - if current_settings is None: - current_settings = self.plant_settings(plant_id) - - # These are the parameters that the form requires, without these an error is thrown. Pre-populate their values with the current values - form_settings = { - "plantCoal": (None, str(current_settings["formulaCoal"])), - "plantSo2": (None, str(current_settings["formulaSo2"])), - "accountName": (None, str(current_settings["userAccount"])), - "plantID": (None, str(current_settings["id"])), - # Hardcoded to 0 as I can't work out what value it should have - "plantFirm": (None, "0"), - "plantCountry": (None, str(current_settings["country"])), - "plantType": (None, str(current_settings["plantType"])), - "plantIncome": (None, str(current_settings["formulaMoneyStr"])), - "plantAddress": (None, str(current_settings["plantAddress"])), - "plantTimezone": (None, str(current_settings["timezone"])), - "plantLng": (None, str(current_settings["plant_lng"])), - "plantCity": (None, str(current_settings["city"])), - "plantCo2": (None, str(current_settings["formulaCo2"])), - "plantMoney": (None, str(current_settings["formulaMoneyUnitId"])), - "plantPower": (None, str(current_settings["nominalPower"])), - "plantLat": (None, str(current_settings["plant_lat"])), - "plantDate": (None, str(current_settings["createDateText"])), - "plantName": (None, str(current_settings["plantName"])), - } - - # Overwrite the current value of the setting with the new value - for setting, value in changed_settings.items(): - form_settings[setting] = (None, str(value)) - - response = self.session.post(self.get_url( - "newTwoPlantAPI.do?op=updatePlant"), files=form_settings) - - return response.json() + return self._request( + "POST", self.get_url("noahDeviceApi/noah/getNoahInfoBySn"), + data={"deviceSn": serial_number}, + ) def update_inverter_setting(self, serial_number: str, setting_type: str, default_parameters: dict[str, Any], parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: @@ -1109,10 +909,10 @@ def update_inverter_setting(self, serial_number: str, setting_type: str, merged = {**default_parameters, **settings_parameters} - response = self.session.post(self.get_url("newTcpsetAPI.do"), - params=merged) - - return response.json() + return self._request( + "POST", self.get_url("newTcpsetAPI.do"), + params=settings_parameters, + ) def update_mix_inverter_setting(self, serial_number: str, setting_type: str, parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: """ @@ -1172,29 +972,27 @@ def update_tlx_inverter_time_segment(self, serial_number: str, segment_id: int, dict: Server JSON response. """ - params = { - "op": "tlxSet" - } - data = { - "serialNum": serial_number, - "type": f"time_segment{segment_id}", - "param1": batt_mode, - "param2": start_time.strftime("%H"), - "param3": start_time.strftime("%M"), - "param4": end_time.strftime("%H"), - "param5": end_time.strftime("%M"), - "param6": "1" if enabled else "0" - } + def _check_success(result): + if not result.get("success", False): + msg = f"Failed to update TLX inverter time segment: {result.get('msg', 'Unknown error')}" + raise GrowattError(msg) + return result - response = self.session.post(self.get_url( - "newTcpsetAPI.do"), params=params, data=data) - result = response.json() - - if not result.get("success", False): - msg = f"Failed to update TLX inverter time segment: {result.get('msg', 'Unknown error')}" - raise GrowattError(msg) - - return result + return self._request( + "POST", self.get_url("newTcpsetAPI.do"), + params={"op": "tlxSet"}, + data={ + "serialNum": serial_number, + "type": f"time_segment{segment_id}", + "param1": batt_mode, + "param2": start_time.strftime("%H"), + "param3": start_time.strftime("%M"), + "param4": end_time.strftime("%H"), + "param5": end_time.strftime("%M"), + "param6": "1" if enabled else "0", + }, + extract=_check_success, + ) def update_tlx_inverter_setting(self, serial_number: str, setting_type: str, parameter: dict[str, Any] | list[Any] | str) -> dict[str, Any]: """ @@ -1253,74 +1051,11 @@ def update_noah_settings(self, serial_number: str, setting_type: str, parameters merged = {**default_parameters, **settings_parameters} - response = self.session.post(self.get_url("noahDeviceApi/noah/set"), - data=merged) - - return response.json() - - def classic_inverter_info(self, device_sn: str) -> dict[str, Any]: - """ - Get classic inverter information by scraping the inverter settings page. - - The Growatt server does not provide a JSON API for classic inverter status, - so this method fetches the HTML settings page and extracts the inverter - data from an embedded JSON object in the JavaScript. - - Args: - device_sn: The serial number of the inverter. - - Returns: - dict: A dictionary containing the inverter information. - 'innerVersion' - 'timezone' - 'isBig' - 'voltageHighLimit' -- High voltage limit e.g. '263.0' - 'wideVoltageEnable' - 'reactiveRate' - 'modelText' - 'haveAfci' - 'activeRate' -- Active power rate e.g. '100' - 'lost' - 'alias' -- Friendly name of the inverter - 'datalogSn' -- Serial number of the datalogger - 'sysTime' -- System time e.g. '2026-03-01 10:02:45' - 'fwVersion' -- Firmware version e.g. 'AH1.0' - 'model' -- Model number - 'sn' -- Serial number of the inverter - 'pvPfCmdMemoryState' - 'onOff' -- Inverter on/off status ('0' = off, '1' = on) - 'voltageLowLimit' -- Low voltage limit e.g. '186.0' - 'plantId' -- The ID of the plant - 'pfModel' - 'workingFrequencyMin' -- Minimum working frequency e.g. '47.53' - 'nominalPower' -- Nominal power in watts e.g. '3600' - 'workingFrequencyMax' -- Maximum working frequency e.g. '51.5' - 'pf' -- Power factor e.g. '1.0' - 'location' -- Location string - 'deviceModel' -- Device model name e.g. 'GROWATT 3000MTL-S' - 'status' -- Inverter status code - 'lastUpdateTime' -- Last data update time - - Raises: - GrowattError: If the inverter data cannot be extracted from the response. - - """ - response = self.session.get( - self.get_url("commonDeviceSetC/setInverter"), - params={"type": "server", "invSn": device_sn}, + return self._request( + "POST", self.get_url("noahDeviceApi/noah/set"), + data=settings_parameters, ) - match = re.search(r"inv=JSON\.parse\('(\{.*?\})'\)", response.text) - if not match: - msg = f"Could not find inverter data in response for device {device_sn}" - raise GrowattError(msg) - - try: - return json.loads(match.group(1)) - except json.JSONDecodeError as err: - msg = f"Failed to parse inverter data JSON for device {device_sn}" - raise GrowattError(msg) from err - def update_classic_inverter_setting(self, default_parameters: dict[str, Any], parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: """ Apply classic inverter settings. @@ -1408,57 +1143,262 @@ def update_classic_inverter_setting(self, default_parameters: dict[str, Any], pa settings_parameters = {**default_parameters, **settings_parameters} - response = self.session.post(self.get_url("tcpSet.do"), - params=settings_parameters) + return self._request( + "POST", self.get_url("tcpSet.do"), + params=settings_parameters, + ) - return response.json() + @staticmethod + def _parse_classic_inverter_html(html, device_sn) -> dict: + """Extract inverter data JSON from the settings page HTML.""" + match = re.search(r"inv=JSON\.parse\('(\{.*?\})'\)", html) + if not match: + msg = f"Could not find inverter data in response for device {device_sn}" + raise GrowattError(msg) - def set_classic_inverter_active_power_rate(self, serial_number, power_rate): + try: + return json.loads(match.group(1)) + except json.JSONDecodeError as err: + msg = f"Failed to parse inverter data JSON for device {device_sn}" + raise GrowattError(msg) from err + + def classic_inverter_info(self, device_sn: str) -> dict[str, Any]: """ - Set the active power rate (output power limit) for a classic inverter. + Get classic inverter information by scraping the inverter settings page. + + The Growatt server does not provide a JSON API for classic inverter status, + so this method fetches the HTML settings page and extracts the inverter + data from an embedded JSON object in the JavaScript. Args: - serial_number: Inverter serial number. - power_rate: Active power rate as percentage (0-100). + device_sn: The serial number of the inverter. Returns: - dict: Server JSON response. + dict: A dictionary containing the inverter information. + 'innerVersion' + 'timezone' + 'isBig' + 'voltageHighLimit' -- High voltage limit e.g. '263.0' + 'wideVoltageEnable' + 'reactiveRate' + 'modelText' + 'haveAfci' + 'activeRate' -- Active power rate e.g. '100' + 'lost' + 'alias' -- Friendly name of the inverter + 'datalogSn' -- Serial number of the datalogger + 'sysTime' -- System time e.g. '2026-03-01 10:02:45' + 'fwVersion' -- Firmware version e.g. 'AH1.0' + 'model' -- Model number + 'sn' -- Serial number of the inverter + 'pvPfCmdMemoryState' + 'onOff' -- Inverter on/off status ('0' = off, '1' = on) + 'voltageLowLimit' -- Low voltage limit e.g. '186.0' + 'plantId' -- The ID of the plant + 'pfModel' + 'workingFrequencyMin' -- Minimum working frequency e.g. '47.53' + 'nominalPower' -- Nominal power in watts e.g. '3600' + 'workingFrequencyMax' -- Maximum working frequency e.g. '51.5' + 'pf' -- Power factor e.g. '1.0' + 'location' -- Location string + 'deviceModel' -- Device model name e.g. 'GROWATT 3000MTL-S' + 'status' -- Inverter status code + 'lastUpdateTime' -- Last data update time + + Raises: + GrowattError: If the inverter data cannot be extracted from the response. """ - default_parameters = { - "action": "inverterSet", - "serialNum": serial_number, - } + return self._request( + "GET", self.get_url("commonDeviceSetC/setInverter"), + params={"type": "server", "invSn": device_sn}, + text=True, + extract=lambda html: self._parse_classic_inverter_html(html, device_sn), + ) - parameters = { - "paramId": "pv_active_p_rate", - "command_1": str(power_rate), - "command_2": "", - } - return self.update_classic_inverter_setting(default_parameters, parameters) +class GrowattApi(_GrowattApiBase): + """Base client for Growatt API endpoints.""" - def set_classic_inverter_on_off(self, serial_number, enabled): + def __init__(self, add_random_user_id: bool = False, agent_identifier: str | None = None) -> None: """ - Turn a classic inverter on or off. + Initialize the Growatt API client. Args: - serial_number: Inverter serial number. - enabled: True to turn on, False to turn off. + add_random_user_id: Append a short random suffix to the user-agent. + agent_identifier: Optional override for the user-agent string. + + """ + super().__init__(add_random_user_id, agent_identifier) + + self.session = httpx.Client( + headers={"User-Agent": self.agent_identifier}, + follow_redirects=True, + timeout=None, # noqa: S113 + event_hooks={"response": [_raise_for_status]}, + ) + + def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Any = None, text: bool = False) -> Any: + """Make an HTTP request and return the JSON response (or text if text=True).""" + kwargs = {} + if params is not None: + kwargs["params"] = params + if data is not None: + kwargs["data"] = data + if follow_redirects is not None: + kwargs["follow_redirects"] = follow_redirects + response = self.session.request(method, url, **kwargs) + result = response.text if text else response.json() + return extract(result) if extract is not None else result + + def close(self) -> None: + """Close the underlying HTTP session.""" + self.session.close() + + def __enter__(self) -> Self: + """Enter the context manager.""" + return self + + def __exit__(self, *args: object) -> None: + """Exit the context manager.""" + self.close() + + # Methods that need direct session access or chain async calls + + def login(self, username: str, password: str, is_password_hashed: bool = False) -> dict[str, Any]: + """ + Log the user in. + + Returns + ------- + 'data' -- A List containing Objects containing the folowing + 'plantName' -- Friendly name of the plant + 'plantId' -- The ID of the plant + 'service' + 'quality' + 'isOpenSmartFamily' + 'totalData' -- An Object + 'success' -- True or False + 'msg' + 'app_code' + 'user' -- An Object containing a lot of user information + 'uid' + 'userLanguage' + 'inverterGroup' -- A List + 'timeZone' -- A Number + 'lat' + 'lng' + 'dataAcqList' -- A List + 'type' + 'accountName' -- The username + 'password' -- The password hash of the user + 'isValiPhone' + 'kind' + 'mailNotice' -- True or False + 'id' + 'lasLoginIp' + 'lastLoginTime' + 'userDeviceType' + 'phoneNum' + 'approved' -- True or False + 'area' -- Continent of the user + 'smsNotice' -- True or False + 'isAgent' + 'token' + 'nickName' + 'parentUserId' + 'customerCode' + 'country' + 'isPhoneNumReg' + 'createDate' + 'rightlevel' + 'appType' + 'serverUrl' + 'roleId' + 'enabled' -- True or False + 'agentCode' + 'inverterList' -- A list + 'email' + 'company' + 'activeName' + 'codeIndex' + 'appAlias' + 'isBigCustomer' + 'noticeType' + + """ + if not is_password_hashed: + password = hash_password(password) + + response = self.session.post(self.get_url("newTwoLoginAPI.do"), data={ + "userName": username, + "password": password + }) + + data = response.json()["back"] + if data["success"]: + data.update({ + "userId": data["user"]["id"], + "userLevel": data["user"]["rightlevel"] + }) + return data + + def device_list(self, plant_id: str) -> list[dict[str, Any]]: + """Get a list of all devices connected to plant.""" + device_list = self.plant_info(plant_id).get("deviceList", []) + + if not device_list: + # for tlx systems, the device_list in plant is empty, so use _get_all_devices() instead + device_list = self._get_all_devices(plant_id) + + return device_list + + def update_plant_settings(self, plant_id: str, changed_settings: dict[str, Any], current_settings: dict[str, Any] | None = None) -> dict[str, Any]: + """ + Update plant settings. + + Args: + plant_id: Plant identifier. + changed_settings: Dict of settings to change. + current_settings: Current settings dict or None. Returns: - dict: Server JSON response. + dict: Server response indicating success or failure. """ - default_parameters = { - "action": "inverterSet", - "serialNum": serial_number, - } + # If no existing settings have been provided then get them from the growatt server + if current_settings is None: + current_settings = self.plant_settings(plant_id) - parameters = { - "paramId": "pv_on_off", - "command_1": "0001" if enabled else "0000", - "command_2": "", + # These are the parameters that the form requires, without these an error is thrown. Pre-populate their values with the current values + form_settings = { + "plantCoal": (None, str(current_settings["formulaCoal"])), + "plantSo2": (None, str(current_settings["formulaSo2"])), + "accountName": (None, str(current_settings["userAccount"])), + "plantID": (None, str(current_settings["id"])), + # Hardcoded to 0 as I can't work out what value it should have + "plantFirm": (None, "0"), + "plantCountry": (None, str(current_settings["country"])), + "plantType": (None, str(current_settings["plantType"])), + "plantIncome": (None, str(current_settings["formulaMoneyStr"])), + "plantAddress": (None, str(current_settings["plantAddress"])), + "plantTimezone": (None, str(current_settings["timezone"])), + "plantLng": (None, str(current_settings["plant_lng"])), + "plantCity": (None, str(current_settings["city"])), + "plantCo2": (None, str(current_settings["formulaCo2"])), + "plantMoney": (None, str(current_settings["formulaMoneyUnitId"])), + "plantPower": (None, str(current_settings["nominalPower"])), + "plantLat": (None, str(current_settings["plant_lat"])), + "plantDate": (None, str(current_settings["createDateText"])), + "plantName": (None, str(current_settings["plantName"])), } - return self.update_classic_inverter_setting(default_parameters, parameters) + # Overwrite the current value of the setting with the new value + for setting, value in changed_settings.items(): + form_settings[setting] = (None, str(value)) + + response = self.session.post(self.get_url( + "newTwoPlantAPI.do?op=updatePlant"), files=form_settings) + + return response.json() + diff --git a/growattServer/exceptions.py b/growattServer/exceptions.py index 48d4e72..ba59765 100644 --- a/growattServer/exceptions.py +++ b/growattServer/exceptions.py @@ -2,15 +2,15 @@ Exception classes and error code constants for the growattServer library. Note that in addition to these custom exceptions, methods may also raise exceptions -from the underlying requests library (requests.exceptions.RequestException and its -subclasses) when network or HTTP errors occur. These are not wrapped and are passed -through directly to the caller. - -Common requests exceptions to handle: -- requests.exceptions.HTTPError: For HTTP error responses (4XX, 5XX) -- requests.exceptions.ConnectionError: For network connection issues -- requests.exceptions.Timeout: For request timeouts -- requests.exceptions.RequestException: The base exception for all requests exceptions +from the underlying httpx library (httpx.HTTPError and its subclasses) when network +or HTTP errors occur. These are not wrapped and are passed through directly to the +caller. + +Common httpx exceptions to handle: +- httpx.HTTPStatusError: For HTTP error responses (4XX, 5XX) +- httpx.ConnectError: For network connection issues +- httpx.TimeoutException: For request timeouts +- httpx.HTTPError: The base exception for all httpx request exceptions """ from __future__ import annotations diff --git a/growattServer/open_api_v1/__init__.py b/growattServer/open_api_v1/__init__.py index 5980980..3c0301b 100644 --- a/growattServer/open_api_v1/__init__.py +++ b/growattServer/open_api_v1/__init__.py @@ -29,14 +29,20 @@ class DeviceType(Enum): PBD = 10 -class OpenApiV1(GrowattApi): +class _OpenApiV1Base: """ - Extended Growatt API client with V1 API support. + Shared V1 API methods for sync and async implementations. - This class extends the base GrowattApi class with methods for MIN and SPH devices using - the public V1 API described here: https://www.showdoc.com.cn/262556420217021/0. + Methods here are defined as regular ``def`` (not ``async def``). + They work transparently with both sync and async subclasses because + they return the result of ``self.v1_request(...)`` or a device method + — both of which produce a coroutine in async context that the caller + can ``await``. """ + _min_class = Min + _sph_class = Sph + def _create_user_agent(self) -> str: python_version = platform.python_version() system = platform.system() @@ -45,24 +51,7 @@ def _create_user_agent(self) -> str: return f"Python/{python_version} ({system} {release}; {machine})" - def __init__(self, token: str) -> None: - """ - Initialize the Growatt API client with V1 API support. - - Args: - token (str): API token for authentication (required for V1 API access). - - """ - # Initialize the base class - super().__init__(agent_identifier=self._create_user_agent()) - - # Add V1 API specific properties - self.api_url = f"{self.server_url}v1/" - - # Set up authentication for V1 API using the provided token - self.session.headers.update({"token": token}) - - def process_response(self, response: dict[str, Any], operation_name: str = "API operation") -> Any: + def process_response(self, response: dict[str, Any], operation_name: str = "API operation") -> dict[str, Any]: """ Process API response and handle errors. @@ -90,7 +79,9 @@ def get_url(self, page: str) -> str: """Return the page URL for the v1 API.""" return self.api_url + page - def plant_list(self) -> dict[str, Any]: # type: ignore[override] + # Plant Methods + + def plant_list(self) -> dict[str, Any]: """ Get a list of all plants with detailed information. @@ -100,24 +91,17 @@ def plant_list(self) -> dict[str, Any]: # type: ignore[override] Raises: GrowattV1ApiError: If the API returns an error response. Endpoint-specific error codes: 10001 - System error - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494058730404880 """ - # Prepare request data - request_data = { - "page": "", - "perpage": "", - "search_type": "", - "search_keyword": "", - } - - # Make the request - response = self.session.get(url=self.get_url("plant/list"), data=request_data) - - return self.process_response(response.json(), "getting plant list") + return self.v1_request( + "GET", "plant/list", + data={"page": "", "perpage": "", "search_type": "", "search_keyword": ""}, + operation_name="getting plant list", + ) def plant_details(self, plant_id: int) -> dict[str, Any]: """ @@ -135,18 +119,18 @@ def plant_details(self, plant_id: int) -> dict[str, Any]: 10002 - Power station does not exist 10003 - Power station ID is empty 10004 - User does not exist - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494060394238679 """ - response = self.session.get( - self.get_url("plant/details"), params={"plant_id": plant_id} + return self.v1_request( + "GET", "plant/details", + params={"plant_id": plant_id}, + operation_name="getting plant details", ) - return self.process_response(response.json(), "getting plant details") - def plant_energy_overview(self, plant_id: int) -> dict[str, Any]: """ Get an overview of a plant's energy data. @@ -162,18 +146,18 @@ def plant_energy_overview(self, plant_id: int) -> dict[str, Any]: 10001 - System error 10002 - Power station does not exist 10003 - Power station ID is empty - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494061093808613 """ - response = self.session.get( - self.get_url("plant/data"), params={"plant_id": plant_id} + return self.v1_request( + "GET", "plant/data", + params={"plant_id": plant_id}, + operation_name="getting plant energy overview", ) - return self.process_response(response.json(), "getting plant energy overview") - def plant_power_overview( self, plant_id: int, day: str | date | None = None ) -> dict: @@ -202,7 +186,7 @@ def plant_power_overview( 10001 - System error 10002 - Power station does not exist 10003 - Power station ID is empty or time format is incorrect - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494062656174173 @@ -211,17 +195,12 @@ def plant_power_overview( if day is None: day = datetime.now(tz=UTC).astimezone().date() - params: dict[str, str | int] = { - "plant_id": plant_id, - "date": str(day), - } - response = self.session.get( - self.get_url("plant/power"), - params=params, + return self.v1_request( + "GET", "plant/power", + params={"plant_id": plant_id, "date": day}, + operation_name="getting plant power overview", ) - return self.process_response(response.json(), "getting plant power overview") - def plant_energy_history( self, plant_id: int, @@ -257,7 +236,7 @@ def plant_energy_history( 10002 - Power station does not exist 10003 - Power station ID is empty 10004 - Time format is incorrect - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494061730868556 @@ -294,8 +273,8 @@ def plant_energy_history( stacklevel=2, ) - response = self.session.get( - self.get_url("plant/energy"), + return self.v1_request( + "GET", "plant/energy", params={ "plant_id": plant_id, "start_date": start_date.strftime("%Y-%m-%d"), @@ -304,11 +283,10 @@ def plant_energy_history( "page": page, "perpage": perpage, }, + operation_name="getting plant energy history", ) - return self.process_response(response.json(), "getting plant energy history") - - def device_list(self, plant_id: int) -> dict[str, Any]: # type: ignore[override] + def device_list(self, plant_id: int) -> dict[str, Any]: """ Get devices associated with plant. @@ -353,30 +331,25 @@ def device_list(self, plant_id: int) -> dict[str, Any]: # type: ignore[override } """ - params: dict[str, str | int] = { - "plant_id": plant_id, - "page": "", - "perpage": "", - } - response = self.session.get( - url=self.get_url("device/list"), - params=params, + return self.v1_request( + "GET", "device/list", + params={"plant_id": plant_id, "page": "", "perpage": ""}, + operation_name="getting device list", ) - return self.process_response(response.json(), "getting device list") def get_device(self, device_sn: str, device_type: int) -> AbstractDevice | None: """Get the device class by serial number and device_type id.""" - match device_type: - case Sph.DEVICE_TYPE_ID: - return Sph(self, device_sn) - case Min.DEVICE_TYPE_ID: - return Min(self, device_sn) - case _: - warnings.warn( - f"Device for type id: {device_type} has not been implemented yet.", - stacklevel=2, - ) - return None + if device_type == self._sph_class.DEVICE_TYPE_ID: + return self._sph_class(self, device_sn) + if device_type == self._min_class.DEVICE_TYPE_ID: + return self._min_class(self, device_sn) + warnings.warn( + f"Device for type id: {device_type} has not been implemented yet.", + stacklevel=2, + ) + return None + + # MIN Device Methods (Device Type 7) def min_detail(self, device_sn: str) -> dict[str, Any]: """ @@ -390,10 +363,10 @@ def min_detail(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Min(self, device_sn).detail() + return self._min_class(self, device_sn).detail() def min_energy(self, device_sn: str) -> dict[str, Any]: """ @@ -407,10 +380,10 @@ def min_energy(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Min(self, device_sn).energy() + return self._min_class(self, device_sn).energy() def min_energy_history( self, @@ -438,10 +411,10 @@ def min_energy_history( Raises: GrowattParameterError: If date interval is invalid (exceeds 7 days). GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Min(self, device_sn).energy_history( + return self._min_class(self, device_sn).energy_history( start_date, end_date, timezone, page, limit ) @@ -457,10 +430,10 @@ def min_settings(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Min(self, device_sn).settings() + return self._min_class(self, device_sn).settings() def min_read_parameter( self, device_sn: str, parameter_id: str, start_address: int | None = None, end_address: int | None = None @@ -480,10 +453,10 @@ def min_read_parameter( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Min(self, device_sn).read_parameter( + return self._min_class(self, device_sn).read_parameter( parameter_id, start_address, end_address ) @@ -504,10 +477,10 @@ def min_write_parameter(self, device_sn: str, parameter_id: str, parameter_value Raises: GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Min(self, device_sn).write_parameter(parameter_id, parameter_values) + return self._min_class(self, device_sn).write_parameter(parameter_id, parameter_values) def min_write_time_segment( self, device_sn: str, segment_id: int, batt_mode: int, start_time: time, end_time: time, enabled: bool = True @@ -529,10 +502,10 @@ def min_write_time_segment( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Min(self, device_sn).write_time_segment( + return self._min_class(self, device_sn).write_time_segment( segment_id, batt_mode, start_time, end_time, enabled ) @@ -571,10 +544,10 @@ def min_read_time_segments(self, device_sn: str, settings_data: dict[str, Any] | Raises: GrowattV1ApiError: If the API request fails - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Min(self, device_sn).read_time_segments(settings_data) + return self._min_class(self, device_sn).read_time_segments(settings_data) # SPH Device Methods (Device Type 5) @@ -590,10 +563,10 @@ def sph_detail(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Sph(self, device_sn).detail() + return self._sph_class(self, device_sn).detail() def sph_energy(self, device_sn: str) -> dict[str, Any]: """ @@ -607,10 +580,10 @@ def sph_energy(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Sph(self, device_sn).energy() + return self._sph_class(self, device_sn).energy() def sph_energy_history( self, @@ -638,10 +611,10 @@ def sph_energy_history( Raises: GrowattParameterError: If date interval is invalid (exceeds 7 days). GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Sph(self, device_sn).energy_history( + return self._sph_class(self, device_sn).energy_history( start_date, end_date, timezone, page, limit ) @@ -663,10 +636,10 @@ def sph_read_parameter( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Sph(self, device_sn).read_parameter( + return self._sph_class(self, device_sn).read_parameter( parameter_id, start_address, end_address ) @@ -687,10 +660,10 @@ def sph_write_parameter(self, device_sn: str, parameter_id: str, parameter_value Raises: GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Sph(self, device_sn).write_parameter(parameter_id, parameter_values) + return self._sph_class(self, device_sn).write_parameter(parameter_id, parameter_values) def sph_write_ac_charge_times( self, device_sn: str, charge_power: int, charge_stop_soc: int, mains_enabled: bool, periods: list[dict[str, Any]] @@ -729,10 +702,10 @@ def sph_write_ac_charge_times( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Sph(self, device_sn).write_ac_charge_times( + return self._sph_class(self, device_sn).write_ac_charge_times( charge_power, charge_stop_soc, mains_enabled, periods ) @@ -771,10 +744,10 @@ def sph_write_ac_discharge_times( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Sph(self, device_sn).write_ac_discharge_times( + return self._sph_class(self, device_sn).write_ac_discharge_times( discharge_power, discharge_stop_soc, periods ) @@ -817,10 +790,10 @@ def sph_read_ac_charge_times(self, device_sn: str, settings_data: dict[str, Any] Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - return Sph(self, device_sn).read_ac_charge_times(settings_data) + return self._sph_class(self, device_sn).read_ac_charge_times(settings_data) def sph_read_ac_discharge_times(self, device_sn: str, settings_data: dict[str, Any] | None = None) -> dict[str, Any]: """ @@ -860,7 +833,33 @@ def sph_read_ac_discharge_times(self, device_sn: str, settings_data: dict[str, A Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. + + """ + return self._sph_class(self, device_sn).read_ac_discharge_times(settings_data) + + +class OpenApiV1(_OpenApiV1Base, GrowattApi): + """ + Extended Growatt API client with V1 API support. + + This class extends the base GrowattApi class with methods for MIN and SPH devices using + the public V1 API described here: https://www.showdoc.com.cn/262556420217021/0. + """ + + def __init__(self, token: str) -> None: + """ + Initialize the Growatt API client with V1 API support. + + Args: + token (str): API token for authentication (required for V1 API access). """ - return Sph(self, device_sn).read_ac_discharge_times(settings_data) + super().__init__(agent_identifier=self._create_user_agent()) + self.api_url = f"{self.server_url}v1/" + self.session.headers.update({"token": token}) + + def v1_request(self, method: str, endpoint: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, operation_name: str = "API operation") -> dict[str, Any]: + """Make a V1 API request and process the response.""" + response = self.session.request(method, self.get_url(endpoint), params=params, data=data) + return self.process_response(response.json(), operation_name) diff --git a/growattServer/open_api_v1/async_open_api_v1.py b/growattServer/open_api_v1/async_open_api_v1.py new file mode 100644 index 0000000..ee0ac53 --- /dev/null +++ b/growattServer/open_api_v1/async_open_api_v1.py @@ -0,0 +1,45 @@ +"""Async OpenApi V1 extensions for Growatt API client.""" + +from __future__ import annotations + +from typing import Any + +from growattServer.async_base_api import AsyncGrowattApi + +from . import _OpenApiV1Base +from .devices.async_min import AsyncMin +from .devices.async_sph import AsyncSph + + +class AsyncOpenApiV1(_OpenApiV1Base, AsyncGrowattApi): + """ + Async extended Growatt API client with V1 API support. + + This class extends the base AsyncGrowattApi class with methods for MIN and SPH devices using + the public V1 API described here: https://www.showdoc.com.cn/262556420217021/0. + + All methods inherited from ``_OpenApiV1Base`` work transparently in async + context because they return ``self.v1_request(...)`` or a device method call, + both of which yield coroutines when the underlying API is async. + """ + + _min_class = AsyncMin + _sph_class = AsyncSph + + def __init__(self, token: str, session: Any = None) -> None: + """ + Initialize the async Growatt API client with V1 API support. + + Args: + token (str): API token for authentication (required for V1 API access). + session: Optional httpx.AsyncClient to reuse. + + """ + super().__init__(agent_identifier=self._create_user_agent(), session=session) + self.api_url = f"{self.server_url}v1/" + self.session.headers.update({"token": token}) + + async def v1_request(self, method: str, endpoint: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, operation_name: str = "API operation") -> dict[str, Any]: + """Make a V1 API request and process the response.""" + response = await self.session.request(method, self.get_url(endpoint), params=params, data=data) + return self.process_response(response.json(), operation_name) diff --git a/growattServer/open_api_v1/devices/__init__.py b/growattServer/open_api_v1/devices/__init__.py index 86c616e..c471130 100644 --- a/growattServer/open_api_v1/devices/__init__.py +++ b/growattServer/open_api_v1/devices/__init__.py @@ -2,5 +2,7 @@ from __future__ import annotations from .abstract_device import AbstractDevice, ParameterValue # noqa: F401 +from .async_min import AsyncMin # noqa: F401 +from .async_sph import AsyncSph # noqa: F401 from .min import Min # noqa: F401 from .sph import Sph # noqa: F401 diff --git a/growattServer/open_api_v1/devices/abstract_device.py b/growattServer/open_api_v1/devices/abstract_device.py index b42b450..2125116 100644 --- a/growattServer/open_api_v1/devices/abstract_device.py +++ b/growattServer/open_api_v1/devices/abstract_device.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from growattServer.open_api_v1 import OpenApiV1 + from growattServer.open_api_v1.async_open_api_v1 import AsyncOpenApiV1 type ParameterValue = str | float | bool | list[Any] | dict[str, Any] @@ -23,7 +24,7 @@ class ReadParamResponse(TypedDict): class AbstractDevice: """Abstract device type. Must not be used directly.""" - def __init__(self, api: OpenApiV1, device_sn: str) -> None: + def __init__(self, api: OpenApiV1 | AsyncOpenApiV1, device_sn: str) -> None: """ Initialize the device with the bare minimum being the device_sn. diff --git a/growattServer/open_api_v1/devices/async_min.py b/growattServer/open_api_v1/devices/async_min.py new file mode 100644 index 0000000..4f5bedd --- /dev/null +++ b/growattServer/open_api_v1/devices/async_min.py @@ -0,0 +1,61 @@ +"""Async Min/TLX device file.""" + +from __future__ import annotations + +from typing import Any + +from .min import Min + + +class AsyncMin(Min): + """ + Async Min/TLX device type. + + Inherits all methods from Min. Most methods work transparently in async + context because they return self.api.v1_request(...) which yields a + coroutine when the api is async. + + Only methods that chain async calls need explicit overrides. + """ + + async def read_time_segments(self, settings_data: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """ + Read Time-of-Use (TOU) settings from a Growatt MIN/TLX inverter. + + Retrieves all 9 time segments from a Growatt MIN/TLX inverter and + parses them into a structured format. + + Note that this function uses min_settings() internally to get the settings data, + To avoid endpoint rate limit, you can pass the settings_data parameter + with the data returned from min_settings(). + + Args: + device_sn (str): The device serial number of the inverter + settings_data (dict, optional): Settings data from min_settings call to avoid repeated API calls. + Can be either the complete response or just the data portion. + + Returns: + list: A list of dictionaries, each containing details for one time segment: + - segment_id (int): The segment number (1-9) + - batt_mode (int): 0=Load First, 1=Battery First, 2=Grid First + - mode_name (str): String representation of the mode + - start_time (str): Start time in format "HH:MM" + - end_time (str): End time in format "HH:MM" + - enabled (bool): Whether the segment is enabled + + Example: + # Option 1: Make a single call + tou_settings = await api.min_read_time_segments("DEVICE_SERIAL_NUMBER") + + # Option 2: Reuse existing settings data + settings_response = await api.min_settings("DEVICE_SERIAL_NUMBER") + tou_settings = await api.min_read_time_segments("DEVICE_SERIAL_NUMBER", settings_response) + + Raises: + GrowattV1ApiError: If the API request fails + httpx.HTTPError: If there is an issue with the HTTP request. + + """ + if settings_data is None: + settings_data = await self.settings() + return self._parse_time_segments(settings_data) diff --git a/growattServer/open_api_v1/devices/async_sph.py b/growattServer/open_api_v1/devices/async_sph.py new file mode 100644 index 0000000..f76d6f6 --- /dev/null +++ b/growattServer/open_api_v1/devices/async_sph.py @@ -0,0 +1,110 @@ +"""Async SPH/MIX device file.""" + +from __future__ import annotations + +from typing import Any + +from .sph import Sph + + +class AsyncSph(Sph): + """ + Async SPH/MIX device type. + + Inherits all methods from Sph. Most methods work transparently in async + context because they return self.api.v1_request(...) which yields a + coroutine when the api is async. + + Only methods that chain async calls need explicit overrides. + """ + + async def read_ac_charge_times(self, settings_data: dict[str, Any] | None = None) -> dict[str, Any]: + """ + Read AC charge time periods and settings from an SPH inverter. + + Retrieves all 3 AC charge time periods plus global charge settings + (power, stop SOC, mains enabled) from an SPH inverter. + + Note that this function uses sph_detail() internally to get the settings data. + To avoid endpoint rate limit, you can pass the settings_data parameter + with the data returned from sph_detail(). + + Args: + device_sn (str): The device serial number of the inverter. + settings_data (dict, optional): Settings data from sph_detail call to avoid repeated API calls. + + Returns: + dict: A dictionary containing: + - charge_power (int): Charging power percentage (0-100) + - charge_stop_soc (int): Stop charging at this SOC percentage (0-100) + - mains_enabled (bool): Whether grid/mains charging is enabled + - periods (list): List of 3 period dicts, each with: + - period_id (int): The period number (1-3) + - start_time (str): Start time in format "HH:MM" + - end_time (str): End time in format "HH:MM" + - enabled (bool): Whether the period is enabled + + Example: + # Option 1: Fetch settings automatically + charge_config = await api.sph_read_ac_charge_times(device_sn="DEVICE_SERIAL_NUMBER") + print(f"Charge power: {charge_config['charge_power']}%") + print(f"Periods: {charge_config['periods']}") + + # Option 2: Reuse existing settings data + settings_response = await api.sph_detail("DEVICE_SERIAL_NUMBER") + charge_config = await api.sph_read_ac_charge_times(device_sn="DEVICE_SERIAL_NUMBER", settings_data=settings_response) + + Raises: + GrowattParameterError: If neither device_sn nor settings_data is provided. + GrowattV1ApiError: If the API request fails. + httpx.HTTPError: If there is an issue with the HTTP request. + + """ + if settings_data is None: + settings_data = await self.detail() + return self._parse_ac_charge_settings(settings_data) + + async def read_ac_discharge_times(self, settings_data: dict[str, Any] | None = None) -> dict[str, Any]: + """ + Read AC discharge time periods and settings from an SPH inverter. + + Retrieves all 3 AC discharge time periods plus global discharge settings + (power, stop SOC) from an SPH inverter. + + Note that this function uses sph_detail() internally to get the settings data. + To avoid endpoint rate limit, you can pass the settings_data parameter + with the data returned from sph_detail(). + + Args: + device_sn (str, optional): The device serial number of the inverter. + settings_data (dict, optional): Settings data from sph_detail call to avoid repeated API calls. + + Returns: + dict: A dictionary containing: + - discharge_power (int): Discharging power percentage (0-100) + - discharge_stop_soc (int): Stop discharging at this SOC percentage (0-100) + - periods (list): List of 3 period dicts, each with: + - period_id (int): The period number (1-3) + - start_time (str): Start time in format "HH:MM" + - end_time (str): End time in format "HH:MM" + - enabled (bool): Whether the period is enabled + + Example: + # Option 1: Fetch settings automatically + discharge_config = await api.sph_read_ac_discharge_times(device_sn="DEVICE_SERIAL_NUMBER") + print(f"Discharge power: {discharge_config['discharge_power']}%") + print(f"Stop SOC: {discharge_config['discharge_stop_soc']}%") + + # Option 2: Reuse existing settings data + settings_response = await api.sph_detail("DEVICE_SERIAL_NUMBER") + discharge_config = await api.sph_read_ac_discharge_times(device_sn="DEVICE_SERIAL_NUMBER", settings_data=settings_response) + + Raises: + GrowattParameterError: If neither device_sn nor settings_data is provided. + GrowattV1ApiError: If the API request fails. + httpx.HTTPError: If there is an issue with the HTTP request. + + """ + if settings_data is None: + settings_data = await self.detail() + return self._parse_ac_discharge_settings(settings_data) diff --git a/growattServer/open_api_v1/devices/min.py b/growattServer/open_api_v1/devices/min.py index 2c9c4b1..548b245 100644 --- a/growattServer/open_api_v1/devices/min.py +++ b/growattServer/open_api_v1/devices/min.py @@ -25,19 +25,16 @@ def detail(self) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. Endpoint-specific error codes: 10001 - System error - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129816412127075 """ - response = self.api.session.get( - self.api.get_url("device/tlx/tlx_data_info"), + return self.api.v1_request( + "GET", "device/tlx/tlx_data_info", params={"device_sn": self.device_sn}, - ) - - return self.api.process_response( - response.json(), "getting MIN inverter details" + operation_name="getting MIN inverter details", ) def energy(self) -> dict[str, Any]: @@ -52,21 +49,16 @@ def energy(self) -> dict[str, Any]: 10001 - System error 10002 - Min does not exist 10003 - Device SN error - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129822090975531 """ - response = self.api.session.post( - url=self.api.get_url("device/tlx/tlx_last_data"), - data={ - "tlx_sn": self.device_sn, - }, - ) - - return self.api.process_response( - response.json(), "getting MIN inverter energy data" + return self.api.v1_request( + "POST", "device/tlx/tlx_last_data", + data={"tlx_sn": self.device_sn}, + operation_name="getting MIN inverter energy data", ) def energy_history( @@ -95,7 +87,7 @@ def energy_history( 10004 - Start date interval has exceeded seven days 10005 - Min does not exist 10011 - Permission is not satisfied - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129764475556048 @@ -111,8 +103,8 @@ def energy_history( if end_date - start_date > timedelta(days=7): raise GrowattParameterError("date interval must not exceed 7 days") - response = self.api.session.post( - url=self.api.get_url("device/tlx/tlx_data"), + return self.api.v1_request( + "POST", "device/tlx/tlx_data", data={ "tlx_sn": self.device_sn, "start_date": start_date.strftime("%Y-%m-%d"), @@ -121,10 +113,7 @@ def energy_history( "page": page, "perpage": limit, }, - ) - - return self.api.process_response( - response.json(), "getting MIN inverter energy history" + operation_name="getting MIN inverter energy history", ) def settings(self) -> dict[str, Any]: @@ -136,19 +125,16 @@ def settings(self) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/8696815667375182 """ - response = self.api.session.get( - self.api.get_url("device/tlx/tlx_set_info"), + return self.api.v1_request( + "GET", "device/tlx/tlx_set_info", params={"device_sn": self.device_sn}, - ) - - return self.api.process_response( - response.json(), "getting MIN inverter settings" + operation_name="getting MIN inverter settings", ) def read_parameter( @@ -177,7 +163,7 @@ def read_parameter( 10007 - The collector version does not support the reading function 10008 - The collector connects to the server error, please restart and try again 10009 - The read setting parameter type does not exist - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129828239577315 @@ -197,18 +183,15 @@ def read_parameter( if end_address is None: end_address = start_address - response = self.api.session.post( - self.api.get_url("readMinParam"), + return self.api.v1_request( + "POST", "readMinParam", data={ "device_sn": self.device_sn, "paramId": parameter_id, "startAddr": start_address, "endAddr": end_address, }, - ) - - return self.api.process_response( - response.json(), f"reading parameter {parameter_id}" + operation_name=f"reading parameter {parameter_id}", ) def write_parameter(self, parameter_id: str, parameter_values: ParameterValue | None = None) -> dict[str, Any]: @@ -238,7 +221,7 @@ def write_parameter(self, parameter_id: str, parameter_values: ParameterValue | 10009 - The date and time format is wrong 10012 - Min does not exist 10013 - The end time cannot be less than the start time - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129826876191828 @@ -272,11 +255,10 @@ def write_parameter(self, parameter_id: str, parameter_values: ParameterValue | for i in range(1, max_min_params + 1): request_data[f"param{i}"] = str(parameters[i]) - # Send the request - response = self.api.session.post(self.api.get_url("tlxSet"), data=request_data) - - return self.api.process_response( - response.json(), f"writing parameter {parameter_id}" + return self.api.v1_request( + "POST", "tlxSet", + data=request_data, + operation_name=f"writing parameter {parameter_id}", ) def write_time_segment( @@ -309,7 +291,7 @@ def write_time_segment( 10009 - The date and time format is wrong 10012 - Min does not exist 10013 - The end time cannot be less than the start time - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129826876191828 @@ -342,11 +324,10 @@ def write_time_segment( for i in range(7, max_min_params + 1): all_params[f"param{i}"] = "" - # Send the request - response = self.api.session.post(self.api.get_url("tlxSet"), data=all_params) - - return self.api.process_response( - response.json(), f"writing time segment {segment_id}" + return self.api.v1_request( + "POST", "tlxSet", + data=all_params, + operation_name=f"writing time segment {segment_id}", ) def read_time_segments(self, settings_data: dict[str, Any] | None = None) -> list[dict[str, Any]]: @@ -384,32 +365,28 @@ def read_time_segments(self, settings_data: dict[str, Any] | None = None) -> lis Raises: GrowattV1ApiError: If the API request fails - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ - # Process the settings data if settings_data is None: - # Fetch settings if not provided settings_data = self.settings() + return self._parse_time_segments(settings_data) - # Define mode names + def _parse_time_segments(self, settings_data) -> list[dict[str, Any]]: + """Parse time segment data from settings into structured format.""" mode_names = {0: "Load First", 1: "Battery First", 2: "Grid First"} segments = [] - # Process each time segment for i in range(1, 10): # Segments 1-9 - # Get raw time values start_time_raw = settings_data.get(f"forcedTimeStart{i}", "0:0") end_time_raw = settings_data.get(f"forcedTimeStop{i}", "0:0") - # Handle 'null' string values if start_time_raw == "null" or not start_time_raw: start_time_raw = "0:0" if end_time_raw == "null" or not end_time_raw: end_time_raw = "0:0" - # Format times with leading zeros (HH:MM) try: start_parts = start_time_raw.split(":") start_hour = int(start_parts[0]) @@ -426,7 +403,6 @@ def read_time_segments(self, settings_data: dict[str, Any] | None = None) -> lis except (ValueError, IndexError): end_time = "00:00" - # Get the mode value safely mode_raw = settings_data.get(f"time{i}Mode") if mode_raw == "null" or mode_raw is None: batt_mode = None @@ -436,7 +412,6 @@ def read_time_segments(self, settings_data: dict[str, Any] | None = None) -> lis except (ValueError, TypeError): batt_mode = None - # Get the enabled status safely enabled_raw = settings_data.get(f"forcedStopSwitch{i}", 0) if enabled_raw == "null" or enabled_raw is None: enabled = False diff --git a/growattServer/open_api_v1/devices/sph.py b/growattServer/open_api_v1/devices/sph.py index 4790079..fda2b02 100644 --- a/growattServer/open_api_v1/devices/sph.py +++ b/growattServer/open_api_v1/devices/sph.py @@ -25,19 +25,16 @@ def detail(self) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. Endpoint-specific error codes: 10001 - System error - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129763571291058 """ - response = self.api.session.get( - self.api.get_url("device/mix/mix_data_info"), + return self.api.v1_request( + "GET", "device/mix/mix_data_info", params={"device_sn": self.device_sn}, - ) - - return self.api.process_response( - response.json(), "getting SPH inverter details" + operation_name="getting SPH inverter details", ) def energy(self) -> dict[str, Any]: @@ -52,21 +49,16 @@ def energy(self) -> dict[str, Any]: 10001 - System error 10002 - Mix does not exist 10003 - Device SN error - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129764475556048 """ - response = self.api.session.post( - url=self.api.get_url("device/mix/mix_last_data"), - data={ - "mix_sn": self.device_sn, - }, - ) - - return self.api.process_response( - response.json(), "getting SPH inverter energy data" + return self.api.v1_request( + "POST", "device/mix/mix_last_data", + data={"mix_sn": self.device_sn}, + operation_name="getting SPH inverter energy data", ) def energy_history( @@ -94,7 +86,7 @@ def energy_history( 10003 - Date format error 10004 - Date interval exceeds seven days 10005 - Mix does not exist - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129765461123058 @@ -110,8 +102,8 @@ def energy_history( if end_date - start_date > timedelta(days=7): raise GrowattParameterError("date interval must not exceed 7 days") - response = self.api.session.post( - url=self.api.get_url("device/mix/mix_data"), + return self.api.v1_request( + "POST", "device/mix/mix_data", data={ "mix_sn": self.device_sn, "start_date": start_date.strftime("%Y-%m-%d"), @@ -120,10 +112,7 @@ def energy_history( "page": page, "perpage": limit, }, - ) - - return self.api.process_response( - response.json(), "getting SPH inverter energy history" + operation_name="getting SPH inverter energy history", ) def read_parameter(self, parameter_id: str | None = None, start_address: int | None = None, end_address: int | None = None) -> dict[str, Any]: @@ -150,7 +139,7 @@ def read_parameter(self, parameter_id: str | None = None, start_address: int | N 10007 - The collector version does not support the reading function 10008 - The collector connects to the server error, please restart and try again 10009 - The read setting parameter type does not exist - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129766954561259 @@ -173,18 +162,15 @@ def read_parameter(self, parameter_id: str | None = None, start_address: int | N # address range parameter_id = "set_any_reg" - response = self.api.session.post( - self.api.get_url("readMixParam"), + return self.api.v1_request( + "POST", "readMixParam", data={ "device_sn": self.device_sn, "paramId": parameter_id, "startAddr": start_address, "endAddr": end_address, }, - ) - - return self.api.process_response( - response.json(), f"reading parameter {parameter_id}" + operation_name=f"reading parameter {parameter_id}", ) def write_parameter(self, parameter_id: str, parameter_values: ParameterValue | None = None) -> dict[str, Any]: @@ -214,7 +200,7 @@ def write_parameter(self, parameter_id: str, parameter_values: ParameterValue | 10009 - Date and time format is wrong 10012 - Hybrid storage integrated machine does not exist 10013 - End time cannot be less than start time - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129761750718760 @@ -248,10 +234,10 @@ def write_parameter(self, parameter_id: str, parameter_values: ParameterValue | for i in range(1, max_sph_params + 1): request_data[f"param{i}"] = str(parameters[i]) - response = self.api.session.post(self.api.get_url("mixSet"), data=request_data) - - return self.api.process_response( - response.json(), f"writing parameter {parameter_id}" + return self.api.v1_request( + "POST", "mixSet", + data=request_data, + operation_name=f"writing parameter {parameter_id}", ) def write_ac_charge_times( @@ -302,7 +288,7 @@ def write_ac_charge_times( 10009 - Date and time format is wrong 10012 - Hybrid storage integrated machine does not exist 10013 - End time cannot be less than start time - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129761750718760 @@ -337,10 +323,10 @@ def write_ac_charge_times( request_data[f"param{base + 3}"] = str(period["end_time"].minute) request_data[f"param{base + 4}"] = "1" if period["enabled"] else "0" - response = self.api.session.post(self.api.get_url("mixSet"), data=request_data) - - return self.api.process_response( - response.json(), "writing AC charge time periods" + return self.api.v1_request( + "POST", "mixSet", + data=request_data, + operation_name="writing AC charge time periods", ) def write_ac_discharge_times(self, discharge_power: int, discharge_stop_soc: int, periods: list[dict[str, Any]]) -> dict[str, Any]: @@ -387,7 +373,7 @@ def write_ac_discharge_times(self, discharge_power: int, discharge_stop_soc: int 10009 - Date and time format is wrong 10012 - Hybrid storage integrated machine does not exist 10013 - End time cannot be less than start time - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129761750718760 @@ -421,10 +407,10 @@ def write_ac_discharge_times(self, discharge_power: int, discharge_stop_soc: int request_data[f"param{base + 3}"] = str(period["end_time"].minute) request_data[f"param{base + 4}"] = "1" if period["enabled"] else "0" - response = self.api.session.post(self.api.get_url("mixSet"), data=request_data) - - return self.api.process_response( - response.json(), "writing AC discharge time periods" + return self.api.v1_request( + "POST", "mixSet", + data=request_data, + operation_name="writing AC discharge time periods", ) def _parse_time_periods(self, settings_data: dict[str, Any], time_type: str) -> list[dict[str, Any]]: @@ -536,18 +522,19 @@ def read_ac_charge_times(self, settings_data: dict[str, Any] | None = None) -> d Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ if settings_data is None: settings_data = self.detail() + return self._parse_ac_charge_settings(settings_data) - # Extract global charge settings + def _parse_ac_charge_settings(self, settings_data): + """Parse AC charge settings from detail data.""" charge_power = settings_data.get("chargePowerCommand", 0) charge_stop_soc = settings_data.get("wchargeSOCLowLimit", 100) mains_enabled_raw = settings_data.get("acChargeEnable", 0) - # Handle null/empty values if charge_power == "null" or charge_power is None or charge_power == "": charge_power = 0 if ( @@ -610,17 +597,18 @@ def read_ac_discharge_times(self, settings_data: dict[str, Any] | None = None) - Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - requests.exceptions.RequestException: If there is an issue with the HTTP request. + httpx.HTTPError: If there is an issue with the HTTP request. """ if settings_data is None: settings_data = self.detail() + return self._parse_ac_discharge_settings(settings_data) - # Extract global discharge settings + def _parse_ac_discharge_settings(self, settings_data): + """Parse AC discharge settings from detail data.""" discharge_power = settings_data.get("disChargePowerCommand", 0) discharge_stop_soc = settings_data.get("wdisChargeSOCLowLimit", 10) - # Handle null/empty values if ( discharge_power == "null" or discharge_power is None diff --git a/setup.py b/setup.py index 0355a53..f9d7d5c 100755 --- a/setup.py +++ b/setup.py @@ -25,6 +25,6 @@ "Operating System :: OS Independent", ], install_requires=[ - "requests", + "httpx", ], ) From 3827ab7a049f2738888150e5210c814937ec5b29 Mon Sep 17 00:00:00 2001 From: Johan Zander Date: Sun, 7 Jun 2026 16:39:00 +0200 Subject: [PATCH 2/9] Add library-owned exceptions and requests compatibility layer Wrap httpx transport errors in GrowattApiError hierarchy so consumers depend on the library API rather than the underlying HTTP library. New exceptions: - GrowattApiError: base for all transport/HTTP errors - GrowattApiConnectionError: connection failures - GrowattApiTimeoutError: request timeouts - GrowattApiStatusError: HTTP 4XX/5XX responses (with status_code attr) For backwards compatibility during the requests-to-httpx transition, GrowattApiError inherits from requests.RequestException when the requests package is installed. A one-time deprecation warning is logged to prompt consumers to migrate their except clauses. Also adds set_classic_inverter_active_power_rate() and set_classic_inverter_on_off() convenience methods from upstream. Co-Authored-By: Claude Opus 4.6 --- growattServer/__init__.py | 8 +++ growattServer/async_base_api.py | 21 ++++++- growattServer/base_api.py | 73 +++++++++++++++++++++- growattServer/exceptions.py | 103 ++++++++++++++++++++++++++++---- setup.py | 3 + 5 files changed, 195 insertions(+), 13 deletions(-) diff --git a/growattServer/__init__.py b/growattServer/__init__.py index 0b4c6e6..389e75f 100755 --- a/growattServer/__init__.py +++ b/growattServer/__init__.py @@ -6,6 +6,10 @@ from .async_base_api import AsyncGrowattApi from .base_api import GrowattApi, Timespan, hash_password from .exceptions import ( + GrowattApiConnectionError, + GrowattApiError, + GrowattApiStatusError, + GrowattApiTimeoutError, GrowattError, GrowattParameterError, GrowattV1ApiError, @@ -22,6 +26,10 @@ "AsyncOpenApiV1", "DeviceType", "GrowattApi", + "GrowattApiConnectionError", + "GrowattApiError", + "GrowattApiStatusError", + "GrowattApiTimeoutError", "GrowattError", "GrowattParameterError", "GrowattV1ApiError", diff --git a/growattServer/async_base_api.py b/growattServer/async_base_api.py index 73cb243..d8b94a4 100644 --- a/growattServer/async_base_api.py +++ b/growattServer/async_base_api.py @@ -10,6 +10,12 @@ import httpx from .base_api import _GrowattApiBase, hash_password +from .exceptions import ( + GrowattApiConnectionError, + GrowattApiError, + GrowattApiStatusError, + GrowattApiTimeoutError, +) async def _async_raise_for_status(response): @@ -58,7 +64,20 @@ async def _request(self, method: str, url: str, *, params: dict[str, Any] | None kwargs["data"] = data if follow_redirects is not None: kwargs["follow_redirects"] = follow_redirects - response = await self.session.request(method, url, **kwargs) + try: + response = await self.session.request(method, url, **kwargs) + except httpx.TimeoutException as exc: + msg = f"Request to {url} timed out" + raise GrowattApiTimeoutError(msg) from exc + except httpx.ConnectError as exc: + msg = f"Failed to connect to {url}" + raise GrowattApiConnectionError(msg) from exc + except httpx.HTTPStatusError as exc: + msg = f"HTTP {exc.response.status_code} error for {url}" + raise GrowattApiStatusError(msg, exc.response.status_code) from exc + except httpx.HTTPError as exc: + msg = f"HTTP error during request to {url}: {exc}" + raise GrowattApiError(msg) from exc result = response.text if text else response.json() return extract(result) if extract is not None else result diff --git a/growattServer/base_api.py b/growattServer/base_api.py index efe9996..4a3c7fb 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -18,7 +18,13 @@ import httpx -from .exceptions import GrowattError +from .exceptions import ( + GrowattApiConnectionError, + GrowattApiError, + GrowattApiStatusError, + GrowattApiTimeoutError, + GrowattError, +) name = "growattServer" @@ -1148,6 +1154,56 @@ def update_classic_inverter_setting(self, default_parameters: dict[str, Any], pa params=settings_parameters, ) + def set_classic_inverter_active_power_rate(self, serial_number, power_rate): + """ + Set the active power rate (output power limit) for a classic inverter. + + Args: + serial_number: Inverter serial number. + power_rate: Active power rate as percentage (0-100). + + Returns: + dict: Server JSON response. + + """ + default_parameters = { + "action": "inverterSet", + "serialNum": serial_number, + } + + parameters = { + "paramId": "pv_active_p_rate", + "command_1": str(power_rate), + "command_2": "", + } + + return self.update_classic_inverter_setting(default_parameters, parameters) + + def set_classic_inverter_on_off(self, serial_number, enabled): + """ + Turn a classic inverter on or off. + + Args: + serial_number: Inverter serial number. + enabled: True to turn on, False to turn off. + + Returns: + dict: Server JSON response. + + """ + default_parameters = { + "action": "inverterSet", + "serialNum": serial_number, + } + + parameters = { + "paramId": "pv_on_off", + "command_1": "0001" if enabled else "0000", + "command_2": "", + } + + return self.update_classic_inverter_setting(default_parameters, parameters) + @staticmethod def _parse_classic_inverter_html(html, device_sn) -> dict: """Extract inverter data JSON from the settings page HTML.""" @@ -1247,7 +1303,20 @@ def _request(self, method: str, url: str, *, params: dict[str, Any] | None = Non kwargs["data"] = data if follow_redirects is not None: kwargs["follow_redirects"] = follow_redirects - response = self.session.request(method, url, **kwargs) + try: + response = self.session.request(method, url, **kwargs) + except httpx.TimeoutException as exc: + msg = f"Request to {url} timed out" + raise GrowattApiTimeoutError(msg) from exc + except httpx.ConnectError as exc: + msg = f"Failed to connect to {url}" + raise GrowattApiConnectionError(msg) from exc + except httpx.HTTPStatusError as exc: + msg = f"HTTP {exc.response.status_code} error for {url}" + raise GrowattApiStatusError(msg, exc.response.status_code) from exc + except httpx.HTTPError as exc: + msg = f"HTTP error during request to {url}: {exc}" + raise GrowattApiError(msg) from exc result = response.text if text else response.json() return extract(result) if extract is not None else result diff --git a/growattServer/exceptions.py b/growattServer/exceptions.py index ba59765..4bec8db 100644 --- a/growattServer/exceptions.py +++ b/growattServer/exceptions.py @@ -1,22 +1,40 @@ """ Exception classes and error code constants for the growattServer library. -Note that in addition to these custom exceptions, methods may also raise exceptions -from the underlying httpx library (httpx.HTTPError and its subclasses) when network -or HTTP errors occur. These are not wrapped and are passed through directly to the -caller. - -Common httpx exceptions to handle: -- httpx.HTTPStatusError: For HTTP error responses (4XX, 5XX) -- httpx.ConnectError: For network connection issues -- httpx.TimeoutException: For request timeouts -- httpx.HTTPError: The base exception for all httpx request exceptions +All transport and HTTP errors from the underlying HTTP library (httpx) are caught +and re-raised as :class:`GrowattApiError` subclasses so that consumers do not need +to depend on the transport library directly. + +**Deprecation notice (v3.0):** +During the transition from ``requests`` to ``httpx``, if the ``requests`` package is +installed, :class:`GrowattApiError` also inherits from +``requests.exceptions.RequestException``. This allows existing consumers that catch +``RequestException`` to continue working, but a deprecation warning will be emitted. +Consumers should migrate to catching :class:`GrowattApiError` (or its subclasses) +instead. The ``requests`` compatibility base class will be removed in a future +major release. """ from __future__ import annotations +import logging from enum import IntEnum +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Deprecation compatibility: if ``requests`` is installed, GrowattApiError +# will also inherit from requests.exceptions.RequestException so that +# existing ``except RequestException`` blocks still work during the +# transition period. +# --------------------------------------------------------------------------- +_has_requests = False +try: + from requests.exceptions import RequestException as _RequestsRequestException + _has_requests = True +except ImportError: + pass + class GrowattV1ApiErrorCode(IntEnum): """ @@ -49,6 +67,69 @@ class GrowattParameterError(GrowattError): """Raised when invalid parameters are provided to API methods.""" +# Build GrowattApiError with optional requests.RequestException base for +# backwards compatibility. Using a tuple of bases lets us conditionally +# include the requests base only when the package is available. +_api_error_bases: tuple[type, ...] = (GrowattError,) +if _has_requests: + _api_error_bases = (GrowattError, _RequestsRequestException) + + +class GrowattApiError(*_api_error_bases): # type: ignore[misc] + """ + Raised when an HTTP or network error occurs during an API request. + + This wraps transport-level errors (e.g. connection failures, timeouts, + HTTP error responses) so that consumers can catch a single library + exception instead of depending on the underlying HTTP library. + + During the deprecation period, this also inherits from + ``requests.exceptions.RequestException`` (if ``requests`` is installed) + for backwards compatibility. A deprecation warning is emitted once to + alert consumers to migrate to catching ``GrowattApiError`` directly. + """ + + _deprecation_warned = False + + def __init__(self, *args: object, **kwargs: object) -> None: + """Initialize and emit a one-time deprecation warning if requests is installed.""" + super().__init__(*args, **kwargs) + if _has_requests and not GrowattApiError._deprecation_warned: + GrowattApiError._deprecation_warned = True + _logger.warning( + "growattServer now raises GrowattApiError instead of " + "requests.RequestException for HTTP/network errors. " + "GrowattApiError currently inherits from RequestException " + "for backwards compatibility, but this will be removed in a " + "future major release. Please update your exception handlers " + "to catch growattServer.GrowattApiError instead." + ) + + +class GrowattApiConnectionError(GrowattApiError): + """Raised when a connection to the Growatt API cannot be established.""" + + +class GrowattApiTimeoutError(GrowattApiError): + """Raised when a request to the Growatt API times out.""" + + +class GrowattApiStatusError(GrowattApiError): + """Raised when the Growatt API returns an HTTP error status (4XX/5XX).""" + + def __init__(self, message: str, status_code: int) -> None: + """ + Initialize the GrowattApiStatusError. + + Args: + message: Human readable error message. + status_code: HTTP status code from the response. + + """ + super().__init__(message) + self.status_code = status_code + + class GrowattV1ApiError(GrowattError): """Raised when a Growatt V1 API request fails or returns an error.""" @@ -66,3 +147,5 @@ def __init__(self, message: str, error_code: int, error_msg: str) -> None: super().__init__(message) self.error_code = error_code self.error_msg = error_msg + + diff --git a/setup.py b/setup.py index f9d7d5c..b943f36 100755 --- a/setup.py +++ b/setup.py @@ -27,4 +27,7 @@ install_requires=[ "httpx", ], + extras_require={ + "compat": ["requests"], + }, ) From 5204e02f824db2581eba59d4c6a4cc3a3e77e901 Mon Sep 17 00:00:00 2001 From: Johan Zander Date: Sun, 7 Jun 2026 20:19:05 +0200 Subject: [PATCH 3/9] Fix incorrect return type annotation for plant_list The method returns a dict (with 'data' and 'totalData' keys) from the API's "back" field, not a list. The annotation incorrectly stated list[dict[str, Any]]. Co-Authored-By: Claude Opus 4.6 --- growattServer/base_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/growattServer/base_api.py b/growattServer/base_api.py index cc0c7c1..7e38c04 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -178,7 +178,7 @@ def login(self, username: str, password: str, is_password_hashed: bool = False) }) return data - def plant_list(self, user_id: str) -> list[dict[str, Any]]: + def plant_list(self, user_id: str) -> dict[str, Any]: """ Get a list of plants connected to this account. @@ -186,7 +186,7 @@ def plant_list(self, user_id: str) -> list[dict[str, Any]]: user_id (str): The ID of the user. Returns: - list: A list of plants connected to the account. + dict: A dictionary containing 'data' (list of plants) and 'totalData' keys. Raises: Exception: If the request to the server fails. From 08e3aad01d7cc13fff897167eac67073a737e509 Mon Sep 17 00:00:00 2001 From: Johan Zander Date: Sun, 7 Jun 2026 20:26:14 +0200 Subject: [PATCH 4/9] Fix __get_all_devices return type and default value The method returns a list of devices (confirmed by TLX examples iterating over the result), not a dict. Fix annotation to list[dict[str, Any]] and default from {} to []. Co-Authored-By: Claude Opus 4.6 --- growattServer/base_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/growattServer/base_api.py b/growattServer/base_api.py index 7e38c04..1ef26bc 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -868,14 +868,14 @@ def inverter_list(self, plant_id: str) -> list[dict[str, Any]]: "This function may be deprecated in the future because naming is not correct, use device_list instead", DeprecationWarning, stacklevel=2) return self.device_list(plant_id) - def __get_all_devices(self, plant_id: str) -> dict[str, Any]: + def __get_all_devices(self, plant_id: str) -> list[dict[str, Any]]: """Get basic plant information with device list.""" response = self.session.get(self.get_url("newTwoPlantAPI.do"), params={"op": "getAllDeviceList", "plantId": plant_id, "language": 1}) - return response.json().get("deviceList", {}) + return response.json().get("deviceList", []) def device_list(self, plant_id: str) -> list[dict[str, Any]]: """Get a list of all devices connected to plant.""" From 9de4a591adc274e6dc60645fcdea046baaf6f406 Mon Sep 17 00:00:00 2001 From: Johan Zander Date: Sun, 7 Jun 2026 21:01:51 +0200 Subject: [PATCH 5/9] Fix review issues: merged params, v1_request exception wrapping, examples - Fix update_inverter_setting passing settings_parameters instead of merged - Fix update_noah_settings passing settings_parameters instead of merged - Wrap v1_request (sync and async) with GrowattApiError exception hierarchy so transport errors are consistently wrapped regardless of code path - Stringify date object in plant_power_overview params - Replace httpx.HTTPError catches in all examples with GrowattApiError - Update docstring Raises sections to reference GrowattApiError - Update docs/README.md migration guidance to reference GrowattApiError Co-Authored-By: Claude Opus 4.6 --- docs/README.md | 2 +- examples/async_min_example.py | 4 +- examples/min_example.py | 4 +- examples/min_example_dashboard.py | 4 +- examples/sph_example.py | 4 +- growattServer/base_api.py | 12 ++-- growattServer/open_api_v1/__init__.py | 72 ++++++++++++------- .../open_api_v1/async_open_api_v1.py | 24 ++++++- .../open_api_v1/devices/async_min.py | 2 +- .../open_api_v1/devices/async_sph.py | 4 +- growattServer/open_api_v1/devices/min.py | 16 ++--- growattServer/open_api_v1/devices/sph.py | 18 ++--- 12 files changed, 101 insertions(+), 65 deletions(-) diff --git a/docs/README.md b/docs/README.md index 793fd12..566358f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ Please refer to the docs for [OpenAPI V1](./openapiv1.md) for it's usage and ava This version replaces the `requests` library with `httpx` for both sync and async HTTP. If your code catches `requests.exceptions.RequestException`, update it to catch -`httpx.HTTPError` instead. See the [exceptions module](../growattServer/exceptions.py) +`growattServer.GrowattApiError` instead. See the [exceptions module](../growattServer/exceptions.py) for details on exception handling. ### Sync/Async Support diff --git a/examples/async_min_example.py b/examples/async_min_example.py index b491a9c..7102da4 100644 --- a/examples/async_min_example.py +++ b/examples/async_min_example.py @@ -10,8 +10,6 @@ import asyncio import json -import httpx - import growattServer # test token from official API docs https://www.showdoc.com.cn/262556420217021/1494053950115877 @@ -54,7 +52,7 @@ async def main(): print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") except growattServer.GrowattParameterError as e: print(f"Parameter Error: {e}") - except httpx.HTTPError as e: + except growattServer.GrowattApiError as e: print(f"Network Error: {e}") diff --git a/examples/min_example.py b/examples/min_example.py index 04d4c9d..eca05fc 100644 --- a/examples/min_example.py +++ b/examples/min_example.py @@ -1,7 +1,5 @@ import json -import httpx - import growattServer """ @@ -88,7 +86,7 @@ print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") except growattServer.GrowattParameterError as e: print(f"Parameter Error: {e}") -except httpx.HTTPError as e: +except growattServer.GrowattApiError as e: print(f"Network Error: {e}") except Exception as e: print(f"Unexpected error: {e}") diff --git a/examples/min_example_dashboard.py b/examples/min_example_dashboard.py index b7da7e1..677b294 100644 --- a/examples/min_example_dashboard.py +++ b/examples/min_example_dashboard.py @@ -1,7 +1,5 @@ import json -import httpx - import growattServer """ @@ -91,7 +89,7 @@ print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") except growattServer.GrowattParameterError as e: print(f"Parameter Error: {e}") -except httpx.HTTPError as e: +except growattServer.GrowattApiError as e: print(f"Network Error: {e}") except Exception as e: print(f"Unexpected error: {e}") diff --git a/examples/sph_example.py b/examples/sph_example.py index f467554..e64e3f7 100644 --- a/examples/sph_example.py +++ b/examples/sph_example.py @@ -9,8 +9,6 @@ import json import os -import httpx - import growattServer # Get the API token from environment variable or use test token @@ -146,7 +144,7 @@ print(f"API Error: {e} (Code: {e.error_code}, Message: {e.error_msg})") except growattServer.GrowattParameterError as e: print(f"Parameter Error: {e}") -except httpx.HTTPError as e: +except growattServer.GrowattApiError as e: print(f"Network Error: {e}") except Exception as e: # noqa: BLE001 print(f"Unexpected error: {e}") diff --git a/growattServer/base_api.py b/growattServer/base_api.py index 4a3c7fb..af16747 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -100,7 +100,7 @@ def get_url(self, page: str) -> str: """Return the page URL.""" return self.server_url + page - def plant_list(self, user_id: str) -> list[dict[str, Any]]: + def plant_list(self, user_id: str) -> dict[str, Any]: """ Get a list of plants connected to this account. @@ -108,7 +108,7 @@ def plant_list(self, user_id: str) -> list[dict[str, Any]]: user_id (str): The ID of the user. Returns: - list: A list of plants connected to the account. + dict: A dictionary containing 'data' (list of plants) and 'totalData' keys. Raises: Exception: If the request to the server fails. @@ -745,12 +745,12 @@ def inverter_list(self, plant_id: str) -> list[dict[str, Any]]: "This function may be deprecated in the future because naming is not correct, use device_list instead", DeprecationWarning, stacklevel=2) return self.device_list(plant_id) - def _get_all_devices(self, plant_id: str) -> dict[str, Any]: + def _get_all_devices(self, plant_id: str) -> list[dict[str, Any]]: """Get basic plant information with device list.""" return self._request( "GET", self.get_url("newTwoPlantAPI.do"), params={"op": "getAllDeviceList", "plantId": plant_id, "language": 1}, - extract=lambda r: r.get("deviceList", {}), + extract=lambda r: r.get("deviceList", []), ) def plant_info(self, plant_id: str) -> dict[str, Any]: @@ -917,7 +917,7 @@ def update_inverter_setting(self, serial_number: str, setting_type: str, return self._request( "POST", self.get_url("newTcpsetAPI.do"), - params=settings_parameters, + params=merged, ) def update_mix_inverter_setting(self, serial_number: str, setting_type: str, parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: @@ -1059,7 +1059,7 @@ def update_noah_settings(self, serial_number: str, setting_type: str, parameters return self._request( "POST", self.get_url("noahDeviceApi/noah/set"), - data=settings_parameters, + data=merged, ) def update_classic_inverter_setting(self, default_parameters: dict[str, Any], parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: diff --git a/growattServer/open_api_v1/__init__.py b/growattServer/open_api_v1/__init__.py index 3c0301b..8517396 100644 --- a/growattServer/open_api_v1/__init__.py +++ b/growattServer/open_api_v1/__init__.py @@ -8,8 +8,16 @@ from enum import Enum from typing import Any +import httpx + from growattServer import GrowattApi -from growattServer.exceptions import GrowattV1ApiError +from growattServer.exceptions import ( + GrowattApiConnectionError, + GrowattApiError, + GrowattApiStatusError, + GrowattApiTimeoutError, + GrowattV1ApiError, +) from .devices import AbstractDevice, Min, ParameterValue, Sph @@ -91,7 +99,7 @@ def plant_list(self) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. Endpoint-specific error codes: 10001 - System error - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494058730404880 @@ -119,7 +127,7 @@ def plant_details(self, plant_id: int) -> dict[str, Any]: 10002 - Power station does not exist 10003 - Power station ID is empty 10004 - User does not exist - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494060394238679 @@ -146,7 +154,7 @@ def plant_energy_overview(self, plant_id: int) -> dict[str, Any]: 10001 - System error 10002 - Power station does not exist 10003 - Power station ID is empty - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494061093808613 @@ -186,7 +194,7 @@ def plant_power_overview( 10001 - System error 10002 - Power station does not exist 10003 - Power station ID is empty or time format is incorrect - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494062656174173 @@ -197,7 +205,7 @@ def plant_power_overview( return self.v1_request( "GET", "plant/power", - params={"plant_id": plant_id, "date": day}, + params={"plant_id": plant_id, "date": str(day)}, operation_name="getting plant power overview", ) @@ -236,7 +244,7 @@ def plant_energy_history( 10002 - Power station does not exist 10003 - Power station ID is empty 10004 - Time format is incorrect - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494061730868556 @@ -363,7 +371,7 @@ def min_detail(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._min_class(self, device_sn).detail() @@ -380,7 +388,7 @@ def min_energy(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._min_class(self, device_sn).energy() @@ -411,7 +419,7 @@ def min_energy_history( Raises: GrowattParameterError: If date interval is invalid (exceeds 7 days). GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._min_class(self, device_sn).energy_history( @@ -430,7 +438,7 @@ def min_settings(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._min_class(self, device_sn).settings() @@ -453,7 +461,7 @@ def min_read_parameter( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._min_class(self, device_sn).read_parameter( @@ -477,7 +485,7 @@ def min_write_parameter(self, device_sn: str, parameter_id: str, parameter_value Raises: GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._min_class(self, device_sn).write_parameter(parameter_id, parameter_values) @@ -502,7 +510,7 @@ def min_write_time_segment( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._min_class(self, device_sn).write_time_segment( @@ -544,7 +552,7 @@ def min_read_time_segments(self, device_sn: str, settings_data: dict[str, Any] | Raises: GrowattV1ApiError: If the API request fails - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._min_class(self, device_sn).read_time_segments(settings_data) @@ -563,7 +571,7 @@ def sph_detail(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._sph_class(self, device_sn).detail() @@ -580,7 +588,7 @@ def sph_energy(self, device_sn: str) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._sph_class(self, device_sn).energy() @@ -611,7 +619,7 @@ def sph_energy_history( Raises: GrowattParameterError: If date interval is invalid (exceeds 7 days). GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._sph_class(self, device_sn).energy_history( @@ -636,7 +644,7 @@ def sph_read_parameter( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._sph_class(self, device_sn).read_parameter( @@ -660,7 +668,7 @@ def sph_write_parameter(self, device_sn: str, parameter_id: str, parameter_value Raises: GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._sph_class(self, device_sn).write_parameter(parameter_id, parameter_values) @@ -702,7 +710,7 @@ def sph_write_ac_charge_times( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._sph_class(self, device_sn).write_ac_charge_times( @@ -744,7 +752,7 @@ def sph_write_ac_discharge_times( Raises: GrowattParameterError: If parameters are invalid. GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._sph_class(self, device_sn).write_ac_discharge_times( @@ -790,7 +798,7 @@ def sph_read_ac_charge_times(self, device_sn: str, settings_data: dict[str, Any] Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._sph_class(self, device_sn).read_ac_charge_times(settings_data) @@ -833,7 +841,7 @@ def sph_read_ac_discharge_times(self, device_sn: str, settings_data: dict[str, A Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ return self._sph_class(self, device_sn).read_ac_discharge_times(settings_data) @@ -861,5 +869,19 @@ def __init__(self, token: str) -> None: def v1_request(self, method: str, endpoint: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, operation_name: str = "API operation") -> dict[str, Any]: """Make a V1 API request and process the response.""" - response = self.session.request(method, self.get_url(endpoint), params=params, data=data) + url = self.get_url(endpoint) + try: + response = self.session.request(method, url, params=params, data=data) + except httpx.TimeoutException as exc: + msg = f"Request to {url} timed out" + raise GrowattApiTimeoutError(msg) from exc + except httpx.ConnectError as exc: + msg = f"Failed to connect to {url}" + raise GrowattApiConnectionError(msg) from exc + except httpx.HTTPStatusError as exc: + msg = f"HTTP {exc.response.status_code} error for {url}" + raise GrowattApiStatusError(msg, exc.response.status_code) from exc + except httpx.HTTPError as exc: + msg = f"HTTP error during request to {url}: {exc}" + raise GrowattApiError(msg) from exc return self.process_response(response.json(), operation_name) diff --git a/growattServer/open_api_v1/async_open_api_v1.py b/growattServer/open_api_v1/async_open_api_v1.py index ee0ac53..d7c9409 100644 --- a/growattServer/open_api_v1/async_open_api_v1.py +++ b/growattServer/open_api_v1/async_open_api_v1.py @@ -4,7 +4,15 @@ from typing import Any +import httpx + from growattServer.async_base_api import AsyncGrowattApi +from growattServer.exceptions import ( + GrowattApiConnectionError, + GrowattApiError, + GrowattApiStatusError, + GrowattApiTimeoutError, +) from . import _OpenApiV1Base from .devices.async_min import AsyncMin @@ -41,5 +49,19 @@ def __init__(self, token: str, session: Any = None) -> None: async def v1_request(self, method: str, endpoint: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, operation_name: str = "API operation") -> dict[str, Any]: """Make a V1 API request and process the response.""" - response = await self.session.request(method, self.get_url(endpoint), params=params, data=data) + url = self.get_url(endpoint) + try: + response = await self.session.request(method, url, params=params, data=data) + except httpx.TimeoutException as exc: + msg = f"Request to {url} timed out" + raise GrowattApiTimeoutError(msg) from exc + except httpx.ConnectError as exc: + msg = f"Failed to connect to {url}" + raise GrowattApiConnectionError(msg) from exc + except httpx.HTTPStatusError as exc: + msg = f"HTTP {exc.response.status_code} error for {url}" + raise GrowattApiStatusError(msg, exc.response.status_code) from exc + except httpx.HTTPError as exc: + msg = f"HTTP error during request to {url}: {exc}" + raise GrowattApiError(msg) from exc return self.process_response(response.json(), operation_name) diff --git a/growattServer/open_api_v1/devices/async_min.py b/growattServer/open_api_v1/devices/async_min.py index 4f5bedd..c3c57d4 100644 --- a/growattServer/open_api_v1/devices/async_min.py +++ b/growattServer/open_api_v1/devices/async_min.py @@ -53,7 +53,7 @@ async def read_time_segments(self, settings_data: dict[str, Any] | None = None) Raises: GrowattV1ApiError: If the API request fails - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ if settings_data is None: diff --git a/growattServer/open_api_v1/devices/async_sph.py b/growattServer/open_api_v1/devices/async_sph.py index f76d6f6..97c66c5 100644 --- a/growattServer/open_api_v1/devices/async_sph.py +++ b/growattServer/open_api_v1/devices/async_sph.py @@ -57,7 +57,7 @@ async def read_ac_charge_times(self, settings_data: dict[str, Any] | None = None Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ if settings_data is None: @@ -102,7 +102,7 @@ async def read_ac_discharge_times(self, settings_data: dict[str, Any] | None = N Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ if settings_data is None: diff --git a/growattServer/open_api_v1/devices/min.py b/growattServer/open_api_v1/devices/min.py index 548b245..728c800 100644 --- a/growattServer/open_api_v1/devices/min.py +++ b/growattServer/open_api_v1/devices/min.py @@ -25,7 +25,7 @@ def detail(self) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. Endpoint-specific error codes: 10001 - System error - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129816412127075 @@ -49,7 +49,7 @@ def energy(self) -> dict[str, Any]: 10001 - System error 10002 - Min does not exist 10003 - Device SN error - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129822090975531 @@ -87,7 +87,7 @@ def energy_history( 10004 - Start date interval has exceeded seven days 10005 - Min does not exist 10011 - Permission is not satisfied - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129764475556048 @@ -125,7 +125,7 @@ def settings(self) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/8696815667375182 @@ -163,7 +163,7 @@ def read_parameter( 10007 - The collector version does not support the reading function 10008 - The collector connects to the server error, please restart and try again 10009 - The read setting parameter type does not exist - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129828239577315 @@ -221,7 +221,7 @@ def write_parameter(self, parameter_id: str, parameter_values: ParameterValue | 10009 - The date and time format is wrong 10012 - Min does not exist 10013 - The end time cannot be less than the start time - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129826876191828 @@ -291,7 +291,7 @@ def write_time_segment( 10009 - The date and time format is wrong 10012 - Min does not exist 10013 - The end time cannot be less than the start time - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129826876191828 @@ -365,7 +365,7 @@ def read_time_segments(self, settings_data: dict[str, Any] | None = None) -> lis Raises: GrowattV1ApiError: If the API request fails - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ if settings_data is None: diff --git a/growattServer/open_api_v1/devices/sph.py b/growattServer/open_api_v1/devices/sph.py index fda2b02..c3ca99e 100644 --- a/growattServer/open_api_v1/devices/sph.py +++ b/growattServer/open_api_v1/devices/sph.py @@ -25,7 +25,7 @@ def detail(self) -> dict[str, Any]: Raises: GrowattV1ApiError: If the API returns an error response. Endpoint-specific error codes: 10001 - System error - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129763571291058 @@ -49,7 +49,7 @@ def energy(self) -> dict[str, Any]: 10001 - System error 10002 - Mix does not exist 10003 - Device SN error - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129764475556048 @@ -86,7 +86,7 @@ def energy_history( 10003 - Date format error 10004 - Date interval exceeds seven days 10005 - Mix does not exist - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129765461123058 @@ -139,7 +139,7 @@ def read_parameter(self, parameter_id: str | None = None, start_address: int | N 10007 - The collector version does not support the reading function 10008 - The collector connects to the server error, please restart and try again 10009 - The read setting parameter type does not exist - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129766954561259 @@ -200,7 +200,7 @@ def write_parameter(self, parameter_id: str, parameter_values: ParameterValue | 10009 - Date and time format is wrong 10012 - Hybrid storage integrated machine does not exist 10013 - End time cannot be less than start time - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129761750718760 @@ -288,7 +288,7 @@ def write_ac_charge_times( 10009 - Date and time format is wrong 10012 - Hybrid storage integrated machine does not exist 10013 - End time cannot be less than start time - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129761750718760 @@ -373,7 +373,7 @@ def write_ac_discharge_times(self, discharge_power: int, discharge_stop_soc: int 10009 - Date and time format is wrong 10012 - Hybrid storage integrated machine does not exist 10013 - End time cannot be less than start time - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/6129761750718760 @@ -522,7 +522,7 @@ def read_ac_charge_times(self, settings_data: dict[str, Any] | None = None) -> d Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ if settings_data is None: @@ -597,7 +597,7 @@ def read_ac_discharge_times(self, settings_data: dict[str, Any] | None = None) - Raises: GrowattParameterError: If neither device_sn nor settings_data is provided. GrowattV1ApiError: If the API request fails. - httpx.HTTPError: If there is an issue with the HTTP request. + GrowattApiError: If there is an issue with the HTTP request. """ if settings_data is None: From 54fa9351ff1cf36a67b7368a1458f53c7c2a0172 Mon Sep 17 00:00:00 2001 From: Johan Zander Date: Sun, 7 Jun 2026 21:38:40 +0200 Subject: [PATCH 6/9] Add strict typing via PEP 561 stubs and fix review issues - Add .pyi type stubs for async classes (PEP 561) providing strict typing - Fix plant_list/device_list MRO conflict with explicit overrides (non-breaking) - Refactor v1_request to delegate to _request with extract callback - Remove event hooks, use explicit raise_for_status() - Switch to warnings.warn(DeprecationWarning) from logging.warning - Add DEFAULT_TIMEOUT=30s with configurable timeout parameter - Add __all__ to devices package for proper exports - Add async detail()/settings() overrides in async device classes - Change process_response return type to Any - Change abstract_device api type to _OpenApiV1Base Co-Authored-By: Claude Opus 4.6 --- docs/architecture.md | 14 +++ growattServer/async_base_api.py | 44 ++++++-- growattServer/async_base_api.pyi | 79 ++++++++++++++ growattServer/base_api.py | 71 +++++++----- growattServer/exceptions.py | 10 +- growattServer/open_api_v1/__init__.py | 52 +++++---- .../open_api_v1/async_open_api_v1.py | 35 ++---- .../open_api_v1/async_open_api_v1.pyi | 102 ++++++++++++++++++ growattServer/open_api_v1/devices/__init__.py | 2 + .../open_api_v1/devices/abstract_device.py | 7 +- .../open_api_v1/devices/async_min.py | 18 +++- .../open_api_v1/devices/async_sph.py | 12 ++- setup.py | 2 +- 13 files changed, 343 insertions(+), 105 deletions(-) create mode 100644 growattServer/async_base_api.pyi create mode 100644 growattServer/open_api_v1/async_open_api_v1.pyi diff --git a/docs/architecture.md b/docs/architecture.md index 11ee8d9..7e8d4fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,6 +116,7 @@ class AsyncSph(Sph): | Layer | Class | Method | Reason | |:------|:------|:-------|:-------| +| Base API | `AsyncGrowattApi` | `plant_list` | Different signature from V1 `plant_list()` | | Base API | `AsyncGrowattApi` | `login` | Post-processes response dict | | Base API | `AsyncGrowattApi` | `device_list` | Chains `plant_info()` then `_get_all_devices()` | | Base API | `AsyncGrowattApi` | `update_plant_settings` | Conditionally calls `plant_settings()` | @@ -125,6 +126,19 @@ class AsyncSph(Sph): All other methods (~60) are shared via base classes with zero duplication. +## Type Safety + +The passthrough pattern means that at runtime, a regular `def` method returns a +coroutine object (not `dict`). To give type checkers correct information, each +async module has a corresponding `.pyi` stub file that redeclares all inherited +methods as `async def`: + +- `async_base_api.pyi` — stubs for `AsyncGrowattApi` +- `open_api_v1/async_open_api_v1.pyi` — stubs for `AsyncOpenApiV1` + +The package includes a `py.typed` marker (PEP 561) so that mypy, pyright, and +other type checkers automatically pick up these stubs. + ## Usage ```python diff --git a/growattServer/async_base_api.py b/growattServer/async_base_api.py index d8b94a4..3fce015 100644 --- a/growattServer/async_base_api.py +++ b/growattServer/async_base_api.py @@ -2,14 +2,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeVar if TYPE_CHECKING: from typing import Self import httpx -from .base_api import _GrowattApiBase, hash_password +from .base_api import DEFAULT_TIMEOUT, _GrowattApiBase, hash_password from .exceptions import ( GrowattApiConnectionError, GrowattApiError, @@ -17,9 +18,7 @@ GrowattApiTimeoutError, ) - -async def _async_raise_for_status(response): - response.raise_for_status() +_T = TypeVar("_T") class AsyncGrowattApi(_GrowattApiBase): @@ -31,7 +30,7 @@ class AsyncGrowattApi(_GrowattApiBase): base method, both of which yield coroutines when ``_request`` is async. """ - def __init__(self, add_random_user_id: bool = False, agent_identifier: str | None = None, session: httpx.AsyncClient | None = None) -> None: + def __init__(self, add_random_user_id: bool = False, agent_identifier: str | None = None, session: httpx.AsyncClient | None = None, timeout: float | None = DEFAULT_TIMEOUT) -> None: """ Initialize the Growatt API client. @@ -39,6 +38,7 @@ def __init__(self, add_random_user_id: bool = False, agent_identifier: str | Non add_random_user_id: Append a short random suffix to the user-agent. agent_identifier: Optional override for the user-agent string. session: Optional httpx.AsyncClient to reuse. + timeout: Request timeout in seconds. Defaults to 30s. Pass None to disable. """ super().__init__(add_random_user_id, agent_identifier) @@ -50,14 +50,13 @@ def __init__(self, add_random_user_id: bool = False, agent_identifier: str | Non self.session = httpx.AsyncClient( headers={"User-Agent": self.agent_identifier}, follow_redirects=True, - timeout=None, # noqa: S113 - event_hooks={"response": [_async_raise_for_status]}, + timeout=timeout, ) self._owns_session = True - async def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Any = None, text: bool = False) -> Any: + async def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], _T] | None = None, text: bool = False) -> Any: """Make an async HTTP request and return the JSON response (or text if text=True).""" - kwargs = {} + kwargs: dict[str, Any] = {} if params is not None: kwargs["params"] = params if data is not None: @@ -66,6 +65,7 @@ async def _request(self, method: str, url: str, *, params: dict[str, Any] | None kwargs["follow_redirects"] = follow_redirects try: response = await self.session.request(method, url, **kwargs) + response.raise_for_status() except httpx.TimeoutException as exc: msg = f"Request to {url} timed out" raise GrowattApiTimeoutError(msg) from exc @@ -96,6 +96,27 @@ async def __aexit__(self, *args: object) -> None: # Methods that need direct session access or chain async calls + async def plant_list(self, user_id: str) -> dict[str, Any]: + """ + Get a list of plants connected to this account. + + Args: + user_id (str): The ID of the user. + + Returns: + dict: A dictionary containing 'data' (list of plants) and 'totalData' keys. + + Raises: + GrowattApiError: If the request to the server fails. + + """ + return await self._request( + "GET", self.get_url("PlantListAPI.do"), + params={"userId": user_id}, + follow_redirects=False, + extract=lambda r: r.get("back", []), + ) + async def login(self, username: str, password: str, is_password_hashed: bool = False) -> dict[str, Any]: """ Log the user in. @@ -165,6 +186,7 @@ async def login(self, username: str, password: str, is_password_hashed: bool = F "userName": username, "password": password }) + response.raise_for_status() data = response.json()["back"] if data["success"]: @@ -230,6 +252,6 @@ async def update_plant_settings(self, plant_id: str, changed_settings: dict[str, response = await self.session.post(self.get_url( "newTwoPlantAPI.do?op=updatePlant"), files=form_settings) + response.raise_for_status() return response.json() - diff --git a/growattServer/async_base_api.pyi b/growattServer/async_base_api.pyi new file mode 100644 index 0000000..e64ce90 --- /dev/null +++ b/growattServer/async_base_api.pyi @@ -0,0 +1,79 @@ +"""Type stubs for async Growatt API client.""" + +from __future__ import annotations + +import datetime +from collections.abc import Callable +from typing import Any, TypeVar + +import httpx + +from .base_api import Timespan + +_T = TypeVar("_T") + + +class AsyncGrowattApi: + server_url: str + agent_identifier: str + session: httpx.AsyncClient + _owns_session: bool + + def __init__(self, add_random_user_id: bool = False, agent_identifier: str | None = None, session: httpx.AsyncClient | None = None, timeout: float | None = ...) -> None: ... + def get_url(self, page: str) -> str: ... + + async def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], _T] | None = None, text: bool = False) -> Any: ... + + async def aclose(self) -> None: ... + async def __aenter__(self) -> AsyncGrowattApi: ... + async def __aexit__(self, *args: object) -> None: ... + + # Methods from _GrowattApiBase re-declared as async + async def plant_list(self, user_id: str) -> dict[str, Any]: ... + async def login(self, username: str, password: str, is_password_hashed: bool = False) -> dict[str, Any]: ... + async def device_list(self, plant_id: str) -> list[dict[str, Any]]: ... + async def update_plant_settings(self, plant_id: str, changed_settings: dict[str, Any], current_settings: dict[str, Any] | None = None) -> dict[str, Any]: ... + + # All inherited methods from _GrowattApiBase + async def plant_detail(self, plant_id: str, timespan: Timespan, date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def plant_list_two(self) -> list[dict[str, Any]]: ... + async def inverter_data(self, inverter_id: str, date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def inverter_detail(self, inverter_id: str) -> dict[str, Any]: ... + async def inverter_detail_two(self, inverter_id: str) -> dict[str, Any]: ... + async def tlx_system_status(self, plant_id: str, tlx_id: str) -> dict[str, Any]: ... + async def tlx_energy_overview(self, plant_id: str, tlx_id: str) -> dict[str, Any]: ... + async def tlx_energy_prod_cons(self, plant_id: str, tlx_id: str, timespan: Timespan = ..., date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def tlx_data(self, tlx_id: str, date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def tlx_detail(self, tlx_id: str) -> dict[str, Any]: ... + async def tlx_params(self, tlx_id: str) -> dict[str, Any]: ... + async def tlx_all_settings(self, tlx_id: str) -> dict[str, Any] | None: ... + async def tlx_enabled_settings(self, tlx_id: str) -> dict[str, Any]: ... + async def tlx_battery_info(self, serial_num: str) -> dict[str, Any]: ... + async def tlx_battery_info_detailed(self, plant_id: str, serial_num: str) -> dict[str, Any]: ... + async def mix_info(self, mix_id: str, plant_id: str | None = None) -> dict[str, Any]: ... + async def mix_totals(self, mix_id: str, plant_id: str) -> dict[str, Any]: ... + async def mix_system_status(self, mix_id: str, plant_id: str) -> dict[str, Any]: ... + async def mix_detail(self, mix_id: str, plant_id: str, timespan: Timespan = ..., date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def get_mix_inverter_settings(self, serial_number: str) -> dict[str, Any]: ... + async def dashboard_data(self, plant_id: str, timespan: Timespan = ..., date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def plant_settings(self, plant_id: str) -> dict[str, Any]: ... + async def storage_detail(self, storage_id: str) -> dict[str, Any]: ... + async def storage_params(self, storage_id: str) -> dict[str, Any]: ... + async def storage_energy_overview(self, plant_id: str, storage_id: str) -> dict[str, Any]: ... + async def inverter_list(self, plant_id: str) -> list[dict[str, Any]]: ... + async def _get_all_devices(self, plant_id: str) -> list[dict[str, Any]]: ... + async def plant_info(self, plant_id: str) -> dict[str, Any]: ... + async def plant_energy_data(self, plant_id: str) -> dict[str, Any]: ... + async def is_plant_noah_system(self, plant_id: str) -> dict[str, Any]: ... + async def noah_system_status(self, serial_number: str) -> dict[str, Any]: ... + async def noah_info(self, serial_number: str) -> dict[str, Any]: ... + async def update_inverter_setting(self, serial_number: str, setting_type: str, default_parameters: dict[str, Any], parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def update_mix_inverter_setting(self, serial_number: str, setting_type: str, parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def update_ac_inverter_setting(self, serial_number: str, setting_type: str, parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def update_tlx_inverter_time_segment(self, serial_number: str, segment_id: int, batt_mode: int, start_time: datetime.time, end_time: datetime.time, enabled: bool) -> dict[str, Any]: ... + async def update_tlx_inverter_setting(self, serial_number: str, setting_type: str, parameter: dict[str, Any] | list[Any] | str) -> dict[str, Any]: ... + async def update_noah_settings(self, serial_number: str, setting_type: str, parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def update_classic_inverter_setting(self, default_parameters: dict[str, Any], parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def set_classic_inverter_active_power_rate(self, serial_number: str, power_rate: int) -> dict[str, Any]: ... + async def set_classic_inverter_on_off(self, serial_number: str, enabled: bool) -> dict[str, Any]: ... + async def classic_inverter_info(self, device_sn: str) -> dict[str, Any]: ... diff --git a/growattServer/base_api.py b/growattServer/base_api.py index af16747..3cf7211 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -10,8 +10,9 @@ import re import secrets import warnings +from collections.abc import Callable from enum import IntEnum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypeVar if TYPE_CHECKING: from typing import Self @@ -26,6 +27,8 @@ GrowattError, ) +_T = TypeVar("_T") + name = "growattServer" BATT_MODE_LOAD_FIRST = 0 @@ -55,8 +58,7 @@ class Timespan(IntEnum): month = 2 -def _raise_for_status(response): - response.raise_for_status() +DEFAULT_TIMEOUT = 30.0 # seconds class _GrowattApiBase: @@ -81,6 +83,14 @@ def __init__(self, add_random_user_id: bool = False, agent_identifier: str | Non random_number = "".join(str(secrets.randbelow(10)) for _ in range(5)) self.agent_identifier += " - " + random_number + def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], Any] | None = None, text: bool = False) -> Any: + """Make an HTTP request. Implemented by subclasses.""" + raise NotImplementedError + + def device_list(self, plant_id: str) -> Any: + """Get device list. Implemented by subclasses.""" + raise NotImplementedError + def _get_date_string(self, timespan: Timespan | None = None, date: datetime.datetime | None = None) -> str: if timespan is not None and not isinstance(timespan, Timespan): raise ValueError("timespan must be a Timespan enum value") @@ -100,27 +110,6 @@ def get_url(self, page: str) -> str: """Return the page URL.""" return self.server_url + page - def plant_list(self, user_id: str) -> dict[str, Any]: - """ - Get a list of plants connected to this account. - - Args: - user_id (str): The ID of the user. - - Returns: - dict: A dictionary containing 'data' (list of plants) and 'totalData' keys. - - Raises: - Exception: If the request to the server fails. - - """ - return self._request( - "GET", self.get_url("PlantListAPI.do"), - params={"userId": user_id}, - follow_redirects=False, - extract=lambda r: r.get("back", []), - ) - def plant_detail(self, plant_id: str, timespan: Timespan, date: datetime.datetime | None = None) -> dict[str, Any]: """ Get plant details for specified timespan. @@ -1276,13 +1265,14 @@ def classic_inverter_info(self, device_sn: str) -> dict[str, Any]: class GrowattApi(_GrowattApiBase): """Base client for Growatt API endpoints.""" - def __init__(self, add_random_user_id: bool = False, agent_identifier: str | None = None) -> None: + def __init__(self, add_random_user_id: bool = False, agent_identifier: str | None = None, timeout: float | None = DEFAULT_TIMEOUT) -> None: """ Initialize the Growatt API client. Args: add_random_user_id: Append a short random suffix to the user-agent. agent_identifier: Optional override for the user-agent string. + timeout: Request timeout in seconds. Defaults to 30s. Pass None to disable. """ super().__init__(add_random_user_id, agent_identifier) @@ -1290,13 +1280,12 @@ def __init__(self, add_random_user_id: bool = False, agent_identifier: str | Non self.session = httpx.Client( headers={"User-Agent": self.agent_identifier}, follow_redirects=True, - timeout=None, # noqa: S113 - event_hooks={"response": [_raise_for_status]}, + timeout=timeout, ) - def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Any = None, text: bool = False) -> Any: + def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], _T] | None = None, text: bool = False) -> Any: """Make an HTTP request and return the JSON response (or text if text=True).""" - kwargs = {} + kwargs: dict[str, Any] = {} if params is not None: kwargs["params"] = params if data is not None: @@ -1305,6 +1294,7 @@ def _request(self, method: str, url: str, *, params: dict[str, Any] | None = Non kwargs["follow_redirects"] = follow_redirects try: response = self.session.request(method, url, **kwargs) + response.raise_for_status() except httpx.TimeoutException as exc: msg = f"Request to {url} timed out" raise GrowattApiTimeoutError(msg) from exc @@ -1334,6 +1324,27 @@ def __exit__(self, *args: object) -> None: # Methods that need direct session access or chain async calls + def plant_list(self, user_id: str) -> dict[str, Any]: + """ + Get a list of plants connected to this account. + + Args: + user_id (str): The ID of the user. + + Returns: + dict: A dictionary containing 'data' (list of plants) and 'totalData' keys. + + Raises: + GrowattApiError: If the request to the server fails. + + """ + return self._request( + "GET", self.get_url("PlantListAPI.do"), + params={"userId": user_id}, + follow_redirects=False, + extract=lambda r: r.get("back", []), + ) + def login(self, username: str, password: str, is_password_hashed: bool = False) -> dict[str, Any]: """ Log the user in. @@ -1403,6 +1414,7 @@ def login(self, username: str, password: str, is_password_hashed: bool = False) "userName": username, "password": password }) + response.raise_for_status() data = response.json()["back"] if data["success"]: @@ -1468,6 +1480,7 @@ def update_plant_settings(self, plant_id: str, changed_settings: dict[str, Any], response = self.session.post(self.get_url( "newTwoPlantAPI.do?op=updatePlant"), files=form_settings) + response.raise_for_status() return response.json() diff --git a/growattServer/exceptions.py b/growattServer/exceptions.py index 4bec8db..86bdded 100644 --- a/growattServer/exceptions.py +++ b/growattServer/exceptions.py @@ -17,11 +17,9 @@ from __future__ import annotations -import logging +import warnings from enum import IntEnum -_logger = logging.getLogger(__name__) - # --------------------------------------------------------------------------- # Deprecation compatibility: if ``requests`` is installed, GrowattApiError # will also inherit from requests.exceptions.RequestException so that @@ -96,13 +94,15 @@ def __init__(self, *args: object, **kwargs: object) -> None: super().__init__(*args, **kwargs) if _has_requests and not GrowattApiError._deprecation_warned: GrowattApiError._deprecation_warned = True - _logger.warning( + warnings.warn( "growattServer now raises GrowattApiError instead of " "requests.RequestException for HTTP/network errors. " "GrowattApiError currently inherits from RequestException " "for backwards compatibility, but this will be removed in a " "future major release. Please update your exception handlers " - "to catch growattServer.GrowattApiError instead." + "to catch growattServer.GrowattApiError instead.", + DeprecationWarning, + stacklevel=2, ) diff --git a/growattServer/open_api_v1/__init__.py b/growattServer/open_api_v1/__init__.py index 8517396..35935ea 100644 --- a/growattServer/open_api_v1/__init__.py +++ b/growattServer/open_api_v1/__init__.py @@ -8,14 +8,9 @@ from enum import Enum from typing import Any -import httpx - from growattServer import GrowattApi +from growattServer.base_api import DEFAULT_TIMEOUT from growattServer.exceptions import ( - GrowattApiConnectionError, - GrowattApiError, - GrowattApiStatusError, - GrowattApiTimeoutError, GrowattV1ApiError, ) @@ -50,6 +45,11 @@ class _OpenApiV1Base: _min_class = Min _sph_class = Sph + api_url: str + + def v1_request(self, method: str, endpoint: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, operation_name: str = "API operation") -> Any: + """Make a V1 API request. Implemented by subclasses.""" + raise NotImplementedError def _create_user_agent(self) -> str: python_version = platform.python_version() @@ -59,7 +59,7 @@ def _create_user_agent(self) -> str: return f"Python/{python_version} ({system} {release}; {machine})" - def process_response(self, response: dict[str, Any], operation_name: str = "API operation") -> dict[str, Any]: + def process_response(self, response: dict[str, Any], operation_name: str = "API operation") -> Any: """ Process API response and handle errors. @@ -294,9 +294,9 @@ def plant_energy_history( operation_name="getting plant energy history", ) - def device_list(self, plant_id: int) -> dict[str, Any]: + def device_list(self, plant_id: int | str) -> dict[str, Any]: """ - Get devices associated with plant. + Get devices associated with plant via V1 API. Note: returned "device_type" mappings: @@ -855,33 +855,31 @@ class OpenApiV1(_OpenApiV1Base, GrowattApi): the public V1 API described here: https://www.showdoc.com.cn/262556420217021/0. """ - def __init__(self, token: str) -> None: + def __init__(self, token: str, timeout: float | None = DEFAULT_TIMEOUT) -> None: """ Initialize the Growatt API client with V1 API support. Args: token (str): API token for authentication (required for V1 API access). + timeout: Request timeout in seconds. Defaults to 30s. Pass None to disable. """ - super().__init__(agent_identifier=self._create_user_agent()) + super().__init__(agent_identifier=self._create_user_agent(), timeout=timeout) self.api_url = f"{self.server_url}v1/" self.session.headers.update({"token": token}) + def plant_list(self) -> dict[str, Any]: # type: ignore[override] + """Get a list of all plants with detailed information (V1 API).""" + return _OpenApiV1Base.plant_list(self) + + def device_list(self, plant_id: int | str) -> dict[str, Any]: # type: ignore[override] + """Get devices associated with plant via V1 API.""" + return _OpenApiV1Base.device_list(self, plant_id) + def v1_request(self, method: str, endpoint: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, operation_name: str = "API operation") -> dict[str, Any]: """Make a V1 API request and process the response.""" - url = self.get_url(endpoint) - try: - response = self.session.request(method, url, params=params, data=data) - except httpx.TimeoutException as exc: - msg = f"Request to {url} timed out" - raise GrowattApiTimeoutError(msg) from exc - except httpx.ConnectError as exc: - msg = f"Failed to connect to {url}" - raise GrowattApiConnectionError(msg) from exc - except httpx.HTTPStatusError as exc: - msg = f"HTTP {exc.response.status_code} error for {url}" - raise GrowattApiStatusError(msg, exc.response.status_code) from exc - except httpx.HTTPError as exc: - msg = f"HTTP error during request to {url}: {exc}" - raise GrowattApiError(msg) from exc - return self.process_response(response.json(), operation_name) + return self._request( + method, self.get_url(endpoint), + params=params, data=data, + extract=lambda r: self.process_response(r, operation_name), + ) diff --git a/growattServer/open_api_v1/async_open_api_v1.py b/growattServer/open_api_v1/async_open_api_v1.py index d7c9409..ec3dce9 100644 --- a/growattServer/open_api_v1/async_open_api_v1.py +++ b/growattServer/open_api_v1/async_open_api_v1.py @@ -4,15 +4,8 @@ from typing import Any -import httpx - from growattServer.async_base_api import AsyncGrowattApi -from growattServer.exceptions import ( - GrowattApiConnectionError, - GrowattApiError, - GrowattApiStatusError, - GrowattApiTimeoutError, -) +from growattServer.base_api import DEFAULT_TIMEOUT from . import _OpenApiV1Base from .devices.async_min import AsyncMin @@ -34,34 +27,24 @@ class AsyncOpenApiV1(_OpenApiV1Base, AsyncGrowattApi): _min_class = AsyncMin _sph_class = AsyncSph - def __init__(self, token: str, session: Any = None) -> None: + def __init__(self, token: str, session: Any = None, timeout: float | None = DEFAULT_TIMEOUT) -> None: """ Initialize the async Growatt API client with V1 API support. Args: token (str): API token for authentication (required for V1 API access). session: Optional httpx.AsyncClient to reuse. + timeout: Request timeout in seconds. Defaults to 30s. Pass None to disable. """ - super().__init__(agent_identifier=self._create_user_agent(), session=session) + super().__init__(agent_identifier=self._create_user_agent(), session=session, timeout=timeout) self.api_url = f"{self.server_url}v1/" self.session.headers.update({"token": token}) async def v1_request(self, method: str, endpoint: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, operation_name: str = "API operation") -> dict[str, Any]: """Make a V1 API request and process the response.""" - url = self.get_url(endpoint) - try: - response = await self.session.request(method, url, params=params, data=data) - except httpx.TimeoutException as exc: - msg = f"Request to {url} timed out" - raise GrowattApiTimeoutError(msg) from exc - except httpx.ConnectError as exc: - msg = f"Failed to connect to {url}" - raise GrowattApiConnectionError(msg) from exc - except httpx.HTTPStatusError as exc: - msg = f"HTTP {exc.response.status_code} error for {url}" - raise GrowattApiStatusError(msg, exc.response.status_code) from exc - except httpx.HTTPError as exc: - msg = f"HTTP error during request to {url}: {exc}" - raise GrowattApiError(msg) from exc - return self.process_response(response.json(), operation_name) + return await self._request( + method, self.get_url(endpoint), + params=params, data=data, + extract=lambda r: self.process_response(r, operation_name), + ) diff --git a/growattServer/open_api_v1/async_open_api_v1.pyi b/growattServer/open_api_v1/async_open_api_v1.pyi new file mode 100644 index 0000000..96e0677 --- /dev/null +++ b/growattServer/open_api_v1/async_open_api_v1.pyi @@ -0,0 +1,102 @@ +"""Type stubs for async OpenApi V1 client.""" + +from __future__ import annotations + +import datetime +from datetime import date, time +from typing import Any + +import httpx + +from growattServer.async_base_api import AsyncGrowattApi +from growattServer.base_api import Timespan +from growattServer.open_api_v1.devices import ParameterValue +from growattServer.open_api_v1.devices.abstract_device import AbstractDevice +from growattServer.open_api_v1.devices.async_min import AsyncMin +from growattServer.open_api_v1.devices.async_sph import AsyncSph + + +class AsyncOpenApiV1(AsyncGrowattApi): + _min_class: type[AsyncMin] + _sph_class: type[AsyncSph] + api_url: str + + def __init__(self, token: str, session: Any = None, timeout: float | None = ...) -> None: ... + + async def v1_request(self, method: str, endpoint: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, operation_name: str = ...) -> dict[str, Any]: ... + + # V1 API methods (from _OpenApiV1Base) + async def plant_list(self) -> dict[str, Any]: ... # type: ignore[override] + async def plant_details(self, plant_id: int) -> dict[str, Any]: ... + async def plant_energy_overview(self, plant_id: int) -> dict[str, Any]: ... + async def plant_power_overview(self, plant_id: int, day: str | date | None = None) -> dict[str, Any]: ... + async def plant_energy_history(self, plant_id: int, start_date: date | None = None, end_date: date | None = None, time_unit: str = ..., page: int | None = None, perpage: int | None = None) -> dict[str, Any]: ... + async def device_list(self, plant_id: int | str) -> dict[str, Any]: ... # type: ignore[override] + def get_device(self, device_sn: str, device_type: int) -> AbstractDevice | None: ... + async def min_detail(self, device_sn: str) -> dict[str, Any]: ... + async def min_energy(self, device_sn: str) -> dict[str, Any]: ... + async def min_energy_history(self, device_sn: str, start_date: date | None = None, end_date: date | None = None, timezone: str | None = None, page: int | None = None, limit: int | None = None) -> dict[str, Any]: ... + async def min_settings(self, device_sn: str) -> dict[str, Any]: ... + async def min_read_parameter(self, device_sn: str, parameter_id: str, start_address: int | None = None, end_address: int | None = None) -> dict[str, Any]: ... + async def min_write_parameter(self, device_sn: str, parameter_id: str, parameter_values: ParameterValue | None = None) -> dict[str, Any]: ... + async def min_write_time_segment(self, device_sn: str, segment_id: int, batt_mode: int, start_time: time, end_time: time, enabled: bool = ...) -> dict[str, Any]: ... + async def min_read_time_segments(self, device_sn: str, settings_data: dict[str, Any] | None = None) -> list[dict[str, Any]]: ... + async def sph_detail(self, device_sn: str) -> dict[str, Any]: ... + async def sph_energy(self, device_sn: str) -> dict[str, Any]: ... + async def sph_energy_history(self, device_sn: str, start_date: date | None = None, end_date: date | None = None, timezone: str | None = None, page: int | None = None, limit: int | None = None) -> dict[str, Any]: ... + async def sph_read_parameter(self, device_sn: str, parameter_id: str | None = None, start_address: int | None = None, end_address: int | None = None) -> dict[str, Any]: ... + async def sph_write_parameter(self, device_sn: str, parameter_id: str, parameter_values: ParameterValue | None = None) -> dict[str, Any]: ... + async def sph_write_ac_charge_times(self, device_sn: str, charge_power: int, charge_stop_soc: int, mains_enabled: bool, periods: list[dict[str, Any]]) -> dict[str, Any]: ... + async def sph_write_ac_discharge_times(self, device_sn: str, discharge_power: int, discharge_stop_soc: int, periods: list[dict[str, Any]]) -> dict[str, Any]: ... + async def sph_read_ac_charge_times(self, device_sn: str, settings_data: dict[str, Any] | None = None) -> dict[str, Any]: ... + async def sph_read_ac_discharge_times(self, device_sn: str, settings_data: dict[str, Any] | None = None) -> dict[str, Any]: ... + + # Inherited from AsyncGrowattApi (legacy API methods) + async def plant_detail(self, plant_id: str, timespan: Timespan, date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def plant_list_two(self) -> list[dict[str, Any]]: ... + async def inverter_data(self, inverter_id: str, date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def inverter_detail(self, inverter_id: str) -> dict[str, Any]: ... + async def inverter_detail_two(self, inverter_id: str) -> dict[str, Any]: ... + async def tlx_system_status(self, plant_id: str, tlx_id: str) -> dict[str, Any]: ... + async def tlx_energy_overview(self, plant_id: str, tlx_id: str) -> dict[str, Any]: ... + async def tlx_energy_prod_cons(self, plant_id: str, tlx_id: str, timespan: Timespan = ..., date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def tlx_data(self, tlx_id: str, date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def tlx_detail(self, tlx_id: str) -> dict[str, Any]: ... + async def tlx_params(self, tlx_id: str) -> dict[str, Any]: ... + async def tlx_all_settings(self, tlx_id: str) -> dict[str, Any] | None: ... + async def tlx_enabled_settings(self, tlx_id: str) -> dict[str, Any]: ... + async def tlx_battery_info(self, serial_num: str) -> dict[str, Any]: ... + async def tlx_battery_info_detailed(self, plant_id: str, serial_num: str) -> dict[str, Any]: ... + async def mix_info(self, mix_id: str, plant_id: str | None = None) -> dict[str, Any]: ... + async def mix_totals(self, mix_id: str, plant_id: str) -> dict[str, Any]: ... + async def mix_system_status(self, mix_id: str, plant_id: str) -> dict[str, Any]: ... + async def mix_detail(self, mix_id: str, plant_id: str, timespan: Timespan = ..., date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def get_mix_inverter_settings(self, serial_number: str) -> dict[str, Any]: ... + async def dashboard_data(self, plant_id: str, timespan: Timespan = ..., date: datetime.datetime | None = None) -> dict[str, Any]: ... + async def plant_settings(self, plant_id: str) -> dict[str, Any]: ... + async def storage_detail(self, storage_id: str) -> dict[str, Any]: ... + async def storage_params(self, storage_id: str) -> dict[str, Any]: ... + async def storage_energy_overview(self, plant_id: str, storage_id: str) -> dict[str, Any]: ... + async def inverter_list(self, plant_id: str) -> list[dict[str, Any]]: ... + async def plant_info(self, plant_id: str) -> dict[str, Any]: ... + async def plant_energy_data(self, plant_id: str) -> dict[str, Any]: ... + async def is_plant_noah_system(self, plant_id: str) -> dict[str, Any]: ... + async def noah_system_status(self, serial_number: str) -> dict[str, Any]: ... + async def noah_info(self, serial_number: str) -> dict[str, Any]: ... + async def update_inverter_setting(self, serial_number: str, setting_type: str, default_parameters: dict[str, Any], parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def update_mix_inverter_setting(self, serial_number: str, setting_type: str, parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def update_ac_inverter_setting(self, serial_number: str, setting_type: str, parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def update_tlx_inverter_time_segment(self, serial_number: str, segment_id: int, batt_mode: int, start_time: datetime.time, end_time: datetime.time, enabled: bool) -> dict[str, Any]: ... + async def update_tlx_inverter_setting(self, serial_number: str, setting_type: str, parameter: dict[str, Any] | list[Any] | str) -> dict[str, Any]: ... + async def update_noah_settings(self, serial_number: str, setting_type: str, parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def update_classic_inverter_setting(self, default_parameters: dict[str, Any], parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: ... + async def set_classic_inverter_active_power_rate(self, serial_number: str, power_rate: int) -> dict[str, Any]: ... + async def set_classic_inverter_on_off(self, serial_number: str, enabled: bool) -> dict[str, Any]: ... + async def classic_inverter_info(self, device_sn: str) -> dict[str, Any]: ... + + # Context manager (from AsyncGrowattApi) + async def aclose(self) -> None: ... + async def __aenter__(self) -> AsyncOpenApiV1: ... + async def __aexit__(self, *args: object) -> None: ... + async def login(self, username: str, password: str, is_password_hashed: bool = False) -> dict[str, Any]: ... + async def update_plant_settings(self, plant_id: str, changed_settings: dict[str, Any], current_settings: dict[str, Any] | None = None) -> dict[str, Any]: ... diff --git a/growattServer/open_api_v1/devices/__init__.py b/growattServer/open_api_v1/devices/__init__.py index c471130..1483b80 100644 --- a/growattServer/open_api_v1/devices/__init__.py +++ b/growattServer/open_api_v1/devices/__init__.py @@ -6,3 +6,5 @@ from .async_sph import AsyncSph # noqa: F401 from .min import Min # noqa: F401 from .sph import Sph # noqa: F401 + +__all__ = ["AbstractDevice", "AsyncMin", "AsyncSph", "Min", "ParameterValue", "Sph"] diff --git a/growattServer/open_api_v1/devices/abstract_device.py b/growattServer/open_api_v1/devices/abstract_device.py index 2125116..681b29d 100644 --- a/growattServer/open_api_v1/devices/abstract_device.py +++ b/growattServer/open_api_v1/devices/abstract_device.py @@ -7,8 +7,7 @@ from growattServer.exceptions import GrowattParameterError if TYPE_CHECKING: - from growattServer.open_api_v1 import OpenApiV1 - from growattServer.open_api_v1.async_open_api_v1 import AsyncOpenApiV1 + from growattServer.open_api_v1 import _OpenApiV1Base type ParameterValue = str | float | bool | list[Any] | dict[str, Any] @@ -24,7 +23,9 @@ class ReadParamResponse(TypedDict): class AbstractDevice: """Abstract device type. Must not be used directly.""" - def __init__(self, api: OpenApiV1 | AsyncOpenApiV1, device_sn: str) -> None: + api: _OpenApiV1Base + + def __init__(self, api: _OpenApiV1Base, device_sn: str) -> None: """ Initialize the device with the bare minimum being the device_sn. diff --git a/growattServer/open_api_v1/devices/async_min.py b/growattServer/open_api_v1/devices/async_min.py index c3c57d4..14a2969 100644 --- a/growattServer/open_api_v1/devices/async_min.py +++ b/growattServer/open_api_v1/devices/async_min.py @@ -18,7 +18,23 @@ class AsyncMin(Min): Only methods that chain async calls need explicit overrides. """ - async def read_time_segments(self, settings_data: dict[str, Any] | None = None) -> list[dict[str, Any]]: + async def detail(self) -> dict[str, Any]: # type: ignore[override] + """Get detailed data for a MIN inverter (async).""" + return await self.api.v1_request( + "GET", "device/tlx/tlx_data_info", + params={"device_sn": self.device_sn}, + operation_name="getting MIN inverter details", + ) + + async def settings(self) -> dict[str, Any]: # type: ignore[override] + """Get settings for a MIN inverter (async).""" + return await self.api.v1_request( + "GET", "device/tlx/tlx_set_info", + params={"device_sn": self.device_sn}, + operation_name="getting MIN inverter settings", + ) + + async def read_time_segments(self, settings_data: dict[str, Any] | None = None) -> list[dict[str, Any]]: # type: ignore[override] """ Read Time-of-Use (TOU) settings from a Growatt MIN/TLX inverter. diff --git a/growattServer/open_api_v1/devices/async_sph.py b/growattServer/open_api_v1/devices/async_sph.py index 97c66c5..e7db344 100644 --- a/growattServer/open_api_v1/devices/async_sph.py +++ b/growattServer/open_api_v1/devices/async_sph.py @@ -18,7 +18,15 @@ class AsyncSph(Sph): Only methods that chain async calls need explicit overrides. """ - async def read_ac_charge_times(self, settings_data: dict[str, Any] | None = None) -> dict[str, Any]: + async def detail(self) -> dict[str, Any]: # type: ignore[override] + """Get detailed data for an SPH inverter (async).""" + return await self.api.v1_request( + "GET", "device/mix/mix_data_info", + params={"device_sn": self.device_sn}, + operation_name="getting SPH inverter details", + ) + + async def read_ac_charge_times(self, settings_data: dict[str, Any] | None = None) -> dict[str, Any]: # type: ignore[override] """ Read AC charge time periods and settings from an SPH inverter. @@ -64,7 +72,7 @@ async def read_ac_charge_times(self, settings_data: dict[str, Any] | None = None settings_data = await self.detail() return self._parse_ac_charge_settings(settings_data) - async def read_ac_discharge_times(self, settings_data: dict[str, Any] | None = None) -> dict[str, Any]: + async def read_ac_discharge_times(self, settings_data: dict[str, Any] | None = None) -> dict[str, Any]: # type: ignore[override] """ Read AC discharge time periods and settings from an SPH inverter. diff --git a/setup.py b/setup.py index b943f36..6125ab6 100755 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ long_description_content_type="text/markdown", url="https://github.com/indykoning/PyPi_GrowattServer", packages=setuptools.find_packages(), - package_data={"growattServer": ["py.typed"]}, + package_data={"growattServer": ["py.typed", "*.pyi", "**/*.pyi"]}, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", From 8d7976007d854134db76f269831537904956c3f2 Mon Sep 17 00:00:00 2001 From: Johan Zander Date: Sun, 7 Jun 2026 21:42:12 +0200 Subject: [PATCH 7/9] Fix ruff and mypy CI failures - Move Callable import into TYPE_CHECKING block (TC003) - Remove docstrings and __future__ annotations from .pyi stubs (PYI021/PYI044) - Use Self return type for __aenter__ in stubs (PYI034) - Remove unused noqa directives from devices __init__ (RUF100) - Remove unused httpx import from V1 stub (F401) Co-Authored-By: Claude Opus 4.6 --- growattServer/async_base_api.py | 2 +- growattServer/async_base_api.pyi | 8 ++------ growattServer/base_api.py | 2 +- growattServer/open_api_v1/async_open_api_v1.pyi | 11 ++--------- growattServer/open_api_v1/devices/__init__.py | 10 +++++----- 5 files changed, 11 insertions(+), 22 deletions(-) diff --git a/growattServer/async_base_api.py b/growattServer/async_base_api.py index 3fce015..67590a3 100644 --- a/growattServer/async_base_api.py +++ b/growattServer/async_base_api.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Callable from typing import TYPE_CHECKING, Any, TypeVar if TYPE_CHECKING: + from collections.abc import Callable from typing import Self import httpx diff --git a/growattServer/async_base_api.pyi b/growattServer/async_base_api.pyi index e64ce90..cc451ce 100644 --- a/growattServer/async_base_api.pyi +++ b/growattServer/async_base_api.pyi @@ -1,10 +1,6 @@ -"""Type stubs for async Growatt API client.""" - -from __future__ import annotations - import datetime from collections.abc import Callable -from typing import Any, TypeVar +from typing import Any, Self, TypeVar import httpx @@ -25,7 +21,7 @@ class AsyncGrowattApi: async def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], _T] | None = None, text: bool = False) -> Any: ... async def aclose(self) -> None: ... - async def __aenter__(self) -> AsyncGrowattApi: ... + async def __aenter__(self) -> Self: ... async def __aexit__(self, *args: object) -> None: ... # Methods from _GrowattApiBase re-declared as async diff --git a/growattServer/base_api.py b/growattServer/base_api.py index 3cf7211..2c463d9 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -10,11 +10,11 @@ import re import secrets import warnings -from collections.abc import Callable from enum import IntEnum from typing import TYPE_CHECKING, Any, TypeVar if TYPE_CHECKING: + from collections.abc import Callable from typing import Self import httpx diff --git a/growattServer/open_api_v1/async_open_api_v1.pyi b/growattServer/open_api_v1/async_open_api_v1.pyi index 96e0677..4217511 100644 --- a/growattServer/open_api_v1/async_open_api_v1.pyi +++ b/growattServer/open_api_v1/async_open_api_v1.pyi @@ -1,12 +1,6 @@ -"""Type stubs for async OpenApi V1 client.""" - -from __future__ import annotations - import datetime from datetime import date, time -from typing import Any - -import httpx +from typing import Any, Self from growattServer.async_base_api import AsyncGrowattApi from growattServer.base_api import Timespan @@ -15,7 +9,6 @@ from growattServer.open_api_v1.devices.abstract_device import AbstractDevice from growattServer.open_api_v1.devices.async_min import AsyncMin from growattServer.open_api_v1.devices.async_sph import AsyncSph - class AsyncOpenApiV1(AsyncGrowattApi): _min_class: type[AsyncMin] _sph_class: type[AsyncSph] @@ -96,7 +89,7 @@ class AsyncOpenApiV1(AsyncGrowattApi): # Context manager (from AsyncGrowattApi) async def aclose(self) -> None: ... - async def __aenter__(self) -> AsyncOpenApiV1: ... + async def __aenter__(self) -> Self: ... async def __aexit__(self, *args: object) -> None: ... async def login(self, username: str, password: str, is_password_hashed: bool = False) -> dict[str, Any]: ... async def update_plant_settings(self, plant_id: str, changed_settings: dict[str, Any], current_settings: dict[str, Any] | None = None) -> dict[str, Any]: ... diff --git a/growattServer/open_api_v1/devices/__init__.py b/growattServer/open_api_v1/devices/__init__.py index 1483b80..220f49f 100644 --- a/growattServer/open_api_v1/devices/__init__.py +++ b/growattServer/open_api_v1/devices/__init__.py @@ -1,10 +1,10 @@ # noqa: D104 from __future__ import annotations -from .abstract_device import AbstractDevice, ParameterValue # noqa: F401 -from .async_min import AsyncMin # noqa: F401 -from .async_sph import AsyncSph # noqa: F401 -from .min import Min # noqa: F401 -from .sph import Sph # noqa: F401 +from .abstract_device import AbstractDevice, ParameterValue +from .async_min import AsyncMin +from .async_sph import AsyncSph +from .min import Min +from .sph import Sph __all__ = ["AbstractDevice", "AsyncMin", "AsyncSph", "Min", "ParameterValue", "Sph"] From 378e94d8003ae2b3b98d0101e57956e8146b87e3 Mon Sep 17 00:00:00 2001 From: Johan Zander Date: Tue, 9 Jun 2026 00:06:05 +0200 Subject: [PATCH 8/9] Add test scaffold with 117 tests covering sync/async parity - pytest + pytest-asyncio test suite using httpx.MockTransport - Tests for _request(), v1_request(), exception mapping, device methods (Min, Sph), coroutine-passthrough parity, and helper functions - CI workflow: pytest on Python 3.12/3.13, stubtest for .pyi sync - Fix _GrowattApiBase._request signature (missing files param) - Move deprecation warning to import-time to avoid firing during except Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 28 +++ growattServer/base_api.py | 2 +- growattServer/exceptions.py | 35 ++- pyproject.toml | 3 + setup.py | 5 + stubtest_allowlist.txt | 7 + stubtest_mypy.ini | 3 + tests/__init__.py | 0 tests/conftest.py | 148 +++++++++++++ tests/test_base_api.py | 155 ++++++++++++++ tests/test_coroutine_passthrough.py | 144 +++++++++++++ tests/test_device_min.py | 317 +++++++++++++++++++++++++++ tests/test_device_sph.py | 318 ++++++++++++++++++++++++++++ tests/test_exception_mapping.py | 165 +++++++++++++++ tests/test_helpers.py | 79 +++++++ tests/test_v1_api.py | 214 +++++++++++++++++++ 16 files changed, 1603 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 pyproject.toml create mode 100644 stubtest_allowlist.txt create mode 100644 stubtest_mypy.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_base_api.py create mode 100644 tests/test_coroutine_passthrough.py create mode 100644 tests/test_device_min.py create mode 100644 tests/test_device_sph.py create mode 100644 tests/test_exception_mapping.py create mode 100644 tests/test_helpers.py create mode 100644 tests/test_v1_api.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d0165e4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Tests +on: + - push + - pull_request + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[dev]" + - run: pytest --tb=short -q + + stubtest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . mypy + - run: python -m mypy.stubtest growattServer.async_base_api growattServer.open_api_v1.async_open_api_v1 --mypy-config-file stubtest_mypy.ini --allowlist stubtest_allowlist.txt --ignore-unused-allowlist diff --git a/growattServer/base_api.py b/growattServer/base_api.py index 2c463d9..38ef288 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -83,7 +83,7 @@ def __init__(self, add_random_user_id: bool = False, agent_identifier: str | Non random_number = "".join(str(secrets.randbelow(10)) for _ in range(5)) self.agent_identifier += " - " + random_number - def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], Any] | None = None, text: bool = False) -> Any: + def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, files: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], Any] | None = None, text: bool = False) -> Any: """Make an HTTP request. Implemented by subclasses.""" raise NotImplementedError diff --git a/growattServer/exceptions.py b/growattServer/exceptions.py index 86bdded..67e45ac 100644 --- a/growattServer/exceptions.py +++ b/growattServer/exceptions.py @@ -83,27 +83,24 @@ class GrowattApiError(*_api_error_bases): # type: ignore[misc] During the deprecation period, this also inherits from ``requests.exceptions.RequestException`` (if ``requests`` is installed) - for backwards compatibility. A deprecation warning is emitted once to - alert consumers to migrate to catching ``GrowattApiError`` directly. + for backwards compatibility. """ - _deprecation_warned = False - - def __init__(self, *args: object, **kwargs: object) -> None: - """Initialize and emit a one-time deprecation warning if requests is installed.""" - super().__init__(*args, **kwargs) - if _has_requests and not GrowattApiError._deprecation_warned: - GrowattApiError._deprecation_warned = True - warnings.warn( - "growattServer now raises GrowattApiError instead of " - "requests.RequestException for HTTP/network errors. " - "GrowattApiError currently inherits from RequestException " - "for backwards compatibility, but this will be removed in a " - "future major release. Please update your exception handlers " - "to catch growattServer.GrowattApiError instead.", - DeprecationWarning, - stacklevel=2, - ) + +# Emit the deprecation warning once at import time so it doesn't fire +# during exception handling (which would be confusing for consumers who +# already catch GrowattApiError correctly). +if _has_requests: + warnings.warn( + "growattServer now raises GrowattApiError instead of " + "requests.RequestException for HTTP/network errors. " + "GrowattApiError currently inherits from RequestException " + "for backwards compatibility, but this will be removed in a " + "future major release. Please update your exception handlers " + "to catch growattServer.GrowattApiError instead.", + DeprecationWarning, + stacklevel=2, + ) class GrowattApiConnectionError(GrowattApiError): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..116b137 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/setup.py b/setup.py index 6125ab6..683b8a3 100755 --- a/setup.py +++ b/setup.py @@ -29,5 +29,10 @@ ], extras_require={ "compat": ["requests"], + "dev": [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "mypy", + ], }, ) diff --git a/stubtest_allowlist.txt b/stubtest_allowlist.txt new file mode 100644 index 0000000..81438de --- /dev/null +++ b/stubtest_allowlist.txt @@ -0,0 +1,7 @@ +# Stubtest allowlist for growattServer +# +# The coroutine-passthrough pattern means some methods are regular `def` +# at runtime (inherited from the shared base class) but declared as +# `async def` in the .pyi stubs. If stubtest flags these, add them here. +# +# Currently empty — stubtest passes without any allowlist entries. diff --git a/stubtest_mypy.ini b/stubtest_mypy.ini new file mode 100644 index 0000000..822da8c --- /dev/null +++ b/stubtest_mypy.ini @@ -0,0 +1,3 @@ +[mypy] +ignore_missing_imports = True +disable_error_code = override diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..187f579 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,148 @@ +"""Shared fixtures and helpers for growattServer tests.""" + +from __future__ import annotations + +import inspect +import json +from typing import Any + +import httpx +import pytest + +from growattServer import ( + AsyncGrowattApi, + AsyncOpenApiV1, + GrowattApi, + OpenApiV1, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def invoke(obj: Any, method_name: str, *args: Any, **kwargs: Any) -> Any: + """Call a method on either a sync or async API object, awaiting if needed.""" + method = getattr(obj, method_name) + result = method(*args, **kwargs) + if inspect.isawaitable(result): + return await result + return result + + +def json_response(data: dict | list, status_code: int = 200) -> httpx.Response: + """Build an httpx.Response with a JSON body.""" + return httpx.Response( + status_code, + content=json.dumps(data).encode(), + headers={"content-type": "application/json"}, + ) + + +def v1_success(data: Any = None) -> dict: + """Build a V1 API success envelope.""" + return {"error_code": 0, "error_msg": "", "data": data or {}} + + +def v1_error(error_code: int = 10001, error_msg: str = "System error") -> dict: + """Build a V1 API error envelope.""" + return {"error_code": error_code, "error_msg": error_msg} + + +# --------------------------------------------------------------------------- +# Transport helpers +# --------------------------------------------------------------------------- + +def make_mock_transport(handler): + """Create a sync mock transport from a request handler. + + ``handler(request) -> httpx.Response`` + """ + return httpx.MockTransport(handler) + + +def make_async_mock_transport(handler): + """Create an async mock transport from an async request handler. + + ``async handler(request) -> httpx.Response`` + """ + return httpx.MockTransport(handler) + + +# --------------------------------------------------------------------------- +# Base API fixtures (sync / async) +# --------------------------------------------------------------------------- + +@pytest.fixture() +def sync_api_factory(): + """Factory for creating a sync GrowattApi with a mock transport.""" + apis = [] + + def _factory(handler): + transport = make_mock_transport(handler) + api = GrowattApi(timeout=5.0) + api.session = httpx.Client(transport=transport, headers=api.session.headers) + apis.append(api) + return api + + yield _factory + for api in apis: + api.close() + + +@pytest.fixture() +def async_api_factory(): + """Factory for creating an AsyncGrowattApi with a mock transport.""" + apis = [] + + def _factory(handler): + transport = make_async_mock_transport(handler) + session = httpx.AsyncClient(transport=transport) + api = AsyncGrowattApi(session=session, timeout=5.0) + apis.append(api) + return api + + yield _factory + # async teardown handled by caller + + +# --------------------------------------------------------------------------- +# V1 API fixtures (sync / async) +# --------------------------------------------------------------------------- + +@pytest.fixture() +def sync_v1_factory(): + """Factory for creating a sync OpenApiV1 with a mock transport.""" + apis = [] + + def _factory(handler): + transport = make_mock_transport(handler) + api = OpenApiV1(token="test-token", timeout=5.0) + api.session = httpx.Client( + transport=transport, + headers={**dict(api.session.headers), "token": "test-token"}, + ) + apis.append(api) + return api + + yield _factory + for api in apis: + api.close() + + +@pytest.fixture() +def async_v1_factory(): + """Factory for creating an AsyncOpenApiV1 with a mock transport.""" + apis = [] + + def _factory(handler): + transport = make_async_mock_transport(handler) + session = httpx.AsyncClient( + transport=transport, + headers={"token": "test-token"}, + ) + api = AsyncOpenApiV1(token="test-token", session=session, timeout=5.0) + apis.append(api) + return api + + yield _factory diff --git a/tests/test_base_api.py b/tests/test_base_api.py new file mode 100644 index 0000000..1269aca --- /dev/null +++ b/tests/test_base_api.py @@ -0,0 +1,155 @@ +"""Tests for GrowattApi and AsyncGrowattApi _request() and context managers.""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from growattServer import AsyncGrowattApi, GrowattApi + +from .conftest import invoke, json_response + + +# --------------------------------------------------------------------------- +# Sync _request tests +# --------------------------------------------------------------------------- + +class TestSyncRequest: + def _make_api(self, handler): + transport = httpx.MockTransport(handler) + api = GrowattApi(timeout=5.0) + api.session = httpx.Client(transport=transport, headers=api.session.headers) + return api + + def test_get_json(self): + def handler(request): + assert request.method == "GET" + return json_response({"key": "value"}) + + api = self._make_api(handler) + result = api._request("GET", "https://example.com/test") + assert result == {"key": "value"} + api.close() + + def test_post_with_data(self): + def handler(request): + assert request.method == "POST" + body = request.content.decode() + assert "field=val" in body + return json_response({"ok": True}) + + api = self._make_api(handler) + result = api._request("POST", "https://example.com/test", data={"field": "val"}) + assert result == {"ok": True} + api.close() + + def test_extract_callback(self): + def handler(request): + return json_response({"back": {"plants": [1, 2]}}) + + api = self._make_api(handler) + result = api._request( + "GET", "https://example.com/test", + extract=lambda r: r["back"]["plants"], + ) + assert result == [1, 2] + api.close() + + def test_text_mode(self): + def handler(request): + return httpx.Response(200, text="hello") + + api = self._make_api(handler) + result = api._request("GET", "https://example.com/test", text=True) + assert "" in result + api.close() + + def test_params_passed(self): + def handler(request): + assert request.url.params["userId"] == "123" + return json_response({"ok": True}) + + api = self._make_api(handler) + api._request("GET", "https://example.com/test", params={"userId": "123"}) + api.close() + + +# --------------------------------------------------------------------------- +# Async _request tests +# --------------------------------------------------------------------------- + +class TestAsyncRequest: + def _make_api(self, handler): + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport) + return AsyncGrowattApi(session=session, timeout=5.0) + + async def test_get_json(self): + async def handler(request): + assert request.method == "GET" + return json_response({"key": "value"}) + + api = self._make_api(handler) + result = await api._request("GET", "https://example.com/test") + assert result == {"key": "value"} + await api.aclose() + + async def test_post_with_data(self): + async def handler(request): + assert request.method == "POST" + return json_response({"ok": True}) + + api = self._make_api(handler) + result = await api._request("POST", "https://example.com/test", data={"field": "val"}) + assert result == {"ok": True} + await api.aclose() + + async def test_extract_callback(self): + async def handler(request): + return json_response({"back": {"plants": [1, 2]}}) + + api = self._make_api(handler) + result = await api._request( + "GET", "https://example.com/test", + extract=lambda r: r["back"]["plants"], + ) + assert result == [1, 2] + await api.aclose() + + async def test_text_mode(self): + async def handler(request): + return httpx.Response(200, text="hello") + + api = self._make_api(handler) + result = await api._request("GET", "https://example.com/test", text=True) + assert "" in result + await api.aclose() + + +# --------------------------------------------------------------------------- +# Context managers +# --------------------------------------------------------------------------- + +class TestContextManagers: + def test_sync_context_manager(self): + def handler(request): + return json_response({"ok": True}) + + transport = httpx.MockTransport(handler) + api = GrowattApi(timeout=5.0) + api.session = httpx.Client(transport=transport, headers=api.session.headers) + # Sync GrowattApi doesn't have __enter__/__exit__, just close() + api._request("GET", "https://example.com/test") + api.close() + + async def test_async_context_manager(self): + async def handler(request): + return json_response({"ok": True}) + + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport) + async with AsyncGrowattApi(session=session, timeout=5.0) as api: + result = await api._request("GET", "https://example.com/test") + assert result == {"ok": True} diff --git a/tests/test_coroutine_passthrough.py b/tests/test_coroutine_passthrough.py new file mode 100644 index 0000000..f0cbf44 --- /dev/null +++ b/tests/test_coroutine_passthrough.py @@ -0,0 +1,144 @@ +"""Tests verifying sync/async feature parity via the coroutine-passthrough pattern.""" + +from __future__ import annotations + +import inspect + +import httpx +import pytest + +from growattServer import AsyncGrowattApi, AsyncOpenApiV1, GrowattApi, OpenApiV1 +from growattServer.base_api import _GrowattApiBase +from growattServer.open_api_v1 import _OpenApiV1Base + +from .conftest import invoke, json_response, v1_success + + +# --------------------------------------------------------------------------- +# Introspection: all base methods exist on both sync and async classes +# --------------------------------------------------------------------------- + +def _get_public_methods(cls): + """Return names of public, non-dunder methods defined on cls (not inherited from object).""" + return { + name for name, method in inspect.getmembers(cls, predicate=inspect.isfunction) + if not name.startswith("_") + } + + +class TestBaseApiParity: + def test_all_base_methods_exist_on_async(self): + base_methods = _get_public_methods(_GrowattApiBase) + async_methods = _get_public_methods(AsyncGrowattApi) + missing = base_methods - async_methods + assert not missing, f"Methods missing from AsyncGrowattApi: {missing}" + + def test_all_base_methods_exist_on_sync(self): + base_methods = _get_public_methods(_GrowattApiBase) + sync_methods = _get_public_methods(GrowattApi) + missing = base_methods - sync_methods + assert not missing, f"Methods missing from GrowattApi: {missing}" + + +class TestV1ApiParity: + def test_all_v1_base_methods_exist_on_async(self): + base_methods = _get_public_methods(_OpenApiV1Base) + async_methods = _get_public_methods(AsyncOpenApiV1) + missing = base_methods - async_methods + assert not missing, f"Methods missing from AsyncOpenApiV1: {missing}" + + def test_all_v1_base_methods_exist_on_sync(self): + base_methods = _get_public_methods(_OpenApiV1Base) + sync_methods = _get_public_methods(OpenApiV1) + missing = base_methods - sync_methods + assert not missing, f"Methods missing from OpenApiV1: {missing}" + + +# --------------------------------------------------------------------------- +# Coroutine passthrough: async API methods return awaitables +# --------------------------------------------------------------------------- + +class TestAsyncReturnsAwaitables: + async def test_plant_detail_returns_awaitable(self): + """A passthrough method on AsyncGrowattApi returns an awaitable.""" + async def handler(request): + return json_response({"back": {"plantId": "1"}}) + + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport) + api = AsyncGrowattApi(session=session, timeout=5.0) + + # plant_detail is defined as regular def in _GrowattApiBase but should + # return a coroutine because _request is async on AsyncGrowattApi. + import datetime + from growattServer import Timespan + result = api.plant_detail("1", Timespan.day, datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)) + assert inspect.isawaitable(result) + value = await result + assert value == {"plantId": "1"} + await api.aclose() + + async def test_v1_plant_details_returns_awaitable(self): + """A passthrough method on AsyncOpenApiV1 returns an awaitable.""" + async def handler(request): + return json_response(v1_success({"plantName": "Test"})) + + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport, headers={"token": "t"}) + api = AsyncOpenApiV1(token="t", session=session, timeout=5.0) + + result = api.plant_details(1) + assert inspect.isawaitable(result) + value = await result + assert value["plantName"] == "Test" + await api.aclose() + + +# --------------------------------------------------------------------------- +# Same result from sync and async for the same mock response +# --------------------------------------------------------------------------- + +class TestSyncAsyncResultParity: + def test_sync_plant_details(self): + def handler(request): + return json_response(v1_success({"plantName": "Solar Farm"})) + + transport = httpx.MockTransport(handler) + api = OpenApiV1(token="t", timeout=5.0) + api.session = httpx.Client(transport=transport, headers={**dict(api.session.headers), "token": "t"}) + result = api.plant_details(1) + assert result == {"plantName": "Solar Farm"} + api.close() + + async def test_async_plant_details(self): + async def handler(request): + return json_response(v1_success({"plantName": "Solar Farm"})) + + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport, headers={"token": "t"}) + api = AsyncOpenApiV1(token="t", session=session, timeout=5.0) + result = await api.plant_details(1) + assert result == {"plantName": "Solar Farm"} + await api.aclose() + + def test_sync_min_detail(self): + def handler(request): + return json_response(v1_success({"vpv1": 310.0})) + + transport = httpx.MockTransport(handler) + api = OpenApiV1(token="t", timeout=5.0) + api.session = httpx.Client(transport=transport, headers={**dict(api.session.headers), "token": "t"}) + result = api.min_detail("SN1") + assert result == {"vpv1": 310.0} + api.close() + + async def test_async_min_detail(self): + async def handler(request): + return json_response(v1_success({"vpv1": 310.0})) + + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport, headers={"token": "t"}) + api = AsyncOpenApiV1(token="t", session=session, timeout=5.0) + result = await api.min_detail("SN1") + assert result == {"vpv1": 310.0} + await api.aclose() diff --git a/tests/test_device_min.py b/tests/test_device_min.py new file mode 100644 index 0000000..104b80c --- /dev/null +++ b/tests/test_device_min.py @@ -0,0 +1,317 @@ +"""Tests for Min / AsyncMin device methods.""" + +from __future__ import annotations + +from datetime import date, time, timedelta + +import httpx +import pytest + +from growattServer import ( + AsyncOpenApiV1, + GrowattParameterError, + OpenApiV1, +) + +from .conftest import json_response, v1_success + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_sync_v1(handler): + transport = httpx.MockTransport(handler) + api = OpenApiV1(token="test-token", timeout=5.0) + api.session = httpx.Client( + transport=transport, + headers={**dict(api.session.headers), "token": "test-token"}, + ) + return api + + +def _make_async_v1(handler): + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport, headers={"token": "test-token"}) + return AsyncOpenApiV1(token="test-token", session=session, timeout=5.0) + + +# --------------------------------------------------------------------------- +# Sync MIN device tests +# --------------------------------------------------------------------------- + +class TestMinSync: + def test_detail(self): + def handler(request): + assert "tlx_data_info" in str(request.url) + assert request.url.params["device_sn"] == "MIN001" + return json_response(v1_success({"vpv1": 300.5})) + + api = _make_sync_v1(handler) + result = api.min_detail("MIN001") + assert result["vpv1"] == 300.5 + api.close() + + def test_energy(self): + def handler(request): + assert "tlx_last_data" in str(request.url) + return json_response(v1_success({"eToday": 5.2})) + + api = _make_sync_v1(handler) + result = api.min_energy("MIN001") + assert result["eToday"] == 5.2 + api.close() + + def test_energy_history(self): + def handler(request): + assert "tlx_data" in str(request.url) + return json_response(v1_success({"datas": []})) + + api = _make_sync_v1(handler) + today = date.today() + result = api.min_energy_history("MIN001", start_date=today, end_date=today) + assert result == {"datas": []} + api.close() + + def test_energy_history_exceeds_7_days(self): + api = OpenApiV1.__new__(OpenApiV1) + with pytest.raises(GrowattParameterError, match="7 days"): + from growattServer.open_api_v1.devices.min import Min + device = Min(api, "MIN001") + device.energy_history( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 10), + ) + + def test_settings(self): + def handler(request): + assert "tlx_set_info" in str(request.url) + return json_response(v1_success({"time1Mode": "1"})) + + api = _make_sync_v1(handler) + result = api.min_settings("MIN001") + assert result["time1Mode"] == "1" + api.close() + + def test_read_parameter_named(self): + def handler(request): + assert "readMinParam" in str(request.url) + return json_response(v1_success({"data": "42"})) + + api = _make_sync_v1(handler) + result = api.min_read_parameter("MIN001", parameter_id="pv_active_p_rate") + assert result["data"] == "42" + api.close() + + def test_read_parameter_address(self): + def handler(request): + assert "readMinParam" in str(request.url) + return json_response(v1_success({"data": "100"})) + + api = _make_sync_v1(handler) + result = api.min_read_parameter("MIN001", parameter_id=None, start_address=100, end_address=102) + assert result["data"] == "100" + api.close() + + def test_read_parameter_no_args_raises(self): + from growattServer.open_api_v1.devices.min import Min + api = OpenApiV1.__new__(OpenApiV1) + device = Min(api, "MIN001") + with pytest.raises(GrowattParameterError): + device.read_parameter(None, None, None) + + def test_read_parameter_both_args_raises(self): + from growattServer.open_api_v1.devices.min import Min + api = OpenApiV1.__new__(OpenApiV1) + device = Min(api, "MIN001") + with pytest.raises(GrowattParameterError, match="not both"): + device.read_parameter("some_param", 100, None) + + def test_write_parameter_string(self): + def handler(request): + assert "tlxSet" in str(request.url) + body = request.content.decode() + assert "param1=42" in body + return json_response(v1_success()) + + api = _make_sync_v1(handler) + api.min_write_parameter("MIN001", "pv_active_p_rate", "42") + api.close() + + def test_write_parameter_list(self): + def handler(request): + body = request.content.decode() + assert "param1=10" in body + assert "param2=20" in body + return json_response(v1_success()) + + api = _make_sync_v1(handler) + api.min_write_parameter("MIN001", "test_type", ["10", "20"]) + api.close() + + def test_write_parameter_dict(self): + def handler(request): + body = request.content.decode() + assert "param3=99" in body + return json_response(v1_success()) + + api = _make_sync_v1(handler) + api.min_write_parameter("MIN001", "test_type", {3: "99"}) + api.close() + + def test_write_time_segment(self): + def handler(request): + assert "tlxSet" in str(request.url) + body = request.content.decode() + assert "type=time_segment1" in body + assert "param1=1" in body # batt_mode + assert "param2=2" in body # start hour + assert "param3=30" in body # start minute + assert "param4=5" in body # end hour + assert "param5=0" in body # end minute + assert "param6=1" in body # enabled + return json_response(v1_success()) + + api = _make_sync_v1(handler) + api.min_write_time_segment( + "MIN001", segment_id=1, batt_mode=1, + start_time=time(2, 30), end_time=time(5, 0), enabled=True, + ) + api.close() + + def test_write_time_segment_invalid_id(self): + from growattServer.open_api_v1.devices.min import Min + api = OpenApiV1.__new__(OpenApiV1) + device = Min(api, "MIN001") + with pytest.raises(GrowattParameterError, match="segment_id"): + device.write_time_segment(0, 1, time(0, 0), time(1, 0)) + + def test_write_time_segment_invalid_batt_mode(self): + from growattServer.open_api_v1.devices.min import Min + api = OpenApiV1.__new__(OpenApiV1) + device = Min(api, "MIN001") + with pytest.raises(GrowattParameterError, match="batt_mode"): + device.write_time_segment(1, 5, time(0, 0), time(1, 0)) + + +# --------------------------------------------------------------------------- +# Parse time segments (pure logic, no HTTP) +# --------------------------------------------------------------------------- + +class TestMinParseTimeSegments: + def test_parse_segments(self): + from growattServer.open_api_v1.devices.min import Min + api = OpenApiV1.__new__(OpenApiV1) + device = Min(api, "MIN001") + + settings = { + "forcedTimeStart1": "1:30", + "forcedTimeStop1": "5:0", + "time1Mode": "1", + "forcedStopSwitch1": "1", + } + # Fill defaults for segments 2-9 + for i in range(2, 10): + settings[f"forcedTimeStart{i}"] = "0:0" + settings[f"forcedTimeStop{i}"] = "0:0" + settings[f"time{i}Mode"] = "null" + settings[f"forcedStopSwitch{i}"] = "0" + + result = device.read_time_segments(settings) + assert len(result) == 9 + assert result[0]["segment_id"] == 1 + assert result[0]["batt_mode"] == 1 + assert result[0]["mode_name"] == "Battery First" + assert result[0]["start_time"] == "01:30" + assert result[0]["end_time"] == "05:00" + assert result[0]["enabled"] is True + assert result[1]["enabled"] is False + + def test_parse_null_values(self): + from growattServer.open_api_v1.devices.min import Min + api = OpenApiV1.__new__(OpenApiV1) + device = Min(api, "MIN001") + + settings = {} + for i in range(1, 10): + settings[f"forcedTimeStart{i}"] = "null" + settings[f"forcedTimeStop{i}"] = "" + settings[f"time{i}Mode"] = "null" + settings[f"forcedStopSwitch{i}"] = "null" + + result = device.read_time_segments(settings) + assert result[0]["start_time"] == "00:00" + assert result[0]["end_time"] == "00:00" + assert result[0]["batt_mode"] is None + assert result[0]["enabled"] is False + + +# --------------------------------------------------------------------------- +# Async MIN device tests +# --------------------------------------------------------------------------- + +class TestMinAsync: + async def test_detail(self): + async def handler(request): + assert "tlx_data_info" in str(request.url) + return json_response(v1_success({"vpv1": 300.5})) + + api = _make_async_v1(handler) + result = await api.min_detail("MIN001") + assert result["vpv1"] == 300.5 + await api.aclose() + + async def test_energy(self): + async def handler(request): + assert "tlx_last_data" in str(request.url) + return json_response(v1_success({"eToday": 5.2})) + + api = _make_async_v1(handler) + result = await api.min_energy("MIN001") + assert result["eToday"] == 5.2 + await api.aclose() + + async def test_settings(self): + async def handler(request): + assert "tlx_set_info" in str(request.url) + return json_response(v1_success({"time1Mode": "1"})) + + api = _make_async_v1(handler) + result = await api.min_settings("MIN001") + assert result["time1Mode"] == "1" + await api.aclose() + + async def test_read_time_segments_from_api(self): + """Tests async chaining: read_time_segments -> settings().""" + call_count = 0 + + async def handler(request): + nonlocal call_count + call_count += 1 + if "tlx_set_info" in str(request.url): + settings = {} + for i in range(1, 10): + settings[f"forcedTimeStart{i}"] = "0:0" + settings[f"forcedTimeStop{i}"] = "0:0" + settings[f"time{i}Mode"] = "0" + settings[f"forcedStopSwitch{i}"] = "0" + return json_response(v1_success(settings)) + return json_response(v1_success()) + + api = _make_async_v1(handler) + result = await api.min_read_time_segments("MIN001") + assert len(result) == 9 + assert call_count >= 1 + await api.aclose() + + async def test_write_time_segment(self): + async def handler(request): + assert "tlxSet" in str(request.url) + return json_response(v1_success()) + + api = _make_async_v1(handler) + await api.min_write_time_segment( + "MIN001", segment_id=1, batt_mode=1, + start_time=time(2, 30), end_time=time(5, 0), enabled=True, + ) + await api.aclose() diff --git a/tests/test_device_sph.py b/tests/test_device_sph.py new file mode 100644 index 0000000..2625733 --- /dev/null +++ b/tests/test_device_sph.py @@ -0,0 +1,318 @@ +"""Tests for Sph / AsyncSph device methods.""" + +from __future__ import annotations + +from datetime import date, time +from typing import Any + +import httpx +import pytest + +from growattServer import ( + AsyncOpenApiV1, + GrowattParameterError, + OpenApiV1, +) + +from .conftest import json_response, v1_success + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_sync_v1(handler): + transport = httpx.MockTransport(handler) + api = OpenApiV1(token="test-token", timeout=5.0) + api.session = httpx.Client( + transport=transport, + headers={**dict(api.session.headers), "token": "test-token"}, + ) + return api + + +def _make_async_v1(handler): + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport, headers={"token": "test-token"}) + return AsyncOpenApiV1(token="test-token", session=session, timeout=5.0) + + +def _sph_detail_response() -> dict[str, Any]: + """Build a realistic SPH detail response with charge/discharge settings.""" + return { + "chargePowerCommand": 80, + "wchargeSOCLowLimit": 95, + "acChargeEnable": "1", + "disChargePowerCommand": 60, + "wdisChargeSOCLowLimit": 15, + # Charge periods + "forcedChargeTimeStart1": "1:0", + "forcedChargeTimeStop1": "5:30", + "forcedChargeStopSwitch1": "1", + "forcedChargeTimeStart2": "0:0", + "forcedChargeTimeStop2": "0:0", + "forcedChargeStopSwitch2": "0", + "forcedChargeTimeStart3": "0:0", + "forcedChargeTimeStop3": "0:0", + "forcedChargeStopSwitch3": "0", + # Discharge periods + "forcedDischargeTimeStart1": "17:0", + "forcedDischargeTimeStop1": "21:0", + "forcedDischargeStopSwitch1": "1", + "forcedDischargeTimeStart2": "0:0", + "forcedDischargeTimeStop2": "0:0", + "forcedDischargeStopSwitch2": "0", + "forcedDischargeTimeStart3": "0:0", + "forcedDischargeTimeStop3": "0:0", + "forcedDischargeStopSwitch3": "0", + } + + +# --------------------------------------------------------------------------- +# Sync SPH tests +# --------------------------------------------------------------------------- + +class TestSphSync: + def test_detail(self): + def handler(request): + assert "mix_data_info" in str(request.url) + assert request.url.params["device_sn"] == "SPH001" + return json_response(v1_success({"vpv1": 250.0})) + + api = _make_sync_v1(handler) + result = api.sph_detail("SPH001") + assert result["vpv1"] == 250.0 + api.close() + + def test_energy(self): + def handler(request): + assert "mix_last_data" in str(request.url) + return json_response(v1_success({"eToday": 3.1})) + + api = _make_sync_v1(handler) + result = api.sph_energy("SPH001") + assert result["eToday"] == 3.1 + api.close() + + def test_energy_history(self): + def handler(request): + assert "mix_data" in str(request.url) + return json_response(v1_success({"datas": []})) + + api = _make_sync_v1(handler) + today = date.today() + result = api.sph_energy_history("SPH001", start_date=today, end_date=today) + assert result == {"datas": []} + api.close() + + def test_energy_history_exceeds_7_days(self): + from growattServer.open_api_v1.devices.sph import Sph + api = OpenApiV1.__new__(OpenApiV1) + device = Sph(api, "SPH001") + with pytest.raises(GrowattParameterError, match="7 days"): + device.energy_history( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 10), + ) + + def test_read_parameter_named(self): + def handler(request): + assert "readMixParam" in str(request.url) + return json_response(v1_success({"data": "55"})) + + api = _make_sync_v1(handler) + result = api.sph_read_parameter("SPH001", parameter_id="pv_active_p_rate") + assert result["data"] == "55" + api.close() + + def test_read_parameter_no_args_raises(self): + from growattServer.open_api_v1.devices.sph import Sph + api = OpenApiV1.__new__(OpenApiV1) + device = Sph(api, "SPH001") + with pytest.raises(GrowattParameterError): + device.read_parameter(None, None, None) + + def test_write_parameter(self): + def handler(request): + assert "mixSet" in str(request.url) + body = request.content.decode() + assert "param1=42" in body + assert "type=test_type" in body + return json_response(v1_success()) + + api = _make_sync_v1(handler) + api.sph_write_parameter("SPH001", "test_type", "42") + api.close() + + def test_write_ac_charge_times(self): + def handler(request): + body = request.content.decode() + assert "type=mix_ac_charge_time_period" in body + assert "param1=100" in body # charge_power + assert "param2=95" in body # charge_stop_soc + assert "param3=1" in body # mains_enabled + return json_response(v1_success()) + + api = _make_sync_v1(handler) + api.sph_write_ac_charge_times( + "SPH001", charge_power=100, charge_stop_soc=95, mains_enabled=True, + periods=[ + {"start_time": time(1, 0), "end_time": time(5, 0), "enabled": True}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + ], + ) + api.close() + + def test_write_ac_charge_times_invalid_power(self): + from growattServer.open_api_v1.devices.sph import Sph + api = OpenApiV1.__new__(OpenApiV1) + device = Sph(api, "SPH001") + with pytest.raises(GrowattParameterError, match="charge_power"): + device.write_ac_charge_times(101, 50, True, [{}] * 3) + + def test_write_ac_charge_times_wrong_period_count(self): + from growattServer.open_api_v1.devices.sph import Sph + api = OpenApiV1.__new__(OpenApiV1) + device = Sph(api, "SPH001") + with pytest.raises(GrowattParameterError, match="3 period"): + device.write_ac_charge_times(50, 50, True, [{}] * 2) + + def test_write_ac_discharge_times(self): + def handler(request): + body = request.content.decode() + assert "type=mix_ac_discharge_time_period" in body + assert "param1=80" in body # discharge_power + assert "param2=10" in body # discharge_stop_soc + return json_response(v1_success()) + + api = _make_sync_v1(handler) + api.sph_write_ac_discharge_times( + "SPH001", discharge_power=80, discharge_stop_soc=10, + periods=[ + {"start_time": time(17, 0), "end_time": time(21, 0), "enabled": True}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + ], + ) + api.close() + + def test_write_ac_discharge_times_invalid_power(self): + from growattServer.open_api_v1.devices.sph import Sph + api = OpenApiV1.__new__(OpenApiV1) + device = Sph(api, "SPH001") + with pytest.raises(GrowattParameterError, match="discharge_power"): + device.write_ac_discharge_times(-1, 10, [{}] * 3) + + +# --------------------------------------------------------------------------- +# Parse AC charge/discharge settings (pure logic, no HTTP) +# --------------------------------------------------------------------------- + +class TestSphParseSettings: + def test_parse_ac_charge_settings(self): + from growattServer.open_api_v1.devices.sph import Sph + api = OpenApiV1.__new__(OpenApiV1) + device = Sph(api, "SPH001") + + result = device._parse_ac_charge_settings(_sph_detail_response()) + assert result["charge_power"] == 80 + assert result["charge_stop_soc"] == 95 + assert result["mains_enabled"] is True + assert len(result["periods"]) == 3 + assert result["periods"][0]["start_time"] == "01:00" + assert result["periods"][0]["end_time"] == "05:30" + assert result["periods"][0]["enabled"] is True + assert result["periods"][1]["enabled"] is False + + def test_parse_ac_discharge_settings(self): + from growattServer.open_api_v1.devices.sph import Sph + api = OpenApiV1.__new__(OpenApiV1) + device = Sph(api, "SPH001") + + result = device._parse_ac_discharge_settings(_sph_detail_response()) + assert result["discharge_power"] == 60 + assert result["discharge_stop_soc"] == 15 + assert len(result["periods"]) == 3 + assert result["periods"][0]["start_time"] == "17:00" + assert result["periods"][0]["end_time"] == "21:00" + assert result["periods"][0]["enabled"] is True + + def test_parse_null_charge_values(self): + from growattServer.open_api_v1.devices.sph import Sph + api = OpenApiV1.__new__(OpenApiV1) + device = Sph(api, "SPH001") + + settings = { + "chargePowerCommand": "null", + "wchargeSOCLowLimit": "", + "acChargeEnable": "null", + } + for i in range(1, 4): + settings[f"forcedChargeTimeStart{i}"] = "null" + settings[f"forcedChargeTimeStop{i}"] = "" + settings[f"forcedChargeStopSwitch{i}"] = "null" + + result = device._parse_ac_charge_settings(settings) + assert result["charge_power"] == 0 + assert result["charge_stop_soc"] == 100 + assert result["mains_enabled"] is False + + +# --------------------------------------------------------------------------- +# Async SPH tests +# --------------------------------------------------------------------------- + +class TestSphAsync: + async def test_detail(self): + async def handler(request): + assert "mix_data_info" in str(request.url) + return json_response(v1_success({"vpv1": 250.0})) + + api = _make_async_v1(handler) + result = await api.sph_detail("SPH001") + assert result["vpv1"] == 250.0 + await api.aclose() + + async def test_read_ac_charge_times_from_api(self): + """Tests async chaining: read_ac_charge_times -> detail().""" + async def handler(request): + if "mix_data_info" in str(request.url): + return json_response(v1_success(_sph_detail_response())) + return json_response(v1_success()) + + api = _make_async_v1(handler) + result = await api.sph_read_ac_charge_times("SPH001") + assert result["charge_power"] == 80 + assert len(result["periods"]) == 3 + await api.aclose() + + async def test_read_ac_discharge_times_from_api(self): + """Tests async chaining: read_ac_discharge_times -> detail().""" + async def handler(request): + if "mix_data_info" in str(request.url): + return json_response(v1_success(_sph_detail_response())) + return json_response(v1_success()) + + api = _make_async_v1(handler) + result = await api.sph_read_ac_discharge_times("SPH001") + assert result["discharge_power"] == 60 + assert len(result["periods"]) == 3 + await api.aclose() + + async def test_write_ac_charge_times(self): + async def handler(request): + body = request.content.decode() + assert "mix_ac_charge_time_period" in body + return json_response(v1_success()) + + api = _make_async_v1(handler) + await api.sph_write_ac_charge_times( + "SPH001", charge_power=100, charge_stop_soc=95, mains_enabled=True, + periods=[ + {"start_time": time(1, 0), "end_time": time(5, 0), "enabled": True}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + {"start_time": time(0, 0), "end_time": time(0, 0), "enabled": False}, + ], + ) + await api.aclose() diff --git a/tests/test_exception_mapping.py b/tests/test_exception_mapping.py new file mode 100644 index 0000000..f0f4ef4 --- /dev/null +++ b/tests/test_exception_mapping.py @@ -0,0 +1,165 @@ +"""Tests for httpx exception → GrowattApiError hierarchy mapping.""" + +from __future__ import annotations + +import httpx +import pytest + +from growattServer import ( + GrowattApi, + AsyncGrowattApi, + GrowattApiConnectionError, + GrowattApiError, + GrowattApiStatusError, + GrowattApiTimeoutError, + GrowattError, +) + + +# --------------------------------------------------------------------------- +# Sync exception mapping +# --------------------------------------------------------------------------- + +class TestSyncExceptionMapping: + def _make_api(self, handler): + transport = httpx.MockTransport(handler) + api = GrowattApi(timeout=5.0) + api.session = httpx.Client(transport=transport, headers=api.session.headers) + return api + + def test_timeout_maps_to_growatt_timeout(self): + def handler(request): + raise httpx.ReadTimeout("read timed out") + + api = self._make_api(handler) + with pytest.raises(GrowattApiTimeoutError, match="timed out"): + api._request("GET", "https://example.com/test") + api.close() + + def test_connect_error_maps_to_connection_error(self): + def handler(request): + raise httpx.ConnectError("connection refused") + + api = self._make_api(handler) + with pytest.raises(GrowattApiConnectionError, match="Failed to connect"): + api._request("GET", "https://example.com/test") + api.close() + + def test_http_status_error_maps_to_status_error(self): + def handler(request): + return httpx.Response(500, text="Internal Server Error") + + api = self._make_api(handler) + with pytest.raises(GrowattApiStatusError) as exc_info: + api._request("GET", "https://example.com/test") + assert exc_info.value.status_code == 500 + api.close() + + @pytest.mark.parametrize("status_code", [400, 401, 403, 404, 500, 503]) + def test_various_status_codes(self, status_code): + def handler(request): + return httpx.Response(status_code, text="error") + + api = self._make_api(handler) + with pytest.raises(GrowattApiStatusError) as exc_info: + api._request("GET", "https://example.com/test") + assert exc_info.value.status_code == status_code + api.close() + + def test_generic_http_error_maps_to_api_error(self): + def handler(request): + raise httpx.DecodingError("decode failed") + + api = self._make_api(handler) + with pytest.raises(GrowattApiError, match="HTTP error"): + api._request("GET", "https://example.com/test") + api.close() + + +# --------------------------------------------------------------------------- +# Async exception mapping +# --------------------------------------------------------------------------- + +class TestAsyncExceptionMapping: + def _make_api(self, handler): + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport) + return AsyncGrowattApi(session=session, timeout=5.0) + + async def test_timeout_maps_to_growatt_timeout(self): + async def handler(request): + raise httpx.ReadTimeout("read timed out") + + api = self._make_api(handler) + with pytest.raises(GrowattApiTimeoutError, match="timed out"): + await api._request("GET", "https://example.com/test") + await api.aclose() + + async def test_connect_error_maps_to_connection_error(self): + async def handler(request): + raise httpx.ConnectError("connection refused") + + api = self._make_api(handler) + with pytest.raises(GrowattApiConnectionError, match="Failed to connect"): + await api._request("GET", "https://example.com/test") + await api.aclose() + + async def test_http_status_error_maps_to_status_error(self): + async def handler(request): + return httpx.Response(500, text="Internal Server Error") + + api = self._make_api(handler) + with pytest.raises(GrowattApiStatusError) as exc_info: + await api._request("GET", "https://example.com/test") + assert exc_info.value.status_code == 500 + await api.aclose() + + @pytest.mark.parametrize("status_code", [400, 401, 403, 404, 500, 503]) + async def test_various_status_codes(self, status_code): + async def handler(request): + return httpx.Response(status_code, text="error") + + api = self._make_api(handler) + with pytest.raises(GrowattApiStatusError) as exc_info: + await api._request("GET", "https://example.com/test") + assert exc_info.value.status_code == status_code + await api.aclose() + + async def test_generic_http_error_maps_to_api_error(self): + async def handler(request): + raise httpx.DecodingError("decode failed") + + api = self._make_api(handler) + with pytest.raises(GrowattApiError, match="HTTP error"): + await api._request("GET", "https://example.com/test") + await api.aclose() + + +# --------------------------------------------------------------------------- +# Exception hierarchy +# --------------------------------------------------------------------------- + +class TestExceptionHierarchy: + def test_timeout_is_api_error(self): + assert issubclass(GrowattApiTimeoutError, GrowattApiError) + + def test_connection_is_api_error(self): + assert issubclass(GrowattApiConnectionError, GrowattApiError) + + def test_status_is_api_error(self): + assert issubclass(GrowattApiStatusError, GrowattApiError) + + def test_api_error_is_growatt_error(self): + assert issubclass(GrowattApiError, GrowattError) + + def test_status_error_has_status_code(self): + exc = GrowattApiStatusError("test", 404) + assert exc.status_code == 404 + + def test_requests_compat(self): + """If requests is installed, GrowattApiError is also a RequestException.""" + try: + from requests.exceptions import RequestException + except ImportError: + pytest.skip("requests not installed") + assert issubclass(GrowattApiError, RequestException) diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..4bf1675 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,79 @@ +"""Tests for pure helper functions (no HTTP mocking needed).""" + +from __future__ import annotations + +import datetime + +import pytest + +from growattServer import Timespan, hash_password + + +class TestHashPassword: + def test_known_value(self): + result = hash_password("test") + assert isinstance(result, str) + assert len(result) == 32 + + def test_c_substitution(self): + """Bytes with a leading '0' nibble get replaced with 'c'.""" + result = hash_password("test") + # Verify no '0' appears at even positions (the algorithm replaces them) + for i in range(0, len(result), 2): + assert result[i] != "0" + + def test_empty_string(self): + result = hash_password("") + assert isinstance(result, str) + assert len(result) == 32 + + def test_deterministic(self): + assert hash_password("hello") == hash_password("hello") + + def test_different_inputs(self): + assert hash_password("a") != hash_password("b") + + +class TestTimespan: + def test_values(self): + assert Timespan.hour == 0 + assert Timespan.day == 1 + assert Timespan.month == 2 + + def test_is_int(self): + assert isinstance(Timespan.hour, int) + + +class TestGetDateString: + """Test _get_date_string via a GrowattApi instance.""" + + def test_day_format(self): + from growattServer import GrowattApi + api = GrowattApi.__new__(GrowattApi) + dt = datetime.datetime(2024, 3, 15, tzinfo=datetime.UTC) + assert api._get_date_string(Timespan.day, dt) == "2024-03-15" + + def test_hour_format(self): + from growattServer import GrowattApi + api = GrowattApi.__new__(GrowattApi) + dt = datetime.datetime(2024, 3, 15, tzinfo=datetime.UTC) + assert api._get_date_string(Timespan.hour, dt) == "2024-03-15" + + def test_month_format(self): + from growattServer import GrowattApi + api = GrowattApi.__new__(GrowattApi) + dt = datetime.datetime(2024, 3, 15, tzinfo=datetime.UTC) + assert api._get_date_string(Timespan.month, dt) == "2024-03" + + def test_default_date_uses_now(self): + from growattServer import GrowattApi + api = GrowattApi.__new__(GrowattApi) + result = api._get_date_string(Timespan.day) + today = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d") + assert result == today + + def test_invalid_timespan_raises(self): + from growattServer import GrowattApi + api = GrowattApi.__new__(GrowattApi) + with pytest.raises(ValueError, match="Timespan"): + api._get_date_string("invalid") diff --git a/tests/test_v1_api.py b/tests/test_v1_api.py new file mode 100644 index 0000000..376b755 --- /dev/null +++ b/tests/test_v1_api.py @@ -0,0 +1,214 @@ +"""Tests for OpenApiV1 / AsyncOpenApiV1 v1_request and process_response.""" + +from __future__ import annotations + +import json +import warnings + +import httpx +import pytest + +from growattServer import ( + AsyncOpenApiV1, + GrowattApiError, + GrowattV1ApiError, + OpenApiV1, +) +from growattServer.open_api_v1.devices.min import Min +from growattServer.open_api_v1.devices.sph import Sph +from growattServer.open_api_v1.devices.async_min import AsyncMin +from growattServer.open_api_v1.devices.async_sph import AsyncSph + +from .conftest import json_response, v1_error, v1_success + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_sync_v1(handler): + transport = httpx.MockTransport(handler) + api = OpenApiV1(token="test-token", timeout=5.0) + api.session = httpx.Client( + transport=transport, + headers={**dict(api.session.headers), "token": "test-token"}, + ) + return api + + +def _make_async_v1(handler): + transport = httpx.MockTransport(handler) + session = httpx.AsyncClient(transport=transport, headers={"token": "test-token"}) + return AsyncOpenApiV1(token="test-token", session=session, timeout=5.0) + + +# --------------------------------------------------------------------------- +# process_response +# --------------------------------------------------------------------------- + +class TestProcessResponse: + def test_success_extracts_data(self): + api = OpenApiV1.__new__(OpenApiV1) + result = api.process_response(v1_success({"plants": [1, 2]})) + assert result == {"plants": [1, 2]} + + def test_error_raises_v1_api_error(self): + api = OpenApiV1.__new__(OpenApiV1) + with pytest.raises(GrowattV1ApiError) as exc_info: + api.process_response(v1_error(10001, "System error"), "test op") + assert exc_info.value.error_code == 10001 + assert exc_info.value.error_msg == "System error" + + def test_error_with_unknown_msg(self): + api = OpenApiV1.__new__(OpenApiV1) + with pytest.raises(GrowattV1ApiError) as exc_info: + api.process_response({"error_code": 99}) + assert exc_info.value.error_msg == "Unknown error" + + +# --------------------------------------------------------------------------- +# Sync v1_request +# --------------------------------------------------------------------------- + +class TestSyncV1Request: + def test_success(self): + def handler(request): + return json_response(v1_success({"count": 5})) + + api = _make_sync_v1(handler) + result = api.v1_request("GET", "plant/list", operation_name="test") + assert result == {"count": 5} + api.close() + + def test_v1_error_raised(self): + def handler(request): + return json_response(v1_error(10012, "Rate limited")) + + api = _make_sync_v1(handler) + with pytest.raises(GrowattV1ApiError) as exc_info: + api.v1_request("GET", "plant/list", operation_name="test") + assert exc_info.value.error_code == 10012 + api.close() + + def test_http_error_wrapped(self): + def handler(request): + return httpx.Response(500, text="error") + + api = _make_sync_v1(handler) + with pytest.raises(GrowattApiError): + api.v1_request("GET", "plant/list") + api.close() + + def test_plant_list(self): + def handler(request): + assert "plant/list" in str(request.url) + return json_response(v1_success({"count": 2, "plants": []})) + + api = _make_sync_v1(handler) + result = api.plant_list() + assert result["count"] == 2 + api.close() + + def test_plant_details(self): + def handler(request): + assert "plant/details" in str(request.url) + assert request.url.params["plant_id"] == "42" + return json_response(v1_success({"plantName": "Test"})) + + api = _make_sync_v1(handler) + result = api.plant_details(42) + assert result["plantName"] == "Test" + api.close() + + def test_device_list(self): + def handler(request): + assert "device/list" in str(request.url) + return json_response(v1_success({"count": 1, "devices": []})) + + api = _make_sync_v1(handler) + result = api.device_list(42) + assert result["count"] == 1 + api.close() + + def test_token_in_headers(self): + def handler(request): + assert request.headers["token"] == "test-token" + return json_response(v1_success()) + + api = _make_sync_v1(handler) + api.v1_request("GET", "plant/list") + api.close() + + +# --------------------------------------------------------------------------- +# Async v1_request +# --------------------------------------------------------------------------- + +class TestAsyncV1Request: + async def test_success(self): + async def handler(request): + return json_response(v1_success({"count": 5})) + + api = _make_async_v1(handler) + result = await api.v1_request("GET", "plant/list", operation_name="test") + assert result == {"count": 5} + await api.aclose() + + async def test_v1_error_raised(self): + async def handler(request): + return json_response(v1_error(10012, "Rate limited")) + + api = _make_async_v1(handler) + with pytest.raises(GrowattV1ApiError) as exc_info: + await api.v1_request("GET", "plant/list", operation_name="test") + assert exc_info.value.error_code == 10012 + await api.aclose() + + async def test_plant_list(self): + async def handler(request): + assert "plant/list" in str(request.url) + return json_response(v1_success({"count": 2, "plants": []})) + + api = _make_async_v1(handler) + result = await api.plant_list() + assert result["count"] == 2 + await api.aclose() + + +# --------------------------------------------------------------------------- +# get_device +# --------------------------------------------------------------------------- + +class TestGetDevice: + def test_min_device(self): + api = OpenApiV1.__new__(OpenApiV1) + api._min_class = Min + api._sph_class = Sph + device = api.get_device("SN123", Min.DEVICE_TYPE_ID) + assert isinstance(device, Min) + assert device.device_sn == "SN123" + + def test_sph_device(self): + api = OpenApiV1.__new__(OpenApiV1) + api._min_class = Min + api._sph_class = Sph + device = api.get_device("SN456", Sph.DEVICE_TYPE_ID) + assert isinstance(device, Sph) + + def test_unknown_device_returns_none(self): + api = OpenApiV1.__new__(OpenApiV1) + api._min_class = Min + api._sph_class = Sph + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + device = api.get_device("SN789", 99) + assert device is None + assert len(w) == 1 + assert "not been implemented" in str(w[0].message) + + def test_async_get_device_uses_async_classes(self): + api = AsyncOpenApiV1.__new__(AsyncOpenApiV1) + api._min_class = AsyncMin + api._sph_class = AsyncSph + device = api.get_device("SN123", Min.DEVICE_TYPE_ID) + assert isinstance(device, AsyncMin) From 2f581df2b2b95904706b231e9c2136bdcb6a1c71 Mon Sep 17 00:00:00 2001 From: Johan Zander Date: Tue, 9 Jun 2026 00:08:13 +0200 Subject: [PATCH 9/9] Fix mypy override error: add files param to _request implementations Both GrowattApi._request and AsyncGrowattApi._request were missing the files keyword argument defined in the base class signature. Co-Authored-By: Claude Opus 4.6 --- growattServer/async_base_api.py | 4 +++- growattServer/base_api.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/growattServer/async_base_api.py b/growattServer/async_base_api.py index 67590a3..89765df 100644 --- a/growattServer/async_base_api.py +++ b/growattServer/async_base_api.py @@ -54,13 +54,15 @@ def __init__(self, add_random_user_id: bool = False, agent_identifier: str | Non ) self._owns_session = True - async def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], _T] | None = None, text: bool = False) -> Any: + async def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, files: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], _T] | None = None, text: bool = False) -> Any: """Make an async HTTP request and return the JSON response (or text if text=True).""" kwargs: dict[str, Any] = {} if params is not None: kwargs["params"] = params if data is not None: kwargs["data"] = data + if files is not None: + kwargs["files"] = files if follow_redirects is not None: kwargs["follow_redirects"] = follow_redirects try: diff --git a/growattServer/base_api.py b/growattServer/base_api.py index 38ef288..21d1cbf 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -1283,13 +1283,15 @@ def __init__(self, add_random_user_id: bool = False, agent_identifier: str | Non timeout=timeout, ) - def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], _T] | None = None, text: bool = False) -> Any: + def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, files: dict[str, Any] | None = None, follow_redirects: bool | None = None, extract: Callable[[Any], _T] | None = None, text: bool = False) -> Any: """Make an HTTP request and return the JSON response (or text if text=True).""" kwargs: dict[str, Any] = {} if params is not None: kwargs["params"] = params if data is not None: kwargs["data"] = data + if files is not None: + kwargs["files"] = files if follow_redirects is not None: kwargs["follow_redirects"] = follow_redirects try: