From 192be86487a76b37449dd71039dc0900019efd3f Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Tue, 25 Aug 2026 19:08:15 +0200 Subject: [PATCH] Initial commit: Check-My-IRVE SaaS (OCPP 1.6, Audit, Diagnostics) --- .gitignore | 5 + backend/Dockerfile | 6 + backend/app/__init__.py | 0 backend/app/core/__init__.py | 0 backend/app/core/diagnostics.py | 99 ++++++++ backend/app/core/ocpp_schemas.py | 39 +++ backend/app/core/session_manager.py | 156 ++++++++++++ backend/app/main.py | 179 +++++++++++++ backend/app/ocpp/__init__.py | 0 backend/app/ocpp/router.py | 58 +++++ backend/requirements.txt | 7 + backend/tests/test_diagnostics.py | 36 +++ deploy_aws.sh | 42 +++ docker-compose.yml | 22 ++ frontend/.dockerignore | 3 + frontend/.gitignore | 24 ++ frontend/.oxlintrc.json | 8 + frontend/Dockerfile | 7 + frontend/README.md | 16 ++ frontend/index.html | 13 + frontend/package.json | 19 ++ frontend/postcss.config.js | 5 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 ++ frontend/src/App.css | 184 ++++++++++++++ frontend/src/App.jsx | 240 ++++++++++++++++++ frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/components/AnalysisPanel.jsx | 61 +++++ frontend/src/components/AuditPanel.jsx | 50 ++++ frontend/src/components/CdrView.jsx | 41 +++ .../src/components/CommissioningAssistant.jsx | 51 ++++ frontend/src/components/ControlPanel.jsx | 45 ++++ frontend/src/components/FleetView.jsx | 34 +++ frontend/src/components/NetworkScene.jsx | 165 ++++++++++++ frontend/src/components/SnifferPanel.jsx | 207 +++++++++++++++ frontend/src/index.css | 11 + frontend/src/main.jsx | 10 + frontend/tailwind.config.js | 6 + frontend/vite.config.js | 17 ++ nginx/nginx.conf | 40 +++ simulator/Dockerfile | 5 + simulator/charge_point_sim.py | 132 ++++++++++ 44 files changed, 2070 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/diagnostics.py create mode 100644 backend/app/core/ocpp_schemas.py create mode 100644 backend/app/core/session_manager.py create mode 100644 backend/app/main.py create mode 100644 backend/app/ocpp/__init__.py create mode 100644 backend/app/ocpp/router.py create mode 100644 backend/requirements.txt create mode 100644 backend/tests/test_diagnostics.py create mode 100755 deploy_aws.sh create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/AnalysisPanel.jsx create mode 100644 frontend/src/components/AuditPanel.jsx create mode 100644 frontend/src/components/CdrView.jsx create mode 100644 frontend/src/components/CommissioningAssistant.jsx create mode 100644 frontend/src/components/ControlPanel.jsx create mode 100644 frontend/src/components/FleetView.jsx create mode 100644 frontend/src/components/NetworkScene.jsx create mode 100644 frontend/src/components/SnifferPanel.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 nginx/nginx.conf create mode 100644 simulator/Dockerfile create mode 100644 simulator/charge_point_sim.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28a850d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +__pycache__ +.env +*.log +.vscode diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a8673b8 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/diagnostics.py b/backend/app/core/diagnostics.py new file mode 100644 index 0000000..23e8094 --- /dev/null +++ b/backend/app/core/diagnostics.py @@ -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 diff --git a/backend/app/core/ocpp_schemas.py b/backend/app/core/ocpp_schemas.py new file mode 100644 index 0000000..d3d0d11 --- /dev/null +++ b/backend/app/core/ocpp_schemas.py @@ -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 diff --git a/backend/app/core/session_manager.py b/backend/app/core/session_manager.py new file mode 100644 index 0000000..7593995 --- /dev/null +++ b/backend/app/core/session_manager.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..78797da --- /dev/null +++ b/backend/app/main.py @@ -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""" + Rapport - {cp_id} + +

Rapport IRVE

Borne: {cp_id}

+

Anomalies

{''.join([f'
{f["title"]}
{f["action"]}
' for f in findings])} +

Trames

{json.dumps(frames[:20], indent=2)}
+ + """ + 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") diff --git a/backend/app/ocpp/__init__.py b/backend/app/ocpp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/ocpp/router.py b/backend/app/ocpp/router.py new file mode 100644 index 0000000..224c976 --- /dev/null +++ b/backend/app/ocpp/router.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ff55741 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/test_diagnostics.py b/backend/tests/test_diagnostics.py new file mode 100644 index 0000000..4da2c4a --- /dev/null +++ b/backend/tests/test_diagnostics.py @@ -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" diff --git a/deploy_aws.sh b/deploy_aws.sh new file mode 100755 index 0000000..5f8a4fb --- /dev/null +++ b/deploy_aws.sh @@ -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 "====================================" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..841ad42 --- /dev/null +++ b/docker-compose.yml @@ -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] diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a21f178 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.git diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..dd726eb --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d937833 --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f94d687 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bf88593 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..e627111 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {} + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -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); + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..4078f25 --- /dev/null +++ b/frontend/src/App.jsx @@ -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 = "" }) => ( + +); +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 ( +
+
+
+
IRVE
+

Check-My-IRVE Console

+
+
+ URL CSMS : + +
+
+ + +
+
+
+
+ +
+
+
+ + + + + +
+
+ {mainView === 'parc' && } + {mainView === 'topo' && { setActiveTab(id); setPanelOpen(true); }} midValue={midValue} config={config} />} + {mainView === 'audit' && } + {mainView === 'comm' && } + {mainView === 'cdr' && } +
+
+
+
terminal_logs.sh
+
{logs.map((log, i) =>
[{log.time}] {log.msg}
)}
+
+
+
+ {panelOpen && ( +
+
+ +
+
+
+ + +
+
+ +
+
+
+ )} +
+
+); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/AnalysisPanel.jsx b/frontend/src/components/AnalysisPanel.jsx new file mode 100644 index 0000000..4dc8a35 --- /dev/null +++ b/frontend/src/components/AnalysisPanel.jsx @@ -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 ( +
+
+

Analyse & Verdicts

+ {frameCount} trames +
+
+ {frameCount < 3 && findings.length === 0 &&
Observation en cours...
} + {frameCount >= 3 && findings.length === 0 &&
Aucune anomalie.
} + {findings.map((f, i) => { + const s = styles[f.severity]; + return ( +
+
+

{f.title}

+
+ {s.label} +
+
+

{f.explication}

+
Action: {f.action}
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/AuditPanel.jsx b/frontend/src/components/AuditPanel.jsx new file mode 100644 index 0000000..b5d393f --- /dev/null +++ b/frontend/src/components/AuditPanel.jsx @@ -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 ( +
+
+

Audit de Configuration

+ +
+
+ {!getConfigResponse &&

Cliquez sur Get Config.

} + {audit && ( + + + + {audit.map((row, i) => ( + + + + + + + ))} + +
CléBorneAttenduStatut
{row.key}{row.borne_val}{row.expected_val}{row.match ? OK : ÉCART}
+ )} +
+
+ ); +} diff --git a/frontend/src/components/CdrView.jsx b/frontend/src/components/CdrView.jsx new file mode 100644 index 0000000..699f777 --- /dev/null +++ b/frontend/src/components/CdrView.jsx @@ -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
Aucune transaction terminée trouvée.
; + return ( +
+
+
+

Données Borne (OCPP)

+
+
Transaction ID: {cdr.ocpp_transaction.transactionId}
+
Compteur Fin (Wh): {cdr.ocpp_transaction.meterStop}
+
+
+
+

Facture eMSP (CDR OCPI)

+
+
CDR ID: {cdr.ocpi_cdr.id}
+
Énergie (kWh): {cdr.ocpi_cdr.total_energy_kwh.toFixed(2)}
+
Coût (€): {cdr.ocpi_cdr.total_cost.toFixed(2)}
+
+
+
+
+

Analyse des Écarts

+
+ Frais de parking appliqués par l'eMSP + +{cdr.delta.cost_diff.toFixed(2)} € +
+
+
+ ); +} diff --git a/frontend/src/components/CommissioningAssistant.jsx b/frontend/src/components/CommissioningAssistant.jsx new file mode 100644 index 0000000..c6eee1f --- /dev/null +++ b/frontend/src/components/CommissioningAssistant.jsx @@ -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 ( +
+
+

Assistant de Mise en Service

+
+
+
+
+
+
+ {steps.map(step => { + const ok = step.check(); + return ( +
+
+ {ok ? '✓' : '•'} +
+ {step.label} +
+ ); + })} +
+ {isDone ? ( +
+ Mise en service conforme. Autorisation de mise en production accordée. +
+ ) : ( +
+ En attente des étapes de validation automatique... +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ControlPanel.jsx b/frontend/src/components/ControlPanel.jsx new file mode 100644 index 0000000..bfe9c98 --- /dev/null +++ b/frontend/src/components/ControlPanel.jsx @@ -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 ( +
+

Commandes CSMS

+
+ {buttons.map((btn) => ( + + ))} +
+ + + + {loading &&

Envoi...

} + {response &&
{response.error ? Erreur: {response.error} : JSON.stringify(response.response, null, 2)}
} +
+ ); +} diff --git a/frontend/src/components/FleetView.jsx b/frontend/src/components/FleetView.jsx new file mode 100644 index 0000000..151a77f --- /dev/null +++ b/frontend/src/components/FleetView.jsx @@ -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 ( +
+

Vue Parc (Multi-bornes)

+
+ + + + {cps.length === 0 && } + {cps.map((cp, i) => ( + + + + + + + + ))} + +
ID BorneModèleStatutTramesAction
Aucune borne connectée.
{cp.id}{cp.vendor} {cp.model}{cp.status}{cp.msg_count}
+
+
+ ); +} diff --git a/frontend/src/components/NetworkScene.jsx b/frontend/src/components/NetworkScene.jsx new file mode 100644 index 0000000..4705d34 --- /dev/null +++ b/frontend/src/components/NetworkScene.jsx @@ -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 ( +
+ + + + + + + + Flux Puissance (AC) + + Flux Données (OCPP/OCPI) + + + + + + + + + {/* 1. SOURCE ÉNERGIE */} + + + + {isPV ? "Onduleur PV" : "Enedis / Tableau"} + + + {isPV ? ( + <> + + Panneaux PV + {config.puissance_souscrite} kVA + + ) : ( + <> + + Compteur Linky + {powerLabel} ({config.puissance_souscrite}kVA) + + )} + + + + + AGCP {config.disj_calibre}A — diff. 500mA + + + {/* 2. COFFRET AC */} + + {config.rdc_dd ? "RDC-DD 6mA actif: Type A suffisant." : "Type B obligatoire (courant continu)."} + + Coffret AC + + + Différentiel 30mA + {config.rdc_dd && config.diff_type === "Type B" ? "Type A (RDC-DD)" : config.diff_type} + + + Disj. Courbe C {config.disj_calibre}A + + + {/* 3. BORNE EVSE */} + + + {config.borne_fabricant} - {config.borne_puissance}kW + + + + Contacteur de Puissance + + + + + + Compteur d'énergie (MID) + {midValue} kWh + + + + + Micro-contrôleur (OCPP {config.csms_version}) + + + + + + Module Comms: {config.comm_type} + {commIcon} + + + + + + + + + + + + CSMS + {config.csms_auth} + + + + + Roaming + Gireve / eMSP + + + + + + + + onInterceptClick('ocpp')}> + + + OCPP + + + onInterceptClick('ocpi')}> + + + OCPI + + + +
+ ); +} diff --git a/frontend/src/components/SnifferPanel.jsx b/frontend/src/components/SnifferPanel.jsx new file mode 100644 index 0000000..2de2420 --- /dev/null +++ b/frontend/src/components/SnifferPanel.jsx @@ -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 ( +
+ +
+
+ + {isReplaying ? 'Mode Replay' : 'Sniffer'} {cpId ? `(${cpId})` : ''} - {activeTab.toUpperCase()} +
+ {activeTab === 'ocpp' && ( +
+ 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" /> + + + +
+ )} +
+ + {activeTab === 'ocpp' ? ( +
+ {filteredFrames.length === 0 && ( +
+ {isReplaying ? 'Rejeu en cours...' : wsStatus === 'connected' ? `En attente de trames pour ${cpId}...` : `Sniffer ${wsStatus}...`} +
+ )} + {filteredFrames.map((f, i) => ( +
setExpanded(expanded === i ? null : i)}> +
+
+ {f.direction === 'IN' ? 'IN' : 'OUT'} + {String(f.action)} + {f.schema_error && SCHEMA} +
+ {formatDate(f.timestamp)} +
+ {expanded === i && ( +
+                  {JSON.stringify(f.payload, null, 2)}
+                
+ )} +
+ ))} +
+ ) : ( +
+
Flux REST CPO - Roaming Hub. Simulé.
+ {ocpiLogs.map((log, i) => ( +
setExpanded(expanded === i ? null : i)}> +
+
+ {log.method} + {log.endpoint} +
+
+ {log.latency} + {log.status} +
+
+ {expanded === i &&
{JSON.stringify(log.payload, null, 2)}
} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5bd5a59 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + , +) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..3fa8820 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,6 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { extend: {} }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..32241a0 --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } +}) diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..821eefa --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } + } +} diff --git a/simulator/Dockerfile b/simulator/Dockerfile new file mode 100644 index 0000000..0ca6b10 --- /dev/null +++ b/simulator/Dockerfile @@ -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"] diff --git a/simulator/charge_point_sim.py b/simulator/charge_point_sim.py new file mode 100644 index 0000000..a4cca90 --- /dev/null +++ b/simulator/charge_point_sim.py @@ -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())