CitrineOS deployment + FM integration + S2 service + skills (mosaik, NemoMod) + Grafana 17 panels
This commit is contained in:
@@ -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
|
||||
|
||||
189
scripts/citrineos_fm_integration.py
Normal file
189
scripts/citrineos_fm_integration.py
Normal 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())
|
||||
Reference in New Issue
Block a user