#!/usr/bin/env python3 import json import os import signal import struct import sys import time from collections import deque from datetime import datetime, timezone from pathlib import Path import paho.mqtt.client as mqtt import revpimodio2 from pymodbus.client import ModbusSerialClient BASE_DIR = Path(__file__).resolve().parent ENV_FILE = BASE_DIR / ".env" START_TIME = time.monotonic() RUNNING = True def handle_signal(signum, frame): global RUNNING RUNNING = False signal.signal(signal.SIGTERM, handle_signal) signal.signal(signal.SIGINT, handle_signal) def utc_now_iso(): return datetime.now(timezone.utc).isoformat() def load_env_file(path: Path): if not path.exists(): return for line in path.read_text().splitlines(): line = line.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 getenv(name, default=None, required=False): value = os.getenv(name, default) if required and not value: print(f"[ERROR] Missing required environment variable: {name}", file=sys.stderr, flush=True) sys.exit(2) return value def decode_float_abcd(registers): raw = registers[0].to_bytes(2, "big") + registers[1].to_bytes(2, "big") return struct.unpack(">f", raw)[0] def read_float(client, address, device_id): rr = client.read_input_registers( address=address, count=2, device_id=device_id, ) if rr.isError(): raise RuntimeError(f"Modbus error at address {address}: {rr}") return decode_float_abcd(rr.registers) def read_sdm120m(): port = getenv("MODBUS_PORT", "/dev/ttyRS485") slave = int(getenv("MODBUS_SLAVE", "1")) baud = int(getenv("MODBUS_BAUD", "9600")) parity = getenv("MODBUS_PARITY", "N") stopbits = int(getenv("MODBUS_STOPBITS", "1")) bytesize = int(getenv("MODBUS_BYTESIZE", "8")) timeout = float(getenv("MODBUS_TIMEOUT", "1.5")) client = ModbusSerialClient( port=port, baudrate=baud, parity=parity, stopbits=stopbits, bytesize=bytesize, timeout=timeout, ) if not client.connect(): raise RuntimeError(f"Cannot open Modbus serial port {port}") try: voltage_v = read_float(client, 0, slave) current_a = read_float(client, 6, slave) active_power_w = read_float(client, 12, slave) frequency_hz = read_float(client, 70, slave) import_kwh = read_float(client, 72, slave) export_kwh = read_float(client, 74, slave) return { "meter_model": "Eastron SDM120-M", "meter_id": f"modbus_{slave}", "modbus_port": port, "modbus_slave": slave, "modbus_baud": baud, "voltage_l1_v": round(voltage_v, 3), "current_l1_a": round(current_a, 3), "power_total_kw": round(active_power_w / 1000.0, 6), "frequency_hz": round(frequency_hz, 3), "import_kwh": round(import_kwh, 3), "export_kwh": round(export_kwh, 3), "comm_ok": True, "sample_valid": True, "error_text": None, } finally: client.close() class DigitalInputTracker: def __init__(self): self.auto_input_name = getenv("DI_AUTO_INPUT", "I_1") self.cycle_input_name = getenv("DI_CYCLE_INPUT", "I_2") self.debounce_s = float(getenv("DI_DEBOUNCE_MS", "50")) / 1000.0 self.window_s = float(getenv("CYCLE_RATE_WINDOW_SECONDS", "60")) self.rpi = revpimodio2.RevPiModIO(autorefresh=True) self.auto_io = getattr(self.rpi.io, self.auto_input_name) self.cycle_io = getattr(self.rpi.io, self.cycle_input_name) now = time.monotonic() # Estado estable de I_1 con antirrebote. self.stable_auto = bool(self.auto_io.value) self.raw_auto = self.stable_auto self.raw_auto_since = now # Pulsos de ciclo I_2. self.cycle_pulse_count = 0 self.previous_cycle = bool(self.cycle_io.value) self.last_edge_ts = 0.0 self.last_cycle_ts = None self.recent_pulses = deque() self.pending_events = [] def update(self): now = time.monotonic() # --- I_1: estado automático con antirrebote y evento inmediato --- raw_auto = bool(self.auto_io.value) if raw_auto != self.raw_auto: self.raw_auto = raw_auto self.raw_auto_since = now elif raw_auto != self.stable_auto and (now - self.raw_auto_since) >= self.debounce_s: previous = self.stable_auto self.stable_auto = raw_auto event_name = "machine_started" if self.stable_auto else "machine_stopped" self.pending_events.append({ "event_type": "machine_state_changed", "event_name": event_name, "previous_auto_signal": previous, "new_auto_signal": self.stable_auto, "machine_running": self.stable_auto, "reason_pending": not self.stable_auto, "event_monotonic_ts": now, }) # --- I_2: flanco ascendente de ciclo con antirrebote --- current_cycle = bool(self.cycle_io.value) if current_cycle and not self.previous_cycle: if now - self.last_edge_ts >= self.debounce_s: self.cycle_pulse_count += 1 self.last_edge_ts = now self.last_cycle_ts = now self.recent_pulses.append(now) self.previous_cycle = current_cycle while self.recent_pulses and now - self.recent_pulses[0] > self.window_s: self.recent_pulses.popleft() def snapshot(self): now = time.monotonic() while self.recent_pulses and now - self.recent_pulses[0] > self.window_s: self.recent_pulses.popleft() cycle_rate_ppm = len(self.recent_pulses) * (60.0 / self.window_s) if self.last_cycle_ts is None: last_cycle_age_s = None else: last_cycle_age_s = round(now - self.last_cycle_ts, 1) current_cycle = bool(self.cycle_io.value) return { "io_ok": True, "machine_auto_signal": self.stable_auto, "machine_running": self.stable_auto, "cycle_input_state": current_cycle, "cycle_pulse_count": self.cycle_pulse_count, "cycle_rate_ppm": round(cycle_rate_ppm, 2), "last_cycle_age_s": last_cycle_age_s, "digital_inputs": { self.auto_input_name: self.stable_auto, self.cycle_input_name: current_cycle, }, } def pop_events(self): events = list(self.pending_events) self.pending_events.clear() return events def close(self): try: self.rpi.exit() except Exception: pass def base_payload(): return { "asset": getenv("ASSET", "revpi_oee_node_01"), "tenant": getenv("TENANT", "ucepsa"), "site": getenv("SITE", "demo_edge_oee"), "vertical": getenv("VERTICAL", "edge_oee"), "node_type": getenv("NODE_TYPE", "revpi_sdm120m_oee_gateway"), "fw": getenv("FW", "0.1.0"), "source": "revpi_edge_oee_node", "ts": utc_now_iso(), "uptime_s": round(time.monotonic() - START_TIME, 1), "machine_id": getenv("MACHINE_ID", "CORT-01"), "order_id": None, } def build_telemetry_payload(di_tracker): payload = base_payload() payload.update(di_tracker.snapshot()) try: payload.update(read_sdm120m()) except Exception as exc: payload.update({ "comm_ok": False, "sample_valid": False, "meter_model": "Eastron SDM120-M", "meter_id": f"modbus_{getenv('MODBUS_SLAVE', '1')}", "voltage_l1_v": None, "current_l1_a": None, "power_total_kw": None, "frequency_hz": None, "import_kwh": None, "export_kwh": None, "error_text": f"{type(exc).__name__}: {exc}", }) return payload def build_event_payload(raw_event, di_tracker): payload = base_payload() payload.update(di_tracker.snapshot()) payload.update({ "event_type": raw_event["event_type"], "event_name": raw_event["event_name"], "previous_auto_signal": raw_event["previous_auto_signal"], "new_auto_signal": raw_event["new_auto_signal"], "machine_running": raw_event["machine_running"], "reason_pending": raw_event["reason_pending"], "event_ts": utc_now_iso(), "event_source": "digital_input", "event_input": getenv("DI_AUTO_INPUT", "I_1"), }) return payload def create_mqtt_client(): mqtt_host = getenv("MQTT_HOST", required=True) mqtt_port = int(getenv("MQTT_PORT", "1883")) mqtt_user = getenv("MQTT_USER", required=True) mqtt_password = getenv("MQTT_PASSWORD", required=True) mqtt_client_id = getenv("MQTT_CLIENT_ID", required=True) topic_status = getenv("MQTT_TOPIC_STATUS", required=True) client = mqtt.Client( mqtt.CallbackAPIVersion.VERSION2, client_id=mqtt_client_id, clean_session=True, ) client.username_pw_set(mqtt_user, mqtt_password) client.will_set(topic_status, payload="offline", qos=0, retain=True) client.connect(mqtt_host, mqtt_port, keepalive=30) client.loop_start() client.publish(topic_status, payload="online", qos=0, retain=True) return client def publish_json(client, topic, payload, retain=False): result = client.publish( topic, payload=json.dumps(payload, ensure_ascii=False), qos=0, retain=retain, ) result.wait_for_publish(timeout=5) if result.rc != mqtt.MQTT_ERR_SUCCESS: raise RuntimeError(f"MQTT publish failed rc={result.rc}") def publish_loop(): topic_telemetry = getenv("MQTT_TOPIC_TELEMETRY", required=True) topic_event = getenv("MQTT_TOPIC_EVENT", required=True) topic_status = getenv("MQTT_TOPIC_STATUS", required=True) interval_s = float(getenv("PUBLISH_INTERVAL_SECONDS", "10")) di_tracker = DigitalInputTracker() client = create_mqtt_client() print("[START] RevPi Edge-OEE MQTT loop", flush=True) print(f"[CONFIG] topic_telemetry={topic_telemetry}", flush=True) print(f"[CONFIG] topic_event={topic_event}", flush=True) print(f"[CONFIG] interval_s={interval_s}", flush=True) print(f"[CONFIG] auto_input={di_tracker.auto_input_name}", flush=True) print(f"[CONFIG] cycle_input={di_tracker.cycle_input_name}", flush=True) next_publish = time.monotonic() try: while RUNNING: di_tracker.update() # Eventos inmediatos por cambio de I_1. for raw_event in di_tracker.pop_events(): event_payload = build_event_payload(raw_event, di_tracker) publish_json(client, topic_event, event_payload, retain=False) print( "[EVENT] " f"{event_payload.get('event_name')} " f"previous={event_payload.get('previous_auto_signal')} " f"new={event_payload.get('new_auto_signal')} " f"pulses={event_payload.get('cycle_pulse_count')} " f"rate_ppm={event_payload.get('cycle_rate_ppm')}", flush=True, ) # Telemetría periódica. now = time.monotonic() if now >= next_publish: telemetry_payload = build_telemetry_payload(di_tracker) publish_json(client, topic_telemetry, telemetry_payload, retain=False) print( "[OK] telemetry " f"auto={telemetry_payload.get('machine_auto_signal')} " f"running={telemetry_payload.get('machine_running')} " f"pulses={telemetry_payload.get('cycle_pulse_count')} " f"rate_ppm={telemetry_payload.get('cycle_rate_ppm')} " f"voltage={telemetry_payload.get('voltage_l1_v')} " f"power_kw={telemetry_payload.get('power_total_kw')}", flush=True, ) next_publish = now + interval_s time.sleep(0.02) finally: try: client.publish(topic_status, payload="offline", qos=0, retain=True) time.sleep(0.2) except Exception: pass try: client.loop_stop() client.disconnect() except Exception: pass di_tracker.close() print("[STOP] RevPi Edge-OEE MQTT loop", flush=True) def main(): load_env_file(ENV_FILE) publish_loop() return 0 if __name__ == "__main__": sys.exit(main())