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