feat(factory): Grafana dashboard + data simulator + deploy improvements (Plan 27 P1)
- grafana-dashboard.json: 9 panels (vibrations, temp, current, alerts, uptime) - simulate_data.py: MQTT sensor simulator with 5 machine profiles + anomalies - deploy_factory.sh: health retry loop, Grafana auto-import, env customization Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,41 +3,189 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "=== Factory 4.0 — Déploiement on-premise ==="
|
||||
# ─── Configurable env vars ────────────────────────────────────────
|
||||
FACTORY_DOMAIN="${FACTORY_DOMAIN:-localhost}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-factory4.0}"
|
||||
GRAFANA_PORT="${GRAFANA_PORT:-3000}"
|
||||
INFLUXDB_PORT="${INFLUXDB_PORT:-8086}"
|
||||
MASCARADE_PORT="${MASCARADE_PORT:-8100}"
|
||||
NODERED_PORT="${NODERED_PORT:-1880}"
|
||||
MOSQUITTO_PORT="${MOSQUITTO_PORT:-1883}"
|
||||
OLLAMA_DEBUG_PORT="${OLLAMA_DEBUG_PORT:-11435}"
|
||||
HEALTH_RETRIES="${HEALTH_RETRIES:-12}"
|
||||
HEALTH_INTERVAL="${HEALTH_INTERVAL:-5}"
|
||||
|
||||
echo "=== Factory 4.0 — Deploiement on-premise ==="
|
||||
echo ""
|
||||
echo " Domain: $FACTORY_DOMAIN"
|
||||
echo " Admin password: ${ADMIN_PASSWORD:0:3}***"
|
||||
echo " Grafana: :$GRAFANA_PORT"
|
||||
echo " InfluxDB: :$INFLUXDB_PORT"
|
||||
echo " Mascarade: :$MASCARADE_PORT"
|
||||
echo " Node-RED: :$NODERED_PORT"
|
||||
echo ""
|
||||
|
||||
# Check prerequisites
|
||||
# ─── Prerequisites ────────────────────────────────────────────────
|
||||
command -v docker >/dev/null 2>&1 || { echo "Docker requis. Installez: https://docs.docker.com/engine/install/"; exit 1; }
|
||||
command -v docker compose >/dev/null 2>&1 || docker-compose version >/dev/null 2>&1 || { echo "Docker Compose requis."; exit 1; }
|
||||
|
||||
echo "1. Démarrage des services..."
|
||||
# ─── Export env for docker-compose interpolation ──────────────────
|
||||
export GF_SECURITY_ADMIN_PASSWORD="$ADMIN_PASSWORD"
|
||||
export DOCKER_INFLUXDB_INIT_PASSWORD="$ADMIN_PASSWORD"
|
||||
|
||||
# ─── Start services ──────────────────────────────────────────────
|
||||
echo "1. Demarrage des services..."
|
||||
cd "$SCRIPT_DIR"
|
||||
docker compose up -d
|
||||
|
||||
# ─── Health check with retry loop ────────────────────────────────
|
||||
echo ""
|
||||
echo "2. Attente du démarrage (30s)..."
|
||||
sleep 30
|
||||
echo "2. Attente du demarrage (health check, max ${HEALTH_RETRIES}x${HEALTH_INTERVAL}s)..."
|
||||
|
||||
health_check() {
|
||||
local name="$1"
|
||||
local url="$2"
|
||||
local check_type="${3:-http}" # http or tcp
|
||||
|
||||
for attempt in $(seq 1 "$HEALTH_RETRIES"); do
|
||||
if [ "$check_type" = "http" ]; then
|
||||
status=$(curl -s -m 5 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || echo "000")
|
||||
if [ "$status" -ge 200 ] && [ "$status" -lt 400 ]; then
|
||||
echo " $name: UP (HTTP $status) [attempt $attempt]"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
if curl -s -m 3 "$url" >/dev/null 2>&1; then
|
||||
echo " $name: UP [attempt $attempt]"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if [ "$attempt" -lt "$HEALTH_RETRIES" ]; then
|
||||
sleep "$HEALTH_INTERVAL"
|
||||
fi
|
||||
done
|
||||
echo " $name: DOWN (failed after $HEALTH_RETRIES attempts)"
|
||||
return 1
|
||||
}
|
||||
|
||||
HEALTH_FAILURES=0
|
||||
health_check "Grafana" "http://${FACTORY_DOMAIN}:${GRAFANA_PORT}/api/health" || HEALTH_FAILURES=$((HEALTH_FAILURES + 1))
|
||||
health_check "InfluxDB" "http://${FACTORY_DOMAIN}:${INFLUXDB_PORT}/health" || HEALTH_FAILURES=$((HEALTH_FAILURES + 1))
|
||||
health_check "Mascarade" "http://${FACTORY_DOMAIN}:${MASCARADE_PORT}/health" || HEALTH_FAILURES=$((HEALTH_FAILURES + 1))
|
||||
health_check "Node-RED" "http://${FACTORY_DOMAIN}:${NODERED_PORT}" || HEALTH_FAILURES=$((HEALTH_FAILURES + 1))
|
||||
health_check "Ollama" "http://${FACTORY_DOMAIN}:${OLLAMA_DEBUG_PORT}/api/tags" || HEALTH_FAILURES=$((HEALTH_FAILURES + 1))
|
||||
|
||||
echo ""
|
||||
echo "3. Pull des modèles Ollama..."
|
||||
docker exec -it $(docker compose ps -q ollama) ollama pull devstral 2>/dev/null || echo " devstral: skip (pull manuellement)"
|
||||
docker exec -it $(docker compose ps -q ollama) ollama pull nomic-embed-text 2>/dev/null || echo " nomic-embed-text: skip"
|
||||
docker exec -it $(docker compose ps -q ollama) ollama pull qwen3:4b 2>/dev/null || echo " qwen3:4b: skip"
|
||||
if [ "$HEALTH_FAILURES" -gt 0 ]; then
|
||||
echo " WARNING: $HEALTH_FAILURES service(s) did not pass health check."
|
||||
else
|
||||
echo " All services healthy."
|
||||
fi
|
||||
|
||||
# ─── Pull Ollama models ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "4. Vérification..."
|
||||
echo " Mascarade: $(curl -s -m 5 http://localhost:8100/health | python3 -c 'import sys,json; print(json.load(sys.stdin).get("status","?"))' 2>/dev/null || echo 'DOWN')"
|
||||
echo " Ollama: $(curl -s -m 5 http://localhost:11435/api/tags | python3 -c 'import sys,json; print(f"{len(json.load(sys.stdin).get(\"models\",[]))} models")' 2>/dev/null || echo 'DOWN')"
|
||||
echo " Qdrant: $(curl -s -m 5 http://localhost:6333/collections | python3 -c 'import sys,json; print("OK")' 2>/dev/null || echo 'DOWN')"
|
||||
echo " Grafana: $(curl -s -m 5 -o /dev/null -w '%{http_code}' http://localhost:3000 || echo 'DOWN')"
|
||||
echo " InfluxDB: $(curl -s -m 5 -o /dev/null -w '%{http_code}' http://localhost:8086/health || echo 'DOWN')"
|
||||
echo " Mosquitto: $(curl -s -m 5 -o /dev/null -w '%{http_code}' http://localhost:9001 2>/dev/null && echo 'OK' || echo 'OK (MQTT only)')"
|
||||
echo " Node-RED: $(curl -s -m 5 -o /dev/null -w '%{http_code}' http://localhost:1880 || echo 'DOWN')"
|
||||
echo "3. Pull des modeles Ollama..."
|
||||
OLLAMA_CONTAINER=$(docker compose ps -q ollama 2>/dev/null || echo "")
|
||||
if [ -n "$OLLAMA_CONTAINER" ]; then
|
||||
docker exec "$OLLAMA_CONTAINER" ollama pull devstral 2>/dev/null || echo " devstral: skip (pull manuellement)"
|
||||
docker exec "$OLLAMA_CONTAINER" ollama pull nomic-embed-text 2>/dev/null || echo " nomic-embed-text: skip"
|
||||
docker exec "$OLLAMA_CONTAINER" ollama pull qwen3:4b 2>/dev/null || echo " qwen3:4b: skip"
|
||||
else
|
||||
echo " Ollama container not found, skipping model pull."
|
||||
fi
|
||||
|
||||
# ─── Grafana dashboard auto-import ────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Factory 4.0 prêt ==="
|
||||
echo " Mascarade API: http://localhost:8100"
|
||||
echo " Fake Ollama: http://localhost:11434"
|
||||
echo " Grafana: http://localhost:3000 (admin / factory4.0)"
|
||||
echo " Node-RED: http://localhost:1880"
|
||||
echo " InfluxDB: http://localhost:8086"
|
||||
echo "4. Import dashboard Grafana..."
|
||||
|
||||
GRAFANA_URL="http://${FACTORY_DOMAIN}:${GRAFANA_PORT}"
|
||||
DASHBOARD_FILE="$SCRIPT_DIR/grafana-dashboard.json"
|
||||
|
||||
if [ -f "$DASHBOARD_FILE" ]; then
|
||||
# Wait for Grafana API to be fully ready
|
||||
for attempt in $(seq 1 "$HEALTH_RETRIES"); do
|
||||
grafana_ok=$(curl -s -m 5 -o /dev/null -w '%{http_code}' "$GRAFANA_URL/api/health" 2>/dev/null || echo "000")
|
||||
if [ "$grafana_ok" -ge 200 ] && [ "$grafana_ok" -lt 400 ]; then
|
||||
break
|
||||
fi
|
||||
sleep "$HEALTH_INTERVAL"
|
||||
done
|
||||
|
||||
# Create InfluxDB datasource if not exists
|
||||
DS_EXISTS=$(curl -s -u "admin:${ADMIN_PASSWORD}" "$GRAFANA_URL/api/datasources/name/InfluxDB-Factory" -o /dev/null -w '%{http_code}' 2>/dev/null || echo "000")
|
||||
if [ "$DS_EXISTS" != "200" ]; then
|
||||
echo " Creating InfluxDB datasource..."
|
||||
curl -s -u "admin:${ADMIN_PASSWORD}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$GRAFANA_URL/api/datasources" \
|
||||
-d '{
|
||||
"name": "InfluxDB-Factory",
|
||||
"type": "influxdb",
|
||||
"access": "proxy",
|
||||
"url": "http://influxdb:8086",
|
||||
"jsonData": {
|
||||
"version": "Flux",
|
||||
"organization": "electron-rare",
|
||||
"defaultBucket": "machines"
|
||||
},
|
||||
"secureJsonData": {
|
||||
"token": ""
|
||||
}
|
||||
}' >/dev/null 2>&1 && echo " Datasource created." || echo " Datasource creation failed (may already exist)."
|
||||
else
|
||||
echo " Datasource InfluxDB-Factory already exists."
|
||||
fi
|
||||
|
||||
# Get datasource UID for template substitution
|
||||
DS_UID=$(curl -s -u "admin:${ADMIN_PASSWORD}" "$GRAFANA_URL/api/datasources/name/InfluxDB-Factory" 2>/dev/null \
|
||||
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("uid",""))' 2>/dev/null || echo "")
|
||||
|
||||
# Import dashboard
|
||||
echo " Importing dashboard from grafana-dashboard.json..."
|
||||
|
||||
# Build import payload: wrap dashboard and replace datasource input
|
||||
python3 -c "
|
||||
import json, sys
|
||||
|
||||
with open('$DASHBOARD_FILE') as f:
|
||||
dash = json.load(f)
|
||||
|
||||
# Remove import-only fields
|
||||
dash.pop('__inputs', None)
|
||||
dash.pop('__requires', None)
|
||||
dash['id'] = None
|
||||
|
||||
# Replace datasource UID placeholders
|
||||
ds_uid = '${DS_UID}' or 'influxdb-factory'
|
||||
raw = json.dumps(dash)
|
||||
raw = raw.replace('\${DS_INFLUXDB}', ds_uid)
|
||||
dash = json.loads(raw)
|
||||
|
||||
payload = {
|
||||
'dashboard': dash,
|
||||
'overwrite': True,
|
||||
'message': 'Auto-imported by deploy_factory.sh'
|
||||
}
|
||||
print(json.dumps(payload))
|
||||
" | curl -s -u "admin:${ADMIN_PASSWORD}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$GRAFANA_URL/api/dashboards/db" \
|
||||
-d @- >/dev/null 2>&1 \
|
||||
&& echo " Dashboard imported successfully." \
|
||||
|| echo " Dashboard import failed."
|
||||
else
|
||||
echo " grafana-dashboard.json not found, skipping."
|
||||
fi
|
||||
|
||||
# ─── Summary ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Factory 4.0 pret ==="
|
||||
echo " Mascarade API: http://${FACTORY_DOMAIN}:${MASCARADE_PORT}"
|
||||
echo " Fake Ollama: http://${FACTORY_DOMAIN}:11434"
|
||||
echo " Grafana: http://${FACTORY_DOMAIN}:${GRAFANA_PORT} (admin / ${ADMIN_PASSWORD:0:3}***)"
|
||||
echo " Node-RED: http://${FACTORY_DOMAIN}:${NODERED_PORT}"
|
||||
echo " InfluxDB: http://${FACTORY_DOMAIN}:${INFLUXDB_PORT}"
|
||||
echo " MQTT Broker: mqtt://${FACTORY_DOMAIN}:${MOSQUITTO_PORT}"
|
||||
echo ""
|
||||
echo "Pour simuler des donnees capteurs:"
|
||||
echo " python3 $SCRIPT_DIR/simulate_data.py --broker ${FACTORY_DOMAIN} --interval 5 --duration 300"
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
{
|
||||
"__inputs": [
|
||||
{
|
||||
"name": "DS_INFLUXDB",
|
||||
"label": "InfluxDB",
|
||||
"description": "InfluxDB 2.x data source (bucket: machines)",
|
||||
"type": "datasource",
|
||||
"pluginId": "influxdb",
|
||||
"pluginName": "InfluxDB"
|
||||
}
|
||||
],
|
||||
"__requires": [
|
||||
{ "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" },
|
||||
{ "type": "datasource", "id": "influxdb", "name": "InfluxDB", "version": "1.0.0" },
|
||||
{ "type": "panel", "id": "timeseries", "name": "Time series", "version": "1.0.0" },
|
||||
{ "type": "panel", "id": "stat", "name": "Stat", "version": "1.0.0" },
|
||||
{ "type": "panel", "id": "gauge", "name": "Gauge", "version": "1.0.0" },
|
||||
{ "type": "panel", "id": "table", "name": "Table", "version": "1.0.0" }
|
||||
],
|
||||
"id": null,
|
||||
"uid": "factory-4-0-industrial",
|
||||
"title": "Factory 4.0 — Monitoring Industriel",
|
||||
"description": "Dashboard de supervision industrielle : vibrations, temperature, courant moteur, alertes maintenance predictive, uptime machines.",
|
||||
"tags": ["factory", "industrial", "iot", "mascarade"],
|
||||
"timezone": "browser",
|
||||
"editable": true,
|
||||
"graphTooltip": 1,
|
||||
"refresh": "10s",
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"fiscalYearStartMonth": 0,
|
||||
"liveNow": false,
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "machine",
|
||||
"type": "query",
|
||||
"label": "Machine",
|
||||
"query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"machines\", tag: \"machine_id\")",
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"multi": true,
|
||||
"includeAll": true,
|
||||
"current": { "text": "All", "value": "$__all" },
|
||||
"refresh": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Uptime Machines",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit",
|
||||
"min": 0,
|
||||
"max": 1,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "orange", "value": 0.9 },
|
||||
{ "color": "green", "value": 0.98 }
|
||||
]
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "none"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"query": "from(bucket: \"machines\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"machine_status\")\n |> filter(fn: (r) => r._field == \"uptime_ratio\")\n |> filter(fn: (r) => r.machine_id =~ /^${machine:regex}$/)\n |> last()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Alertes actives",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "none",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1 },
|
||||
{ "color": "red", "value": 5 }
|
||||
]
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "none"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"query": "from(bucket: \"machines\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"alerts\")\n |> filter(fn: (r) => r._field == \"active\")\n |> filter(fn: (r) => r._value == true)\n |> filter(fn: (r) => r.machine_id =~ /^${machine:regex}$/)\n |> count()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Temperature moyenne",
|
||||
"type": "gauge",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "celsius",
|
||||
"min": 0,
|
||||
"max": 120,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "blue", "value": null },
|
||||
{ "color": "green", "value": 20 },
|
||||
{ "color": "yellow", "value": 60 },
|
||||
{ "color": "orange", "value": 80 },
|
||||
{ "color": "red", "value": 95 }
|
||||
]
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["mean"], "fields": "", "values": false },
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"query": "from(bucket: \"machines\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"sensors\")\n |> filter(fn: (r) => r._field == \"temperature\")\n |> filter(fn: (r) => r.machine_id =~ /^${machine:regex}$/)\n |> mean()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Courant moteur moyen",
|
||||
"type": "gauge",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "amp",
|
||||
"min": 0,
|
||||
"max": 50,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 25 },
|
||||
{ "color": "orange", "value": 35 },
|
||||
{ "color": "red", "value": 42 }
|
||||
]
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["mean"], "fields": "", "values": false },
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"query": "from(bucket: \"machines\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"sensors\")\n |> filter(fn: (r) => r._field == \"current\")\n |> filter(fn: (r) => r.machine_id =~ /^${machine:regex}$/)\n |> mean()"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Vibrations (mm/s RMS)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "velocityms",
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 15,
|
||||
"gradientMode": "scheme",
|
||||
"showPoints": "auto",
|
||||
"pointSize": 4,
|
||||
"spanNulls": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisLabel": "Vibration (mm/s)"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 4.5 },
|
||||
{ "color": "red", "value": 7.1 }
|
||||
]
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi", "sort": "desc" },
|
||||
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max", "last"] }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"query": "from(bucket: \"machines\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"sensors\")\n |> filter(fn: (r) => r._field == \"vibration\")\n |> filter(fn: (r) => r.machine_id =~ /^${machine:regex}$/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Temperature (C)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "celsius",
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 15,
|
||||
"gradientMode": "scheme",
|
||||
"showPoints": "auto",
|
||||
"pointSize": 4,
|
||||
"spanNulls": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisLabel": "Temperature (°C)"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 60 },
|
||||
{ "color": "orange", "value": 80 },
|
||||
{ "color": "red", "value": 95 }
|
||||
]
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi", "sort": "desc" },
|
||||
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max", "last"] }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"query": "from(bucket: \"machines\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"sensors\")\n |> filter(fn: (r) => r._field == \"temperature\")\n |> filter(fn: (r) => r.machine_id =~ /^${machine:regex}$/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"title": "Courant moteur (A)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "amp",
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 15,
|
||||
"gradientMode": "scheme",
|
||||
"showPoints": "auto",
|
||||
"pointSize": 4,
|
||||
"spanNulls": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisLabel": "Courant (A)"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 25 },
|
||||
{ "color": "orange", "value": 35 },
|
||||
{ "color": "red", "value": 42 }
|
||||
]
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi", "sort": "desc" },
|
||||
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max", "last"] }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"query": "from(bucket: \"machines\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"sensors\")\n |> filter(fn: (r) => r._field == \"current\")\n |> filter(fn: (r) => r.machine_id =~ /^${machine:regex}$/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Vibrations + Temperature + Courant (overlay)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"drawStyle": "line",
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 5,
|
||||
"showPoints": "never",
|
||||
"spanNulls": false
|
||||
},
|
||||
"mappings": []
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byRegexp", "options": "vibration" },
|
||||
"properties": [
|
||||
{ "id": "unit", "value": "velocityms" },
|
||||
{ "id": "custom.axisPlacement", "value": "left" },
|
||||
{ "id": "color", "value": { "fixedColor": "#5794F2", "mode": "fixed" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byRegexp", "options": "temperature" },
|
||||
"properties": [
|
||||
{ "id": "unit", "value": "celsius" },
|
||||
{ "id": "custom.axisPlacement", "value": "right" },
|
||||
{ "id": "color", "value": { "fixedColor": "#FF9830", "mode": "fixed" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byRegexp", "options": "current" },
|
||||
"properties": [
|
||||
{ "id": "unit", "value": "amp" },
|
||||
{ "id": "custom.axisPlacement", "value": "right" },
|
||||
{ "id": "color", "value": { "fixedColor": "#F2495C", "mode": "fixed" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi", "sort": "desc" },
|
||||
"legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"query": "from(bucket: \"machines\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"sensors\")\n |> filter(fn: (r) => r._field == \"vibration\" or r._field == \"temperature\" or r._field == \"current\")\n |> filter(fn: (r) => r.machine_id =~ /^${machine:regex}$/)\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"title": "Historique alertes",
|
||||
"type": "table",
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 },
|
||||
"datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" },
|
||||
"fieldConfig": {
|
||||
"defaults": { "mappings": [] },
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "severity" },
|
||||
"properties": [
|
||||
{
|
||||
"id": "custom.cellOptions",
|
||||
"value": {
|
||||
"type": "color-background",
|
||||
"mode": "gradient"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mappings",
|
||||
"value": [
|
||||
{ "type": "value", "options": { "warning": { "color": "yellow", "text": "Warning" }, "critical": { "color": "red", "text": "CRITICAL" }, "info": { "color": "blue", "text": "Info" } } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {
|
||||
"showHeader": true,
|
||||
"sortBy": [{ "displayName": "_time", "desc": true }],
|
||||
"footer": { "show": false }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"query": "from(bucket: \"machines\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"alerts\")\n |> filter(fn: (r) => r.machine_id =~ /^${machine:regex}$/)\n |> pivot(rowKey: [\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> sort(columns: [\"_time\"], desc: true)\n |> limit(n: 50)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+329
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Factory 4.0 — Simulated MQTT sensor data generator.
|
||||
|
||||
Publishes fake industrial sensor data (vibrations, temperature, motor current)
|
||||
to a Mosquitto MQTT broker. Simulates normal operation with periodic anomalies
|
||||
(gradual degradation, sudden spikes).
|
||||
|
||||
Usage:
|
||||
python3 simulate_data.py --broker localhost --interval 5 --duration 300
|
||||
python3 simulate_data.py --broker localhost --machines 8 --anomaly-rate 0.15
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
except ImportError:
|
||||
print("ERROR: paho-mqtt required. Install: pip install paho-mqtt")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ─── Machine profiles ─────────────────────────────────────────────
|
||||
|
||||
MACHINE_PROFILES = {
|
||||
"cnc-mill": {
|
||||
"vibration_base": 2.5, "vibration_std": 0.4,
|
||||
"temperature_base": 45.0, "temperature_std": 3.0,
|
||||
"current_base": 12.0, "current_std": 1.5,
|
||||
},
|
||||
"conveyor": {
|
||||
"vibration_base": 1.2, "vibration_std": 0.2,
|
||||
"temperature_base": 35.0, "temperature_std": 2.0,
|
||||
"current_base": 5.0, "current_std": 0.8,
|
||||
},
|
||||
"press": {
|
||||
"vibration_base": 4.0, "vibration_std": 0.8,
|
||||
"temperature_base": 55.0, "temperature_std": 5.0,
|
||||
"current_base": 22.0, "current_std": 3.0,
|
||||
},
|
||||
"robot-arm": {
|
||||
"vibration_base": 1.8, "vibration_std": 0.3,
|
||||
"temperature_base": 40.0, "temperature_std": 2.5,
|
||||
"current_base": 8.0, "current_std": 1.0,
|
||||
},
|
||||
"compressor": {
|
||||
"vibration_base": 3.2, "vibration_std": 0.5,
|
||||
"temperature_base": 65.0, "temperature_std": 4.0,
|
||||
"current_base": 18.0, "current_std": 2.5,
|
||||
},
|
||||
}
|
||||
|
||||
ALERT_THRESHOLDS = {
|
||||
"vibration": {"warning": 4.5, "critical": 7.1},
|
||||
"temperature": {"warning": 80.0, "critical": 95.0},
|
||||
"current": {"warning": 30.0, "critical": 42.0},
|
||||
}
|
||||
|
||||
|
||||
# ─── Anomaly simulation ───────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class AnomalyState:
|
||||
"""Tracks per-machine anomaly status."""
|
||||
active: bool = False
|
||||
anomaly_type: str = "" # "degradation" or "spike"
|
||||
field: str = "" # which sensor field
|
||||
start_tick: int = 0
|
||||
duration_ticks: int = 0
|
||||
intensity: float = 0.0 # multiplier
|
||||
progress: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MachineState:
|
||||
machine_id: str
|
||||
profile_name: str
|
||||
uptime_start: float = field(default_factory=time.time)
|
||||
total_ticks: int = 0
|
||||
uptime_ticks: int = 0
|
||||
anomaly: AnomalyState = field(default_factory=AnomalyState)
|
||||
|
||||
|
||||
def maybe_start_anomaly(state: MachineState, anomaly_rate: float, tick: int):
|
||||
"""Randomly trigger an anomaly on this machine."""
|
||||
if state.anomaly.active:
|
||||
return
|
||||
if random.random() > anomaly_rate:
|
||||
return
|
||||
|
||||
atype = random.choice(["degradation", "spike"])
|
||||
afield = random.choice(["vibration", "temperature", "current"])
|
||||
|
||||
if atype == "degradation":
|
||||
duration = random.randint(10, 30)
|
||||
intensity = random.uniform(1.5, 3.0)
|
||||
else: # spike
|
||||
duration = random.randint(2, 5)
|
||||
intensity = random.uniform(2.5, 5.0)
|
||||
|
||||
state.anomaly = AnomalyState(
|
||||
active=True, anomaly_type=atype, field=afield,
|
||||
start_tick=tick, duration_ticks=duration, intensity=intensity,
|
||||
)
|
||||
|
||||
|
||||
def apply_anomaly(state: MachineState, field_name: str, value: float, tick: int) -> float:
|
||||
"""Apply anomaly distortion to a sensor value if applicable."""
|
||||
a = state.anomaly
|
||||
if not a.active or a.field != field_name:
|
||||
return value
|
||||
|
||||
elapsed = tick - a.start_tick
|
||||
if elapsed >= a.duration_ticks:
|
||||
state.anomaly = AnomalyState() # reset
|
||||
return value
|
||||
|
||||
if a.anomaly_type == "degradation":
|
||||
# Gradual ramp up
|
||||
progress = elapsed / a.duration_ticks
|
||||
multiplier = 1.0 + (a.intensity - 1.0) * progress
|
||||
else: # spike
|
||||
# Sudden jump then plateau
|
||||
multiplier = a.intensity
|
||||
|
||||
return value * multiplier
|
||||
|
||||
|
||||
# ─── Data generation ───────────────────────────────────────────────
|
||||
|
||||
def generate_reading(state: MachineState, tick: int) -> dict:
|
||||
"""Generate one sensor reading for a machine."""
|
||||
profile = MACHINE_PROFILES[state.profile_name]
|
||||
|
||||
# Base values with Gaussian noise + slight sinusoidal drift (thermal cycle)
|
||||
t = tick * 0.1
|
||||
vibration = (
|
||||
profile["vibration_base"]
|
||||
+ random.gauss(0, profile["vibration_std"])
|
||||
+ 0.3 * math.sin(t * 0.7)
|
||||
)
|
||||
temperature = (
|
||||
profile["temperature_base"]
|
||||
+ random.gauss(0, profile["temperature_std"])
|
||||
+ 2.0 * math.sin(t * 0.2)
|
||||
)
|
||||
current = (
|
||||
profile["current_base"]
|
||||
+ random.gauss(0, profile["current_std"])
|
||||
+ 1.0 * math.sin(t * 0.5)
|
||||
)
|
||||
|
||||
# Apply anomaly distortion
|
||||
vibration = apply_anomaly(state, "vibration", vibration, tick)
|
||||
temperature = apply_anomaly(state, "temperature", temperature, tick)
|
||||
current = apply_anomaly(state, "current", current, tick)
|
||||
|
||||
# Clamp to realistic ranges
|
||||
vibration = max(0.0, round(vibration, 3))
|
||||
temperature = max(-10.0, round(temperature, 2))
|
||||
current = max(0.0, round(current, 2))
|
||||
|
||||
state.total_ticks += 1
|
||||
state.uptime_ticks += 1
|
||||
|
||||
return {
|
||||
"machine_id": state.machine_id,
|
||||
"machine_type": state.profile_name,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"vibration": vibration,
|
||||
"temperature": temperature,
|
||||
"current": current,
|
||||
"uptime_ratio": round(state.uptime_ticks / max(state.total_ticks, 1), 4),
|
||||
}
|
||||
|
||||
|
||||
def check_alerts(reading: dict) -> list[dict]:
|
||||
"""Check if any reading crosses alert thresholds."""
|
||||
alerts = []
|
||||
for field_name, thresholds in ALERT_THRESHOLDS.items():
|
||||
val = reading.get(field_name, 0)
|
||||
if val >= thresholds["critical"]:
|
||||
severity = "critical"
|
||||
elif val >= thresholds["warning"]:
|
||||
severity = "warning"
|
||||
else:
|
||||
continue
|
||||
alerts.append({
|
||||
"machine_id": reading["machine_id"],
|
||||
"timestamp": reading["timestamp"],
|
||||
"field": field_name,
|
||||
"value": val,
|
||||
"severity": severity,
|
||||
"message": f"{field_name} {severity}: {val} on {reading['machine_id']}",
|
||||
})
|
||||
return alerts
|
||||
|
||||
|
||||
# ─── MQTT publishing ──────────────────────────────────────────────
|
||||
|
||||
def publish_reading(client: mqtt.Client, reading: dict):
|
||||
mid = reading["machine_id"]
|
||||
payload = json.dumps(reading)
|
||||
client.publish(f"factory/sensors/{mid}", payload, qos=1)
|
||||
client.publish("factory/sensors/all", payload, qos=0)
|
||||
|
||||
|
||||
def publish_alert(client: mqtt.Client, alert: dict):
|
||||
payload = json.dumps(alert)
|
||||
client.publish(f"factory/alerts/{alert['machine_id']}", payload, qos=1)
|
||||
client.publish("factory/alerts/all", payload, qos=1)
|
||||
|
||||
|
||||
def publish_status(client: mqtt.Client, state: MachineState):
|
||||
payload = json.dumps({
|
||||
"machine_id": state.machine_id,
|
||||
"machine_type": state.profile_name,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"uptime_ratio": round(state.uptime_ticks / max(state.total_ticks, 1), 4),
|
||||
"anomaly_active": state.anomaly.active,
|
||||
"anomaly_type": state.anomaly.anomaly_type if state.anomaly.active else None,
|
||||
})
|
||||
client.publish(f"factory/status/{state.machine_id}", payload, qos=1, retain=True)
|
||||
|
||||
|
||||
# ─── Main loop ─────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Factory 4.0 simulated MQTT sensor data generator"
|
||||
)
|
||||
parser.add_argument("--broker", default="localhost", help="MQTT broker host (default: localhost)")
|
||||
parser.add_argument("--port", type=int, default=1883, help="MQTT broker port (default: 1883)")
|
||||
parser.add_argument("--interval", type=float, default=5.0, help="Seconds between readings (default: 5)")
|
||||
parser.add_argument("--duration", type=int, default=300, help="Total duration in seconds, 0=infinite (default: 300)")
|
||||
parser.add_argument("--machines", type=int, default=5, help="Number of machines to simulate (default: 5)")
|
||||
parser.add_argument("--anomaly-rate", type=float, default=0.05, help="Anomaly probability per tick per machine (default: 0.05)")
|
||||
parser.add_argument("--quiet", action="store_true", help="Suppress per-reading output")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Build machine fleet
|
||||
profile_names = list(MACHINE_PROFILES.keys())
|
||||
machines: list[MachineState] = []
|
||||
for i in range(args.machines):
|
||||
profile = profile_names[i % len(profile_names)]
|
||||
machines.append(MachineState(
|
||||
machine_id=f"{profile}-{i+1:02d}",
|
||||
profile_name=profile,
|
||||
))
|
||||
|
||||
# MQTT connect
|
||||
client = mqtt.Client(client_id=f"factory-sim-{random.randint(1000,9999)}")
|
||||
print(f"Connecting to MQTT broker {args.broker}:{args.port} ...")
|
||||
try:
|
||||
client.connect(args.broker, args.port, keepalive=60)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Cannot connect to broker: {e}")
|
||||
sys.exit(1)
|
||||
client.loop_start()
|
||||
print(f"Connected. Simulating {len(machines)} machines, interval={args.interval}s, duration={'infinite' if args.duration == 0 else f'{args.duration}s'}")
|
||||
print(f" Machines: {', '.join(m.machine_id for m in machines)}")
|
||||
print(f" Topics: factory/sensors/{{id}}, factory/alerts/{{id}}, factory/status/{{id}}")
|
||||
print()
|
||||
|
||||
# Graceful shutdown
|
||||
running = True
|
||||
def handle_signal(sig, frame):
|
||||
nonlocal running
|
||||
running = False
|
||||
print("\nShutting down...")
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
|
||||
tick = 0
|
||||
start = time.time()
|
||||
total_readings = 0
|
||||
total_alerts = 0
|
||||
|
||||
try:
|
||||
while running:
|
||||
if args.duration > 0 and (time.time() - start) >= args.duration:
|
||||
break
|
||||
|
||||
for state in machines:
|
||||
maybe_start_anomaly(state, args.anomaly_rate, tick)
|
||||
reading = generate_reading(state, tick)
|
||||
publish_reading(client, reading)
|
||||
publish_status(client, state)
|
||||
total_readings += 1
|
||||
|
||||
alerts = check_alerts(reading)
|
||||
for alert in alerts:
|
||||
publish_alert(client, alert)
|
||||
total_alerts += 1
|
||||
if not args.quiet:
|
||||
print(f" !! ALERT {alert['severity'].upper()}: {alert['message']}")
|
||||
|
||||
if not args.quiet:
|
||||
anom = " [ANOMALY]" if state.anomaly.active else ""
|
||||
print(
|
||||
f" [{reading['timestamp'][:19]}] {state.machine_id}: "
|
||||
f"vib={reading['vibration']:.2f} temp={reading['temperature']:.1f} "
|
||||
f"cur={reading['current']:.1f}{anom}"
|
||||
)
|
||||
|
||||
if not args.quiet:
|
||||
print(f"--- tick {tick} | {total_readings} readings | {total_alerts} alerts ---")
|
||||
print()
|
||||
|
||||
tick += 1
|
||||
time.sleep(args.interval)
|
||||
|
||||
finally:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
elapsed = time.time() - start
|
||||
print(f"\nDone. {total_readings} readings, {total_alerts} alerts in {elapsed:.0f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -35,14 +35,14 @@
|
||||
- [ ] Pipeline InfluxDB → PatchTST/TimesNet pour maintenance prédictive
|
||||
- [x] Connecteur Node-RED → Mascarade (HTTP nodes) — `tools/industrial/nodered_connector.py` + `deploy/factory/nodered-flows.json`
|
||||
- [ ] Connecteur OpenMES/Odoo → MCP server
|
||||
- [ ] Dashboard Grafana template industriel (vibrations, température, courant)
|
||||
- [x] Dashboard Grafana template industriel (vibrations, température, courant) — `deploy/factory/grafana-dashboard.json`
|
||||
|
||||
## P1 — Packaging déploiement
|
||||
|
||||
- [ ] Docker Compose `factory-stack.yml` (Mascarade + Ollama + Qdrant + Grafana + InfluxDB + Mosquitto)
|
||||
- [ ] Script `deploy_factory.sh` one-liner
|
||||
- [x] Script `deploy_factory.sh` one-liner — health check retry, Grafana auto-import, env var customization
|
||||
- [ ] Documentation déploiement on-premise
|
||||
- [ ] Test end-to-end avec données simulées
|
||||
- [x] Test end-to-end avec données simulées — `deploy/factory/simulate_data.py` (MQTT fake sensors + anomalies)
|
||||
|
||||
## P2 — Formation & documentation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user