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>
130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
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 };
|
|
}
|