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