From eb13a5cbe5fed1d7044e59cbe0c86921896164cf Mon Sep 17 00:00:00 2001 From: npdsomerhayes Date: Sun, 24 May 2026 15:29:13 +1200 Subject: [PATCH 1/3] Add get_mode_settings(), set_mode_reserve(), fix get_mode() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_mode() had a known-broken TODO: it used runingMode from _switch_status() which is an unreliable rolling ID, not a stable mode identifier. The touMinSoc/selfMinSoc/backupMaxSoc fields it read are also incorrect — they do not reflect the user-visible battery reserve percentages. This commit fixes all of that by introducing two new methods built on a newly discovered API endpoint (getGatewayTouListV2), found via MITM interception of the FranklinWH mobile app. New: get_mode_settings() -> ModeSettings POST /hes-gateway/terminal/tou/getGatewayTouListV2 Returns a ModeSettings dataclass listing all three operating modes with their installation-specific ids, names, reserve SOC percentages, and which mode is currently active (via current_mode_id / current_work_mode). ModeSettings.reserves provides a convenient workMode→soc dict. New: set_mode_reserve(work_mode, soc) POST /hes-gateway/terminal/tou/updateSocV2 Updates the reserve SOC for a single mode WITHOUT switching to that mode. Validates work_mode against WORK_MODE_MAP and soc against 0-100 before making the API call. Fixed: get_mode() rewritten to use get_mode_settings() Preserves the existing (mode_name, soc) return signature for backward compatibility. Now raises InvalidDataException (consistent with the rest of the library) instead of RuntimeError or KeyError. New: WORK_MODE_MAP {1: TOU, 2: Self-Consumption, 3: Emergency Backup} Maps the workMode integers returned by getGatewayTouListV2 and getDeviceCompositeInfo to the existing MODE_* constants. New: ModeInfo and ModeSettings dataclasses exported from __init__.py Notes on the API: - currendId in the API response is a known typo (not currENTId); preserved as-is - electricityType=1 is a required parameter for updateSocV2 (purpose unknown) - Emergency Backup always has editSocFlag=false; the server rejects SOC writes - The mode ids returned by getGatewayTouListV2 (e.g. 83132, 79680, 77244) are installation-specific and differ from the hardcoded values in Mode factory methods (9322/9323/9324) used by updateTouMode — both sets are correct for their respective endpoints Co-Authored-By: Claude Sonnet 4.6 --- franklinwh/__init__.py | 6 ++ franklinwh/client.py | 170 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 165 insertions(+), 11 deletions(-) diff --git a/franklinwh/__init__.py b/franklinwh/__init__.py index 83fdae4..a336395 100644 --- a/franklinwh/__init__.py +++ b/franklinwh/__init__.py @@ -10,9 +10,12 @@ GridStatus, HttpClientFactory, Mode, + ModeInfo, + ModeSettings, Stats, SwitchState, TokenFetcher, + WORK_MODE_MAP, ) __all__ = [ @@ -25,7 +28,10 @@ "GridStatus", "HttpClientFactory", "Mode", + "ModeInfo", + "ModeSettings", "Stats", "SwitchState", "TokenFetcher", + "WORK_MODE_MAP", ] diff --git a/franklinwh/client.py b/franklinwh/client.py index 0d40166..e1b7025 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -175,6 +175,51 @@ class ExportSettings: limit_kw: float | None +@dataclass +class ModeInfo: + """Configuration for a single FranklinWH operating mode. + + Attributes: + id: Installation-specific identifier for this mode entry. + work_mode: Mode type integer (1=TOU, 2=Self-Consumption, 3=Emergency Backup). + name: Human-readable name as configured in the FranklinWH app. + soc: Battery reserve SOC percentage for this mode. + edit_soc_flag: Whether the reserve SOC can be changed for this mode. + """ + + id: int + work_mode: int + name: str + soc: int + edit_soc_flag: bool + + +@dataclass +class ModeSettings: + """All operating mode configurations and the currently active mode. + + Attributes: + modes: List of all available operating modes. + current_mode_id: Installation-specific id of the currently active mode. + """ + + modes: list[ModeInfo] + current_mode_id: int | None + + @property + def reserves(self) -> dict[int, int]: + """Return a mapping of workMode integer to configured reserve SOC.""" + return {mode.work_mode: mode.soc for mode in self.modes} + + @property + def current_work_mode(self) -> int | None: + """Return the workMode integer of the currently active mode.""" + for mode in self.modes: + if mode.id == self.current_mode_id: + return mode.work_mode + return None + + @dataclass class Current: """Current statistics for FranklinWH gateway.""" @@ -227,6 +272,14 @@ class Stats: 9324: MODE_EMERGENCY_BACKUP, } +# Maps the workMode integer from getGatewayTouListV2 / getDeviceCompositeInfo +# to the MODE_* constants above. Uses the same encoding as Mode.workMode. +WORK_MODE_MAP = { + 1: MODE_TIME_OF_USE, + 2: MODE_SELF_CONSUMPTION, + 3: MODE_EMERGENCY_BACKUP, +} + class Mode: """Represents an operating mode for the FranklinWH gateway. @@ -682,17 +735,26 @@ async def set_mode(self, mode): await self._post_form(url, payload) async def get_mode(self): - """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']}") + """Get the current operating mode of the FranklinWH gateway. + + Returns + ------- + tuple[str, int] + A ``(mode_name, soc)`` pair where ``mode_name`` is one of the + ``MODE_*`` constants and ``soc`` is the battery reserve percentage + currently configured for that mode. + """ + settings = await self.get_mode_settings() + work_mode = settings.current_work_mode + if work_mode is None: + raise InvalidDataException("Could not determine current work mode from gateway") + mode_name = WORK_MODE_MAP.get(work_mode) + if mode_name is None: + raise InvalidDataException(f"Unknown workMode: {work_mode!r}") + soc = settings.reserves.get(work_mode) + if soc is None: + raise InvalidDataException(f"Active workMode {work_mode} has no reserve SOC in mode list") + return (mode_name, soc) async def get_stats(self) -> Stats: """Get current statistics for the FHP. @@ -865,6 +927,92 @@ async def get_composite_info(self): params = {"refreshFlag": 1} return (await self._get(url, params))["result"] + async def set_mode_reserve(self, work_mode: int, soc: int) -> None: + """Set the battery reserve SOC for an operating mode without switching to it. + + Calls ``POST /hes-gateway/terminal/tou/updateSocV2``. + + This is different from ``set_mode()`` — it only updates the stored + reserve percentage for the given mode; the currently active mode is + **not** changed. + + Parameters + ---------- + work_mode : int + The mode whose reserve to update. Must be one of the keys in + ``WORK_MODE_MAP`` (1=TOU, 2=Self-Consumption, 3=Emergency Backup). + soc : int + New battery reserve percentage (0–100). Note that Emergency + Backup has ``editSocFlag = false`` in ``get_mode_settings()``; the + server will reject writes to that mode. + + Raises + ------ + ValueError + If ``work_mode`` is not a recognised mode integer or ``soc`` is + outside the range 0–100. + """ + if work_mode not in WORK_MODE_MAP: + raise ValueError( + f"Invalid work_mode: {work_mode!r}. Must be one of {sorted(WORK_MODE_MAP)}" + ) + if not 0 <= soc <= 100: + raise ValueError(f"Invalid soc: {soc!r}. Must be between 0 and 100.") + + url = self.url_base + "hes-gateway/terminal/tou/updateSocV2" + result = await self._post( + url, + "", + params={ + "workMode": str(work_mode), + "electricityType": "1", + "soc": str(soc), + }, + ) + if result.get("code") != 200: + raise InvalidDataException(f"set_mode_reserve failed: {result}") + + async def get_mode_settings(self) -> ModeSettings: + """Fetch all operating modes and their configured battery reserve SOC. + + Calls ``POST /hes-gateway/terminal/tou/getGatewayTouListV2`` and returns + a structured ``ModeSettings`` object describing all modes and the + currently active one. + + The ``soc`` field on each ``ModeInfo`` is the battery reserve percentage + stored on the gateway for that mode — the minimum SOC the battery will + discharge to before drawing from the grid. + + Returns + ------- + ModeSettings + Dataclass containing a list of all ``ModeInfo`` entries and the + ``current_mode_id`` identifying the active mode. Use the + ``current_work_mode`` and ``reserves`` properties for convenient + access to the most commonly needed values. + + Raises + ------ + InvalidDataException + If the API response cannot be parsed. + """ + url = self.url_base + "hes-gateway/terminal/tou/getGatewayTouListV2" + try: + result = (await self._post(url, "", params={"showType": "1"}))["result"] + modes = [ + ModeInfo( + id=entry["id"], + work_mode=entry["workMode"], + name=entry["name"], + soc=entry["soc"], + edit_soc_flag=entry["editSocFlag"], + ) + for entry in result["list"] + ] + return ModeSettings(modes=modes, current_mode_id=result["currendId"]) + except (KeyError, TypeError) as e: + raise InvalidDataException(f"Could not parse mode settings response: {e}") from e + async def set_generator(self, enabled: bool): """Enable or disable the generator on the FranklinWH gateway. From 204f35847fdfcaeeb49f6bc69569d9659c40a45c Mon Sep 17 00:00:00 2001 From: npdsomerhayes Date: Sun, 24 May 2026 16:05:02 +1200 Subject: [PATCH 2/3] Fix ModeInfo.soc type (float not int); add test script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The getGatewayTouListV2 API returns soc as a float (e.g. 10.0, 100.0). ModeInfo.soc was declared as int which would cause a type mismatch. Updated to float and aligned the ModeSettings.reserves return annotation. get_mode() docstring return type updated from tuple[str, int] to tuple[str, float] accordingly. Also adds bin/test-tou-reserve.py — a standalone script that exercises get_mode_settings(), get_mode(), and set_mode_reserve() against a real gateway without requiring a Home Assistant installation. Tested successfully against gateway 10060006A02F24170129. Co-Authored-By: Claude Sonnet 4.6 --- bin/test-tou-reserve.py | 77 +++++++++++++++++++++++++++++++++++++++++ franklinwh/client.py | 8 ++--- 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 bin/test-tou-reserve.py diff --git a/bin/test-tou-reserve.py b/bin/test-tou-reserve.py new file mode 100644 index 0000000..9aee97d --- /dev/null +++ b/bin/test-tou-reserve.py @@ -0,0 +1,77 @@ +"""Test script for get_mode_settings(), get_mode() and set_mode_reserve(). + +Run from the franklinwh-python directory: + python3 bin/test-tou-reserve.py + +What it does: + 1. Calls get_mode_settings() — prints all modes and reserves + 2. Calls get_mode() — prints current mode via the new path + 3. Calls set_mode_reserve() — writes back the SAME value (no visible change) + +Nothing destructive: the set call uses whatever value is already stored. +""" + +import asyncio +import sys +import os + +# Run against the local source tree, not the installed PyPI package +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import franklinwh +from franklinwh.client import WORK_MODE_MAP + + +async def main(username: str, password: str, gateway: str) -> None: + fetcher = franklinwh.TokenFetcher(username, password) + client = franklinwh.Client(fetcher, gateway) + + # ── 1. get_mode_settings() ─────────────────────────────────────────────── + print("\n── get_mode_settings() ─────────────────────────────────────────") + settings = await client.get_mode_settings() + print(f" current_mode_id : {settings.current_mode_id}") + print(f" current_work_mode: {settings.current_work_mode} " + f"({WORK_MODE_MAP.get(settings.current_work_mode, '?')})") + print(f" reserves : {settings.reserves}") + print(" modes:") + for m in settings.modes: + active = " ← active" if m.id == settings.current_mode_id else "" + editable = "editable" if m.edit_soc_flag else "read-only" + print(f" workMode={m.work_mode} id={m.id:6d} soc={m.soc:5.1f}%" + f" {editable:9s} name={m.name!r}{active}") + + # ── 2. get_mode() ──────────────────────────────────────────────────────── + print("\n── get_mode() ──────────────────────────────────────────────────") + mode_name, soc = await client.get_mode() + print(f" mode_name: {mode_name!r}") + print(f" soc : {soc}%") + + # ── 3. set_mode_reserve() — write back the existing value ──────────────── + active_wm = settings.current_work_mode + if active_wm in settings.reserves: + current_soc = int(settings.reserves[active_wm]) + mode_label = WORK_MODE_MAP.get(active_wm, "?") + print(f"\n── set_mode_reserve(work_mode={active_wm}, soc={current_soc}) " + f"[{mode_label}, no-op] ───") + if active_wm == 3: + print(" Skipping Emergency Backup (editSocFlag=false, server will reject)") + else: + await client.set_mode_reserve(active_wm, current_soc) + print(" ✓ Success — value written back unchanged") + + # Confirm by re-reading + confirm = await client.get_mode_settings() + confirmed_soc = confirm.reserves.get(active_wm) + match = "✓" if confirmed_soc == current_soc else "✗ MISMATCH" + print(f" {match} Confirmed reserve still {confirmed_soc}%") + else: + print("\n Could not determine active work mode — skipping set test") + + print("\n── All tests passed ────────────────────────────────────────────\n") + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + asyncio.run(main(sys.argv[1], sys.argv[2], sys.argv[3])) diff --git a/franklinwh/client.py b/franklinwh/client.py index e1b7025..2bf0abb 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -183,14 +183,14 @@ class ModeInfo: id: Installation-specific identifier for this mode entry. work_mode: Mode type integer (1=TOU, 2=Self-Consumption, 3=Emergency Backup). name: Human-readable name as configured in the FranklinWH app. - soc: Battery reserve SOC percentage for this mode. + soc: Battery reserve SOC percentage for this mode (returned as float by the API). edit_soc_flag: Whether the reserve SOC can be changed for this mode. """ id: int work_mode: int name: str - soc: int + soc: float edit_soc_flag: bool @@ -207,7 +207,7 @@ class ModeSettings: current_mode_id: int | None @property - def reserves(self) -> dict[int, int]: + def reserves(self) -> dict[int, float]: """Return a mapping of workMode integer to configured reserve SOC.""" return {mode.work_mode: mode.soc for mode in self.modes} @@ -739,7 +739,7 @@ async def get_mode(self): Returns ------- - tuple[str, int] + tuple[str, float] A ``(mode_name, soc)`` pair where ``mode_name`` is one of the ``MODE_*`` constants and ``soc`` is the battery reserve percentage currently configured for that mode. From 009bea6d85380f6e37e059dab17229c72a34f0d0 Mon Sep 17 00:00:00 2001 From: npdsomerhayes Date: Sun, 24 May 2026 16:13:20 +1200 Subject: [PATCH 3/3] Clarify ModeInfo.name is user-defined tariff label, not a mode identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'name' field returned by getGatewayTouListV2 is whatever the user named their electricity plan in the FranklinWH app — it is not a fixed or predictable mode label. Only work_mode (1/2/3) reliably identifies the mode type. Updated ModeInfo docstring and test script output accordingly. Co-Authored-By: Claude Sonnet 4.6 --- bin/test-tou-reserve.py | 3 ++- franklinwh/client.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/test-tou-reserve.py b/bin/test-tou-reserve.py index 9aee97d..d312745 100644 --- a/bin/test-tou-reserve.py +++ b/bin/test-tou-reserve.py @@ -37,8 +37,9 @@ async def main(username: str, password: str, gateway: str) -> None: for m in settings.modes: active = " ← active" if m.id == settings.current_mode_id else "" editable = "editable" if m.edit_soc_flag else "read-only" + # name is the user's tariff profile label — installation-specific print(f" workMode={m.work_mode} id={m.id:6d} soc={m.soc:5.1f}%" - f" {editable:9s} name={m.name!r}{active}") + f" {editable:9s} name={m.name!r} (user-defined){active}") # ── 2. get_mode() ──────────────────────────────────────────────────────── print("\n── get_mode() ──────────────────────────────────────────────────") diff --git a/franklinwh/client.py b/franklinwh/client.py index 2bf0abb..e78a305 100644 --- a/franklinwh/client.py +++ b/franklinwh/client.py @@ -182,7 +182,10 @@ class ModeInfo: Attributes: id: Installation-specific identifier for this mode entry. work_mode: Mode type integer (1=TOU, 2=Self-Consumption, 3=Emergency Backup). - name: Human-readable name as configured in the FranklinWH app. + name: User-defined tariff profile name as set in the FranklinWH app. + This is **not** a fixed mode label — it reflects whatever the user + named their electricity plan (e.g. "Peak/Off-Peak", "Flat Rate"). + Use ``work_mode`` to identify the mode type reliably. soc: Battery reserve SOC percentage for this mode (returned as float by the API). edit_soc_flag: Whether the reserve SOC can be changed for this mode. """