Add separate simulator UI

This commit is contained in:
masterdev
2026-06-17 01:06:21 +01:00
parent e083a22bff
commit e3d8f856e3
16 changed files with 2361 additions and 14 deletions

View File

@@ -19,7 +19,7 @@ class Settings(BaseSettings):
mqtt_topic: str = "plasttrack/machines/+/events"
mqtt_client_id: str = "plast-track-backend"
frontend_origin: str | None = None
frontend_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
frontend_origins: str = "http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174"
downtime_check_interval_sec: int = 5
database_startup_retries: int = 30
database_startup_retry_delay_sec: float = 2.0

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from datetime import date, datetime, timezone
from datetime import date, datetime, timedelta, timezone
from fastapi import Depends, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
@@ -13,9 +13,17 @@ from sqlalchemy.orm import Session
from .config import get_settings
from .database import Base, SessionLocal, engine, get_db
from .models import Cycle, Downtime, DowntimeReason, Machine, MachineEvent, ProductionOrder, Scrap, ScrapReason
from .schemas import DowntimeQualify, MachineConfigUpdate, ProductionOrderCreate, ScrapCreate
from .schemas import (
DowntimeQualify,
MachineConfigUpdate,
MachineEventIn,
ProductionOrderCreate,
ScrapCreate,
SimulatorEventCreate,
SimulatorScenarioCreate,
)
from .services.dashboard import build_dashboard_snapshot, serialize_machine_card, serialize_machine_config
from .services.events import detect_missing_cycles, get_open_downtime, get_running_order
from .services.events import detect_missing_cycles, get_open_downtime, get_running_order, handle_machine_event
from .services.mqtt_listener import MqttListener
from .services.oee import compute_daily_oee, compute_machine_oee
from .services.realtime import hub
@@ -259,6 +267,160 @@ def get_machine_events(machine_id: int, limit: int = Query(default=50, le=200),
]
def _get_machine_by_code_or_404(db: Session, machine_code: str) -> Machine:
machine = db.execute(select(Machine).where(Machine.code == machine_code)).scalar_one_or_none()
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
return machine
def _emit_simulator_event(db: Session, payload: SimulatorEventCreate) -> dict:
_get_machine_by_code_or_404(db, payload.machine_id)
incoming = MachineEventIn(
machine_id=payload.machine_id,
event_type=payload.event_type,
timestamp=payload.timestamp or datetime.now(UTC),
cycle_time_sec=payload.cycle_time_sec,
source="simulator-ui",
input_name=payload.input_name,
input_value=payload.input_value,
)
result = handle_machine_event(db, incoming)
if result["should_refresh"]:
hub.notify({"type": "refresh", "source": "simulator-ui", **result})
return result
@app.post("/api/simulator/events")
def create_simulator_event(payload: SimulatorEventCreate, db: Session = Depends(get_db)) -> dict:
return _emit_simulator_event(db, payload)
@app.post("/api/simulator/scenarios")
def run_simulator_scenario(payload: SimulatorScenarioCreate, db: Session = Depends(get_db)) -> dict:
machine = _get_machine_by_code_or_404(db, payload.machine_id)
now = datetime.now(UTC)
cycle_time_sec = float(payload.cycle_time_sec or 20)
events: list[SimulatorEventCreate] = []
if payload.scenario == "power_on":
events.append(SimulatorEventCreate(machine_id=machine.code, event_type="machine_power_on", timestamp=now))
elif payload.scenario == "power_off":
events.append(SimulatorEventCreate(machine_id=machine.code, event_type="machine_power_off", timestamp=now))
elif payload.scenario == "cycle_completed":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="cycle_completed",
timestamp=now,
cycle_time_sec=cycle_time_sec,
)
)
elif payload.scenario == "cycle_burst":
first_event_time = now - timedelta(seconds=cycle_time_sec * max(payload.count - 1, 0))
for index in range(payload.count):
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="cycle_completed",
timestamp=first_event_time + timedelta(seconds=cycle_time_sec * index),
cycle_time_sec=cycle_time_sec,
)
)
elif payload.scenario == "cycle_signal_edge":
current_value = machine.cycle_signal_edge != "falling"
previous_value = not current_value
events.extend(
[
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now - timedelta(milliseconds=max(machine.debounce_ms + 100, 500)),
input_name="cycle_signal",
input_value=previous_value,
),
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="cycle_signal",
input_value=current_value,
cycle_time_sec=cycle_time_sec,
),
]
)
elif payload.scenario == "debounce_noise":
current_value = machine.cycle_signal_edge != "falling"
previous_value = not current_value
events.extend(
[
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now - timedelta(milliseconds=max(machine.debounce_ms - 50, 1)),
input_name="cycle_signal",
input_value=previous_value,
),
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="cycle_signal",
input_value=current_value,
cycle_time_sec=cycle_time_sec,
),
]
)
elif payload.scenario == "machine_stopped":
events.append(SimulatorEventCreate(machine_id=machine.code, event_type="machine_stopped", timestamp=now))
elif payload.scenario == "recover_with_cycle":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="cycle_completed",
timestamp=now,
cycle_time_sec=cycle_time_sec,
)
)
elif payload.scenario == "alarm_on":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="general_alarm",
input_value=True,
)
)
elif payload.scenario == "alarm_off":
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name="general_alarm",
input_value=False,
)
)
elif payload.scenario == "raw_input":
if not payload.input_name:
raise HTTPException(status_code=400, detail="input_name is required for raw_input")
events.append(
SimulatorEventCreate(
machine_id=machine.code,
event_type="digital_input_changed",
timestamp=now,
input_name=payload.input_name,
input_value=payload.input_value,
)
)
else:
raise HTTPException(status_code=400, detail="Unsupported simulator scenario")
results = [_emit_simulator_event(db, event) for event in events]
return {"scenario": payload.scenario, "machine_id": machine.code, "events": results}
@app.get("/api/machines/{machine_id}/cycles")
def get_machine_cycles(machine_id: int, limit: int = Query(default=100, le=500), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(

View File

@@ -57,3 +57,21 @@ class MachineConfigUpdate(BaseModel):
min_cycle_time_sec: int | None = Field(default=None, ge=1)
max_cycle_time_sec: int | None = Field(default=None, ge=1)
stop_detection_delay_sec: int | None = Field(default=None, ge=1)
class SimulatorEventCreate(BaseModel):
machine_id: str
event_type: str
timestamp: datetime | None = None
input_name: str | None = None
input_value: bool | int | str | None = None
cycle_time_sec: float | None = Field(default=None, gt=0)
class SimulatorScenarioCreate(BaseModel):
machine_id: str
scenario: str
cycle_time_sec: float | None = Field(default=None, gt=0)
count: int = Field(default=1, ge=1, le=100)
input_name: str | None = None
input_value: bool | int | str | None = None