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>
205 lines
7.7 KiB
Python
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
|