backup: UCEPSA Edge-OEE piloto productivo activo 2026-06-25

This commit is contained in:
Victor Fraile Garcia 2026-06-25 18:13:18 +02:00
parent 7c5ad7f5a3
commit 42dc050fc7
65 changed files with 37651 additions and 3925 deletions

67
ucepsa/edge-oee-demo/.gitignore vendored Normal file
View File

@ -0,0 +1,67 @@
# Secrets
.env
.env.*
**/.env
**/.env.*
!**/.env.example
# Python
**/__pycache__/
**/*.pyc
**/.venv/
**/venv/
# Logs
*.log
logs/
**/logs/
# Runtime data / volumes
data/
volumes/
postgres_data/
grafana/data/
mosquitto/data/
mosquitto/log/
minio/data/
# Backups and dumps with data
backups/
*.dump
*.backup
*.tar
*.tar.gz
*.tgz
*.zip
*.gz
*.sql.gz
# Keys / certificates
*.pem
*.key
*.crt
*.p12
id_rsa*
id_ed25519*
# Local env files
**/.env.local
**/*.env.local
# Backup / old files
*.bak
*.bak.*
*.bak_*
*_OLD
# Python cache
**/__pycache__/
**/*.pyc
# Grafana runtime data
grafana/data/
**/grafana/data/
# Audit packages
*_audit_*.tgz
*_audit_*.zip

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,54 @@
# Identidad
TENANT=ucepsa
SITE=demo_edge_oee
VERTICAL=edge_oee
MACHINE_ID=CORT-01
ASSET=revpi_oee_node_01
# PostgreSQL MESAVAULT
POSTGRES_DB=mesavault_ucepsa_hot
POSTGRES_USER=mesavault
# Grafana
GRAFANA_ADMIN_USER=admin
# MQTT local
MQTT_PORT=1883
# MQTT credentials for local Edge-OEE sink
MQTT_USER_REVPI=revpi_ucepsa_edge_oee_01
POSTGRES_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
# PostgreSQL writer for Odoo sync
# Odoo UCEPSA
#ODOO_URL=https://17.126.1.61
ODOO_URL=http://17.126.1.61:8069
ODOO_DB=ucepsa_test_jp
ODOO_USER=__SET_IN_ENV_OR_VAULTWARDEN__
ODOO_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
ODOO_WORKCENTER_CODE=CORT-01
ODOO_INSTANCE=odoo_ucepsa_test
ODOO_SYNC_INTERVAL_SECONDS=30
ODOO_WORKORDER_LIMIT=100
ODOO_WORKORDER_STATES=waiting,ready,progress,done
# PostgreSQL writer for Odoo sync
ODOO_SYNC_PGUSER=edge_oee_odoo_sync_writer
ODOO_SYNC_PGPASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
# Operator stop console
OPERATOR_PGUSER=edge_oee_operator_writer
OPERATOR_PGPASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__
OPERATOR_APP_PORT=8080
OPERATOR_HOST_PORT=8088
OPERATOR_DEFAULT_MACHINE_ID=CORT-01
OPERATOR_DEFAULT_ASSET=revpi_oee_node_01
OPERATOR_ALLOWED_MACHINES=CORT-00,CORT-01,CORT-02,CORT-03,CORT-04,CORT-05,CORT-06,CORT-08,CORT-09,CORT-10,CORT-FAS1,CORT-FAS2
# Operator console auto refresh
OPERATOR_AUTO_REFRESH_SECONDS=5
MQTT_PASSWORD_REVPI=__SET_IN_ENV_OR_VAULTWARDEN__
OPERATOR_DEFAULT_MACHINE=CORT-00
GRAFANA_ADMIN_PASSWORD=__SET_IN_ENV_OR_VAULTWARDEN__

View File

@ -0,0 +1,33 @@
services:
mv_ucepsa_edge_oee_mqtt_pg_sink:
build:
context: ./services/edge-oee-mqtt-pg-sink
container_name: mv_ucepsa_edge_oee_mqtt_pg_sink
restart: unless-stopped
environment:
PYTHONUNBUFFERED: "1"
MQTT_HOST: mv_ucepsa_mosquitto
MQTT_PORT: 1883
MQTT_USER: ${MQTT_USER_REVPI}
MQTT_PASSWORD: ${MQTT_PASSWORD_REVPI}
MQTT_TOPIC: vertical/ucepsa/ucepsa_onpremise/edge_oee/revpi_oee_node_01/+
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
TENANT: ucepsa
SITE: ucepsa_onpremise
VERTICAL: edge_oee
ASSET: revpi_oee_node_01
MACHINE_ID: CORT-01
VALUE_HOUR_EUR: 36
TZ: Europe/Madrid
depends_on:
- mv_ucepsa_postgres_hot
- mv_ucepsa_mosquitto
networks:
- mv_ucepsa_net

View File

@ -0,0 +1,35 @@
services:
mv_ucepsa_odoo_workorders_sync:
build:
context: ./services/odoo-workorders-sync
container_name: mv_ucepsa_odoo_workorders_sync
restart: unless-stopped
environment:
PYTHONUNBUFFERED: "1"
ODOO_URL: ${ODOO_URL}
ODOO_DB: ${ODOO_DB}
ODOO_USER: ${ODOO_USER}
ODOO_PASSWORD: ${ODOO_PASSWORD}
ODOO_WORKCENTER_CODE: ${ODOO_WORKCENTER_CODE}
ODOO_INSTANCE: ${ODOO_INSTANCE}
ODOO_SYNC_INTERVAL_SECONDS: ${ODOO_SYNC_INTERVAL_SECONDS}
ODOO_WORKORDER_LIMIT: ${ODOO_WORKORDER_LIMIT}
ODOO_WORKORDER_STATES: ${ODOO_WORKORDER_STATES}
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${ODOO_SYNC_PGUSER}
PGPASSWORD: ${ODOO_SYNC_PGPASSWORD}
TENANT: ucepsa
SITE: ucepsa_test
MACHINE_ID: CORT-01
ASSET: revpi_oee_node_01
TARGET_CYCLE_RATE_PPM: 10
TZ: Europe/Madrid
depends_on:
- mv_ucepsa_postgres_hot
networks:
- mv_ucepsa_net

View File

@ -0,0 +1,28 @@
services:
mv_ucepsa_operator_stop_console:
build:
context: ./services/operator-stop-console
container_name: mv_ucepsa_operator_stop_console
restart: unless-stopped
ports:
- "${OPERATOR_HOST_PORT:-8088}:${OPERATOR_APP_PORT:-8080}"
environment:
PYTHONUNBUFFERED: "1"
PGHOST: mv_ucepsa_postgres_hot
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB}
PGUSER: ${OPERATOR_PGUSER}
PGPASSWORD: ${OPERATOR_PGPASSWORD}
APP_PORT: ${OPERATOR_APP_PORT:-8080}
APP_TITLE: "MESAVAULT Edge-OEE - Consola de paros UCEPSA"
DEFAULT_MACHINE_ID: ${OPERATOR_DEFAULT_MACHINE_ID:-CORT-01}
DEFAULT_ASSET: ${OPERATOR_DEFAULT_ASSET:-revpi_oee_node_01}
ALLOWED_MACHINES: ${OPERATOR_ALLOWED_MACHINES:-CORT-01}
AUTO_REFRESH_SECONDS: ${OPERATOR_AUTO_REFRESH_SECONDS:-5}
TZ: Europe/Madrid
depends_on:
- mv_ucepsa_postgres_hot
networks:
- mv_ucepsa_net

View File

@ -0,0 +1,37 @@
services:
mv_ucepsa_odoo_order_state_publisher:
image: edge-oee-ucepsa-mv_ucepsa_edge_oee_mqtt_pg_sink
container_name: mv_ucepsa_odoo_order_state_publisher
restart: unless-stopped
env_file:
- .env
environment:
ODOO_DB_OVERRIDE: ucepsa_prod
SITE_OVERRIDE: ucepsa_onpremise
MQTT_HOST_OVERRIDE: mv_ucepsa_mosquitto
MQTT_USER: ${MQTT_USER_REVPI}
MQTT_PASSWORD: ${MQTT_PASSWORD_REVPI}
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
volumes:
- ./:/app:ro
working_dir: /app
command:
- python
- tools/odoo_order_state_publisher.py
- --publish
- --loop
- --interval
- "5"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
external: true
name: mv_ucepsa_net

View File

@ -0,0 +1,49 @@
services:
mv_ucepsa_postgres_hot:
image: postgres:16-alpine
container_name: mv_ucepsa_postgres_hot
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: Europe/Madrid
volumes:
- ./postgres/data:/var/lib/postgresql/data
networks:
- mv_ucepsa_net
mv_ucepsa_mosquitto:
image: eclipse-mosquitto:2
container_name: mv_ucepsa_mosquitto
restart: unless-stopped
ports:
- "1883:1883"
volumes:
- ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
networks:
- mv_ucepsa_net
mv_ucepsa_grafana:
image: grafana/grafana-oss:latest
container_name: mv_ucepsa_grafana
restart: unless-stopped
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: "false"
TZ: Europe/Madrid
volumes:
- ./grafana/data:/var/lib/grafana
depends_on:
- mv_ucepsa_postgres_hot
networks:
- mv_ucepsa_net
networks:
mv_ucepsa_net:
name: mv_ucepsa_net

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,524 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode()
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
body = resp.read().decode()
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode(), file=sys.stderr)
raise
datasources = api("GET", "/api/datasources")
print("Datasources encontrados:")
for d in datasources:
print(f"- name={d.get('name')} type={d.get('type')} uid={d.get('uid')} url={d.get('url')} database={d.get('database')}")
pg = next(
(
d for d in datasources
if d.get("type") in ("postgres", "grafana-postgresql-datasource")
or "postgres" in (d.get("type") or "").lower()
or "postgres" in (d.get("name") or "").lower()
or "mesavault" in (d.get("name") or "").lower()
),
None,
)
if not pg:
print("ERROR: no encuentro datasource PostgreSQL/MESAVAULT en Grafana", file=sys.stderr)
sys.exit(1)
DS = {
"type": "postgres",
"uid": pg["uid"],
}
def target(sql, ref="A", fmt="table"):
return {
"refId": ref,
"datasource": DS,
"format": fmt,
"rawQuery": True,
"rawSql": sql,
"editorMode": "code",
}
def stat_panel(panel_id, title, x, y, w, h, sql, unit="none", mappings=None):
field_defaults = {
"unit": unit,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": None},
],
},
}
if mappings:
field_defaults["mappings"] = mappings
return {
"id": panel_id,
"type": "stat",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql)],
"options": {
"reduceOptions": {
"values": False,
"calcs": ["lastNotNull"],
"fields": "",
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
},
"fieldConfig": {
"defaults": field_defaults,
"overrides": [],
},
}
def timeseries_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "timeseries",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="time_series")],
"fieldConfig": {
"defaults": {
"unit": unit,
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 5,
"showPoints": "never",
},
},
"overrides": [],
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": True,
},
"tooltip": {
"mode": "multi",
"sort": "none",
},
},
}
def table_panel(panel_id, title, x, y, w, h, sql):
return {
"id": panel_id,
"type": "table",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql)],
"options": {
"showHeader": True,
"cellHeight": "sm",
},
"fieldConfig": {
"defaults": {},
"overrides": [],
},
}
comm_mapping = [
{
"type": "value",
"options": {
"1": {"text": "OK", "color": "green"},
"0": {"text": "NO OK", "color": "red"},
},
}
]
run_mapping = [
{
"type": "value",
"options": {
"1": {"text": "EN MARCHA", "color": "green"},
"0": {"text": "PARADA", "color": "yellow"},
},
}
]
dashboard = {
"id": None,
"uid": DASHBOARD_UID,
"title": "MESAVAULT Edge-OEE — Máquina",
"tags": ["mesavault", "ucepsa", "edge-oee"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "5s",
"time": {
"from": "now-30m",
"to": "now",
},
"templating": {
"list": [
{
"name": "machine_id",
"type": "query",
"label": "Máquina",
"datasource": DS,
"refresh": 1,
"sort": 1,
"multi": False,
"includeAll": False,
"query": "SELECT machine_id FROM mv_reports_ucepsa_demo.machine_state_latest_multi ORDER BY machine_id",
"current": {
"text": "CORT-00",
"value": "CORT-00",
"selected": True,
},
}
]
},
"panels": [
stat_panel(
1,
"Comunicación",
0,
0,
6,
4,
"""
SELECT
CASE
WHEN age_s < 30 AND comm_ok = true AND COALESCE(sample_valid, true) = true THEN 1
ELSE 0
END AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
mappings=comm_mapping,
),
stat_panel(
2,
"Edad último dato",
6,
0,
6,
4,
"""
SELECT age_s AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="s",
),
stat_panel(
3,
"Estado máquina",
12,
0,
6,
4,
"""
SELECT
CASE WHEN machine_running = true THEN 1 ELSE 0 END AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
mappings=run_mapping,
),
stat_panel(
4,
"Ciclos/min",
18,
0,
6,
4,
"""
SELECT cycle_rate_ppm AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="cpm",
),
stat_panel(
5,
"Potencia total",
0,
4,
6,
4,
"""
SELECT power_total_kw AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="kwatt",
),
stat_panel(
6,
"Energía importada",
6,
4,
6,
4,
"""
SELECT import_kwh AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="kwatth",
),
stat_panel(
7,
"Frecuencia",
12,
4,
6,
4,
"""
SELECT frequency_hz AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="hertz",
),
stat_panel(
8,
"Pulsos acumulados",
18,
4,
6,
4,
"""
SELECT cycle_pulse_count AS value
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
unit="none",
),
timeseries_panel(
9,
"Potencia por fase",
0,
8,
12,
8,
"""
SELECT
ts AS "time",
active_power_l1_w AS "L1 W",
active_power_l2_w AS "L2 W",
active_power_l3_w AS "L3 W",
active_power_w AS "Total W"
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE $__timeFilter(ts)
AND site = 'ucepsa_onpremise'
AND machine_id = '$machine_id'
ORDER BY ts
""",
unit="watt",
),
timeseries_panel(
10,
"Tensiones L1/L2/L3",
12,
8,
12,
8,
"""
SELECT
ts AS "time",
voltage_l1_v AS "L1 V",
voltage_l2_v AS "L2 V",
voltage_l3_v AS "L3 V"
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE $__timeFilter(ts)
AND site = 'ucepsa_onpremise'
AND machine_id = '$machine_id'
ORDER BY ts
""",
unit="volt",
),
timeseries_panel(
11,
"Corrientes L1/L2/L3",
0,
16,
12,
8,
"""
SELECT
ts AS "time",
current_l1_a AS "L1 A",
current_l2_a AS "L2 A",
current_l3_a AS "L3 A"
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE $__timeFilter(ts)
AND site = 'ucepsa_onpremise'
AND machine_id = '$machine_id'
ORDER BY ts
""",
unit="amp",
),
table_panel(
12,
"Última lectura trifásica",
12,
16,
12,
8,
"""
SELECT
machine_id,
round(age_s::numeric, 1) AS age_s,
comm_ok,
sample_valid,
energy_map,
machine_running,
cycle_input_state,
cycle_pulse_count,
wise_cycle_counter_raw,
round(voltage_l1_v::numeric, 2) AS v_l1,
round(voltage_l2_v::numeric, 2) AS v_l2,
round(voltage_l3_v::numeric, 2) AS v_l3,
round(current_l1_a::numeric, 3) AS i_l1,
round(current_l2_a::numeric, 3) AS i_l2,
round(current_l3_a::numeric, 3) AS i_l3,
round(power_total_kw::numeric, 4) AS kw_total,
digital_inputs
FROM mv_reports_ucepsa_demo.machine_state_latest_multi
WHERE machine_id = '$machine_id'
""",
),
table_panel(
13,
"Odoo vs máquina",
0,
24,
12,
7,
"""
SELECT
machine_id,
order_ref,
product_name,
odoo_state,
machine_running,
telemetry_age_s,
cycle_rate_ppm,
status_check,
diagnostic_message
FROM mv_reports_ucepsa_demo.odoo_vs_machine_demo
WHERE machine_id = '$machine_id'
""",
),
table_panel(
14,
"Loss Intelligence",
12,
24,
12,
7,
"""
SELECT
priority_rank,
priority_score,
priority_level,
machine_id,
order_ref,
loss_type,
severity,
duration_min,
cost_eur,
status,
description,
recommended_action
FROM mv_reports_ucepsa_demo.loss_priority_board
WHERE machine_id = '$machine_id'
ORDER BY priority_rank
""",
),
],
}
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Create MESAVAULT Edge-OEE machine dashboard with machine selector",
}
result = api("POST", "/api/dashboards/db", payload)
print("Dashboard creado/actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
raise
def walk_panels(panels):
for p in panels or []:
yield p
for child in walk_panels(p.get("panels", [])):
yield child
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
dashboard = doc["dashboard"]
meta = doc.get("meta", {})
changed = 0
for panel in walk_panels(dashboard.get("panels", [])):
if panel.get("title") in ("Pareto causas de paro", "Pareto causas clasificadas"):
panel["title"] = "Pareto causas clasificadas por minutos"
for target in panel.get("targets", []):
target["rawSql"] = """
SELECT
cause_name AS "Causa",
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'CLASSIFIED'
AND status = 'CLOSED'
GROUP BY cause_name
ORDER BY SUM(duration_min) DESC
LIMIT 10
"""
target["format"] = "table"
panel.setdefault("fieldConfig", {}).setdefault("defaults", {})["unit"] = "min"
changed += 1
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Clean stop cause pareto panel",
}
if meta.get("folderUid"):
payload["folderUid"] = meta["folderUid"]
result = api("POST", "/api/dashboards/db", payload)
print(f"Paneles modificados: {changed}")
print("Dashboard actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -0,0 +1,507 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
STOP_PANEL_IDS = set(range(300, 320))
STOP_PANEL_TITLES = {
"Informe de paros",
"Paros abiertos",
"Pendientes clasificar",
"Nº paros",
"Minutos paro",
"Coste paros",
"Duración media",
"Pareto causas de paro",
"Minutos de paro por hora",
"Paros pendientes de clasificación",
"Detalle completo de paros",
}
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
raise
def flatten_panels(panels):
result = []
for p in panels or []:
result.append(p)
if p.get("panels"):
result.extend(flatten_panels(p.get("panels")))
return result
def find_datasource_from_dashboard(dashboard):
for panel in flatten_panels(dashboard.get("panels", [])):
ds = panel.get("datasource")
if isinstance(ds, dict) and ds.get("uid"):
return {
"type": ds.get("type", "postgres"),
"uid": ds["uid"],
}
for target in panel.get("targets", []) or []:
tds = target.get("datasource")
if isinstance(tds, dict) and tds.get("uid"):
return {
"type": tds.get("type", "postgres"),
"uid": tds["uid"],
}
return None
def find_datasource_fallback():
datasources = api("GET", "/api/datasources")
for d in datasources:
if (
d.get("type") in ("postgres", "grafana-postgresql-datasource")
or "postgres" in (d.get("type") or "").lower()
or "postgres" in (d.get("name") or "").lower()
or "mesavault" in (d.get("name") or "").lower()
):
return {
"type": d.get("type", "postgres"),
"uid": d["uid"],
}
print("Datasources encontrados:")
for d in datasources:
print(f"- name={d.get('name')} type={d.get('type')} uid={d.get('uid')}")
return None
def target(sql, ref="A", fmt="table"):
return {
"refId": ref,
"datasource": DS,
"format": fmt,
"rawQuery": True,
"rawSql": sql,
"editorMode": "code",
}
def stat_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "stat",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="table")],
"options": {
"reduceOptions": {
"values": False,
"calcs": ["lastNotNull"],
"fields": "",
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
},
"fieldConfig": {
"defaults": {
"unit": unit,
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": None},
{"color": "yellow", "value": 1},
{"color": "red", "value": 5},
],
},
},
"overrides": [],
},
}
def table_panel(panel_id, title, x, y, w, h, sql):
return {
"id": panel_id,
"type": "table",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="table")],
"options": {
"showHeader": True,
"cellHeight": "sm",
},
"fieldConfig": {
"defaults": {},
"overrides": [],
},
}
def timeseries_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "timeseries",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="time_series")],
"fieldConfig": {
"defaults": {
"unit": unit,
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 5,
"showPoints": "never",
},
},
"overrides": [],
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": True,
},
"tooltip": {
"mode": "multi",
"sort": "none",
},
},
}
def barchart_panel(panel_id, title, x, y, w, h, sql, unit="none"):
return {
"id": panel_id,
"type": "barchart",
"title": title,
"datasource": DS,
"gridPos": {"x": x, "y": y, "w": w, "h": h},
"targets": [target(sql, fmt="table")],
"fieldConfig": {
"defaults": {
"unit": unit,
},
"overrides": [],
},
"options": {
"orientation": "horizontal",
"xField": "Causa",
"showValue": "always",
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": True,
},
},
}
def max_y(panels):
y = 0
for p in panels:
gp = p.get("gridPos") or {}
y = max(y, int(gp.get("y", 0)) + int(gp.get("h", 1)))
return y
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
dashboard = doc["dashboard"]
meta = doc.get("meta", {})
DS = find_datasource_from_dashboard(dashboard) or find_datasource_fallback()
if not DS:
print("ERROR: no encuentro datasource PostgreSQL/MESAVAULT", file=sys.stderr)
sys.exit(1)
print(f"Datasource usado: type={DS.get('type')} uid={DS.get('uid')}")
existing = dashboard.get("panels", [])
filtered = []
for p in existing:
title = p.get("title")
pid = p.get("id")
if pid in STOP_PANEL_IDS or title in STOP_PANEL_TITLES:
continue
filtered.append(p)
base_y = max_y(filtered) + 1
row = {
"id": 300,
"type": "row",
"title": "Informe de paros",
"collapsed": False,
"gridPos": {"x": 0, "y": base_y, "w": 24, "h": 1},
"panels": [],
}
panels = [
row,
stat_panel(
301,
"Paros abiertos",
0,
base_y + 1,
4,
4,
"""
SELECT COUNT(*) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND status = 'OPEN'
""",
),
stat_panel(
302,
"Pendientes clasificar",
4,
base_y + 1,
4,
4,
"""
SELECT COUNT(*) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'PENDING'
""",
),
stat_panel(
303,
"Nº paros",
8,
base_y + 1,
4,
4,
"""
SELECT COUNT(*) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
),
stat_panel(
304,
"Minutos paro",
12,
base_y + 1,
4,
4,
"""
SELECT COALESCE(SUM(duration_min), 0) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
unit="min",
),
stat_panel(
305,
"Coste paros",
16,
base_y + 1,
4,
4,
"""
SELECT COALESCE(SUM(cost_eur), 0) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
unit="currencyEUR",
),
stat_panel(
306,
"Duración media",
20,
base_y + 1,
4,
4,
"""
SELECT COALESCE(AVG(duration_min), 0) AS value
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
""",
unit="min",
),
barchart_panel(
307,
"Pareto causas de paro",
0,
base_y + 5,
12,
8,
"""
SELECT
cause_name AS "Causa",
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos",
COUNT(*) AS "Nº paros",
ROUND(SUM(cost_eur)::numeric, 2) AS "Coste €"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
GROUP BY cause_name
ORDER BY SUM(duration_min) DESC
LIMIT 10
""",
unit="min",
),
timeseries_panel(
308,
"Minutos de paro por hora",
12,
base_y + 5,
12,
8,
"""
SELECT
date_trunc('hour', started_at) AS "time",
SUM(duration_min) AS "Minutos de paro"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
GROUP BY 1
ORDER BY 1
""",
unit="min",
),
table_panel(
309,
"Paros pendientes de clasificación",
0,
base_y + 13,
24,
6,
"""
SELECT
id AS "ID",
started_at AS "Inicio",
ROUND(duration_min::numeric, 2) AS "Min",
cost_eur AS "Coste €",
status AS "Estado",
classification_status AS "Clasificación",
cause_name AS "Causa",
operator_note AS "Nota operario"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'PENDING'
ORDER BY started_at DESC
""",
),
table_panel(
310,
"Detalle completo de paros",
0,
base_y + 19,
24,
10,
"""
SELECT
id AS "ID",
machine_id AS "Máquina",
started_at AS "Inicio",
ended_at AS "Fin",
duration_min AS "Min",
cost_eur AS "Coste €",
stop_status_label AS "Estado",
classification_status AS "Clasificación",
cause_code AS "Código causa",
cause_name AS "Causa",
start_event_name AS "Evento inicio",
end_event_name AS "Evento fin",
operator_note AS "Nota operario"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND $__timeFilter(started_at)
ORDER BY started_at DESC
LIMIT 100
""",
),
]
dashboard["panels"] = filtered + panels
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Add stop report section to machine dashboard",
}
if meta.get("folderUid"):
payload["folderUid"] = meta["folderUid"]
result = api("POST", "/api/dashboards/db", payload)
print("Dashboard actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -0,0 +1,151 @@
#!/usr/bin/env python3
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
BASE_URL = "http://127.0.0.1:3000"
DASHBOARD_UID = "ucepsa-edge-oee-machine"
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
env[key.strip()] = value.strip().strip('"').strip("'")
return env
env = read_env()
user = env.get("GRAFANA_ADMIN_USER", "admin")
password = env.get("GRAFANA_ADMIN_PASSWORD")
if not password:
print("ERROR: no encuentro GRAFANA_ADMIN_PASSWORD en .env", file=sys.stderr)
sys.exit(1)
token = base64.b64encode(f"{user}:{password}".encode()).decode()
def api(method, path, payload=None):
data = None
headers = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=data,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
print("HTTP ERROR:", e.code, e.read().decode("utf-8"), file=sys.stderr)
raise
def all_panels(panels):
out = []
for p in panels or []:
out.append(p)
out.extend(all_panels(p.get("panels", [])))
return out
doc = api("GET", f"/api/dashboards/uid/{DASHBOARD_UID}")
dashboard = doc["dashboard"]
meta = doc.get("meta", {})
changed = 0
for panel in all_panels(dashboard.get("panels", [])):
title = panel.get("title", "")
if title == "Nº paros":
panel["title"] = "Nº paros en rango"
changed += 1
elif title == "Minutos paro":
panel["title"] = "Minutos paro en rango"
changed += 1
elif title == "Coste paros":
panel["title"] = "Coste paros en rango"
changed += 1
elif title == "Duración media":
panel["title"] = "Duración media en rango"
changed += 1
elif title == "Pareto causas de paro":
panel["title"] = "Pareto causas clasificadas"
for target in panel.get("targets", []):
target["rawSql"] = """
SELECT
cause_name AS "Causa",
ROUND(SUM(duration_min)::numeric, 2) AS "Minutos",
COUNT(*) AS "Nº paros",
ROUND(SUM(cost_eur)::numeric, 2) AS "Coste €"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'CLASSIFIED'
GROUP BY cause_name
ORDER BY SUM(duration_min) DESC
LIMIT 10
"""
target["format"] = "table"
changed += 1
elif title == "Paros pendientes de clasificación":
# Dejamos este panel sin filtro temporal: es backlog operativo.
for target in panel.get("targets", []):
target["rawSql"] = """
SELECT
id AS "ID",
started_at AS "Inicio",
ROUND(duration_min::numeric, 2) AS "Min",
cost_eur AS "Coste €",
status AS "Estado",
classification_status AS "Clasificación",
cause_name AS "Causa",
operator_note AS "Nota operario"
FROM mv_reports_ucepsa_demo.machine_stops_grafana
WHERE machine_id = '$machine_id'
AND classification_status = 'PENDING'
ORDER BY started_at DESC
"""
changed += 1
payload = {
"dashboard": dashboard,
"overwrite": True,
"message": "Fix stop report pareto and clarify time-scoped panels",
}
if meta.get("folderUid"):
payload["folderUid"] = meta["folderUid"]
result = api("POST", "/api/dashboards/db", payload)
print(f"Paneles modificados: {changed}")
print("Dashboard actualizado:")
print(BASE_URL + result.get("url", f"/d/{DASHBOARD_UID}"))

View File

@ -0,0 +1,30 @@
MQTT_HOST=17.126.1.70
MQTT_PORT=1883
MQTT_USER=revpi_ucepsa_edge_oee_01
MQTT_PASSWORD=***REDACTED***
MQTT_CLIENT_ID=ucepsa_edge_oee_revpi_cutters_node_01
TENANT=ucepsa
SITE=ucepsa_test
VERTICAL=edge_oee
ASSET=revpi_oee_node_01
MQTT_TOPIC_TELEMETRY=vertical/ucepsa/ucepsa_test/edge_oee/revpi_oee_node_01/telemetry
MQTT_TOPIC_STATUS=vertical/ucepsa/ucepsa_test/edge_oee/revpi_oee_node_01/status
MQTT_TOPIC_EVENT=vertical/ucepsa/ucepsa_test/edge_oee/revpi_oee_node_01/event
MODBUS_PORT=/dev/ttyRS485
MODBUS_SLAVE=1
MODBUS_BAUD=9600
MODBUS_PARITY=N
MODBUS_STOPBITS=1
MODBUS_BYTESIZE=8
MODBUS_TIMEOUT=1.5
DI_AUTO_INPUT=I_1
DI_CYCLE_INPUT=I_2
DI_DEBOUNCE_MS=50
CYCLE_RATE_WINDOW_SECONDS=60
PUBLISH_INTERVAL_SECONDS=10
MACHINE_ID=CORT-01

View File

@ -0,0 +1,41 @@
# RevPi Edge-OEE Inventory
## Identificación
Hostname:
IP:
Modelo RevPi:
Sistema operativo:
Kernel:
## Red
Interfaz Ethernet:
IP:
Gateway:
DNS:
## Serie / RS-485
Dispositivos detectados:
- /dev/...
## Uso previsto
Captura industrial real para demo MESAVAULT Edge-OEE.
Primera señal objetivo:
- Eastron Modbus RTU
- lectura de tensión
- corriente
- potencia
- kWh

View File

@ -0,0 +1,474 @@
#!/usr/bin/env python3
import json
import os
import time
from datetime import datetime
from pathlib import Path
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusTcpClient
DO0_BLUE_COIL = 16
DO1_RED_COIL = 17
TELEMETRY_TTL_S = 30
ORDER_TTL_S = 30
BLINK_PERIOD_S = 1.0
OFFLINE_LOG_INTERVAL_S = 30
WISE_NODES_FILE = Path("wise_nodes.json")
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def now_ts():
return time.time()
def load_nodes():
data = json.loads(WISE_NODES_FILE.read_text())
nodes = {}
for node in data.get("nodes", []):
if not node.get("enabled", True):
continue
machine_id = node["machine_id"]
host = (
node.get("wise_ip")
or node.get("wise_host")
or node.get("host")
or node.get("ip")
)
if not host:
raise RuntimeError(f"Nodo sin IP: {node}")
nodes[machine_id] = {
"machine_id": machine_id,
"source_node": node.get("source_node", ""),
"host": host,
"port": int(node.get("wise_port", 502)),
"unit": int(node.get("unit", 1)),
}
return nodes
def bool_from_payload(value):
return bool(value)
def order_is_fresh(order):
if not order:
return False
return (now_ts() - order.get("_rx_ts", 0)) <= ORDER_TTL_S
def telemetry_is_fresh(telemetry):
if not telemetry:
return False
return (now_ts() - telemetry.get("_rx_ts", 0)) <= TELEMETRY_TTL_S
class WiseOutputs:
def __init__(self, nodes):
self.nodes = nodes
self.clients = {}
self.last_written = {}
self.last_error_ts = {}
self.offline_until = {}
self.offline_retry_s = float(os.getenv("WISE_OFFLINE_RETRY_S", "15"))
def get_client(self, machine_id):
client = self.clients.get(machine_id)
if client is not None:
return client
node = self.nodes[machine_id]
client = ModbusTcpClient(node["host"], port=node["port"], timeout=2)
if not client.connect():
try:
client.close()
except Exception:
pass
raise RuntimeError(f"No conecta con {machine_id} {node['host']}:{node['port']}")
self.clients[machine_id] = client
return client
def close_client(self, machine_id):
client = self.clients.pop(machine_id, None)
if client is not None:
try:
client.close()
except Exception:
pass
def in_offline_backoff(self, machine_id):
return now_ts() < self.offline_until.get(machine_id, 0)
def mark_offline(self, machine_id):
self.offline_until[machine_id] = now_ts() + self.offline_retry_s
self.last_written.pop(machine_id, None)
def write_coil(self, machine_id, address, value):
node = self.nodes[machine_id]
client = self.get_client(machine_id)
rr = client.write_coil(
address=address,
value=bool(value),
device_id=node["unit"],
)
if rr.isError():
self.close_client(machine_id)
raise RuntimeError(f"Error escribiendo {machine_id} coil {address}: {rr}")
def set_beacon(self, machine_id, blue_on, red_on):
do0 = bool(blue_on)
# Roja con contacto NC:
# rojo ON -> DO1 OFF
# rojo OFF -> DO1 ON
do1 = not bool(red_on)
current = (do0, do1)
previous = self.last_written.get(machine_id)
if previous == current:
return
self.write_coil(machine_id, DO0_BLUE_COIL, do0)
self.write_coil(machine_id, DO1_RED_COIL, do1)
self.last_written[machine_id] = current
self.offline_until.pop(machine_id, None)
if os.getenv("BEACON_LOG_WRITES", "0").lower() in ("1", "true", "yes", "on"):
print(
f"BEACON {machine_id} "
f"blue_on={blue_on} red_on={red_on} "
f"DO0={do0} DO1={do1}",
flush=True,
)
def log_offline_error(self, machine_id, reason):
now = now_ts()
last = self.last_error_ts.get(machine_id, 0)
if now - last >= OFFLINE_LOG_INTERVAL_S:
self.last_error_ts[machine_id] = now
print(f"WISE_OFFLINE {machine_id} {reason}", flush=True)
def safe_connected_only(self):
for machine_id in list(self.clients.keys()):
try:
self.set_beacon(machine_id, blue_on=False, red_on=True)
except Exception as e:
print(f"ERROR dejando safe {machine_id}: {e}", flush=True)
self.close_client(machine_id)
def close_all(self):
for machine_id in list(self.clients.keys()):
self.close_client(machine_id)
def mode_to_bool(mode, blink_on):
if mode == "ON":
return True
if mode == "OFF":
return False
if mode == "BLINK":
return blink_on
return False
def decide_modes(machine_id, telemetry, order):
"""
Devuelve:
blue_mode, red_mode, reason, should_write
should_write=False significa:
máquina probablemente sin corriente y sin orden cargada.
No intentamos escribir en WISE para evitar ruido.
"""
tel_fresh = telemetry_is_fresh(telemetry)
ord_fresh = order_is_fresh(order)
order_present = False
order_coherent = False
force_machine_fault = False
if ord_fresh:
order_present = bool_from_payload(order.get("order_present", False))
order_coherent = bool_from_payload(order.get("order_coherent", False))
force_machine_fault = bool_from_payload(order.get("force_machine_fault", False))
order_valid_loaded = order_present and order_coherent
if not tel_fresh and not order_present:
return "OFF", "OFF", "machine_power_off_or_wise_offline_without_order", False
if not tel_fresh and order_valid_loaded:
return "ON", "BLINK", "order_loaded_but_no_telemetry_or_wise_offline", True
if not tel_fresh and order_present and not order_coherent:
return "BLINK", "BLINK", "odoo_incoherent_but_no_telemetry_or_wise_offline", True
comm_ok = bool_from_payload(telemetry.get("comm_ok", False))
sample_valid = bool_from_payload(telemetry.get("sample_valid", False))
if not comm_ok or not sample_valid:
return "OFF", "BLINK", "telemetry_invalid", True
machine_running = bool_from_payload(telemetry.get("machine_running", False))
last_cycle_age_s = telemetry.get("last_cycle_age_s")
machine_fault = force_machine_fault
if machine_running and isinstance(last_cycle_age_s, (int, float)) and last_cycle_age_s > 20:
machine_fault = True
odoo_incoherent = False
if machine_running and not order_valid_loaded:
odoo_incoherent = True
if order_present and not order_coherent:
odoo_incoherent = True
if machine_fault and odoo_incoherent:
return "BLINK", "BLINK", "machine_fault_and_odoo_incoherent", True
if machine_fault and order_valid_loaded:
return "ON", "BLINK", "machine_fault_with_valid_order", True
if odoo_incoherent and machine_running:
return "BLINK", "OFF", "machine_running_without_valid_odoo_order", True
if odoo_incoherent and not machine_running:
return "BLINK", "ON", "odoo_incoherent_machine_stopped", True
if order_valid_loaded and machine_running:
return "ON", "OFF", "valid_order_and_machine_running", True
if order_valid_loaded and not machine_running:
return "ON", "ON", "valid_order_and_machine_stopped", True
return "OFF", "ON", "no_valid_order_machine_stopped", True
def main():
env = read_env()
nodes = load_nodes()
machine_filter_raw = os.getenv("BEACON_MACHINE_FILTER", "").strip()
if machine_filter_raw:
allowed = {x.strip() for x in machine_filter_raw.split(",") if x.strip()}
nodes = {mid: node for mid, node in nodes.items() if mid in allowed}
if not nodes:
raise RuntimeError(
f"BEACON_MACHINE_FILTER={machine_filter_raw} no coincide con ningún nodo"
)
print(
f"Filtro de máquinas activo: {', '.join(nodes.keys())}",
flush=True,
)
mqtt_host = (
os.getenv("MQTT_HOST_OVERRIDE")
or env.get("MQTT_HOST")
or "127.0.0.1"
)
mqtt_port = int(
os.getenv("MQTT_PORT_OVERRIDE")
or env.get("MQTT_PORT")
or "1883"
)
mqtt_user = (
os.getenv("MQTT_USER_OVERRIDE")
or os.getenv("MQTT_USERNAME_OVERRIDE")
or env.get("MQTT_USER")
or env.get("MQTT_USERNAME")
or env.get("MQTT_USER_REVPI")
)
mqtt_password = (
os.getenv("MQTT_PASSWORD_OVERRIDE")
or os.getenv("MQTT_PASS_OVERRIDE")
or env.get("MQTT_PASSWORD")
or env.get("MQTT_PASS")
or env.get("MQTT_PASSWORD_REVPI")
)
tenant = env.get("TENANT", "ucepsa")
site = env.get("SITE", "ucepsa_onpremise")
vertical = env.get("VERTICAL", "edge_oee")
telemetry_topic = f"vertical/{tenant}/{site}/{vertical}/+/telemetry"
order_topic = f"vertical/{tenant}/{site}/{vertical}/order_state/+"
latest_telemetry = {}
latest_order = {}
last_logged_order = {}
outputs = WiseOutputs(nodes)
def on_connect(client, userdata, flags, rc, properties=None):
print(f"MQTT conectado rc={rc}", flush=True)
if rc == 0:
client.subscribe(telemetry_topic)
client.subscribe(order_topic)
print(f"SUB {telemetry_topic}", flush=True)
print(f"SUB {order_topic}", flush=True)
def on_disconnect(client, userdata, rc, properties=None):
print(f"MQTT desconectado rc={rc}", flush=True)
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload.decode("utf-8"))
payload["_rx_ts"] = now_ts()
topic = msg.topic
if "/order_state/" in topic:
machine_id = payload.get("machine_id") or topic.rsplit("/", 1)[-1]
latest_order[machine_id] = payload
diagnostics = payload.get("diagnostics") or {}
order_signature = (
bool(payload.get("order_present", False)),
bool(payload.get("order_coherent", False)),
payload.get("odoo_state"),
payload.get("workorder_id"),
payload.get("production_order"),
payload.get("product"),
diagnostics.get("waiting_count"),
diagnostics.get("ready_count"),
diagnostics.get("progress_count"),
)
if last_logged_order.get(machine_id) != order_signature:
last_logged_order[machine_id] = order_signature
print(
f"ORDER_CHANGE {machine_id} "
f"present={payload.get('order_present')} "
f"coherent={payload.get('order_coherent')} "
f"state={payload.get('odoo_state')} "
f"mo={payload.get('production_order')} "
f"product={payload.get('product')} "
f"diag={diagnostics}",
flush=True,
)
elif topic.endswith("/telemetry"):
machine_id = payload.get("machine_id")
if machine_id:
latest_telemetry[machine_id] = payload
except Exception as e:
print(f"ERROR procesando MQTT {msg.topic}: {e}", flush=True)
client = mqtt.Client(client_id="mesavault_beacon_controller_power_aware")
if mqtt_user:
client.username_pw_set(mqtt_user, mqtt_password)
print(f"MQTT auth user={mqtt_user}", flush=True)
else:
print("WARN: MQTT sin usuario", flush=True)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
client.connect(mqtt_host, mqtt_port, 60)
client.loop_start()
print("Controlador de balizas power-aware arrancado", flush=True)
print(f"Máquinas: {', '.join(nodes.keys())}", flush=True)
print("Ctrl+C para parar", flush=True)
last_reason = {}
try:
while True:
blink_on = int(time.time() / BLINK_PERIOD_S) % 2 == 0
for machine_id in nodes:
telemetry = latest_telemetry.get(machine_id)
order = latest_order.get(machine_id)
blue_mode, red_mode, reason, should_write = decide_modes(
machine_id,
telemetry,
order,
)
if last_reason.get(machine_id) != reason:
last_reason[machine_id] = reason
print(
f"STATE {machine_id} "
f"blue={blue_mode} red={red_mode} "
f"write={should_write} reason={reason}",
flush=True,
)
if not should_write:
continue
blue_on = mode_to_bool(blue_mode, blink_on)
red_on = mode_to_bool(red_mode, blink_on)
if outputs.in_offline_backoff(machine_id):
continue
try:
outputs.set_beacon(
machine_id,
blue_on=blue_on,
red_on=red_on,
)
except Exception as e:
outputs.close_client(machine_id)
outputs.mark_offline(machine_id)
outputs.log_offline_error(machine_id, str(e))
time.sleep(0.5)
except KeyboardInterrupt:
print("Parando controlador de balizas", flush=True)
finally:
outputs.safe_connected_only()
outputs.close_all()
client.loop_stop()
client.disconnect()
print("Controlador parado", flush=True)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,182 @@
#!/usr/bin/env python3
import json
import sys
import time
from pathlib import Path
from pymodbus.client import ModbusTcpClient
UNIT = 1
DO0_BLUE_COIL = 16
DO1_RED_COIL = 17
WISE_NODES_FILE = Path("wise_nodes.json")
def load_nodes():
data = json.loads(WISE_NODES_FILE.read_text())
nodes = data.get("nodes", [])
result = {}
for node in nodes:
machine_id = node.get("machine_id")
host = node.get("wise_ip") or node.get("wise_host") or node.get("host") or node.get("ip")
if machine_id and host:
result[machine_id] = {
"machine_id": machine_id,
"source_node": node.get("source_node", ""),
"host": host,
}
return result
def write_coil_checked(client, address, value):
rr = client.write_coil(address=address, value=value, device_id=UNIT)
if rr.isError():
raise RuntimeError(f"Error escribiendo coil {address}: {rr}")
def read_outputs(client):
rr = client.read_coils(address=DO0_BLUE_COIL, count=2, device_id=UNIT)
if rr.isError():
raise RuntimeError(f"Error leyendo coils DO: {rr}")
do0 = bool(rr.bits[0])
do1 = bool(rr.bits[1])
blue_on = do0
red_on = not do1
return do0, do1, blue_on, red_on
def set_beacon(client, blue_on, red_on):
# DO0 gobierna azul directamente.
write_coil_checked(client, DO0_BLUE_COIL, bool(blue_on))
# DO1 gobierna roja invertida por contacto NC.
# red_on=True -> DO1 False
# red_on=False -> DO1 True
write_coil_checked(client, DO1_RED_COIL, not bool(red_on))
def print_state(client, label):
do0, do1, blue_on, red_on = read_outputs(client)
print(
f"{label}: "
f"DO0_BLUE={do0} DO1_RED={do1} "
f"=> blue_on={blue_on} red_on={red_on}"
)
def apply_mode(client, mode, seconds):
blink_period = 1.0
end = time.time() + seconds
if mode == "safe":
set_beacon(client, blue_on=False, red_on=True)
print_state(client, "safe")
time.sleep(seconds)
elif mode == "ok_running":
set_beacon(client, blue_on=True, red_on=False)
print_state(client, "ok_running")
time.sleep(seconds)
elif mode == "ok_stopped":
set_beacon(client, blue_on=True, red_on=True)
print_state(client, "ok_stopped")
time.sleep(seconds)
elif mode == "odoo_bad_running":
print("odoo_bad_running: azul intermitente, rojo apagado")
while time.time() < end:
set_beacon(client, blue_on=True, red_on=False)
time.sleep(blink_period)
set_beacon(client, blue_on=False, red_on=False)
time.sleep(blink_period)
elif mode == "machine_fault":
print("machine_fault: azul fijo, rojo intermitente")
while time.time() < end:
set_beacon(client, blue_on=True, red_on=True)
time.sleep(blink_period)
set_beacon(client, blue_on=True, red_on=False)
time.sleep(blink_period)
elif mode == "odoo_bad_and_fault":
print("odoo_bad_and_fault: azul intermitente, rojo intermitente")
while time.time() < end:
set_beacon(client, blue_on=True, red_on=True)
time.sleep(blink_period)
set_beacon(client, blue_on=False, red_on=False)
time.sleep(blink_period)
elif mode == "system_fault":
print("system_fault: azul apagado, rojo intermitente")
while time.time() < end:
set_beacon(client, blue_on=False, red_on=True)
time.sleep(blink_period)
set_beacon(client, blue_on=False, red_on=False)
time.sleep(blink_period)
else:
raise ValueError(f"Modo no reconocido: {mode}")
def test_machine(machine_id, host, mode, seconds):
print(f"Probando {machine_id} en {host} modo={mode} durante {seconds}s")
client = ModbusTcpClient(host, port=502, timeout=3)
if not client.connect():
raise RuntimeError(f"No conecta con {machine_id} {host}:502")
try:
apply_mode(client, mode, seconds)
print_state(client, "estado final antes de safe")
# Dejamos siempre estado seguro al terminar la prueba manual.
set_beacon(client, blue_on=False, red_on=True)
print_state(client, "safe final")
finally:
client.close()
def main():
if len(sys.argv) < 3:
print("Uso:")
print(" python beacon_manual_test.py CORT-00 safe 5")
print(" python beacon_manual_test.py CORT-00 ok_running 5")
print(" python beacon_manual_test.py CORT-00 ok_stopped 5")
print(" python beacon_manual_test.py CORT-00 odoo_bad_running 10")
print(" python beacon_manual_test.py CORT-00 machine_fault 10")
print(" python beacon_manual_test.py CORT-00 odoo_bad_and_fault 10")
print(" python beacon_manual_test.py CORT-00 system_fault 10")
print(" python beacon_manual_test.py all safe 5")
raise SystemExit(1)
target = sys.argv[1]
mode = sys.argv[2]
seconds = float(sys.argv[3]) if len(sys.argv) >= 4 else 5.0
nodes = load_nodes()
if target == "all":
for machine_id, node in nodes.items():
test_machine(machine_id, node["host"], mode, seconds)
return
if target not in nodes:
raise SystemExit(f"No encuentro {target} en wise_nodes.json")
node = nodes[target]
test_machine(target, node["host"], mode, seconds)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
echo "--- HOSTNAME ---"
hostname
echo "--- OS ---"
cat /etc/os-release || true
echo "--- KERNEL ---"
uname -a
echo "--- IP ---"
ip -br a
echo "--- ROUTES ---"
ip route
echo "--- SERIAL DEVICES ---"
ls -l /dev/tty* | head -n 80
echo "--- DMESG TTY ---"
dmesg | grep -i tty || true
echo "--- USB ---"
lsusb || true

View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
import json
from pathlib import Path
import paho.mqtt.client as mqtt
def read_env(path=".env"):
env = {}
p = Path(path)
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def load_machine_ids():
data = json.loads(Path("wise_nodes.json").read_text())
return [
n["machine_id"]
for n in data.get("nodes", [])
if n.get("enabled", True)
]
env = read_env()
mqtt_host = env.get("MQTT_HOST", "127.0.0.1")
mqtt_port = int(env.get("MQTT_PORT", "1883"))
mqtt_user = env.get("MQTT_USERNAME") or env.get("MQTT_USER")
mqtt_password = env.get("MQTT_PASSWORD")
tenant = env.get("TENANT", "ucepsa")
site = env.get("SITE", "ucepsa_onpremise")
vertical = env.get("VERTICAL", "edge_oee")
client = mqtt.Client(client_id="clear_order_state_retained")
if mqtt_user:
client.username_pw_set(mqtt_user, mqtt_password)
client.connect(mqtt_host, mqtt_port, 60)
client.loop_start()
for machine_id in load_machine_ids():
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/{machine_id}"
# Payload vacío + retain=True borra el mensaje retenido del broker.
info = client.publish(topic, payload=b"", qos=0, retain=True)
info.wait_for_publish(timeout=5)
print(f"Retained eliminado: {topic}")
client.loop_stop()
client.disconnect()
print("OK: estados simulados de Odoo eliminados")

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
import struct
from pymodbus.client import ModbusTcpClient
WISE_IP = "17.126.1.201"
UNIT = 1
def f32_abcd(hi, lo):
raw = hi.to_bytes(2, "big") + lo.to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_holding(client, start, count, label):
rr = client.read_holding_registers(address=start, count=count, device_id=UNIT)
if rr.isError():
print(f"{label}: ERROR {rr}")
return None
print(f"{label}: {rr.registers}")
return rr.registers
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
if not client.connect():
raise SystemExit(f"No conecta con WISE {WISE_IP}:502")
try:
print("CORT-01 / wise-cort-01 /", WISE_IP)
data = read_holding(client, 1000, 20, "DATA 1000..1019")
bit_err = read_holding(client, 1100, 20, "BIT ERR 1100..1119")
word_err = read_holding(client, 1200, 20, "WORD ERR 1200..1219")
if data:
print("\nValores float interpretados:")
labels = [
("voltage_l1_v", 0),
("current_l1_a", 2),
("active_power_w", 4),
("apparent_power_va", 6),
("reactive_power_var", 8),
("power_factor", 10),
("frequency_hz", 12),
("import_kwh", 14),
("export_kwh", 16),
("total_active_energy_kwh", 18),
]
for name, offset in labels:
print(f"{name:28s}: {f32_abcd(data[offset], data[offset+1]):.6f}")
finally:
client.close()

View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
import time
import signal
import sys
from collections import deque
import revpimodio2
RUNNING = True
def handle_signal(signum, frame):
global RUNNING
RUNNING = False
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
def main():
rpi = revpimodio2.RevPiModIO(autorefresh=True)
auto_io = getattr(rpi.io, "I_1")
cycle_io = getattr(rpi.io, "I_2")
cycle_pulse_count = 0
previous_cycle = bool(cycle_io.value)
last_edge_ts = 0.0
last_cycle_ts = None
debounce_s = 0.05
pulse_window_s = 60.0
recent_pulses = deque()
print("[START] DI probe")
print("I_1 = machine_auto_signal")
print("I_2 = cycle_pulse")
print("Pulsa CTRL+C para salir")
try:
last_print = 0.0
while RUNNING:
now = time.monotonic()
machine_auto_signal = bool(auto_io.value)
current_cycle = bool(cycle_io.value)
# Flanco ascendente en I_2
if current_cycle and not previous_cycle:
if now - last_edge_ts >= debounce_s:
cycle_pulse_count += 1
last_edge_ts = now
last_cycle_ts = now
recent_pulses.append(now)
previous_cycle = current_cycle
# Limpiar pulsos fuera de la ventana
while recent_pulses and now - recent_pulses[0] > pulse_window_s:
recent_pulses.popleft()
cycle_rate_ppm = len(recent_pulses) * (60.0 / pulse_window_s)
if last_cycle_ts is None:
last_cycle_age_s = None
else:
last_cycle_age_s = round(now - last_cycle_ts, 1)
# Para la demo, machine_running se asocia al interruptor I_1.
# Más adelante podemos hacerlo depender también de pulsos recientes.
machine_running = machine_auto_signal
if now - last_print >= 0.5:
last_print = now
print(
"auto={auto} running={running} cycle_input={cycle} "
"pulse_count={count} cycle_rate_ppm={rate:.1f} last_cycle_age_s={age}".format(
auto=int(machine_auto_signal),
running=int(machine_running),
cycle=int(current_cycle),
count=cycle_pulse_count,
rate=cycle_rate_ppm,
age=last_cycle_age_s,
),
flush=True,
)
time.sleep(0.02)
finally:
rpi.exit()
print("[STOP] DI probe")
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
import argparse
import json
import struct
import sys
from datetime import datetime, timezone
from pymodbus.client import ModbusSerialClient
def decode_float_abcd(registers):
if len(registers) != 2:
raise ValueError(f"Expected 2 registers, got {len(registers)}")
raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_float(client, address, device_id):
rr = client.read_input_registers(
address=address,
count=2,
device_id=device_id,
)
if rr.isError():
raise RuntimeError(f"Modbus error at address {address}: {rr}")
return decode_float_abcd(rr.registers)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--port", default="/dev/ttyRS485")
parser.add_argument("--slave", type=int, default=1)
parser.add_argument("--baud", type=int, default=9600)
parser.add_argument("--parity", default="N")
parser.add_argument("--stopbits", type=int, default=1)
parser.add_argument("--bytesize", type=int, default=8)
parser.add_argument("--timeout", type=float, default=1.5)
args = parser.parse_args()
client = ModbusSerialClient(
port=args.port,
baudrate=args.baud,
parity=args.parity,
stopbits=args.stopbits,
bytesize=args.bytesize,
timeout=args.timeout,
)
if not client.connect():
print("[ERROR] Cannot open Modbus serial port", file=sys.stderr)
return 2
try:
voltage_v = read_float(client, 0, args.slave)
current_a = read_float(client, 6, args.slave)
active_power_w = read_float(client, 12, args.slave)
frequency_hz = read_float(client, 70, args.slave)
import_kwh = read_float(client, 72, args.slave)
export_kwh = read_float(client, 74, args.slave)
payload = {
"ts": datetime.now(timezone.utc).isoformat(),
"source": "revpi_sdm120m",
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{args.slave}",
"port": args.port,
"slave": args.slave,
"baud": args.baud,
"voltage_l1_v": round(voltage_v, 3),
"current_l1_a": round(current_a, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 3),
"export_kwh": round(export_kwh, 3),
"comm_ok": True,
}
print(json.dumps(payload, indent=2, ensure_ascii=False))
return 0
except Exception as exc:
error_payload = {
"ts": datetime.now(timezone.utc).isoformat(),
"source": "revpi_sdm120m",
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{args.slave}",
"port": args.port,
"slave": args.slave,
"baud": args.baud,
"comm_ok": False,
"error_text": f"{type(exc).__name__}: {exc}",
}
print(json.dumps(error_payload, indent=2, ensure_ascii=False))
return 1
finally:
client.close()
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,205 @@
#!/usr/bin/env python3
import json
import os
import struct
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusSerialClient
BASE_DIR = Path(__file__).resolve().parent
ENV_FILE = BASE_DIR / ".env"
START_TIME = time.monotonic()
def load_env_file(path: Path):
if not path.exists():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required environment variable: {name}", file=sys.stderr)
sys.exit(2)
return value
def decode_float_abcd(registers):
raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_float(client, address, device_id):
rr = client.read_input_registers(
address=address,
count=2,
device_id=device_id,
)
if rr.isError():
raise RuntimeError(f"Modbus error at address {address}: {rr}")
return decode_float_abcd(rr.registers)
def read_sdm120m():
port = getenv("MODBUS_PORT", "/dev/ttyRS485")
slave = int(getenv("MODBUS_SLAVE", "1"))
baud = int(getenv("MODBUS_BAUD", "9600"))
parity = getenv("MODBUS_PARITY", "N")
stopbits = int(getenv("MODBUS_STOPBITS", "1"))
bytesize = int(getenv("MODBUS_BYTESIZE", "8"))
timeout = float(getenv("MODBUS_TIMEOUT", "1.5"))
client = ModbusSerialClient(
port=port,
baudrate=baud,
parity=parity,
stopbits=stopbits,
bytesize=bytesize,
timeout=timeout,
)
if not client.connect():
raise RuntimeError(f"Cannot open Modbus serial port {port}")
try:
voltage_v = read_float(client, 0, slave)
current_a = read_float(client, 6, slave)
active_power_w = read_float(client, 12, slave)
frequency_hz = read_float(client, 70, slave)
import_kwh = read_float(client, 72, slave)
export_kwh = read_float(client, 74, slave)
return {
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{slave}",
"modbus_port": port,
"modbus_slave": slave,
"modbus_baud": baud,
"voltage_l1_v": round(voltage_v, 3),
"current_l1_a": round(current_a, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 3),
"export_kwh": round(export_kwh, 3),
"comm_ok": True,
"sample_valid": True,
"error_text": None,
}
finally:
client.close()
def build_payload():
tenant = getenv("TENANT", "ucepsa")
site = getenv("SITE", "demo_edge_oee")
vertical = getenv("VERTICAL", "edge_oee")
asset = getenv("ASSET", "revpi_oee_node_01")
base = {
"asset": asset,
"tenant": tenant,
"site": site,
"vertical": vertical,
"node_type": "revpi_sdm120m_oee_gateway",
"fw": "0.1.0",
"ts": datetime.now(timezone.utc).isoformat(),
"uptime_s": round(time.monotonic() - START_TIME, 1),
"machine_id": None,
"machine_running": None,
"cycle_pulse_count": None,
"cycle_rate_hz": None,
"digital_inputs": {},
}
try:
data = read_sdm120m()
base.update(data)
except Exception as exc:
base.update({
"comm_ok": False,
"sample_valid": False,
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{getenv('MODBUS_SLAVE', '1')}",
"voltage_l1_v": None,
"current_l1_a": None,
"power_total_kw": None,
"frequency_hz": None,
"import_kwh": None,
"export_kwh": None,
"error_text": f"{type(exc).__name__}: {exc}",
})
return base
def publish_once(payload):
mqtt_host = getenv("MQTT_HOST", required=True)
mqtt_port = int(getenv("MQTT_PORT", "1883"))
mqtt_user = getenv("MQTT_USER", required=True)
mqtt_password = getenv("MQTT_PASSWORD", required=True)
mqtt_client_id = getenv("MQTT_CLIENT_ID", required=True)
topic_telemetry = getenv("MQTT_TOPIC_TELEMETRY", required=True)
topic_status = getenv("MQTT_TOPIC_STATUS", required=True)
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=mqtt_client_id,
clean_session=True,
)
client.username_pw_set(mqtt_user, mqtt_password)
client.will_set(topic_status, payload="offline", qos=0, retain=True)
client.connect(mqtt_host, mqtt_port, keepalive=30)
client.loop_start()
client.publish(topic_status, payload="online", qos=0, retain=True)
result = client.publish(
topic_telemetry,
payload=json.dumps(payload, ensure_ascii=False),
qos=0,
retain=False,
)
result.wait_for_publish(timeout=5)
client.loop_stop()
client.disconnect()
if result.rc != mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError(f"MQTT publish failed rc={result.rc}")
def main():
load_env_file(ENV_FILE)
payload = build_payload()
print(json.dumps(payload, indent=2, ensure_ascii=False))
publish_once(payload)
print("[OK] MQTT telemetry published")
return 0 if payload.get("comm_ok") else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,419 @@
#!/usr/bin/env python3
import json
import os
import signal
import struct
import sys
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
import paho.mqtt.client as mqtt
import revpimodio2
from pymodbus.client import ModbusSerialClient
BASE_DIR = Path(__file__).resolve().parent
ENV_FILE = BASE_DIR / ".env"
START_TIME = time.monotonic()
RUNNING = True
def handle_signal(signum, frame):
global RUNNING
RUNNING = False
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
def utc_now_iso():
return datetime.now(timezone.utc).isoformat()
def load_env_file(path: Path):
if not path.exists():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required environment variable: {name}", file=sys.stderr, flush=True)
sys.exit(2)
return value
def decode_float_abcd(registers):
raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_float(client, address, device_id):
rr = client.read_input_registers(
address=address,
count=2,
device_id=device_id,
)
if rr.isError():
raise RuntimeError(f"Modbus error at address {address}: {rr}")
return decode_float_abcd(rr.registers)
def read_sdm120m():
port = getenv("MODBUS_PORT", "/dev/ttyRS485")
slave = int(getenv("MODBUS_SLAVE", "1"))
baud = int(getenv("MODBUS_BAUD", "9600"))
parity = getenv("MODBUS_PARITY", "N")
stopbits = int(getenv("MODBUS_STOPBITS", "1"))
bytesize = int(getenv("MODBUS_BYTESIZE", "8"))
timeout = float(getenv("MODBUS_TIMEOUT", "1.5"))
client = ModbusSerialClient(
port=port,
baudrate=baud,
parity=parity,
stopbits=stopbits,
bytesize=bytesize,
timeout=timeout,
)
if not client.connect():
raise RuntimeError(f"Cannot open Modbus serial port {port}")
try:
voltage_v = read_float(client, 0, slave)
current_a = read_float(client, 6, slave)
active_power_w = read_float(client, 12, slave)
frequency_hz = read_float(client, 70, slave)
import_kwh = read_float(client, 72, slave)
export_kwh = read_float(client, 74, slave)
return {
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{slave}",
"modbus_port": port,
"modbus_slave": slave,
"modbus_baud": baud,
"voltage_l1_v": round(voltage_v, 3),
"current_l1_a": round(current_a, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 3),
"export_kwh": round(export_kwh, 3),
"comm_ok": True,
"sample_valid": True,
"error_text": None,
}
finally:
client.close()
class DigitalInputTracker:
def __init__(self):
self.auto_input_name = getenv("DI_AUTO_INPUT", "I_1")
self.cycle_input_name = getenv("DI_CYCLE_INPUT", "I_2")
self.debounce_s = float(getenv("DI_DEBOUNCE_MS", "50")) / 1000.0
self.window_s = float(getenv("CYCLE_RATE_WINDOW_SECONDS", "60"))
self.rpi = revpimodio2.RevPiModIO(autorefresh=True)
self.auto_io = getattr(self.rpi.io, self.auto_input_name)
self.cycle_io = getattr(self.rpi.io, self.cycle_input_name)
now = time.monotonic()
# Estado estable de I_1 con antirrebote.
self.stable_auto = bool(self.auto_io.value)
self.raw_auto = self.stable_auto
self.raw_auto_since = now
# Pulsos de ciclo I_2.
self.cycle_pulse_count = 0
self.previous_cycle = bool(self.cycle_io.value)
self.last_edge_ts = 0.0
self.last_cycle_ts = None
self.recent_pulses = deque()
self.pending_events = []
def update(self):
now = time.monotonic()
# --- I_1: estado automático con antirrebote y evento inmediato ---
raw_auto = bool(self.auto_io.value)
if raw_auto != self.raw_auto:
self.raw_auto = raw_auto
self.raw_auto_since = now
elif raw_auto != self.stable_auto and (now - self.raw_auto_since) >= self.debounce_s:
previous = self.stable_auto
self.stable_auto = raw_auto
event_name = "machine_started" if self.stable_auto else "machine_stopped"
self.pending_events.append({
"event_type": "machine_state_changed",
"event_name": event_name,
"previous_auto_signal": previous,
"new_auto_signal": self.stable_auto,
"machine_running": self.stable_auto,
"reason_pending": not self.stable_auto,
"event_monotonic_ts": now,
})
# --- I_2: flanco ascendente de ciclo con antirrebote ---
current_cycle = bool(self.cycle_io.value)
if current_cycle and not self.previous_cycle:
if now - self.last_edge_ts >= self.debounce_s:
self.cycle_pulse_count += 1
self.last_edge_ts = now
self.last_cycle_ts = now
self.recent_pulses.append(now)
self.previous_cycle = current_cycle
while self.recent_pulses and now - self.recent_pulses[0] > self.window_s:
self.recent_pulses.popleft()
def snapshot(self):
now = time.monotonic()
while self.recent_pulses and now - self.recent_pulses[0] > self.window_s:
self.recent_pulses.popleft()
cycle_rate_ppm = len(self.recent_pulses) * (60.0 / self.window_s)
if self.last_cycle_ts is None:
last_cycle_age_s = None
else:
last_cycle_age_s = round(now - self.last_cycle_ts, 1)
current_cycle = bool(self.cycle_io.value)
return {
"io_ok": True,
"machine_auto_signal": self.stable_auto,
"machine_running": self.stable_auto,
"cycle_input_state": current_cycle,
"cycle_pulse_count": self.cycle_pulse_count,
"cycle_rate_ppm": round(cycle_rate_ppm, 2),
"last_cycle_age_s": last_cycle_age_s,
"digital_inputs": {
self.auto_input_name: self.stable_auto,
self.cycle_input_name: current_cycle,
},
}
def pop_events(self):
events = list(self.pending_events)
self.pending_events.clear()
return events
def close(self):
try:
self.rpi.exit()
except Exception:
pass
def base_payload():
return {
"asset": getenv("ASSET", "revpi_oee_node_01"),
"tenant": getenv("TENANT", "ucepsa"),
"site": getenv("SITE", "demo_edge_oee"),
"vertical": getenv("VERTICAL", "edge_oee"),
"node_type": getenv("NODE_TYPE", "revpi_sdm120m_oee_gateway"),
"fw": getenv("FW", "0.1.0"),
"source": "revpi_edge_oee_node",
"ts": utc_now_iso(),
"uptime_s": round(time.monotonic() - START_TIME, 1),
"machine_id": getenv("MACHINE_ID", "CORT-01"),
"order_id": None,
}
def build_telemetry_payload(di_tracker):
payload = base_payload()
payload.update(di_tracker.snapshot())
try:
payload.update(read_sdm120m())
except Exception as exc:
payload.update({
"comm_ok": False,
"sample_valid": False,
"meter_model": "Eastron SDM120-M",
"meter_id": f"modbus_{getenv('MODBUS_SLAVE', '1')}",
"voltage_l1_v": None,
"current_l1_a": None,
"power_total_kw": None,
"frequency_hz": None,
"import_kwh": None,
"export_kwh": None,
"error_text": f"{type(exc).__name__}: {exc}",
})
return payload
def build_event_payload(raw_event, di_tracker):
payload = base_payload()
payload.update(di_tracker.snapshot())
payload.update({
"event_type": raw_event["event_type"],
"event_name": raw_event["event_name"],
"previous_auto_signal": raw_event["previous_auto_signal"],
"new_auto_signal": raw_event["new_auto_signal"],
"machine_running": raw_event["machine_running"],
"reason_pending": raw_event["reason_pending"],
"event_ts": utc_now_iso(),
"event_source": "digital_input",
"event_input": getenv("DI_AUTO_INPUT", "I_1"),
})
return payload
def create_mqtt_client():
mqtt_host = getenv("MQTT_HOST", required=True)
mqtt_port = int(getenv("MQTT_PORT", "1883"))
mqtt_user = getenv("MQTT_USER", required=True)
mqtt_password = getenv("MQTT_PASSWORD", required=True)
mqtt_client_id = getenv("MQTT_CLIENT_ID", required=True)
topic_status = getenv("MQTT_TOPIC_STATUS", required=True)
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=mqtt_client_id,
clean_session=True,
)
client.username_pw_set(mqtt_user, mqtt_password)
client.will_set(topic_status, payload="offline", qos=0, retain=True)
client.connect(mqtt_host, mqtt_port, keepalive=30)
client.loop_start()
client.publish(topic_status, payload="online", qos=0, retain=True)
return client
def publish_json(client, topic, payload, retain=False):
result = client.publish(
topic,
payload=json.dumps(payload, ensure_ascii=False),
qos=0,
retain=retain,
)
result.wait_for_publish(timeout=5)
if result.rc != mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError(f"MQTT publish failed rc={result.rc}")
def publish_loop():
topic_telemetry = getenv("MQTT_TOPIC_TELEMETRY", required=True)
topic_event = getenv("MQTT_TOPIC_EVENT", required=True)
topic_status = getenv("MQTT_TOPIC_STATUS", required=True)
interval_s = float(getenv("PUBLISH_INTERVAL_SECONDS", "10"))
di_tracker = DigitalInputTracker()
client = create_mqtt_client()
print("[START] RevPi Edge-OEE MQTT loop", flush=True)
print(f"[CONFIG] topic_telemetry={topic_telemetry}", flush=True)
print(f"[CONFIG] topic_event={topic_event}", flush=True)
print(f"[CONFIG] interval_s={interval_s}", flush=True)
print(f"[CONFIG] auto_input={di_tracker.auto_input_name}", flush=True)
print(f"[CONFIG] cycle_input={di_tracker.cycle_input_name}", flush=True)
next_publish = time.monotonic()
try:
while RUNNING:
di_tracker.update()
# Eventos inmediatos por cambio de I_1.
for raw_event in di_tracker.pop_events():
event_payload = build_event_payload(raw_event, di_tracker)
publish_json(client, topic_event, event_payload, retain=False)
print(
"[EVENT] "
f"{event_payload.get('event_name')} "
f"previous={event_payload.get('previous_auto_signal')} "
f"new={event_payload.get('new_auto_signal')} "
f"pulses={event_payload.get('cycle_pulse_count')} "
f"rate_ppm={event_payload.get('cycle_rate_ppm')}",
flush=True,
)
# Telemetría periódica.
now = time.monotonic()
if now >= next_publish:
telemetry_payload = build_telemetry_payload(di_tracker)
publish_json(client, topic_telemetry, telemetry_payload, retain=False)
print(
"[OK] telemetry "
f"auto={telemetry_payload.get('machine_auto_signal')} "
f"running={telemetry_payload.get('machine_running')} "
f"pulses={telemetry_payload.get('cycle_pulse_count')} "
f"rate_ppm={telemetry_payload.get('cycle_rate_ppm')} "
f"voltage={telemetry_payload.get('voltage_l1_v')} "
f"power_kw={telemetry_payload.get('power_total_kw')}",
flush=True,
)
next_publish = now + interval_s
time.sleep(0.02)
finally:
try:
client.publish(topic_status, payload="offline", qos=0, retain=True)
time.sleep(0.2)
except Exception:
pass
try:
client.loop_stop()
client.disconnect()
except Exception:
pass
di_tracker.close()
print("[STOP] RevPi Edge-OEE MQTT loop", flush=True)
def main():
load_env_file(ENV_FILE)
publish_loop()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,53 @@
#!/usr/bin/env python3
import time
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
UNIT = 1
def u32_lo_hi(lo, hi):
return (lo & 0xFFFF) + ((hi & 0xFFFF) << 16)
client = ModbusTcpClient(WISE_IP, port=502, timeout=2)
if not client.connect():
raise RuntimeError("No conecta con WISE")
last_di1_counter = None
try:
print("Leyendo contador DI1 por Modbus/TCP. Pulsa DI1 y observa cambios.")
while True:
rr = client.read_holding_registers(
address=0,
count=4,
device_id=UNIT,
)
if rr.isError():
print("ERROR:", rr)
time.sleep(1)
continue
regs = rr.registers
di0_counter = u32_lo_hi(regs[0], regs[1])
di1_counter = u32_lo_hi(regs[2], regs[3])
if di1_counter != last_di1_counter:
print(
f"DI0_counter={di0_counter} "
f"DI1_counter={di1_counter} "
f"raw={regs}"
)
last_di1_counter = di1_counter
time.sleep(0.2)
except KeyboardInterrupt:
print("Parado")
finally:
client.close()

View File

@ -0,0 +1,212 @@
#!/usr/bin/env python3
import csv
import math
import signal
import struct
import time
from datetime import datetime
from pathlib import Path
from pymodbus.client import ModbusTcpClient
WISE_IP = "17.126.1.200"
UNIT = 1
INTERVAL_S = 2.0
DATA_BASE = 1000
DATA_COUNT = 20
WORD_ERR_BASE = 1200
WORD_ERR_COUNT = 20
LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)
csv_path = LOG_DIR / f"cort01_eastron_continuo_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
running = True
def stop_handler(signum, frame):
global running
running = False
signal.signal(signal.SIGINT, stop_handler)
signal.signal(signal.SIGTERM, stop_handler)
def f32_abcd(hi, lo):
raw = hi.to_bytes(2, "big") + lo.to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_holding(client, addr, count):
rr = client.read_holding_registers(address=addr, count=count, device_id=UNIT)
if rr.isError():
raise RuntimeError(rr)
return rr.registers
def valid_number(x):
return isinstance(x, float) and math.isfinite(x)
def open_client():
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
if not client.connect():
raise RuntimeError(f"No conecta con WISE {WISE_IP}:502")
return client
fields = [
"local_ts",
"ok",
"fail_reason",
"voltage_l1_v",
"current_l1_a",
"active_power_w",
"apparent_power_va",
"reactive_power_var",
"power_factor",
"frequency_hz",
"import_kwh",
"export_kwh",
"total_active_energy_kwh",
"word_err_nonzero",
"word_err_values",
]
ok_count = 0
fail_count = 0
client = None
print(f"Monitor continuo CORT-01 / wise-cort-01 / {WISE_IP}")
print(f"CSV: {csv_path}")
print("Ctrl+C para parar")
print()
with csv_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
while running:
ts = datetime.now().isoformat(timespec="seconds")
row = {
"local_ts": ts,
"ok": False,
"fail_reason": "",
"voltage_l1_v": "",
"current_l1_a": "",
"active_power_w": "",
"apparent_power_va": "",
"reactive_power_var": "",
"power_factor": "",
"frequency_hz": "",
"import_kwh": "",
"export_kwh": "",
"total_active_energy_kwh": "",
"word_err_nonzero": "",
"word_err_values": "",
}
try:
if client is None:
client = open_client()
data = read_holding(client, DATA_BASE, DATA_COUNT)
word_err = read_holding(client, WORD_ERR_BASE, WORD_ERR_COUNT)
voltage_l1_v = f32_abcd(data[0], data[1])
current_l1_a = f32_abcd(data[2], data[3])
active_power_w = f32_abcd(data[4], data[5])
apparent_power_va = f32_abcd(data[6], data[7])
reactive_power_var = f32_abcd(data[8], data[9])
power_factor = f32_abcd(data[10], data[11])
frequency_hz = f32_abcd(data[12], data[13])
import_kwh = f32_abcd(data[14], data[15])
export_kwh = f32_abcd(data[16], data[17])
total_active_energy_kwh = f32_abcd(data[18], data[19])
non_zero_errs = [(WORD_ERR_BASE + i, v) for i, v in enumerate(word_err) if v != 0]
values_ok = (
valid_number(voltage_l1_v)
and valid_number(current_l1_a)
and valid_number(active_power_w)
and valid_number(frequency_hz)
and 150 <= voltage_l1_v <= 260
and 45 <= frequency_hz <= 55
and current_l1_a >= 0
and len(non_zero_errs) == 0
)
row.update({
"ok": values_ok,
"voltage_l1_v": round(voltage_l1_v, 4),
"current_l1_a": round(current_l1_a, 6),
"active_power_w": round(active_power_w, 4),
"apparent_power_va": round(apparent_power_va, 4),
"reactive_power_var": round(reactive_power_var, 4),
"power_factor": round(power_factor, 6),
"frequency_hz": round(frequency_hz, 4),
"import_kwh": round(import_kwh, 6),
"export_kwh": round(export_kwh, 6),
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
"word_err_nonzero": len(non_zero_errs),
"word_err_values": str(non_zero_errs),
})
if values_ok:
ok_count += 1
status = "OK"
else:
fail_count += 1
status = "BAD"
row["fail_reason"] = "valores_fuera_rango_o_word_err"
print(
f"{ts} {status} "
f"OK={ok_count} FAIL={fail_count} "
f"V={voltage_l1_v:7.2f} "
f"I={current_l1_a:7.3f} "
f"P={active_power_w:8.2f} "
f"F={frequency_hz:6.2f} "
f"kWh={import_kwh:.4f} "
f"WORD_ERR={non_zero_errs}"
)
except Exception as e:
fail_count += 1
row["ok"] = False
row["fail_reason"] = str(e)
print(
f"{ts} ERROR "
f"OK={ok_count} FAIL={fail_count} "
f"{e}"
)
if client is not None:
try:
client.close()
except Exception:
pass
client = None
writer.writerow(row)
f.flush()
time.sleep(INTERVAL_S)
if client is not None:
client.close()
total = ok_count + fail_count
print()
print("Resumen")
print("-------")
print(f"OK={ok_count}")
print(f"FAIL={fail_count}")
if total:
print(f"Disponibilidad lectura={ok_count / total * 100:.2f}%")
print(f"CSV guardado en: {csv_path}")

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
from pymodbus.client import ModbusTcpClient
NODES = [
{"machine_id": "CORT-01", "source_node": "wise-cort-00", "ip": "10.43.54.249"},
{"machine_id": "CORT-00", "source_node": "wise-cort-01", "ip": "10.43.54.251"},
{"machine_id": "CORT-02", "source_node": "wise-cort-02", "ip": "10.43.54.55"},
]
UNIT = 1
def u32_lo_hi(lo, hi):
return (lo & 0xFFFF) + ((hi & 0xFFFF) << 16)
for node in NODES:
print()
print("==================================================")
print(node["machine_id"], node["source_node"], node["ip"])
print("==================================================")
client = ModbusTcpClient(node["ip"], port=502, timeout=3)
if not client.connect():
print("ERROR: no conecta")
continue
try:
rr_di = client.read_discrete_inputs(address=0, count=2, device_id=UNIT)
if rr_di.isError():
print("ERROR DI:", rr_di)
else:
print("DI0 marcha:", bool(rr_di.bits[0]))
print("DI1 estado:", bool(rr_di.bits[1]))
rr_cnt = client.read_holding_registers(address=0, count=4, device_id=UNIT)
if rr_cnt.isError():
print("ERROR counter:", rr_cnt)
else:
regs = rr_cnt.registers
di0_counter = u32_lo_hi(regs[0], regs[1])
di1_counter = u32_lo_hi(regs[2], regs[3])
print("DI0 counter:", di0_counter)
print("DI1 counter:", di1_counter)
print("raw counter:", regs)
rr_energy = client.read_holding_registers(address=1000, count=20, device_id=UNIT)
if rr_energy.isError():
print("ERROR energy:", rr_energy)
else:
print("Energy registers OK, first values:", rr_energy.registers[:6])
finally:
client.close()

View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
import struct
from pymodbus.client import ModbusTcpClient
WISE_IP = "17.126.1.201"
UNIT = 1
DATA_BASE = 1000 # WISE 4X 1001 en base cero
DATA_COUNT = 20
ERROR_BASE = 1200 # WISE 4X 1201 en base cero, error codes COM1 expansion word
ERROR_COUNT = 20
def f32_abcd(hi, lo):
raw = hi.to_bytes(2, "big") + lo.to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_holding(client, start, count):
rr = client.read_holding_registers(address=start, count=count, device_id=UNIT)
if rr.isError():
raise RuntimeError(rr)
return rr.registers
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
if not client.connect():
raise RuntimeError(f"No conecta con WISE {WISE_IP}:502")
try:
print("==================================================")
print("CORT-01 / wise-cort-01 /", WISE_IP)
print("==================================================")
regs = read_holding(client, DATA_BASE, DATA_COUNT)
print("\nRegistros mapeados Eastron en WISE")
print("----------------------------------")
for i in range(0, len(regs), 2):
addr1 = DATA_BASE + i
addr2 = DATA_BASE + i + 1
hi = regs[i]
lo = regs[i + 1]
val = f32_abcd(hi, lo)
print(f"{addr1}/{addr2}: raw=({hi},{lo}) float={val:.6f}")
print("\nInterpretación según mapeo actual")
print("---------------------------------")
labels = [
("voltage_l1_v", 0),
("current_l1_a", 2),
("active_power_w", 4),
("apparent_power_va", 6),
("reactive_power_var", 8),
("power_factor", 10),
("frequency_hz", 12),
("import_kwh", 14),
("export_kwh", 16),
("total_active_energy_kwh", 18),
]
for name, offset in labels:
if offset + 1 < len(regs):
print(f"{name:28s}: {f32_abcd(regs[offset], regs[offset + 1]):.6f}")
print("\nRegistros de error COM1 Expansion Word")
print("--------------------------------------")
try:
errs = read_holding(client, ERROR_BASE, ERROR_COUNT)
non_zero = False
for i, value in enumerate(errs):
if value != 0:
non_zero = True
print(f"ERROR_REG {ERROR_BASE + i}: {value}")
if not non_zero:
print("Todos los registros de error están a 0")
except Exception as e:
print("No se pudieron leer registros de error:", e)
finally:
client.close()

View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
import struct
from pymodbus.client import ModbusTcpClient
WISE_IP = "17.126.1.202" # ajusta si cambia
UNIT = 1
def f32(hi, lo):
return struct.unpack(">f", hi.to_bytes(2, "big") + lo.to_bytes(2, "big"))[0]
client = ModbusTcpClient(WISE_IP, port=502, timeout=3)
if not client.connect():
raise SystemExit("No conecta con WISE")
try:
rr = client.read_holding_registers(address=1000, count=34, device_id=UNIT)
if rr.isError():
raise SystemExit(rr)
r = rr.registers
values = {
"V_L1_N": f32(r[0], r[1]),
"V_L2_N": f32(r[2], r[3]),
"V_L3_N": f32(r[4], r[5]),
"I_L1": f32(r[6], r[7]),
"I_L2": f32(r[8], r[9]),
"I_L3": f32(r[10], r[11]),
"P_L1_W": f32(r[12], r[13]),
"P_L2_W": f32(r[14], r[15]),
"P_L3_W": f32(r[16], r[17]),
"P_TOTAL_W": f32(r[18], r[19]),
"S_TOTAL_VA": f32(r[20], r[21]),
"Q_TOTAL_VAR": f32(r[22], r[23]),
"PF_TOTAL": f32(r[24], r[25]),
"FREQ_HZ": f32(r[26], r[27]),
"IMPORT_KWH": f32(r[28], r[29]),
"EXPORT_KWH": f32(r[30], r[31]),
"TOTAL_ACTIVE_KWH": f32(r[32], r[33]),
}
for k, v in values.items():
print(f"{k:18s}: {v:.4f}")
finally:
client.close()

View File

@ -0,0 +1,97 @@
#!/usr/bin/env python3
import argparse
import struct
from pymodbus.client import ModbusSerialClient
def regs_to_bytes(regs, mode):
a = regs[0]
b = regs[1]
ab = a.to_bytes(2, "big")
cd = b.to_bytes(2, "big")
if mode == "ABCD":
return ab + cd
if mode == "CDAB":
return cd + ab
if mode == "BADC":
return ab[::-1] + cd[::-1]
if mode == "DCBA":
return cd[::-1] + ab[::-1]
raise ValueError(mode)
def decode_float_candidates(regs):
out = {}
for mode in ["ABCD", "CDAB", "BADC", "DCBA"]:
try:
raw = regs_to_bytes(regs, mode)
out[mode] = struct.unpack(">f", raw)[0]
except Exception as exc:
out[mode] = f"ERR: {exc}"
return out
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--port", default="/dev/ttyRS485")
parser.add_argument("--slave", type=int, default=1)
parser.add_argument("--baud", type=int, default=9600)
parser.add_argument("--parity", default="N", choices=["N", "E", "O"])
parser.add_argument("--stopbits", type=int, default=1)
parser.add_argument("--bytesize", type=int, default=8)
parser.add_argument("--address", type=int, default=0)
parser.add_argument("--count", type=int, default=2)
parser.add_argument("--timeout", type=float, default=1.5)
args = parser.parse_args()
print("--- MODBUS RTU PROBE ---")
print(f"port={args.port}")
print(f"slave={args.slave}")
print(f"baud={args.baud}")
print(f"parity={args.parity}")
print(f"address={args.address}")
print(f"count={args.count}")
client = ModbusSerialClient(
port=args.port,
baudrate=args.baud,
parity=args.parity,
stopbits=args.stopbits,
bytesize=args.bytesize,
timeout=args.timeout,
)
if not client.connect():
print("[ERROR] Cannot open serial port")
return 2
try:
rr = client.read_input_registers(
address=args.address,
count=args.count,
device_id=args.slave,
)
if rr.isError():
print(f"[ERROR] Modbus error: {rr}")
return 3
regs = rr.registers
print(f"registers={regs}")
print("float_candidates:")
for mode, value in decode_float_candidates(regs).items():
print(f" {mode}: {value}")
return 0
except Exception as exc:
print(f"[ERROR] No Modbus response: {type(exc).__name__}: {exc}")
return 3
finally:
client.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
WISE_PORT = 502
UNIT = 1
def read_holding(client, start, count):
rr = client.read_holding_registers(
address=start,
count=count,
device_id=UNIT,
)
if rr.isError():
raise RuntimeError(rr)
return rr.registers
def u32_hi_lo(hi, lo):
return ((hi & 0xFFFF) << 16) | (lo & 0xFFFF)
def u32_lo_hi(hi, lo):
return ((lo & 0xFFFF) << 16) | (hi & 0xFFFF)
def print_block(name, start, regs):
print(f"\n{name} start={start}")
print("16-bit:")
for i, value in enumerate(regs):
print(f"addr {start+i:4d}: {value}")
print("\n32-bit pairs:")
for i in range(0, len(regs) - 1, 2):
a = start + i
hi = regs[i]
lo = regs[i + 1]
print(
f"addr {a:4d}/{a+1:4d}: "
f"hi_lo={u32_hi_lo(hi, lo)} "
f"lo_hi={u32_lo_hi(hi, lo)}"
)
def snapshot(client):
return {
"base0": read_holding(client, 0, 12),
"base1": read_holding(client, 1, 12),
}
def main():
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
if not client.connect():
raise RuntimeError("No conecta con WISE")
try:
print("Snapshot inicial")
before = snapshot(client)
for name, regs in before.items():
start = 0 if name == "base0" else 1
print_block(name, start, regs)
input("\nPulsa DI1 exactamente 10 veces y después pulsa ENTER...")
print("\nSnapshot final")
after = snapshot(client)
for name, regs in after.items():
start = 0 if name == "base0" else 1
print_block(name, start, regs)
print("\nDeltas 16-bit:")
for name in before:
start = 0 if name == "base0" else 1
for i, (b, a) in enumerate(zip(before[name], after[name])):
if a != b:
delta = (a - b) & 0xFFFF
print(f"{name} addr {start+i}: before={b} after={a} delta={delta}")
print("\nDeltas 32-bit:")
for name in before:
start = 0 if name == "base0" else 1
bregs = before[name]
aregs = after[name]
for i in range(0, len(bregs) - 1, 2):
b_hi_lo = u32_hi_lo(bregs[i], bregs[i + 1])
a_hi_lo = u32_hi_lo(aregs[i], aregs[i + 1])
d_hi_lo = (a_hi_lo - b_hi_lo) & 0xFFFFFFFF
b_lo_hi = u32_lo_hi(bregs[i], bregs[i + 1])
a_lo_hi = u32_lo_hi(aregs[i], aregs[i + 1])
d_lo_hi = (a_lo_hi - b_lo_hi) & 0xFFFFFFFF
if d_hi_lo or d_lo_hi:
print(
f"{name} addr {start+i}/{start+i+1}: "
f"delta_hi_lo={d_hi_lo} "
f"delta_lo_hi={d_lo_hi}"
)
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,120 @@
#!/usr/bin/env python3
import argparse
import struct
from pymodbus.client import ModbusTcpClient
def call_with_unit(fn, unit, **kwargs):
"""
Compatible con varias versiones de pymodbus:
- pymodbus 3.x usa device_id
- versiones anteriores pueden usar slave o unit
"""
last_error = None
for key in ("device_id", "slave", "unit"):
try:
return fn(**kwargs, **{key: unit})
except TypeError as e:
last_error = e
raise last_error
def f32_abcd(reg_hi, reg_lo):
raw = reg_hi.to_bytes(2, "big") + reg_lo.to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def print_holding(client, unit, start, count):
print(f"\n=== HOLDING REGISTERS start={start}, count={count}, unit={unit} ===")
try:
rr = call_with_unit(
client.read_holding_registers,
unit,
address=start,
count=count
)
except Exception as e:
print("EXCEPTION:", repr(e))
return
if rr.isError():
print("MODBUS ERROR:", rr)
return
regs = rr.registers
for i, value in enumerate(regs):
print(f"{start + i}: {value}")
print("\nFloat ABCD por parejas:")
for i in range(0, len(regs) - 1, 2):
try:
value = f32_abcd(regs[i], regs[i + 1])
print(f"{start + i}/{start + i + 1}: {value:.6f}")
except Exception as e:
print(f"{start + i}/{start + i + 1}: ERROR {e}")
def print_bits(client, unit, kind, start, count):
print(f"\n=== {kind.upper()} start={start}, count={count}, unit={unit} ===")
fn = client.read_discrete_inputs if kind == "discrete" else client.read_coils
try:
rr = call_with_unit(
fn,
unit,
address=start,
count=count
)
except Exception as e:
print("EXCEPTION:", repr(e))
return
if rr.isError():
print("MODBUS ERROR:", rr)
return
bits = rr.bits[:count]
for i, value in enumerate(bits):
print(f"{start + i}: {int(value)}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=502)
parser.add_argument("--unit", type=int, default=1)
args = parser.parse_args()
client = ModbusTcpClient(args.host, port=args.port, timeout=3)
if not client.connect():
print(f"ERROR: no conecta con {args.host}:{args.port}")
return
print(f"OK: conectado a {args.host}:{args.port}")
try:
# El WISE muestra Mapping Address(4X) 1001.
# Probamos base cero y base uno.
print_holding(client, args.unit, 1000, 20)
print_holding(client, args.unit, 1001, 20)
# Intentos típicos para DI por Modbus/TCP.
# Luego afinamos según Bit Status del WISE.
print_bits(client, args.unit, "discrete", 0, 16)
print_bits(client, args.unit, "discrete", 1, 16)
print_bits(client, args.unit, "coils", 0, 16)
print_bits(client, args.unit, "coils", 1, 16)
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import paho.mqtt.client as mqtt
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def build_payload(machine_id, mode):
base = {
"machine_id": machine_id,
"ts": utc_now(),
"source": "odoo_simulator",
}
if mode == "loaded":
base.update({
"order_present": True,
"order_coherent": True,
"odoo_state": "ready",
"workorder_id": 1000,
"production_order": f"SIM-{machine_id}-OK",
"product": "Orden simulada correcta",
})
elif mode == "no_order":
base.update({
"order_present": False,
"order_coherent": False,
"odoo_state": "none",
"workorder_id": None,
"production_order": None,
"product": None,
})
elif mode == "bad":
base.update({
"order_present": True,
"order_coherent": False,
"odoo_state": "wrong_machine",
"workorder_id": 9999,
"production_order": f"SIM-{machine_id}-BAD",
"product": "Orden simulada incoherente",
"reason": "Orden asignada a otra máquina o estado no válido",
})
elif mode == "fault":
base.update({
"order_present": True,
"order_coherent": True,
"odoo_state": "ready",
"workorder_id": 1001,
"production_order": f"SIM-{machine_id}-FAULT",
"product": "Orden simulada con fallo máquina",
"force_machine_fault": True,
})
else:
raise SystemExit(f"Modo no reconocido: {mode}")
return base
def main():
if len(sys.argv) != 3:
print("Uso:")
print(" python publish_order_state.py CORT-00 loaded")
print(" python publish_order_state.py CORT-00 no_order")
print(" python publish_order_state.py CORT-00 bad")
print(" python publish_order_state.py CORT-00 fault")
raise SystemExit(1)
machine_id = sys.argv[1]
mode = sys.argv[2]
env = read_env()
mqtt_host = env.get("MQTT_HOST", "127.0.0.1")
mqtt_port = int(env.get("MQTT_PORT", "1883"))
mqtt_user = env.get("MQTT_USERNAME") or env.get("MQTT_USER")
mqtt_password = env.get("MQTT_PASSWORD")
tenant = env.get("TENANT", "ucepsa")
site = env.get("SITE", "ucepsa_onpremise")
vertical = env.get("VERTICAL", "edge_oee")
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/{machine_id}"
payload = build_payload(machine_id, mode)
client = mqtt.Client(client_id=f"order_state_sim_{machine_id}")
if mqtt_user:
client.username_pw_set(mqtt_user, mqtt_password)
client.connect(mqtt_host, mqtt_port, 60)
client.loop_start()
info = client.publish(topic, json.dumps(payload, separators=(",", ":")), qos=0, retain=True)
info.wait_for_publish(timeout=5)
client.loop_stop()
client.disconnect()
print(f"Publicado en {topic}")
print(json.dumps(payload, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,125 @@
#!/usr/bin/env python3
import argparse
import time
from pymodbus.client import ModbusTcpClient
def read_block(client, kind, start, count, unit):
fn = client.read_holding_registers if kind == "holding" else client.read_input_registers
try:
rr = fn(address=start, count=count, device_id=unit)
except Exception:
return None
if rr.isError():
return None
return rr.registers
def snapshot(client, unit):
ranges = [
("holding", 0, 200),
("holding", 1000, 100),
("holding", 2000, 200),
("holding", 3000, 200),
("holding", 4000, 200),
("input", 0, 200),
("input", 1000, 100),
("input", 2000, 200),
("input", 3000, 200),
("input", 4000, 200),
]
data = {}
for kind, start, count in ranges:
regs = read_block(client, kind, start, count, unit)
if regs is None:
continue
for i, value in enumerate(regs):
data[(kind, start + i)] = value
return data
def u16_delta(before, after):
return (after - before) & 0xFFFF
def u32(hi, lo):
return ((hi & 0xFFFF) << 16) | (lo & 0xFFFF)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", required=True)
parser.add_argument("--unit", type=int, default=1)
parser.add_argument("--expected", type=int, default=10)
args = parser.parse_args()
client = ModbusTcpClient(args.host, port=502, timeout=2)
if not client.connect():
raise RuntimeError("No conecta con WISE")
try:
print("Tomando snapshot inicial...")
before = snapshot(client, args.unit)
print(f"\nAhora pulsa DI1 exactamente {args.expected} veces.")
input("Cuando termines, pulsa ENTER aquí...")
time.sleep(0.5)
print("Tomando snapshot final...")
after = snapshot(client, args.unit)
print("\nCambios 16-bit detectados:")
changes = []
for key, before_value in before.items():
if key not in after:
continue
after_value = after[key]
if after_value != before_value:
delta = u16_delta(before_value, after_value)
closeness = abs(delta - args.expected)
changes.append((closeness, key, before_value, after_value, delta))
for closeness, key, b, a, delta in sorted(changes, key=lambda x: x[0])[:40]:
kind, addr = key
print(f"{kind:7s} addr={addr:5d} before={b:6d} after={a:6d} delta={delta}")
print("\nCambios 32-bit por parejas:")
pair_changes = []
keys = sorted(before.keys())
for kind, addr in keys:
key_hi = (kind, addr)
key_lo = (kind, addr + 1)
if key_lo not in before or key_hi not in after or key_lo not in after:
continue
before_u32 = u32(before[key_hi], before[key_lo])
after_u32 = u32(after[key_hi], after[key_lo])
if before_u32 != after_u32:
delta = (after_u32 - before_u32) & 0xFFFFFFFF
closeness = abs(delta - args.expected)
pair_changes.append((closeness, kind, addr, before_u32, after_u32, delta))
for closeness, kind, addr, b, a, delta in sorted(pair_changes, key=lambda x: x[0])[:40]:
print(f"{kind:7s} addr={addr:5d}/{addr+1:5d} before={b} after={a} delta={delta}")
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
import os
import socket
import time
import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
ENV_PATH = BASE_DIR / ".env"
if ENV_PATH.exists():
for raw in ENV_PATH.read_text().splitlines():
line = raw.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
host = os.environ.get("MQTT_HOST", "17.126.1.70")
port = int(os.environ.get("MQTT_PORT", "1883"))
deadline = time.time() + 120
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout=5):
print(f"[OK] MQTT reachable {host}:{port}")
sys.exit(0)
except OSError as exc:
print(f"[WAIT] MQTT {host}:{port} not reachable yet: {exc}")
time.sleep(5)
print(f"[ERROR] MQTT {host}:{port} not reachable after timeout")
sys.exit(1)

View File

@ -0,0 +1,105 @@
#!/usr/bin/env python3
import time
from datetime import datetime, timezone
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
WISE_PORT = 502
WISE_UNIT = 1
POLL_INTERVAL_S = 0.02
DEBOUNCE_S = 0.02
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def read_di(client):
rr = client.read_discrete_inputs(
address=0,
count=2,
device_id=WISE_UNIT,
)
if rr.isError():
raise RuntimeError(f"Error leyendo DI: {rr}")
di0 = bool(rr.bits[0])
di1 = bool(rr.bits[1])
return di0, di1
def main():
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=1)
if not client.connect():
raise RuntimeError(f"No conecta con WISE {WISE_IP}:{WISE_PORT}")
print(f"Conectado a WISE {WISE_IP}:{WISE_PORT}")
print("DI0 = marcha / automático")
print("DI1 = pulso sellador")
print("Prueba: empieza con DI1 abierto, pulsa y suelta claramente.\n")
last_di0 = None
last_di1 = None
last_rising_ts = 0.0
count = 0
loops = 0
stats_start = time.perf_counter()
max_loop_s = 0.0
try:
while True:
loop_start = time.perf_counter()
now = loop_start
di0, di1 = read_di(client)
if last_di0 is None:
last_di0 = di0
last_di1 = di1
print(f"{utc_now()} INIT DI0={int(di0)} DI1={int(di1)}")
if di0 != last_di0:
print(f"{utc_now()} CHANGE DI0 {int(last_di0)} -> {int(di0)}")
last_di0 = di0
if di1 != last_di1:
print(f"{utc_now()} CHANGE DI1 {int(last_di1)} -> {int(di1)}")
if di1 and not last_di1:
if (now - last_rising_ts) >= DEBOUNCE_S:
count += 1
last_rising_ts = now
print(f"{utc_now()} RISING DI1 count={count}")
last_di1 = di1
loops += 1
loop_s = time.perf_counter() - loop_start
max_loop_s = max(max_loop_s, loop_s)
if time.perf_counter() - stats_start >= 5.0:
elapsed = time.perf_counter() - stats_start
hz = loops / elapsed
print(f"{utc_now()} STATS poll_hz={hz:.1f} max_loop_ms={max_loop_s * 1000:.1f} count={count}")
loops = 0
stats_start = time.perf_counter()
max_loop_s = 0.0
sleep_s = max(0.0, POLL_INTERVAL_S - loop_s)
time.sleep(sleep_s)
except KeyboardInterrupt:
print(f"\nParado. Conteo final DI1={count}")
finally:
client.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,37 @@
{
"nodes": [
{
"enabled": true,
"machine_id": "CORT-01",
"source_node": "wise-cort-01",
"wise_ip": "17.126.1.201",
"wise_port": 502,
"unit": 1,
"holding_base": 1000,
"holding_count": 34,
"session_env": "WISE_SESSION_ID_CORT_01"
},
{
"enabled": true,
"machine_id": "CORT-00",
"source_node": "wise-cort-00",
"wise_ip": "17.126.1.200",
"wise_port": 502,
"unit": 1,
"holding_base": 1000,
"holding_count": 34,
"session_env": "WISE_SESSION_ID_CORT_00"
},
{
"enabled": true,
"machine_id": "CORT-02",
"source_node": "wise-cort-02",
"wise_ip": "17.126.1.202",
"wise_port": 502,
"unit": 1,
"holding_base": 1000,
"holding_count": 34,
"session_env": "WISE_SESSION_ID_CORT_02"
}
]
}

View File

@ -0,0 +1,222 @@
#!/usr/bin/env python3
import json
import struct
import time
from collections import deque
from datetime import datetime, timezone
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
WISE_PORT = 502
WISE_UNIT = 1
HOLDING_BASE = 1000
DI_BASE = 0
POLL_INTERVAL_S = 0.1
PUBLISH_INTERVAL_S = 2.0
DEBOUNCE_S = 0.05
RATE_WINDOW_S = 60.0
TENANT = "ucepsa"
SITE = "demo_edge_oee"
VERTICAL = "edge_oee"
ASSET = "revpi_oee_node_01"
MACHINE_ID = "CORT-01"
SOURCE_NODE = "wise-banco-01"
NODE_TYPE = "revpi_wise_oee_gateway"
FW = "0.1.0"
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def f32_abcd(reg_hi, reg_lo):
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_holdings(client):
rr = client.read_holding_registers(
address=HOLDING_BASE,
count=20,
device_id=WISE_UNIT,
)
if rr.isError():
raise RuntimeError(f"Error leyendo holding registers: {rr}")
return rr.registers
def read_discrete_inputs(client):
rr = client.read_discrete_inputs(
address=DI_BASE,
count=2,
device_id=WISE_UNIT,
)
if rr.isError():
raise RuntimeError(f"Error leyendo discrete inputs: {rr}")
di0 = bool(rr.bits[0])
di1 = bool(rr.bits[1])
return di0, di1
def decode_energy(regs):
voltage_l1_v = f32_abcd(regs[0], regs[1])
current_l1_a = f32_abcd(regs[2], regs[3])
active_power_w = f32_abcd(regs[4], regs[5])
apparent_power_va = f32_abcd(regs[6], regs[7])
reactive_power_var = f32_abcd(regs[8], regs[9])
power_factor = f32_abcd(regs[10], regs[11])
frequency_hz = f32_abcd(regs[12], regs[13])
import_kwh = f32_abcd(regs[14], regs[15])
export_kwh = f32_abcd(regs[16], regs[17])
total_active_energy_kwh = f32_abcd(regs[18], regs[19])
return {
"voltage_l1_v": round(voltage_l1_v, 3),
"current_l1_a": round(current_l1_a, 6),
"active_power_w": round(active_power_w, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"apparent_power_va": round(apparent_power_va, 3),
"reactive_power_var": round(reactive_power_var, 3),
"power_factor": round(power_factor, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 6),
"export_kwh": round(export_kwh, 6),
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
}
def main():
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
cycle_pulses = deque()
cycle_pulse_count = 0
last_cycle_state = None
last_auto_signal = None
last_pulse_ts = 0.0
last_publish_ts = 0.0
print(f"Conectando con WISE {WISE_IP}:{WISE_PORT} unit={WISE_UNIT}")
while True:
now = time.time()
try:
if not client.connected:
if not client.connect():
raise RuntimeError("No conecta con WISE")
auto_signal, cycle_state = read_discrete_inputs(client)
if last_cycle_state is None:
last_cycle_state = cycle_state
rising_edge = cycle_state and not last_cycle_state
if rising_edge and (now - last_pulse_ts) >= DEBOUNCE_S:
cycle_pulse_count += 1
cycle_pulses.append(now)
last_pulse_ts = now
last_cycle_state = cycle_state
while cycle_pulses and (now - cycle_pulses[0]) > RATE_WINDOW_S:
cycle_pulses.popleft()
cycle_rate_ppm = len(cycle_pulses) * 60.0 / RATE_WINDOW_S
last_cycle_age_s = None if not cycle_pulses else round(now - cycle_pulses[-1], 3)
if last_auto_signal is not None and auto_signal != last_auto_signal:
event_name = "machine_started" if auto_signal else "machine_stopped"
event = {
"ts": utc_now(),
"tenant": TENANT,
"site": SITE,
"vertical": VERTICAL,
"asset": ASSET,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"event_type": "machine_state_changed",
"event_name": event_name,
"previous_auto_signal": last_auto_signal,
"new_auto_signal": auto_signal,
"machine_running": auto_signal,
"reason_pending": not auto_signal,
}
print("EVENT", json.dumps(event, ensure_ascii=False))
last_auto_signal = auto_signal
if (now - last_publish_ts) >= PUBLISH_INTERVAL_S:
regs = read_holdings(client)
energy = decode_energy(regs)
payload = {
"ts": utc_now(),
"asset": ASSET,
"tenant": TENANT,
"site": SITE,
"vertical": VERTICAL,
"node_type": NODE_TYPE,
"fw": FW,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"wise_host": WISE_IP,
"machine_auto_signal": auto_signal,
"machine_running": auto_signal,
"cycle_input_state": cycle_state,
"cycle_pulse_count": cycle_pulse_count,
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
"last_cycle_age_s": last_cycle_age_s,
"digital_inputs": {
"DI0": auto_signal,
"DI1": cycle_state,
},
**energy,
"comm_ok": True,
"sample_valid": True,
}
print("TELEMETRY", json.dumps(payload, ensure_ascii=False))
last_publish_ts = now
except KeyboardInterrupt:
print("Parado por usuario")
break
except Exception as exc:
print("ERROR", json.dumps({
"ts": utc_now(),
"asset": ASSET,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"wise_host": WISE_IP,
"comm_ok": False,
"sample_valid": False,
"error": str(exc),
}, ensure_ascii=False))
try:
client.close()
except Exception:
pass
time.sleep(2)
time.sleep(POLL_INTERVAL_S)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,303 @@
#!/usr/bin/env python3
import json
import os
import struct
import time
import urllib.request
from collections import deque
from datetime import datetime, timezone
from pymodbus.client import ModbusTcpClient
WISE_IP = "10.43.54.249"
WISE_PORT = 502
WISE_UNIT = 1
WISE_DI_URL = f"http://{WISE_IP}/di_value/slot_0"
WISE_SESSION_ID = os.getenv("WISE_SESSION_ID", "").strip()
HOLDING_BASE = 1000
DI_POLL_INTERVAL_S = 0.2
ENERGY_INTERVAL_S = 2.0
PRINT_INTERVAL_S = 2.0
RATE_WINDOW_S = 60.0
TENANT = "ucepsa"
SITE = "demo_edge_oee"
VERTICAL = "edge_oee"
ASSET = "revpi_oee_node_01"
MACHINE_ID = "CORT-01"
SOURCE_NODE = "wise-banco-01"
NODE_TYPE = "revpi_wise_oee_gateway"
FW = "0.2.0"
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def f32_abcd(reg_hi, reg_lo):
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def read_wise_di_api():
from urllib.error import HTTPError
if not WISE_SESSION_ID:
raise RuntimeError(
"Falta WISE_SESSION_ID. Ejecuta primero: export WISE_SESSION_ID"
)
req = urllib.request.Request(
WISE_DI_URL,
headers={
"Accept": "text/plain, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"http://{WISE_IP}/config/index.html",
"User-Agent": "Mozilla/5.0",
"Cookie": f"adamsessionid={WISE_SESSION_ID}",
},
)
try:
with urllib.request.urlopen(req, timeout=2) as response:
text = response.read().decode("utf-8", errors="replace")
except HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(
f"WISE HTTP error {e.code}: {e.reason}. Body={body[:300]!r}"
)
data = json.loads(text)
if isinstance(data, dict) and data.get("Err"):
raise RuntimeError(
f"WISE API error Err={data.get('Err')} Msg={data.get('Msg')!r}"
)
channels = {int(item["Ch"]): item for item in data.get("DIVal", [])}
ch0 = channels.get(0, {})
ch1 = channels.get(1, {})
return {
"di0_stat": bool(ch0.get("Stat", 0)),
"di1_stat": bool(ch1.get("Stat", 0)),
"di1_counter_raw": int(ch1.get("CtFq", 0)),
"di1_counting": bool(ch1.get("Cnting", 0)),
"di1_mode": int(ch1.get("Md", -1)),
"raw": data,
}
def read_holdings(client):
if not client.connected:
if not client.connect():
raise RuntimeError("No conecta con WISE por Modbus/TCP")
rr = client.read_holding_registers(
address=HOLDING_BASE,
count=20,
device_id=WISE_UNIT,
)
if rr.isError():
raise RuntimeError(f"Error leyendo holding registers: {rr}")
return rr.registers
def decode_energy(regs):
active_power_w = f32_abcd(regs[4], regs[5])
return {
"voltage_l1_v": round(f32_abcd(regs[0], regs[1]), 3),
"current_l1_a": round(f32_abcd(regs[2], regs[3]), 6),
"active_power_w": round(active_power_w, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"apparent_power_va": round(f32_abcd(regs[6], regs[7]), 3),
"reactive_power_var": round(f32_abcd(regs[8], regs[9]), 3),
"power_factor": round(f32_abcd(regs[10], regs[11]), 6),
"frequency_hz": round(f32_abcd(regs[12], regs[13]), 3),
"import_kwh": round(f32_abcd(regs[14], regs[15]), 6),
"export_kwh": round(f32_abcd(regs[16], regs[17]), 6),
"total_active_energy_kwh": round(f32_abcd(regs[18], regs[19]), 6),
}
def counter_delta(previous, current):
if previous is None:
return 0, False
if current >= previous:
return current - previous, False
# Reset manual, reinicio del WISE o pérdida del contador.
return 0, True
def main():
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
previous_wise_counter = None
cycle_pulse_count = 0
cycle_events = deque()
last_auto_signal = None
last_energy = {}
last_energy_ts = 0.0
last_print_ts = 0.0
last_delta = 0
counter_reset_detected = False
print(f"Leyendo WISE {WISE_IP}")
print(f"DI API: {WISE_DI_URL}")
print(f"Modbus/TCP: {WISE_IP}:{WISE_PORT}, holding base {HOLDING_BASE}")
print("DI0 = marcha / automático")
print("DI1 = contador de ciclos\n")
while True:
now = time.time()
try:
di = read_wise_di_api()
auto_signal = di["di0_stat"]
cycle_input_state = di["di1_stat"]
wise_counter = di["di1_counter_raw"]
counter_valid = di["di1_mode"] == 1 and di["di1_counting"]
delta, reset = counter_delta(previous_wise_counter, wise_counter)
counter_reset_detected = reset
if delta > 0:
cycle_pulse_count += delta
for _ in range(min(delta, 1000)):
cycle_events.append(now)
print(
"CYCLES",
json.dumps(
{
"ts": utc_now(),
"delta": delta,
"wise_counter": wise_counter,
"cycle_pulse_count": cycle_pulse_count,
},
ensure_ascii=False,
),
)
previous_wise_counter = wise_counter
last_delta = delta
while cycle_events and (now - cycle_events[0]) > RATE_WINDOW_S:
cycle_events.popleft()
cycle_rate_ppm = len(cycle_events) * 60.0 / RATE_WINDOW_S
last_cycle_age_s = None if not cycle_events else round(now - cycle_events[-1], 3)
if last_auto_signal is not None and auto_signal != last_auto_signal:
event_name = "machine_started" if auto_signal else "machine_stopped"
print(
"EVENT",
json.dumps(
{
"ts": utc_now(),
"tenant": TENANT,
"site": SITE,
"vertical": VERTICAL,
"asset": ASSET,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"event_type": "machine_state_changed",
"event_name": event_name,
"previous_auto_signal": last_auto_signal,
"new_auto_signal": auto_signal,
"machine_running": auto_signal,
"reason_pending": not auto_signal,
},
ensure_ascii=False,
),
)
last_auto_signal = auto_signal
if now - last_energy_ts >= ENERGY_INTERVAL_S:
regs = read_holdings(client)
last_energy = decode_energy(regs)
last_energy_ts = now
if now - last_print_ts >= PRINT_INTERVAL_S:
payload = {
"ts": utc_now(),
"asset": ASSET,
"tenant": TENANT,
"site": SITE,
"vertical": VERTICAL,
"node_type": NODE_TYPE,
"fw": FW,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"wise_host": WISE_IP,
"machine_auto_signal": auto_signal,
"machine_running": auto_signal,
"cycle_input_state": cycle_input_state,
"wise_cycle_counter_raw": wise_counter,
"cycle_delta_last": last_delta,
"cycle_pulse_count": cycle_pulse_count,
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
"last_cycle_age_s": last_cycle_age_s,
"counter_valid": counter_valid,
"counter_reset_detected": counter_reset_detected,
"digital_inputs": {
"DI0": auto_signal,
"DI1": cycle_input_state,
},
**last_energy,
"comm_ok": True,
"sample_valid": True,
}
print("TELEMETRY", json.dumps(payload, ensure_ascii=False))
last_print_ts = now
except KeyboardInterrupt:
print("\nParado por usuario")
break
except Exception as exc:
print(
"ERROR",
json.dumps(
{
"ts": utc_now(),
"asset": ASSET,
"machine_id": MACHINE_ID,
"source_node": SOURCE_NODE,
"wise_host": WISE_IP,
"comm_ok": False,
"sample_valid": False,
"error": str(exc),
},
ensure_ascii=False,
),
)
try:
client.close()
except Exception:
pass
time.sleep(2)
time.sleep(DI_POLL_INTERVAL_S)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,491 @@
#!/usr/bin/env python3
import json
import os
import signal
import struct
import time
import urllib.request
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import HTTPError, URLError
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusTcpClient
BASE_DIR = Path(__file__).resolve().parent
ENV_PATH = BASE_DIR / ".env"
NODES_PATH = BASE_DIR / "wise_nodes.json"
def load_env(path: Path):
if not path.exists():
return
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def env(name, default=None, required=False, cast=str):
value = os.environ.get(name, default)
if required and (value is None or str(value).strip() == ""):
raise RuntimeError(f"Falta variable obligatoria: {name}")
if value is None:
return None
return cast(value)
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def dumps(data):
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def f32_abcd(reg_hi, reg_lo):
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def counter_delta(previous, current):
if previous is None:
return 0, False
if current >= previous:
return current - previous, False
return 0, True
def load_nodes():
data = json.loads(NODES_PATH.read_text())
nodes = [n for n in data.get("nodes", []) if n.get("enabled", True)]
if not nodes:
raise RuntimeError("No hay nodos WISE habilitados en wise_nodes.json")
return nodes
def make_mqtt_client():
host = env("MQTT_HOST", required=True)
port = env("MQTT_PORT", 1883, cast=int)
user = env("MQTT_USER", "")
password = env("MQTT_PASSWORD", "")
client_id = env("MQTT_CLIENT_ID", required=True)
try:
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id)
except AttributeError:
client = mqtt.Client(client_id=client_id)
if user:
client.username_pw_set(user, password)
client.connect(host, port, keepalive=60)
client.loop_start()
return client
def publish(client, topic, payload):
client.publish(topic, dumps(payload), qos=0, retain=False)
def read_wise_di_api(node):
wise_ip = node["wise_ip"]
session_env = node["session_env"]
session_id = os.environ.get(session_env, "").strip()
if not session_id:
raise RuntimeError(f"Falta {session_env}. Exporta la cookie adamsessionid del WISE.")
req = urllib.request.Request(
f"http://{wise_ip}/di_value/slot_0",
headers={
"Accept": "text/plain, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"http://{wise_ip}/config/index.html",
"User-Agent": "Mozilla/5.0",
"Cookie": f"adamsessionid={session_id}",
},
)
try:
with urllib.request.urlopen(req, timeout=2) as response:
text = response.read().decode("utf-8", errors="replace")
except HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"WISE HTTP {e.code}: {e.reason}. Body={body[:200]!r}")
except URLError as e:
raise RuntimeError(f"WISE HTTP unreachable: {e}")
data = json.loads(text)
if isinstance(data, dict) and data.get("Err"):
raise RuntimeError(f"WISE API Err={data.get('Err')} Msg={data.get('Msg')!r}")
channels = {int(item["Ch"]): item for item in data.get("DIVal", [])}
ch0 = channels.get(0, {})
ch1 = channels.get(1, {})
return {
"di0_stat": bool(ch0.get("Stat", 0)),
"di1_stat": bool(ch1.get("Stat", 0)),
"di1_counter_raw": int(ch1.get("CtFq", 0)),
"di1_counting": bool(ch1.get("Cnting", 0)),
"di1_mode": int(ch1.get("Md", -1)),
}
def u32_lo_hi(lo, hi):
return (int(lo) & 0xFFFF) + ((int(hi) & 0xFFFF) << 16)
def read_wise_inputs_modbus(state, node):
client = ensure_modbus_client(state, node)
unit = int(node.get("unit", 1))
rr_di = client.read_discrete_inputs(
address=0,
count=2,
device_id=unit,
)
if rr_di.isError():
raise RuntimeError(f"Error leyendo DI status: {rr_di}")
rr_counter = client.read_holding_registers(
address=0,
count=4,
device_id=unit,
)
if rr_counter.isError():
raise RuntimeError(f"Error leyendo contadores DI: {rr_counter}")
regs = rr_counter.registers
di0_counter = u32_lo_hi(regs[0], regs[1])
di1_counter = u32_lo_hi(regs[2], regs[3])
return {
"di0_stat": bool(rr_di.bits[0]),
"di1_stat": bool(rr_di.bits[1]),
"di0_counter_raw": di0_counter,
"di1_counter_raw": di1_counter,
"di1_counting": True,
"di1_mode": 1,
}
def ensure_modbus_client(state, node):
client = state.get("modbus_client")
if client is None:
client = ModbusTcpClient(
node["wise_ip"],
port=int(node.get("wise_port", 502)),
timeout=3,
)
state["modbus_client"] = client
if not client.connected:
if not client.connect():
raise RuntimeError("No conecta con WISE por Modbus/TCP")
return client
def read_holdings(state, node):
client = ensure_modbus_client(state, node)
rr = client.read_holding_registers(
address=int(node.get("holding_base", 1000)),
count=int(node.get("holding_count", 20)),
device_id=int(node.get("unit", 1)),
)
if rr.isError():
raise RuntimeError(f"Error leyendo holding registers: {rr}")
return rr.registers
def decode_energy(regs):
if len(regs) < 34:
raise ValueError(f"Se esperaban 34 registros SDM630, recibidos {len(regs)}")
def v(offset):
return f32_abcd(regs[offset], regs[offset + 1])
voltage_l1_v = v(0)
voltage_l2_v = v(2)
voltage_l3_v = v(4)
current_l1_a = v(6)
current_l2_a = v(8)
current_l3_a = v(10)
active_power_l1_w = v(12)
active_power_l2_w = v(14)
active_power_l3_w = v(16)
active_power_w = v(18)
apparent_power_va = v(20)
reactive_power_var = v(22)
power_factor = v(24)
frequency_hz = v(26)
import_kwh = v(28)
export_kwh = v(30)
total_active_energy_kwh = v(32)
return {
"energy_meter_model": "eastron_sdm630",
"energy_map": "sdm630_3phase_v1",
"voltage_l1_v": round(voltage_l1_v, 3),
"voltage_l2_v": round(voltage_l2_v, 3),
"voltage_l3_v": round(voltage_l3_v, 3),
"current_l1_a": round(current_l1_a, 6),
"current_l2_a": round(current_l2_a, 6),
"current_l3_a": round(current_l3_a, 6),
"active_power_l1_w": round(active_power_l1_w, 3),
"active_power_l2_w": round(active_power_l2_w, 3),
"active_power_l3_w": round(active_power_l3_w, 3),
"active_power_w": round(active_power_w, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"apparent_power_va": round(apparent_power_va, 3),
"reactive_power_var": round(reactive_power_var, 3),
"power_factor": round(power_factor, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 6),
"export_kwh": round(export_kwh, 6),
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
}
def base_payload(node):
return {
"tenant": env("TENANT", "ucepsa"),
"site": env("SITE", "ucepsa_test"),
"vertical": env("VERTICAL", "edge_oee"),
"asset": env("ASSET", "revpi_oee_node_01"),
"machine_id": node["machine_id"],
"source_node": node["source_node"],
"wise_host": node["wise_ip"],
"node_type": "revpi_wise_oee_gateway",
"fw": "0.3.0",
}
def init_state():
return {
"previous_counter": None,
"cycle_pulse_count": 0,
"cycle_events": deque(),
"last_auto_signal": None,
"last_energy": {},
"last_energy_ts": 0.0,
"last_publish_ts": 0.0,
"last_delta": 0,
"counter_reset_detected": False,
"modbus_client": None,
}
def main():
load_env(ENV_PATH)
nodes = load_nodes()
states = {node["source_node"]: init_state() for node in nodes}
telemetry_topic = env("MQTT_TOPIC_TELEMETRY", required=True)
status_topic = env("MQTT_TOPIC_STATUS", required=True)
event_topic = env("MQTT_TOPIC_EVENT", required=True)
publish_interval_s = env("PUBLISH_INTERVAL_SECONDS", 10, cast=float)
di_poll_interval_s = env("WISE_DI_POLL_INTERVAL_SECONDS", 0.5, cast=float)
energy_interval_s = env("WISE_ENERGY_INTERVAL_SECONDS", 2.0, cast=float)
rate_window_s = env("CYCLE_RATE_WINDOW_SECONDS", 60, cast=float)
mqtt_client = make_mqtt_client()
stop = {"value": False}
def on_stop(signum, frame):
stop["value"] = True
signal.signal(signal.SIGINT, on_stop)
signal.signal(signal.SIGTERM, on_stop)
print(f"Nodos WISE habilitados: {len(nodes)}")
for node in nodes:
print(f"- {node['machine_id']} {node['source_node']} {node['wise_ip']}")
publish(mqtt_client, status_topic, {
"ts": utc_now(),
"status": "started",
"asset": env("ASSET", "revpi_oee_node_01"),
"enabled_nodes": [
{
"machine_id": n["machine_id"],
"source_node": n["source_node"],
"wise_ip": n["wise_ip"],
}
for n in nodes
],
})
while not stop["value"]:
loop_start = time.time()
for node in nodes:
state = states[node["source_node"]]
now = time.time()
try:
di = read_wise_inputs_modbus(state, node)
auto_signal = di["di0_stat"]
cycle_input_state = di["di1_stat"]
wise_counter = di["di1_counter_raw"]
counter_valid = di["di1_mode"] == 1 and di["di1_counting"]
delta, reset = counter_delta(state["previous_counter"], wise_counter)
state["counter_reset_detected"] = reset
if delta > 0:
state["cycle_pulse_count"] += delta
for _ in range(min(delta, 1000)):
state["cycle_events"].append(now)
event = {
"ts": utc_now(),
**base_payload(node),
"event_type": "cycle_count_changed",
"delta": delta,
"wise_cycle_counter_raw": wise_counter,
"cycle_pulse_count": state["cycle_pulse_count"],
}
publish(mqtt_client, event_topic, event)
print("CYCLES", dumps(event))
state["previous_counter"] = wise_counter
state["last_delta"] = delta
while state["cycle_events"] and (now - state["cycle_events"][0]) > rate_window_s:
state["cycle_events"].popleft()
cycle_rate_ppm = len(state["cycle_events"]) * 60.0 / rate_window_s
last_cycle_age_s = None
if state["cycle_events"]:
last_cycle_age_s = round(now - state["cycle_events"][-1], 3)
if state["last_auto_signal"] is not None and auto_signal != state["last_auto_signal"]:
event_name = "machine_started" if auto_signal else "machine_stopped"
event = {
"ts": utc_now(),
**base_payload(node),
"event_type": "machine_state_changed",
"event_name": event_name,
"previous_auto_signal": state["last_auto_signal"],
"new_auto_signal": auto_signal,
"machine_running": auto_signal,
"reason_pending": not auto_signal,
}
publish(mqtt_client, event_topic, event)
print("EVENT", dumps(event))
state["last_auto_signal"] = auto_signal
if now - state["last_energy_ts"] >= energy_interval_s:
regs = read_holdings(state, node)
state["last_energy"] = decode_energy(regs)
state["last_energy_ts"] = now
if now - state["last_publish_ts"] >= publish_interval_s:
payload = {
"ts": utc_now(),
**base_payload(node),
"machine_auto_signal": auto_signal,
"machine_running": auto_signal,
"cycle_input_state": cycle_input_state,
"wise_cycle_counter_raw": wise_counter,
"cycle_delta_last": state["last_delta"],
"cycle_pulse_count": state["cycle_pulse_count"],
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
"last_cycle_age_s": last_cycle_age_s,
"counter_valid": counter_valid,
"counter_reset_detected": state["counter_reset_detected"],
"digital_inputs": {
"DI0": auto_signal,
"DI1": cycle_input_state,
},
**state["last_energy"],
"comm_ok": True,
"sample_valid": True,
}
publish(mqtt_client, telemetry_topic, payload)
print("TELEMETRY", dumps(payload))
state["last_publish_ts"] = now
except Exception as exc:
error_payload = {
"ts": utc_now(),
**base_payload(node),
"comm_ok": False,
"sample_valid": False,
"error": str(exc),
}
publish(mqtt_client, status_topic, error_payload)
print("ERROR", dumps(error_payload))
client = state.get("modbus_client")
if client is not None:
try:
client.close()
except Exception:
pass
state["modbus_client"] = None
elapsed = time.time() - loop_start
time.sleep(max(0.05, di_poll_interval_s - elapsed))
publish(mqtt_client, status_topic, {
"ts": utc_now(),
"status": "stopped",
"asset": env("ASSET", "revpi_oee_node_01"),
})
for state in states.values():
client = state.get("modbus_client")
if client is not None:
try:
client.close()
except Exception:
pass
mqtt_client.loop_stop()
mqtt_client.disconnect()
print("Parado correctamente")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,2 @@
[sudo] password for pi:
^C^C^C^C^Csudo: a password is required

View File

@ -0,0 +1,2 @@
[sudo] password for pi:
^C^C^C^C^C^Csudo: a password is required

View File

@ -1,278 +0,0 @@
#!/usr/bin/env python3
import json
import os
import signal
import sys
from datetime import datetime, timezone
import paho.mqtt.client as mqtt
import psycopg2
from psycopg2.extras import Json
RUNNING = True
def stop_handler(signum, frame):
global RUNNING
RUNNING = False
signal.signal(signal.SIGTERM, stop_handler)
signal.signal(signal.SIGINT, stop_handler)
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required env var: {name}", file=sys.stderr)
sys.exit(2)
return value
MQTT_HOST = getenv("MQTT_HOST", required=True)
MQTT_PORT = int(getenv("MQTT_PORT", "1883"))
MQTT_TOPIC = getenv("MQTT_TOPIC", required=True)
PGHOST = getenv("PGHOST", required=True)
PGPORT = int(getenv("PGPORT", "5432"))
PGDATABASE = getenv("PGDATABASE", required=True)
PGUSER = getenv("PGUSER", required=True)
PGPASSWORD = getenv("PGPASSWORD", required=True)
DEFAULT_TENANT = getenv("DEFAULT_TENANT", "ucepsa")
DEFAULT_SITE = getenv("DEFAULT_SITE", "demo_edge_oee")
DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01")
def num(payload, key):
value = payload.get(key)
if value is None or value == "":
return None
try:
return float(value)
except Exception:
return None
def bool_or_false(payload, key):
value = payload.get(key)
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "ok")
return bool(value)
def parse_ts(payload):
raw = payload.get("ts")
if not raw:
return datetime.now(timezone.utc)
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return datetime.now(timezone.utc)
def connect_pg():
return psycopg2.connect(
host=PGHOST,
port=PGPORT,
dbname=PGDATABASE,
user=PGUSER,
password=PGPASSWORD,
connect_timeout=5,
)
PG_CONN = connect_pg()
PG_CONN.autocommit = True
def ensure_pg():
global PG_CONN
try:
with PG_CONN.cursor() as cur:
cur.execute("SELECT 1;")
except Exception:
try:
PG_CONN.close()
except Exception:
pass
PG_CONN = connect_pg()
PG_CONN.autocommit = True
def insert_payload(topic, payload):
ensure_pg()
tenant = payload.get("tenant") or DEFAULT_TENANT
site = payload.get("site") or DEFAULT_SITE
asset = payload.get("asset") or DEFAULT_ASSET
machine_id = payload.get("machine_id") or None
order_id = payload.get("order_id") or None
comm_ok = bool_or_false(payload, "comm_ok")
error_text = payload.get("error_text")
sql = """
INSERT INTO mv_hot.edge_oee_eastron_readings (
ts,
tenant,
site,
asset,
source,
meter_model,
meter_id,
machine_id,
order_id,
voltage_l1_v,
voltage_l2_v,
voltage_l3_v,
current_l1_a,
current_l2_a,
current_l3_a,
power_l1_kw,
power_l2_kw,
power_l3_kw,
power_total_kw,
frequency_hz,
import_kwh,
export_kwh,
comm_ok,
error_text,
payload_json
)
VALUES (
%(ts)s,
%(tenant)s,
%(site)s,
%(asset)s,
%(source)s,
%(meter_model)s,
%(meter_id)s,
%(machine_id)s,
%(order_id)s,
%(voltage_l1_v)s,
%(voltage_l2_v)s,
%(voltage_l3_v)s,
%(current_l1_a)s,
%(current_l2_a)s,
%(current_l3_a)s,
%(power_l1_kw)s,
%(power_l2_kw)s,
%(power_l3_kw)s,
%(power_total_kw)s,
%(frequency_hz)s,
%(import_kwh)s,
%(export_kwh)s,
%(comm_ok)s,
%(error_text)s,
%(payload_json)s
);
"""
power_total_kw = num(payload, "power_total_kw")
params = {
"ts": parse_ts(payload),
"tenant": tenant,
"site": site,
"asset": asset,
"source": payload.get("source") or "mqtt_edge_oee_sink",
"meter_model": payload.get("meter_model"),
"meter_id": payload.get("meter_id"),
"machine_id": machine_id,
"order_id": order_id,
"voltage_l1_v": num(payload, "voltage_l1_v"),
"voltage_l2_v": num(payload, "voltage_l2_v"),
"voltage_l3_v": num(payload, "voltage_l3_v"),
"current_l1_a": num(payload, "current_l1_a"),
"current_l2_a": num(payload, "current_l2_a"),
"current_l3_a": num(payload, "current_l3_a"),
"power_l1_kw": num(payload, "power_l1_kw") if payload.get("power_l1_kw") is not None else power_total_kw,
"power_l2_kw": num(payload, "power_l2_kw"),
"power_l3_kw": num(payload, "power_l3_kw"),
"power_total_kw": power_total_kw,
"frequency_hz": num(payload, "frequency_hz"),
"import_kwh": num(payload, "import_kwh"),
"export_kwh": num(payload, "export_kwh"),
"comm_ok": comm_ok,
"error_text": error_text,
"payload_json": Json({
**payload,
"_mqtt_topic": topic,
"_ingested_by": "edge_oee_mqtt_pg_sink",
}),
}
with PG_CONN.cursor() as cur:
cur.execute(sql, params)
def on_connect(client, userdata, flags, reason_code, properties=None):
print(f"[MQTT] connected reason_code={reason_code}")
client.subscribe(MQTT_TOPIC)
print(f"[MQTT] subscribed topic={MQTT_TOPIC}")
def on_message(client, userdata, msg):
try:
body = msg.payload.decode("utf-8")
payload = json.loads(body)
insert_payload(msg.topic, payload)
asset = payload.get("asset")
voltage = payload.get("voltage_l1_v")
power = payload.get("power_total_kw")
comm_ok = payload.get("comm_ok")
print(f"[OK] topic={msg.topic} asset={asset} voltage={voltage} power_kw={power} comm_ok={comm_ok}")
except Exception as exc:
print(f"[ERROR] processing topic={msg.topic}: {type(exc).__name__}: {exc}", file=sys.stderr)
def main():
print("[START] edge_oee_mqtt_pg_sink")
print(f"[CONFIG] MQTT {MQTT_HOST}:{MQTT_PORT} topic={MQTT_TOPIC}")
print(f"[CONFIG] PostgreSQL {PGHOST}:{PGPORT}/{PGDATABASE} user={PGUSER}")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_message = on_message
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
client.loop_start()
try:
while RUNNING:
signal.pause()
except AttributeError:
import time
while RUNNING:
time.sleep(1)
finally:
client.loop_stop()
client.disconnect()
try:
PG_CONN.close()
except Exception:
pass
print("[STOP] edge_oee_mqtt_pg_sink")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,410 +0,0 @@
#!/usr/bin/env python3
import json
import os
import signal
import sys
from datetime import datetime, timezone
import paho.mqtt.client as mqtt
import psycopg2
from psycopg2.extras import Json
RUNNING = True
def stop_handler(signum, frame):
global RUNNING
RUNNING = False
signal.signal(signal.SIGTERM, stop_handler)
signal.signal(signal.SIGINT, stop_handler)
def getenv(name, default=None, required=False):
value = os.getenv(name, default)
if required and not value:
print(f"[ERROR] Missing required env var: {name}", file=sys.stderr)
sys.exit(2)
return value
MQTT_HOST = getenv("MQTT_HOST", required=True)
MQTT_PORT = int(getenv("MQTT_PORT", "1883"))
MQTT_TOPIC = getenv("MQTT_TOPIC", required=True)
PGHOST = getenv("PGHOST", required=True)
PGPORT = int(getenv("PGPORT", "5432"))
PGDATABASE = getenv("PGDATABASE", required=True)
PGUSER = getenv("PGUSER", required=True)
PGPASSWORD = getenv("PGPASSWORD", required=True)
DEFAULT_TENANT = getenv("DEFAULT_TENANT", "ucepsa")
DEFAULT_SITE = getenv("DEFAULT_SITE", "demo_edge_oee")
DEFAULT_ASSET = getenv("DEFAULT_ASSET", "revpi_oee_node_01")
def num(payload, key):
value = payload.get(key)
if value is None or value == "":
return None
try:
return float(value)
except Exception:
return None
def bool_or_false(payload, key):
value = payload.get(key)
if isinstance(value, bool):
return value
if value is None:
return False
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "ok")
return bool(value)
def parse_ts(payload):
raw = payload.get("ts")
if not raw:
return datetime.now(timezone.utc)
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return datetime.now(timezone.utc)
def connect_pg():
return psycopg2.connect(
host=PGHOST,
port=PGPORT,
dbname=PGDATABASE,
user=PGUSER,
password=PGPASSWORD,
connect_timeout=5,
)
PG_CONN = connect_pg()
PG_CONN.autocommit = True
def ensure_pg():
global PG_CONN
try:
with PG_CONN.cursor() as cur:
cur.execute("SELECT 1;")
except Exception:
try:
PG_CONN.close()
except Exception:
pass
PG_CONN = connect_pg()
PG_CONN.autocommit = True
def insert_payload(topic, payload):
ensure_pg()
tenant = payload.get("tenant") or DEFAULT_TENANT
site = payload.get("site") or DEFAULT_SITE
asset = payload.get("asset") or DEFAULT_ASSET
machine_id = payload.get("machine_id") or None
order_id = payload.get("order_id") or None
comm_ok = bool_or_false(payload, "comm_ok")
error_text = payload.get("error_text")
sql = """
INSERT INTO mv_hot.edge_oee_eastron_readings (
ts,
tenant,
site,
asset,
source,
meter_model,
meter_id,
machine_id,
order_id,
voltage_l1_v,
voltage_l2_v,
voltage_l3_v,
current_l1_a,
current_l2_a,
current_l3_a,
power_l1_kw,
power_l2_kw,
power_l3_kw,
power_total_kw,
frequency_hz,
import_kwh,
export_kwh,
comm_ok,
error_text,
payload_json
)
VALUES (
%(ts)s,
%(tenant)s,
%(site)s,
%(asset)s,
%(source)s,
%(meter_model)s,
%(meter_id)s,
%(machine_id)s,
%(order_id)s,
%(voltage_l1_v)s,
%(voltage_l2_v)s,
%(voltage_l3_v)s,
%(current_l1_a)s,
%(current_l2_a)s,
%(current_l3_a)s,
%(power_l1_kw)s,
%(power_l2_kw)s,
%(power_l3_kw)s,
%(power_total_kw)s,
%(frequency_hz)s,
%(import_kwh)s,
%(export_kwh)s,
%(comm_ok)s,
%(error_text)s,
%(payload_json)s
);
"""
power_total_kw = num(payload, "power_total_kw")
params = {
"ts": parse_ts(payload),
"tenant": tenant,
"site": site,
"asset": asset,
"source": payload.get("source") or "mqtt_edge_oee_sink",
"meter_model": payload.get("meter_model"),
"meter_id": payload.get("meter_id"),
"machine_id": machine_id,
"order_id": order_id,
"voltage_l1_v": num(payload, "voltage_l1_v"),
"voltage_l2_v": num(payload, "voltage_l2_v"),
"voltage_l3_v": num(payload, "voltage_l3_v"),
"current_l1_a": num(payload, "current_l1_a"),
"current_l2_a": num(payload, "current_l2_a"),
"current_l3_a": num(payload, "current_l3_a"),
"power_l1_kw": num(payload, "power_l1_kw") if payload.get("power_l1_kw") is not None else power_total_kw,
"power_l2_kw": num(payload, "power_l2_kw"),
"power_l3_kw": num(payload, "power_l3_kw"),
"power_total_kw": power_total_kw,
"frequency_hz": num(payload, "frequency_hz"),
"import_kwh": num(payload, "import_kwh"),
"export_kwh": num(payload, "export_kwh"),
"comm_ok": comm_ok,
"error_text": error_text,
"payload_json": Json({
**payload,
"_mqtt_topic": topic,
"_ingested_by": "edge_oee_mqtt_pg_sink",
}),
}
with PG_CONN.cursor() as cur:
cur.execute(sql, params)
def insert_event(topic, payload):
ensure_pg()
tenant = payload.get("tenant") or DEFAULT_TENANT
site = payload.get("site") or DEFAULT_SITE
vertical = payload.get("vertical") or "edge_oee"
asset = payload.get("asset") or DEFAULT_ASSET
raw_ts = payload.get("event_ts") or payload.get("ts")
try:
event_ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")) if raw_ts else datetime.now(timezone.utc)
except Exception:
event_ts = datetime.now(timezone.utc)
def to_bool(value):
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "ok")
return bool(value)
def to_int(value):
if value is None or value == "":
return None
try:
return int(value)
except Exception:
return None
def to_float(value):
if value is None or value == "":
return None
try:
return float(value)
except Exception:
return None
sql = """
INSERT INTO mv_hot.edge_oee_node_events (
event_ts,
topic,
tenant,
site,
vertical,
asset,
machine_id,
order_id,
event_type,
event_name,
previous_auto_signal,
new_auto_signal,
machine_running,
reason_pending,
cycle_pulse_count,
cycle_rate_ppm,
last_cycle_age_s,
payload_json
)
VALUES (
%(event_ts)s,
%(topic)s,
%(tenant)s,
%(site)s,
%(vertical)s,
%(asset)s,
%(machine_id)s,
%(order_id)s,
%(event_type)s,
%(event_name)s,
%(previous_auto_signal)s,
%(new_auto_signal)s,
%(machine_running)s,
%(reason_pending)s,
%(cycle_pulse_count)s,
%(cycle_rate_ppm)s,
%(last_cycle_age_s)s,
%(payload_json)s
);
"""
params = {
"event_ts": event_ts,
"topic": topic,
"tenant": tenant,
"site": site,
"vertical": vertical,
"asset": asset,
"machine_id": payload.get("machine_id") or None,
"order_id": payload.get("order_id") or None,
"event_type": payload.get("event_type") or "unknown",
"event_name": payload.get("event_name") or "unknown",
"previous_auto_signal": to_bool(payload.get("previous_auto_signal")),
"new_auto_signal": to_bool(payload.get("new_auto_signal")),
"machine_running": to_bool(payload.get("machine_running")),
"reason_pending": to_bool(payload.get("reason_pending")),
"cycle_pulse_count": to_int(payload.get("cycle_pulse_count")),
"cycle_rate_ppm": to_float(payload.get("cycle_rate_ppm")),
"last_cycle_age_s": to_float(payload.get("last_cycle_age_s")),
"payload_json": Json({
**payload,
"_mqtt_topic": topic,
"_ingested_by": "edge_oee_mqtt_pg_sink",
}),
}
with PG_CONN.cursor() as cur:
cur.execute(sql, params)
def on_connect(client, userdata, flags, reason_code, properties=None):
print(f"[MQTT] connected reason_code={reason_code}")
client.subscribe(MQTT_TOPIC)
print(f"[MQTT] subscribed topic={MQTT_TOPIC}")
def on_message(client, userdata, msg):
try:
body = msg.payload.decode("utf-8")
payload = json.loads(body)
if msg.topic.endswith("/telemetry"):
insert_payload(msg.topic, payload)
asset = payload.get("asset")
voltage = payload.get("voltage_l1_v")
power = payload.get("power_total_kw")
comm_ok = payload.get("comm_ok")
pulses = payload.get("cycle_pulse_count")
rate = payload.get("cycle_rate_ppm")
print(
f"[OK][telemetry] topic={msg.topic} asset={asset} "
f"voltage={voltage} power_kw={power} pulses={pulses} rate_ppm={rate} comm_ok={comm_ok}",
flush=True,
)
elif msg.topic.endswith("/event"):
insert_event(msg.topic, payload)
print(
f"[OK][event] topic={msg.topic} "
f"event_name={payload.get('event_name')} "
f"asset={payload.get('asset')} "
f"previous={payload.get('previous_auto_signal')} "
f"new={payload.get('new_auto_signal')} "
f"pulses={payload.get('cycle_pulse_count')}",
flush=True,
)
else:
print(f"[WARN] Ignored topic={msg.topic}", flush=True)
except Exception as exc:
print(f"[ERROR] processing topic={msg.topic}: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
def main():
print("[START] edge_oee_mqtt_pg_sink")
print(f"[CONFIG] MQTT {MQTT_HOST}:{MQTT_PORT} topic={MQTT_TOPIC}")
print(f"[CONFIG] PostgreSQL {PGHOST}:{PGPORT}/{PGDATABASE} user={PGUSER}")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_message = on_message
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
client.loop_start()
try:
while RUNNING:
signal.pause()
except AttributeError:
import time
while RUNNING:
time.sleep(1)
finally:
client.loop_stop()
client.disconnect()
try:
PG_CONN.close()
except Exception:
pass
print("[STOP] edge_oee_mqtt_pg_sink")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,121 @@
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.odoo_discrepancies_demo;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.odoo_vs_machine_demo;
CREATE VIEW mv_reports_ucepsa_demo.odoo_vs_machine_demo AS
WITH odoo AS (
SELECT *
FROM mv_reports_ucepsa_demo.odoo_current_order_demo
),
machine AS (
SELECT *
FROM mv_reports_ucepsa_demo.latest_machine_state_fast
WHERE machine_id = 'CORT-01'
ORDER BY ingest_ts DESC
LIMIT 1
),
stops AS (
SELECT
o.odoo_workorder_id,
COUNT(s.*)::int AS stop_count,
COALESCE(ROUND(SUM(s.duration_min)::numeric, 2), 0) AS stop_min,
COALESCE(ROUND(SUM(s.cost_eur)::numeric, 2), 0) AS stop_cost_eur
FROM odoo o
LEFT JOIN mv_reports_ucepsa_demo.stops_recent s
ON s.machine_id = o.machine_id
AND s.started_at >= COALESCE(o.odoo_reference_start, o.odoo_start, o.snapshot_at)
AND s.started_at <= COALESCE(o.odoo_end, now())
GROUP BY o.odoo_workorder_id
)
SELECT
o.odoo_workorder_id,
o.odoo_production_id,
o.odoo_order_ref,
o.odoo_state,
o.odoo_is_started,
o.product,
o.machine_id,
o.asset,
o.odoo_start,
o.odoo_reference_start,
o.odoo_end,
COALESCE(m.machine_running, false) AS machine_running,
m.age_s AS telemetry_age_s,
COALESCE(m.cycle_pulse_count, 0) AS cycle_pulse_count,
COALESCE(m.cycle_rate_ppm, 0) AS cycle_rate_ppm,
ma.first_machine_activity,
CASE
WHEN o.odoo_is_started = true
AND ma.first_machine_activity IS NOT NULL
AND COALESCE(o.odoo_reference_start, o.odoo_start) IS NOT NULL
THEN ROUND(
GREATEST(
EXTRACT(EPOCH FROM (ma.first_machine_activity - COALESCE(o.odoo_reference_start, o.odoo_start)))::numeric,
0
) / 60.0,
2
)
ELSE NULL::numeric
END AS start_delay_min,
COALESCE(s.stop_count, 0) AS stop_count,
COALESCE(s.stop_min, 0) AS stop_min,
COALESCE(s.stop_cost_eur, 0) AS stop_cost_eur,
COALESCE(m.cycle_pulse_count, 0)::numeric AS real_cycles,
COALESCE(o.planned_cycles, o.qty_planned, 0)::numeric AS planned_cycles,
CASE
WHEN COALESCE(o.planned_cycles, o.qty_planned, 0) > 0
THEN ROUND(
(
COALESCE(m.cycle_pulse_count, 0)::numeric
/ COALESCE(o.planned_cycles, o.qty_planned, 0)::numeric
) * 100,
2
)
ELSE 0::numeric
END AS cycle_completion_pct,
CASE
WHEN m.age_s IS NULL OR m.age_s > 120 THEN 'SIN_TELEMETRIA'
WHEN o.odoo_state = 'ready' AND COALESCE(m.machine_running, false) = true THEN 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA'
WHEN o.odoo_state = 'ready' AND COALESCE(m.machine_running, false) = false THEN 'ORDEN_PREPARADA_MAQUINA_PARADA'
WHEN o.odoo_state IN ('progress', 'in_progress') AND COALESCE(m.machine_running, false) = false THEN 'ORDEN_EN_CURSO_MAQUINA_PARADA'
ELSE 'OK'
END AS status_check
FROM odoo o
LEFT JOIN machine m ON true
LEFT JOIN LATERAL (
SELECT
MIN(r.ingest_ts) AS first_machine_activity
FROM mv_hot.edge_oee_eastron_readings r
WHERE r.machine_id = o.machine_id
AND (r.payload_json ->> 'machine_running')::boolean = true
AND r.ingest_ts >= COALESCE(o.odoo_reference_start, o.odoo_start, o.snapshot_at)
AND r.ingest_ts <= COALESCE(o.odoo_end, now())
) ma ON true
LEFT JOIN stops s ON s.odoo_workorder_id = o.odoo_workorder_id;
CREATE VIEW mv_reports_ucepsa_demo.odoo_discrepancies_demo AS
SELECT
now() AS checked_at,
odoo_order_ref,
product,
machine_id,
status_check,
CASE
WHEN status_check = 'MAQUINA_EN_MARCHA_SIN_ORDEN_INICIADA'
THEN 'La máquina está en marcha, pero la orden de Odoo está preparada o en espera, no iniciada.'
WHEN status_check = 'ORDEN_PREPARADA_MAQUINA_PARADA'
THEN 'La orden está preparada/en espera y la máquina está parada. No hay desviación productiva activa.'
WHEN status_check = 'ORDEN_EN_CURSO_MAQUINA_PARADA'
THEN 'La orden está en curso en Odoo, pero la máquina está parada.'
WHEN status_check = 'SIN_TELEMETRIA'
THEN 'No hay telemetría reciente de máquina.'
ELSE 'Sin discrepancia relevante.'
END AS descripcion,
start_delay_min,
stop_count,
stop_min,
stop_cost_eur,
cycle_completion_pct
FROM mv_reports_ucepsa_demo.odoo_vs_machine_demo;
GRANT SELECT ON mv_reports_ucepsa_demo.odoo_vs_machine_demo TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.odoo_discrepancies_demo TO grafana_ucepsa_ro;

View File

@ -0,0 +1,114 @@
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.machine_state_latest_multi;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.energy_3phase_latest;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.energy_3phase_timeseries;
CREATE VIEW mv_reports_ucepsa_demo.energy_3phase_timeseries AS
SELECT
r.ingest_ts AS ts,
r.ingest_ts,
r.tenant,
r.site,
r.asset,
r.machine_id,
EXTRACT(EPOCH FROM (now() - r.ingest_ts))::double precision AS age_s,
r.comm_ok,
CASE
WHEN lower(r.payload_json ->> 'sample_valid') IN ('true','t','1','yes') THEN true
WHEN lower(r.payload_json ->> 'sample_valid') IN ('false','f','0','no') THEN false
ELSE NULL
END AS sample_valid,
r.payload_json ->> 'energy_meter_model' AS energy_meter_model,
r.payload_json ->> 'energy_map' AS energy_map,
r.voltage_l1_v,
NULLIF(r.payload_json ->> 'voltage_l2_v','')::double precision AS voltage_l2_v,
NULLIF(r.payload_json ->> 'voltage_l3_v','')::double precision AS voltage_l3_v,
r.current_l1_a,
NULLIF(r.payload_json ->> 'current_l2_a','')::double precision AS current_l2_a,
NULLIF(r.payload_json ->> 'current_l3_a','')::double precision AS current_l3_a,
NULLIF(r.payload_json ->> 'active_power_l1_w','')::double precision AS active_power_l1_w,
NULLIF(r.payload_json ->> 'active_power_l2_w','')::double precision AS active_power_l2_w,
NULLIF(r.payload_json ->> 'active_power_l3_w','')::double precision AS active_power_l3_w,
COALESCE(
NULLIF(r.payload_json ->> 'active_power_w','')::double precision,
r.power_total_kw * 1000.0
) AS active_power_w,
r.power_total_kw,
NULLIF(r.payload_json ->> 'apparent_power_va','')::double precision AS apparent_power_va,
NULLIF(r.payload_json ->> 'reactive_power_var','')::double precision AS reactive_power_var,
NULLIF(r.payload_json ->> 'power_factor','')::double precision AS power_factor,
r.frequency_hz,
r.import_kwh,
NULLIF(r.payload_json ->> 'export_kwh','')::double precision AS export_kwh,
NULLIF(r.payload_json ->> 'total_active_energy_kwh','')::double precision AS total_active_energy_kwh,
r.payload_json
FROM mv_hot.edge_oee_eastron_readings r;
CREATE VIEW mv_reports_ucepsa_demo.energy_3phase_latest AS
SELECT DISTINCT ON (machine_id)
*
FROM mv_reports_ucepsa_demo.energy_3phase_timeseries
WHERE site = 'ucepsa_onpremise'
ORDER BY machine_id, ingest_ts DESC;
CREATE VIEW mv_reports_ucepsa_demo.machine_state_latest_multi AS
SELECT
l.ingest_ts,
l.ts,
l.tenant,
l.site,
l.asset,
l.machine_id,
l.age_s,
l.comm_ok,
l.sample_valid,
l.energy_map,
CASE
WHEN lower(l.payload_json ->> 'machine_running') IN ('true','t','1','yes') THEN true
WHEN lower(l.payload_json ->> 'machine_running') IN ('false','f','0','no') THEN false
ELSE false
END AS machine_running,
COALESCE(NULLIF(l.payload_json ->> 'cycle_pulse_count','')::numeric::bigint, 0) AS cycle_pulse_count,
COALESCE(NULLIF(l.payload_json ->> 'cycle_rate_ppm','')::double precision, 0.0) AS cycle_rate_ppm,
NULLIF(l.payload_json ->> 'wise_cycle_counter_raw','')::numeric::bigint AS wise_cycle_counter_raw,
CASE
WHEN lower(l.payload_json ->> 'cycle_input_state') IN ('true','t','1','yes') THEN true
WHEN lower(l.payload_json ->> 'cycle_input_state') IN ('false','f','0','no') THEN false
ELSE NULL
END AS cycle_input_state,
l.payload_json -> 'digital_inputs' AS digital_inputs,
l.voltage_l1_v,
l.voltage_l2_v,
l.voltage_l3_v,
l.current_l1_a,
l.current_l2_a,
l.current_l3_a,
l.active_power_l1_w,
l.active_power_l2_w,
l.active_power_l3_w,
l.power_total_kw,
l.frequency_hz,
l.import_kwh,
l.payload_json
FROM mv_reports_ucepsa_demo.energy_3phase_latest l;
GRANT SELECT ON mv_reports_ucepsa_demo.energy_3phase_timeseries TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.energy_3phase_latest TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.machine_state_latest_multi TO grafana_ucepsa_ro;

View File

@ -0,0 +1,81 @@
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.machine_stops_grafana AS
SELECT
s.id,
s.machine_id,
s.started_at,
s.ended_at,
COALESCE(s.ended_at, now()) AS effective_ended_at,
s.status,
s.classification_status,
s.start_event_name,
s.end_event_name,
COALESCE(
s.duration_s,
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
) AS duration_s_current,
ROUND(
(
COALESCE(
s.duration_s,
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
) / 60.0
)::numeric,
2
) AS duration_min,
COALESCE(s.cost_eur, 0)::numeric AS cost_eur,
COALESCE(
to_jsonb(s) ->> 'cause_code',
to_jsonb(s) ->> 'stop_cause_code',
to_jsonb(s) ->> 'reason_code',
CASE
WHEN s.classification_status = 'PENDING' THEN 'PENDIENTE'
ELSE 'SIN_CAUSA'
END
) AS cause_code,
COALESCE(
to_jsonb(s) ->> 'cause_name',
to_jsonb(s) ->> 'stop_cause_name',
to_jsonb(s) ->> 'reason_name',
CASE
WHEN s.classification_status = 'PENDING' THEN 'Pendiente de clasificar'
ELSE 'Sin causa registrada'
END
) AS cause_name,
to_jsonb(s) ->> 'operator_note' AS operator_note,
to_jsonb(s) ->> 'classified_by' AS classified_by,
to_jsonb(s) ->> 'order_ref' AS order_ref,
to_jsonb(s) ->> 'production_order' AS production_order,
CASE WHEN s.status = 'OPEN' THEN true ELSE false END AS is_open,
CASE
WHEN s.classification_status = 'PENDING' THEN true
ELSE false
END AS is_pending_classification,
CASE
WHEN s.status = 'OPEN' THEN 'ABIERTO'
WHEN s.classification_status = 'PENDING' THEN 'CERRADO SIN CLASIFICAR'
WHEN s.classification_status = 'CLASSIFIED' THEN 'CLASIFICADO'
ELSE COALESCE(s.status, 'SIN_ESTADO')
END AS stop_status_label,
CASE
WHEN s.status = 'OPEN' THEN 3
WHEN s.classification_status = 'PENDING' THEN 2
WHEN s.classification_status = 'CLASSIFIED' THEN 1
ELSE 0
END AS stop_priority_score
FROM mv_hot.edge_oee_machine_stops s;
GRANT SELECT ON mv_reports_ucepsa_demo.machine_stops_grafana TO grafana_ucepsa_ro;

View File

@ -0,0 +1,93 @@
CREATE OR REPLACE VIEW mv_reports_ucepsa_demo.machine_stops_grafana AS
SELECT
s.id,
s.machine_id,
s.started_at,
s.ended_at,
COALESCE(s.ended_at, now()) AS effective_ended_at,
s.status,
s.classification_status,
s.start_event_name,
s.end_event_name,
COALESCE(
s.duration_s,
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
) AS duration_s_current,
ROUND(
(
COALESCE(
s.duration_s,
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
) / 60.0
)::numeric,
2
) AS duration_min,
COALESCE(s.cost_eur, 0)::numeric AS cost_eur,
COALESCE(
to_jsonb(s) ->> 'cause_code',
to_jsonb(s) ->> 'stop_cause_code',
to_jsonb(s) ->> 'reason_code',
to_jsonb(c) ->> 'code',
to_jsonb(c) ->> 'cause_code',
CASE
WHEN s.classification_status = 'PENDING' THEN 'PENDIENTE'
ELSE 'SIN_CAUSA'
END
) AS cause_code,
COALESCE(
to_jsonb(s) ->> 'cause_name',
to_jsonb(s) ->> 'stop_cause_name',
to_jsonb(s) ->> 'reason_name',
to_jsonb(c) ->> 'name',
to_jsonb(c) ->> 'description',
to_jsonb(c) ->> 'label',
CASE
WHEN s.classification_status = 'PENDING' THEN 'Pendiente de clasificar'
ELSE 'Sin causa registrada'
END
) AS cause_name,
to_jsonb(s) ->> 'operator_note' AS operator_note,
to_jsonb(s) ->> 'classified_by' AS classified_by,
to_jsonb(s) ->> 'classified_at' AS classified_at,
to_jsonb(s) ->> 'order_ref' AS order_ref,
to_jsonb(s) ->> 'production_order' AS production_order,
CASE WHEN s.status = 'OPEN' THEN true ELSE false END AS is_open,
CASE
WHEN s.classification_status = 'PENDING' THEN true
ELSE false
END AS is_pending_classification,
CASE
WHEN s.status = 'OPEN' THEN 'ABIERTO'
WHEN s.classification_status = 'PENDING' THEN 'CERRADO SIN CLASIFICAR'
WHEN s.classification_status = 'CLASSIFIED' THEN 'CLASIFICADO'
ELSE COALESCE(s.status, 'SIN_ESTADO')
END AS stop_status_label,
CASE
WHEN s.status = 'OPEN' THEN 3
WHEN s.classification_status = 'PENDING' THEN 2
WHEN s.classification_status = 'CLASSIFIED' THEN 1
ELSE 0
END AS stop_priority_score
FROM mv_hot.edge_oee_machine_stops s
LEFT JOIN mv_hot.edge_oee_stop_causes c
ON (to_jsonb(c) ->> 'id') = COALESCE(
to_jsonb(s) ->> 'cause_id',
to_jsonb(s) ->> 'stop_cause_id',
to_jsonb(s) ->> 'reason_id'
);
GRANT SELECT ON mv_reports_ucepsa_demo.machine_stops_grafana TO grafana_ucepsa_ro;

View File

@ -0,0 +1,144 @@
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.machine_stops_grafana;
CREATE VIEW mv_reports_ucepsa_demo.machine_stops_grafana AS
WITH base AS (
SELECT
s.*,
to_jsonb(s) AS sj,
COALESCE(
to_jsonb(s) ->> 'cause_code',
to_jsonb(s) ->> 'stop_cause_code',
to_jsonb(s) ->> 'reason_code'
) AS raw_cause_code,
COALESCE(
to_jsonb(s) ->> 'cause_id',
to_jsonb(s) ->> 'stop_cause_id',
to_jsonb(s) ->> 'reason_id'
) AS raw_cause_id,
COALESCE(
to_jsonb(s) ->> 'cause_name',
to_jsonb(s) ->> 'stop_cause_name',
to_jsonb(s) ->> 'reason_name'
) AS raw_cause_name
FROM mv_hot.edge_oee_machine_stops s
),
base_with_cause AS (
SELECT
b.*,
cause.cj AS cause_json
FROM base b
LEFT JOIN LATERAL (
SELECT to_jsonb(sc) AS cj
FROM mv_hot.edge_oee_stop_causes sc
WHERE
(
b.raw_cause_id IS NOT NULL
AND to_jsonb(sc) ->> 'id' = b.raw_cause_id
)
OR
(
b.raw_cause_code IS NOT NULL
AND (
to_jsonb(sc) ->> 'code' = b.raw_cause_code
OR to_jsonb(sc) ->> 'cause_code' = b.raw_cause_code
OR to_jsonb(sc) ->> 'name' = b.raw_cause_code
)
)
LIMIT 1
) cause ON true
)
SELECT
b.id,
b.machine_id,
b.started_at,
b.ended_at,
COALESCE(b.ended_at, now()) AS effective_ended_at,
b.status,
b.classification_status,
b.start_event_name,
b.end_event_name,
COALESCE(
b.duration_s,
EXTRACT(EPOCH FROM (COALESCE(b.ended_at, now()) - b.started_at))
) AS duration_s_current,
ROUND(
(
COALESCE(
b.duration_s,
EXTRACT(EPOCH FROM (COALESCE(b.ended_at, now()) - b.started_at))
) / 60.0
)::numeric,
2
) AS duration_min,
COALESCE(b.cost_eur, 0)::numeric AS cost_eur,
COALESCE(
NULLIF(b.raw_cause_code, ''),
NULLIF(b.cause_json ->> 'code', ''),
NULLIF(b.cause_json ->> 'cause_code', ''),
CASE
WHEN b.classification_status = 'PENDING' THEN 'PENDIENTE'
ELSE 'SIN_CAUSA'
END
) AS cause_code,
COALESCE(
NULLIF(b.raw_cause_name, ''),
NULLIF(b.cause_json ->> 'name', ''),
NULLIF(b.cause_json ->> 'description', ''),
NULLIF(b.cause_json ->> 'label', ''),
CASE COALESCE(NULLIF(b.raw_cause_code, ''), NULLIF(b.cause_json ->> 'code', ''))
WHEN 'cambio_bobina' THEN 'Cambio de bobina'
WHEN 'espera_instrucciones' THEN 'Espera de instrucciones'
WHEN 'fallo_empalme' THEN 'Fallo de empalme'
WHEN 'falta_material' THEN 'Falta de material'
WHEN 'ajuste_sellado' THEN 'Ajuste de sellado'
ELSE NULL
END,
CASE
WHEN b.classification_status = 'PENDING' THEN 'Pendiente de clasificar'
ELSE 'Sin causa registrada'
END
) AS cause_name,
b.sj ->> 'operator_note' AS operator_note,
b.sj ->> 'classified_by' AS classified_by,
b.sj ->> 'classified_at' AS classified_at,
b.sj ->> 'order_ref' AS order_ref,
b.sj ->> 'production_order' AS production_order,
CASE WHEN b.status = 'OPEN' THEN true ELSE false END AS is_open,
CASE
WHEN b.classification_status = 'PENDING' THEN true
ELSE false
END AS is_pending_classification,
CASE
WHEN b.status = 'OPEN' THEN 'ABIERTO'
WHEN b.classification_status = 'PENDING' THEN 'CERRADO SIN CLASIFICAR'
WHEN b.classification_status = 'CLASSIFIED' THEN 'CLASIFICADO'
ELSE COALESCE(b.status, 'SIN_ESTADO')
END AS stop_status_label,
CASE
WHEN b.status = 'OPEN' THEN 3
WHEN b.classification_status = 'PENDING' THEN 2
WHEN b.classification_status = 'CLASSIFIED' THEN 1
ELSE 0
END AS stop_priority_score
FROM base_with_cause b;
GRANT SELECT ON mv_reports_ucepsa_demo.machine_stops_grafana TO grafana_ucepsa_ro;

View File

@ -1,885 +0,0 @@
-- MESAVAULT Edge-OEE UCEPSA
-- Order execution report views
-- Generated from validated PostgreSQL views.
-- Contains no data and no secrets.
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_report_full_v1;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_data_quality_v1;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_timeline_v1;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_stop_causes_v1;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_stops_v1;
DROP VIEW IF EXISTS mv_reports_ucepsa_demo.order_execution_report_v1;
--
-- PostgreSQL database dump
--
\restrict YbA0VcRIrZ1kSDSxIf4zctoL9eu8TYoJ5cvkbSD5WkFTQ1jRyH26snWSZehz5VJ
-- Dumped from database version 16.14
-- Dumped by pg_dump version 16.14
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: order_execution_report_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
--
CREATE VIEW mv_reports_ucepsa_demo.order_execution_report_v1 AS
WITH orders AS (
SELECT o_1.id,
o_1.tenant,
o_1.site,
o_1.odoo_instance,
o_1.odoo_workorder_id,
o_1.odoo_production_id,
o_1.odoo_order_ref,
o_1.odoo_state,
o_1.odoo_workcenter_code,
o_1.odoo_workcenter_name,
o_1.machine_id,
o_1.asset,
o_1.product,
o_1.qty_planned,
o_1.qty_done,
o_1.uom,
o_1.planned_cycles,
o_1.target_cycle_rate_ppm,
o_1.odoo_start,
o_1.odoo_end,
o_1.notes,
o_1.snapshot_at,
o_1.odoo_start AS start_ts,
CASE
WHEN (o_1.odoo_state = ANY (ARRAY['done'::text, 'to_close'::text, 'cancel'::text])) THEN COALESCE(o_1.odoo_end, o_1.snapshot_at)
ELSE COALESCE(( SELECT max(r.ingest_ts) AS max
FROM mv_hot.edge_oee_eastron_readings r
WHERE ((r.machine_id = o_1.machine_id) AND (r.ingest_ts >= o_1.odoo_start) AND (r.comm_ok = true) AND (r.meter_model = 'Eastron SDM120-M'::text))), o_1.snapshot_at)
END AS end_ts
FROM mv_edge_oee_ucepsa_demo.odoo_workorders_demo o_1
WHERE ((o_1.odoo_instance = 'odoo_ucepsa_test'::text) AND (o_1.machine_id IS NOT NULL) AND (o_1.odoo_start IS NOT NULL))
), energy AS (
SELECT o_1.id AS order_row_id,
count(r.id) AS energy_samples,
min(r.ingest_ts) AS energy_first_ts,
max(r.ingest_ts) AS energy_last_ts,
min(r.import_kwh) AS start_import_kwh,
max(r.import_kwh) AS end_import_kwh,
round((max(r.import_kwh) - min(r.import_kwh)), 3) AS kwh_consumed,
round(avg(r.power_total_kw), 3) AS avg_kw,
round(max(r.power_total_kw), 3) AS max_kw
FROM (orders o_1
LEFT JOIN mv_hot.edge_oee_eastron_readings r ON (((r.machine_id = o_1.machine_id) AND (r.ingest_ts >= o_1.start_ts) AND (r.ingest_ts <= o_1.end_ts) AND (r.comm_ok = true) AND (r.meter_model = 'Eastron SDM120-M'::text))))
GROUP BY o_1.id
), cycles AS (
SELECT o_1.id AS order_row_id,
count(r.id) AS telemetry_samples,
min(r.ingest_ts) AS telemetry_first_ts,
max(r.ingest_ts) AS telemetry_last_ts,
min(((r.payload_json ->> 'cycle_pulse_count'::text))::bigint) AS min_cycle_pulse_count,
max(((r.payload_json ->> 'cycle_pulse_count'::text))::bigint) AS max_cycle_pulse_count,
GREATEST((max(((r.payload_json ->> 'cycle_pulse_count'::text))::bigint) - min(((r.payload_json ->> 'cycle_pulse_count'::text))::bigint)), (0)::bigint) AS real_cycle_delta,
round(avg(((r.payload_json ->> 'cycle_rate_ppm'::text))::numeric), 2) AS avg_cycle_rate_ppm,
round(avg(NULLIF(((r.payload_json ->> 'cycle_rate_ppm'::text))::numeric, (0)::numeric)), 2) AS avg_nonzero_cycle_rate_ppm
FROM (orders o_1
LEFT JOIN mv_hot.edge_oee_eastron_readings r ON (((r.machine_id = o_1.machine_id) AND (r.ingest_ts >= o_1.start_ts) AND (r.ingest_ts <= o_1.end_ts) AND (r.comm_ok = true) AND (r.meter_model = 'Eastron SDM120-M'::text) AND (r.payload_json ? 'cycle_pulse_count'::text))))
GROUP BY o_1.id
), stops AS (
SELECT o_1.id AS order_row_id,
count(ms.id) AS stop_count,
round((COALESCE(sum(GREATEST(EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, o_1.end_ts), o_1.end_ts) - GREATEST(ms.started_at, o_1.start_ts))), (0)::numeric)), (0)::numeric) / 60.0), 2) AS stop_min,
count(ms.id) FILTER (WHERE (ms.classification_status = 'PENDING'::text)) AS pending_stops,
count(ms.id) FILTER (WHERE (ms.classification_status = 'CLASSIFIED'::text)) AS classified_stops,
round(COALESCE(sum(((GREATEST(EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, o_1.end_ts), o_1.end_ts) - GREATEST(ms.started_at, o_1.start_ts))), (0)::numeric) / 3600.0) * ms.value_hour_eur)), (0)::numeric), 2) AS stop_cost_eur
FROM (orders o_1
LEFT JOIN mv_hot.edge_oee_machine_stops ms ON (((ms.machine_id = o_1.machine_id) AND (ms.started_at < o_1.end_ts) AND (COALESCE(ms.ended_at, now()) > o_1.start_ts))))
GROUP BY o_1.id
)
SELECT o.id AS order_row_id,
o.tenant,
o.site,
o.odoo_instance,
o.odoo_workorder_id,
o.odoo_production_id,
o.odoo_order_ref,
o.odoo_state,
o.odoo_workcenter_code,
o.odoo_workcenter_name,
o.machine_id,
o.asset,
o.product,
o.qty_planned,
o.qty_done,
o.uom,
o.planned_cycles,
o.target_cycle_rate_ppm,
o.start_ts,
o.end_ts,
round((EXTRACT(epoch FROM (o.end_ts - o.start_ts)) / 60.0), 2) AS window_min,
COALESCE(c.telemetry_samples, (0)::bigint) AS telemetry_samples,
c.telemetry_first_ts,
c.telemetry_last_ts,
COALESCE(c.min_cycle_pulse_count, (0)::bigint) AS min_cycle_pulse_count,
COALESCE(c.max_cycle_pulse_count, (0)::bigint) AS max_cycle_pulse_count,
COALESCE(c.real_cycle_delta, (0)::bigint) AS real_cycle_delta,
c.avg_cycle_rate_ppm,
c.avg_nonzero_cycle_rate_ppm,
COALESCE(e.energy_samples, (0)::bigint) AS energy_samples,
e.energy_first_ts,
e.energy_last_ts,
e.start_import_kwh,
e.end_import_kwh,
e.kwh_consumed,
e.avg_kw,
e.max_kw,
COALESCE(s.stop_count, (0)::bigint) AS stop_count,
COALESCE(s.stop_min, (0)::numeric) AS stop_min,
COALESCE(s.classified_stops, (0)::bigint) AS classified_stops,
COALESCE(s.pending_stops, (0)::bigint) AS pending_stops,
COALESCE(s.stop_cost_eur, (0)::numeric) AS stop_cost_eur,
CASE
WHEN (COALESCE(c.telemetry_samples, (0)::bigint) = 0) THEN 'SIN_TELEMETRIA_EN_VENTANA'::text
WHEN (COALESCE(e.energy_samples, (0)::bigint) = 0) THEN 'SIN_ENERGIA_EN_VENTANA'::text
WHEN (o.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'PARCIAL_ORDEN_EN_CURSO'::text
WHEN (COALESCE(s.pending_stops, (0)::bigint) > 0) THEN 'PARCIAL_PAROS_PENDIENTES'::text
ELSE 'CERRADO_CALCULABLE'::text
END AS report_status,
CASE
WHEN (COALESCE(c.telemetry_samples, (0)::bigint) = 0) THEN 'No hay telemetría Edge-OEE dentro de la ventana de la orden.'::text
WHEN (COALESCE(e.energy_samples, (0)::bigint) = 0) THEN 'No hay muestras energéticas válidas dentro de la ventana de la orden.'::text
WHEN (o.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'La orden sigue en curso. El informe es parcial.'::text
WHEN (COALESCE(s.pending_stops, (0)::bigint) > 0) THEN 'Existen paros pendientes de clasificación.'::text
ELSE 'Orden cerrada con datos suficientes para informe.'::text
END AS report_status_description
FROM (((orders o
LEFT JOIN cycles c ON ((c.order_row_id = o.id)))
LEFT JOIN energy e ON ((e.order_row_id = o.id)))
LEFT JOIN stops s ON ((s.order_row_id = o.id)));
--
-- Name: order_execution_energy_timeseries_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
--
CREATE VIEW mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1 AS
WITH base AS (
SELECT r.order_row_id,
r.tenant,
r.site,
r.odoo_instance,
r.odoo_workorder_id,
r.odoo_production_id,
r.odoo_order_ref,
r.odoo_state,
r.product,
r.machine_id,
r.asset,
r.start_ts AS order_start_ts,
r.end_ts AS order_end_ts,
r.report_status,
e.id AS reading_id,
e.ts AS meter_ts,
e.ingest_ts AS "time",
e.voltage_l1_v,
e.current_l1_a,
e.power_total_kw,
e.frequency_hz,
e.import_kwh,
e.export_kwh,
e.comm_ok,
e.error_text,
CASE
WHEN (e.payload_json ? 'machine_running'::text) THEN ((e.payload_json ->> 'machine_running'::text))::boolean
ELSE NULL::boolean
END AS machine_running,
CASE
WHEN (e.payload_json ? 'machine_auto_signal'::text) THEN ((e.payload_json ->> 'machine_auto_signal'::text))::boolean
ELSE NULL::boolean
END AS machine_auto_signal,
CASE
WHEN (e.payload_json ? 'cycle_pulse_count'::text) THEN (NULLIF((e.payload_json ->> 'cycle_pulse_count'::text), ''::text))::bigint
ELSE NULL::bigint
END AS cycle_pulse_count,
CASE
WHEN (e.payload_json ? 'cycle_rate_ppm'::text) THEN (NULLIF((e.payload_json ->> 'cycle_rate_ppm'::text), ''::text))::numeric
ELSE NULL::numeric
END AS cycle_rate_ppm,
CASE
WHEN (e.payload_json ? 'last_cycle_age_s'::text) THEN (NULLIF((e.payload_json ->> 'last_cycle_age_s'::text), ''::text))::numeric
ELSE NULL::numeric
END AS last_cycle_age_s,
e.payload_json
FROM (mv_reports_ucepsa_demo.order_execution_report_v1 r
JOIN mv_hot.edge_oee_eastron_readings e ON (((e.machine_id = r.machine_id) AND (e.ingest_ts >= r.start_ts) AND (e.ingest_ts <= r.end_ts) AND (e.meter_model = 'Eastron SDM120-M'::text))))
), enriched AS (
SELECT b.order_row_id,
b.tenant,
b.site,
b.odoo_instance,
b.odoo_workorder_id,
b.odoo_production_id,
b.odoo_order_ref,
b.odoo_state,
b.product,
b.machine_id,
b.asset,
b.order_start_ts,
b.order_end_ts,
b.report_status,
b.reading_id,
b.meter_ts,
b."time",
b.voltage_l1_v,
b.current_l1_a,
b.power_total_kw,
b.frequency_hz,
b.import_kwh,
b.export_kwh,
b.comm_ok,
b.error_text,
b.machine_running,
b.machine_auto_signal,
b.cycle_pulse_count,
b.cycle_rate_ppm,
b.last_cycle_age_s,
b.payload_json,
round((EXTRACT(epoch FROM (b."time" - b.order_start_ts)) / 60.0), 2) AS minute_from_order_start,
round(EXTRACT(epoch FROM (b."time" - lag(b."time") OVER (PARTITION BY b.order_row_id ORDER BY b."time"))), 2) AS sample_gap_s,
min(b.import_kwh) FILTER (WHERE ((b.comm_ok = true) AND (b.import_kwh IS NOT NULL))) OVER (PARTITION BY b.order_row_id) AS order_start_import_kwh,
min(b.cycle_pulse_count) FILTER (WHERE (b.cycle_pulse_count IS NOT NULL)) OVER (PARTITION BY b.order_row_id) AS order_start_cycle_pulse_count
FROM base b
)
SELECT order_row_id,
tenant,
site,
odoo_instance,
odoo_workorder_id,
odoo_production_id,
odoo_order_ref,
odoo_state,
product,
machine_id,
asset,
order_start_ts,
order_end_ts,
report_status,
reading_id,
meter_ts,
"time",
minute_from_order_start,
sample_gap_s,
voltage_l1_v,
current_l1_a,
power_total_kw,
frequency_hz,
import_kwh,
export_kwh,
round(GREATEST((import_kwh - order_start_import_kwh), (0)::numeric), 3) AS kwh_from_order_start,
comm_ok,
error_text,
machine_running,
machine_auto_signal,
cycle_pulse_count,
GREATEST((cycle_pulse_count - order_start_cycle_pulse_count), (0)::bigint) AS cycles_from_order_start,
cycle_rate_ppm,
last_cycle_age_s,
CASE
WHEN (comm_ok = false) THEN 'BAD_COMMUNICATION'::text
WHEN ((sample_gap_s IS NOT NULL) AND (sample_gap_s > (30)::numeric)) THEN 'TELEMETRY_GAP'::text
WHEN (power_total_kw IS NULL) THEN 'NO_POWER_VALUE'::text
WHEN (import_kwh IS NULL) THEN 'NO_IMPORT_KWH'::text
ELSE 'OK'::text
END AS sample_quality_status,
payload_json
FROM enriched;
--
-- Name: order_execution_data_quality_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
--
CREATE VIEW mv_reports_ucepsa_demo.order_execution_data_quality_v1 AS
WITH base AS (
SELECT r.order_row_id,
r.tenant,
r.site,
r.odoo_instance,
r.odoo_workorder_id,
r.odoo_production_id,
r.odoo_order_ref,
r.odoo_state,
r.odoo_workcenter_code,
r.odoo_workcenter_name,
r.machine_id,
r.asset,
r.product,
r.qty_planned,
r.qty_done,
r.uom,
r.planned_cycles,
r.target_cycle_rate_ppm,
r.start_ts,
r.end_ts,
r.window_min,
r.telemetry_samples,
r.telemetry_first_ts,
r.telemetry_last_ts,
r.min_cycle_pulse_count,
r.max_cycle_pulse_count,
r.real_cycle_delta,
r.avg_cycle_rate_ppm,
r.avg_nonzero_cycle_rate_ppm,
r.energy_samples,
r.energy_first_ts,
r.energy_last_ts,
r.start_import_kwh,
r.end_import_kwh,
r.kwh_consumed,
r.avg_kw,
r.max_kw,
r.stop_count,
r.stop_min,
r.classified_stops,
r.pending_stops,
r.stop_cost_eur,
r.report_status,
r.report_status_description,
GREATEST(((floor((EXTRACT(epoch FROM (r.end_ts - r.start_ts)) / 10.0)))::integer + 1), 1) AS expected_samples_10s
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
), gap_stats AS (
SELECT t.order_row_id,
count(*) FILTER (WHERE ((t.sample_gap_s IS NOT NULL) AND (t.sample_gap_s > (30)::numeric))) AS telemetry_gap_count_30s,
round(max(t.sample_gap_s), 2) AS max_sample_gap_s,
round(avg(t.sample_gap_s) FILTER (WHERE (t.sample_gap_s IS NOT NULL)), 2) AS avg_sample_gap_s,
count(*) FILTER (WHERE (t.sample_quality_status <> 'OK'::text)) AS non_ok_samples
FROM mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1 t
GROUP BY t.order_row_id
), scored AS (
SELECT b.order_row_id,
b.tenant,
b.site,
b.odoo_instance,
b.odoo_workorder_id,
b.odoo_production_id,
b.odoo_order_ref,
b.odoo_state,
b.product,
b.machine_id,
b.asset,
b.start_ts,
b.end_ts,
b.window_min,
b.qty_planned,
b.qty_done,
b.uom,
b.planned_cycles,
b.target_cycle_rate_ppm,
b.telemetry_samples,
b.energy_samples,
b.expected_samples_10s,
round((((COALESCE(b.telemetry_samples, (0)::bigint))::numeric / (NULLIF(b.expected_samples_10s, 0))::numeric) * 100.0), 2) AS telemetry_sample_coverage_pct,
b.telemetry_first_ts,
b.telemetry_last_ts,
b.energy_first_ts,
b.energy_last_ts,
b.real_cycle_delta,
b.avg_cycle_rate_ppm,
b.avg_nonzero_cycle_rate_ppm,
b.kwh_consumed,
b.avg_kw,
b.max_kw,
b.stop_count,
b.stop_min,
b.classified_stops,
b.pending_stops,
b.stop_cost_eur,
COALESCE(g.telemetry_gap_count_30s, (0)::bigint) AS telemetry_gap_count_30s,
COALESCE(g.max_sample_gap_s, (0)::numeric) AS max_sample_gap_s,
COALESCE(g.avg_sample_gap_s, (0)::numeric) AS avg_sample_gap_s,
COALESCE(g.non_ok_samples, (0)::bigint) AS non_ok_samples,
b.report_status,
b.report_status_description,
CASE
WHEN (COALESCE(b.planned_cycles, (0)::numeric) <= (0)::numeric) THEN 'SIN_OBJETIVO_CICLOS'::text
WHEN ((COALESCE(b.real_cycle_delta, (0)::bigint) = 0) AND (COALESCE(b.qty_done, (0)::numeric) > (0)::numeric)) THEN 'CANTIDAD_ODOO_SIN_CICLOS_EDGE'::text
WHEN ((COALESCE(b.real_cycle_delta, (0)::bigint) > 0) AND (COALESCE(b.planned_cycles, (0)::numeric) > (0)::numeric) AND ((((COALESCE(b.real_cycle_delta, (0)::bigint))::numeric / NULLIF(b.planned_cycles, (0)::numeric)) >= 0.5) AND (((COALESCE(b.real_cycle_delta, (0)::bigint))::numeric / NULLIF(b.planned_cycles, (0)::numeric)) <= 1.5))) THEN 'CICLOS_COHERENTES_CON_OBJETIVO'::text
ELSE 'REVISAR_CONVERSION_ODOO_CICLOS'::text
END AS production_conversion_status,
GREATEST(0, (((((((100 -
CASE
WHEN (COALESCE(b.telemetry_samples, (0)::bigint) = 0) THEN 60
ELSE 0
END) -
CASE
WHEN (COALESCE(b.energy_samples, (0)::bigint) = 0) THEN 50
ELSE 0
END) -
CASE
WHEN (b.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 20
ELSE 0
END) -
CASE
WHEN (COALESCE(b.pending_stops, (0)::bigint) > 0) THEN 20
ELSE 0
END) -
CASE
WHEN (COALESCE(g.telemetry_gap_count_30s, (0)::bigint) > 0) THEN LEAST(((COALESCE(g.telemetry_gap_count_30s, (0)::bigint))::integer * 2), 15)
ELSE 0
END) -
CASE
WHEN (COALESCE(g.max_sample_gap_s, (0)::numeric) > (60)::numeric) THEN 10
ELSE 0
END) -
CASE
WHEN (COALESCE(g.non_ok_samples, (0)::bigint) > 0) THEN 10
ELSE 0
END)) AS technical_quality_score
FROM (base b
LEFT JOIN gap_stats g ON ((g.order_row_id = b.order_row_id)))
)
SELECT order_row_id,
tenant,
site,
odoo_instance,
odoo_workorder_id,
odoo_production_id,
odoo_order_ref,
odoo_state,
product,
machine_id,
asset,
start_ts,
end_ts,
window_min,
qty_planned,
qty_done,
uom,
planned_cycles,
target_cycle_rate_ppm,
telemetry_samples,
energy_samples,
expected_samples_10s,
telemetry_sample_coverage_pct,
telemetry_first_ts,
telemetry_last_ts,
energy_first_ts,
energy_last_ts,
real_cycle_delta,
avg_cycle_rate_ppm,
avg_nonzero_cycle_rate_ppm,
kwh_consumed,
avg_kw,
max_kw,
stop_count,
stop_min,
classified_stops,
pending_stops,
stop_cost_eur,
telemetry_gap_count_30s,
max_sample_gap_s,
avg_sample_gap_s,
non_ok_samples,
report_status,
report_status_description,
production_conversion_status,
technical_quality_score,
CASE
WHEN (technical_quality_score >= 90) THEN 'FIABLE'::text
WHEN (technical_quality_score >= 70) THEN 'ACEPTABLE_CON_OBSERVACIONES'::text
WHEN (technical_quality_score >= 40) THEN 'PARCIAL'::text
ELSE 'NO_DEFENDIBLE'::text
END AS technical_quality_level,
CASE
WHEN (COALESCE(telemetry_samples, (0)::bigint) = 0) THEN 'No hay telemetría Edge-OEE dentro de la ventana de la orden. El informe solo puede apoyarse en Odoo.'::text
WHEN (COALESCE(energy_samples, (0)::bigint) = 0) THEN 'Faltan muestras energéticas válidas dentro de la ventana de la orden.'::text
WHEN (odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'La orden sigue abierta. El informe debe tratarse como parcial.'::text
WHEN (COALESCE(pending_stops, (0)::bigint) > 0) THEN 'Hay paros pendientes de clasificación. Conviene clasificarlos antes de cerrar el informe.'::text
WHEN (COALESCE(telemetry_gap_count_30s, (0)::bigint) > 0) THEN 'Hay gaps de telemetría superiores a 30 segundos. Revisar continuidad de señal.'::text
WHEN (production_conversion_status = 'REVISAR_CONVERSION_ODOO_CICLOS'::text) THEN 'El informe técnico es válido para paros, energía y ciclos reales; revisar la conversión entre cantidad Odoo y ciclos/golpes.'::text
ELSE 'Datos suficientes para generar informe técnico de orden.'::text
END AS recommended_action
FROM scored;
--
-- Name: order_execution_stops_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
--
CREATE VIEW mv_reports_ucepsa_demo.order_execution_stops_v1 AS
SELECT r.order_row_id,
r.tenant,
r.site,
r.odoo_instance,
r.odoo_workorder_id,
r.odoo_production_id,
r.odoo_order_ref,
r.odoo_state,
r.product,
r.machine_id,
r.asset,
r.start_ts AS order_start_ts,
r.end_ts AS order_end_ts,
r.report_status AS order_report_status,
ms.id AS stop_id,
ms.started_at AS stop_started_at,
ms.ended_at AS stop_ended_at,
GREATEST(ms.started_at, r.start_ts) AS attributed_started_at,
LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) AS attributed_ended_at,
round(ms.duration_s, 2) AS original_duration_s,
round((ms.duration_s / 60.0), 2) AS original_duration_min,
round(EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) - GREATEST(ms.started_at, r.start_ts))), 2) AS attributed_duration_s,
round((EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) - GREATEST(ms.started_at, r.start_ts))) / 60.0), 2) AS attributed_duration_min,
ms.status AS stop_status,
ms.classification_status,
ms.cause_code,
ms.operator_note,
ms.value_hour_eur,
round(((EXTRACT(epoch FROM (LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) - GREATEST(ms.started_at, r.start_ts))) / 3600.0) * ms.value_hour_eur), 2) AS attributed_cost_eur,
ms.start_event_name,
ms.end_event_name,
ms.created_at,
ms.updated_at,
CASE
WHEN (ms.status = 'OPEN'::text) THEN 'PARO_ABIERTO'::text
WHEN (ms.classification_status = 'PENDING'::text) THEN 'PENDIENTE_CLASIFICACION'::text
WHEN (ms.classification_status = 'CLASSIFIED'::text) THEN 'CLASIFICADO'::text
ELSE 'SIN_ESTADO_CLARO'::text
END AS stop_quality_status
FROM (mv_reports_ucepsa_demo.order_execution_report_v1 r
JOIN mv_hot.edge_oee_machine_stops ms ON (((ms.machine_id = r.machine_id) AND (ms.started_at < r.end_ts) AND (COALESCE(ms.ended_at, r.end_ts) > r.start_ts))))
WHERE (LEAST(COALESCE(ms.ended_at, r.end_ts), r.end_ts) > GREATEST(ms.started_at, r.start_ts));
--
-- Name: order_execution_stop_causes_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
--
CREATE VIEW mv_reports_ucepsa_demo.order_execution_stop_causes_v1 AS
SELECT order_row_id,
tenant,
site,
odoo_instance,
odoo_workorder_id,
odoo_production_id,
odoo_order_ref,
product,
machine_id,
cause_code,
count(*) AS stop_count,
round(sum(attributed_duration_min), 2) AS total_stop_min,
round(sum(attributed_cost_eur), 2) AS total_stop_cost_eur,
count(*) FILTER (WHERE (classification_status = 'CLASSIFIED'::text)) AS classified_stop_count,
count(*) FILTER (WHERE (classification_status = 'PENDING'::text)) AS pending_stop_count,
min(stop_started_at) AS first_stop_at,
max(stop_ended_at) AS last_stop_at
FROM mv_reports_ucepsa_demo.order_execution_stops_v1
GROUP BY order_row_id, tenant, site, odoo_instance, odoo_workorder_id, odoo_production_id, odoo_order_ref, product, machine_id, cause_code;
--
-- Name: order_execution_timeline_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
--
CREATE VIEW mv_reports_ucepsa_demo.order_execution_timeline_v1 AS
SELECT r.order_row_id,
r.tenant,
r.site,
r.odoo_instance,
r.odoo_workorder_id,
r.odoo_production_id,
r.odoo_order_ref,
r.product,
r.machine_id,
r.asset,
r.start_ts AS event_ts,
'ORDER_START'::text AS event_type,
'Inicio de orden'::text AS event_label,
'INFO'::text AS severity,
NULL::bigint AS stop_id,
NULL::bigint AS node_event_id,
NULL::text AS cause_code,
NULL::numeric AS duration_min,
NULL::numeric AS cost_eur,
'Inicio de la ventana de ejecución de la orden según Odoo.'::text AS description,
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_report_v1', 'odoo_state', r.odoo_state, 'qty_planned', r.qty_planned, 'qty_done', r.qty_done, 'uom', r.uom) AS evidence_json
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
WHERE (r.start_ts IS NOT NULL)
UNION ALL
SELECT r.order_row_id,
r.tenant,
r.site,
r.odoo_instance,
r.odoo_workorder_id,
r.odoo_production_id,
r.odoo_order_ref,
r.product,
r.machine_id,
r.asset,
r.telemetry_first_ts AS event_ts,
'FIRST_TELEMETRY'::text AS event_type,
'Primera telemetría Edge-OEE'::text AS event_label,
'INFO'::text AS severity,
NULL::bigint AS stop_id,
NULL::bigint AS node_event_id,
NULL::text AS cause_code,
NULL::numeric AS duration_min,
NULL::numeric AS cost_eur,
'Primera muestra de telemetría recibida dentro de la ventana de la orden.'::text AS description,
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_report_v1', 'telemetry_samples', r.telemetry_samples, 'energy_samples', r.energy_samples) AS evidence_json
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
WHERE (r.telemetry_first_ts IS NOT NULL)
UNION ALL
SELECT s.order_row_id,
s.tenant,
s.site,
s.odoo_instance,
s.odoo_workorder_id,
s.odoo_production_id,
s.odoo_order_ref,
s.product,
s.machine_id,
s.asset,
s.attributed_started_at AS event_ts,
'STOP_START'::text AS event_type,
'Inicio de paro'::text AS event_label,
CASE
WHEN (s.classification_status = 'PENDING'::text) THEN 'WARNING'::text
ELSE 'LOSS'::text
END AS severity,
s.stop_id,
NULL::bigint AS node_event_id,
s.cause_code,
s.attributed_duration_min AS duration_min,
s.attributed_cost_eur AS cost_eur,
concat('Inicio de paro imputado a la orden. Causa: ', COALESCE(s.cause_code, 'sin clasificar'::text), '. Duración imputada: ', COALESCE((s.attributed_duration_min)::text, '0'::text), ' min.') AS description,
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_stops_v1', 'stop_id', s.stop_id, 'classification_status', s.classification_status, 'operator_note', s.operator_note, 'original_started_at', s.stop_started_at, 'original_ended_at', s.stop_ended_at) AS evidence_json
FROM mv_reports_ucepsa_demo.order_execution_stops_v1 s
UNION ALL
SELECT s.order_row_id,
s.tenant,
s.site,
s.odoo_instance,
s.odoo_workorder_id,
s.odoo_production_id,
s.odoo_order_ref,
s.product,
s.machine_id,
s.asset,
s.attributed_ended_at AS event_ts,
'STOP_END'::text AS event_type,
'Fin de paro'::text AS event_label,
CASE
WHEN (s.classification_status = 'PENDING'::text) THEN 'WARNING'::text
ELSE 'INFO'::text
END AS severity,
s.stop_id,
NULL::bigint AS node_event_id,
s.cause_code,
s.attributed_duration_min AS duration_min,
s.attributed_cost_eur AS cost_eur,
concat('Fin de paro imputado a la orden. Causa: ', COALESCE(s.cause_code, 'sin clasificar'::text), '. Coste imputado: ', COALESCE((s.attributed_cost_eur)::text, '0'::text), ' €.') AS description,
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_stops_v1', 'stop_id', s.stop_id, 'classification_status', s.classification_status, 'operator_note', s.operator_note, 'value_hour_eur', s.value_hour_eur) AS evidence_json
FROM mv_reports_ucepsa_demo.order_execution_stops_v1 s
UNION ALL
SELECT r.order_row_id,
r.tenant,
r.site,
r.odoo_instance,
r.odoo_workorder_id,
r.odoo_production_id,
r.odoo_order_ref,
r.product,
r.machine_id,
r.asset,
r.telemetry_last_ts AS event_ts,
'LAST_TELEMETRY'::text AS event_type,
'Última telemetría Edge-OEE'::text AS event_label,
CASE
WHEN (r.report_status = 'PARCIAL_ORDEN_EN_CURSO'::text) THEN 'WARNING'::text
ELSE 'INFO'::text
END AS severity,
NULL::bigint AS stop_id,
NULL::bigint AS node_event_id,
NULL::text AS cause_code,
NULL::numeric AS duration_min,
NULL::numeric AS cost_eur,
'Última muestra de telemetría recibida dentro de la ventana analizada.'::text AS description,
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_report_v1', 'real_cycle_delta', r.real_cycle_delta, 'kwh_consumed', r.kwh_consumed, 'avg_kw', r.avg_kw, 'max_kw', r.max_kw) AS evidence_json
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
WHERE (r.telemetry_last_ts IS NOT NULL)
UNION ALL
SELECT r.order_row_id,
r.tenant,
r.site,
r.odoo_instance,
r.odoo_workorder_id,
r.odoo_production_id,
r.odoo_order_ref,
r.product,
r.machine_id,
r.asset,
r.end_ts AS event_ts,
CASE
WHEN (r.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'REPORT_CUTOFF'::text
ELSE 'ORDER_END'::text
END AS event_type,
CASE
WHEN (r.odoo_state = ANY (ARRAY['progress'::text, 'in_progress'::text, 'started'::text])) THEN 'Corte provisional del informe'::text
ELSE 'Fin de orden'::text
END AS event_label,
CASE
WHEN (r.report_status = 'CERRADO_CALCULABLE'::text) THEN 'INFO'::text
ELSE 'WARNING'::text
END AS severity,
NULL::bigint AS stop_id,
NULL::bigint AS node_event_id,
NULL::text AS cause_code,
NULL::numeric AS duration_min,
NULL::numeric AS cost_eur,
r.report_status_description AS description,
jsonb_build_object('source', 'mv_reports_ucepsa_demo.order_execution_report_v1', 'report_status', r.report_status, 'window_min', r.window_min, 'stop_count', r.stop_count, 'stop_min', r.stop_min, 'stop_cost_eur', r.stop_cost_eur) AS evidence_json
FROM mv_reports_ucepsa_demo.order_execution_report_v1 r
WHERE (r.end_ts IS NOT NULL);
--
-- Name: order_execution_report_full_v1; Type: VIEW; Schema: mv_reports_ucepsa_demo; Owner: -
--
CREATE VIEW mv_reports_ucepsa_demo.order_execution_report_full_v1 AS
WITH stop_causes_json AS (
SELECT order_execution_stop_causes_v1.order_row_id,
jsonb_agg(jsonb_build_object('cause_code', order_execution_stop_causes_v1.cause_code, 'stop_count', order_execution_stop_causes_v1.stop_count, 'total_stop_min', order_execution_stop_causes_v1.total_stop_min, 'total_stop_cost_eur', order_execution_stop_causes_v1.total_stop_cost_eur, 'classified_stop_count', order_execution_stop_causes_v1.classified_stop_count, 'pending_stop_count', order_execution_stop_causes_v1.pending_stop_count, 'first_stop_at', order_execution_stop_causes_v1.first_stop_at, 'last_stop_at', order_execution_stop_causes_v1.last_stop_at) ORDER BY order_execution_stop_causes_v1.total_stop_min DESC) AS stop_causes_json,
string_agg(concat(COALESCE(order_execution_stop_causes_v1.cause_code, 'sin_clasificar'::text), ': ', order_execution_stop_causes_v1.stop_count, ' paros / ', order_execution_stop_causes_v1.total_stop_min, ' min / ', order_execution_stop_causes_v1.total_stop_cost_eur, ''), '; '::text ORDER BY order_execution_stop_causes_v1.total_stop_min DESC) AS stop_causes_summary
FROM mv_reports_ucepsa_demo.order_execution_stop_causes_v1
GROUP BY order_execution_stop_causes_v1.order_row_id
), stops_json AS (
SELECT order_execution_stops_v1.order_row_id,
jsonb_agg(jsonb_build_object('stop_id', order_execution_stops_v1.stop_id, 'stop_started_at', order_execution_stops_v1.stop_started_at, 'stop_ended_at', order_execution_stops_v1.stop_ended_at, 'attributed_started_at', order_execution_stops_v1.attributed_started_at, 'attributed_ended_at', order_execution_stops_v1.attributed_ended_at, 'attributed_duration_min', order_execution_stops_v1.attributed_duration_min, 'cause_code', order_execution_stops_v1.cause_code, 'operator_note', order_execution_stops_v1.operator_note, 'classification_status', order_execution_stops_v1.classification_status, 'attributed_cost_eur', order_execution_stops_v1.attributed_cost_eur, 'stop_quality_status', order_execution_stops_v1.stop_quality_status) ORDER BY order_execution_stops_v1.attributed_started_at) AS stops_json
FROM mv_reports_ucepsa_demo.order_execution_stops_v1
GROUP BY order_execution_stops_v1.order_row_id
), timeline_json AS (
SELECT order_execution_timeline_v1.order_row_id,
jsonb_agg(jsonb_build_object('event_ts', order_execution_timeline_v1.event_ts, 'event_type', order_execution_timeline_v1.event_type, 'event_label', order_execution_timeline_v1.event_label, 'severity', order_execution_timeline_v1.severity, 'stop_id', order_execution_timeline_v1.stop_id, 'cause_code', order_execution_timeline_v1.cause_code, 'duration_min', order_execution_timeline_v1.duration_min, 'cost_eur', order_execution_timeline_v1.cost_eur, 'description', order_execution_timeline_v1.description) ORDER BY order_execution_timeline_v1.event_ts, order_execution_timeline_v1.event_type) AS timeline_json
FROM mv_reports_ucepsa_demo.order_execution_timeline_v1
GROUP BY order_execution_timeline_v1.order_row_id
)
SELECT r.order_row_id,
r.tenant,
r.site,
r.odoo_instance,
r.odoo_workorder_id,
r.odoo_production_id,
r.odoo_order_ref,
r.odoo_state,
r.odoo_workcenter_code,
r.odoo_workcenter_name,
r.machine_id,
r.asset,
r.product,
r.qty_planned,
r.qty_done,
r.uom,
r.planned_cycles,
r.target_cycle_rate_ppm,
r.start_ts,
r.end_ts,
r.window_min,
GREATEST((r.window_min - r.stop_min), (0)::numeric) AS run_min,
CASE
WHEN (r.window_min > (0)::numeric) THEN round(((r.stop_min / r.window_min) * 100.0), 2)
ELSE NULL::numeric
END AS stop_pct,
CASE
WHEN (r.window_min > (0)::numeric) THEN round(((GREATEST((r.window_min - r.stop_min), (0)::numeric) / r.window_min) * 100.0), 2)
ELSE NULL::numeric
END AS availability_pct,
r.telemetry_samples,
r.telemetry_first_ts,
r.telemetry_last_ts,
r.energy_samples,
r.energy_first_ts,
r.energy_last_ts,
r.start_import_kwh,
r.end_import_kwh,
r.kwh_consumed,
r.avg_kw,
r.max_kw,
r.min_cycle_pulse_count,
r.max_cycle_pulse_count,
r.real_cycle_delta,
r.avg_cycle_rate_ppm,
r.avg_nonzero_cycle_rate_ppm,
CASE
WHEN (r.real_cycle_delta > 0) THEN round((r.kwh_consumed / (NULLIF(r.real_cycle_delta, 0))::numeric), 6)
ELSE NULL::numeric
END AS kwh_per_cycle,
CASE
WHEN (GREATEST((r.window_min - r.stop_min), (0)::numeric) > (0)::numeric) THEN round(((r.real_cycle_delta)::numeric / NULLIF(GREATEST((r.window_min - r.stop_min), (0)::numeric), (0)::numeric)), 2)
ELSE NULL::numeric
END AS cycles_per_run_min,
r.stop_count,
r.stop_min,
r.classified_stops,
r.pending_stops,
r.stop_cost_eur,
COALESCE(sc.stop_causes_summary, 'Sin paros imputados'::text) AS stop_causes_summary,
COALESCE(sc.stop_causes_json, '[]'::jsonb) AS stop_causes_json,
COALESCE(sj.stops_json, '[]'::jsonb) AS stops_json,
COALESCE(tj.timeline_json, '[]'::jsonb) AS timeline_json,
q.expected_samples_10s,
q.telemetry_sample_coverage_pct,
q.telemetry_gap_count_30s,
q.max_sample_gap_s,
q.avg_sample_gap_s,
q.non_ok_samples,
q.production_conversion_status,
q.technical_quality_score,
q.technical_quality_level,
r.report_status,
r.report_status_description,
q.recommended_action,
CASE
WHEN ((r.report_status = 'CERRADO_CALCULABLE'::text) AND (q.technical_quality_level = 'FIABLE'::text) AND (q.production_conversion_status = 'CICLOS_COHERENTES_CON_OBJETIVO'::text)) THEN 'LISTO_INFORME_FINAL'::text
WHEN ((r.report_status = 'CERRADO_CALCULABLE'::text) AND (q.technical_quality_level = 'FIABLE'::text) AND (q.production_conversion_status = 'REVISAR_CONVERSION_ODOO_CICLOS'::text)) THEN 'LISTO_TECNICO_REVISAR_CONVERSION'::text
WHEN ((r.report_status = 'CERRADO_CALCULABLE'::text) AND (q.technical_quality_level = ANY (ARRAY['FIABLE'::text, 'ACEPTABLE_CON_OBSERVACIONES'::text]))) THEN 'LISTO_CON_OBSERVACIONES'::text
WHEN (r.report_status = 'PARCIAL_ORDEN_EN_CURSO'::text) THEN 'INFORME_PARCIAL_ORDEN_EN_CURSO'::text
WHEN (r.report_status = 'SIN_TELEMETRIA_EN_VENTANA'::text) THEN 'BLOQUEADO_SIN_TELEMETRIA'::text
WHEN (r.report_status = 'SIN_ENERGIA_EN_VENTANA'::text) THEN 'BLOQUEADO_SIN_ENERGIA'::text
WHEN (r.pending_stops > 0) THEN 'PENDIENTE_CLASIFICAR_PAROS'::text
ELSE 'REVISAR'::text
END AS final_report_status,
concat('Orden ', r.odoo_order_ref, ' en ', r.machine_id, ': duración ', r.window_min, ' min; ciclos reales ', r.real_cycle_delta, '; energía ', COALESCE((r.kwh_consumed)::text, 'sin dato'::text), ' kWh; paros ', r.stop_count, ' (', r.stop_min, ' min); coste estimado ', r.stop_cost_eur, ' €. Estado técnico: ', q.technical_quality_level, '.') AS executive_summary
FROM ((((mv_reports_ucepsa_demo.order_execution_report_v1 r
LEFT JOIN mv_reports_ucepsa_demo.order_execution_data_quality_v1 q ON ((q.order_row_id = r.order_row_id)))
LEFT JOIN stop_causes_json sc ON ((sc.order_row_id = r.order_row_id)))
LEFT JOIN stops_json sj ON ((sj.order_row_id = r.order_row_id)))
LEFT JOIN timeline_json tj ON ((tj.order_row_id = r.order_row_id)));
--
-- PostgreSQL database dump complete
--
\unrestrict YbA0VcRIrZ1kSDSxIf4zctoL9eu8TYoJ5cvkbSD5WkFTQ1jRyH26snWSZehz5VJ
GRANT USAGE ON SCHEMA mv_reports_ucepsa_demo TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_report_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_stops_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_stop_causes_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_timeline_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_energy_timeseries_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_data_quality_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_demo.order_execution_report_full_v1 TO grafana_ucepsa_ro;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
BEGIN;
CREATE SCHEMA IF NOT EXISTS mv_edge_oee_ucepsa_prod;
CREATE SCHEMA IF NOT EXISTS mv_reports_ucepsa_prod;
-- Cache comercial/productiva de Odoo PROD.
-- Se clona estructura desde la tabla demo existente, pero se corrige la secuencia del id.
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_prod.odoo_order_commercials
(
LIKE mv_edge_oee_ucepsa_demo.odoo_order_commercials
INCLUDING ALL
);
CREATE SEQUENCE IF NOT EXISTS mv_edge_oee_ucepsa_prod.odoo_order_commercials_id_seq
OWNED BY mv_edge_oee_ucepsa_prod.odoo_order_commercials.id;
ALTER TABLE mv_edge_oee_ucepsa_prod.odoo_order_commercials
ALTER COLUMN id SET DEFAULT nextval('mv_edge_oee_ucepsa_prod.odoo_order_commercials_id_seq'::regclass);
-- Parámetros económicos productivos.
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_prod.margin_cost_parameters
(
LIKE mv_edge_oee_ucepsa_demo.margin_cost_parameters
INCLUDING ALL
);
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_prod.raw_material_cost_parameters
(
LIKE mv_edge_oee_ucepsa_demo.raw_material_cost_parameters
INCLUDING ALL
);
CREATE TABLE IF NOT EXISTS mv_edge_oee_ucepsa_prod.data_quality_contract_rules
(
LIKE mv_edge_oee_ucepsa_demo.data_quality_contract_rules
INCLUDING ALL
);
-- Copia de reglas DQC demo a contexto productivo.
-- Mantiene la lógica, cambia site a ucepsa_onpremise.
INSERT INTO mv_edge_oee_ucepsa_prod.data_quality_contract_rules (
tenant,
site,
rule_code,
rule_name,
domain,
dimension,
severity,
owner_area,
applies_to,
description,
expected_condition,
blocking_for,
is_active,
created_at
)
SELECT
tenant,
'ucepsa_onpremise' AS site,
rule_code,
rule_name,
domain,
dimension,
severity,
owner_area,
applies_to,
description,
expected_condition,
blocking_for,
is_active,
now()
FROM mv_edge_oee_ucepsa_demo.data_quality_contract_rules d
WHERE d.tenant = 'ucepsa'
AND d.site = 'ucepsa_test'
AND NOT EXISTS (
SELECT 1
FROM mv_edge_oee_ucepsa_prod.data_quality_contract_rules p
WHERE p.tenant = d.tenant
AND p.site = 'ucepsa_onpremise'
AND p.rule_code = d.rule_code
);
-- Permisos defensivos si los roles existen.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro') THEN
GRANT USAGE ON SCHEMA mv_edge_oee_ucepsa_prod TO grafana_ucepsa_ro;
GRANT USAGE ON SCHEMA mv_reports_ucepsa_prod TO grafana_ucepsa_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA mv_edge_oee_ucepsa_prod TO grafana_ucepsa_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA mv_reports_ucepsa_prod TO grafana_ucepsa_ro;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'edge_oee_odoo_sync_writer') THEN
GRANT USAGE ON SCHEMA mv_edge_oee_ucepsa_prod TO edge_oee_odoo_sync_writer;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA mv_edge_oee_ucepsa_prod TO edge_oee_odoo_sync_writer;
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA mv_edge_oee_ucepsa_prod TO edge_oee_odoo_sync_writer;
END IF;
END $$;
COMMIT;

View File

@ -0,0 +1,136 @@
BEGIN;
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.current_order_margin_readiness_v1 AS
WITH current_orders AS (
SELECT
tenant,
site,
machine_id,
workcenter_id,
workcenter_name,
order_present,
order_coherent,
odoo_state AS order_state_from_shopfloor,
workorder_id AS odoo_workorder_id,
production_order AS odoo_order_ref,
product AS order_state_product,
waiting_count,
ready_count,
progress_count,
mqtt_ts,
received_at,
payload_json,
NULLIF(payload_json ->> 'production_id', '')::integer AS odoo_production_id,
NULLIF(payload_json ->> 'product_id', '')::integer AS order_state_product_id,
NULLIF(payload_json ->> 'qty_production', '')::numeric AS order_state_qty_production,
payload_json ->> 'date_start' AS order_state_date_start,
payload_json ->> 'date_finished' AS order_state_date_finished
FROM mv_hot.odoo_order_state_current
WHERE site = 'ucepsa_onpremise'
),
commercial AS (
SELECT
*
FROM mv_edge_oee_ucepsa_prod.odoo_order_commercials
WHERE site = 'ucepsa_onpremise'
AND odoo_instance = 'odoo_ucepsa_prod'
),
joined AS (
SELECT
co.tenant,
co.site,
co.machine_id,
co.workcenter_id,
co.workcenter_name,
co.order_present,
co.order_coherent,
co.order_state_from_shopfloor,
co.odoo_workorder_id,
co.odoo_production_id,
co.odoo_order_ref,
co.order_state_product,
co.order_state_product_id,
co.order_state_qty_production,
co.waiting_count,
co.ready_count,
co.progress_count,
co.mqtt_ts,
co.received_at,
c.odoo_instance,
c.odoo_state AS production_state,
c.odoo_origin,
c.product_id,
c.product_default_code,
COALESCE(c.product_name, co.order_state_product) AS product_name,
c.product_uom,
c.production_qty,
c.qty_producing,
c.production_uom,
c.sale_line_id,
c.sale_order_id,
c.sale_order_ref,
c.customer_id,
c.customer_name,
c.sale_state,
c.sale_date_order,
c.currency,
c.sale_line_qty,
c.sale_line_qty_delivered,
c.sale_price_unit,
c.sale_discount_pct,
c.revenue_net_eur,
c.revenue_total_eur,
c.raw_material_value_eur,
c.finished_value_eur,
c.material_cost_status,
jsonb_array_length(COALESCE(c.stock_raw_moves_json, '[]'::jsonb)) AS raw_material_line_count,
jsonb_array_length(COALESCE(c.stock_finished_moves_json, '[]'::jsonb)) AS finished_move_line_count,
c.stock_raw_moves_json,
c.stock_finished_moves_json,
c.snapshot_at AS commercial_snapshot_at
FROM current_orders co
LEFT JOIN commercial c
ON c.odoo_production_id = co.odoo_production_id
)
SELECT
*,
CASE
WHEN order_present IS FALSE THEN 'NO_ORDER_LOADED'
WHEN order_coherent IS FALSE THEN 'ODOO_ORDER_INCOHERENT'
WHEN odoo_production_id IS NULL THEN 'MISSING_PRODUCTION_ID_FROM_ORDER_STATE'
WHEN odoo_instance IS NULL THEN 'MISSING_COMMERCIAL_SNAPSHOT'
WHEN sale_order_ref IS NULL OR revenue_net_eur IS NULL THEN 'MISSING_REVENUE_LINK'
WHEN customer_name IS NULL THEN 'MISSING_CUSTOMER'
WHEN raw_material_line_count = 0 THEN 'MISSING_RAW_MATERIALS'
WHEN material_cost_status = 'UNAVAILABLE_OR_ZERO' THEN 'MISSING_MATERIAL_COST'
ELSE 'READY_FOR_MARGIN_BASE'
END AS margin_readiness_status,
CASE
WHEN order_present IS FALSE THEN 'No hay orden cargada en Odoo Shop Floor para esta máquina.'
WHEN order_coherent IS FALSE THEN 'Hay estado Odoo incoherente para esta máquina.'
WHEN odoo_production_id IS NULL THEN 'El order_state no trae production_id; revisar publicador Odoo.'
WHEN odoo_instance IS NULL THEN 'La orden existe en order_state, pero aún no tiene snapshot comercial en Edge-OEE.'
WHEN sale_order_ref IS NULL OR revenue_net_eur IS NULL THEN 'Falta vínculo económico con pedido/línea de venta.'
WHEN customer_name IS NULL THEN 'Falta cliente asociado al pedido de venta.'
WHEN raw_material_line_count = 0 THEN 'No se detectan movimientos de materia prima para la orden.'
WHEN material_cost_status = 'UNAVAILABLE_OR_ZERO' THEN 'Hay materia prima, pero Odoo no aporta valoración económica útil. Cargar coste €/kg o corregir valoración.'
ELSE 'Datos comerciales mínimos disponibles para calcular margen base.'
END AS margin_readiness_description,
CASE
WHEN order_present IS TRUE THEN 20 ELSE 0 END
+ CASE WHEN order_coherent IS TRUE THEN 15 ELSE 0 END
+ CASE WHEN odoo_instance IS NOT NULL THEN 15 ELSE 0 END
+ CASE WHEN sale_order_ref IS NOT NULL AND revenue_net_eur IS NOT NULL THEN 20 ELSE 0 END
+ CASE WHEN customer_name IS NOT NULL THEN 10 ELSE 0 END
+ CASE WHEN raw_material_line_count > 0 THEN 10 ELSE 0 END
+ CASE WHEN material_cost_status <> 'UNAVAILABLE_OR_ZERO' THEN 10 ELSE 0 END
AS margin_readiness_score
FROM joined;
GRANT SELECT ON mv_reports_ucepsa_prod.current_order_margin_readiness_v1 TO grafana_ucepsa_ro;
COMMIT;

View File

@ -0,0 +1,271 @@
BEGIN;
-- ============================================================
-- 1) Higiene de secuencias productivas
-- ============================================================
CREATE SEQUENCE IF NOT EXISTS mv_edge_oee_ucepsa_prod.margin_cost_parameters_id_seq
OWNED BY mv_edge_oee_ucepsa_prod.margin_cost_parameters.id;
ALTER TABLE mv_edge_oee_ucepsa_prod.margin_cost_parameters
ALTER COLUMN id SET DEFAULT nextval('mv_edge_oee_ucepsa_prod.margin_cost_parameters_id_seq'::regclass);
SELECT setval(
'mv_edge_oee_ucepsa_prod.margin_cost_parameters_id_seq',
COALESCE((SELECT max(id) FROM mv_edge_oee_ucepsa_prod.margin_cost_parameters), 0) + 1,
false
);
CREATE SEQUENCE IF NOT EXISTS mv_edge_oee_ucepsa_prod.raw_material_cost_parameters_id_seq
OWNED BY mv_edge_oee_ucepsa_prod.raw_material_cost_parameters.id;
ALTER TABLE mv_edge_oee_ucepsa_prod.raw_material_cost_parameters
ALTER COLUMN id SET DEFAULT nextval('mv_edge_oee_ucepsa_prod.raw_material_cost_parameters_id_seq'::regclass);
SELECT setval(
'mv_edge_oee_ucepsa_prod.raw_material_cost_parameters_id_seq',
COALESCE((SELECT max(id) FROM mv_edge_oee_ucepsa_prod.raw_material_cost_parameters), 0) + 1,
false
);
CREATE SEQUENCE IF NOT EXISTS mv_edge_oee_ucepsa_prod.data_quality_contract_rules_id_seq
OWNED BY mv_edge_oee_ucepsa_prod.data_quality_contract_rules.id;
ALTER TABLE mv_edge_oee_ucepsa_prod.data_quality_contract_rules
ALTER COLUMN id SET DEFAULT nextval('mv_edge_oee_ucepsa_prod.data_quality_contract_rules_id_seq'::regclass);
SELECT setval(
'mv_edge_oee_ucepsa_prod.data_quality_contract_rules_id_seq',
COALESCE((SELECT max(id) FROM mv_edge_oee_ucepsa_prod.data_quality_contract_rules), 0) + 1,
false
);
-- ============================================================
-- 2) Ampliar parámetros de coste para permitir match por ID Odoo
-- ============================================================
ALTER TABLE mv_edge_oee_ucepsa_prod.raw_material_cost_parameters
ADD COLUMN IF NOT EXISTS raw_product_id integer;
ALTER TABLE mv_edge_oee_ucepsa_prod.raw_material_cost_parameters
ADD COLUMN IF NOT EXISTS raw_uom text;
CREATE INDEX IF NOT EXISTS idx_raw_material_cost_params_prod_product_id
ON mv_edge_oee_ucepsa_prod.raw_material_cost_parameters(raw_product_id);
CREATE INDEX IF NOT EXISTS idx_raw_material_cost_params_prod_code
ON mv_edge_oee_ucepsa_prod.raw_material_cost_parameters(raw_product_default_code);
CREATE INDEX IF NOT EXISTS idx_raw_material_cost_params_prod_name
ON mv_edge_oee_ucepsa_prod.raw_material_cost_parameters(raw_product_name);
-- ============================================================
-- 3) Vista productiva de líneas de materia prima
-- ============================================================
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.order_raw_material_costs_v1 AS
WITH raw_moves AS (
SELECT
c.id AS commercial_row_id,
c.tenant,
c.site,
c.odoo_instance,
c.odoo_production_id,
c.odoo_order_ref,
c.sale_order_ref,
c.customer_name,
c.product_default_code AS finished_product_default_code,
c.product_name AS finished_product_name,
NULLIF(rm.raw_move ->> 'id', '')::integer AS raw_move_id,
NULLIF((rm.raw_move -> 'product_id') ->> 0, '')::integer AS raw_product_id,
(rm.raw_move -> 'product_id') ->> 1 AS raw_product_name,
substring((rm.raw_move -> 'product_id') ->> 1, '\[([^\]]+)\]') AS raw_product_default_code,
NULLIF(rm.raw_move ->> 'product_uom_qty', '')::numeric AS planned_qty,
NULLIF(rm.raw_move ->> 'quantity', '')::numeric AS consumed_qty,
(rm.raw_move -> 'product_uom') ->> 1 AS raw_uom,
NULLIF(rm.raw_move ->> 'price_unit', '')::numeric AS odoo_price_unit,
NULLIF(rm.raw_move ->> 'value', '')::numeric AS odoo_value,
rm.raw_move ->> 'state' AS move_state,
rm.raw_move AS raw_move_json
FROM mv_edge_oee_ucepsa_prod.odoo_order_commercials c
CROSS JOIN LATERAL jsonb_array_elements(c.stock_raw_moves_json) rm(raw_move)
),
costed AS (
SELECT
r.*,
p.id AS raw_material_cost_parameter_id,
p.material_cost_eur_per_kg,
p.source AS raw_material_cost_source,
p.notes AS raw_material_cost_notes,
CASE
WHEN COALESCE(r.odoo_value, 0::numeric) <> 0::numeric
THEN abs(r.odoo_value)
WHEN lower(COALESCE(r.raw_uom, '')) = 'kg'
AND p.material_cost_eur_per_kg IS NOT NULL
AND r.consumed_qty IS NOT NULL
THEN r.consumed_qty * p.material_cost_eur_per_kg
ELSE NULL::numeric
END AS material_cost_estimated_eur,
CASE
WHEN COALESCE(r.odoo_value, 0::numeric) <> 0::numeric
THEN 'FROM_ODOO_STOCK_MOVE_VALUE'
WHEN r.raw_product_id IS NULL
THEN 'MISSING_RAW_PRODUCT_ID'
WHEN lower(COALESCE(r.raw_uom, '')) <> 'kg'
THEN 'UNSUPPORTED_RAW_UOM'
WHEN p.material_cost_eur_per_kg IS NULL
THEN 'MISSING_RAW_MATERIAL_COST_PARAMETER'
WHEN r.consumed_qty IS NULL
THEN 'MISSING_CONSUMED_QTY'
ELSE 'FROM_RAW_MATERIAL_COST_PARAMETER'
END AS material_cost_status
FROM raw_moves r
LEFT JOIN LATERAL (
SELECT p1.*
FROM mv_edge_oee_ucepsa_prod.raw_material_cost_parameters p1
WHERE p1.tenant = r.tenant
AND p1.site = r.site
AND p1.is_active = true
AND p1.valid_from <= now()
AND (p1.valid_to IS NULL OR p1.valid_to > now())
AND (
(p1.raw_product_id IS NOT NULL AND p1.raw_product_id = r.raw_product_id)
OR (p1.raw_product_id IS NULL AND p1.raw_product_default_code IS NOT NULL AND p1.raw_product_default_code = r.raw_product_default_code)
OR (p1.raw_product_id IS NULL AND p1.raw_product_default_code IS NULL AND p1.raw_product_name IS NOT NULL AND p1.raw_product_name = r.raw_product_name)
)
ORDER BY
CASE
WHEN p1.raw_product_id IS NOT NULL AND p1.raw_product_id = r.raw_product_id THEN 1
WHEN p1.raw_product_default_code IS NOT NULL AND p1.raw_product_default_code = r.raw_product_default_code THEN 2
WHEN p1.raw_product_name IS NOT NULL AND p1.raw_product_name = r.raw_product_name THEN 3
ELSE 9
END,
p1.valid_from DESC,
p1.id DESC
LIMIT 1
) p ON true
)
SELECT
commercial_row_id,
tenant,
site,
odoo_instance,
odoo_production_id,
odoo_order_ref,
sale_order_ref,
customer_name,
finished_product_default_code,
finished_product_name,
raw_move_id,
raw_product_id,
raw_product_name,
raw_product_default_code,
planned_qty,
consumed_qty,
raw_uom,
odoo_price_unit,
odoo_value,
move_state,
raw_material_cost_parameter_id,
material_cost_eur_per_kg,
raw_material_cost_source,
raw_material_cost_notes,
material_cost_estimated_eur,
round(material_cost_estimated_eur, 4) AS material_cost_estimated_eur_rounded,
material_cost_status,
raw_move_json
FROM costed;
-- ============================================================
-- 4) Resumen de coste de materia prima por orden
-- ============================================================
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.order_raw_material_costs_summary_v1 AS
SELECT
tenant,
site,
odoo_instance,
odoo_production_id,
odoo_order_ref,
sale_order_ref,
customer_name,
finished_product_default_code,
finished_product_name,
count(*) AS raw_material_line_count,
round(sum(COALESCE(consumed_qty, 0::numeric)), 3) AS total_consumed_qty,
string_agg(
concat(
COALESCE(raw_product_default_code, raw_product_id::text, 'sin_codigo'),
' - ',
COALESCE(raw_product_name, 'sin_nombre'),
': ',
COALESCE(consumed_qty::text, 'sin_cantidad'),
' ',
COALESCE(raw_uom, '')
),
'; '
ORDER BY raw_product_name
) AS raw_materials_summary,
round(sum(COALESCE(material_cost_estimated_eur, 0::numeric)), 4) AS material_cost_estimated_eur,
count(*) FILTER (WHERE material_cost_estimated_eur IS NOT NULL) AS costed_raw_material_line_count,
count(*) FILTER (
WHERE material_cost_status <> 'FROM_RAW_MATERIAL_COST_PARAMETER'
AND material_cost_status <> 'FROM_ODOO_STOCK_MOVE_VALUE'
) AS missing_or_invalid_raw_material_cost_count,
string_agg(DISTINCT material_cost_status, ', ' ORDER BY material_cost_status) AS material_cost_status_summary
FROM mv_reports_ucepsa_prod.order_raw_material_costs_v1
GROUP BY
tenant,
site,
odoo_instance,
odoo_production_id,
odoo_order_ref,
sale_order_ref,
customer_name,
finished_product_default_code,
finished_product_name;
-- ============================================================
-- 5) Vista de costes pendientes de parametrizar
-- ============================================================
CREATE OR REPLACE VIEW mv_reports_ucepsa_prod.raw_material_cost_requirements_v1 AS
SELECT
tenant,
site,
raw_product_id,
raw_product_default_code,
raw_product_name,
raw_uom,
count(*) AS affected_order_lines,
count(DISTINCT odoo_order_ref) AS affected_orders,
string_agg(DISTINCT odoo_order_ref, ', ' ORDER BY odoo_order_ref) AS affected_order_refs,
round(sum(COALESCE(consumed_qty, 0::numeric)), 3) AS total_consumed_qty,
string_agg(DISTINCT customer_name, ', ' ORDER BY customer_name) AS affected_customers,
max(material_cost_status) AS current_status,
'Cargar coste €/kg en mv_edge_oee_ucepsa_prod.raw_material_cost_parameters' AS recommended_action
FROM mv_reports_ucepsa_prod.order_raw_material_costs_v1
WHERE material_cost_status IN (
'MISSING_RAW_MATERIAL_COST_PARAMETER',
'MISSING_RAW_PRODUCT_ID',
'MISSING_CONSUMED_QTY',
'UNSUPPORTED_RAW_UOM'
)
GROUP BY
tenant,
site,
raw_product_id,
raw_product_default_code,
raw_product_name,
raw_uom;
GRANT SELECT ON mv_reports_ucepsa_prod.order_raw_material_costs_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.order_raw_material_costs_summary_v1 TO grafana_ucepsa_ro;
GRANT SELECT ON mv_reports_ucepsa_prod.raw_material_cost_requirements_v1 TO grafana_ucepsa_ro;
COMMIT;

View File

@ -0,0 +1,92 @@
#!/usr/bin/env python3
import os
import time
from pathlib import Path
import paho.mqtt.client as mqtt
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
env = read_env()
host = os.getenv("MQTT_HOST_OVERRIDE") or env.get("MQTT_HOST", "127.0.0.1")
port = int(os.getenv("MQTT_PORT_OVERRIDE") or env.get("MQTT_PORT", "1883"))
user = (
os.getenv("MQTT_USER_OVERRIDE")
or os.getenv("MQTT_USERNAME_OVERRIDE")
or os.getenv("MQTT_USER")
or os.getenv("MQTT_USERNAME")
or env.get("MQTT_USER")
or env.get("MQTT_USERNAME")
or env.get("MOSQUITTO_USER")
or env.get("MOSQUITTO_USERNAME")
)
password = (
os.getenv("MQTT_PASSWORD_OVERRIDE")
or os.getenv("MQTT_PASS_OVERRIDE")
or os.getenv("MQTT_PASSWORD")
or os.getenv("MQTT_PASS")
or env.get("MQTT_PASSWORD")
or env.get("MQTT_PASS")
or env.get("MOSQUITTO_PASSWORD")
or env.get("MOSQUITTO_PASS")
)
print("host =", host)
print("port =", port)
print("user_present =", bool(user))
print("password_present =", bool(password))
result = {
"connect_rc": None,
"disconnect_rc": None,
}
def on_connect(client, userdata, flags, rc, properties=None):
result["connect_rc"] = rc
print("on_connect rc =", rc)
def on_disconnect(client, userdata, rc, properties=None):
result["disconnect_rc"] = rc
print("on_disconnect rc =", rc)
client = mqtt.Client(client_id="mqtt_auth_probe")
if user:
client.username_pw_set(user, password)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.connect(host, port, 60)
client.loop_start()
deadline = time.time() + 5
while time.time() < deadline and result["connect_rc"] is None:
time.sleep(0.1)
client.loop_stop()
client.disconnect()
if result["connect_rc"] != 0:
raise SystemExit(f"MQTT auth fallida. connect_rc={result['connect_rc']}")
print("OK: MQTT conectado correctamente")

View File

@ -0,0 +1,528 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
import xmlrpc.client
from datetime import datetime, timezone
try:
import psycopg2
import psycopg2.extras
except Exception as exc:
print(f"ERROR: psycopg2 no disponible en esta imagen Python: {exc}", file=sys.stderr)
sys.exit(2)
TENANT = os.getenv("TENANT_OVERRIDE", "ucepsa")
SITE = os.getenv("SITE_OVERRIDE", "ucepsa_onpremise")
ODOO_INSTANCE = os.getenv("ODOO_INSTANCE_OVERRIDE", "odoo_ucepsa_prod")
ODOO_DB = os.getenv("ODOO_DB_OVERRIDE") or os.getenv("ODOO_DB")
ODOO_URL = (os.getenv("ODOO_URL") or "").rstrip("/")
ODOO_USER = os.getenv("ODOO_USER")
ODOO_PASSWORD = os.getenv("ODOO_PASSWORD")
PGHOST = os.getenv("POSTGRES_HOST") or os.getenv("PGHOST") or "mv_ucepsa_postgres_hot"
PGPORT = int(os.getenv("POSTGRES_PORT") or os.getenv("PGPORT") or "5432")
PGDATABASE = os.getenv("POSTGRES_DB") or os.getenv("PGDATABASE")
PGUSER = os.getenv("POSTGRES_USER") or os.getenv("PGUSER")
PGPASSWORD = os.getenv("POSTGRES_PASSWORD") or os.getenv("PGPASSWORD")
def require_env():
missing = []
for name, value in {
"ODOO_URL": ODOO_URL,
"ODOO_DB/ODOO_DB_OVERRIDE": ODOO_DB,
"ODOO_USER": ODOO_USER,
"ODOO_PASSWORD": ODOO_PASSWORD,
"POSTGRES_DB": PGDATABASE,
"POSTGRES_USER": PGUSER,
"POSTGRES_PASSWORD": PGPASSWORD,
}.items():
if not value:
missing.append(name)
if missing:
raise RuntimeError("Faltan variables de entorno: " + ", ".join(missing))
def odoo_connect():
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common", allow_none=True)
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_PASSWORD, {})
if not uid:
raise RuntimeError("No se pudo autenticar contra Odoo")
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object", allow_none=True)
return uid, models
def odoo_call(models, uid, model, method, *args, **kwargs):
return models.execute_kw(ODOO_DB, uid, ODOO_PASSWORD, model, method, list(args), kwargs or {})
def pg_connect():
return psycopg2.connect(
host=PGHOST,
port=PGPORT,
dbname=PGDATABASE,
user=PGUSER,
password=PGPASSWORD,
)
def as_id(value):
if isinstance(value, list) and value:
return value[0]
if isinstance(value, tuple) and value:
return value[0]
if isinstance(value, int):
return value
return None
def as_name(value):
if isinstance(value, list) and len(value) > 1:
return value[1]
if isinstance(value, tuple) and len(value) > 1:
return value[1]
return None
def read_map_by_ids(models, uid, model, ids, fields):
ids = [int(x) for x in ids if x]
if not ids:
return {}
rows = odoo_call(models, uid, model, "read", ids, fields=fields)
return {r["id"]: r for r in rows}
def extract_default_code(display_name):
if not display_name:
return None
if display_name.startswith("[") and "]" in display_name:
return display_name[1:display_name.index("]")]
return None
def build_row(prod, product, sale_line, sale_order, raw_moves, finished_moves):
product_name = as_name(prod.get("product_id")) or (product or {}).get("display_name")
product_default_code = (product or {}).get("default_code") or extract_default_code(product_name)
product_standard_price = product.get("standard_price") if product else None
product_list_price = product.get("list_price") if product else None
product_uom = as_name((product or {}).get("uom_id")) or as_name(prod.get("product_uom_id"))
sale_order_ref = None
sale_order_id = None
customer_id = None
customer_name = None
sale_state = None
sale_date_order = None
currency = None
sale_line_qty = None
sale_line_qty_delivered = None
sale_price_unit = None
sale_discount_pct = None
revenue_net_eur = None
revenue_total_eur = None
sale_line_id = None
if sale_line:
sale_line_id = sale_line.get("id")
sale_line_qty = sale_line.get("product_uom_qty")
sale_line_qty_delivered = sale_line.get("qty_delivered")
sale_price_unit = sale_line.get("price_unit")
sale_discount_pct = sale_line.get("discount")
revenue_net_eur = sale_line.get("price_subtotal")
revenue_total_eur = sale_line.get("price_total")
if sale_order:
sale_order_id = sale_order.get("id")
sale_order_ref = sale_order.get("name")
sale_state = sale_order.get("state")
sale_date_order = sale_order.get("date_order")
customer_id = as_id(sale_order.get("partner_id"))
customer_name = as_name(sale_order.get("partner_id"))
currency = as_name(sale_order.get("currency_id"))
raw_material_value_eur = sum([float(m.get("value") or 0.0) for m in raw_moves])
finished_value_eur = sum([float(m.get("value") or 0.0) for m in finished_moves])
if raw_material_value_eur and raw_material_value_eur != 0:
material_cost_status = "FROM_ODOO_STOCK_MOVE_VALUE"
else:
material_cost_status = "UNAVAILABLE_OR_ZERO"
raw_json = {
"production": prod,
"product": product,
"sale_line": sale_line,
"sale_order": sale_order,
}
return {
"tenant": TENANT,
"site": SITE,
"odoo_instance": ODOO_INSTANCE,
"odoo_production_id": prod.get("id"),
"odoo_order_ref": prod.get("name"),
"odoo_origin": prod.get("origin"),
"odoo_state": prod.get("state"),
"product_id": as_id(prod.get("product_id")),
"product_default_code": product_default_code,
"product_name": product_name,
"product_uom": product_uom,
"product_standard_price": product_standard_price,
"product_list_price": product_list_price,
"production_qty": prod.get("product_qty"),
"qty_producing": prod.get("qty_producing"),
"production_uom": as_name(prod.get("product_uom_id")),
"sale_line_id": sale_line_id,
"sale_order_id": sale_order_id,
"sale_order_ref": sale_order_ref,
"customer_id": customer_id,
"customer_name": customer_name,
"sale_state": sale_state,
"sale_date_order": sale_date_order,
"currency": currency,
"sale_line_qty": sale_line_qty,
"sale_line_qty_delivered": sale_line_qty_delivered,
"sale_price_unit": sale_price_unit,
"sale_discount_pct": sale_discount_pct,
"revenue_net_eur": revenue_net_eur,
"revenue_total_eur": revenue_total_eur,
"raw_material_value_eur": raw_material_value_eur,
"finished_value_eur": finished_value_eur,
"material_cost_status": material_cost_status,
"stock_raw_moves_json": raw_moves,
"stock_finished_moves_json": finished_moves,
"raw_json": raw_json,
}
def upsert_rows(conn, rows):
if not rows:
return 0
sql = """
INSERT INTO mv_edge_oee_ucepsa_prod.odoo_order_commercials (
tenant,
site,
odoo_instance,
odoo_production_id,
odoo_order_ref,
odoo_origin,
odoo_state,
product_id,
product_default_code,
product_name,
product_uom,
product_standard_price,
product_list_price,
production_qty,
qty_producing,
production_uom,
sale_line_id,
sale_order_id,
sale_order_ref,
customer_id,
customer_name,
sale_state,
sale_date_order,
currency,
sale_line_qty,
sale_line_qty_delivered,
sale_price_unit,
sale_discount_pct,
revenue_net_eur,
revenue_total_eur,
raw_material_value_eur,
finished_value_eur,
material_cost_status,
stock_raw_moves_json,
stock_finished_moves_json,
raw_json,
snapshot_at
)
VALUES (
%(tenant)s,
%(site)s,
%(odoo_instance)s,
%(odoo_production_id)s,
%(odoo_order_ref)s,
%(odoo_origin)s,
%(odoo_state)s,
%(product_id)s,
%(product_default_code)s,
%(product_name)s,
%(product_uom)s,
%(product_standard_price)s,
%(product_list_price)s,
%(production_qty)s,
%(qty_producing)s,
%(production_uom)s,
%(sale_line_id)s,
%(sale_order_id)s,
%(sale_order_ref)s,
%(customer_id)s,
%(customer_name)s,
%(sale_state)s,
%(sale_date_order)s,
%(currency)s,
%(sale_line_qty)s,
%(sale_line_qty_delivered)s,
%(sale_price_unit)s,
%(sale_discount_pct)s,
%(revenue_net_eur)s,
%(revenue_total_eur)s,
%(raw_material_value_eur)s,
%(finished_value_eur)s,
%(material_cost_status)s,
%(stock_raw_moves_json)s,
%(stock_finished_moves_json)s,
%(raw_json)s,
now()
)
ON CONFLICT (odoo_instance, odoo_production_id)
DO UPDATE SET
tenant = EXCLUDED.tenant,
site = EXCLUDED.site,
odoo_order_ref = EXCLUDED.odoo_order_ref,
odoo_origin = EXCLUDED.odoo_origin,
odoo_state = EXCLUDED.odoo_state,
product_id = EXCLUDED.product_id,
product_default_code = EXCLUDED.product_default_code,
product_name = EXCLUDED.product_name,
product_uom = EXCLUDED.product_uom,
product_standard_price = EXCLUDED.product_standard_price,
product_list_price = EXCLUDED.product_list_price,
production_qty = EXCLUDED.production_qty,
qty_producing = EXCLUDED.qty_producing,
production_uom = EXCLUDED.production_uom,
sale_line_id = EXCLUDED.sale_line_id,
sale_order_id = EXCLUDED.sale_order_id,
sale_order_ref = EXCLUDED.sale_order_ref,
customer_id = EXCLUDED.customer_id,
customer_name = EXCLUDED.customer_name,
sale_state = EXCLUDED.sale_state,
sale_date_order = EXCLUDED.sale_date_order,
currency = EXCLUDED.currency,
sale_line_qty = EXCLUDED.sale_line_qty,
sale_line_qty_delivered = EXCLUDED.sale_line_qty_delivered,
sale_price_unit = EXCLUDED.sale_price_unit,
sale_discount_pct = EXCLUDED.sale_discount_pct,
revenue_net_eur = EXCLUDED.revenue_net_eur,
revenue_total_eur = EXCLUDED.revenue_total_eur,
raw_material_value_eur = EXCLUDED.raw_material_value_eur,
finished_value_eur = EXCLUDED.finished_value_eur,
material_cost_status = EXCLUDED.material_cost_status,
stock_raw_moves_json = EXCLUDED.stock_raw_moves_json,
stock_finished_moves_json = EXCLUDED.stock_finished_moves_json,
raw_json = EXCLUDED.raw_json,
snapshot_at = now()
"""
prepared = []
for row in rows:
r = dict(row)
r["stock_raw_moves_json"] = psycopg2.extras.Json(r["stock_raw_moves_json"])
r["stock_finished_moves_json"] = psycopg2.extras.Json(r["stock_finished_moves_json"])
r["raw_json"] = psycopg2.extras.Json(r["raw_json"])
prepared.append(r)
with conn.cursor() as cur:
psycopg2.extras.execute_batch(cur, sql, prepared, page_size=50)
return len(rows)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--production-id", type=int, action="append", help="ID Odoo mrp.production concreto")
parser.add_argument("--order-ref", action="append", help="Referencia WH/MO/xxxxx concreta")
parser.add_argument("--limit", type=int, default=50)
parser.add_argument("--states", default="confirmed,progress,to_close,done", help="Estados mrp.production separados por coma")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
require_env()
uid, models = odoo_connect()
print(f"Conectado a Odoo: url={ODOO_URL} db={ODOO_DB} user={ODOO_USER} uid={uid}", flush=True)
domain = []
if args.production_id:
domain.append(("id", "in", args.production_id))
elif args.order_ref:
domain.append(("name", "in", args.order_ref))
else:
states = [s.strip() for s in args.states.split(",") if s.strip()]
domain.append(("state", "in", states))
prod_fields = [
"id",
"name",
"state",
"origin",
"product_id",
"product_qty",
"qty_producing",
"product_uom_id",
"bom_id",
"move_raw_ids",
"move_finished_ids",
"sale_line_id",
"date_start",
"date_finished",
"company_id",
]
production_ids = odoo_call(
models,
uid,
"mrp.production",
"search",
domain,
limit=args.limit,
order="id desc",
)
print(f"Producciones encontradas: {len(production_ids)} -> {production_ids}", flush=True)
if not production_ids:
return 0
productions = odoo_call(models, uid, "mrp.production", "read", production_ids, fields=prod_fields)
product_ids = [as_id(p.get("product_id")) for p in productions if as_id(p.get("product_id"))]
sale_line_ids = [as_id(p.get("sale_line_id")) for p in productions if as_id(p.get("sale_line_id"))]
raw_move_ids = []
finished_move_ids = []
for p in productions:
raw_move_ids.extend(p.get("move_raw_ids") or [])
finished_move_ids.extend(p.get("move_finished_ids") or [])
products = read_map_by_ids(
models,
uid,
"product.product",
product_ids,
[
"id",
"display_name",
"default_code",
"standard_price",
"list_price",
"uom_id",
"categ_id",
"barcode",
"product_tmpl_id",
],
)
sale_lines = read_map_by_ids(
models,
uid,
"sale.order.line",
sale_line_ids,
[
"id",
"name",
"order_id",
"product_id",
"product_uom_qty",
"qty_delivered",
"price_unit",
"discount",
"price_subtotal",
"price_total",
],
)
sale_order_ids = [as_id(sl.get("order_id")) for sl in sale_lines.values() if as_id(sl.get("order_id"))]
sale_orders = read_map_by_ids(
models,
uid,
"sale.order",
sale_order_ids,
[
"id",
"name",
"state",
"origin",
"date_order",
"partner_id",
"currency_id",
"amount_total",
"amount_untaxed",
"client_order_ref",
"order_line",
],
)
move_fields = [
"id",
"state",
"value",
"quantity",
"price_unit",
"product_id",
"product_uom",
"production_id",
"product_uom_qty",
"raw_material_production_id",
]
raw_moves_by_id = read_map_by_ids(models, uid, "stock.move", raw_move_ids, move_fields)
finished_moves_by_id = read_map_by_ids(models, uid, "stock.move", finished_move_ids, move_fields)
rows = []
for p in productions:
product = products.get(as_id(p.get("product_id")))
sale_line = sale_lines.get(as_id(p.get("sale_line_id")))
sale_order = sale_orders.get(as_id(sale_line.get("order_id"))) if sale_line else None
raw_moves = [raw_moves_by_id[mid] for mid in (p.get("move_raw_ids") or []) if mid in raw_moves_by_id]
finished_moves = [finished_moves_by_id[mid] for mid in (p.get("move_finished_ids") or []) if mid in finished_moves_by_id]
row = build_row(p, product, sale_line, sale_order, raw_moves, finished_moves)
rows.append(row)
print(json.dumps(
[
{
"odoo_order_ref": r["odoo_order_ref"],
"odoo_production_id": r["odoo_production_id"],
"product": r["product_name"],
"sale_order_ref": r["sale_order_ref"],
"customer_name": r["customer_name"],
"revenue_net_eur": r["revenue_net_eur"],
"raw_moves": len(r["stock_raw_moves_json"]),
"finished_moves": len(r["stock_finished_moves_json"]),
"material_cost_status": r["material_cost_status"],
}
for r in rows
],
indent=2,
ensure_ascii=False,
default=str,
), flush=True)
if args.dry_run:
print("DRY RUN: no se escribe en PostgreSQL", flush=True)
return 0
with pg_connect() as conn:
count = upsert_rows(conn, rows)
conn.commit()
print(f"Filas insertadas/actualizadas en mv_edge_oee_ucepsa_prod.odoo_order_commercials: {count}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,388 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sys
import time
import xmlrpc.client
from datetime import datetime, timezone
from pathlib import Path
MACHINE_MAP = {
"CORT-00": "Cortadora 0",
"CORT-01": "Cortadora 01",
"CORT-02": "Cortadora 02",
}
VALID_LOADED_STATES = ["progress"]
DIAGNOSTIC_STATES = ["waiting", "ready", "progress"]
def read_env(path=".env"):
env = {}
p = Path(path)
if not p.exists():
return env
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def m2o_id(value):
if isinstance(value, list) and len(value) >= 1:
return value[0]
return None
def m2o_name(value):
if isinstance(value, list) and len(value) >= 2:
return value[1]
return None
def connect_odoo(env):
url = env["ODOO_URL"].rstrip("/")
db = os.getenv("ODOO_DB_OVERRIDE") or env["ODOO_DB"]
user = env["ODOO_USER"]
password = env["ODOO_PASSWORD"]
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
uid = common.authenticate(db, user, password, {})
if not uid:
raise RuntimeError("Autenticación Odoo fallida")
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
return {
"url": url,
"db": db,
"user": user,
"password": password,
"uid": uid,
"models": models,
}
def existing_fields(odoo, model, wanted):
fields = odoo["models"].execute_kw(
odoo["db"],
odoo["uid"],
odoo["password"],
model,
"fields_get",
[],
{"attributes": ["string", "type"]},
)
return [f for f in wanted if f in fields]
def search_read(odoo, model, domain, wanted_fields, limit=50, order="id desc"):
fields = existing_fields(odoo, model, wanted_fields)
return odoo["models"].execute_kw(
odoo["db"],
odoo["uid"],
odoo["password"],
model,
"search_read",
[domain],
{
"fields": fields,
"limit": limit,
"order": order,
},
)
def get_workcenter(odoo, wc_name):
rows = search_read(
odoo,
"mrp.workcenter",
[("name", "=", wc_name)],
["id", "name", "code", "active", "display_name"],
limit=5,
order="id asc",
)
if not rows:
return None
if len(rows) > 1:
# Esto no debería pasar con nombres exactos, pero lo devolvemos como incoherencia.
return {
"ambiguous": True,
"rows": rows,
}
row = rows[0]
row["ambiguous"] = False
return row
def get_workorders_for_wc(odoo, wc_id, states):
return search_read(
odoo,
"mrp.workorder",
[
("workcenter_id", "=", wc_id),
("state", "in", states),
],
[
"id",
"name",
"state",
"workcenter_id",
"production_id",
"product_id",
"date_start",
"date_finished",
"duration_expected",
"duration",
"qty_production",
],
limit=100,
order="id desc",
)
def build_order_state(machine_id, wc_name, wc, loaded_workorders, diagnostic_workorders):
ts = utc_now()
if wc is None:
return {
"machine_id": machine_id,
"ts": ts,
"source": "odoo_order_state_publisher",
"order_present": False,
"order_coherent": False,
"odoo_state": "workcenter_not_found",
"reason": f"No existe centro de trabajo exacto: {wc_name}",
"workcenter_name": wc_name,
}
if wc.get("ambiguous"):
return {
"machine_id": machine_id,
"ts": ts,
"source": "odoo_order_state_publisher",
"order_present": True,
"order_coherent": False,
"odoo_state": "ambiguous_workcenter",
"reason": f"Más de un centro de trabajo exacto para {wc_name}",
"workcenter_name": wc_name,
"workcenters": wc.get("rows", []),
}
ready_count = sum(1 for wo in diagnostic_workorders if wo.get("state") == "ready")
waiting_count = sum(1 for wo in diagnostic_workorders if wo.get("state") == "waiting")
progress_count = len(loaded_workorders)
base = {
"machine_id": machine_id,
"ts": ts,
"source": "odoo_order_state_publisher",
"workcenter_id": wc.get("id"),
"workcenter_name": wc.get("name"),
"workcenter_display_name": wc.get("display_name"),
"loaded_state_rule": VALID_LOADED_STATES,
"diagnostics": {
"waiting_count": waiting_count,
"ready_count": ready_count,
"progress_count": progress_count,
},
}
if progress_count == 0:
base.update({
"order_present": False,
"order_coherent": False,
"odoo_state": "no_loaded_order",
"reason": "No hay orden en progress para esta cortadora",
"workorder_id": None,
"production_order": None,
"product": None,
})
return base
if progress_count > 1:
base.update({
"order_present": True,
"order_coherent": False,
"odoo_state": "multiple_loaded_orders",
"reason": "Hay más de una orden en progress para esta cortadora",
"workorders": [
{
"workorder_id": wo.get("id"),
"state": wo.get("state"),
"production_order": m2o_name(wo.get("production_id")),
"product": m2o_name(wo.get("product_id")),
"date_start": wo.get("date_start"),
"qty_production": wo.get("qty_production"),
}
for wo in loaded_workorders
],
})
return base
wo = loaded_workorders[0]
base.update({
"order_present": True,
"order_coherent": True,
"odoo_state": "loaded",
"reason": "Existe exactamente una orden en progress para esta cortadora",
"workorder_id": wo.get("id"),
"workorder_name": wo.get("name"),
"workorder_state": wo.get("state"),
"production_id": m2o_id(wo.get("production_id")),
"production_order": m2o_name(wo.get("production_id")),
"product_id": m2o_id(wo.get("product_id")),
"product": m2o_name(wo.get("product_id")),
"date_start": wo.get("date_start"),
"date_finished": wo.get("date_finished"),
"qty_production": wo.get("qty_production"),
})
return base
def build_all_states(odoo):
result = {}
for machine_id, wc_name in MACHINE_MAP.items():
wc = get_workcenter(odoo, wc_name)
if wc is None or wc.get("ambiguous"):
result[machine_id] = build_order_state(machine_id, wc_name, wc, [], [])
continue
loaded_workorders = get_workorders_for_wc(odoo, wc["id"], VALID_LOADED_STATES)
diagnostic_workorders = get_workorders_for_wc(odoo, wc["id"], DIAGNOSTIC_STATES)
result[machine_id] = build_order_state(
machine_id,
wc_name,
wc,
loaded_workorders,
diagnostic_workorders,
)
return result
def publish_mqtt(env, states):
try:
import paho.mqtt.client as mqtt
except ImportError:
raise RuntimeError("Falta paho-mqtt. Instala dependencias o ejecuta dentro de contenedor.")
mqtt_host = os.getenv("MQTT_HOST_OVERRIDE") or env.get("MQTT_HOST", "127.0.0.1")
mqtt_port = int(os.getenv("MQTT_PORT_OVERRIDE") or env.get("MQTT_PORT", "1883"))
mqtt_user = (
os.getenv("MQTT_USER_OVERRIDE")
or os.getenv("MQTT_USERNAME_OVERRIDE")
or env.get("MQTT_USERNAME")
or env.get("MQTT_USER")
or env.get("MQTT_USER_REVPI")
or env.get("MOSQUITTO_USERNAME")
or env.get("MOSQUITTO_USER")
)
mqtt_password = (
os.getenv("MQTT_PASSWORD_OVERRIDE")
or os.getenv("MQTT_PASS_OVERRIDE")
or os.getenv("MQTT_PASSWORD")
or os.getenv("MQTT_PASS")
or env.get("MQTT_PASSWORD")
or env.get("MQTT_PASS")
or env.get("MQTT_PASSWORD_REVPI")
or env.get("MOSQUITTO_PASSWORD")
or env.get("MOSQUITTO_PASS")
)
tenant = env.get("TENANT", "ucepsa")
site = os.getenv("SITE_OVERRIDE") or env.get("SITE", "ucepsa_onpremise")
vertical = env.get("VERTICAL", "edge_oee")
client = mqtt.Client(client_id="odoo_order_state_publisher")
if mqtt_user:
client.username_pw_set(mqtt_user, mqtt_password)
client.connect(mqtt_host, mqtt_port, 60)
client.loop_start()
for machine_id, payload in states.items():
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/{machine_id}"
info = client.publish(
topic,
json.dumps(payload, separators=(",", ":"), ensure_ascii=False),
qos=0,
retain=True,
)
info.wait_for_publish(timeout=5)
print(f"MQTT published retained: {topic}")
client.loop_stop()
client.disconnect()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--publish", action="store_true", help="Publica en MQTT retained")
parser.add_argument("--loop", action="store_true", help="Ejecuta en bucle")
parser.add_argument("--interval", type=float, default=5.0)
parser.add_argument("--json", action="store_true", help="Salida JSON compacta")
args = parser.parse_args()
env = read_env()
odoo = connect_odoo(env)
print(f"ODOO_URL={odoo['url']}", flush=True)
print(f"ODOO_DB={odoo['db']}", flush=True)
print(f"ODOO_USER={odoo['user']}", flush=True)
print(f"publish={args.publish}", flush=True)
while True:
states = build_all_states(odoo)
if args.json:
print(json.dumps(states, indent=2, ensure_ascii=False), flush=True)
else:
print()
print("=" * 100)
print(utc_now())
print("=" * 100)
for machine_id, payload in states.items():
print(
f"{machine_id}: "
f"present={payload.get('order_present')} "
f"coherent={payload.get('order_coherent')} "
f"state={payload.get('odoo_state')} "
f"mo={payload.get('production_order')} "
f"product={payload.get('product')} "
f"diag={payload.get('diagnostics')}"
)
if args.publish:
publish_mqtt(env, states)
if not args.loop:
break
time.sleep(args.interval)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,170 @@
#!/usr/bin/env python3
import os
import sys
import xmlrpc.client
from pathlib import Path
from datetime import datetime
MACHINE_MAP = {
"CORT-00": "Cortadora 0",
"CORT-01": "Cortadora 01",
"CORT-02": "Cortadora 02",
}
WORKORDER_STATES = ["waiting", "ready", "progress", "done"]
def read_env(path=".env"):
env = {}
p = Path(path)
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def existing_fields(models, db, uid, password, model, wanted):
fields = models.execute_kw(
db, uid, password,
model, "fields_get",
[],
{"attributes": ["string", "type"]},
)
return [f for f in wanted if f in fields]
def search_read(models, db, uid, password, model, domain, fields, limit=20, order="id asc"):
fields = existing_fields(models, db, uid, password, model, fields)
return models.execute_kw(
db, uid, password,
model, "search_read",
[domain],
{"fields": fields, "limit": limit, "order": order},
)
def main():
env = read_env()
url = env["ODOO_URL"].rstrip("/")
db = os.getenv("ODOO_DB_OVERRIDE") or env["ODOO_DB"]
user = env["ODOO_USER"]
password = env["ODOO_PASSWORD"]
print("ODOO_URL =", url)
print("ODOO_DB =", db)
print("ODOO_USER=", user)
print()
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
uid = common.authenticate(db, user, password, {})
if not uid:
raise SystemExit("ERROR: autenticación Odoo fallida")
print("uid =", uid)
print()
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
wc_fields = existing_fields(
models, db, uid, password,
"mrp.workcenter",
["id", "name", "code", "active", "display_name"],
)
wo_fields = existing_fields(
models, db, uid, password,
"mrp.workorder",
[
"id",
"name",
"state",
"workcenter_id",
"production_id",
"product_id",
"date_start",
"date_finished",
"duration_expected",
"duration",
"qty_production",
],
)
print("Campos workcenter disponibles:", wc_fields)
print("Campos workorder disponibles :", wo_fields)
print()
for machine_id, wc_name in MACHINE_MAP.items():
print("=" * 80)
print(f"{machine_id} -> {wc_name}")
workcenters = search_read(
models, db, uid, password,
"mrp.workcenter",
[("name", "=", wc_name)],
["id", "name", "code", "active", "display_name"],
limit=5,
order="id asc",
)
if not workcenters:
print("ERROR: no se encuentra centro de trabajo exacto")
continue
if len(workcenters) > 1:
print("AVISO: hay más de un centro exacto")
wc = workcenters[0]
wc_id = wc["id"]
print("Workcenter:", wc)
workorders = search_read(
models, db, uid, password,
"mrp.workorder",
[
("workcenter_id", "=", wc_id),
("state", "in", WORKORDER_STATES),
],
[
"id",
"name",
"state",
"workcenter_id",
"production_id",
"product_id",
"date_start",
"date_finished",
"duration_expected",
"duration",
"qty_production",
],
limit=20,
order="id desc",
)
if not workorders:
print("Sin workorders candidatas")
continue
print(f"Workorders candidatas: {len(workorders)}")
for wo in workorders:
print(
" -",
{
"id": wo.get("id"),
"name": wo.get("name"),
"state": wo.get("state"),
"production_id": wo.get("production_id"),
"product_id": wo.get("product_id"),
"date_start": wo.get("date_start"),
"date_finished": wo.get("date_finished"),
"qty_production": wo.get("qty_production"),
},
)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,94 @@
#!/usr/bin/env python3
import json
import os
import time
from pathlib import Path
import paho.mqtt.client as mqtt
def read_env(path=".env"):
env = {}
p = Path(path)
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
env = read_env()
mqtt_host = os.getenv("MQTT_HOST_OVERRIDE") or env.get("MQTT_HOST", "127.0.0.1")
mqtt_port = int(os.getenv("MQTT_PORT_OVERRIDE") or env.get("MQTT_PORT", "1883"))
mqtt_user = (
os.getenv("MQTT_USER_OVERRIDE")
or os.getenv("MQTT_USERNAME_OVERRIDE")
or env.get("MQTT_USERNAME")
or env.get("MQTT_USER")
or env.get("MQTT_USER_REVPI")
or env.get("MOSQUITTO_USERNAME")
or env.get("MOSQUITTO_USER")
)
mqtt_password = (
os.getenv("MQTT_PASSWORD_OVERRIDE")
or os.getenv("MQTT_PASS_OVERRIDE")
or os.getenv("MQTT_PASSWORD")
or os.getenv("MQTT_PASS")
or env.get("MQTT_PASSWORD")
or env.get("MQTT_PASS")
or env.get("MQTT_PASSWORD_REVPI")
or env.get("MOSQUITTO_PASSWORD")
or env.get("MOSQUITTO_PASS")
)
tenant = env.get("TENANT", "ucepsa")
site = os.getenv("SITE_OVERRIDE") or env.get("SITE", "ucepsa_onpremise")
vertical = env.get("VERTICAL", "edge_oee")
topic = f"vertical/{tenant}/{site}/{vertical}/order_state/+"
messages = {}
def on_connect(client, userdata, flags, rc, properties=None):
print(f"MQTT conectado rc={rc}")
print(f"SUB {topic}")
client.subscribe(topic)
def on_message(client, userdata, msg):
machine_id = msg.topic.rsplit("/", 1)[-1]
try:
payload = json.loads(msg.payload.decode("utf-8"))
except Exception:
payload = msg.payload.decode("utf-8", errors="replace")
messages[machine_id] = {
"topic": msg.topic,
"retain": bool(msg.retain),
"payload": payload,
}
client = mqtt.Client(client_id="read_order_state_retained")
if mqtt_user:
client.username_pw_set(mqtt_user, mqtt_password)
client.on_connect = on_connect
client.on_message = on_message
client.connect(mqtt_host, mqtt_port, 60)
client.loop_start()
deadline = time.time() + 5
while time.time() < deadline and len(messages) < 3:
time.sleep(0.2)
client.loop_stop()
client.disconnect()
print(json.dumps(messages, indent=2, ensure_ascii=False))

View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
import os
import time
import sys
import xmlrpc.client
from pathlib import Path
from datetime import datetime
MACHINE_MAP = {
"CORT-00": "Cortadora 0",
"CORT-01": "Cortadora 01",
"CORT-02": "Cortadora 02",
}
WATCH_STATES = ["waiting", "ready", "progress"]
def read_env(path=".env"):
env = {}
p = Path(path)
for raw in p.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k.strip()] = v.strip().strip('"').strip("'")
return env
def existing_fields(models, db, uid, password, model, wanted):
fields = models.execute_kw(
db, uid, password,
model, "fields_get",
[],
{"attributes": ["string", "type"]},
)
return [f for f in wanted if f in fields]
def search_read(models, db, uid, password, model, domain, fields, limit=50, order="id desc"):
valid_fields = existing_fields(models, db, uid, password, model, fields)
return models.execute_kw(
db, uid, password,
model, "search_read",
[domain],
{
"fields": valid_fields,
"limit": limit,
"order": order,
},
)
def m2o_name(value):
if isinstance(value, list) and len(value) >= 2:
return value[1]
return None
def connect():
env = read_env()
url = env["ODOO_URL"].rstrip("/")
db = os.getenv("ODOO_DB_OVERRIDE") or env["ODOO_DB"]
user = env["ODOO_USER"]
password = env["ODOO_PASSWORD"]
common = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/common")
uid = common.authenticate(db, user, password, {})
if not uid:
raise RuntimeError("Autenticación Odoo fallida")
models = xmlrpc.client.ServerProxy(f"{url}/xmlrpc/2/object")
return url, db, user, password, uid, models
def print_state(models, db, uid, password):
print()
print("=" * 100)
print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
print("=" * 100)
for machine_id, wc_name in MACHINE_MAP.items():
workcenters = search_read(
models, db, uid, password,
"mrp.workcenter",
[("name", "=", wc_name)],
["id", "name", "active", "display_name"],
limit=5,
order="id asc",
)
if not workcenters:
print(f"{machine_id} | {wc_name} | ERROR: no existe centro de trabajo")
continue
wc = workcenters[0]
wc_id = wc["id"]
workorders = search_read(
models, db, uid, password,
"mrp.workorder",
[
("workcenter_id", "=", wc_id),
("state", "in", WATCH_STATES),
],
[
"id",
"name",
"state",
"workcenter_id",
"production_id",
"product_id",
"date_start",
"date_finished",
"qty_production",
],
limit=100,
order="id desc",
)
by_state = {s: [] for s in WATCH_STATES}
for wo in workorders:
by_state.setdefault(wo.get("state"), []).append(wo)
progress = by_state.get("progress", [])
ready = by_state.get("ready", [])
waiting = by_state.get("waiting", [])
if len(progress) == 1:
beacon_state = "AZUL_FIJO_CANDIDATO"
elif len(progress) > 1:
beacon_state = "INCOHERENTE_MULTIPLES_PROGRESS"
else:
beacon_state = "SIN_ORDEN_CARGADA"
print(
f"{machine_id} | {wc_name} | "
f"waiting={len(waiting)} ready={len(ready)} progress={len(progress)} | "
f"{beacon_state}"
)
for wo in progress:
print(
f" ACTIVE progress: "
f"wo_id={wo.get('id')} "
f"mo={m2o_name(wo.get('production_id'))} "
f"product={m2o_name(wo.get('product_id'))} "
f"date_start={wo.get('date_start')}"
)
if not progress and ready:
first = ready[0]
print(
f" primera ready: "
f"wo_id={first.get('id')} "
f"mo={m2o_name(first.get('production_id'))} "
f"product={m2o_name(first.get('product_id'))}"
)
def main():
watch = False
interval = 2.0
if "--watch" in sys.argv:
watch = True
idx = sys.argv.index("--watch")
if len(sys.argv) > idx + 1:
interval = float(sys.argv[idx + 1])
url, db, user, password, uid, models = connect()
print(f"ODOO_URL={url}")
print(f"ODOO_DB={db}")
print(f"ODOO_USER={user}")
print(f"uid={uid}")
if watch:
while True:
print_state(models, db, uid, password)
time.sleep(interval)
else:
print_state(models, db, uid, password)
if __name__ == "__main__":
main()