23 Commits

Author SHA1 Message Date
Eric F
e054db5ca4 fix: Remove ocppConnectionName from connectors and transactions queries, fix OCPPMessages filter 2026-06-16 09:07:53 -04:00
Eric F
c1706f337d fix: Disable S3 image loading and fix station detail data extraction 2026-06-16 08:41:53 -04:00
Eric F
c301692a98 fix: Extract station from ChargingStations array in detail component 2026-06-16 08:08:22 -04:00
Eric F
43b8c3e008 fix: Add cache-busting headers for static files 2026-06-16 07:39:07 -04:00
Eric F
f11a57d318 fix: Remove ocppConnectionName from evses and connectors in CHARGING_STATIONS_GET_QUERY 2026-06-16 07:01:50 -04:00
Eric F
dcb2889824 fix: Use String(id) instead of Number(id) for charging station ID 2026-06-16 06:30:08 -04:00
Eric F
5c76add30b fix: Change chargingStations to ChargingStations in GetChargingStationsWithLocationAndLatestStatusNotificationsAndtransactions query 2026-06-16 05:37:51 -04:00
Eric F
6f329184e2 fix: Revert to PascalCase for all ChargingStations types after removing Hasura customization 2026-06-16 00:30:46 -04:00
Eric F
8510f680da fix: Change ChargingStations_order_by/bool_exp to camelCase in all queries 2026-06-16 00:20:29 -04:00
Eric F
051fc3cb22 fix: Change ChargingStations to chargingStations in ChargingStationsList query 2026-06-16 00:10:41 -04:00
Eric F
9dc297d24b fix: Revert chargingPool alias to ChargingStations (PascalCase) in locations query 2026-06-16 00:00:03 -04:00
Eric F
9930419c3c fix: Change ChargingStations to chargingStations (camelCase) in all queries 2026-06-15 23:48:18 -04:00
Eric F
7ba5bdc604 fix: Use chargingStations(where: {id: {_eq: }}) instead of ChargingStations_by_pk 2026-06-15 23:35:14 -04:00
Eric F
3b882df2e1 fix: Add Referrer-Policy header for OSM tiles 2026-06-15 22:50:07 -04:00
Eric F
b06dbdbba9 fix: VariableAttributes mapping + locations detail parseInt + .gitignore pnpm-store 2026-06-15 22:28:31 -04:00
Eric F
438f9aa952 fix: Convert location ID to integer for Locations_by_pk query
Root cause: Next.js URL params are always strings, but Locations.id
is integer in PostgreSQL. Hasura/PostgreSQL cannot compare varchar
with integer without explicit cast.

Fix: Parse params.id to integer in LocationsDetail component before
passing to Refine useOne().
2026-06-15 21:41:45 -04:00
Eric F
2e1a8a768d fix: Patch UI queries to use camelCase Hasura relations
- Patch GraphQL queries in source code (charging.stations.ts, locations.ts, transactions.ts)
- Rebuild Docker image with patched source
- Fix missing Transactions.authorization relationship in Hasura
- All 3 critical queries now work: GetChargingStations, LocationsList, TransactionList
- UI rebuilt from scratch with Node.js 24.16.0
2026-06-15 21:14:29 -04:00
Eric F
a3a066034d fix: Hasura camelCase relations + UI patch + NodeRED
- Fix Evses.evseId type (varchar→integer) for FK compatibility
- Recreate all Hasura relations with camelCase names matching UI expectations
- Patch UI .next JS files: PascalCase→camelCase (Evses, Connectors, etc.)
- Add fix-hasura-relations.py script for future maintenance
- NodeRED accessible at nodered.digitribe.fr
- All 15 stations online with working map and detail pages
2026-06-15 20:27:41 -04:00
Eric F
d0fbcd1e2d feat: Everest entrypoint + OCPI/EVerest config scripts 2026-06-15 18:38:14 -04:00
Eric F
a70f5adf15 feat: OCPP 2.0.1 multi-station simulator + Hasura/UI fixes
- Add ocpp-simulator-multi.js: 15 stations CP001-CP015 via WebSocket SP1
- Add ocpp-sp0-connector.js: Security Profile 0 connector
- Add configure-auth.py: BasicAuthPassword setup for all stations
- Add Dockerfile.simulator + Dockerfile.sp0 for containerized simulators
- Fix Hasura DB password (ALTER USER citrine)
- Fix UI NEXTAUTH_SECRET mismatch (C1tR1n30S2... vs Digitribe972)
- Fix UI network (traefik-public) + Traefik labels
- Update docker-compose-citrineos.yml with simulator services
- Set isOnline=true for all 15 stations in DB
2026-06-15 16:40:27 -04:00
Eric F
85ddea41e4 fix: restore getPlaceDetails with Nominatim OSM implementation
- Replace stub with working Nominatim OSM place details lookup
- Fixes 'Failed to find Server Action' errors in logs
- Free, no API key required
2026-06-14 08:32:24 -04:00
Eric F
d0c0cc8b0e chore: update pnpm-lock.yaml after leaflet dependency changes 2026-06-13 10:29:25 -04:00
Eric F
e20790dce4 docs: update infrastructure documentation for Leaflet OSM migration 2026-06-12 11:10:09 -04:00
54 changed files with 24743 additions and 93 deletions

2
.env.citrineos-ui Normal file
View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_AUTH_PROVIDER=***
N...**

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ tools/*/__pycache__
tools/*/.git
*.zip
tools/citrineos-core-main/src
tools/citrineos-core-main/.pnpm-store/

View File

@@ -0,0 +1,158 @@
version: '3.8'
services:
citrineos-server:
image: ghcr.io/citrineos/citrineos-server:latest
container_name: cariflex-citrineos-server
restart: unless-stopped
environment:
APP_NAME: "all"
APP_ENV: "docker"
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
DB_STRATEGY: "migrate"
BOOTSTRAP_CITRINEOS_DATABASE_HOST: "cariflex-citrineos-db"
BOOTSTRAP_CITRINEOS_CONFIG_FILENAME: "config.json"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_TYPE: "local"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_LOCAL_FILE_PATH: "/data"
CONFIG_CITRINEOS_WIPE_FILE_ON_START: "true"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
cariflex-amqp:
condition: service_healthy
volumes:
- citrineos-data:/data
ports:
- "8081:8080"
networks:
- cariflex-internal
cariflex-citrineos-db:
image: postgis/postgis:16-3.5
container_name: cariflex-citrineos-db
restart: unless-stopped
environment:
POSTGRES_DB: citrine
POSTGRES_USER: citrine
POSTGRES_PASSWORD: citrine
volumes:
- citrineos-db-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready --username=citrine
interval: 5s
timeout: 10s
retries: 5
networks:
- cariflex-internal
cariflex-amqp:
image: rabbitmq:3-management
container_name: cariflex-amqp
networks:
cariflex-internal:
aliases:
- amqp-broker
traefik-public:
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
labels:
- "traefik.enable=true"
- "traefik.http.routers.rabbitmq.rule=Host(`amqp.digitribe.fr`)"
- "traefik.http.routers.rabbitmq.entrypoints=websecure"
- "traefik.http.routers.rabbitmq.tls.certresolver=letsencrypt"
- "traefik.http.services.rabbitmq.loadbalancer.server.port=15672"
volumes:
- citrineos-amqp-data:/var/lib/rabbitmq
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
hasura:
image: hasura/graphql-engine:v2.40.0
container_name: cariflex-hasura
restart: unless-stopped
ports:
- "8082:8080"
environment:
HASURA_GRAPHQL_DATABASE_URL: "postgresql://citrine:***@cariflex-citrineos-db:5432/citrine"
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: "Digitribe972"
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: "anonymous"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.hasura.rule=Host(`hasura.digitribe.fr`)"
- "traefik.http.routers.hasura.entrypoints=websecure"
- "traefik.http.routers.hasura.tls.certresolver=letsencrypt"
- "traefik.http.services.hasura.loadbalancer.server.port=8080"
networks:
- traefik-public
- cariflex-internal
citrineos-operator-ui:
image: citrineos-core-main-citrine-ui:latest
container_name: cariflex-citrineos-operator-ui
restart: unless-stopped
ports:
- "3002:3000"
environment:
NEXTAUTH_SECRET: Digitribe972
ADMIN_PASSWORD: Digitribe972
depends_on:
- hasura
labels:
- "traefik.enable=true"
- "traefik.http.routers.citrineos-ui.rule=Host(`citrineos.digitribe.fr`)"
- "traefik.http.routers.citrineos-ui.entrypoints=websecure"
- "traefik.http.routers.citrineos-ui.tls.certresolver=letsencrypt"
- "traefik.http.services.citrineos-ui.loadbalancer.server.port=3000"
networks:
- traefik-public
- cariflex-internal
# === EVerest (simulateur de charge OCPP 2.0.1) ===
everest-mqtt:
image: ghcr.io/everest/everest-demo/mqtt-server:0.0.16
container_name: cariflex-everest-mqtt
restart: unless-stopped
networks:
- cariflex-internal
everest-nodered:
image: ghcr.io/everest/everest-demo/nodered:0.0.16
container_name: cariflex-everest-nodered
restart: unless-stopped
depends_on:
- everest-mqtt
environment:
- MQTT_SERVER_ADDRESS=everest-mqtt
- FLOWS=/config/config-sil-two-evse-flow.json
networks:
- cariflex-internal
ports:
- "1880:1880"
volumes:
citrineos-data:
driver: local
citrineos-db-data:
driver: local
citrineos-amqp-data:
driver: local
networks:
traefik-public:
external: true
cariflex-internal:
name: config_cariflex-internal
external: true

View File

@@ -120,6 +120,41 @@ services:
- traefik-public
- cariflex-internal
# === EVerest MQTT + NodeRED (UI de contrôle) ===
everest-mqtt:
image: ghcr.io/everest/everest-demo/mqtt-server:0.0.16
container_name: cariflex-everest-mqtt
restart: unless-stopped
networks:
- cariflex-internal
everest-nodered:
image: ghcr.io/everest/everest-demo/nodered:0.0.16
container_name: cariflex-everest-nodered
restart: unless-stopped
depends_on:
- everest-mqtt
environment:
- MQTT_SERVER_ADDRESS=everest-mqtt
- FLOWS=/config/config-sil-two-evse-flow.json
networks:
- cariflex-internal
ports:
- "1880:1880"
# === OCPP 2.0.1 Simulators ===
ocpp-simulator:
build:
context: /home/eric/cariflex/scripts
dockerfile: Dockerfile.simulator
container_name: cariflex-ocpp-simulator
restart: unless-stopped
environment:
OCPP_HOST: "cariflex-citrineos-server"
OCPP_PORT: "8082"
networks:
- cariflex-internal
volumes:
citrineos-data:
driver: local

View File

@@ -142,6 +142,16 @@
| `/home/eric/flexmeasures/docker-compose.yml` | FM core stack |
| `/home/eric/flexmeasures/docker-compose.openadr.yml` | OpenADR VTN/VEN |
| `/home/eric/cariflex/config/docker-compose-citrineos.yml` | CitrineOS stack |
| `/home/eric/cariflex/config/docker-compose-citrineos-ui-noproxy.yml` | UI without Traefik (testing) |
### CitrineOS Operator UI
- **URL**: https://citrineos.digitribe.fr
- **Image**: `citrineos-core-main-citrine-ui:latest` (custom build)
- **Build**: `cd /home/eric/cariflex/tools/citrineos-core-main && docker compose -f docker-compose.local.yml build citrine-ui`
- **Tech stack**: Next.js 15.2, React 19, Refine, Leaflet + OpenStreetMap
- **Map**: Replaced Google Maps with Leaflet + OpenStreetMap (no API key needed)
- **Geocoding**: Nominatim OSM (free, no API key)
- **Dependencies**: `leaflet`, `react-leaflet`, `@types/leaflet`
### Deployment Commands
```bash
@@ -154,6 +164,9 @@ cd /home/eric/flexmeasures && docker compose -f docker-compose.openadr.yml up -d
# CitrineOS stack
cd /home/eric/cariflex/config && docker compose -f docker-compose-citrineos.yml up -d
# Rebuild UI (after code changes)
cd /home/eric/cariflex/tools/citrineos-core-main && docker compose -f docker-compose.local.yml build citrine-ui && docker compose -f docker-compose-citrineos.yml up -d citrineos-operator-ui
# Verify all
docker ps --format "table {{.Names}}\t{{.Status}}" | grep -E "flexmeasures|openadr|citrine|grafana"
```

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,5 @@
FROM node:22-alpine
WORKDIR /app
COPY ocpp-simulator.js .
COPY ocpp-simulator-multi.js .
CMD ["node", "ocpp-simulator-multi.js"]

5
scripts/Dockerfile.sp0 Normal file
View File

@@ -0,0 +1,5 @@
FROM node:22-alpine
WORKDIR /app
COPY ocpp-simulator-multi.js .
COPY ocpp-sp0-connector.js .
CMD ["node", "ocpp-sp0-connector.js"]

29
scripts/configure-auth.py Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Configure BasicAuthPassword for all 15 Cariflex charging stations"""
import json, time, urllib.request, urllib.error
CITRINEOS_URL = "http://localhost:8081"
PASSWORD = "DEADBEEFDEADBEEF"
for i in range(1, 16):
cp_id = f"CP{i:03d}"
url = f"{CITRINEOS_URL}/data/monitoring/variableAttribute?stationId={cp_id}&setOnCharger=true"
payload = json.dumps({
"component": {"name": "SecurityCtrlr"},
"variable": {"name": "BasicAuthPassword"},
"variableAttribute": [{"value": PASSWORD}],
"variableCharacteristics": {"dataType": "passwordString", "supportsMonitoring": False}
}).encode()
req = urllib.request.Request(url, data=payload, method='PUT',
headers={'Content-Type': 'application/json'})
try:
resp = urllib.request.urlopen(req, timeout=10)
print(f"OK {cp_id}: HTTP {resp.status}")
except urllib.error.HTTPError as e:
print(f"FAIL {cp_id}: HTTP {e.code}")
except Exception as e:
print(f"FAIL {cp_id}: {e}")
time.sleep(0.2)
print("Done")

33
scripts/configure-auth.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Configure BasicAuthPassword for all 15 Cariflex charging stations
# This allows OCPP 2.0.1 simulators to connect via Security Profile 1
set -e
CitRINEOS_URL="http://localhost:8081"
PASSWORD="DEADBEEFDEADBEEF"
echo "=== Configuring BasicAuthPassword for all stations ==="
for i in $(seq 1 15); do
# Format: CP001, CP002, ... CP015
CP_ID=$(printf "CP%03d" $i)
response=$(curl -s -o /dev/null -w "%{http_code}" --location --request PUT \
"${CitRINEOS_URL}/data/monitoring/variableAttribute?stationId=${CP_ID}&setOnCharger=true" \
--header "Content-Type: application/json" \
--data-raw "{
\"component\": { \"name\": \"SecurityCtrlr\" },
\"variable\": { \"name\": \"BasicAuthPassword\" },
\"variableAttribute\": [{ \"value\": \"${PASSWORD}\" }],
\"variableCharacteristics\": { \"dataType\": \"passwordString\", \"supportsMonitoring\": false }
}")
if [ "$response" -ge 200 ] && [ "$response" -lt 300 ]; then
echo "${CP_ID}: BasicAuthPassword configured"
else
echo "${CP_ID}: Failed (HTTP ${response})"
fi
done
echo "=== Done ==="

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# === Post-deploy OCPI + Everest configuration for Cariflex ===
# Run this AFTER docker compose up succeeds
set -e
CSMS_URL="http://localhost:8081"
GRAPHQL_URL="http://localhost:8090/v1/graphql"
DB_CONTAINER="cariflex-citrineos-db"
echo "=== Step 1: Seed OCPI database ==="
# Run inside the OCPI container to seed default tenant + emsp
docker exec cariflex-ocpi npm run seed-db 2>&1 || echo "Seed may have run before, continuing..."
echo "=== Step 2: Verify Citiveness ==="
# Check CitrineOS Core is healthy
curl -sf "${CSMS_URL}/ocpp/health" && echo "CitrineOS Core: OK" || echo "CitrineOS Core: UNHEALTHY"
echo "=== Step 3: Configure EVerest Manager OCPP target ==="
# The manager already points to ws://cariflex-citrineos-server:8080/cp001 via Dockerfile
# Check manager is running
docker ps --filter "name=cariflex-everest-manager" --format "{{.Status}}" && echo "Everest Manager: running" || echo "Everest Manager: NOT RUNNING"
echo "=== Step 4: Verify UI connectivity ==="
echo " Everest NodeRED UI: http://localhost:1880/ui/"
echo " Everest OCPP Logs: http://localhost:8888"
echo " CitrineOS UI: https://citrineos.digitribe.fr"
echo " Hasura Console: https://hasura.digitribe.fr"
echo ""
echo "=== DONE ==="

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Entrypoint personnalisé pour Everest Manager - Cariflex
# Configure l'OCPP CSMS URL puis lance le simulateur
set -e
CSMS_URL="${EVEREST_TARGET_URL:-ws://cariflex-citrineos-server:8080/cp001}"
echo "=== EVerest Manager - Cariflex ==="
echo "CSMS URL: $CSMS_URL"
# Installer sqlite3 si nécessaire
apk add --no-cache sqlite3 2>/dev/null || true
# Configurer l'OCPP CSMS URL dans la base de données
DB_PATH="/ext/source/build/dist/share/everest/modules/OCPP201/device_model_storage.db"
if [ -f "$DB_PATH" ]; then
echo "Configuring OCPP CSMS URL in device model DB..."
sqlite3 "$DB_PATH" "UPDATE VARIABLE_ATTRIBUTE SET value = '[{\"configurationSlot\": 1, \"connectionData\": {\"messageTimeout\": 30, \"ocppCsmsUrl\": \"$CSMS_URL\", \"ocppInterface\": \"Wired0\", \"ocppTransport\": \"JSON\", \"ocppVersion\": \"OCPP20\", \"securityProfile\": 1}}]' WHERE variable_Id IN (SELECT id FROM VARIABLE WHERE name = 'NetworkConnectionProfiles');"
echo "OCPP CSMS URL configured."
else
echo "WARNING: device_model_storage.db not found at $DB_PATH"
fi
# Installer http-server pour les logs
npm i -g http-server 2>/dev/null || true
# Lancer le simulateur OCPP 2.0.1 avec PNC (Plug & Charge)
echo "Starting EVerest OCPP 2.0.1 simulator..."
chmod +x /ext/source/build/run-scripts/run-sil-ocpp201-pnc.sh
exec /ext/source/build/run-scripts/run-sil-ocpp201-pnc.sh

View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Script pour recréer toutes les relations Hasura avec les noms camelCase.
Utilise l'API metadata Hasura pour créer les relations manquantes.
"""
import json
import subprocess
import sys
ADMIN_SECRET = 'Digitribe972'
METADATA_URL = 'http://localhost:8082/v1/metadata'
GRAPHQL_URL = 'http://localhost:8082/v1/graphql'
def graphql_query(query):
"""Execute a GraphQL query and return the result."""
r = subprocess.run(
['curl', '-s', '-X', 'POST', GRAPHQL_URL,
'-H', f'x-hasura-admin-secret: {ADMIN_SECRET}',
'-H', 'Content-Type: application/json',
'-d', json.dumps({"query": query})],
capture_output=True, text=True, timeout=15
)
return json.loads(r.stdout)
def metadata_command(payload):
"""Execute a metadata command and return the result."""
r = subprocess.run(
['curl', '-s', '-X', 'POST', METADATA_URL,
'-H', f'x-hasura-admin-secret: {ADMIN_SECRET}',
'-H', 'Content-Type: application/json',
'-d', json.dumps(payload)],
capture_output=True, text=True, timeout=15
)
return json.loads(r.stdout)
def test_relation(table, relation, fields='{ id }'):
"""Test if a relation works by querying it."""
query = f'{{ {table}(where: {{id: {{_eq: "CP001"}}}}) {{ id {relation} {fields} }} }}'
result = graphql_query(query)
if 'errors' in result:
return False, result['errors'][0]['message'][:100]
return True, "OK"
def create_relation(table, name, rel_type, remote_table, col_map):
"""Create a relationship in Hasura."""
if rel_type == 'object':
payload = {
"type": "pg_create_object_relationship",
"args": {
"source": "default",
"table": {"schema": "public", "name": table},
"name": name,
"using": {
"manual_configuration": {
"remote_table": {"schema": "public", "name": remote_table},
"column_mapping": col_map
}
}
}
}
else:
payload = {
"type": "pg_create_array_relationship",
"args": {
"source": "default",
"table": {"schema": "public", "name": table},
"name": name,
"using": {
"manual_configuration": {
"remote_table": {"schema": "public", "name": remote_table},
"column_mapping": col_map
}
}
}
}
result = metadata_command(payload)
if 'message' in result and result['message'] == 'success':
return True
elif 'error' in result:
if 'already exists' in result['error']:
return True # Already exists, that's OK
print(f" ERROR: {result['error'][:200]}")
return False
return False
def main():
print("=== Diagnostic des relations Hasura ===\n")
# Test critical relations
critical_relations = [
("ChargingStations", "location", "{ id name }"),
("ChargingStations", "evses", "{ id evseId }"),
("ChargingStations", "connectors", "{ id status }"),
("ChargingStations", "transactions", "{ id }"),
("ChargingStations", "latestStatusNotifications", "{ statusNotification { connectorStatus } }"),
("ChargingStations", "tenant", "{ id name }"),
("Evses", "connectors", "{ id status }"),
("Transactions", "chargingStation", "{ id }"),
("Transactions", "evse", "{ id }"),
("Transactions", "connector", "{ id }"),
("Transactions", "location", "{ id name }"),
("LatestStatusNotifications", "statusNotification", "{ connectorStatus }"),
]
print("Test des relations critiques:")
all_ok = True
for table, relation, fields in critical_relations:
ok, msg = test_relation(table, relation, fields)
status = "OK" if ok else "FAIL"
print(f" {status}: {table}.{relation}")
if not ok:
all_ok = False
print(f" -> {msg}")
if all_ok:
print("\n=== Toutes les relations fonctionnent ! ===")
return
print("\n=== Recréation des relations manquantes ===\n")
# Define all relations to create
relations = [
# ChargingStations
("ChargingStations", "location", "object", "Locations", {"locationId": "id"}),
("ChargingStations", "tenant", "object", "Tenants", {"tenantId": "id"}),
("ChargingStations", "evses", "array", "Evses", {"id": "stationId"}),
("ChargingStations", "connectors", "array", "Connectors", {"id": "stationId"}),
("ChargingStations", "transactions", "array", "Transactions", {"id": "stationId"}),
("ChargingStations", "latestStatusNotifications", "array", "LatestStatusNotifications", {"id": "stationId"}),
("ChargingStations", "variableAttributes", "array", "VariableAttributes", {"id": "stationPkId"}),
# Evses
("Evses", "connectors", "array", "Connectors", {"evseId": "evseId"}),
("Evses", "transactions", "array", "Transactions", {"evseId": "evseId"}),
("Evses", "chargingStation", "object", "ChargingStations", {"stationId": "id"}),
# Connectors
("Connectors", "evse", "object", "Evses", {"evseId": "id"}),
("Connectors", "chargingStation", "object", "ChargingStations", {"stationId": "id"}),
# Transactions
("Transactions", "chargingStation", "object", "ChargingStations", {"stationId": "id"}),
("Transactions", "evse", "object", "Evses", {"evseId": "id"}),
("Transactions", "connector", "object", "Connectors", {"connectorId": "id"}),
("Transactions", "authorization", "object", "Authorizations", {"authorizationId": "id"}),
("Transactions", "location", "object", "Locations", {"locationId": "id"}),
# LatestStatusNotifications
("LatestStatusNotifications", "statusNotification", "object", "StatusNotifications", {"statusNotificationId": "id"}),
("LatestStatusNotifications", "chargingStation", "object", "ChargingStations", {"stationId": "id"}),
# Locations
("Locations", "chargingStations", "array", "ChargingStations", {"locationId": "id"}),
# StatusNotifications
("StatusNotifications", "chargingStation", "object", "ChargingStations", {"stationId": "id"}),
("StatusNotifications", "evse", "object", "Evses", {"evseId": "id"}),
("StatusNotifications", "connector", "object", "Connectors", {"connectorId": "id"}),
# Authorizations
("Authorizations", "transactions", "array", "Transactions", {"authorizationId": "id"}),
("Authorizations", "chargingStation", "object", "ChargingStations", {"stationId": "id"}),
# Tenants
("Tenants", "chargingStations", "array", "ChargingStations", {"tenantId": "id"}),
("Tenants", "evses", "array", "Evses", {"tenantId": "id"}),
("Tenants", "connectors", "array", "Connectors", {"tenantId": "id"}),
("Tenants", "transactions", "array", "Transactions", {"tenantId": "id"}),
("Tenants", "locations", "array", "Locations", {"tenantId": "id"}),
("Tenants", "authorizations", "array", "Authorizations", {"tenantId": "id"}),
("Tenants", "tenantPartners", "array", "TenantPartners", {"tenantId": "id"}),
("Tenants", "statusNotifications", "array", "StatusNotifications", {"tenantId": "id"}),
("Tenants", "latestStatusNotifications", "array", "LatestStatusNotifications", {"tenantId": "id"}),
("Tenants", "variableAttributes", "array", "VariableAttributes", {"tenantId": "id"}),
("Tenants", "chargingProfiles", "array", "ChargingProfiles", {"tenantId": "id"}),
("Tenants", "chargingSchedules", "array", "ChargingSchedules", {"tenantId": "id"}),
("Tenants", "salesTariffs", "array", "SalesTariffs", {"tenantId": "id"}),
("Tenants", "messageInfos", "array", "MessageInfos", {"tenantId": "id"}),
("Tenants", "securityEvents", "array", "SecurityEvents", {"tenantId": "id"}),
("Tenants", "certificates", "array", "Certificates", {"tenantId": "id"}),
("Tenants", "boots", "array", "Boots", {"tenantId": "id"}),
("Tenants", "asyncJobStatuses", "array", "AsyncJobStatuses", {"tenantId": "id"}),
("Tenants", "eventData", "array", "EventData", {"tenantId": "id"}),
("Tenants", "subscriptions", "array", "Subscriptions", {"tenantId": "id"}),
("Tenants", "reservations", "array", "Reservations", {"tenantId": "id"}),
("Tenants", "transactionEvents", "array", "TransactionEvents", {"tenantId": "id"}),
("Tenants", "startTransactions", "array", "StartTransactions", {"tenantId": "id"}),
("Tenants", "stopTransactions", "array", "StopTransactions", {"tenantId": "id"}),
("Tenants", "meterValues", "array", "MeterValues", {"tenantId": "id"}),
("Tenants", "chargingStationNetworkProfiles", "array", "ChargingStationNetworkProfiles", {"tenantId": "id"}),
("Tenants", "serverNetworkProfiles", "array", "ServerNetworkProfiles", {"tenantId": "id"}),
("Tenants", "componentVariables", "array", "ComponentVariables", {"tenantId": "id"}),
("Tenants", "components", "array", "Components", {"tenantId": "id"}),
("Tenants", "variables", "array", "Variables", {"tenantId": "id"}),
("Tenants", "evseTypes", "array", "EvseTypes", {"tenantId": "id"}),
("Tenants", "compositeSchedules", "array", "CompositeSchedules", {"tenantId": "id"}),
("Tenants", "changeConfigurations", "array", "ChangeConfigurations", {"tenantId": "id"}),
("Tenants", "chargingStationSequences", "array", "ChargingStationSequences", {"tenantId": "id"}),
("Tenants", "chargingStationSecurityInfos", "array", "ChargingStationSecurityInfos", {"tenantId": "id"}),
("Tenants", "localListAuthorizations", "array", "LocalListAuthorizations", {"tenantId": "id"}),
("Tenants", "localListVersionAuthorizations", "array", "LocalListVersionAuthorizations", {"tenantId": "id"}),
("Tenants", "localListVersions", "array", "LocalListVersions", {"tenantId": "id"}),
("Tenants", "sendLocalListAuthorizations", "array", "SendLocalListAuthorizations", {"tenantId": "id"}),
("Tenants", "sendLocalLists", "array", "SendLocalLists", {"tenantId": "id"}),
("Tenants", "deleteCertificateAttempts", "array", "DeleteCertificateAttempts", {"tenantId": "id"}),
("Tenants", "installCertificateAttempts", "array", "InstallCertificateAttempts", {"tenantId": "id"}),
("Tenants", "installedCertificates", "array", "InstalledCertificates", {"tenantId": "id"}),
]
created = 0
failed = 0
for table, name, rel_type, remote_table, col_map in relations:
print(f"Creating {table}.{name}...", end=" ")
if create_relation(table, name, rel_type, remote_table, col_map):
print("OK")
created += 1
else:
print("FAIL")
failed += 1
print(f"\n=== Résultat: {created} créées, {failed} échouées ===")
# Retest critical relations
print("\n=== Vérification finale ===\n")
all_ok = True
for table, relation, fields in critical_relations:
ok, msg = test_relation(table, relation, fields)
status = "OK" if ok else "FAIL"
print(f" {status}: {table}.{relation}")
if not ok:
all_ok = False
if all_ok:
print("\n🎉 Toutes les relations fonctionnent !")
else:
print("\n❌ Certaines relations ne fonctionnent pas encore.")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
const net = require('net');
const crypto = require('crypto');
const PASSWORD='DEADBEEFDEADBEEF';
const STATIONS = Array.from({length: 15}, (_, i) => ({
id: `CP${String(i+1).padStart(3, '0')}`,
path: `/1/CP${String(i+1).padStart(3, '0')}`,
}));
const WS_HOST = process.env.OCPP_HOST || 'cariflex-citrineos-server';
const WS_PORT = parseInt(process.env.OCPP_PORT || '8082');
let msgId = 0;
function encodeFrame(payload) {
const maskKey = crypto.randomBytes(4);
const pb = Buffer.from(payload, 'utf8');
const len = pb.length;
const fl = len < 126 ? 6 + len : 8 + len;
const f = Buffer.alloc(fl);
f[0] = 0x81;
if (len < 126) { f[1] = 0x80 | len; }
else { f[1] = 0x80 | 126; f.writeUInt16BE(len, 2); }
maskKey.copy(f, fl - len - 4);
for (let i = 0; i < len; i++) f[fl - len + i] = pb[i] ^ maskKey[i % 4];
return f;
}
function parseFrames(buf) {
const msgs = [];
while (buf.length >= 2) {
const op = buf[0] & 0x0F;
const masked = (buf[1] & 0x80) !== 0;
let pl = buf[1] & 0x7F, hl = 2;
if (pl === 126) { if (buf.length < 4) return msgs; pl = buf.readUInt16BE(2); hl = 4; }
else if (pl === 127) { if (buf.length < 10) return msgs; pl = Number(buf.readBigUInt64BE(2)); hl = 10; }
if (masked) hl += 4;
const tl = hl + pl;
if (buf.length < tl) return msgs;
let payload = buf.slice(hl, hl + pl);
if (masked) {
const mk = buf.slice(hl - 4, hl);
const u = Buffer.alloc(pl);
for (let i = 0; i < pl; i++) u[i] = payload[i] ^ mk[i % 4];
payload = u;
}
msgs.push({ op, payload: payload.toString('utf8') });
buf = buf.slice(tl);
}
return msgs;
}
function connect(station) {
return new Promise((resolve, reject) => {
const sock = net.createConnection(WS_PORT, WS_HOST, () => {
const key = crypto.randomBytes(16).toString('base64');
const enc = Buffer.from(station.id + ':' + PASSWORD).toString('base64');
sock.write('GET ' + station.path + ' HTTP/1.1\r\nHost: ' + WS_HOST + ':' + WS_PORT + '\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ' + key + '\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: ocpp2.0.1\r\nAuthorization: Basic ' + enc + '\r\n\r\n');
});
let buf = Buffer.alloc(0), ready = false;
const sp = new Map();
sock.on('data', (data) => {
buf = Buffer.concat([buf, data]);
if (!ready) {
const s = buf.toString('utf8');
const he = s.indexOf('\r\n\r\n');
if (he === -1) return;
const sl = s.split('\r\n')[0];
if (sl.includes('101')) { ready = true; buf = buf.slice(he + 4); resolve({ sock, station, sendCall: mkSend(sp, sock), sp }); }
else { console.log('[' + station.id + '] Handshake: ' + sl); sock.end(); reject(new Error('fail')); }
return;
}
const msgs = parseFrames(buf);
for (const m of msgs) { if (m.op === 0x1) handleMsg(m.payload, sp, sock); else if (m.op === 0x8) sock.end(); }
});
sock.on('error', (e) => { console.error('[' + station.id + '] ' + e.message); reject(e); });
sock.on('close', () => console.log('[' + station.id + '] DC'));
setTimeout(() => reject(new Error('timeout')), 10000);
});
}
function mkSend(sp, sock) {
return function(action, payload) {
return new Promise((res, rej) => {
const id = String(++msgId);
sp.set(id, { res, rej });
sock.write(encodeFrame(JSON.stringify([2, id, action, payload])));
setTimeout(() => { if (sp.has(id)) { sp.delete(id); rej(new Error('timeout:' + action)); } }, 15000);
});
};
}
function handleMsg(msg, sp, sock) {
try {
const d = JSON.parse(msg);
if (d[0] === 3 && sp.has(d[1])) { sp.get(d[1]).res(d[2]); sp.delete(d[1]); }
else if (d[0] === 2) { sock.write(encodeFrame(JSON.stringify([3, d[1], {}]))); }
} catch(e) {}
}
async function bootStation(station) {
// Retry connection up to 10 times with 3s delay
for (let attempt = 1; attempt <= 10; attempt++) {
try {
const { sock, sendCall } = await connect(station);
console.log('[' + station.id + '] Connected (attempt ' + attempt + ')');
const boot = await sendCall('BootNotification', { chargingStation: { model: station.id, vendorName: 'Cariflex', firmwareVersion: '1.0.0', serialNumber: station.id }, reason: 'PowerUp' });
console.log('[' + station.id + '] Boot: ' + boot.status);
await sendCall('StatusNotification', { evseId: 1, connectorId: 1, connectorStatus: 'Available', timestamp: new Date().toISOString() });
console.log('[' + station.id + '] Available');
return { sock, sendCall };
} catch(e) {
console.log('[' + station.id + '] Attempt ' + attempt + ' failed: ' + e.message);
if (attempt < 10) await new Promise(r => setTimeout(r, 3000));
}
}
throw new Error('Failed after 10 attempts');
}
async function main() {
const tenantId = '1';
console.log('Simulating ' + STATIONS.length + ' stations...');
const online = [];
for (const st of STATIONS) {
try { const s = await bootStation(st); online.push(Object.assign({}, st, s)); await new Promise(r => setTimeout(r, 300)); }
catch(e) { console.error('[' + st.id + '] ' + e.message); }
}
console.log('\n=== ' + online.length + '/' + STATIONS.length + ' ONLINE ===');
setInterval(async () => { for (const s of online) { try { await s.sendCall('Heartbeat', {}); } catch(e) {} } console.log('[HB] ' + online.length); }, 60000);
}
process.on('SIGINT', () => { console.log('exit'); process.exit(0); });
main().catch(e => { console.error(e); process.exit(1); });

281
scripts/ocpp-simulator.js Normal file
View File

@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* OCPP 2.0.1 Charging Station Simulator for Cariflex/CitrineOS
* Uses raw TCP WebSocket (bypassing ws module subprotocol issues)
*/
const net = require('net');
const crypto = require('crypto');
const CONFIG = {
host: process.env.OCPP_HOST || 'localhost',
port: parseInt(process.env.OCPP_PORT || '8082'),
path: '/1/CP001',
stationId: 'CP001',
password: 'DEADBEEFDEADBEEF',
vendorName: 'Cariflex',
firmwareVersion: '1.0.0',
};
let msgId = 0;
const pending = new Map();
let socket = null;
let buffer = Buffer.alloc(0);
let wsReady = false;
function encodeFrame(payload) {
const maskKey = crypto.randomBytes(4);
const payloadBuf = Buffer.from(payload, 'utf8');
const len = payloadBuf.length;
let frameLen;
if (len < 126) {
frameLen = 6 + len; // 2 header + 4 mask + payload
} else if (len < 65536) {
frameLen = 8 + len; // 2 header + 2 ext len + 4 mask + payload
} else {
frameLen = 16 + len; // 2 header + 8 ext len + 4 mask + payload
}
const frame = Buffer.alloc(frameLen);
frame[0] = 0x81; // FIN + text
if (len < 126) {
frame[1] = 0x80 | len;
} else if (len < 65536) {
frame[1] = 0x80 | 126;
frame.writeUInt16BE(len, 2);
} else {
frame[1] = 0x80 | 127;
frame.writeBigUInt64BE(BigInt(len), 2);
}
maskKey.copy(frame, frameLen - len - 4);
for (let i = 0; i < len; i++) {
frame[frameLen - len + i] = payloadBuf[i] ^ maskKey[i % 4];
}
return frame;
}
function parseFrames() {
const messages = [];
while (buffer.length >= 2) {
const firstByte = buffer[0];
const secondByte = buffer[1];
const opcode = firstByte & 0x0F;
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7F;
let headerLen = 2;
if (payloadLen === 126) {
if (buffer.length < 4) return messages;
payloadLen = buffer.readUInt16BE(2);
headerLen = 4;
} else if (payloadLen === 127) {
if (buffer.length < 10) return messages;
payloadLen = Number(buffer.readBigUInt64BE(2));
headerLen = 10;
}
if (masked) headerLen += 4;
const totalLen = headerLen + payloadLen;
if (buffer.length < totalLen) return messages;
let payload = buffer.slice(headerLen, headerLen + payloadLen);
if (masked) {
const maskKey = buffer.slice(headerLen - 4, headerLen);
const unmasked = Buffer.alloc(payloadLen);
for (let i = 0; i < payloadLen; i++) {
unmasked[i] = payload[i] ^ maskKey[i % 4];
}
payload = unmasked;
}
messages.push({ opcode, payload: payload.toString('utf8') });
buffer = buffer.slice(totalLen);
}
return messages;
}
function sendCall(action, payload) {
return new Promise((resolve, reject) => {
const id = String(++msgId);
const msg = JSON.stringify([2, id, action, payload]);
pending.set(id, { resolve, reject });
console.log(`[SEND] ${action} (${id})`);
socket.write(encodeFrame(msg));
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`Timeout: ${action}`));
}
}, 15000);
});
}
function handleMessage(msg) {
try {
const data = JSON.parse(msg);
if (data[0] === 3) { // CallResult
const id = data[1];
if (pending.has(id)) {
pending.get(id).resolve(data[2]);
pending.delete(id);
}
} else if (data[0] === 2) { // Call from server
const id = data[1];
const action = data[2];
console.log(`[CALL] ${action} from server`);
const response = JSON.stringify([3, id, {}]);
socket.write(encodeFrame(response));
} else if (data[0] === 4) { // Error
const id = data[1];
console.log(`[ERROR] ${data[2]}: ${data[3]}`);
if (pending.has(id)) {
pending.get(id).reject(new Error(`${data[2]}: ${data[3]}`));
pending.delete(id);
}
}
} catch (e) {
console.error('[PARSE ERROR]', e.message, '- raw:', JSON.stringify(msg.substring(0, 100)));
}
}
function connect() {
return new Promise((resolve, reject) => {
console.log(`Connecting to ${CONFIG.host}:${CONFIG.port}${CONFIG.path}...`);
socket = net.createConnection(CONFIG.port, CONFIG.host, () => {
const key = crypto.randomBytes(16).toString('base64');
const encoded = Buffer.from(CONFIG.stationId + ':' + CONFIG.password).toString('base64');
const handshake = `GET ${CONFIG.path} HTTP/1.1\r\n` +
`Host: ${CONFIG.host}:${CONFIG.port}\r\n` +
`Upgrade: websocket\r\n` +
`Connection: Upgrade\r\n` +
`Sec-WebSocket-Key: ${key}\r\n` +
`Sec-WebSocket-Version: 13\r\n` +
`Sec-WebSocket-Protocol: ocpp2.0.1\r\n` +
`Authorization: Basic ${encoded}\r\n` +
`\r\n`;
socket.write(handshake);
});
socket.on('data', (data) => {
buffer = Buffer.concat([buffer, data]);
if (!wsReady) {
const str = buffer.toString('utf8');
const headerEnd = str.indexOf('\r\n\r\n');
if (headerEnd === -1) return; // Wait for full HTTP response
const statusLine = str.split('\r\n')[0];
console.log('Handshake:', statusLine);
if (statusLine.includes('101')) {
wsReady = true;
buffer = buffer.slice(headerEnd + 4); // Skip past HTTP headers
console.log('✅ WebSocket connected!');
resolve();
} else {
console.log('❌ Handshake failed');
console.log(str.substring(0, 500));
socket.end();
reject(new Error('Handshake failed'));
}
return;
}
// Parse WebSocket frames
const messages = parseFrames();
for (const msg of messages) {
if (msg.opcode === 0x1) { // Text frame
handleMessage(msg.payload);
} else if (msg.opcode === 0x8) { // Close frame
console.log('[CLOSE] Server sent close frame');
socket.end();
} else if (msg.opcode === 0x9) { // Ping
// Send pong
socket.write(encodeFrame(''));
}
}
});
socket.on('error', (e) => {
console.error('[SOCKET ERROR]', e.message);
reject(e);
});
socket.on('close', () => {
console.log('[DISCONNECTED]');
});
setTimeout(() => reject(new Error('Connection timeout')), 10000);
});
}
async function run() {
try {
await connect();
// BootNotification
console.log('Sending BootNotification...');
const bootResult = await sendCall('BootNotification', {
chargingStation: {
model: CONFIG.stationId,
vendorName: CONFIG.vendorName,
firmwareVersion: CONFIG.firmwareVersion,
serialNumber: CONFIG.stationId,
},
reason: 'PowerUp',
});
console.log('✅ BootNotification result:', bootResult);
// StatusNotification - Available
console.log('Sending StatusNotification (Available)...');
await sendCall('StatusNotification', {
evseId: 1,
connectorId: 1,
connectorStatus: 'Available',
timestamp: new Date().toISOString(),
});
console.log('✅ Connector 1 is Available');
// Heartbeat
console.log('Sending Heartbeat...');
const hbResult = await sendCall('Heartbeat', {});
console.log('✅ Heartbeat result:', hbResult);
console.log('');
console.log('=== ✅ Station CP001 is ONLINE and READY ===');
console.log('=== Press Ctrl+C to stop ===');
// Keep alive with heartbeats every 60s
setInterval(async () => {
try {
await sendCall('Heartbeat', {});
console.log('[HEARTBEAT] ✅');
} catch (e) {
console.error('[HEARTBEAT] ❌', e.message);
}
}, 60000);
} catch (e) {
console.error('❌ Failed:', e.message);
process.exit(1);
}
}
process.on('SIGINT', () => {
console.log('\nShutting down...');
if (socket) socket.end();
process.exit(0);
});
run();

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* OCPP 2.0.1 Security Profile 0 connector for Cariflex/CitrineOS
* Connects via WebSocket to CSMS on port 8081 (no auth) to update ocppConnectionName
* The main simulator handles port 8082 (Security Profile 1, Basic Auth) for actual OCPP messages
*/
const net = require('net');
const crypto = require('crypto');
const WS_HOST = process.env.OCPP_HOST || 'cariflex-citrineos-server';
const WS_PORT = parseInt(process.env.OCPP_PORT || '8081');
function encodeFrame(payload) {
const maskKey = crypto.randomBytes(4);
const pb = Buffer.from(payload, 'utf8');
const len = pb.length;
const fl = len < 126 ? 6 + len : 8 + len;
const f = Buffer.alloc(fl);
f[0] = 0x81;
if (len < 126) { f[1] = 0x80 | len; }
else { f[1] = 0x80 | 126; f.writeUInt16BE(len, 2); }
maskKey.copy(f, fl - len - 4);
for (let i = 0; i < len; i++) f[fl - len + i] = pb[i] ^ maskKey[i % 4];
return f;
}
function parseFrames(buf) {
const msgs = [];
while (buf.length >= 2) {
const op = buf[0] & 0x0F;
const masked = (buf[1] & 0x80) !== 0;
let pl = buf[1] & 0x7F, hl = 2;
if (pl === 126) { if (buf.length < 4) return msgs; pl = buf.readUInt16BE(2); hl = 4; }
else if (pl === 127) { if (buf.length < 10) return msgs; pl = Number(buf.readBigUInt64BE(2)); hl = 10; }
if (masked) hl += 4;
const tl = hl + pl;
if (buf.length < tl) return msgs;
let payload = buf.slice(hl, hl + pl);
if (masked) {
const mk = buf.slice(hl - 4, hl);
const u = Buffer.alloc(pl);
for (let i = 0; i < pl; i++) u[i] = payload[i] ^ mk[i % 4];
payload = u;
}
msgs.push({ op, payload: payload.toString('utf8') });
buf = buf.slice(tl);
}
return msgs;
}
async function connectStation(stationId) {
return new Promise((resolve, reject) => {
const sock = net.createConnection(WS_PORT, WS_HOST, () => {
const key = crypto.randomBytes(16).toString('base64');
const handshake = `GET /${stationId} HTTP/1.1\r\nHost: ${WS_HOST}:${WS_PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: ocpp2.0.1\r\n\r\n`;
sock.write(handshake);
});
let buf = Buffer.alloc(0), ready = false, msgId = 0;
const pending = new Map();
sock.on('data', (data) => {
buf = Buffer.concat([buf, data]);
if (!ready) {
const s = buf.toString('utf8');
const he = s.indexOf('\r\n\r\n');
if (he === -1) return;
const sl = s.split('\r\n')[0];
if (sl.includes('101')) {
ready = true;
buf = buf.slice(he + 4);
// Send BootNotification
const id = String(++msgId);
const payload = JSON.stringify([2, id, 'BootNotification', JSON.stringify({ chargingStation: { model: stationId, vendorName: 'Cariflex', firmwareVersion: '1.0.0', serialNumber: stationId }, reason: 'PowerUp' })]);
sock.write(encodeFrame(payload));
pending.set(id, { resolve: () => {}, reject: () => {} });
} else {
sock.end();
reject(new Error('Handshake failed: ' + sl));
}
return;
}
const msgs = parseFrames(buf);
for (const m of msgs) {
if (m.op === 0x1) {
try {
const d = JSON.parse(m.payload);
if (d[0] === 3) {
console.log('[' + stationId + '] Boot: ' + d[2].status);
// Now send StatusNotification
const id2 = String(++msgId);
const sn = JSON.stringify([2, id2, 'StatusNotification', JSON.stringify({ evseId: 1, connectorId: 1, connectorStatus: 'Available', timestamp: new Date().toISOString() })]);
sock.write(encodeFrame(sn));
console.log('[' + stationId + '] StatusNotification sent');
}
// Respond to server calls
if (d[0] === 2) {
sock.write(encodeFrame(JSON.stringify([3, d[1], {}])));
}
} catch(e) {}
if (d[0] === 3 && d[2] && d[2].status === 'Accepted') {
resolve({ sock, pending });
}
} else if (m.op === 0x8) {
sock.end();
}
}
});
sock.on('error', (e) => { console.error('[' + stationId + '] ' + e.message); reject(e); });
sock.on('close', () => console.log('[' + stationId + '] DC'));
setTimeout(() => reject(new Error('timeout')), 10000);
});
}
async function main() {
const stations = Array.from({length: 15}, (_, i) => 'CP' + String(i+1).padStart(3, '0'));
console.log('Connecting ' + stations.length + ' stations to port ' + WS_PORT + ' (Security Profile 0)...');
const connections = [];
for (const st of stations) {
try {
const conn = await connectStation(st);
connections.push({ id: st, ...conn });
console.log('[' + st + '] Connected!');
await new Promise(r => setTimeout(r, 200));
} catch(e) {
console.error('[' + st + '] Failed: ' + e.message);
}
}
console.log('\n=== ' + connections.length + '/' + stations.length + ' connected on SP0 ===');
// Keep alive
setInterval(() => {}, 60000);
}
process.on('SIGINT', () => { console.log('exit'); process.exit(0); });
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1 @@
{"server":{"host":"0.0.0.0","port":8080},"database":{"host":"cariflex-citrineos-db","port":5432,"database":"citrine","username":"citrine","password":"citrine"},"amqp":{"host":"amqp-broker","port":5672,"username":"guest","password":"guest"},"logging":{"level":"info"}}

View File

@@ -0,0 +1,10 @@
NEXT_PUBLIC_AUTH_PROVIDER=generic
NEXT_PUBLIC_API_URL=https://hasura.digitribe.fr/v1/graphql
NEXT_PUBLIC_CITRINE_CORE_URL=https://citrineos-core.digitribe.fr
HASURA_ADMIN_SECRET=Digitribe972
NEXT_PUBLIC_TENANT_ID=1
NEXT_PUBLIC_ADMIN_EMAIL=admin@digitribe.fr
ADMIN_PASSWORD=Digitribe972
NEXT_PUBLIC_WS_URL=wss://hasura.digitribe.fr/v1/graphql
NEXTAUTH_SECRET=C1tR1n30S2026S3cr3t
NEXTAUTH_URL=https://citrineos.digitribe.fr

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
cariflex-citrineos-operator-ui citrineos-core-main-citrine-ui:latest Up 3 hours
cariflex-hasura hasura/graphql-engine:v2.40.0 Up 4 hours (healthy)
cariflex-ocpp-simulator config-ocpp-simulator Up 5 hours
f33f58046fca_cariflex-amqp rabbitmq:3-management Up 5 hours (healthy)
cariflex-everest-manager everest-manager:latest Restarting (1) 11 seconds ago
cariflex-everest-nodered ghcr.io/everest/everest-demo/nodered:0.0.16 Up 17 hours (healthy)
cariflex-everest-mqtt ghcr.io/everest/everest-demo/mqtt-server:0.0.16 Up 17 hours
cariflex-citrineos-server ghcr.io/citrineos/citrineos-server:latest Up 4 days
cariflex-citrineos-db postgis/postgis:16-3.5 Up 4 days (healthy)

View File

@@ -0,0 +1,158 @@
version: '3.8'
services:
citrineos-server:
image: ghcr.io/citrineos/citrineos-server:latest
container_name: cariflex-citrineos-server
restart: unless-stopped
environment:
APP_NAME: "all"
APP_ENV: "docker"
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
DB_STRATEGY: "migrate"
BOOTSTRAP_CITRINEOS_DATABASE_HOST: "cariflex-citrineos-db"
BOOTSTRAP_CITRINEOS_CONFIG_FILENAME: "config.json"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_TYPE: "local"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_LOCAL_FILE_PATH: "/data"
CONFIG_CITRINEOS_WIPE_FILE_ON_START: "true"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
cariflex-amqp:
condition: service_healthy
volumes:
- citrineos-data:/data
ports:
- "8081:8080"
networks:
- cariflex-internal
cariflex-citrineos-db:
image: postgis/postgis:16-3.5
container_name: cariflex-citrineos-db
restart: unless-stopped
environment:
POSTGRES_DB: citrine
POSTGRES_USER: citrine
POSTGRES_PASSWORD: citrine
volumes:
- citrineos-db-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready --username=citrine
interval: 5s
timeout: 10s
retries: 5
networks:
- cariflex-internal
cariflex-amqp:
image: rabbitmq:3-management
container_name: cariflex-amqp
networks:
cariflex-internal:
aliases:
- amqp-broker
traefik-public:
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
labels:
- "traefik.enable=true"
- "traefik.http.routers.rabbitmq.rule=Host(`amqp.digitribe.fr`)"
- "traefik.http.routers.rabbitmq.entrypoints=websecure"
- "traefik.http.routers.rabbitmq.tls.certresolver=letsencrypt"
- "traefik.http.services.rabbitmq.loadbalancer.server.port=15672"
volumes:
- citrineos-amqp-data:/var/lib/rabbitmq
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
hasura:
image: hasura/graphql-engine:v2.40.0
container_name: cariflex-hasura
restart: unless-stopped
ports:
- "8082:8080"
environment:
HASURA_GRAPHQL_DATABASE_URL: "postgresql://citrine:***@cariflex-citrineos-db:5432/citrine"
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: "Digitribe972"
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: "anonymous"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.hasura.rule=Host(`hasura.digitribe.fr`)"
- "traefik.http.routers.hasura.entrypoints=websecure"
- "traefik.http.routers.hasura.tls.certresolver=letsencrypt"
- "traefik.http.services.hasura.loadbalancer.server.port=8080"
networks:
- traefik-public
- cariflex-internal
citrineos-operator-ui:
image: citrineos-core-main-citrine-ui:latest
container_name: cariflex-citrineos-operator-ui
restart: unless-stopped
ports:
- "3002:3000"
environment:
NEXTAUTH_SECRET: Digitribe972
ADMIN_PASSWORD: Digitribe972
depends_on:
- hasura
labels:
- "traefik.enable=true"
- "traefik.http.routers.citrineos-ui.rule=Host(`citrineos.digitribe.fr`)"
- "traefik.http.routers.citrineos-ui.entrypoints=websecure"
- "traefik.http.routers.citrineos-ui.tls.certresolver=letsencrypt"
- "traefik.http.services.citrineos-ui.loadbalancer.server.port=3000"
networks:
- traefik-public
- cariflex-internal
# === EVerest (simulateur de charge OCPP 2.0.1) ===
everest-mqtt:
image: ghcr.io/everest/everest-demo/mqtt-server:0.0.16
container_name: cariflex-everest-mqtt
restart: unless-stopped
networks:
- cariflex-internal
everest-nodered:
image: ghcr.io/everest/everest-demo/nodered:0.0.16
container_name: cariflex-everest-nodered
restart: unless-stopped
depends_on:
- everest-mqtt
environment:
- MQTT_SERVER_ADDRESS=everest-mqtt
- FLOWS=/config/config-sil-two-evse-flow.json
networks:
- cariflex-internal
ports:
- "1880:1880"
volumes:
citrineos-data:
driver: local
citrineos-db-data:
driver: local
citrineos-amqp-data:
driver: local
networks:
traefik-public:
external: true
cariflex-internal:
name: config_cariflex-internal
external: true

View File

@@ -0,0 +1,171 @@
version: '3.8'
services:
citrineos-server:
image: ghcr.io/citrineos/citrineos-server:latest
container_name: cariflex-citrineos-server
restart: unless-stopped
environment:
APP_NAME: "all"
APP_ENV: "docker"
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
DB_STRATEGY: "migrate"
BOOTSTRAP_CITRINEOS_DATABASE_HOST: "cariflex-citrineos-db"
BOOTSTRAP_CITRINEOS_CONFIG_FILENAME: "config.json"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_TYPE: "local"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_LOCAL_FILE_PATH: "/data"
CONFIG_CITRINEOS_WIPE_FILE_ON_START: "true"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
cariflex-amqp:
condition: service_healthy
volumes:
- citrineos-data:/data
ports:
- "8081:8080"
networks:
- cariflex-internal
cariflex-citrineos-db:
image: postgis/postgis:16-3.5
container_name: cariflex-citrineos-db
restart: unless-stopped
environment:
POSTGRES_DB: citrine
POSTGRES_USER: citrine
POSTGRES_PASSWORD: citrine
volumes:
- citrineos-db-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready --username=citrine
interval: 5s
timeout: 10s
retries: 5
networks:
- cariflex-internal
cariflex-amqp:
image: rabbitmq:3-management
container_name: cariflex-amqp
networks:
cariflex-internal:
aliases:
- amqp-broker
traefik-public:
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
labels:
- "traefik.enable=true"
- "traefik.http.routers.rabbitmq.rule=Host(`amqp.digitribe.fr`)"
- "traefik.http.routers.rabbitmq.entrypoints=websecure"
- "traefik.http.routers.rabbitmq.tls.certresolver=letsencrypt"
- "traefik.http.services.rabbitmq.loadbalancer.server.port=15672"
volumes:
- citrineos-amqp-data:/var/lib/rabbitmq
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
hasura:
image: hasura/graphql-engine:v2.40.0
container_name: cariflex-hasura
restart: unless-stopped
ports:
- "8082:8080"
environment:
HASURA_GRAPHQL_DATABASE_URL: "postgresql://citrine:citrine@cariflex-citrineos-db:5432/citrine"
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: "Digitribe972"
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: "anonymous"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.hasura.rule=Host(`hasura.digitribe.fr`)"
- "traefik.http.routers.hasura.entrypoints=websecure"
- "traefik.http.routers.hasura.tls.certresolver=letsencrypt"
- "traefik.http.services.hasura.loadbalancer.server.port=8080"
networks:
- traefik-public
- cariflex-internal
citrineos-operator-ui:
image: citrineos-core-main-citrine-ui:latest
container_name: cariflex-citrineos-operator-ui
restart: unless-stopped
ports:
- "3002:3000"
environment:
NEXTAUTH_SECRET: Digitribe972
ADMIN_PASSWORD: Digitribe972
depends_on:
- hasura
labels:
- "traefik.enable=true"
- "traefik.http.routers.citrineos-ui.rule=Host(`citrineos.digitribe.fr`)"
- "traefik.http.routers.citrineos-ui.entrypoints=websecure"
- "traefik.http.routers.citrineos-ui.tls.certresolver=letsencrypt"
- "traefik.http.services.citrineos-ui.loadbalancer.server.port=3000"
networks:
- traefik-public
- cariflex-internal
# === EVerest MQTT + NodeRED (UI de contrôle) ===
everest-mqtt:
image: ghcr.io/everest/everest-demo/mqtt-server:0.0.16
container_name: cariflex-everest-mqtt
restart: unless-stopped
networks:
- cariflex-internal
everest-nodered:
image: ghcr.io/everest/everest-demo/nodered:0.0.16
container_name: cariflex-everest-nodered
restart: unless-stopped
depends_on:
- everest-mqtt
environment:
- MQTT_SERVER_ADDRESS=everest-mqtt
- FLOWS=/config/config-sil-two-evse-flow.json
networks:
- cariflex-internal
ports:
- "1880:1880"
# === OCPP 2.0.1 Simulators ===
ocpp-simulator:
build:
context: /home/eric/cariflex/scripts
dockerfile: Dockerfile.simulator
container_name: cariflex-ocpp-simulator
restart: unless-stopped
environment:
OCPP_HOST: "cariflex-citrineos-server"
OCPP_PORT: "8082"
networks:
- cariflex-internal
volumes:
citrineos-data:
driver: local
citrineos-db-data:
driver: local
citrineos-amqp-data:
driver: local
networks:
traefik-public:
external: true
cariflex-internal:
name: config_cariflex-internal
external: true

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
FROM node:22-alpine
WORKDIR /app
COPY ocpp-simulator.js .
COPY ocpp-simulator-multi.js .
CMD ["node", "ocpp-simulator-multi.js"]

View File

@@ -0,0 +1,5 @@
FROM node:22-alpine
WORKDIR /app
COPY ocpp-simulator-multi.js .
COPY ocpp-sp0-connector.js .
CMD ["node", "ocpp-sp0-connector.js"]

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Configure BasicAuthPassword for all 15 Cariflex charging stations"""
import json, time, urllib.request, urllib.error
CITRINEOS_URL = "http://localhost:8081"
PASSWORD = "DEADBEEFDEADBEEF"
for i in range(1, 16):
cp_id = f"CP{i:03d}"
url = f"{CITRINEOS_URL}/data/monitoring/variableAttribute?stationId={cp_id}&setOnCharger=true"
payload = json.dumps({
"component": {"name": "SecurityCtrlr"},
"variable": {"name": "BasicAuthPassword"},
"variableAttribute": [{"value": PASSWORD}],
"variableCharacteristics": {"dataType": "passwordString", "supportsMonitoring": False}
}).encode()
req = urllib.request.Request(url, data=payload, method='PUT',
headers={'Content-Type': 'application/json'})
try:
resp = urllib.request.urlopen(req, timeout=10)
print(f"OK {cp_id}: HTTP {resp.status}")
except urllib.error.HTTPError as e:
print(f"FAIL {cp_id}: HTTP {e.code}")
except Exception as e:
print(f"FAIL {cp_id}: {e}")
time.sleep(0.2)
print("Done")

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# === Post-deploy OCPI + Everest configuration for Cariflex ===
# Run this AFTER docker compose up succeeds
set -e
CSMS_URL="http://localhost:8081"
GRAPHQL_URL="http://localhost:8090/v1/graphql"
DB_CONTAINER="cariflex-citrineos-db"
echo "=== Step 1: Seed OCPI database ==="
# Run inside the OCPI container to seed default tenant + emsp
docker exec cariflex-ocpi npm run seed-db 2>&1 || echo "Seed may have run before, continuing..."
echo "=== Step 2: Verify Citiveness ==="
# Check CitrineOS Core is healthy
curl -sf "${CSMS_URL}/ocpp/health" && echo "CitrineOS Core: OK" || echo "CitrineOS Core: UNHEALTHY"
echo "=== Step 3: Configure EVerest Manager OCPP target ==="
# The manager already points to ws://cariflex-citrineos-server:8080/cp001 via Dockerfile
# Check manager is running
docker ps --filter "name=cariflex-everest-manager" --format "{{.Status}}" && echo "Everest Manager: running" || echo "Everest Manager: NOT RUNNING"
echo "=== Step 4: Verify UI connectivity ==="
echo " Everest NodeRED UI: http://localhost:1880/ui/"
echo " Everest OCPP Logs: http://localhost:8888"
echo " CitrineOS UI: https://citrineos.digitribe.fr"
echo " Hasura Console: https://hasura.digitribe.fr"
echo ""
echo "=== DONE ==="

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
const net = require('net');
const crypto = require('crypto');
const PASSWORD='DEADBEEFDEADBEEF';
const STATIONS = Array.from({length: 15}, (_, i) => ({
id: `CP${String(i+1).padStart(3, '0')}`,
path: `/1/CP${String(i+1).padStart(3, '0')}`,
}));
const WS_HOST = process.env.OCPP_HOST || 'cariflex-citrineos-server';
const WS_PORT = parseInt(process.env.OCPP_PORT || '8082');
let msgId = 0;
function encodeFrame(payload) {
const maskKey = crypto.randomBytes(4);
const pb = Buffer.from(payload, 'utf8');
const len = pb.length;
const fl = len < 126 ? 6 + len : 8 + len;
const f = Buffer.alloc(fl);
f[0] = 0x81;
if (len < 126) { f[1] = 0x80 | len; }
else { f[1] = 0x80 | 126; f.writeUInt16BE(len, 2); }
maskKey.copy(f, fl - len - 4);
for (let i = 0; i < len; i++) f[fl - len + i] = pb[i] ^ maskKey[i % 4];
return f;
}
function parseFrames(buf) {
const msgs = [];
while (buf.length >= 2) {
const op = buf[0] & 0x0F;
const masked = (buf[1] & 0x80) !== 0;
let pl = buf[1] & 0x7F, hl = 2;
if (pl === 126) { if (buf.length < 4) return msgs; pl = buf.readUInt16BE(2); hl = 4; }
else if (pl === 127) { if (buf.length < 10) return msgs; pl = Number(buf.readBigUInt64BE(2)); hl = 10; }
if (masked) hl += 4;
const tl = hl + pl;
if (buf.length < tl) return msgs;
let payload = buf.slice(hl, hl + pl);
if (masked) {
const mk = buf.slice(hl - 4, hl);
const u = Buffer.alloc(pl);
for (let i = 0; i < pl; i++) u[i] = payload[i] ^ mk[i % 4];
payload = u;
}
msgs.push({ op, payload: payload.toString('utf8') });
buf = buf.slice(tl);
}
return msgs;
}
function connect(station) {
return new Promise((resolve, reject) => {
const sock = net.createConnection(WS_PORT, WS_HOST, () => {
const key = crypto.randomBytes(16).toString('base64');
const enc = Buffer.from(station.id + ':' + PASSWORD).toString('base64');
sock.write('GET ' + station.path + ' HTTP/1.1\r\nHost: ' + WS_HOST + ':' + WS_PORT + '\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ' + key + '\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: ocpp2.0.1\r\nAuthorization: Basic ' + enc + '\r\n\r\n');
});
let buf = Buffer.alloc(0), ready = false;
const sp = new Map();
sock.on('data', (data) => {
buf = Buffer.concat([buf, data]);
if (!ready) {
const s = buf.toString('utf8');
const he = s.indexOf('\r\n\r\n');
if (he === -1) return;
const sl = s.split('\r\n')[0];
if (sl.includes('101')) { ready = true; buf = buf.slice(he + 4); resolve({ sock, station, sendCall: mkSend(sp, sock), sp }); }
else { console.log('[' + station.id + '] Handshake: ' + sl); sock.end(); reject(new Error('fail')); }
return;
}
const msgs = parseFrames(buf);
for (const m of msgs) { if (m.op === 0x1) handleMsg(m.payload, sp, sock); else if (m.op === 0x8) sock.end(); }
});
sock.on('error', (e) => { console.error('[' + station.id + '] ' + e.message); reject(e); });
sock.on('close', () => console.log('[' + station.id + '] DC'));
setTimeout(() => reject(new Error('timeout')), 10000);
});
}
function mkSend(sp, sock) {
return function(action, payload) {
return new Promise((res, rej) => {
const id = String(++msgId);
sp.set(id, { res, rej });
sock.write(encodeFrame(JSON.stringify([2, id, action, payload])));
setTimeout(() => { if (sp.has(id)) { sp.delete(id); rej(new Error('timeout:' + action)); } }, 15000);
});
};
}
function handleMsg(msg, sp, sock) {
try {
const d = JSON.parse(msg);
if (d[0] === 3 && sp.has(d[1])) { sp.get(d[1]).res(d[2]); sp.delete(d[1]); }
else if (d[0] === 2) { sock.write(encodeFrame(JSON.stringify([3, d[1], {}]))); }
} catch(e) {}
}
async function bootStation(station) {
// Retry connection up to 10 times with 3s delay
for (let attempt = 1; attempt <= 10; attempt++) {
try {
const { sock, sendCall } = await connect(station);
console.log('[' + station.id + '] Connected (attempt ' + attempt + ')');
const boot = await sendCall('BootNotification', { chargingStation: { model: station.id, vendorName: 'Cariflex', firmwareVersion: '1.0.0', serialNumber: station.id }, reason: 'PowerUp' });
console.log('[' + station.id + '] Boot: ' + boot.status);
await sendCall('StatusNotification', { evseId: 1, connectorId: 1, connectorStatus: 'Available', timestamp: new Date().toISOString() });
console.log('[' + station.id + '] Available');
return { sock, sendCall };
} catch(e) {
console.log('[' + station.id + '] Attempt ' + attempt + ' failed: ' + e.message);
if (attempt < 10) await new Promise(r => setTimeout(r, 3000));
}
}
throw new Error('Failed after 10 attempts');
}
async function main() {
const tenantId = '1';
console.log('Simulating ' + STATIONS.length + ' stations...');
const online = [];
for (const st of STATIONS) {
try { const s = await bootStation(st); online.push(Object.assign({}, st, s)); await new Promise(r => setTimeout(r, 300)); }
catch(e) { console.error('[' + st.id + '] ' + e.message); }
}
console.log('\n=== ' + online.length + '/' + STATIONS.length + ' ONLINE ===');
setInterval(async () => { for (const s of online) { try { await s.sendCall('Heartbeat', {}); } catch(e) {} } console.log('[HB] ' + online.length); }, 60000);
}
process.on('SIGINT', () => { console.log('exit'); process.exit(0); });
main().catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* OCPP 2.0.1 Charging Station Simulator for Cariflex/CitrineOS
* Uses raw TCP WebSocket (bypassing ws module subprotocol issues)
*/
const net = require('net');
const crypto = require('crypto');
const CONFIG = {
host: process.env.OCPP_HOST || 'localhost',
port: parseInt(process.env.OCPP_PORT || '8082'),
path: '/1/CP001',
stationId: 'CP001',
password: 'DEADBEEFDEADBEEF',
vendorName: 'Cariflex',
firmwareVersion: '1.0.0',
};
let msgId = 0;
const pending = new Map();
let socket = null;
let buffer = Buffer.alloc(0);
let wsReady = false;
function encodeFrame(payload) {
const maskKey = crypto.randomBytes(4);
const payloadBuf = Buffer.from(payload, 'utf8');
const len = payloadBuf.length;
let frameLen;
if (len < 126) {
frameLen = 6 + len; // 2 header + 4 mask + payload
} else if (len < 65536) {
frameLen = 8 + len; // 2 header + 2 ext len + 4 mask + payload
} else {
frameLen = 16 + len; // 2 header + 8 ext len + 4 mask + payload
}
const frame = Buffer.alloc(frameLen);
frame[0] = 0x81; // FIN + text
if (len < 126) {
frame[1] = 0x80 | len;
} else if (len < 65536) {
frame[1] = 0x80 | 126;
frame.writeUInt16BE(len, 2);
} else {
frame[1] = 0x80 | 127;
frame.writeBigUInt64BE(BigInt(len), 2);
}
maskKey.copy(frame, frameLen - len - 4);
for (let i = 0; i < len; i++) {
frame[frameLen - len + i] = payloadBuf[i] ^ maskKey[i % 4];
}
return frame;
}
function parseFrames() {
const messages = [];
while (buffer.length >= 2) {
const firstByte = buffer[0];
const secondByte = buffer[1];
const opcode = firstByte & 0x0F;
const masked = (secondByte & 0x80) !== 0;
let payloadLen = secondByte & 0x7F;
let headerLen = 2;
if (payloadLen === 126) {
if (buffer.length < 4) return messages;
payloadLen = buffer.readUInt16BE(2);
headerLen = 4;
} else if (payloadLen === 127) {
if (buffer.length < 10) return messages;
payloadLen = Number(buffer.readBigUInt64BE(2));
headerLen = 10;
}
if (masked) headerLen += 4;
const totalLen = headerLen + payloadLen;
if (buffer.length < totalLen) return messages;
let payload = buffer.slice(headerLen, headerLen + payloadLen);
if (masked) {
const maskKey = buffer.slice(headerLen - 4, headerLen);
const unmasked = Buffer.alloc(payloadLen);
for (let i = 0; i < payloadLen; i++) {
unmasked[i] = payload[i] ^ maskKey[i % 4];
}
payload = unmasked;
}
messages.push({ opcode, payload: payload.toString('utf8') });
buffer = buffer.slice(totalLen);
}
return messages;
}
function sendCall(action, payload) {
return new Promise((resolve, reject) => {
const id = String(++msgId);
const msg = JSON.stringify([2, id, action, payload]);
pending.set(id, { resolve, reject });
console.log(`[SEND] ${action} (${id})`);
socket.write(encodeFrame(msg));
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`Timeout: ${action}`));
}
}, 15000);
});
}
function handleMessage(msg) {
try {
const data = JSON.parse(msg);
if (data[0] === 3) { // CallResult
const id = data[1];
if (pending.has(id)) {
pending.get(id).resolve(data[2]);
pending.delete(id);
}
} else if (data[0] === 2) { // Call from server
const id = data[1];
const action = data[2];
console.log(`[CALL] ${action} from server`);
const response = JSON.stringify([3, id, {}]);
socket.write(encodeFrame(response));
} else if (data[0] === 4) { // Error
const id = data[1];
console.log(`[ERROR] ${data[2]}: ${data[3]}`);
if (pending.has(id)) {
pending.get(id).reject(new Error(`${data[2]}: ${data[3]}`));
pending.delete(id);
}
}
} catch (e) {
console.error('[PARSE ERROR]', e.message, '- raw:', JSON.stringify(msg.substring(0, 100)));
}
}
function connect() {
return new Promise((resolve, reject) => {
console.log(`Connecting to ${CONFIG.host}:${CONFIG.port}${CONFIG.path}...`);
socket = net.createConnection(CONFIG.port, CONFIG.host, () => {
const key = crypto.randomBytes(16).toString('base64');
const encoded = Buffer.from(CONFIG.stationId + ':' + CONFIG.password).toString('base64');
const handshake = `GET ${CONFIG.path} HTTP/1.1\r\n` +
`Host: ${CONFIG.host}:${CONFIG.port}\r\n` +
`Upgrade: websocket\r\n` +
`Connection: Upgrade\r\n` +
`Sec-WebSocket-Key: ${key}\r\n` +
`Sec-WebSocket-Version: 13\r\n` +
`Sec-WebSocket-Protocol: ocpp2.0.1\r\n` +
`Authorization: Basic ${encoded}\r\n` +
`\r\n`;
socket.write(handshake);
});
socket.on('data', (data) => {
buffer = Buffer.concat([buffer, data]);
if (!wsReady) {
const str = buffer.toString('utf8');
const headerEnd = str.indexOf('\r\n\r\n');
if (headerEnd === -1) return; // Wait for full HTTP response
const statusLine = str.split('\r\n')[0];
console.log('Handshake:', statusLine);
if (statusLine.includes('101')) {
wsReady = true;
buffer = buffer.slice(headerEnd + 4); // Skip past HTTP headers
console.log('✅ WebSocket connected!');
resolve();
} else {
console.log('❌ Handshake failed');
console.log(str.substring(0, 500));
socket.end();
reject(new Error('Handshake failed'));
}
return;
}
// Parse WebSocket frames
const messages = parseFrames();
for (const msg of messages) {
if (msg.opcode === 0x1) { // Text frame
handleMessage(msg.payload);
} else if (msg.opcode === 0x8) { // Close frame
console.log('[CLOSE] Server sent close frame');
socket.end();
} else if (msg.opcode === 0x9) { // Ping
// Send pong
socket.write(encodeFrame(''));
}
}
});
socket.on('error', (e) => {
console.error('[SOCKET ERROR]', e.message);
reject(e);
});
socket.on('close', () => {
console.log('[DISCONNECTED]');
});
setTimeout(() => reject(new Error('Connection timeout')), 10000);
});
}
async function run() {
try {
await connect();
// BootNotification
console.log('Sending BootNotification...');
const bootResult = await sendCall('BootNotification', {
chargingStation: {
model: CONFIG.stationId,
vendorName: CONFIG.vendorName,
firmwareVersion: CONFIG.firmwareVersion,
serialNumber: CONFIG.stationId,
},
reason: 'PowerUp',
});
console.log('✅ BootNotification result:', bootResult);
// StatusNotification - Available
console.log('Sending StatusNotification (Available)...');
await sendCall('StatusNotification', {
evseId: 1,
connectorId: 1,
connectorStatus: 'Available',
timestamp: new Date().toISOString(),
});
console.log('✅ Connector 1 is Available');
// Heartbeat
console.log('Sending Heartbeat...');
const hbResult = await sendCall('Heartbeat', {});
console.log('✅ Heartbeat result:', hbResult);
console.log('');
console.log('=== ✅ Station CP001 is ONLINE and READY ===');
console.log('=== Press Ctrl+C to stop ===');
// Keep alive with heartbeats every 60s
setInterval(async () => {
try {
await sendCall('Heartbeat', {});
console.log('[HEARTBEAT] ✅');
} catch (e) {
console.error('[HEARTBEAT] ❌', e.message);
}
}, 60000);
} catch (e) {
console.error('❌ Failed:', e.message);
process.exit(1);
}
}
process.on('SIGINT', () => {
console.log('\nShutting down...');
if (socket) socket.end();
process.exit(0);
});
run();

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* OCPP 2.0.1 Security Profile 0 connector for Cariflex/CitrineOS
* Connects via WebSocket to CSMS on port 8081 (no auth) to update ocppConnectionName
* The main simulator handles port 8082 (Security Profile 1, Basic Auth) for actual OCPP messages
*/
const net = require('net');
const crypto = require('crypto');
const WS_HOST = process.env.OCPP_HOST || 'cariflex-citrineos-server';
const WS_PORT = parseInt(process.env.OCPP_PORT || '8081');
function encodeFrame(payload) {
const maskKey = crypto.randomBytes(4);
const pb = Buffer.from(payload, 'utf8');
const len = pb.length;
const fl = len < 126 ? 6 + len : 8 + len;
const f = Buffer.alloc(fl);
f[0] = 0x81;
if (len < 126) { f[1] = 0x80 | len; }
else { f[1] = 0x80 | 126; f.writeUInt16BE(len, 2); }
maskKey.copy(f, fl - len - 4);
for (let i = 0; i < len; i++) f[fl - len + i] = pb[i] ^ maskKey[i % 4];
return f;
}
function parseFrames(buf) {
const msgs = [];
while (buf.length >= 2) {
const op = buf[0] & 0x0F;
const masked = (buf[1] & 0x80) !== 0;
let pl = buf[1] & 0x7F, hl = 2;
if (pl === 126) { if (buf.length < 4) return msgs; pl = buf.readUInt16BE(2); hl = 4; }
else if (pl === 127) { if (buf.length < 10) return msgs; pl = Number(buf.readBigUInt64BE(2)); hl = 10; }
if (masked) hl += 4;
const tl = hl + pl;
if (buf.length < tl) return msgs;
let payload = buf.slice(hl, hl + pl);
if (masked) {
const mk = buf.slice(hl - 4, hl);
const u = Buffer.alloc(pl);
for (let i = 0; i < pl; i++) u[i] = payload[i] ^ mk[i % 4];
payload = u;
}
msgs.push({ op, payload: payload.toString('utf8') });
buf = buf.slice(tl);
}
return msgs;
}
async function connectStation(stationId) {
return new Promise((resolve, reject) => {
const sock = net.createConnection(WS_PORT, WS_HOST, () => {
const key = crypto.randomBytes(16).toString('base64');
const handshake = `GET /${stationId} HTTP/1.1\r\nHost: ${WS_HOST}:${WS_PORT}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: ocpp2.0.1\r\n\r\n`;
sock.write(handshake);
});
let buf = Buffer.alloc(0), ready = false, msgId = 0;
const pending = new Map();
sock.on('data', (data) => {
buf = Buffer.concat([buf, data]);
if (!ready) {
const s = buf.toString('utf8');
const he = s.indexOf('\r\n\r\n');
if (he === -1) return;
const sl = s.split('\r\n')[0];
if (sl.includes('101')) {
ready = true;
buf = buf.slice(he + 4);
// Send BootNotification
const id = String(++msgId);
const payload = JSON.stringify([2, id, 'BootNotification', JSON.stringify({ chargingStation: { model: stationId, vendorName: 'Cariflex', firmwareVersion: '1.0.0', serialNumber: stationId }, reason: 'PowerUp' })]);
sock.write(encodeFrame(payload));
pending.set(id, { resolve: () => {}, reject: () => {} });
} else {
sock.end();
reject(new Error('Handshake failed: ' + sl));
}
return;
}
const msgs = parseFrames(buf);
for (const m of msgs) {
if (m.op === 0x1) {
try {
const d = JSON.parse(m.payload);
if (d[0] === 3) {
console.log('[' + stationId + '] Boot: ' + d[2].status);
// Now send StatusNotification
const id2 = String(++msgId);
const sn = JSON.stringify([2, id2, 'StatusNotification', JSON.stringify({ evseId: 1, connectorId: 1, connectorStatus: 'Available', timestamp: new Date().toISOString() })]);
sock.write(encodeFrame(sn));
console.log('[' + stationId + '] StatusNotification sent');
}
// Respond to server calls
if (d[0] === 2) {
sock.write(encodeFrame(JSON.stringify([3, d[1], {}])));
}
} catch(e) {}
if (d[0] === 3 && d[2] && d[2].status === 'Accepted') {
resolve({ sock, pending });
}
} else if (m.op === 0x8) {
sock.end();
}
}
});
sock.on('error', (e) => { console.error('[' + stationId + '] ' + e.message); reject(e); });
sock.on('close', () => console.log('[' + stationId + '] DC'));
setTimeout(() => reject(new Error('timeout')), 10000);
});
}
async function main() {
const stations = Array.from({length: 15}, (_, i) => 'CP' + String(i+1).padStart(3, '0'));
console.log('Connecting ' + stations.length + ' stations to port ' + WS_PORT + ' (Security Profile 0)...');
const connections = [];
for (const st of stations) {
try {
const conn = await connectStation(st);
connections.push({ id: st, ...conn });
console.log('[' + st + '] Connected!');
await new Promise(r => setTimeout(r, 200));
} catch(e) {
console.error('[' + st + '] Failed: ' + e.message);
}
}
console.log('\n=== ' + connections.length + '/' + stations.length + ' connected on SP0 ===');
// Keep alive
setInterval(() => {}, 60000);
}
process.on('SIGINT', () => { console.log('exit'); process.exit(0); });
main().catch(e => { console.error(e); process.exit(1); });

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
NEXT_PUBLIC_AUTH_PROVIDER=generic
NEXT_PUBLIC_API_URL=https://hasura.digitribe.fr/v1/graphql
NEXT_PUBLIC_CITRINE_CORE_URL=https://citrineos-core.digitribe.fr
HASURA_ADMIN_SECRET=Digitribe972
NEXT_PUBLIC_TENANT_ID=1
NEXT_PUBLIC_ADMIN_EMAIL=admin@digitribe.fr
ADMIN_PASSWORD=Digitribe972
NEXT_PUBLIC_WS_URL=wss://hasura.digitribe.fr/v1/graphql
NEXTAUTH_SECRET=C1tR1n30S2026S3cr3t
NEXTAUTH_URL=https://citrineos.digitribe.fr

View File

@@ -0,0 +1,158 @@
version: '3.8'
services:
citrineos-server:
image: ghcr.io/citrineos/citrineos-server:latest
container_name: cariflex-citrineos-server
restart: unless-stopped
environment:
APP_NAME: "all"
APP_ENV: "docker"
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
DB_STRATEGY: "migrate"
BOOTSTRAP_CITRINEOS_DATABASE_HOST: "cariflex-citrineos-db"
BOOTSTRAP_CITRINEOS_CONFIG_FILENAME: "config.json"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_TYPE: "local"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_LOCAL_FILE_PATH: "/data"
CONFIG_CITRINEOS_WIPE_FILE_ON_START: "true"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
cariflex-amqp:
condition: service_healthy
volumes:
- citrineos-data:/data
ports:
- "8081:8080"
networks:
- cariflex-internal
cariflex-citrineos-db:
image: postgis/postgis:16-3.5
container_name: cariflex-citrineos-db
restart: unless-stopped
environment:
POSTGRES_DB: citrine
POSTGRES_USER: citrine
POSTGRES_PASSWORD: citrine
volumes:
- citrineos-db-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready --username=citrine
interval: 5s
timeout: 10s
retries: 5
networks:
- cariflex-internal
cariflex-amqp:
image: rabbitmq:3-management
container_name: cariflex-amqp
networks:
cariflex-internal:
aliases:
- amqp-broker
traefik-public:
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
labels:
- "traefik.enable=true"
- "traefik.http.routers.rabbitmq.rule=Host(`amqp.digitribe.fr`)"
- "traefik.http.routers.rabbitmq.entrypoints=websecure"
- "traefik.http.routers.rabbitmq.tls.certresolver=letsencrypt"
- "traefik.http.services.rabbitmq.loadbalancer.server.port=15672"
volumes:
- citrineos-amqp-data:/var/lib/rabbitmq
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
hasura:
image: hasura/graphql-engine:v2.40.0
container_name: cariflex-hasura
restart: unless-stopped
ports:
- "8082:8080"
environment:
HASURA_GRAPHQL_DATABASE_URL: "postgresql://citrine:***@cariflex-citrineos-db:5432/citrine"
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: "Digitribe972"
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: "anonymous"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.hasura.rule=Host(`hasura.digitribe.fr`)"
- "traefik.http.routers.hasura.entrypoints=websecure"
- "traefik.http.routers.hasura.tls.certresolver=letsencrypt"
- "traefik.http.services.hasura.loadbalancer.server.port=8080"
networks:
- traefik-public
- cariflex-internal
citrineos-operator-ui:
image: citrineos-core-main-citrine-ui:latest
container_name: cariflex-citrineos-operator-ui
restart: unless-stopped
ports:
- "3002:3000"
environment:
NEXTAUTH_SECRET: Digitribe972
ADMIN_PASSWORD: Digitribe972
depends_on:
- hasura
labels:
- "traefik.enable=true"
- "traefik.http.routers.citrineos-ui.rule=Host(`citrineos.digitribe.fr`)"
- "traefik.http.routers.citrineos-ui.entrypoints=websecure"
- "traefik.http.routers.citrineos-ui.tls.certresolver=letsencrypt"
- "traefik.http.services.citrineos-ui.loadbalancer.server.port=3000"
networks:
- traefik-public
- cariflex-internal
# === EVerest (simulateur de charge OCPP 2.0.1) ===
everest-mqtt:
image: ghcr.io/everest/everest-demo/mqtt-server:0.0.16
container_name: cariflex-everest-mqtt
restart: unless-stopped
networks:
- cariflex-internal
everest-nodered:
image: ghcr.io/everest/everest-demo/nodered:0.0.16
container_name: cariflex-everest-nodered
restart: unless-stopped
depends_on:
- everest-mqtt
environment:
- MQTT_SERVER_ADDRESS=everest-mqtt
- FLOWS=/config/config-sil-two-evse-flow.json
networks:
- cariflex-internal
ports:
- "1880:1880"
volumes:
citrineos-data:
driver: local
citrineos-db-data:
driver: local
citrineos-amqp-data:
driver: local
networks:
traefik-public:
external: true
cariflex-internal:
name: config_cariflex-internal
external: true

View File

@@ -0,0 +1,171 @@
version: '3.8'
services:
citrineos-server:
image: ghcr.io/citrineos/citrineos-server:latest
container_name: cariflex-citrineos-server
restart: unless-stopped
environment:
APP_NAME: "all"
APP_ENV: "docker"
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
DB_STRATEGY: "migrate"
BOOTSTRAP_CITRINEOS_DATABASE_HOST: "cariflex-citrineos-db"
BOOTSTRAP_CITRINEOS_CONFIG_FILENAME: "config.json"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_TYPE: "local"
BOOTSTRAP_CITRINEOS_FILE_ACCESS_LOCAL_FILE_PATH: "/data"
CONFIG_CITRINEOS_WIPE_FILE_ON_START: "true"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
cariflex-amqp:
condition: service_healthy
volumes:
- citrineos-data:/data
ports:
- "8081:8080"
networks:
- cariflex-internal
cariflex-citrineos-db:
image: postgis/postgis:16-3.5
container_name: cariflex-citrineos-db
restart: unless-stopped
environment:
POSTGRES_DB: citrine
POSTGRES_USER: citrine
POSTGRES_PASSWORD: citrine
volumes:
- citrineos-db-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready --username=citrine
interval: 5s
timeout: 10s
retries: 5
networks:
- cariflex-internal
cariflex-amqp:
image: rabbitmq:3-management
container_name: cariflex-amqp
networks:
cariflex-internal:
aliases:
- amqp-broker
traefik-public:
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
labels:
- "traefik.enable=true"
- "traefik.http.routers.rabbitmq.rule=Host(`amqp.digitribe.fr`)"
- "traefik.http.routers.rabbitmq.entrypoints=websecure"
- "traefik.http.routers.rabbitmq.tls.certresolver=letsencrypt"
- "traefik.http.services.rabbitmq.loadbalancer.server.port=15672"
volumes:
- citrineos-amqp-data:/var/lib/rabbitmq
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 15s
timeout: 10s
retries: 10
start_period: 30s
hasura:
image: hasura/graphql-engine:v2.40.0
container_name: cariflex-hasura
restart: unless-stopped
ports:
- "8082:8080"
environment:
HASURA_GRAPHQL_DATABASE_URL: "postgresql://citrine:citrine@cariflex-citrineos-db:5432/citrine"
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: "Digitribe972"
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: "anonymous"
depends_on:
cariflex-citrineos-db:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.hasura.rule=Host(`hasura.digitribe.fr`)"
- "traefik.http.routers.hasura.entrypoints=websecure"
- "traefik.http.routers.hasura.tls.certresolver=letsencrypt"
- "traefik.http.services.hasura.loadbalancer.server.port=8080"
networks:
- traefik-public
- cariflex-internal
citrineos-operator-ui:
image: citrineos-core-main-citrine-ui:latest
container_name: cariflex-citrineos-operator-ui
restart: unless-stopped
ports:
- "3002:3000"
environment:
NEXTAUTH_SECRET: Digitribe972
ADMIN_PASSWORD: Digitribe972
depends_on:
- hasura
labels:
- "traefik.enable=true"
- "traefik.http.routers.citrineos-ui.rule=Host(`citrineos.digitribe.fr`)"
- "traefik.http.routers.citrineos-ui.entrypoints=websecure"
- "traefik.http.routers.citrineos-ui.tls.certresolver=letsencrypt"
- "traefik.http.services.citrineos-ui.loadbalancer.server.port=3000"
networks:
- traefik-public
- cariflex-internal
# === EVerest MQTT + NodeRED (UI de contrôle) ===
everest-mqtt:
image: ghcr.io/everest/everest-demo/mqtt-server:0.0.16
container_name: cariflex-everest-mqtt
restart: unless-stopped
networks:
- cariflex-internal
everest-nodered:
image: ghcr.io/everest/everest-demo/nodered:0.0.16
container_name: cariflex-everest-nodered
restart: unless-stopped
depends_on:
- everest-mqtt
environment:
- MQTT_SERVER_ADDRESS=everest-mqtt
- FLOWS=/config/config-sil-two-evse-flow.json
networks:
- cariflex-internal
ports:
- "1880:1880"
# === OCPP 2.0.1 Simulators ===
ocpp-simulator:
build:
context: /home/eric/cariflex/scripts
dockerfile: Dockerfile.simulator
container_name: cariflex-ocpp-simulator
restart: unless-stopped
environment:
OCPP_HOST: "cariflex-citrineos-server"
OCPP_PORT: "8082"
networks:
- cariflex-internal
volumes:
citrineos-data:
driver: local
citrineos-db-data:
driver: local
citrineos-amqp-data:
driver: local
networks:
traefik-public:
external: true
cariflex-internal:
name: config_cariflex-internal
external: true

View File

@@ -0,0 +1,62 @@
cariflex-citrineos-operator-ui citrineos-core-main-citrine-ui:latest Up 5 minutes
cariflex-everest-nodered ghcr.io/everest/everest-demo/nodered:0.0.16 Up 2 minutes (healthy)
cariflex-hasura hasura/graphql-engine:v2.40.0 Up 6 hours (healthy)
cariflex-ocpp-simulator config-ocpp-simulator Up 7 hours
f33f58046fca_cariflex-amqp rabbitmq:3-management Up 7 hours (healthy)
cariflex-everest-manager everest-manager:latest Restarting (1) 38 seconds ago
cariflex-everest-mqtt ghcr.io/everest/everest-demo/mqtt-server:0.0.16 Up 19 hours
cariflex-citrineos-server ghcr.io/citrineos/citrineos-server:latest Up 4 days
cariflex-citrineos-db postgis/postgis:16-3.5 Up 4 days (healthy)
phpipam-phpipam-web-1 phpipam/phpipam-www:latest Up 5 days
phpipam-phpipam-cron-1 phpipam/phpipam-cron:latest Up 5 days
phpipam-phpipam-mariadb-1 mariadb:latest Up 5 days
openadr-ven flexmeasures-openadr-ven Up 5 days
openadr-vtn flexmeasures-openadr-vtn Up 5 days
flexmeasures-redis redis:7-alpine Up 6 days
flexmeasures-worker lfenergy/flexmeasures:latest Up 6 days
flexmeasures-server lfenergy/flexmeasures:latest Up 5 days
smart-city-grafana grafana/grafana:11.3.0 Up 5 days
flexmeasures-db postgres:17 Up 7 days
smart-city-simulator smart-city-digital-twin-martinique_simulator Up 8 days
starrocks-be starrocks/be-ubuntu:3.3.5 Up 8 days
starrocks-fe starrocks/fe-ubuntu:3.3.5 Up 8 days (healthy)
smart-city-orion-ld fiware/orion-ld:latest Up 8 days (healthy)
emqx_emqx_1 emqx/emqx:5.4 Up 8 days
smart-city-chirpstack-rest-api-1 chirpstack/chirpstack-rest-api:4 Up 8 days
smart-city-chirpstack-1 chirpstack/chirpstack:latest Up 8 days
smart-city-chirpstack-gateway-bridge-1 chirpstack/chirpstack-gateway-bridge:4 Up 8 days
smart-city-mosquitto-1 eclipse-mosquitto:2 Up 8 days
smart-city-redis-1 redis:7-alpine Up 8 days
smart-city-postgres-1 postgres:14-alpine Up 8 days
bunkerm-bunkerm-1 bunkeriot/bunkerm:latest Up 8 days (healthy)
metabase-app metabase/metabase:latest Up 8 days (healthy)
smart-city-iot-agent-mosquitto fiware/iotagent-json:latest Up 8 days (healthy)
smart-city-redpanda-consumer python:3.11-slim Up 8 days (unhealthy)
smart-city-geojson-proxy smart-city-geojson-proxy Up 8 days
smart-city-loki grafana/loki:latest Up 8 days
smart-city-telegraf telegraf:1.28 Up 8 days
smart-city-iot-agent-emqx fiware/iotagent-json:latest Up 8 days (healthy)
smart-city-tts-redis redis:7 Up 8 days
smart-city-cratedb crate:5.5 Up 8 days (healthy)
metabase-postgres postgres:15-alpine Up 8 days (healthy)
smart-city-iot-mongodb mongo:4.4 Up 8 days (healthy)
smart-city-tts-postgres postgres:14 Up 8 days
smart-city-influxdb influxdb:2.7-alpine Up 8 days (healthy)
smart-city-promtail grafana/promtail:latest Up 8 days
smart-city-kepler nginx:alpine Up 8 days
kepler-backend crazycapivara/kepler.gl:latest Up 9 days
trino trinodb/trino:450 Up 9 days (healthy)
trino-nginx nginx:alpine Up 10 days
streamlit python:3.11-slim Up 10 days
delta-lake delta-lake:local Up 10 days
clickhouse clickhouse/clickhouse-server:24.5 Up 10 days
duckdb duckdb-api:local Up 10 days
test-backend nginx:alpine Up 10 days
airflow-scheduler apache/airflow:2.9.3-python3.11 Up 12 days (healthy)
airflow-webserver apache/airflow:2.9.3-python3.11 Up 12 days (healthy)
airflow-postgres postgres:16 Up 12 days (healthy)
smartapp-api smartapp-api:latest Up 13 days
smartapp-web nginx:alpine Up 13 days
gitea-runner gitea/act_runner:latest Up 13 days
traefik traefik:v3.1 Up 16 minutes
gitea gitea/gitea:latest Up 13 days

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Configure BasicAuthPassword for all 15 Cariflex charging stations"""
import json, time, urllib.request, urllib.error
CITRINEOS_URL = "http://localhost:8081"
PASSWORD = "DEADBEEFDEADBEEF"
for i in range(1, 16):
cp_id = f"CP{i:03d}"
url = f"{CITRINEOS_URL}/data/monitoring/variableAttribute?stationId={cp_id}&setOnCharger=true"
payload = json.dumps({
"component": {"name": "SecurityCtrlr"},
"variable": {"name": "BasicAuthPassword"},
"variableAttribute": [{"value": PASSWORD}],
"variableCharacteristics": {"dataType": "passwordString", "supportsMonitoring": False}
}).encode()
req = urllib.request.Request(url, data=payload, method='PUT',
headers={'Content-Type': 'application/json'})
try:
resp = urllib.request.urlopen(req, timeout=10)
print(f"OK {cp_id}: HTTP {resp.status}")
except urllib.error.HTTPError as e:
print(f"FAIL {cp_id}: HTTP {e.code}")
except Exception as e:
print(f"FAIL {cp_id}: {e}")
time.sleep(0.2)
print("Done")

View File

@@ -0,0 +1,21 @@
http:
routers:
nodered:
rule: "Host(`nodered.digitribe.fr`)"
entryPoints:
- websecure
service: nodered
tls:
certResolver: letsencrypt
nodered-http:
rule: "Host(`nodered.digitribe.fr`)"
entryPoints:
- web
middlewares:
- redirect-https
service: nodered
services:
nodered:
loadBalancer:
servers:
- url: "http://172.29.0.39:1880"

View File

@@ -0,0 +1,12 @@
FROM node:24.16.0-alpine
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --chown=nextjs:nodejs apps/operator-ui/.next/standalone ./
COPY --chown=nextjs:nodejs apps/operator-ui/.next/static ./apps/operator-ui/.next/static
COPY --chown=nextjs:nodejs apps/operator-ui/public ./apps/operator-ui/public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "apps/operator-ui/server.js"]

View File

@@ -40,7 +40,7 @@ const nextConfig = {
},
],
},
webpack: (config) => {
webpack: (config, { isServer }) => {
config.resolve.alias = {
...config.resolve.alias,
'class-transformer/types/storage': resolve(
@@ -48,8 +48,35 @@ const nextConfig = {
'node_modules/class-transformer/cjs/storage.js',
),
};
// Force new file names to bust cache
if (!isServer) {
config.output.filename = 'static/chunks/[name].[contenthash:8].js';
config.output.chunkFilename = 'static/chunks/[name].[contenthash:8].js';
}
return config;
},
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Referrer-Policy',
value: 'no-referrer-when-downgrade',
},
],
},
{
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate',
},
],
},
];
},
};
export default withNextIntl(nextConfig);

View File

@@ -10,5 +10,5 @@ type PageProps = {
export default async function ShowChargingStationPage({ params }: PageProps) {
const { id } = await params;
return <ChargingStationDetail params={{ id: Number(id) }} />;
return <ChargingStationDetail params={{ id: String(id) }} />;
}

View File

@@ -77,7 +77,8 @@ export const ChargingStationDetailCard = ({
queryOptions: getPlainToInstanceOptions(ChargingStationClass, true),
});
const station = data?.data;
const stationData = data as any;
const station = stationData?.data?.ChargingStations?.[0] || stationData?.data || null;
const {
query: { data: latestLogsData },
@@ -89,9 +90,9 @@ export const ChargingStationDetailCard = ({
sorters: [{ field: OCPPMessageProps.timestamp, order: 'desc' }],
filters: [
{
field: OCPPMessageProps.ocppConnectionName,
field: 'stationId',
operator: 'eq',
value: station?.ocppConnectionName,
value: station?.id,
},
],
pagination: {

View File

@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project
//
// SPDX-License-Identifier: Apache-2.0
// CACHE_BUST: 2026-06-16-08-30-00
'use client';
import React, { useEffect, useState } from 'react';
@@ -10,27 +11,23 @@ import { ChargingStationDetailCard } from '@lib/client/pages/charging-stations/d
import { pageFlex, pageMargin } from '@lib/client/styles/page';
import { ChargingStationDetailTabsCard } from '@lib/client/pages/charging-stations/detail/charging.station.detail.tabs.card';
import { S3_BUCKET_FOLDER_IMAGES_CHARGING_STATIONS } from '@lib/utils/consts';
import { getPresignedUrlForGet } from '@lib/server/actions/file/getPresingedUrlForGet';
// import { getPresignedUrlForGet } from '@lib/server/actions/file/getPresingedUrlForGet';
import { AccessDeniedFallbackCard } from '@lib/client/components/access-denied-fallback-card';
import { Skeleton } from '@lib/client/components/ui/skeleton';
type ChargingStationDetailProps = {
params: { id: number };
params: { id: string };
};
export const ChargingStationDetail: React.FC<ChargingStationDetailProps> = ({ params }) => {
const { id } = params;
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [forceUpdate] = useState(Date.now()); // Force cache bust
useEffect(() => {
if (id) {
getPresignedUrlForGet(`${S3_BUCKET_FOLDER_IMAGES_CHARGING_STATIONS}/${id}`).then((result) => {
if (result.success) {
return setImageUrl(result.data);
}
});
}
}, [id]);
// S3 image loading disabled - no bucket configured
void forceUpdate; // Used to force re-render
}, [id, forceUpdate]);
if (!id) {
return (

View File

@@ -28,11 +28,12 @@ type LocationDetailProps = {
export const LocationsDetail = ({ params }: LocationDetailProps) => {
const { id } = params;
const numericId = parseInt(id, 10);
const {
query: { data, isLoading },
} = useOne<LocationDto>({
resource: ResourceType.LOCATIONS,
id,
id: numericId,
meta: {
gqlQuery: LOCATIONS_GET_QUERY,
},

View File

@@ -25,7 +25,7 @@ export const CHARGING_STATIONS_LIST_QUERY = gql`
floorLevel
parkingRestrictions
capabilities
location: Location {
location: location {
id
name
address
@@ -37,20 +37,20 @@ export const CHARGING_STATIONS_LIST_QUERY = gql`
createdAt
updatedAt
}
evses: Evses {
evses: evses {
id
evseTypeId
evseId
physicalReference
}
LatestStatusNotifications {
latestStatusNotifications {
id
stationId
ocppConnectionName
statusNotificationId
updatedAt
createdAt
StatusNotification {
statusNotification {
connectorId
connectorStatus
createdAt
@@ -62,7 +62,7 @@ export const CHARGING_STATIONS_LIST_QUERY = gql`
updatedAt
}
}
transactions: Transactions(where: { isActive: { _eq: true } }) {
transactions: transactions(where: { isActive: { _eq: true } }) {
id
timeSpentCharging
isActive
@@ -77,7 +77,7 @@ export const CHARGING_STATIONS_LIST_QUERY = gql`
createdAt
updatedAt
}
connectors: Connectors {
connectors: connectors {
connectorId
status
errorCode
@@ -126,7 +126,7 @@ export const FAULTED_CHARGING_STATIONS_LIST_QUERY = gql`
locationId
createdAt
updatedAt
location: Location {
location: location {
id
name
address
@@ -138,13 +138,13 @@ export const FAULTED_CHARGING_STATIONS_LIST_QUERY = gql`
createdAt
updatedAt
}
LatestStatusNotifications {
latestStatusNotifications {
id
ocppConnectionName
statusNotificationId
updatedAt
createdAt
StatusNotification {
statusNotification {
connectorId
connectorStatus
createdAt
@@ -193,8 +193,8 @@ export const CHARGING_STATIONS_STATUS_COUNT_QUERY = gql`
`;
export const CHARGING_STATIONS_GET_QUERY = gql`
query GetChargingStationById($id: Int!) {
ChargingStations_by_pk(id: $id) {
query GetChargingStationById($id: String!) {
ChargingStations(where: {id: {_eq: $id}}) {
id
tenantId
ocppConnectionName
@@ -211,7 +211,7 @@ export const CHARGING_STATIONS_GET_QUERY = gql`
capabilities
coordinates
use16StatusNotification0
location: Location {
location: location {
id
name
address
@@ -223,9 +223,8 @@ export const CHARGING_STATIONS_GET_QUERY = gql`
createdAt
updatedAt
}
evses: Evses {
evses: evses {
id
ocppConnectionName
stationId
evseTypeId
evseId
@@ -233,9 +232,8 @@ export const CHARGING_STATIONS_GET_QUERY = gql`
removed
createdAt
updatedAt
connectors: Connectors {
connectors: connectors {
id
ocppConnectionName
evseId
evseTypeConnectorId
connectorId
@@ -257,33 +255,29 @@ export const CHARGING_STATIONS_GET_QUERY = gql`
updatedAt
}
}
LatestStatusNotifications {
latestStatusNotifications {
id
ocppConnectionName
stationId
statusNotificationId
updatedAt
createdAt
StatusNotification {
statusNotification {
connectorId
connectorStatus
createdAt
evseId
ocppConnectionName
id
stationId
timestamp
updatedAt
}
}
transactions: Transactions(where: { isActive: { _eq: true } }) {
transactions: transactions(where: { isActive: { _eq: true } }) {
id
stationId
ocppConnectionName
timeSpentCharging
isActive
chargingState
ocppConnectionName
stoppedReason
transactionId
evseId
@@ -292,9 +286,8 @@ export const CHARGING_STATIONS_GET_QUERY = gql`
createdAt
updatedAt
}
connectors: Connectors {
connectors: connectors {
id
ocppConnectionName
stationId
evseId
connectorId
@@ -320,7 +313,7 @@ export const CHARGING_STATIONS_GET_QUERY = gql`
`;
export const GET_CHARGING_STATIONS_WITH_LOCATION_AND_LATEST_STATUS_NOTIFICATIONS_AND_TRANSACTIONS = gql`
query GetChargingStationsWithLocationAndLatestStatusNotificationsAndTransactions {
query GetChargingStationsWithLocationAndLatestStatusNotificationsAndtransactions {
ChargingStations {
id
ocppConnectionName
@@ -329,8 +322,8 @@ export const GET_CHARGING_STATIONS_WITH_LOCATION_AND_LATEST_STATUS_NOTIFICATIONS
locationId
createdAt
updatedAt
latestStatusNotifications: LatestStatusNotifications {
statusNotification: StatusNotification {
latestStatusNotifications: latestStatusNotifications {
statusNotification: statusNotification {
id
ocppConnectionName
evseId
@@ -341,12 +334,11 @@ export const GET_CHARGING_STATIONS_WITH_LOCATION_AND_LATEST_STATUS_NOTIFICATIONS
updatedAt
}
}
transactions: Transactions(where: { isActive: { _eq: true } }) {
transactions: transactions(where: { isActive: { _eq: true } }) {
id
timeSpentCharging
isActive
chargingState
ocppConnectionName
stoppedReason
transactionId
evseId
@@ -355,7 +347,7 @@ export const GET_CHARGING_STATIONS_WITH_LOCATION_AND_LATEST_STATUS_NOTIFICATIONS
createdAt
updatedAt
}
location: Location {
location: location {
id
name
address
@@ -367,7 +359,7 @@ export const GET_CHARGING_STATIONS_WITH_LOCATION_AND_LATEST_STATUS_NOTIFICATIONS
createdAt
updatedAt
}
evses: Evses {
evses: evses {
id
evseTypeId
createdAt

View File

@@ -32,20 +32,20 @@ export const LOCATIONS_LIST_QUERY = gql`
protocol
createdAt
updatedAt
evses: Evses {
evses: evses {
id
evseTypeId
evseId
createdAt
updatedAt
}
LatestStatusNotifications {
latestStatusNotifications {
id
ocppConnectionName
statusNotificationId
updatedAt
createdAt
StatusNotification {
statusNotification {
connectorId
connectorStatus
createdAt
@@ -56,7 +56,7 @@ export const LOCATIONS_LIST_QUERY = gql`
updatedAt
}
}
transactions: Transactions(where: { isActive: { _eq: true } }) {
transactions: transactions(where: { isActive: { _eq: true } }) {
id
timeSpentCharging
isActive
@@ -70,7 +70,7 @@ export const LOCATIONS_LIST_QUERY = gql`
createdAt
updatedAt
}
connectors: Connectors {
connectors: connectors {
connectorId
status
errorCode
@@ -123,13 +123,13 @@ export const LOCATIONS_GET_QUERY = gql`
createdAt
updatedAt
}
LatestStatusNotifications {
latestStatusNotifications {
id
ocppConnectionName
statusNotificationId
updatedAt
createdAt
StatusNotification {
statusNotification {
connectorId
connectorStatus
createdAt
@@ -140,7 +140,7 @@ export const LOCATIONS_GET_QUERY = gql`
updatedAt
}
}
Transactions(where: { isActive: { _eq: true } }) {
transactions(where: { isActive: { _eq: true } }) {
id
timeSpentCharging
isActive

View File

@@ -26,7 +26,7 @@ export const TRANSACTION_LIST_QUERY = gql`
endTime
createdAt
updatedAt
location: Location {
location: location {
id
name
address
@@ -38,19 +38,19 @@ export const TRANSACTION_LIST_QUERY = gql`
createdAt
updatedAt
}
evse: Evse {
evse: evse {
id
createdAt
updatedAt
}
connector: Connector {
connector: connector {
id
connectorId
type
createdAt
updatedAt
}
authorization: Authorization {
authorization: authorization {
id
idToken
idTokenType
@@ -66,7 +66,7 @@ export const TRANSACTION_LIST_QUERY = gql`
createdAt
updatedAt
}
chargingStation: ChargingStation {
chargingStation: chargingStation {
id
ocppConnectionName
isOnline
@@ -74,7 +74,7 @@ export const TRANSACTION_LIST_QUERY = gql`
locationId
createdAt
updatedAt
location: Location {
location: location {
id
name
address
@@ -124,7 +124,7 @@ export const GET_TRANSACTIONS_FOR_AUTHORIZATION = gql`
endTime
createdAt
updatedAt
chargingStation: ChargingStation {
chargingStation: chargingStation {
id
ocppConnectionName
isOnline
@@ -132,7 +132,7 @@ export const GET_TRANSACTIONS_FOR_AUTHORIZATION = gql`
locationId
createdAt
updatedAt
location: Location {
location: location {
id
name
address
@@ -199,17 +199,17 @@ export const GET_TRANSACTION_LIST_FOR_STATION = gql`
StartTransaction {
idTokenDatabaseId
}
authorization: Authorization {
authorization: authorization {
id
idToken
}
chargingStation: ChargingStation {
chargingStation: chargingStation {
id
isOnline
locationId
createdAt
updatedAt
location: Location {
location: location {
id
name
address
@@ -267,7 +267,7 @@ export const TRANSACTION_GET_QUERY = gql`
endTime
createdAt
updatedAt
location: Location {
location: location {
name
address
city
@@ -278,14 +278,14 @@ export const TRANSACTION_GET_QUERY = gql`
createdAt
updatedAt
}
evse: Evse {
evse: evse {
id
evseTypeId
evseId
createdAt
updatedAt
}
connector: Connector {
connector: connector {
id
connectorId
type
@@ -297,7 +297,7 @@ export const TRANSACTION_GET_QUERY = gql`
pricePerKwh
}
}
authorization: Authorization {
authorization: authorization {
id
idToken
idTokenType

View File

@@ -38,7 +38,33 @@ export async function getPlaceDetails(
throw new Error('Place ID is required');
}
// Return stub data - Google Places API removed, using OSM/Nominatim instead
try {
// Use Nominatim OSM for place details (free, no API key)
const url = `https://nominatim.openstreetmap.org/lookup?format=json&osm_ids=${placeId}&addressdetails=1`;
const response = await fetch(url, {
headers: { 'User-Agent': 'CitrineOS-Operator-UI/1.0' },
});
if (!response.ok) {
throw new Error(`Nominatim error: ${response.status}`);
}
const data = await response.json();
if (data && data.length > 0) {
const place = data[0];
return {
id: place.place_id?.toString() || placeId,
displayName: { text: place.display_name || '', languageCode: 'en' },
formattedAddress: place.display_name || '',
addressComponents: [],
location: {
latitude: parseFloat(place.lat) || 0,
longitude: parseFloat(place.lon) || 0,
},
types: place.type ? [place.type] : [],
};
}
return {
id: placeId,
displayName: { text: '', languageCode: 'en' },
@@ -46,5 +72,15 @@ export async function getPlaceDetails(
addressComponents: [],
location: { latitude: 0, longitude: 0 },
};
} catch (error) {
console.error('getPlaceDetails error:', error);
return {
id: placeId,
displayName: { text: '', languageCode: 'en' },
formattedAddress: '',
addressComponents: [],
location: { latitude: 0, longitude: 0 },
};
}
});
}

View File

@@ -81,7 +81,7 @@ importers:
version: 6.6.5
sequelize-typescript:
specifier: ^2.1.6
version: 2.1.6(@types/node@25.9.1)(@types/validator@13.15.10)(reflect-metadata@0.2.2)(sequelize@6.37.8)
version: 2.1.6(@types/node@25.9.1)(@types/validator@13.15.10)(reflect-metadata@0.2.2)(sequelize@6.37.8(sqlite3@5.1.7))
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
@@ -194,7 +194,7 @@ importers:
version: 6.37.8(pg-hstore@2.3.4)(pg@8.11.3)(sqlite3@5.1.7)
sequelize-typescript:
specifier: 2.1.6
version: 2.1.6(@types/node@25.9.1)(@types/validator@13.15.10)(reflect-metadata@0.2.2)(sequelize@6.37.8)
version: 2.1.6(@types/node@25.9.1)(@types/validator@13.15.10)(reflect-metadata@0.2.2)(sequelize@6.37.8(sqlite3@5.1.7))
ts-node:
specifier: 10.9.2
version: 10.9.2(@types/node@25.9.1)(typescript@6.0.3)
@@ -319,6 +319,9 @@ importers:
'@tanstack/react-table':
specifier: 8.21.3
version: 8.21.3(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
'@types/leaflet':
specifier: ^1.9.21
version: 1.9.21
'@vis.gl/react-google-maps':
specifier: 1.5.1
version: 1.5.1(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
@@ -364,6 +367,9 @@ importers:
js-cookie:
specifier: 3.0.5
version: 3.0.5
leaflet:
specifier: ^1.9.4
version: 1.9.4
lodash:
specifier: ^4.18.1
version: 4.18.1
@@ -406,6 +412,9 @@ importers:
react-hook-form:
specifier: 7.65.0
version: 7.65.0(react@19.1.4)
react-leaflet:
specifier: ^5.0.0
version: 5.0.0(leaflet@1.9.4)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
react-redux:
specifier: 9.2.0
version: 9.2.0(@types/react@19.1.4)(react@19.1.4)(redux@5.0.1)
@@ -698,7 +707,7 @@ importers:
version: 6.37.8(pg-hstore@2.3.4)(pg@8.11.3)(sqlite3@5.1.7)
sequelize-typescript:
specifier: 2.1.6
version: 2.1.6(@types/node@25.9.1)(@types/validator@13.15.10)(reflect-metadata@0.2.2)(sequelize@6.37.8)
version: 2.1.6(@types/node@25.9.1)(@types/validator@13.15.10)(reflect-metadata@0.2.2)(sequelize@6.37.8(sqlite3@5.1.7))
ssh2-sftp-client:
specifier: ^10.0.3
version: 10.0.3
@@ -3304,6 +3313,13 @@ packages:
'@reach/observe-rect@1.2.0':
resolution: {integrity: sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==}
'@react-leaflet/core@3.0.0':
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
'@redis/bloom@1.2.0':
resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
peerDependencies:
@@ -3940,6 +3956,9 @@ packages:
'@types/jsrsasign@10.5.15':
resolution: {integrity: sha512-3stUTaSRtN09PPzVWR6aySD9gNnuymz+WviNHoTb85dKu+BjaV4uBbWWGykBBJkfwPtcNZVfTn2lbX00U+yhpQ==}
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/lodash.debounce@4.0.9':
resolution: {integrity: sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==}
@@ -6987,6 +7006,9 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
leaflet@1.9.4:
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -8214,6 +8236,13 @@ packages:
react-is@19.1.4:
resolution: {integrity: sha512-4oz30Hl3G6Ji4MpJEMwfXCjsfjAarc9nUPLmSXJLDjHbQRTeXMgf8UAPcrsUYVV+7E7mPHqNfhi5KVVMhl+zcg==}
react-leaflet@5.0.0:
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
peerDependencies:
leaflet: ^1.9.0
react: ^19.0.0
react-dom: ^19.0.0
react-reconciler@0.29.2:
resolution: {integrity: sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==}
engines: {node: '>=0.10.0'}
@@ -12262,6 +12291,12 @@ snapshots:
'@reach/observe-rect@1.2.0': {}
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)':
dependencies:
leaflet: 1.9.4
react: 19.1.4
react-dom: 19.1.4(react@19.1.4)
'@redis/bloom@1.2.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
@@ -12920,6 +12955,10 @@ snapshots:
'@types/jsrsasign@10.5.15': {}
'@types/leaflet@1.9.21':
dependencies:
'@types/geojson': 7946.0.16
'@types/lodash.debounce@4.0.9':
dependencies:
'@types/lodash': 4.17.24
@@ -14934,8 +14973,8 @@ snapshots:
'@typescript-eslint/parser': 8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3)
eslint: 9.16.0(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.16.0(jiti@2.7.0)))(eslint@9.16.0(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.16.0(jiti@2.7.0))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.16.0(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.17.0(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.16.0(jiti@2.7.0))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.16.0(jiti@2.7.0))
eslint-plugin-react: 7.37.5(eslint@9.16.0(jiti@2.7.0))
eslint-plugin-react-hooks: 5.2.0(eslint@9.16.0(jiti@2.7.0))
@@ -14958,7 +14997,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.16.0(jiti@2.7.0)))(eslint@9.16.0(jiti@2.7.0)):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.16.0(jiti@2.7.0)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@5.5.0)
@@ -14969,22 +15008,21 @@ snapshots:
tinyglobby: 0.2.17
unrs-resolver: 1.12.2
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.16.0(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.17.0(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.16.0(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.16.0(jiti@2.7.0)))(eslint@9.16.0(jiti@2.7.0)))(eslint@9.16.0(jiti@2.7.0)):
eslint-module-utils@2.13.0(@typescript-eslint/parser@8.17.0(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.16.0(jiti@2.7.0)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/parser': 8.17.0(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3)
eslint: 9.16.0(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.16.0(jiti@2.7.0)))(eslint@9.16.0(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.16.0(jiti@2.7.0)):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.17.0(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.16.0(jiti@2.7.0)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -14995,7 +15033,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.16.0(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.16.0(jiti@2.7.0)))(eslint@9.16.0(jiti@2.7.0)))(eslint@9.16.0(jiti@2.7.0))
eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.17.0(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.16.0(jiti@2.7.0))
hasown: 2.0.4
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -15007,7 +15045,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.60.1(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3)
'@typescript-eslint/parser': 8.17.0(eslint@9.16.0(jiti@2.7.0))(typescript@6.0.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -16487,6 +16525,8 @@ snapshots:
dependencies:
readable-stream: 2.3.8
leaflet@1.9.4: {}
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -17761,6 +17801,13 @@ snapshots:
react-is@19.1.4: {}
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.1.4(react@19.1.4))(react@19.1.4):
dependencies:
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)
leaflet: 1.9.4
react: 19.1.4
react-dom: 19.1.4(react@19.1.4)
react-reconciler@0.29.2(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -18217,7 +18264,7 @@ snapshots:
sequelize-pool@7.1.0: {}
sequelize-typescript@2.1.6(@types/node@25.9.1)(@types/validator@13.15.10)(reflect-metadata@0.2.2)(sequelize@6.37.8):
sequelize-typescript@2.1.6(@types/node@25.9.1)(@types/validator@13.15.10)(reflect-metadata@0.2.2)(sequelize@6.37.8(sqlite3@5.1.7)):
dependencies:
'@types/node': 25.9.1
'@types/validator': 13.15.10