From 25414bc5d0f6040aeb8f930b661015a8879ba533 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Tue, 13 Jan 2026 07:47:38 -0500 Subject: [PATCH 1/3] introduce time_cached for low-level APIs --- franklinwh/client.py | 25 ++++++++++++++++--------- franklinwh/time_cached.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 franklinwh/time_cached.py diff --git a/franklinwh/client.py b/franklinwh/client.py index 0d40166..c1ed32a 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -5,9 +5,9 @@ """ from __future__ import annotations -from collections.abc import Callable import asyncio +from collections.abc import Callable from dataclasses import dataclass from enum import Enum import hashlib @@ -19,6 +19,7 @@ import httpx from .api import DEFAULT_URL_BASE +from .time_cached import time_cached class AccessoryType(Enum): @@ -403,7 +404,7 @@ class GatewayOfflineException(Exception): class InvalidDataException(Exception): - """raised when the API returns data that is structurally invalid""" + """raised when the API returns data that is structurally invalid.""" class HttpClientFactory: @@ -649,12 +650,14 @@ def set_value(keys, value): return json.loads(data) # Sends a 203 which is a high level status + @time_cached() async def _status(self): payload = self._build_payload(203, {"opt": 1, "refreshData": 1}) data = (await self._mqtt_send(payload))["result"]["dataArea"] return json.loads(data) # Sends a 311 which appears to be a more specific switch command + @time_cached() async def _switch_status(self): payload = self._build_payload(311, {"opt": 0, "order": self.gateway}) data = (await self._mqtt_send(payload))["result"]["dataArea"] @@ -662,6 +665,7 @@ async def _switch_status(self): # Sends a 353 which grabs real-time smart-circuit load information # https://github.com/richo/homeassistant-franklinwh/issues/27#issuecomment-2714422732 + @time_cached() async def _switch_usage(self): payload = self._build_payload(353, {"opt": 0, "order": self.gateway}) data = (await self._mqtt_send(payload))["result"]["dataArea"] @@ -841,13 +845,15 @@ async def set_export_settings( discharge_max = 0.0 payload = {k: v for k, v in current.items() if v is not None} - payload.update({ - "gatewayId": self.gateway, - "lang": "EN_US", - "gridFeedMaxFlag": mode.value, - "gridFeedMax": feed_max, - "globalGridDischargeMax": discharge_max, - }) + payload.update( + { + "gatewayId": self.gateway, + "lang": "EN_US", + "gridFeedMaxFlag": mode.value, + "gridFeedMax": feed_max, + "globalGridDischargeMax": discharge_max, + } + ) res = await self.session.post( set_url, @@ -859,6 +865,7 @@ async def set_export_settings( if body.get("code") != 200: raise RuntimeError(f"set_export_settings failed: {body}") + @time_cached() async def get_composite_info(self): """Get composite information about the FranklinWH gateway.""" url = self.url_base + "hes-gateway/terminal/getDeviceCompositeInfo" diff --git a/franklinwh/time_cached.py b/franklinwh/time_cached.py new file mode 100644 index 0000000..d725f5a --- /dev/null +++ b/franklinwh/time_cached.py @@ -0,0 +1,35 @@ +"""Cache a function result for a specified time. + +This design provides a locked cache PER DECORATOR INSTANCE so SHOULD apply +only to functions that are definitely called periodically, otherwise it may +cache arguments and results indefinitely, think 'self' for member functions. +""" + +import asyncio +from datetime import datetime, timedelta +from functools import wraps + + +def time_cached(ttl: timedelta = timedelta(seconds=2)): + """Decorator to cache function results for a specified time-to-live (TTL).""" + + def wrapper(func): + __cache = {} + __lock = asyncio.Lock() + + @wraps(func) + async def wrapped(*args, **kwargs): + async with __lock: + now = datetime.now() + for key, value in __cache.copy().items(): + if now > value[0]: + del __cache[key] + key = (args, frozenset(kwargs.items())) + if key not in __cache: + __cache[key] = (now + ttl, await func(*args, **kwargs)) + return __cache[key][1] + + setattr(wrapped, "clear", __cache.clear) + return wrapped + + return wrapper From 53da2076a7dcaea580b7a92403df63ec2af6acf8 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Sun, 5 Apr 2026 08:55:01 -0400 Subject: [PATCH 2/3] introduce a new API for SmartCircuits --- bin/get_info.py | 3 + bin/login.py | 0 bin/set_circuit.py | 85 ++++++++++++++++++ franklinwh/__init__.py | 6 ++ franklinwh/client.py | 199 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 293 insertions(+) mode change 100644 => 100755 bin/get_info.py mode change 100644 => 100755 bin/login.py create mode 100755 bin/set_circuit.py diff --git a/bin/get_info.py b/bin/get_info.py old mode 100644 new mode 100755 index 90b41ef..fdd35d1 --- a/bin/get_info.py +++ b/bin/get_info.py @@ -107,10 +107,13 @@ async def main(): "_status": None, "_switch_status": None, "_switch_usage": None, + "get_export_settings": None, "get_home_gateway_list": None, "get_accessories": None, # "get_mode": None, # KeyError: 21669 "get_smart_switch_state": None, + "get_smart_circuits": None, + "get_smart_circuits_enhanced": None, "get_stats": None, } diff --git a/bin/login.py b/bin/login.py old mode 100644 new mode 100755 diff --git a/bin/set_circuit.py b/bin/set_circuit.py new file mode 100755 index 0000000..9f947d3 --- /dev/null +++ b/bin/set_circuit.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Get information about the FranklinHW installation.""" + +import argparse +import asyncio +import logging +import sys + +from franklinwh import Client, TokenFetcher +import jsonpickle + + +def truthy(value: str) -> bool: + """Convert a string to a boolean.""" + sure = ("yes", "true", "t", "y", "1", "on") + nope = ("no", "false", "f", "n", "0", "off") + if value.lower() in sure: + return True + if value.lower() in nope: + return False + raise argparse.ArgumentTypeError( + "Boolean must be one of " + ", ".join(sure + nope) + "." + ) + + +async def main(): + """Do all the work.""" + parser = argparse.ArgumentParser(description="Get FranklinWH installation info.") + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging.", + ) + parser.add_argument( + "username", + type=str, + help="The username for the installation.", + ) + parser.add_argument( + "password", + type=str, + help="The password for the installation.", + ) + parser.add_argument( + "gateway", + type=str, + help="The gateway / serial number to query.", + ) + parser.add_argument( + "--merged", + action=argparse.BooleanOptionalAction, + help="Merge Circuits 1 and 2.", + ) + parser.add_argument( + "circuit", + type=int, + choices=range(1, 4), + help="The circuit number to query.", + ) + parser.add_argument("on", type=truthy, help="Turn on or off.") + + args = parser.parse_args() + + if args.debug: + logging.basicConfig() + logging.getLogger("franklinwh").setLevel(logging.DEBUG) + logging.getLogger("httpx").setLevel(logging.DEBUG) + + fetcher = TokenFetcher(args.username, args.password) + client = Client(fetcher, args.gateway) + if args.merged is not None: + await client.set_smart_circuits_merged(args.merged) + await client.set_circuit(args.circuit, args.on) + + print( # noqa: T201 + jsonpickle.dumps( + await client.get_smart_circuits_enhanced(), indent=2, unpicklable=False + ) + ) + + sys.exit(0) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/franklinwh/__init__.py b/franklinwh/__init__.py index 83fdae4..51f68f0 100644 --- a/franklinwh/__init__.py +++ b/franklinwh/__init__.py @@ -4,12 +4,15 @@ from .caching_thread import CachingThread from .client import ( AccessoryType, + Circuit, Client, + EnhancedCircuit, ExportMode, ExportSettings, GridStatus, HttpClientFactory, Mode, + SmartCircuits, Stats, SwitchState, TokenFetcher, @@ -19,12 +22,15 @@ "DEFAULT_URL_BASE", "AccessoryType", "CachingThread", + "Circuit", "Client", + "EnhancedCircuit", "ExportMode", "ExportSettings", "GridStatus", "HttpClientFactory", "Mode", + "SmartCircuits", "Stats", "SwitchState", "TokenFetcher", diff --git a/franklinwh/client.py b/franklinwh/client.py index c1ed32a..4f799d5 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -9,11 +9,13 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass +from datetime import timedelta from enum import Enum import hashlib import json import logging import time +from typing import Any import zlib import httpx @@ -383,6 +385,113 @@ def __new__(cls, lst: list[bool | None] | None = None): return super().__new__(cls, lst) +@dataclass +class Circuit: + """Represents the basic state of a smart circuit.""" + + on: bool + + +@dataclass +class EnhancedCircuit(Circuit): + """Represents the enhanced state of a smart circuit. + + Attributes: + ---------- + name : str + The name of the circuit. + load : float + The current load value for the circuit. + export : float + The total export value for the circuit. + soc_threshold : float + The state of charge threshold for the circuit. + """ + + # these come from configuration + name: str + soc_threshold: int + """The state of charge threshold for the circuit.""" + schedule: dict[str, Any] + # these come from status + power: float + """The current consumption in kW for the circuit.""" + export_energy: float + """The total export energy in kWh for the circuit.""" + import_energy: float + """The total import energy in kWh for the circuit.""" + + +class SmartCircuits: + """Represents the state of the SmartCircuits module. + + Attributes: + ---------- + merged : bool + Indicates whether Circuits 1 and 2 are merged. + circuits : list[Circuit | None] + A list of objects representing the state of each Circuit. + To avoid off-by-one issues, the 0th element of the list is unused and will always be None. + If merged, the second object will be None. + Depending on which API you call, these may be either Circuit or EnhancedCircuit objects. + """ + + @staticmethod + def is_merged(data: dict) -> bool: + """Determine if Circuits 1 and 2 are merged based on the provided data. + + Parameters + ---------- + data : dict + The data dictionary containing the "merge" key. + + Returns: + ------- + bool + True if Circuits 1 and 2 are merged, False otherwise. + """ + return "merge" in data and data["merge"][0] == 1 + + @staticmethod + def openAction(_on: bool) -> int: + """Convert a boolean on/off value to the corresponding openAction integer. + + Parameters + ---------- + on : bool + True to turn the circuit on, False to turn it off. + + Returns: + ------- + int + """ + return 2 if _on else 1 + + on = openAction(True) + + def __init__(self, merged: bool, circuits: list[Circuit | None]) -> None: + """Initialize a SmartCircuits instance.""" + self.merged = merged + match len(circuits): + case 3: + self.circuits = [None, *circuits] + case 4: + self.circuits = [None, *circuits[1:]] + case _: + raise ValueError("circuits must be a list of 3 or 4 elements") + # fix statistics + for c in self.circuits[1:]: + if isinstance(c, EnhancedCircuit): + if not c.on: + c.power = 0.0 + if merged: + if isinstance(self.circuits[2], EnhancedCircuit): + self.circuits[1].power += self.circuits[2].power + self.circuits[1].export_energy += self.circuits[2].export_energy + self.circuits[1].import_energy += self.circuits[2].import_energy + self.circuits[2] = None + + class TokenExpiredException(Exception): """raised when the token has expired to signal upstream that you need to create a new client or inject a new token.""" @@ -897,6 +1006,96 @@ async def get_home_gateway_list(self): url = DEFAULT_URL_BASE + "hes-gateway/terminal/getHomeGatewayList" return (await self._get(url))["result"] + @time_cached(ttl=timedelta(seconds=5)) + async def __387(self): + """Get SmartCircuits module configuration.""" + payload = self._build_payload(387, {"opt": 0}) + data = (await self._mqtt_send(payload))["result"]["dataArea"] + return json.loads(data) + + @time_cached() + async def __389(self): + """Get SmartCircuits module status.""" + payload = self._build_payload(389, {"opt": 0}) + data = (await self._mqtt_send(payload))["result"]["dataArea"] + return json.loads(data) + + async def get_smart_circuits(self, data: dict | None = None) -> SmartCircuits: + """Get the basic state of the SmartCircuits module.""" + if data is None: + data = await self.__387() + circuits = [ + Circuit(on=x["openAction"] == SmartCircuits.on) for x in data["smartSwitch"] + ] + return SmartCircuits(SmartCircuits.is_merged(data), circuits) + + async def get_smart_circuits_enhanced(self) -> SmartCircuits: + """Get the enhanced state of the SmartCircuits module.""" + tasks = [f() for f in [self.__387, self.__389]] + data, status = await asyncio.gather(*tasks) + circuits = [ + EnhancedCircuit( + on=d["openAction"] == SmartCircuits.on, + name=d["name"], + soc_threshold=d["socThreshold"], + schedule=d["schedule"], + power=s["power"] / 1000.0, + export_energy=s["exportEnergy"] / 1000.0, + import_energy=s["importEnergy"] / 1000.0, + ) + for d, s in zip(data["smartSwitch"], status["smartSwitchData"], strict=True) + ] + return SmartCircuits(SmartCircuits.is_merged(data), circuits) + + async def set_circuit(self, circuit: int, on: bool) -> SmartCircuits: + """Set the state of a specific circuit on the SmartCircuits module. + + When merged, Circuit 1 also affects Circuit 2. + + Parameters + ---------- + circuit : int + The circuit number to set (1,3) and (2) if not merged. + on : bool + True to turn the circuit on, False to turn it off. + """ + data = await self.__387() + match circuit: + case 3: + pass + case 2: + if SmartCircuits.is_merged(data): + raise ValueError("Circuit 2 cannot be set when merged") + case 1: + if SmartCircuits.is_merged(data): + # if merged also set Circuit 2 the same way + data["smartSwitch"][1]["openAction"] = SmartCircuits.openAction(on) + case _: + raise ValueError("Circuit must be 1-3") + data["smartSwitch"][circuit - 1]["openAction"] = SmartCircuits.openAction(on) + data["opt"] = 1 + payload = self._build_payload(387, data) + return await self.get_smart_circuits( + json.loads((await self._mqtt_send(payload))["result"]["dataArea"]) + ) + + async def set_smart_circuits_merged(self, merged: bool) -> SmartCircuits: + """Set whether Circuits 1 and 2 are merged and adjust Circuit 2 accordingly.""" + data = await self.__387() + if not merged: + # separate + data["merge"] = [0, 0] + else: + # align Circuit 2 with Circuit 1 + data["smartSwitch"][1]["openAction"] = data["smartSwitch"][0]["openAction"] + # and merge + data["merge"] = [1, 2] + data["opt"] = 1 + payload = self._build_payload(387, data) + return await self.get_smart_circuits( + json.loads((await self._mqtt_send(payload))["result"]["dataArea"]) + ) + class UnknownMethodsClient(Client): """A client that also implements some methods that don't obviously work, for research purposes.""" From 4f73421ef4df30cb977484e3d0e97ecea16b32f2 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:10:24 -0400 Subject: [PATCH 3/3] deprecate the old API regarding switches --- franklinwh/__init__.py | 4 ++-- franklinwh/client.py | 25 ++++++++++++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/franklinwh/__init__.py b/franklinwh/__init__.py index 51f68f0..b30cc02 100644 --- a/franklinwh/__init__.py +++ b/franklinwh/__init__.py @@ -14,7 +14,7 @@ Mode, SmartCircuits, Stats, - SwitchState, + SwitchState, # deprecated TokenFetcher, ) @@ -32,6 +32,6 @@ "Mode", "SmartCircuits", "Stats", - "SwitchState", + "SwitchState", # deprecated "TokenFetcher", ] diff --git a/franklinwh/client.py b/franklinwh/client.py index 4f799d5..135fea3 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -16,6 +16,7 @@ import logging import time from typing import Any +from warnings import deprecated, warn import zlib import httpx @@ -189,9 +190,9 @@ class Current: grid_use: float home_load: float battery_soc: float - switch_1_load: float - switch_2_load: float - v2l_use: float + switch_1_load: float # deprecated + switch_2_load: float # deprecated + v2l_use: float # deprecated grid_status: GridStatus @@ -206,10 +207,10 @@ class Totals: solar: float generator: float home_use: float - switch_1_use: float - switch_2_use: float - v2l_export: float - v2l_import: float + switch_1_use: float # deprecated + switch_2_use: float # deprecated + v2l_export: float # deprecated + v2l_import: float # deprecated @dataclass @@ -351,6 +352,7 @@ def payload(self, gateway) -> dict: } +@deprecated("use SmartCircuits instead") class SwitchState(tuple[bool | None, bool | None, bool | None]): """Represents the state of the smart switches connected to the FranklinWH gateway. @@ -709,6 +711,7 @@ async def get_accessories(self): # {"code":200,"message":"Query success!","result":[],"success":true,"total":0} return (await self._get(url))["result"] + @deprecated("use get_smart_circuits() instead") async def get_smart_switch_state(self) -> SwitchState: """Get the current state of the smart switches.""" # TODO(richo) This API is super in flux, both because of how vague the @@ -720,6 +723,7 @@ async def get_smart_switch_state(self) -> SwitchState: switches = [x == 1 for x in status["pro_load"]] return SwitchState(switches) + @deprecated("use set_circuit() instead") async def set_smart_switch_state(self, state: SwitchState): """Set the state of the smart circuits. @@ -766,6 +770,7 @@ async def _status(self): return json.loads(data) # Sends a 311 which appears to be a more specific switch command + @deprecated("use get_smart_circuits() or get_smart_circuits_enhanced() instead") @time_cached() async def _switch_status(self): payload = self._build_payload(311, {"opt": 0, "order": self.gateway}) @@ -774,6 +779,7 @@ async def _switch_status(self): # Sends a 353 which grabs real-time smart-circuit load information # https://github.com/richo/homeassistant-franklinwh/issues/27#issuecomment-2714422732 + @deprecated("use get_smart_circuits_enhanced() instead") @time_cached() async def _switch_usage(self): payload = self._build_payload(353, {"opt": 0, "order": self.gateway}) @@ -812,6 +818,11 @@ async def get_stats(self) -> Stats: This includes instantaneous measurements for current power, as well as totals for today (in local time) """ + warn( + "switch statistics are deprecated from get_stats(), use get_smart_circuits_enhanced() instead", + DeprecationWarning, + stacklevel=2, + ) tasks = [f() for f in [self.get_composite_info, self._switch_usage]] info, sw_data = await asyncio.gather(*tasks) data = info["runtimeData"]