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,284 @@
|
|
|
1
|
+
"""Deterministic customer-mapping keyed operational KPI and alarm FAT replay.
|
|
2
|
+
|
|
3
|
+
This module does NOT verify PLC identity, provide live HMI bindings, acknowledge
|
|
4
|
+
real alarms or authorize writes. All resulting evidence is LAB REPLAY ONLY.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import copy
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from .model import qualify
|
|
15
|
+
|
|
16
|
+
MAX_FRAMES = 120
|
|
17
|
+
MAX_RECORDS = 200
|
|
18
|
+
MAX_AGE_SECONDS = 15
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OperationsHold(ValueError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _date(value):
|
|
26
|
+
if not isinstance(value, str) or len(value) > 40:
|
|
27
|
+
raise OperationsHold('INVALID_SOURCE_TIMESTAMP')
|
|
28
|
+
try:
|
|
29
|
+
t = dt.datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise OperationsHold('INVALID_SOURCE_TIMESTAMP') from exc
|
|
32
|
+
if t.tzinfo is None or t.utcoffset() is None:
|
|
33
|
+
raise OperationsHold('TIMESTAMP_MUST_HAVE_TIMEZONE')
|
|
34
|
+
return t.astimezone(dt.timezone.utc)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mapping_digest(model):
|
|
38
|
+
"""Content-addressed signal-to-asset binding contract, independent of layout."""
|
|
39
|
+
signals = model.get('signals', [])
|
|
40
|
+
rows = sorted((s['id'], s['asset_id'], s['role'], s['datatype'],
|
|
41
|
+
s['source']['namespace_uri'], s['source']['node_id'],
|
|
42
|
+
s.get('unit', ''), s.get('polarity', '')) for s in signals)
|
|
43
|
+
return hashlib.sha256(json.dumps(rows, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def policy(model):
|
|
47
|
+
if qualify(model)['status'] != 'PASS_STATIC_CONTRACT_ONLY':
|
|
48
|
+
raise OperationsHold('INVALID_CUSTOMER_MAPPING_CONTRACT')
|
|
49
|
+
roles = Counter(s['role'] for s in model['signals'])
|
|
50
|
+
features = {
|
|
51
|
+
'equipment_running': 'run_feedback',
|
|
52
|
+
'active_faults': 'fault',
|
|
53
|
+
'photoeyes_blocked': 'photoeye_blocked',
|
|
54
|
+
'total_scans': 'scan_count',
|
|
55
|
+
'no_reads': 'no_read_count',
|
|
56
|
+
'delivered': 'delivered_count',
|
|
57
|
+
'parcels_in_transit': 'in_transit_count',
|
|
58
|
+
'last_scan_result': 'scanner_result',
|
|
59
|
+
'last_barcode': 'scanner_barcode',
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
'schema': 'devagent.operations.policy.v1',
|
|
63
|
+
'status': 'STATIC_PROPOSAL_ONLY',
|
|
64
|
+
'mapping_digest': mapping_digest(model),
|
|
65
|
+
'signal_count': len(model['signals']),
|
|
66
|
+
'assets_count': len(model['equipment']),
|
|
67
|
+
'metrics': {key: {
|
|
68
|
+
'role': role, 'sources': roles[role],
|
|
69
|
+
'supported': roles[role] > 0,
|
|
70
|
+
'requirement': 'ALL_DECLARED_SOURCES_GOOD_AND_FRESH',
|
|
71
|
+
} for key, role in features.items()},
|
|
72
|
+
'alarm_roles': ['fault', 'photoeye_blocked'],
|
|
73
|
+
'unsupported_values': 'N/A — SOURCE NOT CONFIGURED',
|
|
74
|
+
'live_opc_verified': False,
|
|
75
|
+
'ignition_runtime_verified': False,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _value_ok(s, value):
|
|
80
|
+
dtype = s['datatype']
|
|
81
|
+
if dtype == 'Boolean': return type(value) is bool
|
|
82
|
+
if dtype == 'Int32': return type(value) is int and -(2**31) <= value <= (2**31-1)
|
|
83
|
+
if dtype == 'Double': return type(value) in (int, float) and math.isfinite(value)
|
|
84
|
+
if dtype == 'String': return isinstance(value, str) and len(value) <= 160
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _signal_state(s, obs, at):
|
|
89
|
+
if obs is None:
|
|
90
|
+
return {'signal_id': s['id'], 'asset_id': s['asset_id'], 'role': s['role'],
|
|
91
|
+
'quality': 'MISSING', 'valid': False, 'value': None, 'source_timestamp': None}
|
|
92
|
+
required = {'signal_id', 'namespace_uri', 'node_id', 'datatype', 'quality',
|
|
93
|
+
'source_timestamp', 'value'}
|
|
94
|
+
if not isinstance(obs, dict) or set(obs) != required:
|
|
95
|
+
raise OperationsHold('UNRECOGNIZED_OR_MISSING_OBSERVATION_FIELDS')
|
|
96
|
+
if (obs['signal_id'] != s['id'] or obs['namespace_uri'] != s['source']['namespace_uri'] or
|
|
97
|
+
obs['node_id'] != s['source']['node_id'] or obs['datatype'] != s['datatype']):
|
|
98
|
+
raise OperationsHold('SOURCE_IDENTITY_OR_TYPE_MISMATCH:'+s['id'])
|
|
99
|
+
if obs['quality'] not in ('GOOD', 'BAD', 'UNCERTAIN'):
|
|
100
|
+
raise OperationsHold('INVALID_OPC_QUALITY')
|
|
101
|
+
timestamp = _date(obs['source_timestamp'])
|
|
102
|
+
age = (at-timestamp).total_seconds()
|
|
103
|
+
if age < -2:
|
|
104
|
+
raise OperationsHold('SOURCE_TIMESTAMP_IN_FUTURE:'+s['id'])
|
|
105
|
+
if not _value_ok(s, obs['value']):
|
|
106
|
+
raise OperationsHold('OBSERVED_VALUE_TYPE_MISMATCH:'+s['id'])
|
|
107
|
+
quality = 'STALE' if age > MAX_AGE_SECONDS else obs['quality']
|
|
108
|
+
good = quality == 'GOOD'
|
|
109
|
+
return {'signal_id': s['id'], 'asset_id': s['asset_id'], 'role': s['role'],
|
|
110
|
+
'quality': quality, 'valid': good, 'value': obs['value'] if good else None, 'source_timestamp': obs['source_timestamp']}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _metric(signals, role):
|
|
114
|
+
matched = [x for x in signals if x['role'] == role]
|
|
115
|
+
if not matched: return {'value': None, 'status': 'NOT_CONFIGURED', 'sources': 0}
|
|
116
|
+
valid = [x for x in matched if x['valid']]
|
|
117
|
+
if len(valid) != len(matched):
|
|
118
|
+
return {'value': None, 'status': 'SOURCE_UNAVAILABLE', 'sources': len(matched), 'good': len(valid)}
|
|
119
|
+
if role in ('scanner_result','scanner_barcode'):
|
|
120
|
+
latest=max(matched,key=lambda x:(x['source_timestamp'] or '',x['signal_id']))
|
|
121
|
+
return {'value': latest['value'],'status':'LAB_REPLAY','sources':len(matched),'asset_id':latest['asset_id']}
|
|
122
|
+
if role in ('run_feedback', 'fault', 'photoeye_blocked'):
|
|
123
|
+
return {'value': sum(x.get('active', x['value']) is True for x in matched), 'status': 'LAB_REPLAY', 'sources': len(matched)}
|
|
124
|
+
if role in ('scan_count', 'no_read_count', 'delivered_count', 'in_transit_count'):
|
|
125
|
+
if any(x['value'] < 0 for x in matched):
|
|
126
|
+
return {'value':None,'status':'COUNTER_INVALID_NEGATIVE','sources':len(matched)}
|
|
127
|
+
if any(x.get('counter_reset') for x in matched):
|
|
128
|
+
return {'value':None,'status':'COUNTER_RESET_REVIEW','sources':len(matched)}
|
|
129
|
+
return {'value': sum(x['value'] for x in matched), 'status': 'LAB_REPLAY', 'sources': len(matched)}
|
|
130
|
+
return {'value': None, 'status': 'UNSUPPORTED_METRIC', 'sources': len(matched)}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _bool_active(s, state):
|
|
134
|
+
if not state['valid']: return None
|
|
135
|
+
polarity = s.get('polarity', '')
|
|
136
|
+
# Polarity is only interpreted when explicitly present in the customer contract.
|
|
137
|
+
if polarity in ('active_low', 'ACTIVE_LOW'):
|
|
138
|
+
return not state['value']
|
|
139
|
+
if polarity in ('', 'active_high', 'ACTIVE_HIGH'):
|
|
140
|
+
return bool(state['value'])
|
|
141
|
+
raise OperationsHold('UNAPPROVED_SIGNAL_POLARITY:'+s['id'])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def evaluate(model, frames, *, origin='SYNTHETIC_OFFLINE_FIXTURE'):
|
|
145
|
+
"""Fail closed on ambiguous identity; label every derived result as lab-only."""
|
|
146
|
+
operations_policy = policy(model)
|
|
147
|
+
if origin not in ('SYNTHETIC_OFFLINE_FIXTURE', 'ISOLATED_LAB_OPC_READ_ONLY'):
|
|
148
|
+
raise OperationsHold('UNSUPPORTED_REPLAY_ORIGIN')
|
|
149
|
+
if not isinstance(frames, list) or not 1 <= len(frames) <= MAX_FRAMES:
|
|
150
|
+
raise OperationsHold('INVALID_FRAME_COUNT')
|
|
151
|
+
signals = {s['id']: s for s in model['signals']}
|
|
152
|
+
if len(signals) != len(model['signals']) or len(signals) > MAX_RECORDS:
|
|
153
|
+
raise OperationsHold('INVALID_SIGNAL_INVENTORY')
|
|
154
|
+
prev_at = None
|
|
155
|
+
previous = {}
|
|
156
|
+
last_counter_values = {}
|
|
157
|
+
all_events = []
|
|
158
|
+
results = []
|
|
159
|
+
known_assets = {e['id'] for e in model['equipment']}
|
|
160
|
+
for ix, frame in enumerate(frames):
|
|
161
|
+
if not isinstance(frame, dict) or set(frame) != {'at', 'observations'}:
|
|
162
|
+
raise OperationsHold('INVALID_FRAME_SCHEMA')
|
|
163
|
+
at = _date(frame['at'])
|
|
164
|
+
if prev_at is not None and at <= prev_at:
|
|
165
|
+
raise OperationsHold('NON_MONOTONIC_FRAME_TIMESTAMPS')
|
|
166
|
+
prev_at = at
|
|
167
|
+
rows = frame['observations']
|
|
168
|
+
if not isinstance(rows, list) or len(rows) > len(signals):
|
|
169
|
+
raise OperationsHold('INVALID_OBSERVATION_COUNT')
|
|
170
|
+
seen = {}
|
|
171
|
+
for row in rows:
|
|
172
|
+
if not isinstance(row, dict) or not isinstance(row.get('signal_id'), str):
|
|
173
|
+
raise OperationsHold('INVALID_OBSERVATION')
|
|
174
|
+
sid = row['signal_id']
|
|
175
|
+
if sid not in signals: raise OperationsHold('UNDECLARED_SIGNAL:'+sid[:50])
|
|
176
|
+
if sid in seen: raise OperationsHold('DUPLICATE_SIGNAL:'+sid)
|
|
177
|
+
seen[sid] = row
|
|
178
|
+
states = [_signal_state(s, seen.get(sid), at) for sid, s in signals.items()]
|
|
179
|
+
by_id = {s['signal_id']: s for s in states}
|
|
180
|
+
for declared in model['signals']:
|
|
181
|
+
if declared['role'] in ('fault', 'photoeye_blocked') and by_id[declared['id']]['valid']:
|
|
182
|
+
by_id[declared['id']]['active'] = _bool_active(declared, by_id[declared['id']])
|
|
183
|
+
counter_events=[]
|
|
184
|
+
for declared in model['signals']:
|
|
185
|
+
sid=declared['id'];state=by_id[sid]
|
|
186
|
+
if declared['role'] not in ('scan_count','no_read_count','delivered_count','photoeye_count','in_transit_count'):
|
|
187
|
+
continue
|
|
188
|
+
if not state['valid']:continue
|
|
189
|
+
old=last_counter_values.get(sid)
|
|
190
|
+
if old is not None and state['value']<old and declared['role']!='in_transit_count':
|
|
191
|
+
state['counter_reset']=True
|
|
192
|
+
counter_events.append({'kind':'COUNTER_RESET','severity':'WARNING',
|
|
193
|
+
'signal_id':sid,'asset_id':declared['asset_id'],
|
|
194
|
+
'role':declared['role'], 'message':'Counter decreased: reset or rollover needs review',
|
|
195
|
+
'at':frame['at'],'source':'LAB_REPLAY'})
|
|
196
|
+
last_counter_values[sid]=state['value']
|
|
197
|
+
metrics = {name: _metric(states, role) for name, role in [
|
|
198
|
+
('equipment_running', 'run_feedback'), ('active_faults','fault'),
|
|
199
|
+
('photoeyes_blocked','photoeye_blocked'), ('total_scans','scan_count'),
|
|
200
|
+
('no_reads','no_read_count'), ('delivered','delivered_count'),
|
|
201
|
+
('parcels_in_transit','in_transit_count'), ('last_scan_result','scanner_result'),
|
|
202
|
+
('last_barcode','scanner_barcode')]}
|
|
203
|
+
metrics['signal_health'] = {'value': sum(s['valid'] for s in states),
|
|
204
|
+
'total': len(states), 'status':'LAB_REPLAY'}
|
|
205
|
+
current = {}
|
|
206
|
+
frame_events = list(counter_events)
|
|
207
|
+
for s in model['signals']:
|
|
208
|
+
state = by_id[s['id']]
|
|
209
|
+
if s['role'] not in ('fault', 'photoeye_blocked', 'scanner_result'): continue
|
|
210
|
+
active = (state['valid'] and state['value'].upper()=='NO_READ') if s['role']=='scanner_result' and state['valid'] else (None if s['role']=='scanner_result' else _bool_active(s, state))
|
|
211
|
+
# Unknown must not be cleared and must not infer normal operation.
|
|
212
|
+
current[s['id']] = active
|
|
213
|
+
old = previous.get(s['id'], None)
|
|
214
|
+
if active is True and old is not True:
|
|
215
|
+
frame_events.append({'kind': 'ASSERTED', 'severity': 'CRITICAL' if s['role']=='fault' else 'WARNING',
|
|
216
|
+
'signal_id':s['id'],'asset_id':s['asset_id'], 'role':s['role'],
|
|
217
|
+
'message': ('Equipment fault' if s['role']=='fault' else 'Scanner NO_READ' if s['role']=='scanner_result' else 'Photoeye blocked'),
|
|
218
|
+
'at':frame['at'], 'source':'LAB_REPLAY'})
|
|
219
|
+
elif active is False and old is True:
|
|
220
|
+
frame_events.append({'kind':'CLEARED','severity':'INFO','signal_id':s['id'],
|
|
221
|
+
'asset_id':s['asset_id'], 'role':s['role'],
|
|
222
|
+
'message':'Condition cleared in replay', 'at':frame['at'],
|
|
223
|
+
'source':'LAB_REPLAY'})
|
|
224
|
+
# Unknown-quality transitions are surfaced separately, without creating fake equipment faults.
|
|
225
|
+
for asset in known_assets:
|
|
226
|
+
asset_states=[x for x in states if x['asset_id']==asset]
|
|
227
|
+
bad=bool(asset_states and any(not x['valid'] for x in asset_states))
|
|
228
|
+
key='QUALITY:'+asset
|
|
229
|
+
old=previous.get(key, None)
|
|
230
|
+
current[key]=bad
|
|
231
|
+
if bad and old is not True:
|
|
232
|
+
frame_events.append({'kind':'DATA_QUALITY','severity':'WARNING',
|
|
233
|
+
'signal_id':None,'asset_id':asset,'role':'quality',
|
|
234
|
+
'message':'Signal missing, stale or bad quality',
|
|
235
|
+
'at':frame['at'],'source':'LAB_REPLAY'})
|
|
236
|
+
elif not bad and old is True:
|
|
237
|
+
frame_events.append({'kind':'QUALITY_RECOVERED','severity':'INFO',
|
|
238
|
+
'signal_id':None,'asset_id':asset,'role':'quality',
|
|
239
|
+
'message':'Replay signal quality restored','at':frame['at'],
|
|
240
|
+
'source':'LAB_REPLAY'})
|
|
241
|
+
previous = current
|
|
242
|
+
all_events.extend(frame_events)
|
|
243
|
+
results.append({'at':frame['at'],'index':ix+1,'signals':states,'metrics':metrics,
|
|
244
|
+
'new_events':frame_events,
|
|
245
|
+
'active_alarms':[{'signal_id':s['id'],'asset_id':s['asset_id'], 'role':s['role'],
|
|
246
|
+
'severity':'CRITICAL' if s['role']=='fault' else 'WARNING'}
|
|
247
|
+
for s in model['signals'] if s['role'] in ('fault','photoeye_blocked','scanner_result')
|
|
248
|
+
and current.get(s['id']) is True],
|
|
249
|
+
'quality_issues':[s['signal_id'] for s in states if not s['valid']]})
|
|
250
|
+
return {'schema':'devagent.operations.replay.v1', 'status':'LAB_REPLAY_ONLY_NOT_VERIFIED',
|
|
251
|
+
'origin':origin, 'mapping_digest': operations_policy['mapping_digest'],
|
|
252
|
+
'project_name':model['name'], 'frames': results, 'events': all_events,
|
|
253
|
+
'evidence':{'mapping_identity':'MATCHED_TO_DECLARED_CONTRACT_ONLY',
|
|
254
|
+
'opc_source_identity':'NOT_PHYSICALLY_VERIFIED',
|
|
255
|
+
'studio_trigger_evidence':'NOT_VERIFIED_BY_THIS_REPORT',
|
|
256
|
+
'ignition_perspective':'NOT_EXECUTED',
|
|
257
|
+
'plc_physical_mapping':'NOT_VERIFIED',
|
|
258
|
+
'production':'HOLD', 'plc_writes':'DISABLED'},
|
|
259
|
+
'notification_acknowledgment':'BROWSER_SESSION_VISUAL_ONLY'}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def make_synthetic_frames(model):
|
|
263
|
+
"""Deterministic demo, not evidence of independent simulator behavior."""
|
|
264
|
+
policy(model)
|
|
265
|
+
t0=dt.datetime(2026,1,1,12,0,tzinfo=dt.timezone.utc)
|
|
266
|
+
frames=[]
|
|
267
|
+
alarm_targets=[s['id'] for s in model['signals'] if s['role'] in ('fault','photoeye_blocked','scanner_result')]
|
|
268
|
+
trigger=alarm_targets[:1]
|
|
269
|
+
for i in range(3):
|
|
270
|
+
at=(t0+dt.timedelta(seconds=i*3)).isoformat()
|
|
271
|
+
rows=[]
|
|
272
|
+
for s in model['signals']:
|
|
273
|
+
dtype=s['datatype']
|
|
274
|
+
val=False if dtype=='Boolean' else 0 if dtype=='Int32' else 0.0 if dtype=='Double' else 'GOOD_READ'
|
|
275
|
+
if s['role']=='run_feedback': val=(i!=0)
|
|
276
|
+
if s['role']=='no_read_count': val=i
|
|
277
|
+
if s['id'] in trigger and i==1:val=True
|
|
278
|
+
if s.get('polarity','').lower()=='active_low' and s['role'] in ('fault','photoeye_blocked'):
|
|
279
|
+
val=not val
|
|
280
|
+
rows.append({'signal_id':s['id'],'namespace_uri':s['source']['namespace_uri'],
|
|
281
|
+
'node_id':s['source']['node_id'],'datatype':dtype,'quality':'GOOD',
|
|
282
|
+
'source_timestamp':at, 'value':val})
|
|
283
|
+
frames.append({'at':at,'observations':rows})
|
|
284
|
+
return frames
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Read-only isolated-lab OPC UA observations, no writes or physical claim.
|
|
2
|
+
|
|
3
|
+
Verified endpoint identity, source timestamps and binding to a live Ignition HMI
|
|
4
|
+
require separate commissioning; this only captures OPC reads in an approved lab.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import asyncio
|
|
8
|
+
import datetime as dt
|
|
9
|
+
from .opc_snapshot import allow_lab_endpoint
|
|
10
|
+
from .operations import OperationsHold, policy
|
|
11
|
+
|
|
12
|
+
async def capture_lab(model, endpoint, samples=3):
|
|
13
|
+
policy(model)
|
|
14
|
+
allow_lab_endpoint(endpoint)
|
|
15
|
+
if not 1<=samples<=12:raise OperationsHold('UNSAFE_CAPTURE_SAMPLE_COUNT')
|
|
16
|
+
try:from asyncua import Client
|
|
17
|
+
except ImportError as exc:raise OperationsHold('ASYNCUA_OPTIONAL_DEPENDENCY_MISSING') from exc
|
|
18
|
+
async def do():
|
|
19
|
+
async with Client(endpoint, timeout=3) as client:
|
|
20
|
+
namespaces=await client.get_namespace_array()
|
|
21
|
+
for s in model['signals']:
|
|
22
|
+
if s['source']['namespace_uri'] not in namespaces:
|
|
23
|
+
raise OperationsHold('CUSTOMER_NAMESPACE_NOT_FOUND:'+s['id'])
|
|
24
|
+
frames=[]
|
|
25
|
+
for _ in range(samples):
|
|
26
|
+
observations=[]
|
|
27
|
+
at=dt.datetime.now(dt.timezone.utc)
|
|
28
|
+
for s in model['signals']:
|
|
29
|
+
ns=namespaces.index(s['source']['namespace_uri'])
|
|
30
|
+
node=f'ns={ns};s={s["source"]["node_id"]}'
|
|
31
|
+
data=await client.get_node(node).read_data_value()
|
|
32
|
+
if data.Value is None:raise OperationsHold('MISSING_DATAVALUE:'+s['id'])
|
|
33
|
+
variant=data.Value.VariantType.name
|
|
34
|
+
if variant!=s['datatype']:raise OperationsHold('OBSERVED_DATATYPE_MISMATCH:'+s['id'])
|
|
35
|
+
stamp=data.SourceTimestamp or data.ServerTimestamp
|
|
36
|
+
if stamp is None:raise OperationsHold('OPC_TIMESTAMP_REQUIRED:'+s['id'])
|
|
37
|
+
if stamp.tzinfo is None:stamp=stamp.replace(tzinfo=dt.timezone.utc)
|
|
38
|
+
observations.append({'signal_id':s['id'],'namespace_uri':s['source']['namespace_uri'],
|
|
39
|
+
'node_id':s['source']['node_id'],'datatype':s['datatype'],
|
|
40
|
+
'quality':'GOOD' if data.StatusCode.is_good() else 'BAD',
|
|
41
|
+
'source_timestamp':stamp.isoformat(), 'value':data.Value.Value})
|
|
42
|
+
frames.append({'at':dt.datetime.now(dt.timezone.utc).isoformat(), 'observations':observations})
|
|
43
|
+
if len(frames)<samples:await asyncio.sleep(1)
|
|
44
|
+
return frames
|
|
45
|
+
try:return await asyncio.wait_for(do(),timeout=max(40,samples*35))
|
|
46
|
+
except (OSError,TimeoutError,RuntimeError,ValueError) as exc:
|
|
47
|
+
raise OperationsHold('LAB_OPC_CAPTURE_HOLD:'+type(exc).__name__) from exc
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Isolated-lab-only Studio namespace adapter. Original customer model immutable.
|
|
2
|
+
|
|
3
|
+
This is a proposed lab alias map, NEVER a statement that physical PLC tags
|
|
4
|
+
or Ignition bindings match. Requires explicit user confirmation and new directory.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import copy
|
|
8
|
+
import csv
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from .operations import mapping_digest, policy, OperationsHold
|
|
16
|
+
|
|
17
|
+
LAB_NAMESPACE='urn:devagent:customer-tags:lab:v1'
|
|
18
|
+
TYPES={'Boolean':'Boolean','Int32':'Int4','Double':'Double','String':'String'}
|
|
19
|
+
|
|
20
|
+
def create_lab_derivative(model:dict,out:Path,confirm:str)->dict:
|
|
21
|
+
if confirm!='ISOLATED_TEST_ONLY':raise OperationsHold('EXPLICIT_ISOLATED_LAB_CONFIRMATION_REQUIRED')
|
|
22
|
+
policy(model)
|
|
23
|
+
destination=out.expanduser().resolve()
|
|
24
|
+
if destination.exists():raise OperationsHold('LAB_OUTPUT_ALREADY_EXISTS')
|
|
25
|
+
if not destination.parent.is_dir():raise OperationsHold('OUTPUT_PARENT_MISSING')
|
|
26
|
+
lab=copy.deepcopy(model)
|
|
27
|
+
rows=[]
|
|
28
|
+
for source,clone in zip(model['signals'],lab['signals']):
|
|
29
|
+
orig=source['source']
|
|
30
|
+
clone['source']['namespace_uri']=LAB_NAMESPACE
|
|
31
|
+
rows.append({'signal_id':source['id'],'asset_id':source['asset_id'],'role':source['role'],
|
|
32
|
+
'customer_namespace':orig['namespace_uri'],'lab_namespace':LAB_NAMESPACE,
|
|
33
|
+
'node_id':orig['node_id'],'datatype':source['datatype'],
|
|
34
|
+
'status':'PROPOSED_LAB_ALIAS_NOT_PHYSICAL_MAPPING'})
|
|
35
|
+
policy(lab)
|
|
36
|
+
temp=Path(tempfile.mkdtemp(prefix='.ops-lab-',dir=str(destination.parent)))
|
|
37
|
+
try:
|
|
38
|
+
(temp/'customer-original.json').write_text(json.dumps(model,indent=2,ensure_ascii=False)+'\n')
|
|
39
|
+
(temp/'lab-model.json').write_text(json.dumps(lab,indent=2,ensure_ascii=False)+'\n')
|
|
40
|
+
with (temp/'studio-tags.csv').open('x',newline='',encoding='utf8') as f:
|
|
41
|
+
writer=csv.writer(f);writer.writerow(['TagPath','DataType','InitialValue'])
|
|
42
|
+
for s in lab['signals']:
|
|
43
|
+
writer.writerow([s['source']['node_id'],TYPES[s['datatype']],
|
|
44
|
+
'false' if s['datatype']=='Boolean' else '0' if s['datatype']!='String' else ''])
|
|
45
|
+
evidence={'schema':'devagent.operations.lab-alias.v1','status':'PROPOSED_LAB_NAMESPACE_TRANSLATION_ONLY',
|
|
46
|
+
'customer_digest':mapping_digest(model),'lab_digest':mapping_digest(lab),
|
|
47
|
+
'customer_identity':'NOT_LIVE_VERIFIED','physical_plc':'NOT_VERIFIED',
|
|
48
|
+
'ignition_hmi':'NOT_VERIFIED','lab_namespace':LAB_NAMESPACE,'alias_rows':rows}
|
|
49
|
+
(temp/'alias-review.json').write_text(json.dumps(evidence,indent=2)+'\n')
|
|
50
|
+
files=sorted(p for p in temp.iterdir() if p.is_file())
|
|
51
|
+
(temp/'SHA256SUMS.txt').write_text(''.join(f'{hashlib.sha256(f.read_bytes()).hexdigest()} {f.name}\n' for f in files))
|
|
52
|
+
# Do not overwrite anything. Concurrent publishing also must be create-only.
|
|
53
|
+
if destination.exists():raise OperationsHold('LAB_OUTPUT_CREATED_CONCURRENTLY')
|
|
54
|
+
libc=__import__('ctypes').CDLL(None,use_errno=True)
|
|
55
|
+
rename=getattr(libc,'renameat2',None)
|
|
56
|
+
if rename is None:raise OperationsHold('ATOMIC_CREATE_ONLY_UNAVAILABLE')
|
|
57
|
+
rename.argtypes=[__import__('ctypes').c_int,__import__('ctypes').c_char_p,__import__('ctypes').c_int,__import__('ctypes').c_char_p,__import__('ctypes').c_uint]
|
|
58
|
+
if rename(-100,os.fsencode(temp),-100,os.fsencode(destination),1)!=0:
|
|
59
|
+
raise OperationsHold('ATOMIC_PUBLISH_HOLD')
|
|
60
|
+
finally:
|
|
61
|
+
if temp.exists():shutil.rmtree(temp)
|
|
62
|
+
return {'status':'PROPOSED_LAB_ALIAS_NOT_VERIFIED', 'path':str(destination),
|
|
63
|
+
'customer_mapping_digest':evidence['customer_digest'], 'lab_mapping_digest':evidence['lab_digest'],
|
|
64
|
+
'signals':len(rows),'node_ids_preserved':True,'customer_model':'UNCHANGED'}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""V0.8.2: snapshot-pinned isolated R9 concept viewer and deterministic virtual lab.
|
|
2
|
+
|
|
3
|
+
This is NOT an Ignition R9 R5 export or a physical device source. All copied
|
|
4
|
+
original R9 web/model and simulation files are immutable and served read-only.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import importlib.util
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
20
|
+
SOURCE = ROOT / 'reference' / 'r9-original'
|
|
21
|
+
SNAPSHOT_MANIFEST_SHA256 = 'c1712712ab5e35f913b27f906e23deca2cbaba9b8737d705975a648a4b49592c'
|
|
22
|
+
SPIRAL_IDS = tuple(f'R9_SPIRAL_{i:02d}' for i in range(1, 5))
|
|
23
|
+
|
|
24
|
+
class OriginalR9Hold(ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def verify_source() -> dict[str, str]:
|
|
29
|
+
manifest = SOURCE / 'SNAPSHOT_SHA256SUMS.txt'
|
|
30
|
+
if (not manifest.is_file() or manifest.is_symlink() or
|
|
31
|
+
hashlib.sha256(manifest.read_bytes()).hexdigest() != SNAPSHOT_MANIFEST_SHA256):
|
|
32
|
+
raise OriginalR9Hold('R9_SNAPSHOT_MANIFEST_HASH_MISMATCH')
|
|
33
|
+
verified = {}
|
|
34
|
+
for line in manifest.read_text(encoding='utf-8').splitlines():
|
|
35
|
+
if not re.fullmatch(r'[0-9a-f]{64} [A-Za-z0-9_./-]+', line):
|
|
36
|
+
raise OriginalR9Hold('R9_SNAPSHOT_MANIFEST_FORMAT_INVALID')
|
|
37
|
+
sha, rel = line.split(' ', 1)
|
|
38
|
+
file = (SOURCE / rel).resolve()
|
|
39
|
+
if (not file.is_relative_to(SOURCE.resolve()) or not file.is_file()
|
|
40
|
+
or file.is_symlink() or rel in verified or
|
|
41
|
+
hashlib.sha256(file.read_bytes()).hexdigest() != sha):
|
|
42
|
+
raise OriginalR9Hold('R9_SNAPSHOT_SOURCE_MISMATCH')
|
|
43
|
+
verified[rel] = sha
|
|
44
|
+
needed = {'web/index.html','web/app.mjs','web/model.mjs',
|
|
45
|
+
'web/state_adapter.mjs','web/lab_panel.mjs','web/styles.css',
|
|
46
|
+
'web/vendor/three.module.js','web/vendor/three.core.js',
|
|
47
|
+
'web/vendor/addons/controls/OrbitControls.js','flow_routes.json',
|
|
48
|
+
'flow_sim.py','lab_control.py'}
|
|
49
|
+
if not needed <= verified.keys():
|
|
50
|
+
raise OriginalR9Hold('R9_SNAPSHOT_INCOMPLETE')
|
|
51
|
+
routes = json.loads((SOURCE/'flow_routes.json').read_text(encoding='utf-8'))
|
|
52
|
+
ids = set(routes['ids'])
|
|
53
|
+
if not set(SPIRAL_IDS) <= ids or len(routes['routes']) < 11:
|
|
54
|
+
raise OriginalR9Hold('R9_FOUR_SPIRAL_TOPOLOGY_INVALID')
|
|
55
|
+
model = (SOURCE/'web/model.mjs').read_text(encoding='utf-8')
|
|
56
|
+
if 'export const FLOORS={1:' not in model or '2:' not in model or '3:' not in model:
|
|
57
|
+
raise OriginalR9Hold('R9_THREE_FLOOR_MODEL_INVALID')
|
|
58
|
+
return verified
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _module(name: str, filename: str):
|
|
62
|
+
spec = importlib.util.spec_from_file_location(name, SOURCE / filename)
|
|
63
|
+
if spec is None or spec.loader is None:
|
|
64
|
+
raise OriginalR9Hold('R9_MODEL_UNAVAILABLE')
|
|
65
|
+
module = importlib.util.module_from_spec(spec)
|
|
66
|
+
sys.modules[name] = module
|
|
67
|
+
try:
|
|
68
|
+
spec.loader.exec_module(module)
|
|
69
|
+
except Exception:
|
|
70
|
+
sys.modules.pop(name, None)
|
|
71
|
+
raise
|
|
72
|
+
return module
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class OriginalR9Demo:
|
|
76
|
+
"""Isolated model-backed in-memory simulation, no external connection."""
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
verify_source()
|
|
79
|
+
self._controller_mod = _module('_devagent_v082_r9_controller', 'lab_control.py')
|
|
80
|
+
self._flow_mod = _module('_devagent_v082_r9_flow', 'flow_sim.py')
|
|
81
|
+
self._lock = threading.RLock()
|
|
82
|
+
self._controller = self._controller_mod.LabController()
|
|
83
|
+
self._flow = self._flow_mod.ParcelFlow()
|
|
84
|
+
self._last_step = time.monotonic()
|
|
85
|
+
self._tick()
|
|
86
|
+
|
|
87
|
+
def _tick(self) -> None:
|
|
88
|
+
now = time.monotonic()
|
|
89
|
+
elapsed = min(0.5, max(0., now - self._last_step))
|
|
90
|
+
self._last_step = now
|
|
91
|
+
state = self._controller.snapshot()
|
|
92
|
+
sensors = self._flow.step(state['run_state'], state['wave'], elapsed)
|
|
93
|
+
self._controller.update_sensors(sensors)
|
|
94
|
+
|
|
95
|
+
def snapshot(self) -> dict[str, Any]:
|
|
96
|
+
with self._lock:
|
|
97
|
+
self._tick()
|
|
98
|
+
return {'enabled': True, 'source':'R9_SNAPSHOT_ISOLATED_SIMULATOR',
|
|
99
|
+
'native_ignition_verified':False, 'opcua_connected':False,
|
|
100
|
+
**self._controller.snapshot()}
|
|
101
|
+
|
|
102
|
+
def command(self, command: str, request_id: str) -> dict[str, Any]:
|
|
103
|
+
if not isinstance(command,str) or not isinstance(request_id,str) or not re.fullmatch(r'[A-Za-z0-9_-]{8,80}',request_id):
|
|
104
|
+
raise ValueError('R9_INVALID_COMMAND')
|
|
105
|
+
with self._lock:
|
|
106
|
+
try:
|
|
107
|
+
self._controller.command(command, request_id)
|
|
108
|
+
except self._controller_mod.CommandConflict as exc:
|
|
109
|
+
raise ValueError('R9_SIMULATOR_COMMAND_CONFLICT') from exc
|
|
110
|
+
self._tick()
|
|
111
|
+
return {'accepted':True, **self.snapshot()}
|
|
112
|
+
|
|
113
|
+
def reset(self) -> dict[str, Any]:
|
|
114
|
+
with self._lock:
|
|
115
|
+
self._controller=self._controller_mod.LabController()
|
|
116
|
+
self._flow=self._flow_mod.ParcelFlow()
|
|
117
|
+
self._last_step=time.monotonic()
|
|
118
|
+
return self.snapshot()
|
|
119
|
+
|
|
120
|
+
def info(self) -> dict[str, Any]:
|
|
121
|
+
return {'status':'SOURCE_BACKED_CONCEPT_ONLY','floors':[1,2,3],
|
|
122
|
+
'spiral_ids':list(SPIRAL_IDS),
|
|
123
|
+
'source_files':len(verify_source()),
|
|
124
|
+
'native_ignition_verified':False, 'plc_mappings_verified':False,
|
|
125
|
+
'simulation':'ISOLATED_IN_MEMORY_NO_OPCUA_OR_PLC',
|
|
126
|
+
'original_release_manifest':'PARTIAL_MISMATCH_INDEX_AND_MISSING_VENDOR_README',
|
|
127
|
+
'production':'HOLD'}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Render the stored R9 source with scoped URL prefixes and the Creator-only
|
|
131
|
+
# fail-closed diagnostics overlay. Source bytes remain immutable; response
|
|
132
|
+
# adaptations exist only in memory.
|
|
133
|
+
# This prevents cross-talk with Creator/V8 and prevents V1C live bridge access.
|
|
134
|
+
_URLS = {
|
|
135
|
+
'index.html': {
|
|
136
|
+
'/styles.css':'/original-r9/styles.css',
|
|
137
|
+
'/vendor/':'/original-r9/vendor/',
|
|
138
|
+
'/app.mjs':'/original-r9/app.mjs',
|
|
139
|
+
'/api/engine/status':'/api/original-r9/engine/status',
|
|
140
|
+
'/lab_panel.mjs':'/original-r9/lab_panel.mjs',
|
|
141
|
+
'</body>':'<script type="module" src="/assets/creator_r9_diagnostics.mjs"></script>\n</body>',
|
|
142
|
+
},
|
|
143
|
+
'lab_panel.mjs': {
|
|
144
|
+
'/api/lab/state':'/api/original-r9/lab/state',
|
|
145
|
+
'/api/lab/session':'/api/original-r9/lab/session',
|
|
146
|
+
'/api/lab/command':'/api/original-r9/lab/command',
|
|
147
|
+
},
|
|
148
|
+
'app.mjs': {'/api/bridge/facility/state':'/api/original-r9/bridge/facility/state'},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def web_response(relative: str) -> tuple[bytes, str]:
|
|
153
|
+
"""Allowlist only R9 HTML, CSS, JS, vendor modules; no source traversal."""
|
|
154
|
+
verify_source()
|
|
155
|
+
allowed={'index.html','styles.css','app.mjs','model.mjs','state_adapter.mjs',
|
|
156
|
+
'lab_panel.mjs','vendor/three.module.js','vendor/three.core.js',
|
|
157
|
+
'vendor/addons/controls/OrbitControls.js'}
|
|
158
|
+
if relative not in allowed:
|
|
159
|
+
raise OriginalR9Hold('R9_WEB_ROUTE_NOT_ALLOWED')
|
|
160
|
+
content=(SOURCE/'web'/relative).read_bytes()
|
|
161
|
+
if relative in _URLS:
|
|
162
|
+
text=content.decode('utf-8')
|
|
163
|
+
for old,new in _URLS[relative].items():
|
|
164
|
+
if old not in text:
|
|
165
|
+
raise OriginalR9Hold('R9_URL_REWRITE_ANCHOR_MISSING')
|
|
166
|
+
text=text.replace(old,new)
|
|
167
|
+
content=text.encode('utf-8')
|
|
168
|
+
mime='text/html; charset=utf-8' if relative.endswith('.html') else ('text/css; charset=utf-8' if relative.endswith('.css') else 'text/javascript; charset=utf-8')
|
|
169
|
+
return content,mime
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Pinned adapter for the unmodified Distribution Operations V8 demo source.
|
|
2
|
+
|
|
3
|
+
The reference tree is executed in place so its topology and synthetic state
|
|
4
|
+
semantics stay source-backed. This adapter adds only integrity verification
|
|
5
|
+
and synchronization for the Creator's localhost HTTP server.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import importlib.util
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import ModuleType
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
18
|
+
SOURCE = ROOT / "reference" / "v8-original"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OriginalV8Hold(ValueError):
|
|
22
|
+
"""The pinned V8 source is missing, changed, or received an invalid action."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def verify_source() -> dict[str, str]:
|
|
26
|
+
"""Verify every file from the original release manifest before execution."""
|
|
27
|
+
manifest = SOURCE / "SHA256SUMS.txt"
|
|
28
|
+
if not manifest.is_file():
|
|
29
|
+
raise OriginalV8Hold("ORIGINAL_V8_MANIFEST_MISSING")
|
|
30
|
+
verified: dict[str, str] = {}
|
|
31
|
+
try:
|
|
32
|
+
lines = manifest.read_text(encoding="utf8").splitlines()
|
|
33
|
+
for line in lines:
|
|
34
|
+
digest, rel = line.split(" ", 1)
|
|
35
|
+
path = (SOURCE / rel).resolve()
|
|
36
|
+
if not path.is_relative_to(SOURCE.resolve()) or not path.is_file() or path.is_symlink():
|
|
37
|
+
raise OriginalV8Hold("ORIGINAL_V8_SOURCE_MISSING")
|
|
38
|
+
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
39
|
+
if actual != digest:
|
|
40
|
+
raise OriginalV8Hold("ORIGINAL_V8_SOURCE_HASH_MISMATCH")
|
|
41
|
+
verified[rel] = digest
|
|
42
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
43
|
+
if isinstance(exc, OriginalV8Hold):
|
|
44
|
+
raise
|
|
45
|
+
raise OriginalV8Hold("ORIGINAL_V8_MANIFEST_INVALID") from exc
|
|
46
|
+
if not {"model.py", "topology.json", "web/index.html", "web/app.js", "web/style.css"} <= verified.keys():
|
|
47
|
+
raise OriginalV8Hold("ORIGINAL_V8_SOURCE_INCOMPLETE")
|
|
48
|
+
return verified
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_model() -> ModuleType:
|
|
52
|
+
verify_source()
|
|
53
|
+
spec = importlib.util.spec_from_file_location("_devagent_pinned_original_v8_model", SOURCE / "model.py")
|
|
54
|
+
if spec is None or spec.loader is None:
|
|
55
|
+
raise OriginalV8Hold("ORIGINAL_V8_MODEL_UNAVAILABLE")
|
|
56
|
+
module = importlib.util.module_from_spec(spec)
|
|
57
|
+
spec.loader.exec_module(module)
|
|
58
|
+
return module
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OriginalV8Demo:
|
|
62
|
+
"""Independent in-memory simulator used only by the original V8 demo."""
|
|
63
|
+
|
|
64
|
+
def __init__(self) -> None:
|
|
65
|
+
self._model = _load_model()
|
|
66
|
+
self._lock = threading.Lock()
|
|
67
|
+
self._state = self._model.new_state()
|
|
68
|
+
|
|
69
|
+
def snapshot(self) -> dict[str, Any]:
|
|
70
|
+
with self._lock:
|
|
71
|
+
return self._model.view_state(self._state)
|
|
72
|
+
|
|
73
|
+
def transition(self, payload: Any) -> dict[str, Any]:
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._state = self._model.transition(self._state, payload)
|
|
76
|
+
return self._model.view_state(self._state)
|
|
77
|
+
|
|
78
|
+
def reset(self) -> dict[str, Any]:
|
|
79
|
+
with self._lock:
|
|
80
|
+
self._state = self._model.new_state()
|
|
81
|
+
return self._model.view_state(self._state)
|