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,277 @@
1
+ """Project-scoped engineering workspace for an EXISTING isolated Studio R2.
2
+
3
+ Never mutates generated HMI/renderer or starts processes. Studio communication is
4
+ fixed to loopback; writes are allowed only to customer nodes in a lab that was
5
+ empty when this Creator session imported the exact project's mappings.
6
+ No physical PLC / Ignition / Perspective / production qualification is claimed.
7
+ """
8
+ from __future__ import annotations
9
+ import asyncio
10
+ import csv
11
+ import io
12
+ import copy
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from pathlib import Path
20
+ from .operations import evaluate, mapping_digest
21
+ from .operations_capture import capture_lab
22
+ from .operations_lab import LAB_NAMESPACE, TYPES, create_lab_derivative
23
+ from .qualification import audit
24
+
25
+ class LabHold(ValueError): pass
26
+
27
+
28
+ def load_project(workspace:Path, project_id:str):
29
+ from .operations import policy
30
+ if not project_id or len(project_id)>100 or not __import__('re').fullmatch(r'[a-z0-9-]+',project_id):
31
+ raise LabHold('INVALID_PROJECT_ID')
32
+ root=(workspace/project_id).resolve()
33
+ if root.parent!=workspace.resolve():raise LabHold('INVALID_PROJECT_PATH')
34
+ result=audit(root)
35
+ if result['status']!='PASS_CONCEPT_PREVIEW_ONLY':raise LabHold('PROJECT_ARTIFACT_INTEGRITY_HOLD')
36
+ model=json.loads((root/'contracts'/'project.json').read_text())
37
+ policy(model)
38
+ return model
39
+
40
+
41
+ def studio_request(port:int,method:str,path:str,body:bytes|None=None):
42
+ """No arbitrary URL, proxy, or user-provided Host. Fixed loopback only."""
43
+ if type(port) is not int or not 1024<=port<=65535:raise LabHold('STUDIO_PORT_INVALID')
44
+ if path not in ('/api/state','/api/manifest','/api/probe','/api/upload-tags','/api/command'):
45
+ raise LabHold('STUDIO_ROUTE_NOT_ALLOWED')
46
+ if method not in ('GET','POST'):raise LabHold('STUDIO_METHOD_NOT_ALLOWED')
47
+ req=urllib.request.Request(f'http://127.0.0.1:{port}{path}',data=body,method=method,
48
+ headers={'Content-Type':'application/json' if path in ('/api/probe','/api/command') else 'text/csv; charset=utf-8'})
49
+ try:
50
+ with urllib.request.urlopen(req,timeout=12 if path!='/api/probe' else 160) as response:
51
+ if response.status!=200:raise LabHold('STUDIO_HTTP_NOT_OK')
52
+ if int(response.headers.get('Content-Length','0'))>2_000_000:
53
+ raise LabHold('STUDIO_RESPONSE_TOO_LARGE')
54
+ raw=response.read(2_000_001)
55
+ if len(raw)>2_000_000:raise LabHold('STUDIO_RESPONSE_TOO_LARGE')
56
+ value=json.loads(raw)
57
+ if not isinstance(value,dict):raise LabHold('STUDIO_RESPONSE_INVALID')
58
+ return value
59
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
60
+ raise LabHold('STUDIO_OFFLINE_OR_REJECTED_'+type(exc).__name__.upper()) from exc
61
+
62
+
63
+ def studio_state(port:int):
64
+ state=studio_request(port,'GET','/api/state')
65
+ customer=state.get('customer',{})
66
+ if (state.get('status')!='OPC_UA_SOURCE_RUNNING' or state.get('lab_only') is not True or
67
+ not isinstance(customer,dict) or customer.get('namespace_uri')!=LAB_NAMESPACE or
68
+ type(customer.get('tag_count')) is not int or type(customer.get('revision')) is not int or
69
+ not isinstance(state.get('opc_endpoint'),str)):
70
+ raise LabHold('NOT_A_SUPPORTED_ISOLATED_STUDIO_R2')
71
+ return state
72
+
73
+
74
+ def project_lab_dir(workspace,project_id):
75
+ p=(workspace/'.devagent-engineering-tests'/project_id).resolve()
76
+ if p.parent!=(workspace/'.devagent-engineering-tests').resolve():raise LabHold('INVALID_LAB_OUTPUT_PATH')
77
+ return p
78
+
79
+
80
+ def verify_prepared_model(workspace,project_id,model):
81
+ """Reject changed lab alias data even when project source is intact."""
82
+ destination=project_lab_dir(workspace,project_id)
83
+ required=('customer-original.json','lab-model.json','studio-tags.csv','alias-review.json')
84
+ try:
85
+ manifest={}
86
+ for line in (destination/'SHA256SUMS.txt').read_text().splitlines():
87
+ digest,filename=line.split(' ',1)
88
+ if (filename not in required or filename in manifest or len(digest)!=64
89
+ or any(c not in '0123456789abcdef' for c in digest)):
90
+ raise LabHold('LAB_MANIFEST_INVALID')
91
+ manifest[filename]=digest
92
+ if set(manifest)!=set(required):raise LabHold('LAB_MANIFEST_INCOMPLETE')
93
+ for filename,digest in manifest.items():
94
+ if hashlib.sha256((destination/filename).read_bytes()).hexdigest()!=digest:
95
+ raise LabHold('LAB_ARTIFACT_TAMPER_HOLD')
96
+ original=json.loads((destination/'customer-original.json').read_text())
97
+ lab=json.loads((destination/'lab-model.json').read_text())
98
+ expected=copy.deepcopy(model)
99
+ for s in expected['signals']:
100
+ s['source']['namespace_uri']=LAB_NAMESPACE
101
+ if original!=model or lab!=expected:
102
+ raise LabHold('LAB_ALIAS_MAPPING_DRIFT')
103
+ parsed=list(csv.DictReader(io.StringIO((destination/'studio-tags.csv').read_text())))
104
+ rows=sorted((r['TagPath'],r['DataType']) for r in parsed)
105
+ if rows!=_expected(lab):raise LabHold('LAB_TAGS_CSV_MODEL_DRIFT')
106
+ return lab
107
+ except (OSError,KeyError,TypeError,ValueError,IndexError,json.JSONDecodeError) as exc:
108
+ if isinstance(exc,LabHold):raise
109
+ raise LabHold('LAB_ARTIFACTS_INVALID') from exc
110
+
111
+
112
+ def prepare(workspace,project_id,model):
113
+ destination=project_lab_dir(workspace,project_id)
114
+ destination.parent.mkdir(parents=True,exist_ok=True)
115
+ if destination.exists():
116
+ verify_prepared_model(workspace,project_id,model)
117
+ return {'status':'LAB_PREPARED_CREATE_ONLY','signal_count':len(model['signals']),'already_prepared':True}
118
+ result=create_lab_derivative(model,destination,'ISOLATED_TEST_ONLY')
119
+ verify_prepared_model(workspace,project_id,model)
120
+ return {'status':'LAB_PREPARED_CREATE_ONLY','signal_count':result['signals'],'already_prepared':False}
121
+
122
+
123
+ def _expected(model):
124
+ return sorted((s['source']['node_id'], TYPES[s['datatype']]) for s in model['signals'])
125
+
126
+
127
+ def _manifest_ok(manifest,lab_model):
128
+ if (manifest.get('status')!='LAB_SIMULATED_NOT_SITE_VERIFIED' or
129
+ manifest.get('namespaceUri')!=LAB_NAMESPACE or
130
+ type(manifest.get('tagCount')) is not int or
131
+ manifest['tagCount']!=len(lab_model['signals'])):
132
+ return False
133
+ items=manifest.get('items')
134
+ if not isinstance(items,list):return False
135
+ received=sorted((r.get('path'),r.get('dataType')) for r in items if isinstance(r,dict))
136
+ return received==_expected(lab_model)
137
+
138
+
139
+ def _same_session(session,state):
140
+ return bool(session and session.get('revision')==state['customer']['revision'] and
141
+ session.get('endpoint')==state['opc_endpoint'] and
142
+ abs(float(session.get('started',-1))-(time.time()-float(state['uptime_seconds'])))<5)
143
+
144
+
145
+ def import_tags(workspace,project_id,port:int,state,session):
146
+ model=load_project(workspace,project_id)
147
+ if session:raise LabHold('STUDIO_ALREADY_IMPORTED_THIS_SESSION')
148
+ if state['customer']['tag_count']!=0:
149
+ raise LabHold('STUDIO_HAS_EXISTING_TAGS_REQUIRE_FRESH_ISOLATED_LAB')
150
+ labdir=project_lab_dir(workspace,project_id)
151
+ if not labdir.is_dir():raise LabHold('PREPARE_LAB_FIRST')
152
+ lab_model=verify_prepared_model(workspace,project_id,model)
153
+ raw=(labdir/'studio-tags.csv').read_bytes()
154
+ result=studio_request(port,'POST','/api/upload-tags',raw)
155
+ if result.get('status')!='IMPORTED':raise LabHold('STUDIO_IMPORT_NOT_CONFIRMED')
156
+ updated=studio_state(port)
157
+ manifest=studio_request(port,'GET','/api/manifest')
158
+ if not _manifest_ok(manifest,lab_model):raise LabHold('STUDIO_IMPORT_MANIFEST_MISMATCH')
159
+ session={'revision':updated['customer']['revision'],'endpoint':updated['opc_endpoint'],
160
+ 'started':time.time()-float(updated['uptime_seconds']),'mapping_digest':mapping_digest(model),
161
+ 'lab_mapping_digest':mapping_digest(lab_model)}
162
+ return {'status':'LAB_TAGS_IMPORTED_NOT_IGNITION_VERIFIED','imported':len(lab_model['signals']),
163
+ 'lab_namespace':LAB_NAMESPACE},session
164
+
165
+
166
+ def require_session(workspace,project_id,port,session):
167
+ model=load_project(workspace,project_id)
168
+ state=studio_state(port)
169
+ if not _same_session(session,state) or session['mapping_digest']!=mapping_digest(model):
170
+ raise LabHold('NO_ACTIVE_PROJECT_BOUND_STUDIO_SESSION')
171
+ labdir=project_lab_dir(workspace,project_id)
172
+ lab_model=verify_prepared_model(workspace,project_id,model)
173
+ if session['lab_mapping_digest']!=mapping_digest(lab_model):raise LabHold('LAB_MODEL_DIGEST_MISMATCH')
174
+ if not _manifest_ok(studio_request(port,'GET','/api/manifest'),lab_model):
175
+ raise LabHold('STUDIO_MANIFEST_CHANGED_HOLD')
176
+ return state,model,lab_model
177
+
178
+
179
+ def write_evidence(workspace,project_id,basename,data):
180
+ if basename not in ('protocol-probe','isolated-fault-scenario'):raise LabHold('INVALID_EVIDENCE_TYPE')
181
+ path=project_lab_dir(workspace,project_id)/(basename+'-'+__import__('uuid').uuid4().hex[:12]+'.json')
182
+ with path.open('x',encoding='utf-8') as output:json.dump(data,output,indent=2)
183
+ return path.name
184
+
185
+
186
+ def protocol_probe(workspace,project_id,port,session):
187
+ state,model,lab=require_session(workspace,project_id,port,session)
188
+ result=studio_request(port,'POST','/api/probe',b'{"source":"customer"}')
189
+ if result.get('status')!='PASS' or result.get('verified')!=len(lab['signals']) or result.get('total')!=len(lab['signals']):
190
+ raise LabHold('OPC_LAB_READBACK_NOT_PASS')
191
+ evidence={'status':'PASS_STUDIO_LAB_PROTOCOL_ONLY','studio_protocol':result,
192
+ 'customer_mapping_digest':mapping_digest(model), 'lab_mapping_digest':mapping_digest(lab),
193
+ 'mapping_to_physical_plc':'NOT_VERIFIED','hmi_perspective_binding':'NOT_VERIFIED',
194
+ 'production':'HOLD','time_utc':__import__('datetime').datetime.now(__import__('datetime').timezone.utc).isoformat()}
195
+ name=write_evidence(workspace,project_id,'protocol-probe',evidence)
196
+ return {'status':evidence['status'],'verified_nodes':result['verified'],'total_nodes':result['total'],
197
+ 'evidence_file':name,'ignition':'NOT_VERIFIED','production':'HOLD'}
198
+
199
+
200
+ def _set_customer(port,node,value):
201
+ result=studio_request(port,'POST','/api/command',json.dumps({'op':'set','source':'customer','path':node,'value':value}).encode())
202
+ if result.get('status')!='APPLIED':raise LabHold('STUDIO_FAULT_STIMULUS_REJECTED')
203
+
204
+
205
+ def run_fault_scenario(workspace,project_id,port,session):
206
+ state,model,lab=require_session(workspace,project_id,port,session)
207
+ signal=next((s for s in lab['signals'] if s['role']=='fault' and s['datatype']=='Boolean'),None)
208
+ if signal is None:raise LabHold('NO_BOOLEAN_FAULT_SIGNAL_FOR_SCENARIO')
209
+ if signal.get('polarity','')=='active_low':raise LabHold('ACTIVE_LOW_FAULT_SCENARIO_NEEDS_SPECIFIC_ADAPTER')
210
+ path=signal['source']['node_id']
211
+ endpoint=state['opc_endpoint']
212
+ # Only the approved loopback/Docker lab OPC endpoint; never physical equipment.
213
+ from .opc_snapshot import allow_lab_endpoint
214
+ allow_lab_endpoint(endpoint)
215
+ rows=[];cleared=False
216
+ try:
217
+ for expected in (False,True,False):
218
+ _set_customer(port,path,expected)
219
+ snapshot=asyncio.run(capture_lab(lab,endpoint,samples=1))[0]
220
+ match=next((o for o in snapshot['observations'] if o['signal_id']==signal['id']),None)
221
+ if not match or match['quality']!='GOOD' or match['value'] is not expected:
222
+ raise LabHold('INDEPENDENT_OPC_READBACK_VALUE_MISMATCH')
223
+ rows.append(snapshot)
224
+ cleared=True
225
+ finally:
226
+ if not cleared:
227
+ try:_set_customer(port,path,False)
228
+ except (LabHold,OSError):raise LabHold('LAB_FAULT_RESET_NOT_CONFIRMED')
229
+ receipt=evaluate(lab,rows,origin='ISOLATED_LAB_OPC_READ_ONLY')
230
+ events=[x['kind'] for x in receipt['events'] if x.get('signal_id')==signal['id']]
231
+ if events!=['ASSERTED','CLEARED']:raise LabHold('FAULT_ASSERT_CLEAR_EVENTS_NOT_CONFIRMED')
232
+ evidence={'status':'PASS_ISOLATED_STUDIO_FAULT_READBACK_ONLY',
233
+ 'test_signal_id':signal['id'],'asset_id':signal['asset_id'],'node_id':path,
234
+ 'test_sequence':['CLEAR','ASSERT','CLEAR'],'independent_opc_readback':True,
235
+ 'alarm_events':events,'receipt':receipt,
236
+ 'ignition_hmi_binding':'NOT_TESTED','physical_plc':'NOT_VERIFIED','production':'HOLD'}
237
+ name=write_evidence(workspace,project_id,'isolated-fault-scenario',evidence)
238
+ return {'status':evidence['status'],'asset_id':signal['asset_id'],'node_id':path,
239
+ 'alarm_events':events,'evidence_file':name,'ignition':'NOT_VERIFIED','production':'HOLD'}
240
+
241
+
242
+ def readonly_probe_existing_unbound(workspace, project_id, port):
243
+ """Read only; no import, simulated stimulus, or adoption of unknown Studio tags.
244
+
245
+ Enables protocol-only coverage when another Creator session left customer lab
246
+ nodes behind. Exact manifest is verified before and after probing. The lab
247
+ remains UNBOUND and cannot run any writes/fault scenario through this path.
248
+ """
249
+ model=load_project(workspace,project_id)
250
+ state=studio_state(port)
251
+ if state['customer']['tag_count']!=len(model['signals']) or not model['signals']:
252
+ raise LabHold('EXISTING_STUDIO_TAG_COUNT_MISMATCH')
253
+ lab=copy.deepcopy(model)
254
+ for s in lab['signals']:
255
+ s['source']['namespace_uri']=LAB_NAMESPACE
256
+ if not _manifest_ok(studio_request(port,'GET','/api/manifest'),lab):
257
+ raise LabHold('EXISTING_STUDIO_MANIFEST_NOT_PROJECT_MATCHED')
258
+ result=studio_request(port,'POST','/api/probe',b'{"source":"customer"}')
259
+ updated=studio_state(port)
260
+ if (updated['opc_endpoint']!=state['opc_endpoint'] or
261
+ updated['customer']['revision']!=state['customer']['revision'] or
262
+ updated['customer']['tag_count']!=state['customer']['tag_count'] or
263
+ abs((time.time()-updated['uptime_seconds'])-(time.time()-state['uptime_seconds']))>5 or
264
+ not _manifest_ok(studio_request(port,'GET','/api/manifest'),lab)):
265
+ raise LabHold('STUDIO_CHANGED_DURING_READONLY_PROBE')
266
+ if (result.get('status')!='PASS' or result.get('verified')!=len(lab['signals']) or
267
+ result.get('total')!=len(lab['signals'])):
268
+ raise LabHold('READONLY_STUDIO_PROBE_NOT_PASS')
269
+ return {'status':'PASS_UNBOUND_READONLY_LAB_PROTOCOL_ONLY',
270
+ 'verified_nodes':result['verified'],'total_nodes':result['total'],
271
+ 'mapping_digest':mapping_digest(model),
272
+ 'studio_identity':{'revision':updated['customer']['revision'],
273
+ 'endpoint':updated['opc_endpoint'],
274
+ 'started':time.time()-float(updated['uptime_seconds'])},
275
+ 'session_authority':'UNBOUND_READONLY_ONLY',
276
+ 'simulated_fault':'NOT_ALLOWED_UNBOUND',
277
+ 'physical_plc':'NOT_VERIFIED','ignition':'NOT_VERIFIED','production':'HOLD'}
@@ -0,0 +1,87 @@
1
+ """Read-only, project-bound Studio test coverage for a compact engineering UX.
2
+
3
+ Only a same-process, same-project, manifest-matched Studio session can retain lab
4
+ results. Aggregate probe results are not mislabeled per-signal physical proofs.
5
+ No HMI, PLC, Studio or project files are modified by report generation.
6
+ """
7
+ from __future__ import annotations
8
+ from .generator import fat_inventory
9
+ import time
10
+ import copy
11
+ from .operations import mapping_digest
12
+ from .studio_workflow import (load_project,studio_state,require_session,LabHold,LAB_NAMESPACE,_manifest_ok,studio_request)
13
+
14
+
15
+ def summarize(workspace, pid, port, session=None, recorded=None):
16
+ model=load_project(workspace,pid)
17
+ recorded=recorded or {}
18
+ active=False
19
+ reason='Lab results have not been run for this project.'
20
+ if session and session.get('mapping_digest')==mapping_digest(model):
21
+ try:
22
+ require_session(workspace,pid,port,session)
23
+ active=True
24
+ reason='Creator project lab session and Studio manifest verified.'
25
+ except (LabHold,ValueError,OSError,KeyError,TypeError):
26
+ reason='Lab source changed, is offline or cannot be verified. Previous results are not current.'
27
+ unbound=False
28
+ ub=recorded.get('unbound_probe')
29
+ if not active and isinstance(ub,dict) and ub.get('status')=='PASS_UNBOUND_READONLY_LAB_PROTOCOL_ONLY' and ub.get('mapping_digest')==mapping_digest(model):
30
+ try:
31
+ state=studio_state(port)
32
+ identity=ub.get('studio_identity',{})
33
+ lab=copy.deepcopy(model)
34
+ for sig in lab['signals']:sig['source']['namespace_uri']=LAB_NAMESPACE
35
+ if (state['customer']['revision']==identity['revision'] and
36
+ state['opc_endpoint']==identity['endpoint'] and
37
+ abs((time.time()-state['uptime_seconds'])-identity['started'])<5 and
38
+ state['customer']['tag_count']==len(lab['signals']) and
39
+ _manifest_ok(studio_request(port,'GET','/api/manifest'),lab)):
40
+ unbound=True
41
+ reason='Unbound existing Studio nodes matched for READ-ONLY batch probe. No fault authority.'
42
+ except (LabHold,ValueError,OSError,KeyError,TypeError):pass
43
+ probe=recorded.get('probe') if active else None
44
+ fault=recorded.get('fault') if active else None
45
+ probe_pass=isinstance(probe,dict) and probe.get('status')=='PASS_STUDIO_LAB_PROTOCOL_ONLY' and probe.get('verified_nodes')==len(model['signals']) and probe.get('total_nodes')==len(model['signals'])
46
+ fault_pass=isinstance(fault,dict) and fault.get('status')=='PASS_ISOLATED_STUDIO_FAULT_READBACK_ONLY' and fault.get('alarm_events')==['ASSERTED','CLEARED']
47
+ if fault_pass and not probe_pass:
48
+ fault_pass=False # Cannot claim standalone full coverage when source gate is unproven.
49
+ signals=[]
50
+ for s in model['signals']:
51
+ signals.append({'id':s['id'],'asset_id':s['asset_id'],'role':s['role'],
52
+ 'datatype':s['datatype'],'node_id':s['source']['node_id'],
53
+ 'namespace_uri':s['source']['namespace_uri'],
54
+ 'protocol':'INCLUDED_IN_BATCH' if probe_pass else 'UNBOUND_READONLY_BATCH' if unbound else 'PENDING',
55
+ 'individual_evidence':'NOT_AVAILABLE', 'physical_source':'NOT_VERIFIED'})
56
+ cases=[];lab_fault_count=0
57
+ for row in fat_inventory(model):
58
+ c=dict(row)
59
+ if fault_pass and c.get('asset_id')==fault.get('asset_id') and c.get('signal_id') and c['id'].endswith('_FAULT') and any(sig['id']==c['signal_id'] and sig['source']['node_id']==fault.get('node_id') for sig in model['signals']):
60
+ c['lab']='LAB_FAULT_PASS'
61
+ lab_fault_count+=1
62
+ elif probe_pass and c.get('signal_id'):
63
+ c['lab']='PROTOCOL_BATCH_ONLY'
64
+ else:c['lab']='NOT_RUN'
65
+ c['hmi']='NOT_VERIFIED'
66
+ c['result']='HOLD_NOT_FULL_FAT'
67
+ cases.append(c)
68
+ return {'schema':'devagent.studio.test-coverage.v1','project_id':pid,'project_name':model['name'],
69
+ 'source':'ISOLATED_STUDIO_LAB_ONLY', 'mapping_digest':mapping_digest(model),
70
+ 'session_verified':active,'unbound_readonly':unbound,'message':reason,
71
+ 'totals':{'assets':len(model['equipment']),'signals':len(signals),
72
+ 'fat_cases':len(cases),'protocol_batch_checked':len(signals) if (probe_pass or unbound) else 0,
73
+ 'individual_signal_receipts':0,'lab_behavior_cases_pass':lab_fault_count,
74
+ 'hmi_cases_pass':0,'fully_qualified_cases':0},
75
+ 'gates':{'G1_customer_mapping':'NOT_PHYSICALLY_VERIFIED',
76
+ 'G2_lab_opc_readback':'PASS_LAB_ONLY' if probe_pass else 'READONLY_UNBOUND_LAB_ONLY' if unbound else 'PENDING',
77
+ 'G3_lab_fault':'PASS_LAB_ONLY' if fault_pass else 'PENDING',
78
+ 'G4_ignition_hmi':'NOT_IMPLEMENTED_NOT_VERIFIED',
79
+ 'G5_production':'HOLD'},
80
+ 'signals':signals,'test_cases':cases,
81
+ 'evidence':{'protocol':probe.get('evidence_file') if probe_pass else None,
82
+ 'fault':fault.get('evidence_file') if fault_pass else None},
83
+ 'demo':recorded.get('sample',{'status':'NOT_RUN'}),
84
+ 'limitations':['Studio probe returns batch totals, not individual per-signal receipts.',
85
+ 'One declared simulated Boolean fault transition is exercised when available.',
86
+ 'Other FAT cases, actual Perspective behavior and physical PLC mapping remain HOLD.'],
87
+ 'production':'HOLD'}
@@ -0,0 +1,180 @@
1
+ """V0.7.6 isolated fake OPC UA protocol FAT (never physical PLC or Ignition FAT).
2
+
3
+ The simulated namespace deliberately differs from every customer-supplied namespace.
4
+ No client-writable nodes or machine commands are exposed. The independent
5
+ observer uses a fresh OPC UA TCP client, not the server's in-memory values.
6
+
7
+ Only an actual asyncua server/client connection may return PASS_LAB_OPC_ONLY.
8
+ A manifest or deterministic test-plan alone ALWAYS remains NOT_EXECUTED.
9
+ """
10
+ from __future__ import annotations
11
+ import asyncio
12
+ import datetime as dt
13
+ import hashlib
14
+ import json
15
+ import re
16
+ from pathlib import Path
17
+ from .model import qualify
18
+
19
+ MAX_LAB_SIGNALS=200
20
+ MAX_LAB_STEPS=3
21
+ LAB_URI_PREFIX='urn:devagent:isolated:fake-opcua:v076:'
22
+ TYPES={'Boolean':'Boolean','Double':'Double','Int32':'Int32','String':'String'}
23
+
24
+ class LabHold(ValueError):pass
25
+
26
+ def model_hash(model):
27
+ return hashlib.sha256(json.dumps(model,sort_keys=True,ensure_ascii=False,separators=(',',':')).encode()).hexdigest()
28
+
29
+ def lab_manifest(model):
30
+ """Generate a separate synthetic mapping, never overwrite customer OPC identities."""
31
+ if qualify(model)['status']=='HOLD':raise LabHold('ENGINEERING_MODEL_HOLD')
32
+ signals=model['signals']
33
+ if not signals:raise LabHold('LAB_HAS_NO_DECLARED_SIGNALS')
34
+ if len(signals)>MAX_LAB_SIGNALS:raise LabHold('LAB_LIMIT_200_SIGNALS')
35
+ digest=model_hash(model)
36
+ uri=LAB_URI_PREFIX+digest[:16]
37
+ rows=[]
38
+ for signal in signals:
39
+ if signal['datatype'] not in TYPES:raise LabHold('LAB_UNSUPPORTED_DATATYPE')
40
+ if signal.get('write_authorized') is True:raise LabHold('WRITABLE_CUSTOMER_SIGNALS_PROHIBITED')
41
+ rows.append({'signal_id':signal['id'],'equipment_id':signal['asset_id'],
42
+ 'role':signal['role'],'datatype':signal['datatype'],
43
+ 'lab_namespace_uri':uri,'lab_node_id':'LAB/'+signal['id'],
44
+ 'ignition_lab_tag_path':None,
45
+ 'production_namespace_uri':None,'production_node_id':None,
46
+ 'binding_review':'PENDING_ENGINEER_REVIEW',
47
+ 'source':'GENERATED_SYNTHETIC_LAB_NOT_CUSTOMER_MAPPING'})
48
+ return {'schema':'devagent.opc.synthetic_manifest.v076','status':'PLAN_ONLY_NOT_EXECUTED',
49
+ 'model_sha256':digest,'lab_namespace_uri':uri,'signals':rows,
50
+ 'physical_mapping':'NOT_VERIFIED','ignition_binding':'NOT_CONFIGURED',
51
+ 'production_release':'HOLD','write_authority':'DISABLED'}
52
+
53
+ def scenario_value(signal,step):
54
+ """Type-preserving fixture, explicitly NOT derived from machinery speeds or states."""
55
+ datatype=signal['datatype'];role=signal['role']
56
+ if datatype=='Boolean':return step==1
57
+ if datatype=='Int32':return 1 if step==1 else 0
58
+ if datatype=='String':return 'LAB_EXERCISE' if step==1 else 'LAB_IDLE'
59
+ if datatype=='Double':
60
+ if step!=1:return 0.0
61
+ return {'vfd_frequency':25.0,'tank_level':50.0,'pressure':1.0,
62
+ 'flow':1.0,'motor_speed':0.5}.get(role,1.0)
63
+ raise LabHold('LAB_UNSUPPORTED_DATATYPE')
64
+
65
+ def plan(model):
66
+ manifest=lab_manifest(model)
67
+ return {'schema':'devagent.fat.plan.v076','status':'NOT_EXECUTED',
68
+ 'model_sha256':manifest['model_sha256'],'lab_manifest':manifest,
69
+ 'steps':['BASELINE','EXERCISE','RESTORE'],
70
+ 'cases_planned':len(manifest['signals'])*MAX_LAB_STEPS,
71
+ 'opc_protocol':'NOT_RUN','ignition_gateway':'NOT_VERIFIED',
72
+ 'perspective_hmi':'NOT_VERIFIED','customer_plc':'NOT_VERIFIED',
73
+ 'production_release':'HOLD'}
74
+
75
+ def _verified_phase(manifest,step,readbacks):
76
+ """Independent evaluator used with values obtained from OPC TCP client."""
77
+ failures=[];cases=[]
78
+ for row in manifest['signals']:
79
+ sid=row['signal_id'];rec=readbacks.get(sid)
80
+ expected=scenario_value(row,step)
81
+ dtype=row['datatype']
82
+ ok=(isinstance(rec,dict) and rec.get('namespace_uri')==row['lab_namespace_uri']
83
+ and rec.get('node_id')==row['lab_node_id']
84
+ and rec.get('datatype')==dtype and rec.get('good') is True
85
+ and type(rec.get('value')) == {'Boolean':bool,'Int32':int,'Double':float,'String':str}[dtype]
86
+ and rec['value']==expected)
87
+ if not ok:failures.append(sid)
88
+ cases.append({'signal_id':sid,'phase':('BASELINE','EXERCISE','RESTORE')[step],
89
+ 'result':'PASS_SYNTHETIC_OPC_READBACK_ONLY' if ok else 'FAIL',
90
+ 'expected_datatype':dtype,'quality_good':bool(rec.get('good')) if isinstance(rec,dict) else False,
91
+ 'actual_value_recorded':False})
92
+ return cases,failures
93
+
94
+ async def run_loopback_fat(model,port:int=4898):
95
+ """Create OPC UA server on loopback, stimulate synthetic signals, read with a client.
96
+
97
+ All nodes are read-only to clients; writes only occur from this server process.
98
+ Does not import to Ignition or touch any customer PLC. Returns fail-closed.
99
+ """
100
+ if type(port) is not int or not 1024<=port<=65535:raise LabHold('LAB_PORT_INVALID')
101
+ manifest=lab_manifest(model)
102
+ try:
103
+ from asyncua import Client, Server, ua
104
+ except ImportError as exc:raise LabHold('ASYNCUA_NOT_INSTALLED') from exc
105
+ server=Server()
106
+ await server.init()
107
+ endpoint=f'opc.tcp://127.0.0.1:{port}/devagent-v076-lab/'
108
+ server.set_endpoint(endpoint)
109
+ server.set_server_name('DevAgent V076 ISOLATED SYNTHETIC LAB ONLY')
110
+ # Anonymous no-security endpoint is only allowed on 127.0.0.1.
111
+ # This is a diagnostic lab server, NEVER expose to external interfaces.
112
+ from asyncua import ua
113
+ server.set_security_policy([ua.SecurityPolicyType.NoSecurity])
114
+ ns=await server.register_namespace(manifest['lab_namespace_uri'])
115
+ root=await server.nodes.objects.add_object(ns,'DevAgentSyntheticLab')
116
+ nodes={}
117
+ for row in manifest['signals']:
118
+ variant_type=getattr(ua.VariantType,row['datatype'])
119
+ nid=ua.NodeId(row['lab_node_id'],ns)
120
+ nodes[row['signal_id']]=await root.add_variable(nid,row['signal_id'],
121
+ ua.Variant(scenario_value(row,0),variant_type))
122
+ # Intentionally DO NOT call set_writable: client writes are prohibited.
123
+ cases=[];issues=[]
124
+ try:
125
+ async with server:
126
+ # Independent TCP connection and OPC UA Session; not in-memory validation.
127
+ client=Client(endpoint,timeout=4)
128
+ async with client:
129
+ namespaces=await client.get_namespace_array()
130
+ if manifest['lab_namespace_uri'] not in namespaces:
131
+ raise LabHold('LAB_NAMESPACE_NOT_REGISTERED')
132
+ observed_ns=namespaces.index(manifest['lab_namespace_uri'])
133
+ for step in range(MAX_LAB_STEPS):
134
+ for row in manifest['signals']:
135
+ await nodes[row['signal_id']].write_value(
136
+ ua.Variant(scenario_value(row,step),getattr(ua.VariantType,row['datatype'])))
137
+ reads={}
138
+ for row in manifest['signals']:
139
+ name=row['signal_id']
140
+ remote=client.get_node(ua.NodeId(row['lab_node_id'],observed_ns))
141
+ try:
142
+ dv=await asyncio.wait_for(remote.read_data_value(raise_on_bad_status=False),timeout=3)
143
+ reads[name]={'namespace_uri':manifest['lab_namespace_uri'],
144
+ 'node_id':row['lab_node_id'],
145
+ 'datatype':dv.Value.VariantType.name if dv.Value is not None else 'NONE',
146
+ 'good':dv.StatusCode.is_good(),
147
+ 'value':dv.Value.Value if dv.Value is not None else None}
148
+ except Exception as e:
149
+ reads[name]={'error_class':type(e).__name__}
150
+ phase,failed=_verified_phase(manifest,step,reads)
151
+ cases.extend(phase)
152
+ issues.extend(failed)
153
+ except LabHold:raise
154
+ except Exception as exc:
155
+ # Do not leak network/host details or vendor strings in persistent reports.
156
+ raise LabHold('ISOLATED_OPC_PROTOCOL_'+type(exc).__name__.upper()) from exc
157
+ passed=not issues and len(cases)==len(manifest['signals'])*3
158
+ return {'schema':'devagent.fat.lab_run.v076',
159
+ 'status':'PASS_LAB_OPC_PROTOCOL_ONLY' if passed else 'FAIL_LAB_OPC_PROTOCOL',
160
+ 'model_sha256':manifest['model_sha256'],
161
+ 'lab_namespace_uri':manifest['lab_namespace_uri'],
162
+ 'opc_endpoint_scope':'LOOPBACK_ONLY','source':'SYNTHETIC_NOT_CUSTOMER',
163
+ 'cases_planned':len(manifest['signals'])*3,
164
+ 'cases_checked':len(cases),
165
+ 'cases_passed':sum(c['result']=='PASS_SYNTHETIC_OPC_READBACK_ONLY' for c in cases),
166
+ 'issues_signal_ids':sorted(set(issues)),'cases':cases,
167
+ 'independent_client_tcp_session':True,
168
+ 'client_write_authority':'DISABLED',
169
+ 'ignition_gateway':'NOT_VERIFIED','perspective_hmi':'NOT_VERIFIED',
170
+ 'physical_mapping':'NOT_VERIFIED','production_release':'HOLD',
171
+ 'recorded_utc':dt.datetime.now(dt.timezone.utc).isoformat(),
172
+ 'qualification_boundary':'OPC protocol synthetic fixtures only; no machine process, Ignition FAT or PLC proof.'}
173
+
174
+ def save_new(path:Path,data:dict):
175
+ path=Path(path)
176
+ path.parent.mkdir(parents=True,exist_ok=True)
177
+ with path.open('x',encoding='utf8') as f:
178
+ json.dump(data,f,ensure_ascii=False,indent=2)
179
+ f.write('\n')
180
+ return path