Files
zpark-ems/backend/app/collectors/sungrow_collector.py

205 lines
7.7 KiB
Python
Raw Normal View History

"""阳光电源 iSolarCloud API 数据采集器"""
import time
from datetime import datetime, timezone
from typing import Optional
import httpx
from app.collectors.base import BaseCollector
class SungrowCollector(BaseCollector):
"""Collect data from Sungrow inverters via iSolarCloud OpenAPI.
connection_params example:
{
"api_base": "https://gateway.isolarcloud.com",
"app_key": "1BF313B6A9F919A6FB6A90BD43D23395",
"sys_code": "901",
"x_access_key": "qpthtsf287zvtmr6t3q9hsc0k70f3tay",
"user_account": "13911211695",
"user_password": "123456#ABC",
"ps_id": "power_station_id",
"device_sn": "optional_device_serial"
}
"""
TOKEN_LIFETIME = 23 * 3600 # Refresh before 24h expiry
def __init__(self, device_id, device_code, connection_params, collect_interval=900):
super().__init__(device_id, device_code, connection_params, collect_interval)
self._api_base = connection_params.get("api_base", "https://gateway.isolarcloud.com").rstrip("/")
self._app_key = connection_params.get("app_key", "")
self._sys_code = connection_params.get("sys_code", "901")
self._x_access_key = connection_params.get("x_access_key", "")
self._user_account = connection_params.get("user_account", "")
self._user_password = connection_params.get("user_password", "")
self._ps_id = connection_params.get("ps_id", "")
self._device_sn = connection_params.get("device_sn", "")
self._client: Optional[httpx.AsyncClient] = None
self._token: Optional[str] = None
self._token_obtained_at: float = 0
async def connect(self):
"""Establish HTTP client and authenticate with iSolarCloud."""
self._client = httpx.AsyncClient(timeout=30)
await self._login()
self.logger.info("Authenticated with iSolarCloud for %s", self.device_code)
async def disconnect(self):
"""Close HTTP client."""
if self._client:
await self._client.aclose()
self._client = None
self._token = None
async def collect(self) -> dict:
"""Collect real-time data from the Sungrow inverter.
Returns a dict mapping data_type -> (value, unit).
"""
if not self._client:
raise ConnectionError("HTTP client not initialized")
# Refresh token if close to expiry
if self._token_needs_refresh():
await self._login()
data = {}
# Fetch power station overview for power/energy data
if self._ps_id:
ps_data = await self._get_station_data()
if ps_data:
data.update(ps_data)
# Fetch device list for per-device metrics
if self._ps_id:
dev_data = await self._get_device_data()
if dev_data:
data.update(dev_data)
return data
# ------------------------------------------------------------------
# Internal API methods
# ------------------------------------------------------------------
async def _login(self):
"""POST /openapi/login to obtain access token."""
payload = {
"appkey": self._app_key,
"sys_code": self._sys_code,
"user_account": self._user_account,
"user_password": self._user_password,
}
result = await self._api_call("/openapi/login", payload, auth=False)
token = result.get("token")
if not token:
raise ConnectionError(f"Login failed: {result.get('msg', 'no token returned')}")
self._token = token
self._token_obtained_at = time.monotonic()
self.logger.info("iSolarCloud login successful for account %s", self._user_account)
async def _get_station_data(self) -> dict:
"""Fetch power station real-time data."""
payload = {"ps_id": self._ps_id}
result = await self._api_call("/openapi/getPowerStationList", payload)
data = {}
stations = result.get("pageList", [])
for station in stations:
if str(station.get("ps_id")) == str(self._ps_id):
# Map station-level fields
if "curr_power" in station:
data["power"] = (float(station["curr_power"]), "kW")
if "today_energy" in station:
data["daily_energy"] = (float(station["today_energy"]), "kWh")
if "total_energy" in station:
data["total_energy"] = (float(station["total_energy"]), "kWh")
break
return data
async def _get_device_data(self) -> dict:
"""Fetch device-level real-time data for the target inverter."""
payload = {"ps_id": self._ps_id}
result = await self._api_call("/openapi/getDeviceList", payload)
data = {}
devices = result.get("pageList", [])
for device in devices:
# Match by serial number if specified, otherwise use first inverter
if self._device_sn and device.get("device_sn") != self._device_sn:
continue
device_type = device.get("device_type", 0)
# device_type 1 = inverter in Sungrow API
if device_type in (1, "1") or not self._device_sn:
if "device_power" in device:
data["power"] = (float(device["device_power"]), "kW")
if "today_energy" in device:
data["daily_energy"] = (float(device["today_energy"]), "kWh")
if "total_energy" in device:
data["total_energy"] = (float(device["total_energy"]), "kWh")
if "temperature" in device:
data["temperature"] = (float(device["temperature"]), "°C")
if "dc_voltage" in device:
data["voltage"] = (float(device["dc_voltage"]), "V")
if "ac_current" in device:
data["current"] = (float(device["ac_current"]), "A")
if "frequency" in device:
data["frequency"] = (float(device["frequency"]), "Hz")
if self._device_sn:
break
return data
async def _api_call(self, path: str, payload: dict, auth: bool = True) -> dict:
"""Make an API call to iSolarCloud.
Args:
path: API endpoint path (e.g. /openapi/login).
payload: Request body parameters.
auth: Whether to include the auth token.
Returns:
The 'result_data' dict from the response, or raises on error.
"""
url = f"{self._api_base}{path}"
headers = {
"Content-Type": "application/json",
"x-access-key": self._x_access_key,
"sys_code": self._sys_code,
}
if auth and self._token:
headers["token"] = self._token
body = {
"appkey": self._app_key,
"lang": "_zh_CN",
**payload,
}
self.logger.debug("API call: %s %s", "POST", url)
response = await self._client.post(url, json=body, headers=headers)
response.raise_for_status()
resp_json = response.json()
result_code = resp_json.get("result_code", -1)
if result_code != 1 and str(result_code) != "1":
msg = resp_json.get("result_msg", "Unknown error")
self.logger.error("API error on %s: code=%s msg=%s", path, result_code, msg)
raise RuntimeError(f"iSolarCloud API error: {msg} (code={result_code})")
return resp_json.get("result_data", {})
def _token_needs_refresh(self) -> bool:
"""Check if the token is close to expiry."""
if not self._token:
return True
elapsed = time.monotonic() - self._token_obtained_at
return elapsed >= self.TOKEN_LIFETIME