devagent-ignition 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devagent_hmi.py +471 -0
- devagent_ignition-0.1.0.dist-info/METADATA +204 -0
- devagent_ignition-0.1.0.dist-info/RECORD +133 -0
- devagent_ignition-0.1.0.dist-info/WHEEL +4 -0
- devagent_ignition-0.1.0.dist-info/entry_points.txt +2 -0
- devagent_spatial/__init__.py +1 -0
- devagent_spatial/_resources/README.md +187 -0
- devagent_spatial/_resources/RELEASE_MANIFEST.json +312 -0
- devagent_spatial/_resources/SHA256SUMS.txt +270 -0
- devagent_spatial/_resources/SOURCE_PROVENANCE.json +186 -0
- devagent_spatial/_resources/assets/agent.js +102 -0
- devagent_spatial/_resources/assets/creator.css +247 -0
- devagent_spatial/_resources/assets/creator_2d.mjs +16 -0
- devagent_spatial/_resources/assets/creator_original_r9.mjs +58 -0
- devagent_spatial/_resources/assets/creator_original_v8.mjs +120 -0
- devagent_spatial/_resources/assets/creator_r9_diagnostics.mjs +53 -0
- devagent_spatial/_resources/assets/creator_scene.mjs +128 -0
- devagent_spatial/_resources/assets/creator_ui.mjs +60 -0
- devagent_spatial/_resources/assets/customer_documents.js +110 -0
- devagent_spatial/_resources/assets/equipment_controls.mjs +27 -0
- devagent_spatial/_resources/assets/facility2d.css +24 -0
- devagent_spatial/_resources/assets/facility2d_app.mjs +45 -0
- devagent_spatial/_resources/assets/facility2d_core.mjs +114 -0
- devagent_spatial/_resources/assets/facility2d_simulation.mjs +167 -0
- devagent_spatial/_resources/assets/facility2d_viewer.html +7 -0
- devagent_spatial/_resources/assets/fallback_scene.js +17 -0
- devagent_spatial/_resources/assets/flow_geometry.mjs +218 -0
- devagent_spatial/_resources/assets/harness.js +78 -0
- devagent_spatial/_resources/assets/live_design_review.js +129 -0
- devagent_spatial/_resources/assets/operations_ui.js +305 -0
- devagent_spatial/_resources/assets/operator_viewer.html +64 -0
- devagent_spatial/_resources/assets/project_package.js +52 -0
- devagent_spatial/_resources/assets/r9_transport.mjs +70 -0
- devagent_spatial/_resources/assets/runtime_feed.js +30 -0
- devagent_spatial/_resources/assets/studio.html +139 -0
- devagent_spatial/_resources/assets/style.css +191 -0
- devagent_spatial/_resources/assets/test_workspace.html +195 -0
- devagent_spatial/_resources/assets/three_scene.js +442 -0
- devagent_spatial/_resources/assets/unified_engineer.js +101 -0
- devagent_spatial/_resources/assets/v076_fat_ui.js +40 -0
- devagent_spatial/_resources/assets/v078_command_ui.js +44 -0
- devagent_spatial/_resources/assets/viewer.html +45 -0
- devagent_spatial/_resources/assets/viewer.js +112 -0
- devagent_spatial/_resources/design-system.json +130 -0
- devagent_spatial/_resources/examples/CV101-command-contract-PENDING.json +30 -0
- devagent_spatial/_resources/examples/cv101_guided.json +272 -0
- devagent_spatial/_resources/examples/make_examples.py +36 -0
- devagent_spatial/_resources/examples/single_floor.json +444 -0
- devagent_spatial/_resources/examples/v0771_training_wizard.json +11 -0
- devagent_spatial/_resources/examples/warehouse-3floor-standard-2d.json +708 -0
- devagent_spatial/_resources/examples/warehouse-operations-synthetic.json +1186 -0
- devagent_spatial/_resources/examples/warehouse-v07-lab-bindings-PENDING.json +372 -0
- devagent_spatial/_resources/examples/warehouse.json +708 -0
- devagent_spatial/_resources/examples/water.json +288 -0
- devagent_spatial/_resources/reference/r9-golden/REFERENCE_SHA256.json +13 -0
- devagent_spatial/_resources/reference/r9-golden/web/app.mjs +745 -0
- devagent_spatial/_resources/reference/r9-golden/web/index.html +213 -0
- devagent_spatial/_resources/reference/r9-golden/web/lab_panel.mjs +53 -0
- devagent_spatial/_resources/reference/r9-golden/web/model.mjs +367 -0
- devagent_spatial/_resources/reference/r9-golden/web/state_adapter.mjs +78 -0
- devagent_spatial/_resources/reference/r9-golden/web/styles.css +76 -0
- devagent_spatial/_resources/reference/r9-original/README_R9.md +27 -0
- devagent_spatial/_resources/reference/r9-original/SHA256SUMS.txt +63 -0
- devagent_spatial/_resources/reference/r9-original/SNAPSHOT_SHA256SUMS.txt +16 -0
- devagent_spatial/_resources/reference/r9-original/flow_routes.json +527 -0
- devagent_spatial/_resources/reference/r9-original/flow_sim.py +117 -0
- devagent_spatial/_resources/reference/r9-original/lab_control.py +73 -0
- devagent_spatial/_resources/reference/r9-original/web/app.mjs +745 -0
- devagent_spatial/_resources/reference/r9-original/web/index.html +213 -0
- devagent_spatial/_resources/reference/r9-original/web/lab_panel.mjs +53 -0
- devagent_spatial/_resources/reference/r9-original/web/model.mjs +367 -0
- devagent_spatial/_resources/reference/r9-original/web/state_adapter.mjs +78 -0
- devagent_spatial/_resources/reference/r9-original/web/styles.css +76 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/LICENSE-three.txt +21 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/SHA256SUMS.txt +4 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/addons/controls/OrbitControls.js +1860 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/three.core.js +58773 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/three.module.js +18251 -0
- devagent_spatial/_resources/reference/v8-original/README.md +36 -0
- devagent_spatial/_resources/reference/v8-original/SHA256SUMS.txt +12 -0
- devagent_spatial/_resources/reference/v8-original/model.py +124 -0
- devagent_spatial/_resources/reference/v8-original/run_demo.sh +4 -0
- devagent_spatial/_resources/reference/v8-original/server.py +100 -0
- devagent_spatial/_resources/reference/v8-original/tests/browser_smoke.py +50 -0
- devagent_spatial/_resources/reference/v8-original/tests/test_v8.py +144 -0
- devagent_spatial/_resources/reference/v8-original/topology.json +24 -0
- devagent_spatial/_resources/reference/v8-original/web/app.js +114 -0
- devagent_spatial/_resources/reference/v8-original/web/index.html +22 -0
- devagent_spatial/_resources/reference/v8-original/web/simulator.html +1 -0
- devagent_spatial/_resources/reference/v8-original/web/simulator.js +6 -0
- devagent_spatial/_resources/reference/v8-original/web/style.css +16 -0
- devagent_spatial/_resources/sample_customer_inputs/Plant_Equipment_Register.csv +4 -0
- devagent_spatial/_resources/sample_customer_inputs/Plant_Flow_Requirements.txt +5 -0
- devagent_spatial/_resources/sample_customer_inputs/Raw_PLC_Tag_Export.csv +4 -0
- devagent_spatial/_resources/templates/warehouse_connections.csv +12 -0
- devagent_spatial/_resources/templates/warehouse_equipment.csv +21 -0
- devagent_spatial/_resources/templates/warehouse_signals.csv +34 -0
- devagent_spatial/_resources/templates/water_connections.csv +7 -0
- devagent_spatial/_resources/templates/water_equipment.csv +10 -0
- devagent_spatial/_resources/templates/water_signals.csv +12 -0
- devagent_spatial/agent_harness.py +299 -0
- devagent_spatial/agent_runtime.py +314 -0
- devagent_spatial/contract_opc_fat.py +216 -0
- devagent_spatial/contract_opc_source.py +265 -0
- devagent_spatial/control_intents.py +38 -0
- devagent_spatial/customer_intake.py +235 -0
- devagent_spatial/engineering_expertise.py +114 -0
- devagent_spatial/generator.py +201 -0
- devagent_spatial/ignition_native.py +234 -0
- devagent_spatial/ignition_project.py +144 -0
- devagent_spatial/live_lab_binding.py +128 -0
- devagent_spatial/model.py +297 -0
- devagent_spatial/native_reference.py +69 -0
- devagent_spatial/opc_snapshot.py +87 -0
- devagent_spatial/operations.py +284 -0
- devagent_spatial/operations_capture.py +47 -0
- devagent_spatial/operations_lab.py +64 -0
- devagent_spatial/original_r9.py +169 -0
- devagent_spatial/original_v8.py +81 -0
- devagent_spatial/preflight.py +140 -0
- devagent_spatial/preview_simulation.py +189 -0
- devagent_spatial/project_package.py +101 -0
- devagent_spatial/qualification.py +152 -0
- devagent_spatial/strict_json.py +29 -0
- devagent_spatial/studio.py +894 -0
- devagent_spatial/studio_workflow.py +277 -0
- devagent_spatial/test_reporting.py +87 -0
- devagent_spatial/v076_opc_fat.py +180 -0
- devagent_spatial/v078_commands.py +277 -0
- devagent_spatial/v078_native_commands.py +161 -0
- devagent_spatial/v07_fat.py +110 -0
- devagent_spatial/v07_native.py +318 -0
- devagent_spatial/v083_native.py +349 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Isolated OPC UA FAT using the customer's declared signal address contract.
|
|
2
|
+
|
|
3
|
+
This is an independent, read-only protocol test, NOT physical PLC verification.
|
|
4
|
+
It mirrors customer NamespaceURI + NodeId identifier + Variant datatype under a
|
|
5
|
+
SEPARATE loopback server identity. No Ignition imports, endpoint switching, or
|
|
6
|
+
production/PLC writes are performed. The original V076 synthetic lab remains
|
|
7
|
+
available for its own protocol tests.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from .model import qualify
|
|
20
|
+
from .v076_opc_fat import LabHold, model_hash, scenario_value
|
|
21
|
+
|
|
22
|
+
MAX_SIGNALS = 200
|
|
23
|
+
TYPES = {"Boolean": bool, "Double": float, "Int32": int, "String": str}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _runtime_version():
|
|
27
|
+
return sys.version_info[:2]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_compatible_opc_runtime():
|
|
31
|
+
"""Fail closed before any socket/import on known asyncua 1.1.8 + Python 3.14 breakage.
|
|
32
|
+
|
|
33
|
+
GitHub upstream FreeOpcUa/opcua-asyncio issue #1880 documents the
|
|
34
|
+
identical ua_binary.create_type_serializer issubclass TypeError.
|
|
35
|
+
Python 3.13 is the exact version validated by the project OPC CI job.
|
|
36
|
+
Never mark skipped/incompatible protocol execution as FAT PASS.
|
|
37
|
+
"""
|
|
38
|
+
if _runtime_version() >= (3, 14):
|
|
39
|
+
raise LabHold("ASYNCUA_1_1_8_PYTHON_3_14_PLUS_INCOMPATIBLE_USE_PYTHON_3_13")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _node_kind(identifier: str) -> tuple[str, str | int]:
|
|
44
|
+
"""Preserve the declared identifier; never infer a namespace index."""
|
|
45
|
+
if not isinstance(identifier, str) or not identifier:
|
|
46
|
+
raise LabHold("CUSTOMER_NODE_ID_REQUIRED")
|
|
47
|
+
if identifier.startswith(("ns=", "nsu=", "g=", "b=")):
|
|
48
|
+
raise LabHold("FULL_OR_UNSUPPORTED_NODE_ID_NEEDS_ENGINEER_NORMALIZATION")
|
|
49
|
+
if identifier.startswith("i="):
|
|
50
|
+
digits = identifier[2:]
|
|
51
|
+
if not re.fullmatch(r"0|[1-9][0-9]*", digits) or int(digits) > 2**32 - 1:
|
|
52
|
+
raise LabHold("INVALID_NUMERIC_NODE_IDENTIFIER")
|
|
53
|
+
return "Numeric", int(digits)
|
|
54
|
+
if identifier.startswith("s="):
|
|
55
|
+
if not identifier[2:]:
|
|
56
|
+
raise LabHold("EMPTY_STRING_NODE_IDENTIFIER")
|
|
57
|
+
return "String", identifier[2:]
|
|
58
|
+
return "String", identifier
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def contract_manifest(model: dict) -> dict:
|
|
62
|
+
"""Use exactly the signal identities from customer input, without remapping."""
|
|
63
|
+
result = qualify(model)
|
|
64
|
+
if result["status"] != "PASS_STATIC_CONTRACT_ONLY":
|
|
65
|
+
raise LabHold("CUSTOMER_ENGINEERING_CONTRACT_HOLD")
|
|
66
|
+
signals = model["signals"]
|
|
67
|
+
if not signals or len(signals) > MAX_SIGNALS:
|
|
68
|
+
raise LabHold("CONTRACT_LAB_REQUIRES_1_TO_200_SIGNALS")
|
|
69
|
+
rows = []
|
|
70
|
+
identities = set()
|
|
71
|
+
for s in signals:
|
|
72
|
+
if s["datatype"] not in TYPES or s.get("write_authorized", False) is not False:
|
|
73
|
+
raise LabHold("UNSUPPORTED_OR_WRITABLE_CUSTOMER_SIGNAL")
|
|
74
|
+
ns = s["source"]["namespace_uri"]
|
|
75
|
+
identifier = s["source"]["node_id"]
|
|
76
|
+
kind, raw_id = _node_kind(identifier)
|
|
77
|
+
pair = (ns, kind, raw_id)
|
|
78
|
+
if pair in identities:
|
|
79
|
+
raise LabHold("DUPLICATE_CUSTOMER_NODE_IDENTITY")
|
|
80
|
+
identities.add(pair)
|
|
81
|
+
rows.append({"signal_id": s["id"], "asset_id": s["asset_id"],
|
|
82
|
+
"role": s["role"], "namespace_uri": ns,
|
|
83
|
+
"node_id": identifier, "node_id_type": kind,
|
|
84
|
+
"datatype": s["datatype"], "write_authorized": False})
|
|
85
|
+
return {"schema": "devagent.contract_opc_fat.v1", "model_sha256": model_hash(model),
|
|
86
|
+
"source": "CUSTOMER_DECLARED_UNVERIFIED",
|
|
87
|
+
"namespace_uris": sorted({r["namespace_uri"] for r in rows}),
|
|
88
|
+
"signals": sorted(rows, key=lambda x: x["signal_id"]),
|
|
89
|
+
"opc_server_identity": "ISOLATED_FAKE_SERVER_NOT_PHYSICAL",
|
|
90
|
+
"ignition": "NOT_VERIFIED", "production": "HOLD"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def run_contract_loopback_fat(model: dict, *, port: int = 4897) -> dict:
|
|
94
|
+
"""Exercise all declared nodes using an independent OPC UA TCP client.
|
|
95
|
+
|
|
96
|
+
No external endpoint is an input; intentionally impossible to contact a PLC.
|
|
97
|
+
This does not verify the original declared physical identity or simulate
|
|
98
|
+
process interlocks. Command tests remain a separate approved lab workflow.
|
|
99
|
+
"""
|
|
100
|
+
if type(port) is not int or not 1024 <= port <= 65535:
|
|
101
|
+
raise LabHold("CONTRACT_LAB_PORT_INVALID")
|
|
102
|
+
manifest = contract_manifest(model)
|
|
103
|
+
require_compatible_opc_runtime()
|
|
104
|
+
try:
|
|
105
|
+
from asyncua import Client, Server, ua
|
|
106
|
+
except ImportError as exc:
|
|
107
|
+
raise LabHold("ASYNCUA_NOT_INSTALLED_PROTOCOL_FAT_NOT_EXECUTED") from exc
|
|
108
|
+
|
|
109
|
+
server = Server()
|
|
110
|
+
await server.init()
|
|
111
|
+
endpoint = "opc.tcp://127.0.0.1:%d/devagent-customer-contract-fake/" % port
|
|
112
|
+
server.set_endpoint(endpoint)
|
|
113
|
+
server.set_server_name("DevAgent CUSTOMER CONTRACT Fake OPC UA - LAB ONLY")
|
|
114
|
+
server.set_security_policy([ua.SecurityPolicyType.NoSecurity])
|
|
115
|
+
indexes = {}
|
|
116
|
+
for uri in manifest["namespace_uris"]:
|
|
117
|
+
indexes[uri] = await server.register_namespace(uri)
|
|
118
|
+
root = await server.nodes.objects.add_object(0, "DevAgentCustomerContractLAB")
|
|
119
|
+
nodes = {}
|
|
120
|
+
source_rows = {s["id"]: s for s in model["signals"]}
|
|
121
|
+
for row in manifest["signals"]:
|
|
122
|
+
kind, node_id = _node_kind(row["node_id"])
|
|
123
|
+
type_enum = getattr(ua.NodeIdType, kind)
|
|
124
|
+
ua_id = ua.NodeId(node_id, indexes[row["namespace_uri"]], type_enum)
|
|
125
|
+
dtype = getattr(ua.VariantType, row["datatype"])
|
|
126
|
+
nodes[row["signal_id"]] = await root.add_variable(
|
|
127
|
+
ua_id, row["signal_id"], ua.Variant(scenario_value(source_rows[row["signal_id"]], 0), dtype))
|
|
128
|
+
# No set_writable: clients cannot mutate a telemetry signal.
|
|
129
|
+
cases = []
|
|
130
|
+
try:
|
|
131
|
+
async with server:
|
|
132
|
+
async with Client(endpoint, timeout=4) as client:
|
|
133
|
+
ns_array = await client.get_namespace_array()
|
|
134
|
+
for row in manifest["signals"]:
|
|
135
|
+
if row["namespace_uri"] not in ns_array:
|
|
136
|
+
raise LabHold("CUSTOMER_NAMESPACE_URI_MISSING_IN_FAKE")
|
|
137
|
+
for phase in range(3):
|
|
138
|
+
for row in manifest["signals"]:
|
|
139
|
+
s = source_rows[row["signal_id"]]
|
|
140
|
+
await nodes[row["signal_id"]].write_value(
|
|
141
|
+
ua.Variant(scenario_value(s, phase), getattr(ua.VariantType, s["datatype"])))
|
|
142
|
+
for row in manifest["signals"]:
|
|
143
|
+
kind, node_id = _node_kind(row["node_id"])
|
|
144
|
+
ns_index = ns_array.index(row["namespace_uri"])
|
|
145
|
+
remote = client.get_node(ua.NodeId(node_id, ns_index, getattr(ua.NodeIdType, kind)))
|
|
146
|
+
dv = await asyncio.wait_for(remote.read_data_value(raise_on_bad_status=False), timeout=3)
|
|
147
|
+
expected = scenario_value(source_rows[row["signal_id"]], phase)
|
|
148
|
+
actual = dv.Value.Value if dv.Value is not None else None
|
|
149
|
+
ok = (dv.StatusCode.is_good() and dv.Value is not None
|
|
150
|
+
and dv.Value.VariantType.name == row["datatype"]
|
|
151
|
+
and type(actual) is TYPES[row["datatype"]]
|
|
152
|
+
and actual == expected)
|
|
153
|
+
cases.append({"signal_id": row["signal_id"],
|
|
154
|
+
"asset_id": row["asset_id"], "phase": ("BASELINE", "EXERCISE", "RESTORE")[phase],
|
|
155
|
+
"namespace_uri": row["namespace_uri"],
|
|
156
|
+
"node_id": row["node_id"], "datatype": row["datatype"],
|
|
157
|
+
"status": "PASS_LAB_ONLY" if ok else "FAIL"})
|
|
158
|
+
if not ok:
|
|
159
|
+
raise LabHold("CUSTOMER_CONTRACT_READBACK_MISMATCH")
|
|
160
|
+
# Verify a remote OPC client cannot write a telemetry node.
|
|
161
|
+
sample = manifest["signals"][0]
|
|
162
|
+
kind, node_id = _node_kind(sample["node_id"])
|
|
163
|
+
remote = client.get_node(ua.NodeId(node_id,
|
|
164
|
+
ns_array.index(sample["namespace_uri"]), getattr(ua.NodeIdType, kind)))
|
|
165
|
+
try:
|
|
166
|
+
await remote.write_value(scenario_value(source_rows[sample["signal_id"]], 1))
|
|
167
|
+
except ua.UaStatusCodeError:
|
|
168
|
+
pass
|
|
169
|
+
else:
|
|
170
|
+
raise LabHold("FAKE_TELEMETRY_CLIENT_WRITE_UNEXPECTEDLY_AUTHORIZED")
|
|
171
|
+
except LabHold:
|
|
172
|
+
raise
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
raise LabHold("CUSTOMER_CONTRACT_OPC_FAT_" + type(exc).__name__.upper()) from exc
|
|
175
|
+
expected_count = 3 * len(manifest["signals"])
|
|
176
|
+
if len(cases) != expected_count or any(c["status"] != "PASS_LAB_ONLY" for c in cases):
|
|
177
|
+
raise LabHold("CUSTOMER_CONTRACT_FAT_COVERAGE_HOLD")
|
|
178
|
+
return {"schema": "devagent.contract_opc_fat.run.v1",
|
|
179
|
+
"status": "PASS_CUSTOMER_CONTRACT_FAKE_OPC_PROTOCOL_ONLY",
|
|
180
|
+
"model_sha256": manifest["model_sha256"],
|
|
181
|
+
"signals_checked": len(manifest["signals"]), "cases_checked": len(cases),
|
|
182
|
+
"cases": cases, "independent_opc_tcp_client": True,
|
|
183
|
+
"customer_nodeids_mirrored": True, "client_telemetry_writes": "DENIED",
|
|
184
|
+
"endpoint_scope": "127.0.0.1_ONLY",
|
|
185
|
+
"fake_server_identity": "NOT_CUSTOMER_SERVER",
|
|
186
|
+
"physical_node_identity": "NOT_VERIFIED",
|
|
187
|
+
"ignition_gateway": "NOT_TESTED", "perspective_hmi": "NOT_TESTED",
|
|
188
|
+
"command_fat": "NOT_EXECUTED", "production": "HOLD",
|
|
189
|
+
"recorded_utc": datetime.now(timezone.utc).isoformat()}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main() -> int:
|
|
193
|
+
p = argparse.ArgumentParser(description="Local customer-tag-contract fake OPC UA protocol FAT; NEVER production")
|
|
194
|
+
p.add_argument("--model", type=Path, required=True)
|
|
195
|
+
p.add_argument("--port", type=int, default=4897)
|
|
196
|
+
p.add_argument("--report", type=Path, required=True, help="New create-only JSON report path")
|
|
197
|
+
args = p.parse_args()
|
|
198
|
+
from .strict_json import loads as strict_loads
|
|
199
|
+
data = strict_loads(args.model.read_text(encoding="utf8"))
|
|
200
|
+
try:
|
|
201
|
+
result = asyncio.run(run_contract_loopback_fat(data, port=args.port))
|
|
202
|
+
except LabHold as exc:
|
|
203
|
+
print("FAT_STATUS=HOLD " + str(exc), file=sys.stderr)
|
|
204
|
+
print("REMEDIATION=RUN_USING_SEPARATE_PYTHON_3_13_VENV", file=sys.stderr)
|
|
205
|
+
print("PRODUCTION=HOLD", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
with args.report.open("x", encoding="utf8") as handle:
|
|
208
|
+
json.dump(result, handle, indent=2, ensure_ascii=False)
|
|
209
|
+
handle.write("\n")
|
|
210
|
+
print(result["status"])
|
|
211
|
+
print("PRODUCTION=HOLD")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Persistent customer-tag-contract Fake OPC UA source — isolated lab only.
|
|
2
|
+
|
|
3
|
+
Unlike contract_opc_fat (which closes its server after the test), this source
|
|
4
|
+
remains online until Ctrl+C or a bounded duration. It does NOT verify an
|
|
5
|
+
Ignition Gateway, physical PLC, safety interlocks or HMI command handling.
|
|
6
|
+
Fixture values are NOT valid machine-state simulation. No telemetry writes
|
|
7
|
+
from clients are permitted. Default bind is strictly 127.0.0.1.
|
|
8
|
+
An exact host docker0 bridge IP requires explicit isolated-lab confirmation.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import errno
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
import ipaddress
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .contract_opc_fat import _node_kind, contract_manifest, require_compatible_opc_runtime, TYPES
|
|
24
|
+
from .v076_opc_fat import LabHold, scenario_value
|
|
25
|
+
|
|
26
|
+
LAB_DOCKER_CONFIRM = "ISOLATED_FAKE_OPCUA_DOCKER_BRIDGE_ONLY"
|
|
27
|
+
LOOPBACK = "127.0.0.1"
|
|
28
|
+
ENDPOINT_SUFFIX = "/devagent-customer-contract-fake/"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_docker0_ipv4() -> str:
|
|
32
|
+
"""Fail closed unless docker0 reports exactly one explicit IPv4 address."""
|
|
33
|
+
try:
|
|
34
|
+
cmd = ["ip", "-j", "-4", "addr", "show", "dev", "docker0"]
|
|
35
|
+
out = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=4)
|
|
36
|
+
items = json.loads(out.stdout)
|
|
37
|
+
if type(items) is not list or len(items) != 1 or items[0].get("ifname") != "docker0":
|
|
38
|
+
raise LabHold("DOCKER0_INTERFACE_IDENTITY_UNVERIFIED")
|
|
39
|
+
ips = [x.get("local") for x in items[0].get("addr_info", [])
|
|
40
|
+
if x.get("family") == "inet" and x.get("scope") == "global"]
|
|
41
|
+
if len(ips) != 1:
|
|
42
|
+
raise LabHold("DOCKER0_INTERFACE_IPV4_AMBIGUOUS")
|
|
43
|
+
return str(ipaddress.IPv4Address(ips[0]))
|
|
44
|
+
except LabHold:
|
|
45
|
+
raise
|
|
46
|
+
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc:
|
|
47
|
+
raise LabHold("DOCKER0_INTERFACE_NOT_VERIFIED") from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_bind(host: str, *, confirm: str = "", docker_ip: str | None = None) -> str:
|
|
51
|
+
"""Deny wildcard/public/listening on non-docker0 interfaces."""
|
|
52
|
+
if type(host) is not str:
|
|
53
|
+
raise LabHold("INVALID_FAKE_OPC_BIND")
|
|
54
|
+
try:
|
|
55
|
+
ip = ipaddress.ip_address(host)
|
|
56
|
+
except ValueError as exc:
|
|
57
|
+
raise LabHold("INVALID_FAKE_OPC_BIND") from exc
|
|
58
|
+
if ip.version != 4 or ip.is_unspecified or ip.is_multicast:
|
|
59
|
+
raise LabHold("EXTERNAL_OR_WILDCARD_FAKE_OPC_BIND_PROHIBITED")
|
|
60
|
+
if host == LOOPBACK:
|
|
61
|
+
if confirm:
|
|
62
|
+
raise LabHold("LOOPBACK_MUST_NOT_USE_DOCKER_CONFIRM")
|
|
63
|
+
return host
|
|
64
|
+
if not ip.is_private or ip.is_loopback or ip.is_link_local:
|
|
65
|
+
raise LabHold("NONPRIVATE_FAKE_OPC_BIND_PROHIBITED")
|
|
66
|
+
if confirm != LAB_DOCKER_CONFIRM:
|
|
67
|
+
raise LabHold("DOCKER_BRIDGE_EXPLICIT_CONFIRM_REQUIRED")
|
|
68
|
+
actual = docker_ip if docker_ip is not None else _read_docker0_ipv4()
|
|
69
|
+
if actual != host:
|
|
70
|
+
raise LabHold("BIND_NOT_EXACT_DOCKER0_INTERFACE_ADDRESS")
|
|
71
|
+
return host
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_settings(*, port: int, interval: float, duration: float):
|
|
75
|
+
if type(port) is not int or not 1024 <= port <= 65535:
|
|
76
|
+
raise LabHold("FAKE_OPC_SOURCE_PORT_INVALID")
|
|
77
|
+
if type(interval) not in (float, int) or not math.isfinite(interval) or not 0.1 <= interval <= 60:
|
|
78
|
+
raise LabHold("FAKE_OPC_SOURCE_INTERVAL_INVALID")
|
|
79
|
+
if type(duration) not in (float, int) or not math.isfinite(duration) or not 0 <= duration <= 86400:
|
|
80
|
+
raise LabHold("FAKE_OPC_SOURCE_DURATION_INVALID")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def probe_contract_source(endpoint: str, manifest: dict, signals: dict,
|
|
84
|
+
phase: int, *, timeout: float = 12.0) -> dict:
|
|
85
|
+
"""Independent real OPC UA TCP client, not server-memory introspection.
|
|
86
|
+
|
|
87
|
+
Checks the HEL/ACK/open session, customer NamespaceURI, every NodeId,
|
|
88
|
+
returned Variant datatype, Good quality and exact synthetic phase value.
|
|
89
|
+
Never substitutes TCP reachability for an OPC handshake.
|
|
90
|
+
"""
|
|
91
|
+
from asyncua import Client, ua
|
|
92
|
+
|
|
93
|
+
async def read_all():
|
|
94
|
+
async with Client(endpoint, timeout=min(timeout, 5)) as client:
|
|
95
|
+
namespace_array = await client.get_namespace_array()
|
|
96
|
+
rows = manifest["signals"]
|
|
97
|
+
for row in rows:
|
|
98
|
+
if row["namespace_uri"] not in namespace_array:
|
|
99
|
+
raise LabHold("FAKE_OPC_PROBE_NAMESPACE_MISSING")
|
|
100
|
+
kind, identifier = _node_kind(row["node_id"])
|
|
101
|
+
node = client.get_node(ua.NodeId(
|
|
102
|
+
identifier, namespace_array.index(row["namespace_uri"]),
|
|
103
|
+
getattr(ua.NodeIdType, kind)))
|
|
104
|
+
dv = await node.read_data_value(raise_on_bad_status=False)
|
|
105
|
+
actual = dv.Value.Value if dv.Value is not None else None
|
|
106
|
+
expected = scenario_value(signals[row["signal_id"]], phase)
|
|
107
|
+
if (not dv.StatusCode.is_good() or dv.Value is None
|
|
108
|
+
or dv.Value.VariantType.name != row["datatype"]
|
|
109
|
+
or type(actual) is not TYPES[row["datatype"]]
|
|
110
|
+
or actual != expected):
|
|
111
|
+
raise LabHold("FAKE_OPC_PROBE_READBACK_MISMATCH")
|
|
112
|
+
return {"status": "PASS_FAKE_OPC_TCP_HELLO_AND_CUSTOMER_SIGNAL_READBACK_ONLY",
|
|
113
|
+
"signals_checked": len(rows), "phase": phase}
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
return await asyncio.wait_for(read_all(), timeout=timeout)
|
|
117
|
+
except LabHold:
|
|
118
|
+
raise
|
|
119
|
+
except (asyncio.TimeoutError, TimeoutError) as exc:
|
|
120
|
+
raise LabHold("FAKE_OPC_PROBE_HELLO_OR_READBACK_TIMEOUT") from exc
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
raise LabHold("FAKE_OPC_PROBE_CLIENT_" + type(exc).__name__.upper()) from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def serve_contract_source(
|
|
126
|
+
model: dict, *, host: str = LOOPBACK, port: int = 4897,
|
|
127
|
+
interval: float = 3.0, duration: float = 0, confirm: str = "",
|
|
128
|
+
started: asyncio.Event | None = None, stop: asyncio.Event | None = None,
|
|
129
|
+
on_ready=None
|
|
130
|
+
) -> None:
|
|
131
|
+
"""A real OPC UA server exposing customer-declared nodes with lab-only data.
|
|
132
|
+
|
|
133
|
+
Positive duration is bounded for tests; zero runs until Ctrl+C or stop event.
|
|
134
|
+
Ready callback/event is dispatched only AFTER an independent OPC UA TCP
|
|
135
|
+
client successfully reads every customer-declared signal. Continued
|
|
136
|
+
liveness is checked after every fixture phase update. Any failure closes
|
|
137
|
+
the server and returns HOLD (no Ignition PASS).
|
|
138
|
+
"""
|
|
139
|
+
validate_settings(port=port, interval=interval, duration=duration)
|
|
140
|
+
manifest = contract_manifest(model)
|
|
141
|
+
require_compatible_opc_runtime()
|
|
142
|
+
bind = validate_bind(host, confirm=confirm)
|
|
143
|
+
try:
|
|
144
|
+
from asyncua import Server, ua
|
|
145
|
+
except ImportError as exc:
|
|
146
|
+
raise LabHold("ASYNCUA_NOT_INSTALLED_PROTOCOL_SOURCE_NOT_STARTED") from exc
|
|
147
|
+
|
|
148
|
+
server = Server()
|
|
149
|
+
await server.init()
|
|
150
|
+
endpoint = f"opc.tcp://{bind}:{port}{ENDPOINT_SUFFIX}"
|
|
151
|
+
server.set_endpoint(endpoint)
|
|
152
|
+
server.set_server_name("DevAgent CUSTOMER CONTRACT FAKE OPC — ISOLATED TEST ONLY")
|
|
153
|
+
server.set_security_policy([ua.SecurityPolicyType.NoSecurity])
|
|
154
|
+
ns_map = {uri: await server.register_namespace(uri) for uri in manifest["namespace_uris"]}
|
|
155
|
+
root = await server.nodes.objects.add_object(0, "DevAgentCustomerContractLAB")
|
|
156
|
+
sources = {s["id"]: s for s in model["signals"]}
|
|
157
|
+
nodes = {}
|
|
158
|
+
for row in manifest["signals"]:
|
|
159
|
+
kind, identifier = _node_kind(row["node_id"])
|
|
160
|
+
nodeid = ua.NodeId(identifier, ns_map[row["namespace_uri"]],
|
|
161
|
+
getattr(ua.NodeIdType, kind))
|
|
162
|
+
variant = ua.Variant(scenario_value(sources[row["signal_id"]], 0),
|
|
163
|
+
getattr(ua.VariantType, row["datatype"]))
|
|
164
|
+
nodes[row["signal_id"]] = await root.add_variable(nodeid, row["signal_id"], variant)
|
|
165
|
+
# Never call set_writable. Anonymous clients may READ but not WRITE.
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
async with server:
|
|
169
|
+
# CRITICAL: successful TCP bind does not imply OPC UA HEL/ACK
|
|
170
|
+
# or data availability. Fail closed before saying RUNNING.
|
|
171
|
+
await probe_contract_source(endpoint, manifest, sources, 0)
|
|
172
|
+
info = {
|
|
173
|
+
"status": "FAKE_OPC_SOURCE_RUNNING_NOT_FAT",
|
|
174
|
+
"schema": "devagent.contract_opc_source.v1",
|
|
175
|
+
"endpoint": endpoint, "source": "CUSTOMER_DECLARED_UNVERIFIED",
|
|
176
|
+
"model_sha256": manifest["model_sha256"],
|
|
177
|
+
"namespace_uris": manifest["namespace_uris"],
|
|
178
|
+
"signals": len(nodes),
|
|
179
|
+
"startup_opc_tcp_handshake": "INDEPENDENT_CLIENT_PASS_LAB_ONLY",
|
|
180
|
+
"startup_all_signals_readback": "INDEPENDENT_CLIENT_PASS_LAB_ONLY",
|
|
181
|
+
"ongoing_opc_tcp_health": "CHECKED_EACH_SYNTHETIC_PHASE",
|
|
182
|
+
"source_values": "CYCLING_TYPE_PRESERVING_FIXTURES_NOT_MACHINE_PROCESS",
|
|
183
|
+
"security": "NO_SECURITY_ISOLATED_LAB_ONLY",
|
|
184
|
+
"client_telemetry_write_authority": "NONE",
|
|
185
|
+
"real_ignition": "NOT_VERIFIED", "physical_opc_source": "NOT_VERIFIED",
|
|
186
|
+
"production": "HOLD", "utc": datetime.now(timezone.utc).isoformat(),
|
|
187
|
+
}
|
|
188
|
+
if on_ready:
|
|
189
|
+
on_ready(info)
|
|
190
|
+
if started is not None:
|
|
191
|
+
started.set()
|
|
192
|
+
loop = asyncio.get_running_loop()
|
|
193
|
+
deadline = loop.time() + duration if duration else None
|
|
194
|
+
phase = 0
|
|
195
|
+
while stop is None or not stop.is_set():
|
|
196
|
+
if deadline is not None and loop.time() >= deadline:
|
|
197
|
+
break
|
|
198
|
+
timeout = min(interval, deadline-loop.time()) if deadline is not None else interval
|
|
199
|
+
if timeout <= 0:
|
|
200
|
+
break
|
|
201
|
+
if stop is None:
|
|
202
|
+
await asyncio.sleep(timeout)
|
|
203
|
+
else:
|
|
204
|
+
try:
|
|
205
|
+
await asyncio.wait_for(stop.wait(), timeout=timeout)
|
|
206
|
+
except asyncio.TimeoutError:
|
|
207
|
+
pass
|
|
208
|
+
if stop.is_set():
|
|
209
|
+
break
|
|
210
|
+
if deadline is not None and loop.time() >= deadline:
|
|
211
|
+
break
|
|
212
|
+
phase = (phase + 1) % 3
|
|
213
|
+
|
|
214
|
+
async def update_fixture_values():
|
|
215
|
+
for row in manifest["signals"]:
|
|
216
|
+
source = sources[row["signal_id"]]
|
|
217
|
+
await nodes[row["signal_id"]].write_value(
|
|
218
|
+
ua.Variant(scenario_value(source, phase),
|
|
219
|
+
getattr(ua.VariantType, row["datatype"])))
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
await asyncio.wait_for(update_fixture_values(), timeout=8)
|
|
223
|
+
except asyncio.TimeoutError as exc:
|
|
224
|
+
raise LabHold("FAKE_OPC_FIXTURE_UPDATE_TIMEOUT") from exc
|
|
225
|
+
# Reconnect with a new TCP client and verify all signals.
|
|
226
|
+
# A hung accept queue / silent OPC server is not healthy.
|
|
227
|
+
await probe_contract_source(endpoint, manifest, sources, phase)
|
|
228
|
+
except OSError as exc:
|
|
229
|
+
if exc.errno == errno.EADDRINUSE:
|
|
230
|
+
raise LabHold("FAKE_OPC_SOURCE_PORT_ALREADY_IN_USE_DO_NOT_START_DUPLICATE") from exc
|
|
231
|
+
raise LabHold("FAKE_OPC_SOURCE_BIND_OR_SESSION_FAILED") from exc
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main() -> int:
|
|
235
|
+
p = argparse.ArgumentParser(description="Persistent fake OPC source — NOT Ignition/PLC FAT")
|
|
236
|
+
p.add_argument("--model", required=True, type=Path, help="Qualified customer model JSON")
|
|
237
|
+
p.add_argument("--port", type=int, default=4897)
|
|
238
|
+
p.add_argument("--interval", type=float, default=3.0)
|
|
239
|
+
p.add_argument("--duration", type=float, default=0, help="0 means until Ctrl+C")
|
|
240
|
+
p.add_argument("--bind", default=LOOPBACK, help="127.0.0.1 or exact host docker0 IP")
|
|
241
|
+
p.add_argument("--confirm", default="", help="Required for isolated docker0 binding")
|
|
242
|
+
args = p.parse_args()
|
|
243
|
+
from .strict_json import loads as strict_loads
|
|
244
|
+
try:
|
|
245
|
+
model = strict_loads(args.model.read_text(encoding="utf8"))
|
|
246
|
+
asyncio.run(serve_contract_source(
|
|
247
|
+
model, host=args.bind, port=args.port, interval=args.interval,
|
|
248
|
+
duration=args.duration, confirm=args.confirm,
|
|
249
|
+
on_ready=lambda data: print(json.dumps(data, sort_keys=True), flush=True)))
|
|
250
|
+
except KeyboardInterrupt:
|
|
251
|
+
print("FAKE_OPC_SOURCE_STOPPED_BY_OPERATOR", flush=True)
|
|
252
|
+
print("PRODUCTION=HOLD", flush=True)
|
|
253
|
+
return 0
|
|
254
|
+
except (LabHold, OSError, ValueError, TypeError) as exc:
|
|
255
|
+
reason = str(exc) if isinstance(exc, LabHold) else "FAKE_OPC_SOURCE_STARTUP_HOLD"
|
|
256
|
+
print("FAKE_OPC_SOURCE=HOLD " + reason, file=sys.stderr, flush=True)
|
|
257
|
+
print("PRODUCTION=HOLD", file=sys.stderr, flush=True)
|
|
258
|
+
return 2
|
|
259
|
+
print("FAKE_OPC_SOURCE_STOPPED", flush=True)
|
|
260
|
+
print("PRODUCTION=HOLD", flush=True)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Versioned operator intent catalog; candidates are always read-only / never PLC-write capable."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
CATALOG_VERSION = "devagent.operator-intents.v1"
|
|
5
|
+
INTENTS = {
|
|
6
|
+
"conveyor": ("START", "STOP", "RESET", "ACK"),
|
|
7
|
+
"spiral": ("START", "STOP", "RESET", "ACK"),
|
|
8
|
+
"sorter": ("START", "STOP", "RESET", "ACK"),
|
|
9
|
+
"motor": ("START", "STOP", "RESET", "ACK"),
|
|
10
|
+
"vfd": ("START", "STOP", "RESET", "ACK"),
|
|
11
|
+
"pump": ("START", "STOP", "RESET", "ACK"),
|
|
12
|
+
"valve": ("OPEN", "CLOSE", "ACK"),
|
|
13
|
+
"robot": ("START", "HOLD", "RESET", "ACK"),
|
|
14
|
+
"scanner": ("TRIGGER", "RESET", "ACK"),
|
|
15
|
+
"photoeye": (), "tank": (), "junction": (), "room": (), "other": (),
|
|
16
|
+
}
|
|
17
|
+
RELEASE_GATES = (
|
|
18
|
+
"command identity and source approved",
|
|
19
|
+
"per-device role and session authorization verified",
|
|
20
|
+
"permissives, interlocks and handshake independently verified",
|
|
21
|
+
"Gateway command audit and simulation/field FAT executed",
|
|
22
|
+
"engineer/customer approval and rollback plan",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def control_plan(model: dict) -> dict:
|
|
26
|
+
"""Only control *intents* for UI review. Never a writable binding or grant."""
|
|
27
|
+
return {
|
|
28
|
+
"schema": CATALOG_VERSION,
|
|
29
|
+
"production_release": "HOLD",
|
|
30
|
+
"runtime_command_authority": "NONE",
|
|
31
|
+
"plc_opcua_writes": "DISABLED",
|
|
32
|
+
"required_gates": list(RELEASE_GATES),
|
|
33
|
+
"devices": [
|
|
34
|
+
{"asset_id": e["id"], "kind": e["kind"], "intents": list(INTENTS[e["kind"]]),
|
|
35
|
+
"enabled": False, "reason": "NO_VERIFIED_DEVICE_COMMAND_CONTRACT"}
|
|
36
|
+
for e in model["equipment"]
|
|
37
|
+
],
|
|
38
|
+
}
|