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