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,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
|