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:
L'électron rare
2026-03-25 06:55:49 +01:00
parent a46f0c23f8
commit 57f68c4a42
4 changed files with 943 additions and 26 deletions
+171 -23
View File
@@ -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"
+440
View File
@@ -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)"
}
]
}
]
}
+329
View File
@@ -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