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/.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..566358f 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 +`growattServer.GrowattApiError` 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..7e8d4fb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,157 @@ +# 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` | `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()` | +| 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. + +## 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 +# 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..7102da4 --- /dev/null +++ b/examples/async_min_example.py @@ -0,0 +1,59 @@ +""" +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 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 growattServer.GrowattApiError as e: + print(f"Network Error: {e}") + + +asyncio.run(main()) diff --git a/examples/min_example.py b/examples/min_example.py index b1fee17..eca05fc 100644 --- a/examples/min_example.py +++ b/examples/min_example.py @@ -1,7 +1,5 @@ import json -import requests - 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 requests.exceptions.RequestException 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 8f479ff..677b294 100644 --- a/examples/min_example_dashboard.py +++ b/examples/min_example_dashboard.py @@ -1,7 +1,5 @@ import json -import requests - 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 requests.exceptions.RequestException 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 9f110a0..e64e3f7 100644 --- a/examples/sph_example.py +++ b/examples/sph_example.py @@ -9,8 +9,6 @@ import json import os -import requests - 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 requests.exceptions.RequestException 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/__init__.py b/growattServer/__init__.py index e6914e8..389e75f 100755 --- a/growattServer/__init__.py +++ b/growattServer/__init__.py @@ -3,21 +3,33 @@ from __future__ import annotations +from .async_base_api import AsyncGrowattApi from .base_api import GrowattApi, Timespan, hash_password from .exceptions import ( + GrowattApiConnectionError, + GrowattApiError, + GrowattApiStatusError, + GrowattApiTimeoutError, GrowattError, GrowattParameterError, GrowattV1ApiError, 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", + "GrowattApiConnectionError", + "GrowattApiError", + "GrowattApiStatusError", + "GrowattApiTimeoutError", "GrowattError", "GrowattParameterError", "GrowattV1ApiError", diff --git a/growattServer/async_base_api.py b/growattServer/async_base_api.py new file mode 100644 index 0000000..89765df --- /dev/null +++ b/growattServer/async_base_api.py @@ -0,0 +1,259 @@ +"""Async Growatt API client.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypeVar + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Self + +import httpx + +from .base_api import DEFAULT_TIMEOUT, _GrowattApiBase, hash_password +from .exceptions import ( + GrowattApiConnectionError, + GrowattApiError, + GrowattApiStatusError, + GrowattApiTimeoutError, +) + +_T = TypeVar("_T") + + +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, 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. + 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) + + 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=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, 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: + 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 + 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 + + 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 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. + + 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 + }) + response.raise_for_status() + + 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) + 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..cc451ce --- /dev/null +++ b/growattServer/async_base_api.pyi @@ -0,0 +1,75 @@ +import datetime +from collections.abc import Callable +from typing import Any, Self, 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) -> Self: ... + 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 fba830a..21d1cbf 100644 --- a/growattServer/base_api.py +++ b/growattServer/base_api.py @@ -11,11 +11,23 @@ import secrets import warnings from enum import IntEnum -from typing import Any +from typing import TYPE_CHECKING, Any, TypeVar -import requests +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Self -from .exceptions import GrowattError +import httpx + +from .exceptions import ( + GrowattApiConnectionError, + GrowattApiError, + GrowattApiStatusError, + GrowattApiTimeoutError, + GrowattError, +) + +_T = TypeVar("_T") name = "growattServer" @@ -46,42 +58,40 @@ class Timespan(IntEnum): month = 2 -class GrowattApi: - """Base client for Growatt API endpoints.""" +DEFAULT_TIMEOUT = 30.0 # seconds + + +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 _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 - def _raise_for_status(response, *args: object, **kwargs: object) -> None: - _ = args - _ = kwargs - response.raise_for_status() - - self.session.hooks = {"response": [_raise_for_status]} + def device_list(self, plant_id: str) -> Any: + """Get device list. Implemented by subclasses.""" + raise NotImplementedError - 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,106 +110,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. - - Args: - user_id (str): The ID of the user. - - Returns: - list: A list of plants connected to the account. - - Raises: - Exception: If the request to the server fails. - - """ - response = self.session.get( - self.get_url("PlantListAPI.do"), - params={"userId": user_id}, - allow_redirects=False - ) - - 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 +126,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 +142,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 +152,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 +172,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 +192,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 +211,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 +231,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 +253,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 +277,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 +302,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 +322,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 +341,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 +360,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 +382,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 +403,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 +425,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 +472,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 +503,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 +545,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 +602,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 +613,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 +627,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 +678,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 +701,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 +734,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) -> list[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 +776,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 +807,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,90 +858,41 @@ 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() + return self._request( + "POST", self.get_url("noahDeviceApi/noah/getNoahInfoBySn"), + data={"deviceSn": serial_number}, + ) - def update_plant_settings(self, plant_id: str, changed_settings: dict[str, Any], current_settings: dict[str, Any] | None = None) -> dict[str, Any]: + 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]: """ - Update plant settings. + Apply inverter settings. Args: - plant_id: Plant identifier. - changed_settings: Dict of settings to change. - current_settings: Current settings dict or None. + serial_number: Serial number of the inverter. + setting_type: Type of setting to configure. + default_parameters: Default parameter mapping for the request. This + should contain the required keys for the specific endpoint + (commonly keys like ``op``, ``serialNum`` and ``type``). + parameters: Parameters to send. May be either a ``dict`` mapping + parameter names to values, or a ``list`` of values. If a + ``list`` is supplied it will be converted to a dictionary of + the form ``{"param1": value1, "param2": value2, ...}``. + + Notes: + - The function merges ``default_parameters`` with the provided + ``parameters`` and issues a POST request to ``newTcpsetAPI.do``. + - For Mix/AC/other inverter types the caller may wrap this helper + with specific defaults (see ``update_mix_inverter_setting`` and + ``update_ac_inverter_setting``). Returns: - dict: Server response indicating success or failure. + dict: Server response JSON. """ - # 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() - - 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]: - """ - Apply inverter settings. - - Args: - serial_number: Serial number of the inverter. - setting_type: Type of setting to configure. - default_parameters: Default parameter mapping for the request. This - should contain the required keys for the specific endpoint - (commonly keys like ``op``, ``serialNum`` and ``type``). - parameters: Parameters to send. May be either a ``dict`` mapping - parameter names to values, or a ``list`` of values. If a - ``list`` is supplied it will be converted to a dictionary of - the form ``{"param1": value1, "param2": value2, ...}``. - - Notes: - - The function merges ``default_parameters`` with the provided - ``parameters`` and issues a POST request to ``newTcpsetAPI.do``. - - For Mix/AC/other inverter types the caller may wrap this helper - with specific defaults (see ``update_mix_inverter_setting`` and - ``update_ac_inverter_setting``). - - Returns: - dict: Server response JSON. - - """ - # Ensure declared but unused args are referenced to satisfy linters - _ = serial_number - _ = setting_type + # Ensure declared but unused args are referenced to satisfy linters + _ = serial_number + _ = setting_type # If we've been passed an array then convert it into a dictionary if isinstance(parameters, list): @@ -1109,10 +904,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=merged, + ) def update_mix_inverter_setting(self, serial_number: str, setting_type: str, parameters: dict[str, Any] | list[Any]) -> dict[str, Any]: """ @@ -1172,29 +967,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 +1046,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=merged, ) - 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,10 +1138,10 @@ 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 response.json() + return self._request( + "POST", self.get_url("tcpSet.do"), + params=settings_parameters, + ) def set_classic_inverter_active_power_rate(self, serial_number, power_rate): """ @@ -1462,3 +1192,297 @@ def set_classic_inverter_on_off(self, serial_number, enabled): } 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.""" + 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) + + 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]: + """ + 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. + + """ + 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), + ) + + +class GrowattApi(_GrowattApiBase): + """Base client for Growatt API endpoints.""" + + 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) + + self.session = httpx.Client( + headers={"User-Agent": self.agent_identifier}, + follow_redirects=True, + timeout=timeout, + ) + + 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: + 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 + 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 + + 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 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. + + 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 + }) + response.raise_for_status() + + 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 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) + response.raise_for_status() + + return response.json() + diff --git a/growattServer/exceptions.py b/growattServer/exceptions.py index 48d4e72..67e45ac 100644 --- a/growattServer/exceptions.py +++ b/growattServer/exceptions.py @@ -1,22 +1,38 @@ """ 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 +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 warnings from enum import IntEnum +# --------------------------------------------------------------------------- +# 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 +65,68 @@ 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. + """ + + +# 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): + """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 +144,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/growattServer/open_api_v1/__init__.py b/growattServer/open_api_v1/__init__.py index 5980980..35935ea 100644 --- a/growattServer/open_api_v1/__init__.py +++ b/growattServer/open_api_v1/__init__.py @@ -9,7 +9,10 @@ from typing import Any from growattServer import GrowattApi -from growattServer.exceptions import GrowattV1ApiError +from growattServer.base_api import DEFAULT_TIMEOUT +from growattServer.exceptions import ( + GrowattV1ApiError, +) from .devices import AbstractDevice, Min, ParameterValue, Sph @@ -29,14 +32,25 @@ 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 + 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() system = platform.system() @@ -45,23 +59,6 @@ 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: """ Process API response and handle errors. @@ -90,7 +87,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 +99,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. + GrowattApiError: 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 +127,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. + GrowattApiError: 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 +154,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. + GrowattApiError: 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 +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 - requests.exceptions.RequestException: 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 @@ -211,17 +203,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": str(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 +244,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. + GrowattApiError: If there is an issue with the HTTP request. References: https://www.showdoc.com.cn/262556420217021/1494061730868556 @@ -294,8 +281,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,13 +291,12 @@ 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 | str) -> dict[str, Any]: """ - Get devices associated with plant. + Get devices associated with plant via V1 API. Note: returned "device_type" mappings: @@ -353,30 +339,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 +371,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. + GrowattApiError: 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 +388,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. + GrowattApiError: 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 +419,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. + GrowattApiError: 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 +438,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. + GrowattApiError: 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 +461,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. + GrowattApiError: 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 +485,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. + GrowattApiError: 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 +510,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. + GrowattApiError: 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 +552,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. + GrowattApiError: 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 +571,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. + GrowattApiError: 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 +588,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. + GrowattApiError: 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 +619,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. + GrowattApiError: 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 +644,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. + GrowattApiError: 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 +668,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. + GrowattApiError: 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 +710,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. + GrowattApiError: 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 +752,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. + GrowattApiError: 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 +798,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. + GrowattApiError: 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 +841,45 @@ 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. + GrowattApiError: 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, 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. """ - return Sph(self, device_sn).read_ac_discharge_times(settings_data) + 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.""" + 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 new file mode 100644 index 0000000..ec3dce9 --- /dev/null +++ b/growattServer/open_api_v1/async_open_api_v1.py @@ -0,0 +1,50 @@ +"""Async OpenApi V1 extensions for Growatt API client.""" + +from __future__ import annotations + +from typing import Any + +from growattServer.async_base_api import AsyncGrowattApi +from growattServer.base_api import DEFAULT_TIMEOUT + +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, 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, 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.""" + 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..4217511 --- /dev/null +++ b/growattServer/open_api_v1/async_open_api_v1.pyi @@ -0,0 +1,95 @@ +import datetime +from datetime import date, time +from typing import Any, Self + +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) -> 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 86c616e..220f49f 100644 --- a/growattServer/open_api_v1/devices/__init__.py +++ b/growattServer/open_api_v1/devices/__init__.py @@ -1,6 +1,10 @@ # noqa: D104 from __future__ import annotations -from .abstract_device import AbstractDevice, ParameterValue # 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"] diff --git a/growattServer/open_api_v1/devices/abstract_device.py b/growattServer/open_api_v1/devices/abstract_device.py index b42b450..681b29d 100644 --- a/growattServer/open_api_v1/devices/abstract_device.py +++ b/growattServer/open_api_v1/devices/abstract_device.py @@ -7,7 +7,7 @@ from growattServer.exceptions import GrowattParameterError if TYPE_CHECKING: - from growattServer.open_api_v1 import OpenApiV1 + from growattServer.open_api_v1 import _OpenApiV1Base type ParameterValue = str | float | bool | list[Any] | dict[str, Any] @@ -23,7 +23,9 @@ class ReadParamResponse(TypedDict): class AbstractDevice: """Abstract device type. Must not be used directly.""" - def __init__(self, api: OpenApiV1, 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 new file mode 100644 index 0000000..14a2969 --- /dev/null +++ b/growattServer/open_api_v1/devices/async_min.py @@ -0,0 +1,77 @@ +"""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 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. + + 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 + GrowattApiError: 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..e7db344 --- /dev/null +++ b/growattServer/open_api_v1/devices/async_sph.py @@ -0,0 +1,118 @@ +"""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 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. + + 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. + GrowattApiError: 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]: # type: ignore[override] + """ + 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. + GrowattApiError: 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..728c800 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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..c3ca99e 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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. + GrowattApiError: 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/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 0355a53..683b8a3 100755 --- a/setup.py +++ b/setup.py @@ -18,13 +18,21 @@ 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", "Operating System :: OS Independent", ], install_requires=[ - "requests", + "httpx", ], + 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)