diff --git a/backend/app/api/routes/maintenance.py b/backend/app/api/routes/maintenance.py index 0f9725dc0..10a9dc9e8 100644 --- a/backend/app/api/routes/maintenance.py +++ b/backend/app/api/routes/maintenance.py @@ -4,12 +4,11 @@ import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from backend.app.core.database import get_db -from backend.app.models.archive import PrintArchive from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance from backend.app.models.printer import Printer from backend.app.schemas.maintenance import ( @@ -71,23 +70,24 @@ DEFAULT_MAINTENANCE_TYPES = [ async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float: - """Calculate total print hours for a printer from archives plus offset. + """Calculate total active hours for a printer from runtime counter plus offset. - Includes ALL prints (completed, failed, cancelled) since the printer - components are being used regardless of print outcome. + Uses the runtime_seconds counter which tracks actual machine active time + (RUNNING and PAUSE states), including calibration, heating, and printing. """ - # Get archive hours (all prints, not just completed) + # Get printer runtime and offset result = await db.execute( - select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.printer_id == printer_id) + select(Printer.runtime_seconds, Printer.print_hours_offset).where(Printer.id == printer_id) ) - total_seconds = result.scalar() or 0 - archive_hours = total_seconds / 3600.0 + row = result.one_or_none() + if not row: + return 0.0 - # Get printer offset - result = await db.execute(select(Printer.print_hours_offset).where(Printer.id == printer_id)) - offset = result.scalar() or 0.0 + runtime_seconds = row[0] or 0 + offset = row[1] or 0.0 - return archive_hours + offset + runtime_hours = runtime_seconds / 3600.0 + return runtime_hours + offset async def ensure_default_types(db: AsyncSession) -> None: @@ -564,23 +564,23 @@ async def set_printer_hours( total_hours: float, db: AsyncSession = Depends(get_db), ): - """Set the total print hours for a printer (adjusts offset to match).""" + """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 archive hours (all prints, not just completed) - # Must match get_printer_total_hours() which includes all prints - result = await db.execute( - select(func.sum(PrintArchive.print_time_seconds)).where(PrintArchive.printer_id == printer_id) - ) - total_seconds = result.scalar() or 0 - archive_hours = total_seconds / 3600.0 + # Get current runtime hours + runtime_hours = (printer.runtime_seconds or 0) / 3600.0 # Calculate needed offset - printer.print_hours_offset = max(0, total_hours - archive_hours) + printer.print_hours_offset = max(0, total_hours - runtime_hours) await db.commit() @@ -611,6 +611,6 @@ async def set_printer_hours( return { "printer_id": printer_id, "total_hours": total_hours, - "archive_hours": archive_hours, + "runtime_hours": runtime_hours, "offset_hours": printer.print_hours_offset, } diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 1f12918c7..5f9798c3c 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -365,6 +365,16 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add runtime tracking columns to printers + try: + await conn.execute(text("ALTER TABLE printers ADD COLUMN runtime_seconds INTEGER DEFAULT 0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE printers ADD COLUMN last_runtime_update DATETIME")) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/backend/app/main.py b/backend/app/main.py index 249bcb77b..e7c68d428 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1440,6 +1440,83 @@ def stop_ams_history_recording(): logging.getLogger(__name__).info("AMS history recording stopped") +# Printer runtime tracking +_runtime_tracking_task: asyncio.Task | None = None +RUNTIME_TRACKING_INTERVAL = 30 # Update every 30 seconds + + +async def track_printer_runtime(): + """Background task to track printer active runtime (RUNNING/PAUSE states).""" + import logging + + logger = logging.getLogger(__name__) + + # Wait for MQTT connections to establish on startup + await asyncio.sleep(15) + + while True: + try: + from backend.app.models.printer import Printer + + async with async_session() as db: + # Get all active printers + result = await db.execute(select(Printer).where(Printer.is_active.is_(True))) + printers = result.scalars().all() + + now = datetime.now() + updated_count = 0 + + for printer in printers: + # Get current state from printer manager + state = printer_manager.get_status(printer.id) + if not state or not state.connected: + continue + + # Check if printer is in an active state (RUNNING or PAUSE) + if state.state in ("RUNNING", "PAUSE"): + # Calculate time since last update + if printer.last_runtime_update: + elapsed = (now - printer.last_runtime_update).total_seconds() + # Sanity check: don't add more than 2x the interval (handles server restarts) + if elapsed > 0 and elapsed < RUNTIME_TRACKING_INTERVAL * 2: + printer.runtime_seconds += int(elapsed) + updated_count += 1 + + printer.last_runtime_update = now + else: + # Printer is idle/offline - clear last_runtime_update + printer.last_runtime_update = None + + if updated_count > 0: + await db.commit() + logger.debug(f"Updated runtime for {updated_count} printer(s)") + + except asyncio.CancelledError: + logger.info("Runtime tracking cancelled") + break + except Exception as e: + logger.warning(f"Runtime tracking failed: {e}") + + await asyncio.sleep(RUNTIME_TRACKING_INTERVAL) + + +def start_runtime_tracking(): + """Start the printer runtime tracking background task.""" + global _runtime_tracking_task + if _runtime_tracking_task is None: + _runtime_tracking_task = asyncio.create_task(track_printer_runtime()) + logging.getLogger(__name__).info("Printer runtime tracking started") + + +def stop_runtime_tracking(): + """Stop the printer runtime tracking background task.""" + global _runtime_tracking_task + if _runtime_tracking_task: + _runtime_tracking_task.cancel() + _runtime_tracking_task = None + logging.getLogger(__name__).info("Printer runtime tracking stopped") + + @asynccontextmanager async def lifespan(app: FastAPI): # Startup @@ -1489,6 +1566,9 @@ async def lifespan(app: FastAPI): # Start AMS history recording start_ams_history_recording() + # Start printer runtime tracking + start_runtime_tracking() + # Start anonymous telemetry (opt-out via settings) asyncio.create_task(start_telemetry_loop(async_session)) @@ -1524,6 +1604,7 @@ async def lifespan(app: FastAPI): smart_plug_manager.stop_scheduler() notification_service.stop_digest_scheduler() stop_ams_history_recording() + stop_runtime_tracking() printer_manager.disconnect_all() await close_spoolman_client() diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index 268e595f8..1bbc3a354 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -1,5 +1,6 @@ from datetime import datetime -from sqlalchemy import String, Boolean, DateTime, Float, func + +from sqlalchemy import Boolean, DateTime, Float, String, func from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base @@ -19,37 +20,27 @@ class Printer(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True) auto_archive: Mapped[bool] = mapped_column(Boolean, default=True) print_hours_offset: Mapped[float] = mapped_column(Float, default=0.0) # Baseline hours to add - created_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) + runtime_seconds: Mapped[int] = mapped_column(default=0) # Accumulated active runtime (RUNNING/PAUSE states) + last_runtime_update: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True + ) # Last time runtime was updated + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) # Relationships - archives: Mapped[list["PrintArchive"]] = relationship( - back_populates="printer", cascade="all, delete-orphan" - ) - smart_plug: Mapped["SmartPlug | None"] = relationship( - back_populates="printer", uselist=False - ) - notification_providers: Mapped[list["NotificationProvider"]] = relationship( - back_populates="printer" - ) + archives: Mapped[list["PrintArchive"]] = relationship(back_populates="printer", cascade="all, delete-orphan") + smart_plug: Mapped["SmartPlug | None"] = relationship(back_populates="printer", uselist=False) + notification_providers: Mapped[list["NotificationProvider"]] = relationship(back_populates="printer") maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship( back_populates="printer", cascade="all, delete-orphan" ) - kprofile_notes: Mapped[list["KProfileNote"]] = relationship( - back_populates="printer", cascade="all, delete-orphan" - ) - ams_history: Mapped[list["AMSSensorHistory"]] = relationship( - back_populates="printer", cascade="all, delete-orphan" - ) + kprofile_notes: Mapped[list["KProfileNote"]] = relationship(back_populates="printer", cascade="all, delete-orphan") + ams_history: Mapped[list["AMSSensorHistory"]] = relationship(back_populates="printer", cascade="all, delete-orphan") -from backend.app.models.archive import PrintArchive # noqa: E402 from backend.app.models.ams_history import AMSSensorHistory # noqa: E402 +from backend.app.models.archive import PrintArchive # noqa: E402 from backend.app.models.kprofile_note import KProfileNote # noqa: E402 -from backend.app.models.smart_plug import SmartPlug # noqa: E402 -from backend.app.models.notification import NotificationProvider # noqa: E402 from backend.app.models.maintenance import PrinterMaintenance # noqa: E402 +from backend.app.models.notification import NotificationProvider # noqa: E402 +from backend.app.models.smart_plug import SmartPlug # noqa: E402 diff --git a/frontend/src/pages/MaintenancePage.tsx b/frontend/src/pages/MaintenancePage.tsx index 402ab4974..b59b572d7 100644 --- a/frontend/src/pages/MaintenancePage.tsx +++ b/frontend/src/pages/MaintenancePage.tsx @@ -340,7 +340,7 @@ function PrinterSection({ className="group" >
- {Math.floor(overview.total_print_hours)} hours + {Math.round(overview.total_print_hours)} hours
Total print time