2026-06-25 18:51:33 +02:00

475 lines
14 KiB
Python
Executable File

#!/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()