From c0dc90ca792b89696091b633dfd8c49b465858ad Mon Sep 17 00:00:00 2001 From: susiederkin Date: Mon, 6 Apr 2026 00:47:16 +0200 Subject: [PATCH] Initial commit --- .gitignore | 4 + SunGrowData.txt | 287 +++++++++++++++++++++++++++++++++++++++++++++++ launch.json | 13 +++ main.py | 92 +++++++++++++++ plant_state.json | 5 + sungrow_api.py | 202 +++++++++++++++++++++++++++++++++ sungrow_state.py | 41 +++++++ 7 files changed, 644 insertions(+) create mode 100644 .gitignore create mode 100644 SunGrowData.txt create mode 100644 launch.json create mode 100644 main.py create mode 100644 plant_state.json create mode 100644 sungrow_api.py create mode 100644 sungrow_state.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86077fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +.vscode/ diff --git a/SunGrowData.txt b/SunGrowData.txt new file mode 100644 index 0000000..f3a8bf0 --- /dev/null +++ b/SunGrowData.txt @@ -0,0 +1,287 @@ +API Status: 200 +API Body: { + "req_serial_num":"202604069c4343c38cc94dfb3a71c7fc", + "result_code":"1", + "result_msg":"success", + "result_data":{ + "pageList":[ + { + "total_energy":{ + "unit":"MWh", + "value":"43.13" + }, + "alarm_count":0, + "latitude":50.56129590941345, + "description":null, + "total_income_update_time":null, + "valid_flag":1, + "curr_power":{ + "unit":"W", + "value":"0" + }, + "ps_fault_status":3, + "co2_reduce_update_time":null, + "install_date":"2024-02-07 08:13:34", + "build_status":2, + "today_energy_update_time":"2026-04-06T05:01:52+08:00", + "year_income_update_time":null, + "total_energy_update_time":"2026-04-06T05:01:52+08:00", + "ps_type":5, + "longitude":9.72615326343812, + "total_capcity_update_time":"2024-03-19T15:51:21+08:00", + "equivalent_hour_update_time":"2026-04-06T05:01:52+08:00", + "ps_name":"PV_Siegel", + "co2_reduce_total":{ + "unit":"kg", + "value":"42936" + }, + "curr_power_update_time":"2026-04-06T05:01:52+08:00", + "today_income":{ + "unit":"", + "value":"--" + }, + "grid_connection_status":0, + "equivalent_hour":{ + "unit":"Hour", + "value":"2.95" + }, + "co2_reduce_total_update_time":null, + "ps_location":"Geisaer Weg 13, 36100 Petersberg, Deutschland", + "total_income":{ + "unit":"EUR", + "value":"6901" + }, + "total_capcity":{ + "unit":"kWp", + "value":"21.93" + }, + "share_type":"0", + "year_income":{ + "unit":"EUR", + "value":"642.409" + }, + "ps_current_time_zone":"GMT+2", + "today_income_update_time":null, + "ps_id":5425899, + "grid_connection_time":null, + "connect_type":2, + "today_energy":{ + "unit":"kWh", + "value":"64.8" + }, + "ps_status":1, + "co2_reduce":{ + "unit":"kg", + "value":"0" + }, + "fault_count":0 + } + ], + "rowCount":1 + } +} + + + +API Status: 200 +API Body: { + "req_serial_num":"2026040649654a0cb04020d10860cc31", + "result_code":"1", + "result_msg":"success", + "result_data":{ + "pageList":[ + { + "type_name":"Batterie", + "ps_key":"5425899_43_2_1", + "firmware_version_info":{ + "bat_version":"SBRBCU-S_22011.01.21" + }, + "device_type":43, + "factory_name":"SUNGROW", + "uuid":3799231, + "grid_connection_date":"2024-02-07 16:42:54", + "device_name":"Battery (WR Master)", + "dev_fault_status":4, + "device_model_id":355481, + "communication_dev_sn":"B2341313692", + "device_model_code":"SBR160", + "chnnl_id":1, + "rel_time":"2024-02-07 16:42:54", + "device_sn":"S2309190060", + "dev_status":"1", + "rel_state":1, + "device_code":2, + "ps_id":5425899 + }, + { + "type_name":"Wechselrichter", + "ps_key":"5425899_1_1_2", + "firmware_version_info":{ + "sdsp_version":"SUBCTL-S_04011.01.01", + "mdsp_version":"BERYL-S_03011.01.74", + "lcd_version":"BERYL-S_01011.01.39", + "afci_version":"AFD_06001.02.03" + }, + "device_type":1, + "factory_name":"SUNGROW", + "uuid":3798939, + "grid_connection_date":"2024-02-07 15:17:44", + "device_name":"WR Slave", + "dev_fault_status":4, + "device_model_id":742, + "communication_dev_sn":"B2321768031", + "device_model_code":"SG10RT", + "chnnl_id":2, + "rel_time":"2024-02-07 15:17:44", + "device_sn":"A2322221161", + "dev_status":"1", + "rel_state":1, + "device_code":1, + "ps_id":5425899 + }, + { + "type_name":"Kommunikationsmodul", + "ps_key":"5425899_22_247_2", + "firmware_version_info":{ + "m_version":"WINET-SV200.001.00.P038" + }, + "device_type":22, + "factory_name":"SUNGROW", + "uuid":3798938, + "grid_connection_date":"2024-02-07 15:17:44", + "device_name":"Communication Module2", + "dev_fault_status":4, + "device_model_id":1361, + "communication_dev_sn":"B2321768031", + "device_model_code":"WiNet-S", + "chnnl_id":2, + "rel_time":"2024-02-07 15:17:44", + "device_sn":"B2321768031", + "dev_status":"1", + "rel_state":1, + "device_code":247, + "ps_id":5425899 + }, + { + "type_name":"Hybrid (speicherfähig)", + "ps_key":"5425899_14_1_1", + "firmware_version_info":{ + "sdsp_version":"SUBCTL-S_04011.01.01", + "mdsp_version":"SAPPHIRE-H_03011.51.04", + "lcd_version":"SAPPHIRE-H_01011.51.05", + "bat_version":"SBRBCU-S_22011.01.21" + }, + "device_type":14, + "factory_name":"SUNGROW", + "uuid":3798934, + "grid_connection_date":"2024-02-07 15:14:36", + "device_name":"WR Master", + "dev_fault_status":4, + "device_model_id":366316, + "communication_dev_sn":"B2341313692", + "device_model_code":"SH10RT-V112", + "chnnl_id":1, + "rel_time":"2024-02-07 15:14:36", + "device_sn":"A2341306461", + "dev_status":"1", + "rel_state":1, + "device_code":1, + "ps_id":5425899 + }, + { + "type_name":"Kommunikationsmodul", + "ps_key":"5425899_22_247_1", + "firmware_version_info":{ + "m_version":"WINET-SV200.001.00.P038" + }, + "device_type":22, + "factory_name":"SUNGROW", + "uuid":3798933, + "grid_connection_date":"2024-02-07 15:14:35", + "device_name":"Communication Module1", + "dev_fault_status":4, + "device_model_id":1361, + "communication_dev_sn":"B2341313692", + "device_model_code":"WiNet-S", + "chnnl_id":1, + "rel_time":"2024-02-07 15:14:35", + "device_sn":"B2341313692", + "dev_status":"1", + "rel_state":1, + "device_code":247, + "ps_id":5425899 + } + ], + "rowCount":5 + } +} + + + +API Status: 200 +API Body: { + "req_serial_num":"20260406cbc64e2ca6dab1c63d4073e3", + "result_code":"1", + "result_msg":"success", + "result_data":{ + "pageList":[ + { + "type_name":"Anlage", + "ps_key":"5425899_11_0_0", + "firmware_version_info":{}, + "device_type":11, + "factory_name":null, + "uuid":3798932, + "grid_connection_date":"2024-02-07 15:13:34", + "device_name":"PV_Siegel", + "dev_fault_status":4, + "device_model_id":null, + "communication_dev_sn":null, + "device_model_code":null, + "chnnl_id":0, + "rel_time":null, + "device_sn":null, + "dev_status":"1", + "rel_state":1, + "device_code":0, + "ps_id":5425899 + } + ], + "rowCount":1 + } +} + + + +API Status: 200 +API Body: { + "req_serial_num":"202604064c60475f975eca70db983580", + "result_code":"1", + "result_msg":"success", + "result_data":{ + "fail_ps_key_list":[], + "device_point_list":[ + { + "device_point":{ + "ps_key":"5425899_11_0_0", + "device_sn":null, + "dev_status":1, + "p83009":"33400.0", + "p83119":"18900.0", + "p83118":"39400.0", + "p83097":"5900.0", + "p83072":"25400.0", + "uuid":3798932, + "p83022":"64800.0", + "p83033":"0.0", + "p83252":"0.557", + "device_name":"PV_Siegel", + "dev_fault_status":4, + "ps_id":5425899, + "communication_dev_sn":null, + "device_time":"20260405234500" + } + } + ] + } +} \ No newline at end of file diff --git a/launch.json b/launch.json new file mode 100644 index 0000000..b64a039 --- /dev/null +++ b/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python: main.py", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/main.py", + "console": "integratedTerminal", + "envFile": "${workspaceFolder}/.env" + } + ] +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..a2442e5 --- /dev/null +++ b/main.py @@ -0,0 +1,92 @@ +from sungrow_api import get_device_list, login, call_api, get_power_station_list, get_device_list, get_device_realtime_data, ensure_success, pretty_print +from sungrow_state import save_state + +def main(): + print("Logging in...") + token = login() + print("Token:", token[:12], "...") + + # 1) Get plant list + plant_list = get_power_station_list(token, debug=True) + print("Plant List Response:") + pretty_print(plant_list) + + # Validate before accessing + ensure_success(plant_list) + + # 2) Extract ps_id + ps_id = plant_list["result_data"]["pageList"][0]["ps_id"] + print("Using ps_id:", ps_id) + + + # 3) Query device list + device_list = get_device_list( + token=token, + ps_id=ps_id, + cur_page=1, + size=20, + is_virtual_unit="0", # 0 physical devices, 1 virtual + #device_type_list=[1, 3, 11], # inverter, grid point, plant + #rel_state="1", # only claimed devices + is_get_firmware_version="1", # 1: include firmware info + debug=True + ) + + # 4) Query device list on plant level + device_list = get_device_list( + token=token, + ps_id=ps_id, + cur_page=1, + size=20, + is_virtual_unit="1", # 0 physical devices, 1 virtual + #device_type_list=[1, 3, 11], # inverter, grid point, plant + #rel_state="1", # only claimed devices + is_get_firmware_version="0", # 1: include firmware info + debug=True + ) + + # Extract relevant data from the first device in the list (assuming it's the plant-level virtual device) + device_info = device_list["result_data"]["pageList"][0] + + ps_key = device_info["ps_key"] + device_name = device_info["device_name"] + + # Save them persistently + save_state(ps_key=ps_key, device_name=device_name) + + + # 4) Query realtime data on plant level + realtime = get_device_realtime_data( + token=token, + point_id_list = [ + "83022", # Daily Yield of Plant "64800.0" ok + "83033", # Plant Power "0.0" + #"83006", # Meter Daily Yield + #"83011", # Meter E-daily Consumption + #"83052", # Load Power + "83072", # Feed-in Energy Today "25400.0" ok + "83252", # Battery Level (SOC) "0.557" ok + #"83129", # Battery SOC + "83009", # Inverter Daily Yield "33400.0" ok, nur Inverter "WR Slave" + "83097", # Daily Direct Energy Consumption "5900.0" ?? + "83118", # Daily Load Consumption "39400.0" ok + "83119" # Daily Feed-in Energy (PV) "18900.0" ?? + ], + device_type=11, + ps_key_list=["5425899_11_0_0"], + debug=True + ) + + + + + print("Device List Response:") + pretty_print(device_list) + + + #print("Response:") + #print(data) + + +if __name__ == "__main__": + main() diff --git a/plant_state.json b/plant_state.json new file mode 100644 index 0000000..3109434 --- /dev/null +++ b/plant_state.json @@ -0,0 +1,5 @@ +{ + "ps_id": 5425899, + "device_name": "PV_Siegel", + "ps_key": "5425899_11_0_0" +} \ No newline at end of file diff --git a/sungrow_api.py b/sungrow_api.py new file mode 100644 index 0000000..6e2c044 --- /dev/null +++ b/sungrow_api.py @@ -0,0 +1,202 @@ +import os +import time +import uuid +import requests +import json +from dotenv import load_dotenv +from sungrow_state import load_ps_id + + + +BASE_URL = os.getenv("SUNGROW_BASE_URL") +USERNAME = os.getenv("SUNGROW_USERNAME") +PASSWORD = os.getenv("SUNGROW_PASSWORD") +APPKEY = os.getenv("SUNGROW_APPKEY") +KEY = os.getenv("SUNGROW_KEY") + + +def login(): + url = f"{BASE_URL}/openapi/login" + + headers = { + "Content-Type": "application/json;charset=UTF-8", + "sys_code": "901", + "x-access-key": KEY + } + + body = { + "user_account": USERNAME, + "user_password": PASSWORD, + "appkey": APPKEY, + "lang": "_de_DE" + } + + response = requests.post(url, json=body, headers=headers, timeout=10) + + print("Login Status:", response.status_code) + print("Login Body:", response.text) + + response.raise_for_status() + + data = response.json() + + if data.get("result_code") != "1": + raise Exception(f"Login failed: {data}") + + return data["result_data"]["token"] + + +def call_api(endpoint: str, token: str, params: dict = None, debug: bool = False): + if params is None: + params = {} + + # Auto-inject ps_id if requested + if "ps_id" in params and params["ps_id"] is None: + stored_ps_id = load_ps_id() + if stored_ps_id is None: + raise ValueError("ps_id was requested but no stored ps_id found.") + params["ps_id"] = stored_ps_id + + url = f"{BASE_URL}{endpoint}" + + headers = { + "Content-Type": "application/json;charset=UTF-8", + "sys_code": "901", + "x-access-key": KEY + } + + base_body = { + "appkey": APPKEY, + "token": token, + "lang": "_de_DE", + "timestamp": str(int(time.time() * 1000)), + "nonce": uuid.uuid4().hex + } + + body = {**base_body, **params} + + # Debug: print full request + if debug: + import json + print("\n=== API REQUEST ===") + print("URL:", url) + print("Headers:", json.dumps(headers, indent=4, ensure_ascii=False)) + print("Body:", json.dumps(body, indent=4, ensure_ascii=False)) + print("===================\n") + + response = requests.post(url, json=body, headers=headers, timeout=10) + + # Debug: print full response + if debug: + print("=== API RESPONSE ===") + print("Status:", response.status_code) + print("Body:", response.text) + print("====================\n") + + response.raise_for_status() + return response.json() + + +def get_power_station_list(token: str, cur_page: int = 1, size: int = 10, debug: bool = False): + """ + Wrapper for /openapi/getPowerStationList + """ + params = { + "curPage": cur_page, + "size": size + } + + return call_api("/openapi/getPowerStationList", token, params, debug=debug) + +def get_device_list( + token: str, + ps_id: int, + cur_page: int = 1, + size: int = 50, + is_virtual_unit: str | None = None, + device_type_list: list | None = None, + rel_state: str | None = None, + is_get_firmware_version: str | None = None, + debug: bool = False +): + """ + Wrapper for /openapi/getDeviceList + + Parameters: + ps_id (int): Plant ID (required) + cur_page (int): Page number (required) + size (int): Page size (required) + is_virtual_unit (str): "1" = virtual, "0" = physical + device_type_list (list): e.g. [1, 3, 11] + rel_state (str): "0" = unclaimed, "1" = claimed + is_get_firmware_version (str): "0" = no, "1" = yes + """ + + params = { + "ps_id": ps_id, + "curPage": cur_page, + "size": size + } + + # Only include optional parameters if they are provided + if is_virtual_unit is not None: + params["is_virtual_unit"] = is_virtual_unit + + if device_type_list is not None: + params["device_type_list"] = device_type_list + + if rel_state is not None: + params["rel_state"] = rel_state + + if is_get_firmware_version is not None: + params["is_get_firmware_version"] = is_get_firmware_version + + return call_api("/openapi/getDeviceList", token, params, debug=debug) + +def get_device_realtime_data( + token: str, + point_id_list: list, + device_type: int, + ps_key_list: list | None = None, + sn_list: list | None = None, + debug: bool = False +): + """ + Wrapper for /openapi/getDeviceRealTimeData + + Required: + point_id_list (list[str]) + device_type (int) + + Optional (choose one): + ps_key_list (list[str]) + sn_list (list[str]) + """ + + if ps_key_list is None and sn_list is None: + raise ValueError("You must provide either ps_key_list or sn_list.") + + params = { + "point_id_list": point_id_list, + "device_type": device_type + } + + if ps_key_list is not None: + params["ps_key_list"] = ps_key_list + + if sn_list is not None: + params["sn_list"] = sn_list + + return call_api("/openapi/getDeviceRealTimeData", token, params, debug=debug) + + +def ensure_success(response: dict): + if response.get("result_code") != "1": + raise Exception( + f"API error {response.get('result_code')}: {response.get('result_msg')}" + ) + + +def pretty_print(data): + import json + print(json.dumps(data, indent=4, ensure_ascii=False)) diff --git a/sungrow_state.py b/sungrow_state.py new file mode 100644 index 0000000..fbce4bf --- /dev/null +++ b/sungrow_state.py @@ -0,0 +1,41 @@ +import json +import os + +STATE_FILE = "plant_state.json" + + +def save_state(ps_id=None, ps_key=None, device_name=None): + state = load_state() or {} + + if device_name is not None: + state["device_name"] = device_name + if ps_id is not None: + state["ps_id"] = ps_id + if ps_key is not None: + state["ps_key"] = ps_key + + + with open(STATE_FILE, "w") as f: + json.dump(state, f, indent=4) + + +def load_state(): + if not os.path.exists(STATE_FILE): + return None + with open(STATE_FILE) as f: + return json.load(f) + + +def load_ps_id(): + state = load_state() + return state.get("ps_id") if state else None + + +def load_ps_key(): + state = load_state() + return state.get("ps_key") if state else None + + +def load_device_name(): + state = load_state() + return state.get("device_name") if state else None