Initial commit: Check-My-IRVE SaaS (OCPP 1.6, Audit, Diagnostics)
This commit is contained in:
commit
192be86487
44 changed files with 2070 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
__pycache__
|
||||
.env
|
||||
*.log
|
||||
.vscode
|
||||
6
backend/Dockerfile
Normal file
6
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
99
backend/app/core/diagnostics.py
Normal file
99
backend/app/core/diagnostics.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import math
|
||||
|
||||
def rule_missing_status_after_boot(frames, ctx):
|
||||
if len(frames) < 3: return None
|
||||
has_boot = any(f['action'] == "BootNotification" for f in frames)
|
||||
has_status = any(f['action'] == "StatusNotification" for f in frames)
|
||||
if has_boot and not has_status:
|
||||
return {
|
||||
"id": "NO_STATUS_AFTER_BOOT", "severity": "warning", "confidence": "medium",
|
||||
"title": "Séquence de démarrage non conforme",
|
||||
"explication": "Aucun StatusNotification n'a été reçu après le BootNotification.",
|
||||
"action": "Envoyez un TriggerMessage(StatusNotification)."
|
||||
}
|
||||
return None
|
||||
|
||||
def rule_bad_websocket_ping(frames, ctx):
|
||||
for f in reversed(frames):
|
||||
if f['action'] == "GetConfiguration" and f['direction'] == "OUT":
|
||||
config = f['payload'].get('configurationKey', [])
|
||||
for item in config:
|
||||
if item.get('key') == 'WebSocketPingInterval' and item.get('value') == '0':
|
||||
return {
|
||||
"id": "BAD_PING_INTERVAL", "severity": "critical", "confidence": "high",
|
||||
"title": "WebSocketPingInterval désactivé (0)",
|
||||
"explication": "La borne n'envoie pas de ping. Derrière une box FAI (NAT), la connexion sera coupée.",
|
||||
"action": "Corrigez cette valeur à 60 depuis l'onglet Audit Config."
|
||||
}
|
||||
return None
|
||||
|
||||
def check_electrical(borne_config_dict, installer_config):
|
||||
findings = []
|
||||
max_c_str = borne_config_dict.get("MaxCurrent", {}).get("value")
|
||||
disj_str = str(installer_config.get("disj_calibre", "0"))
|
||||
p_decl_str = str(installer_config.get("borne_puissance", "0"))
|
||||
raccordement = installer_config.get("raccordement", "")
|
||||
|
||||
if not (max_c_str and disj_str and p_decl_str): return findings
|
||||
|
||||
try:
|
||||
max_c = int(max_c_str)
|
||||
disj = int(disj_str)
|
||||
p_decl = int(p_decl_str)
|
||||
|
||||
# a) Sécurité disjoncteur
|
||||
if disj < max_c:
|
||||
findings.append({
|
||||
"id": "ELEC_UNDER_PROTECTED", "severity": "critical", "confidence": "high",
|
||||
"title": "Protection sous-dimensionnée",
|
||||
"explication": f"Disjoncteur {disj}A inférieur au courant max borne {max_c}A.",
|
||||
"action": "Risque de déclenchement en charge. Vérifier le calibre et la section de câble."
|
||||
})
|
||||
|
||||
# b) Puissance réelle vs déclarée
|
||||
if "mono" in raccordement:
|
||||
p_reelle = 230 * max_c
|
||||
else:
|
||||
p_reelle = 400 * max_c * math.sqrt(3)
|
||||
|
||||
p_reelle_kw = round(p_reelle / 1000, 1)
|
||||
|
||||
if p_reelle_kw < p_decl * 0.85:
|
||||
findings.append({
|
||||
"id": "ELEC_POWER_BRIDLED", "severity": "critical", "confidence": "high",
|
||||
"title": "Borne bridée logiquement",
|
||||
"explication": f"Borne bridée à {p_reelle_kw} kW ({max_c}A x {400 if 'tri' in raccordement else 230}V) alors que {p_decl} kW sont déclarés.",
|
||||
"action": "Le firmware limite le courant. Vérifier le paramétrage usine ou une limitation logicielle."
|
||||
})
|
||||
|
||||
# c) 22kW sur Mono
|
||||
if p_decl > 7.4 and "mono" in raccordement:
|
||||
findings.append({
|
||||
"id": "ELEC_MONO_OVERPOWER", "severity": "critical", "confidence": "high",
|
||||
"title": "Raccordement incohérent",
|
||||
"explication": f"Puissance {p_decl}kW déclarée sur du monophasé.",
|
||||
"action": "Au-delà de 7.4kW, le triphasé est obligatoire."
|
||||
})
|
||||
except ValueError:
|
||||
pass
|
||||
return findings
|
||||
|
||||
def analyze_frames(frames, cp_data, installer_config=None):
|
||||
findings = []
|
||||
|
||||
# Rècles comportementales
|
||||
rules = [rule_missing_status_after_boot, rule_bad_websocket_ping]
|
||||
for rule in rules:
|
||||
res = rule(frames, cp_data)
|
||||
if res: findings.append(res)
|
||||
|
||||
# Règles électriques (nécessitent la config installateur)
|
||||
if installer_config:
|
||||
borne_config_dict = {}
|
||||
for f in reversed(frames):
|
||||
if f['action'] == "GetConfiguration" and f['direction'] == "OUT":
|
||||
borne_config_dict = {k["key"]: k for k in f['payload'].get('configurationKey', [])}
|
||||
break
|
||||
findings.extend(check_electrical(borne_config_dict, installer_config))
|
||||
|
||||
return findings
|
||||
39
backend/app/core/ocpp_schemas.py
Normal file
39
backend/app/core/ocpp_schemas.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import jsonschema
|
||||
|
||||
# Schémas simplifiés pour la démo (basés sur la spec OCPP 1.6)
|
||||
SCHEMAS = {
|
||||
"BootNotification": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chargePointVendor": {"type": "string", "maxLength": 20},
|
||||
"chargePointModel": {"type": "string", "maxLength": 20}
|
||||
},
|
||||
"required": ["chargePointVendor", "chargePointModel"]
|
||||
},
|
||||
"Authorize": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"idTag": {"type": "string", "maxLength": 20}
|
||||
},
|
||||
"required": ["idTag"]
|
||||
},
|
||||
"StartTransaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connectorId": {"type": "integer"},
|
||||
"idTag": {"type": "string"},
|
||||
"meterStart": {"type": "integer"},
|
||||
"timestamp": {"type": "string"}
|
||||
},
|
||||
"required": ["connectorId", "idTag", "meterStart", "timestamp"]
|
||||
}
|
||||
}
|
||||
|
||||
def validate_payload(action, payload):
|
||||
if action in SCHEMAS:
|
||||
try:
|
||||
jsonschema.validate(instance=payload, schema=SCHEMAS[action])
|
||||
return None
|
||||
except jsonschema.ValidationError as e:
|
||||
return e.message
|
||||
return None
|
||||
156
backend/app/core/session_manager.py
Normal file
156
backend/app/core/session_manager.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import time
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger("session_manager")
|
||||
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
REDIS_AVAILABLE = True
|
||||
except ImportError:
|
||||
REDIS_AVAILABLE = False
|
||||
|
||||
def normalize_keys(obj):
|
||||
"""Convertit récursivement les clés snake_case du payload OCPP en camelCase."""
|
||||
if isinstance(obj, dict):
|
||||
new_dict = {}
|
||||
for k, v in obj.items():
|
||||
parts = k.split('_')
|
||||
new_key = parts[0] + ''.join(p.title() for p in parts[1:])
|
||||
new_dict[new_key] = normalize_keys(v)
|
||||
return new_dict
|
||||
elif isinstance(obj, list):
|
||||
return [normalize_keys(v) for v in obj]
|
||||
return obj
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self, redis_url=None):
|
||||
self.redis = None
|
||||
self.memory_cps = {}
|
||||
self.memory_frames = defaultdict(lambda: deque(maxlen=200))
|
||||
self.pending_calls = {}
|
||||
self.action_map = {}
|
||||
|
||||
if redis_url and REDIS_AVAILABLE:
|
||||
try:
|
||||
self.redis = aioredis.from_url(redis_url)
|
||||
logger.info("SessionManager connecté à Redis.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis indisponible: {e}")
|
||||
self.redis = None
|
||||
|
||||
async def register_cp(self, cp_id, model, vendor, subprotocol):
|
||||
data = {
|
||||
"id": cp_id, "model": str(model), "vendor": str(vendor),
|
||||
"last_activity": time.time(), "status": "Connected", "msg_count": 0,
|
||||
"boot_time_ms": None, "reconnections": 0, "clock_drift": None,
|
||||
"rtt_avg_ms": 0, "subprotocol": subprotocol, "last_disconnect_code": None
|
||||
}
|
||||
if self.redis:
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(data))
|
||||
else:
|
||||
self.memory_cps[cp_id] = data
|
||||
|
||||
async def update_disconnect(self, cp_id, code):
|
||||
if self.redis:
|
||||
data = await self.redis.hget("chargepoints", cp_id)
|
||||
if data:
|
||||
cp = json.loads(data)
|
||||
cp["last_disconnect_code"] = code
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(cp))
|
||||
elif cp_id in self.memory_cps:
|
||||
self.memory_cps[cp_id]["last_disconnect_code"] = code
|
||||
|
||||
async def update_cp_status(self, cp_id, status):
|
||||
if self.redis:
|
||||
data = await self.redis.hget("chargepoints", cp_id)
|
||||
if data:
|
||||
cp = json.loads(data)
|
||||
cp["status"] = str(status)
|
||||
cp["last_activity"] = time.time()
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(cp))
|
||||
elif cp_id in self.memory_cps:
|
||||
self.memory_cps[cp_id]["status"] = str(status)
|
||||
self.memory_cps[cp_id]["last_activity"] = time.time()
|
||||
|
||||
async def purge_frames(self, cp_id):
|
||||
if self.redis:
|
||||
await self.redis.delete(f"frames:{cp_id}")
|
||||
await self.redis.delete(f"stream:{cp_id}")
|
||||
data = await self.redis.hget("chargepoints", cp_id)
|
||||
if data:
|
||||
cp = json.loads(data)
|
||||
cp["msg_count"] = 0
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(cp))
|
||||
elif cp_id in self.memory_frames:
|
||||
self.memory_frames[cp_id].clear()
|
||||
if cp_id in self.memory_cps:
|
||||
self.memory_cps[cp_id]["msg_count"] = 0
|
||||
|
||||
async def log_frame(self, cp_id, direction, messageTypeId, action, uniqueId, payload):
|
||||
now = time.time()
|
||||
rtt_ms = None
|
||||
schema_error = None
|
||||
|
||||
# Normalisation du payload en camelCase
|
||||
payload = normalize_keys(payload)
|
||||
|
||||
if messageTypeId == 2:
|
||||
self.action_map[uniqueId] = action
|
||||
self.pending_calls[uniqueId] = now
|
||||
elif messageTypeId == 3 and action is None:
|
||||
rtt_ms = (now - self.pending_calls.pop(uniqueId, now)) * 1000
|
||||
action = self.action_map.pop(uniqueId, "Unknown")
|
||||
elif messageTypeId == 4 and action is None:
|
||||
rtt_ms = (now - self.pending_calls.pop(uniqueId, now)) * 1000
|
||||
action = self.action_map.pop(uniqueId, "Unknown")
|
||||
|
||||
frame = {
|
||||
"timestamp": now, "direction": direction, "messageTypeId": messageTypeId,
|
||||
"action": action, "uniqueId": uniqueId, "payload": payload
|
||||
}
|
||||
|
||||
if self.redis:
|
||||
stream_frame = {
|
||||
"timestamp": str(now), "direction": str(direction), "messageTypeId": str(messageTypeId),
|
||||
"action": str(action), "uniqueId": str(uniqueId), "payload": json.dumps(payload)
|
||||
}
|
||||
await self.redis.xadd(f"stream:{cp_id}", stream_frame)
|
||||
|
||||
await self.redis.lpush(f"frames:{cp_id}", json.dumps(frame))
|
||||
await self.redis.ltrim(f"frames:{cp_id}", 0, 199)
|
||||
await self.redis.expire(f"frames:{cp_id}", 86400) # TTL 24h
|
||||
|
||||
await self.redis.publish("live_frames", json.dumps({"cp_id": cp_id, **frame}))
|
||||
|
||||
data = await self.redis.hget("chargepoints", cp_id)
|
||||
if data:
|
||||
cp = json.loads(data)
|
||||
cp["msg_count"] = cp.get("msg_count", 0) + 1
|
||||
if action in ["StartTransaction", "StopTransaction"] and "timestamp" in payload:
|
||||
try:
|
||||
cp_time = datetime.fromisoformat(payload["timestamp"].replace("Z", "+00:00"))
|
||||
cp["clock_drift"] = abs((datetime.now(timezone.utc) - cp_time).total_seconds())
|
||||
except: pass
|
||||
if rtt_ms: cp["rtt_avg_ms"] = int(rtt_ms)
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(cp))
|
||||
else:
|
||||
self.memory_frames[cp_id].append(frame)
|
||||
if cp_id in self.memory_cps:
|
||||
self.memory_cps[cp_id]["msg_count"] += 1
|
||||
|
||||
async def get_cps(self):
|
||||
if self.redis:
|
||||
data = await self.redis.hgetall("chargepoints")
|
||||
return [json.loads(v) for v in data.values()]
|
||||
return list(self.memory_cps.values())
|
||||
|
||||
async def get_frames(self, cp_id):
|
||||
if self.redis:
|
||||
data = await self.redis.lrange(f"frames:{cp_id}", 0, -1)
|
||||
return [json.loads(f) for f in reversed(data)]
|
||||
return list(self.memory_frames[cp_id])
|
||||
|
||||
session_mgr = SessionManager(redis_url="redis://redis:6379")
|
||||
179
backend/app/main.py
Normal file
179
backend/app/main.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from app.ocpp.router import CustomChargePoint
|
||||
from app.core.session_manager import session_mgr
|
||||
from app.core.diagnostics import analyze_frames, check_electrical
|
||||
from ocpp.v16 import call as v16_call
|
||||
import logging
|
||||
import json
|
||||
import time
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
app = FastAPI(title="Check-My-IRVE API", version="3.5.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
class SocketAdapter:
|
||||
def __init__(self, websocket: WebSocket, cp_id: str):
|
||||
self._ws = websocket
|
||||
self.cp_id = cp_id
|
||||
|
||||
async def recv(self):
|
||||
msg = await self._ws.receive_text()
|
||||
try:
|
||||
parsed = json.loads(msg)
|
||||
msg_type = parsed[0]
|
||||
unique_id = parsed[1]
|
||||
if msg_type == 2: action, payload = parsed[2], parsed[3] if len(parsed) > 3 else {}
|
||||
elif msg_type == 3: action, payload = None, parsed[2] if len(parsed) > 2 else {}
|
||||
elif msg_type == 4: action, payload = None, {"errorCode": parsed[2]}
|
||||
else: return msg
|
||||
await session_mgr.log_frame(self.cp_id, "IN", msg_type, action, unique_id, payload)
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur parsing IN: {e}")
|
||||
return msg
|
||||
|
||||
async def send(self, message):
|
||||
try:
|
||||
parsed = json.loads(message)
|
||||
msg_type = parsed[0]
|
||||
unique_id = parsed[1]
|
||||
if msg_type == 2: action, payload = parsed[2], parsed[3] if len(parsed) > 3 else {}
|
||||
elif msg_type == 3: action, payload = None, parsed[2] if len(parsed) > 2 else {}
|
||||
elif msg_type == 4: action, payload = None, {"errorCode": parsed[2]}
|
||||
else: return await self._ws.send_text(message)
|
||||
await session_mgr.log_frame(self.cp_id, "OUT", msg_type, action, unique_id, payload)
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur parsing OUT: {e}")
|
||||
await self._ws.send_text(message)
|
||||
|
||||
active_charge_points = {}
|
||||
|
||||
@app.websocket("/ocpp/{client_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
||||
subprotocol = websocket.headers.get("sec-websocket-protocol", "Unknown")
|
||||
await websocket.accept(subprotocol="ocpp1.6")
|
||||
adapter = SocketAdapter(websocket, client_id)
|
||||
cp = CustomChargePoint(client_id, adapter)
|
||||
cp.subprotocol = subprotocol
|
||||
active_charge_points[client_id] = cp
|
||||
|
||||
if session_mgr.redis:
|
||||
data = await session_mgr.redis.hget("chargepoints", client_id)
|
||||
if data:
|
||||
cp_data = json.loads(data)
|
||||
cp_data["reconnections"] = cp_data.get("reconnections", 0) + 1
|
||||
await session_mgr.redis.hset("chargepoints", client_id, json.dumps(cp_data))
|
||||
|
||||
try:
|
||||
await cp.start()
|
||||
except WebSocketDisconnect as e:
|
||||
logging.info(f"Borne {client_id} déconnectée (Code: {e.code}).")
|
||||
await session_mgr.update_disconnect(client_id, e.code)
|
||||
await session_mgr.update_cp_status(client_id, "Disconnected")
|
||||
if client_id in active_charge_points: del active_charge_points[client_id]
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur OCPP sur {client_id}: {e}")
|
||||
|
||||
@app.get("/api/chargepoints")
|
||||
async def get_cps(): return await session_mgr.get_cps()
|
||||
|
||||
@app.get("/api/chargepoints/{cp_id}/frames")
|
||||
async def get_frames(cp_id: str): return await session_mgr.get_frames(cp_id)
|
||||
|
||||
@app.delete("/api/chargepoints/{cp_id}/frames")
|
||||
async def purge_frames(cp_id: str):
|
||||
await session_mgr.purge_frames(cp_id)
|
||||
return {"status": "success"}
|
||||
|
||||
@app.post("/api/chargepoints/{cp_id}/diagnostics")
|
||||
async def get_diagnostics(cp_id: str, payload: dict):
|
||||
frames = await session_mgr.get_frames(cp_id)
|
||||
cps = await session_mgr.get_cps()
|
||||
cp_data = next((cp for cp in cps if cp["id"] == cp_id), {})
|
||||
installer_config = payload.get("installer_config", {})
|
||||
return analyze_frames(frames, cp_data, installer_config)
|
||||
|
||||
@app.get("/api/chargepoints/{cp_id}/report", response_class=HTMLResponse)
|
||||
async def get_report(cp_id: str):
|
||||
frames = await session_mgr.get_frames(cp_id)
|
||||
cps = await session_mgr.get_cps()
|
||||
cp_data = next((cp for cp in cps if cp["id"] == cp_id), {})
|
||||
findings = analyze_frames(frames, cp_data, {})
|
||||
|
||||
html = f"""
|
||||
<html><head><title>Rapport - {cp_id}</title>
|
||||
<style>body{{font-family:sans-serif;margin:40px}}pre{{background:#0f172a;color:#e2e8f0;padding:15px}}</style></head>
|
||||
<body><h1>Rapport IRVE</h1><p>Borne: {cp_id}</p>
|
||||
<h2>Anomalies</h2>{''.join([f'<div style="border-left:4px solid red;padding:10px;margin-bottom:10px"><b>{f["title"]}</b><br>{f["action"]}</div>' for f in findings])}
|
||||
<h2>Trames</h2><pre>{json.dumps(frames[:20], indent=2)}</pre>
|
||||
</body></html>
|
||||
"""
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
@app.post("/api/chargepoints/{cp_id}/audit")
|
||||
async def audit_config(payload: dict):
|
||||
borne_config_dict = {k["key"]: k for k in payload.get("borne_config", {}).get("configurationKey", [])}
|
||||
installer_config = payload.get("installer_config", {})
|
||||
|
||||
audit_result = []
|
||||
|
||||
REFERENCE_CONFIG = {
|
||||
"HeartbeatInterval": {"expected": "300", "severity": "warning", "readonly": False},
|
||||
"WebSocketPingInterval": {"expected": "60", "severity": "critical", "readonly": False},
|
||||
"MeterValueSampleInterval": {"expected": "10", "severity": "info", "readonly": False},
|
||||
"ConnectionTimeOut": {"expected": "60", "severity": "warning", "readonly": False}
|
||||
}
|
||||
for key, ref in REFERENCE_CONFIG.items():
|
||||
borne_val = borne_config_dict.get(key, {}).get("value", "Manquant")
|
||||
audit_result.append({
|
||||
"key": key, "borne_val": borne_val, "expected_val": ref["expected"],
|
||||
"match": borne_val == ref["expected"], "severity": ref["severity"], "readonly": ref["readonly"]
|
||||
})
|
||||
|
||||
elec_findings = check_electrical(borne_config_dict, installer_config)
|
||||
for f in elec_findings:
|
||||
audit_result.append({
|
||||
"key": f['title'], "borne_val": f['explication'], "expected_val": f['action'],
|
||||
"match": False, "severity": f['severity'], "readonly": True
|
||||
})
|
||||
|
||||
return audit_result
|
||||
|
||||
@app.post("/api/chargepoints/{cp_id}/command")
|
||||
async def send_command(cp_id: str, command: dict):
|
||||
if cp_id not in active_charge_points: return {"error": "CP not connected"}
|
||||
cp = active_charge_points[cp_id]
|
||||
action = command.get("action")
|
||||
payload = command.get("payload", {})
|
||||
try:
|
||||
if action == "TriggerMessage": req = v16_call.TriggerMessagePayload(requested_message=payload.get("requested_message", "StatusNotification"), connector_id=payload.get("connector_id", 0))
|
||||
elif action == "GetConfiguration": req = v16_call.GetConfigurationPayload(key=payload.get("key", []))
|
||||
elif action == "RemoteStartTransaction": req = v16_call.RemoteStartTransactionPayload(id_tag="CHARGEMAP_PASS", connector_id=1)
|
||||
elif action == "Reset": req = v16_call.ResetPayload(type="Soft")
|
||||
else: return {"error": "Unsupported"}
|
||||
resp = await cp.call(req)
|
||||
return {"status": "success", "response": resp}
|
||||
except Exception as e: return {"error": str(e)}
|
||||
|
||||
@app.websocket("/api/live")
|
||||
async def websocket_live(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
if not session_mgr.redis:
|
||||
await websocket.send_text(json.dumps({"error": "Redis indisponible"}))
|
||||
await websocket.close()
|
||||
return
|
||||
pubsub = session_mgr.redis.pubsub()
|
||||
await pubsub.subscribe("live_frames")
|
||||
try:
|
||||
async for message in pubsub.listen():
|
||||
if message['type'] == 'message': await websocket.send_text(message['data'].decode('utf-8'))
|
||||
except: pass
|
||||
finally: await pubsub.unsubscribe("live_frames")
|
||||
0
backend/app/ocpp/__init__.py
Normal file
0
backend/app/ocpp/__init__.py
Normal file
58
backend/app/ocpp/router.py
Normal file
58
backend/app/ocpp/router.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from ocpp.routing import on
|
||||
from ocpp.v16 import ChargePoint as cp
|
||||
from ocpp.v16 import call_result
|
||||
from ocpp.v16.enums import Action, RegistrationStatus
|
||||
from app.core.session_manager import session_mgr
|
||||
|
||||
logger = logging.getLogger("ocpp_router")
|
||||
|
||||
def get_utc_time():
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
HEARTBEAT_INTERVAL = int(os.getenv("OCPP_HEARTBEAT_INTERVAL", 300))
|
||||
|
||||
class CustomChargePoint(cp):
|
||||
@on(Action.BootNotification)
|
||||
async def on_boot_notification(self, charge_point_vendor, charge_point_model, **kwargs):
|
||||
await session_mgr.register_cp(self.id, charge_point_model, charge_point_vendor, getattr(self, 'subprotocol', 'Unknown'))
|
||||
response = call_result.BootNotificationPayload(
|
||||
current_time=get_utc_time(),
|
||||
interval=HEARTBEAT_INTERVAL,
|
||||
status=RegistrationStatus.accepted
|
||||
)
|
||||
return response
|
||||
|
||||
@on(Action.Heartbeat)
|
||||
async def on_heartbeat(self):
|
||||
response = call_result.HeartbeatPayload(current_time=get_utc_time())
|
||||
return response
|
||||
|
||||
@on(Action.Authorize)
|
||||
async def on_authorize(self, id_tag, **kwargs):
|
||||
status = "Accepted" if id_tag == "CHARGEMAP_PASS" else "Invalid"
|
||||
response = call_result.AuthorizePayload(id_tag_info={"status": status})
|
||||
return response
|
||||
|
||||
@on(Action.MeterValues)
|
||||
async def on_meter_values(self, connector_id, meter_value, **kwargs):
|
||||
response = call_result.MeterValuesPayload()
|
||||
return response
|
||||
|
||||
@on(Action.StatusNotification)
|
||||
async def on_status_notification(self, connector_id, status, error_code, **kwargs):
|
||||
await session_mgr.update_cp_status(self.id, str(status))
|
||||
response = call_result.StatusNotificationPayload()
|
||||
return response
|
||||
|
||||
@on(Action.StartTransaction)
|
||||
async def on_start_transaction(self, connector_id, id_tag, meter_start, timestamp, **kwargs):
|
||||
response = call_result.StartTransactionPayload(transaction_id=123456, id_tag_info={"status": "Accepted"})
|
||||
return response
|
||||
|
||||
@on(Action.StopTransaction)
|
||||
async def on_stop_transaction(self, transaction_id, meter_stop, timestamp, **kwargs):
|
||||
response = call_result.StopTransactionPayload(id_tag_info={"status": "Accepted"})
|
||||
return response
|
||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
ocpp==2.0.0rc1
|
||||
websockets==12.0
|
||||
redis==5.0.1
|
||||
pydantic==2.4.2
|
||||
jsonschema==4.20.0
|
||||
36
backend/tests/test_diagnostics.py
Normal file
36
backend/tests/test_diagnostics.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'app')))
|
||||
|
||||
from core.diagnostics import analyze_frames
|
||||
|
||||
def test_missing_status_after_boot():
|
||||
frames = [
|
||||
{"action": "BootNotification", "uniqueId": "1", "payload": {}},
|
||||
{"action": "Heartbeat", "uniqueId": "2", "payload": {}}
|
||||
]
|
||||
findings = analyze_frames(frames, {})
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["id"] == "NO_STATUS_AFTER_BOOT"
|
||||
|
||||
def test_no_finding_when_status_present():
|
||||
frames = [
|
||||
{"action": "BootNotification", "uniqueId": "1", "payload": {}},
|
||||
{"action": "StatusNotification", "uniqueId": "2", "payload": {"status": "Available"}}
|
||||
]
|
||||
findings = analyze_frames(frames, {})
|
||||
assert len(findings) == 0
|
||||
|
||||
def test_clock_drift_none():
|
||||
findings = analyze_frames([], {"clock_drift": None})
|
||||
assert len(findings) == 0
|
||||
|
||||
def test_clock_drift_critical():
|
||||
findings = analyze_frames([], {"clock_drift": 120})
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["id"] == "CLOCK_DRIFT"
|
||||
|
||||
def test_abnormal_disconnect():
|
||||
findings = analyze_frames([], {"last_disconnect_code": 1006})
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["id"] == "ABNORMAL_DISCONNECT"
|
||||
42
deploy_aws.sh
Executable file
42
deploy_aws.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
|||
#!/bin/bash
|
||||
|
||||
# 1. Créer le Security Group
|
||||
echo "Création du Security Group..."
|
||||
SG_ID=$(aws ec2 create-security-group --group-name "CheckMyIRVE-Pro-SG" --description "SG for Check-My-IRVE Prod" --query 'GroupId' --output text 2>/dev/null || aws ec2 describe-security-groups --group-names "CheckMyIRVE-Pro-SG" --query 'SecurityGroups[0].GroupId' --output text)
|
||||
|
||||
aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 22 --cidr 0.0.0.0/0 2>/dev/null
|
||||
aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 80 --cidr 0.0.0.0/0 2>/dev/null
|
||||
|
||||
# 2. Script d'initialisation (User Data)
|
||||
cat << 'USERDATA' > init_server.sh
|
||||
#!/bin/bash
|
||||
apt update -y
|
||||
apt install -y docker.io docker-compose git
|
||||
systemctl start docker
|
||||
systemctl enable docker
|
||||
cd /opt
|
||||
git clone https://github.com/votre-repo/check-my-irve-pro.git /opt/app
|
||||
cd /opt/app
|
||||
docker-compose up -d --build
|
||||
USERDATA
|
||||
|
||||
# 3. Récupérer l'ID de l'AMI Ubuntu 22.04 ARM64 automatiquement
|
||||
AMI_ID=$(aws ssm get-parameters --names /aws/service/canonical/ubuntu/server/22.04/pvt/arm64/hvm/ebs-gp2/ami-id --query 'Parameters[0].Value' --output text)
|
||||
|
||||
echo "Lancement de l'instance EC2 avec l'AMI $AMI_ID..."
|
||||
INSTANCE_ID=$(aws ec2 run-instances \
|
||||
--image-id $AMI_ID \
|
||||
--count 1 \
|
||||
--instance-type t4g.micro \
|
||||
--security-group-ids $SG_ID \
|
||||
--user-data file://init_server.sh \
|
||||
--query 'Instances[0].InstanceId' --output text)
|
||||
|
||||
echo "Instance ID: $INSTANCE_ID"
|
||||
aws ec2 wait instance-running --instance-ids $INSTANCE_ID
|
||||
|
||||
PUBLIC_IP=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID --query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
|
||||
echo "===================================="
|
||||
echo "Déploiement en cours sur l'IP: http://$PUBLIC_IP"
|
||||
echo "Attendez environ 2-3 minutes que Docker s'installe et lance les conteneurs sur le serveur."
|
||||
echo "===================================="
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports: ["8000:8000"]
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379
|
||||
depends_on: [redis]
|
||||
redis:
|
||||
image: "redis:alpine"
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports: ["5173:5173"]
|
||||
nginx:
|
||||
image: nginx:latest
|
||||
ports: ["80:80"]
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on: [backend, frontend]
|
||||
simulator:
|
||||
build: ./simulator
|
||||
profiles: ["sim"]
|
||||
depends_on: [backend]
|
||||
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
8
frontend/.oxlintrc.json
Normal file
8
frontend/.oxlintrc.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
7
frontend/Dockerfile
Normal file
7
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM node:20-slim
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm cache clean --force && rm -rf node_modules package-lock.json && npm install --legacy-peer-deps
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
16
frontend/README.md
Normal file
16
frontend/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "5.4.8"
|
||||
}
|
||||
}
|
||||
5
frontend/postcss.config.js
Normal file
5
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
184
frontend/src/App.css
Normal file
184
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
240
frontend/src/App.jsx
Normal file
240
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import NetworkScene from './components/NetworkScene';
|
||||
import SnifferPanel from './components/SnifferPanel';
|
||||
import AnalysisPanel from './components/AnalysisPanel';
|
||||
import ControlPanel from './components/ControlPanel';
|
||||
import AuditPanel from './components/AuditPanel';
|
||||
import CommissioningAssistant from './components/CommissioningAssistant';
|
||||
import FleetView from './components/FleetView';
|
||||
import CdrView from './components/CdrView';
|
||||
|
||||
const Icon = ({ path, size = 20, className = "" }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d={path} /></svg>
|
||||
);
|
||||
const icons = { play: "M5 3l14 9-14 9V3z", close: "M18 6L6 18M6 6l12 12", stop: "M6 6h12v12H6z", car: "M5 17h14v-5l-2-5H7L5 12v5zM5 17v2h2v-2h10v2h2v-2M7 12h10" };
|
||||
|
||||
export default function App() {
|
||||
const [config, setConfig] = useState({
|
||||
raccordement: 'BT tri 400V', puissance_souscrite: 36, source: 'Réseau Enedis', disj_calibre: 40, diff_type: 'Type B', rdc_dd: false,
|
||||
borne_fabricant: 'Schneider', borne_puissance: 22, comm_type: 'SIM 4G M2M',
|
||||
csms_url: 'ws://localhost:8000/ocpp/WEB-TEST-01', csms_version: '1.6J', csms_auth: 'Basic'
|
||||
});
|
||||
|
||||
const [status, setStatus] = useState({ grid: 'idle', box: 'idle', evse: 'idle', csms: 'idle', roaming: 'idle' });
|
||||
const [logs, setLogs] = useState([{ time: '00:00:00', msg: "Système prêt.", type: "info" }]);
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('ocpp');
|
||||
const [midValue, setMidValue] = useState(0.0);
|
||||
const [metrics, setMetrics] = useState({ boot_time_ms: 0, rtt_avg_ms: 0, reconnections: 0, clock_drift: 'n/a', subprotocol: 'n/a' });
|
||||
const [frameCount, setFrameCount] = useState(0);
|
||||
const [frames, setFrames] = useState([]);
|
||||
const [getConfigResp, setGetConfigResp] = useState(null);
|
||||
const [mainView, setMainView] = useState('parc');
|
||||
const [selectedCp, setSelectedCp] = useState('WEB-TEST-01');
|
||||
|
||||
const [isDemoRunning, setIsDemoRunning] = useState(false);
|
||||
const demoWsRef = useRef(null);
|
||||
const demoIntervalRef = useRef(null);
|
||||
const msgIdCounter = useRef(200);
|
||||
|
||||
const wsRef = useRef(null);
|
||||
const timeoutRef = useRef(null);
|
||||
|
||||
const handleChange = (e) => setConfig(prev => ({ ...prev, [e.target.name]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }));
|
||||
const addLog = (msg, type) => setLogs(prev => [...prev, { time: new Date().toLocaleTimeString('fr-FR'), msg, type }]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:8000/api/chargepoints');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const cp = data.find(c => c.id === selectedCp);
|
||||
if (cp) {
|
||||
setMetrics({ boot_time_ms: cp.boot_time_ms || 0, rtt_avg_ms: cp.rtt_avg_ms || 0, reconnections: cp.reconnections || 0, clock_drift: cp.clock_drift === null ? 'n/a' : Math.round(cp.clock_drift), subprotocol: cp.subprotocol || 'n/a' });
|
||||
setFrameCount(cp.msg_count || 0);
|
||||
}
|
||||
}
|
||||
const fRes = await fetch(`http://localhost:8000/api/chargepoints/${selectedCp}/frames`);
|
||||
if (fRes.ok) setFrames(await fRes.json());
|
||||
} catch (e) {}
|
||||
};
|
||||
const interval = setInterval(fetchMetrics, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedCp]);
|
||||
|
||||
const launchTest = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.close(); return; }
|
||||
setStatus({ grid: 'active', box: 'active', evse: 'loading', csms: 'loading', roaming: 'idle' });
|
||||
addLog(`Connexion à ${config.csms_url}...`, "info");
|
||||
try {
|
||||
const ws = new WebSocket(config.csms_url, ['ocpp1.6']);
|
||||
wsRef.current = ws;
|
||||
timeoutRef.current = setTimeout(() => { if (ws.readyState !== WebSocket.OPEN) { addLog("Timeout.", "error"); setStatus({ grid: 'active', box: 'active', evse: 'error', csms: 'error', roaming: 'idle' }); ws.close(); } }, 10000);
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
addLog("WebSocket ouverte. Envoi BootNotification...", "info");
|
||||
setStatus({ grid: 'active', box: 'active', evse: 'active', csms: 'loading', roaming: 'idle' });
|
||||
ws.send(JSON.stringify([2, "101", "BootNotification", { "chargePointVendor": config.borne_fabricant, "chargePointModel": "CheckMyIRVE" }]));
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg[0] === 3 && msg[1] === "101") {
|
||||
if (msg[2].status === "Accepted") { addLog("BootNotification Accepted.", "success"); setStatus({ grid: 'active', box: 'active', evse: 'active', csms: 'active', roaming: 'active' }); }
|
||||
else { addLog(`Rejetée: ${msg[2].status}`, "error"); setStatus({ grid: 'active', box: 'active', evse: 'error', csms: 'error', roaming: 'idle' }); }
|
||||
} else if (msg[0] === 2) {
|
||||
const action = msg[2], msgId = msg[1], payload = msg[3];
|
||||
addLog(`Req CSMS: ${action}`, "info");
|
||||
let respPayload = {};
|
||||
if (action === "GetConfiguration") {
|
||||
respPayload = { "configurationKey": [{"key":"WebSocketPingInterval","value":"0","readonly":false},{"key":"HeartbeatInterval","value":"30","readonly":false},{"key":"MeterValueSampleInterval","value":"5","readonly":false},{"key":"ConnectionTimeOut","value":"30","readonly":false},{"key":"MaxCurrent","value":"16","readonly":false}], "unknownKey": []
|
||||
};
|
||||
setGetConfigResp(respPayload);
|
||||
}
|
||||
else if (action === "TriggerMessage") { respPayload = { "status": "Accepted" }; if (payload.requestedMessage === "StatusNotification") setTimeout(() => ws.send(JSON.stringify([2, "trig-status-1", "StatusNotification", { "connectorId": 1, "status": "Available", "errorCode": "NoError" }])), 500); else if (payload.requestedMessage === "MeterValues") setTimeout(() => ws.send(JSON.stringify([2, "trig-meter-1", "MeterValues", { "connectorId": 1, "transactionId": 99, "meterValue": [{ "timestamp": new Date().toISOString(), "sampledValue": [{ "value": "1.5" }] }] }])), 500); }
|
||||
else if (action === "RemoteStartTransaction") { respPayload = { "status": "Accepted" }; setTimeout(() => { ws.send(JSON.stringify([2, "auth-tx-1", "Authorize", { "idTag": "CHARGEMAP_PASS" }])); setTimeout(() => { ws.send(JSON.stringify([2, "start-tx-1", "StartTransaction", { "connectorId": 1, "idTag": "CHARGEMAP_PASS", "meterStart": 0, "timestamp": new Date().toISOString() }])); setMidValue(0.1); setTimeout(() => { ws.send(JSON.stringify([2, "meter-tx-1", "MeterValues", { "connectorId": 1, "transactionId": 99, "meterValue": [{ "timestamp": new Date().toISOString(), "sampledValue": [{ "value": "2.5" }] }] }])); setMidValue(2.5); setTimeout(() => ws.send(JSON.stringify([2, "stop-tx-1", "StopTransaction", { "transactionId": 99, "meterStop": 2500, "timestamp": new Date().toISOString() }])), 1500); }, 1000); }, 500); }, 500); }
|
||||
else if (action === "Reset") respPayload = { "status": "Accepted" };
|
||||
else if (action === "ChangeConfiguration") { respPayload = { "status": "Accepted" }; if (payload?.key && payload?.value) setGetConfigResp(prev => { if (!prev || !prev.configurationKey) return prev; return { ...prev, configurationKey: prev.configurationKey.map(c => c.key === payload.key ? { ...c, value: payload.value } : c) }; }); }
|
||||
ws.send(JSON.stringify([3, msgId, respPayload]));
|
||||
}
|
||||
} catch (err) { addLog("Parsing error.", "error"); }
|
||||
};
|
||||
ws.onerror = () => addLog("Erreur WebSocket.", "error");
|
||||
ws.onclose = (e) => { clearTimeout(timeoutRef.current); addLog(`Fermée (${e.code}).`, "info"); setStatus({ grid: 'idle', box: 'idle', evse: 'idle', csms: 'idle', roaming: 'idle' }); wsRef.current = null; };
|
||||
} catch (err) { addLog(`Critique: ${err.message}`, "error"); }
|
||||
};
|
||||
|
||||
const runCarCycle = async () => {
|
||||
const ws = demoWsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const id = () => `demo-${msgIdCounter.current++}`;
|
||||
const ts = () => new Date().toISOString();
|
||||
|
||||
ws.send(JSON.stringify([2, id(), "StatusNotification", { "connectorId": 1, "status": "Preparing", "errorCode": "NoError" }]));
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
ws.send(JSON.stringify([2, id(), "Authorize", { "idTag": "CHARGEMAP_PASS" }]));
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
ws.send(JSON.stringify([2, id(), "StartTransaction", { "connectorId": 1, "idTag": "CHARGEMAP_PASS", "meterStart": 0, "timestamp": ts() }]));
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
ws.send(JSON.stringify([2, id(), "StatusNotification", { "connectorId": 1, "status": "Charging", "errorCode": "NoError" }]));
|
||||
for(let i=1; i<=3; i++) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
ws.send(JSON.stringify([2, id(), "MeterValues", { "connectorId": 1, "transactionId": 99, "meterValue": [{ "timestamp": ts(), "sampledValue": [{ "value": String(i * 1500) }] }] }]));
|
||||
}
|
||||
ws.send(JSON.stringify([2, id(), "StopTransaction", { "transactionId": 99, "meterStop": 4500, "timestamp": ts() }]));
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
ws.send(JSON.stringify([2, id(), "StatusNotification", { "connectorId": 1, "status": "Available", "errorCode": "NoError" }]));
|
||||
};
|
||||
|
||||
const toggleDemo = () => {
|
||||
if (isDemoRunning) {
|
||||
clearInterval(demoIntervalRef.current);
|
||||
demoWsRef.current?.close();
|
||||
setIsDemoRunning(false);
|
||||
addLog("Simulation arrêtée.", "info");
|
||||
} else {
|
||||
const ws = new WebSocket("ws://localhost:8000/ocpp/SIM-DEMO-01", ["ocpp1.6"]);
|
||||
demoWsRef.current = ws;
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify([2, "demo-boot", "BootNotification", { "chargePointVendor": "Tesla", "chargePointModel": "Model3" }]));
|
||||
ws.send(JSON.stringify([2, "demo-status-init", "StatusNotification", { "connectorId": 1, "status": "Available", "errorCode": "NoError" }]));
|
||||
setIsDemoRunning(true);
|
||||
addLog("Démo lancée: SIM-DEMO-01 connectée.", "success");
|
||||
runCarCycle();
|
||||
demoIntervalRef.current = setInterval(runCarCycle, 12000);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg[0] === 2) {
|
||||
const action = msg[2], msgId = msg[1];
|
||||
let resp = {};
|
||||
if (action === "GetConfiguration") resp = { configurationKey: [{ key: "MaxCurrent", value: "16", readonly: false }, { key: "HeartbeatInterval", value: "300", readonly: false }, { key: "WebSocketPingInterval", value: "0", readonly: false }], unknownKey: [] };
|
||||
else if (action === "TriggerMessage") resp = { status: "Accepted" };
|
||||
else if (action === "Reset") resp = { status: "Accepted" };
|
||||
ws.send(JSON.stringify([3, msgId, resp]));
|
||||
}
|
||||
};
|
||||
ws.onclose = () => { setIsDemoRunning(false); };
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCp = (id) => {
|
||||
setSelectedCp(id);
|
||||
setPanelOpen(true);
|
||||
setActiveTab('ocpp');
|
||||
setMainView('topo');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-slate-50 text-slate-900 font-sans overflow-hidden">
|
||||
<header className="h-14 bg-white border-b border-slate-200 px-6 flex items-center justify-between flex-shrink-0">
|
||||
<div className="flex items-center gap-3 w-64">
|
||||
<div className="w-8 h-8 bg-slate-900 rounded-md flex items-center justify-center text-white text-xs font-bold">IRVE</div>
|
||||
<h1 className="text-sm font-semibold tracking-tight text-slate-900">Check-My-IRVE Console</h1>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center items-center gap-2">
|
||||
<span className="text-[10px] uppercase text-slate-500 font-semibold">URL CSMS :</span>
|
||||
<input type="text" name="csms_url" value={config.csms_url} onChange={handleChange} className="bg-slate-100 text-xs font-mono text-blue-700 px-3 py-1.5 rounded-md border border-slate-200 focus:ring-1 focus:ring-blue-500 outline-none w-80" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-600 w-64 justify-end">
|
||||
<button onClick={toggleDemo} className={`px-3 py-2 rounded-md text-xs font-semibold transition-colors flex items-center gap-1 ${isDemoRunning ? 'bg-red-100 text-red-700 hover:bg-red-200' : 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'}`}>
|
||||
<Icon path={isDemoRunning ? icons.stop : icons.car} size={14} />
|
||||
{isDemoRunning ? 'Stop Démo' : 'Démo Voiture'}
|
||||
</button>
|
||||
<button onClick={() => setPanelOpen(!panelOpen)} className="px-3 py-2 rounded-md text-xs font-semibold bg-slate-100 text-slate-600 hover:bg-slate-200">Panneaux</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 overflow-hidden p-6 gap-6 min-h-0">
|
||||
<div className="flex-1 flex gap-6 overflow-hidden min-h-0">
|
||||
<aside className="w-[300px] flex-shrink-0 bg-white border border-slate-200 rounded-lg shadow-sm p-4 flex flex-col gap-4 overflow-y-auto">
|
||||
<div><h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-3">Infrastructure</h2><div className="space-y-3"><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Raccordement</label><select name="raccordement" value={config.raccordement} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>BT mono 230V</option><option>BT tri 400V</option><option>HTA avec poste privé</option></select></div><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Puissance souscrite (kVA)</label><input type="number" name="puissance_souscrite" value={config.puissance_souscrite} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs font-mono text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none" /></div><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Source</label><select name="source" value={config.source} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>Réseau Enedis</option><option>Autoconsommation PV</option><option>PV + stockage</option></select></div><div className="flex gap-2"><div className="flex-1"><label className="block text-[11px] font-medium text-slate-600 mb-1">Disj (A)</label><input type="number" name="disj_calibre" value={config.disj_calibre} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs font-mono text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none" /></div><div className="flex-1"><label className="block text-[11px] font-medium text-slate-600 mb-1">Diff</label><select name="diff_type" value={config.diff_type} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>Type A</option><option>Type B</option></select></div></div><label className="flex items-center gap-2 text-[11px] text-slate-600"><input type="checkbox" name="rdc_dd" checked={config.rdc_dd} onChange={handleChange} className="rounded border-slate-300 text-blue-600 focus:ring-blue-500" />Borne intègre RDC-DD 6mA</label></div></div>
|
||||
<div><h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-3">Borne & Comms</h2><div className="space-y-3"><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Fabricant</label><select name="borne_fabricant" value={config.borne_fabricant} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>ABB</option><option>Schneider</option><option>Wallbox</option><option>Alfen</option><option>EVBox</option><option>Hager</option></select></div><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Puissance borne (kW)</label><input type="number" name="borne_puissance" value={config.borne_puissance} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs font-mono text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none" /></div><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Communication</label><select name="comm_type" value={config.comm_type} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>Ethernet RJ45</option><option>WiFi</option><option>SIM 4G M2M</option><option>CPL</option></select></div></div></div>
|
||||
<ControlPanel cpId={selectedCp} />
|
||||
<div className="mt-auto"><button onClick={launchTest} className={`w-full h-10 text-white rounded-md text-sm font-semibold transition-colors flex items-center justify-center gap-2 shadow-sm ${wsRef.current && wsRef.current.readyState === WebSocket.OPEN ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'}`}><Icon path={wsRef.current && wsRef.current.readyState === WebSocket.OPEN ? icons.stop : icons.play} size={16} />{wsRef.current && wsRef.current.readyState === WebSocket.OPEN ? 'Arrêter' : 'Lancer Test'}</button></div>
|
||||
</aside>
|
||||
<div className="flex-1 flex flex-col gap-6 overflow-hidden min-h-0">
|
||||
<div className="flex-1 bg-white border border-slate-200 rounded-lg shadow-sm flex flex-col overflow-hidden min-h-0">
|
||||
<div className="h-10 border-b border-slate-200 flex items-center bg-slate-50 flex-shrink-0">
|
||||
<button onClick={() => setMainView('parc')} className={`h-full px-4 text-xs font-semibold ${mainView === 'parc' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Parc</button>
|
||||
<button onClick={() => setMainView('topo')} className={`h-full px-4 text-xs font-semibold ${mainView === 'topo' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Topologie</button>
|
||||
<button onClick={() => setMainView('audit')} className={`h-full px-4 text-xs font-semibold ${mainView === 'audit' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Audit Config</button>
|
||||
<button onClick={() => setMainView('comm')} className={`h-full px-4 text-xs font-semibold ${mainView === 'comm' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Mise en Service</button>
|
||||
<button onClick={() => setMainView('cdr')} className={`h-full px-4 text-xs font-semibold ${mainView === 'cdr' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Réconciliation CDR</button>
|
||||
</div>
|
||||
<div className="flex-1 p-4 overflow-auto min-h-0">
|
||||
{mainView === 'parc' && <FleetView onSelectCp={handleSelectCp} />}
|
||||
{mainView === 'topo' && <NetworkScene status={status} onInterceptClick={(id) => { setActiveTab(id); setPanelOpen(true); }} midValue={midValue} config={config} />}
|
||||
{mainView === 'audit' && <AuditPanel getConfigResponse={getConfigResp} installerConfig={config} />}
|
||||
{mainView === 'comm' && <CommissioningAssistant frames={frames} status={status} />}
|
||||
{mainView === 'cdr' && <CdrView cpId={selectedCp} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-40 bg-slate-900 rounded-lg border border-slate-800 shadow-sm flex flex-col overflow-hidden flex-shrink-0">
|
||||
<div className="h-8 border-b border-slate-800 px-4 flex items-center justify-between bg-slate-900"><span className="text-xs text-slate-400 font-mono">terminal_logs.sh</span></div>
|
||||
<div className="flex-1 p-4 overflow-y-auto font-mono text-xs flex flex-col gap-1 min-h-0">{logs.map((log, i) => <div key={i} className={log.type === 'error' ? 'text-red-400' : log.type === 'success' ? 'text-emerald-400' : 'text-slate-300'}><span className="text-slate-600">[{log.time}]</span> {log.msg}</div>)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{panelOpen && (
|
||||
<div className="w-[420px] flex-shrink-0 flex flex-col gap-6 overflow-hidden min-h-0">
|
||||
<div className="h-[260px] flex-shrink-0 flex flex-col overflow-hidden">
|
||||
<AnalysisPanel cpId={selectedCp} frameCount={frameCount} installerConfig={config} />
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-slate-200 bg-white rounded-t-lg flex-shrink-0">
|
||||
<button onClick={() => setActiveTab('ocpp')} className={`flex-1 py-2 text-xs font-semibold ${activeTab === 'ocpp' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>OCPP (WebSocket)</button>
|
||||
<button onClick={() => setActiveTab('ocpi')} className={`flex-1 py-2 text-xs font-semibold ${activeTab === 'ocpi' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>OCPI (REST Roaming)</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<SnifferPanel cpId={selectedCp} activeTab={activeTab} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
61
frontend/src/components/AnalysisPanel.jsx
Normal file
61
frontend/src/components/AnalysisPanel.jsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export default function AnalysisPanel({ cpId, frameCount, installerConfig }) {
|
||||
const [findings, setFindings] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDiag = async () => {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/chargepoints/${cpId}/diagnostics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ installer_config: installerConfig })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFindings(data.sort((a, b) => {
|
||||
const order = { critical: 0, warning: 1, info: 2 };
|
||||
return order[a.severity] - order[b.severity];
|
||||
}));
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
fetchDiag();
|
||||
const interval = setInterval(fetchDiag, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [cpId, installerConfig]);
|
||||
|
||||
const styles = {
|
||||
critical: { border: 'border-red-500', bg: 'bg-red-50', text: 'text-red-700', label: 'Critique' },
|
||||
warning: { border: 'border-amber-500', bg: 'bg-amber-50', text: 'text-amber-700', label: 'Avertissement' },
|
||||
info: { border: 'border-blue-500', bg: 'bg-blue-50', text: 'text-blue-700', label: 'Info' }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white rounded-lg border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-slate-200 bg-slate-50 flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Analyse & Verdicts</h3>
|
||||
<span className="text-[10px] text-slate-400 font-mono">{frameCount} trames</span>
|
||||
</div>
|
||||
<div className="flex-1 p-3 space-y-3 overflow-y-auto min-h-0">
|
||||
{frameCount < 3 && findings.length === 0 && <div className="text-slate-400 italic text-sm text-center pt-4">Observation en cours...</div>}
|
||||
{frameCount >= 3 && findings.length === 0 && <div className="text-emerald-600 italic text-sm text-center pt-4">Aucune anomalie.</div>}
|
||||
{findings.map((f, i) => {
|
||||
const s = styles[f.severity];
|
||||
return (
|
||||
<div key={i} className={`p-3 rounded-lg border-l-4 ${s.border} ${s.bg}`}>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<h4 className={`text-sm font-bold ${s.text}`}>{f.title}</h4>
|
||||
<div className="flex gap-1">
|
||||
<span className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${s.text} ${s.bg} border ${s.border}`}>{s.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mb-2">{f.explication}</p>
|
||||
<div className="text-xs text-slate-800 bg-white/50 p-2 rounded border border-slate-200"><span className="font-bold">Action: </span>{f.action}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/AuditPanel.jsx
Normal file
50
frontend/src/components/AuditPanel.jsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export default function AuditPanel({ getConfigResponse, installerConfig }) {
|
||||
const [audit, setAudit] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const runAudit = async (configData) => {
|
||||
if (!configData || !configData.configurationKey) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/chargepoints/WEB-TEST-01/audit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ borne_config: configData, installer_config: installerConfig })
|
||||
});
|
||||
const data = await res.json();
|
||||
setAudit(data);
|
||||
} catch (e) {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { if (getConfigResponse) runAudit(getConfigResponse); }, [getConfigResponse, installerConfig]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white rounded-lg border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-slate-200 bg-slate-50 flex justify-between items-center">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Audit de Configuration</h3>
|
||||
<button onClick={() => runAudit(getConfigResponse)} disabled={!getConfigResponse} className={`px-3 py-1 text-[10px] font-semibold rounded ${getConfigResponse ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-slate-200 text-slate-400 cursor-not-allowed'}`}>{loading ? 'Analyse...' : 'Relancer'}</button>
|
||||
</div>
|
||||
<div className="flex-1 p-3 overflow-y-auto min-h-0">
|
||||
{!getConfigResponse && <p className="text-slate-400 italic text-sm text-center pt-4">Cliquez sur Get Config.</p>}
|
||||
{audit && (
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-200 text-slate-500"><th className="text-left py-1 font-medium">Clé</th><th className="text-left py-1 font-medium">Borne</th><th className="text-left py-1 font-medium">Attendu</th><th className="text-left py-1 font-medium">Statut</th></tr></thead>
|
||||
<tbody>
|
||||
{audit.map((row, i) => (
|
||||
<tr key={i} className={`border-b border-slate-100 ${row.match ? 'bg-emerald-50/50' : row.severity === 'critical' ? 'bg-red-50/50' : 'bg-amber-50/50'}`}>
|
||||
<td className="py-1.5 font-mono text-slate-700">{row.key}</td>
|
||||
<td className="py-1.5 font-mono text-slate-900 font-bold">{row.borne_val}</td>
|
||||
<td className="py-1.5 font-mono text-slate-500">{row.expected_val}</td>
|
||||
<td className="py-1.5">{row.match ? <span className="text-emerald-600 text-[10px] font-bold">OK</span> : <span className="text-red-600 text-[10px] font-bold">ÉCART</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/CdrView.jsx
Normal file
41
frontend/src/components/CdrView.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export default function CdrView({ cpId }) {
|
||||
const [cdr, setCdr] = useState(null);
|
||||
useEffect(() => {
|
||||
const fetchCdr = async () => { try { const res = await fetch(`/api/chargepoints/${cpId}/cdr`); if (res.ok) { const data = await res.json(); if (!data.error) setCdr(data); } } catch (e) {} };
|
||||
fetchCdr();
|
||||
const interval = setInterval(fetchCdr, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [cpId]);
|
||||
|
||||
if (!cdr) return <div className="p-4 text-slate-400 italic text-sm">Aucune transaction terminée trouvée.</div>;
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4 overflow-auto">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h4 className="text-xs font-bold uppercase text-slate-500 mb-2">Données Borne (OCPP)</h4>
|
||||
<div className="space-y-1 text-sm font-mono">
|
||||
<div className="flex justify-between"><span className="text-slate-500">Transaction ID:</span> <span className="font-bold">{cdr.ocpp_transaction.transactionId}</span></div>
|
||||
<div className="flex justify-between"><span className="text-slate-500">Compteur Fin (Wh):</span> <span className="font-bold">{cdr.ocpp_transaction.meterStop}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h4 className="text-xs font-bold uppercase text-slate-500 mb-2">Facture eMSP (CDR OCPI)</h4>
|
||||
<div className="space-y-1 text-sm font-mono">
|
||||
<div className="flex justify-between"><span className="text-slate-500">CDR ID:</span> <span className="font-bold">{cdr.ocpi_cdr.id}</span></div>
|
||||
<div className="flex justify-between"><span className="text-slate-500">Énergie (kWh):</span> <span className="font-bold">{cdr.ocpi_cdr.total_energy_kwh.toFixed(2)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-slate-500">Coût (€):</span> <span className="font-bold">{cdr.ocpi_cdr.total_cost.toFixed(2)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h4 className="text-xs font-bold uppercase text-amber-700 mb-2">Analyse des Écarts</h4>
|
||||
<div className="flex items-center justify-between bg-white p-2 rounded border border-amber-100">
|
||||
<span className="text-sm text-slate-700">Frais de parking appliqués par l'eMSP</span>
|
||||
<span className="text-sm font-bold text-amber-600">+{cdr.delta.cost_diff.toFixed(2)} €</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/CommissioningAssistant.jsx
Normal file
51
frontend/src/components/CommissioningAssistant.jsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function CommissioningAssistant({ frames, status }) {
|
||||
const steps = [
|
||||
{ id: 'connect', label: 'Connectivité WebSocket', check: () => status.csms === 'active' || status.csms === 'loading' },
|
||||
{ id: 'boot', label: 'BootNotification acceptée', check: () => frames.some(f => f.action === 'BootNotification' && f.direction === 'OUT') },
|
||||
{ id: 'status', label: 'Statut Available reçu', check: () => frames.some(f => f.action === 'StatusNotification') },
|
||||
{ id: 'auth', label: 'Badge de test autorisé', check: () => frames.some(f => f.action === 'Authorize') },
|
||||
{ id: 'tx', label: 'Transaction démarrée', check: () => frames.some(f => f.action === 'StartTransaction') },
|
||||
{ id: 'meter', label: 'MeterValues cohérentes', check: () => frames.some(f => f.action === 'MeterValues') },
|
||||
];
|
||||
|
||||
const completed = steps.filter(s => s.check()).length;
|
||||
const progress = (completed / steps.length) * 100;
|
||||
const isDone = completed === steps.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white rounded-lg border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-slate-200 bg-slate-50">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Assistant de Mise en Service</h3>
|
||||
</div>
|
||||
<div className="p-4 flex flex-col h-full">
|
||||
<div className="w-full bg-slate-200 rounded-full h-2.5 mb-4">
|
||||
<div className={`h-2.5 rounded-full transition-all duration-500 ${isDone ? 'bg-emerald-500' : 'bg-blue-600'}`} style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
<div className="space-y-2 flex-1">
|
||||
{steps.map(step => {
|
||||
const ok = step.check();
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ${ok ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-400'}`}>
|
||||
{ok ? '✓' : '•'}
|
||||
</div>
|
||||
<span className={`text-sm ${ok ? 'text-slate-900 font-medium' : 'text-slate-400'}`}>{step.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isDone ? (
|
||||
<div className="mt-4 p-2 bg-emerald-50 border border-emerald-200 rounded text-xs text-emerald-700 font-bold text-center">
|
||||
Mise en service conforme. Autorisation de mise en production accordée.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 p-2 bg-slate-50 border border-slate-200 rounded text-xs text-slate-500 text-center">
|
||||
En attente des étapes de validation automatique...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/ControlPanel.jsx
Normal file
45
frontend/src/components/ControlPanel.jsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function ControlPanel({ cpId }) {
|
||||
const [response, setResponse] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const sendCommand = async (action, payload = {}) => {
|
||||
setLoading(true); setResponse(null);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/chargepoints/${cpId}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, payload }) });
|
||||
const data = await res.json();
|
||||
setResponse(data);
|
||||
} catch (e) { setResponse({ error: e.message }); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const generateReport = () => {
|
||||
window.open(`http://localhost:8000/api/chargepoints/${cpId}/report`, '_blank');
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
{ label: "Trigger Status", action: "TriggerMessage", payload: { requested_message: "StatusNotification", connector_id: 0 } },
|
||||
{ label: "Get Config", action: "GetConfiguration", payload: { key: [] } },
|
||||
{ label: "Remote Start", action: "RemoteStartTransaction", payload: {} },
|
||||
{ label: "Soft Reset", action: "Reset", payload: {} },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 p-3 rounded-lg border border-slate-700 mt-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-2">Commandes CSMS</h3>
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
{buttons.map((btn) => (
|
||||
<button key={btn.label} onClick={() => sendCommand(btn.action, btn.payload)} className="px-2 py-1.5 rounded text-[11px] font-semibold bg-slate-700 text-slate-200 hover:bg-slate-600 border border-slate-600">{btn.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button onClick={generateReport} className="w-full mt-2 px-2 py-1.5 rounded text-[11px] font-semibold bg-blue-600 text-white hover:bg-blue-700 border border-blue-500">
|
||||
📄 Générer Rapport HTML
|
||||
</button>
|
||||
|
||||
{loading && <p className="text-[10px] text-slate-500 mt-2 animate-pulse">Envoi...</p>}
|
||||
{response && <div className="mt-2 p-2 bg-slate-950 rounded text-[10px] font-mono text-slate-300 max-h-24 overflow-y-auto border border-slate-700">{response.error ? <span className="text-red-400">Erreur: {response.error}</span> : JSON.stringify(response.response, null, 2)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/FleetView.jsx
Normal file
34
frontend/src/components/FleetView.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
export default function FleetView({ onSelectCp }) {
|
||||
const [cps, setCps] = useState([]);
|
||||
useEffect(() => {
|
||||
const fetchCps = async () => { try { const res = await fetch('/api/chargepoints'); if (res.ok) setCps(await res.json()); } catch (e) {} };
|
||||
fetchCps();
|
||||
const interval = setInterval(fetchCps, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white rounded-lg border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-2 border-b border-slate-200 bg-slate-50"><h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Vue Parc (Multi-bornes)</h3></div>
|
||||
<div className="flex-1 p-4 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-slate-200 text-slate-500"><th className="text-left py-2 px-2 font-medium">ID Borne</th><th className="text-left py-2 px-2 font-medium">Modèle</th><th className="text-left py-2 px-2 font-medium">Statut</th><th className="text-left py-2 px-2 font-medium">Trames</th><th className="text-left py-2 px-2 font-medium">Action</th></tr></thead>
|
||||
<tbody>
|
||||
{cps.length === 0 && <tr><td colSpan="5" className="text-center py-4 text-slate-400 italic">Aucune borne connectée.</td></tr>}
|
||||
{cps.map((cp, i) => (
|
||||
<tr key={i} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-2 px-2 font-mono text-blue-600 font-semibold">{cp.id}</td>
|
||||
<td className="py-2 px-2 text-slate-600">{cp.vendor} {cp.model}</td>
|
||||
<td className="py-2 px-2"><span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${cp.status === 'Connected' || cp.status === 'Available' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'}`}>{cp.status}</span></td>
|
||||
<td className="py-2 px-2 font-mono text-slate-500">{cp.msg_count}</td>
|
||||
<td className="py-2 px-2"><button onClick={() => onSelectCp(cp.id)} className="px-2 py-1 bg-blue-600 text-white rounded text-[10px] hover:bg-blue-700">Diagnostiquer</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
frontend/src/components/NetworkScene.jsx
Normal file
165
frontend/src/components/NetworkScene.jsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import React from 'react';
|
||||
|
||||
const stateClass = (state) => {
|
||||
switch (state) {
|
||||
case 'active': return 'stroke-emerald-500 fill-emerald-50 text-emerald-700';
|
||||
case 'error': return 'stroke-red-500 fill-red-50 text-red-700';
|
||||
case 'loading': return 'stroke-amber-500 fill-amber-50 text-amber-700';
|
||||
default: return 'stroke-slate-400 fill-slate-50 text-slate-500';
|
||||
}
|
||||
};
|
||||
|
||||
const dotClass = (state) => {
|
||||
switch (state) {
|
||||
case 'active': return 'fill-emerald-500';
|
||||
case 'error': return 'fill-red-500';
|
||||
case 'loading': return 'fill-amber-500 animate-pulse';
|
||||
default: return 'fill-slate-300';
|
||||
}
|
||||
};
|
||||
|
||||
export default function NetworkScene({ status, onInterceptClick, midValue, config }) {
|
||||
const isPV = config.source.includes("PV");
|
||||
const isMono = config.raccordement.includes("mono");
|
||||
const powerLabel = isMono ? "230V AC Mono" : "400V AC Tri";
|
||||
|
||||
// Icône de communication
|
||||
const commIcon = config.comm_type.includes("SIM") ? "📶" : config.comm_type.includes("WiFi") ? "📡" : "🔌";
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-4">
|
||||
<svg viewBox="0 0 1400 620" preserveAspectRatio="xMidYMid meet" className="w-full h-full font-mono">
|
||||
<defs>
|
||||
<style>{`
|
||||
.dash-anim { stroke-dasharray: 8 4; animation: dash 1s linear infinite; }
|
||||
@keyframes dash { to { stroke-dashoffset: -12; } }
|
||||
.intercept-group:hover .halo { opacity: 0.4; r: 18; }
|
||||
.halo { transition: all 0.2s ease; opacity: 0; }
|
||||
`}</style>
|
||||
</defs>
|
||||
|
||||
<g id="legend" transform="translate(20, 580)">
|
||||
<line x1="0" y1="0" x2="30" y2="0" stroke="#f59e0b" strokeWidth="8" strokeLinecap="round" />
|
||||
<text x="40" y="4" fontSize="11" fill="#334155" className="font-sans">Flux Puissance (AC)</text>
|
||||
<line x1="180" y1="0" x2="210" y2="0" stroke="#3b82f6" strokeWidth="2" strokeDasharray="8 4" />
|
||||
<text x="220" y="4" fontSize="11" fill="#334155" className="font-sans">Flux Données (OCPP/OCPI)</text>
|
||||
</g>
|
||||
|
||||
<g id="flux-power">
|
||||
<path d="M 160 140 L 480 140" fill="none" stroke="#f59e0b" strokeWidth="8" strokeLinecap="round" />
|
||||
<circle cx="160" cy="140" r="6" fill="#f59e0b" />
|
||||
<circle cx="480" cy="140" r="6" fill="#f59e0b" />
|
||||
</g>
|
||||
|
||||
{/* 1. SOURCE ÉNERGIE */}
|
||||
<g id="enedis-tableau" transform="translate(40, 100)">
|
||||
<rect x="0" y="0" width="160" height="220" rx="4" className={`${stateClass(status.grid)} stroke-[2.5]`} />
|
||||
<text x="80" y="-10" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">
|
||||
{isPV ? "Onduleur PV" : "Enedis / Tableau"}
|
||||
</text>
|
||||
|
||||
{isPV ? (
|
||||
<>
|
||||
<rect x="20" y="20" width="120" height="60" rx="2" className="fill-white stroke-slate-300 stroke-[1.5]" />
|
||||
<text x="80" y="45" fontSize="9" textAnchor="middle" className="fill-slate-500">Panneaux PV</text>
|
||||
<text x="80" y="65" fontSize="11" fontWeight="700" textAnchor="middle" className="fill-slate-800">{config.puissance_souscrite} kVA</text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<rect x="20" y="20" width="120" height="60" rx="2" className="fill-white stroke-slate-300 stroke-[1.5]" />
|
||||
<text x="80" y="45" fontSize="9" textAnchor="middle" className="fill-slate-500">Compteur Linky</text>
|
||||
<text x="80" y="65" fontSize="11" fontWeight="700" textAnchor="middle" className="fill-slate-800">{powerLabel} ({config.puissance_souscrite}kVA)</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<rect x="20" y="100" width="120" height="40" rx="2" className="fill-slate-100 stroke-slate-300 stroke-[1.5]" />
|
||||
<line x1="40" y1="110" x2="40" y2="130" stroke="#334155" strokeWidth="1.5" />
|
||||
<line x1="120" y1="110" x2="120" y2="130" stroke="#334155" strokeWidth="1.5" />
|
||||
<text x="80" y="155" fontSize="9" textAnchor="middle" className="fill-slate-500">AGCP {config.disj_calibre}A — diff. 500mA</text>
|
||||
</g>
|
||||
|
||||
{/* 2. COFFRET AC */}
|
||||
<g id="coffret-ac" transform="translate(240, 100)">
|
||||
<title>{config.rdc_dd ? "RDC-DD 6mA actif: Type A suffisant." : "Type B obligatoire (courant continu)."}</title>
|
||||
<rect x="0" y="0" width="160" height="220" rx="4" className={`${stateClass(status.box)} stroke-[2.5]`} />
|
||||
<text x="80" y="-10" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">Coffret AC</text>
|
||||
|
||||
<rect x="20" y="20" width="120" height="50" rx="2" className="fill-white stroke-slate-300 stroke-[1.5]" />
|
||||
<text x="80" y="40" fontSize="9" textAnchor="middle" className="fill-slate-500">Différentiel 30mA</text>
|
||||
<text x="80" y="55" fontSize="11" fontWeight="700" textAnchor="middle" className="fill-slate-800">{config.rdc_dd && config.diff_type === "Type B" ? "Type A (RDC-DD)" : config.diff_type}</text>
|
||||
|
||||
<rect x="20" y="90" width="120" height="40" rx="2" className="fill-slate-100 stroke-slate-300 stroke-[1.5]" />
|
||||
<text x="80" y="115" fontSize="9" textAnchor="middle" className="fill-slate-500">Disj. Courbe C {config.disj_calibre}A</text>
|
||||
</g>
|
||||
|
||||
{/* 3. BORNE EVSE */}
|
||||
<g id="evse-cutaway" transform="translate(460, 80)">
|
||||
<rect x="0" y="0" width="380" height="280" rx="4" className={`${stateClass(status.evse)} stroke-[2.5]`} />
|
||||
<text x="190" y="-10" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">{config.borne_fabricant} - {config.borne_puissance}kW</text>
|
||||
|
||||
<g id="evse-contactor">
|
||||
<rect x="20" y="20" width="340" height="50" rx="2" className={`${stateClass(status.evse)} stroke-[1.5]`} />
|
||||
<text x="30" y="40" fontSize="9" className="fill-slate-600">Contacteur de Puissance</text>
|
||||
<circle cx="320" cy="45" r="4" className={dotClass(status.evse)} />
|
||||
</g>
|
||||
|
||||
<g id="evse-mid">
|
||||
<rect x="20" y="80" width="340" height="50" rx="2" className={`${stateClass(status.evse)} stroke-[1.5]`} />
|
||||
<text x="30" y="100" fontSize="9" className="fill-slate-600">Compteur d'énergie (MID)</text>
|
||||
<text x="340" y="110" fontSize="11" textAnchor="end" className="fill-slate-700 font-bold">{midValue} kWh</text>
|
||||
</g>
|
||||
|
||||
<g id="evse-mcu">
|
||||
<rect x="20" y="140" width="340" height="50" rx="2" className={`${stateClass(status.evse)} stroke-[1.5]`} />
|
||||
<text x="30" y="160" fontSize="9" className="fill-slate-600">Micro-contrôleur (OCPP {config.csms_version})</text>
|
||||
<rect x="280" y="150" width="60" height="20" rx="2" className="fill-slate-200 stroke-slate-400 stroke-[1]" />
|
||||
</g>
|
||||
|
||||
<g id="evse-comm">
|
||||
<rect x="20" y="200" width="340" height="50" rx="2" className={`${stateClass(status.box)} stroke-[1.5]`} />
|
||||
<text x="30" y="220" fontSize="9" className="fill-slate-600">Module Comms: {config.comm_type}</text>
|
||||
<text x="300" y="230" fontSize="14" className="fill-slate-700">{commIcon}</text>
|
||||
</g>
|
||||
|
||||
<g id="evse-leds" transform="translate(20, 260)">
|
||||
<circle cx="10" cy="10" r="4" className={dotClass(status.evse)} />
|
||||
<circle cx="25" cy="10" r="4" className="fill-slate-200" />
|
||||
<circle cx="40" cy="10" r="4" className="fill-slate-200" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g id="csms-cloud" transform="translate(920, 200)">
|
||||
<path d="M 50 0 C 20 0 10 30 30 40 C 10 60 40 80 60 70 C 70 90 110 90 120 70 C 150 80 170 50 140 40 C 160 20 130 0 100 10 C 90 -10 60 -10 50 0 Z"
|
||||
className={`${stateClass(status.csms)} stroke-[2.5]`} />
|
||||
<text x="85" y="45" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">CSMS</text>
|
||||
<text x="85" y="60" fontSize="9" textAnchor="middle" className="fill-slate-500">{config.csms_auth}</text>
|
||||
</g>
|
||||
|
||||
<g id="roaming-hub" transform="translate(1180, 200)">
|
||||
<path d="M 50 0 C 20 0 10 30 30 40 C 10 60 40 80 60 70 C 70 90 110 90 120 70 C 150 80 170 50 140 40 C 160 20 130 0 100 10 C 90 -10 60 -10 50 0 Z"
|
||||
className={`${stateClass(status.roaming)} stroke-[2.5]`} />
|
||||
<text x="85" y="45" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">Roaming</text>
|
||||
<text x="85" y="60" fontSize="9" textAnchor="middle" className="fill-slate-500">Gireve / eMSP</text>
|
||||
</g>
|
||||
|
||||
<g id="flux-data">
|
||||
<path id="link-ocpp" d="M 820 230 L 820 160 L 950 160 L 950 220" fill="none" stroke="#3b82f6" strokeWidth="2" className="dash-anim" />
|
||||
<path id="link-ocpi" d="M 1050 240 L 1100 240 L 1100 240 L 1180 240" fill="none" stroke="#3b82f6" strokeWidth="2" className="dash-anim" />
|
||||
</g>
|
||||
|
||||
<g className="intercept-group cursor-pointer" onClick={() => onInterceptClick('ocpp')}>
|
||||
<circle cx="885" cy="160" r="12" className="halo fill-blue-500" />
|
||||
<circle cx="885" cy="160" r="8" className="fill-white stroke-blue-600 stroke-[2]" />
|
||||
<text x="885" y="163" fontSize="8" textAnchor="middle" className="font-sans fill-blue-600 font-bold">OCPP</text>
|
||||
</g>
|
||||
|
||||
<g className="intercept-group cursor-pointer" onClick={() => onInterceptClick('ocpi')}>
|
||||
<circle cx="1115" cy="240" r="12" className="halo fill-blue-500" />
|
||||
<circle cx="1115" cy="240" r="8" className="fill-white stroke-blue-600 stroke-[2]" />
|
||||
<text x="1115" y="243" fontSize="8" textAnchor="middle" className="font-sans fill-blue-600 font-bold">OCPI</text>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
frontend/src/components/SnifferPanel.jsx
Normal file
207
frontend/src/components/SnifferPanel.jsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export default function SnifferPanel({ cpId, activeTab }) {
|
||||
const [frames, setFrames] = useState([]);
|
||||
const [wsStatus, setWsStatus] = useState('disconnected');
|
||||
const [isReplaying, setIsReplaying] = useState(false);
|
||||
const [ocpiLogs] = useState([
|
||||
{ method: 'GET', endpoint: '/ocpi/2.2.1/tokens/CHARGEMAP_PASS', status: 200, latency: '820ms', payload: { valid: true } },
|
||||
{ method: 'POST', endpoint: '/ocpi/2.2.1/sessions/SESS-99', status: 201, latency: '450ms', payload: { id: 'SESS-99', status: 'ACTIVE' } }
|
||||
]);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
const wsRef = useRef(null);
|
||||
const reconnectTimer = useRef(null);
|
||||
const backoff = useRef(1000);
|
||||
const replayTimer = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:8000/api/chargepoints/${cpId || 'SIM-001'}/frames`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFrames(data.reverse().slice(0, 50));
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isReplaying) return;
|
||||
|
||||
let isClosedByUser = false;
|
||||
|
||||
const connect = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
|
||||
const ws = new WebSocket(`ws://localhost:8000/api/live`);
|
||||
wsRef.current = ws;
|
||||
setWsStatus('connecting');
|
||||
|
||||
ws.onopen = () => { backoff.current = 1000; setWsStatus('connected'); fetchHistory(); };
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const frame = JSON.parse(e.data);
|
||||
if (!cpId || frame.cp_id === cpId) {
|
||||
setFrames(prev => {
|
||||
if (prev.some(f => f.uniqueId === frame.uniqueId && f.direction === frame.direction && f.timestamp === frame.timestamp)) {
|
||||
console.warn(`[Sniffer] Doublon écarté: ${frame.action} ${frame.uniqueId}`);
|
||||
return prev;
|
||||
}
|
||||
return [frame, ...prev].slice(0, 100);
|
||||
});
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
setWsStatus('disconnected');
|
||||
if (!isClosedByUser) {
|
||||
setWsStatus('reconnecting');
|
||||
reconnectTimer.current = setTimeout(() => {
|
||||
backoff.current = Math.min(backoff.current * 2, 10000);
|
||||
connect();
|
||||
}, backoff.current);
|
||||
}
|
||||
};
|
||||
};
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isClosedByUser = true;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) wsRef.current.close();
|
||||
};
|
||||
}, [cpId, isReplaying]);
|
||||
|
||||
const handleExport = () => {
|
||||
const blob = new Blob([JSON.stringify(frames, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ocpp_session_${cpId || 'export'}.json`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
const handlePurge = async () => {
|
||||
if (window.confirm(`Purger l'historique de ${cpId} ?`)) {
|
||||
try {
|
||||
await fetch(`http://localhost:8000/api/chargepoints/${cpId}/frames`, { method: 'DELETE' });
|
||||
setFrames([]);
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => fileInputRef.current?.click();
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const importedFrames = JSON.parse(event.target.result);
|
||||
if (Array.isArray(importedFrames)) {
|
||||
setIsReplaying(true);
|
||||
setFrames([]);
|
||||
let i = 0;
|
||||
replayTimer.current = setInterval(() => {
|
||||
if (i < importedFrames.length) {
|
||||
setFrames(prev => [importedFrames[i], ...prev].slice(0, 100));
|
||||
i++;
|
||||
} else {
|
||||
clearInterval(replayTimer.current);
|
||||
setIsReplaying(false);
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Fichier JSON invalide");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = null;
|
||||
};
|
||||
|
||||
const filteredFrames = frames.filter(f =>
|
||||
(!filter || String(f.action).toLowerCase().includes(filter.toLowerCase()))
|
||||
);
|
||||
|
||||
const statusColors = {
|
||||
connected: 'bg-emerald-500', connecting: 'bg-amber-500 animate-pulse',
|
||||
reconnecting: 'bg-amber-500 animate-pulse', disconnected: 'bg-red-500'
|
||||
};
|
||||
|
||||
const formatDate = (ts) => {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-slate-900 text-slate-200 font-mono text-xs overflow-hidden">
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept=".json" className="hidden" />
|
||||
<div className="p-2 border-b border-slate-700 flex justify-between items-center bg-slate-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${isReplaying ? 'bg-purple-500 animate-pulse' : statusColors[wsStatus]}`}></span>
|
||||
<span>{isReplaying ? 'Mode Replay' : 'Sniffer'} {cpId ? `(${cpId})` : ''} - {activeTab.toUpperCase()}</span>
|
||||
</div>
|
||||
{activeTab === 'ocpp' && (
|
||||
<div className="flex gap-1 items-center">
|
||||
<input type="text" placeholder="Filtrer..." value={filter} onChange={(e) => setFilter(e.target.value)} className="bg-slate-700 text-slate-200 px-2 py-1 rounded border border-slate-600 outline-none focus:border-blue-500 w-20" />
|
||||
<button onClick={handlePurge} className="px-2 py-1 bg-red-900 hover:bg-red-800 rounded text-[10px] uppercase">Purger</button>
|
||||
<button onClick={handleImportClick} className="px-2 py-1 bg-purple-700 hover:bg-purple-600 rounded text-[10px] uppercase">Replay</button>
|
||||
<button onClick={handleExport} className="px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-[10px] uppercase">Export</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'ocpp' ? (
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
|
||||
{filteredFrames.length === 0 && (
|
||||
<div className="text-slate-500 italic p-4 text-center">
|
||||
{isReplaying ? 'Rejeu en cours...' : wsStatus === 'connected' ? `En attente de trames pour ${cpId}...` : `Sniffer ${wsStatus}...`}
|
||||
</div>
|
||||
)}
|
||||
{filteredFrames.map((f, i) => (
|
||||
<div key={i} className={`border rounded p-1.5 hover:bg-slate-800 cursor-pointer ${f.schema_error ? 'border-red-500 bg-red-950/30' : 'border-slate-700'}`} onClick={() => setExpanded(expanded === i ? null : i)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className={f.direction === 'IN' ? 'text-emerald-400' : 'text-blue-400'}>{f.direction === 'IN' ? 'IN' : 'OUT'}</span>
|
||||
<span className="font-semibold text-slate-300">{String(f.action)}</span>
|
||||
{f.schema_error && <span className="text-red-500 text-[9px] font-bold ml-1">SCHEMA</span>}
|
||||
</div>
|
||||
<span className="text-slate-500 text-[10px]">{formatDate(f.timestamp)}</span>
|
||||
</div>
|
||||
{expanded === i && (
|
||||
<pre className="mt-1 text-[10px] text-slate-400 bg-slate-950 p-1 rounded overflow-x-auto max-h-40 overflow-y-auto">
|
||||
{JSON.stringify(f.payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
|
||||
<div className="text-slate-500 italic p-2 text-center text-[10px]">Flux REST CPO - Roaming Hub. Simulé.</div>
|
||||
{ocpiLogs.map((log, i) => (
|
||||
<div key={i} className="border border-slate-700 rounded p-1.5 hover:bg-slate-800 cursor-pointer" onClick={() => setExpanded(expanded === i ? null : i)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-purple-400 font-bold">{log.method}</span>
|
||||
<span className="text-slate-300">{log.endpoint}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-amber-400">{log.latency}</span>
|
||||
<span className="text-emerald-400 font-bold">{log.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
{expanded === i && <pre className="mt-1 text-[10px] text-slate-400 bg-slate-950 p-1 rounded overflow-x-auto">{JSON.stringify(log.payload, null, 2)}</pre>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
frontend/src/index.css
Normal file
11
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@keyframes flow {
|
||||
to { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.animate-flow {
|
||||
background-image: linear-gradient(90deg, transparent 0%, #3b82f6 50%, transparent 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: flow 1.5s linear infinite;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
6
frontend/tailwind.config.js
Normal file
6
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
17
frontend/vite.config.js
Normal file
17
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['localhost', '0.0.0.0', '127.0.0.1'],
|
||||
proxy: {
|
||||
'/api': 'http://backend:8000',
|
||||
'/ocpp': {
|
||||
target: 'ws://backend:8000',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
40
nginx/nginx.conf
Normal file
40
nginx/nginx.conf
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
events {}
|
||||
http {
|
||||
upstream backend { server backend:8000; }
|
||||
upstream frontend { server frontend:5173; }
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
location /ocpp/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
simulator/Dockerfile
Normal file
5
simulator/Dockerfile
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY charge_point_sim.py .
|
||||
RUN pip install websockets ocpp==2.0.0rc1
|
||||
CMD ["python", "charge_point_sim.py"]
|
||||
132
simulator/charge_point_sim.py
Normal file
132
simulator/charge_point_sim.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import asyncio
|
||||
import websockets
|
||||
import argparse
|
||||
import random
|
||||
from ocpp.routing import on
|
||||
from ocpp.v16 import ChargePoint as cp
|
||||
from ocpp.v16 import call
|
||||
from ocpp.v16 import call_result
|
||||
from ocpp.v16.enums import Action
|
||||
|
||||
class SimulatorChargePoint(cp):
|
||||
def __init__(self, id, connection, vendor, model):
|
||||
super().__init__(id, connection)
|
||||
self.vendor = vendor
|
||||
self.model = model
|
||||
|
||||
# --- Handlers pour les commandes CSMS -> Borne ---
|
||||
@on(Action.GetConfiguration)
|
||||
async def on_get_config(self, **kwargs):
|
||||
return call_result.GetConfiguration(
|
||||
configuration_key=[
|
||||
{"key": "HeartbeatInterval", "value": "30", "readonly": False},
|
||||
{"key": "WebSocketPingInterval", "value": "60", "readonly": False}
|
||||
],
|
||||
unknown_key=[]
|
||||
)
|
||||
|
||||
@on(Action.RemoteStartTransaction)
|
||||
async def on_remote_start(self, **kwargs):
|
||||
return call_result.RemoteStartTransaction(status="Accepted")
|
||||
|
||||
@on(Action.RemoteStopTransaction)
|
||||
async def on_remote_stop(self, **kwargs):
|
||||
return call_result.RemoteStopTransaction(status="Accepted")
|
||||
|
||||
@on(Action.Reset)
|
||||
async def on_reset(self, **kwargs):
|
||||
return call_result.Reset(status="Accepted")
|
||||
|
||||
@on(Action.TriggerMessage)
|
||||
async def on_trigger(self, **kwargs):
|
||||
return call_result.TriggerMessage(status="Accepted")
|
||||
|
||||
@on(Action.ChangeConfiguration)
|
||||
async def on_change_config(self, **kwargs):
|
||||
return call_result.ChangeConfiguration(status="Accepted")
|
||||
|
||||
@on(Action.UnlockConnector)
|
||||
async def on_unlock(self, **kwargs):
|
||||
return call_result.UnlockConnector(status="Unlocked")
|
||||
|
||||
# --- Tâche de fond : Heartbeat ---
|
||||
async def send_heartbeat(self):
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
await self.call(call.Heartbeat())
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
break
|
||||
|
||||
# --- Cycle de vie continu (Voitures qui arrivent et repartent) ---
|
||||
async def run_lifecycle(self):
|
||||
# 1. Boot initial
|
||||
await self.call(call.BootNotification(charge_point_vendor=self.vendor, charge_point_model=self.model))
|
||||
await self.call(call.StatusNotification(connector_id=1, error_code="NoError", status="Available"))
|
||||
print(f"[{self.id}] Borne en ligne. En attente de véhicules...")
|
||||
|
||||
while True:
|
||||
# Temps d'attente avant l'arrivée d'une voiture (5 à 15 secondes)
|
||||
wait_time = random.randint(5, 15)
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# 2. Arrivée d'un véhicule
|
||||
await self.call(call.StatusNotification(connector_id=1, error_code="NoError", status="Preparing"))
|
||||
auth = await self.call(call.Authorize(id_tag="CHARGEMAP_PASS"))
|
||||
|
||||
if auth.id_tag_info["status"] == "Accepted":
|
||||
tx = await self.call(call.StartTransaction(
|
||||
connector_id=1, id_tag="CHARGEMAP_PASS", meter_start=0,
|
||||
timestamp="2026-08-11T14:00:00Z"
|
||||
))
|
||||
await self.call(call.StatusNotification(connector_id=1, error_code="NoError", status="Charging"))
|
||||
|
||||
# 3. Cycle de charge (3 à 6 envois de MeterValues)
|
||||
consumption = 0
|
||||
charge_cycles = random.randint(3, 6)
|
||||
for _ in range(charge_cycles):
|
||||
consumption += random.randint(500, 1500)
|
||||
await self.call(call.MeterValues(
|
||||
connector_id=1,
|
||||
transaction_id=tx.transaction_id,
|
||||
meter_value=[{"timestamp": "2026-08-11T14:00:00Z", "sampled_value": [{"value": str(consumption)}]}]
|
||||
))
|
||||
await asyncio.sleep(3) # Recharge de 3s
|
||||
|
||||
# 4. Départ du véhicule
|
||||
await self.call(call.StopTransaction(
|
||||
transaction_id=tx.transaction_id, meter_stop=consumption,
|
||||
timestamp="2026-08-11T14:01:00Z"
|
||||
))
|
||||
await self.call(call.StatusNotification(connector_id=1, error_code="NoError", status="Finishing"))
|
||||
await asyncio.sleep(2)
|
||||
await self.call(call.StatusNotification(connector_id=1, error_code="NoError", status="Available"))
|
||||
else:
|
||||
# Badge refusé
|
||||
await self.call(call.StatusNotification(connector_id=1, error_code="NoError", status="SuspendedEV"))
|
||||
await asyncio.sleep(5)
|
||||
await self.call(call.StatusNotification(connector_id=1, error_code="NoError", status="Available"))
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", default="ws://backend:8000/ocpp")
|
||||
parser.add_argument("--cp-id", default="SIM-001")
|
||||
parser.add_argument("--vendor", default="Tesla")
|
||||
parser.add_argument("--model", default="Model3")
|
||||
args = parser.parse_args()
|
||||
|
||||
full_url = f"{args.url}/{args.cp_id}"
|
||||
print(f"Connexion à {full_url}...")
|
||||
|
||||
async with websockets.connect(full_url, subprotocols=["ocpp1.6"]) as ws:
|
||||
cp = SimulatorChargePoint(args.cp_id, ws, args.vendor, args.model)
|
||||
|
||||
# On lance l'écoute CSMS, les Heartbeats et le cycle de vie en parallèle
|
||||
await asyncio.gather(
|
||||
cp.start(),
|
||||
cp.send_heartbeat(),
|
||||
cp.run_lifecycle()
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue