mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
01d6b5d26c
> Tracking issue #4576. #4577 is merged, so this branch is rebased onto > `master` and contains only the Machinery commits. ## Summary Second of four stacked PRs. Adds the OPC UA for Machinery series as model packages plus a server and a client SDK, following the companion pattern of DI and Robotics, and the two models it depends on that the repository does not carry yet. | Package | Content | |---|---| | `Opc.Ua.IA` | OPC 10000-200 IA 1.01.4, **moved out of `Opc.Ua.Robotics`** (see below) | | `Opc.Ua.ECM` | OPC 34100 Energy Consumption Management 1.0.1 | | `Opc.Ua.PADIM` | OPC 30081 PA-DIM 1.02.0 with the IRDI dictionary | | `Opc.Ua.Machinery` | OPC 40001-1 Basic Building Blocks 1.04.1 | | `Opc.Ua.Machinery.ProcessValues` | OPC 40001-2 Process Values 1.00.0 | | `Opc.Ua.Machinery.Jobs` | OPC 40001-3 Job Management 1.0.1 | | `Opc.Ua.Machinery.Energy` | OPC 40001-4 Energy 1.00 | | `Opc.Ua.Machinery.Result` | OPC 40001-101 Result Transfer 1.01.0 | | `Opc.Ua.Machinery.Server` | `AddMachinery()` / `ConfigureMachinery(...)`, fluent machine builder, server-driven state machines, process values, job management over the shared ISA-95 V2 binder, energy, result management incl. `GenerateFileForRead` | | `Opc.Ua.Machinery.Client` | machine discovery, identification, state observation, process values, job and result access | OPC 40001-101 `ResultTransferType` redeclares `GenerateFileForRead` with its own arguments; the generator fix for such overrides, merged with #4577, is what makes it appear on the `ResultTransfer` instances. All NodeSets are byte-identical to `UA-Nodeset/latest`. Each model carries a pinned `Model/*.NodeIds.csv` the generator validates, next to the unmodified upstream table; `tools/nodesets/derive-identifier-table.py` derives the former from the latter. ## Changes to existing code - **IA moves out of Robotics.** Machinery needs IA as well (OPC 40001-1 types `Stacklight` with `BasicStacklightType`), and two assemblies generating the same model under the same prefix collide at any consumer referencing both. The NodeSet moves verbatim, so no generated type changes; the prefix stays `Opc.Ua.IA`. Source-compatible, but IA types now live in `Opc.Ua.IA.dll` rather than `Opc.Ua.Robotics.dll` — binary-breaking for consumers of the Robotics package. - **`samples/OpenUsd/GeneratorServer`** drops its hand-reduced Machinery NodeSet copy and the script that produced it (a workaround for the generator defect fixed by #4577) and references `Opc.Ua.Machinery` instead. - `README.md` and `docs/README.md` list the new models; the README also corrects the Industrial Automation entry (Part 200, not OPC 40001-1). ## Review follow-ups Separate commits after the docs/samples commit, so the reviewed state stays comparable. Each fix comes with a test that fails without it. - Copilot review: a transition is committed only while the state machine is still in its source state; optional Jobs/Result namespaces the server does not load are reported as absent, as documented; resources of registered machines (result transfer, job-management change pumps) are released with the node manager (`DisposeMachineryResourcesAsync` for a custom DI manager); `NodeId`/`LocalizedText` use their null sentinels instead of `Nullable<T>`. - Own review with the rubric in `.github/copilot-instructions.md`: a failed browse call propagates from the client instead of reading as "no children"; the predefined result metadata compares `CreationTime` against the `DateTimeUtc` sentinels (the `DateTime` conversion shifted `DateTime.MaxValue` by the host's UTC offset); process values below `Monitoring` are found; a second metering point named `Main` is rejected; the zero-point event is reported when the handler throws; smaller state-sharing and documentation corrections. ## Testing - `tests/Opc.Ua.Machinery.Tests`: 162 tests on net8 and net10 and with the netstandard2.1 libraries; the integration tests connect a real client session over `opc.tcp` to a hosted server. - With IA extracted: Robotics 636, OpenUsd 921, DI 433 and SourceGeneration 263 tests pass. - `UA.slnx` builds for net472, net48, netstandard2.0/2.1 and net8/9/10 with SDK 10.0.401 and no warnings. - Changed-line coverage of this layer (`check-coverage.ps1`): 91.68 % (6059 of 6609 changed lines, against `master`). - Fork CI, full PR matrix incl. macOS, on the current master: green, all 184 jobs ([run 36582685653](https://github.com/biancode/UA-.NETStandard/actions/runs/36582685653)). CodeQL: green ([run 36582689589](https://github.com/biancode/UA-.NETStandard/actions/runs/36582689589)). - Beyond the in-repo tests the implementation has been validated end to end against a separate, non-public OPC UA client and server. ## Reviewing Commits are ordered so each builds: IA extraction → identifier-table tool → ECM/PA-DIM → Machinery models → server → client → tests → docs/samples → GeneratorServer sample → review follow-ups. The model commits are large only because of the NodeSets and identifier tables; the hand-written code starts at the server commit. --------- Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
309 lines
12 KiB
Python
Executable File
309 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Derives a source-generator-compatible identifier table from a NodeSet2 file.
|
|
|
|
The OPC Foundation publishes a *.NodeIds.csv next to every NodeSet2 document.
|
|
Its numeric ids and node classes are authoritative, but its symbolic names follow
|
|
the UA-ModelCompiler's conventions, which differ from the ones this repository's
|
|
model source generator derives. Feeding the published table to the generator as
|
|
a ModelSourceGeneratorIdentifierFile therefore fails with MODELGEN025.
|
|
|
|
This script recomputes each symbolic name with the generator's own rules
|
|
(NodeSetToModelDesign.ImportSymbolicName / BuildSymbolicId) and emits a repaired
|
|
table. It also reports every row whose numeric id or node class disagrees with
|
|
the NodeSet, which is a real upstream defect rather than a naming convention.
|
|
|
|
Usage:
|
|
derive-identifier-table.py <nodeset.xml> <upstream.NodeIds.csv> <out.NodeIds.csv>
|
|
"""
|
|
|
|
import sys
|
|
import csv
|
|
import xml.etree.ElementTree as ET
|
|
|
|
NS = "{http://opcfoundation.org/UA/2011/03/UANodeSet.xsd}"
|
|
|
|
# NodeSetToModelDesign.s_keywords - the C# keywords a symbolic name may not be.
|
|
KEYWORDS = {
|
|
"private", "public", "protected", "internal", "lock", "char", "byte", "int",
|
|
"uint", "long", "ulong", "short", "ushort", "float", "double", "decimal",
|
|
"bool", "string", "object", "void", "class", "struct", "enum", "interface",
|
|
"namespace", "using", "static", "readonly", "const", "new", "override",
|
|
"virtual", "abstract", "sealed", "partial", "base", "this", "null", "true",
|
|
"false", "if", "else", "switch", "case", "default", "for", "foreach",
|
|
"while", "do", "break", "continue", "return", "goto", "try", "catch",
|
|
"finally", "throw", "operator", "params", "ref", "out", "in", "is", "as",
|
|
"typeof", "sizeof", "checked", "unchecked", "unsafe", "fixed", "delegate",
|
|
"event", "explicit", "implicit", "extern", "stackalloc", "volatile",
|
|
}
|
|
|
|
DATA_TYPE_ENCODING_TYPE = "i=76"
|
|
HAS_TYPE_DEFINITION = "i=40"
|
|
HAS_ENCODING = "i=38"
|
|
HAS_PROPERTY = "i=46"
|
|
HAS_COMPONENT = "i=47"
|
|
|
|
INSTANCE_TAGS = {"UAObject", "UAVariable", "UAMethod", "UAView"}
|
|
|
|
|
|
def to_symbolic_name(name):
|
|
"""Port of NodeSetToModelDesign.ToSymbolicName."""
|
|
if name in KEYWORDS:
|
|
name = "_" + name
|
|
out = []
|
|
for ch in name:
|
|
if not ch.isalnum():
|
|
out.append("x" if not out else "_")
|
|
continue
|
|
if not out and ch.isdigit():
|
|
out.append("n")
|
|
out.append(ch)
|
|
return "".join(out)
|
|
|
|
|
|
def browse_name_parts(browse_name):
|
|
"""Splits 'ns:Name' and applies ImportQualifiedName's <> rewrite to Name."""
|
|
raw = browse_name or ""
|
|
if ":" in raw:
|
|
prefix, _, rest = raw.partition(":")
|
|
if prefix.isdigit():
|
|
raw_name = rest
|
|
else:
|
|
raw_name = raw
|
|
else:
|
|
raw_name = raw
|
|
return raw_name, raw_name.replace("<", "_").replace(">", "_")
|
|
|
|
|
|
class NodeSet:
|
|
def __init__(self, path):
|
|
self.root = ET.parse(path).getroot()
|
|
# A NodeSet writes reference types through the <Aliases> table, so every
|
|
# ReferenceType attribute has to be resolved before it can be compared
|
|
# with a NodeId.
|
|
self.aliases = {}
|
|
alias_table = self.root.find(NS + "Aliases")
|
|
if alias_table is not None:
|
|
for alias in alias_table:
|
|
self.aliases[alias.get("Alias")] = (alias.text or "").strip()
|
|
self.by_id = {}
|
|
self.nodes = []
|
|
for node in self.root:
|
|
tag = node.tag.replace(NS, "")
|
|
if not tag.startswith("UA"):
|
|
continue
|
|
node_id = node.get("NodeId")
|
|
if node_id is None:
|
|
continue
|
|
self.by_id[node_id] = node
|
|
self.nodes.append(node)
|
|
self._normalize_encoding_symbolic_names()
|
|
self._infer_missing_parents()
|
|
self._cache = {}
|
|
|
|
def tag(self, node):
|
|
return node.tag.replace(NS, "")
|
|
|
|
def refs(self, node):
|
|
container = node.find(NS + "References")
|
|
return list(container) if container is not None else []
|
|
|
|
def resolve(self, reference_type):
|
|
return self.aliases.get(reference_type, reference_type)
|
|
|
|
def target(self, node, reference_type, is_forward=True):
|
|
for ref in self.refs(node):
|
|
if self.resolve(ref.get("ReferenceType")) != reference_type:
|
|
continue
|
|
forward = ref.get("IsForward", "true") != "false"
|
|
if forward == is_forward:
|
|
return (ref.text or "").strip()
|
|
return None
|
|
|
|
def _normalize_encoding_symbolic_names(self):
|
|
"""Port of the DefaultBinary / DefaultXml fixups in GetImportedSymbols."""
|
|
for node in self.nodes:
|
|
if self.target(node, HAS_TYPE_DEFINITION) != DATA_TYPE_ENCODING_TYPE:
|
|
continue
|
|
symbolic = node.get("SymbolicName")
|
|
if not symbolic:
|
|
_, name = browse_name_parts(node.get("BrowseName"))
|
|
if name == "Default Binary":
|
|
node.set("SymbolicName", "DefaultBinary")
|
|
elif name == "Default XML":
|
|
node.set("SymbolicName", "DefaultXml")
|
|
elif symbolic == "DefaultXML":
|
|
node.set("SymbolicName", "DefaultXml")
|
|
|
|
def _infer_missing_parents(self):
|
|
"""Port of the inverse HasProperty/HasComponent parent inference."""
|
|
for node in self.nodes:
|
|
if self.tag(node) not in INSTANCE_TAGS:
|
|
continue
|
|
if node.get("ParentNodeId"):
|
|
continue
|
|
for ref in self.refs(node):
|
|
if ref.get("IsForward", "true") != "false":
|
|
continue
|
|
if self.resolve(ref.get("ReferenceType")) in (HAS_PROPERTY, HAS_COMPONENT):
|
|
node.set("ParentNodeId", (ref.text or "").strip())
|
|
break
|
|
|
|
def namespace_index(self, node_id):
|
|
if node_id and node_id.startswith("ns="):
|
|
return int(node_id[3:node_id.index(";")])
|
|
return 0
|
|
|
|
def import_symbolic_name(self, node):
|
|
"""Port of NodeSetToModelDesign.ImportSymbolicName."""
|
|
raw_name, rewritten = browse_name_parts(node.get("BrowseName"))
|
|
symbolic = node.get("SymbolicName")
|
|
if symbolic:
|
|
if ":<" in (node.get("BrowseName") or "") and not symbolic.endswith("_Placeholder"):
|
|
return symbolic + "_Placeholder"
|
|
return symbolic
|
|
if len(raw_name) > 2 and raw_name[0] == "<" and raw_name[-1] == ">":
|
|
return to_symbolic_name(raw_name[1:-1]) + "_Placeholder"
|
|
return to_symbolic_name(rewritten)
|
|
|
|
def import_and_fix_symbolic_name(self, node):
|
|
"""Port of NodeSetToModelDesign.ImportAndFixSymbolicName."""
|
|
name = self.import_symbolic_name(node)
|
|
if self.target(node, HAS_TYPE_DEFINITION) != DATA_TYPE_ENCODING_TYPE:
|
|
return name
|
|
data_type_id = self.target(node, HAS_ENCODING, is_forward=False)
|
|
if not data_type_id:
|
|
encoding_id = node.get("NodeId")
|
|
for candidate in self.nodes:
|
|
if self.tag(candidate) != "UADataType":
|
|
continue
|
|
for ref in self.refs(candidate):
|
|
if (self.resolve(ref.get("ReferenceType")) == HAS_ENCODING
|
|
and ref.get("IsForward", "true") != "false"
|
|
and (ref.text or "").strip() == encoding_id):
|
|
return f"{self.import_symbolic_name(candidate)}_Encoding_{name}"
|
|
return name
|
|
data_type = self.by_id.get(data_type_id)
|
|
if data_type is not None:
|
|
return f"{self.import_symbolic_name(data_type)}_Encoding_{name}"
|
|
return name
|
|
|
|
def build_symbolic_id(self, node):
|
|
"""Port of NodeSetToModelDesign.BuildSymbolicId."""
|
|
node_id = node.get("NodeId")
|
|
cached = self._cache.get(node_id)
|
|
if cached is not None:
|
|
return cached
|
|
if self.tag(node) not in INSTANCE_TAGS:
|
|
result = self.import_and_fix_symbolic_name(node)
|
|
else:
|
|
parent_id = node.get("ParentNodeId")
|
|
if not parent_id:
|
|
result = self.import_and_fix_symbolic_name(node)
|
|
elif self.namespace_index(parent_id) != self.namespace_index(node_id):
|
|
result = self.import_and_fix_symbolic_name(node)
|
|
else:
|
|
parent = self.by_id.get(parent_id)
|
|
if parent is None:
|
|
raise SystemExit(
|
|
f"Parent node ({parent_id}) for {node_id} not found.")
|
|
result = f"{self.build_symbolic_id(parent)}_{self.import_symbolic_name(node)}"
|
|
self._cache[node_id] = result
|
|
return result
|
|
|
|
def symbolic_ids(self):
|
|
"""Port of GetImportedSymbols, including its collision disambiguation."""
|
|
assigned = {}
|
|
taken = set()
|
|
for node in self.nodes:
|
|
symbolic_id = self.build_symbolic_id(node)
|
|
while symbolic_id in taken:
|
|
symbolic_id = f"{symbolic_id}_{node.get('NodeId').split('i=')[-1]}"
|
|
taken.add(symbolic_id)
|
|
assigned[node.get("NodeId")] = symbolic_id
|
|
return assigned
|
|
|
|
|
|
def read_upstream(path):
|
|
"""Yields (symbolic name, numeric id, node class) from a published table.
|
|
|
|
The OPC Foundation does not publish these tables in one shape. Most are
|
|
comma separated as `SymbolicName,NodeId,NodeClass`; PADIM's is tab
|
|
separated with an `ID<TAB>Browsename<TAB>Node Class` header, i.e. the
|
|
identifier comes first. Both are accepted here rather than being
|
|
hand-converted, so re-vendoring a model stays a single command.
|
|
"""
|
|
with open(path, encoding="utf-8-sig", newline="") as handle:
|
|
text = handle.read()
|
|
|
|
delimiter = "\t" if "\t" in text.split("\n")[0] else ","
|
|
identifier_first = False
|
|
|
|
for record in csv.reader(text.splitlines(), delimiter=delimiter):
|
|
if len(record) != 3:
|
|
continue
|
|
first, second, third = (field.strip() for field in record)
|
|
lowered = first.lower()
|
|
if lowered in ("symbolicname", "id", "browsename"):
|
|
identifier_first = lowered == "id"
|
|
continue
|
|
if identifier_first:
|
|
numeric, name, node_class = first, second, third
|
|
else:
|
|
name, numeric, node_class = first, second, third
|
|
if not numeric.isdigit():
|
|
continue
|
|
yield name, int(numeric), node_class
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 4:
|
|
print(__doc__)
|
|
return 2
|
|
|
|
nodeset_path, upstream_path, out_path = sys.argv[1:4]
|
|
nodeset = NodeSet(nodeset_path)
|
|
target_uri = nodeset.root.find(NS + "Models").find(NS + "Model").get("ModelUri")
|
|
uris = [u.text for u in nodeset.root.find(NS + "NamespaceUris")]
|
|
target_index = uris.index(target_uri) + 1
|
|
|
|
symbolic_ids = nodeset.symbolic_ids()
|
|
by_numeric = {}
|
|
for node_id, symbolic in symbolic_ids.items():
|
|
prefix = f"ns={target_index};i="
|
|
if node_id.startswith(prefix):
|
|
numeric = int(node_id[len(prefix):])
|
|
by_numeric[numeric] = (symbolic, nodeset.tag(node_id and nodeset.by_id[node_id])[2:])
|
|
|
|
rows, renamed, defects = [], 0, []
|
|
for name, numeric, node_class in read_upstream(upstream_path):
|
|
if numeric not in by_numeric:
|
|
defects.append(f" id {numeric} ({name}) is not in the NodeSet")
|
|
continue
|
|
derived, derived_class = by_numeric[numeric]
|
|
if derived_class != node_class:
|
|
defects.append(
|
|
f" id {numeric} ({name}): NodeSet says {derived_class}, table says {node_class}")
|
|
if derived != name:
|
|
renamed += 1
|
|
rows.append((derived, numeric, derived_class))
|
|
|
|
covered = {numeric for _, numeric, _ in rows}
|
|
missing = sorted(set(by_numeric) - covered)
|
|
|
|
with open(out_path, "w", encoding="utf-8", newline="\n") as handle:
|
|
handle.write("SymbolicName,NodeId,NodeClass\n")
|
|
for name, numeric, node_class in rows:
|
|
handle.write(f"{name},{numeric},{node_class}\n")
|
|
|
|
print(f"wrote {out_path}: {len(rows)} row(s), {renamed} renamed, "
|
|
f"{len(missing)} NodeSet node(s) not in the upstream table")
|
|
if missing:
|
|
print(" not in table:", ", ".join(str(m) for m in missing[:20]))
|
|
for defect in defects:
|
|
print("DEFECT" + defect)
|
|
return 1 if defects else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|