From 3af5ed5884559e6da077369bf8627c6b0401d2f0 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Sat, 3 Jan 2026 06:28:29 -0500 Subject: [PATCH 1/3] skip a redundant conversion --- franklinwh/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/franklinwh/client.py b/franklinwh/client.py index c8967c6..91b256f 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -620,8 +620,8 @@ def next_snno(self): return self.snno def _build_payload(self, ty, data): - blob = json.dumps(data, separators=(",", ":")).encode("utf-8") - # crc = to_hex(zlib.crc32(blob.encode("ascii"))) + raw = json.dumps(data, separators=(",", ":")) + blob = raw.encode("utf-8") crc = to_hex(zlib.crc32(blob)) ts = int(time.time()) @@ -639,7 +639,7 @@ def _build_payload(self, ty, data): } ) # We do it this way because without a canonical way to generate JSON we can't risk reordering breaking the CRC. - return temp.replace('"DATA"', blob.decode("utf-8")) + return temp.replace('"DATA"', raw) async def _mqtt_send(self, payload): url = DEFAULT_URL_BASE + "hes-gateway/terminal/sendMqtt" From fee5485013d9bddd6949255292744ee4634a2811 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Sat, 3 Jan 2026 06:29:04 -0500 Subject: [PATCH 2/3] switch get_client to async --- franklinwh/client.py | 127 +++++++++++++++++++++++++------------------ 1 file changed, 74 insertions(+), 53 deletions(-) diff --git a/franklinwh/client.py b/franklinwh/client.py index 91b256f..df11d38 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -4,6 +4,7 @@ and retrieve statistics from FranklinWH energy gateway devices. """ +from collections.abc import Awaitable, Callable from dataclasses import dataclass from enum import Enum import hashlib @@ -291,16 +292,28 @@ class DeviceTimeoutException(Exception): class GatewayOfflineException(Exception): """raised when the gateway is offline.""" + class HttpClientFactory: - # If you store a function in an attribute, it becomes a bound method - factory = (lambda: httpx.AsyncClient(http2=True),) + """Factory for creating httpx.AsyncClient.""" + + @staticmethod + async def _default_get_client() -> httpx.AsyncClient: + return httpx.AsyncClient(http2=True) + + factory: Callable[..., Awaitable[httpx.AsyncClient]] = _default_get_client + + @classmethod + def set_client_factory( + cls, factory: Callable[..., Awaitable[httpx.AsyncClient]] + ) -> None: + """Set the async factory method for creating HTTP/2 clients.""" + cls.factory = factory @classmethod - def set_client_factory(cls, factory): - cls.factory = (factory,) + async def get_client(cls) -> httpx.AsyncClient: + """Create a new httpx.AsyncClient using the configured async factory method.""" + return await cls.factory() - def get_client(self): - return self.factory[0]() class TokenFetcher(HttpClientFactory): """Fetches and refreshes authentication tokens for FranklinWH API.""" @@ -311,7 +324,7 @@ def __init__(self, username: str, password: str) -> None: self.password = password self.info: dict | None = None - async def get_token(self): + async def get_token(self) -> str: """Fetch a new authentication token using the stored credentials. Store the intermediate account information in self.info. @@ -320,15 +333,11 @@ async def get_token(self): return self.info["token"] @staticmethod - async def login(username: str, password: str): + async def login(username: str, password: str) -> None: """Log in to the FranklinWH API and retrieve an authentication token.""" await TokenFetcher(username, password).get_token() - @staticmethod - async def _login(username: str, password: str) -> dict: - await TokenFetcher(username, password).get_token() - - async def fetch_token(self): + async def fetch_token(self) -> dict: """Log in to the FranklinWH API and retrieve account information.""" url = ( DEFAULT_URL_BASE + "hes-gateway/terminal/initialize/appUserOrInstallerLogin" @@ -339,7 +348,7 @@ async def fetch_token(self): "lang": "en_US", "type": 1, } - async with self.get_client() as client: + async with await self.get_client() as client: res = await client.post(url, data=form, timeout=10) res.raise_for_status() js = res.json() @@ -374,54 +383,61 @@ def __init__( self.url_base = url_base self.token = "" self.snno = 0 - self.session = self.get_client() + self.session: httpx.AsyncClient | None = None # to enable detailed logging add this to configuration.yaml: # logger: # logs: # franklinwh: debug - logger = logging.getLogger("franklinwh") - logger.warning("Session class: %s" % type(self.session)) - self.logger = logger - if logger.isEnabledFor(logging.DEBUG): - - async def debug_request(request: httpx.Request): - body = request.content - if body and request.headers.get("Content-Type", "").startswith( - "application/json" - ): - body = json.dumps(json.loads(body), ensure_ascii=False) - self.logger.debug( - "Request: %s %s %s %s", - request.method, - request.url, - request.headers, - body, - ) - return request - - async def debug_response(response: httpx.Response): - await response.aread() - self.logger.debug( - "Response: %s %s %s %s", - response.status_code, - response.url, - response.headers, - response.json(), - ) - return response - + self.logger = logging.getLogger("franklinwh") + + async def get_client(self) -> httpx.AsyncClient: + """Return the session or create a new session with optional debug logging.""" + if self.session is None: + self.session = await super().get_client() + if self.logger.isEnabledFor(logging.DEBUG): + + async def debug_request(request: httpx.Request): + body = request.content + if body and request.headers.get("Content-Type", "").startswith( + "application/json" + ): + body = json.dumps(json.loads(body), ensure_ascii=False) + self.logger.debug( + "Request: %s %s %s %s", + request.method, + request.url, + request.headers, + body, + ) + return request + + async def debug_response(response: httpx.Response): + await response.aread() + self.logger.debug( + "Response: %s %s %s %s", + response.status_code, + response.url, + response.headers, + response.json(), + ) + return response + + self.session.event_hooks["request"].append(debug_request) + self.session.event_hooks["response"].append(debug_response) + return self.session # TODO(richo) Setup timeouts and deal with them gracefully. async def _post(self, url, payload, params: dict | None = None): + session = await self.get_client() if params is not None: params = params.copy() params.update({"gatewayId": self.gateway, "lang": "en_US"}) async def __post(): return ( - await self.session.post( + await session.post( url, params=params, headers={ @@ -435,9 +451,11 @@ async def __post(): return await retry(__post, lambda j: j["code"] != 401, self.refresh_token) async def _post_form(self, url, payload): + session = await self.get_client() + async def __post(): return ( - await self.session.post( + await session.post( url, headers={ "loginToken": self.token, @@ -451,6 +469,7 @@ async def __post(): return await retry(__post, lambda j: j["code"] != 401, self.refresh_token) async def _get(self, url, params: dict | None = None): + session = await self.get_client() if params is None: params = {} else: @@ -459,7 +478,7 @@ async def _get(self, url, params: dict | None = None): async def __get(): return ( - await self.session.get( + await session.get( url, params=params, headers={"loginToken": self.token} ) ).json() @@ -579,7 +598,6 @@ async def get_stats(self) -> Stats: This includes instantaneous measurements for current power, as well as totals for today (in local time) """ - self.logger.warning("get_stats: Session class: %s" % type(self.session)) data = await self._status() grid_status: GridStatus = GridStatus.NORMAL if "offgridreason" in data: @@ -680,7 +698,8 @@ async def get_controllable_loads(self): ) params = {"id": self.gateway, "lang": "en_US"} headers = {"loginToken": self.token} - res = await self.session.get(url, params=params, headers=headers) + session = await self.get_client() + res = await session.get(url, params=params, headers=headers) return res.json() async def get_accessory_list(self): @@ -688,7 +707,8 @@ async def get_accessory_list(self): url = self.url_base + "hes-gateway/terminal/getIotAccessoryList" params = {"gatewayId": self.gateway, "lang": "en_US"} headers = {"loginToken": self.token} - res = await self.session.get(url, params=params, headers=headers) + session = await self.get_client() + res = await session.get(url, params=params, headers=headers) return res.json() async def get_equipment_list(self): @@ -696,5 +716,6 @@ async def get_equipment_list(self): url = self.url_base + "hes-gateway/manage/getEquipmentList" params = {"gatewayId": self.gateway, "lang": "en_US"} headers = {"loginToken": self.token} - res = await self.session.get(url, params=params, headers=headers) + session = await self.get_client() + res = await session.get(url, params=params, headers=headers) return res.json() From 7b145db602394fc63b368051648f1c0972af8523 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Sat, 3 Jan 2026 06:46:01 -0500 Subject: [PATCH 3/3] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cfe1222..2aa5ace 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "franklinwh" -version = "1.0.0" +version = "1.1.0" authors = [ { name="Richo Butts", email="richo@psych0tik.net" }, ]