#!/usr/bin/env python3 import json import os import struct import sys import time from datetime import datetime, timezone from pathlib import Path import paho.mqtt.client as mqtt from pymodbus.client import ModbusSerialClient BASE_DIR = Path(__file__).resolve().parent ENV_FILE = BASE_DIR / ".env" START_TIME = time.monotonic() 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) 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() def build_payload(): tenant = getenv("TENANT", "ucepsa") site = getenv("SITE", "demo_edge_oee") vertical = getenv("VERTICAL", "edge_oee") asset = getenv("ASSET", "revpi_oee_node_01") base = { "asset": asset, "tenant": tenant, "site": site, "vertical": vertical, "node_type": "revpi_sdm120m_oee_gateway", "fw": "0.1.0", "ts": datetime.now(timezone.utc).isoformat(), "uptime_s": round(time.monotonic() - START_TIME, 1), "machine_id": None, "machine_running": None, "cycle_pulse_count": None, "cycle_rate_hz": None, "digital_inputs": {}, } try: data = read_sdm120m() base.update(data) except Exception as exc: base.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 base def publish_once(payload): 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_telemetry = getenv("MQTT_TOPIC_TELEMETRY", 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) result = client.publish( topic_telemetry, payload=json.dumps(payload, ensure_ascii=False), qos=0, retain=False, ) result.wait_for_publish(timeout=5) client.loop_stop() client.disconnect() if result.rc != mqtt.MQTT_ERR_SUCCESS: raise RuntimeError(f"MQTT publish failed rc={result.rc}") def main(): load_env_file(ENV_FILE) payload = build_payload() print(json.dumps(payload, indent=2, ensure_ascii=False)) publish_once(payload) print("[OK] MQTT telemetry published") return 0 if payload.get("comm_ok") else 1 if __name__ == "__main__": sys.exit(main())