ems-core v1.0.0: Standard EMS platform core

Shared backend + frontend for multi-customer EMS deployments.
- 12 enterprise modules: quota, cost, charging, maintenance, analysis, etc.
- 120+ API endpoints, 37 database tables
- Customer config mechanism (CUSTOMER env var + YAML config)
- Collectors: Modbus TCP, MQTT, HTTP API, Sungrow iSolarCloud
- Frontend: React 19 + Ant Design + ECharts + Three.js
- Infrastructure: Redis cache, rate limiting, aggregation engine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Du Wenbo
2026-04-04 18:14:11 +08:00
commit 92ec910a13
227 changed files with 39179 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getDevices, getDeviceStats, getDashboardOverview, getRealtimeData } from '../../../services/api';
import type { DeviceInfo, DeviceWithPosition, OverviewData, RealtimePowerData } from '../types';
import { DEVICE_POSITIONS, POLL_INTERVAL } from '../constants';
interface DeviceStats {
online: number;
offline: number;
alarm: number;
maintenance: number;
total: number;
}
// Ordered position keys by device type for fuzzy matching
const POSITION_KEYS_BY_TYPE: Record<string, string[]> = {
pv_inverter: ['PV-INV-01', 'PV-INV-02', 'PV-INV-03'],
heat_pump: ['HP-01', 'HP-02', 'HP-03', 'HP-04'],
meter: ['MTR-GRID', 'MTR-PV', 'MTR-HP', 'MTR-PUMP'],
sensor: ['SENSOR-01', 'SENSOR-02', 'SENSOR-03', 'SENSOR-04', 'SENSOR-05'],
heat_meter: ['HM-01'],
};
function matchDevicesToPositions(devices: DeviceInfo[]): DeviceWithPosition[] {
const usedPositions = new Set<string>();
const result: DeviceWithPosition[] = [];
// Group devices by type
const byType: Record<string, DeviceInfo[]> = {};
for (const device of devices) {
const type = device.device_type || 'unknown';
if (!byType[type]) byType[type] = [];
byType[type].push(device);
}
for (const [type, typeDevices] of Object.entries(byType)) {
const positionKeys = POSITION_KEYS_BY_TYPE[type] || [];
typeDevices.forEach((device, index) => {
// Try exact match by device code first
let matchedKey: string | undefined;
if (device.code && DEVICE_POSITIONS[device.code] && !usedPositions.has(device.code)) {
matchedKey = device.code;
}
// Fall back to ordered assignment by type
if (!matchedKey && index < positionKeys.length) {
const key = positionKeys[index];
if (!usedPositions.has(key)) {
matchedKey = key;
}
}
const withPos: DeviceWithPosition = { ...device };
if (matchedKey) {
usedPositions.add(matchedKey);
const posData = DEVICE_POSITIONS[matchedKey];
withPos.position3D = posData.position;
withPos.rotation3D = posData.rotation;
// Override code with matched key so 3D components can look up positions by code
withPos.code = matchedKey;
}
result.push(withPos);
});
}
return result;
}
export function useDeviceData() {
const [devices, setDevices] = useState<DeviceInfo[]>([]);
const [deviceStats, setDeviceStats] = useState<DeviceStats | null>(null);
const [overview, setOverview] = useState<OverviewData | null>(null);
const [realtimeData, setRealtimeData] = useState<RealtimePowerData | null>(null);
const [devicesWithPositions, setDevicesWithPositions] = useState<DeviceWithPosition[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchAll = useCallback(async () => {
try {
const results = await Promise.allSettled([
getDevices({ page_size: 100 }) as Promise<any>,
getDeviceStats() as Promise<any>,
getDashboardOverview() as Promise<any>,
getRealtimeData() as Promise<any>,
]);
// Devices
if (results[0].status === 'fulfilled') {
const devData = results[0].value;
const items: DeviceInfo[] = devData?.items || [];
setDevices(items);
setDevicesWithPositions(matchDevicesToPositions(items));
}
// Stats
if (results[1].status === 'fulfilled') {
setDeviceStats(results[1].value as DeviceStats);
}
// Overview
if (results[2].status === 'fulfilled') {
setOverview(results[2].value as OverviewData);
}
// Realtime
if (results[3].status === 'fulfilled') {
setRealtimeData(results[3].value as RealtimePowerData);
}
setError(null);
} catch (err: any) {
setError(err?.message || 'Failed to fetch device data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
intervalRef.current = setInterval(fetchAll, POLL_INTERVAL);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchAll]);
return { devices, deviceStats, overview, realtimeData, devicesWithPositions, loading, error };
}