commit 192be86487a76b37449dd71039dc0900019efd3f Author: Adam Belkacemi Date: Tue Aug 25 19:08:15 2026 +0200 Initial commit: Check-My-IRVE SaaS (OCPP 1.6, Audit, Diagnostics) 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 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ 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())