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
+ """V0.7.8 isolated Device Command Contract and Virtual PLC FAT.
2
+
3
+ No production endpoints accepted. Control authority is restricted to a single
4
+ fake OPC UA server on loopback. Native Perspective output is a disabled-by-default
5
+ lab candidate until independently imported/tested on an isolated Ignition Gateway.
6
+ """
7
+ from __future__ import annotations
8
+ import asyncio
9
+ import datetime as dt
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import uuid
14
+ from pathlib import Path
15
+ from .model import qualify
16
+
17
+ SCHEMA = 'devagent.device_command_contract.v078'
18
+ REPORT_SCHEMA = 'devagent.fat.device_commands.v078'
19
+ LAB_NS_PREFIX = 'urn:devagent:isolated:virtual-plc:v078:'
20
+ COMMANDS = ('START', 'STOP', 'RESET')
21
+ SUPPORTED = frozenset({'conveyor', 'spiral', 'sorter', 'motor', 'vfd', 'pump'})
22
+ ASSET_RE = re.compile(r'[A-Za-z][A-Za-z0-9_]{1,59}\Z')
23
+ LAB_PROVIDER = 'DevAgentLabCommands'
24
+ LAB_CONFIRM = 'ISOLATED_VIRTUAL_PLC_LOOPBACK_ONLY'
25
+
26
+ class CommandHold(ValueError):
27
+ pass
28
+
29
+
30
+ def digest(model):
31
+ return hashlib.sha256(json.dumps(model, ensure_ascii=False, sort_keys=True,
32
+ separators=(',', ':')).encode()).hexdigest()
33
+
34
+
35
+ def contract_template(model:dict, asset_id:str) -> dict:
36
+ """Build *unapproved* lab contract; never infer a physical PLC address."""
37
+ if qualify(model)['status'] != 'PASS_STATIC_CONTRACT_ONLY':
38
+ raise CommandHold('CUSTOMER_MODEL_HOLD')
39
+ assets = [x for x in model['equipment'] if x['id'] == asset_id]
40
+ if len(assets) != 1 or assets[0]['kind'] not in SUPPORTED or not ASSET_RE.fullmatch(asset_id):
41
+ raise CommandHold('EQUIPMENT_NOT_ELIGIBLE_FOR_DEMO_COMMANDS')
42
+ base = '[%s]VirtualPLC/%s/' % (LAB_PROVIDER, asset_id)
43
+ return {'schema':SCHEMA, 'model_sha256':digest(model),
44
+ 'scope':'ISOLATED_FAKE_OPCUA_ONLY','asset_id':asset_id,
45
+ 'kind':assets[0]['kind'], 'actions':list(COMMANDS),
46
+ 'tag_paths':{'request':base+'CommandRequest','ack_id':base+'AckId',
47
+ 'result':base+'CommandResult','state':base+'State',
48
+ 'run_feedback':base+'RunFeedback','fault':base+'Fault',
49
+ 'permissive':base+'Permissive'},
50
+ 'simulated_namespace_uri':LAB_NS_PREFIX+digest(model)[:16],
51
+ 'engineer':'','approval':'PENDING_LAB_REVIEW',
52
+ 'timeout_ms':2500, 'role':'Authenticated/Roles/DevAgentLabOperator',
53
+ 'command_identity':'LAB_SYNTHETIC_NOT_CUSTOMER_TAG',
54
+ 'production_mapping':None, 'production_authority':'NONE',
55
+ 'note':'Explicit lab-only contract. No customer NodeIds or field authorization.'}
56
+
57
+
58
+ def validate_contract(model, c, *, require_lab_approval=False):
59
+ if not isinstance(c,dict) or set(c)!=set(contract_template(model,c.get('asset_id',''))):
60
+ raise CommandHold('COMMAND_CONTRACT_SCHEMA_MISMATCH')
61
+ skeleton=contract_template(model,c['asset_id'])
62
+ immutable=['schema','model_sha256','scope','asset_id','kind','actions','tag_paths',
63
+ 'simulated_namespace_uri','timeout_ms','role','command_identity',
64
+ 'production_mapping','production_authority']
65
+ for key in immutable:
66
+ if c[key] != skeleton[key]: raise CommandHold('COMMAND_CONTRACT_FIELD_MISMATCH:'+key)
67
+ if not isinstance(c['note'],str) or len(c['note'])>400:
68
+ raise CommandHold('CONTRACT_NOTE_INVALID')
69
+ if require_lab_approval:
70
+ if c['approval']!='APPROVED_FOR_ISOLATED_FAT_ONLY' or not isinstance(c['engineer'],str) or not 3<=len(c['engineer'].strip())<=100:
71
+ raise CommandHold('EXPLICIT_ENGINEER_LAB_APPROVAL_REQUIRED')
72
+ elif c['approval'] not in ('PENDING_LAB_REVIEW','APPROVED_FOR_ISOLATED_FAT_ONLY'):
73
+ raise CommandHold('CONTRACT_APPROVAL_INVALID')
74
+ return c
75
+
76
+
77
+ def transition(state, action, permissive, fault):
78
+ """Functional control model; independent oracle for OPC UA readback tests."""
79
+ if action not in COMMANDS: return state,'REJECTED_UNKNOWN_COMMAND'
80
+ if action=='STOP': return 'FAULT' if fault else 'STOPPED','ACCEPTED_STOP'
81
+ if action=='RESET':
82
+ if state=='FAULT' and fault: return 'STOPPED','ACCEPTED_RESET'
83
+ return state,'REJECTED_RESET_REQUIRES_FAULT'
84
+ if fault or state=='FAULT': return state,'REJECTED_FAULT_ACTIVE'
85
+ if not permissive: return state,'REJECTED_PERMISSIVE'
86
+ if state!='STOPPED': return state,'REJECTED_NOT_STOPPED'
87
+ return 'RUNNING','ACCEPTED_START'
88
+
89
+
90
+ async def run_virtual_plc_fat(model, contract:dict, *, port=4899, confirm=None):
91
+ """Run a local OPC UA server + independent TCP OPC UA client command/feedback FAT.
92
+
93
+ Client writes are limited to the synthetic CommandRequest node on 127.0.0.1.
94
+ All mechanical interlocks here are simulated, not field-proven.
95
+ """
96
+ if confirm!=LAB_CONFIRM: raise CommandHold('LAB_CONFIRM_REQUIRED')
97
+ validate_contract(model,contract,require_lab_approval=True)
98
+ if type(port) is not int or not 1024<=port<=65535:raise CommandHold('PORT_INVALID')
99
+ try:
100
+ from asyncua import Server, Client, ua
101
+ except ImportError as ex:
102
+ raise CommandHold('ASYNCUA_NOT_INSTALLED_REAL_OPC_TEST_NOT_RUN') from ex
103
+ server=Server()
104
+ await server.init()
105
+ uri=contract['simulated_namespace_uri']
106
+ endpoint='opc.tcp://127.0.0.1:%d/devagent-virtual-plc/' % port
107
+ server.set_endpoint(endpoint)
108
+ server.set_server_name('DevAgent V078 ISOLATED VIRTUAL PLC (NOT PHYSICAL)')
109
+ server.set_security_policy([ua.SecurityPolicyType.NoSecurity])
110
+ namespace=await server.register_namespace(uri)
111
+ root=await server.nodes.objects.add_object(namespace, 'VirtualPLC_'+contract['asset_id'])
112
+ # Test node identities are generated solely inside a fresh lab namespace.
113
+ variables={}
114
+ node_types={'CommandRequest':ua.VariantType.String,'AckId':ua.VariantType.String,
115
+ 'CommandResult':ua.VariantType.String,'State':ua.VariantType.String,
116
+ 'RunFeedback':ua.VariantType.Boolean,'Fault':ua.VariantType.Boolean,
117
+ 'Permissive':ua.VariantType.Boolean}
118
+ initials={'CommandRequest':'','AckId':'','CommandResult':'IDLE',
119
+ 'State':'STOPPED','RunFeedback':False,'Fault':False,'Permissive':True}
120
+ for name in node_types:
121
+ nid=ua.NodeId('VPLC/'+contract['asset_id']+'/'+name,namespace)
122
+ variables[name]=await root.add_variable(nid,name,ua.Variant(initials[name],node_types[name]))
123
+ # Only command request is client-writable. No physical output is present.
124
+ await variables['CommandRequest'].set_writable()
125
+ state={'value':'STOPPED','fault':False,'permissive':True}
126
+ processed=set()
127
+ stop=asyncio.Event()
128
+ async def worker():
129
+ while not stop.is_set():
130
+ raw=await variables['CommandRequest'].read_value()
131
+ if raw and raw not in processed:
132
+ processed.add(raw)
133
+ try:
134
+ req,action=raw.split('|',1)
135
+ if not re.fullmatch('[0-9a-f]{32}',req):raise ValueError
136
+ except ValueError:
137
+ req='INVALID';action='INVALID'
138
+ new,outcome=transition(state['value'],action,state['permissive'],state['fault'])
139
+ if outcome=='ACCEPTED_RESET':state['fault']=False
140
+ state['value']=new
141
+ await variables['State'].write_value(new)
142
+ await variables['Fault'].write_value(state['fault'])
143
+ await variables['RunFeedback'].write_value(new=='RUNNING')
144
+ await variables['CommandResult'].write_value(outcome)
145
+ await variables['AckId'].write_value(req)
146
+ await asyncio.sleep(.02)
147
+ tests=[]
148
+ async def scenario(client,action,expected_state,expected_result):
149
+ request_id=uuid.uuid4().hex
150
+ await client.get_node(ua.NodeId('VPLC/'+contract['asset_id']+'/CommandRequest',ns_client)).write_value(request_id+'|'+action)
151
+ deadline=asyncio.get_running_loop().time()+contract['timeout_ms']/1000
152
+ observations={}
153
+ while asyncio.get_running_loop().time()<deadline:
154
+ readings={}
155
+ for name in ('AckId','CommandResult','State','RunFeedback','Fault','Permissive'):
156
+ remote=client.get_node(ua.NodeId('VPLC/'+contract['asset_id']+'/'+name,ns_client))
157
+ dv=await remote.read_data_value(raise_on_bad_status=False)
158
+ readings[name]=dv
159
+ if readings['AckId'].Value.Value==request_id:
160
+ observations={k:v.Value.Value for k,v in readings.items()}
161
+ observations['all_quality_good']=all(v.StatusCode.is_good() for v in readings.values())
162
+ break
163
+ await asyncio.sleep(.03)
164
+ passed=bool(observations and observations['all_quality_good'] and
165
+ observations['State']==expected_state and observations['CommandResult']==expected_result and
166
+ observations['RunFeedback']==(expected_state=='RUNNING'))
167
+ tests.append({'command':action,'expected_state':expected_state,'expected_result':expected_result,
168
+ 'status':'PASS_LAB_ONLY' if passed else 'FAIL',
169
+ 'observed':{k:v for k,v in observations.items() if k!='AckId'},
170
+ 'independent_opc_tcp':True})
171
+ if not passed:raise CommandHold('OPC_COMMAND_ACK_OR_FEEDBACK_MISMATCH_'+action)
172
+
173
+ try:
174
+ async with server:
175
+ task=asyncio.create_task(worker())
176
+ try:
177
+ # TCP client has its own OPC UA session independent of server memory.
178
+ async with Client(endpoint,timeout=4) as client:
179
+ arr=await client.get_namespace_array()
180
+ if uri not in arr:raise CommandHold('SYNTHETIC_NAMESPACE_MISMATCH')
181
+ ns_client=arr.index(uri)
182
+ await scenario(client,'START','RUNNING','ACCEPTED_START')
183
+ await scenario(client,'START','RUNNING','REJECTED_NOT_STOPPED')
184
+ await scenario(client,'STOP','STOPPED','ACCEPTED_STOP')
185
+ await scenario(client,'RESET','STOPPED','REJECTED_RESET_REQUIRES_FAULT')
186
+ # Fault stimulus is *server-side* only, not a writable OPC tag.
187
+ state.update(value='FAULT',fault=True)
188
+ await variables['State'].write_value('FAULT')
189
+ await variables['Fault'].write_value(True)
190
+ await scenario(client,'START','FAULT','REJECTED_FAULT_ACTIVE')
191
+ await scenario(client,'RESET','STOPPED','ACCEPTED_RESET')
192
+ state['permissive']=False
193
+ await variables['Permissive'].write_value(False)
194
+ await scenario(client,'START','STOPPED','REJECTED_PERMISSIVE')
195
+ state['permissive']=True
196
+ await variables['Permissive'].write_value(True)
197
+ await scenario(client,'START','RUNNING','ACCEPTED_START')
198
+ await scenario(client,'STOP','STOPPED','ACCEPTED_STOP')
199
+ # Verify readback tags cannot be remotely overwritten.
200
+ try:
201
+ await client.get_node(ua.NodeId('VPLC/'+contract['asset_id']+'/RunFeedback',ns_client)).write_value(True)
202
+ raise CommandHold('PROTECTED_FEEDBACK_UNEXPECTEDLY_WRITABLE')
203
+ except ua.UaStatusCodeError:
204
+ tests.append({'command':'UNAUTHORIZED_FEEDBACK_WRITE','status':'PASS_LAB_ONLY',
205
+ 'expected_result':'ACCESS_DENIED'})
206
+ finally:
207
+ stop.set()
208
+ task.cancel()
209
+ try:await task
210
+ except asyncio.CancelledError:pass
211
+ except CommandHold:raise
212
+ except Exception as exc:raise CommandHold('LAB_RUNTIME_'+type(exc).__name__.upper()) from exc
213
+ ok=len(tests)==10 and all(x['status']=='PASS_LAB_ONLY' for x in tests)
214
+ return {'schema':REPORT_SCHEMA,'status':'PASS_ISOLATED_VIRTUAL_PLC_COMMAND_FAT_ONLY' if ok else 'FAIL',
215
+ 'asset_id':contract['asset_id'],'model_sha256':contract['model_sha256'],
216
+ 'contract_sha256':hashlib.sha256(json.dumps(contract,sort_keys=True).encode()).hexdigest(),
217
+ 'cases':tests,'case_count':len(tests),'endpoint_scope':'127.0.0.1_ONLY',
218
+ 'fake_opcua':'EXECUTED_WITH_INDEPENDENT_TCP_CLIENT','ignition_gateway':'NOT_TESTED',
219
+ 'physical_plc':'NOT_VERIFIED','production_release':'HOLD',
220
+ 'recorded_utc':dt.datetime.now(dt.timezone.utc).isoformat()}
221
+
222
+ async def serve_virtual_plc(model, contract:dict, *, port=4899, seconds=300, confirm=None):
223
+ """Temporary live Virtual PLC for *manual* Ignition test on the SAME HOST.
224
+
225
+ Unlike the automatic FAT runner, this stays online for a bounded time so
226
+ Ignition Perspective can issue lab CommandRequest writes through approved
227
+ OPC tags. No production or Docker/public binding is attempted.
228
+ """
229
+ if confirm!=LAB_CONFIRM:raise CommandHold('LAB_CONFIRM_REQUIRED')
230
+ validate_contract(model,contract,require_lab_approval=True)
231
+ if type(port) is not int or not 1024<=port<=65535:raise CommandHold('PORT_INVALID')
232
+ if type(seconds) is not int or not 10<=seconds<=900:raise CommandHold('LAB_DURATION_LIMIT_10_TO_900_SECONDS')
233
+ try:
234
+ from asyncua import Server,ua
235
+ except ImportError as exc:raise CommandHold('ASYNCUA_NOT_INSTALLED_REAL_OPC_TEST_NOT_RUN') from exc
236
+ server=Server();await server.init()
237
+ uri=contract['simulated_namespace_uri']
238
+ endpoint=f'opc.tcp://127.0.0.1:{port}/devagent-virtual-plc/'
239
+ server.set_endpoint(endpoint)
240
+ server.set_server_name('DevAgent V078 LAB ONLY OPC UA -- NEVER PRODUCTION')
241
+ server.set_security_policy([ua.SecurityPolicyType.NoSecurity])
242
+ ns=await server.register_namespace(uri)
243
+ obj=await server.nodes.objects.add_object(ns,'VirtualPLC_'+contract['asset_id'])
244
+ fields={'CommandRequest':('',ua.VariantType.String), 'AckId':('',ua.VariantType.String),
245
+ 'CommandResult':('IDLE',ua.VariantType.String), 'State':('STOPPED',ua.VariantType.String),
246
+ 'RunFeedback':(False,ua.VariantType.Boolean), 'Fault':(False,ua.VariantType.Boolean),
247
+ 'Permissive':(True,ua.VariantType.Boolean)}
248
+ nodes={}
249
+ for name,(initial,kind) in fields.items():
250
+ nodes[name]=await obj.add_variable(ua.NodeId('VPLC/'+contract['asset_id']+'/'+name,ns),name,
251
+ ua.Variant(initial,kind))
252
+ await nodes['CommandRequest'].set_writable()
253
+ processed=set();state='STOPPED';fault=False
254
+ start=asyncio.get_running_loop().time()
255
+ try:
256
+ async with server:
257
+ while asyncio.get_running_loop().time()-start<seconds:
258
+ raw=await nodes['CommandRequest'].read_value()
259
+ if raw and raw not in processed:
260
+ processed.add(raw)
261
+ try:
262
+ request_id,action=raw.split('|',1)
263
+ if not re.fullmatch('[0-9a-f]{32}',request_id):raise ValueError
264
+ except ValueError:
265
+ request_id='INVALID';action='INVALID'
266
+ permissible=await nodes['Permissive'].read_value()
267
+ state,outcome=transition(state,action,permissible,fault)
268
+ if outcome=='ACCEPTED_RESET':fault=False
269
+ await nodes['State'].write_value(state)
270
+ await nodes['Fault'].write_value(fault)
271
+ await nodes['RunFeedback'].write_value(state=='RUNNING')
272
+ await nodes['CommandResult'].write_value(outcome)
273
+ await nodes['AckId'].write_value(request_id)
274
+ await asyncio.sleep(.025)
275
+ except Exception as exc:raise CommandHold('LAB_SERVER_'+type(exc).__name__.upper()) from exc
276
+ return {'status':'LAB_SERVER_STOPPED_AFTER_BOUNDED_WINDOW','endpoint_scope':'LOOPBACK_ONLY',
277
+ 'physical_plc':'NOT_VERIFIED','ignition_runtime':'NOT_VERIFIED','production':'HOLD'}
@@ -0,0 +1,161 @@
1
+ """Native Perspective lab command panel candidate; no Gateway I/O or production authority.
2
+
3
+ Extends V07 isolated read-only project export with reviewed, LAB-only command actions.
4
+ Only project-owned, disabled-by-default candidate ZIPs are generated. All operator
5
+ writes are to fixed DevAgentLabCommands provider; role, tag quality and simulated
6
+ permissives are checked at click time. Gateway FAT is mandatory before using.
7
+ """
8
+ from __future__ import annotations
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import tempfile
13
+ import zipfile
14
+ from pathlib import Path
15
+ from .v078_commands import validate_contract, CommandHold, LAB_PROVIDER
16
+ from .v07_native import expected_members as v07_expected, _walk
17
+ from .ignition_project import _zip_blob
18
+ from .ignition_native import sha, NativeHold
19
+
20
+
21
+ def _lab_script(c, action):
22
+ tags=c['tag_paths']
23
+ # The script contains ONLY fixed lab tag paths from the validated contract.
24
+ # Role must be configured via Ignition IdP; tag write permissions separately.
25
+ # This script is a *Gateway-test candidate*, not a production control strategy.
26
+ lines=[
27
+ 'import uuid',
28
+ 'logger = system.util.getLogger("DevAgent.LAB.DeviceCommands")',
29
+ 'if not system.perspective.isAuthorized(False, ["Authenticated/Roles/DevAgentLabOperator"]):',
30
+ ' logger.warn("DENIED: lab operator security level required")',
31
+ 'else:',
32
+ ' paths = '+repr([tags['state'],tags['fault'],tags['permissive']]),
33
+ ' checks = system.tag.readBlocking(paths, 2000)',
34
+ ' if not all(x.quality.isGood() for x in checks):',
35
+ ' logger.warn("DENIED: lab feedback quality unavailable")',
36
+ ' else:',
37
+ ' state, fault, permissive = [x.value for x in checks]',
38
+ ]
39
+ pred={'START':'state == "STOPPED" and not fault and permissive',
40
+ 'STOP':'state in ("STOPPED", "RUNNING", "FAULT")',
41
+ 'RESET':'state == "FAULT" and fault'}[action]
42
+ lines += [
43
+ ' if not ('+pred+'):',
44
+ ' logger.warn("DENIED: lab command precondition")',
45
+ ' else:',
46
+ ' request = str(uuid.uuid4()).replace("-", "") + "|'+action+'"',
47
+ ' # LAB PROVIDER ONLY; never substitute a customer tag path.',
48
+ ' result = system.tag.writeBlocking(['+repr(tags['request'])+'], [request], 2000)',
49
+ ' if not result or not result[0].isGood():',
50
+ ' logger.error("LAB request write failed; check OPC quality/permissions")',
51
+ ' else:',
52
+ ' logger.info("LAB command request submitted; verify AckId and State before FAT PASS")',
53
+ ]
54
+ return '\n'.join('\t'+line for line in lines)+'\n'
55
+
56
+
57
+ def _button(c, action):
58
+ return {'type':'ia.input.button','meta':{'name':'Lab_'+action},
59
+ 'props':{'text':action+' · LAB ONLY','enabled':True,
60
+ 'style':{'backgroundColor':'#174c67','color':'#f4faff','padding':10}},
61
+ 'position':{'basis':'auto','grow':0,'shrink':0},
62
+ 'events':{'component':{'onActionPerformed':{'type':'script','scope':'G',
63
+ 'config':{'script':_lab_script(c,action)}}}}}
64
+
65
+
66
+ def _label_binding(name, tag_path):
67
+ return {'type':'ia.display.label','meta':{'name':'Lab_'+name},
68
+ 'props':{'text':'UNKNOWN','style':{'color':'#f3cb7d','fontSize':13}},
69
+ 'position':{'basis':'auto','grow':0,'shrink':0},
70
+ 'propConfig':{'props.text':{'binding':{'type':'tag',
71
+ 'config':{'mode':'direct','tagPath':tag_path}}}}}
72
+
73
+
74
+ def expected_lab_members(model, binding_manifest, c, reference, template_view, reference_sha, name):
75
+ validate_contract(model,c,require_lab_approval=True)
76
+ members,meta=v07_expected(model,binding_manifest,Path(reference),template_view,
77
+ reference_sha,name,project_enabled=False)
78
+ view_paths=[n for n in members if n.endswith('/view.json')]
79
+ if len(view_paths)!=1:raise CommandHold('NATIVE_VIEW_COUNT_INVALID')
80
+ vp=view_paths[0]
81
+ view=json.loads(members[vp].decode())
82
+ root=view['root']
83
+ panel={'type':'ia.container.flex','meta':{'name':'DevAgentV078LabCommandPanel'},
84
+ 'props':{'direction':'column','style':{'padding':18,'backgroundColor':'#102b40',
85
+ 'border':'2px solid #d8a75a','borderRadius':8,'gap':8}},
86
+ 'position':{'basis':'auto','grow':0,'shrink':0},
87
+ 'children':[
88
+ {'type':'ia.display.label','meta':{'name':'CommandNotice'},
89
+ 'props':{'text':'VIRTUAL PLC LAB ONLY · NEVER USE WITH PHYSICAL PLC',
90
+ 'style':{'color':'#ffcc72','fontWeight':'bold'}},
91
+ 'position':{'basis':'auto','grow':0,'shrink':0}},
92
+ {'type':'ia.display.label','meta':{'name':'CommandAsset'},
93
+ 'props':{'text':'Equipment: '+c['asset_id']+' | simulator authority only'},
94
+ 'position':{'basis':'auto','grow':0,'shrink':0}},
95
+ *[_label_binding(k, c['tag_paths'][k]) for k in ('state','run_feedback','fault','permissive','ack_id','result')],
96
+ {'type':'ia.container.flex','meta':{'name':'LabCommandActions'},
97
+ 'props':{'direction':'row','wrap':'wrap','style':{'gap':12}},
98
+ 'position':{'basis':'auto','grow':0,'shrink':0},
99
+ 'children':[_button(c,action) for action in c['actions']]},
100
+ ]}
101
+ root['children'].append(panel)
102
+ if sum(1 for _ in _walk(root))>1600:raise CommandHold('NATIVE_COMPONENT_LIMIT')
103
+ members[vp]=(json.dumps(view,sort_keys=True,indent=2)+'\n').encode()
104
+ _audit_native_members(members,c)
105
+ return members, {'base':meta,'view_path':vp}
106
+
107
+
108
+ def _audit_native_members(members,c):
109
+ if len(members)!=5:raise CommandHold('NATIVE_MEMBER_COUNT_INVALID')
110
+ if not any(n.endswith('page-config/config.json') for n in members):
111
+ raise CommandHold('PAGE_ROUTING_MISSING')
112
+ project=[json.loads(v) for k,v in members.items() if k.endswith('project.json')]
113
+ if len(project)!=1 or project[0].get('enabled') is not False:
114
+ raise CommandHold('NATIVE_PROJECT_MUST_BE_DISABLED')
115
+ views=[json.loads(v) for k,v in members.items() if k.endswith('/view.json')]
116
+ if len(views)!=1:raise CommandHold('INVALID_NATIVE_VIEW')
117
+ panels=[x for x in _walk(views[0]['root']) if x.get('meta',{}).get('name')=='DevAgentV078LabCommandPanel']
118
+ if len(panels)!=1:raise CommandHold('LAB_PANEL_MISSING')
119
+ buttons=[x for x in _walk(views[0]['root']) if x.get('meta',{}).get('name','').startswith('Lab_') and x.get('type')=='ia.input.button']
120
+ if len(buttons)!=3:raise CommandHold('LAB_BUTTON_COUNT_INVALID')
121
+ expected=[_lab_script(c,action) for action in c['actions']]
122
+ actual=[x['events']['component']['onActionPerformed']['config']['script'] for x in buttons]
123
+ if actual!=expected:raise CommandHold('LAB_SCRIPT_TAMPER_DETECTED')
124
+ for value in members.values():
125
+ if b'[default]' in value or b'prod:' in value.lower():raise CommandHold('PRODUCTION_PROVIDER_REFERENCE_DETECTED')
126
+ return True
127
+
128
+
129
+ def compile_lab_native(model,bindings,c,reference,template_view,reference_sha,name,out,confirm):
130
+ if confirm!='ISOLATED_IGNITION_LAB_CANDIDATE_ONLY':raise CommandHold('CONFIRM_REQUIRED')
131
+ target=Path(out).expanduser().absolute()
132
+ if target.suffix.lower()!='.zip' or not target.parent.is_dir() or target.exists():
133
+ raise CommandHold('CREATE_ONLY_ZIP_REQUIRED')
134
+ prior=sha(Path(reference))
135
+ members,meta=expected_lab_members(model,bindings,c,reference,template_view,reference_sha,name)
136
+ if sha(Path(reference))!=prior:raise CommandHold('REFERENCE_CHANGED')
137
+ payload=_zip_blob(members)
138
+ fd,tmp=tempfile.mkstemp(prefix='.v078-native-',suffix='.zip',dir=target.parent)
139
+ try:
140
+ with os.fdopen(fd,'wb') as f:
141
+ f.write(payload);f.flush();os.fsync(f.fileno())
142
+ if Path(tmp).read_bytes()!=payload:raise CommandHold('STAGED_BYTES_INVALID')
143
+ try:os.link(tmp,target)
144
+ except FileExistsError:raise CommandHold('TARGET_ALREADY_EXISTS') from None
145
+ os.chmod(target,0o600)
146
+ finally:Path(tmp).unlink(missing_ok=True)
147
+ return {'status':'PASS_OFFLINE_NATIVE_LAB_COMMAND_CANDIDATE_ONLY',
148
+ 'path':str(target),'sha256':sha(target),'asset_id':c['asset_id'],
149
+ 'number_of_lab_commands':3,'project_default_enabled':False,
150
+ 'native_events':'GENERATED_NOT_GATEWAY_TESTED',
151
+ 'virtual_plc_opc':'SEPARATE_LAB_FAT_REQUIRED','live_ignition':'NOT_VERIFIED',
152
+ 'physical_plc':'NOT_VERIFIED','production_release':'HOLD'}
153
+
154
+
155
+ def verify_lab_native(model,bindings,c,reference,template_view,reference_sha,name,candidate):
156
+ target=Path(candidate)
157
+ if not target.is_file() or target.is_symlink():raise CommandHold('CANDIDATE_UNSAFE')
158
+ members,_=expected_lab_members(model,bindings,c,reference,template_view,reference_sha,name)
159
+ if target.read_bytes()!=_zip_blob(members):raise CommandHold('NATIVE_CANDIDATE_NOT_REPRODUCIBLE')
160
+ return {'status':'PASS_OFFLINE_NATIVE_COMMAND_VERIFICATION_ONLY','sha256':sha(target),
161
+ 'native_ignition_runtime':'NOT_TESTED','production_release':'HOLD'}
@@ -0,0 +1,110 @@
1
+ """V0.7 independent consistency assessment for supplied LAB observations.
2
+
3
+ The data is UNATTESTED: this module cannot observe a Gateway or Perspective UI.
4
+ It must never return Ignition FAT PASS, physical mapping PASS, or production approval.
5
+ """
6
+ from __future__ import annotations
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import re
11
+ from pathlib import Path
12
+ from .v07_native import model_sha, canonical, validate_manifest
13
+ from .ignition_native import NativeHold
14
+
15
+ SCHEMA='devagent.fat.v07.supplied_observations.v1'
16
+ EXPECTED=('GOOD','BAD','STALE')
17
+ PHASES={'GOOD':'GOOD','BAD':'BAD','STALE':'STALE'}
18
+
19
+
20
+ def _same_value(v1,v2,datatype):
21
+ if datatype=='Boolean':
22
+ if type(v1) is not bool:return False
23
+ return (type(v2) is bool and v1==v2) or (isinstance(v2,str) and v2.lower()==str(v1).lower())
24
+ if datatype=='Int32':
25
+ return type(v1) is int and -2147483648<=v1<=2147483647 and (
26
+ (type(v2) is int and v1==v2) or (isinstance(v2,str) and v2==str(v1)))
27
+ if datatype=='Double':
28
+ if type(v1) not in (int,float) or not math.isfinite(v1):return False
29
+ try:return (type(v2) in (int,float,str) and math.isfinite(float(v2))
30
+ and abs(float(v1)-float(v2))<=max(1e-6,abs(v1)*1e-7))
31
+ except (ValueError,TypeError,OverflowError):return False
32
+ if datatype=='String':return isinstance(v1,str) and v1==v2
33
+ return False
34
+
35
+
36
+ def assess(model:dict,manifest:dict,observations:dict,candidate:Path) -> dict:
37
+ approved=validate_manifest(model,manifest)
38
+ if not isinstance(observations,dict) or set(observations)!={'schema','model_sha256','candidate_sha256','source','signals'}:
39
+ raise NativeHold('FAT_OBSERVATIONS_SCHEMA_INVALID')
40
+ if observations['schema']!=SCHEMA or observations['model_sha256']!=model_sha(model):
41
+ raise NativeHold('FAT_MODEL_IDENTITY_MISMATCH')
42
+ if (not isinstance(observations['candidate_sha256'],str)
43
+ or not re.fullmatch('[a-f0-9]{64}',observations['candidate_sha256'])):
44
+ raise NativeHold('FAT_CANDIDATE_SHA256_REQUIRED')
45
+ candidate=Path(candidate)
46
+ if (not candidate.is_file() or candidate.is_symlink()
47
+ or candidate.stat().st_size>4_000_000
48
+ or hashlib.sha256(candidate.read_bytes()).hexdigest()!=observations['candidate_sha256']):
49
+ raise NativeHold('FAT_CANDIDATE_IDENTITY_MISMATCH')
50
+ if observations['source']!='EXTERNALLY_SUPPLIED_UNATTESTED_LAB_OBSERVATIONS':
51
+ raise NativeHold('FAT_SOURCE_CLAIM_NOT_ALLOWED')
52
+ samples=observations['signals']
53
+ if not isinstance(samples,list) or len(samples)>len(model['signals']):
54
+ raise NativeHold('FAT_SAMPLE_COLLECTION_INVALID')
55
+ declared={s['id']:s for s in model['signals']}
56
+ found=set();cases=[]
57
+ for item in samples:
58
+ if not isinstance(item,dict) or set(item)!={'signal_id','phases'}:
59
+ raise NativeHold('FAT_SIGNAL_OBSERVATION_INVALID')
60
+ sid=item['signal_id']
61
+ if not isinstance(sid,str) or sid not in declared or sid in found:
62
+ raise NativeHold('FAT_SIGNAL_ID_UNKNOWN_OR_DUPLICATE')
63
+ found.add(sid)
64
+ if sid not in approved:raise NativeHold('FAT_UNAPPROVED_BINDING_OBSERVATION')
65
+ readings=item['phases']
66
+ if not isinstance(readings,list) or len(readings)!=3:
67
+ cases.append({'signal_id':sid,'result':'HOLD_PHASE_COVERAGE','issues':['PHASES_REQUIRED_GOOD_BAD_STALE']})
68
+ continue
69
+ by_phase={};issues=[]
70
+ for phase in readings:
71
+ if not isinstance(phase,dict) or set(phase)!={'phase','opc','ignition','hmi'}:
72
+ issues.append('PHASE_FORMAT_INVALID');continue
73
+ key=phase['phase']
74
+ if key not in EXPECTED or key in by_phase:
75
+ issues.append('PHASE_DUPLICATE_OR_UNKNOWN');continue
76
+ by_phase[key]=phase
77
+ if set(by_phase)!=set(EXPECTED):issues.append('PHASE_COVERAGE_MISMATCH')
78
+ for phase_name,record in by_phase.items():
79
+ for source in ('opc','ignition','hmi'):
80
+ entry=record[source]
81
+ if not isinstance(entry,dict) or 'quality' not in entry or entry['quality'] not in EXPECTED:
82
+ issues.append(phase_name+'_'+source+'_QUALITY_INVALID')
83
+ elif entry['quality']!=phase_name:
84
+ issues.append(phase_name+'_'+source+'_QUALITY_MISMATCH')
85
+ if any(not isinstance(record[s],dict) for s in ('opc','ignition','hmi')):continue
86
+ if phase_name=='GOOD':
87
+ if 'value' not in record['opc'] or 'value' not in record['ignition'] or 'display_value' not in record['hmi']:
88
+ issues.append('GOOD_READBACK_DATA_MISSING')
89
+ else:
90
+ dt=declared[sid]['datatype'];v=record['opc']['value']
91
+ if not _same_value(v,record['ignition']['value'],dt):issues.append('OPC_IGNITION_VALUE_MISMATCH')
92
+ if not _same_value(v,record['hmi']['display_value'],dt):issues.append('IGNITION_HMI_VALUE_MISMATCH')
93
+ else:
94
+ if record['hmi'].get('quality_indicator_visible') is not True:
95
+ issues.append(phase_name+'_HMI_QUALITY_INDICATION_MISSING')
96
+ cases.append({'signal_id':sid,'result':'PASS_SUPPLIED_DATA_CONSISTENCY_ONLY' if not issues else 'HOLD',
97
+ 'issues':sorted(set(issues))})
98
+ for sid in declared:
99
+ if sid not in found:cases.append({'signal_id':sid,'result':'HOLD_NO_OBSERVATIONS',
100
+ 'issues':['NO_SUPPLIED_OBSERVATIONS']})
101
+ cases.sort(key=lambda c:c['signal_id'])
102
+ complete=all(c['result']=='PASS_SUPPLIED_DATA_CONSISTENCY_ONLY' for c in cases)
103
+ return {'schema':'devagent.fat.v07.consistency_review.v1',
104
+ 'status':'PASS_SUPPLIED_DATA_CONSISTENCY_ONLY' if complete else 'HOLD',
105
+ 'model_sha256':model_sha(model), 'candidate_sha256':observations['candidate_sha256'],
106
+ 'observations_sha256':hashlib.sha256(canonical(observations)).hexdigest(),
107
+ 'cases':cases,'total_signals':len(declared),'cases_consistent':sum(c['result']=='PASS_SUPPLIED_DATA_CONSISTENCY_ONLY' for c in cases),
108
+ 'source_trust':'UNATTESTED', 'requirements_fat':'NOT_EXECUTED', 'real_ignition_fat':'NOT_VERIFIED',
109
+ 'physical_mapping':'NOT_VERIFIED','production_release':'HOLD',
110
+ 'remarks':'Supplied capture data is not proof of Gateway execution. Obtain independently attested readbacks and live UI evidence.'}