Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions backend/app/api/routes/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from backend.app.models.spool_k_profile import SpoolKProfile
from backend.app.models.user import User
from backend.app.schemas.spool import (
PendingSlotAssignmentCreateRequest,
PendingSlotAssignmentResponse,
SpoolAssignmentCreate,
SpoolAssignmentResponse,
SpoolBulkCreate,
Expand Down Expand Up @@ -1494,6 +1496,70 @@ async def unassign_spool(
return {"status": "deleted"}


# ── Pending Slot Assignments (assign-on-next-slot) ────────────────────────────


@router.post("/spools/assign-on-next-slot", response_model=PendingSlotAssignmentResponse)
async def assign_on_next_slot(
body: PendingSlotAssignmentCreateRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Create a pending spool-to-slot assignment request."""
from backend.app.services.pending_slot_assignment import create_pending_assignment

logger.info(
"Received assign-on-next-slot request: spool_id=%d tray_uuid=%s tag_uid=%s source=%s",
body.spool_id,
body.tray_uuid,
body.tag_uid,
body.source,
)
assignment = await create_pending_assignment(
db=db,
tray_uuid=body.tray_uuid,
tag_uid=body.tag_uid,
spool_id=body.spool_id,
printer_id=body.printer_id,
source=body.source,
timeout_seconds=body.timeout,
)
return PendingSlotAssignmentResponse.from_model(pending_assignment=assignment)


@router.get("/spools/assignments/{assignment_id}", response_model=PendingSlotAssignmentResponse)
async def get_pending_assignment_status(
assignment_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Get the status of a pending slot assignment."""
from backend.app.services.pending_slot_assignment import get_pending_assignment

assignment = await get_pending_assignment(db=db, assignment_id=assignment_id)
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
return PendingSlotAssignmentResponse.from_model(pending_assignment=assignment)


@router.delete("/spools/assignments/{assignment_id}", response_model=PendingSlotAssignmentResponse)
async def cancel_pending_assignment_endpoint(
assignment_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Cancel a pending slot assignment. Only pending assignments can be cancelled."""
from backend.app.services.pending_slot_assignment import cancel_pending_assignment

assignment = await cancel_pending_assignment(db=db, assignment_id=assignment_id)
if not assignment:
raise HTTPException(
status_code=404,
detail="Assignment not found or not in pending status",
)
return PendingSlotAssignmentResponse.from_model(pending_assignment=assignment)


# ── Tag Linking ───────────────────────────────────────────────────────────────


Expand Down
1 change: 1 addition & 0 deletions backend/app/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ async def init_db():
notification_template,
oidc_provider,
orca_base_cache,
pending_slot_assignment,
pending_upload,
print_batch,
print_log,
Expand Down
34 changes: 34 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1668,6 +1668,35 @@ async def on_ams_change(printer_id: int, ams_data: list):
)
continue

# Check pending assign-on-next-slot requests before auto-assign
try:
from backend.app.services.pending_slot_assignment import (
try_complete_pending_assignments,
)

if await try_complete_pending_assignments(
db=db,
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
slot_tray_uuid=tray_uuid or None,
slot_tag_uid=tag_uid or None,
):
logger.info(
"Pending slot assignment completed for printer %d AMS%d-T%d",
printer_id,
ams_id,
tray_id,
)
continue
except Exception:
logger.exception(
"Pending slot assignment check failed for printer %d AMS%d-T%d",
printer_id,
ams_id,
tray_id,
)

if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
# BL spool with RFID tag: auto-match → inventory match → auto-create
spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
Expand Down Expand Up @@ -5272,6 +5301,11 @@ async def lifespan(app: FastAPI):
# Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
await printer_manager.load_awaiting_plate_clear_from_db()

# Restore pending slot assignment timeout tasks that survived a restart
from backend.app.services.pending_slot_assignment import restore_pending_timeouts

await restore_pending_timeouts()

# Layer change callback for external camera timelapse
async def on_layer_change(printer_id: int, layer_num: int):
"""Capture timelapse frame on layer change + first layer notification."""
Expand Down
57 changes: 57 additions & 0 deletions backend/app/models/pending_slot_assignment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Model for pending spool-to-slot assignment requests.

A pending assignment represents a request to assign a spool to the next AMS slot
that transitions from empty → filled. The backend monitors AMS events and completes
the assignment automatically when a matching slot change is detected.
"""

from datetime import datetime, timezone

from sqlalchemy import DateTime, Float, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column

from backend.app.core.database import Base


class PendingSlotAssignment(Base):
"""A pending request to assign a spool to the next available AMS slot."""

__tablename__ = "pending_slot_assignment"

id: Mapped[int] = mapped_column(primary_key=True)
# Spool identifiers — at least one must be non-null
tray_uuid: Mapped[str | None] = mapped_column(String(64), index=True)
tag_uid: Mapped[str | None] = mapped_column(String(32), index=True)
# Resolved spool_id once matched (nullable until resolved)
spool_id: Mapped[int | None] = mapped_column(Integer)
# Target printer (NULL = any printer)
printer_id: Mapped[int | None] = mapped_column(Integer)
# Source of the request
source: Mapped[str] = mapped_column(String(20)) # "nfc" | "qr" | "spoolbuddy"
# Status: pending, completed, timed_out, cancelled
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
# Timeout in seconds
timeout_seconds: Mapped[int] = mapped_column(Integer, default=300)

# Completion details (filled when status transitions to completed)
assigned_printer_id: Mapped[int | None] = mapped_column(Integer)
assigned_ams_id: Mapped[int | None] = mapped_column(Integer)
assigned_tray_id: Mapped[int | None] = mapped_column(Integer)
completed_at: Mapped[datetime | None] = mapped_column(DateTime)

# Metrics
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
time_to_placement: Mapped[float | None] = mapped_column(Float) # seconds

@property
def lookup_key(self) -> str:
"""Return the primary identifier used for spool lookup (tray_uuid preferred)."""
return self.tray_uuid or self.tag_uid or ""

@property
def is_expired(self) -> bool:
"""Check if this pending assignment has timed out."""
if self.status != "pending":
return False
elapsed = (datetime.now(timezone.utc) - self.created_at.replace(tzinfo=timezone.utc)).total_seconds()
return elapsed > self.timeout_seconds
44 changes: 44 additions & 0 deletions backend/app/schemas/pending_slot_assignment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Pydantic schemas for the pending slot assignment API."""

from datetime import datetime

from pydantic import BaseModel, model_validator


class PendingSlotAssignmentCreate(BaseModel):
"""Request body for POST /api/v1/inventory/spools/assign-on-next-slot."""

tray_uuid: str | None = None
tag_uid: str | None = None
printer_id: int | None = None
source: str
timeout: int

@model_validator(mode="after")
def _require_at_least_one_identifier(self) -> "PendingSlotAssignmentCreate":
if not self.tray_uuid and not self.tag_uid:
raise ValueError("At least one of tray_uuid or tag_uid must be provided.")
return self


class PendingSlotAssignmentResponse(BaseModel):
"""Response body for assignment endpoints."""

assignment_id: int
tray_uuid: str | None = None
tag_uid: str | None = None
spool_id: int | None = None
printer_id: int | None = None
source: str
status: str # pending, completed, timed_out, cancelled
timeout_seconds: int
# Completion details
assigned_printer_id: int | None = None
assigned_ams_id: int | None = None
assigned_tray_id: int | None = None
completed_at: datetime | None = None
time_to_placement: float | None = None
created_at: datetime

class Config:
from_attributes = True
54 changes: 54 additions & 0 deletions backend/app/schemas/spool.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def _validate_effect_type(cls, v: str | None) -> str | None:
# assignment). Column has lived on the ORM since the inventory rework
# but was missing from this schema, so writes were silently dropped (#1291).
storage_location: str | None = Field(default=None, max_length=255)
location_id: int | None = Field(default=None, gt=0)


class SpoolCreate(SpoolBase):
Expand Down Expand Up @@ -174,6 +175,7 @@ def _validate_effect_type(cls, v: str | None) -> str | None:
category: str | None = Field(default=None, max_length=50)
low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
storage_location: str | None = Field(default=None, max_length=255)
location_id: int | None = Field(default=None, gt=0)


class SpoolKProfileBase(BaseModel):
Expand Down Expand Up @@ -243,3 +245,55 @@ class SpoolAssignmentResponse(BaseModel):

class Config:
from_attributes = True


class PendingSlotAssignmentCreateRequest(BaseModel):
"""Pending create response."""

spool_id: int | None = None
tray_uuid: str | None = None
tag_uid: str | None = None
printer_id: int | None = None
source: str
timeout: int


class PendingSlotAssignmentResponse(BaseModel):
"""Response body for pending assignment endpoints."""

assignment_id: int
tray_uuid: str | None = None
tag_uid: str | None = None
spool_id: int | None = None
printer_id: int | None = None
source: str
status: str # pending, completed, timed_out, cancelled
timeout_seconds: int
assigned_printer_id: int | None = None
assigned_ams_id: int | None = None
assigned_tray_id: int | None = None
completed_at: str | None = None
time_to_placement: float | None = None
created_at: str

class Config:
from_attributes = True

@staticmethod
def from_model(pending_assignment) -> "PendingSlotAssignmentResponse":
return PendingSlotAssignmentResponse(
assignment_id=pending_assignment.id,
tray_uuid=pending_assignment.tray_uuid,
tag_uid=pending_assignment.tag_uid,
spool_id=pending_assignment.spool_id,
printer_id=pending_assignment.printer_id,
source=pending_assignment.source,
status=pending_assignment.status,
timeout_seconds=pending_assignment.timeout_seconds,
assigned_printer_id=pending_assignment.assigned_printer_id,
assigned_ams_id=pending_assignment.assigned_ams_id,
assigned_tray_id=pending_assignment.assigned_tray_id,
completed_at=pending_assignment.completed_at.isoformat() if pending_assignment.completed_at else None,
time_to_placement=pending_assignment.time_to_placement,
created_at=pending_assignment.created_at.isoformat() if pending_assignment.created_at else "",
)
50 changes: 50 additions & 0 deletions backend/app/services/mqtt_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,56 @@ async def on_smart_plug_energy(
},
)

async def on_slot_assignment_completed(
self,
assignment_id: int,
tray_uuid: str | None,
tag_uid: str | None,
spool_id: int,
printer_id: int,
ams_id: int,
tray_id: int,
time_to_placement: float,
):
"""Publish event when a pending slot assignment is completed."""
if not self.enabled or not self.connected:
return

self._publish(
f"{self.topic_prefix}/inventory/slot_assignment/completed",
{
"assignment_id": assignment_id,
"tray_uuid": tray_uuid,
"tag_uid": tag_uid,
"spool_id": spool_id,
"printer_id": printer_id,
"ams_id": ams_id,
"tray_id": tray_id,
"time_to_placement": time_to_placement,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)

async def on_slot_assignment_timed_out(
self,
assignment_id: int,
tray_uuid: str | None,
tag_uid: str | None,
):
"""Publish event when a pending slot assignment times out."""
if not self.enabled or not self.connected:
return

self._publish(
f"{self.topic_prefix}/inventory/slot_assignment/timed_out",
{
"assignment_id": assignment_id,
"tray_uuid": tray_uuid,
"tag_uid": tag_uid,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)


# Global instance
mqtt_relay = MQTTRelayService()
Loading