Initial commit: Check-My-IRVE SaaS (OCPP 1.6, Audit, Diagnostics)
This commit is contained in:
commit
192be86487
44 changed files with 2070 additions and 0 deletions
6
backend/Dockerfile
Normal file
6
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
99
backend/app/core/diagnostics.py
Normal file
99
backend/app/core/diagnostics.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import math
|
||||
|
||||
def rule_missing_status_after_boot(frames, ctx):
|
||||
if len(frames) < 3: return None
|
||||
has_boot = any(f['action'] == "BootNotification" for f in frames)
|
||||
has_status = any(f['action'] == "StatusNotification" for f in frames)
|
||||
if has_boot and not has_status:
|
||||
return {
|
||||
"id": "NO_STATUS_AFTER_BOOT", "severity": "warning", "confidence": "medium",
|
||||
"title": "Séquence de démarrage non conforme",
|
||||
"explication": "Aucun StatusNotification n'a été reçu après le BootNotification.",
|
||||
"action": "Envoyez un TriggerMessage(StatusNotification)."
|
||||
}
|
||||
return None
|
||||
|
||||
def rule_bad_websocket_ping(frames, ctx):
|
||||
for f in reversed(frames):
|
||||
if f['action'] == "GetConfiguration" and f['direction'] == "OUT":
|
||||
config = f['payload'].get('configurationKey', [])
|
||||
for item in config:
|
||||
if item.get('key') == 'WebSocketPingInterval' and item.get('value') == '0':
|
||||
return {
|
||||
"id": "BAD_PING_INTERVAL", "severity": "critical", "confidence": "high",
|
||||
"title": "WebSocketPingInterval désactivé (0)",
|
||||
"explication": "La borne n'envoie pas de ping. Derrière une box FAI (NAT), la connexion sera coupée.",
|
||||
"action": "Corrigez cette valeur à 60 depuis l'onglet Audit Config."
|
||||
}
|
||||
return None
|
||||
|
||||
def check_electrical(borne_config_dict, installer_config):
|
||||
findings = []
|
||||
max_c_str = borne_config_dict.get("MaxCurrent", {}).get("value")
|
||||
disj_str = str(installer_config.get("disj_calibre", "0"))
|
||||
p_decl_str = str(installer_config.get("borne_puissance", "0"))
|
||||
raccordement = installer_config.get("raccordement", "")
|
||||
|
||||
if not (max_c_str and disj_str and p_decl_str): return findings
|
||||
|
||||
try:
|
||||
max_c = int(max_c_str)
|
||||
disj = int(disj_str)
|
||||
p_decl = int(p_decl_str)
|
||||
|
||||
# a) Sécurité disjoncteur
|
||||
if disj < max_c:
|
||||
findings.append({
|
||||
"id": "ELEC_UNDER_PROTECTED", "severity": "critical", "confidence": "high",
|
||||
"title": "Protection sous-dimensionnée",
|
||||
"explication": f"Disjoncteur {disj}A inférieur au courant max borne {max_c}A.",
|
||||
"action": "Risque de déclenchement en charge. Vérifier le calibre et la section de câble."
|
||||
})
|
||||
|
||||
# b) Puissance réelle vs déclarée
|
||||
if "mono" in raccordement:
|
||||
p_reelle = 230 * max_c
|
||||
else:
|
||||
p_reelle = 400 * max_c * math.sqrt(3)
|
||||
|
||||
p_reelle_kw = round(p_reelle / 1000, 1)
|
||||
|
||||
if p_reelle_kw < p_decl * 0.85:
|
||||
findings.append({
|
||||
"id": "ELEC_POWER_BRIDLED", "severity": "critical", "confidence": "high",
|
||||
"title": "Borne bridée logiquement",
|
||||
"explication": f"Borne bridée à {p_reelle_kw} kW ({max_c}A x {400 if 'tri' in raccordement else 230}V) alors que {p_decl} kW sont déclarés.",
|
||||
"action": "Le firmware limite le courant. Vérifier le paramétrage usine ou une limitation logicielle."
|
||||
})
|
||||
|
||||
# c) 22kW sur Mono
|
||||
if p_decl > 7.4 and "mono" in raccordement:
|
||||
findings.append({
|
||||
"id": "ELEC_MONO_OVERPOWER", "severity": "critical", "confidence": "high",
|
||||
"title": "Raccordement incohérent",
|
||||
"explication": f"Puissance {p_decl}kW déclarée sur du monophasé.",
|
||||
"action": "Au-delà de 7.4kW, le triphasé est obligatoire."
|
||||
})
|
||||
except ValueError:
|
||||
pass
|
||||
return findings
|
||||
|
||||
def analyze_frames(frames, cp_data, installer_config=None):
|
||||
findings = []
|
||||
|
||||
# Rècles comportementales
|
||||
rules = [rule_missing_status_after_boot, rule_bad_websocket_ping]
|
||||
for rule in rules:
|
||||
res = rule(frames, cp_data)
|
||||
if res: findings.append(res)
|
||||
|
||||
# Règles électriques (nécessitent la config installateur)
|
||||
if installer_config:
|
||||
borne_config_dict = {}
|
||||
for f in reversed(frames):
|
||||
if f['action'] == "GetConfiguration" and f['direction'] == "OUT":
|
||||
borne_config_dict = {k["key"]: k for k in f['payload'].get('configurationKey', [])}
|
||||
break
|
||||
findings.extend(check_electrical(borne_config_dict, installer_config))
|
||||
|
||||
return findings
|
||||
39
backend/app/core/ocpp_schemas.py
Normal file
39
backend/app/core/ocpp_schemas.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import jsonschema
|
||||
|
||||
# Schémas simplifiés pour la démo (basés sur la spec OCPP 1.6)
|
||||
SCHEMAS = {
|
||||
"BootNotification": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chargePointVendor": {"type": "string", "maxLength": 20},
|
||||
"chargePointModel": {"type": "string", "maxLength": 20}
|
||||
},
|
||||
"required": ["chargePointVendor", "chargePointModel"]
|
||||
},
|
||||
"Authorize": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"idTag": {"type": "string", "maxLength": 20}
|
||||
},
|
||||
"required": ["idTag"]
|
||||
},
|
||||
"StartTransaction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"connectorId": {"type": "integer"},
|
||||
"idTag": {"type": "string"},
|
||||
"meterStart": {"type": "integer"},
|
||||
"timestamp": {"type": "string"}
|
||||
},
|
||||
"required": ["connectorId", "idTag", "meterStart", "timestamp"]
|
||||
}
|
||||
}
|
||||
|
||||
def validate_payload(action, payload):
|
||||
if action in SCHEMAS:
|
||||
try:
|
||||
jsonschema.validate(instance=payload, schema=SCHEMAS[action])
|
||||
return None
|
||||
except jsonschema.ValidationError as e:
|
||||
return e.message
|
||||
return None
|
||||
156
backend/app/core/session_manager.py
Normal file
156
backend/app/core/session_manager.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import time
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger("session_manager")
|
||||
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
REDIS_AVAILABLE = True
|
||||
except ImportError:
|
||||
REDIS_AVAILABLE = False
|
||||
|
||||
def normalize_keys(obj):
|
||||
"""Convertit récursivement les clés snake_case du payload OCPP en camelCase."""
|
||||
if isinstance(obj, dict):
|
||||
new_dict = {}
|
||||
for k, v in obj.items():
|
||||
parts = k.split('_')
|
||||
new_key = parts[0] + ''.join(p.title() for p in parts[1:])
|
||||
new_dict[new_key] = normalize_keys(v)
|
||||
return new_dict
|
||||
elif isinstance(obj, list):
|
||||
return [normalize_keys(v) for v in obj]
|
||||
return obj
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self, redis_url=None):
|
||||
self.redis = None
|
||||
self.memory_cps = {}
|
||||
self.memory_frames = defaultdict(lambda: deque(maxlen=200))
|
||||
self.pending_calls = {}
|
||||
self.action_map = {}
|
||||
|
||||
if redis_url and REDIS_AVAILABLE:
|
||||
try:
|
||||
self.redis = aioredis.from_url(redis_url)
|
||||
logger.info("SessionManager connecté à Redis.")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis indisponible: {e}")
|
||||
self.redis = None
|
||||
|
||||
async def register_cp(self, cp_id, model, vendor, subprotocol):
|
||||
data = {
|
||||
"id": cp_id, "model": str(model), "vendor": str(vendor),
|
||||
"last_activity": time.time(), "status": "Connected", "msg_count": 0,
|
||||
"boot_time_ms": None, "reconnections": 0, "clock_drift": None,
|
||||
"rtt_avg_ms": 0, "subprotocol": subprotocol, "last_disconnect_code": None
|
||||
}
|
||||
if self.redis:
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(data))
|
||||
else:
|
||||
self.memory_cps[cp_id] = data
|
||||
|
||||
async def update_disconnect(self, cp_id, code):
|
||||
if self.redis:
|
||||
data = await self.redis.hget("chargepoints", cp_id)
|
||||
if data:
|
||||
cp = json.loads(data)
|
||||
cp["last_disconnect_code"] = code
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(cp))
|
||||
elif cp_id in self.memory_cps:
|
||||
self.memory_cps[cp_id]["last_disconnect_code"] = code
|
||||
|
||||
async def update_cp_status(self, cp_id, status):
|
||||
if self.redis:
|
||||
data = await self.redis.hget("chargepoints", cp_id)
|
||||
if data:
|
||||
cp = json.loads(data)
|
||||
cp["status"] = str(status)
|
||||
cp["last_activity"] = time.time()
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(cp))
|
||||
elif cp_id in self.memory_cps:
|
||||
self.memory_cps[cp_id]["status"] = str(status)
|
||||
self.memory_cps[cp_id]["last_activity"] = time.time()
|
||||
|
||||
async def purge_frames(self, cp_id):
|
||||
if self.redis:
|
||||
await self.redis.delete(f"frames:{cp_id}")
|
||||
await self.redis.delete(f"stream:{cp_id}")
|
||||
data = await self.redis.hget("chargepoints", cp_id)
|
||||
if data:
|
||||
cp = json.loads(data)
|
||||
cp["msg_count"] = 0
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(cp))
|
||||
elif cp_id in self.memory_frames:
|
||||
self.memory_frames[cp_id].clear()
|
||||
if cp_id in self.memory_cps:
|
||||
self.memory_cps[cp_id]["msg_count"] = 0
|
||||
|
||||
async def log_frame(self, cp_id, direction, messageTypeId, action, uniqueId, payload):
|
||||
now = time.time()
|
||||
rtt_ms = None
|
||||
schema_error = None
|
||||
|
||||
# Normalisation du payload en camelCase
|
||||
payload = normalize_keys(payload)
|
||||
|
||||
if messageTypeId == 2:
|
||||
self.action_map[uniqueId] = action
|
||||
self.pending_calls[uniqueId] = now
|
||||
elif messageTypeId == 3 and action is None:
|
||||
rtt_ms = (now - self.pending_calls.pop(uniqueId, now)) * 1000
|
||||
action = self.action_map.pop(uniqueId, "Unknown")
|
||||
elif messageTypeId == 4 and action is None:
|
||||
rtt_ms = (now - self.pending_calls.pop(uniqueId, now)) * 1000
|
||||
action = self.action_map.pop(uniqueId, "Unknown")
|
||||
|
||||
frame = {
|
||||
"timestamp": now, "direction": direction, "messageTypeId": messageTypeId,
|
||||
"action": action, "uniqueId": uniqueId, "payload": payload
|
||||
}
|
||||
|
||||
if self.redis:
|
||||
stream_frame = {
|
||||
"timestamp": str(now), "direction": str(direction), "messageTypeId": str(messageTypeId),
|
||||
"action": str(action), "uniqueId": str(uniqueId), "payload": json.dumps(payload)
|
||||
}
|
||||
await self.redis.xadd(f"stream:{cp_id}", stream_frame)
|
||||
|
||||
await self.redis.lpush(f"frames:{cp_id}", json.dumps(frame))
|
||||
await self.redis.ltrim(f"frames:{cp_id}", 0, 199)
|
||||
await self.redis.expire(f"frames:{cp_id}", 86400) # TTL 24h
|
||||
|
||||
await self.redis.publish("live_frames", json.dumps({"cp_id": cp_id, **frame}))
|
||||
|
||||
data = await self.redis.hget("chargepoints", cp_id)
|
||||
if data:
|
||||
cp = json.loads(data)
|
||||
cp["msg_count"] = cp.get("msg_count", 0) + 1
|
||||
if action in ["StartTransaction", "StopTransaction"] and "timestamp" in payload:
|
||||
try:
|
||||
cp_time = datetime.fromisoformat(payload["timestamp"].replace("Z", "+00:00"))
|
||||
cp["clock_drift"] = abs((datetime.now(timezone.utc) - cp_time).total_seconds())
|
||||
except: pass
|
||||
if rtt_ms: cp["rtt_avg_ms"] = int(rtt_ms)
|
||||
await self.redis.hset("chargepoints", cp_id, json.dumps(cp))
|
||||
else:
|
||||
self.memory_frames[cp_id].append(frame)
|
||||
if cp_id in self.memory_cps:
|
||||
self.memory_cps[cp_id]["msg_count"] += 1
|
||||
|
||||
async def get_cps(self):
|
||||
if self.redis:
|
||||
data = await self.redis.hgetall("chargepoints")
|
||||
return [json.loads(v) for v in data.values()]
|
||||
return list(self.memory_cps.values())
|
||||
|
||||
async def get_frames(self, cp_id):
|
||||
if self.redis:
|
||||
data = await self.redis.lrange(f"frames:{cp_id}", 0, -1)
|
||||
return [json.loads(f) for f in reversed(data)]
|
||||
return list(self.memory_frames[cp_id])
|
||||
|
||||
session_mgr = SessionManager(redis_url="redis://redis:6379")
|
||||
179
backend/app/main.py
Normal file
179
backend/app/main.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from app.ocpp.router import CustomChargePoint
|
||||
from app.core.session_manager import session_mgr
|
||||
from app.core.diagnostics import analyze_frames, check_electrical
|
||||
from ocpp.v16 import call as v16_call
|
||||
import logging
|
||||
import json
|
||||
import time
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
app = FastAPI(title="Check-My-IRVE API", version="3.5.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
class SocketAdapter:
|
||||
def __init__(self, websocket: WebSocket, cp_id: str):
|
||||
self._ws = websocket
|
||||
self.cp_id = cp_id
|
||||
|
||||
async def recv(self):
|
||||
msg = await self._ws.receive_text()
|
||||
try:
|
||||
parsed = json.loads(msg)
|
||||
msg_type = parsed[0]
|
||||
unique_id = parsed[1]
|
||||
if msg_type == 2: action, payload = parsed[2], parsed[3] if len(parsed) > 3 else {}
|
||||
elif msg_type == 3: action, payload = None, parsed[2] if len(parsed) > 2 else {}
|
||||
elif msg_type == 4: action, payload = None, {"errorCode": parsed[2]}
|
||||
else: return msg
|
||||
await session_mgr.log_frame(self.cp_id, "IN", msg_type, action, unique_id, payload)
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur parsing IN: {e}")
|
||||
return msg
|
||||
|
||||
async def send(self, message):
|
||||
try:
|
||||
parsed = json.loads(message)
|
||||
msg_type = parsed[0]
|
||||
unique_id = parsed[1]
|
||||
if msg_type == 2: action, payload = parsed[2], parsed[3] if len(parsed) > 3 else {}
|
||||
elif msg_type == 3: action, payload = None, parsed[2] if len(parsed) > 2 else {}
|
||||
elif msg_type == 4: action, payload = None, {"errorCode": parsed[2]}
|
||||
else: return await self._ws.send_text(message)
|
||||
await session_mgr.log_frame(self.cp_id, "OUT", msg_type, action, unique_id, payload)
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur parsing OUT: {e}")
|
||||
await self._ws.send_text(message)
|
||||
|
||||
active_charge_points = {}
|
||||
|
||||
@app.websocket("/ocpp/{client_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
||||
subprotocol = websocket.headers.get("sec-websocket-protocol", "Unknown")
|
||||
await websocket.accept(subprotocol="ocpp1.6")
|
||||
adapter = SocketAdapter(websocket, client_id)
|
||||
cp = CustomChargePoint(client_id, adapter)
|
||||
cp.subprotocol = subprotocol
|
||||
active_charge_points[client_id] = cp
|
||||
|
||||
if session_mgr.redis:
|
||||
data = await session_mgr.redis.hget("chargepoints", client_id)
|
||||
if data:
|
||||
cp_data = json.loads(data)
|
||||
cp_data["reconnections"] = cp_data.get("reconnections", 0) + 1
|
||||
await session_mgr.redis.hset("chargepoints", client_id, json.dumps(cp_data))
|
||||
|
||||
try:
|
||||
await cp.start()
|
||||
except WebSocketDisconnect as e:
|
||||
logging.info(f"Borne {client_id} déconnectée (Code: {e.code}).")
|
||||
await session_mgr.update_disconnect(client_id, e.code)
|
||||
await session_mgr.update_cp_status(client_id, "Disconnected")
|
||||
if client_id in active_charge_points: del active_charge_points[client_id]
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur OCPP sur {client_id}: {e}")
|
||||
|
||||
@app.get("/api/chargepoints")
|
||||
async def get_cps(): return await session_mgr.get_cps()
|
||||
|
||||
@app.get("/api/chargepoints/{cp_id}/frames")
|
||||
async def get_frames(cp_id: str): return await session_mgr.get_frames(cp_id)
|
||||
|
||||
@app.delete("/api/chargepoints/{cp_id}/frames")
|
||||
async def purge_frames(cp_id: str):
|
||||
await session_mgr.purge_frames(cp_id)
|
||||
return {"status": "success"}
|
||||
|
||||
@app.post("/api/chargepoints/{cp_id}/diagnostics")
|
||||
async def get_diagnostics(cp_id: str, payload: dict):
|
||||
frames = await session_mgr.get_frames(cp_id)
|
||||
cps = await session_mgr.get_cps()
|
||||
cp_data = next((cp for cp in cps if cp["id"] == cp_id), {})
|
||||
installer_config = payload.get("installer_config", {})
|
||||
return analyze_frames(frames, cp_data, installer_config)
|
||||
|
||||
@app.get("/api/chargepoints/{cp_id}/report", response_class=HTMLResponse)
|
||||
async def get_report(cp_id: str):
|
||||
frames = await session_mgr.get_frames(cp_id)
|
||||
cps = await session_mgr.get_cps()
|
||||
cp_data = next((cp for cp in cps if cp["id"] == cp_id), {})
|
||||
findings = analyze_frames(frames, cp_data, {})
|
||||
|
||||
html = f"""
|
||||
<html><head><title>Rapport - {cp_id}</title>
|
||||
<style>body{{font-family:sans-serif;margin:40px}}pre{{background:#0f172a;color:#e2e8f0;padding:15px}}</style></head>
|
||||
<body><h1>Rapport IRVE</h1><p>Borne: {cp_id}</p>
|
||||
<h2>Anomalies</h2>{''.join([f'<div style="border-left:4px solid red;padding:10px;margin-bottom:10px"><b>{f["title"]}</b><br>{f["action"]}</div>' for f in findings])}
|
||||
<h2>Trames</h2><pre>{json.dumps(frames[:20], indent=2)}</pre>
|
||||
</body></html>
|
||||
"""
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
@app.post("/api/chargepoints/{cp_id}/audit")
|
||||
async def audit_config(payload: dict):
|
||||
borne_config_dict = {k["key"]: k for k in payload.get("borne_config", {}).get("configurationKey", [])}
|
||||
installer_config = payload.get("installer_config", {})
|
||||
|
||||
audit_result = []
|
||||
|
||||
REFERENCE_CONFIG = {
|
||||
"HeartbeatInterval": {"expected": "300", "severity": "warning", "readonly": False},
|
||||
"WebSocketPingInterval": {"expected": "60", "severity": "critical", "readonly": False},
|
||||
"MeterValueSampleInterval": {"expected": "10", "severity": "info", "readonly": False},
|
||||
"ConnectionTimeOut": {"expected": "60", "severity": "warning", "readonly": False}
|
||||
}
|
||||
for key, ref in REFERENCE_CONFIG.items():
|
||||
borne_val = borne_config_dict.get(key, {}).get("value", "Manquant")
|
||||
audit_result.append({
|
||||
"key": key, "borne_val": borne_val, "expected_val": ref["expected"],
|
||||
"match": borne_val == ref["expected"], "severity": ref["severity"], "readonly": ref["readonly"]
|
||||
})
|
||||
|
||||
elec_findings = check_electrical(borne_config_dict, installer_config)
|
||||
for f in elec_findings:
|
||||
audit_result.append({
|
||||
"key": f['title'], "borne_val": f['explication'], "expected_val": f['action'],
|
||||
"match": False, "severity": f['severity'], "readonly": True
|
||||
})
|
||||
|
||||
return audit_result
|
||||
|
||||
@app.post("/api/chargepoints/{cp_id}/command")
|
||||
async def send_command(cp_id: str, command: dict):
|
||||
if cp_id not in active_charge_points: return {"error": "CP not connected"}
|
||||
cp = active_charge_points[cp_id]
|
||||
action = command.get("action")
|
||||
payload = command.get("payload", {})
|
||||
try:
|
||||
if action == "TriggerMessage": req = v16_call.TriggerMessagePayload(requested_message=payload.get("requested_message", "StatusNotification"), connector_id=payload.get("connector_id", 0))
|
||||
elif action == "GetConfiguration": req = v16_call.GetConfigurationPayload(key=payload.get("key", []))
|
||||
elif action == "RemoteStartTransaction": req = v16_call.RemoteStartTransactionPayload(id_tag="CHARGEMAP_PASS", connector_id=1)
|
||||
elif action == "Reset": req = v16_call.ResetPayload(type="Soft")
|
||||
else: return {"error": "Unsupported"}
|
||||
resp = await cp.call(req)
|
||||
return {"status": "success", "response": resp}
|
||||
except Exception as e: return {"error": str(e)}
|
||||
|
||||
@app.websocket("/api/live")
|
||||
async def websocket_live(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
if not session_mgr.redis:
|
||||
await websocket.send_text(json.dumps({"error": "Redis indisponible"}))
|
||||
await websocket.close()
|
||||
return
|
||||
pubsub = session_mgr.redis.pubsub()
|
||||
await pubsub.subscribe("live_frames")
|
||||
try:
|
||||
async for message in pubsub.listen():
|
||||
if message['type'] == 'message': await websocket.send_text(message['data'].decode('utf-8'))
|
||||
except: pass
|
||||
finally: await pubsub.unsubscribe("live_frames")
|
||||
0
backend/app/ocpp/__init__.py
Normal file
0
backend/app/ocpp/__init__.py
Normal file
58
backend/app/ocpp/router.py
Normal file
58
backend/app/ocpp/router.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from ocpp.routing import on
|
||||
from ocpp.v16 import ChargePoint as cp
|
||||
from ocpp.v16 import call_result
|
||||
from ocpp.v16.enums import Action, RegistrationStatus
|
||||
from app.core.session_manager import session_mgr
|
||||
|
||||
logger = logging.getLogger("ocpp_router")
|
||||
|
||||
def get_utc_time():
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
HEARTBEAT_INTERVAL = int(os.getenv("OCPP_HEARTBEAT_INTERVAL", 300))
|
||||
|
||||
class CustomChargePoint(cp):
|
||||
@on(Action.BootNotification)
|
||||
async def on_boot_notification(self, charge_point_vendor, charge_point_model, **kwargs):
|
||||
await session_mgr.register_cp(self.id, charge_point_model, charge_point_vendor, getattr(self, 'subprotocol', 'Unknown'))
|
||||
response = call_result.BootNotificationPayload(
|
||||
current_time=get_utc_time(),
|
||||
interval=HEARTBEAT_INTERVAL,
|
||||
status=RegistrationStatus.accepted
|
||||
)
|
||||
return response
|
||||
|
||||
@on(Action.Heartbeat)
|
||||
async def on_heartbeat(self):
|
||||
response = call_result.HeartbeatPayload(current_time=get_utc_time())
|
||||
return response
|
||||
|
||||
@on(Action.Authorize)
|
||||
async def on_authorize(self, id_tag, **kwargs):
|
||||
status = "Accepted" if id_tag == "CHARGEMAP_PASS" else "Invalid"
|
||||
response = call_result.AuthorizePayload(id_tag_info={"status": status})
|
||||
return response
|
||||
|
||||
@on(Action.MeterValues)
|
||||
async def on_meter_values(self, connector_id, meter_value, **kwargs):
|
||||
response = call_result.MeterValuesPayload()
|
||||
return response
|
||||
|
||||
@on(Action.StatusNotification)
|
||||
async def on_status_notification(self, connector_id, status, error_code, **kwargs):
|
||||
await session_mgr.update_cp_status(self.id, str(status))
|
||||
response = call_result.StatusNotificationPayload()
|
||||
return response
|
||||
|
||||
@on(Action.StartTransaction)
|
||||
async def on_start_transaction(self, connector_id, id_tag, meter_start, timestamp, **kwargs):
|
||||
response = call_result.StartTransactionPayload(transaction_id=123456, id_tag_info={"status": "Accepted"})
|
||||
return response
|
||||
|
||||
@on(Action.StopTransaction)
|
||||
async def on_stop_transaction(self, transaction_id, meter_stop, timestamp, **kwargs):
|
||||
response = call_result.StopTransactionPayload(id_tag_info={"status": "Accepted"})
|
||||
return response
|
||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
ocpp==2.0.0rc1
|
||||
websockets==12.0
|
||||
redis==5.0.1
|
||||
pydantic==2.4.2
|
||||
jsonschema==4.20.0
|
||||
36
backend/tests/test_diagnostics.py
Normal file
36
backend/tests/test_diagnostics.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'app')))
|
||||
|
||||
from core.diagnostics import analyze_frames
|
||||
|
||||
def test_missing_status_after_boot():
|
||||
frames = [
|
||||
{"action": "BootNotification", "uniqueId": "1", "payload": {}},
|
||||
{"action": "Heartbeat", "uniqueId": "2", "payload": {}}
|
||||
]
|
||||
findings = analyze_frames(frames, {})
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["id"] == "NO_STATUS_AFTER_BOOT"
|
||||
|
||||
def test_no_finding_when_status_present():
|
||||
frames = [
|
||||
{"action": "BootNotification", "uniqueId": "1", "payload": {}},
|
||||
{"action": "StatusNotification", "uniqueId": "2", "payload": {"status": "Available"}}
|
||||
]
|
||||
findings = analyze_frames(frames, {})
|
||||
assert len(findings) == 0
|
||||
|
||||
def test_clock_drift_none():
|
||||
findings = analyze_frames([], {"clock_drift": None})
|
||||
assert len(findings) == 0
|
||||
|
||||
def test_clock_drift_critical():
|
||||
findings = analyze_frames([], {"clock_drift": 120})
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["id"] == "CLOCK_DRIFT"
|
||||
|
||||
def test_abnormal_disconnect():
|
||||
findings = analyze_frames([], {"last_disconnect_code": 1006})
|
||||
assert len(findings) == 1
|
||||
assert findings[0]["id"] == "ABNORMAL_DISCONNECT"
|
||||
Loading…
Add table
Add a link
Reference in a new issue