CitrineOS deployment + FM integration + S2 service + skills (mosaik, NemoMod) + Grafana 17 panels

This commit is contained in:
Eric F
2026-06-10 13:13:37 -04:00
parent 84667c7126
commit 5c7d6e5611
2 changed files with 210 additions and 17 deletions

View File

@@ -1,6 +1,3 @@
# Cariflex - CitrineOS docker-compose (adapté pour l'intégration Cariflex)
# Basé sur https://github.com/citrineos/citrineos-core
version: '3.8'
services:
@@ -23,19 +20,21 @@ services:
depends_on:
cariflex-citrineos-db:
condition: service_healthy
cariflex-amqp:
condition: service_healthy
ports:
- 8080:8080
- 8443:8443
- "8081:8080"
volumes:
- citrineos-data:/data
healthcheck:
test: ["CMD-SHELL", "node -e \"const net = require('net'); const client = net.createConnection(8080, '127.0.0.1', () => { client.end(); process.exit(0); }); client.on('error', () => process.exit(1)); client.setTimeout(5000, () => { client.destroy(); process.exit(1); });\""]
test: ["CMD-SHELL", "node -e \"const net = require('net'); const c = net.createConnection(8080, '127.0.0.1', () => { c.end(); process.exit(0); }); c.on('error', () => process.exit(1));\""]
interval: 30s
timeout: 10s
retries: 5
networks:
- traefik-public
- cariflex-internal
cariflex-internal:
aliases:
- citrineos-server
cariflex-citrineos-db:
image: postgis/postgis:16-3.5
@@ -48,12 +47,14 @@ services:
volumes:
- citrineos-db-data:/var/lib/postgresql/data
healthcheck:
test: "pg_isready --username=citrine"
test: pg_isready --username=citrine
interval: 5s
timeout: 10s
retries: 5
networks:
- cariflex-internal
cariflex-internal:
aliases:
- citrineos-db
cariflex-amqp:
image: rabbitmq:3-management
@@ -65,12 +66,16 @@ services:
volumes:
- citrineos-amqp-data:/var/lib/rabbitmq
healthcheck:
test: rabbitmq-diagnostics -q check_port_connectivity
interval: 10s
test: rabbitmq-diagnostics -q ping
interval: 15s
timeout: 10s
retries: 3
retries: 10
start_period: 30s
networks:
- cariflex-internal
cariflex-internal:
aliases:
- amqp-broker
- cariflex-amqp
volumes:
citrineos-data:
@@ -81,7 +86,6 @@ volumes:
driver: local
networks:
traefik-public:
external: true
cariflex-internal:
driver: bridge
name: config_cariflex-internal
external: true

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Cariflex - CitrineOS ↔ FlexMeasures Integration Service
Links OCPP charging sessions to FM sensors for energy management.
"""
import json, logging, os, re, sys
from datetime import datetime, timezone, timedelta
import requests
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("cariflex-citrineos-fm")
# Config
FM_HOST = os.getenv("FM_HOST", "https://cariflex.digitribe.fr")
FM_EMAIL = os.getenv("FM_EMAIL", "admin@digitribe.fr")
FM_PWD = os.getenv("FM_PWD", "Digitribe972")
CITRINEOS_HOST = os.getenv("CITRINEOS_HOST", "http://cariflex-citrineos-server:8080")
# OCPP Charge Point ID -> FM Sensor ID mapping
# Each charge point (EVSE) maps to a sensor in FlexMeasures
CHARGEPOINT_SENSOR_MAP = {
"CP001": 61, # EV_01
"CP002": 62, # EV_02
"CP003": 63, # EV_03
"CP004": 64, # EV_04
"CP005": 65, # EV_05
"CP006": 66, # EV_06
"CP007": 67, # EV_07
"CP008": 68, # EV_08
"CP009": 69, # EV_09
"CP010": 70, # EV_10
}
fm_session = None
def fm_login():
global fm_session
try:
s = requests.Session(); s.verify = False
r = s.get(f"{FM_HOST}/login", timeout=15)
m = re.search(r"csrf_token[^>]*value=[\"\\']([^\"\\']+)", r.text)
if m:
r = s.post(f"{FM_HOST}/login", data={"email":FM_EMAIL,"password":FM_PWD,"csrf_token":m.group(1),"remember":"y"}, allow_redirects=True, timeout=15)
if "dashboard" in r.url or r.status_code == 200:
fm_session = s
return True
except Exception as e:
logger.error(f"FM login: {e}")
return False
def post_sensor_data(sensor_id, value, unit, start, duration="PT1H"):
if not fm_session:
return False
try:
r = fm_session.post(f"{FM_HOST}/api/v3_0/sensors/{sensor_id}/data",
json={"values":[value],"start":start,"duration":duration,"unit":unit}, timeout=30)
return r.status_code in [200,201,202]
except Exception as e:
logger.error(f"Post: {e}")
return False
def get_citrineos_sessions():
"""Get active charging sessions from CitrineOS."""
try:
r = requests.get(f"{CITRINEOS_HOST}/api/v1/sessions", timeout=10)
if r.status_code == 200:
return r.json()
except Exception as e:
logger.error(f"CitrineOS sessions: {e}")
return []
def get_citrineos_meter_values(session_id):
"""Get meter values for a charging session."""
try:
r = requests.get(f"{CITRINEOS_HOST}/api/v1/sessions/{session_id}/meter-values", timeout=10)
if r.status_code == 200:
return r.json()
except Exception as e:
logger.error(f"CitrineOS meter values: {e}")
return []
def process_charging_session(session):
"""Process a charging session and update FM sensors."""
charge_point_id = session.get("chargePointId")
session_id = session.get("id")
if charge_point_id not in CHARGEPOINT_SENSOR_MAP:
logger.warning(f"Unknown charge point: {charge_point_id}")
return
fm_sensor_id = CHARGEPOINT_SENSOR_MAP[charge_point_id]
# Get meter values
meter_values = get_citrineos_meter_values(session_id)
for mv in meter_values:
value = mv.get("value", 0)
unit = mv.get("unit", "W")
timestamp = mv.get("timestamp", datetime.now(timezone.utc).isoformat())
# Convert W to kW
if unit == "W":
value = value / 1000
unit = "kW"
# Post to FM
ok = post_sensor_data(fm_sensor_id, value, unit, timestamp)
logger.info(f"CitrineOS {charge_point_id} -> FM sensor {fm_sensor_id}: {value} {unit}: {'OK' if ok else 'FAIL'}")
def send_charging_profile(charge_point_id, power_limit_kw):
"""Send a charging profile to a charge point via CitrineOS."""
try:
payload = {
"chargePointId": charge_point_id,
"chargingProfile": {
"chargingProfileId": 1,
"stackLevel": 0,
"chargingProfilePurpose": "TxDefaultProfile",
"chargingProfileKind": "Absolute",
"chargingSchedule": {
"chargingRateUnit": "W",
"chargingSchedulePeriod": [{
"startPeriod": 0,
"limit": int(power_limit_kw * 1000) # Convert kW to W
}]
}
}
}
r = requests.post(f"{CITRINEOS_HOST}/api/v1/charge-points/{charge_pointId}/charging-profiles",
json=payload, timeout=10)
logger.info(f"Charging profile sent to {charge_point_id}: {power_limit_kw} kW -> {r.status_code}")
return r.status_code == 200
except Exception as e:
logger.error(f"Send charging profile: {e}")
return False
def process_fm_schedules():
"""Read FM schedules and send to CitrineOS charge points."""
for cp_id, sensor_id in CHARGEPOINT_SENSOR_MAP.items():
try:
# Get latest schedule from FM
r = fm_session.get(f"{FM_HOST}/api/v3_0/sensors/{sensor_id}/data?limit=1", timeout=10)
if r.status_code == 200:
data = r.json()
if data and len(data) > 0:
latest = data[-1]
power = float(latest.get("event_value", 0))
if power > 0:
send_charging_profile(cp_id, power)
except Exception as e:
logger.error(f"FM schedule for {cp_id}: {e}")
async def main():
logger.info("Cariflex CitrineOS-FM Integration starting")
logger.info(f"FM: {FM_HOST}")
logger.info(f"CitrineOS: {CITRINEOS_HOST}")
logger.info(f"Charge points: {len(CHARGEPOINT_SENSOR_MAP)}")
fm_login()
while True:
try:
# 1. Get charging sessions from CitrineOS
sessions = get_citrineos_sessions()
for session in sessions:
if session.get("status") == "Active":
process_charging_session(session)
# 2. Send FM schedules to CitrineOS
process_fm_schedules()
except Exception as e:
logger.error(f"Integration error: {e}")
fm_login()
await asyncio.sleep(30)
if __name__ == "__main__":
asyncio.run(main())