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
16 changes: 16 additions & 0 deletions docs/shinephone.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ Any methods that may be useful.
| `api.mix_totals(mix_id, plant_id)` | mix_id: String, plant_id: String | Get daily and overall total information for the Mix system (duplicates some of the information from `mix_info`). |
| `api.mix_system_status(mix_id, plant_id)` | mix_id: String, plant_id: String | Get instantaneous values for Mix system, e.g., current import/export, generation, charging rates, etc. |
| `api.mix_detail(mix_id, plant_id, timespan, date)` | mix_id: String, plant_id: String, timespan: Int <0=hour, 1=day, 2=month>, date: String | Get detailed values for a timespan. The API call also returns totals data for the same values in this time window. |
| `api.sph_system_status(plant_id, sph_sn)` | plant_id: String, sph_sn: String | Get real-time values for an SPH/SPM hybrid inverter (SOC, vBat, ppv, per-string PV, pCharge1/pDisCharge1, pacToGrid/pacToUser, pLocalLoad, vAc1, fAc, status). Requires `api.server_url` to point at the regional host (see Variables). |
| `api.sph_energy_overview(plant_id, sph_sn)` | plant_id: String, sph_sn: String | Get daily and lifetime energy totals for an SPH inverter (eChargeToday/Total, eDisChargeToday/Total, epvToday/Total, elocalLoadToday/Total, eToGridToday/Total). |
| `api.sph_energy_prod_and_cons(plant_id, sph_sn, date, chart_type)` | plant_id: String, sph_sn: String, date: Date (optional), chart_type: Int <0=day, 1=month, 2=year, 3=total> | Get production and consumption chart series plus aggregate totals for the period. Returns `chartData` arrays (pacToGrid, ppv, pself, elocalLoad, pacToUser) at 5-min / daily / monthly / yearly resolution depending on `chart_type`. |
| `api.sph_settings(sph_sn)` | sph_sn: String | Get the full SPH settings bean — every adjustable parameter and its current value. Use this to discover supported `setting_type` keys for `update_sph_inverter_setting`. |
| `api.storage_detail(storage_id)` | storage_id: String | Get detailed data on storage (battery). |
| `api.storage_params(storage_id)` | storage_id: String | Get extensive information on storage (more info, more convoluted). |
| `api.storage_energy_overview(plant_id, storage_id)` | plant_id: String, storage_id: String | Get the information you see in the "Generation overview". |
Expand All @@ -61,6 +65,7 @@ Any methods that may be useful.
| `api.update_tlx_inverter_setting(serial_number, setting_type, parameter)` | serial_number: String, setting_type: String, parameter: Any | Apply the provided parameter for the specified setting on the specified tlx inverter. see: [details](./shinephone/inverter_settings.md) |
| `api.update_tlx_inverter_time_segment(serial_number, segment_id, batt_mode, start_time, end_time, enabled)` | serial_number: String, segment_id: Int, batt_mode: String, start_time: String, end_time: String, enabled: Bool | Update one of the 9 time segments with the specified battery mode (load, battery, grid first). see: [details](./shinephone/inverter_settings.md) |
| `api.update_mix_inverter_setting(serial_number, setting_type, parameters)` | serial_number: String, setting_type: String, parameters: Dict/Array | Apply the provided parameters for the specified setting on the specified Mix inverter. see: [details](./shinephone/inverter_settings.md) |
| `api.update_sph_inverter_setting(serial_number, setting_type, parameters)` | serial_number: String, setting_type: String, parameters: Any/List/Dict | Write a setting on an SPH inverter via `newTcpsetAPI.do?op=sphSet`. A scalar value is wrapped as `{"param1": value}`; a list maps to `{"param1": v1, "param2": v2, ...}`. Use `sph_settings()` to discover supported `setting_type` keys. |
| `api.update_ac_inverter_setting(serial_number, setting_type, parameters)` | serial_number: String, setting_type: String, parameters: Dict/Array | Apply the provided parameters for the specified setting on the specified AC-coupled inverter. see: [details](./shinephone/inverter_settings.md) |
| `api.update_noah_settings(serial_number, setting_type, parameters)` | serial_number: String, setting_type: String, parameters: Dict/Array | Apply the provided parameters for the specified setting on the specified Noah device. see: [details](./shinephone/noah_settings.md) |
| `api.update_classic_inverter_setting(default_parameters, parameters)` | default_parameters: Dict, parameters: Dict/Array | Applies settings for specified system based on serial number. This function is only going to work for classic inverters. |
Expand All @@ -78,6 +83,17 @@ You may need a different URL depending on where your account is registered:
'https://openapi-us.growatt.com/' (North American server)
'https://openapi.growatt.com/' (Other regional server: e.g. Europe)

The SPH-specific methods (`sph_system_status`, `sph_energy_overview`,
`sph_energy_prod_and_cons`, `sph_settings`, `update_sph_inverter_setting`)
require a *regional* mobile host instead — these endpoints are not served
by `openapi.growatt.com`. Set `api.server_url` to the host matching your
account region:

'https://server-cn-api.growatt.com/' (Chinese server)
'https://server-us-api.growatt.com/' (North American server)
'https://server-au-api.growatt.com/' (Australia / Oceania)
'https://server-api.growatt.com/' (Other regional server: e.g. Europe)

## Initialisation

The library can be initialised to introduce randomness into the User Agent field that is used when communicating with the servers.
Expand Down
159 changes: 159 additions & 0 deletions examples/sph_legacy_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""
Read live data and totals from an SPH/SPM hybrid inverter via the regional
mobile API (the surface ShinePhone uses).

This is the *legacy* (mobile) path, distinct from the V1 OpenAPI flow shown
in sph_example.py. Use this when V1 isn't an option — most notably for
newer SPH/SPM models like SPM-10000TL-HU which are not registered in V1's
device_list and whose data is null/zero on the legacy mix_*/tlx_* endpoints
of openapi.growatt.com.

The mobile API exposes SPH endpoints under newTwoSphAPI.do on regional
hosts (server-{region}-api.growatt.com). Set `api.server_url` to your
regional host before calling `login()`. Common regions:
server-au-api.growatt.com (Australia / Oceania)
server-cn-api.growatt.com (China)
server-us-api.growatt.com (North America)
server-api.growatt.com (Europe / other)
"""

import getpass
import pprint

import growattServer

pp = pprint.PrettyPrinter(indent=4)


def indent_print(message: str, indent: int) -> None:
print(" " * indent + message)


# Prompt for credentials and region.
username = input("Enter username: ")
user_pass = getpass.getpass("Enter password: ")
region = input(
"Enter regional host (e.g. server-au-api.growatt.com) "
"[server-api.growatt.com]: "
).strip() or "server-api.growatt.com"

# The mobile API expects a ShinePhone-style User-Agent. The default
# identifier works too, but matching the mobile app is the most compatible.
api = growattServer.GrowattApi(
agent_identifier="ShinePhone/8.4.7 (iPhone; iOS 26.4; Scale/3.00)",
)
api.server_url = f"https://{region}/"

login_response = api.login(username, user_pass)
if not login_response.get("success"):
raise SystemExit(f"Login failed: {login_response}")

user_id = login_response["user"]["id"]
plant_list = api.plant_list(user_id)

print("\n***List of plants***")
for plant in plant_list["data"]:
indent_print(f"ID: {plant['plantId']}, Name: {plant['plantName']}", 2)

for plant in plant_list["data"]:
plant_id = plant["plantId"]
plant_name = plant["plantName"]
plant_info = api.plant_info(plant_id)

print(f"\n***Plant {plant_id} - {plant_name}***")
indent_print(f"Solar Energy Today (kWh): {plant_info['todayEnergy']}", 2)
indent_print(f"Solar Energy Total (kWh): {plant_info['totalEnergy']}", 2)

# SPH devices live in plant_info["sphList"], not "deviceList" (which
# only contains classic-class inverters). plant_info also has lists for
# other device classes — invList, mixList (in some accounts),
# storageList, witList, etc.
sph_devices = plant_info.get("sphList", [])
if not sph_devices:
indent_print("No SPH devices in this plant — skipping.", 2)
continue

for device in sph_devices:
sph_sn = device["deviceSn"]
print(f"\n ** SPH Device {sph_sn} (sphType={device['sphType']}) **")

# 1. Live system status — instantaneous values.
status = api.sph_system_status(plant_id, sph_sn)
# pp.pprint(status)
indent_print("== Batteries ==", 4)
indent_print(f"SOC: {status['SOC']} %", 6)
indent_print(f"Battery voltage: {status['vBat']} V", 6)
indent_print(f"Charging at: {status['pCharge1']} kW", 6)
indent_print(f"Discharging at: {status['pDisCharge1']} kW", 6)

indent_print("== PV ==", 4)
indent_print(f"Total PV power: {status['ppv']} kW", 6)
indent_print(
f"PV1: {status['ppv1']} W ({status['vpv1']} V), "
f"PV2: {status['ppv2']} W ({status['vpv2']} V), "
f"PV3: {status['ppv3']} W ({status['vpv3']} V)",
6,
)

indent_print("== Grid / Load ==", 4)
indent_print(f"Importing from grid: {status['pacToUser']} kW", 6)
indent_print(f"Exporting to grid: {status['pacToGrid']} kW", 6)
indent_print(f"Local load: {status['pLocalLoad']} kW", 6)
indent_print(
f"Grid: {status['vAc1']} V @ {status['fAc']} Hz "
f"(status={status['status']})",
6,
)

# 2. Daily / lifetime energy totals.
totals = api.sph_energy_overview(plant_id, sph_sn)
# pp.pprint(totals)
indent_print("== Energy today ==", 4)
indent_print(f"PV: {totals['epvToday']} kWh", 6)
indent_print(f"Battery charged: {totals['eChargeToday']} kWh", 6)
indent_print(f"Battery discharged: {totals['eDisChargeToday']} kWh", 6)
indent_print(f"Local load: {totals['elocalLoadToday']} kWh", 6)
indent_print(f"Exported to grid: {totals['eToGridToday']} kWh", 6)

indent_print("== Energy lifetime ==", 4)
indent_print(f"PV: {totals['epvTotal']} kWh", 6)
indent_print(f"Battery charged: {totals['eChargeTotal']} kWh", 6)
indent_print(f"Battery discharged: {totals['eDisChargeTotal']} kWh", 6)
indent_print(f"Local load: {totals['elocalLoadTotal']} kWh", 6)
indent_print(f"Exported to grid: {totals['eToGridTotal']} kWh", 6)

# 3. Per-day chart series (5-minute resolution). Other chart_type
# values: 1=month, 2=year, 3=total (lifetime, 5 yearly buckets).
day_chart = api.sph_energy_prod_and_cons(plant_id, sph_sn, chart_type=0)
ppv_series = day_chart["chartData"]["ppv"]
# Each value is a 5-minute average in kW; sum × (5/60) ≈ kWh.
ppv_today_kwh = round(sum(float(v or 0) for v in ppv_series) * (5 / 60), 2)
indent_print("== Today's chart (calculated from 5-min samples) ==", 4)
indent_print(f"PV (calculated from chart): {ppv_today_kwh} kWh", 6)
indent_print(f"Self-consumption (API): {day_chart['eChargeToday1']} kWh", 6)
indent_print(f"Grid import (API): {day_chart['etouser']} kWh", 6)

# 4. Settings — full bean of every adjustable parameter.
settings = api.sph_settings(sph_sn)
# pp.pprint(settings)
indent_print(f"== Settings ({len(settings)} keys) ==", 4)
for key in (
"sys_work_mode",
"pv_on_off",
"cutoff_soc",
"cuton_soc",
"bat_max_charge_current",
"bat_max_discharge_current",
"zero_ct_sell",
"zero_load_sell",
):
if key in settings:
indent_print(f"{key}: {settings[key]}", 6)

# 5. Writing a setting (commented out — uncomment to enable).
#
# On this device, sys_work_mode=1 enables On Grid mode (PV
# sell-back). Use sph_settings() to discover the supported setting
# types and read the current value before writing.
#
# api.update_sph_inverter_setting(sph_sn, "sys_work_mode", 1)
176 changes: 175 additions & 1 deletion growattServer/base_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ def __get_date_string(self, timespan=None, date=None):
raise ValueError("timespan must be a Timespan enum value")

if date is None:
date = datetime.datetime.now(datetime.UTC)
# The Growatt API interprets the date string in the plant's
# local timezone, not UTC. Default to system-local time so
# callers in non-UTC zones don't get yesterday's data near
# midnight.
date = datetime.datetime.now()

date_str = ""
if timespan == Timespan.month:
Expand Down Expand Up @@ -175,6 +179,176 @@ def login(self, username, password, is_password_hashed=False):
})
return data

def sph_system_status(self, plant_id, sph_sn):
"""
Get real-time SPH device status via the regional mobile API.

This is the unified mobile API path used by the ShinePhone app.
Set `api.server_url` to your regional host (e.g.
`https://server-au-api.growatt.com/`) and call `login()` first.
The session cookie set by login authenticates the call.

Returns instantaneous battery/PV/grid/load values, including:
SOC, vBat, ppv, ppv1/2/3, vpv1/2/3, pDisCharge1, pCharge1,
vAc1, fAc, pacToGrid, pacToUser, pLocalLoad, pex, status,
sysStatus, lost, deviceType, pMax, epvToday, epvTotal.

Args:
plant_id (str): The plant ID.
sph_sn (str): The SPH device serial number.

Returns:
dict: The `obj` payload.

"""
response = self.session.post(
self.get_url("newTwoSphAPI.do"),
params={"op": "getSystemStatus"},
data={"plantid": str(plant_id), "sphSn": sph_sn},
)
return response.json().get("obj", {})

def sph_energy_overview(self, plant_id, sph_sn):
"""
Get cumulative SPH device energy stats via the regional mobile API.

Returns:
dict: With fields eChargeToday/Total, eDisChargeToday/Total,
epvToday/Total, elocalLoadToday/Total, eToGridToday/Total.

"""
response = self.session.post(
self.get_url("newTwoSphAPI.do"),
params={"op": "getEnergyOverview"},
data={"plantid": str(plant_id), "sphSn": sph_sn},
)
return response.json().get("obj", {})

def sph_settings(self, sph_sn, language=1):
"""
Read the full SPH settings bean.

Returns every adjustable parameter the device exposes (system work
mode, AC charge enable, charge/discharge schedules, battery
priority, export power limit, etc.) along with their current
values. Use this both as a settings snapshot and to discover the
valid `setting_type` keys for `update_sph_inverter_setting()`.

Args:
sph_sn (str): The SPH device serial number.
language (int): UI language code; 1 = English.

Returns:
dict: The `obj` payload — a flat dict of setting names to
current values.

"""
response = self.session.post(
self.get_url("newTwoSphAPI.do"),
params={"op": "getSphSetBean"},
data={"lan": str(language), "sphSn": sph_sn},
)
return response.json().get("obj", {})

def update_sph_inverter_setting(self, serial_number, setting_type,
parameters, language=1):
"""
Write a setting to an SPH inverter.

Hits ``newTcpsetAPI.do?op=sphSet`` with the setting body in
``application/x-www-form-urlencoded`` form (matching the wire
format used by ShinePhone). Common ``setting_type`` values:
- ``sys_work_mode`` — system work mode (param1=1 = On Grid /
sell power on this device).
Use :meth:`sph_settings` to discover all supported types and
their current values.

Args:
serial_number (str): SPH inverter serial number.
setting_type (str): The setting name (e.g. ``sys_work_mode``).
parameters (dict | list | Any): Parameters to send. A list is
converted to ``{"param1": v1, "param2": v2, ...}``; a
scalar is wrapped as ``{"param1": value}``.
language (int): UI language code; 1 = English.

Returns:
dict: Server response JSON.

"""
if isinstance(parameters, list):
params_dict = {f"param{i}": v
for i, v in enumerate(parameters, start=1)}
elif isinstance(parameters, dict):
params_dict = parameters
else:
params_dict = {"param1": parameters}

body = {
"lan": str(language),
"serialNum": serial_number,
"type": setting_type,
**params_dict,
}
response = self.session.post(
self.get_url("newTcpsetAPI.do"),
params={"op": "sphSet"},
data=body,
)
return response.json()

def sph_energy_prod_and_cons(self, plant_id, sph_sn, date=None,
chart_type=0, language=1):
"""
Get the SPH per-period production-and-consumption chart series.

Returns time-series data for PV, grid in/out, load, and battery
for `date` at the resolution implied by `chart_type`.

Args:
plant_id (str): The plant ID.
sph_sn (str): The SPH device serial number.
date (str | datetime.date, optional): Date to query
(YYYY-MM-DD for day, YYYY-MM for month, YYYY for year).
Defaults to today.
chart_type (int): 0 = day (288 5-min samples),
1 = month (31 daily samples),
2 = year (12 monthly samples),
3 = total (5 yearly samples — lifetime).
language (int): UI language code; 1 = English. Required by
the server but tolerant of most values.

Returns:
dict: The `obj` payload — `chartData` (per-bucket arrays for
pacToGrid, ppv, pself, elocalLoad, pacToUser) plus
aggregates: etouser, eCharge, eAcCharge, eChargeToday1,
eChargeToday2, elocalLoad.

"""
if date is None:
# Plant local timezone, not UTC — see note in __get_date_string.
date = datetime.datetime.now()
if hasattr(date, "strftime"):
if chart_type == 1:
date_str = date.strftime("%Y-%m")
elif chart_type == 2:
date_str = date.strftime("%Y")
else:
date_str = date.strftime("%Y-%m-%d")
else:
date_str = str(date)
response = self.session.post(
self.get_url("newTwoSphAPI.do"),
params={"op": "getEnergyProdAndConsData"},
data={
"dateStr": date_str,
"language": str(language),
"plantid": str(plant_id),
"sphSn": sph_sn,
"type": str(chart_type),
},
)
return response.json().get("obj", {})

def plant_list(self, user_id):
"""
Get a list of plants connected to this account.
Expand Down