From 0b775c0ffad08be29dfd4f469b89fc62de902152 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/4] 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 bf0102d..97876bc 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 PermissionDeniedException(Exception): @@ -653,12 +654,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"] @@ -666,6 +669,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"] @@ -847,13 +851,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, @@ -865,6 +871,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 d5350910d2fbe80a384e0434fa069c8171fcd646 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:35:20 -0500 Subject: [PATCH 2/4] rework Mode formalize workMode formalize names introduce cached get_modes --- bin/get_info.py | 2 +- bin/login.py | 0 franklinwh/__init__.py | 2 + franklinwh/api.py | 1 + franklinwh/client.py | 329 ++++++++++++++++++++++++++++++++--------- 5 files changed, 262 insertions(+), 72 deletions(-) mode change 100644 => 100755 bin/get_info.py mode change 100644 => 100755 bin/login.py diff --git a/bin/get_info.py b/bin/get_info.py old mode 100644 new mode 100755 index 90b41ef..ec95a11 --- a/bin/get_info.py +++ b/bin/get_info.py @@ -109,7 +109,7 @@ async def main(): "_switch_usage": None, "get_home_gateway_list": None, "get_accessories": None, - # "get_mode": None, # KeyError: 21669 + "get_mode": None, "get_smart_switch_state": None, "get_stats": None, } diff --git a/bin/login.py b/bin/login.py old mode 100644 new mode 100755 diff --git a/franklinwh/__init__.py b/franklinwh/__init__.py index 83fdae4..818711c 100644 --- a/franklinwh/__init__.py +++ b/franklinwh/__init__.py @@ -13,6 +13,7 @@ Stats, SwitchState, TokenFetcher, + WorkMode, ) __all__ = [ @@ -28,4 +29,5 @@ "Stats", "SwitchState", "TokenFetcher", + "WorkMode", ] diff --git a/franklinwh/api.py b/franklinwh/api.py index b239a91..2c3a62a 100644 --- a/franklinwh/api.py +++ b/franklinwh/api.py @@ -3,3 +3,4 @@ from typing import Final DEFAULT_URL_BASE: Final[str] = "https://energy.franklinwh.com/" +ISSUES_URL: Final[str] = "https://github.com/richo/franklinwh-python/issues" diff --git a/franklinwh/client.py b/franklinwh/client.py index 97876bc..6810d4e 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -7,13 +7,14 @@ from __future__ import annotations import asyncio -from collections.abc import Callable +from collections.abc import Callable, Generator from dataclasses import dataclass from enum import Enum import hashlib import json import logging import time +from typing import Any, Self import zlib import httpx @@ -218,15 +219,109 @@ class Stats: totals: Totals -MODE_TIME_OF_USE = "time_of_use" -MODE_SELF_CONSUMPTION = "self_consumption" -MODE_EMERGENCY_BACKUP = "emergency_backup" +class Id(Enum): + """Add identification to an enum.""" -MODE_MAP = { - 9322: MODE_TIME_OF_USE, - 9323: MODE_SELF_CONSUMPTION, - 9324: MODE_EMERGENCY_BACKUP, -} + id: int + + def __new__(cls, title, id) -> Self: + """Add identification to an enum.""" + obj = object.__new__(cls) + obj._value_ = title + obj.id = id + return obj + + @classmethod + def ids(cls) -> Generator[int]: + """Generate the ids of the enum members.""" + for item in cls: + yield item.id + + @classmethod + def names(cls) -> Generator[str]: + """Generate the names of the enum members.""" + for item in cls: + yield item.name + + @classmethod + def values(cls) -> Generator[str]: + """Generate the values of the enum members.""" + for item in cls: + yield item.value + + @classmethod + def from_id(cls, id: int) -> Self: + """Get the enum member corresponding to the given id. + + Parameters + ---------- + id : int + The id to look up. + + Returns: + ------- + Self + The enum member corresponding to the given id. + + Raises: + ------ + ValueError + If no enum member has the given id. + """ + for item in cls: + if item.id == id: + return item + raise ValueError(f"No {cls.__name__} with id {id}") + + @classmethod + def from_value(cls, value: str) -> Self: + """Get the enum member corresponding to the given value. + + Parameters + ---------- + value : str + The value to look up. + + Returns: + ------- + Self + The enum member corresponding to the given value. + + Raises: + ------ + ValueError + If no enum member has the given value. + """ + for item in cls: + if item.value == value: + return item + raise ValueError(f"No {cls.__name__} with value {value}") + + +class WorkMode(Id): + """Represents the workMode values of the FranklinWH gateway. + + These are the only operating mode constants in the FranklinWH API. + + Attributes: + TIME_OF_USE (int): Time of Use mode, id = 1. + SELF_CONSUMPTION (int): Self-Consumption mode, id = 2. + EMERGENCY_BACKUP (int): Emergency Backup mode, id = 3. + + These are artificial and controlled by API, support or provider. + + Attributes: + GENERATOR (int): Generator mode, id = 7. + DEBUG (int): Debug mode, id = 8. + VPP_MODE (int): VPP Mode, id = 9. + """ + + TIME_OF_USE = ("Time Of Use (TOU)", 1) + SELF_CONSUMPTION = ("Self-Consumption", 2) + EMERGENCY_BACKUP = ("Emergency Backup", 3) + GENERATOR = ("Generator", 7) + DEBUG = ("Debug", 8) + VPP_MODE = ("VPP Mode", 9) class Mode: @@ -235,29 +330,65 @@ class Mode: Provides static methods to create specific modes (time of use, emergency backup, self consumption) and generates payloads for API requests to set the gateway's operating mode. - Attributes: - ---------- - soc : int - The state of charge value for the mode. - currendId : int | None - The current mode identifier. - workMode : int | None - The work mode value. - Methods: ------- - time_of_use(soc=20) + time_of_use(optional soc) Create a time of use mode instance. - emergency_backup(soc=100) + emergency_backup(optional soc) Create an emergency backup mode instance. - self_consumption(soc=20) + self_consumption(optional soc) Create a self consumption mode instance. payload(gateway) Generate the payload dictionary for API requests. """ - @staticmethod - def time_of_use(soc=20): + _modes: dict[int, Any] = { + mode.id: { # compatible with result of getGatewayTouListV2 + "id": mode.id, + "oldIndex": 3, + "name": mode.value, + "soc": 100.0, + "maxSoc": 100.0, + "minSoc": 100.0, + "dischargeDepthSoc": None, + "editSocFlag": False, + "multiSOCFlag": False, + "workMode": mode.id, + "energyIncentivesType": 0, + "electricityType": 1, + "displayFlag": None, + } + for mode in WorkMode + } + + @classmethod + @time_cached(timedelta(hours=1)) # eventually consistent with changes via app + async def get_modes(cls, client: Client) -> dict[int, Any]: + """Get the available modes for the FranklinWH gateway. + + MUST be called once before using other methods, e.g., through get_mode(). + + Parameters + ---------- + client : Client + The FranklinWH client instance. + + Returns: + ------- + dict[int, Any] + A dictionary of available modes keyed by workMode. + + get_modes[TIME_OF_USE]["name"] returns the actual rate name + """ + body = await client._post( # noqa: SLF001 + DEFAULT_URL_BASE + "hes-gateway/terminal/tou/getGatewayTouListV2", None + ) + for v in body["result"]["list"]: + cls._modes[v["workMode"]] = v + return cls._modes + + @classmethod + def time_of_use(cls, soc: int | None = None) -> Mode: """Create a time of use mode instance. Parameters @@ -270,13 +401,12 @@ def time_of_use(soc=20): Mode An instance of Mode configured for time of use. """ - mode = Mode(soc) - mode.currendId = 9322 - mode.workMode = 1 - return mode + if soc is None: + soc = 20 + return Mode(WorkMode.TIME_OF_USE.id, soc) - @staticmethod - def emergency_backup(soc=100): + @classmethod + def emergency_backup(cls, soc: int | None = None) -> Mode: """Create an emergency backup mode instance. Parameters @@ -289,13 +419,12 @@ def emergency_backup(soc=100): Mode An instance of Mode configured for emergency backup. """ - mode = Mode(soc) - mode.currendId = 9324 - mode.workMode = 3 - return mode + if soc is None: + soc = 100 + return Mode(WorkMode.EMERGENCY_BACKUP.id, soc) - @staticmethod - def self_consumption(soc=20): + @classmethod + def self_consumption(cls, soc: int | None = None) -> Mode: """Create a self consumption mode instance. Parameters @@ -308,12 +437,46 @@ def self_consumption(soc=20): Mode An instance of Mode configured for self consumption. """ - mode = Mode(soc) - mode.currendId = 9323 - mode.workMode = 2 - return mode + if soc is None: + soc = 20 + return Mode(WorkMode.SELF_CONSUMPTION.value, soc) + + @classmethod + def vpp_mode(cls, _: int | None = None) -> Mode: + """Create a virtual power plant mode instance. + + Returns: + ------- + Mode + An instance of Mode configured for virtual power plant mode. + """ + return Mode(WorkMode.VPP_MODE.value, 100) + + @classmethod + def get_by_name(cls, name: str) -> Mode: + """Get a Mode instance by its name. + + Parameters + ---------- + name : str + The name of the mode. + + Returns: + ------- + Mode + An instance of Mode corresponding to the given name. + + Raises: + ------ + ValueError + If the mode name is unknown. + """ + for mode in WorkMode: + if mode.value == name: + return Mode(mode.id, cls._modes[mode.id].get("soc")) + raise ValueError(f"Unknown mode name: {name}") - def __init__(self, soc: int) -> None: + def __init__(self, workMode: int, soc: int) -> None: """Initialize a Mode instance with the given state of charge. Parameters @@ -321,32 +484,39 @@ def __init__(self, soc: int) -> None: soc : int The state of charge value for the mode. """ + self.workMode = workMode self.soc = soc - self.currendId = None - self.workMode = None + mode = self._modes[workMode] + self.name = WorkMode.from_id(workMode).value + self.currendId = mode["id"] + self.oldIndex = mode["oldIndex"] - def payload(self, gateway) -> dict: + def payload(self, gateway, soc: int | None = None) -> dict: """Generate the payload dictionary for API requests to set the gateway's operating mode. Parameters ---------- gateway : str The gateway identifier. + soc : int, optional + New State of Charge value. Returns: ------- dict The payload dictionary for the API request. """ - return { + params = { "currendId": str(self.currendId), "gatewayId": gateway, "lang": "EN_US", - "oldIndex": "1", # Who knows if this matters - "soc": str(self.soc), + "oldIndex": str(self.oldIndex), "stromEn": "1", "workMode": str(self.workMode), } + if soc is not None: + params["soc"] = str(soc) + return params class SwitchState(tuple[bool | None, bool | None, bool | None]): @@ -542,9 +712,11 @@ async def debug_response(response: httpx.Response): # TODO(richo) Setup timeouts and deal with them gracefully. async def _post(self, url, payload, params: dict | None = None): - if params is not None: + if params is None: + params = {} + else: params = params.copy() - params.update({"gatewayId": self.gateway, "lang": "en_US"}) + params.update({"gatewayId": self.gateway, "lang": "en_US"}) async def __post(): return ( @@ -675,32 +847,47 @@ async def _switch_usage(self): data = (await self._mqtt_send(payload))["result"]["dataArea"] return json.loads(data) - async def set_mode(self, mode): + async def set_mode(self, mode: Mode): """Set the operating mode of the FranklinWH gateway.""" - # Time of use: - # currendId=9322&gatewayId=___&lang=EN_US&oldIndex=3&soc=15&stromEn=1&workMode=1 - - # Emergency Backup: - # currendId=9324&gatewayId=___&lang=EN_US&oldIndex=1&soc=100&stromEn=1&workMode=3 - - # Self consumption - # currendId=9323&gatewayId=___&lang=EN_US&oldIndex=2&soc=20&stromEn=1&workMode=2 - url = DEFAULT_URL_BASE + "hes-gateway/terminal/tou/updateTouMode" + if mode.workMode > WorkMode.EMERGENCY_BACKUP.id: + raise ValueError(mode.name + " cannot be set directly.") + url = self.url_base + "hes-gateway/terminal/tou/updateTouModeV2" payload = mode.payload(self.gateway) await self._post_form(url, payload) + Mode.get_modes.clear() - async def get_mode(self): + async def get_mode(self) -> Mode: """Get the current operating mode of the FranklinWH gateway.""" - status = await self._switch_status() - # TODO(richo) These are actually wrong but I can't obviously find where to get the correct values right now. - mode_name = MODE_MAP[status["runingMode"]] - if mode_name == MODE_TIME_OF_USE: - return (mode_name, status["touMinSoc"]) - if mode_name == MODE_SELF_CONSUMPTION: - return (mode_name, status["selfMinSoc"]) - if mode_name == MODE_EMERGENCY_BACKUP: - return (mode_name, status["backupMaxSoc"]) - raise RuntimeError(f"Unknown mode {status['runingMode']}") + modes = await Mode.get_modes(self) + status = await self.get_composite_info() + for v in modes.values(): + if v["id"] == status["runtimeData"]["mode"]: + return Mode(v["workMode"], v.get("soc")) + self.logger.warning( + "Unknown mode ID: %s, please report at %s", + status["runtimeData"]["mode"], + ISSUES_URL, + ) + return modes[status["currentWorkMode"]] + + async def set_backup_reserve(self, soc: int) -> None: + """Set the backup reserve for the FranklinWH gateway. + + Parameters + ---------- + soc : int + The desired State of Charge percentage to set for backup reserve. + """ + mode = await self.get_mode() + if mode.workMode >= WorkMode.EMERGENCY_BACKUP.id: + raise ValueError("Backup Reserve cannot be set in " + mode.name + ".") + url = self.url_base + "hes-gateway/terminal/tou/updateSocV2" + params = { + "soc": soc, + "workMode": mode.workMode, + } + await self._post(url, None, params) + Mode.get_modes.clear() async def get_stats(self) -> Stats: """Get current statistics for the FHP. @@ -708,8 +895,8 @@ async def get_stats(self) -> Stats: This includes instantaneous measurements for current power, as well as totals for today (in local time) """ tasks = [f() for f in [self.get_composite_info, self._switch_usage]] - info, sw_data = await asyncio.gather(*tasks) - data = info["runtimeData"] + data, sw_data = await asyncio.gather(*tasks) + data = data["runtimeData"] if data is None: raise InvalidDataException From 3ce3ce8c67c79d00a47c084922a12481ce987ae2 Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:25:28 -0400 Subject: [PATCH 3/4] Mode values are also properties mode.soc and mode["soc"] are both legal --- franklinwh/client.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/franklinwh/client.py b/franklinwh/client.py index 6810d4e..8b96adc 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -324,7 +324,7 @@ class WorkMode(Id): VPP_MODE = ("VPP Mode", 9) -class Mode: +class Mode(dict[str, Any]): """Represents an operating mode for the FranklinWH gateway. Provides static methods to create specific modes (time of use, emergency backup, self consumption) @@ -342,7 +342,7 @@ class Mode: Generate the payload dictionary for API requests. """ - _modes: dict[int, Any] = { + _modes: dict[int, dict[str, Any]] = { mode.id: { # compatible with result of getGatewayTouListV2 "id": mode.id, "oldIndex": 3, @@ -363,7 +363,7 @@ class Mode: @classmethod @time_cached(timedelta(hours=1)) # eventually consistent with changes via app - async def get_modes(cls, client: Client) -> dict[int, Any]: + async def get_modes(cls, client: Client) -> dict[int, dict[str, Any]]: """Get the available modes for the FranklinWH gateway. MUST be called once before using other methods, e.g., through get_mode(). @@ -388,12 +388,12 @@ async def get_modes(cls, client: Client) -> dict[int, Any]: return cls._modes @classmethod - def time_of_use(cls, soc: int | None = None) -> Mode: + def time_of_use(cls, soc: float | None = None) -> Mode: """Create a time of use mode instance. Parameters ---------- - soc : int, optional + soc : float, optional The state of charge value for the mode, defaults to 20. Returns: @@ -406,12 +406,12 @@ def time_of_use(cls, soc: int | None = None) -> Mode: return Mode(WorkMode.TIME_OF_USE.id, soc) @classmethod - def emergency_backup(cls, soc: int | None = None) -> Mode: + def emergency_backup(cls, soc: float | None = None) -> Mode: """Create an emergency backup mode instance. Parameters ---------- - soc : int, optional + soc : float, optional The state of charge value for the mode, defaults to 100. Returns: @@ -424,12 +424,12 @@ def emergency_backup(cls, soc: int | None = None) -> Mode: return Mode(WorkMode.EMERGENCY_BACKUP.id, soc) @classmethod - def self_consumption(cls, soc: int | None = None) -> Mode: + def self_consumption(cls, soc: float | None = None) -> Mode: """Create a self consumption mode instance. Parameters ---------- - soc : int, optional + soc : float, optional The state of charge value for the mode, defaults to 20. Returns: @@ -442,7 +442,7 @@ def self_consumption(cls, soc: int | None = None) -> Mode: return Mode(WorkMode.SELF_CONSUMPTION.value, soc) @classmethod - def vpp_mode(cls, _: int | None = None) -> Mode: + def vpp_mode(cls, _: float | None = None) -> Mode: """Create a virtual power plant mode instance. Returns: @@ -476,18 +476,22 @@ def get_by_name(cls, name: str) -> Mode: return Mode(mode.id, cls._modes[mode.id].get("soc")) raise ValueError(f"Unknown mode name: {name}") - def __init__(self, workMode: int, soc: int) -> None: - """Initialize a Mode instance with the given state of charge. + def __init__(self, *args, **kwargs) -> None: + """Initialize a Mode instance with the specified work mode and state of charge. Parameters ---------- - soc : int + workMode : int + The work mode id for the FranklinWH gateway. + soc : float | None The state of charge value for the mode. """ - self.workMode = workMode - self.soc = soc - mode = self._modes[workMode] - self.name = WorkMode.from_id(workMode).value + super().__init__() + self.__dict__ = self + self.workMode = kwargs.get("workMode") or args[0] + self.soc = float(kwargs.get("soc") or args[1]) + mode = self._modes[self.workMode] + self.name = WorkMode.from_id(self.workMode).value self.currendId = mode["id"] self.oldIndex = mode["oldIndex"] @@ -868,7 +872,7 @@ async def get_mode(self) -> Mode: status["runtimeData"]["mode"], ISSUES_URL, ) - return modes[status["currentWorkMode"]] + return Mode(**modes[status["currentWorkMode"]]) async def set_backup_reserve(self, soc: int) -> None: """Set the backup reserve for the FranklinWH gateway. From dc8390063b8758ed59b39464878b96246fc65c7e Mon Sep 17 00:00:00 2001 From: Jack Thomasson <4302889+jkt628@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:18:18 -0500 Subject: [PATCH 4/4] introduce RunStatus --- franklinwh/__init__.py | 2 ++ franklinwh/client.py | 38 +++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/franklinwh/__init__.py b/franklinwh/__init__.py index 818711c..ab4e50c 100644 --- a/franklinwh/__init__.py +++ b/franklinwh/__init__.py @@ -10,6 +10,7 @@ GridStatus, HttpClientFactory, Mode, + RunStatus, Stats, SwitchState, TokenFetcher, @@ -26,6 +27,7 @@ "GridStatus", "HttpClientFactory", "Mode", + "RunStatus", "Stats", "SwitchState", "TokenFetcher", diff --git a/franklinwh/client.py b/franklinwh/client.py index 8b96adc..14b964a 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -9,6 +9,7 @@ import asyncio from collections.abc import Callable, Generator from dataclasses import dataclass +from datetime import timedelta from enum import Enum import hashlib import json @@ -19,7 +20,7 @@ import httpx -from .api import DEFAULT_URL_BASE +from .api import DEFAULT_URL_BASE, ISSUES_URL from .time_cached import time_cached @@ -72,6 +73,7 @@ def empty_stats(): 0.0, 0.0, GridStatus.NORMAL, + RunStatus.STANDBY, ), Totals( 0.0, @@ -192,6 +194,7 @@ class Current: switch_2_load: float v2l_use: float grid_status: GridStatus + run_status: RunStatus @dataclass @@ -298,6 +301,38 @@ def from_value(cls, value: str) -> Self: raise ValueError(f"No {cls.__name__} with value {value}") +class RunStatus(Id): + """Represent run_status values of the FranklinWH gateway.""" + + STANDBY = ("Standby", 0) + CHARGING = ("Charging", 1) + DISCHARGING = ("Discharging", 2) + + @staticmethod + def from_id(id: int) -> RunStatus: + """Convert a run_status id to a RunStatus enum member. + + Parameters + ---------- + value : int + The run_status id to convert. + + Returns: + ------- + RunStatus + The corresponding RunStatus enum member. + """ + match id: + case 0: + return RunStatus.STANDBY + case 1: + return RunStatus.CHARGING + case 2: + return RunStatus.DISCHARGING + case _: + raise ValueError(f"Unknown run_status id: {id}") + + class WorkMode(Id): """Represents the workMode values of the FranklinWH gateway. @@ -921,6 +956,7 @@ async def get_stats(self) -> Stats: sw_data["SW2ExpPower"], sw_data["CarSWPower"], grid_status, + RunStatus.from_id(data["run_status"]), ), Totals( data["kwh_fhp_chg"], diff --git a/pyproject.toml b/pyproject.toml index a33c03b..b425367 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ ] description = "Python wrapper for FranklinWH" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" classifiers = [ ] dependencies = [