Files
tianpu-ems/backend/app/collectors/sungrow_collector.py
Du Wenbo 02c4698b59 feat: multi-customer architecture + Z-Park support + Gitea migration scripts
Multi-customer config system:
- CUSTOMER env var selects customer (tianpu/zpark)
- customers/tianpu/config.yaml — Tianpu branding, collectors, features
- customers/zpark/config.yaml — Z-Park branding, Sungrow collector
- GET /api/v1/branding endpoint for customer-specific branding
- main.py loads customer config for app title, CORS, logging
- Collector manager filters by customer's enabled collectors

Z-Park (中关村医疗器械园) support:
- Sungrow iSolarCloud API collector (sungrow_collector.py)
- Z-Park device definitions (10 inverters, 8 combiner boxes, 22+ buildings)
- Z-Park TOU pricing config (Beijing 2026 rates)
- Z-Park seed script (seed_zpark.py)

Gitea migration scripts (Mac Studio → labmac3):
- 5 migration scripts + README in scripts/gitea-migration/
- Creates 3-repo structure: ems-core, tp-ems, zpark-ems

Version: v1.0.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:23:33 +08:00

205 lines
7.7 KiB
Python

"""阳光电源 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