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:
Du Wenbo
2026-04-05 23:43:24 +08:00
parent ed30ac31e4
commit d3f47d664c
121 changed files with 21784 additions and 23 deletions

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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