Overview
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ucls_unified_meter_gateway.py
-------------------------------------------------------------------------------------------------
UCLS Unified Meter Gateway — a compatible, standardized, SAFE-BY-DEFAULT bridge that can *read*
from diverse meters and standards across interdisciplinary sciences and fields, normalize all
values into a canonical UCLS Measurement Model (UCLS-MM), and publish to dashboards.
READ-ONLY by construction. No control/operate paths are implemented.
COVERAGE (adapters = optional libs; all degrade to simulation/dry-run):
• Electric / multi-utility meters:
- DLMS/COSEM (IEC 62056) [gurux-dlms if installed; else simulate]
- ANSI C12.x (C12.18/.19/.22) [stub simulate]
- M-Bus / OMS (EN 13757) [pymbus/pymeterbus if installed; else simulate]
- Modbus/TCP [pymodbus if installed; else simulate]
• Building/industrial:
- BACnet/IP (ASHRAE 135) [BAC0/pybacnet if installed; else simulate]
- OPC UA [python-opcua if installed; else simulate]
• IoT / Web / Messaging:
- OGC SensorThings API [HTTP GET; requests if installed; else simulate]
- MQTT (Sparkplug B & JSON) [paho-mqtt if installed; else simulate]
- WebSocket push (dashboards) [websocket-client if installed; else simulate]
CANONICAL DATA MODEL (UCLS-MM)
{
"id": "uuid or ULID",
"device_id": "string",
"stream_id": "string (protocol/topic/obis/path)",
"observed_property": "string (e.g., 'active_power')",
"value": float,
"unit_ucum": "UCUM code (e.g., 'W', 'kW', 'kWh', 'm3/h', 'Cel')",
"unit_si": "SI base unit (e.g., 'W','J','m3/s','K')",
"si_value": float, # converted to SI if possible
"quality": {"flags": ["valid","simulated","overflow",...], "status": "ok|warn|bad"},
"time": "ISO8601 UTC",
"location": {"lat": float, "lon": float, "elev_m": float} (optional),
"provenance": {"protocol": "dlms|mbus|modbus|bacnet|opcua|sensorthings|mqtt", "adapter": "classname", "source": "..."},
"tags": {"discipline":"energy|hydrology|air|bio|...","sector":"grid|bms|plant|lab|..."}
}
SAFETY
- LIVE=0 by default → simulation/dry-run only
- Allowlist REQUIRED for any live host/device (UCLS_WHITELIST, UCLS_MBUS_SERIAL, UCLS_DLMS_SERIAL etc.)
- READ-ONLY across all protocols (no write/operate)
- HMAC-signed JSON audit log per operation
CLI (examples)
# Simulated quick reads
python ucls_unified_meter_gateway.py demo
# DLMS read (DRY-RUN unless LIVE=1 and allow-listed)
UCLS_LIVE=1 UCLS_WHITELIST="meter01.utility" \
python ucls_unified_meter_gateway.py read-dlms --host meter01.utility --port 4059 --obis 1-0:1.8.0
# M-Bus TCP
UCLS_LIVE=1 UCLS_WHITELIST="mbus.gateway.local" \
python ucls_unified_meter_gateway.py read-mbus-tcp --host mbus.gateway.local --port 502 --secondary-id 12345678
# Modbus/TCP (holding registers)
UCLS_LIVE=1 UCLS_WHITELIST="plc.site.local" \
python ucls_unified_meter_gateway.py read-modbus --host plc.site.local --addr 30001 --count 2
# OPC UA
UCLS_LIVE=1 UCLS_WHITELIST="opcua.site.local" \
python ucls_unified_meter_gateway.py read-opcua --endpoint opc.tcp://opcua.site.local:4840 --node "ns=2;i=10853"
# SensorThings API
UCLS_LIVE=1 UCLS_WHITELIST="sensors.example.com" \
python ucls_unified_meter_gateway.py read-sensorthings --base https://sensors.example.com/v1.1 --observation 12345
# MQTT (SparkplugB/JSON) subscribe for 10s
UCLS_LIVE=1 UCLS_WHITELIST="mqtt.broker.local" \
python ucls_unified_meter_gateway.py mqtt-sub --broker mqtt.broker.local --topic "spBv1.0/#" --seconds 10
# Normalize any raw JSON into UCLS-MM
python ucls_unified_meter_gateway.py normalize --json '{"device_id":"x","observed_property":"temp","value":72,"unit_ucum":"[degF]"}'
# Publish a measurement to MQTT/WebSocket
python ucls_unified_meter_gateway.py publish --json '{...ucls-mm...}' --mqtt-broker mqtt.local --mqtt-topic ucls/mm --ws-url ws://localhost:8765
"""
from __future__ import annotations
import os, sys, json, time, re, socket, hmac, hashlib, uuid, math, argparse
from dataclasses import dataclass, asdict
from typing import Optional, Dict, Any, List, Tuple
# ---------- Optional dependencies (graceful) ----------
try:
import requests
HAS_REQUESTS = True
except Exception:
HAS_REQUESTS = False
try:
from pymodbus.client import ModbusTcpClient
HAS_PYMODBUS = True
except Exception:
HAS_PYMODBUS = False
try:
from opcua import Client as OPCUAClient
HAS_OPCUA = True
except Exception:
HAS_OPCUA = False
try:
import paho.mqtt.client as paho_mqtt
HAS_PAHO = True
except Exception:
HAS_PAHO = False
try:
import websocket as ws_client
HAS_WSCLIENT = True
except Exception:
HAS_WSCLIENT = False
# DLMS/COSEM + M-Bus + BACnet: provide stubs unless libs available
try:
import gurux_dlms # type: ignore # placeholder; typical package name is gurux_dlms (pip)
HAS_DLMS = True
except Exception:
HAS_DLMS = False
try:
import meterbus # type: ignore
HAS_MBUS = True
except Exception:
HAS_MBUS = False
try:
import BAC0 # type: ignore
HAS_BACNET = True
except Exception:
HAS_BACNET = False
# ===================== Safety & Audit =====================
def is_live() -> bool:
return os.environ.get("UCLS_LIVE","0") in ("1","true","TRUE","yes","YES")
def allowlist() -> List[str]:
raw = os.environ.get("UCLS_WHITELIST","")
return [h.strip() for h in raw.split(",") if h.strip()]
def host_allowed(host_or_url: str) -> bool:
host = host_or_url
m = re.match(r"^https?://([^/:]+)", host_or_url, re.I)
if m: host = m.group(1)
wl = allowlist()
if not wl: return False
try:
ip = socket.gethostbyname(host)
except Exception:
ip = None
return host in wl or (ip and ip in wl)
AUDIT_KEY = os.environ.get("UCLS_AUDIT_KEY","ucls-dev-key").encode()
def audit(action: str, target: str, payload: Dict[str,Any], result: Any):
rec = {
"ts": time.time(), "actor": os.environ.get("USER","ucls"),
"action": action, "target": target, "payload": payload,
"result_preview": (json.dumps(result, ensure_ascii=False)[:400] + "…") if len(str(result))>420 else result
}
body = json.dumps(rec, sort_keys=True, ensure_ascii=False).encode()
rec["signature"] = hmac.new(AUDIT_KEY, body, hashlib.sha256).hexdigest()
print(json.dumps(rec, ensure_ascii=False))
# ===================== Canonical Units & Conversion (UCUM→SI) =====================
# Minimal UCUM→SI mapping for common disciplines (extend as needed)
UNIT_MAP: Dict[str, Tuple[str, float, float]] = {
# ucum : (si_unit, scale, offset)
"W": ("W", 1.0, 0.0),
"kW": ("W", 1000.0, 0.0),
"MW": ("W", 1e6, 0.0),
"Wh": ("J", 3600.0, 0.0),
"kWh": ("J", 3.6e6, 0.0),
"m3": ("m3", 1.0, 0.0),
"m3/h": ("m3/s", 1.0/3600.0, 0.0),
"L": ("m3", 1e-3, 0.0),
"Pa": ("Pa", 1.0, 0.0),
"bar": ("Pa", 1e5, 0.0),
"°C": ("K", 1.0, 273.15),
"Cel": ("K", 1.0, 273.15),
"[degF]": ("K", 5.0/9.0, 255.3722222), # (F-32)*5/9 + 273.15
"%RH": ("%", 1.0, 0.0),
"Hz": ("Hz", 1.0, 0.0),
"V": ("V", 1.0, 0.0),
"A": ("A", 1.0, 0.0),
"VAR": ("VAR", 1.0, 0.0),
"VA": ("VA", 1.0, 0.0),
"pf": ("ratio", 1.0, 0.0),
"ppm": ("ppm", 1.0, 0.0),
"mg/m3":("kg/m3", 1e-6, 0.0),
"ug/m3":("kg/m3", 1e-9, 0.0)
}
def to_si(value: float, unit_ucum: str) -> Tuple[float, str]:
u = UNIT_MAP.get(unit_ucum)
if not u: return value, unit_ucum # unknown, keep
si_u, scale, offset = u
# handle temperature with offset (C/F)
if unit_ucum in ("°C","Cel","[degF]"):
return value*scale + offset, si_u
return value*scale + offset, si_u
# ===================== JSON-LD Context & SHACL (validation payloads) =====================
UCLS_MM_CONTEXT = {
"@context": {
"@vocab": "https://ucls.org/mm/",
"id": "@id", "type": "@type",
"device_id": "https://ucls.org/mm/device_id",
"stream_id": "https://ucls.org/mm/stream_id",
"observed_property": "https://ucls.org/mm/observed_property",
"value": "https://ucls.org/mm/value",
"unit_ucum": "https://ucls.org/mm/unit_ucum",
"unit_si": "https://ucls.org/mm/unit_si",
"si_value": "https://ucls.org/mm/si_value",
"quality": "https://ucls.org/mm/quality",
"time": "https://ucls.org/mm/time",
"location": "https://ucls.org/mm/location",
"provenance": "https://ucls.org/mm/provenance",
"tags": "https://ucls.org/mm/tags"
}
}
UCLS_MM_SHACL_TTL = """@prefix sh: <http://www.w3.org/ns/shacl#> .
@prefix ucls: <https://ucls.org/mm/> .
ucls:MeasurementShape a sh:NodeShape ;
sh:targetClass ucls:Measurement ;
sh:property [ sh:path ucls:device_id ; sh:minCount 1 ] ;
sh:property [ sh:path ucls:observed_property ; sh:minCount 1 ] ;
sh:property [ sh:path ucls:value ; sh:minCount 1 ] ;
sh:property [ sh:path ucls:unit_ucum ; sh:minCount 1 ] ;
sh:property [ sh:path ucls:time ; sh:minCount 1 ] .
"""
# ===================== Canonical Builder =====================
def make_mm(device_id: str, stream_id: str, observed_property: str, value: float,
unit_ucum: str, protocol: str, adapter: str, source: str,
tags: Optional[Dict[str,Any]]=None, quality_flags: Optional[List[str]]=None) -> Dict[str,Any]:
si_value, unit_si = to_si(value, unit_ucum)
meas = {
"id": str(uuid.uuid4()),
"device_id": device_id,
"stream_id": stream_id,
"observed_property": observed_property,
"value": float(value),
"unit_ucum": unit_ucum,
"unit_si": unit_si,
"si_value": float(si_value),
"quality": {"flags": quality_flags or ["valid"], "status": "ok"},
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"provenance": {"protocol": protocol, "adapter": adapter, "source": source},
"tags": tags or {}
}
return meas
# ===================== Adapters (READ-ONLY; SIM fallback) =====================
class DLMSReader:
"""DLMS/COSEM OBIS read (IEC 62056). Sim fallback if gurux_dlms not present or LIVE=0."""
def read_obis(self, host: str, port: int, obis: str, device_id: Optional[str]=None) -> Dict[str,Any]:
if not is_live() or not host_allowed(host) or not HAS_DLMS:
# Simulate common OBIS 1-0:1.8.0 (active energy import, total)
val = 12345.678 # kWh
mm = make_mm(device_id or host, obis, "active_energy_import_total",
val, "kWh", "dlms", "DLMSReader", f"{host}:{port}",
tags={"discipline":"energy","sector":"grid"}, quality_flags=["simulated"])
audit("dlms-read-sim", host, {"obis":obis,"port":port}, mm); return mm
# Real DLMS call would go here (association, GET by OBIS) — omitted for safety.
mm = make_mm(device_id or host, obis, "unknown_property",
float("nan"), "", "dlms", "DLMSReader", f"{host}:{port}", quality_flags=["unknown"])
audit("dlms-read", host, {"obis":obis,"port":port}, mm); return mm
class MBusReader:
"""M-Bus EN 13757/OMS — TCP or serial. Sim fallback."""
def read_tcp(self, host: str, port: int, secondary_id: Optional[str]) -> Dict[str,Any]:
if not is_live() or not host_allowed(host) or not HAS_MBUS:
mm = make_mm(secondary_id or host, f"mbus:{secondary_id or 'sim'}",
"volume_total", 12.345, "m3", "mbus", "MBusReader", f"{host}:{port}",
tags={"discipline":"hydrology","sector":"utility"}, quality_flags=["simulated"])
audit("mbus-read-sim", host, {"port":port,"sec_id":secondary_id}, mm); return mm
mm = make_mm(secondary_id or host, f"mbus:{secondary_id}",
"unknown_property", float("nan"), "", "mbus","MBusReader", f"{host}:{port}", quality_flags=["unknown"])
audit("mbus-read", host, {"port":port,"sec_id":secondary_id}, mm); return mm
class ANSI_C12Reader:
"""ANSI C12.x stub — simulate common reads (demand/energy)."""
def read(self, port: str, register: str, device_id: Optional[str]=None) -> Dict[str,Any]:
# Simulation only in this scaffold
mm = make_mm(device_id or port, f"c12:{register}", "active_energy_import_total",
6789.01, "kWh", "ansi_c12", "ANSI_C12Reader", port,
tags={"discipline":"energy","sector":"grid"}, quality_flags=["simulated"])
audit("ansi-c12-read-sim", port, {"register":register}, mm); return mm
class ModbusReader:
def read_holding(self, host: str, addr: int, count: int, device_id: Optional[str]=None, port: int = 502) -> Dict[str,Any]:
if not is_live() or not host_allowed(host) or not HAS_PYMODBUS:
# Simulate two registers composing a 32-bit float (e.g., flow)
value = 123.4
mm = make_mm(device_id or host, f"modbus:{addr}/{count}", "flow_rate",
value, "m3/h", "modbus", "ModbusReader", f"{host}:{port}",
tags={"discipline":"process","sector":"plant"}, quality_flags=["simulated"])
audit("modbus-read-sim", host, {"addr":addr,"count":count}, mm); return mm
client = ModbusTcpClient(host=host, port=port)
client.connect()
rr = client.read_holding_registers(addr, count, slave=1)
client.close()
value = float(rr.registers[0]) if rr and hasattr(rr,"registers") else float("nan")
mm = make_mm(device_id or host, f"modbus:{addr}/{count}", "value_raw",
value, "", "modbus","ModbusReader", f"{host}:{port}")
audit("modbus-read", host, {"addr":addr,"count":count}, mm); return mm
class BACnetReader:
def read(self, device: str, obj: str, prop: str, device_id: Optional[str]=None) -> Dict[str,Any]:
if not is_live() or not HAS_BACNET:
mm = make_mm(device_id or device, f"bacnet:{obj}/{prop}", "air_temperature",
22.4, "Cel", "bacnet", "BACnetReader", device,
tags={"discipline":"hvac","sector":"bms"}, quality_flags=["simulated"])
audit("bacnet-read-sim", device, {"obj":obj,"prop":prop}, mm); return mm
mm = make_mm(device_id or device, f"bacnet:{obj}/{prop}", "value_raw",
float("nan"), "", "bacnet","BACnetReader", device, quality_flags=["unknown"])
audit("bacnet-read", device, {"obj":obj,"prop":prop}, mm); return mm
class OPCUAReader:
def read_node(self, endpoint: str, node: str, device_id: Optional[str]=None) -> Dict[str,Any]:
if not is_live() or not HAS_OPCUA:
mm = make_mm(device_id or endpoint, f"opcua:{node}", "pressure",
2.1, "bar", "opcua","OPCUAReader", endpoint,
tags={"discipline":"process","sector":"plant"}, quality_flags=["simulated"])
audit("opcua-read-sim", endpoint, {"node":node}, mm); return mm
host = re.sub(r"^opc\.tcp://","", endpoint).split(":")[0]
if not host_allowed(host):
raise PermissionError("Endpoint host not allowed")
client = OPCUAClient(endpoint); client.connect()
v = client.get_node(node).get_value(); client.disconnect()
val = float(v) if isinstance(v,(int,float)) else float("nan")
mm = make_mm(device_id or endpoint, f"opcua:{node}", "value_raw",
val, "", "opcua","OPCUAReader", endpoint)
audit("opcua-read", endpoint, {"node":node}, mm); return mm
class SensorThingsReader:
def read_observation(self, base: str, observation_id: str, device_id: Optional[str]=None) -> Dict[str,Any]:
if not is_live() or not host_allowed(base) or not HAS_REQUESTS:
mm = make_mm(device_id or base, f"sensorthings:obs/{observation_id}", "pm25",
12.7, "ug/m3", "sensorthings","SensorThingsReader", base,
tags={"discipline":"air","sector":"env"}, quality_flags=["simulated"])
audit("sensorthings-read-sim", base, {"obs_id":observation_id}, mm); return mm
url = f"{base.rstrip('/')}/Observations({observation_id})"
r = requests.get(url, timeout=10)
if r.status_code != 200:
mm = make_mm(device_id or base, f"sensorthings:obs/{observation_id}", "value_raw",
float("nan"), "", "sensorthings","SensorThingsReader", base, quality_flags=["bad"])
audit("sensorthings-read", base, {"obs_id":observation_id}, {"status": r.status_code}); return mm
data = r.json()
value = data.get("result")
prop = (data.get("Datastream",{}) or {}).get("name","observation")
unit = ((data.get("Datastream",{}) or {}).get("unitOfMeasurement",{}) or {}).get("symbol","")
mm = make_mm(device_id or base, f"sensorthings:obs/{observation_id}", prop,
float(value) if isinstance(value,(int,float)) else float("nan"),
unit or "", "sensorthings","SensorThingsReader", url)
audit("sensorthings-read", base, {"obs_id":observation_id}, mm); return mm
class MQTTMonitor:
def subscribe(self, broker: str, topic: str, seconds: int = 10) -> List[Dict[str,Any]]:
if not is_live() or not HAS_PAHO or not host_allowed(broker):
mm = make_mm(broker, f"mqtt:{topic}", "message_count",
1, "", "mqtt","MQTTMonitor", broker,
tags={"discipline":"messaging"}, quality_flags=["simulated"])
audit("mqtt-sub-sim", broker, {"topic":topic}, mm); return [mm]
messages: List[Dict[str,Any]] = []
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload.decode(errors="replace"))
# If SparkplugB or JSON with value/unit, attempt normalization
if isinstance(payload, dict) and "value" in payload and "unit" in payload:
mm = make_mm(broker, f"mqtt:{msg.topic}", payload.get("observed_property","value"),
float(payload["value"]), str(payload["unit"]), "mqtt","MQTTMonitor", broker)
else:
mm = make_mm(broker, f"mqtt:{msg.topic}", "message",
1.0, "", "mqtt","MQTTMonitor", broker, quality_flags=["raw"])
except Exception:
mm = make_mm(broker, f"mqtt:{msg.topic}", "message", 1.0, "", "mqtt","MQTTMonitor", broker, quality_flags=["raw"])
messages.append(mm)
client = paho_mqtt.Client()
client.on_message = on_message; client.connect(broker, 1883, 60)
client.subscribe(topic); client.loop_start()
t0 = time.time()
while time.time() - t0 < seconds: time.sleep(0.1)
client.loop_stop(); client.disconnect()
for m in messages: audit("mqtt-sub", broker, {"topic":topic}, m)
return messages
# ===================== Publishers =====================
def publish_mqtt(obj: Dict[str,Any], broker: Optional[str], topic: Optional[str]) -> Dict[str,Any]:
if not broker or not topic:
return {"published": False, "reason": "no broker/topic"}
if not is_live() or not HAS_PAHO or not host_allowed(broker):
res = {"published": False, "simulated": True, "broker": broker, "topic": topic}
audit("mqtt-publish-sim", broker, {"topic":topic}, res); return res
client = paho_mqtt.Client(); client.connect(broker, 1883, 60)
payload = json.dumps(obj, ensure_ascii=False); rc = client.publish(topic, payload, qos=0, retain=False)
client.disconnect()
res = {"published": rc.rc == 0, "broker": broker, "topic": topic}
audit("mqtt-publish", broker, {"topic":topic}, res); return res
def publish_ws(obj: Dict[str,Any], ws_url: Optional[str]) -> Dict[str,Any]:
if not ws_url:
return {"published": False, "reason": "no ws_url"}
payload = json.dumps(obj, ensure_ascii=False)
if not is_live() or not HAS_WSCLIENT or not host_allowed(ws_url):
res = {"published": False, "simulated": True, "ws_url": ws_url}
audit("ws-publish-sim", ws_url, {}, res); return res
ws = ws_client.create_connection(ws_url, timeout=5)
ws.send(payload)
try:
ack = ws.recv()
except Exception:
ack = None
ws.close()
res = {"published": True, "ws_url": ws_url, "ack": (ack[:200]+"…") if isinstance(ack,str) and len(ack)>200 else ack}
audit("ws-publish", ws_url, {}, res); return res
# ===================== CLI =====================
def main():
dlms = DLMSReader(); mbus = MBusReader(); c12 = ANSI_C12Reader()
modbus = ModbusReader(); bacnet = BACnetReader(); opcua = OPCUAReader()
sens = SensorThingsReader(); mqttm = MQTTMonitor()
p = argparse.ArgumentParser(description="UCLS Unified Meter Gateway — standardized, read-only")
sub = p.add_subparsers(dest="cmd")
sub.add_parser("demo", help="Run simulated reads across adapters")
# DLMS
d = sub.add_parser("read-dlms"); d.add_argument("--host", required=True); d.add_argument("--port", type=int, default=4059)
d.add_argument("--obis", required=True); d.add_argument("--device-id")
# M-Bus TCP
mt = sub.add_parser("read-mbus-tcp"); mt.add_argument("--host", required=True); mt.add_argument("--port", type=int, default=502)
mt.add_argument("--secondary-id"); mt.add_argument("--device-id")
# ANSI C12 stub (serial id)
c = sub.add_parser("read-c12"); c.add_argument("--port", required=True); c.add_argument("--register", default="1.0.0"); c.add_argument("--device-id")
# Modbus/TCP
m = sub.add_parser("read-modbus"); m.add_argument("--host", required=True); m.add_argument("--addr", type=int, required=True)
m.add_argument("--count", type=int, default=2); m.add_argument("--port", type=int, default=502); m.add_argument("--device-id")
# BACnet/IP
b = sub.add_parser("read-bacnet"); b.add_argument("--device", required=True); b.add_argument("--object", required=True); b.add_argument("--property", required=True); b.add_argument("--device-id")
# OPC UA
o = sub.add_parser("read-opcua"); o.add_argument("--endpoint", required=True); o.add_argument("--node", required=True); o.add_argument("--device-id")
# SensorThings
s = sub.add_parser("read-sensorthings"); s.add_argument("--base", required=True); s.add_argument("--observation", required=True); s.add_argument("--device-id")
# MQTT subscribe
mq = sub.add_parser("mqtt-sub"); mq.add_argument("--broker", required=True); mq.add_argument("--topic", required=True); mq.add_argument("--seconds", type=int, default=10)
# Normalize / Publish
n = sub.add_parser("normalize"); n.add_argument("--json", required=True)
pub = sub.add_parser("publish"); pub.add_argument("--json", required=True); pub.add_argument("--mqtt-broker"); pub.add_argument("--mqtt-topic"); pub.add_argument("--ws-url")
args = p.parse_args()
if args.cmd in (None, "demo"):
out = []
out.append(dlms.read_obis("meter.sim", 4059, "1-0:1.8.0"))
out.append(mbus.read_tcp("mbus.sim", 502, "12345678"))
out.append(c12.read("/dev/ttyS0", "1.0.0"))
out.append(modbus.read_holding("plc.sim", 30001, 2))
out.append(bacnet.read("bacnet.sim", "analogInput,1", "presentValue"))
out.append(opcua.read_node("opc.tcp://opcua.sim:4840", "ns=2;i=10853"))
out.append(sens.read_observation("https://sens.sim/v1.1", "1"))
msgs = mqttm.subscribe("mqtt.sim","ucls/#", seconds=2); out.extend(msgs)
print(json.dumps(out, ensure_ascii=False, indent=2)); return
if args.cmd == "read-dlms":
print(json.dumps(dlms.read_obis(args.host, args.port, args.obis, args.device_id), ensure_ascii=False, indent=2)); return
if args.cmd == "read-mbus-tcp":
print(json.dumps(mbus.read_tcp(args.host, args.port, args.secondary_id), ensure_ascii=False, indent=2)); return
if args.cmd == "read-c12":
print(json.dumps(c12.read(args.port, args.register, args.device_id), ensure_ascii=False, indent=2)); return
if args.cmd == "read-modbus":
print(json.dumps(modbus.read_holding(args.host, args.addr, args.count, args.device_id, args.port), ensure_ascii=False, indent=2)); return
if args.cmd == "read-bacnet":
print(json.dumps(bacnet.read(args.device, args.object, args.property, args.device_id), ensure_ascii=False, indent=2)); return
if args.cmd == "read-opcua":
print(json.dumps(opcua.read_node(args.endpoint, args.node, args.device_id), ensure_ascii=False, indent=2)); return
if args.cmd == "read-sensorthings":
print(json.dumps(sens.read_observation(args.base, args.observation, args.device_id), ensure_ascii=False, indent=2)); return
if args.cmd == "mqtt-sub":
print(json.dumps(mqttm.subscribe(args.broker, args.topic, args.seconds), ensure_ascii=False, indent=2)); return
if args.cmd == "normalize":
raw = json.loads(args.json)
# If already UCLS-MM, just echo; else try to map common patterns
if {"device_id","observed_property","value","unit_ucum","time"} <= set(raw.keys()):
mm = raw
else:
# Heuristic mapping
device_id = raw.get("device") or raw.get("device_id") or "unknown"
prop = raw.get("observed_property") or raw.get("property") or "value"
val = float(raw.get("value", "nan"))
unit = raw.get("unit") or raw.get("unit_ucum") or ""
mm = make_mm(device_id, raw.get("stream","raw"), prop, val, unit, "normalize","mapper","local", quality_flags=["normalized"])
print(json.dumps(mm, ensure_ascii=False, indent=2)); return
if args.cmd == "publish":
obj = json.loads(args.json)
if not isinstance(obj, dict):
print(json.dumps({"error":"payload must be an object"}, ensure_ascii=False)); return
if args.mqtt_broker and args.mqtt_topic:
publish_mqtt(obj, args.mqtt_broker, args.mqtt_topic)
if args.ws_url:
publish_ws(obj, args.ws_url)
print(json.dumps({"ok": True}, ensure_ascii=False)); return
if __name__ == "__main__":
main()
Key terms in plain language
Open a term for a concise explanation of language used on this page.
API
An application programming interface is a defined way for software systems to exchange data or request functions from one another.
Artificial Intelligence (AI)
Software designed to perform tasks involving prediction, classification, generation, reasoning, or decision support. Business use still requires clear data, governance, security, and human accountability.
Cloud Computing
Computing resources—such as applications, servers, storage, or databases—delivered from remote infrastructure and scaled as requirements change.
Cybersecurity
The practices and controls used to protect identities, devices, networks, applications, and data from unauthorized access, disruption, or manipulation.
Identity and Access Management (IAM)
The systems and policies that determine who a user is, what resources they may access, and how that access is authenticated and reviewed.
Bandwidth
The amount of data a connection can carry in a given time, usually measured in Mbps or Gbps. More bandwidth supports more users, devices, and simultaneous applications.