Files
bambuddy/backend/app/api/routes/maintenance.py
T
maziggy 368999a234 Add MQTT publishing for external automation integration
New feature: Publish BamBuddy events to external MQTT brokers for integration
with Home Assistant, Node-RED, and other automation platforms.

Backend changes:
  - New MQTTRelayService (backend/app/services/mqtt_relay.py) with paho-mqtt
  - MQTT settings added to AppSettings schema (enabled, broker, port, auth, TLS)
  - New /settings/mqtt/status endpoint for connection status
  - Event hooks in main.py, print_queue.py, print_scheduler.py, maintenance.py, smart_plugs.py
  - PrinterInfo class and get_printer() method added to PrinterManager
  - MQTT reconfiguration added to backup/restore flow
  - Printer status throttled to 1 update/sec to avoid flooding

Frontend changes:
  - New "Network" tab in Settings (between Filament and API Keys)
  - FTP Retry settings moved from General to Network tab
  - MQTT configuration card with broker, port, TLS toggle, auth fields
  - Port auto-populates when TLS toggled (1883 ↔ 8883)
  - Connection status indicator (green/red dot) in tab and card header
  - Real-time status polling when on Network tab

Published topics:
  - bambuddy/status - Online/offline
  - bambuddy/printers/{serial}/status - Printer state (throttled)
  - bambuddy/printers/{serial}/print/* - Print lifecycle
  - bambuddy/printers/{serial}/ams/changed - AMS changes
  - bambuddy/queue/* - Queue events
  - bambuddy/maintenance/* - Maintenance alerts
  - bambuddy/smart_plugs/* - Plug state/energy
  - bambuddy/archive/* - Archive events

Tests:
  - Added MQTT settings and status endpoint tests

Closes #78
2026-01-13 12:31:45 +01:00

643 lines
22 KiB
Python

"""Maintenance tracking API routes."""
import logging
from datetime import datetime
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.database import get_db
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
from backend.app.models.printer import Printer
from backend.app.schemas.maintenance import (
MaintenanceHistoryResponse,
MaintenanceStatus,
MaintenanceTypeCreate,
MaintenanceTypeResponse,
MaintenanceTypeUpdate,
PerformMaintenanceRequest,
PrinterMaintenanceOverview,
PrinterMaintenanceResponse,
PrinterMaintenanceUpdate,
)
from backend.app.services.notification_service import notification_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/maintenance", tags=["maintenance"])
# Default maintenance types
DEFAULT_MAINTENANCE_TYPES = [
{
"name": "Lubricate Linear Rails",
"description": "Apply lubricant to linear rails and rods for smooth motion",
"default_interval_hours": 50.0,
"icon": "Droplet",
},
{
"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 Carbon Rods",
"description": "Wipe carbon rods with a dry cloth",
"default_interval_hours": 100.0,
"icon": "Sparkles",
},
{
"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",
},
]
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."""
result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_system.is_(True)))
existing = result.scalars().all()
existing_names = {t.name for t in existing}
for type_def in DEFAULT_MAINTENANCE_TYPES:
if type_def["name"] not in existing_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)):
"""Get all maintenance types."""
await ensure_default_types(db)
result = await db.execute(select(MaintenanceType).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),
):
"""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),
):
"""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),
):
"""Delete a custom 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:
raise HTTPException(status_code=400, detail="Cannot delete system maintenance type")
await db.delete(maint_type)
await db.commit()
return {"status": "deleted"}
# ============== 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))
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.utcnow()
for maint_type in all_types:
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),
):
"""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)):
"""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),
):
"""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),
):
"""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),
):
"""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),
):
"""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.utcnow()
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),
):
"""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)):
"""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),
):
"""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(f"Failed to send maintenance notification: {e}")
return {
"printer_id": printer_id,
"total_hours": total_hours,
"runtime_hours": runtime_hours,
"offset_hours": printer.print_hours_offset,
}