feat: customer frontend, Sungrow collector fixes, real data (v1.2.0)
- Add frontend/ at root (no Three.js, no Charging, green #52c41a theme) - Fix Sungrow collector: add curPage/size params, unit conversion - Fix station-level dedup to prevent double-counting - Add shared token cache for API rate limit protection - Add .githooks/pre-commit, CLAUDE.md, .gitignore - Update docker-compose.override.yml frontend -> ./frontend - Pin bcrypt in requirements.txt - Add BUYOFF_RESULTS_2026-04-05.md (39/43 pass) - Data accuracy: 0.0% diff vs iSolarCloud Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ from app.collectors.base import BaseCollector
|
||||
from app.collectors.modbus_tcp import ModbusTcpCollector
|
||||
from app.collectors.mqtt_collector import MqttCollector
|
||||
from app.collectors.http_collector import HttpCollector
|
||||
from app.collectors.sungrow_collector import SungrowCollector
|
||||
|
||||
logger = logging.getLogger("collector.manager")
|
||||
|
||||
@@ -19,6 +20,7 @@ COLLECTOR_REGISTRY: dict[str, type[BaseCollector]] = {
|
||||
"modbus_tcp": ModbusTcpCollector,
|
||||
"mqtt": MqttCollector,
|
||||
"http_api": HttpCollector,
|
||||
"sungrow_api": SungrowCollector,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ class SungrowCollector(BaseCollector):
|
||||
|
||||
TOKEN_LIFETIME = 23 * 3600 # Refresh before 24h expiry
|
||||
|
||||
# Class-level shared token cache: {account -> (token, obtained_at)}
|
||||
_shared_tokens: dict[str, tuple[str, float]] = {}
|
||||
# Track which ps_id has already been collected this round to avoid double-counting
|
||||
_station_collected: dict[str, float] = {} # ps_id -> last_collect_timestamp
|
||||
|
||||
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("/")
|
||||
@@ -67,17 +72,32 @@ class SungrowCollector(BaseCollector):
|
||||
|
||||
data = {}
|
||||
|
||||
# Fetch power station overview for power/energy data
|
||||
if self._ps_id:
|
||||
if not self._ps_id:
|
||||
return data
|
||||
|
||||
# Use station-level data, but only allow ONE collector per ps_id
|
||||
# to report it — prevents double-counting across devices sharing
|
||||
# the same power station.
|
||||
now = time.monotonic()
|
||||
last = SungrowCollector._station_collected.get(self._ps_id, 0)
|
||||
is_first_for_station = (now - last) > (self.collect_interval * 0.8)
|
||||
|
||||
if is_first_for_station:
|
||||
SungrowCollector._station_collected[self._ps_id] = now
|
||||
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)
|
||||
self.logger.info(
|
||||
"Station %s data: power=%.1f kW, daily=%.1f kWh",
|
||||
self._ps_id,
|
||||
ps_data.get("power", (0,))[0],
|
||||
ps_data.get("daily_energy", (0,))[0],
|
||||
)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"Skipping station data for %s (already collected by another device)",
|
||||
self.device_code,
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -86,7 +106,21 @@ class SungrowCollector(BaseCollector):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _login(self):
|
||||
"""POST /openapi/login to obtain access token."""
|
||||
"""POST /openapi/login to obtain access token.
|
||||
|
||||
Uses a class-level shared token cache so multiple collectors
|
||||
sharing the same account don't each trigger a separate login.
|
||||
"""
|
||||
cache_key = self._user_account
|
||||
cached = SungrowCollector._shared_tokens.get(cache_key)
|
||||
if cached:
|
||||
token, obtained_at = cached
|
||||
if (time.monotonic() - obtained_at) < self.TOKEN_LIFETIME:
|
||||
self._token = token
|
||||
self._token_obtained_at = obtained_at
|
||||
self.logger.debug("Reusing shared token for %s", self._user_account)
|
||||
return
|
||||
|
||||
payload = {
|
||||
"appkey": self._app_key,
|
||||
"sys_code": self._sys_code,
|
||||
@@ -101,31 +135,59 @@ class SungrowCollector(BaseCollector):
|
||||
|
||||
self._token = token
|
||||
self._token_obtained_at = time.monotonic()
|
||||
SungrowCollector._shared_tokens[cache_key] = (token, self._token_obtained_at)
|
||||
self.logger.info("iSolarCloud login successful for account %s", self._user_account)
|
||||
|
||||
@staticmethod
|
||||
def _extract_value(raw, default_unit=""):
|
||||
"""Extract numeric value from Sungrow API field.
|
||||
|
||||
Handles both plain numbers and dict format like
|
||||
{'unit': 'kW', 'value': '644.477'}.
|
||||
Returns (float_value, unit_str).
|
||||
"""
|
||||
if isinstance(raw, dict):
|
||||
val = raw.get("value", 0)
|
||||
unit = raw.get("unit", default_unit)
|
||||
# Normalize Chinese units
|
||||
if unit == "万度":
|
||||
return float(val) * 10000, "kWh"
|
||||
if unit in ("度",):
|
||||
return float(val), "kWh"
|
||||
if unit in ("MW", "兆瓦"):
|
||||
return float(val) * 1000, "kW"
|
||||
if unit in ("MWh", "兆瓦时"):
|
||||
return float(val) * 1000, "kWh"
|
||||
return float(val), unit
|
||||
if raw is None or raw == "":
|
||||
return 0.0, default_unit
|
||||
return float(raw), default_unit
|
||||
|
||||
async def _get_station_data(self) -> dict:
|
||||
"""Fetch power station real-time data."""
|
||||
payload = {"ps_id": self._ps_id}
|
||||
payload = {"ps_id": self._ps_id, "curPage": 1, "size": 20}
|
||||
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")
|
||||
val, unit = self._extract_value(station["curr_power"], "kW")
|
||||
data["power"] = (val, unit)
|
||||
if "today_energy" in station:
|
||||
data["daily_energy"] = (float(station["today_energy"]), "kWh")
|
||||
val, unit = self._extract_value(station["today_energy"], "kWh")
|
||||
data["daily_energy"] = (val, unit)
|
||||
if "total_energy" in station:
|
||||
data["total_energy"] = (float(station["total_energy"]), "kWh")
|
||||
val, unit = self._extract_value(station["total_energy"], "kWh")
|
||||
data["total_energy"] = (val, unit)
|
||||
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}
|
||||
payload = {"ps_id": self._ps_id, "curPage": 1, "size": 100}
|
||||
result = await self._api_call("/openapi/getDeviceList", payload)
|
||||
|
||||
data = {}
|
||||
@@ -139,19 +201,26 @@ class SungrowCollector(BaseCollector):
|
||||
# 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")
|
||||
val, unit = self._extract_value(device["device_power"], "kW")
|
||||
data["power"] = (val, unit)
|
||||
if "today_energy" in device:
|
||||
data["daily_energy"] = (float(device["today_energy"]), "kWh")
|
||||
val, unit = self._extract_value(device["today_energy"], "kWh")
|
||||
data["daily_energy"] = (val, unit)
|
||||
if "total_energy" in device:
|
||||
data["total_energy"] = (float(device["total_energy"]), "kWh")
|
||||
val, unit = self._extract_value(device["total_energy"], "kWh")
|
||||
data["total_energy"] = (val, unit)
|
||||
if "temperature" in device:
|
||||
data["temperature"] = (float(device["temperature"]), "°C")
|
||||
val, unit = self._extract_value(device["temperature"], "°C")
|
||||
data["temperature"] = (val, unit)
|
||||
if "dc_voltage" in device:
|
||||
data["voltage"] = (float(device["dc_voltage"]), "V")
|
||||
val, unit = self._extract_value(device["dc_voltage"], "V")
|
||||
data["voltage"] = (val, unit)
|
||||
if "ac_current" in device:
|
||||
data["current"] = (float(device["ac_current"]), "A")
|
||||
val, unit = self._extract_value(device["ac_current"], "A")
|
||||
data["current"] = (val, unit)
|
||||
if "frequency" in device:
|
||||
data["frequency"] = (float(device["frequency"]), "Hz")
|
||||
val, unit = self._extract_value(device["frequency"], "Hz")
|
||||
data["frequency"] = (val, unit)
|
||||
if self._device_sn:
|
||||
break
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.3
|
||||
python-multipart==0.0.18
|
||||
redis[hiredis]==5.2.1
|
||||
celery==5.4.0
|
||||
|
||||
Reference in New Issue
Block a user