Context
Issue #148 proposed migrating from requests to aiohttp to meet Home Assistant's Platinum quality scale requirements (async-dependency and inject-websession). That proposal involved a full breaking change (all methods becoming async).
After researching the HA requirements and migration approaches in depth, we believe there is a better path: replace requests with httpx and provide both sync and async clients as a non-breaking change.
This issue supersedes #148 with a revised design.
Why httpx instead of aiohttp?
HA does not require aiohttp
The HA quality scale rules say:
- async-dependency: "your library should ideally also use asyncio" — no specific library mandated
- inject-websession: "support passing in a web session to the dependency client" — HA provides helpers for both
aiohttp and httpx:
async_get_clientsession(hass) returns aiohttp.ClientSession
get_async_client(hass) returns httpx.AsyncClient
Several HA core integrations already use httpx (openai_conversation, rest platform, generic camera).
httpx gives us sync + async in one dependency
httpx provides httpx.Client (sync, API nearly identical to requests.Session) and httpx.AsyncClient (async) in a single package. This means:
- One HTTP dependency instead of two (
requests + aiohttp)
- The sync client is a near drop-in for
requests.Session — same .get(), .post(), .raise_for_status() patterns
- No code duplication between sync and async implementations beyond the HTTP call itself
Proven pattern
This is exactly what the OpenAI, Anthropic, and other modern Python SDKs do: sync class + async class, both backed by httpx.
Why not other approaches?
We evaluated several alternatives:
| Approach |
Problem |
| aiohttp-only (#148) |
Breaking change for all sync users — every call site needs await and asyncio.run() |
Async core + asyncio.run() sync wrappers |
Crashes inside already-running event loops (Jupyter, HA). nest_asyncio workaround is broken on Python 3.14+ |
| Background thread event loop |
Complex lifecycle management, thread-safety concerns, hard to debug |
| Keep requests + add aiohttp |
Two HTTP dependencies, significant code duplication across ~80 methods, double maintenance burden |
Proposed design
Structure
growattServer/
__init__.py # adds AsyncGrowattApi, AsyncOpenApiV1 to exports
_base.py # shared logic: URL building, param construction, hashing
api.py # GrowattApi (sync, httpx.Client)
async_api.py # AsyncGrowattApi (async, httpx.AsyncClient)
exceptions.py # unchanged
open_api_v1/
__init__.py # OpenApiV1 (sync)
async_client.py # AsyncOpenApiV1 (async)
devices/
abstract_device.py
min.py # Min (sync) + AsyncMin (async)
sph.py # Sph (sync) + AsyncSph (async)
Shared base (minimize duplication)
Extract all non-HTTP logic into a shared _base.py:
- URL construction (
get_url(), server_url)
- Parameter building (the
data={...} and params={...} dicts each method constructs)
- Response parsing (JSON extraction, error checking)
- Password hashing, user-agent construction
- Constants (
BATT_MODE_*, Timespan, etc.)
The sync and async classes inherit from this base and only differ in the HTTP call itself.
Sync API (non-breaking)
GrowattApi keeps the exact same constructor signature, method signatures, and return types:
class GrowattApi(GrowattApiBase):
def __init__(self, add_random_user_id=False, agent_identifier=None):
super().__init__(add_random_user_id, agent_identifier)
self.session = httpx.Client(
headers={"User-Agent": self.agent_identifier}, ...
)
def login(self, username, password, is_password_hashed=False):
# same signature, same return value
response = self.session.post(
self.get_url("newTwoLoginAPI.do"), data={...}
)
response.raise_for_status()
return response.json()["back"]
Existing user code works unchanged:
from growattServer import GrowattApi
api = GrowattApi()
api.login("user", "pass")
plants = api.plant_list(user_id)
Async API (new, additive)
AsyncGrowattApi mirrors the sync class with async def methods and accepts an optional session:
class AsyncGrowattApi(GrowattApiBase):
def __init__(
self,
add_random_user_id=False,
agent_identifier=None,
session: httpx.AsyncClient | None = None,
):
super().__init__(add_random_user_id, agent_identifier)
self._external_session = session is not None
self.session = session or httpx.AsyncClient(
headers={"User-Agent": self.agent_identifier}, ...
)
async def login(self, username, password, is_password_hashed=False):
response = await self.session.post(
self.get_url("newTwoLoginAPI.do"), data={...}
)
response.raise_for_status()
return response.json()["back"]
async def close(self):
if not self._external_session:
await self.session.aclose()
Note: session is the last constructor parameter to avoid breaking positional callers in subclasses.
Home Assistant integration usage:
from growattServer import AsyncGrowattApi
from homeassistant.helpers.httpx_client import get_async_client
session = get_async_client(hass)
api = AsyncGrowattApi(session=session)
data = await api.login("user", "pass")
OpenApiV1 — same pattern
class OpenApiV1(GrowattApi): # sync, unchanged signature
def __init__(self, token: str): ...
class AsyncOpenApiV1(AsyncGrowattApi): # new async variant
def __init__(self, token: str, session: httpx.AsyncClient | None = None): ...
Device classes
Min, Sph get async counterparts (AsyncMin, AsyncSph) that take an AsyncOpenApiV1 instead of OpenApiV1. The shared parameter construction and response parsing logic lives in common base classes.
Exception handling
Replace requests.exceptions.* references in docstrings:
requests.exceptions.HTTPError -> httpx.HTTPStatusError
requests.exceptions.ConnectionError -> httpx.ConnectError
requests.exceptions.Timeout -> httpx.TimeoutException
The custom exceptions (GrowattError, GrowattParameterError, GrowattV1ApiError) remain unchanged.
Dependencies
install_requires=[
"httpx",
]
requests is dropped. httpx is the sole HTTP dependency.
Migration path for existing users
Sync users: nothing changes
# Before (v2.x)
from growattServer import GrowattApi
api = GrowattApi()
api.login("user", "pass")
# After (v3.0) — identical
from growattServer import GrowattApi
api = GrowattApi()
api.login("user", "pass")
Users accessing api.session directly
api.session changes from requests.Session to httpx.Client. The .get() / .post() API is nearly identical, but this is the one potential compatibility edge case. This is undocumented/internal usage, but we should note it in the changelog.
Async/HA users: new opt-in
from growattServer import AsyncGrowattApi
api = AsyncGrowattApi(session=external_session)
await api.login("user", "pass")
Versioning
Since the public API is non-breaking but the underlying HTTP dependency changes (requests -> httpx), this warrants a major version bump to 3.0.0. The api.session type change and dependency swap justify it even though method signatures are preserved.
Scope summary
| What |
Breaking? |
GrowattApi constructor and methods |
No — same signatures, same returns |
OpenApiV1 constructor and methods |
No — same signatures, same returns |
api.session type |
Yes (minor) — requests.Session -> httpx.Client |
| Dependency |
Yes — requests -> httpx |
New AsyncGrowattApi class |
Additive |
New AsyncOpenApiV1 class |
Additive |
| Custom exceptions |
No change |
hash_password, Timespan, etc. |
No change |
I'm happy to implement this — wanted to align on the design first.
Context
Issue #148 proposed migrating from
requeststoaiohttpto meet Home Assistant's Platinum quality scale requirements (async-dependency and inject-websession). That proposal involved a full breaking change (all methods becomingasync).After researching the HA requirements and migration approaches in depth, we believe there is a better path: replace
requestswithhttpxand provide both sync and async clients as a non-breaking change.This issue supersedes #148 with a revised design.
Why httpx instead of aiohttp?
HA does not require aiohttp
The HA quality scale rules say:
aiohttpandhttpx:async_get_clientsession(hass)returnsaiohttp.ClientSessionget_async_client(hass)returnshttpx.AsyncClientSeveral HA core integrations already use httpx (openai_conversation, rest platform, generic camera).
httpx gives us sync + async in one dependency
httpxprovideshttpx.Client(sync, API nearly identical torequests.Session) andhttpx.AsyncClient(async) in a single package. This means:requests+aiohttp)requests.Session— same.get(),.post(),.raise_for_status()patternsProven pattern
This is exactly what the OpenAI, Anthropic, and other modern Python SDKs do: sync class + async class, both backed by httpx.
Why not other approaches?
We evaluated several alternatives:
awaitandasyncio.run()asyncio.run()sync wrappersnest_asyncioworkaround is broken on Python 3.14+Proposed design
Structure
Shared base (minimize duplication)
Extract all non-HTTP logic into a shared
_base.py:get_url(),server_url)data={...}andparams={...}dicts each method constructs)BATT_MODE_*,Timespan, etc.)The sync and async classes inherit from this base and only differ in the HTTP call itself.
Sync API (non-breaking)
GrowattApikeeps the exact same constructor signature, method signatures, and return types:Existing user code works unchanged:
Async API (new, additive)
AsyncGrowattApimirrors the sync class withasync defmethods and accepts an optional session:Note:
sessionis the last constructor parameter to avoid breaking positional callers in subclasses.Home Assistant integration usage:
OpenApiV1 — same pattern
Device classes
Min,Sphget async counterparts (AsyncMin,AsyncSph) that take anAsyncOpenApiV1instead ofOpenApiV1. The shared parameter construction and response parsing logic lives in common base classes.Exception handling
Replace
requests.exceptions.*references in docstrings:requests.exceptions.HTTPError->httpx.HTTPStatusErrorrequests.exceptions.ConnectionError->httpx.ConnectErrorrequests.exceptions.Timeout->httpx.TimeoutExceptionThe custom exceptions (
GrowattError,GrowattParameterError,GrowattV1ApiError) remain unchanged.Dependencies
requestsis dropped.httpxis the sole HTTP dependency.Migration path for existing users
Sync users: nothing changes
Users accessing
api.sessiondirectlyapi.sessionchanges fromrequests.Sessiontohttpx.Client. The.get()/.post()API is nearly identical, but this is the one potential compatibility edge case. This is undocumented/internal usage, but we should note it in the changelog.Async/HA users: new opt-in
Versioning
Since the public API is non-breaking but the underlying HTTP dependency changes (
requests->httpx), this warrants a major version bump to 3.0.0. Theapi.sessiontype change and dependency swap justify it even though method signatures are preserved.Scope summary
GrowattApiconstructor and methodsOpenApiV1constructor and methodsapi.sessiontyperequests.Session->httpx.Clientrequests->httpxAsyncGrowattApiclassAsyncOpenApiV1classhash_password,Timespan, etc.I'm happy to implement this — wanted to align on the design first.