602 lines
29 KiB
Python
602 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
otopcua_uns.py — reloadable populate + verify for the OtOpcUa galaxy UNS.
|
|
|
|
Recreates and verifies an OtOpcUa Unified-Namespace load grounded in the real
|
|
AVEVA Galaxy "DEV" hierarchy (the 40 TestMachine instances). Designed to be
|
|
re-run after the OtOpcUa docker-dev instance is rebuilt.
|
|
|
|
Pipeline (see scadaproj/memory otopcua-uns-deploy-and-value-streaming):
|
|
|
|
populate ──SQL──▶ live config tables (Tag rows, nw-* prefix)
|
|
│
|
|
you click "Deploy current configuration" at :9200
|
|
│
|
|
▼
|
|
driver applies ▶ materialises OtOpcUa/<machine>/<signal> ▶ SubscribeBulk
|
|
│
|
|
verify ──OPC UA──▶ browse + read live values on :4840
|
|
|
|
What it loads: one SystemPlatform Tag per (machine, signal) bound to the
|
|
existing GalaxyMxGateway driver. Each tag's FolderPath is the Galaxy object and
|
|
its Name the attribute, so the materialised variable NodeId is exactly the
|
|
MXAccess ref the driver subscribes to — giving live values. Every row carries
|
|
the `nw-` id prefix so `clean` can remove them without touching other config.
|
|
|
|
Idempotent: populate upserts by TagId; re-running is a no-op when unchanged.
|
|
|
|
There are TWO overlays:
|
|
• the galaxy-native mirror (`populate`) — SystemPlatform driver Tags, 396 tags;
|
|
• the Northwind company shape (`populate-equipment`) — an Equipment-kind namespace
|
|
whose 1036 signals are VirtualTags. Each VirtualTag's Script simply mirrors the
|
|
live galaxy-mirror tag (`return ctx.GetTag("<fullTagReference>").Value;`), so the
|
|
company shape carries live VALUES driven off the same Galaxy source.
|
|
|
|
Subcommands:
|
|
generate Build the load plan from galaxy-hierarchy.json (writes load-plan.json)
|
|
populate Upsert the SystemPlatform mirror Tag rows into the config DB
|
|
populate-equipment Load the company shape as VirtualTag+Script rows (mirror the galaxy tags)
|
|
verify Check DB rows present + live OPC UA values are Good on :4840
|
|
verify-equipment Browse the company tree; --require-good asserts live values
|
|
status Show config-DB + address-space state
|
|
clean Delete all nw-* mirror Tags + the company VirtualTag/Script overlay
|
|
|
|
Deploy is a human-gated AdminUI action (no SQL/REST trigger exists); populate
|
|
and clean print the reminder and `verify --wait` polls until it lands.
|
|
|
|
Deps: pymssql, asyncua (see requirements.txt; use the bundled .venv).
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import uuid
|
|
|
|
# ── config (overridable via env / flags) ───────────────────────────────────
|
|
DEF_MSSQL = dict(
|
|
host=os.environ.get("OTOPCUA_SQL_HOST", "localhost"),
|
|
port=int(os.environ.get("OTOPCUA_SQL_PORT", "14330")),
|
|
user=os.environ.get("OTOPCUA_SQL_USER", "sa"),
|
|
password=os.environ.get("OTOPCUA_SQL_PASSWORD", "OtOpcUa!Dev123"),
|
|
database=os.environ.get("OTOPCUA_SQL_DB", "OtOpcUa"),
|
|
)
|
|
DEF_OPCUA = os.environ.get("OTOPCUA_OPCUA_ENDPOINT", "opc.tcp://localhost:4840")
|
|
DEF_DRIVER = os.environ.get("OTOPCUA_GALAXY_DRIVER", "MAIN-galaxy-mxgw")
|
|
DEF_GALAXY_JSON = os.environ.get(
|
|
"OTOPCUA_GALAXY_JSON",
|
|
os.path.join(os.path.dirname(__file__), "..", "galaxy-hierarchy.json"),
|
|
)
|
|
DEF_COMPANY_JSON = os.environ.get(
|
|
"OTOPCUA_COMPANY_JSON",
|
|
os.path.join(os.path.dirname(__file__), "..", "company-uns.json"),
|
|
)
|
|
ID_PREFIX = "nw-mirror-" # SystemPlatform galaxy-mirror TagId prefix
|
|
LOAD_PLAN = os.path.join(os.path.dirname(__file__), "load-plan.json")
|
|
|
|
# Equipment-overlay (company-shape) object ids — all carry the nw- prefix so
|
|
# `clean` can remove them. The Equipment namespace is a SECOND namespace loaded
|
|
# alongside the galaxy mirror. Each company signal is a VirtualTag (+ Script) whose
|
|
# script mirrors the live SystemPlatform galaxy-mirror tag for that signal — so the
|
|
# overlay carries live VALUES (scope doc WS-3), not just structure.
|
|
EQ_CLUSTER = os.environ.get("OTOPCUA_EQ_CLUSTER", "MAIN")
|
|
EQ_NS = "nw-uns"
|
|
EQ_ID_PREFIX = "nweq-" # VirtualTag/Script logical-id prefix (cleanup by prefix scan)
|
|
|
|
# galaxy dataTypeName / gen_uns dtype → valid OtOpcUa DriverDataType
|
|
_DTYPE_FIX = {"Double": "Float64", "Float": "Float32"}
|
|
_ACCESS = {"ReadOnly": "0", "Read": "0", "ReadWrite": "1"}
|
|
|
|
# ── the value signals we mirror, per $TestMachine instance ──────────────────
|
|
# (galaxy attribute name, OtOpcUa DriverDataType, access '0'=Read/'1'=ReadWrite)
|
|
SIGNALS = [
|
|
("TestChangingInt", "Int32", "0"),
|
|
("TestHistoryValue", "Int32", "0"),
|
|
("TestDouble", "Float64", "0"),
|
|
("TestFloat", "Float32", "0"),
|
|
("TestDuration", "Float64", "0"),
|
|
("TestDateTime", "DateTime", "0"),
|
|
("ProtectedValue", "Boolean", "1"),
|
|
("ProtectedValue1", "Boolean", "1"),
|
|
("TestAlarm001", "Boolean", "0"),
|
|
("TestAlarm002", "Boolean", "0"),
|
|
("TestAlarm003", "Boolean", "0"),
|
|
("InAlarm", "Boolean", "0"),
|
|
]
|
|
|
|
|
|
# ── plan generation (grounded in the real galaxy) ───────────────────────────
|
|
def build_plan(galaxy_json, driver):
|
|
with open(galaxy_json) as f:
|
|
gal = json.load(f)
|
|
machines = [
|
|
o for o in gal["objects"]
|
|
if "$TestMachine" in o.get("templateChain", [])
|
|
]
|
|
machines.sort(key=lambda o: o["tagName"])
|
|
rows = []
|
|
for m in machines:
|
|
have = {a["attributeName"] for a in m["attributes"]}
|
|
for attr, dtype, access in SIGNALS:
|
|
if attr not in have:
|
|
continue # only mirror attributes this instance really has
|
|
rows.append({
|
|
"tag_id": f"{ID_PREFIX}{m['tagName']}-{attr}".lower(),
|
|
"driver_instance_id": driver,
|
|
"name": attr,
|
|
"folder_path": m["tagName"], # → folder; ref = folder.name
|
|
"data_type": dtype,
|
|
"access_level": access,
|
|
"mxaccess_ref": f"{m['tagName']}.{attr}",
|
|
})
|
|
return {
|
|
"source": galaxy_json,
|
|
"driver_instance_id": driver,
|
|
"machines": len(machines),
|
|
"tags": len(rows),
|
|
"rows": rows,
|
|
}
|
|
|
|
|
|
# ── DB helpers ──────────────────────────────────────────────────────────────
|
|
def connect(cfg):
|
|
import pymssql
|
|
conn = pymssql.connect(
|
|
server=cfg["host"], port=str(cfg["port"]), user=cfg["user"],
|
|
password=cfg["password"], database=cfg["database"], autocommit=False,
|
|
)
|
|
cur = conn.cursor()
|
|
# The Tag table has filtered indexes / computed columns; writes require this.
|
|
cur.execute("SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON;")
|
|
return conn, cur
|
|
|
|
|
|
def driver_exists(cur, driver):
|
|
cur.execute(
|
|
"SELECT n.Kind FROM dbo.DriverInstance d "
|
|
"JOIN dbo.Namespace n ON n.NamespaceId = d.NamespaceId "
|
|
"WHERE d.DriverInstanceId = %s", (driver,))
|
|
r = cur.fetchone()
|
|
return r[0] if r else None
|
|
|
|
|
|
# ── commands ────────────────────────────────────────────────────────────────
|
|
def cmd_generate(args):
|
|
plan = build_plan(args.galaxy_json, args.driver)
|
|
with open(LOAD_PLAN, "w") as f:
|
|
json.dump(plan, f, indent=2)
|
|
print(f"plan: {plan['machines']} machines → {plan['tags']} mirror tags (driver {plan['driver_instance_id']})")
|
|
print(f"wrote {LOAD_PLAN}")
|
|
return 0
|
|
|
|
|
|
def cmd_populate(args):
|
|
plan = build_plan(args.galaxy_json, args.driver)
|
|
conn, cur = connect(args.mssql)
|
|
kind = driver_exists(cur, args.driver)
|
|
if kind is None:
|
|
print(f"ERROR: driver instance '{args.driver}' not found in config DB.", file=sys.stderr)
|
|
return 2
|
|
if kind != "SystemPlatform":
|
|
print(f"ERROR: driver '{args.driver}' is in a {kind} namespace; the galaxy mirror needs a "
|
|
f"SystemPlatform/GalaxyMxGateway driver.", file=sys.stderr)
|
|
return 2
|
|
|
|
inserted = updated = 0
|
|
for r in plan["rows"]:
|
|
# Upsert by the SystemPlatform natural key (DriverInstanceId, FolderPath, Name)
|
|
# — the UX_Tag_FolderPath unique index. This adopts any pre-existing seed row for
|
|
# the same ref into our nw-* set (so `clean` can remove it) and stays idempotent on
|
|
# re-run. RowId/RowVersion are server-managed.
|
|
cur.execute(
|
|
"SELECT TagId FROM dbo.Tag WHERE DriverInstanceId=%s AND FolderPath=%s "
|
|
"AND Name=%s AND EquipmentId IS NULL",
|
|
(r["driver_instance_id"], r["folder_path"], r["name"]))
|
|
if cur.fetchone():
|
|
cur.execute(
|
|
"UPDATE dbo.Tag SET TagId=%s, DataType=%s, AccessLevel=%s, "
|
|
"WriteIdempotent=0, TagConfig='{}' "
|
|
"WHERE DriverInstanceId=%s AND FolderPath=%s AND Name=%s AND EquipmentId IS NULL",
|
|
(r["tag_id"], r["data_type"], r["access_level"],
|
|
r["driver_instance_id"], r["folder_path"], r["name"]))
|
|
updated += 1
|
|
else:
|
|
cur.execute(
|
|
"INSERT INTO dbo.Tag (TagRowId, TagId, DriverInstanceId, EquipmentId, "
|
|
"Name, FolderPath, DataType, AccessLevel, WriteIdempotent, TagConfig) "
|
|
"VALUES (NEWID(), %s, %s, NULL, %s, %s, %s, %s, 0, '{}')",
|
|
(r["tag_id"], r["driver_instance_id"], r["name"], r["folder_path"],
|
|
r["data_type"], r["access_level"]))
|
|
inserted += 1
|
|
conn.commit()
|
|
conn.close()
|
|
print(f"populated: {inserted} inserted, {updated} updated "
|
|
f"({plan['tags']} mirror tags across {plan['machines']} machines)")
|
|
print()
|
|
print(">>> NEXT: open the AdminUI, sign in, and click "
|
|
"'Deploy current configuration' to seal + serve the load:")
|
|
print(f" {args.deploy_url}")
|
|
print(">>> then run: otopcua_uns.py verify --wait")
|
|
return 0
|
|
|
|
|
|
def _eq_signal_ids(equipment_id, folder, name):
|
|
"""Deterministic (VirtualTagId, ScriptId) for a company signal. Both carry the
|
|
EQ_ID_PREFIX so `clean` removes exactly what was created. The two ids share the
|
|
same per-signal hash but differ by a kind token so they never collide across the
|
|
global UX_VirtualTag_LogicalId / UX_Script_LogicalId unique indexes. Capped at the
|
|
64-char id column width."""
|
|
base = hashlib.sha1(f"{equipment_id}|{folder}|{name}".encode()).hexdigest()[:20]
|
|
return EQ_ID_PREFIX + "vt-" + base, EQ_ID_PREFIX + "sc-" + base
|
|
|
|
|
|
def cmd_populate_equipment(args):
|
|
"""Load the company-shape Equipment namespace from company-uns.json: a second
|
|
(Equipment-kind) namespace alongside the galaxy mirror, with the Northwind
|
|
Area/Line/Equipment/Signal tree. Each signal is a VirtualTag whose Script mirrors
|
|
the live galaxy-mirror tag for that signal — `return ctx.GetTag("<ref>").Value;` —
|
|
so the company shape streams live VALUES off the same Galaxy source (no driver,
|
|
no BadWaitingForInitialData once the galaxy mirror is up). Idempotent:
|
|
drop-and-recreate of the nw- overlay rows."""
|
|
with open(args.company_json) as f:
|
|
doc = json.load(f)
|
|
u = doc["uns"]
|
|
conn, cur = connect(args.mssql)
|
|
|
|
# Drop any prior overlay (child rows first), then recreate. VirtualTag/Script go
|
|
# before Equipment (VirtualTag.EquipmentId logical-FKs Equipment). Equipment is
|
|
# scoped by its overlay UnsLine ('nw-line-%') — NOT by the EquipmentId, which is now the
|
|
# canonical 'EQ-'+uuid form (see DraftValidator) and no longer carries an 'nw-' prefix.
|
|
cur.execute("DELETE FROM dbo.VirtualTag WHERE VirtualTagId LIKE %s", (EQ_ID_PREFIX + "%",))
|
|
cur.execute("DELETE FROM dbo.Script WHERE ScriptId LIKE %s", (EQ_ID_PREFIX + "%",))
|
|
cur.execute("DELETE FROM dbo.Equipment WHERE UnsLineId LIKE 'nw-line-%'")
|
|
cur.execute("DELETE FROM dbo.UnsLine WHERE UnsLineId LIKE 'nw-line-%'")
|
|
cur.execute("DELETE FROM dbo.UnsArea WHERE UnsAreaId LIKE 'nw-area-%'")
|
|
# Equipment is now driver-less, but purge any driver still bound to the overlay namespace —
|
|
# self-heals environments that ran an older loader which created the 'nw-uns-modbus' placeholder.
|
|
cur.execute("DELETE FROM dbo.DriverInstance WHERE NamespaceId=%s", (EQ_NS,))
|
|
cur.execute("DELETE FROM dbo.Namespace WHERE NamespaceId=%s", (EQ_NS,))
|
|
|
|
cur.execute(
|
|
"INSERT INTO dbo.Namespace (NamespaceRowId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled) "
|
|
"VALUES (NEWID(), %s, %s, 'Equipment', %s, 1)",
|
|
(EQ_NS, EQ_CLUSTER, doc.get("namespace", {}).get("namespaceUri", "urn:northwind:birmingham:uns")))
|
|
|
|
for a in u["unsAreas"]:
|
|
cur.execute("INSERT INTO dbo.UnsArea (UnsAreaRowId, UnsAreaId, ClusterId, Name) VALUES (NEWID(), %s, %s, %s)",
|
|
("nw-" + a["unsAreaId"], EQ_CLUSTER, a["name"]))
|
|
for l in u["unsLines"]:
|
|
cur.execute("INSERT INTO dbo.UnsLine (UnsLineRowId, UnsLineId, UnsAreaId, Name) VALUES (NEWID(), %s, %s, %s)",
|
|
("nw-" + l["unsLineId"], "nw-" + l["unsAreaId"], l["name"]))
|
|
|
|
eq_n = vt_n = 0
|
|
for e in u["equipment"]:
|
|
eq_uuid = uuid.uuid5(uuid.NAMESPACE_URL, "otopcua-nw-eq/" + e["equipmentId"])
|
|
# Canonical EquipmentId: matches OtOpcUa DraftValidator.DeriveEquipmentId
|
|
# ("EQ-" + EquipmentUuid.ToString("N")[..12].ToLowerInvariant()). uuid.UUID.hex is
|
|
# already lowercase, 32 hex chars, no dashes — .hex[:12] is the first 12.
|
|
eq_id = "EQ-" + eq_uuid.hex[:12].lower()
|
|
eq_uuid = str(eq_uuid)
|
|
cur.execute(
|
|
"INSERT INTO dbo.Equipment (EquipmentRowId, EquipmentId, EquipmentUuid, UnsLineId, "
|
|
"Name, MachineCode, Manufacturer, Model, Enabled) VALUES (NEWID(), %s, %s, %s, %s, %s, %s, %s, 1)",
|
|
(eq_id, eq_uuid, "nw-" + e["unsLineId"], e["name"], e["machineCode"],
|
|
e.get("manufacturer"), e.get("model")))
|
|
eq_n += 1
|
|
for t in e["tags"]:
|
|
dtype = _DTYPE_FIX.get(t["dataType"], t["dataType"])
|
|
folder = t.get("folderPath")
|
|
# The galaxy-mirror MXAccess ref (e.g. TestMachine_001.TestDouble) is the upstream
|
|
# the VirtualTag mirrors. DependencyExtractor harvests the literal in ctx.GetTag(),
|
|
# so the engine subscribes to exactly this path on the galaxy-mirror driver.
|
|
full = t["source"]["fullTagReference"]
|
|
vt_id, sc_id = _eq_signal_ids(e["equipmentId"], folder, t["name"])
|
|
source_code = f'return ctx.GetTag("{full}").Value;'
|
|
source_hash = hashlib.sha256(source_code.encode()).hexdigest()
|
|
cur.execute(
|
|
"INSERT INTO dbo.Script (ScriptRowId, ScriptId, Name, SourceCode, SourceHash, Language) "
|
|
"VALUES (NEWID(), %s, %s, %s, %s, 'CSharp')",
|
|
(sc_id, t["name"], source_code, source_hash))
|
|
cur.execute(
|
|
"INSERT INTO dbo.VirtualTag (VirtualTagRowId, VirtualTagId, EquipmentId, Name, DataType, "
|
|
"ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) "
|
|
"VALUES (NEWID(), %s, %s, %s, %s, %s, 1, NULL, %s, 1)",
|
|
(vt_id, eq_id, t["name"], dtype, sc_id, 1 if t.get("historize") else 0))
|
|
vt_n += 1
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
print(f"populated equipment overlay: namespace {EQ_NS} ({EQ_CLUSTER}), "
|
|
f"{len(u['unsAreas'])} areas, {len(u['unsLines'])} lines, {eq_n} equipment, "
|
|
f"{vt_n} VirtualTags (+ {vt_n} mirror Scripts)")
|
|
print()
|
|
print(f">>> NEXT: deploy (headless) — curl -s -X POST {args.deploy_url.replace('/deployments','')}/api/deployments "
|
|
f"-H 'X-Api-Key: {args.deploy_key}'")
|
|
print(">>> then run: otopcua_uns.py verify-equipment --require-good 1036 --wait")
|
|
return 0
|
|
|
|
|
|
def cmd_clean(args):
|
|
conn, cur = connect(args.mssql)
|
|
cur.execute("DELETE FROM dbo.Tag WHERE TagId LIKE %s", (ID_PREFIX + "%",))
|
|
n = cur.rowcount
|
|
# Also drop the company-shape Equipment overlay (child rows first): VirtualTag and
|
|
# Script (both nweq-*) before Equipment, then the rest.
|
|
# Equipment is scoped by its overlay UnsLine ('nw-line-%') — NOT by the EquipmentId,
|
|
# which is now the canonical 'EQ-'+uuid form (see DraftValidator) with no 'nw-' prefix.
|
|
cur.execute("DELETE FROM dbo.VirtualTag WHERE VirtualTagId LIKE %s", (EQ_ID_PREFIX + "%",))
|
|
cur.execute("DELETE FROM dbo.Script WHERE ScriptId LIKE %s", (EQ_ID_PREFIX + "%",))
|
|
cur.execute("DELETE FROM dbo.Equipment WHERE UnsLineId LIKE 'nw-line-%'")
|
|
cur.execute("DELETE FROM dbo.UnsLine WHERE UnsLineId LIKE 'nw-line-%'")
|
|
cur.execute("DELETE FROM dbo.UnsArea WHERE UnsAreaId LIKE 'nw-area-%'")
|
|
# Purge any driver still bound to the overlay namespace (e.g. the legacy 'nw-uns-modbus'
|
|
# placeholder created by an older loader) so 'clean' fully removes the overlay.
|
|
cur.execute("DELETE FROM dbo.DriverInstance WHERE NamespaceId=%s", (EQ_NS,))
|
|
cur.execute("DELETE FROM dbo.Namespace WHERE NamespaceId=%s", (EQ_NS,))
|
|
conn.commit()
|
|
conn.close()
|
|
print(f"removed {n} nw-* mirror tag(s) + the {EQ_NS} equipment overlay. "
|
|
f"Deploy again at {args.deploy_url} to drop them from the address space.")
|
|
return 0
|
|
|
|
|
|
def cmd_status(args):
|
|
conn, cur = connect(args.mssql)
|
|
cur.execute("SELECT COUNT(*) FROM dbo.Tag WHERE TagId LIKE %s", (ID_PREFIX + "%",))
|
|
db_tags = cur.fetchone()[0]
|
|
# Deployment.Status: 2 = Sealed (the snapshot driver nodes apply).
|
|
cur.execute("SELECT TOP 1 RevisionHash, SealedAtUtc FROM dbo.Deployment "
|
|
"WHERE Status=2 ORDER BY SealedAtUtc DESC")
|
|
dep = cur.fetchone()
|
|
conn.close()
|
|
print(f"config DB : {db_tags} mirror tags (nw-*) present")
|
|
print(f"last sealed : {('rev '+dep[0][:12]+'… @ '+str(dep[1])) if dep else '(none)'}")
|
|
folders, variables, good = browse_summary(args.opcua_endpoint)
|
|
print(f"address space : {folders} machine folder(s), {variables} variable(s), {good} value(s) Good on {args.opcua_endpoint}")
|
|
return 0
|
|
|
|
|
|
def cmd_verify(args):
|
|
plan = build_plan(args.galaxy_json, args.driver)
|
|
expected = plan["tags"]
|
|
deadline = time.time() + (args.wait_seconds if args.wait else 0)
|
|
while True:
|
|
folders, variables, good = browse_summary(args.opcua_endpoint)
|
|
ok = variables >= expected and good >= max(1, int(expected * 0.5))
|
|
if ok or time.time() >= deadline:
|
|
break
|
|
print(f" waiting for deploy… ({variables}/{expected} vars, {good} Good)")
|
|
time.sleep(5)
|
|
|
|
print(f"expected mirror tags : {expected}")
|
|
print(f"address-space vars : {variables} (in {folders} folders)")
|
|
print(f"values Good (live) : {good}")
|
|
sample = sample_values(args.opcua_endpoint, 6)
|
|
for nm, val, sc in sample:
|
|
print(f" {nm} = {val} [{sc}]")
|
|
passed = variables >= expected and good >= max(1, int(expected * 0.5))
|
|
print("VERIFY:", "PASS — UNS loaded and live" if passed else "INCOMPLETE — did you Deploy at the AdminUI?")
|
|
return 0 if passed else 1
|
|
|
|
|
|
# ── OPC UA helpers (asyncua) ────────────────────────────────────────────────
|
|
def browse_summary(endpoint):
|
|
import asyncio
|
|
from asyncua import Client
|
|
|
|
async def run():
|
|
folders = variables = good = 0
|
|
async with Client(endpoint) as c:
|
|
for k in await c.nodes.objects.get_children():
|
|
if (await k.read_browse_name()).Name != "OtOpcUa":
|
|
continue
|
|
for f in await k.get_children():
|
|
folders += 1
|
|
for v in await f.get_children():
|
|
variables += 1
|
|
try:
|
|
dv = await v.read_data_value()
|
|
if dv.StatusCode and dv.StatusCode.is_good():
|
|
good += 1
|
|
except Exception:
|
|
pass
|
|
return folders, variables, good
|
|
try:
|
|
return asyncio.run(run())
|
|
except Exception as e:
|
|
return (f"<{type(e).__name__}>", 0, 0)
|
|
|
|
|
|
def sample_values(endpoint, n):
|
|
import asyncio
|
|
from asyncua import Client
|
|
|
|
async def run():
|
|
out = []
|
|
async with Client(endpoint) as c:
|
|
for k in await c.nodes.objects.get_children():
|
|
if (await k.read_browse_name()).Name != "OtOpcUa":
|
|
continue
|
|
for f in await k.get_children():
|
|
for v in await f.get_children():
|
|
try:
|
|
dv = await v.read_data_value()
|
|
sc = dv.StatusCode.name if dv.StatusCode else "?"
|
|
out.append(((await v.read_browse_name()).Name, dv.Value.Value, sc))
|
|
except Exception as e:
|
|
out.append(((await v.read_browse_name()).Name, f"<{type(e).__name__}>", "?"))
|
|
if len(out) >= n:
|
|
return out
|
|
return out
|
|
try:
|
|
return asyncio.run(run())
|
|
except Exception as e:
|
|
return [("<browse error>", str(e), "?")]
|
|
|
|
|
|
def browse_tree(endpoint, max_depth=8, top_prefix=None, read_values=False):
|
|
"""Recursively descend the OtOpcUa address space and count leaf variables, returning
|
|
(folder_count, leaf_count, leaf_paths, good_count). A node with no children is a leaf
|
|
signal — this correctly handles the DEEP Equipment UNS tree
|
|
(Area/Line/Equipment/[FolderPath]/Signal), unlike browse_summary which assumes the flat
|
|
2-level Galaxy hierarchy. When top_prefix is set, only top-level OtOpcUa folders whose
|
|
browse name starts with it are counted (e.g. 'nw-area-' scopes to the company Equipment
|
|
overlay, excluding the Galaxy mirror folders). When read_values is True, each leaf's value
|
|
is read and good_count tallies the Good-quality ones (else good_count is 0)."""
|
|
import asyncio
|
|
from asyncua import Client
|
|
|
|
async def maybe_good(node, acc):
|
|
if not read_values:
|
|
return
|
|
try:
|
|
dv = await node.read_data_value()
|
|
if dv.StatusCode and dv.StatusCode.is_good():
|
|
acc["good"] += 1
|
|
except Exception:
|
|
pass
|
|
|
|
async def walk(node, path, depth, acc):
|
|
if depth >= max_depth:
|
|
return
|
|
for ch in await node.get_children():
|
|
try:
|
|
name = (await ch.read_browse_name()).Name
|
|
except Exception:
|
|
continue
|
|
child_path = path + "/" + name
|
|
grandkids = await ch.get_children()
|
|
if grandkids:
|
|
acc["folders"] += 1
|
|
await walk(ch, child_path, depth + 1, acc)
|
|
else:
|
|
acc["leaves"] += 1
|
|
acc["paths"].append(child_path)
|
|
await maybe_good(ch, acc)
|
|
|
|
async def run():
|
|
acc = {"folders": 0, "leaves": 0, "paths": [], "good": 0}
|
|
async with Client(endpoint) as c:
|
|
for k in await c.nodes.objects.get_children():
|
|
if (await k.read_browse_name()).Name != "OtOpcUa":
|
|
continue
|
|
for top in await k.get_children():
|
|
tn = (await top.read_browse_name()).Name
|
|
if top_prefix and not tn.startswith(top_prefix):
|
|
continue
|
|
if await top.get_children():
|
|
acc["folders"] += 1
|
|
await walk(top, "OtOpcUa/" + tn, 1, acc)
|
|
else:
|
|
acc["leaves"] += 1
|
|
acc["paths"].append("OtOpcUa/" + tn)
|
|
await maybe_good(top, acc)
|
|
return acc["folders"], acc["leaves"], acc["paths"], acc["good"]
|
|
try:
|
|
return asyncio.run(run())
|
|
except Exception as e:
|
|
return (f"<{type(e).__name__}: {e}>", 0, [], 0)
|
|
|
|
|
|
def cmd_verify_equipment(args):
|
|
"""Browse the full UNS tree by friendly Area/Line/Equipment/Signal names and report the leaf
|
|
signal count. With --expect N, exit non-zero unless exactly N leaf signals are present (the
|
|
equipment-namespace structure-materialisation check). With --require-good N (>0), also read
|
|
each leaf's value and require at least N Good ones (the live-VALUE check for the VirtualTag
|
|
overlay) — back-compat default 0 = structure-only. --wait polls so it can wait for the deploy
|
|
+ change-triggered VirtualTag evaluations to land."""
|
|
top_prefix = None if args.all else "nw-area-"
|
|
scope = "whole address space" if args.all else "company overlay (nw-area-*)"
|
|
read_values = args.require_good > 0
|
|
deadline = time.time() + (args.wait_seconds if args.wait else 0)
|
|
while True:
|
|
folders, leaves, paths, good = browse_tree(
|
|
args.opcua_endpoint, top_prefix=top_prefix, read_values=read_values)
|
|
struct_ok = args.expect is None or leaves == args.expect
|
|
good_ok = good >= args.require_good
|
|
if (struct_ok and good_ok) or time.time() >= deadline:
|
|
break
|
|
print(f" waiting for deploy/values… ({leaves} leaves"
|
|
+ (f", {good} Good" if read_values else "") + ")")
|
|
time.sleep(5)
|
|
|
|
suffix = f", {good} Good value(s)" if read_values else ""
|
|
print(f"equipment tree : {folders} folder(s), {leaves} leaf signal(s){suffix} "
|
|
f"on {args.opcua_endpoint} [{scope}]")
|
|
for p in sorted(paths)[:args.show]:
|
|
print(f" {p}")
|
|
if len(paths) > args.show:
|
|
print(f" … and {len(paths) - args.show} more")
|
|
|
|
passed = True
|
|
if args.expect is not None:
|
|
struct_ok = leaves == args.expect
|
|
passed = passed and struct_ok
|
|
print(" structure :",
|
|
f"PASS ({leaves} == {args.expect})" if struct_ok
|
|
else f"FAIL (expected {args.expect}, found {leaves})")
|
|
if args.require_good > 0:
|
|
good_ok = good >= args.require_good
|
|
passed = passed and good_ok
|
|
print(" live good :",
|
|
f"PASS ({good} >= {args.require_good})" if good_ok
|
|
else f"FAIL (expected >= {args.require_good} Good, found {good})")
|
|
if args.expect is None and args.require_good == 0:
|
|
return 0
|
|
print("VERIFY-EQUIPMENT:", "PASS" if passed else "FAIL")
|
|
return 0 if passed else 1
|
|
|
|
|
|
# ── arg parsing ─────────────────────────────────────────────────────────────
|
|
def main(argv):
|
|
p = argparse.ArgumentParser(description="Reloadable populate + verify for the OtOpcUa galaxy UNS.")
|
|
p.add_argument("--galaxy-json", default=DEF_GALAXY_JSON)
|
|
p.add_argument("--driver", default=DEF_DRIVER, help="SystemPlatform GalaxyMxGateway driver instance id")
|
|
p.add_argument("--opcua-endpoint", default=DEF_OPCUA)
|
|
p.add_argument("--deploy-url", default="http://localhost:9200/deployments")
|
|
p.add_argument("--deploy-key", default=os.environ.get("OTOPCUA_DEPLOY_KEY", "docker-dev-deploy-key"),
|
|
help="X-Api-Key for the headless POST /api/deployments endpoint")
|
|
p.add_argument("--company-json", default=DEF_COMPANY_JSON)
|
|
p.add_argument("--sql-host", default=DEF_MSSQL["host"])
|
|
p.add_argument("--sql-port", type=int, default=DEF_MSSQL["port"])
|
|
p.add_argument("--sql-user", default=DEF_MSSQL["user"])
|
|
p.add_argument("--sql-password", default=DEF_MSSQL["password"])
|
|
p.add_argument("--sql-db", default=DEF_MSSQL["database"])
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
sub.add_parser("generate")
|
|
sub.add_parser("populate")
|
|
sub.add_parser("populate-equipment",
|
|
help="load the company-shape Equipment namespace from company-uns.json (structure-only)")
|
|
sub.add_parser("clean")
|
|
sub.add_parser("status")
|
|
vp = sub.add_parser("verify")
|
|
vp.add_argument("--wait", action="store_true", help="poll until the deploy lands")
|
|
vp.add_argument("--wait-seconds", type=int, default=120)
|
|
ep = sub.add_parser("verify-equipment",
|
|
help="recursively browse the Equipment UNS tree + count leaf signals "
|
|
"(+ optionally assert live Good values)")
|
|
ep.add_argument("--expect", type=int, default=None, help="assert exactly N leaf signals")
|
|
ep.add_argument("--require-good", type=int, default=0,
|
|
help="read each leaf's value and require >= N Good ones (0 = structure-only, default)")
|
|
ep.add_argument("--show", type=int, default=20, help="how many leaf paths to print")
|
|
ep.add_argument("--all", action="store_true",
|
|
help="count the whole address space (default: only the nw-area-* company overlay)")
|
|
ep.add_argument("--wait", action="store_true",
|
|
help="poll until the deploy lands + (with --require-good) values go Good")
|
|
ep.add_argument("--wait-seconds", type=int, default=120)
|
|
|
|
a = p.parse_args(argv)
|
|
a.mssql = dict(host=a.sql_host, port=a.sql_port, user=a.sql_user,
|
|
password=a.sql_password, database=a.sql_db)
|
|
return {
|
|
"generate": cmd_generate, "populate": cmd_populate,
|
|
"populate-equipment": cmd_populate_equipment, "clean": cmd_clean,
|
|
"status": cmd_status, "verify": cmd_verify, "verify-equipment": cmd_verify_equipment,
|
|
}[a.cmd](a)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|