Initial commit: Check-My-IRVE SaaS (OCPP 1.6, Audit, Diagnostics)

This commit is contained in:
Adam Belkacemi 2026-08-25 19:08:15 +02:00
commit 192be86487
44 changed files with 2070 additions and 0 deletions

View file

View 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

View 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

View 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")