Procházet zdrojové kódy

- Fixed bug in printer's hour counter

maziggy před 4 měsíci
rodič
revize
219d26b8ab

+ 23 - 23
backend/app/api/routes/maintenance.py

@@ -4,12 +4,11 @@ import logging
 from datetime import datetime
 from datetime import datetime
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import func, select
+from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.database import get_db
 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.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.schemas.maintenance import (
 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:
 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(
     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:
 async def ensure_default_types(db: AsyncSession) -> None:
@@ -564,23 +564,23 @@ async def set_printer_hours(
     total_hours: float,
     total_hours: float,
     db: AsyncSession = Depends(get_db),
     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
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:
         raise HTTPException(status_code=404, detail="Printer not found")
         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
     # 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()
     await db.commit()
 
 
@@ -611,6 +611,6 @@ async def set_printer_hours(
     return {
     return {
         "printer_id": printer_id,
         "printer_id": printer_id,
         "total_hours": total_hours,
         "total_hours": total_hours,
-        "archive_hours": archive_hours,
+        "runtime_hours": runtime_hours,
         "offset_hours": printer.print_hours_offset,
         "offset_hours": printer.print_hours_offset,
     }
     }

+ 10 - 0
backend/app/core/database.py

@@ -365,6 +365,16 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         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():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 81 - 0
backend/app/main.py

@@ -1440,6 +1440,83 @@ def stop_ams_history_recording():
         logging.getLogger(__name__).info("AMS history recording stopped")
         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
 @asynccontextmanager
 async def lifespan(app: FastAPI):
 async def lifespan(app: FastAPI):
     # Startup
     # Startup
@@ -1489,6 +1566,9 @@ async def lifespan(app: FastAPI):
     # Start AMS history recording
     # Start AMS history recording
     start_ams_history_recording()
     start_ams_history_recording()
 
 
+    # Start printer runtime tracking
+    start_runtime_tracking()
+
     # Start anonymous telemetry (opt-out via settings)
     # Start anonymous telemetry (opt-out via settings)
     asyncio.create_task(start_telemetry_loop(async_session))
     asyncio.create_task(start_telemetry_loop(async_session))
 
 
@@ -1524,6 +1604,7 @@ async def lifespan(app: FastAPI):
     smart_plug_manager.stop_scheduler()
     smart_plug_manager.stop_scheduler()
     notification_service.stop_digest_scheduler()
     notification_service.stop_digest_scheduler()
     stop_ams_history_recording()
     stop_ams_history_recording()
+    stop_runtime_tracking()
     printer_manager.disconnect_all()
     printer_manager.disconnect_all()
     await close_spoolman_client()
     await close_spoolman_client()
 
 

+ 16 - 25
backend/app/models/printer.py

@@ -1,5 +1,6 @@
 from datetime import datetime
 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 sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -19,37 +20,27 @@ class Printer(Base):
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: 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
     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
     # 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(
     maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
         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.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.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.maintenance import PrinterMaintenance  # noqa: E402
+from backend.app.models.notification import NotificationProvider  # noqa: E402
+from backend.app.models.smart_plug import SmartPlug  # noqa: E402

+ 1 - 1
frontend/src/pages/MaintenancePage.tsx

@@ -340,7 +340,7 @@ function PrinterSection({
                 className="group"
                 className="group"
               >
               >
                 <div className="text-sm font-medium text-white group-hover:text-bambu-green transition-colors flex items-center gap-1">
                 <div className="text-sm font-medium text-white group-hover:text-bambu-green transition-colors flex items-center gap-1">
-                  {Math.floor(overview.total_print_hours)} hours
+                  {Math.round(overview.total_print_hours)} hours
                   <Edit3 className="w-3 h-3 text-bambu-gray group-hover:text-bambu-green" />
                   <Edit3 className="w-3 h-3 text-bambu-gray group-hover:text-bambu-green" />
                 </div>
                 </div>
                 <div className="text-xs text-bambu-gray">Total print time</div>
                 <div className="text-xs text-bambu-gray">Total print time</div>