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