Initial Plast Track MVP

This commit is contained in:
masterdev
2026-06-15 13:35:22 +01:00
commit 3bf4c622d8
47 changed files with 7339 additions and 0 deletions

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Plast Track MVP backend package."""

21
backend/app/config.py Normal file
View File

@@ -0,0 +1,21 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "Plast Track MVP"
database_url: str = "postgresql+psycopg://plasttrack:plasttrack@database:5432/plasttrack"
mqtt_host: str = "mqtt"
mqtt_port: int = 1883
mqtt_topic: str = "plasttrack/machines/+/events"
mqtt_client_id: str = "plast-track-backend"
frontend_origin: str = "http://localhost:5173"
downtime_check_interval_sec: int = 5
@lru_cache
def get_settings() -> Settings:
return Settings()

23
backend/app/database.py Normal file
View File

@@ -0,0 +1,23 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
session = SessionLocal()
try:
yield session
finally:
session.close()

466
backend/app/main.py Normal file
View File

@@ -0,0 +1,466 @@
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from datetime import date, datetime, timezone
from fastapi import Depends, FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import func, select
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 .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.mqtt_listener import MqttListener
from .services.oee import compute_daily_oee, compute_machine_oee
from .services.realtime import hub
settings = get_settings()
mqtt_listener = MqttListener(hub.notify)
UTC = timezone.utc
def seed_defaults(session: Session) -> None:
if session.execute(select(func.count(Machine.id))).scalar_one() == 0:
session.add_all(
[
Machine(code="INJ-01", name="INJ-01", brand="Haitian", model="Mars II"),
Machine(code="INJ-02", name="INJ-02", brand="Engel", model="e-victory"),
Machine(code="INJ-03", name="INJ-03", brand="Arburg", model="Allrounder"),
]
)
if session.execute(select(func.count(DowntimeReason.code))).scalar_one() == 0:
session.add_all(
[
DowntimeReason(code="changement_moule", label="Changement moule"),
DowntimeReason(code="reglage", label="Reglage"),
DowntimeReason(code="attente_matiere", label="Attente matiere"),
DowntimeReason(code="attente_operateur", label="Attente operateur"),
DowntimeReason(code="panne_machine", label="Panne machine"),
DowntimeReason(code="panne_moule", label="Panne moule"),
DowntimeReason(code="attente_qualite", label="Attente qualite"),
DowntimeReason(code="nettoyage", label="Nettoyage"),
DowntimeReason(code="pause", label="Pause"),
DowntimeReason(code="micro_arret", label="Micro arret"),
DowntimeReason(code="autre", label="Autre"),
]
)
if session.execute(select(func.count(ScrapReason.code))).scalar_one() == 0:
session.add_all(
[
ScrapReason(code="bavure", label="Bavure"),
ScrapReason(code="manque_matiere", label="Manque matiere"),
ScrapReason(code="brulure", label="Brulure"),
ScrapReason(code="retassure", label="Retassure"),
ScrapReason(code="deformation", label="Deformation"),
ScrapReason(code="point_noir", label="Point noir"),
ScrapReason(code="humidite", label="Humidite"),
ScrapReason(code="couleur_non_conforme", label="Couleur non conforme"),
ScrapReason(code="collage_moule", label="Collage moule"),
ScrapReason(code="casse_piece", label="Casse piece"),
ScrapReason(code="defaut_dimensionnel", label="Defaut dimensionnel"),
ScrapReason(code="autre", label="Autre"),
]
)
if session.execute(select(func.count(ProductionOrder.id))).scalar_one() == 0:
machine_1 = session.execute(select(Machine).where(Machine.code == "INJ-01")).scalar_one()
machine_2 = session.execute(select(Machine).where(Machine.code == "INJ-02")).scalar_one()
now = datetime.now(UTC)
session.add_all(
[
ProductionOrder(
order_number="OF-1001",
machine_id=machine_1.id,
article_ref="ART-001",
article_name="Boitier 1L",
mold_ref="M-001",
material_ref="PP-NEUTRAL",
planned_qty=4000,
cavities=2,
theoretical_cycle_time_sec=18.5,
status="running",
started_at=now,
),
ProductionOrder(
order_number="OF-1002",
machine_id=machine_2.id,
article_ref="ART-002",
article_name="Capot Technique",
mold_ref="M-002",
material_ref="ABS-BLACK",
planned_qty=2500,
cavities=1,
theoretical_cycle_time_sec=24.0,
status="running",
started_at=now,
),
]
)
session.commit()
async def downtime_monitor() -> None:
while True:
with SessionLocal() as session:
changes = detect_missing_cycles(session)
for change in changes:
hub.notify({"type": "refresh", "source": "downtime-monitor", **change})
await asyncio.sleep(settings.downtime_check_interval_sec)
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
with SessionLocal() as session:
seed_defaults(session)
hub.bind_loop(asyncio.get_running_loop())
mqtt_listener.start()
app.state.downtime_task = asyncio.create_task(downtime_monitor())
try:
yield
finally:
mqtt_listener.stop()
app.state.downtime_task.cancel()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin, "http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def healthcheck() -> dict:
return {"status": "ok"}
@app.get("/api/dashboard")
def get_dashboard(db: Session = Depends(get_db)) -> dict:
return build_dashboard_snapshot(db)
@app.get("/api/reference-data")
def get_reference_data(db: Session = Depends(get_db)) -> dict:
return {
"downtime_reasons": [
{"code": row.code, "label": row.label}
for row in db.execute(select(DowntimeReason).order_by(DowntimeReason.label)).scalars().all()
],
"scrap_reasons": [
{"code": row.code, "label": row.label}
for row in db.execute(select(ScrapReason).order_by(ScrapReason.label)).scalars().all()
],
}
@app.get("/api/machines")
def list_machines(db: Session = Depends(get_db)) -> list[dict]:
machines = db.execute(select(Machine).order_by(Machine.code)).scalars().all()
return [serialize_machine_card(db, machine) for machine in machines]
@app.get("/api/machines/{machine_id}")
def get_machine(machine_id: int, db: Session = Depends(get_db)) -> dict:
machine = db.get(Machine, machine_id)
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
return serialize_machine_card(db, machine)
@app.get("/api/machines/{machine_id}/config")
def get_machine_config(machine_id: int, db: Session = Depends(get_db)) -> dict:
machine = db.get(Machine, machine_id)
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
return {"machine_id": machine.id, "code": machine.code, **serialize_machine_config(machine)}
@app.patch("/api/machines/{machine_id}/config")
def update_machine_config(machine_id: int, payload: MachineConfigUpdate, db: Session = Depends(get_db)) -> dict:
machine = db.get(Machine, machine_id)
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
updates = payload.model_dump(exclude_unset=True)
if "cycle_signal_edge" in updates and updates["cycle_signal_edge"] not in {"rising", "falling"}:
raise HTTPException(status_code=400, detail="cycle_signal_edge must be rising or falling")
min_cycle = updates.get("min_cycle_time_sec", machine.min_cycle_time_sec)
max_cycle = updates.get("max_cycle_time_sec", machine.max_cycle_time_sec)
if min_cycle > max_cycle:
raise HTTPException(status_code=400, detail="min_cycle_time_sec must be less than or equal to max_cycle_time_sec")
for field, value in updates.items():
setattr(machine, field, value)
db.commit()
db.refresh(machine)
hub.notify({"type": "refresh", "source": "api", "machine_id": machine.id, "event_type": "machine_config_updated"})
return {"machine_id": machine.id, "code": machine.code, **serialize_machine_config(machine)}
@app.get("/api/machines/{machine_id}/status")
def get_machine_status(machine_id: int, db: Session = Depends(get_db)) -> dict:
machine = db.get(Machine, machine_id)
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
running_order = get_running_order(db, machine.id)
open_downtime = get_open_downtime(db, machine.id)
return {
"machine_id": machine.id,
"code": machine.code,
"status": machine.status,
"powered_on": machine.powered_on,
"last_cycle_at": machine.last_cycle_at.isoformat() if machine.last_cycle_at else None,
"active_order_id": running_order.id if running_order else None,
"open_downtime_id": open_downtime.id if open_downtime else None,
}
@app.get("/api/machines/{machine_id}/events")
def get_machine_events(machine_id: int, limit: int = Query(default=50, le=200), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(
select(MachineEvent).where(MachineEvent.machine_id == machine_id).order_by(MachineEvent.timestamp.desc()).limit(limit)
).scalars()
return [
{
"id": row.id,
"event_type": row.event_type,
"timestamp": row.timestamp.isoformat(),
"payload": row.payload,
}
for row in rows
]
@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(
select(Cycle).where(Cycle.machine_id == machine_id).order_by(Cycle.timestamp.desc()).limit(limit)
).scalars()
return [
{
"id": row.id,
"timestamp": row.timestamp.isoformat(),
"cycle_time_sec": row.cycle_time_sec,
"produced_qty": row.produced_qty,
"is_valid": row.is_valid,
}
for row in rows
]
@app.get("/api/machines/{machine_id}/downtimes")
def get_machine_downtimes(machine_id: int, limit: int = Query(default=100, le=500), db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(
select(Downtime).where(Downtime.machine_id == machine_id).order_by(Downtime.start_time.desc()).limit(limit)
).scalars()
return [
{
"id": row.id,
"start_time": row.start_time.isoformat(),
"end_time": row.end_time.isoformat() if row.end_time else None,
"duration_sec": row.duration_sec,
"reason_code": row.reason_code,
"comment": row.comment,
"auto_detected": row.auto_detected,
"qualified_by": row.qualified_by,
}
for row in rows
]
@app.get("/api/production-orders")
def list_production_orders(db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(select(ProductionOrder).order_by(ProductionOrder.created_at.desc())).scalars()
return [
{
"id": row.id,
"order_number": row.order_number,
"machine_id": row.machine_id,
"article_ref": row.article_ref,
"article_name": row.article_name,
"mold_ref": row.mold_ref,
"material_ref": row.material_ref,
"planned_qty": row.planned_qty,
"cavities": row.cavities,
"theoretical_cycle_time_sec": row.theoretical_cycle_time_sec,
"status": row.status,
"started_at": row.started_at.isoformat() if row.started_at else None,
"ended_at": row.ended_at.isoformat() if row.ended_at else None,
}
for row in rows
]
@app.post("/api/production-orders")
def create_production_order(payload: ProductionOrderCreate, db: Session = Depends(get_db)) -> dict:
order = ProductionOrder(**payload.model_dump(), status="planned")
db.add(order)
db.commit()
db.refresh(order)
hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_created"})
return {"id": order.id, "status": order.status}
def _find_order_or_404(db: Session, order_id: int) -> ProductionOrder:
order = db.get(ProductionOrder, order_id)
if order is None:
raise HTTPException(status_code=404, detail="Production order not found")
return order
@app.post("/api/production-orders/{order_id}/start")
def start_production_order(order_id: int, db: Session = Depends(get_db)) -> dict:
order = _find_order_or_404(db, order_id)
running_order = get_running_order(db, order.machine_id)
if running_order and running_order.id != order.id:
raise HTTPException(status_code=400, detail="Another order is already running on this machine")
order.status = "running"
order.started_at = order.started_at or datetime.now(UTC)
machine = db.get(Machine, order.machine_id)
if machine:
machine.status = "production"
machine.status_since = datetime.now(UTC)
db.commit()
hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_started"})
return {"id": order.id, "status": order.status}
@app.post("/api/production-orders/{order_id}/pause")
def pause_production_order(order_id: int, db: Session = Depends(get_db)) -> dict:
order = _find_order_or_404(db, order_id)
order.status = "paused"
machine = db.get(Machine, order.machine_id)
if machine:
machine.status = "hors_planning"
machine.status_since = datetime.now(UTC)
db.commit()
hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_paused"})
return {"id": order.id, "status": order.status}
@app.post("/api/production-orders/{order_id}/close")
def close_production_order(order_id: int, db: Session = Depends(get_db)) -> dict:
order = _find_order_or_404(db, order_id)
order.status = "closed"
order.ended_at = datetime.now(UTC)
machine = db.get(Machine, order.machine_id)
if machine:
machine.status = "hors_planning"
machine.status_since = datetime.now(UTC)
open_downtime = get_open_downtime(db, order.machine_id)
if open_downtime is not None:
open_downtime.end_time = datetime.now(UTC)
open_downtime.duration_sec = max(int((open_downtime.end_time - open_downtime.start_time).total_seconds()), 0)
db.commit()
hub.notify({"type": "refresh", "source": "api", "machine_id": order.machine_id, "event_type": "production_order_closed"})
return {"id": order.id, "status": order.status}
@app.get("/api/downtimes/open")
def get_open_downtimes(db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(select(Downtime).where(Downtime.end_time.is_(None)).order_by(Downtime.start_time.asc())).scalars()
return [
{
"id": row.id,
"machine_id": row.machine_id,
"production_order_id": row.production_order_id,
"start_time": row.start_time.isoformat(),
"reason_code": row.reason_code,
"comment": row.comment,
"auto_detected": row.auto_detected,
"qualified_by": row.qualified_by,
}
for row in rows
]
@app.post("/api/downtimes/{downtime_id}/qualify")
def qualify_downtime(downtime_id: int, payload: DowntimeQualify, db: Session = Depends(get_db)) -> dict:
downtime = db.get(Downtime, downtime_id)
if downtime is None:
raise HTTPException(status_code=404, detail="Downtime not found")
downtime.reason_code = payload.reason_code
downtime.comment = payload.comment
downtime.qualified_by = payload.qualified_by
machine = db.get(Machine, downtime.machine_id)
if machine and downtime.end_time is None:
machine.status = "reglage" if payload.reason_code in {"reglage", "changement_moule"} else "arret_qualifie"
machine.status_since = datetime.now(UTC)
db.commit()
hub.notify({"type": "refresh", "source": "api", "machine_id": downtime.machine_id, "event_type": "downtime_qualified"})
return {"id": downtime.id, "reason_code": downtime.reason_code}
@app.post("/api/scraps")
def create_scrap(payload: ScrapCreate, db: Session = Depends(get_db)) -> dict:
machine = db.execute(select(Machine).where(Machine.code == payload.machine_id)).scalar_one_or_none()
if machine is None:
raise HTTPException(status_code=404, detail="Machine not found")
scrap = Scrap(
machine_id=machine.id,
production_order_id=payload.production_order_id,
quantity=payload.quantity,
reason_code=payload.reason_code,
comment=payload.comment,
operator_name=payload.operator_name,
timestamp=payload.timestamp or datetime.now(UTC),
)
db.add(scrap)
db.commit()
hub.notify({"type": "refresh", "source": "api", "machine_id": machine.id, "event_type": "scrap_created"})
return {"id": scrap.id}
@app.get("/api/scraps")
def list_scraps(db: Session = Depends(get_db)) -> list[dict]:
rows = db.execute(select(Scrap).order_by(Scrap.timestamp.desc()).limit(200)).scalars()
return [
{
"id": row.id,
"machine_id": row.machine_id,
"production_order_id": row.production_order_id,
"quantity": row.quantity,
"reason_code": row.reason_code,
"comment": row.comment,
"operator_name": row.operator_name,
"timestamp": row.timestamp.isoformat(),
}
for row in rows
]
@app.get("/api/oee/daily")
def get_daily_oee(date_value: date = Query(alias="date"), db: Session = Depends(get_db)) -> dict:
machine_ids = db.execute(select(Machine.id).order_by(Machine.code)).scalars().all()
return compute_daily_oee(db, date_value, list(machine_ids))
@app.get("/api/oee/machines/{machine_id}")
def get_machine_oee(machine_id: int, from_value: datetime = Query(alias="from"), to_value: datetime = Query(alias="to"), db: Session = Depends(get_db)) -> dict:
if db.get(Machine, machine_id) is None:
raise HTTPException(status_code=404, detail="Machine not found")
return compute_machine_oee(db, machine_id, from_value, to_value)
@app.websocket("/ws/dashboard")
async def dashboard_websocket(websocket: WebSocket):
await hub.connect(websocket)
try:
with SessionLocal() as session:
await websocket.send_json({"type": "snapshot", "data": build_dashboard_snapshot(session)})
while True:
await websocket.receive_text()
except WebSocketDisconnect:
hub.disconnect(websocket)

126
backend/app/models.py Normal file
View File

@@ -0,0 +1,126 @@
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
class Machine(Base):
__tablename__ = "machines"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name: Mapped[str] = mapped_column(String(120))
brand: Mapped[str | None] = mapped_column(String(120))
model: Mapped[str | None] = mapped_column(String(120))
status: Mapped[str] = mapped_column(String(50), default="hors_planning")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
powered_on: Mapped[bool] = mapped_column(Boolean, default=True)
cycle_signal_edge: Mapped[str] = mapped_column(String(20), default="rising")
debounce_ms: Mapped[int] = mapped_column(Integer, default=300)
min_cycle_time_sec: Mapped[int] = mapped_column(Integer, default=5)
max_cycle_time_sec: Mapped[int] = mapped_column(Integer, default=300)
stop_detection_delay_sec: Mapped[int] = mapped_column(Integer, default=180)
last_cycle_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ProductionOrder(Base):
__tablename__ = "production_orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
order_number: Mapped[str] = mapped_column(String(80), unique=True, index=True)
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
article_ref: Mapped[str] = mapped_column(String(80))
article_name: Mapped[str] = mapped_column(String(160))
mold_ref: Mapped[str | None] = mapped_column(String(80))
material_ref: Mapped[str | None] = mapped_column(String(80))
planned_qty: Mapped[int] = mapped_column(Integer)
cavities: Mapped[int] = mapped_column(Integer, default=1)
theoretical_cycle_time_sec: Mapped[float] = mapped_column(Float)
status: Mapped[str] = mapped_column(String(40), default="planned")
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class MachineEvent(Base):
__tablename__ = "machine_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
event_type: Mapped[str] = mapped_column(String(80), index=True)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
payload: Mapped[dict] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Cycle(Base):
__tablename__ = "cycles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
production_order_id: Mapped[int | None] = mapped_column(ForeignKey("production_orders.id"), index=True)
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
cycle_time_sec: Mapped[float] = mapped_column(Float)
cavities: Mapped[int] = mapped_column(Integer, default=1)
produced_qty: Mapped[int] = mapped_column(Integer, default=1)
is_valid: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Downtime(Base):
__tablename__ = "downtimes"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
production_order_id: Mapped[int | None] = mapped_column(ForeignKey("production_orders.id"), index=True)
start_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
end_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
duration_sec: Mapped[int | None] = mapped_column(Integer)
reason_code: Mapped[str | None] = mapped_column(String(80), ForeignKey("downtime_reasons.code"))
comment: Mapped[str | None] = mapped_column(Text)
auto_detected: Mapped[bool] = mapped_column(Boolean, default=True)
qualified_by: Mapped[str | None] = mapped_column(String(120))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class Scrap(Base):
__tablename__ = "scraps"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
machine_id: Mapped[int] = mapped_column(ForeignKey("machines.id"), index=True)
production_order_id: Mapped[int | None] = mapped_column(ForeignKey("production_orders.id"), index=True)
quantity: Mapped[int] = mapped_column(Integer)
reason_code: Mapped[str] = mapped_column(String(80), ForeignKey("scrap_reasons.code"))
comment: Mapped[str | None] = mapped_column(Text)
operator_name: Mapped[str] = mapped_column(String(120))
timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DowntimeReason(Base):
__tablename__ = "downtime_reasons"
code: Mapped[str] = mapped_column(String(80), primary_key=True)
label: Mapped[str] = mapped_column(String(160))
class ScrapReason(Base):
__tablename__ = "scrap_reasons"
code: Mapped[str] = mapped_column(String(80), primary_key=True)
label: Mapped[str] = mapped_column(String(160))
class Operator(Base):
__tablename__ = "operators"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

59
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,59 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
class MachineEventIn(BaseModel):
model_config = ConfigDict(extra="allow")
machine_id: str
event_type: str
timestamp: datetime
cycle_time_sec: float | None = None
source: str | None = "digital_input"
input_name: str | None = None
input_value: bool | int | str | None = None
class ProductionOrderCreate(BaseModel):
order_number: str
machine_id: int
article_ref: str
article_name: str
mold_ref: str | None = None
material_ref: str | None = None
planned_qty: int = Field(gt=0)
cavities: int = Field(default=1, gt=0)
theoretical_cycle_time_sec: float = Field(gt=0)
class DowntimeQualify(BaseModel):
reason_code: str
comment: str | None = None
qualified_by: str
class ScrapCreate(BaseModel):
machine_id: str
production_order_id: int | None = None
quantity: int = Field(gt=0)
reason_code: str
comment: str | None = None
operator_name: str
timestamp: datetime | None = None
class DailyOeeQuery(BaseModel):
date: date
class MachineConfigUpdate(BaseModel):
name: str | None = None
brand: str | None = None
model: str | None = None
is_active: bool | None = None
cycle_signal_edge: str | None = None
debounce_ms: int | None = Field(default=None, ge=0)
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)

View File

@@ -0,0 +1 @@
"""Backend service modules."""

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import datetime, time, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..models import Cycle, Downtime, Machine, ProductionOrder, Scrap
from .events import get_open_downtime, get_running_order
from .oee import compute_machine_oee
UTC = timezone.utc
def _today_bounds() -> tuple[datetime, datetime]:
start = datetime.combine(datetime.now(UTC).date(), time.min, tzinfo=UTC)
return start, start + timedelta(days=1)
def serialize_machine_config(machine: Machine) -> dict:
return {
"name": machine.name,
"brand": machine.brand,
"model": machine.model,
"is_active": machine.is_active,
"cycle_signal_edge": machine.cycle_signal_edge,
"debounce_ms": machine.debounce_ms,
"min_cycle_time_sec": machine.min_cycle_time_sec,
"max_cycle_time_sec": machine.max_cycle_time_sec,
"stop_detection_delay_sec": machine.stop_detection_delay_sec,
}
def serialize_machine_card(session: Session, machine: Machine) -> dict:
running_order = get_running_order(session, machine.id)
open_downtime = get_open_downtime(session, machine.id)
today_start, today_end = _today_bounds()
recent_cycles = session.execute(
select(Cycle.cycle_time_sec).where(Cycle.machine_id == machine.id).order_by(Cycle.timestamp.desc()).limit(20)
).scalars().all()
avg_cycle = sum(recent_cycles) / len(recent_cycles) if recent_cycles else 0
order_produced_qty = 0
order_scrap_qty = 0
progress = 0
if running_order is not None:
order_produced_qty = session.execute(
select(func.coalesce(func.sum(Cycle.produced_qty), 0)).where(
Cycle.production_order_id == running_order.id,
Cycle.is_valid.is_(True),
)
).scalar_one()
order_scrap_qty = session.execute(
select(func.coalesce(func.sum(Scrap.quantity), 0)).where(Scrap.production_order_id == running_order.id)
).scalar_one()
progress = min(order_produced_qty / running_order.planned_qty, 1) if running_order.planned_qty else 0
daily_oee = compute_machine_oee(session, machine.id, today_start, today_end)
latest_downtime = session.execute(
select(Downtime).where(Downtime.machine_id == machine.id).order_by(Downtime.start_time.desc()).limit(1)
).scalar_one_or_none()
return {
"id": machine.id,
"code": machine.code,
"name": machine.name,
"brand": machine.brand,
"model": machine.model,
"status": machine.status,
"status_since": machine.status_since.isoformat() if machine.status_since else None,
"powered_on": machine.powered_on,
"config": serialize_machine_config(machine),
"active_order": None
if running_order is None
else {
"id": running_order.id,
"order_number": running_order.order_number,
"article_ref": running_order.article_ref,
"article_name": running_order.article_name,
"mold_ref": running_order.mold_ref,
"material_ref": running_order.material_ref,
"planned_qty": running_order.planned_qty,
"cavities": running_order.cavities,
"theoretical_cycle_time_sec": running_order.theoretical_cycle_time_sec,
"status": running_order.status,
},
"cycle_real_avg_sec": round(avg_cycle, 2),
"produced_qty": int(order_produced_qty),
"scrap_qty": int(order_scrap_qty),
"order_progress": round(progress, 4),
"today_oee": daily_oee,
"open_downtime": None
if open_downtime is None
else {
"id": open_downtime.id,
"reason_code": open_downtime.reason_code,
"start_time": open_downtime.start_time.isoformat(),
"comment": open_downtime.comment,
},
"last_downtime": None
if latest_downtime is None
else {
"id": latest_downtime.id,
"reason_code": latest_downtime.reason_code,
"start_time": latest_downtime.start_time.isoformat(),
"end_time": latest_downtime.end_time.isoformat() if latest_downtime.end_time else None,
"duration_sec": latest_downtime.duration_sec,
},
}
def build_dashboard_snapshot(session: Session) -> dict:
machines = session.execute(select(Machine).where(Machine.is_active.is_(True)).order_by(Machine.code)).scalars().all()
return {
"timestamp": datetime.now(UTC).isoformat(),
"machines": [serialize_machine_card(session, machine) for machine in machines],
}

View File

@@ -0,0 +1,295 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import Cycle, Downtime, Machine, MachineEvent, ProductionOrder
from ..schemas import MachineEventIn
REGALAGE_REASON_CODES = {"reglage", "changement_moule"}
SIGNAL_EVENT_TYPE = "digital_input_changed"
POWER_INPUT_NAME = "machine_power_on"
CYCLE_INPUT_NAME = "cycle_signal"
UTC = timezone.utc
def _ensure_utc(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def parse_machine_event(payload: str | bytes) -> MachineEventIn:
raw = payload.decode("utf-8") if isinstance(payload, bytes) else payload
data = json.loads(raw)
return MachineEventIn.model_validate(data)
def normalize_signal_value(value: bool | int | str | None) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, int):
return value != 0
normalized = str(value).strip().lower()
if normalized in {"1", "true", "on", "high"}:
return True
if normalized in {"0", "false", "off", "low"}:
return False
return None
def _get_or_create_machine(session: Session, machine_code: str) -> Machine:
machine = session.execute(select(Machine).where(Machine.code == machine_code)).scalar_one_or_none()
if machine is None:
machine = Machine(code=machine_code, name=machine_code, status="hors_planning")
session.add(machine)
session.flush()
return machine
def get_running_order(session: Session, machine_id: int) -> ProductionOrder | None:
return session.execute(
select(ProductionOrder).where(
ProductionOrder.machine_id == machine_id,
ProductionOrder.status == "running",
).order_by(ProductionOrder.started_at.desc())
).scalar_one_or_none()
def get_open_downtime(session: Session, machine_id: int) -> Downtime | None:
return session.execute(
select(Downtime).where(
Downtime.machine_id == machine_id,
Downtime.end_time.is_(None),
).order_by(Downtime.start_time.desc())
).scalar_one_or_none()
def get_last_input_event(session: Session, machine_id: int, input_name: str) -> MachineEvent | None:
rows = session.execute(
select(MachineEvent).where(
MachineEvent.machine_id == machine_id,
MachineEvent.event_type == SIGNAL_EVENT_TYPE,
).order_by(MachineEvent.timestamp.desc()).limit(50)
).scalars()
for row in rows:
if row.payload.get("input_name") == input_name:
return row
return None
def derive_machine_status(machine: Machine, open_downtime: Downtime | None, running_order: ProductionOrder | None) -> str:
if not machine.powered_on or running_order is None:
return "hors_planning"
if open_downtime is None:
return "production"
if open_downtime.reason_code in REGALAGE_REASON_CODES:
return "reglage"
if open_downtime.reason_code:
return "arret_qualifie"
return "arret_non_qualifie"
def open_downtime_if_needed(session: Session, machine: Machine, event_time: datetime, auto_detected: bool = True) -> Downtime | None:
running_order = get_running_order(session, machine.id)
if running_order is None or not machine.powered_on:
machine.status = "hors_planning"
machine.status_since = event_time
return None
open_downtime = get_open_downtime(session, machine.id)
if open_downtime is not None:
return open_downtime
downtime = Downtime(
machine_id=machine.id,
production_order_id=running_order.id,
start_time=event_time,
auto_detected=auto_detected,
)
session.add(downtime)
machine.status = "arret_non_qualifie"
machine.status_since = event_time
session.flush()
return downtime
def close_open_downtime(session: Session, machine: Machine, event_time: datetime) -> Downtime | None:
downtime = get_open_downtime(session, machine.id)
if downtime is None:
return None
downtime.end_time = event_time
downtime.duration_sec = max(int((event_time - downtime.start_time).total_seconds()), 0)
machine.status_since = event_time
session.flush()
return downtime
def _is_matching_edge(previous_value: bool | None, current_value: bool | None, configured_edge: str) -> bool:
if previous_value is None or current_value is None:
return False
if configured_edge == "falling":
return previous_value is True and current_value is False
return previous_value is False and current_value is True
def _persist_cycle(
session: Session,
machine: Machine,
running_order: ProductionOrder | None,
event_time: datetime,
cycle_time_sec: float,
) -> None:
cavities = running_order.cavities if running_order else 1
is_valid = machine.min_cycle_time_sec <= cycle_time_sec <= machine.max_cycle_time_sec
session.add(
Cycle(
machine_id=machine.id,
production_order_id=running_order.id if running_order else None,
timestamp=event_time,
cycle_time_sec=cycle_time_sec,
cavities=cavities,
produced_qty=cavities if is_valid else 0,
is_valid=is_valid,
)
)
if cycle_time_sec >= machine.min_cycle_time_sec:
machine.last_cycle_at = event_time
machine.status_since = event_time
def _handle_cycle_signal_event(
session: Session,
machine: Machine,
running_order: ProductionOrder | None,
incoming_event: MachineEventIn,
event_time: datetime,
previous_signal_event: MachineEvent | None,
) -> None:
input_value = normalize_signal_value(incoming_event.input_value)
previous_value = None
previous_signal_time = None
if previous_signal_event is not None:
previous_value = normalize_signal_value(previous_signal_event.payload.get("input_value"))
previous_signal_time = _ensure_utc(previous_signal_event.timestamp)
if input_value is None or not _is_matching_edge(previous_value, input_value, machine.cycle_signal_edge):
return
if previous_signal_time is not None:
elapsed_ms = (event_time - previous_signal_time).total_seconds() * 1000
if elapsed_ms < machine.debounce_ms:
return
close_open_downtime(session, machine, event_time)
if machine.last_cycle_at is None:
fallback_cycle_time = incoming_event.cycle_time_sec
if fallback_cycle_time and fallback_cycle_time >= machine.min_cycle_time_sec:
_persist_cycle(session, machine, running_order, event_time, float(fallback_cycle_time))
else:
machine.last_cycle_at = event_time
machine.status_since = event_time
return
cycle_time_sec = (event_time - _ensure_utc(machine.last_cycle_at)).total_seconds()
_persist_cycle(session, machine, running_order, event_time, cycle_time_sec)
def handle_machine_event(session: Session, incoming_event: MachineEventIn) -> dict:
event_time = _ensure_utc(incoming_event.timestamp)
machine = _get_or_create_machine(session, incoming_event.machine_id)
previous_signal_event = None
should_refresh = False
if incoming_event.event_type == SIGNAL_EVENT_TYPE and incoming_event.input_name:
previous_signal_event = get_last_input_event(session, machine.id, incoming_event.input_name)
session.add(
MachineEvent(
machine_id=machine.id,
event_type=incoming_event.event_type,
timestamp=event_time,
payload=incoming_event.model_dump(mode="json"),
)
)
running_order = get_running_order(session, machine.id)
if incoming_event.event_type == "machine_power_on":
machine.powered_on = True
should_refresh = True
elif incoming_event.event_type == "machine_power_off":
machine.powered_on = False
close_open_downtime(session, machine, event_time)
should_refresh = True
elif incoming_event.event_type == "machine_stopped":
open_downtime_if_needed(session, machine, event_time, auto_detected=True)
should_refresh = True
elif incoming_event.event_type == "cycle_completed":
close_open_downtime(session, machine, event_time)
cycle_time_sec = float(incoming_event.cycle_time_sec or 0)
_persist_cycle(session, machine, running_order, event_time, cycle_time_sec)
should_refresh = True
elif incoming_event.event_type == SIGNAL_EVENT_TYPE:
input_name = incoming_event.input_name
input_value = normalize_signal_value(incoming_event.input_value)
if input_name == POWER_INPUT_NAME and input_value is not None:
machine.powered_on = input_value
if not input_value:
close_open_downtime(session, machine, event_time)
should_refresh = True
elif input_name == CYCLE_INPUT_NAME:
previous_cycle_at = machine.last_cycle_at
_handle_cycle_signal_event(session, machine, running_order, incoming_event, event_time, previous_signal_event)
should_refresh = machine.last_cycle_at != previous_cycle_at
elif input_name == "general_alarm" and input_value:
should_refresh = True
machine.status = derive_machine_status(machine, get_open_downtime(session, machine.id), running_order)
session.commit()
return {
"machine_code": machine.code,
"machine_id": machine.id,
"event_type": incoming_event.event_type,
"timestamp": event_time.isoformat(),
"status": machine.status,
"should_refresh": should_refresh,
}
def detect_missing_cycles(session: Session, now: datetime | None = None) -> list[dict]:
current_time = _ensure_utc(now) if now else datetime.now(UTC)
machines = session.execute(select(Machine).where(Machine.is_active.is_(True))).scalars().all()
changes: list[dict] = []
for machine in machines:
running_order = get_running_order(session, machine.id)
if running_order is None or not machine.powered_on or machine.last_cycle_at is None:
continue
delay_sec = max(machine.stop_detection_delay_sec, 1)
last_cycle_at = _ensure_utc(machine.last_cycle_at)
elapsed = (current_time - last_cycle_at).total_seconds()
if elapsed < delay_sec:
continue
downtime = get_open_downtime(session, machine.id)
if downtime is not None:
continue
downtime_start = last_cycle_at + timedelta(seconds=delay_sec)
open_downtime_if_needed(session, machine, downtime_start, auto_detected=True)
machine.status = "arret_non_qualifie"
machine.status_since = current_time
changes.append({"machine_code": machine.code, "event_type": "machine_stopped", "status": machine.status})
if changes:
session.commit()
else:
session.rollback()
return changes

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import logging
import threading
import paho.mqtt.client as mqtt
from ..config import get_settings
from ..database import SessionLocal
from .events import handle_machine_event, parse_machine_event
logger = logging.getLogger(__name__)
class MqttListener:
def __init__(self, notify_callback) -> None:
self.settings = get_settings()
self.notify_callback = notify_callback
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=self.settings.mqtt_client_id)
self.client.on_connect = self.on_connect
self.client.on_message = self.on_message
self.thread: threading.Thread | None = None
def start(self) -> None:
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def stop(self) -> None:
try:
self.client.disconnect()
except Exception:
logger.exception("MQTT disconnect failed")
def _run(self) -> None:
self.client.connect(self.settings.mqtt_host, self.settings.mqtt_port, 60)
self.client.loop_forever()
def on_connect(self, client, userdata, flags, reason_code, properties) -> None:
logger.info("Connected to MQTT with code %s", reason_code)
client.subscribe(self.settings.mqtt_topic)
def on_message(self, client, userdata, message) -> None:
try:
incoming_event = parse_machine_event(message.payload)
with SessionLocal() as session:
result = handle_machine_event(session, incoming_event)
if result.pop("should_refresh", False):
self.notify_callback({"type": "refresh", "source": "mqtt", **result})
except Exception:
logger.exception("Failed to handle MQTT message on topic %s", message.topic)

202
backend/app/services/oee.py Normal file
View File

@@ -0,0 +1,202 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..models import Cycle, Downtime, ProductionOrder, Scrap
UTC = timezone.utc
@dataclass
class OeeTotals:
planned_time_sec: float = 0
downtime_sec: float = 0
operating_time_sec: float = 0
total_cycles: int = 0
theoretical_cycle_time_sec: float = 0
performance_time_sec: float = 0
total_produced_qty: int = 0
good_qty: int = 0
scrap_qty: int = 0
availability: float = 0
performance: float = 0
quality: float = 0
oee: float = 0
def as_dict(self) -> dict[str, float | int]:
return {
"planned_time_sec": round(self.planned_time_sec, 2),
"downtime_sec": round(self.downtime_sec, 2),
"operating_time_sec": round(self.operating_time_sec, 2),
"total_cycles": self.total_cycles,
"theoretical_cycle_time_sec": round(self.theoretical_cycle_time_sec, 2),
"total_produced_qty": self.total_produced_qty,
"good_qty": self.good_qty,
"scrap_qty": self.scrap_qty,
"availability": round(self.availability, 4),
"performance": round(self.performance, 4),
"quality": round(self.quality, 4),
"oee": round(self.oee, 4),
}
def calculate_oee_metrics(
*,
planned_time_sec: float,
downtime_sec: float,
total_cycles: int,
performance_time_sec: float,
theoretical_cycle_time_sec: float,
total_produced_qty: int,
scrap_qty: int,
) -> OeeTotals:
operating_time_sec = max(planned_time_sec - downtime_sec, 0)
good_qty = max(total_produced_qty - scrap_qty, 0)
availability = operating_time_sec / planned_time_sec if planned_time_sec > 0 else 0
performance = performance_time_sec / operating_time_sec if operating_time_sec > 0 else 0
performance = min(max(performance, 0), 1)
quality = good_qty / total_produced_qty if total_produced_qty > 0 else 0
oee = availability * performance * quality
return OeeTotals(
planned_time_sec=planned_time_sec,
downtime_sec=downtime_sec,
operating_time_sec=operating_time_sec,
total_cycles=total_cycles,
theoretical_cycle_time_sec=theoretical_cycle_time_sec,
performance_time_sec=performance_time_sec,
total_produced_qty=total_produced_qty,
good_qty=good_qty,
scrap_qty=scrap_qty,
availability=availability,
performance=performance,
quality=quality,
oee=oee,
)
def overlap_seconds(window_start: datetime, window_end: datetime, start: datetime, end: datetime | None) -> float:
actual_end = end or window_end
overlap_start = max(window_start, start)
overlap_end = min(window_end, actual_end)
if overlap_end <= overlap_start:
return 0
return (overlap_end - overlap_start).total_seconds()
def compute_machine_oee(session: Session, machine_id: int, window_start: datetime, window_end: datetime) -> dict:
current_time = datetime.now(UTC)
effective_window_end = min(window_end, current_time)
if effective_window_end <= window_start:
effective_window_end = window_end
order_rows = session.execute(
select(ProductionOrder).where(
ProductionOrder.machine_id == machine_id,
ProductionOrder.started_at.is_not(None),
ProductionOrder.started_at < effective_window_end,
func.coalesce(ProductionOrder.ended_at, effective_window_end) > window_start,
)
).scalars()
planned_time_sec = 0.0
for order in order_rows:
effective_order_end = min(order.ended_at, effective_window_end) if order.ended_at else effective_window_end
planned_time_sec += overlap_seconds(window_start, effective_window_end, order.started_at, effective_order_end)
cycle_rows = session.execute(
select(
Cycle.production_order_id,
func.count(Cycle.id),
func.coalesce(func.sum(Cycle.produced_qty), 0),
).where(
Cycle.machine_id == machine_id,
Cycle.timestamp >= window_start,
Cycle.timestamp <= effective_window_end,
Cycle.is_valid.is_(True),
).group_by(Cycle.production_order_id)
).all()
total_cycles = sum(int(row[1]) for row in cycle_rows)
total_produced_qty = sum(int(row[2]) for row in cycle_rows)
performance_time_sec = 0.0
weighted_theoretical = 0.0
counted_cycles = 0
for production_order_id, cycle_count, _ in cycle_rows:
if production_order_id is None:
continue
order = session.get(ProductionOrder, production_order_id)
if order is None:
continue
performance_time_sec += order.theoretical_cycle_time_sec * int(cycle_count)
weighted_theoretical += order.theoretical_cycle_time_sec * int(cycle_count)
counted_cycles += int(cycle_count)
theoretical_cycle_time_sec = weighted_theoretical / counted_cycles if counted_cycles > 0 else 0
downtime_rows = session.execute(
select(Downtime).where(
Downtime.machine_id == machine_id,
Downtime.start_time < effective_window_end,
func.coalesce(Downtime.end_time, effective_window_end) > window_start,
)
).scalars()
downtime_sec = sum(
overlap_seconds(window_start, effective_window_end, row.start_time, min(row.end_time, effective_window_end) if row.end_time else effective_window_end)
for row in downtime_rows
)
scrap_qty = session.execute(
select(func.coalesce(func.sum(Scrap.quantity), 0)).where(
Scrap.machine_id == machine_id,
Scrap.timestamp >= window_start,
Scrap.timestamp <= effective_window_end,
)
).scalar_one()
if planned_time_sec == 0 and (total_cycles > 0 or downtime_sec > 0):
planned_time_sec = (effective_window_end - window_start).total_seconds()
totals = calculate_oee_metrics(
planned_time_sec=planned_time_sec,
downtime_sec=downtime_sec,
total_cycles=total_cycles,
performance_time_sec=performance_time_sec,
theoretical_cycle_time_sec=theoretical_cycle_time_sec,
total_produced_qty=total_produced_qty,
scrap_qty=int(scrap_qty),
)
return {
"machine_id": machine_id,
"from": window_start.isoformat(),
"to": effective_window_end.isoformat(),
**totals.as_dict(),
}
def compute_daily_oee(session: Session, target_date: date, machine_ids: list[int]) -> dict:
window_start = datetime.combine(target_date, time.min, tzinfo=UTC)
window_end = window_start + timedelta(days=1)
machine_metrics = [compute_machine_oee(session, machine_id, window_start, window_end) for machine_id in machine_ids]
overall = calculate_oee_metrics(
planned_time_sec=sum(item["planned_time_sec"] for item in machine_metrics),
downtime_sec=sum(item["downtime_sec"] for item in machine_metrics),
total_cycles=sum(item["total_cycles"] for item in machine_metrics),
performance_time_sec=sum(item["theoretical_cycle_time_sec"] * item["total_cycles"] for item in machine_metrics),
theoretical_cycle_time_sec=0,
total_produced_qty=sum(item["total_produced_qty"] for item in machine_metrics),
scrap_qty=sum(item["scrap_qty"] for item in machine_metrics),
)
return {
"date": target_date.isoformat(),
"machines": machine_metrics,
"overall": overall.as_dict(),
}

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from fastapi import WebSocket
class RealtimeHub:
def __init__(self) -> None:
self._connections: set[WebSocket] = set()
self._loop: asyncio.AbstractEventLoop | None = None
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
async def connect(self, websocket: WebSocket) -> None:
await websocket.accept()
self._connections.add(websocket)
def disconnect(self, websocket: WebSocket) -> None:
self._connections.discard(websocket)
async def broadcast(self, payload: dict) -> None:
stale: list[WebSocket] = []
for websocket in self._connections:
try:
await websocket.send_json(payload)
except Exception:
stale.append(websocket)
for websocket in stale:
self._connections.discard(websocket)
def notify(self, payload: dict) -> None:
if self._loop is None:
return
asyncio.run_coroutine_threadsafe(self.broadcast(payload), self._loop)
hub = RealtimeHub()