492 lines
15 KiB
Python
Executable File
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()
|