206 lines
5.6 KiB
Python
Executable File
206 lines
5.6 KiB
Python
Executable File
#!/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())
|