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,36 @@
1
+ # DevAgent Distribution Operations HMI V8
2
+
3
+ **Operator-first, connected warehouse material-flow concept.** Replaces V7's artificial 6-conveyor circle with a branched hub similar in *operating model* to parcel sorting and airport baggage handling: two receiving sources feed induction/gapping → scanning/merge → main sorter → three discharge destinations, with a no-read/exception route.
4
+
5
+ This is a **self-contained local browser demo**, NOT a finished Ignition Perspective project and NOT connected to any PLC, WCS, VFD, safety system, or live facility. Throughput, parcels, speeds, equipment health and alarms are **synthetic**.
6
+
7
+ ## Run
8
+
9
+ ```bash
10
+ cd "$HOME/Downloads"
11
+ unzip -n devagent-warehouse-facility-overview-v8.zip
12
+ cd devagent-warehouse-facility-overview-v8
13
+ sha256sum -c SHA256SUMS.txt
14
+ python3 -m unittest discover -s tests -v
15
+ ./run_demo.sh
16
+ ```
17
+
18
+ Open **operator overview** at `http://127.0.0.1:8768/index.html`. Open **separate engineering console** at `http://127.0.0.1:8768/simulator.html`.
19
+
20
+ ## Using the interface
21
+
22
+ - The main page is a continuous, non-loop topology. Follow the receiving conveyors into two induction branches, shared scan/merge, linear sorter and the outbound diverts. Map sections and their label badges show operational status.
23
+ - Select a conveyor **path or equipment ID badge** to zoom into it. The right panel switches to a read-only motor, VFD, photoeye, throughput and diagnostic faceplate. **Return to overview**, press **Esc**, or click **Fit View** to reset.
24
+ - Select **Receiving / Induction / Sortation / Outbound** to zoom an area. Mouse wheel zooms at the pointer; drag blank background to pan. The right panel shows area health and changes to show faults/affected routes.
25
+ - In the *engineering console only*, inject a jam, VFD trip, bad photoeye, congestion, low speed or global stop. The operator screen polls every 1.4 seconds. Normal restores all sections. Targeted jam/fault scenarios emulate upstream backpressure and downstream starvation. These propagated states are **conceptual**, not a verified control algorithm.
26
+ - The main HMI cannot send start, stop, reset or other actuator commands. `/api/scenario` changes in-memory synthetic data only, requires local host, and is not mounted to equipment.
27
+
28
+ ## Project layout
29
+
30
+ `topology.json` — branched section graph, SVG paths and area geometry; `model.py` — state machine, propagation and simulated process values; `server.py` — Python standard-library localhost web server, allowlisted static files and simulated test API; `web/*` — operator HMI & isolated engineering simulator; `tests/test_v8.py` — topology, scenario, security, API tests.
31
+
32
+ ## Verification boundary
33
+
34
+ `python3 -m unittest discover -s tests -v` verifies structural, data and HTTP contracts. Browser smoke tests validate rendering, zoom/inspection and cross-tab scenario updates when run in a Chromium/Playwright environment. These tests **do not establish functional safety, equipment-level fidelity, real-time determinism, scan-routing accuracy, PLC integration or Ignition gateway compatibility**.
35
+
36
+ For a production Ignition Perspective implementation, convert topology to a model-driven SVG/coordinate view, bind qualified tag values via a tested UDT contract, enforce alarm quality/staleness and navigation roles, and verify in an isolated gateway after full fresh `.gwbk` backup and preflight. All physical topology/safety interlocks must match actual engineering drawings.
@@ -0,0 +1,12 @@
1
+ dd4032360e10c1597d839a6e84f3c528cd4b64e12b67f271744cd532cb8ca373 README.md
2
+ 7ce53dbea176e3f9b9f356695ffea855e9d6e33fbb7dfbf7b659dd484b05a51d model.py
3
+ 373a34f1e93621d6da71d4aa57d0923a4ccc5274db9f50bfd0bb03170ebedc9a run_demo.sh
4
+ 41392a8b9665e93baa667e94e7fdc5d82c7c485d8655112e9b73695f47d04685 server.py
5
+ e1c8342d7142433a953b0e14ee0e9b24877cb6b16f26dd251b4001b223e9cc94 tests/browser_smoke.py
6
+ 75f13679facb939577f4e22bcdf2abf83f74177f703aec056a5160778ed8a976 tests/test_v8.py
7
+ 1137cce6b83c594ff1be3eaaeddc210a78b6f5b7fe2dbeaf7151f228964edbbf topology.json
8
+ b82fe00959c43f39fc658ae8c89fb03fc2b61649d4abc30471d37ebcbb1a151c web/app.js
9
+ 870f2d3cf5913d35ef6f46c7e853c3a7283e65888778d40e64cf6ae64a099f92 web/index.html
10
+ ab2d98a3a6944913e1d5ce35ab9f16dc12d6007f9a0ca4728b3f63fcc2ad0966 web/simulator.html
11
+ 2758752d746b768aecd3b8a1c2a99ec898507379513683f18847f0d82522299e web/simulator.js
12
+ e14b737638102bd95492cb94851bc40ea6502f574fc3f2c0c8be6dba5086faa3 web/style.css
@@ -0,0 +1,124 @@
1
+ """Local-only, deterministic synthetic conveyor network. No PLC operations."""
2
+ from __future__ import annotations
3
+ import json
4
+ from pathlib import Path
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ TOPOLOGY = json.loads((Path(__file__).resolve().parent / "topology.json").read_text())
9
+ SECTIONS = {x["id"]: x for x in TOPOLOGY["sections"]}
10
+ SCENARIOS = ("normal", "jam", "vfd_fault", "photoeye_fault", "congestion", "slow", "line_stop")
11
+
12
+ def utc_now():
13
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
14
+
15
+ def new_state():
16
+ return {"scenario": "normal", "target": "CV108", "revision": 0, "since": utc_now(),
17
+ "events": [{"time": utc_now(), "level": "info", "asset": "SYSTEM", "message": "Simulation started; no PLC connection"}]}
18
+
19
+ def validate_command(payload: Any):
20
+ if not isinstance(payload, dict) or set(payload) - {"scenario", "target"}:
21
+ raise ValueError("Expected JSON object with scenario and target")
22
+ scenario, target = payload.get("scenario"), payload.get("target", "CV108")
23
+ if scenario not in SCENARIOS or target not in SECTIONS:
24
+ raise ValueError("Invalid scenario or equipment ID")
25
+ return scenario, target
26
+
27
+ def traverse(target: str, direction: str):
28
+ """Return all reachable upstream or downstream section IDs, cycle safe."""
29
+ seen, frontier = set(), list(SECTIONS[target][direction])
30
+ while frontier:
31
+ item = frontier.pop()
32
+ if item not in seen:
33
+ seen.add(item)
34
+ frontier.extend(SECTIONS[item][direction])
35
+ return seen
36
+
37
+ def transition(state, payload):
38
+ scenario, target = validate_command(payload)
39
+ if (scenario, target) == (state["scenario"], state["target"]):
40
+ return state
41
+ label = "System simulation restored" if scenario == "normal" else (
42
+ "Entire simulated line stopped" if scenario == "line_stop" else f"{scenario.replace('_', ' ').upper()} injected at {target}")
43
+ event = {"time": utc_now(), "level": "info" if scenario == "normal" else "warning" if scenario in ("slow", "congestion", "line_stop") else "alarm",
44
+ "asset": "SYSTEM" if scenario in ("normal", "line_stop") else target, "message": label}
45
+ return {"scenario": scenario, "target": target, "revision": state["revision"] + 1,
46
+ "since": utc_now(), "events": (state["events"] + [event])[-30:]}
47
+
48
+ def view_state(controller):
49
+ scenario, target = controller["scenario"], controller["target"]
50
+ upstream = traverse(target, "upstream") if scenario in ("jam", "vfd_fault", "congestion") else set()
51
+ downstream = traverse(target, "downstream") if scenario in ("jam", "vfd_fault") else set()
52
+ units = []
53
+ for n, c in enumerate(TOPOLOGY["sections"]):
54
+ asset, status = c["id"], "RUNNING"
55
+ speed, frequency = c["speed"], round(c["speed"] * .88, 1)
56
+ quality, fault_code = "GOOD", "—"
57
+ motor_run, motor_feedback, drive_ready, drive_fault = True, True, True, False
58
+ pe_in, pe_out = "CLEAR" if n % 2 else "DETECTED", "CLEAR"
59
+ diag = "Nominal simulated operation. No active diagnostics."
60
+ if scenario == "line_stop":
61
+ status = "STOPPED"
62
+ elif asset == target:
63
+ if scenario == "jam":
64
+ status, pe_in, pe_out = "JAM", "BLOCKED", "BLOCKED"
65
+ diag = "Simulated jam: infeed/outfeed photoeyes blocked beyond 4.0 s. Verify transfer safely."
66
+ elif scenario == "vfd_fault":
67
+ status, drive_fault, drive_ready, fault_code = "FAULT", True, False, "F003"
68
+ quality = "BAD"
69
+ diag = "Simulated VFD trip F003. Check drive diagnostics and safe isolation procedure."
70
+ elif scenario == "photoeye_fault":
71
+ status, pe_in, quality = "SENSOR FAULT", "BAD QUALITY", "BAD"
72
+ diag = "Simulated photoeye signal quality failure; inspect I/O and sensor health."
73
+ elif scenario == "congestion":
74
+ status, pe_out = "CONGESTED", "BLOCKED"
75
+ diag = "Simulated rising accumulation at discharge; inspect downstream capacity."
76
+ elif scenario == "slow":
77
+ status, speed = "SLOW", round(speed * .35, 1)
78
+ diag = "Simulated measured speed below nominal threshold."
79
+ elif asset in upstream and scenario in ("jam", "vfd_fault", "congestion"):
80
+ # Synthetic dependency propagation illustrates upstream accumulation, not deterministic PLC behavior.
81
+ status = "BACKPRESSURE"
82
+ diag = f"Upstream accumulation associated with {target} (simulation only)."
83
+ elif asset in downstream:
84
+ status = "STARVED"
85
+ diag = f"No new product supplied from {target} (simulation only)."
86
+ if status in ("STOPPED", "JAM", "FAULT", "STARVED"):
87
+ speed, frequency, motor_run, motor_feedback = 0, 0, False, False
88
+ elif status == "BACKPRESSURE":
89
+ speed, frequency = round(speed*.35, 1), round(frequency*.35, 1)
90
+ elif status == "SLOW":
91
+ frequency = round(frequency * .35, 1)
92
+ units.append({
93
+ "id": asset, "name": c["name"], "area": c["area"],
94
+ "upstream": c["upstream"], "downstream": c["downstream"],
95
+ "status": status, "speed_mpm": speed, "nominal_speed_mpm": c["speed"],
96
+ "rate_pph": 0 if speed == 0 else int(c["rate"] * speed / c["speed"]),
97
+ "quality": quality, "diagnostic": diag,
98
+ "motor": {"id": "M-"+asset[2:], "run_command": motor_run, "run_feedback": motor_feedback,
99
+ "current_a": round((3.8 + n * .18) * (1.2 if status == "JAM" else 1.0 if speed else 0.0), 1),
100
+ "rpm": round(speed * 34)},
101
+ "vfd": {"id": "VFD-"+asset[2:], "ready": drive_ready, "drive_fault": drive_fault,
102
+ "frequency_hz": frequency, "fault_code": fault_code},
103
+ "sensors": {"infeed": {"id": "PE-"+asset[2:]+"-IN", "state": pe_in},
104
+ "outfeed": {"id": "PE-"+asset[2:]+"-OUT", "state": pe_out}},
105
+ })
106
+ alarms = sum(u["status"] in ("JAM", "FAULT", "SENSOR FAULT") for u in units)
107
+ affected = sum(u["status"] != "RUNNING" for u in units)
108
+ rate = {"normal": 7284, "jam": 3180, "vfd_fault": 3490, "photoeye_fault": 5910,
109
+ "congestion": 5015, "slow": 6380, "line_stop": 0}[scenario]
110
+ regions = []
111
+ for area in TOPOLOGY["zones"]:
112
+ members = [u for u in units if u["area"] == area["id"]]
113
+ regions.append({"id": area["id"], "name": area["name"], "total": len(members),
114
+ "attention": sum(u["status"] != "RUNNING" for u in members),
115
+ "alarms": sum(u["status"] in ("JAM", "FAULT", "SENSOR FAULT") for u in members)})
116
+ return {"meta": {"name": TOPOLOGY["facility"], "demo": True, "connected_to_plc": False,
117
+ "timestamp": utc_now(), "revision": controller["revision"], "since": controller["since"]},
118
+ "overview": {"status": "STOPPED" if scenario == "line_stop" else "ALARM" if alarms else "ATTENTION" if affected else "RUNNING",
119
+ "throughput_pph": rate, "capacity_pph": 9200, "active_alarms": alarms,
120
+ "affected_units": affected, "healthy_units": len(units) - affected,
121
+ "total_units": len(units), "parcels_in_system": 0 if scenario == "line_stop" else 247 + (controller["revision"] % 9),
122
+ "scan_read_pct": 98.9 if scenario == "photoeye_fault" else 99.3,
123
+ "scenario": scenario, "target": target},
124
+ "regions": regions, "units": units, "events": controller["events"][-10:][::-1]}
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ cd "$(dirname "$0")"
4
+ exec python3 server.py --port "${PORT:-8768}"
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+ """Localhost-only demo HTTP server; scenario endpoints DO NOT control hardware."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from pathlib import Path
8
+ import json
9
+ import threading
10
+ from urllib.parse import urlsplit
11
+
12
+ from model import new_state, transition, view_state
13
+
14
+ ROOT = Path(__file__).resolve().parent / "web"
15
+ MIME = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
16
+ ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".json": "application/json"}
17
+ ALLOWED = {"/index.html", "/simulator.html", "/app.js", "/simulator.js", "/style.css", "/topology.json"}
18
+
19
+
20
+ class DemoServer(ThreadingHTTPServer):
21
+ daemon_threads = True
22
+ allow_reuse_address = True
23
+
24
+ def __init__(self, addr):
25
+ super().__init__(addr, DemoHandler)
26
+ self.lock = threading.Lock()
27
+ self.model_state = new_state()
28
+
29
+
30
+ class DemoHandler(BaseHTTPRequestHandler):
31
+ server: DemoServer
32
+
33
+ def _send(self, code, body, content_type="application/json; charset=utf-8"):
34
+ self.send_response(code)
35
+ self.send_header("Content-Type", content_type)
36
+ self.send_header("Content-Length", str(len(body)))
37
+ self.send_header("Cache-Control", "no-store")
38
+ self.send_header("X-Content-Type-Options", "nosniff")
39
+ self.send_header("X-Frame-Options", "DENY")
40
+ self.end_headers()
41
+ self.wfile.write(body)
42
+
43
+ def _json(self, code, value):
44
+ self._send(code, json.dumps(value, ensure_ascii=False).encode("utf-8"))
45
+
46
+ def do_GET(self):
47
+ path = urlsplit(self.path).path
48
+ if path == "/api/state":
49
+ with self.server.lock:
50
+ data = view_state(self.server.model_state)
51
+ return self._json(200, data)
52
+ if path == "/":
53
+ path = "/index.html"
54
+ if path not in ALLOWED:
55
+ return self._json(404, {"error": "Not found"})
56
+ data = (Path(__file__).resolve().parent / "topology.json").read_bytes() if path == "/topology.json" else (ROOT / path[1:]).read_bytes()
57
+ self._send(200, data, MIME[Path(path).suffix])
58
+
59
+ def do_POST(self):
60
+ if urlsplit(self.path).path != "/api/scenario":
61
+ return self._json(404, {"error": "Not found"})
62
+ if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json":
63
+ return self._json(415, {"error": "Use application/json"})
64
+ try:
65
+ size = int(self.headers.get("Content-Length", "0"))
66
+ if not 0 < size <= 1024:
67
+ return self._json(413, {"error": "Invalid payload size"})
68
+ payload = json.loads(self.rfile.read(size).decode("utf-8"))
69
+ with self.server.lock:
70
+ new_state = transition(self.server.model_state, payload)
71
+ self.server.model_state = new_state
72
+ data = view_state(new_state)
73
+ self._json(200, data)
74
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
75
+ self._json(400, {"error": str(exc)})
76
+
77
+ def log_message(self, fmt, *args):
78
+ pass # no noisy access logs in demonstration mode
79
+
80
+
81
+ def build_server(host="127.0.0.1", port=8767):
82
+ if host not in ("127.0.0.1", "localhost"):
83
+ raise ValueError("This simulation must bind only to localhost")
84
+ return DemoServer(("127.0.0.1", port))
85
+
86
+
87
+ if __name__ == "__main__":
88
+ parser = argparse.ArgumentParser(description="DevAgent connected conveyor demonstration")
89
+ parser.add_argument("--port", type=int, default=8767)
90
+ args = parser.parse_args()
91
+ httpd = build_server(port=args.port)
92
+ print(f"DEVAGENT_V8_DEMO=http://127.0.0.1:{httpd.server_port}/index.html", flush=True)
93
+ print(f"DEVAGENT_V8_ENGINEERING=http://127.0.0.1:{httpd.server_port}/simulator.html", flush=True)
94
+ print("SIMULATION ONLY — NO LIVE PLC / DRIVE CONNECTION", flush=True)
95
+ try:
96
+ httpd.serve_forever()
97
+ except KeyboardInterrupt:
98
+ pass
99
+ finally:
100
+ httpd.server_close()
@@ -0,0 +1,50 @@
1
+ """Optional real Chromium interaction acceptance test, local server; saves screenshot."""
2
+ import sys,threading,time
3
+ from pathlib import Path
4
+ BASE=Path(__file__).resolve().parents[1]
5
+ sys.path.insert(0,str(BASE))
6
+ from server import build_server
7
+ from playwright.sync_api import sync_playwright
8
+ server=build_server(port=0)
9
+ thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
10
+ url=f'http://127.0.0.1:{server.server_port}'
11
+ try:
12
+ with sync_playwright() as p:
13
+ b=p.chromium.launch(headless=True,executable_path='/usr/bin/chromium',args=['--no-sandbox'])
14
+ page=b.new_page(viewport={'width':1710,'height':1070},device_scale_factor=1)
15
+ errors=[];page.on('pageerror',lambda e:errors.append(str(e)))
16
+ page.goto(url+'/index.html',wait_until='networkidle')
17
+ page.wait_for_function("document.getElementById('healthValue').textContent==='OPERATIONAL'")
18
+ assert page.locator('.eq-route').count()==13
19
+ page.screenshot(path=str(BASE/'warehouse-v8-overview.png'),full_page=True)
20
+ before=page.locator('#factoryMap').get_attribute('viewBox')
21
+ page.locator('#eq-CV108').click()
22
+ page.wait_for_function("document.getElementById('mapTitle').textContent.includes('CV108')")
23
+ page.wait_for_timeout(500)
24
+ after=page.locator('#factoryMap').get_attribute('viewBox')
25
+ assert before!=after,(before,after)
26
+ assert page.get_by_text('M-108 / VFD-108').count()>=1
27
+ page.screenshot(path=str(BASE/'warehouse-v8-zoom.png'),full_page=True)
28
+ engineer=b.new_page(viewport={'width':1080,'height':850})
29
+ engineer.goto(url+'/simulator.html',wait_until='networkidle')
30
+ engineer.select_option('#target','CV108')
31
+ engineer.select_option('#scenario','jam')
32
+ engineer.locator('#inject').click()
33
+ engineer.wait_for_function("document.getElementById('currentScenario').textContent==='JAM'")
34
+ page.wait_for_function("document.getElementById('healthValue').textContent==='ALARM'",timeout=8000)
35
+ assert 'JAM' in page.locator('.statebadge').inner_text()
36
+ page.screenshot(path=str(BASE/'warehouse-v8-alarm.png'),full_page=True)
37
+ engineer.locator('#restore').click()
38
+ engineer.wait_for_function("document.getElementById('currentScenario').textContent==='NORMAL'")
39
+ page.wait_for_function("document.getElementById('healthValue').textContent==='OPERATIONAL'",timeout=8000)
40
+ page.locator('#fitBtn').click()
41
+ page.wait_for_timeout(400)
42
+ assert page.locator('#factoryMap').get_attribute('viewBox')=='0.0 0.0 1400.0 700.0'
43
+ page.get_by_role('button',name='Receiving').click()
44
+ page.wait_for_function("document.getElementById('mapTitle').textContent.includes('RECEIVING')")
45
+ assert not errors,errors
46
+ print('BROWSER_SMOKE=PASS segments=13 click_to_zoom=PASS faceplate=PASS cross_tab_jam=PASS restore=PASS fit=PASS area_zoom=PASS pageerrors=0')
47
+ print('SCREENSHOTS='+','.join(str(BASE / ('warehouse-v8-'+name+'.png')) for name in ('overview','zoom','alarm')))
48
+ b.close()
49
+ finally:
50
+ server.shutdown();server.server_close();thread.join(timeout=3)
@@ -0,0 +1,144 @@
1
+ """V8 contract and localhost functional checks: no external PLC dependencies."""
2
+ import json, os, sys, unittest, threading, urllib.request, urllib.error
3
+ from pathlib import Path
4
+ BASE=Path(__file__).resolve().parents[1]
5
+ sys.path.insert(0,str(BASE))
6
+ from model import TOPOLOGY, SECTIONS, SCENARIOS, new_state, validate_command, transition, view_state, traverse
7
+ from server import build_server
8
+
9
+ class Topology(unittest.TestCase):
10
+ def test_13_sections(self): self.assertEqual(13,len(SECTIONS))
11
+ def test_four_operating_areas(self): self.assertEqual(4,len(TOPOLOGY['zones']))
12
+ def test_unique_sections(self): self.assertEqual(len(SECTIONS),len(set(SECTIONS)))
13
+ def test_no_duplicate_area(self): self.assertEqual(len(TOPOLOGY['zones']),len(set(z['id'] for z in TOPOLOGY['zones'])))
14
+ def test_two_receiving_sources(self): self.assertEqual(['CV101','CV102'],[s['id'] for s in TOPOLOGY['sections'] if not s['upstream']])
15
+ def test_connected_merge(self): self.assertEqual({'CV105','CV106'},set(SECTIONS['CV107']['upstream']))
16
+ def test_branch_outbound(self): self.assertEqual({'CV109','CV111'},set(SECTIONS['CV108']['downstream']))
17
+ def test_three_terminal_destinations(self):
18
+ terminals={s['id'] for s in TOPOLOGY['sections'] if not s['downstream']}
19
+ self.assertEqual({'CV111','CV112','CV113'},terminals)
20
+ def test_edges_bidirectional_metadata(self):
21
+ for unit in TOPOLOGY['sections']:
22
+ for v in unit['downstream']:
23
+ self.assertIn(unit['id'],SECTIONS[v]['upstream'])
24
+ for v in unit['upstream']:
25
+ self.assertIn(unit['id'],SECTIONS[v]['downstream'])
26
+ def test_no_orphan_equipment(self):
27
+ for u in SECTIONS.values(): self.assertTrue(u['upstream'] or u['downstream'])
28
+ def test_full_reachability(self):
29
+ found={'CV101','CV102'}|traverse('CV101','downstream')|traverse('CV102','downstream')
30
+ self.assertEqual(set(SECTIONS),found)
31
+ def test_no_unwanted_circle(self):
32
+ def dfs(u,stack,seen):
33
+ if u in stack: raise AssertionError(f'Cycle found at {u}')
34
+ if u in seen:return
35
+ seen.add(u)
36
+ for n in SECTIONS[u]['downstream']:dfs(n,stack|{u},seen)
37
+ visited=set()
38
+ for k in ('CV101','CV102'):dfs(k,set(),visited)
39
+ self.assertEqual(set(SECTIONS),visited)
40
+ def test_all_equipment_has_paths(self):
41
+ for c in TOPOLOGY['sections']:
42
+ self.assertRegex(c['path'],r'^M\d+ \d+')
43
+ self.assertEqual(2,len(c['label']))
44
+ def test_paths_branch_into_main_trunk(self):
45
+ for id in ('CV111','CV112','CV113'):self.assertIn('340',SECTIONS[id]['path'])
46
+ def test_every_section_has_area(self):
47
+ areas={z['id'] for z in TOPOLOGY['zones']}
48
+ for c in TOPOLOGY['sections']:self.assertIn(c['area'],areas)
49
+
50
+ class Behavior(unittest.TestCase):
51
+ def setUp(self):self.s=new_state()
52
+ def v(self,s,t='CV108'):return view_state(transition(self.s,{'scenario':s,'target':t}))
53
+ def u(self,x,sid):return next(v for v in x['units'] if v['id']==sid)
54
+ def test_baseline(self):
55
+ v=view_state(self.s)
56
+ self.assertEqual('RUNNING',v['overview']['status'])
57
+ self.assertEqual(0,v['overview']['active_alarms'])
58
+ def test_plc_is_always_disconnected(self):self.assertFalse(view_state(self.s)['meta']['connected_to_plc'])
59
+ def test_all_scenarios_accepted(self):
60
+ for k in SCENARIOS:self.assertEqual(k,validate_command({'scenario':k,'target':'CV107'})[0])
61
+ def test_reject_invalid_scenario(self):
62
+ with self.assertRaises(ValueError):validate_command({'scenario':'arbitrary_js','target':'CV101'})
63
+ def test_reject_invalid_equipment(self):
64
+ with self.assertRaises(ValueError):validate_command({'scenario':'jam','target':'CV999'})
65
+ def test_reject_extra_commands(self):
66
+ with self.assertRaises(ValueError):validate_command({'scenario':'normal','target':'CV101','start_motor':True})
67
+ def test_reject_non_object(self):
68
+ with self.assertRaises(ValueError):validate_command(['normal','CV101'])
69
+ def test_jam_updates_alarm(self):
70
+ v=self.v('jam');self.assertEqual('ALARM',v['overview']['status']);self.assertEqual('JAM',self.u(v,'CV108')['status'])
71
+ def test_jam_upstream_backpressure(self):self.assertEqual('BACKPRESSURE',self.u(self.v('jam'),'CV107')['status'])
72
+ def test_jam_downstream_starved(self):self.assertEqual('STARVED',self.u(self.v('jam'),'CV111')['status'])
73
+ def test_unaffected_receiving_not_false_alarm(self):
74
+ v=self.v('jam','CV102');self.assertEqual('RUNNING',self.u(v,'CV101')['status'])
75
+ def test_vfd_fault_code_and_speed(self):
76
+ v=self.v('vfd_fault');u=self.u(v,'CV108');self.assertEqual('F003',u['vfd']['fault_code']);self.assertEqual(0,u['speed_mpm'])
77
+ def test_photoeye_bad_quality(self):
78
+ v=self.v('photoeye_fault');self.assertEqual('BAD',self.u(v,'CV108')['quality'])
79
+ def test_congestion_alarm_distinction(self):
80
+ v=self.v('congestion');self.assertEqual('ATTENTION',v['overview']['status']);self.assertEqual(0,v['overview']['active_alarms'])
81
+ def test_slow_reduces_speed(self):
82
+ v=self.v('slow');u=self.u(v,'CV108');self.assertLess(u['speed_mpm'],u['nominal_speed_mpm'])
83
+ def test_stop_entire_line(self):
84
+ v=self.v('line_stop');self.assertEqual('STOPPED',v['overview']['status']);self.assertTrue(all(u['status']=='STOPPED' for u in v['units']))
85
+ def test_stop_zero_rate(self):self.assertEqual(0,self.v('line_stop')['overview']['throughput_pph'])
86
+ def test_restore_all_nominal(self):
87
+ v=view_state(transition(transition(self.s,{'scenario':'jam','target':'CV108'}),{'scenario':'normal','target':'CV108'}))
88
+ self.assertTrue(all(u['status']=='RUNNING' for u in v['units']))
89
+ def test_duplicate_no_revision(self):
90
+ s=transition(self.s,{'scenario':'normal','target':'CV108'});self.assertEqual(self.s['revision'],s['revision'])
91
+ def test_new_event_at_each_transition(self):
92
+ s=transition(self.s,{'scenario':'jam','target':'CV108'});self.assertEqual(len(self.s['events'])+1,len(s['events']))
93
+ def test_view_does_not_mutate_model(self):
94
+ copy=json.dumps(self.s,sort_keys=True);view_state(self.s);self.assertEqual(copy,json.dumps(self.s,sort_keys=True))
95
+ def test_line_status_count(self):
96
+ v=self.v('jam');self.assertEqual(13,v['overview']['total_units'])
97
+ self.assertEqual(13,v['overview']['healthy_units']+v['overview']['affected_units'])
98
+
99
+ class HTTP(unittest.TestCase):
100
+ @classmethod
101
+ def setUpClass(cls):
102
+ cls.srv=build_server(port=0)
103
+ cls.worker=threading.Thread(target=cls.srv.serve_forever,daemon=True)
104
+ cls.worker.start()
105
+ cls.url=f'http://127.0.0.1:{cls.srv.server_port}'
106
+ @classmethod
107
+ def tearDownClass(cls):cls.srv.shutdown();cls.srv.server_close();cls.worker.join(timeout=2)
108
+ def get(self,path):
109
+ with urllib.request.urlopen(self.url+path,timeout=2) as r:return r.status,r.read(),r.headers
110
+ def post(self,path,payload,ct='application/json'):
111
+ request=urllib.request.Request(self.url+path,method='POST',data=json.dumps(payload).encode(),headers={'Content-Type':ct})
112
+ with urllib.request.urlopen(request,timeout=2) as r:return r.status,json.load(r)
113
+ def test_index(self):self.assertIn(b'Warehouse overview',self.get('/')[1])
114
+ def test_topology(self):self.assertEqual(13,len(json.loads(self.get('/topology.json')[1])['sections']))
115
+ def test_no_cache_state(self):self.assertEqual('no-store',self.get('/api/state')[2].get('Cache-Control'))
116
+ def test_engineering_is_separate(self):
117
+ self.assertIn(b'Scenario controller',self.get('/simulator.html')[1])
118
+ self.assertNotIn(b'APPLY TEST SCENARIO',self.get('/index.html')[1])
119
+ def test_frontend_has_no_post(self):
120
+ src=self.get('/app.js')[1].decode()
121
+ self.assertNotIn("method:'POST'",src)
122
+ self.assertNotIn('api/scenario',src)
123
+ def test_injection_changes_live_view(self):
124
+ status,_=self.post('/api/scenario',{'scenario':'jam','target':'CV109'})
125
+ self.assertEqual(200,status)
126
+ v=json.loads(self.get('/api/state')[1]);self.assertEqual('JAM',next(u['status'] for u in v['units'] if u['id']=='CV109'))
127
+ self.post('/api/scenario',{'scenario':'normal','target':'CV108'})
128
+ def test_reject_write_static(self):
129
+ with self.assertRaises(urllib.error.HTTPError) as err:self.post('/index.html',{'scenario':'jam'})
130
+ self.assertEqual(404,err.exception.code)
131
+ def test_reject_directory_traversal(self):
132
+ with self.assertRaises(urllib.error.HTTPError) as err:self.get('/%2e%2e/server.py')
133
+ self.assertEqual(404,err.exception.code)
134
+ def test_reject_json_wrong_type(self):
135
+ with self.assertRaises(urllib.error.HTTPError) as err:self.post('/api/scenario',{'scenario':'jam'},ct='text/plain')
136
+ self.assertEqual(415,err.exception.code)
137
+ def test_response_no_sniff(self):self.assertEqual('nosniff',self.get('/')[2].get('X-Content-Type-Options'))
138
+ def test_invalid_scenario_http400(self):
139
+ with self.assertRaises(urllib.error.HTTPError) as err:self.post('/api/scenario',{'scenario':'cmd_motor_run','target':'CV101'})
140
+ self.assertEqual(400,err.exception.code)
141
+ def test_server_rejects_remote_bind(self):
142
+ with self.assertRaises(ValueError):build_server(host='0.0.0.0',port=0)
143
+
144
+ if __name__=='__main__':unittest.main(verbosity=2)
@@ -0,0 +1,24 @@
1
+ {
2
+ "facility": "NORTH DISTRIBUTION CENTER", "line": "Parcel Sortation / Area A", "canvas": {"width": 1400, "height": 700},
3
+ "zones": [
4
+ {"id":"INBOUND","name":"01 / RECEIVING","sub":"Dock A + Dock B","x":64,"y":95,"width":293,"height":555},
5
+ {"id":"INDUCTION","name":"02 / INDUCTION","sub":"Metering + singulation","x":366,"y":95,"width":315,"height":555},
6
+ {"id":"SORTATION","name":"03 / SORTATION","sub":"Scan + divert logic","x":690,"y":95,"width":390,"height":555},
7
+ {"id":"DISPATCH","name":"04 / DISPATCH","sub":"Outbound destinations","x":1090,"y":95,"width":255,"height":555}
8
+ ],
9
+ "sections": [
10
+ {"id":"CV101","name":"Receiving Infeed A","area":"INBOUND","path":"M126 225 H330","label":[204,193],"upstream":[],"downstream":["CV103"],"rate":1120,"speed":36.4},
11
+ {"id":"CV102","name":"Receiving Infeed B","area":"INBOUND","path":"M126 462 H330","label":[204,495],"upstream":[],"downstream":["CV104"],"rate":1080,"speed":36.8},
12
+ {"id":"CV103","name":"Induction A","area":"INDUCTION","path":"M330 225 H418 Q456 225 482 304 H527","label":[414,191],"upstream":["CV101"],"downstream":["CV105"],"rate":1100,"speed":39.4},
13
+ {"id":"CV104","name":"Induction B","area":"INDUCTION","path":"M330 462 H418 Q456 462 482 375 H527","label":[410,497],"upstream":["CV102"],"downstream":["CV106"],"rate":1090,"speed":39.1},
14
+ {"id":"CV105","name":"Gapping A","area":"INDUCTION","path":"M527 304 H576 Q615 304 615 340","label":[552,270],"upstream":["CV103"],"downstream":["CV107"],"rate":1090,"speed":43.2},
15
+ {"id":"CV106","name":"Gapping B","area":"INDUCTION","path":"M527 375 H576 Q615 375 615 340","label":[550,412],"upstream":["CV104"],"downstream":["CV107"],"rate":1070,"speed":43.0},
16
+ {"id":"CV107","name":"Merge & Scan Tunnel","area":"SORTATION","path":"M615 340 H839","label":[727,307],"upstream":["CV105","CV106"],"downstream":["CV108"],"rate":2110,"speed":57.2},
17
+ {"id":"CV108","name":"Primary Sorter","area":"SORTATION","path":"M839 340 H992","label":[908,306],"upstream":["CV107"],"downstream":["CV109","CV111"],"rate":2040,"speed":61.5},
18
+ {"id":"CV109","name":"Sorter Transfer","area":"SORTATION","path":"M992 340 H1111","label":[1046,305],"upstream":["CV108"],"downstream":["CV110","CV112"],"rate":1800,"speed":60.0},
19
+ {"id":"CV110","name":"Exception Transfer","area":"DISPATCH","path":"M1111 340 H1293","label":[1220,306],"upstream":["CV109"],"downstream":["CV113"],"rate":280,"speed":40.0},
20
+ {"id":"CV111","name":"Outbound Lane 01","area":"DISPATCH","path":"M952 340 V215 H1278","label":[1172,178],"upstream":["CV108"],"downstream":[],"rate":750,"speed":41.2},
21
+ {"id":"CV112","name":"Outbound Lane 02","area":"DISPATCH","path":"M1068 340 V463 H1278","label":[1182,430],"upstream":["CV109"],"downstream":[],"rate":690,"speed":41.0},
22
+ {"id":"CV113","name":"Priority / Exceptions","area":"DISPATCH","path":"M1171 340 V565 H1278","label":[1201,536],"upstream":["CV110"],"downstream":[],"rate":270,"speed":32.1}
23
+ ]
24
+ }