Files
bambuddy/backend/app/api/routes/settings.py
T
maziggy 691fb133b7 Add external network camera support for printers
Add support for external network cameras (MJPEG, RTSP, HTTP snapshot)
that replace a printer's built-in camera when configured.

Features:
- Live streaming on printers page (replaces built-in camera)
- Finish photo capture from external camera on print complete
- Layer-based timelapse: captures frame on each layer change,
  stitches to MP4 video on print completion

Backend changes:
- Add external_camera_url, external_camera_type, external_camera_enabled
  fields to Printer model with database migration
- New external_camera.py service: MJPEG/RTSP/snapshot frame capture,
  connection testing, MJPEG stream generation
- New layer_timelapse.py service: TimelapseSession management,
  layer-by-layer frame capture, ffmpeg video stitching
- Add on_layer_change callback to MQTT client and printer manager
- Update camera routes with external camera streaming and tracking
- Update print lifecycle hooks for timelapse start/stitch/cancel
- Add external camera fields to backup/restore
- Rate limiting for external camera streams (prevents browser freeze)

Frontend changes:
- Add external camera configuration UI in Settings > Camera
- Per-printer enable toggle, URL input, type selector, test button
- Toast notification on save

Closes #143
2026-01-24 09:58:45 +01:00

2108 lines
95 KiB
Python

import io
import json
import zipfile
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, Query, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.config import settings as app_settings
from backend.app.core.database import get_db
from backend.app.models.api_key import APIKey
from backend.app.models.archive import PrintArchive
from backend.app.models.external_link import ExternalLink
from backend.app.models.filament import Filament
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
from backend.app.models.notification import NotificationProvider
from backend.app.models.notification_template import NotificationTemplate
from backend.app.models.pending_upload import PendingUpload
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.project_bom import ProjectBOMItem
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.user import User
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
from backend.app.services.printer_manager import printer_manager
from backend.app.services.spoolman import init_spoolman_client
router = APIRouter(prefix="/settings", tags=["settings"])
# Default settings
DEFAULT_SETTINGS = AppSettings()
async def get_setting(db: AsyncSession, key: str) -> str | None:
"""Get a single setting value by key."""
result = await db.execute(select(Settings).where(Settings.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def set_setting(db: AsyncSession, key: str, value: str) -> None:
"""Set a single setting value."""
from sqlalchemy import func
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
# Use upsert (INSERT ... ON CONFLICT UPDATE) for reliability
stmt = sqlite_insert(Settings).values(key=key, value=value)
stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": value, "updated_at": func.now()})
await db.execute(stmt)
@router.get("", response_model=AppSettings)
@router.get("/", response_model=AppSettings)
async def get_settings(db: AsyncSession = Depends(get_db)):
"""Get all application settings."""
settings_dict = DEFAULT_SETTINGS.model_dump()
# Load saved settings from database
result = await db.execute(select(Settings))
db_settings = result.scalars().all()
for setting in db_settings:
if setting.key in settings_dict:
# Parse the value based on the expected type
if setting.key in [
"auto_archive",
"save_thumbnails",
"capture_finish_photo",
"spoolman_enabled",
"check_updates",
"telemetry_enabled",
"virtual_printer_enabled",
"ftp_retry_enabled",
"mqtt_enabled",
"mqtt_use_tls",
"ha_enabled",
"per_printer_mapping_expanded",
]:
settings_dict[setting.key] = setting.value.lower() == "true"
elif setting.key in [
"default_filament_cost",
"energy_cost_per_kwh",
"ams_temp_good",
"ams_temp_fair",
"library_disk_warning_gb",
]:
settings_dict[setting.key] = float(setting.value)
elif setting.key in [
"ams_humidity_good",
"ams_humidity_fair",
"ams_history_retention_days",
"ftp_retry_count",
"ftp_retry_delay",
"mqtt_port",
]:
settings_dict[setting.key] = int(setting.value)
elif setting.key == "default_printer_id":
# Handle nullable integer
settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
else:
settings_dict[setting.key] = setting.value
return AppSettings(**settings_dict)
@router.put("/", response_model=AppSettings)
async def update_settings(
settings_update: AppSettingsUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update application settings."""
update_data = settings_update.model_dump(exclude_unset=True)
# Check if any MQTT settings are being updated
mqtt_keys = {
"mqtt_enabled",
"mqtt_broker",
"mqtt_port",
"mqtt_username",
"mqtt_password",
"mqtt_topic_prefix",
"mqtt_use_tls",
}
mqtt_updated = bool(mqtt_keys & set(update_data.keys()))
for key, value in update_data.items():
# Convert value to string for storage
if isinstance(value, bool):
str_value = "true" if value else "false"
elif value is None:
str_value = "None"
else:
str_value = str(value)
await set_setting(db, key, str_value)
await db.commit()
# Expire all objects to ensure fresh reads after commit
db.expire_all()
# Reconfigure MQTT relay if any MQTT settings changed
if mqtt_updated:
try:
from backend.app.services.mqtt_relay import mqtt_relay
mqtt_settings = {
"mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true",
"mqtt_broker": await get_setting(db, "mqtt_broker") or "",
"mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
"mqtt_username": await get_setting(db, "mqtt_username") or "",
"mqtt_password": await get_setting(db, "mqtt_password") or "",
"mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy",
"mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
}
await mqtt_relay.configure(mqtt_settings)
except Exception:
pass # Don't fail the settings update if MQTT reconfiguration fails
# Return updated settings
return await get_settings(db)
@router.post("/reset", response_model=AppSettings)
async def reset_settings(db: AsyncSession = Depends(get_db)):
"""Reset all settings to defaults."""
# Delete all settings
result = await db.execute(select(Settings))
for setting in result.scalars().all():
await db.delete(setting)
await db.commit()
return DEFAULT_SETTINGS
@router.get("/check-ffmpeg")
async def check_ffmpeg():
"""Check if ffmpeg is installed and available."""
from backend.app.services.camera import get_ffmpeg_path
ffmpeg_path = get_ffmpeg_path()
return {
"installed": ffmpeg_path is not None,
"path": ffmpeg_path,
}
@router.get("/spoolman")
async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
"""Get Spoolman integration settings."""
spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
spoolman_url = await get_setting(db, "spoolman_url") or ""
spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
return {
"spoolman_enabled": spoolman_enabled,
"spoolman_url": spoolman_url,
"spoolman_sync_mode": spoolman_sync_mode,
}
@router.put("/spoolman")
async def update_spoolman_settings(
settings: dict,
db: AsyncSession = Depends(get_db),
):
"""Update Spoolman integration settings."""
if "spoolman_enabled" in settings:
await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
if "spoolman_url" in settings:
await set_setting(db, "spoolman_url", settings["spoolman_url"])
if "spoolman_sync_mode" in settings:
await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
await db.commit()
db.expire_all()
# Return updated settings
return await get_spoolman_settings(db)
@router.get("/backup")
async def export_backup(
db: AsyncSession = Depends(get_db),
include_settings: bool = Query(True, description="Include app settings"),
include_notifications: bool = Query(True, description="Include notification providers"),
include_templates: bool = Query(True, description="Include notification templates"),
include_smart_plugs: bool = Query(True, description="Include smart plugs"),
include_external_links: bool = Query(True, description="Include external sidebar links"),
include_printers: bool = Query(False, description="Include printers (without access codes)"),
include_filaments: bool = Query(False, description="Include filament inventory"),
include_maintenance: bool = Query(
False, description="Include maintenance types, per-printer settings, and history"
),
include_print_queue: bool = Query(False, description="Include print queue items"),
include_archives: bool = Query(False, description="Include print archive metadata"),
include_projects: bool = Query(False, description="Include projects with BOM items"),
include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"),
include_users: bool = Query(
False, description="Include users (passwords not exported - users will need new passwords)"
),
):
"""Export selected data as JSON backup."""
backup: dict = {
"version": "2.0",
"exported_at": datetime.utcnow().isoformat(),
"included": [],
}
# Settings
if include_settings:
result = await db.execute(select(Settings))
db_settings = result.scalars().all()
backup["settings"] = {s.key: s.value for s in db_settings}
backup["included"].append("settings")
# Notification providers
if include_notifications:
# Build printer ID to serial lookup for cross-system backup
printer_id_to_serial: dict[int, str] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_id_to_serial[pr.id] = pr.serial_number
result = await db.execute(select(NotificationProvider))
providers = result.scalars().all()
backup["notification_providers"] = []
for p in providers:
# Use printer_serial for cross-system compatibility
provider_printer_id = getattr(p, "printer_id", None)
printer_serial = printer_id_to_serial.get(provider_printer_id) if provider_printer_id else None
backup["notification_providers"].append(
{
"name": p.name,
"provider_type": p.provider_type,
"enabled": p.enabled,
"config": json.loads(p.config) if isinstance(p.config, str) else p.config,
"on_print_start": p.on_print_start,
"on_print_complete": p.on_print_complete,
"on_print_failed": p.on_print_failed,
"on_print_stopped": p.on_print_stopped,
"on_print_progress": p.on_print_progress,
"on_printer_offline": p.on_printer_offline,
"on_printer_error": p.on_printer_error,
"on_filament_low": p.on_filament_low,
"on_maintenance_due": p.on_maintenance_due,
"on_ams_humidity_high": getattr(p, "on_ams_humidity_high", False),
"on_ams_temperature_high": getattr(p, "on_ams_temperature_high", False),
"on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
"on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
"quiet_hours_enabled": p.quiet_hours_enabled,
"quiet_hours_start": p.quiet_hours_start,
"quiet_hours_end": p.quiet_hours_end,
"daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
"daily_digest_time": getattr(p, "daily_digest_time", None),
"printer_serial": printer_serial,
}
)
backup["included"].append("notification_providers")
# Notification templates
if include_templates:
result = await db.execute(select(NotificationTemplate))
templates = result.scalars().all()
backup["notification_templates"] = []
for t in templates:
backup["notification_templates"].append(
{
"event_type": t.event_type,
"name": t.name,
"title_template": t.title_template,
"body_template": t.body_template,
"is_default": t.is_default,
}
)
backup["included"].append("notification_templates")
# Smart plugs
if include_smart_plugs:
result = await db.execute(select(SmartPlug))
plugs = result.scalars().all()
backup["smart_plugs"] = []
# Build printer ID to serial mapping
printer_id_to_serial: dict[int, str] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_id_to_serial[pr.id] = pr.serial_number
for plug in plugs:
backup["smart_plugs"].append(
{
"name": plug.name,
"plug_type": plug.plug_type,
"ip_address": plug.ip_address,
"ha_entity_id": plug.ha_entity_id,
"ha_power_entity": plug.ha_power_entity,
"ha_energy_today_entity": plug.ha_energy_today_entity,
"ha_energy_total_entity": plug.ha_energy_total_entity,
"printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
"enabled": plug.enabled,
"auto_on": plug.auto_on,
"auto_off": plug.auto_off,
"off_delay_mode": plug.off_delay_mode,
"off_delay_minutes": plug.off_delay_minutes,
"off_temp_threshold": plug.off_temp_threshold,
"username": plug.username,
"password": plug.password,
"power_alert_enabled": plug.power_alert_enabled,
"power_alert_high": plug.power_alert_high,
"power_alert_low": plug.power_alert_low,
"schedule_enabled": plug.schedule_enabled,
"schedule_on_time": plug.schedule_on_time,
"schedule_off_time": plug.schedule_off_time,
"show_in_switchbar": plug.show_in_switchbar,
}
)
backup["included"].append("smart_plugs")
# External links
if include_external_links:
result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order))
links = result.scalars().all()
backup["external_links"] = []
icons_dir = app_settings.base_dir / "icons"
for link in links:
link_data = {
"name": link.name,
"url": link.url,
"icon": link.icon,
"sort_order": link.sort_order,
}
# Include custom icon file path if exists
if link.custom_icon:
link_data["custom_icon"] = link.custom_icon
icon_path = icons_dir / link.custom_icon
if icon_path.exists():
link_data["custom_icon_path"] = f"icons/{link.custom_icon}"
backup["external_links"].append(link_data)
backup["included"].append("external_links")
# Printers (access codes only included if explicitly requested)
if include_printers:
result = await db.execute(select(Printer))
printers = result.scalars().all()
backup["printers"] = []
for printer in printers:
printer_data = {
"name": printer.name,
"serial_number": printer.serial_number,
"ip_address": printer.ip_address,
"model": printer.model,
"location": printer.location,
"nozzle_count": printer.nozzle_count,
"is_active": printer.is_active,
"auto_archive": printer.auto_archive,
"print_hours_offset": printer.print_hours_offset,
"runtime_seconds": printer.runtime_seconds,
"external_camera_url": printer.external_camera_url,
"external_camera_type": printer.external_camera_type,
"external_camera_enabled": printer.external_camera_enabled,
}
if include_access_codes:
printer_data["access_code"] = printer.access_code
backup["printers"].append(printer_data)
backup["included"].append("printers")
if include_access_codes:
backup["included"].append("access_codes")
# Filaments
if include_filaments:
result = await db.execute(select(Filament))
filaments = result.scalars().all()
backup["filaments"] = []
for f in filaments:
backup["filaments"].append(
{
"name": f.name,
"type": f.type,
"brand": f.brand,
"color": f.color,
"color_hex": f.color_hex,
"cost_per_kg": f.cost_per_kg,
"spool_weight_g": f.spool_weight_g,
"currency": f.currency,
"density": f.density,
"print_temp_min": f.print_temp_min,
"print_temp_max": f.print_temp_max,
"bed_temp_min": f.bed_temp_min,
"bed_temp_max": f.bed_temp_max,
}
)
backup["included"].append("filaments")
# Maintenance types and records
if include_maintenance:
# Maintenance types
result = await db.execute(select(MaintenanceType))
types = result.scalars().all()
backup["maintenance_types"] = []
for mt in types:
backup["maintenance_types"].append(
{
"name": mt.name,
"description": mt.description,
"default_interval_hours": mt.default_interval_hours,
"interval_type": mt.interval_type,
"icon": mt.icon,
"is_system": mt.is_system,
}
)
backup["included"].append("maintenance_types")
# Printer maintenance settings (per-printer custom intervals, enabled status, last performed)
result = await db.execute(select(PrinterMaintenance))
printer_maint = result.scalars().all()
backup["printer_maintenance"] = []
# Build lookups for printer serial and maintenance type name
printer_id_to_serial: dict[int, str] = {}
maint_type_id_to_name: dict[int, str] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_id_to_serial[pr.id] = pr.serial_number
for mt in types:
maint_type_id_to_name[mt.id] = mt.name
for pm in printer_maint:
backup["printer_maintenance"].append(
{
"printer_serial": printer_id_to_serial.get(pm.printer_id),
"maintenance_type_name": maint_type_id_to_name.get(pm.maintenance_type_id),
"custom_interval_hours": pm.custom_interval_hours,
"custom_interval_type": pm.custom_interval_type,
"enabled": pm.enabled,
"last_performed_at": pm.last_performed_at.isoformat() if pm.last_performed_at else None,
"last_performed_hours": pm.last_performed_hours,
}
)
backup["included"].append("printer_maintenance")
# Maintenance history
result = await db.execute(select(MaintenanceHistory))
history = result.scalars().all()
backup["maintenance_history"] = []
# Build printer_maintenance ID to (printer_serial, maint_type_name) mapping
pm_id_to_info: dict[int, tuple[str | None, str | None]] = {}
for pm in printer_maint:
pm_id_to_info[pm.id] = (
printer_id_to_serial.get(pm.printer_id),
maint_type_id_to_name.get(pm.maintenance_type_id),
)
for mh in history:
info = pm_id_to_info.get(mh.printer_maintenance_id, (None, None))
backup["maintenance_history"].append(
{
"printer_serial": info[0],
"maintenance_type_name": info[1],
"performed_at": mh.performed_at.isoformat() if mh.performed_at else None,
"hours_at_maintenance": mh.hours_at_maintenance,
"notes": mh.notes,
}
)
backup["included"].append("maintenance_history")
# Print queue
if include_print_queue:
result = await db.execute(select(PrintQueueItem))
queue_items = result.scalars().all()
backup["print_queue"] = []
# Build lookups
printer_id_to_serial: dict[int, str] = {}
archive_id_to_hash: dict[int, str | None] = {}
project_id_to_name: dict[int, str] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_id_to_serial[pr.id] = pr.serial_number
ar_result = await db.execute(select(PrintArchive))
for ar in ar_result.scalars().all():
archive_id_to_hash[ar.id] = ar.content_hash
proj_result = await db.execute(select(Project))
for proj in proj_result.scalars().all():
project_id_to_name[proj.id] = proj.name
for qi in queue_items:
backup["print_queue"].append(
{
"printer_serial": printer_id_to_serial.get(qi.printer_id) if qi.printer_id else None,
"archive_hash": archive_id_to_hash.get(qi.archive_id),
"project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None,
"position": qi.position,
"scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
"require_previous_success": qi.require_previous_success,
"auto_off_after": qi.auto_off_after,
"manual_start": qi.manual_start,
"ams_mapping": qi.ams_mapping,
"plate_id": qi.plate_id,
"bed_levelling": qi.bed_levelling,
"flow_cali": qi.flow_cali,
"vibration_cali": qi.vibration_cali,
"layer_inspect": qi.layer_inspect,
"timelapse": qi.timelapse,
"use_ams": qi.use_ams,
"status": qi.status,
"started_at": qi.started_at.isoformat() if qi.started_at else None,
"completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
"error_message": qi.error_message,
}
)
backup["included"].append("print_queue")
# Collect files for ZIP (icons + archives + project attachments)
backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path)
base_dir = app_settings.base_dir
# Add external link icon files
if include_external_links and "external_links" in backup:
icons_dir = base_dir / "icons"
for link_data in backup["external_links"]:
if "custom_icon_path" in link_data:
icon_path = icons_dir / link_data["custom_icon"]
if icon_path.exists():
backup_files.append((link_data["custom_icon_path"], icon_path))
# Print archives with file paths for ZIP
if include_archives:
result = await db.execute(select(PrintArchive))
archives = result.scalars().all()
backup["archives"] = []
# Build project ID to name mapping for archive export
project_id_to_name: dict[int, str] = {}
if include_projects:
proj_result = await db.execute(select(Project))
for proj in proj_result.scalars().all():
project_id_to_name[proj.id] = proj.name
# Build printer ID to serial mapping for archive export
printer_id_to_serial: dict[int, str] = {}
if include_printers:
printer_result = await db.execute(select(Printer))
for pr in printer_result.scalars().all():
printer_id_to_serial[pr.id] = pr.serial_number
for a in archives:
archive_data = {
"filename": a.filename,
"project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
"printer_serial": printer_id_to_serial.get(a.printer_id) if a.printer_id else None,
"file_size": a.file_size,
"content_hash": a.content_hash,
"print_name": a.print_name,
"print_time_seconds": a.print_time_seconds,
"filament_used_grams": a.filament_used_grams,
"filament_type": a.filament_type,
"filament_color": a.filament_color,
"layer_height": a.layer_height,
"total_layers": a.total_layers,
"nozzle_diameter": a.nozzle_diameter,
"bed_temperature": a.bed_temperature,
"nozzle_temperature": a.nozzle_temperature,
"status": a.status,
"started_at": a.started_at.isoformat() if a.started_at else None,
"completed_at": a.completed_at.isoformat() if a.completed_at else None,
"makerworld_url": a.makerworld_url,
"designer": a.designer,
"is_favorite": a.is_favorite,
"tags": a.tags,
"notes": a.notes,
"cost": a.cost,
"failure_reason": a.failure_reason,
"quantity": a.quantity,
"energy_kwh": a.energy_kwh,
"energy_cost": a.energy_cost,
"extra_data": a.extra_data,
"photos": a.photos,
}
# Collect file paths for ZIP
if a.file_path:
file_path = base_dir / a.file_path
if file_path.exists():
archive_data["file_path"] = a.file_path
backup_files.append((a.file_path, file_path))
if a.thumbnail_path:
thumb_path = base_dir / a.thumbnail_path
if thumb_path.exists():
archive_data["thumbnail_path"] = a.thumbnail_path
backup_files.append((a.thumbnail_path, thumb_path))
if a.timelapse_path:
timelapse_path = base_dir / a.timelapse_path
if timelapse_path.exists():
archive_data["timelapse_path"] = a.timelapse_path
backup_files.append((a.timelapse_path, timelapse_path))
if a.source_3mf_path:
source_path = base_dir / a.source_3mf_path
if source_path.exists():
archive_data["source_3mf_path"] = a.source_3mf_path
backup_files.append((a.source_3mf_path, source_path))
if a.f3d_path:
f3d_path = base_dir / a.f3d_path
if f3d_path.exists():
archive_data["f3d_path"] = a.f3d_path
backup_files.append((a.f3d_path, f3d_path))
# Include photos
if a.photos:
for photo in a.photos:
photo_path = base_dir / "archive" / "photos" / photo
if photo_path.exists():
zip_photo_path = f"archive/photos/{photo}"
backup_files.append((zip_photo_path, photo_path))
backup["archives"].append(archive_data)
backup["included"].append("archives")
# Projects with BOM items
if include_projects:
result = await db.execute(select(Project))
projects = result.scalars().all()
backup["projects"] = []
for p in projects:
# Get BOM items for this project
bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id))
bom_items = bom_result.scalars().all()
project_data = {
"name": p.name,
"description": p.description,
"color": p.color,
"status": p.status,
"target_count": p.target_count,
"notes": p.notes,
"tags": p.tags,
"due_date": p.due_date.isoformat() if p.due_date else None,
"priority": p.priority,
"budget": p.budget,
"is_template": p.is_template,
"template_source_id": p.template_source_id,
"parent_id": p.parent_id,
"bom_items": [
{
"name": item.name,
"quantity_needed": item.quantity_needed,
"quantity_acquired": item.quantity_acquired,
"unit_price": item.unit_price,
"sourcing_url": item.sourcing_url,
"stl_filename": item.stl_filename,
"remarks": item.remarks,
"sort_order": item.sort_order,
}
for item in bom_items
],
}
# Include attachment files for ZIP
if p.attachments:
project_data["attachments"] = p.attachments
attachments_dir = base_dir / "projects" / str(p.id) / "attachments"
for att in p.attachments:
att_path = attachments_dir / att.get("filename", "")
if att_path.exists():
zip_path = f"projects/{p.id}/attachments/{att['filename']}"
backup_files.append((zip_path, att_path))
backup["projects"].append(project_data)
backup["included"].append("projects")
# Pending uploads (virtual printer queue mode)
if include_pending_uploads:
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
pending_uploads = result.scalars().all()
backup["pending_uploads"] = []
for p in pending_uploads:
upload_data = {
"filename": p.filename,
"file_size": p.file_size,
"source_ip": p.source_ip,
"status": p.status,
"tags": p.tags,
"notes": p.notes,
"project_id": p.project_id,
"uploaded_at": p.uploaded_at.isoformat() if p.uploaded_at else None,
}
# Include the actual file if it exists
if p.file_path:
file_path = Path(p.file_path)
if file_path.exists():
# Store relative path for ZIP
rel_path = f"pending_uploads/{p.filename}"
upload_data["file_path"] = rel_path
backup_files.append((rel_path, file_path))
backup["pending_uploads"].append(upload_data)
backup["included"].append("pending_uploads")
# API keys (note: key_hash cannot be restored, new keys must be generated)
if include_api_keys:
# Build printer ID to serial mapping for cross-system compatibility
printer_id_to_serial: dict[int, str] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_id_to_serial[pr.id] = pr.serial_number
result = await db.execute(select(APIKey))
api_keys = result.scalars().all()
backup["api_keys"] = []
for key in api_keys:
# Convert printer_ids from list of IDs to list of serials
printer_serials = None
if key.printer_ids:
printer_serials = [
printer_id_to_serial.get(pid) for pid in key.printer_ids if pid in printer_id_to_serial
]
backup["api_keys"].append(
{
"name": key.name,
"key_prefix": key.key_prefix, # For identification only
"can_queue": key.can_queue,
"can_control_printer": key.can_control_printer,
"can_read_status": key.can_read_status,
"printer_serials": printer_serials, # Use serials instead of IDs
"enabled": key.enabled,
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
}
)
backup["included"].append("api_keys")
# Users (note: passwords not exported for security - users will need new passwords on import)
if include_users:
result = await db.execute(select(User))
users = result.scalars().all()
backup["users"] = []
for user in users:
backup["users"].append(
{
"username": user.username,
"role": user.role,
"is_active": user.is_active,
# password_hash intentionally not exported for security
}
)
backup["included"].append("users")
# If there are files to include (icons or archives), create ZIP file
if backup_files:
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
# Add backup.json
zf.writestr("backup.json", json.dumps(backup, indent=2))
# Add all backup files (icons, archives, etc.)
added_files = set()
for zip_path, local_path in backup_files:
if zip_path not in added_files and local_path.exists():
try:
zf.write(local_path, zip_path)
added_files.add(zip_path)
except Exception:
pass # Skip files that can't be read
zip_buffer.seek(0)
filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
# Otherwise return JSON
return JSONResponse(
content=backup,
headers={
"Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
},
)
@router.post("/restore")
async def import_backup(
file: UploadFile = File(...),
overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
db: AsyncSession = Depends(get_db),
):
"""Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing."""
try:
content = await file.read()
base_dir = app_settings.base_dir
files_restored = 0
# Check if it's a ZIP file
if file.filename and file.filename.endswith(".zip"):
try:
zip_buffer = io.BytesIO(content)
with zipfile.ZipFile(zip_buffer, "r") as zf:
# Extract backup.json
if "backup.json" not in zf.namelist():
return {"success": False, "message": "Invalid ZIP: missing backup.json"}
backup_content = zf.read("backup.json")
backup = json.loads(backup_content.decode("utf-8"))
# Extract all other files to base_dir
for zip_path in zf.namelist():
if zip_path == "backup.json":
continue
# Ensure path is safe (no path traversal)
if ".." in zip_path or zip_path.startswith("/"):
continue
target_path = base_dir / zip_path
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(zip_path) as src, open(target_path, "wb") as dst:
dst.write(src.read())
files_restored += 1
except zipfile.BadZipFile:
return {"success": False, "message": "Invalid ZIP file"}
else:
backup = json.loads(content.decode("utf-8"))
except json.JSONDecodeError as e:
return {"success": False, "message": f"Invalid JSON: {str(e)}"}
except Exception as e:
return {"success": False, "message": f"Invalid backup file: {str(e)}"}
restored = {
"settings": 0,
"notification_providers": 0,
"notification_templates": 0,
"smart_plugs": 0,
"external_links": 0,
"printers": 0,
"filaments": 0,
"maintenance_types": 0,
"projects": 0,
"pending_uploads": 0,
"users": 0,
}
skipped = {
"settings": 0,
"notification_providers": 0,
"notification_templates": 0,
"smart_plugs": 0,
"external_links": 0,
"printers": 0,
"filaments": 0,
"maintenance_types": 0,
"archives": 0,
"projects": 0,
"pending_uploads": 0,
"users": 0,
}
skipped_details = {
"notification_providers": [],
"smart_plugs": [],
"external_links": [],
"printers": [],
"filaments": [],
"maintenance_types": [],
"archives": [],
"projects": [],
"pending_uploads": [],
"users": [],
}
# Restore settings (always overwrites)
if "settings" in backup:
for key, value in backup["settings"].items():
# Convert value to proper string format for storage
if isinstance(value, bool):
str_value = "true" if value else "false"
elif value is None:
str_value = "None"
else:
str_value = str(value)
await set_setting(db, key, str_value)
restored["settings"] += 1
# Flush settings to ensure they're persisted before continuing
await db.flush()
# Restore printers FIRST (skip or overwrite duplicates by serial_number)
# Nearly everything in the app references printers, so they must be imported first
if "printers" in backup:
for printer_data in backup["printers"]:
result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
existing.name = printer_data["name"]
existing.ip_address = printer_data["ip_address"]
existing.model = printer_data.get("model")
existing.location = printer_data.get("location")
existing.nozzle_count = printer_data.get("nozzle_count", 1)
existing.auto_archive = printer_data.get("auto_archive", True)
existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
existing.runtime_seconds = printer_data.get("runtime_seconds", 0)
# If backup includes access_code, also update access_code and is_active
backup_access_code = printer_data.get("access_code")
if backup_access_code and backup_access_code != "CHANGE_ME":
existing.access_code = backup_access_code
is_active_val = printer_data.get("is_active", False)
if isinstance(is_active_val, str):
is_active_val = is_active_val.lower() == "true"
existing.is_active = is_active_val
restored["printers"] += 1
else:
skipped["printers"] += 1
skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
else:
# Use access code from backup if provided, otherwise require manual setup
access_code = printer_data.get("access_code")
has_access_code = access_code and access_code != "CHANGE_ME"
is_active_from_backup = printer_data.get("is_active", False)
# Handle bool or string "true"/"false"
if isinstance(is_active_from_backup, str):
is_active_from_backup = is_active_from_backup.lower() == "true"
printer = Printer(
name=printer_data["name"],
serial_number=printer_data["serial_number"],
ip_address=printer_data["ip_address"],
access_code=access_code if has_access_code else "CHANGE_ME",
model=printer_data.get("model"),
location=printer_data.get("location"),
nozzle_count=printer_data.get("nozzle_count", 1),
is_active=is_active_from_backup if has_access_code else False,
auto_archive=printer_data.get("auto_archive", True),
print_hours_offset=printer_data.get("print_hours_offset", 0.0),
runtime_seconds=printer_data.get("runtime_seconds", 0),
external_camera_url=printer_data.get("external_camera_url"),
external_camera_type=printer_data.get("external_camera_type"),
external_camera_enabled=printer_data.get("external_camera_enabled", False),
)
db.add(printer)
restored["printers"] += 1
# Flush printers so other sections can look them up
await db.flush()
# Restore notification providers (skip or overwrite duplicates by name)
# Build printer serial to ID lookup (printers were restored first)
if "notification_providers" in backup:
printer_serial_to_id: dict[str, int] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_serial_to_id[pr.serial_number] = pr.id
for provider_data in backup["notification_providers"]:
# Look up printer_id from serial (supports both old printer_id and new printer_serial format)
printer_serial = provider_data.get("printer_serial")
printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else provider_data.get("printer_id")
result = await db.execute(
select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
)
existing = result.scalar_one_or_none()
if existing:
if overwrite:
# Update existing provider
existing.provider_type = provider_data["provider_type"]
existing.enabled = provider_data.get("enabled", True)
existing.config = json.dumps(provider_data.get("config", {}))
existing.on_print_start = provider_data.get("on_print_start", False)
existing.on_print_complete = provider_data.get("on_print_complete", True)
existing.on_print_failed = provider_data.get("on_print_failed", True)
existing.on_print_stopped = provider_data.get("on_print_stopped", True)
existing.on_print_progress = provider_data.get("on_print_progress", False)
existing.on_printer_offline = provider_data.get("on_printer_offline", False)
existing.on_printer_error = provider_data.get("on_printer_error", False)
existing.on_filament_low = provider_data.get("on_filament_low", False)
existing.on_maintenance_due = provider_data.get("on_maintenance_due", False)
existing.on_ams_humidity_high = provider_data.get("on_ams_humidity_high", False)
existing.on_ams_temperature_high = provider_data.get("on_ams_temperature_high", False)
existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False)
existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False)
existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
existing.quiet_hours_start = provider_data.get("quiet_hours_start")
existing.quiet_hours_end = provider_data.get("quiet_hours_end")
existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
existing.daily_digest_time = provider_data.get("daily_digest_time")
existing.printer_id = printer_id
restored["notification_providers"] += 1
else:
skipped["notification_providers"] += 1
skipped_details["notification_providers"].append(provider_data["name"])
else:
provider = NotificationProvider(
name=provider_data["name"],
provider_type=provider_data["provider_type"],
enabled=provider_data.get("enabled", True),
config=json.dumps(provider_data.get("config", {})),
on_print_start=provider_data.get("on_print_start", False),
on_print_complete=provider_data.get("on_print_complete", True),
on_print_failed=provider_data.get("on_print_failed", True),
on_print_stopped=provider_data.get("on_print_stopped", True),
on_print_progress=provider_data.get("on_print_progress", False),
on_printer_offline=provider_data.get("on_printer_offline", False),
on_printer_error=provider_data.get("on_printer_error", False),
on_filament_low=provider_data.get("on_filament_low", False),
on_maintenance_due=provider_data.get("on_maintenance_due", False),
on_ams_humidity_high=provider_data.get("on_ams_humidity_high", False),
on_ams_temperature_high=provider_data.get("on_ams_temperature_high", False),
on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False),
on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
quiet_hours_start=provider_data.get("quiet_hours_start"),
quiet_hours_end=provider_data.get("quiet_hours_end"),
daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
daily_digest_time=provider_data.get("daily_digest_time"),
printer_id=printer_id,
)
db.add(provider)
restored["notification_providers"] += 1
# Restore notification templates (update existing by event_type)
if "notification_templates" in backup:
for template_data in backup["notification_templates"]:
result = await db.execute(
select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"])
)
existing = result.scalar_one_or_none()
if existing:
# Update existing template
existing.name = template_data.get("name", existing.name)
existing.title_template = template_data.get("title_template", existing.title_template)
existing.body_template = template_data.get("body_template", existing.body_template)
existing.is_default = template_data.get("is_default", False)
else:
template = NotificationTemplate(
event_type=template_data["event_type"],
name=template_data["name"],
title_template=template_data["title_template"],
body_template=template_data["body_template"],
is_default=template_data.get("is_default", False),
)
db.add(template)
restored["notification_templates"] += 1
# Restore smart plugs (skip or overwrite duplicates by IP)
# Note: Smart plugs reference printers, so printers should be restored first
if "smart_plugs" in backup:
# Build printer serial to ID lookup
printer_serial_to_id: dict[str, int] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_serial_to_id[pr.serial_number] = pr.id
for plug_data in backup["smart_plugs"]:
# Look up printer_id from serial (supports both old printer_id and new printer_serial format)
printer_serial = plug_data.get("printer_serial")
printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id")
# Determine plug type (default to tasmota for backwards compatibility)
plug_type = plug_data.get("plug_type", "tasmota")
# Find existing plug by IP (Tasmota) or entity_id (Home Assistant)
existing = None
if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
existing = result.scalar_one_or_none()
plug_identifier = plug_data["ha_entity_id"]
elif plug_data.get("ip_address"):
result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
existing = result.scalar_one_or_none()
plug_identifier = plug_data["ip_address"]
else:
# Skip invalid plug data
continue
if existing:
if overwrite:
existing.name = plug_data["name"]
existing.plug_type = plug_type
existing.ha_entity_id = plug_data.get("ha_entity_id")
existing.ha_power_entity = plug_data.get("ha_power_entity")
existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity")
existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity")
existing.printer_id = printer_id
existing.enabled = plug_data.get("enabled", True)
existing.auto_on = plug_data.get("auto_on", True)
existing.auto_off = plug_data.get("auto_off", True)
existing.off_delay_mode = plug_data.get("off_delay_mode", "time")
existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5)
existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70)
existing.username = plug_data.get("username")
existing.password = plug_data.get("password")
existing.power_alert_enabled = plug_data.get("power_alert_enabled", False)
existing.power_alert_high = plug_data.get("power_alert_high")
existing.power_alert_low = plug_data.get("power_alert_low")
existing.schedule_enabled = plug_data.get("schedule_enabled", False)
existing.schedule_on_time = plug_data.get("schedule_on_time")
existing.schedule_off_time = plug_data.get("schedule_off_time")
existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
restored["smart_plugs"] += 1
else:
skipped["smart_plugs"] += 1
skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_identifier})")
else:
plug = SmartPlug(
name=plug_data["name"],
plug_type=plug_type,
ip_address=plug_data.get("ip_address"),
ha_entity_id=plug_data.get("ha_entity_id"),
ha_power_entity=plug_data.get("ha_power_entity"),
ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
printer_id=printer_id,
enabled=plug_data.get("enabled", True),
auto_on=plug_data.get("auto_on", True),
auto_off=plug_data.get("auto_off", True),
off_delay_mode=plug_data.get("off_delay_mode", "time"),
off_delay_minutes=plug_data.get("off_delay_minutes", 5),
off_temp_threshold=plug_data.get("off_temp_threshold", 70),
username=plug_data.get("username"),
password=plug_data.get("password"),
power_alert_enabled=plug_data.get("power_alert_enabled", False),
power_alert_high=plug_data.get("power_alert_high"),
power_alert_low=plug_data.get("power_alert_low"),
schedule_enabled=plug_data.get("schedule_enabled", False),
schedule_on_time=plug_data.get("schedule_on_time"),
schedule_off_time=plug_data.get("schedule_off_time"),
show_in_switchbar=plug_data.get("show_in_switchbar", False),
)
db.add(plug)
restored["smart_plugs"] += 1
# Restore external links (skip or overwrite duplicates by name+url)
if "external_links" in backup:
icons_dir = base_dir / "icons"
icons_dir.mkdir(parents=True, exist_ok=True)
for link_data in backup["external_links"]:
result = await db.execute(
select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"])
)
existing = result.scalar_one_or_none()
if existing:
if overwrite:
existing.icon = link_data.get("icon", "link")
existing.sort_order = link_data.get("sort_order", 0)
# Handle custom icon
if link_data.get("custom_icon"):
existing.custom_icon = link_data["custom_icon"]
restored["external_links"] += 1
else:
skipped["external_links"] += 1
skipped_details["external_links"].append(link_data["name"])
else:
link = ExternalLink(
name=link_data["name"],
url=link_data["url"],
icon=link_data.get("icon", "link"),
custom_icon=link_data.get("custom_icon"),
sort_order=link_data.get("sort_order", 0),
)
db.add(link)
restored["external_links"] += 1
# Restore filaments (skip or overwrite duplicates by name+type+brand)
if "filaments" in backup:
for filament_data in backup["filaments"]:
result = await db.execute(
select(Filament).where(
Filament.name == filament_data["name"],
Filament.type == filament_data["type"],
Filament.brand == filament_data.get("brand"),
)
)
existing = result.scalar_one_or_none()
if existing:
if overwrite:
existing.color = filament_data.get("color")
existing.color_hex = filament_data.get("color_hex")
existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0)
existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0)
existing.currency = filament_data.get("currency", "USD")
existing.density = filament_data.get("density")
existing.print_temp_min = filament_data.get("print_temp_min")
existing.print_temp_max = filament_data.get("print_temp_max")
existing.bed_temp_min = filament_data.get("bed_temp_min")
existing.bed_temp_max = filament_data.get("bed_temp_max")
restored["filaments"] += 1
else:
skipped["filaments"] += 1
skipped_details["filaments"].append(
f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})"
)
else:
filament = Filament(
name=filament_data["name"],
type=filament_data["type"],
brand=filament_data.get("brand"),
color=filament_data.get("color"),
color_hex=filament_data.get("color_hex"),
cost_per_kg=filament_data.get("cost_per_kg", 25.0),
spool_weight_g=filament_data.get("spool_weight_g", 1000.0),
currency=filament_data.get("currency", "USD"),
density=filament_data.get("density"),
print_temp_min=filament_data.get("print_temp_min"),
print_temp_max=filament_data.get("print_temp_max"),
bed_temp_min=filament_data.get("bed_temp_min"),
bed_temp_max=filament_data.get("bed_temp_max"),
)
db.add(filament)
restored["filaments"] += 1
# Restore maintenance types (skip or overwrite duplicates by name)
if "maintenance_types" in backup:
for mt_data in backup["maintenance_types"]:
result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"]))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
existing.description = mt_data.get("description")
existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0)
existing.interval_type = mt_data.get("interval_type", "hours")
existing.icon = mt_data.get("icon")
# Don't overwrite is_system
restored["maintenance_types"] += 1
else:
skipped["maintenance_types"] += 1
skipped_details["maintenance_types"].append(mt_data["name"])
else:
mt = MaintenanceType(
name=mt_data["name"],
description=mt_data.get("description"),
default_interval_hours=mt_data.get("default_interval_hours", 100.0),
interval_type=mt_data.get("interval_type", "hours"),
icon=mt_data.get("icon"),
is_system=mt_data.get("is_system", False),
)
db.add(mt)
restored["maintenance_types"] += 1
# Restore printer maintenance settings (per-printer)
if "printer_maintenance" in backup:
# Build lookups
printer_serial_to_id: dict[str, int] = {}
maint_type_name_to_id: dict[str, int] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_serial_to_id[pr.serial_number] = pr.id
mt_result = await db.execute(select(MaintenanceType))
for mt in mt_result.scalars().all():
maint_type_name_to_id[mt.name] = mt.id
restored["printer_maintenance"] = 0
skipped["printer_maintenance"] = 0
skipped_details["printer_maintenance"] = []
for pm_data in backup["printer_maintenance"]:
printer_serial = pm_data.get("printer_serial")
maint_type_name = pm_data.get("maintenance_type_name")
if not printer_serial or not maint_type_name:
continue
printer_id = printer_serial_to_id.get(printer_serial)
maint_type_id = maint_type_name_to_id.get(maint_type_name)
if not printer_id or not maint_type_id:
skipped["printer_maintenance"] += 1
skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
continue
# Check if exists
result = await db.execute(
select(PrinterMaintenance).where(
PrinterMaintenance.printer_id == printer_id,
PrinterMaintenance.maintenance_type_id == maint_type_id,
)
)
existing = result.scalar_one_or_none()
if existing:
if overwrite:
existing.custom_interval_hours = pm_data.get("custom_interval_hours")
existing.custom_interval_type = pm_data.get("custom_interval_type")
existing.enabled = pm_data.get("enabled", True)
existing.last_performed_hours = pm_data.get("last_performed_hours", 0.0)
if pm_data.get("last_performed_at"):
existing.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
restored["printer_maintenance"] += 1
else:
skipped["printer_maintenance"] += 1
skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
else:
pm = PrinterMaintenance(
printer_id=printer_id,
maintenance_type_id=maint_type_id,
custom_interval_hours=pm_data.get("custom_interval_hours"),
custom_interval_type=pm_data.get("custom_interval_type"),
enabled=pm_data.get("enabled", True),
last_performed_hours=pm_data.get("last_performed_hours", 0.0),
)
if pm_data.get("last_performed_at"):
pm.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
db.add(pm)
restored["printer_maintenance"] += 1
# Restore maintenance history
if "maintenance_history" in backup:
# Build lookups
printer_serial_to_id: dict[str, int] = {}
maint_type_name_to_id: dict[str, int] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_serial_to_id[pr.serial_number] = pr.id
mt_result = await db.execute(select(MaintenanceType))
for mt in mt_result.scalars().all():
maint_type_name_to_id[mt.name] = mt.id
restored["maintenance_history"] = 0
skipped["maintenance_history"] = 0
skipped_details["maintenance_history"] = []
for mh_data in backup["maintenance_history"]:
printer_serial = mh_data.get("printer_serial")
maint_type_name = mh_data.get("maintenance_type_name")
if not printer_serial or not maint_type_name:
continue
printer_id = printer_serial_to_id.get(printer_serial)
maint_type_id = maint_type_name_to_id.get(maint_type_name)
if not printer_id or not maint_type_id:
skipped["maintenance_history"] += 1
continue
# Find the PrinterMaintenance record
result = await db.execute(
select(PrinterMaintenance).where(
PrinterMaintenance.printer_id == printer_id,
PrinterMaintenance.maintenance_type_id == maint_type_id,
)
)
pm = result.scalar_one_or_none()
if not pm:
skipped["maintenance_history"] += 1
continue
# Create history entry (no duplicate check - history is append-only)
mh = MaintenanceHistory(
printer_maintenance_id=pm.id,
hours_at_maintenance=mh_data.get("hours_at_maintenance", 0.0),
notes=mh_data.get("notes"),
)
if mh_data.get("performed_at"):
mh.performed_at = datetime.fromisoformat(mh_data["performed_at"])
db.add(mh)
restored["maintenance_history"] += 1
# Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
if "archives" in backup:
# Build printer serial to ID mapping
printer_serial_to_id: dict[str, int] = {}
printer_result = await db.execute(select(Printer))
for pr in printer_result.scalars().all():
printer_serial_to_id[pr.serial_number] = pr.id
for archive_data in backup["archives"]:
# Skip if no content_hash or already exists
content_hash = archive_data.get("content_hash")
if content_hash:
result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
existing = result.scalar_one_or_none()
if existing:
skipped["archives"] += 1
skipped_details["archives"].append(archive_data.get("filename", "Unknown"))
continue
# Only restore if file exists (from ZIP extraction)
file_path = archive_data.get("file_path")
if file_path and (base_dir / file_path).exists():
# Look up printer_id from serial
printer_serial = archive_data.get("printer_serial")
printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
archive = PrintArchive(
filename=archive_data["filename"],
file_path=file_path,
file_size=archive_data.get("file_size", 0),
content_hash=content_hash,
printer_id=printer_id,
thumbnail_path=archive_data.get("thumbnail_path"),
timelapse_path=archive_data.get("timelapse_path"),
source_3mf_path=archive_data.get("source_3mf_path"),
f3d_path=archive_data.get("f3d_path"),
print_name=archive_data.get("print_name"),
print_time_seconds=archive_data.get("print_time_seconds"),
filament_used_grams=archive_data.get("filament_used_grams"),
filament_type=archive_data.get("filament_type"),
filament_color=archive_data.get("filament_color"),
layer_height=archive_data.get("layer_height"),
total_layers=archive_data.get("total_layers"),
nozzle_diameter=archive_data.get("nozzle_diameter"),
bed_temperature=archive_data.get("bed_temperature"),
nozzle_temperature=archive_data.get("nozzle_temperature"),
status=archive_data.get("status", "completed"),
makerworld_url=archive_data.get("makerworld_url"),
designer=archive_data.get("designer"),
is_favorite=archive_data.get("is_favorite", False),
tags=archive_data.get("tags"),
notes=archive_data.get("notes"),
cost=archive_data.get("cost"),
failure_reason=archive_data.get("failure_reason"),
quantity=archive_data.get("quantity", 1),
energy_kwh=archive_data.get("energy_kwh"),
energy_cost=archive_data.get("energy_cost"),
extra_data=archive_data.get("extra_data"),
photos=archive_data.get("photos"),
)
db.add(archive)
restored["archives"] = restored.get("archives", 0) + 1
# Restore projects (skip or overwrite duplicates by name)
if "projects" in backup:
for project_data in backup["projects"]:
result = await db.execute(select(Project).where(Project.name == project_data["name"]))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
# Update existing project
existing.description = project_data.get("description")
existing.color = project_data.get("color")
existing.status = project_data.get("status", "active")
existing.target_count = project_data.get("target_count")
existing.notes = project_data.get("notes")
existing.tags = project_data.get("tags")
existing.priority = project_data.get("priority", "normal")
existing.budget = project_data.get("budget")
existing.is_template = project_data.get("is_template", False)
existing.template_source_id = project_data.get("template_source_id")
existing.parent_id = project_data.get("parent_id")
existing.attachments = project_data.get("attachments")
if project_data.get("due_date"):
existing.due_date = datetime.fromisoformat(project_data["due_date"])
# Delete existing BOM items and re-add
await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id))
for bom_data in project_data.get("bom_items", []):
bom_item = ProjectBOMItem(
project_id=existing.id,
name=bom_data["name"],
quantity_needed=bom_data.get("quantity_needed", 1),
quantity_acquired=bom_data.get("quantity_acquired", 0),
unit_price=bom_data.get("unit_price"),
sourcing_url=bom_data.get("sourcing_url"),
stl_filename=bom_data.get("stl_filename"),
remarks=bom_data.get("remarks"),
sort_order=bom_data.get("sort_order", 0),
)
db.add(bom_item)
restored["projects"] += 1
else:
skipped["projects"] += 1
skipped_details["projects"].append(project_data["name"])
else:
# Create new project
project = Project(
name=project_data["name"],
description=project_data.get("description"),
color=project_data.get("color"),
status=project_data.get("status", "active"),
target_count=project_data.get("target_count"),
notes=project_data.get("notes"),
tags=project_data.get("tags"),
priority=project_data.get("priority", "normal"),
budget=project_data.get("budget"),
is_template=project_data.get("is_template", False),
template_source_id=project_data.get("template_source_id"),
parent_id=project_data.get("parent_id"),
attachments=project_data.get("attachments"),
)
if project_data.get("due_date"):
project.due_date = datetime.fromisoformat(project_data["due_date"])
db.add(project)
await db.flush() # Get the project ID
# Add BOM items
for bom_data in project_data.get("bom_items", []):
bom_item = ProjectBOMItem(
project_id=project.id,
name=bom_data["name"],
quantity_needed=bom_data.get("quantity_needed", 1),
quantity_acquired=bom_data.get("quantity_acquired", 0),
unit_price=bom_data.get("unit_price"),
sourcing_url=bom_data.get("sourcing_url"),
stl_filename=bom_data.get("stl_filename"),
remarks=bom_data.get("remarks"),
sort_order=bom_data.get("sort_order", 0),
)
db.add(bom_item)
restored["projects"] += 1
# Link archives to projects by name (after both are restored)
if "archives" in backup and "projects" in backup:
# Build project name to ID mapping
proj_result = await db.execute(select(Project))
project_name_to_id: dict[str, int] = {}
for proj in proj_result.scalars().all():
project_name_to_id[proj.name] = proj.id
# Update archives with project_id
for archive_data in backup["archives"]:
project_name = archive_data.get("project_name")
if project_name and project_name in project_name_to_id:
content_hash = archive_data.get("content_hash")
if content_hash:
result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
archive = result.scalar_one_or_none()
if archive:
archive.project_id = project_name_to_id[project_name]
# Restore print queue (must be after archives and projects)
if "print_queue" in backup:
# Build lookups
printer_serial_to_id: dict[str, int] = {}
archive_hash_to_id: dict[str, int] = {}
project_name_to_id: dict[str, int] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_serial_to_id[pr.serial_number] = pr.id
ar_result = await db.execute(select(PrintArchive))
for ar in ar_result.scalars().all():
if ar.content_hash:
archive_hash_to_id[ar.content_hash] = ar.id
proj_result = await db.execute(select(Project))
for proj in proj_result.scalars().all():
project_name_to_id[proj.name] = proj.id
restored["print_queue"] = 0
skipped["print_queue"] = 0
skipped_details["print_queue"] = []
for qi_data in backup["print_queue"]:
printer_serial = qi_data.get("printer_serial") # Can be None for unassigned items
archive_hash = qi_data.get("archive_hash")
# Archive is required, but printer can be None (unassigned)
if not archive_hash:
skipped["print_queue"] += 1
continue
# Look up printer_id (None if unassigned or printer not found)
printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
archive_id = archive_hash_to_id.get(archive_hash)
# Archive must exist, but printer is optional (unassigned items)
if not archive_id:
skipped["print_queue"] += 1
skipped_details["print_queue"].append(
f"{printer_serial or 'unassigned'}/{archive_hash[:8] if archive_hash else 'N/A'}"
)
continue
# If printer_serial was specified but printer not found, skip
if printer_serial and not printer_id:
skipped["print_queue"] += 1
skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8]}")
continue
project_name = qi_data.get("project_name")
project_id = project_name_to_id.get(project_name) if project_name else None
qi = PrintQueueItem(
printer_id=printer_id, # Can be None for unassigned items
archive_id=archive_id,
project_id=project_id,
position=qi_data.get("position", 0),
require_previous_success=qi_data.get("require_previous_success", False),
auto_off_after=qi_data.get("auto_off_after", False),
manual_start=qi_data.get("manual_start", False),
ams_mapping=qi_data.get("ams_mapping"),
plate_id=qi_data.get("plate_id"),
bed_levelling=qi_data.get("bed_levelling", True),
flow_cali=qi_data.get("flow_cali", False),
vibration_cali=qi_data.get("vibration_cali", True),
layer_inspect=qi_data.get("layer_inspect", False),
timelapse=qi_data.get("timelapse", False),
use_ams=qi_data.get("use_ams", True),
status=qi_data.get("status", "pending"),
error_message=qi_data.get("error_message"),
)
if qi_data.get("scheduled_time"):
qi.scheduled_time = datetime.fromisoformat(qi_data["scheduled_time"])
if qi_data.get("started_at"):
qi.started_at = datetime.fromisoformat(qi_data["started_at"])
if qi_data.get("completed_at"):
qi.completed_at = datetime.fromisoformat(qi_data["completed_at"])
db.add(qi)
restored["print_queue"] += 1
# Restore pending uploads (skip duplicates by filename)
if "pending_uploads" in backup:
# Ensure the pending uploads directory exists
pending_uploads_dir = base_dir / "virtual_printer" / "uploads"
pending_uploads_dir.mkdir(parents=True, exist_ok=True)
for upload_data in backup["pending_uploads"]:
# Check for existing by filename
result = await db.execute(
select(PendingUpload).where(
PendingUpload.filename == upload_data["filename"],
PendingUpload.status == "pending",
)
)
existing = result.scalar_one_or_none()
if existing:
if overwrite:
# Update existing
existing.file_size = upload_data.get("file_size", 0)
existing.source_ip = upload_data.get("source_ip")
existing.tags = upload_data.get("tags")
existing.notes = upload_data.get("notes")
existing.project_id = upload_data.get("project_id")
# Update file path if file was restored from ZIP
if upload_data.get("file_path"):
restored_file = base_dir / upload_data["file_path"]
if restored_file.exists():
# Move to proper location
target_path = pending_uploads_dir / upload_data["filename"]
if restored_file != target_path:
import shutil
shutil.move(str(restored_file), str(target_path))
existing.file_path = str(target_path)
restored["pending_uploads"] += 1
else:
skipped["pending_uploads"] += 1
skipped_details["pending_uploads"].append(upload_data["filename"])
else:
# Determine file path
file_path_str = None
if upload_data.get("file_path"):
restored_file = base_dir / upload_data["file_path"]
if restored_file.exists():
# Move to proper location
target_path = pending_uploads_dir / upload_data["filename"]
if restored_file != target_path:
import shutil
shutil.move(str(restored_file), str(target_path))
file_path_str = str(target_path)
# Parse uploaded_at
uploaded_at = None
if upload_data.get("uploaded_at"):
try:
uploaded_at = datetime.fromisoformat(upload_data["uploaded_at"].replace("Z", "+00:00"))
except (ValueError, AttributeError):
uploaded_at = datetime.utcnow()
else:
uploaded_at = datetime.utcnow()
pending = PendingUpload(
filename=upload_data["filename"],
file_path=file_path_str or "",
file_size=upload_data.get("file_size", 0),
source_ip=upload_data.get("source_ip"),
status="pending",
tags=upload_data.get("tags"),
notes=upload_data.get("notes"),
project_id=upload_data.get("project_id"),
uploaded_at=uploaded_at,
)
db.add(pending)
restored["pending_uploads"] += 1
# Restore API keys (generates new keys since we can't restore the hash)
new_api_keys: list[dict] = [] # Track newly generated keys for response
if "api_keys" in backup:
from backend.app.core.auth import generate_api_key
# Build printer serial to ID mapping
printer_serial_to_id: dict[str, int] = {}
pr_result = await db.execute(select(Printer))
for pr in pr_result.scalars().all():
printer_serial_to_id[pr.serial_number] = pr.id
restored["api_keys"] = 0
skipped["api_keys"] = 0
skipped_details["api_keys"] = []
for key_data in backup["api_keys"]:
# Check if key with same name already exists
result = await db.execute(select(APIKey).where(APIKey.name == key_data["name"]))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
# Update permissions but keep the existing key
existing.can_queue = key_data.get("can_queue", True)
existing.can_control_printer = key_data.get("can_control_printer", False)
existing.can_read_status = key_data.get("can_read_status", True)
existing.enabled = key_data.get("enabled", True)
if key_data.get("expires_at"):
existing.expires_at = datetime.fromisoformat(key_data["expires_at"])
# Convert printer serials to IDs
if key_data.get("printer_serials"):
existing.printer_ids = [
printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
]
restored["api_keys"] += 1
else:
skipped["api_keys"] += 1
skipped_details["api_keys"].append(key_data["name"])
else:
# Generate new key
full_key, key_hash, key_prefix = generate_api_key()
# Convert printer serials to IDs
printer_ids = None
if key_data.get("printer_serials"):
printer_ids = [
printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
]
api_key = APIKey(
name=key_data["name"],
key_hash=key_hash,
key_prefix=key_prefix,
can_queue=key_data.get("can_queue", True),
can_control_printer=key_data.get("can_control_printer", False),
can_read_status=key_data.get("can_read_status", True),
printer_ids=printer_ids,
enabled=key_data.get("enabled", True),
)
if key_data.get("expires_at"):
api_key.expires_at = datetime.fromisoformat(key_data["expires_at"])
db.add(api_key)
restored["api_keys"] += 1
# Track the new key so user can see it
new_api_keys.append(
{
"name": key_data["name"],
"key": full_key,
"key_prefix": key_prefix,
}
)
# Restore users (note: passwords not included in backup - users will need new passwords)
# Users are skipped by default since they have no passwords; admin must recreate them
new_users: list[str] = []
if "users" in backup:
from backend.app.core.auth import get_password_hash
for user_data in backup["users"]:
result = await db.execute(select(User).where(User.username == user_data["username"]))
existing = result.scalar_one_or_none()
if existing:
if overwrite:
existing.role = user_data.get("role", "user")
existing.is_active = user_data.get("is_active", True)
# Don't change password - keep existing
restored["users"] += 1
else:
skipped["users"] += 1
skipped_details["users"].append(user_data["username"])
else:
# Create user with a temporary password that must be changed
# Generate a random temporary password
import secrets
temp_password = secrets.token_urlsafe(16)
user = User(
username=user_data["username"],
password_hash=get_password_hash(temp_password),
role=user_data.get("role", "user"),
is_active=user_data.get("is_active", True),
)
db.add(user)
restored["users"] += 1
new_users.append(f"{user_data['username']} (temp password: {temp_password})")
await db.commit()
# If printers were in the backup (restored, updated, or skipped), reconnect all active printers
# This ensures connections are re-established after restore, even if printers were skipped
if "printers" in backup:
# Need fresh query after commit to get proper IDs for newly created printers
result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
active_printers = result.scalars().all()
for printer in active_printers:
# This will disconnect existing connection (if any) and reconnect
try:
await printer_manager.connect_printer(printer)
except Exception:
pass # Connection failed, but don't fail the restore
# If settings were restored, check if Spoolman needs to be reconnected
if "settings" in backup:
spoolman_enabled = await get_setting(db, "spoolman_enabled")
spoolman_url = await get_setting(db, "spoolman_url")
if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
try:
client = await init_spoolman_client(spoolman_url)
if await client.health_check():
pass # Connected successfully
except Exception:
pass # Spoolman connection failed, but don't fail the restore
# Reconfigure virtual printer if settings were restored
try:
from backend.app.services.virtual_printer import virtual_printer_manager
vp_enabled = await get_setting(db, "virtual_printer_enabled")
vp_access_code = await get_setting(db, "virtual_printer_access_code")
vp_mode = await get_setting(db, "virtual_printer_mode")
vp_model = await get_setting(db, "virtual_printer_model")
enabled = vp_enabled and vp_enabled.lower() == "true"
access_code = vp_access_code or ""
mode = vp_mode or "immediate"
model = vp_model or ""
if enabled and access_code:
await virtual_printer_manager.configure(
enabled=True,
access_code=access_code,
mode=mode,
model=model,
)
elif not enabled and virtual_printer_manager.is_enabled:
await virtual_printer_manager.configure(
enabled=False,
access_code=access_code,
mode=mode,
model=model,
)
except Exception:
pass # Virtual printer config failed, but don't fail the restore
# Reconfigure MQTT relay if settings were restored
try:
from backend.app.services.mqtt_relay import mqtt_relay
mqtt_settings = {
"mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true",
"mqtt_broker": await get_setting(db, "mqtt_broker") or "",
"mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
"mqtt_username": await get_setting(db, "mqtt_username") or "",
"mqtt_password": await get_setting(db, "mqtt_password") or "",
"mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy",
"mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
}
await mqtt_relay.configure(mqtt_settings)
except Exception:
pass # MQTT relay config failed, but don't fail the restore
# Build summary message
restored_parts = []
for key, count in restored.items():
if count > 0:
restored_parts.append(f"{count} {key.replace('_', ' ')}")
if files_restored > 0:
restored_parts.append(f"{files_restored} files")
skipped_parts = []
total_skipped = sum(skipped.values())
for key, count in skipped.items():
if count > 0:
skipped_parts.append(f"{count} {key.replace('_', ' ')}")
message_parts = []
if restored_parts:
message_parts.append(f"Restored: {', '.join(restored_parts)}")
if skipped_parts:
message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
response = {
"success": True,
"message": ". ".join(message_parts) if message_parts else "Nothing to restore",
"restored": restored,
"skipped": skipped,
"skipped_details": skipped_details,
"files_restored": files_restored,
"total_skipped": total_skipped,
}
# Include newly generated API keys if any (so user can see them)
if new_api_keys:
response["new_api_keys"] = new_api_keys
# Include newly created users with temp passwords (so admin can share them)
if new_users:
response["new_users"] = new_users
return response
# =============================================================================
# Virtual Printer Settings
# =============================================================================
@router.get("/virtual-printer/models")
async def get_virtual_printer_models():
"""Get available virtual printer models."""
from backend.app.services.virtual_printer import (
DEFAULT_VIRTUAL_PRINTER_MODEL,
VIRTUAL_PRINTER_MODELS,
)
return {
"models": VIRTUAL_PRINTER_MODELS,
"default": DEFAULT_VIRTUAL_PRINTER_MODEL,
}
@router.get("/virtual-printer")
async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
"""Get virtual printer settings and status."""
from backend.app.services.virtual_printer import (
DEFAULT_VIRTUAL_PRINTER_MODEL,
virtual_printer_manager,
)
enabled = await get_setting(db, "virtual_printer_enabled")
access_code = await get_setting(db, "virtual_printer_access_code")
mode = await get_setting(db, "virtual_printer_mode")
model = await get_setting(db, "virtual_printer_model")
return {
"enabled": enabled == "true" if enabled else False,
"access_code_set": bool(access_code),
"mode": mode or "immediate",
"model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
"status": virtual_printer_manager.get_status(),
}
@router.put("/virtual-printer")
async def update_virtual_printer_settings(
enabled: bool = None,
access_code: str = None,
mode: str = None,
model: str = None,
db: AsyncSession = Depends(get_db),
):
"""Update virtual printer settings and restart services if needed."""
from backend.app.services.virtual_printer import (
DEFAULT_VIRTUAL_PRINTER_MODEL,
VIRTUAL_PRINTER_MODELS,
virtual_printer_manager,
)
# Get current values
current_enabled = await get_setting(db, "virtual_printer_enabled") == "true"
current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
# Apply updates
new_enabled = enabled if enabled is not None else current_enabled
new_access_code = access_code if access_code is not None else current_access_code
new_mode = mode if mode is not None else current_mode
new_model = model if model is not None else current_model
# Validate mode
# "review" is the new name for "queue" (pending review before archiving)
# "print_queue" archives and adds to print queue (unassigned)
if new_mode not in ("immediate", "queue", "review", "print_queue"):
return JSONResponse(
status_code=400,
content={"detail": "Mode must be 'immediate', 'review', or 'print_queue'"},
)
# Normalize legacy "queue" to "review" for storage
if new_mode == "queue":
new_mode = "review"
# Validate model
if model is not None and model not in VIRTUAL_PRINTER_MODELS:
return JSONResponse(
status_code=400,
content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
)
# Validate access code when enabling
if new_enabled and not new_access_code:
return JSONResponse(
status_code=400,
content={"detail": "Access code is required when enabling virtual printer"},
)
# Validate access code length (Bambu Studio requires exactly 8 characters)
if access_code is not None and len(access_code) != 8:
return JSONResponse(
status_code=400,
content={"detail": "Access code must be exactly 8 characters"},
)
# Save settings
await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
if access_code is not None:
await set_setting(db, "virtual_printer_access_code", access_code)
await set_setting(db, "virtual_printer_mode", new_mode)
if model is not None:
await set_setting(db, "virtual_printer_model", model)
await db.commit()
db.expire_all()
# Reconfigure virtual printer
try:
await virtual_printer_manager.configure(
enabled=new_enabled,
access_code=new_access_code,
mode=new_mode,
model=new_model,
)
except ValueError as e:
return JSONResponse(
status_code=400,
content={"detail": str(e)},
)
except Exception as e:
return JSONResponse(
status_code=500,
content={"detail": f"Failed to configure virtual printer: {e}"},
)
return await get_virtual_printer_settings(db)
# =============================================================================
# MQTT Relay Settings
# =============================================================================
@router.get("/mqtt/status")
async def get_mqtt_status():
"""Get MQTT relay connection status."""
from backend.app.services.mqtt_relay import mqtt_relay
return mqtt_relay.get_status()