Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions bin/test-tou-reserve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Test script for get_mode_settings(), get_mode() and set_mode_reserve().

Run from the franklinwh-python directory:
python3 bin/test-tou-reserve.py <username> <password> <gateway_id>

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"
# 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} (user-defined){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]} <username> <password> <gateway_id>")
sys.exit(1)
asyncio.run(main(sys.argv[1], sys.argv[2], sys.argv[3]))
6 changes: 6 additions & 0 deletions franklinwh/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
GridStatus,
HttpClientFactory,
Mode,
ModeInfo,
ModeSettings,
Stats,
SwitchState,
TokenFetcher,
WORK_MODE_MAP,
)

__all__ = [
Expand All @@ -25,7 +28,10 @@
"GridStatus",
"HttpClientFactory",
"Mode",
"ModeInfo",
"ModeSettings",
"Stats",
"SwitchState",
"TokenFetcher",
"WORK_MODE_MAP",
]
173 changes: 162 additions & 11 deletions franklinwh/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,54 @@ 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: 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.
"""

id: int
work_mode: int
name: str
soc: float
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, float]:
"""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."""
Expand Down Expand Up @@ -227,6 +275,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.
Expand Down Expand Up @@ -682,17 +738,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, 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.
"""
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.
Expand Down Expand Up @@ -865,6 +930,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.

Expand Down