304 lines
9.3 KiB
Python
Executable File
304 lines
9.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import json
|
|
import os
|
|
import struct
|
|
import time
|
|
import urllib.request
|
|
from collections import deque
|
|
from datetime import datetime, timezone
|
|
|
|
from pymodbus.client import ModbusTcpClient
|
|
|
|
|
|
WISE_IP = "10.43.54.249"
|
|
WISE_PORT = 502
|
|
WISE_UNIT = 1
|
|
|
|
WISE_DI_URL = f"http://{WISE_IP}/di_value/slot_0"
|
|
WISE_SESSION_ID = os.getenv("WISE_SESSION_ID", "").strip()
|
|
|
|
HOLDING_BASE = 1000
|
|
|
|
DI_POLL_INTERVAL_S = 0.2
|
|
ENERGY_INTERVAL_S = 2.0
|
|
PRINT_INTERVAL_S = 2.0
|
|
RATE_WINDOW_S = 60.0
|
|
|
|
TENANT = "ucepsa"
|
|
SITE = "demo_edge_oee"
|
|
VERTICAL = "edge_oee"
|
|
ASSET = "revpi_oee_node_01"
|
|
MACHINE_ID = "CORT-01"
|
|
SOURCE_NODE = "wise-banco-01"
|
|
NODE_TYPE = "revpi_wise_oee_gateway"
|
|
FW = "0.2.0"
|
|
|
|
|
|
def utc_now():
|
|
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
|
|
|
|
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 read_wise_di_api():
|
|
from urllib.error import HTTPError
|
|
|
|
if not WISE_SESSION_ID:
|
|
raise RuntimeError(
|
|
"Falta WISE_SESSION_ID. Ejecuta primero: export WISE_SESSION_ID"
|
|
)
|
|
|
|
req = urllib.request.Request(
|
|
WISE_DI_URL,
|
|
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={WISE_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 error {e.code}: {e.reason}. Body={body[:300]!r}"
|
|
)
|
|
|
|
data = json.loads(text)
|
|
|
|
if isinstance(data, dict) and data.get("Err"):
|
|
raise RuntimeError(
|
|
f"WISE API error 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)),
|
|
"raw": data,
|
|
}
|
|
|
|
|
|
def read_holdings(client):
|
|
if not client.connected:
|
|
if not client.connect():
|
|
raise RuntimeError("No conecta con WISE por Modbus/TCP")
|
|
|
|
rr = client.read_holding_registers(
|
|
address=HOLDING_BASE,
|
|
count=20,
|
|
device_id=WISE_UNIT,
|
|
)
|
|
|
|
if rr.isError():
|
|
raise RuntimeError(f"Error leyendo holding registers: {rr}")
|
|
|
|
return rr.registers
|
|
|
|
|
|
def decode_energy(regs):
|
|
active_power_w = f32_abcd(regs[4], regs[5])
|
|
|
|
return {
|
|
"voltage_l1_v": round(f32_abcd(regs[0], regs[1]), 3),
|
|
"current_l1_a": round(f32_abcd(regs[2], regs[3]), 6),
|
|
"active_power_w": round(active_power_w, 3),
|
|
"power_total_kw": round(active_power_w / 1000.0, 6),
|
|
"apparent_power_va": round(f32_abcd(regs[6], regs[7]), 3),
|
|
"reactive_power_var": round(f32_abcd(regs[8], regs[9]), 3),
|
|
"power_factor": round(f32_abcd(regs[10], regs[11]), 6),
|
|
"frequency_hz": round(f32_abcd(regs[12], regs[13]), 3),
|
|
"import_kwh": round(f32_abcd(regs[14], regs[15]), 6),
|
|
"export_kwh": round(f32_abcd(regs[16], regs[17]), 6),
|
|
"total_active_energy_kwh": round(f32_abcd(regs[18], regs[19]), 6),
|
|
}
|
|
|
|
|
|
def counter_delta(previous, current):
|
|
if previous is None:
|
|
return 0, False
|
|
|
|
if current >= previous:
|
|
return current - previous, False
|
|
|
|
# Reset manual, reinicio del WISE o pérdida del contador.
|
|
return 0, True
|
|
|
|
|
|
def main():
|
|
client = ModbusTcpClient(WISE_IP, port=WISE_PORT, timeout=3)
|
|
|
|
previous_wise_counter = None
|
|
cycle_pulse_count = 0
|
|
cycle_events = deque()
|
|
|
|
last_auto_signal = None
|
|
last_energy = {}
|
|
last_energy_ts = 0.0
|
|
last_print_ts = 0.0
|
|
last_delta = 0
|
|
counter_reset_detected = False
|
|
|
|
print(f"Leyendo WISE {WISE_IP}")
|
|
print(f"DI API: {WISE_DI_URL}")
|
|
print(f"Modbus/TCP: {WISE_IP}:{WISE_PORT}, holding base {HOLDING_BASE}")
|
|
print("DI0 = marcha / automático")
|
|
print("DI1 = contador de ciclos\n")
|
|
|
|
while True:
|
|
now = time.time()
|
|
|
|
try:
|
|
di = read_wise_di_api()
|
|
|
|
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(previous_wise_counter, wise_counter)
|
|
counter_reset_detected = reset
|
|
|
|
if delta > 0:
|
|
cycle_pulse_count += delta
|
|
|
|
for _ in range(min(delta, 1000)):
|
|
cycle_events.append(now)
|
|
|
|
print(
|
|
"CYCLES",
|
|
json.dumps(
|
|
{
|
|
"ts": utc_now(),
|
|
"delta": delta,
|
|
"wise_counter": wise_counter,
|
|
"cycle_pulse_count": cycle_pulse_count,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
)
|
|
|
|
previous_wise_counter = wise_counter
|
|
last_delta = delta
|
|
|
|
while cycle_events and (now - cycle_events[0]) > RATE_WINDOW_S:
|
|
cycle_events.popleft()
|
|
|
|
cycle_rate_ppm = len(cycle_events) * 60.0 / RATE_WINDOW_S
|
|
last_cycle_age_s = None if not cycle_events else round(now - cycle_events[-1], 3)
|
|
|
|
if last_auto_signal is not None and auto_signal != last_auto_signal:
|
|
event_name = "machine_started" if auto_signal else "machine_stopped"
|
|
|
|
print(
|
|
"EVENT",
|
|
json.dumps(
|
|
{
|
|
"ts": utc_now(),
|
|
"tenant": TENANT,
|
|
"site": SITE,
|
|
"vertical": VERTICAL,
|
|
"asset": ASSET,
|
|
"machine_id": MACHINE_ID,
|
|
"source_node": SOURCE_NODE,
|
|
"event_type": "machine_state_changed",
|
|
"event_name": event_name,
|
|
"previous_auto_signal": last_auto_signal,
|
|
"new_auto_signal": auto_signal,
|
|
"machine_running": auto_signal,
|
|
"reason_pending": not auto_signal,
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
)
|
|
|
|
last_auto_signal = auto_signal
|
|
|
|
if now - last_energy_ts >= ENERGY_INTERVAL_S:
|
|
regs = read_holdings(client)
|
|
last_energy = decode_energy(regs)
|
|
last_energy_ts = now
|
|
|
|
if now - last_print_ts >= PRINT_INTERVAL_S:
|
|
payload = {
|
|
"ts": utc_now(),
|
|
"asset": ASSET,
|
|
"tenant": TENANT,
|
|
"site": SITE,
|
|
"vertical": VERTICAL,
|
|
"node_type": NODE_TYPE,
|
|
"fw": FW,
|
|
"machine_id": MACHINE_ID,
|
|
"source_node": SOURCE_NODE,
|
|
"wise_host": WISE_IP,
|
|
"machine_auto_signal": auto_signal,
|
|
"machine_running": auto_signal,
|
|
"cycle_input_state": cycle_input_state,
|
|
"wise_cycle_counter_raw": wise_counter,
|
|
"cycle_delta_last": last_delta,
|
|
"cycle_pulse_count": 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": counter_reset_detected,
|
|
"digital_inputs": {
|
|
"DI0": auto_signal,
|
|
"DI1": cycle_input_state,
|
|
},
|
|
**last_energy,
|
|
"comm_ok": True,
|
|
"sample_valid": True,
|
|
}
|
|
|
|
print("TELEMETRY", json.dumps(payload, ensure_ascii=False))
|
|
last_print_ts = now
|
|
|
|
except KeyboardInterrupt:
|
|
print("\nParado por usuario")
|
|
break
|
|
|
|
except Exception as exc:
|
|
print(
|
|
"ERROR",
|
|
json.dumps(
|
|
{
|
|
"ts": utc_now(),
|
|
"asset": ASSET,
|
|
"machine_id": MACHINE_ID,
|
|
"source_node": SOURCE_NODE,
|
|
"wise_host": WISE_IP,
|
|
"comm_ok": False,
|
|
"sample_valid": False,
|
|
"error": str(exc),
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
)
|
|
|
|
try:
|
|
client.close()
|
|
except Exception:
|
|
pass
|
|
|
|
time.sleep(2)
|
|
|
|
time.sleep(DI_POLL_INTERVAL_S)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|