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..ab4e50c 100644 --- a/franklinwh/__init__.py +++ b/franklinwh/__init__.py @@ -10,9 +10,11 @@ GridStatus, HttpClientFactory, Mode, + RunStatus, Stats, SwitchState, TokenFetcher, + WorkMode, ) __all__ = [ @@ -25,7 +27,9 @@ "GridStatus", "HttpClientFactory", "Mode", + "RunStatus", "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 bf0102d..14b964a 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -5,20 +5,23 @@ """ from __future__ import annotations -from collections.abc import Callable import asyncio +from collections.abc import Callable, Generator from dataclasses import dataclass +from datetime import timedelta from enum import Enum import hashlib import json import logging import time +from typing import Any, Self import zlib import httpx -from .api import DEFAULT_URL_BASE +from .api import DEFAULT_URL_BASE, ISSUES_URL +from .time_cached import time_cached class AccessoryType(Enum): @@ -70,6 +73,7 @@ def empty_stats(): 0.0, 0.0, GridStatus.NORMAL, + RunStatus.STANDBY, ), Totals( 0.0, @@ -190,6 +194,7 @@ class Current: switch_2_load: float v2l_use: float grid_status: GridStatus + run_status: RunStatus @dataclass @@ -217,51 +222,213 @@ 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 -class Mode: + @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 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. + + 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(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) 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, dict[str, 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, dict[str, 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: 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: @@ -269,18 +436,17 @@ 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: 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: @@ -288,18 +454,17 @@ 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: 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: @@ -307,45 +472,90 @@ 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) - def __init__(self, soc: int) -> None: - """Initialize a Mode instance with the given state of charge. + @classmethod + def vpp_mode(cls, _: float | 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 ---------- - soc : int - The state of charge value for the mode. + 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. """ - self.soc = soc - self.currendId = None - self.workMode = None + 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 payload(self, gateway) -> dict: + def __init__(self, *args, **kwargs) -> None: + """Initialize a Mode instance with the specified work mode and state of charge. + + Parameters + ---------- + workMode : int + The work mode id for the FranklinWH gateway. + soc : float | None + The state of charge value for the mode. + """ + 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"] + + 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]): @@ -403,7 +613,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): @@ -541,9 +751,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 ( @@ -653,12 +865,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,37 +880,53 @@ 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"] 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 Mode(**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. @@ -704,8 +934,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 @@ -726,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"], @@ -847,13 +1078,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 +1098,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 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 = [