420 lines
13 KiB
Python
Executable File
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())
|