mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
All backend timestamps used datetime.now() (server local time) or the deprecated datetime.utcnow(). The frontend's parseUTCDate() assumes timestamps without timezone indicators are UTC and appends 'Z', so stored timestamps were off by the timezone offset when the container's timezone wasn't UTC. Backend: replaced datetime.now() and datetime.utcnow() with datetime.now(timezone.utc) across 16 files (~80 call sites) for all database fields and DB comparisons. Cosmetic timestamps (filenames, user-facing local time formatting) intentionally left as local time. Frontend: replaced 13 new Date(backendTimestamp) calls with parseUTCDate() across 8 files to correctly interpret UTC timestamps.
748 lines
26 KiB
Python
748 lines
26 KiB
Python
"""Maintenance tracking API routes."""
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
|
from backend.app.core.database import get_db
|
|
from backend.app.core.permissions import Permission
|
|
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.models.user import User
|
|
from backend.app.schemas.maintenance import (
|
|
MaintenanceHistoryResponse,
|
|
MaintenanceStatus,
|
|
MaintenanceTypeCreate,
|
|
MaintenanceTypeResponse,
|
|
MaintenanceTypeUpdate,
|
|
PerformMaintenanceRequest,
|
|
PrinterMaintenanceOverview,
|
|
PrinterMaintenanceResponse,
|
|
PrinterMaintenanceUpdate,
|
|
)
|
|
from backend.app.services.notification_service import notification_service
|
|
from backend.app.utils.printer_models import get_rod_type
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/maintenance", tags=["maintenance"])
|
|
|
|
# Default maintenance types
|
|
DEFAULT_MAINTENANCE_TYPES = [
|
|
# Carbon rod models only (X1/P1/P2S)
|
|
{
|
|
"name": "Lubricate Carbon Rods",
|
|
"description": "Apply lubricant to carbon rods for smooth motion",
|
|
"default_interval_hours": 50.0,
|
|
"icon": "Droplet",
|
|
},
|
|
{
|
|
"name": "Clean Carbon Rods",
|
|
"description": "Wipe carbon rods with a dry cloth",
|
|
"default_interval_hours": 100.0,
|
|
"icon": "Sparkles",
|
|
},
|
|
# Linear rail models only (A1/H2)
|
|
{
|
|
"name": "Lubricate Linear Rails",
|
|
"description": "Apply lubricant to linear rails for smooth motion",
|
|
"default_interval_hours": 50.0,
|
|
"icon": "Droplet",
|
|
},
|
|
{
|
|
"name": "Clean Linear Rails",
|
|
"description": "Wipe linear rails with a dry cloth to remove dust and debris",
|
|
"default_interval_hours": 100.0,
|
|
"icon": "Sparkles",
|
|
},
|
|
# Universal (all models)
|
|
{
|
|
"name": "Clean Nozzle/Hotend",
|
|
"description": "Clean nozzle exterior and perform cold pull if needed",
|
|
"default_interval_hours": 100.0,
|
|
"icon": "Flame",
|
|
},
|
|
{
|
|
"name": "Check Belt Tension",
|
|
"description": "Verify and adjust belt tension for X/Y axes",
|
|
"default_interval_hours": 200.0,
|
|
"icon": "Ruler",
|
|
},
|
|
{
|
|
"name": "Clean Build Plate",
|
|
"description": "Deep clean build plate with IPA or soap",
|
|
"default_interval_hours": 25.0,
|
|
"icon": "Square",
|
|
},
|
|
{
|
|
"name": "Check PTFE Tube",
|
|
"description": "Inspect PTFE tube for wear or discoloration",
|
|
"default_interval_hours": 500.0,
|
|
"icon": "Cable",
|
|
},
|
|
]
|
|
|
|
# System types that only apply to printers with a specific rod/rail type.
|
|
# "carbon" = X1/P1/P2S series (carbon rods), "linear_rail" = A1/H2 series.
|
|
# Types not listed here apply to all printers.
|
|
_ROD_TYPE_REQUIREMENTS: dict[str, str] = {
|
|
"Lubricate Carbon Rods": "carbon",
|
|
"Clean Carbon Rods": "carbon",
|
|
"Lubricate Linear Rails": "linear_rail",
|
|
"Clean Linear Rails": "linear_rail",
|
|
}
|
|
|
|
|
|
def _should_apply_to_printer(type_name: str, printer_model: str | None) -> bool:
|
|
"""Check if a system maintenance type should apply to a given printer model."""
|
|
rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name)
|
|
if rod_requirement is None:
|
|
return True # Not model-specific, applies to all
|
|
|
|
rod_type = get_rod_type(printer_model)
|
|
if rod_type is None:
|
|
# Unknown model — default to carbon rods (legacy behavior)
|
|
return rod_requirement == "carbon"
|
|
|
|
return rod_type == rod_requirement
|
|
|
|
|
|
async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
|
|
"""Calculate total active hours for a printer from runtime counter plus offset.
|
|
|
|
Uses the runtime_seconds counter which tracks actual machine active time
|
|
(RUNNING and PAUSE states), including calibration, heating, and printing.
|
|
"""
|
|
# Get printer runtime and offset
|
|
result = await db.execute(
|
|
select(Printer.runtime_seconds, Printer.print_hours_offset).where(Printer.id == printer_id)
|
|
)
|
|
row = result.one_or_none()
|
|
if not row:
|
|
return 0.0
|
|
|
|
runtime_seconds = row[0] or 0
|
|
offset = row[1] or 0.0
|
|
|
|
runtime_hours = runtime_seconds / 3600.0
|
|
return runtime_hours + offset
|
|
|
|
|
|
async def ensure_default_types(db: AsyncSession) -> None:
|
|
"""Ensure default maintenance types exist, remove stale/duplicate ones."""
|
|
result = await db.execute(
|
|
select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).order_by(MaintenanceType.id)
|
|
)
|
|
existing = result.scalars().all()
|
|
|
|
default_names = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
|
|
|
|
# Remove stale system types no longer in defaults (e.g. renamed types)
|
|
# and deduplicate: if concurrent requests created the same type twice,
|
|
# keep only the first (lowest id) and delete the rest.
|
|
seen_names: set[str] = set()
|
|
for t in existing:
|
|
if t.name not in default_names or t.name in seen_names:
|
|
await db.delete(t)
|
|
else:
|
|
seen_names.add(t.name)
|
|
|
|
# Create any missing default types
|
|
for type_def in DEFAULT_MAINTENANCE_TYPES:
|
|
if type_def["name"] not in seen_names:
|
|
new_type = MaintenanceType(
|
|
name=type_def["name"],
|
|
description=type_def["description"],
|
|
default_interval_hours=type_def["default_interval_hours"],
|
|
icon=type_def["icon"],
|
|
is_system=True,
|
|
)
|
|
db.add(new_type)
|
|
|
|
await db.commit()
|
|
|
|
|
|
# ============== Maintenance Types ==============
|
|
|
|
|
|
@router.get("/types", response_model=list[MaintenanceTypeResponse])
|
|
async def get_maintenance_types(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
|
):
|
|
"""Get all maintenance types."""
|
|
await ensure_default_types(db)
|
|
result = await db.execute(
|
|
select(MaintenanceType)
|
|
.where(MaintenanceType.is_deleted.is_(False))
|
|
.order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post("/types", response_model=MaintenanceTypeResponse)
|
|
async def create_maintenance_type(
|
|
data: MaintenanceTypeCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
|
|
):
|
|
"""Create a custom maintenance type."""
|
|
new_type = MaintenanceType(
|
|
name=data.name,
|
|
description=data.description,
|
|
default_interval_hours=data.default_interval_hours,
|
|
interval_type=data.interval_type,
|
|
icon=data.icon,
|
|
is_system=False,
|
|
)
|
|
db.add(new_type)
|
|
await db.commit()
|
|
await db.refresh(new_type)
|
|
return new_type
|
|
|
|
|
|
@router.patch("/types/{type_id}", response_model=MaintenanceTypeResponse)
|
|
async def update_maintenance_type(
|
|
type_id: int,
|
|
data: MaintenanceTypeUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
|
|
):
|
|
"""Update a maintenance type."""
|
|
result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
|
|
maint_type = result.scalar_one_or_none()
|
|
if not maint_type:
|
|
raise HTTPException(status_code=404, detail="Maintenance type not found")
|
|
|
|
update_data = data.model_dump(exclude_unset=True)
|
|
for key, value in update_data.items():
|
|
setattr(maint_type, key, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(maint_type)
|
|
return maint_type
|
|
|
|
|
|
@router.delete("/types/{type_id}")
|
|
async def delete_maintenance_type(
|
|
type_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
|
|
):
|
|
"""Delete a maintenance type."""
|
|
result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
|
|
maint_type = result.scalar_one_or_none()
|
|
if not maint_type:
|
|
raise HTTPException(status_code=404, detail="Maintenance type not found")
|
|
|
|
if maint_type.is_system:
|
|
maint_type.is_deleted = True
|
|
await db.commit()
|
|
return {"status": "deleted"}
|
|
|
|
await db.delete(maint_type)
|
|
await db.commit()
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.post("/types/restore-defaults")
|
|
async def restore_default_maintenance_types(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
|
|
):
|
|
"""Restore deleted default maintenance types."""
|
|
await ensure_default_types(db)
|
|
result = await db.execute(
|
|
select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).where(MaintenanceType.is_deleted.is_(True))
|
|
)
|
|
deleted_types = result.scalars().all()
|
|
for maint_type in deleted_types:
|
|
maint_type.is_deleted = False
|
|
|
|
await db.commit()
|
|
return {"restored": len(deleted_types)}
|
|
|
|
|
|
# ============== Printer Maintenance ==============
|
|
|
|
|
|
async def _get_printer_maintenance_internal(
|
|
printer_id: int,
|
|
db: AsyncSession,
|
|
commit: bool = True,
|
|
) -> PrinterMaintenanceOverview:
|
|
"""Internal helper to get maintenance overview for a specific printer."""
|
|
await ensure_default_types(db)
|
|
|
|
# Get printer
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(status_code=404, detail="Printer not found")
|
|
|
|
total_hours = await get_printer_total_hours(db, printer_id)
|
|
|
|
# Get all maintenance types
|
|
result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_deleted.is_(False)))
|
|
all_types = result.scalars().all()
|
|
|
|
# Get printer's maintenance items
|
|
result = await db.execute(
|
|
select(PrinterMaintenance)
|
|
.where(PrinterMaintenance.printer_id == printer_id)
|
|
.options(selectinload(PrinterMaintenance.maintenance_type))
|
|
)
|
|
existing_items = {item.maintenance_type_id: item for item in result.scalars().all()}
|
|
|
|
maintenance_items = []
|
|
due_count = 0
|
|
warning_count = 0
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
for maint_type in all_types:
|
|
# Skip system types that don't apply to this printer model
|
|
# (e.g., "Clean Carbon Rods" for H2D which has steel rods)
|
|
if maint_type.is_system and not _should_apply_to_printer(maint_type.name, printer.model):
|
|
continue
|
|
|
|
item = existing_items.get(maint_type.id)
|
|
default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
|
|
|
|
if item:
|
|
interval = item.custom_interval_hours or maint_type.default_interval_hours
|
|
# Use custom interval type if set, otherwise use type's default
|
|
interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
|
|
enabled = item.enabled
|
|
last_performed_hours = item.last_performed_hours
|
|
last_performed_at = item.last_performed_at
|
|
item_id = item.id
|
|
else:
|
|
# Only auto-create maintenance items for system types
|
|
# Custom types need to be manually assigned per printer
|
|
if not maint_type.is_system:
|
|
continue
|
|
|
|
# Create default entry for this printer/type
|
|
item = PrinterMaintenance(
|
|
printer_id=printer_id,
|
|
maintenance_type_id=maint_type.id,
|
|
enabled=True,
|
|
last_performed_hours=0.0,
|
|
)
|
|
db.add(item)
|
|
await db.flush()
|
|
|
|
interval = maint_type.default_interval_hours
|
|
interval_type = default_interval_type
|
|
enabled = True
|
|
last_performed_hours = 0.0
|
|
last_performed_at = None
|
|
item_id = item.id
|
|
|
|
# Calculate status based on interval type
|
|
if interval_type == "days":
|
|
# Time-based: calculate days since last performed
|
|
if last_performed_at:
|
|
days_since = (now - last_performed_at).total_seconds() / 86400.0
|
|
else:
|
|
# Never performed - consider it due
|
|
days_since = interval + 1
|
|
|
|
days_until = interval - days_since
|
|
is_due = days_until <= 0
|
|
is_warning = days_until <= (interval * 0.1) and not is_due
|
|
|
|
# For compatibility, also set hours values (but they won't be primary)
|
|
hours_since = total_hours - last_performed_hours
|
|
hours_until = 0 # Not applicable for time-based
|
|
else:
|
|
# Print-hours based (default)
|
|
hours_since = total_hours - last_performed_hours
|
|
hours_until = interval - hours_since
|
|
is_due = hours_until <= 0
|
|
is_warning = hours_until <= (interval * 0.1) and not is_due
|
|
|
|
# Calculate days for reference
|
|
if last_performed_at:
|
|
days_since = (now - last_performed_at).total_seconds() / 86400.0
|
|
else:
|
|
days_since = None
|
|
days_until = None
|
|
|
|
if enabled:
|
|
if is_due:
|
|
due_count += 1
|
|
elif is_warning:
|
|
warning_count += 1
|
|
|
|
maintenance_items.append(
|
|
MaintenanceStatus(
|
|
id=item_id,
|
|
printer_id=printer_id,
|
|
printer_name=printer.name,
|
|
printer_model=printer.model,
|
|
maintenance_type_id=maint_type.id,
|
|
maintenance_type_name=maint_type.name,
|
|
maintenance_type_icon=maint_type.icon,
|
|
maintenance_type_wiki_url=getattr(maint_type, "wiki_url", None),
|
|
enabled=enabled,
|
|
interval_hours=interval,
|
|
interval_type=interval_type,
|
|
current_hours=total_hours,
|
|
hours_since_maintenance=hours_since,
|
|
hours_until_due=hours_until,
|
|
days_since_maintenance=days_since if interval_type == "days" else None,
|
|
days_until_due=days_until if interval_type == "days" else None,
|
|
is_due=is_due,
|
|
is_warning=is_warning,
|
|
last_performed_at=last_performed_at,
|
|
)
|
|
)
|
|
|
|
if commit:
|
|
await db.commit()
|
|
|
|
return PrinterMaintenanceOverview(
|
|
printer_id=printer_id,
|
|
printer_name=printer.name,
|
|
printer_model=printer.model,
|
|
total_print_hours=total_hours,
|
|
maintenance_items=maintenance_items,
|
|
due_count=due_count,
|
|
warning_count=warning_count,
|
|
)
|
|
|
|
|
|
@router.get("/printers/{printer_id}", response_model=PrinterMaintenanceOverview)
|
|
async def get_printer_maintenance(
|
|
printer_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
|
):
|
|
"""Get maintenance overview for a specific printer."""
|
|
return await _get_printer_maintenance_internal(printer_id, db, commit=True)
|
|
|
|
|
|
@router.get("/overview", response_model=list[PrinterMaintenanceOverview])
|
|
async def get_all_maintenance_overview(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
|
):
|
|
"""Get maintenance overview for all active printers."""
|
|
await ensure_default_types(db)
|
|
|
|
result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
|
|
printers = result.scalars().all()
|
|
|
|
overviews = []
|
|
for printer in printers:
|
|
# Don't commit after each printer, commit once at the end
|
|
overview = await _get_printer_maintenance_internal(printer.id, db, commit=False)
|
|
overviews.append(overview)
|
|
|
|
# Commit any new maintenance items created
|
|
await db.commit()
|
|
|
|
return overviews
|
|
|
|
|
|
@router.patch("/items/{item_id}", response_model=PrinterMaintenanceResponse)
|
|
async def update_printer_maintenance(
|
|
item_id: int,
|
|
data: PrinterMaintenanceUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
|
|
):
|
|
"""Update a printer maintenance item (e.g., custom interval, enabled)."""
|
|
result = await db.execute(
|
|
select(PrinterMaintenance)
|
|
.where(PrinterMaintenance.id == item_id)
|
|
.options(selectinload(PrinterMaintenance.maintenance_type))
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Maintenance item not found")
|
|
|
|
update_data = data.model_dump(exclude_unset=True)
|
|
for key, value in update_data.items():
|
|
setattr(item, key, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(item)
|
|
return item
|
|
|
|
|
|
@router.post("/printers/{printer_id}/assign/{type_id}", response_model=PrinterMaintenanceResponse)
|
|
async def assign_maintenance_type(
|
|
printer_id: int,
|
|
type_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
|
|
):
|
|
"""Assign a maintenance type to a specific printer (for custom types)."""
|
|
# Verify printer exists
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(status_code=404, detail="Printer not found")
|
|
|
|
# Verify maintenance type exists
|
|
result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
|
|
maint_type = result.scalar_one_or_none()
|
|
if not maint_type:
|
|
raise HTTPException(status_code=404, detail="Maintenance type not found")
|
|
|
|
# Check if already assigned
|
|
result = await db.execute(
|
|
select(PrinterMaintenance).where(
|
|
PrinterMaintenance.printer_id == printer_id,
|
|
PrinterMaintenance.maintenance_type_id == type_id,
|
|
)
|
|
)
|
|
existing = result.scalar_one_or_none()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Maintenance type already assigned to this printer")
|
|
|
|
# Create the assignment
|
|
item = PrinterMaintenance(
|
|
printer_id=printer_id,
|
|
maintenance_type_id=type_id,
|
|
enabled=True,
|
|
last_performed_hours=0.0,
|
|
)
|
|
db.add(item)
|
|
await db.commit()
|
|
|
|
# Re-fetch with relationship loaded for response serialization
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
result = await db.execute(
|
|
select(PrinterMaintenance)
|
|
.options(selectinload(PrinterMaintenance.maintenance_type))
|
|
.where(PrinterMaintenance.id == item.id)
|
|
)
|
|
item = result.scalar_one()
|
|
|
|
return item
|
|
|
|
|
|
@router.delete("/items/{item_id}")
|
|
async def remove_maintenance_item(
|
|
item_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
|
|
):
|
|
"""Remove a maintenance item (unassign a custom type from a printer)."""
|
|
result = await db.execute(
|
|
select(PrinterMaintenance)
|
|
.where(PrinterMaintenance.id == item_id)
|
|
.options(selectinload(PrinterMaintenance.maintenance_type))
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Maintenance item not found")
|
|
|
|
# Only allow removing custom (non-system) types
|
|
if item.maintenance_type.is_system:
|
|
raise HTTPException(status_code=400, detail="Cannot remove system maintenance types")
|
|
|
|
await db.delete(item)
|
|
await db.commit()
|
|
|
|
return {"status": "removed"}
|
|
|
|
|
|
@router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
|
|
async def perform_maintenance(
|
|
item_id: int,
|
|
data: PerformMaintenanceRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
|
|
):
|
|
"""Mark maintenance as performed (reset the counter)."""
|
|
result = await db.execute(
|
|
select(PrinterMaintenance)
|
|
.where(PrinterMaintenance.id == item_id)
|
|
.options(selectinload(PrinterMaintenance.maintenance_type))
|
|
)
|
|
item = result.scalar_one_or_none()
|
|
if not item:
|
|
raise HTTPException(status_code=404, detail="Maintenance item not found")
|
|
|
|
# Get printer for name
|
|
result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
|
|
printer = result.scalar_one()
|
|
|
|
# Get current hours
|
|
current_hours = await get_printer_total_hours(db, item.printer_id)
|
|
|
|
# Create history entry
|
|
history = MaintenanceHistory(
|
|
printer_maintenance_id=item.id,
|
|
hours_at_maintenance=current_hours,
|
|
notes=data.notes,
|
|
)
|
|
db.add(history)
|
|
|
|
# Update item
|
|
item.last_performed_at = datetime.now(timezone.utc)
|
|
item.last_performed_hours = current_hours
|
|
|
|
await db.commit()
|
|
|
|
# MQTT relay - publish maintenance reset
|
|
try:
|
|
from backend.app.services.mqtt_relay import mqtt_relay
|
|
|
|
await mqtt_relay.on_maintenance_reset(
|
|
printer_id=item.printer_id,
|
|
printer_name=printer.name,
|
|
maintenance_type=item.maintenance_type.name,
|
|
)
|
|
except Exception:
|
|
pass # Don't fail if MQTT fails
|
|
|
|
# Calculate status
|
|
interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
|
|
interval_type = getattr(item.maintenance_type, "interval_type", "hours") or "hours"
|
|
hours_since = current_hours - item.last_performed_hours
|
|
hours_until = interval - hours_since
|
|
|
|
return MaintenanceStatus(
|
|
id=item.id,
|
|
printer_id=item.printer_id,
|
|
printer_name=printer.name,
|
|
printer_model=printer.model,
|
|
maintenance_type_id=item.maintenance_type_id,
|
|
maintenance_type_name=item.maintenance_type.name,
|
|
maintenance_type_icon=item.maintenance_type.icon,
|
|
maintenance_type_wiki_url=getattr(item.maintenance_type, "wiki_url", None),
|
|
enabled=item.enabled,
|
|
interval_hours=interval,
|
|
interval_type=interval_type,
|
|
current_hours=current_hours,
|
|
hours_since_maintenance=hours_since,
|
|
hours_until_due=hours_until if interval_type == "hours" else 0,
|
|
days_since_maintenance=0 if interval_type == "days" else None,
|
|
days_until_due=interval if interval_type == "days" else None,
|
|
is_due=False,
|
|
is_warning=False,
|
|
last_performed_at=item.last_performed_at,
|
|
)
|
|
|
|
|
|
@router.get("/items/{item_id}/history", response_model=list[MaintenanceHistoryResponse])
|
|
async def get_maintenance_history(
|
|
item_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
|
):
|
|
"""Get maintenance history for a specific item."""
|
|
result = await db.execute(
|
|
select(MaintenanceHistory)
|
|
.where(MaintenanceHistory.printer_maintenance_id == item_id)
|
|
.order_by(MaintenanceHistory.performed_at.desc())
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/summary")
|
|
async def get_maintenance_summary(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
|
|
):
|
|
"""Get a summary of maintenance status across all printers."""
|
|
await ensure_default_types(db)
|
|
|
|
result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
|
|
printers = result.scalars().all()
|
|
|
|
total_due = 0
|
|
total_warning = 0
|
|
printers_with_issues = []
|
|
|
|
for printer in printers:
|
|
overview = await get_printer_maintenance(printer.id, db)
|
|
total_due += overview.due_count
|
|
total_warning += overview.warning_count
|
|
if overview.due_count > 0 or overview.warning_count > 0:
|
|
printers_with_issues.append(
|
|
{
|
|
"printer_id": printer.id,
|
|
"printer_name": printer.name,
|
|
"due_count": overview.due_count,
|
|
"warning_count": overview.warning_count,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"total_due": total_due,
|
|
"total_warning": total_warning,
|
|
"printers_with_issues": printers_with_issues,
|
|
}
|
|
|
|
|
|
@router.patch("/printers/{printer_id}/hours")
|
|
async def set_printer_hours(
|
|
printer_id: int,
|
|
total_hours: float,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
|
|
):
|
|
"""Set the total print hours for a printer (adjusts offset to match).
|
|
|
|
The offset is calculated as: offset = total_hours - runtime_hours
|
|
Where runtime_hours comes from the runtime_seconds counter that tracks
|
|
actual machine active time (RUNNING/PAUSE states).
|
|
"""
|
|
# Get printer
|
|
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
|
printer = result.scalar_one_or_none()
|
|
if not printer:
|
|
raise HTTPException(status_code=404, detail="Printer not found")
|
|
|
|
# Get current runtime hours
|
|
runtime_hours = (printer.runtime_seconds or 0) / 3600.0
|
|
|
|
# Calculate needed offset
|
|
printer.print_hours_offset = max(0, total_hours - runtime_hours)
|
|
|
|
await db.commit()
|
|
|
|
# Check for maintenance items that need attention and send notification
|
|
try:
|
|
await ensure_default_types(db)
|
|
overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)
|
|
|
|
items_needing_attention = [
|
|
{
|
|
"name": item.maintenance_type_name,
|
|
"is_due": item.is_due,
|
|
"is_warning": item.is_warning,
|
|
}
|
|
for item in overview.maintenance_items
|
|
if item.enabled and (item.is_due or item.is_warning)
|
|
]
|
|
|
|
if items_needing_attention:
|
|
await notification_service.on_maintenance_due(printer_id, printer.name, items_needing_attention, db)
|
|
logger.info(
|
|
f"Sent maintenance notification for printer {printer_id}: "
|
|
f"{len(items_needing_attention)} items need attention"
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to send maintenance notification: %s", e)
|
|
|
|
return {
|
|
"printer_id": printer_id,
|
|
"total_hours": total_hours,
|
|
"runtime_hours": runtime_hours,
|
|
"offset_hours": printer.print_hours_offset,
|
|
}
|