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

492 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
import json
import os
import signal
import struct
import time
import urllib.request
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from urllib.error import HTTPError, URLError
import paho.mqtt.client as mqtt
from pymodbus.client import ModbusTcpClient
BASE_DIR = Path(__file__).resolve().parent
ENV_PATH = BASE_DIR / ".env"
NODES_PATH = BASE_DIR / "wise_nodes.json"
def load_env(path: Path):
if not path.exists():
return
for raw in path.read_text().splitlines():
line = raw.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 env(name, default=None, required=False, cast=str):
value = os.environ.get(name, default)
if required and (value is None or str(value).strip() == ""):
raise RuntimeError(f"Falta variable obligatoria: {name}")
if value is None:
return None
return cast(value)
def utc_now():
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def dumps(data):
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def f32_abcd(reg_hi, reg_lo):
raw = int(reg_hi).to_bytes(2, "big") + int(reg_lo).to_bytes(2, "big")
return struct.unpack(">f", raw)[0]
def counter_delta(previous, current):
if previous is None:
return 0, False
if current >= previous:
return current - previous, False
return 0, True
def load_nodes():
data = json.loads(NODES_PATH.read_text())
nodes = [n for n in data.get("nodes", []) if n.get("enabled", True)]
if not nodes:
raise RuntimeError("No hay nodos WISE habilitados en wise_nodes.json")
return nodes
def make_mqtt_client():
host = env("MQTT_HOST", required=True)
port = env("MQTT_PORT", 1883, cast=int)
user = env("MQTT_USER", "")
password = env("MQTT_PASSWORD", "")
client_id = env("MQTT_CLIENT_ID", required=True)
try:
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id)
except AttributeError:
client = mqtt.Client(client_id=client_id)
if user:
client.username_pw_set(user, password)
client.connect(host, port, keepalive=60)
client.loop_start()
return client
def publish(client, topic, payload):
client.publish(topic, dumps(payload), qos=0, retain=False)
def read_wise_di_api(node):
wise_ip = node["wise_ip"]
session_env = node["session_env"]
session_id = os.environ.get(session_env, "").strip()
if not session_id:
raise RuntimeError(f"Falta {session_env}. Exporta la cookie adamsessionid del WISE.")
req = urllib.request.Request(
f"http://{wise_ip}/di_value/slot_0",
headers={
"Accept": "text/plain, */*; q=0.01",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"http://{wise_ip}/config/index.html",
"User-Agent": "Mozilla/5.0",
"Cookie": f"adamsessionid={session_id}",
},
)
try:
with urllib.request.urlopen(req, timeout=2) as response:
text = response.read().decode("utf-8", errors="replace")
except HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError(f"WISE HTTP {e.code}: {e.reason}. Body={body[:200]!r}")
except URLError as e:
raise RuntimeError(f"WISE HTTP unreachable: {e}")
data = json.loads(text)
if isinstance(data, dict) and data.get("Err"):
raise RuntimeError(f"WISE API Err={data.get('Err')} Msg={data.get('Msg')!r}")
channels = {int(item["Ch"]): item for item in data.get("DIVal", [])}
ch0 = channels.get(0, {})
ch1 = channels.get(1, {})
return {
"di0_stat": bool(ch0.get("Stat", 0)),
"di1_stat": bool(ch1.get("Stat", 0)),
"di1_counter_raw": int(ch1.get("CtFq", 0)),
"di1_counting": bool(ch1.get("Cnting", 0)),
"di1_mode": int(ch1.get("Md", -1)),
}
def u32_lo_hi(lo, hi):
return (int(lo) & 0xFFFF) + ((int(hi) & 0xFFFF) << 16)
def read_wise_inputs_modbus(state, node):
client = ensure_modbus_client(state, node)
unit = int(node.get("unit", 1))
rr_di = client.read_discrete_inputs(
address=0,
count=2,
device_id=unit,
)
if rr_di.isError():
raise RuntimeError(f"Error leyendo DI status: {rr_di}")
rr_counter = client.read_holding_registers(
address=0,
count=4,
device_id=unit,
)
if rr_counter.isError():
raise RuntimeError(f"Error leyendo contadores DI: {rr_counter}")
regs = rr_counter.registers
di0_counter = u32_lo_hi(regs[0], regs[1])
di1_counter = u32_lo_hi(regs[2], regs[3])
return {
"di0_stat": bool(rr_di.bits[0]),
"di1_stat": bool(rr_di.bits[1]),
"di0_counter_raw": di0_counter,
"di1_counter_raw": di1_counter,
"di1_counting": True,
"di1_mode": 1,
}
def ensure_modbus_client(state, node):
client = state.get("modbus_client")
if client is None:
client = ModbusTcpClient(
node["wise_ip"],
port=int(node.get("wise_port", 502)),
timeout=3,
)
state["modbus_client"] = client
if not client.connected:
if not client.connect():
raise RuntimeError("No conecta con WISE por Modbus/TCP")
return client
def read_holdings(state, node):
client = ensure_modbus_client(state, node)
rr = client.read_holding_registers(
address=int(node.get("holding_base", 1000)),
count=int(node.get("holding_count", 20)),
device_id=int(node.get("unit", 1)),
)
if rr.isError():
raise RuntimeError(f"Error leyendo holding registers: {rr}")
return rr.registers
def decode_energy(regs):
if len(regs) < 34:
raise ValueError(f"Se esperaban 34 registros SDM630, recibidos {len(regs)}")
def v(offset):
return f32_abcd(regs[offset], regs[offset + 1])
voltage_l1_v = v(0)
voltage_l2_v = v(2)
voltage_l3_v = v(4)
current_l1_a = v(6)
current_l2_a = v(8)
current_l3_a = v(10)
active_power_l1_w = v(12)
active_power_l2_w = v(14)
active_power_l3_w = v(16)
active_power_w = v(18)
apparent_power_va = v(20)
reactive_power_var = v(22)
power_factor = v(24)
frequency_hz = v(26)
import_kwh = v(28)
export_kwh = v(30)
total_active_energy_kwh = v(32)
return {
"energy_meter_model": "eastron_sdm630",
"energy_map": "sdm630_3phase_v1",
"voltage_l1_v": round(voltage_l1_v, 3),
"voltage_l2_v": round(voltage_l2_v, 3),
"voltage_l3_v": round(voltage_l3_v, 3),
"current_l1_a": round(current_l1_a, 6),
"current_l2_a": round(current_l2_a, 6),
"current_l3_a": round(current_l3_a, 6),
"active_power_l1_w": round(active_power_l1_w, 3),
"active_power_l2_w": round(active_power_l2_w, 3),
"active_power_l3_w": round(active_power_l3_w, 3),
"active_power_w": round(active_power_w, 3),
"power_total_kw": round(active_power_w / 1000.0, 6),
"apparent_power_va": round(apparent_power_va, 3),
"reactive_power_var": round(reactive_power_var, 3),
"power_factor": round(power_factor, 6),
"frequency_hz": round(frequency_hz, 3),
"import_kwh": round(import_kwh, 6),
"export_kwh": round(export_kwh, 6),
"total_active_energy_kwh": round(total_active_energy_kwh, 6),
}
def base_payload(node):
return {
"tenant": env("TENANT", "ucepsa"),
"site": env("SITE", "ucepsa_test"),
"vertical": env("VERTICAL", "edge_oee"),
"asset": env("ASSET", "revpi_oee_node_01"),
"machine_id": node["machine_id"],
"source_node": node["source_node"],
"wise_host": node["wise_ip"],
"node_type": "revpi_wise_oee_gateway",
"fw": "0.3.0",
}
def init_state():
return {
"previous_counter": None,
"cycle_pulse_count": 0,
"cycle_events": deque(),
"last_auto_signal": None,
"last_energy": {},
"last_energy_ts": 0.0,
"last_publish_ts": 0.0,
"last_delta": 0,
"counter_reset_detected": False,
"modbus_client": None,
}
def main():
load_env(ENV_PATH)
nodes = load_nodes()
states = {node["source_node"]: init_state() for node in nodes}
telemetry_topic = env("MQTT_TOPIC_TELEMETRY", required=True)
status_topic = env("MQTT_TOPIC_STATUS", required=True)
event_topic = env("MQTT_TOPIC_EVENT", required=True)
publish_interval_s = env("PUBLISH_INTERVAL_SECONDS", 10, cast=float)
di_poll_interval_s = env("WISE_DI_POLL_INTERVAL_SECONDS", 0.5, cast=float)
energy_interval_s = env("WISE_ENERGY_INTERVAL_SECONDS", 2.0, cast=float)
rate_window_s = env("CYCLE_RATE_WINDOW_SECONDS", 60, cast=float)
mqtt_client = make_mqtt_client()
stop = {"value": False}
def on_stop(signum, frame):
stop["value"] = True
signal.signal(signal.SIGINT, on_stop)
signal.signal(signal.SIGTERM, on_stop)
print(f"Nodos WISE habilitados: {len(nodes)}")
for node in nodes:
print(f"- {node['machine_id']} {node['source_node']} {node['wise_ip']}")
publish(mqtt_client, status_topic, {
"ts": utc_now(),
"status": "started",
"asset": env("ASSET", "revpi_oee_node_01"),
"enabled_nodes": [
{
"machine_id": n["machine_id"],
"source_node": n["source_node"],
"wise_ip": n["wise_ip"],
}
for n in nodes
],
})
while not stop["value"]:
loop_start = time.time()
for node in nodes:
state = states[node["source_node"]]
now = time.time()
try:
di = read_wise_inputs_modbus(state, node)
auto_signal = di["di0_stat"]
cycle_input_state = di["di1_stat"]
wise_counter = di["di1_counter_raw"]
counter_valid = di["di1_mode"] == 1 and di["di1_counting"]
delta, reset = counter_delta(state["previous_counter"], wise_counter)
state["counter_reset_detected"] = reset
if delta > 0:
state["cycle_pulse_count"] += delta
for _ in range(min(delta, 1000)):
state["cycle_events"].append(now)
event = {
"ts": utc_now(),
**base_payload(node),
"event_type": "cycle_count_changed",
"delta": delta,
"wise_cycle_counter_raw": wise_counter,
"cycle_pulse_count": state["cycle_pulse_count"],
}
publish(mqtt_client, event_topic, event)
print("CYCLES", dumps(event))
state["previous_counter"] = wise_counter
state["last_delta"] = delta
while state["cycle_events"] and (now - state["cycle_events"][0]) > rate_window_s:
state["cycle_events"].popleft()
cycle_rate_ppm = len(state["cycle_events"]) * 60.0 / rate_window_s
last_cycle_age_s = None
if state["cycle_events"]:
last_cycle_age_s = round(now - state["cycle_events"][-1], 3)
if state["last_auto_signal"] is not None and auto_signal != state["last_auto_signal"]:
event_name = "machine_started" if auto_signal else "machine_stopped"
event = {
"ts": utc_now(),
**base_payload(node),
"event_type": "machine_state_changed",
"event_name": event_name,
"previous_auto_signal": state["last_auto_signal"],
"new_auto_signal": auto_signal,
"machine_running": auto_signal,
"reason_pending": not auto_signal,
}
publish(mqtt_client, event_topic, event)
print("EVENT", dumps(event))
state["last_auto_signal"] = auto_signal
if now - state["last_energy_ts"] >= energy_interval_s:
regs = read_holdings(state, node)
state["last_energy"] = decode_energy(regs)
state["last_energy_ts"] = now
if now - state["last_publish_ts"] >= publish_interval_s:
payload = {
"ts": utc_now(),
**base_payload(node),
"machine_auto_signal": auto_signal,
"machine_running": auto_signal,
"cycle_input_state": cycle_input_state,
"wise_cycle_counter_raw": wise_counter,
"cycle_delta_last": state["last_delta"],
"cycle_pulse_count": state["cycle_pulse_count"],
"cycle_rate_ppm": round(cycle_rate_ppm, 3),
"last_cycle_age_s": last_cycle_age_s,
"counter_valid": counter_valid,
"counter_reset_detected": state["counter_reset_detected"],
"digital_inputs": {
"DI0": auto_signal,
"DI1": cycle_input_state,
},
**state["last_energy"],
"comm_ok": True,
"sample_valid": True,
}
publish(mqtt_client, telemetry_topic, payload)
print("TELEMETRY", dumps(payload))
state["last_publish_ts"] = now
except Exception as exc:
error_payload = {
"ts": utc_now(),
**base_payload(node),
"comm_ok": False,
"sample_valid": False,
"error": str(exc),
}
publish(mqtt_client, status_topic, error_payload)
print("ERROR", dumps(error_payload))
client = state.get("modbus_client")
if client is not None:
try:
client.close()
except Exception:
pass
state["modbus_client"] = None
elapsed = time.time() - loop_start
time.sleep(max(0.05, di_poll_interval_s - elapsed))
publish(mqtt_client, status_topic, {
"ts": utc_now(),
"status": "stopped",
"asset": env("ASSET", "revpi_oee_node_01"),
})
for state in states.values():
client = state.get("modbus_client")
if client is not None:
try:
client.close()
except Exception:
pass
mqtt_client.loop_stop()
mqtt_client.disconnect()
print("Parado correctamente")
if __name__ == "__main__":
main()