389 lines
11 KiB
Python
Executable File
389 lines
11 KiB
Python
Executable File
#!/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()
|