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,297 @@
|
|
|
1
|
+
"""Fail-closed, non-operational customer project contracts for Spatial HMI V0.2."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, asdict
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
ID = re.compile(r'^[A-Z][A-Z0-9_]{1,31}$')
|
|
11
|
+
KINDS = frozenset('conveyor spiral sorter motor vfd photoeye scanner tank pump valve robot junction room other'.split())
|
|
12
|
+
LAYOUTS = frozenset(('spatial_overview', 'process_flow', 'standard_2d_flow', 'machine_detail', 'multi_zone'))
|
|
13
|
+
ROLES = {
|
|
14
|
+
'run_feedback': ('Boolean', None),
|
|
15
|
+
'run_command': ('Boolean', None),
|
|
16
|
+
'fault': ('Boolean', None),
|
|
17
|
+
'fault_code': ('String', None),
|
|
18
|
+
'photoeye_blocked': ('Boolean', None),
|
|
19
|
+
'photoeye_count': ('Int32', 'count'),
|
|
20
|
+
'scanner_result': ('String', None),
|
|
21
|
+
'scanner_barcode': ('String', None),
|
|
22
|
+
'scan_count': ('Int32', 'count'),
|
|
23
|
+
'delivered_count': ('Int32', 'count'),
|
|
24
|
+
'in_transit_count': ('Int32', 'count'),
|
|
25
|
+
'no_read_count': ('Int32', 'count'),
|
|
26
|
+
'motor_speed': ('Double', 'm/s'),
|
|
27
|
+
'vfd_frequency': ('Double', 'Hz'),
|
|
28
|
+
'tank_level': ('Double', '%'),
|
|
29
|
+
'pressure': ('Double', 'bar'),
|
|
30
|
+
'flow': ('Double', 'm3/h'),
|
|
31
|
+
'valve_open': ('Boolean', None),
|
|
32
|
+
'mode': ('String', None),
|
|
33
|
+
'status': ('String', None),
|
|
34
|
+
}
|
|
35
|
+
NUMERIC = {'Int32', 'Double'}
|
|
36
|
+
MAX_EQUIPMENT, MAX_SIGNALS, MAX_CONNECTIONS, MAX_REQUIREMENTS = 150, 1200, 400, 200
|
|
37
|
+
PALETTES = frozenset({'r9_cyan', 'steel_blue', 'teal', 'warm_amber'})
|
|
38
|
+
ALLOWED_ROOT = {'schema_version','name','industry','description','layout','theme','palette','equipment','connections','signals','requirements'}
|
|
39
|
+
ALLOWED_EQUIPMENT = {'id','name','kind','floor','zone','position','dimensions'}
|
|
40
|
+
ALLOWED_SIGNAL = {'id','asset_id','role','datatype','unit','polarity','source','write_authorized'}
|
|
41
|
+
ALLOWED_SOURCE = {'namespace_uri','node_id'}
|
|
42
|
+
ALLOWED_CONNECTION = {'from','to'}
|
|
43
|
+
ALLOWED_REQUIREMENT = {'id','text'}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_fields(value, allowed, issues, path):
|
|
47
|
+
if isinstance(value, dict):
|
|
48
|
+
for key in value:
|
|
49
|
+
if key not in allowed:
|
|
50
|
+
issue(issues,'HOLD','UNRECOGNIZED_FIELD',f'{path}.{key}',
|
|
51
|
+
'Unknown field not accepted; explicit schema review required')
|
|
52
|
+
|
|
53
|
+
DEFAULT_SIZES = {
|
|
54
|
+
'conveyor': (4.5, 0.4, 1.1), 'spiral': (3, 4.5, 3), 'sorter': (4, .8, 3),
|
|
55
|
+
'motor': (.8, .7, .9), 'vfd': (.8, 1.8, .5), 'photoeye': (.3, 1.1, .3),
|
|
56
|
+
'scanner': (.7, 1.4, .6), 'tank': (3, 5, 3), 'pump': (1.6, 1, 1.3),
|
|
57
|
+
'valve': (.6, .7, .6), 'robot': (2, 2.8, 2), 'junction': (1.8, .4, 1.8),
|
|
58
|
+
'room': (4, 3, 4), 'other': (2, 1, 2),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Issue:
|
|
64
|
+
severity: str
|
|
65
|
+
code: str
|
|
66
|
+
path: str
|
|
67
|
+
message: str
|
|
68
|
+
|
|
69
|
+
def dump(self):
|
|
70
|
+
return asdict(self)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def issue(items, severity, code, path, message):
|
|
74
|
+
items.append(Issue(severity, code, path, message))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def clean_project(data: Any) -> tuple[dict, list[Issue]]:
|
|
78
|
+
"""Return normalized copy + issues. HOLDs prevent build. Never infer source identities."""
|
|
79
|
+
problems = []
|
|
80
|
+
if not isinstance(data, dict):
|
|
81
|
+
return {}, [Issue('HOLD', 'INVALID_ROOT', '$', 'Expected project JSON object')]
|
|
82
|
+
data = copy.deepcopy(data)
|
|
83
|
+
# Reject text that cannot be serialized to UTF-8. A malformed customer
|
|
84
|
+
# label must never crash generation or leave a partially published project.
|
|
85
|
+
pending = [data]
|
|
86
|
+
examined = 0
|
|
87
|
+
while pending:
|
|
88
|
+
item = pending.pop()
|
|
89
|
+
examined += 1
|
|
90
|
+
if examined > 20000:
|
|
91
|
+
issue(problems, 'HOLD', 'MODEL_NESTING_LIMIT', '$', 'Input has excessive nested elements')
|
|
92
|
+
break
|
|
93
|
+
if isinstance(item, dict):
|
|
94
|
+
pending.extend(item.values())
|
|
95
|
+
elif isinstance(item, list):
|
|
96
|
+
pending.extend(item)
|
|
97
|
+
elif isinstance(item, str):
|
|
98
|
+
try:
|
|
99
|
+
item.encode('utf-8', 'strict')
|
|
100
|
+
except UnicodeEncodeError:
|
|
101
|
+
issue(problems, 'HOLD', 'INVALID_UNICODE', '$', 'Input text contains invalid Unicode')
|
|
102
|
+
break
|
|
103
|
+
check_fields(data,ALLOWED_ROOT,problems,'$')
|
|
104
|
+
if data.get('schema_version') != 1:
|
|
105
|
+
issue(problems, 'HOLD', 'UNSUPPORTED_SCHEMA', 'schema_version', 'Expected integer schema_version=1')
|
|
106
|
+
for key in ('name', 'industry'):
|
|
107
|
+
v = data.get(key)
|
|
108
|
+
if not isinstance(v, str) or not 2 <= len(v.strip()) <= 90:
|
|
109
|
+
issue(problems, 'HOLD', 'INVALID_TEXT', key, 'Required text length 2–90')
|
|
110
|
+
description = data.get('description', '')
|
|
111
|
+
if not isinstance(description, str) or len(description) > 4000:
|
|
112
|
+
issue(problems, 'HOLD', 'INVALID_DESCRIPTION', 'description', 'Maximum length 4000')
|
|
113
|
+
if not isinstance(data.get('layout'),str) or data.get('layout') not in LAYOUTS:
|
|
114
|
+
issue(problems, 'HOLD', 'LAYOUT_INVALID', 'layout', f'Choose one of {sorted(LAYOUTS)}')
|
|
115
|
+
if data.get('theme', 'devagent_spatial_v1') != 'devagent_spatial_v1':
|
|
116
|
+
issue(problems, 'HOLD', 'UNAPPROVED_THEME', 'theme', 'V0.1 supports devagent_spatial_v1 only')
|
|
117
|
+
data['theme'] = 'devagent_spatial_v1'
|
|
118
|
+
palette = data.get('palette', 'r9_cyan')
|
|
119
|
+
if not isinstance(palette, str) or palette not in PALETTES:
|
|
120
|
+
issue(problems, 'HOLD', 'UNAPPROVED_BRAND_PALETTE', 'palette', 'Only validated DevAgent branding palettes are allowed')
|
|
121
|
+
else:
|
|
122
|
+
data['palette'] = palette
|
|
123
|
+
eq = data.get('equipment', [])
|
|
124
|
+
if not isinstance(eq, list) or not 1 <= len(eq) <= MAX_EQUIPMENT:
|
|
125
|
+
issue(problems, 'HOLD', 'EQUIPMENT_COUNT', 'equipment', f'Require 1–{MAX_EQUIPMENT} assets')
|
|
126
|
+
eq = []
|
|
127
|
+
ids = set()
|
|
128
|
+
floors = set()
|
|
129
|
+
layout_auto = 0
|
|
130
|
+
for i, e in enumerate(eq):
|
|
131
|
+
path = f'equipment[{i}]'
|
|
132
|
+
if not isinstance(e, dict):
|
|
133
|
+
issue(problems, 'HOLD', 'INVALID_EQUIPMENT', path, 'Expected object')
|
|
134
|
+
continue
|
|
135
|
+
check_fields(e,ALLOWED_EQUIPMENT,problems,path)
|
|
136
|
+
eid, kind = e.get('id'), e.get('kind')
|
|
137
|
+
if not isinstance(eid, str) or not ID.fullmatch(eid):
|
|
138
|
+
issue(problems, 'HOLD', 'INVALID_ASSET_ID', path+'.id', 'Use 2–32 uppercase letters, digits, underscores, starting with letter')
|
|
139
|
+
elif eid in ids:
|
|
140
|
+
issue(problems, 'HOLD', 'DUPLICATE_ASSET_ID', path+'.id', 'Asset ID must be unique')
|
|
141
|
+
else:
|
|
142
|
+
ids.add(eid)
|
|
143
|
+
if not isinstance(kind, str) or kind not in KINDS:
|
|
144
|
+
issue(problems, 'HOLD', 'INVALID_EQUIPMENT_KIND', path+'.kind', f'Choose {sorted(KINDS)}')
|
|
145
|
+
if not isinstance(e.get('name'), str) or not 1 <= len(e['name'].strip()) <= 100:
|
|
146
|
+
issue(problems, 'HOLD', 'INVALID_ASSET_NAME', path+'.name', 'Require name length 1–100')
|
|
147
|
+
if not isinstance(e.get('zone', ''), str) or len(e.get('zone', '')) > 70:
|
|
148
|
+
issue(problems, 'HOLD', 'INVALID_ZONE', path+'.zone', 'Zone must be text up to 70 chars')
|
|
149
|
+
floor = e.get('floor', 1)
|
|
150
|
+
if type(floor) is not int or not 1 <= floor <= 20:
|
|
151
|
+
issue(problems, 'HOLD', 'INVALID_FLOOR', path+'.floor', 'Floor must be integer 1–20')
|
|
152
|
+
else:
|
|
153
|
+
floors.add(floor)
|
|
154
|
+
pos = e.get('position')
|
|
155
|
+
if pos is None:
|
|
156
|
+
layout_auto += 1
|
|
157
|
+
elif not isinstance(pos, dict) or set(pos) != {'x','y','z'} or not all(k in pos and type(pos[k]) in (int, float) and math.isfinite(pos[k]) and -500 <= pos[k] <= 500 for k in ('x', 'y', 'z')):
|
|
158
|
+
issue(problems, 'HOLD', 'INVALID_POSITION', path+'.position', 'Use finite x,y,z in range ±500 meters')
|
|
159
|
+
dims = e.get('dimensions')
|
|
160
|
+
if dims is not None and (not isinstance(dims, dict) or set(dims) != {'x','y','z'} or not all(k in dims and type(dims[k]) in (int, float) and math.isfinite(dims[k]) and .15 <= dims[k] <= 50 for k in ('x', 'y', 'z'))):
|
|
161
|
+
issue(problems, 'HOLD', 'INVALID_DIMENSIONS', path+'.dimensions', 'Use finite positive x,y,z (0.15–50)')
|
|
162
|
+
connections = data.get('connections', [])
|
|
163
|
+
if not isinstance(connections, list) or len(connections) > MAX_CONNECTIONS:
|
|
164
|
+
issue(problems, 'HOLD', 'CONNECTIONS_COUNT', 'connections', f'Maximum {MAX_CONNECTIONS} connections')
|
|
165
|
+
connections = []
|
|
166
|
+
links = set()
|
|
167
|
+
for i, c in enumerate(connections):
|
|
168
|
+
path = f'connections[{i}]'
|
|
169
|
+
check_fields(c,ALLOWED_CONNECTION,problems,path)
|
|
170
|
+
if (not isinstance(c, dict) or not isinstance(c.get('from'), str) or
|
|
171
|
+
not isinstance(c.get('to'), str) or c.get('from') not in ids or c.get('to') not in ids):
|
|
172
|
+
issue(problems, 'HOLD', 'DANGLING_CONNECTION', path, 'Connection must refer to known equipment')
|
|
173
|
+
elif c['from'] == c['to'] or (c['from'], c['to']) in links:
|
|
174
|
+
issue(problems, 'HOLD', 'INVALID_CONNECTION', path, 'Self-loop or duplicate connection')
|
|
175
|
+
else:
|
|
176
|
+
links.add((c['from'], c['to']))
|
|
177
|
+
signals = data.get('signals', [])
|
|
178
|
+
if not isinstance(signals, list) or len(signals) > MAX_SIGNALS:
|
|
179
|
+
issue(problems, 'HOLD', 'SIGNAL_COUNT', 'signals', f'Maximum {MAX_SIGNALS} signals')
|
|
180
|
+
signals = []
|
|
181
|
+
signal_ids, identities, asset_roles = set(), set(), set()
|
|
182
|
+
for i, s in enumerate(signals):
|
|
183
|
+
path = f'signals[{i}]'
|
|
184
|
+
if not isinstance(s, dict):
|
|
185
|
+
issue(problems, 'HOLD', 'SIGNAL_INVALID', path, 'Expected object')
|
|
186
|
+
continue
|
|
187
|
+
check_fields(s,ALLOWED_SIGNAL,problems,path)
|
|
188
|
+
sid, eid, role = s.get('id'), s.get('asset_id'), s.get('role')
|
|
189
|
+
if not isinstance(sid, str) or not ID.fullmatch(sid) or sid in signal_ids:
|
|
190
|
+
issue(problems, 'HOLD', 'SIGNAL_ID_INVALID', path+'.id', 'Unique uppercase signal ID required')
|
|
191
|
+
else:
|
|
192
|
+
signal_ids.add(sid)
|
|
193
|
+
if not isinstance(eid, str) or eid not in ids:
|
|
194
|
+
issue(problems, 'HOLD', 'SIGNAL_UNKNOWN_ASSET', path+'.asset_id', 'Signal must link to an existing asset')
|
|
195
|
+
if not isinstance(role, str) or role not in ROLES:
|
|
196
|
+
issue(problems, 'HOLD', 'SIGNAL_ROLE_UNSUPPORTED', path+'.role', f'Supported: {sorted(ROLES)}')
|
|
197
|
+
continue
|
|
198
|
+
dt, default_unit = ROLES[role]
|
|
199
|
+
if s.get('datatype') != dt:
|
|
200
|
+
issue(problems, 'HOLD', 'SIGNAL_DATATYPE_MISMATCH', path+'.datatype', f'{role} must be {dt}')
|
|
201
|
+
actual_unit = s.get('unit', '')
|
|
202
|
+
if not isinstance(actual_unit, str) or len(actual_unit) > 24:
|
|
203
|
+
issue(problems, 'HOLD', 'UNIT_INVALID', path+'.unit', 'Unit must be short text')
|
|
204
|
+
if role in ('motor_speed', 'vfd_frequency', 'tank_level', 'pressure', 'flow') and not actual_unit:
|
|
205
|
+
issue(problems, 'HOLD', 'UNIT_REQUIRED', path+'.unit', f'{role} requires engineering units (e.g. {default_unit})')
|
|
206
|
+
elif default_unit is not None and actual_unit != default_unit:
|
|
207
|
+
issue(problems, 'HOLD', 'UNIT_SCALE_NOT_DEFINED', path+'.unit', f'{role} requires {default_unit}; supply approved scaling before alternate units')
|
|
208
|
+
elif default_unit is None and actual_unit:
|
|
209
|
+
issue(problems, 'HOLD', 'UNEXPECTED_SIGNAL_UNIT', path+'.unit', f'{role} must not claim engineering units')
|
|
210
|
+
polarity = s.get('polarity', '')
|
|
211
|
+
if role in ('photoeye_blocked', 'run_feedback', 'fault'):
|
|
212
|
+
if polarity not in ('active_high', 'active_low'):
|
|
213
|
+
issue(problems, 'HOLD', 'POLARITY_REQUIRED', path+'.polarity',
|
|
214
|
+
'Boolean signal must declare active_high or active_low')
|
|
215
|
+
elif polarity != '':
|
|
216
|
+
issue(problems, 'HOLD', 'UNEXPECTED_SIGNAL_POLARITY', path+'.polarity',
|
|
217
|
+
'Polarity applies only to supported boolean feedback/fault roles')
|
|
218
|
+
source = s.get('source', {})
|
|
219
|
+
if not isinstance(source, dict):
|
|
220
|
+
issue(problems, 'HOLD', 'INVALID_SOURCE', path+'.source', 'Expected source object')
|
|
221
|
+
continue
|
|
222
|
+
check_fields(source,ALLOWED_SOURCE,problems,path+'.source')
|
|
223
|
+
ns, node = source.get('namespace_uri'), source.get('node_id')
|
|
224
|
+
if not isinstance(ns, str) or not 8 <= len(ns) <= 240 or any(ord(ch)<32 for ch in ns) or not (ns.startswith('urn:') or ns.startswith('http:') or ns.startswith('https:')):
|
|
225
|
+
issue(problems, 'HOLD', 'NAMESPACE_UNRESOLVED', path+'.source.namespace_uri', 'Explicit namespace URI required; never guess namespace index')
|
|
226
|
+
if not isinstance(node, str) or not 1 <= len(node) <= 160 or any(ord(x) < 32 for x in node):
|
|
227
|
+
issue(problems, 'HOLD', 'NODE_UNRESOLVED', path+'.source.node_id', 'Explicit OPC UA NodeId identifier required')
|
|
228
|
+
if isinstance(ns, str) and isinstance(node, str):
|
|
229
|
+
if (ns, node) in identities:
|
|
230
|
+
issue(problems, 'HOLD', 'DUPLICATE_OPC_SOURCE', path+'.source', 'Multiple signals point at the same OPC source')
|
|
231
|
+
identities.add((ns, node))
|
|
232
|
+
if not isinstance(eid, str):
|
|
233
|
+
continue # Unknown asset already HOLD; do not hash arbitrary JSON
|
|
234
|
+
if (eid, role) in asset_roles:
|
|
235
|
+
issue(problems, 'HOLD', 'DUPLICATE_ASSET_ROLE', path, 'Same asset and role mapped more than once')
|
|
236
|
+
asset_roles.add((eid, role))
|
|
237
|
+
if s.get('write_authorized', False) is not False:
|
|
238
|
+
issue(problems, 'HOLD', 'WRITE_AUTHORITY_NOT_ALLOWED', path+'.write_authorized', 'V0.1 generated views are read-only')
|
|
239
|
+
requirements = data.get('requirements', [])
|
|
240
|
+
if not isinstance(requirements, list) or len(requirements) > MAX_REQUIREMENTS:
|
|
241
|
+
issue(problems, 'HOLD', 'REQUIREMENTS_INVALID', 'requirements', f'Maximum {MAX_REQUIREMENTS} requirements')
|
|
242
|
+
else:
|
|
243
|
+
seen = set()
|
|
244
|
+
for i, r in enumerate(requirements):
|
|
245
|
+
check_fields(r,ALLOWED_REQUIREMENT,problems,f'requirements[{i}]')
|
|
246
|
+
if not isinstance(r, dict) or not isinstance(r.get('id'), str) or not ID.fullmatch(r['id']) or not isinstance(r.get('text'), str) or not 5 <= len(r['text']) <= 500:
|
|
247
|
+
issue(problems, 'HOLD', 'REQUIREMENT_INVALID', f'requirements[{i}]', 'Unique ID and 5–500 characters of text required')
|
|
248
|
+
elif r['id'] in seen:
|
|
249
|
+
issue(problems, 'HOLD', 'REQUIREMENT_DUPLICATE', f'requirements[{i}].id', 'Duplicate ID')
|
|
250
|
+
else:
|
|
251
|
+
seen.add(r['id'])
|
|
252
|
+
if data.get('industry') == 'Unspecified Industry':
|
|
253
|
+
issue(problems, 'WARNING', 'INDUSTRY_NOT_SPECIFIED', 'industry',
|
|
254
|
+
'Generic design only until the customer confirms the process domain')
|
|
255
|
+
if any(isinstance(e,dict) and e.get('zone') == 'FLOOR_UNSPECIFIED_PREVIEW_F1' for e in eq):
|
|
256
|
+
issue(problems, 'WARNING', 'FLOOR_ASSUMED_FOR_PREVIEW', 'equipment',
|
|
257
|
+
'Floor 1 is a conceptual display default, not verified facility floor count')
|
|
258
|
+
if any(isinstance(e,dict) and e.get('zone') == 'INFERRED_FROM_TAGS_REVIEW_REQUIRED' for e in eq):
|
|
259
|
+
issue(problems, 'WARNING', 'EQUIPMENT_INFERRED_FROM_TAGS', 'equipment',
|
|
260
|
+
'Equipment and kind are display candidates inferred from tag asset IDs; customer confirmation required')
|
|
261
|
+
if any(isinstance(e,dict) and e.get('kind') == 'other' for e in eq):
|
|
262
|
+
issue(problems, 'WARNING', 'GENERIC_EQUIPMENT_SYMBOL', 'equipment',
|
|
263
|
+
'Some equipment kinds are unspecified; generic symbols are conceptual, not machine identification')
|
|
264
|
+
from .engineering_expertise import review as expert_review
|
|
265
|
+
expert=expert_review(data)
|
|
266
|
+
for mismatch in expert['role_asset_mismatches']:
|
|
267
|
+
issue(problems, 'HOLD', 'ROLE_ASSET_KIND_MISMATCH', 'signals',
|
|
268
|
+
f"{mismatch['signal_id']}: role {mismatch['role']} is not appropriate for {mismatch['actual_kind']} ({mismatch['asset_id']})")
|
|
269
|
+
if not signals:
|
|
270
|
+
issue(problems, 'WARNING', 'NO_SIGNAL_MAPPINGS', 'signals', 'Preview works; no live signal mappings supplied')
|
|
271
|
+
if layout_auto:
|
|
272
|
+
issue(problems, 'WARNING', 'CONCEPTUAL_GEOMETRY', 'equipment', f'{layout_auto} assets require conceptual auto-layout; not an as-built representation')
|
|
273
|
+
if not connections and len(eq) > 1:
|
|
274
|
+
issue(problems, 'WARNING', 'TOPOLOGY_MISSING', 'connections', 'No equipment connections provided')
|
|
275
|
+
if signals:
|
|
276
|
+
issue(problems, 'WARNING', 'OPC_NOT_LIVE_VERIFIED', 'signals', 'All supplied mappings are proposed until read back from a verified OPC UA source')
|
|
277
|
+
if not requirements:
|
|
278
|
+
issue(problems, 'WARNING', 'REQUIREMENTS_NOT_PROVIDED', 'requirements', 'Generated FAT inventory cannot prove missing customer requirements')
|
|
279
|
+
return data, problems
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def qualify(data: dict) -> dict:
|
|
283
|
+
normalized, issues = clean_project(data)
|
|
284
|
+
holds = [x for x in issues if x.severity == 'HOLD']
|
|
285
|
+
return {'status': 'HOLD' if holds else 'PASS_STATIC_CONTRACT_ONLY',
|
|
286
|
+
'schema': 'devagent.spatial.contract_review.v1',
|
|
287
|
+
'issues': [x.dump() for x in issues],
|
|
288
|
+
'equipment_count': len(normalized.get('equipment', [])) if isinstance(normalized.get('equipment'), list) else 0,
|
|
289
|
+
'signals_proposed': len(normalized.get('signals', [])) if isinstance(normalized.get('signals'), list) else 0,
|
|
290
|
+
'live_opcua': 'NOT_VERIFIED', 'ignition_runtime': 'NOT_VERIFIED',
|
|
291
|
+
'physical_mapping': 'NOT_VERIFIED', 'hmi_visual_acceptance': 'NOT_VERIFIED',
|
|
292
|
+
'write_capability': 'DISABLED'}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def json_embed(data: dict) -> str:
|
|
296
|
+
"""Safe for embedding inside HTML <script> text."""
|
|
297
|
+
return json.dumps(data, ensure_ascii=False, separators=(',', ':'), sort_keys=True).replace('<', r'\u003c').replace('>', r'\u003e').replace('&', r'\u0026')
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Read-only safety and structure audit for a customer-supplied Ignition native ZIP.
|
|
2
|
+
|
|
3
|
+
This cannot verify the Perspective resource schema without a qualified reference.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import hashlib
|
|
7
|
+
import stat
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
|
|
11
|
+
MAX_BYTES=100_000_000
|
|
12
|
+
MAX_ITEM=24_000_000
|
|
13
|
+
MAX_MEMBERS=12000
|
|
14
|
+
MAX_RATIO=200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def audit_ignition_archive(path:Path)->dict:
|
|
18
|
+
file=Path(path).expanduser()
|
|
19
|
+
codes=[]
|
|
20
|
+
def hold(code):codes.append(code)
|
|
21
|
+
if not file.is_file() or file.is_symlink():
|
|
22
|
+
return {'status':'HOLD','issues':['MISSING_OR_SYMLINK_NATIVE_ZIP']}
|
|
23
|
+
if file.stat().st_size>MAX_BYTES or file.stat().st_size<100:
|
|
24
|
+
return {'status':'HOLD','issues':['ZIP_SIZE_OUT_OF_BOUNDS']}
|
|
25
|
+
try:
|
|
26
|
+
digest=hashlib.sha256(file.read_bytes()).hexdigest()
|
|
27
|
+
with zipfile.ZipFile(file) as z:
|
|
28
|
+
members=z.infolist()
|
|
29
|
+
if len(members)>MAX_MEMBERS:hold('ARCHIVE_TOO_MANY_FILES')
|
|
30
|
+
if z.comment: hold('UNEXPECTED_ZIP_COMMENT')
|
|
31
|
+
names=set();total=0; native=False
|
|
32
|
+
for item in members:
|
|
33
|
+
name=item.filename
|
|
34
|
+
key=PurePosixPath(name)
|
|
35
|
+
if (not name or key.is_absolute() or '\\' in name or '..' in key.parts or
|
|
36
|
+
name.startswith('./') or ':' in key.parts[0] or '\x00' in name):
|
|
37
|
+
hold('UNSAFE_NATIVE_ARCHIVE_PATH')
|
|
38
|
+
if name in names:hold('DUPLICATE_NATIVE_ARCHIVE_ENTRY')
|
|
39
|
+
if item.file_size < 0 or item.compress_size < 0:hold('INVALID_NATIVE_MEMBER_SIZE')
|
|
40
|
+
names.add(name)
|
|
41
|
+
if item.flag_bits & 1:hold('ENCRYPTED_NATIVE_RESOURCE')
|
|
42
|
+
mode=(item.external_attr>>16)&0xffff
|
|
43
|
+
if mode and stat.S_IFMT(mode)==stat.S_IFLNK:hold('NATIVE_ARCHIVE_SYMLINK')
|
|
44
|
+
if not item.is_dir():
|
|
45
|
+
total+=item.file_size
|
|
46
|
+
if item.file_size>MAX_ITEM or (item.compress_size==0 and item.file_size>0):
|
|
47
|
+
hold('NATIVE_RESOURCE_TOO_LARGE')
|
|
48
|
+
elif item.file_size>MAX_RATIO*max(1,item.compress_size) and item.file_size>1_000_000:
|
|
49
|
+
hold('SUSPICIOUS_NATIVE_COMPRESSION_RATIO')
|
|
50
|
+
if 'com.inductiveautomation.perspective' in name.lower() or '/perspective/' in name.lower():
|
|
51
|
+
native=True
|
|
52
|
+
if total>MAX_BYTES:hold('NATIVE_UNCOMPRESSED_SIZE_EXCEEDED')
|
|
53
|
+
# Read all CRCs; refuse to execute scripts, extract, import, or parse scripts.
|
|
54
|
+
if not codes:
|
|
55
|
+
bad=z.testzip()
|
|
56
|
+
if bad:hold('NATIVE_ARCHIVE_CRC_ERROR')
|
|
57
|
+
if not native:hold('PERSPECTIVE_RESOURCE_SIGNATURE_NOT_FOUND')
|
|
58
|
+
except (OSError, zipfile.BadZipFile, RuntimeError, ValueError, EOFError):
|
|
59
|
+
hold('INVALID_NATIVE_ZIP')
|
|
60
|
+
return {
|
|
61
|
+
'schema':'devagent.ignition.native_reference_audit.v1',
|
|
62
|
+
'status':'HOLD' if codes else 'PASS_ZIP_SAFETY_ONLY',
|
|
63
|
+
'sha256':digest if 'digest' in locals() else None,
|
|
64
|
+
'issues':sorted(set(codes)),
|
|
65
|
+
'native_resource_schema':'NOT_VERIFIED',
|
|
66
|
+
'ignition_import':'NOT_TESTED',
|
|
67
|
+
'runtime_qualification':'NOT_VERIFIED',
|
|
68
|
+
'no_mutations':True,
|
|
69
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Optional read-only OPC UA protocol snapshot for isolated labs only.
|
|
2
|
+
|
|
3
|
+
No server write nodes are accessed. No control commands are generated.
|
|
4
|
+
An OPC read alone does not verify a physical device, trigger, or Ignition HMI.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import asyncio
|
|
8
|
+
import datetime as dt
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
from .model import qualify, ROLES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SnapshotHold(ValueError):pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def allow_lab_endpoint(endpoint):
|
|
17
|
+
p=urlsplit(endpoint)
|
|
18
|
+
if (p.scheme!='opc.tcp' or p.hostname not in ('127.0.0.1','localhost','172.17.0.1') or
|
|
19
|
+
p.username or p.password or not p.port or not 1024<=p.port<=65535):
|
|
20
|
+
raise SnapshotHold('ONLY_APPROVED_LOOPBACK_OR_DOCKER_BRIDGE_LAB_ENDPOINT')
|
|
21
|
+
return endpoint
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def check_reads(model:dict, namespace_array:list[str], read_node):
|
|
25
|
+
"""Pure protocol verification, supports test doubles and the asyncua adapter."""
|
|
26
|
+
expected=qualify(model)
|
|
27
|
+
if expected['status']=='HOLD':raise SnapshotHold('PROJECT_CONTRACT_HOLD')
|
|
28
|
+
if len(model.get('signals',[]))>200:raise SnapshotHold('LAB_SNAPSHOT_MAX_200_SIGNALS')
|
|
29
|
+
rows=[]
|
|
30
|
+
for s in model.get('signals',[]):
|
|
31
|
+
source=s['source'];ns=source['namespace_uri']
|
|
32
|
+
row={'id':s['id'],'asset_id':s['asset_id'],'role':s['role'],
|
|
33
|
+
'namespace_uri':ns,'node_id':source['node_id'],
|
|
34
|
+
'expected_datatype':s['datatype'],'unit_contract':s.get('unit',''),
|
|
35
|
+
'polarity_contract':s.get('polarity',''),
|
|
36
|
+
'trigger':'NOT_EXERCISED','hmi':'NOT_VERIFIED','physical_mapping':'NOT_VERIFIED'}
|
|
37
|
+
if ns not in namespace_array:
|
|
38
|
+
row.update(status='HOLD_NAMESPACE_NOT_FOUND');rows.append(row);continue
|
|
39
|
+
index=namespace_array.index(ns)
|
|
40
|
+
node=f'ns={index};s={source["node_id"]}'
|
|
41
|
+
try:
|
|
42
|
+
observed=await read_node(node)
|
|
43
|
+
# Contract: transport adapter must report: good, variant type name, timestamp presence.
|
|
44
|
+
if not observed['good']:
|
|
45
|
+
row['status']='HOLD_BAD_OPC_QUALITY'
|
|
46
|
+
elif observed['type']!=s['datatype']:
|
|
47
|
+
row['status']='HOLD_DATATYPE_MISMATCH'
|
|
48
|
+
else:
|
|
49
|
+
row['status']='PASS_PROTOCOL_VALUE_ONLY'
|
|
50
|
+
row['observed_datatype']=observed['type']
|
|
51
|
+
row['timestamp_present']=bool(observed.get('timestamp_present'))
|
|
52
|
+
row['quality_good']=bool(observed['good'])
|
|
53
|
+
# Do not persist sensitive raw customer payloads in diagnostics.
|
|
54
|
+
row['value_recorded']=False
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
row['status']='HOLD_READ_FAILED'
|
|
57
|
+
row['read_error_class']=type(exc).__name__
|
|
58
|
+
rows.append(row)
|
|
59
|
+
return {'schema':'devagent.spatial.opc_lab_snapshot.v1',
|
|
60
|
+
'status':'PASS_LAB_PROTOCOL_SNAPSHOT_ONLY' if rows and all(x['status']=='PASS_PROTOCOL_VALUE_ONLY' for x in rows) else 'HOLD',
|
|
61
|
+
'signals':rows,'signals_checked':len(rows),
|
|
62
|
+
'server_identity':'NOT_PROVEN_NO_SECURITY',
|
|
63
|
+
'physical_mapping':'NOT_VERIFIED','trigger':'NOT_EXERCISED','ignition_hmi':'NOT_VERIFIED',
|
|
64
|
+
'write_authority':'DISABLED'}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def probe_live(model,endpoint):
|
|
68
|
+
allow_lab_endpoint(endpoint)
|
|
69
|
+
try:
|
|
70
|
+
from asyncua import Client
|
|
71
|
+
except ImportError as exc:
|
|
72
|
+
raise SnapshotHold('ASYNCUA_NOT_INSTALLED_INSTALL_OPTIONAL_CLIENT_DEPENDENCY') from exc
|
|
73
|
+
async def run():
|
|
74
|
+
client=Client(endpoint,timeout=3)
|
|
75
|
+
async with client:
|
|
76
|
+
namespaces=await client.get_namespace_array()
|
|
77
|
+
async def read_node(node_id):
|
|
78
|
+
value=await client.get_node(node_id).read_data_value()
|
|
79
|
+
if value.Value is None:
|
|
80
|
+
return {'good':False,'type':'NONE','timestamp_present':False}
|
|
81
|
+
return {'good':value.StatusCode.is_good(),
|
|
82
|
+
'type':value.Value.VariantType.name,
|
|
83
|
+
'timestamp_present':bool(value.SourceTimestamp or value.ServerTimestamp)}
|
|
84
|
+
return await check_reads(model,namespaces,read_node)
|
|
85
|
+
try:return await asyncio.wait_for(run(),timeout=30)
|
|
86
|
+
except (OSError,TimeoutError,RuntimeError,ValueError) as exc:
|
|
87
|
+
raise SnapshotHold('OPC_PROTOCOL_READ_UNAVAILABLE_'+type(exc).__name__) from exc
|