power-compare/app/isolarcloud/client.py
dlawler489 84c4922bbc Initial commit: solar usage vs AER plan comparison app
FastAPI app that pulls interval usage from Sungrow iSolarCloud (OAuth2),
caches AER Consumer Data Right electricity plan data, and compares actual
metered usage against every available plan to estimate real annual cost
(TOU, tiered feed-in, and flat tariffs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:07:01 +10:00

236 lines
11 KiB
Python

"""Real iSolarCloud client -- "V1 account login" (non-OAuth2) app.
Implements the exact same `fetch_interval_data(since, until)` interface as
`mock_client.MockISolarCloudClient` so the scheduler can swap between them
via `settings.use_mock_isolarcloud` with no other code changes.
Auth flow, headers and the core endpoints (`/openapi/login`,
`/openapi/getPowerStationList`, `/openapi/getDeviceList`,
`/openapi/getDevicePointMinuteDataList`, `/openapi/getDeviceRealTimeData`)
are taken from steven-g-w/HA-Sungrow (github.com/steven-g-w/HA-Sungrow), a
Home Assistant integration built directly against
https://developer-api.isolarcloud.com/ using this same "V1 login" app type
(as opposed to the separate OAuth2 app type, which uses different
`/openapi/platform/...` endpoints and an authorization-code browser flow --
not what an app with an App Key + Secret Key + username/password uses).
What's NOT independently verified here (confirm with
scripts/verify_isolarcloud_cli.py against your real account before trusting
the numbers -- see SETUP.md):
- RSA password encryption. Some V1-login apps are issued an RSA public key
(Settings -> Open API in iSolarCloud) and are expected to encrypt the
password before sending it; others aren't and send it as plain text over
HTTPS. The exact padding scheme Sungrow expects isn't published anywhere
public -- `_encrypt_password` below uses the common RSA/PKCS1v1.5 +
base64 convention as a best effort. If login fails with an RSA key
configured, try without one (some accounts don't need it) or adjust the
padding here based on what the portal's own "Documentation" page (visible
after logging in) specifies for the login endpoint.
- Which device serves generation vs. import/export/load. iSolarCloud
reports these per *device* (inverter, meter, ...), not per plant, in this
API family. `fetch_interval_data` picks the first METER device for
import/export/load and the first INVERTER for generation, which is
correct for the common single-inverter, grid-meter household setup this
app targets, but should be confirmed with
`scripts/verify_isolarcloud_cli.py devices` against your real plant.
"""
from __future__ import annotations
import base64
import logging
from datetime import datetime
import httpx
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import load_der_public_key, load_pem_public_key
from app.config import settings
from app.db.repositories import get_sync_state, set_sync_state
from app.db.session import get_session
from app.isolarcloud.parsing import POINT_EXPORT, POINT_GENERATION, POINT_IMPORT, POINT_LOAD, TS_FORMAT, merge_frames
from app.isolarcloud.schemas import IntervalDataPoint
_LOGGER = logging.getLogger(__name__)
REGION_GATEWAYS = {
"australia": "https://augateway.isolarcloud.com",
"europe": "https://gateway.isolarcloud.eu",
"china": "https://gateway.isolarcloud.com",
"international": "https://gateway.isolarcloud.com.hk",
}
LOGIN_PATH = "/openapi/login"
POWER_STATION_LIST_PATH = "/openapi/getPowerStationList"
DEVICE_LIST_PATH = "/openapi/getDeviceList"
MINUTE_HISTORY_PATH = "/openapi/getDevicePointMinuteDataList"
DEVICE_TYPE_INVERTER = 1
DEVICE_TYPE_METER = 7
# result_code values meaning the token is invalid/expired -- worth one
# re-login + retry. Per HA-Sungrow, E900 covers both an expired token and
# "no permission", so a retry can be futile, but it only costs one call.
TOKEN_ERROR_CODES = {"E900"}
class ISolarCloudAuthError(RuntimeError):
pass
def _load_public_key(key_str: str):
"""Accepts either a full PEM key or the raw base64 (standard or
URL-safe -- the developer portal's "Overview" page gives it without
BEGIN/END headers and with -/_ in place of +//) DER SubjectPublicKeyInfo
that the iSolarCloud portal actually hands out."""
key_str = key_str.strip()
if "BEGIN" in key_str:
return load_pem_public_key(key_str.encode())
# Raw base64: normalize URL-safe alphabet and padding, then DER-decode.
normalized = key_str.replace("-", "+").replace("_", "/")
normalized += "=" * (-len(normalized) % 4)
der_bytes = base64.b64decode(normalized)
return load_der_public_key(der_bytes)
def _encrypt_password(password: str, public_key_str: str) -> str:
"""Best-effort RSA/PKCS1v1.5 encryption, base64-encoded. See the module
docstring -- this is the common convention for this class of API, not
an independently confirmed one for Sungrow specifically."""
public_key = _load_public_key(public_key_str)
ciphertext = public_key.encrypt(password.encode(), padding.PKCS1v15())
return base64.b64encode(ciphertext).decode()
class ISolarCloudClient:
def __init__(self) -> None:
self.host = REGION_GATEWAYS[settings.isolarcloud_region]
self.appkey = settings.isolarcloud_app_key
self.secret_key = settings.isolarcloud_app_secret
self.username = settings.isolarcloud_username
self.password = settings.isolarcloud_password
self.rsa_public_key = settings.isolarcloud_rsa_public_key
self._client = httpx.Client(timeout=30)
self._token: str | None = None
def _headers(self) -> dict[str, str]:
headers = {
"Content-Type": "application/json",
"x-access-key": self.secret_key,
"sys_code": "901",
}
if self._token:
headers["token"] = self._token
return headers
def _post(self, path: str, payload: dict) -> dict:
body = {"appkey": self.appkey, "lang": "_en_US", **payload}
if self._token and "user_password" not in payload:
body["token"] = self._token
resp = self._client.post(f"{self.host}{path}", json=body, headers=self._headers())
resp.raise_for_status()
return resp.json()
def login(self) -> None:
if not self.username or not self.password:
raise ISolarCloudAuthError("iSolarCloud username/password not configured -- set them in .env.")
password = self.password
if self.rsa_public_key:
password = _encrypt_password(password, self.rsa_public_key)
data = self._post(LOGIN_PATH, {"user_account": self.username, "user_password": password, "login_type": "1"})
result_code = str(data.get("result_code"))
result_data = data.get("result_data") or {}
if result_code != "1":
raise ISolarCloudAuthError(f"Login failed (result_code={result_code}): {data.get('result_msg')}")
if str(result_data.get("login_state")) not in ("1", "None", ""):
raise ISolarCloudAuthError(
f"Login rejected (login_state={result_data.get('login_state')}): "
f"{result_data.get('msg') or data.get('result_msg')}"
)
token = result_data.get("token")
if not token:
raise ISolarCloudAuthError("Login succeeded but no token was returned")
self._token = token
with get_session() as session:
set_sync_state(session, "isolarcloud_token", token)
_LOGGER.debug("Logged in to iSolarCloud, token acquired")
def _load_cached_token(self) -> None:
if self._token is not None:
return
with get_session() as session:
self._token = get_sync_state(session, "isolarcloud_token")
def _authenticated_post(self, path: str, payload: dict) -> dict:
self._load_cached_token()
if self._token is None:
self.login()
data = self._post(path, payload)
result_code = str(data.get("result_code"))
result_msg = str(data.get("result_msg") or "")
if result_code != "1" and (result_code in TOKEN_ERROR_CODES or "token" in result_msg.lower()):
_LOGGER.debug("Token rejected (result_code=%s, msg=%s); re-authenticating", result_code, result_msg)
self._token = None
self.login()
data = self._post(path, payload)
result_code = str(data.get("result_code"))
if result_code != "1":
raise ISolarCloudAuthError(f"iSolarCloud error on {path} (result_code={result_code}): {data.get('result_msg')}")
return data.get("result_data") or {}
def get_plant_list(self) -> list[dict]:
result = self._authenticated_post(POWER_STATION_LIST_PATH, {"curPage": 1, "size": 100})
return result.get("pageList") or result.get("page_list") or []
def get_device_list(self, ps_id: str) -> list[dict]:
result = self._authenticated_post(DEVICE_LIST_PATH, {"ps_id": ps_id, "curPage": 1, "size": 100})
return result.get("pageList") or result.get("page_list") or []
def get_minute_history(self, ps_key: str, point_ids: list[str], start: str, end: str, minute_interval: int = 5) -> list[dict]:
result = self._authenticated_post(
MINUTE_HISTORY_PATH,
{
"ps_key_list": [ps_key],
"points": ",".join(f"p{p}" for p in point_ids),
"start_time_stamp": start,
"end_time_stamp": end,
"minute_interval": str(minute_interval),
},
)
frames = result.get(ps_key) or []
return [f for f in frames if isinstance(f, dict)]
def fetch_interval_data(self, since: datetime, until: datetime) -> list[IntervalDataPoint]:
from app.db.repositories import get_household_config
with get_session() as session:
ps_id = get_household_config(session).isolarcloud_ps_id or settings.isolarcloud_plant_id
if not ps_id:
plants = self.get_plant_list()
if not plants:
raise ISolarCloudAuthError("No plants visible to this account.")
ps_id = plants[0]["ps_id"]
devices = self.get_device_list(ps_id)
meter = next((d for d in devices if int(d.get("device_type", -1)) == DEVICE_TYPE_METER), None)
inverter = next((d for d in devices if int(d.get("device_type", -1)) == DEVICE_TYPE_INVERTER), None)
if meter is None or inverter is None:
raise ISolarCloudAuthError(
"Could not find both a meter and an inverter device on this plant -- "
"run `python scripts/verify_isolarcloud_cli.py devices` and adjust "
"app/isolarcloud/client.py's device selection for your setup."
)
# The endpoint takes timestamps in the plant's local time zone, not
# UTC (consistent with the OAuth2 app family's equivalent endpoint,
# confirmed against Sungrow's official docs -- see oauth_client.py).
from app.isolarcloud.parsing import PLANT_TZ
start_s = since.astimezone(PLANT_TZ).strftime(TS_FORMAT)
end_s = until.astimezone(PLANT_TZ).strftime(TS_FORMAT)
meter_frames = self.get_minute_history(meter["ps_key"], [POINT_LOAD, POINT_IMPORT, POINT_EXPORT], start_s, end_s)
inverter_frames = self.get_minute_history(inverter["ps_key"], [POINT_GENERATION], start_s, end_s)
return merge_frames(meter_frames, inverter_frames)