831 lines
27 KiB
Python
831 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import queue
|
|
import re
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
import simpy
|
|
|
|
from tern import Tern
|
|
from transition import NODES_N, TRANSITIONS
|
|
|
|
|
|
def active_edges() -> set[tuple[str, str, str]]:
|
|
"""Graph the live engine drives."""
|
|
return NODES_N
|
|
|
|
|
|
PIN_ID = "0"
|
|
DEFAULT_HOST = "127.0.0.1"
|
|
DEFAULT_PORT = 8765
|
|
FRONTEND_DIR = Path(__file__).with_name("frontend").joinpath("dist")
|
|
SESSION_ID_RE = re.compile(r"[^a-zA-Z0-9_-]+")
|
|
|
|
|
|
def normalize_pin_value(value: object) -> str:
|
|
text = str(value if value is not None else "N").strip()
|
|
|
|
if text in {"1", "+"} or text.lower() == "true":
|
|
return "+"
|
|
|
|
if text in {"-1", "0", "-"} or text.lower() == "false":
|
|
return "0"
|
|
|
|
if text in {"", "N", "?", "unknown"}:
|
|
return "N"
|
|
|
|
return text
|
|
|
|
|
|
def tern_event_value(value: Tern) -> str:
|
|
match value:
|
|
case Tern.Y:
|
|
return "+"
|
|
case Tern.N:
|
|
return "0"
|
|
case Tern.U:
|
|
return "N"
|
|
|
|
|
|
def event_value_to_tern(value: object) -> Tern:
|
|
match normalize_pin_value(value):
|
|
case "+":
|
|
return Tern.Y
|
|
case "0":
|
|
return Tern.N
|
|
case _:
|
|
return Tern.U
|
|
|
|
|
|
def snapshot_values(values: dict[str, Tern]) -> dict[str, str]:
|
|
return {name: tern_event_value(value) for name, value in sorted(values.items())}
|
|
|
|
|
|
def graph_topology(values: dict[str, Tern]) -> dict[str, Any]:
|
|
current_values = snapshot_values(values)
|
|
edges: list[dict[str, str]] = []
|
|
|
|
for index, (nn, uu, yy) in enumerate(sorted(active_edges())):
|
|
edges.append(
|
|
{
|
|
"id": f"{uu}->{nn}:0:{index}",
|
|
"source": uu,
|
|
"target": nn,
|
|
"label": "0",
|
|
}
|
|
)
|
|
edges.append(
|
|
{
|
|
"id": f"{uu}->{yy}:1:{index}",
|
|
"source": uu,
|
|
"target": yy,
|
|
"label": "+",
|
|
}
|
|
)
|
|
|
|
return {
|
|
"nodes": [
|
|
{
|
|
"id": name,
|
|
"label": name,
|
|
"pins": [PIN_ID],
|
|
"value": value,
|
|
}
|
|
for name, value in current_values.items()
|
|
],
|
|
"edges": edges,
|
|
"values": current_values,
|
|
}
|
|
|
|
|
|
def transition_char(value: object) -> str:
|
|
"""Event value ("+"/"0"/"N") -> TRANSITIONS char ("1"/"0"/"N")."""
|
|
return str(event_value_to_tern(value))
|
|
|
|
|
|
def char_event_value(char: str) -> str:
|
|
"""TRANSITIONS char ("1"/"0"/"N") -> event value ("+"/"0"/"N")."""
|
|
return tern_event_value(Tern.from_string(char))
|
|
|
|
|
|
def derive_triples(topology: dict[str, Any]) -> list[tuple[str, str, str]]:
|
|
"""Invert graph_topology(): a node with a "0" out-edge (-> nn) and a "+"
|
|
out-edge (-> yy) is a transition node, yielding the triple (nn, source, yy).
|
|
"""
|
|
zero_target: dict[str, str] = {}
|
|
plus_target: dict[str, str] = {}
|
|
|
|
for edge in topology.get("edges", []):
|
|
source = edge.get("source")
|
|
target = edge.get("target")
|
|
if not source or not target:
|
|
continue
|
|
|
|
label = normalize_pin_value(edge.get("label", ""))
|
|
if label == "0":
|
|
zero_target.setdefault(source, target)
|
|
elif label == "+":
|
|
plus_target.setdefault(source, target)
|
|
|
|
triples = {
|
|
(zero_target[source], source, plus_target[source])
|
|
for source in zero_target
|
|
if source in plus_target
|
|
}
|
|
|
|
return sorted(triples, key=lambda triple: (triple[1], triple[0], triple[2]))
|
|
|
|
|
|
class EventHub:
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self._subscribers: set[queue.Queue[dict[str, Any]]] = set()
|
|
self._latest: dict[str, Any] | None = None
|
|
|
|
def subscribe(self) -> queue.Queue[dict[str, Any]]:
|
|
events: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=100)
|
|
|
|
with self._lock:
|
|
self._subscribers.add(events)
|
|
if self._latest is not None:
|
|
events.put_nowait(self._latest)
|
|
|
|
return events
|
|
|
|
def unsubscribe(self, events: queue.Queue[dict[str, Any]]) -> None:
|
|
with self._lock:
|
|
self._subscribers.discard(events)
|
|
|
|
def publish(self, event: dict[str, Any]) -> None:
|
|
with self._lock:
|
|
self._latest = event
|
|
subscribers = list(self._subscribers)
|
|
|
|
for events in subscribers:
|
|
try:
|
|
events.put_nowait(event)
|
|
except queue.Full:
|
|
try:
|
|
_ = events.get_nowait()
|
|
except queue.Empty:
|
|
pass
|
|
try:
|
|
events.put_nowait(event)
|
|
except queue.Full:
|
|
pass
|
|
|
|
|
|
class GraphSession:
|
|
def __init__(
|
|
self,
|
|
session_id: str,
|
|
name: str,
|
|
topology: dict[str, Any],
|
|
*,
|
|
editable: bool = True,
|
|
kind: str = "user",
|
|
on_value_update: Callable[[str, str, str], None] | None = None,
|
|
):
|
|
self.id = session_id
|
|
self.name = name
|
|
self.kind = kind
|
|
self.editable = editable
|
|
self.created_at = time.time()
|
|
self.event_hub = EventHub()
|
|
self._lock = threading.Lock()
|
|
self._topology = self._normalize_topology(topology)
|
|
self._values = self._values_from_topology(self._topology, topology.get("values", {}))
|
|
self._frame = 0
|
|
self._on_value_update = on_value_update
|
|
self._triples = derive_triples(self._topology)
|
|
self.steppable = kind != "simpy" and bool(self._triples)
|
|
|
|
self._publish_initial()
|
|
|
|
def summary(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"kind": self.kind,
|
|
"editable": self.editable,
|
|
"steppable": self.steppable,
|
|
"createdAt": self.created_at,
|
|
"frame": self._frame,
|
|
"nodeCount": len(self._topology["nodes"]),
|
|
"edgeCount": len(self._topology["edges"]),
|
|
}
|
|
|
|
def graph(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
topology = json.loads(json.dumps(self._topology))
|
|
values = self._public_values()
|
|
|
|
for node in topology["nodes"]:
|
|
node_values = self._values.get(node["id"], {})
|
|
node["value"] = node_values.get(PIN_ID, "N")
|
|
|
|
topology["values"] = values
|
|
return topology
|
|
|
|
def set_value(self, node_id: str, pin_id: str, value: object, *, time_value: float | None = None) -> dict[str, Any]:
|
|
normalized_value = normalize_pin_value(value)
|
|
|
|
with self._lock:
|
|
if not self.editable:
|
|
raise ValueError("session is not editable")
|
|
|
|
node = self._ensure_node(node_id, pin_id)
|
|
old_value = node.get(pin_id, "N")
|
|
|
|
if old_value == normalized_value:
|
|
return {
|
|
"frame": self._frame,
|
|
"time": time_value,
|
|
"changed": [],
|
|
}
|
|
|
|
node[pin_id] = normalized_value
|
|
self._frame += 1
|
|
event = {
|
|
"frame": self._frame,
|
|
"changed": [
|
|
{
|
|
"node": node_id,
|
|
"pin": pin_id,
|
|
"from": old_value,
|
|
"to": normalized_value,
|
|
}
|
|
],
|
|
}
|
|
|
|
if time_value is not None:
|
|
event["time"] = time_value
|
|
|
|
if self._on_value_update is not None:
|
|
self._on_value_update(node_id, pin_id, normalized_value)
|
|
|
|
self.event_hub.publish(event)
|
|
return event
|
|
|
|
def step(self, *, time_value: float | None = None) -> dict[str, Any]:
|
|
"""Advance the simulation by one tick across all derived triples.
|
|
|
|
Triples are applied in deterministic order, each reading the current
|
|
(possibly already-mutated) values — mirroring the live engine's
|
|
sequential, shared-state update within a tick.
|
|
"""
|
|
with self._lock:
|
|
if not self.steppable:
|
|
raise ValueError("session is not steppable")
|
|
|
|
changed: list[dict[str, str]] = []
|
|
|
|
for nn, uu, yy in self._triples:
|
|
old = "".join(transition_char(self._ensure_node(name, PIN_ID)[PIN_ID]) for name in (nn, uu, yy))
|
|
new = TRANSITIONS.get(old)
|
|
if new is None:
|
|
continue
|
|
|
|
for name, char in zip((nn, uu, yy), new):
|
|
node = self._ensure_node(name, PIN_ID)
|
|
next_value = char_event_value(char)
|
|
old_value = node[PIN_ID]
|
|
if old_value == next_value:
|
|
continue
|
|
|
|
node[PIN_ID] = next_value
|
|
changed.append({"node": name, "pin": PIN_ID, "from": old_value, "to": next_value})
|
|
|
|
self._frame += 1
|
|
event: dict[str, Any] = {"frame": self._frame, "changed": changed}
|
|
if time_value is not None:
|
|
event["time"] = time_value
|
|
|
|
self.event_hub.publish(event)
|
|
return event
|
|
|
|
def sync_external_values(
|
|
self,
|
|
values: dict[str, str],
|
|
*,
|
|
frame: int,
|
|
time_value: float,
|
|
changed: list[dict[str, str]],
|
|
) -> None:
|
|
with self._lock:
|
|
self._frame = frame
|
|
for node, value in values.items():
|
|
self._ensure_node(node, PIN_ID)[PIN_ID] = normalize_pin_value(value)
|
|
|
|
if changed:
|
|
self.event_hub.publish(
|
|
{
|
|
"frame": frame,
|
|
"time": time_value,
|
|
"changed": changed,
|
|
}
|
|
)
|
|
|
|
def _publish_initial(self) -> None:
|
|
changed = []
|
|
|
|
with self._lock:
|
|
for node, pins in sorted(self._values.items()):
|
|
for pin, value in sorted(pins.items()):
|
|
changed.append(
|
|
{
|
|
"node": node,
|
|
"pin": pin,
|
|
"from": value,
|
|
"to": value,
|
|
}
|
|
)
|
|
|
|
self.event_hub.publish(
|
|
{
|
|
"frame": 0,
|
|
"changed": changed,
|
|
}
|
|
)
|
|
|
|
def _ensure_node(self, node_id: str, pin_id: str) -> dict[str, str]:
|
|
if node_id not in self._values:
|
|
self._values[node_id] = {}
|
|
self._topology["nodes"].append(
|
|
{
|
|
"id": node_id,
|
|
"label": node_id,
|
|
"pins": [pin_id],
|
|
"value": "N",
|
|
}
|
|
)
|
|
|
|
if pin_id not in self._values[node_id]:
|
|
self._values[node_id][pin_id] = "N"
|
|
|
|
node_definition = next((node for node in self._topology["nodes"] if node["id"] == node_id), None)
|
|
if node_definition is not None:
|
|
pins = node_definition.setdefault("pins", [])
|
|
if pin_id not in pins:
|
|
pins.append(pin_id)
|
|
|
|
return self._values[node_id]
|
|
|
|
def _public_values(self) -> dict[str, str | dict[str, str]]:
|
|
result: dict[str, str | dict[str, str]] = {}
|
|
|
|
for node, pins in self._values.items():
|
|
if set(pins) == {PIN_ID}:
|
|
result[node] = pins[PIN_ID]
|
|
else:
|
|
result[node] = dict(sorted(pins.items()))
|
|
|
|
return result
|
|
|
|
def _normalize_topology(self, topology: dict[str, Any]) -> dict[str, Any]:
|
|
nodes_by_id: dict[str, dict[str, Any]] = {}
|
|
|
|
for raw_node in topology.get("nodes", []):
|
|
node_id = str(raw_node.get("id", "")).strip()
|
|
if not node_id:
|
|
continue
|
|
|
|
pins = [str(pin) for pin in raw_node.get("pins", [PIN_ID]) if str(pin)]
|
|
nodes_by_id[node_id] = {
|
|
"id": node_id,
|
|
"label": str(raw_node.get("label") or node_id),
|
|
"pins": pins or [PIN_ID],
|
|
"value": normalize_pin_value(raw_node.get("value", "N")),
|
|
}
|
|
|
|
edges: list[dict[str, str]] = []
|
|
for index, raw_edge in enumerate(topology.get("edges", [])):
|
|
source = str(raw_edge.get("source", "")).strip()
|
|
target = str(raw_edge.get("target", "")).strip()
|
|
|
|
if not source or not target:
|
|
continue
|
|
|
|
nodes_by_id.setdefault(source, {"id": source, "label": source, "pins": [PIN_ID], "value": "N"})
|
|
nodes_by_id.setdefault(target, {"id": target, "label": target, "pins": [PIN_ID], "value": "N"})
|
|
|
|
edge_id = str(raw_edge.get("id") or f"{source}->{target}:{index}")
|
|
edges.append(
|
|
{
|
|
"id": edge_id,
|
|
"source": source,
|
|
"target": target,
|
|
"label": str(raw_edge.get("label") or ""),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"nodes": [nodes_by_id[node_id] for node_id in sorted(nodes_by_id)],
|
|
"edges": edges,
|
|
}
|
|
|
|
def _values_from_topology(self, topology: dict[str, Any], raw_values: object) -> dict[str, dict[str, str]]:
|
|
values: dict[str, dict[str, str]] = {}
|
|
|
|
for node in topology["nodes"]:
|
|
node_id = node["id"]
|
|
values[node_id] = {}
|
|
raw_value = raw_values.get(node_id, node.get("value", "N")) if isinstance(raw_values, dict) else node.get("value", "N")
|
|
|
|
if isinstance(raw_value, dict):
|
|
for pin, value in raw_value.items():
|
|
values[node_id][str(pin)] = normalize_pin_value(value)
|
|
|
|
for pin in node.get("pins", [PIN_ID]):
|
|
values[node_id].setdefault(str(pin), normalize_pin_value(raw_value if not isinstance(raw_value, dict) else "N"))
|
|
|
|
return values
|
|
|
|
|
|
class SessionRegistry:
|
|
def __init__(self, simpy_values: dict[str, Tern]):
|
|
self._lock = threading.Lock()
|
|
self._sessions: dict[str, GraphSession] = {}
|
|
self.simpy_values = simpy_values
|
|
self.simpy_session = GraphSession(
|
|
"simpy",
|
|
"SimPy live",
|
|
graph_topology(simpy_values),
|
|
kind="simpy",
|
|
on_value_update=self._update_simpy_value,
|
|
)
|
|
self._sessions[self.simpy_session.id] = self.simpy_session
|
|
|
|
def list_sessions(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
sessions = [session.summary() for session in self._sessions.values()]
|
|
|
|
return {"sessions": sorted(sessions, key=lambda session: (session["kind"] != "simpy", session["createdAt"]))}
|
|
|
|
def get(self, session_id: str) -> GraphSession:
|
|
with self._lock:
|
|
session = self._sessions.get(session_id)
|
|
|
|
if session is None:
|
|
raise KeyError(session_id)
|
|
|
|
return session
|
|
|
|
def create(self, name: str, topology: dict[str, Any]) -> GraphSession:
|
|
base_id = SESSION_ID_RE.sub("-", name.strip().lower()).strip("-") or "graph"
|
|
session_id = base_id
|
|
|
|
with self._lock:
|
|
suffix = 2
|
|
while session_id in self._sessions:
|
|
session_id = f"{base_id}-{suffix}"
|
|
suffix += 1
|
|
|
|
session = GraphSession(session_id, name.strip() or session_id, topology)
|
|
self._sessions[session_id] = session
|
|
|
|
return session
|
|
|
|
def sync_simpy(self, values: dict[str, str], *, frame: int, time_value: float, changed: list[dict[str, str]]) -> None:
|
|
self.simpy_session.sync_external_values(values, frame=frame, time_value=time_value, changed=changed)
|
|
|
|
def _update_simpy_value(self, node_id: str, pin_id: str, value: str) -> None:
|
|
if pin_id != PIN_ID:
|
|
return
|
|
|
|
if node_id in self.simpy_values:
|
|
self.simpy_values[node_id] = event_value_to_tern(value)
|
|
|
|
|
|
class GraphHTTPServer(ThreadingHTTPServer):
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
|
|
def __init__(self, address: tuple[str, int], values: dict[str, Tern], registry: SessionRegistry):
|
|
super().__init__(address, GraphRequestHandler)
|
|
self.values = values
|
|
self.registry = registry
|
|
self.frontend_dir = FRONTEND_DIR.resolve()
|
|
|
|
|
|
class GraphRequestHandler(BaseHTTPRequestHandler):
|
|
server: GraphHTTPServer
|
|
|
|
def do_OPTIONS(self):
|
|
self.send_response(HTTPStatus.NO_CONTENT)
|
|
self._send_cors_headers()
|
|
self.end_headers()
|
|
|
|
def do_GET(self):
|
|
path = urllib.parse.urlparse(self.path).path
|
|
|
|
if path == "/api/graph":
|
|
self._send_json(self.server.registry.get("simpy").graph())
|
|
return
|
|
|
|
if path == "/api/events":
|
|
self._send_events(self.server.registry.get("simpy"))
|
|
return
|
|
|
|
if path == "/api/sessions":
|
|
self._send_json(self.server.registry.list_sessions())
|
|
return
|
|
|
|
session_id, action = self._session_route(path)
|
|
if session_id and action == "graph":
|
|
try:
|
|
self._send_json(self.server.registry.get(session_id).graph())
|
|
except KeyError:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "session not found")
|
|
return
|
|
|
|
if session_id and action == "events":
|
|
try:
|
|
self._send_events(self.server.registry.get(session_id))
|
|
except KeyError:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "session not found")
|
|
return
|
|
|
|
self._send_static(path)
|
|
|
|
def do_POST(self):
|
|
path = urllib.parse.urlparse(self.path).path
|
|
|
|
if path == "/api/sessions":
|
|
try:
|
|
payload = self._read_json()
|
|
except ValueError as error:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, str(error))
|
|
return
|
|
|
|
topology = payload.get("graph")
|
|
|
|
if not isinstance(topology, dict):
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, "expected graph topology")
|
|
return
|
|
|
|
session = self.server.registry.create(str(payload.get("name") or "Graph"), topology)
|
|
self._send_json({"session": session.summary(), "graph": session.graph()}, status=HTTPStatus.CREATED)
|
|
return
|
|
|
|
session_id, action = self._session_route(path)
|
|
if session_id and action == "step":
|
|
try:
|
|
event = self.server.registry.get(session_id).step()
|
|
except KeyError:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "session not found")
|
|
return
|
|
except ValueError as error:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, str(error))
|
|
return
|
|
|
|
self._send_json({"event": event})
|
|
return
|
|
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "unknown endpoint")
|
|
|
|
def do_PATCH(self):
|
|
path = urllib.parse.urlparse(self.path).path
|
|
session_id, action = self._session_route(path)
|
|
|
|
if session_id and action == "values":
|
|
try:
|
|
payload = self._read_json()
|
|
except ValueError as error:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, str(error))
|
|
return
|
|
|
|
try:
|
|
event = self.server.registry.get(session_id).set_value(
|
|
str(payload.get("node") or ""),
|
|
str(payload.get("pin") or PIN_ID),
|
|
payload.get("value", "N"),
|
|
)
|
|
except KeyError:
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "session not found")
|
|
return
|
|
except ValueError as error:
|
|
self._send_error_json(HTTPStatus.BAD_REQUEST, str(error))
|
|
return
|
|
|
|
self._send_json({"event": event})
|
|
return
|
|
|
|
self._send_error_json(HTTPStatus.NOT_FOUND, "unknown endpoint")
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
return
|
|
|
|
def _session_route(self, path: str) -> tuple[str | None, str | None]:
|
|
parts = [urllib.parse.unquote(part) for part in path.strip("/").split("/")]
|
|
|
|
if len(parts) == 4 and parts[:2] == ["api", "sessions"]:
|
|
return parts[2], parts[3]
|
|
|
|
return None, None
|
|
|
|
def _read_json(self) -> dict[str, Any]:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
body = self.rfile.read(length).decode("utf-8") if length else "{}"
|
|
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError as error:
|
|
raise ValueError(f"invalid JSON: {error}") from error
|
|
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("expected JSON object")
|
|
|
|
return payload
|
|
|
|
def _send_json(self, payload: dict[str, Any], *, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
|
|
self.send_response(status)
|
|
self._send_cors_headers()
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_error_json(self, status: HTTPStatus, message: str) -> None:
|
|
self._send_json({"error": message}, status=status)
|
|
|
|
def _send_events(self, session: GraphSession) -> None:
|
|
self.send_response(HTTPStatus.OK)
|
|
self._send_cors_headers()
|
|
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("Connection", "keep-alive")
|
|
self.send_header("X-Accel-Buffering", "no")
|
|
self.end_headers()
|
|
|
|
events = session.event_hub.subscribe()
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
event = events.get(timeout=15)
|
|
self._write_sse("frame", event)
|
|
except queue.Empty:
|
|
self.wfile.write(b": ping\n\n")
|
|
self.wfile.flush()
|
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
pass
|
|
finally:
|
|
session.event_hub.unsubscribe(events)
|
|
|
|
def _write_sse(self, event_name: str, payload: dict[str, Any]) -> None:
|
|
data = json.dumps(payload, ensure_ascii=True)
|
|
self.wfile.write(f"event: {event_name}\n".encode("utf-8"))
|
|
self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
|
|
self.wfile.flush()
|
|
|
|
def _send_static(self, path: str) -> None:
|
|
if not self.server.frontend_dir.exists():
|
|
self._send_missing_frontend()
|
|
return
|
|
|
|
requested = "/index.html" if path == "/" else path
|
|
target = (self.server.frontend_dir / requested.lstrip("/")).resolve()
|
|
|
|
try:
|
|
target.relative_to(self.server.frontend_dir)
|
|
except ValueError:
|
|
self.send_error(HTTPStatus.FORBIDDEN)
|
|
return
|
|
|
|
if target.is_dir():
|
|
target = target / "index.html"
|
|
|
|
if not target.exists():
|
|
target = self.server.frontend_dir / "index.html"
|
|
|
|
if not target.exists():
|
|
self._send_missing_frontend()
|
|
return
|
|
|
|
content = target.read_bytes()
|
|
content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
|
|
|
if target.suffix == ".js":
|
|
content_type = "text/javascript"
|
|
|
|
self.send_response(HTTPStatus.OK)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(content)))
|
|
self.end_headers()
|
|
self.wfile.write(content)
|
|
|
|
def _send_missing_frontend(self) -> None:
|
|
body = (
|
|
"<!doctype html><title>Frontend is not built</title>"
|
|
"<main style='font-family: system-ui; padding: 32px'>"
|
|
"<h1>Frontend is not built</h1>"
|
|
"<p>Run <code>cd frontend && npm install && npm run build</code>.</p>"
|
|
"</main>"
|
|
).encode("utf-8")
|
|
|
|
self.send_response(HTTPStatus.SERVICE_UNAVAILABLE)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _send_cors_headers(self) -> None:
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
|
|
_server_lock = threading.Lock()
|
|
_server: GraphHTTPServer | None = None
|
|
_server_url: str | None = None
|
|
_registry: SessionRegistry | None = None
|
|
|
|
|
|
def ensure_graph_server(values: dict[str, Tern]) -> str:
|
|
global _registry
|
|
global _server
|
|
global _server_url
|
|
|
|
with _server_lock:
|
|
if _server is not None and _server_url is not None:
|
|
return _server_url
|
|
|
|
host = os.environ.get("SIMPY_TERNS_GRAPH_HOST", DEFAULT_HOST)
|
|
port = int(os.environ.get("SIMPY_TERNS_GRAPH_PORT", DEFAULT_PORT))
|
|
_registry = SessionRegistry(values)
|
|
|
|
for candidate_port in range(port, port + 50):
|
|
try:
|
|
server = GraphHTTPServer((host, candidate_port), values, _registry)
|
|
break
|
|
except OSError:
|
|
continue
|
|
else:
|
|
raise RuntimeError(f"could not bind graph server starting at {host}:{port}")
|
|
|
|
_server = server
|
|
_server_url = f"http://{host}:{server.server_port}"
|
|
|
|
threading.Thread(
|
|
target=server.serve_forever,
|
|
name="graph-http-server",
|
|
daemon=True,
|
|
).start()
|
|
|
|
return _server_url
|
|
|
|
|
|
def render_graph_process(
|
|
env: simpy.Environment,
|
|
values: dict[str, Tern],
|
|
):
|
|
url = ensure_graph_server(values)
|
|
print(f"[graph] {url}")
|
|
|
|
last_values: dict[str, str] | None = None
|
|
frame_number = 0
|
|
|
|
while True:
|
|
current_values = snapshot_values(values)
|
|
|
|
if last_values is None:
|
|
changed = [
|
|
{"node": node, "pin": PIN_ID, "from": value, "to": value}
|
|
for node, value in current_values.items()
|
|
]
|
|
else:
|
|
changed = [
|
|
{
|
|
"node": node,
|
|
"pin": PIN_ID,
|
|
"from": last_values.get(node, "N"),
|
|
"to": value,
|
|
}
|
|
for node, value in current_values.items()
|
|
if last_values.get(node) != value
|
|
]
|
|
|
|
if changed:
|
|
frame_number += 1
|
|
if _registry is not None:
|
|
_registry.sync_simpy(
|
|
current_values,
|
|
frame=frame_number,
|
|
time_value=env.now,
|
|
changed=changed,
|
|
)
|
|
|
|
last_values = current_values
|
|
yield env.timeout(0.1)
|