- Fixed bug in printer's hour counter

This commit is contained in:
maziggy
2025-12-31 12:01:17 +01:00
parent 16c21cf1fa
commit 219d26b8ab
5 changed files with 131 additions and 49 deletions
+23 -23
View File
@@ -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,
}
+10
View File
@@ -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."""
+81
View File
@@ -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()
+16 -25
View File
@@ -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
+1 -1
View File
@@ -340,7 +340,7 @@ function PrinterSection({
className="group"
>
<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" />
</div>
<div className="text-xs text-bambu-gray">Total print time</div>