475 lines
14 KiB
Python
Executable File
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()
|