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.
Files changed (133) hide show
  1. devagent_hmi.py +471 -0
  2. devagent_ignition-0.1.0.dist-info/METADATA +204 -0
  3. devagent_ignition-0.1.0.dist-info/RECORD +133 -0
  4. devagent_ignition-0.1.0.dist-info/WHEEL +4 -0
  5. devagent_ignition-0.1.0.dist-info/entry_points.txt +2 -0
  6. devagent_spatial/__init__.py +1 -0
  7. devagent_spatial/_resources/README.md +187 -0
  8. devagent_spatial/_resources/RELEASE_MANIFEST.json +312 -0
  9. devagent_spatial/_resources/SHA256SUMS.txt +270 -0
  10. devagent_spatial/_resources/SOURCE_PROVENANCE.json +186 -0
  11. devagent_spatial/_resources/assets/agent.js +102 -0
  12. devagent_spatial/_resources/assets/creator.css +247 -0
  13. devagent_spatial/_resources/assets/creator_2d.mjs +16 -0
  14. devagent_spatial/_resources/assets/creator_original_r9.mjs +58 -0
  15. devagent_spatial/_resources/assets/creator_original_v8.mjs +120 -0
  16. devagent_spatial/_resources/assets/creator_r9_diagnostics.mjs +53 -0
  17. devagent_spatial/_resources/assets/creator_scene.mjs +128 -0
  18. devagent_spatial/_resources/assets/creator_ui.mjs +60 -0
  19. devagent_spatial/_resources/assets/customer_documents.js +110 -0
  20. devagent_spatial/_resources/assets/equipment_controls.mjs +27 -0
  21. devagent_spatial/_resources/assets/facility2d.css +24 -0
  22. devagent_spatial/_resources/assets/facility2d_app.mjs +45 -0
  23. devagent_spatial/_resources/assets/facility2d_core.mjs +114 -0
  24. devagent_spatial/_resources/assets/facility2d_simulation.mjs +167 -0
  25. devagent_spatial/_resources/assets/facility2d_viewer.html +7 -0
  26. devagent_spatial/_resources/assets/fallback_scene.js +17 -0
  27. devagent_spatial/_resources/assets/flow_geometry.mjs +218 -0
  28. devagent_spatial/_resources/assets/harness.js +78 -0
  29. devagent_spatial/_resources/assets/live_design_review.js +129 -0
  30. devagent_spatial/_resources/assets/operations_ui.js +305 -0
  31. devagent_spatial/_resources/assets/operator_viewer.html +64 -0
  32. devagent_spatial/_resources/assets/project_package.js +52 -0
  33. devagent_spatial/_resources/assets/r9_transport.mjs +70 -0
  34. devagent_spatial/_resources/assets/runtime_feed.js +30 -0
  35. devagent_spatial/_resources/assets/studio.html +139 -0
  36. devagent_spatial/_resources/assets/style.css +191 -0
  37. devagent_spatial/_resources/assets/test_workspace.html +195 -0
  38. devagent_spatial/_resources/assets/three_scene.js +442 -0
  39. devagent_spatial/_resources/assets/unified_engineer.js +101 -0
  40. devagent_spatial/_resources/assets/v076_fat_ui.js +40 -0
  41. devagent_spatial/_resources/assets/v078_command_ui.js +44 -0
  42. devagent_spatial/_resources/assets/viewer.html +45 -0
  43. devagent_spatial/_resources/assets/viewer.js +112 -0
  44. devagent_spatial/_resources/design-system.json +130 -0
  45. devagent_spatial/_resources/examples/CV101-command-contract-PENDING.json +30 -0
  46. devagent_spatial/_resources/examples/cv101_guided.json +272 -0
  47. devagent_spatial/_resources/examples/make_examples.py +36 -0
  48. devagent_spatial/_resources/examples/single_floor.json +444 -0
  49. devagent_spatial/_resources/examples/v0771_training_wizard.json +11 -0
  50. devagent_spatial/_resources/examples/warehouse-3floor-standard-2d.json +708 -0
  51. devagent_spatial/_resources/examples/warehouse-operations-synthetic.json +1186 -0
  52. devagent_spatial/_resources/examples/warehouse-v07-lab-bindings-PENDING.json +372 -0
  53. devagent_spatial/_resources/examples/warehouse.json +708 -0
  54. devagent_spatial/_resources/examples/water.json +288 -0
  55. devagent_spatial/_resources/reference/r9-golden/REFERENCE_SHA256.json +13 -0
  56. devagent_spatial/_resources/reference/r9-golden/web/app.mjs +745 -0
  57. devagent_spatial/_resources/reference/r9-golden/web/index.html +213 -0
  58. devagent_spatial/_resources/reference/r9-golden/web/lab_panel.mjs +53 -0
  59. devagent_spatial/_resources/reference/r9-golden/web/model.mjs +367 -0
  60. devagent_spatial/_resources/reference/r9-golden/web/state_adapter.mjs +78 -0
  61. devagent_spatial/_resources/reference/r9-golden/web/styles.css +76 -0
  62. devagent_spatial/_resources/reference/r9-original/README_R9.md +27 -0
  63. devagent_spatial/_resources/reference/r9-original/SHA256SUMS.txt +63 -0
  64. devagent_spatial/_resources/reference/r9-original/SNAPSHOT_SHA256SUMS.txt +16 -0
  65. devagent_spatial/_resources/reference/r9-original/flow_routes.json +527 -0
  66. devagent_spatial/_resources/reference/r9-original/flow_sim.py +117 -0
  67. devagent_spatial/_resources/reference/r9-original/lab_control.py +73 -0
  68. devagent_spatial/_resources/reference/r9-original/web/app.mjs +745 -0
  69. devagent_spatial/_resources/reference/r9-original/web/index.html +213 -0
  70. devagent_spatial/_resources/reference/r9-original/web/lab_panel.mjs +53 -0
  71. devagent_spatial/_resources/reference/r9-original/web/model.mjs +367 -0
  72. devagent_spatial/_resources/reference/r9-original/web/state_adapter.mjs +78 -0
  73. devagent_spatial/_resources/reference/r9-original/web/styles.css +76 -0
  74. devagent_spatial/_resources/reference/r9-original/web/vendor/LICENSE-three.txt +21 -0
  75. devagent_spatial/_resources/reference/r9-original/web/vendor/SHA256SUMS.txt +4 -0
  76. devagent_spatial/_resources/reference/r9-original/web/vendor/addons/controls/OrbitControls.js +1860 -0
  77. devagent_spatial/_resources/reference/r9-original/web/vendor/three.core.js +58773 -0
  78. devagent_spatial/_resources/reference/r9-original/web/vendor/three.module.js +18251 -0
  79. devagent_spatial/_resources/reference/v8-original/README.md +36 -0
  80. devagent_spatial/_resources/reference/v8-original/SHA256SUMS.txt +12 -0
  81. devagent_spatial/_resources/reference/v8-original/model.py +124 -0
  82. devagent_spatial/_resources/reference/v8-original/run_demo.sh +4 -0
  83. devagent_spatial/_resources/reference/v8-original/server.py +100 -0
  84. devagent_spatial/_resources/reference/v8-original/tests/browser_smoke.py +50 -0
  85. devagent_spatial/_resources/reference/v8-original/tests/test_v8.py +144 -0
  86. devagent_spatial/_resources/reference/v8-original/topology.json +24 -0
  87. devagent_spatial/_resources/reference/v8-original/web/app.js +114 -0
  88. devagent_spatial/_resources/reference/v8-original/web/index.html +22 -0
  89. devagent_spatial/_resources/reference/v8-original/web/simulator.html +1 -0
  90. devagent_spatial/_resources/reference/v8-original/web/simulator.js +6 -0
  91. devagent_spatial/_resources/reference/v8-original/web/style.css +16 -0
  92. devagent_spatial/_resources/sample_customer_inputs/Plant_Equipment_Register.csv +4 -0
  93. devagent_spatial/_resources/sample_customer_inputs/Plant_Flow_Requirements.txt +5 -0
  94. devagent_spatial/_resources/sample_customer_inputs/Raw_PLC_Tag_Export.csv +4 -0
  95. devagent_spatial/_resources/templates/warehouse_connections.csv +12 -0
  96. devagent_spatial/_resources/templates/warehouse_equipment.csv +21 -0
  97. devagent_spatial/_resources/templates/warehouse_signals.csv +34 -0
  98. devagent_spatial/_resources/templates/water_connections.csv +7 -0
  99. devagent_spatial/_resources/templates/water_equipment.csv +10 -0
  100. devagent_spatial/_resources/templates/water_signals.csv +12 -0
  101. devagent_spatial/agent_harness.py +299 -0
  102. devagent_spatial/agent_runtime.py +314 -0
  103. devagent_spatial/contract_opc_fat.py +216 -0
  104. devagent_spatial/contract_opc_source.py +265 -0
  105. devagent_spatial/control_intents.py +38 -0
  106. devagent_spatial/customer_intake.py +235 -0
  107. devagent_spatial/engineering_expertise.py +114 -0
  108. devagent_spatial/generator.py +201 -0
  109. devagent_spatial/ignition_native.py +234 -0
  110. devagent_spatial/ignition_project.py +144 -0
  111. devagent_spatial/live_lab_binding.py +128 -0
  112. devagent_spatial/model.py +297 -0
  113. devagent_spatial/native_reference.py +69 -0
  114. devagent_spatial/opc_snapshot.py +87 -0
  115. devagent_spatial/operations.py +284 -0
  116. devagent_spatial/operations_capture.py +47 -0
  117. devagent_spatial/operations_lab.py +64 -0
  118. devagent_spatial/original_r9.py +169 -0
  119. devagent_spatial/original_v8.py +81 -0
  120. devagent_spatial/preflight.py +140 -0
  121. devagent_spatial/preview_simulation.py +189 -0
  122. devagent_spatial/project_package.py +101 -0
  123. devagent_spatial/qualification.py +152 -0
  124. devagent_spatial/strict_json.py +29 -0
  125. devagent_spatial/studio.py +894 -0
  126. devagent_spatial/studio_workflow.py +277 -0
  127. devagent_spatial/test_reporting.py +87 -0
  128. devagent_spatial/v076_opc_fat.py +180 -0
  129. devagent_spatial/v078_commands.py +277 -0
  130. devagent_spatial/v078_native_commands.py +161 -0
  131. devagent_spatial/v07_fat.py +110 -0
  132. devagent_spatial/v07_native.py +318 -0
  133. 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
+ }