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>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops
|
||||
from app.api.v1 import auth, users, devices, energy, monitoring, alarms, reports, carbon, dashboard, collectors, websocket, audit, settings, charging, quota, cost, maintenance, management, prediction, energy_strategy, weather, ai_ops, branding
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -25,3 +25,4 @@ api_router.include_router(prediction.router)
|
||||
api_router.include_router(energy_strategy.router)
|
||||
api_router.include_router(weather.router)
|
||||
api_router.include_router(ai_ops.router)
|
||||
api_router.include_router(branding.router)
|
||||
|
||||
20
backend/app/api/v1/branding.py
Normal file
20
backend/app/api/v1/branding.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter
|
||||
from app.core.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/branding", tags=["品牌配置"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_branding():
|
||||
"""Return customer-specific branding configuration"""
|
||||
settings = get_settings()
|
||||
customer_config = settings.load_customer_config()
|
||||
return {
|
||||
"customer": settings.CUSTOMER,
|
||||
"customer_name": customer_config.get("customer_name", settings.CUSTOMER),
|
||||
"platform_name": customer_config.get("platform_name", settings.APP_NAME),
|
||||
"platform_name_en": customer_config.get("platform_name_en", "Smart EMS"),
|
||||
"logo_url": customer_config.get("logo_url", ""),
|
||||
"theme_color": customer_config.get("theme_color", "#1890ff"),
|
||||
"features": customer_config.get("features", {}),
|
||||
}
|
||||
@@ -4,6 +4,7 @@ from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import async_session
|
||||
from app.models.device import Device
|
||||
from app.collectors.base import BaseCollector
|
||||
@@ -13,7 +14,7 @@ from app.collectors.http_collector import HttpCollector
|
||||
|
||||
logger = logging.getLogger("collector.manager")
|
||||
|
||||
# Registry mapping protocol names to collector classes
|
||||
# Full registry mapping protocol names to collector classes
|
||||
COLLECTOR_REGISTRY: dict[str, type[BaseCollector]] = {
|
||||
"modbus_tcp": ModbusTcpCollector,
|
||||
"mqtt": MqttCollector,
|
||||
@@ -21,6 +22,26 @@ COLLECTOR_REGISTRY: dict[str, type[BaseCollector]] = {
|
||||
}
|
||||
|
||||
|
||||
def get_enabled_collectors() -> dict[str, type[BaseCollector]]:
|
||||
"""Return collector registry filtered by customer config.
|
||||
|
||||
If the customer config specifies a 'collectors' list, only those
|
||||
protocols are enabled. Otherwise fall back to the full registry.
|
||||
"""
|
||||
settings = get_settings()
|
||||
customer_config = settings.load_customer_config()
|
||||
enabled_list = customer_config.get("collectors")
|
||||
if enabled_list is None:
|
||||
return COLLECTOR_REGISTRY
|
||||
enabled = {}
|
||||
for name in enabled_list:
|
||||
if name in COLLECTOR_REGISTRY:
|
||||
enabled[name] = COLLECTOR_REGISTRY[name]
|
||||
else:
|
||||
logger.warning("Customer config references unknown collector '%s', skipping", name)
|
||||
return enabled
|
||||
|
||||
|
||||
class CollectorManager:
|
||||
"""Manages lifecycle of all device collectors."""
|
||||
|
||||
@@ -47,11 +68,13 @@ class CollectorManager:
|
||||
|
||||
async def _load_and_start_collectors(self):
|
||||
"""Load active devices with supported protocols and start collectors."""
|
||||
enabled = get_enabled_collectors()
|
||||
logger.info("Enabled collectors: %s", list(enabled.keys()))
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Device).where(
|
||||
Device.is_active == True,
|
||||
Device.protocol.in_(list(COLLECTOR_REGISTRY.keys())),
|
||||
Device.protocol.in_(list(enabled.keys())),
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
|
||||
204
backend/app/collectors/sungrow_collector.py
Normal file
204
backend/app/collectors/sungrow_collector.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""阳光电源 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
|
||||
@@ -2,12 +2,18 @@ from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "TianpuEMS"
|
||||
DEBUG: bool = True
|
||||
API_V1_PREFIX: str = "/api/v1"
|
||||
|
||||
# Customer configuration
|
||||
CUSTOMER: str = "tianpu" # tianpu, zpark, etc.
|
||||
CUSTOMER_DISPLAY_NAME: str = "" # Loaded from customer config
|
||||
|
||||
# Database: set DATABASE_URL in .env to override.
|
||||
# Default: SQLite for local dev. Docker sets PostgreSQL via env var.
|
||||
# Examples:
|
||||
@@ -50,6 +56,19 @@ class Settings(BaseSettings):
|
||||
def is_sqlite(self) -> bool:
|
||||
return "sqlite" in self.DATABASE_URL
|
||||
|
||||
@property
|
||||
def customer_config_path(self) -> str:
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
"..", "customers", self.CUSTOMER)
|
||||
|
||||
def load_customer_config(self) -> dict:
|
||||
"""Load customer-specific config from customers/{CUSTOMER}/config.yaml"""
|
||||
config_file = os.path.join(self.customer_config_path, "config.yaml")
|
||||
if os.path.exists(config_file):
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
return {}
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "ignore"
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.collectors.manager import CollectorManager
|
||||
from app.collectors.queue import IngestionWorker
|
||||
|
||||
settings = get_settings()
|
||||
customer_config = settings.load_customer_config()
|
||||
simulator = DataSimulator()
|
||||
collector_manager: Optional[CollectorManager] = None
|
||||
ingestion_worker: Optional[IngestionWorker] = None
|
||||
@@ -28,6 +29,9 @@ logger = logging.getLogger("app")
|
||||
async def lifespan(app: FastAPI):
|
||||
global collector_manager, ingestion_worker
|
||||
|
||||
logger.info("Loading customer: %s (%s)", settings.CUSTOMER,
|
||||
customer_config.get("customer_name", settings.CUSTOMER))
|
||||
|
||||
# Initialize Redis cache
|
||||
if settings.REDIS_ENABLED:
|
||||
redis = await get_redis()
|
||||
@@ -80,15 +84,19 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="天普零碳园区智慧能源管理平台",
|
||||
description="Tianpu Zero-Carbon Park Smart Energy Management System",
|
||||
title=customer_config.get("platform_name", "天普零碳园区智慧能源管理平台"),
|
||||
description=customer_config.get("platform_name_en", "Tianpu Zero-Carbon Park Smart Energy Management System"),
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
_default_origins = ["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]
|
||||
_customer_origins = customer_config.get("cors_origins", [])
|
||||
_cors_origins = list(set(_default_origins + _customer_origins))
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"],
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
@@ -23,3 +23,4 @@ pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
pytest-cov==6.0.0
|
||||
aiosqlite==0.20.0
|
||||
pyyaml>=6.0
|
||||
|
||||
Reference in New Issue
Block a user