#!/usr/bin/env python3 import json import os import signal import struct import time import urllib.request from collections import deque from datetime import datetime, timezone from pathlib import Path from urllib.error import HTTPError, URLError import paho.mqtt.client as mqtt from pymodbus.client import ModbusTcpClient BASE_DIR = Path(__file__).resolve().parent ENV_PATH = BASE_DIR / ".env" NODES_PATH = BASE_DIR / "wise_nodes.json" def load_env(path: Path): if not path.exists(): return for raw in path.read_text().splitlines(): line = raw.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) def env(name, default=None, required=False, cast=str): value = os.environ.get(name, default) if required and (value is None or str(value).strip() == ""): raise RuntimeError(f"Falta variable obligatoria: {name}") if value is None: return None return cast(value) def utc_now(): return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") def dumps(data): return json.dumps(data, ensure_ascii=False, separators=(",", ":")) def f32_abcd(reg_hi, reg_lo): raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big") return struct.unpack(">f", raw)[0] def counter_delta(previous, current): if previous is None: return 0, False if current >= previous: return current - previous, False return 0, True def load_nodes(): data = json.loads(NODES_PATH.read_text()) nodes = [n for n in data.get("nodes", []) if n.get("enabled", True)] if not nodes: raise RuntimeError("No hay nodos WISE habilitados en wise_nodes.json") return nodes def make_mqtt_client(): host = env("MQTT_HOST", required=True) port = env("MQTT_PORT", 1883, cast=int) user = env("MQTT_USER", "") password = env("MQTT_PASSWORD", "") client_id = env("MQTT_CLIENT_ID", required=True) try: client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id) except AttributeError: client = mqtt.Client(client_id=client_id) if user: client.username_pw_set(user, password) client.connect(host, port, keepalive=60) client.loop_start() return client def publish(client, topic, payload): client.publish(topic, dumps(payload), qos=0, retain=False) def read_wise_di_api(node): wise_ip = node["wise_ip"] session_env = node["session_env"] session_id = os.environ.get(session_env, "").strip() if not session_id: raise RuntimeError(f"Falta {session_env}. Exporta la cookie adamsessionid del WISE.") req = urllib.request.Request( f"http://{wise_ip}/di_value/slot_0", headers={ "Accept": "text/plain, */*; q=0.01", "X-Requested-With": "XMLHttpRequest", "Referer": f"http://{wise_ip}/config/index.html", "User-Agent": "Mozilla/5.0", "Cookie": f"adamsessionid={session_id}", }, ) try: with urllib.request.urlopen(req, timeout=2) as response: text = response.read().decode("utf-8", errors="replace") except HTTPError as e: body = e.read().decode("utf-8", errors="replace") raise RuntimeError(f"WISE HTTP {e.code}: {e.reason}. Body={body[:200]!r}") except URLError as e: raise RuntimeError(f"WISE HTTP unreachable: {e}") data = json.loads(text) if isinstance(data, dict) and data.get("Err"): raise RuntimeError(f"WISE API Err={data.get('Err')} Msg={data.get('Msg')!r}") channels = {int(item["Ch"]): item for item in data.get("DIVal", [])} ch0 = channels.get(0, {}) ch1 = channels.get(1, {}) return { "di0_stat": bool(ch0.get("Stat", 0)), "di1_stat": bool(ch1.get("Stat", 0)), "di1_counter_raw": int(ch1.get("CtFq", 0)), "di1_counting": bool(ch1.get("Cnting", 0)), "di1_mode": int(ch1.get("Md", -1)), } def u32_lo_hi(lo, hi): return (int(lo) & 0xFFFF) + ((int(hi) & 0xFFFF) << 16) def read_wise_inputs_modbus(state, node): client = ensure_modbus_client(state, node) unit = int(node.get("unit", 1)) rr_di = client.read_discrete_inputs( address=0, count=2, device_id=unit, ) if rr_di.isError(): raise RuntimeError(f"Error leyendo DI status: {rr_di}") rr_counter = client.read_holding_registers( address=0, count=4, device_id=unit, ) if rr_counter.isError(): raise RuntimeError(f"Error leyendo contadores DI: {rr_counter}") regs = rr_counter.registers di0_counter = u32_lo_hi(regs[0], regs[1]) di1_counter = u32_lo_hi(regs[2], regs[3]) return { "di0_stat": bool(rr_di.bits[0]), "di1_stat": bool(rr_di.bits[1]), "di0_counter_raw": di0_counter, "di1_counter_raw": di1_counter, "di1_counting": True, "di1_mode": 1, } def ensure_modbus_client(state, node): client = state.get("modbus_client") if client is None: client = ModbusTcpClient( node["wise_ip"], port=int(node.get("wise_port", 502)), timeout=3, ) state["modbus_client"] = client if not client.connected: if not client.connect(): raise RuntimeError("No conecta con WISE por Modbus/TCP") return client def read_holdings(state, node): client = ensure_modbus_client(state, node) rr = client.read_holding_registers( address=int(node.get("holding_base", 1000)), count=int(node.get("holding_count", 20)), device_id=int(node.get("unit", 1)), ) if rr.isError(): raise RuntimeError(f"Error leyendo holding registers: {rr}") return rr.registers def decode_energy(regs): if len(regs) < 34: raise ValueError(f"Se esperaban 34 registros SDM630, recibidos {len(regs)}") def v(offset): return f32_abcd(regs[offset], regs[offset + 1]) voltage_l1_v = v(0) voltage_l2_v = v(2) voltage_l3_v = v(4) current_l1_a = v(6) current_l2_a = v(8) current_l3_a = v(10) active_power_l1_w = v(12) active_power_l2_w = v(14) active_power_l3_w = v(16) active_power_w = v(18) apparent_power_va = v(20) reactive_power_var = v(22) power_factor = v(24) frequency_hz = v(26) import_kwh = v(28) export_kwh = v(30) total_active_energy_kwh = v(32) return { "energy_meter_model": "eastron_sdm630", "energy_map": "sdm630_3phase_v1", "voltage_l1_v": round(voltage_l1_v, 3), "voltage_l2_v": round(voltage_l2_v, 3), "voltage_l3_v": round(voltage_l3_v, 3), "current_l1_a": round(current_l1_a, 6), "current_l2_a": round(current_l2_a, 6), "current_l3_a": round(current_l3_a, 6), "active_power_l1_w": round(active_power_l1_w, 3), "active_power_l2_w": round(active_power_l2_w, 3), "active_power_l3_w": round(active_power_l3_w, 3), "active_power_w": round(active_power_w, 3), "power_total_kw": round(active_power_w / 1000.0, 6), "apparent_power_va": round(apparent_power_va, 3), "reactive_power_var": round(reactive_power_var, 3), "power_factor": round(power_factor, 6), "frequency_hz": round(frequency_hz, 3), "import_kwh": round(import_kwh, 6), "export_kwh": round(export_kwh, 6), "total_active_energy_kwh": round(total_active_energy_kwh, 6), } def base_payload(node): return { "tenant": env("TENANT", "ucepsa"), "site": env("SITE", "ucepsa_test"), "vertical": env("VERTICAL", "edge_oee"), "asset": env("ASSET", "revpi_oee_node_01"), "machine_id": node["machine_id"], "source_node": node["source_node"], "wise_host": node["wise_ip"], "node_type": "revpi_wise_oee_gateway", "fw": "0.3.0", } def init_state(): return { "previous_counter": None, "cycle_pulse_count": 0, "cycle_events": deque(), "last_auto_signal": None, "last_energy": {}, "last_energy_ts": 0.0, "last_publish_ts": 0.0, "last_delta": 0, "counter_reset_detected": False, "modbus_client": None, } def main(): load_env(ENV_PATH) nodes = load_nodes() states = {node["source_node"]: init_state() for node in nodes} telemetry_topic = env("MQTT_TOPIC_TELEMETRY", required=True) status_topic = env("MQTT_TOPIC_STATUS", required=True) event_topic = env("MQTT_TOPIC_EVENT", required=True) publish_interval_s = env("PUBLISH_INTERVAL_SECONDS", 10, cast=float) di_poll_interval_s = env("WISE_DI_POLL_INTERVAL_SECONDS", 0.5, cast=float) energy_interval_s = env("WISE_ENERGY_INTERVAL_SECONDS", 2.0, cast=float) rate_window_s = env("CYCLE_RATE_WINDOW_SECONDS", 60, cast=float) mqtt_client = make_mqtt_client() stop = {"value": False} def on_stop(signum, frame): stop["value"] = True signal.signal(signal.SIGINT, on_stop) signal.signal(signal.SIGTERM, on_stop) print(f"Nodos WISE habilitados: {len(nodes)}") for node in nodes: print(f"- {node['machine_id']} {node['source_node']} {node['wise_ip']}") publish(mqtt_client, status_topic, { "ts": utc_now(), "status": "started", "asset": env("ASSET", "revpi_oee_node_01"), "enabled_nodes": [ { "machine_id": n["machine_id"], "source_node": n["source_node"], "wise_ip": n["wise_ip"], } for n in nodes ], }) while not stop["value"]: loop_start = time.time() for node in nodes: state = states[node["source_node"]] now = time.time() try: di = read_wise_inputs_modbus(state, node) auto_signal = di["di0_stat"] cycle_input_state = di["di1_stat"] wise_counter = di["di1_counter_raw"] counter_valid = di["di1_mode"] == 1 and di["di1_counting"] delta, reset = counter_delta(state["previous_counter"], wise_counter) state["counter_reset_detected"] = reset if delta > 0: state["cycle_pulse_count"] += delta for _ in range(min(delta, 1000)): state["cycle_events"].append(now) event = { "ts": utc_now(), **base_payload(node), "event_type": "cycle_count_changed", "delta": delta, "wise_cycle_counter_raw": wise_counter, "cycle_pulse_count": state["cycle_pulse_count"], } publish(mqtt_client, event_topic, event) print("CYCLES", dumps(event)) state["previous_counter"] = wise_counter state["last_delta"] = delta while state["cycle_events"] and (now - state["cycle_events"][0]) > rate_window_s: state["cycle_events"].popleft() cycle_rate_ppm = len(state["cycle_events"]) * 60.0 / rate_window_s last_cycle_age_s = None if state["cycle_events"]: last_cycle_age_s = round(now - state["cycle_events"][-1], 3) if state["last_auto_signal"] is not None and auto_signal != state["last_auto_signal"]: event_name = "machine_started" if auto_signal else "machine_stopped" event = { "ts": utc_now(), **base_payload(node), "event_type": "machine_state_changed", "event_name": event_name, "previous_auto_signal": state["last_auto_signal"], "new_auto_signal": auto_signal, "machine_running": auto_signal, "reason_pending": not auto_signal, } publish(mqtt_client, event_topic, event) print("EVENT", dumps(event)) state["last_auto_signal"] = auto_signal if now - state["last_energy_ts"] >= energy_interval_s: regs = read_holdings(state, node) state["last_energy"] = decode_energy(regs) state["last_energy_ts"] = now if now - state["last_publish_ts"] >= publish_interval_s: payload = { "ts": utc_now(), **base_payload(node), "machine_auto_signal": auto_signal, "machine_running": auto_signal, "cycle_input_state": cycle_input_state, "wise_cycle_counter_raw": wise_counter, "cycle_delta_last": state["last_delta"], "cycle_pulse_count": state["cycle_pulse_count"], "cycle_rate_ppm": round(cycle_rate_ppm, 3), "last_cycle_age_s": last_cycle_age_s, "counter_valid": counter_valid, "counter_reset_detected": state["counter_reset_detected"], "digital_inputs": { "DI0": auto_signal, "DI1": cycle_input_state, }, **state["last_energy"], "comm_ok": True, "sample_valid": True, } publish(mqtt_client, telemetry_topic, payload) print("TELEMETRY", dumps(payload)) state["last_publish_ts"] = now except Exception as exc: error_payload = { "ts": utc_now(), **base_payload(node), "comm_ok": False, "sample_valid": False, "error": str(exc), } publish(mqtt_client, status_topic, error_payload) print("ERROR", dumps(error_payload)) client = state.get("modbus_client") if client is not None: try: client.close() except Exception: pass state["modbus_client"] = None elapsed = time.time() - loop_start time.sleep(max(0.05, di_poll_interval_s - elapsed)) publish(mqtt_client, status_topic, { "ts": utc_now(), "status": "stopped", "asset": env("ASSET", "revpi_oee_node_01"), }) for state in states.values(): client = state.get("modbus_client") if client is not None: try: client.close() except Exception: pass mqtt_client.loop_stop() mqtt_client.disconnect() print("Parado correctamente") if __name__ == "__main__": main()