Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ share/python-wheels/
*.egg
MANIFEST

# Virtual environments
.venv/

# Symlink
examples/growattServer
23 changes: 21 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
157 changes: 157 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -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()
```
43 changes: 37 additions & 6 deletions docs/openapiv1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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 |
Expand Down
17 changes: 16 additions & 1 deletion docs/shinephone.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +18,21 @@ login_response = api.login(<username>, <password>)
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(<username>, <password>)
# Get a list of growatt plants.
print(await api.plant_list(login_response['user']['id']))

asyncio.run(main())
```

## Methods and Variables

### Methods
Expand Down
59 changes: 59 additions & 0 deletions examples/async_min_example.py
Original file line number Diff line number Diff line change
@@ -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())
Loading