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,234 @@
|
|
|
1
|
+
"""Guarded native Perspective VIEW RESOURCE candidate compiler.
|
|
2
|
+
|
|
3
|
+
Generated from customer models and a user-supplied *real* Perspective export.
|
|
4
|
+
It never reaches a Gateway, invents tag-provider paths, or claims Ignition import.
|
|
5
|
+
Only a new, read-only Perspective view is emitted. No scripts, events, or writes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
import zipfile
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from copy import deepcopy
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
|
|
19
|
+
from .model import qualify
|
|
20
|
+
from .native_reference import audit_ignition_archive
|
|
21
|
+
from .generator import slugify
|
|
22
|
+
from .strict_json import loads as strict_loads
|
|
23
|
+
|
|
24
|
+
VIEW_PATH = re.compile(r'^(?P<prefix>(?:[^/]+/)*)com\.inductiveautomation\.perspective/views/(?P<name>.+)/view\.json$')
|
|
25
|
+
CANDIDATE_SCHEMA = 'devagent.ignition.native_candidate.v0.3'
|
|
26
|
+
MAX_NATIVE_COMPONENTS = 1600
|
|
27
|
+
|
|
28
|
+
class NativeHold(ValueError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sha(path: Path) -> str:
|
|
33
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _j(blob: bytes, label: str):
|
|
37
|
+
if len(blob) > 3_000_000: raise NativeHold(f'{label}_TOO_LARGE')
|
|
38
|
+
try:
|
|
39
|
+
return strict_loads(blob.decode('utf-8'))
|
|
40
|
+
except (ValueError, UnicodeDecodeError, RecursionError):
|
|
41
|
+
raise NativeHold(f'{label}_NOT_VALID_JSON') from None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_views(path: Path) -> dict:
|
|
45
|
+
"""Safety inspect a project export and enumerate candidate template resources."""
|
|
46
|
+
audit = audit_ignition_archive(path)
|
|
47
|
+
if audit['status'] != 'PASS_ZIP_SAFETY_ONLY':
|
|
48
|
+
raise NativeHold('REFERENCE_ARCHIVE_HOLD: '+','.join(audit['issues']))
|
|
49
|
+
with zipfile.ZipFile(path) as z:
|
|
50
|
+
members = set(z.namelist())
|
|
51
|
+
viewfiles = []
|
|
52
|
+
for name in sorted(members):
|
|
53
|
+
match = VIEW_PATH.fullmatch(name)
|
|
54
|
+
if match and (name[:-len('view.json')]+'resource.json') in members:
|
|
55
|
+
viewfiles.append(name)
|
|
56
|
+
if not viewfiles:
|
|
57
|
+
raise NativeHold('NO_PERSPECTIVE_VIEWS_WITH_RESOURCE_METADATA')
|
|
58
|
+
projects = [n for n in sorted(members) if n == 'project.json' or n.endswith('/project.json')]
|
|
59
|
+
return {'status':'PASS_REFERENCE_STRUCTURE_ONLY','sha256':audit['sha256'],
|
|
60
|
+
'views':viewfiles,'project_json_candidates':projects,
|
|
61
|
+
'ignition_version':'NOT_ATTESTED','runtime_import':'NOT_TESTED'}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _label(text: str, size=14, bold=False, color='#ecf5ff', *, name='Label', pad=None) -> dict:
|
|
65
|
+
style = {'color':color,'fontSize':f'{size}px','fontFamily':'Arial, sans-serif',
|
|
66
|
+
'fontWeight':700 if bold else 400, 'whiteSpace':'normal'}
|
|
67
|
+
if pad: style['padding'] = pad
|
|
68
|
+
return {'type':'ia.display.label', 'version':0, 'meta':{'name':name},
|
|
69
|
+
'props':{'text':str(text)[:320], 'style':style},
|
|
70
|
+
'position':{'basis':'auto','grow':0,'shrink':0}}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _flex(children, *, name, direction='column', style=None, grow=0) -> dict:
|
|
74
|
+
return {'type':'ia.container.flex','version':0,'meta':{'name':name},
|
|
75
|
+
'props':{'direction':direction,'wrap':'wrap' if direction=='row' else 'nowrap',
|
|
76
|
+
'style':style or {}},
|
|
77
|
+
'position':{'basis':'auto','grow':grow,'shrink':1},'children':children}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_view(model: dict) -> dict:
|
|
81
|
+
"""Native read-only view tree; asset and mapping identities are *not* live tag bindings."""
|
|
82
|
+
report = qualify(model)
|
|
83
|
+
if report['status'] != 'PASS_STATIC_CONTRACT_ONLY':
|
|
84
|
+
raise NativeHold('CUSTOMER_CONTRACT_HOLD')
|
|
85
|
+
palette = {'bg':'#07131f','panel':'#0e2638','border':'#294b61',
|
|
86
|
+
'accent':'#58c1dc','text':'#ecf5ff','muted':'#82a6b9','warning':'#e6bd70'}
|
|
87
|
+
by_asset = defaultdict(list)
|
|
88
|
+
for s in model.get('signals', []):by_asset[s['asset_id']].append(s)
|
|
89
|
+
floors = defaultdict(list)
|
|
90
|
+
for e in model['equipment']:floors[e.get('floor',1)].append(e)
|
|
91
|
+
top = _flex([
|
|
92
|
+
_label('DEVAGENT / SPATIAL OPERATIONS',13,True,palette['accent'],name='Brand'),
|
|
93
|
+
_label(model['name'],27,True,name='ProjectName'),
|
|
94
|
+
_label('READ-ONLY ENGINEERING CANDIDATE • ALL SIGNALS NOT LIVE VERIFIED',12,True,palette['warning'],name='ReleaseNotice')
|
|
95
|
+
],name='Header',style={'backgroundColor':palette['panel'], 'borderBottom':'1px solid '+palette['border'], 'padding':18,'gap':5})
|
|
96
|
+
stats = _flex([
|
|
97
|
+
_label(f'{len(floors)} FLOOR(S)',15,True,palette['accent'],name='FloorCount'),
|
|
98
|
+
_label(f'{len(model["equipment"])} ASSETS',15,True,palette['accent'],name='AssetCount'),
|
|
99
|
+
_label(f'{len(model.get("signals",[]))} PROPOSED SIGNALS',15,True,palette['accent'],name='SignalCount'),
|
|
100
|
+
_label(f'{len(model.get("connections",[]))} CONNECTIONS',15,True,palette['accent'],name='ConnectionCount')
|
|
101
|
+
],name='Summary',direction='row',style={'gap':28,'padding':16,'backgroundColor':palette['bg']})
|
|
102
|
+
group_views=[]
|
|
103
|
+
for floor, equip in sorted(floors.items()):
|
|
104
|
+
cards=[]
|
|
105
|
+
for e in equip:
|
|
106
|
+
rows=[_label(e['id'],16,True,palette['accent'],name='AssetId'),
|
|
107
|
+
_label(e['name'],14,True,name='AssetName'),
|
|
108
|
+
_label(f'{e["kind"]} • {e.get("zone") or "UNASSIGNED ZONE"}',11,color=palette['muted'],name='AssetDetail')]
|
|
109
|
+
asset_signals=sorted(by_asset[e['id']],key=lambda s:s['role'])
|
|
110
|
+
rows.extend(_label(f'{s["role"]} / {s["datatype"]} / NOT VERIFIED',11,
|
|
111
|
+
color=palette['muted'],name='ProposedSignal') for s in asset_signals[:8])
|
|
112
|
+
if len(asset_signals)>8:
|
|
113
|
+
rows.append(_label(f'+{len(asset_signals)-8} additional proposed signals',11,color=palette['muted'],name='MoreSignals'))
|
|
114
|
+
if not asset_signals: rows.append(_label('NO SIGNAL MAPPING PROVIDED',11,color=palette['warning'],name='NoSignals'))
|
|
115
|
+
cards.append(_flex(rows,name='AssetCard',style={'backgroundColor':palette['panel'],'border':'1px solid '+palette['border'],
|
|
116
|
+
'borderRadius':8,'padding':14,'minWidth':235,'maxWidth':330,'gap':5}))
|
|
117
|
+
group_views.append(_flex([
|
|
118
|
+
_label('FLOOR '+str(floor),20,True,palette['accent'],name='FloorHeading'),
|
|
119
|
+
_flex(cards,name='EquipmentGrid',direction='row',style={'gap':12,'paddingTop':10})
|
|
120
|
+
],name='FloorGroup',style={'padding':16,'gap':4}))
|
|
121
|
+
footer = _label('Engineer review required: verify equipment topology, tag providers, live OPC data, and Ignition runtime before deployment.',
|
|
122
|
+
13,color=palette['warning'],name='QualificationFooter',pad=12)
|
|
123
|
+
root = _flex([top,stats,*group_views,footer],name='DevAgentRoot',
|
|
124
|
+
style={'backgroundColor':palette['bg'],'color':palette['text'],
|
|
125
|
+
'minHeight':650,'overflow':'auto','gap':2})
|
|
126
|
+
root.pop('position',None) # a Perspective view's root has no parent container
|
|
127
|
+
# These standard view-envelope fields and component versions are present in
|
|
128
|
+
# genuine Ignition 8.3 Perspective exports. Keep them explicit so the
|
|
129
|
+
# generated resource is native Perspective rather than browser-preview JSON.
|
|
130
|
+
view = {'custom':{},'params':{},'propConfig':{},
|
|
131
|
+
'props':{'defaultSize':{'width':1440,'height':900}},'root':root}
|
|
132
|
+
def walk(c):
|
|
133
|
+
yield c
|
|
134
|
+
for ch in c.get('children',[]): yield from walk(ch)
|
|
135
|
+
count=sum(1 for _ in walk(root))
|
|
136
|
+
if count>MAX_NATIVE_COMPONENTS: raise NativeHold('NATIVE_VIEW_COMPLEXITY_LIMIT')
|
|
137
|
+
return view
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _template_reference(reference_zip:Path, template_view:str, expected_sha256:str):
|
|
141
|
+
index = find_views(reference_zip)
|
|
142
|
+
if not re.fullmatch('[0-9a-f]{64}', expected_sha256):
|
|
143
|
+
raise NativeHold('EXACT_REFERENCE_SHA256_REQUIRED')
|
|
144
|
+
if index['sha256']!=expected_sha256:
|
|
145
|
+
raise NativeHold('NATIVE_REFERENCE_SHA256_MISMATCH')
|
|
146
|
+
if template_view not in index['views']:
|
|
147
|
+
raise NativeHold('TEMPLATE_VIEW_NOT_FOUND_IN_REFERENCE')
|
|
148
|
+
with zipfile.ZipFile(reference_zip) as z:
|
|
149
|
+
refview = _j(z.read(template_view),'REFERENCE_VIEW')
|
|
150
|
+
resource = _j(z.read(template_view[:-len('view.json')]+'resource.json'),'REFERENCE_RESOURCE')
|
|
151
|
+
# Deliberately do not execute, copy, or re-use a template component event/script.
|
|
152
|
+
if not isinstance(refview,dict) or not isinstance(refview.get('root'),dict) or not str(refview['root'].get('type','')).startswith('ia.container.'):
|
|
153
|
+
raise NativeHold('REFERENCE_VIEW_ENVELOPE_UNSUPPORTED')
|
|
154
|
+
if not isinstance(resource,dict) or 'view.json' not in resource.get('files',[]):
|
|
155
|
+
raise NativeHold('REFERENCE_RESOURCE_METADATA_UNSUPPORTED')
|
|
156
|
+
if any(not isinstance(k,str) for k in resource):raise NativeHold('REFERENCE_RESOURCE_METADATA_UNSUPPORTED')
|
|
157
|
+
return index,resource
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def native_member_names(template_view:str, target_name:str):
|
|
161
|
+
match=VIEW_PATH.fullmatch(template_view)
|
|
162
|
+
if not match:raise NativeHold('TEMPLATE_PATH_UNSUPPORTED')
|
|
163
|
+
base=match.group('prefix')+'com.inductiveautomation.perspective/views/DevAgentGenerated/'+target_name+'/'
|
|
164
|
+
return base+'view.json',base+'resource.json'
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _candidate_resources(model,reference,template_view,reference_sha256):
|
|
168
|
+
index,base_resource=_template_reference(reference,template_view,reference_sha256)
|
|
169
|
+
view=build_view(model)
|
|
170
|
+
target = slugify(model['name']).replace('-','_')
|
|
171
|
+
viewpath,respath=native_member_names(template_view,target)
|
|
172
|
+
if viewpath in index['views']: raise NativeHold('TARGET_VIEW_ALREADY_EXISTS_IN_SOURCE')
|
|
173
|
+
# Construct fresh minimal resource metadata from the verified template.
|
|
174
|
+
# Do NOT clone UUIDs, attributes, secrets, or opaque template identity fields.
|
|
175
|
+
metadata={key:deepcopy(base_resource[key]) for key in ('scope','version','restricted','overridable') if key in base_resource}
|
|
176
|
+
# Match the standard reference envelope. Ignition 8.3's deserializer also
|
|
177
|
+
# accepts omitted attributes; this is not a fix for missing resource data.
|
|
178
|
+
# Never copy unreviewed source attributes.
|
|
179
|
+
metadata['attributes']={}
|
|
180
|
+
metadata['files']=['view.json']
|
|
181
|
+
# Do not carry template scripts, bindings, or extra files into a new view.
|
|
182
|
+
return {viewpath:(json.dumps(view,indent=2,ensure_ascii=False)+'\n').encode(),
|
|
183
|
+
respath:(json.dumps(metadata,indent=2,ensure_ascii=False)+'\n').encode()},index
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def compile_candidate(model:dict,reference:Path,template_view:str,expected_sha256:str,output:Path):
|
|
187
|
+
"""Publish only NEW resources in a separate ZIP (not a full project export)."""
|
|
188
|
+
out=Path(output).expanduser().resolve()
|
|
189
|
+
if out.exists(): raise NativeHold('OUTPUT_ALREADY_EXISTS')
|
|
190
|
+
if out.suffix.lower()!='.zip' or not out.parent.is_dir():raise NativeHold('OUTPUT_MUST_BE_ZIP_IN_EXISTING_FOLDER')
|
|
191
|
+
before=sha(Path(reference))
|
|
192
|
+
resources,index=_candidate_resources(model,Path(reference),template_view,expected_sha256)
|
|
193
|
+
fd,tmp=tempfile.mkstemp(prefix='.devagent-native-',suffix='.zip',dir=str(out.parent))
|
|
194
|
+
os.close(fd)
|
|
195
|
+
try:
|
|
196
|
+
with zipfile.ZipFile(tmp,'w',compression=zipfile.ZIP_DEFLATED) as z:
|
|
197
|
+
for name,blob in sorted(resources.items()):
|
|
198
|
+
# No additional ZIP entries, metadata, tag provider, script, or control.
|
|
199
|
+
z.writestr(name,blob)
|
|
200
|
+
if before!=sha(Path(reference)):raise NativeHold('REFERENCE_MUTATED_DURING_GENERATION')
|
|
201
|
+
# Audit the actual staged candidate content before publish.
|
|
202
|
+
with zipfile.ZipFile(tmp) as z:
|
|
203
|
+
if set(z.namelist())!=set(resources):raise NativeHold('STAGED_NATIVE_FILES_MISMATCH')
|
|
204
|
+
if z.testzip():raise NativeHold('STAGED_NATIVE_CRC_ERROR')
|
|
205
|
+
for name,blob in resources.items():
|
|
206
|
+
if z.read(name)!=blob:raise NativeHold('STAGED_NATIVE_RESOURCE_CHANGED')
|
|
207
|
+
try: os.link(tmp,out) # create-only atomic publication; existing output never overwritten
|
|
208
|
+
except FileExistsError: raise NativeHold('OUTPUT_ALREADY_EXISTS_AT_PUBLISH') from None
|
|
209
|
+
try:
|
|
210
|
+
os.chmod(out,0o600)
|
|
211
|
+
except OSError:pass
|
|
212
|
+
finally:
|
|
213
|
+
Path(tmp).unlink(missing_ok=True)
|
|
214
|
+
return {'schema':CANDIDATE_SCHEMA,'status':'PASS_OFFLINE_NATIVE_VIEW_CANDIDATE_ONLY',
|
|
215
|
+
'path':str(out),'sha256':sha(out),'reference_sha256':before,
|
|
216
|
+
'members':sorted(resources),'asset_count':len(model['equipment']),
|
|
217
|
+
'signal_count':len(model.get('signals',[])),
|
|
218
|
+
'native_view':'GENERATED_READ_ONLY','live_tag_bindings':'NOT_IMPLEMENTED',
|
|
219
|
+
'ignition_import':'NOT_TESTED','runtime_fat':'NOT_EXECUTED',
|
|
220
|
+
'production_release':'HOLD'}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def verify_candidate(model,reference,template_view,expected_sha256,candidate):
|
|
224
|
+
path=Path(candidate)
|
|
225
|
+
if not path.is_file() or path.is_symlink():raise NativeHold('CANDIDATE_NOT_SAFE_REGULAR_FILE')
|
|
226
|
+
expected,index=_candidate_resources(model,Path(reference),template_view,expected_sha256)
|
|
227
|
+
with zipfile.ZipFile(path) as z:
|
|
228
|
+
if len(z.infolist()) != len(expected) or set(z.namelist())!=set(expected):
|
|
229
|
+
raise NativeHold('CANDIDATE_FILE_ALLOWLIST_MISMATCH')
|
|
230
|
+
if z.testzip():raise NativeHold('CANDIDATE_CRC_FAILURE')
|
|
231
|
+
for name,blob in expected.items():
|
|
232
|
+
if z.read(name)!=blob:raise NativeHold('CANDIDATE_CONTENT_NOT_REPRODUCIBLE')
|
|
233
|
+
return {'status':'PASS_OFFLINE_NATIVE_VIEW_CANDIDATE_ONLY','sha256':sha(path),
|
|
234
|
+
'ignition_import':'NOT_TESTED','live_bindings':'NOT_IMPLEMENTED','production_release':'HOLD'}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Conservative *offline* native Ignition project-candidate packager.
|
|
2
|
+
|
|
3
|
+
A project candidate contains only a scrubbed project.json + new generated
|
|
4
|
+
Perspective view resource. It is NOT a Gateway-qualified project export.
|
|
5
|
+
No original scripts, Perspective page routes, tags, bindings, or controls are copied.
|
|
6
|
+
This module never communicates with or modifies an Ignition Gateway.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import stat
|
|
15
|
+
import tempfile
|
|
16
|
+
import zipfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from .ignition_native import (_candidate_resources, NativeHold, sha, _j, VIEW_PATH)
|
|
19
|
+
from .native_reference import audit_ignition_archive
|
|
20
|
+
from .model import qualify
|
|
21
|
+
|
|
22
|
+
SCHEMA='devagent.ignition.isolated_project_candidate.v0.4'
|
|
23
|
+
PROJECT_ID=re.compile(r'[A-Za-z][A-Za-z0-9_]{2,49}\Z')
|
|
24
|
+
CONFIRM='OFFLINE_CANDIDATE_NO_GATEWAY_IMPORT'
|
|
25
|
+
MAX_CANDIDATE_BYTES=4_000_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _project_blob(reference:Path, template_view:str, name:str, *, enabled:bool=False) -> tuple[str,bytes,dict]:
|
|
29
|
+
if not PROJECT_ID.fullmatch(name):
|
|
30
|
+
raise NativeHold('NEW_PROJECT_NAME_INVALID_USE_ASCII_LETTER_DIGIT_UNDERSCORE')
|
|
31
|
+
# A single explicit project.json must correspond to the view's envelope.
|
|
32
|
+
with zipfile.ZipFile(reference) as z:
|
|
33
|
+
names=set(z.namelist())
|
|
34
|
+
prefix=VIEW_PATH.fullmatch(template_view)
|
|
35
|
+
if prefix is None:raise NativeHold('TEMPLATE_VIEW_PATH_UNSUPPORTED')
|
|
36
|
+
project_path=prefix.group('prefix')+'project.json'
|
|
37
|
+
projects={n for n in names if n=='project.json' or n.endswith('/project.json')}
|
|
38
|
+
if projects!={project_path}:
|
|
39
|
+
raise NativeHold('REFERENCE_PROJECT_ROOT_AMBIGUOUS')
|
|
40
|
+
existing=_j(z.read(project_path),'PROJECT_PROPERTIES')
|
|
41
|
+
if not isinstance(existing,dict):raise NativeHold('REFERENCE_PROJECT_PROPERTIES_INVALID')
|
|
42
|
+
if not isinstance(existing.get('title',''),str):raise NativeHold('REFERENCE_PROJECT_TITLE_INVALID')
|
|
43
|
+
if existing.get('title','').casefold()==name.casefold():
|
|
44
|
+
raise NativeHold('NEW_PROJECT_NAME_MATCHES_SOURCE_TITLE')
|
|
45
|
+
# Importing a project may support naming at the Gateway rather than from title;
|
|
46
|
+
# this is intentionally only a display-title hint for an isolated NEW project.
|
|
47
|
+
# Never copy source project scripts, parent inheritance, or opaque metadata.
|
|
48
|
+
if not isinstance(enabled,bool):raise NativeHold('PROJECT_ENABLED_FLAG_INVALID')
|
|
49
|
+
properties={'title':name,'description':'DevAgent offline candidate — review and isolated Gateway qualification required',
|
|
50
|
+
'enabled':enabled,'inheritable':False,'parent':''}
|
|
51
|
+
return project_path,(json.dumps(properties,sort_keys=True,indent=2)+'\n').encode(),{
|
|
52
|
+
'source_title':existing.get('title','UNSPECIFIED'),
|
|
53
|
+
'discarded_project_fields':sorted(set(existing)-set(properties)),
|
|
54
|
+
'enabled':enabled,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def expected_files(model:dict, reference:Path,template_view:str,expected_sha256:str,name:str):
|
|
59
|
+
if qualify(model)['status']!='PASS_STATIC_CONTRACT_ONLY':
|
|
60
|
+
raise NativeHold('CUSTOMER_MODEL_HOLD')
|
|
61
|
+
reference=Path(reference)
|
|
62
|
+
resources,index=_candidate_resources(model,reference,template_view,expected_sha256)
|
|
63
|
+
project_path,properties,project_info=_project_blob(reference,template_view,name)
|
|
64
|
+
members={project_path:properties,**resources}
|
|
65
|
+
if len(members)!=3:raise NativeHold('CANDIDATE_MEMBER_COLLISION')
|
|
66
|
+
return members,project_info
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _zip_blob(members:dict)->bytes:
|
|
70
|
+
import io
|
|
71
|
+
buffer=io.BytesIO()
|
|
72
|
+
with zipfile.ZipFile(buffer,'w',zipfile.ZIP_DEFLATED,compresslevel=9) as z:
|
|
73
|
+
for name,payload in sorted(members.items()):
|
|
74
|
+
info=zipfile.ZipInfo(name,date_time=(2020,1,1,0,0,0))
|
|
75
|
+
info.compress_type=zipfile.ZIP_DEFLATED
|
|
76
|
+
info.external_attr=(0o100600<<16)
|
|
77
|
+
z.writestr(info,payload)
|
|
78
|
+
result=buffer.getvalue()
|
|
79
|
+
if len(result)>MAX_CANDIDATE_BYTES:raise NativeHold('CANDIDATE_TOO_LARGE')
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compile_project(model:dict,reference:Path,template_view:str,expected_sha256:str,
|
|
84
|
+
name:str,out:Path,confirm:str):
|
|
85
|
+
if confirm!=CONFIRM:raise NativeHold('OFFLINE_CANDIDATE_CONFIRMATION_REQUIRED')
|
|
86
|
+
out=Path(out).expanduser().absolute()
|
|
87
|
+
if out.suffix.lower()!='.zip' or not out.parent.is_dir() or out.is_symlink():
|
|
88
|
+
raise NativeHold('OUTPUT_MUST_BE_NEW_ZIP_IN_EXISTING_FOLDER')
|
|
89
|
+
if out.exists():raise NativeHold('OUTPUT_ALREADY_EXISTS')
|
|
90
|
+
src=Path(reference).expanduser().absolute()
|
|
91
|
+
original_sha=sha(src)
|
|
92
|
+
members,source_info=expected_files(model,src,template_view,expected_sha256,name)
|
|
93
|
+
content=_zip_blob(members)
|
|
94
|
+
if original_sha!=sha(src):raise NativeHold('SOURCE_CHANGED_DURING_BUILD')
|
|
95
|
+
fd,temp=tempfile.mkstemp(prefix='.devagent-project-',suffix='.zip',dir=str(out.parent))
|
|
96
|
+
try:
|
|
97
|
+
with os.fdopen(fd,'wb') as f:
|
|
98
|
+
f.write(content)
|
|
99
|
+
f.flush()
|
|
100
|
+
os.fsync(f.fileno())
|
|
101
|
+
# Full staged artifact must match independent generated expectation.
|
|
102
|
+
with zipfile.ZipFile(temp) as z:
|
|
103
|
+
if set(z.namelist())!=set(members) or z.testzip():
|
|
104
|
+
raise NativeHold('STAGED_PROJECT_INVALID')
|
|
105
|
+
for path,payload in members.items():
|
|
106
|
+
if z.read(path)!=payload:raise NativeHold('STAGED_PROJECT_MISMATCH')
|
|
107
|
+
try:os.link(temp,out)
|
|
108
|
+
except FileExistsError:raise NativeHold('OUTPUT_ALREADY_EXISTS_AT_PUBLISH') from None
|
|
109
|
+
try:os.chmod(out,0o600)
|
|
110
|
+
except OSError:pass
|
|
111
|
+
finally:Path(temp).unlink(missing_ok=True)
|
|
112
|
+
return {'schema':SCHEMA,'status':'PASS_OFFLINE_ISOLATED_PROJECT_CANDIDATE_ONLY',
|
|
113
|
+
'candidate_path':str(out),'candidate_sha256':sha(out),
|
|
114
|
+
'reference_sha256':original_sha,'generated_files':sorted(members),
|
|
115
|
+
'source_project_properties':source_info,
|
|
116
|
+
'source_resources_inherited':0,'gateway_scripts_inherited':0,
|
|
117
|
+
'generated_write_actions':0,'native_page_routing':'NOT_GENERATED',
|
|
118
|
+
'live_bindings':'NOT_GENERATED','ignition_import':'NOT_TESTED',
|
|
119
|
+
'ignition_runtime':'NOT_TESTED','production_release':'HOLD'}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def verify_project(model:dict,reference:Path,template_view:str,expected_sha256:str,
|
|
123
|
+
name:str,out:Path):
|
|
124
|
+
out=Path(out)
|
|
125
|
+
if not out.is_file() or out.is_symlink():raise NativeHold('CANDIDATE_NOT_SAFE_FILE')
|
|
126
|
+
members,source_info=expected_files(model,Path(reference),template_view,expected_sha256,name)
|
|
127
|
+
try:
|
|
128
|
+
if out.stat().st_size>MAX_CANDIDATE_BYTES:raise NativeHold('CANDIDATE_TOO_LARGE')
|
|
129
|
+
with zipfile.ZipFile(out) as z:
|
|
130
|
+
entries=z.infolist()
|
|
131
|
+
if len(entries)!=3 or set(z.namelist())!=set(members):
|
|
132
|
+
raise NativeHold('CANDIDATE_ALLOWLIST_VIOLATION')
|
|
133
|
+
if z.testzip():raise NativeHold('CANDIDATE_CRC_ERROR')
|
|
134
|
+
for e in entries:
|
|
135
|
+
if e.flag_bits & 1 or stat.S_IFMT(e.external_attr>>16)==stat.S_IFLNK:
|
|
136
|
+
raise NativeHold('CANDIDATE_UNSAFE_ZIP_ENTRY')
|
|
137
|
+
if e.file_size>3_000_000:raise NativeHold('CANDIDATE_RESOURCE_TOO_LARGE')
|
|
138
|
+
if z.read(e)!=members[e.filename]:raise NativeHold('CANDIDATE_REPRODUCIBILITY_MISMATCH')
|
|
139
|
+
except zipfile.BadZipFile:raise NativeHold('CANDIDATE_BAD_ZIP') from None
|
|
140
|
+
if out.read_bytes()!=_zip_blob(members):raise NativeHold('CANDIDATE_ZIP_BYTES_NOT_REPRODUCIBLE')
|
|
141
|
+
return {'schema':SCHEMA,'status':'PASS_OFFLINE_ISOLATED_PROJECT_CANDIDATE_ONLY',
|
|
142
|
+
'candidate_sha256':sha(out),'reference_sha256':sha(Path(reference)),
|
|
143
|
+
'members_verified':len(members),'ignition_import':'NOT_TESTED',
|
|
144
|
+
'live_bindings':'NOT_GENERATED','production_release':'HOLD'}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Fail-closed read-only Studio -> generated HMI lab signal adapter.
|
|
2
|
+
|
|
3
|
+
This is NOT an Ignition binding or physical PLC mapping verification. The
|
|
4
|
+
sole authority is a project-scoped read-only Studio session established in the
|
|
5
|
+
external Engineering test workspace. No write path exists in this module.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import asyncio
|
|
9
|
+
import copy
|
|
10
|
+
import time
|
|
11
|
+
from threading import Lock
|
|
12
|
+
from collections import deque
|
|
13
|
+
from .operations import OperationsHold
|
|
14
|
+
from .operations import evaluate, mapping_digest, OperationsHold
|
|
15
|
+
from .operations_capture import capture_lab
|
|
16
|
+
from .operations_lab import LAB_NAMESPACE
|
|
17
|
+
from . import studio_workflow as wf
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LiveLabHold(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LiveLabBridge:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self._sessions = {}
|
|
27
|
+
self._lock = Lock()
|
|
28
|
+
|
|
29
|
+
def attach(self, workspace, pid, port, *, session=None, readonly_receipt=None):
|
|
30
|
+
"""Attach only after proof of exact lab namespace/NodeIds/type manifest."""
|
|
31
|
+
model=wf.load_project(workspace,pid)
|
|
32
|
+
state=wf.studio_state(port)
|
|
33
|
+
lab=copy.deepcopy(model)
|
|
34
|
+
for signal in lab['signals']:
|
|
35
|
+
signal['source']['namespace_uri']=LAB_NAMESPACE
|
|
36
|
+
if not wf._manifest_ok(wf.studio_request(port,'GET','/api/manifest'),lab):
|
|
37
|
+
raise LiveLabHold('STUDIO_MANIFEST_DOES_NOT_MATCH_PROJECT')
|
|
38
|
+
if session is not None:
|
|
39
|
+
wf.require_session(workspace,pid,port,session)
|
|
40
|
+
authority='CREATOR_LAB_SESSION_READONLY'
|
|
41
|
+
else:
|
|
42
|
+
# An unbound lab can ONLY attach after a completed, exact read-only
|
|
43
|
+
# probe; this DOES NOT authorize fault stimulation or tag writes.
|
|
44
|
+
if not isinstance(readonly_receipt,dict) or readonly_receipt.get('status')!='PASS_UNBOUND_READONLY_LAB_PROTOCOL_ONLY':
|
|
45
|
+
raise LiveLabHold('READONLY_PROJECT_PROBE_EVIDENCE_REQUIRED')
|
|
46
|
+
if (readonly_receipt.get('mapping_digest')!=mapping_digest(model) or
|
|
47
|
+
readonly_receipt.get('verified_nodes')!=len(model['signals']) or
|
|
48
|
+
readonly_receipt.get('total_nodes')!=len(model['signals'])):
|
|
49
|
+
raise LiveLabHold('READONLY_PROJECT_PROBE_IDENTITY_MISMATCH')
|
|
50
|
+
identity=readonly_receipt.get('studio_identity',{})
|
|
51
|
+
if (identity.get('revision')!=state['customer']['revision'] or
|
|
52
|
+
identity.get('endpoint')!=state['opc_endpoint'] or
|
|
53
|
+
abs(float(identity.get('started',-999999))-(time.time()-float(state['uptime_seconds'])))>5):
|
|
54
|
+
raise LiveLabHold('READONLY_PROJECT_SESSION_CHANGED')
|
|
55
|
+
authority='UNBOUND_VERIFIED_MANIFEST_READONLY'
|
|
56
|
+
binding={'project_digest':mapping_digest(model), 'lab_digest':mapping_digest(lab),
|
|
57
|
+
'endpoint':state['opc_endpoint'], 'revision':state['customer']['revision'],
|
|
58
|
+
'studio_started':time.time()-float(state['uptime_seconds']),
|
|
59
|
+
'authority':authority, 'attached_at':time.monotonic(), 'observed_frames':deque(maxlen=40)}
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._sessions[pid]=binding
|
|
62
|
+
return {'status':'READONLY_LAB_FEED_ATTACHED','project_id':pid,
|
|
63
|
+
'signal_count':len(model['signals']), 'authority':authority,
|
|
64
|
+
'source':'ISOLATED_STUDIO_OPC_UA','ignition':'NOT_VERIFIED','production':'HOLD'}
|
|
65
|
+
|
|
66
|
+
def enabled(self,pid):
|
|
67
|
+
with self._lock:return pid in self._sessions
|
|
68
|
+
|
|
69
|
+
def detach(self,pid):
|
|
70
|
+
with self._lock:self._sessions.pop(pid,None)
|
|
71
|
+
|
|
72
|
+
def _guard(self,workspace,pid,port,binding):
|
|
73
|
+
model=wf.load_project(workspace,pid)
|
|
74
|
+
if mapping_digest(model)!=binding['project_digest']:
|
|
75
|
+
raise LiveLabHold('PROJECT_MAPPING_CHANGED')
|
|
76
|
+
state=wf.studio_state(port)
|
|
77
|
+
if (state['customer']['revision']!=binding['revision'] or
|
|
78
|
+
state['opc_endpoint']!=binding['endpoint'] or
|
|
79
|
+
abs((time.time()-float(state['uptime_seconds']))-binding['studio_started'])>5):
|
|
80
|
+
raise LiveLabHold('STUDIO_SOURCE_SESSION_CHANGED')
|
|
81
|
+
lab=copy.deepcopy(model)
|
|
82
|
+
for signal in lab['signals']:
|
|
83
|
+
signal['source']['namespace_uri']=LAB_NAMESPACE
|
|
84
|
+
if mapping_digest(lab)!=binding['lab_digest']:
|
|
85
|
+
raise LiveLabHold('LAB_ALIAS_CHANGED')
|
|
86
|
+
if not wf._manifest_ok(wf.studio_request(port,'GET','/api/manifest'),lab):
|
|
87
|
+
raise LiveLabHold('STUDIO_TAG_MANIFEST_CHANGED')
|
|
88
|
+
return model,lab,state
|
|
89
|
+
|
|
90
|
+
def read(self,workspace,pid,port):
|
|
91
|
+
with self._lock:binding=self._sessions.get(pid)
|
|
92
|
+
if binding is None:
|
|
93
|
+
return {'status':'NO_APPROVED_LAB_SOURCE','mode':'NO_LIVE_SOURCE',
|
|
94
|
+
'ignition':'NOT_VERIFIED','production':'HOLD'}
|
|
95
|
+
try:
|
|
96
|
+
model,lab,state=self._guard(workspace,pid,port,binding)
|
|
97
|
+
frames=asyncio.run(capture_lab(lab,state['opc_endpoint'],samples=1))
|
|
98
|
+
# Reconcile LAB namespace alias to the exact customer logical signal
|
|
99
|
+
# contract. Never modify the customer model or claim PLC identity.
|
|
100
|
+
for frame in frames:
|
|
101
|
+
for observed in frame['observations']:
|
|
102
|
+
original=next(s for s in model['signals'] if s['id']==observed['signal_id'])
|
|
103
|
+
observed['namespace_uri']=original['source']['namespace_uri']
|
|
104
|
+
with self._lock:
|
|
105
|
+
if self._sessions.get(pid) is not binding:
|
|
106
|
+
raise LiveLabHold('LAB_SOURCE_DETACHED_DURING_CAPTURE')
|
|
107
|
+
# One history per exact authorized lab session: event transitions
|
|
108
|
+
# are tracked across OPC polls, never invented on each refresh.
|
|
109
|
+
observed=binding['observed_frames']
|
|
110
|
+
if observed and frames[0]['at']<=observed[-1]['at']:
|
|
111
|
+
raise LiveLabHold('NON_MONOTONIC_LIVE_CAPTURE')
|
|
112
|
+
observed.extend(frames)
|
|
113
|
+
history=list(observed)
|
|
114
|
+
receipt=evaluate(model,history,origin='ISOLATED_LAB_OPC_READ_ONLY')
|
|
115
|
+
if receipt['mapping_digest']!=binding['project_digest']:
|
|
116
|
+
raise LiveLabHold('RECEIPT_CONTRACT_MISMATCH')
|
|
117
|
+
return {'status':'PASS_STUDIO_LAB_DISPLAY_ONLY',
|
|
118
|
+
'source':'ISOLATED_STUDIO_OPC_UA_READONLY',
|
|
119
|
+
'authority':binding['authority'],
|
|
120
|
+
'studio_source_identity':'LAB_ONLY_NOT_PHYSICAL_PLC',
|
|
121
|
+
'receipt':receipt,
|
|
122
|
+
'ignition':'NOT_VERIFIED','production':'HOLD'}
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
self.detach(pid)
|
|
125
|
+
code=str(exc) if isinstance(exc,(wf.LabHold,OperationsHold,LiveLabHold)) else 'LAB_SOURCE_READ_HOLD'
|
|
126
|
+
if len(code)>115:code='LAB_SOURCE_READ_HOLD'
|
|
127
|
+
return {'status':'HOLD','reason':code, 'mode':'SOURCE_UNKNOWN',
|
|
128
|
+
'ignition':'NOT_VERIFIED','production':'HOLD'}
|