40-clients/ucepsa/edge-oee-demo/revpi/edge-oee/eastron_reader_mqtt_loop.py
2026-06-25 18:51:33 +02:00

420 lines
13 KiB
Python
Executable File

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