diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a01c3ae5..7a0c5080d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to Bambuddy will be documented in this file. +## [0.1.6b4] - 2026-01-01 + +### Added +- **Camera popup window improvements** + - Auto-resize to fit video resolution on first open + - Persist window size and position to localStorage + - Restore saved window state for subsequent opens +- **Slicer protocol handler** - OS detection for correct protocol (Windows: `bambustudio://`, macOS/Linux: `bambustudioopen://`) + +### Fixed +- **Maintenance duration display** - Show weeks instead of imprecise months for better countdown precision +- **Print hours display** - Convert large hour values to readable units (e.g., 478h → 3w, 100h → 4d) +- **Printer hour counter** - Fixed runtime_seconds not incrementing during prints (first timestamp was set but never committed) + +### Changed +- **Printer cards** - Refactored AMS section for better visual grouping and spacing + +## [0.1.6b3] - 2025-12-31 + +### Added +- **Customizable Theme System** - Comprehensive theme customization with independent settings for dark and light modes: + - **Style**: Classic (clean shadows), Glow (accent-colored glow effects), Vibrant (dramatic deep shadows) + - **Background**: Neutral, Warm, Cool (light mode) + OLED, Slate, Forest (dark mode only) + - **Accent Colors**: Green, Teal, Blue, Orange, Purple, Red + - All combinations work together (e.g., Glow style + Forest background + Teal accent) + - Settings sync across devices via database + - Live preview in Settings → Appearance + +### Fixed +- **Printer hour counter** - Fixed bug in printer's hour counter display + +### Changed +- **Sidebar power switch** - Added confirmation modal to sidebar's quick power switch + ## [0.1.6b2] - 2025-12-29 ### Added diff --git a/Dockerfile b/Dockerfile index c96c85b88..a24bcf552 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ RUN mkdir -p /app/data /app/logs # Environment variables ENV PYTHONUNBUFFERED=1 ENV DATA_DIR=/app/data +ENV LOG_DIR=/app/logs EXPOSE 8000 diff --git a/README.md b/README.md index 63556d358..e0ac935ee 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ -**Plus:** Dark/light theme • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard +**Plus:** Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard --- diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py index e9a5c85f6..b285014ad 100644 --- a/backend/app/api/routes/cloud.py +++ b/backend/app/api/routes/cloud.py @@ -5,33 +5,36 @@ Handles authentication and profile management with Bambu Cloud. """ import json +import logging from pathlib import Path from typing import Literal -from fastapi import APIRouter, HTTPException, Depends -from sqlalchemy.ext.asyncio import AsyncSession +from fastapi import APIRouter, Body, Depends, HTTPException from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.database import get_db from backend.app.models.settings import Settings -from backend.app.services.bambu_cloud import ( - get_cloud_service, - BambuCloudError, - BambuCloudAuthError, -) from backend.app.schemas.cloud import ( - CloudLoginRequest, - CloudVerifyRequest, - CloudLoginResponse, CloudAuthStatus, - CloudTokenRequest, - SlicerSettingsResponse, - SlicerSetting, CloudDevice, + CloudLoginRequest, + CloudLoginResponse, + CloudTokenRequest, + CloudVerifyRequest, + SlicerSetting, SlicerSettingCreate, - SlicerSettingUpdate, SlicerSettingDeleteResponse, + SlicerSettingsResponse, + SlicerSettingUpdate, ) +from backend.app.services.bambu_cloud import ( + BambuCloudAuthError, + BambuCloudError, + get_cloud_service, +) + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/cloud", tags=["cloud"]) @@ -43,9 +46,7 @@ CLOUD_EMAIL_KEY = "bambu_cloud_email" async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]: """Get stored cloud token and email from database.""" - result = await db.execute( - select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])) - ) + result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))) settings = {s.key: s.value for s in result.scalars().all()} return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY) @@ -64,9 +65,7 @@ async def store_token(db: AsyncSession, token: str, email: str) -> None: async def clear_token(db: AsyncSession) -> None: """Clear stored cloud token and email.""" - result = await db.execute( - select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])) - ) + result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))) for setting in result.scalars().all(): await db.delete(setting) await db.commit() @@ -213,14 +212,16 @@ async def get_slicer_settings( parsed = [] for s in all_settings: - parsed.append(SlicerSetting( - setting_id=s.get("setting_id", s.get("id", "")), - name=s.get("name", "Unknown"), - type=our_type, - version=s.get("version"), - user_id=s.get("user_id"), - updated_time=s.get("updated_time"), - )) + parsed.append( + SlicerSetting( + setting_id=s.get("setting_id", s.get("id", "")), + name=s.get("name", "Unknown"), + type=our_type, + version=s.get("version"), + user_id=s.get("user_id"), + updated_time=s.get("updated_time"), + ) + ) setattr(result, our_type, parsed) return result @@ -258,6 +259,80 @@ async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db) raise HTTPException(status_code=500, detail=str(e)) +# Cache for filament preset info (setting_id -> {name, k}) +_filament_cache: dict[str, dict] = {} +_filament_cache_time: float = 0 +FILAMENT_CACHE_TTL = 300 # 5 minutes + + +@router.post("/filament-info") +async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)): + """ + Get filament preset info (name and K value) for multiple setting IDs. + + Used to enrich AMS tray tooltips with cloud preset data. + """ + import time + + logger.info(f"get_filament_info called with {len(setting_ids)} IDs: {setting_ids}") + + global _filament_cache, _filament_cache_time + + # Clear stale cache + if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL: + _filament_cache = {} + _filament_cache_time = time.time() + + token, _ = await get_stored_token(db) + if not token: + logger.info("get_filament_info: Not authenticated, returning empty") + # Return empty results if not authenticated (graceful degradation) + return {} + + cloud = get_cloud_service() + cloud.set_token(token) + + if not cloud.is_authenticated: + return {} + + result = {} + for setting_id in setting_ids: + if not setting_id: + continue + + # Check cache first + if setting_id in _filament_cache: + result[setting_id] = _filament_cache[setting_id] + continue + + try: + data = await cloud.get_setting_detail(setting_id) + setting = data.get("setting", {}) + + # Extract name (e.g., "Bambu PLA Basic Jade White") + name = data.get("name", "") + + # Extract K value (pressure_advance) + k_value = setting.get("pressure_advance") + if k_value is not None: + try: + k_value = float(k_value) + except (ValueError, TypeError): + k_value = None + + info = {"name": name, "k": k_value} + _filament_cache[setting_id] = info + result[setting_id] = info + + except Exception as e: + logger.warning(f"Failed to get cloud preset {setting_id}: {e}") + # Cache the failure to avoid repeated requests + _filament_cache[setting_id] = {"name": "", "k": None} + result[setting_id] = {"name": "", "k": None} + + return result + + @router.get("/devices", response_model=list[CloudDevice]) async def get_devices(db: AsyncSession = Depends(get_db)): """ @@ -425,7 +500,7 @@ def _load_fields(preset_type: str) -> dict: if not file_path.exists(): raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}") - with open(file_path, "r") as f: + with open(file_path) as f: data = json.load(f) _fields_cache[preset_type] = data diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 2f12c9ddc..30ecdbf34 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -169,6 +169,16 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) ams_exists = False raw_data = state.raw_data or {} + # Build K-profile lookup map: cali_idx -> k_value + # This allows looking up the calibrated K value for each AMS slot + kprofile_map: dict[int, float] = {} + for kp in state.kprofiles or []: + if kp.slot_id is not None and kp.k_value: + try: + kprofile_map[kp.slot_id] = float(kp.k_value) + except (ValueError, TypeError): + pass + if "ams" in raw_data and isinstance(raw_data["ams"], list): ams_exists = True for ams_data in raw_data["ams"]: @@ -184,6 +194,13 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) tray_uuid = tray_data.get("tray_uuid", "") if tray_uuid in ("", "00000000000000000000000000000000"): tray_uuid = None + + # Get K value: first try tray's k field, then lookup from K-profiles + k_value = tray_data.get("k") + cali_idx = tray_data.get("cali_idx") + if k_value is None and cali_idx is not None and cali_idx in kprofile_map: + k_value = kprofile_map[cali_idx] + trays.append( AMSTray( id=tray_data.get("id", 0), @@ -193,7 +210,8 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) tray_id_name=tray_data.get("tray_id_name"), tray_info_idx=tray_data.get("tray_info_idx"), remain=tray_data.get("remain", 0), - k=tray_data.get("k"), + k=k_value, + cali_idx=cali_idx, tag_uid=tag_uid, tray_uuid=tray_uuid, nozzle_temp_min=tray_data.get("nozzle_temp_min"), @@ -239,13 +257,23 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) vt_tray_uuid = vt_data.get("tray_uuid", "") if vt_tray_uuid in ("", "00000000000000000000000000000000"): vt_tray_uuid = None + + # Get K value: first try tray's k field, then lookup from K-profiles + vt_k_value = vt_data.get("k") + vt_cali_idx = vt_data.get("cali_idx") + if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map: + vt_k_value = kprofile_map[vt_cali_idx] + vt_tray = AMSTray( id=254, # Virtual tray ID tray_color=vt_data.get("tray_color"), tray_type=vt_data.get("tray_type"), tray_sub_brands=vt_data.get("tray_sub_brands"), + tray_id_name=vt_data.get("tray_id_name"), + tray_info_idx=vt_data.get("tray_info_idx"), remain=vt_data.get("remain", 0), - k=vt_data.get("k"), + k=vt_k_value, + cali_idx=vt_cali_idx, tag_uid=vt_tag_uid, tray_uuid=vt_tray_uuid, nozzle_temp_min=vt_data.get("nozzle_temp_min"), @@ -977,7 +1005,7 @@ async def debug_simulate_print_complete( "timelapse_was_active": False, } - logger.info(f"[DEBUG] Simulating print complete for printer {printer_id}, archive {archive.id}") + logger.info(f"Simulating print complete for printer {printer_id}, archive {archive.id}") # Call the actual on_print_complete handler await on_print_complete(printer_id, data) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 50ebae51c..fc266f05f 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -14,10 +14,11 @@ from backend.app.core.database import get_db 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 MaintenanceType +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 @@ -42,14 +43,13 @@ async def get_setting(db: AsyncSession, key: str) -> str | None: async def set_setting(db: AsyncSession, key: str, value: str) -> None: """Set a single setting value.""" - result = await db.execute(select(Settings).where(Settings.key == key)) - setting = result.scalar_one_or_none() + from sqlalchemy import func + from sqlalchemy.dialects.sqlite import insert as sqlite_insert - if setting: - setting.value = value - else: - setting = Settings(key=key, value=value) - db.add(setting) + # 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) @@ -180,7 +180,10 @@ async def export_backup( 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 and records"), + 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"), @@ -202,10 +205,20 @@ async def export_backup( # 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, @@ -221,12 +234,16 @@ async def export_backup( "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_id": getattr(p, "printer_id", None), + "printer_serial": printer_serial, } ) backup["included"].append("notification_providers") @@ -253,12 +270,19 @@ async def export_backup( 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, "ip_address": plug.ip_address, - "printer_id": plug.printer_id, + "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, @@ -316,6 +340,7 @@ async def export_backup( "is_active": printer.is_active, "auto_archive": printer.auto_archive, "print_hours_offset": printer.print_hours_offset, + "runtime_seconds": printer.runtime_seconds, } if include_access_codes: printer_data["access_code"] = printer.access_code @@ -368,6 +393,99 @@ async def export_backup( ) 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), + "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, + "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) backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path) @@ -394,10 +512,18 @@ async def export_backup( 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, @@ -485,6 +611,8 @@ async def export_backup( "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, @@ -671,10 +799,79 @@ async def import_backup( 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), + ) + 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"]) ) @@ -694,12 +891,16 @@ async def import_backup( 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 = provider_data.get("printer_id") + existing.printer_id = printer_id restored["notification_providers"] += 1 else: skipped["notification_providers"] += 1 @@ -719,12 +920,16 @@ async def import_backup( 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=provider_data.get("printer_id"), + printer_id=printer_id, ) db.add(provider) restored["notification_providers"] += 1 @@ -754,14 +959,25 @@ async def import_backup( 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") + result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])) existing = result.scalar_one_or_none() if existing: if overwrite: existing.name = plug_data["name"] - existing.printer_id = plug_data.get("printer_id") + 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) @@ -785,7 +1001,7 @@ async def import_backup( plug = SmartPlug( name=plug_data["name"], ip_address=plug_data["ip_address"], - printer_id=plug_data.get("printer_id"), + 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), @@ -837,58 +1053,6 @@ async def import_backup( db.add(link) restored["external_links"] += 1 - # Restore printers (skip or overwrite duplicates by serial_number) - 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) - - # 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), - ) - db.add(printer) - restored["printers"] += 1 - # Restore filaments (skip or overwrite duplicates by name+type+brand) if "filaments" in backup: for filament_data in backup["filaments"]: @@ -965,8 +1129,138 @@ async def import_backup( 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") @@ -981,11 +1275,16 @@ async def import_backup( # 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"), @@ -1032,6 +1331,8 @@ async def import_backup( 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"]) @@ -1069,6 +1370,8 @@ async def import_backup( 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"): @@ -1113,6 +1416,68 @@ async def import_backup( 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") + archive_hash = qi_data.get("archive_hash") + + if not printer_serial or not archive_hash: + skipped["print_queue"] += 1 + continue + + printer_id = printer_serial_to_id.get(printer_serial) + archive_id = archive_hash_to_id.get(archive_hash) + + if not printer_id or not archive_id: + skipped["print_queue"] += 1 + skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8] if archive_hash else 'N/A'}") + 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, + 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), + 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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9b3e47f00..a4bfe3db9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,4 +1,5 @@ import logging +import os from pathlib import Path from pydantic_settings import BaseSettings @@ -7,14 +8,23 @@ from pydantic_settings import BaseSettings APP_VERSION = "0.1.6b3" GITHUB_REPO = "maziggy/bambuddy" -# Base directory for path calculations -_base_dir = Path(__file__).resolve().parent.parent.parent.parent +# App directory - where the application is installed (for static files) +_app_dir = Path(__file__).resolve().parent.parent.parent.parent + +# Data directory - for persistent data (database, archives) +# Use DATA_DIR env var if set (Docker), otherwise use project root (local dev) +_data_dir_env = os.environ.get("DATA_DIR") +_data_dir = Path(_data_dir_env) if _data_dir_env else _app_dir + +# Log directory - use LOG_DIR env var if set, otherwise use app_dir/logs +_log_dir_env = os.environ.get("LOG_DIR") +_log_dir = Path(_log_dir_env) if _log_dir_env else _app_dir / "logs" def _migrate_database() -> Path: """Migrate database from old name to new name if needed.""" - old_db = _base_dir / "bambutrack.db" - new_db = _base_dir / "bambuddy.db" + old_db = _data_dir / "bambutrack.db" + new_db = _data_dir / "bambuddy.db" # If old database exists and new one doesn't, rename it if old_db.exists() and not new_db.exists(): @@ -40,10 +50,10 @@ class Settings(BaseSettings): debug: bool = False # Default to production mode # Paths - base_dir: Path = _base_dir - archive_dir: Path = base_dir / "archive" - static_dir: Path = base_dir / "static" - log_dir: Path = base_dir / "logs" + base_dir: Path = _data_dir # For backwards compatibility + archive_dir: Path = _data_dir / "archive" + static_dir: Path = _app_dir / "static" # Static files are part of app, not data + log_dir: Path = _log_dir database_url: str = f"sqlite+aiosqlite:///{_db_path}" # Logging diff --git a/backend/app/main.py b/backend/app/main.py index e7c68d428..64e5d87c4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1466,6 +1466,8 @@ async def track_printer_runtime(): now = datetime.now() updated_count = 0 + needs_commit = False + for printer in printers: # Get current state from printer manager state = printer_manager.get_status(printer.id) @@ -1481,15 +1483,22 @@ async def track_printer_runtime(): if elapsed > 0 and elapsed < RUNTIME_TRACKING_INTERVAL * 2: printer.runtime_seconds += int(elapsed) updated_count += 1 + needs_commit = True + else: + # First time seeing printer active - need to commit to save timestamp + needs_commit = True printer.last_runtime_update = now else: # Printer is idle/offline - clear last_runtime_update - printer.last_runtime_update = None + if printer.last_runtime_update is not None: + printer.last_runtime_update = None + needs_commit = True - if updated_count > 0: + if needs_commit: await db.commit() - logger.debug(f"Updated runtime for {updated_count} printer(s)") + if updated_count > 0: + logger.debug(f"Updated runtime for {updated_count} printer(s)") except asyncio.CancelledError: logger.info("Runtime tracking cancelled") diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index bd29ecf8f..f46065646 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -1,4 +1,5 @@ from datetime import datetime + from pydantic import BaseModel, Field @@ -54,7 +55,8 @@ class AMSTray(BaseModel): tray_id_name: str | None = None # Bambu filament ID like "A00-Y2" (can decode to color) tray_info_idx: str | None = None # Filament preset ID like "GFA00" remain: int = 0 - k: float | None = None # Pressure advance value + k: float | None = None # Pressure advance value (from tray or K-profile lookup) + cali_idx: int | None = None # Calibration index for K-profile lookup tag_uid: str | None = None # RFID tag UID (any tag) tray_uuid: str | None = None # Bambu Lab spool UUID (32-char hex) nozzle_temp_min: int | None = None # Min nozzle temperature @@ -76,6 +78,7 @@ class NozzleInfoResponse(BaseModel): class PrintOptionsResponse(BaseModel): """AI detection and print options from xcam data.""" + # Core AI detectors spaghetti_detector: bool = False print_halt: bool = False diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index a1e167162..295ab61e9 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -56,6 +56,18 @@ class AppSettings(BaseModel): virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication") virtual_printer_mode: str = Field(default="immediate", description="Archive mode: 'immediate' or 'queue'") + # Dark mode theme settings + dark_style: str = Field(default="classic", description="Dark mode style: classic, glow, vibrant") + dark_background: str = Field( + default="neutral", description="Dark mode background: neutral, warm, cool, oled, slate, forest" + ) + dark_accent: str = Field(default="green", description="Dark mode accent: green, teal, blue, orange, purple, red") + + # Light mode theme settings + light_style: str = Field(default="classic", description="Light mode style: classic, glow, vibrant") + light_background: str = Field(default="neutral", description="Light mode background: neutral, warm, cool") + light_accent: str = Field(default="green", description="Light mode accent: green, teal, blue, orange, purple, red") + class AppSettingsUpdate(BaseModel): """Schema for updating settings (all fields optional).""" @@ -84,3 +96,9 @@ class AppSettingsUpdate(BaseModel): virtual_printer_enabled: bool | None = None virtual_printer_access_code: str | None = None virtual_printer_mode: str | None = None + dark_style: str | None = None + dark_background: str | None = None + dark_accent: str | None = None + light_style: str | None = None + light_background: str | None = None + light_accent: str | None = None diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 777d05407..358698138 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -733,9 +733,9 @@ class BambuMQTTClient: parsed_tray_now = raw_tray_now if raw_tray_now is not None else 255 # H2D dual-nozzle printers report only slot number (0-3), not global tray ID - # Use pending_tray_target from our load command tracking for disambiguation + # Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to if parsed_tray_now >= 0 and parsed_tray_now <= 3: - # Check if we have a pending target that matches this slot + # First, check if we have a pending target that matches this slot pending_target = self.state.pending_tray_target if pending_target is not None: pending_slot = pending_target % 4 @@ -758,28 +758,45 @@ class BambuMQTTClient: # Clear pending target since it's stale self.state.pending_tray_target = None else: - # No pending target - check if we already have a resolved global ID - # that matches this slot (from a previous successful disambiguation) - current_tray = self.state.tray_now - if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now: - # Current tray_now is already a valid global ID that matches this slot - # Keep it (don't overwrite with raw slot number) - logger.debug( - f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} " - f"(matches incoming slot {parsed_tray_now})" + # No pending target - use active_extruder + ams_extruder_map to disambiguate + # Find which AMS is connected to the active extruder + active_ext = self.state.active_extruder # 0=right, 1=left + ams_map = self.state.ams_extruder_map # {ams_id: extruder_id} + + # Find the AMS connected to the active extruder + active_ams_id = None + for ams_id_str, ext_id in ams_map.items(): + if ext_id == active_ext: + try: + active_ams_id = int(ams_id_str) + except ValueError: + pass + break + + if active_ams_id is not None: + # Calculate global tray ID using the active AMS + global_tray_id = active_ams_id * 4 + parsed_tray_now + logger.info( + f"[{self.serial_number}] H2D tray_now disambiguation: " + f"slot {parsed_tray_now} + active_extruder {active_ext} -> AMS {active_ams_id} -> global ID {global_tray_id}" ) + self.state.tray_now = global_tray_id else: - # No pending target and no valid existing global ID - # For H2D with multiple AMS units, we can't reliably determine which AMS - # the slot belongs to without a pending_tray_target from our load command. - # Use slot number as-is - this may be incorrect for multi-AMS setups, - # but it's better than guessing wrong based on unreliable heuristics. - # The user can load filament via our API to get correct tracking. - logger.warning( - f"[{self.serial_number}] H2D tray_now: no pending target, " - f"using slot {parsed_tray_now} as global ID (may be incorrect for multi-AMS)" - ) - self.state.tray_now = parsed_tray_now + # No AMS found for active extruder - check if we already have a resolved global ID + current_tray = self.state.tray_now + if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now: + # Current tray_now is already a valid global ID that matches this slot + logger.debug( + f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} " + f"(matches incoming slot {parsed_tray_now})" + ) + else: + # Fallback: use slot as-is + logger.warning( + f"[{self.serial_number}] H2D tray_now: no ams_extruder_map for active_extruder {active_ext}, " + f"using slot {parsed_tray_now} as global ID (may be incorrect for multi-AMS)" + ) + self.state.tray_now = parsed_tray_now else: # tray_now > 3 means it's already a global ID, or 255 means unloaded # Note: Do NOT clear pending_tray_target on tray_now=255 here. @@ -806,15 +823,6 @@ class BambuMQTTClient: # Extract ams_extruder_map from each AMS unit's info field # According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left) - # Log AMS unit fields once to discover available fields - if not hasattr(self, "_ams_fields_logged") and ams_list: - first_unit = ams_list[0] - logger.info(f"[{self.serial_number}] AMS unit fields: {sorted(first_unit.keys())}") - for ams_unit in ams_list: - ams_id = ams_unit.get("id") - unit_info = {k: v for k, v in ams_unit.items() if k != "tray"} - logger.info(f"[{self.serial_number}] AMS {ams_id} data: {unit_info}") - self._ams_fields_logged = True ams_extruder_map = {} for ams_unit in ams_list: diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 27d11eb4b..f4e115c87 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -338,6 +338,15 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> vt_tray = None raw_data = state.raw_data or {} + # Build K-profile lookup map: cali_idx -> k_value + kprofile_map: dict[int, float] = {} + for kp in state.kprofiles or []: + if kp.slot_id is not None and kp.k_value: + try: + kprofile_map[kp.slot_id] = float(kp.k_value) + except (ValueError, TypeError): + pass + if "ams" in raw_data and isinstance(raw_data["ams"], list): for ams_data in raw_data["ams"]: trays = [] @@ -348,16 +357,28 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> tray_uuid = tray.get("tray_uuid") if tray_uuid in ("", "00000000000000000000000000000000"): tray_uuid = None + + # Get K value: first try tray's k field, then lookup from K-profiles + k_value = tray.get("k") + cali_idx = tray.get("cali_idx") + if k_value is None and cali_idx is not None and cali_idx in kprofile_map: + k_value = kprofile_map[cali_idx] + trays.append( { "id": tray.get("id", 0), "tray_color": tray.get("tray_color"), "tray_type": tray.get("tray_type"), "tray_sub_brands": tray.get("tray_sub_brands"), + "tray_id_name": tray.get("tray_id_name"), + "tray_info_idx": tray.get("tray_info_idx"), "remain": tray.get("remain", 0), - "k": tray.get("k"), + "k": k_value, + "cali_idx": cali_idx, "tag_uid": tag_uid, "tray_uuid": tray_uuid, + "nozzle_temp_min": tray.get("nozzle_temp_min"), + "nozzle_temp_max": tray.get("nozzle_temp_max"), } ) # Prefer humidity_raw (actual percentage) over humidity (index 1-5) @@ -396,13 +417,30 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> vt_tag_uid = vt_data.get("tag_uid") if vt_tag_uid in ("", "0000000000000000"): vt_tag_uid = None + vt_tray_uuid = vt_data.get("tray_uuid") + if vt_tray_uuid in ("", "00000000000000000000000000000000"): + vt_tray_uuid = None + + # Get K value for vt_tray + vt_k_value = vt_data.get("k") + vt_cali_idx = vt_data.get("cali_idx") + if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map: + vt_k_value = kprofile_map[vt_cali_idx] + vt_tray = { "id": 254, "tray_color": vt_data.get("tray_color"), "tray_type": vt_data.get("tray_type"), "tray_sub_brands": vt_data.get("tray_sub_brands"), + "tray_id_name": vt_data.get("tray_id_name"), + "tray_info_idx": vt_data.get("tray_info_idx"), "remain": vt_data.get("remain", 0), + "k": vt_k_value, + "cali_idx": vt_cali_idx, "tag_uid": vt_tag_uid, + "tray_uuid": vt_tray_uuid, + "nozzle_temp_min": vt_data.get("nozzle_temp_min"), + "nozzle_temp_max": vt_data.get("nozzle_temp_max"), } # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field) diff --git a/backend/tests/integration/test_settings_api.py b/backend/tests/integration/test_settings_api.py index 51a692dd7..5e308ebff 100644 --- a/backend/tests/integration/test_settings_api.py +++ b/backend/tests/integration/test_settings_api.py @@ -53,10 +53,7 @@ class TestSettingsAPI: # Update to opposite value new_value = not original - response = await async_client.put( - "/api/v1/settings/", - json={"auto_archive": new_value} - ) + response = await async_client.put("/api/v1/settings/", json={"auto_archive": new_value}) assert response.status_code == 200 assert response.json()["auto_archive"] == new_value @@ -65,10 +62,7 @@ class TestSettingsAPI: @pytest.mark.integration async def test_update_currency(self, async_client: AsyncClient): """Verify currency can be updated.""" - response = await async_client.put( - "/api/v1/settings/", - json={"currency": "EUR"} - ) + response = await async_client.put("/api/v1/settings/", json={"currency": "EUR"}) assert response.status_code == 200 assert response.json()["currency"] == "EUR" @@ -77,10 +71,7 @@ class TestSettingsAPI: @pytest.mark.integration async def test_update_date_format(self, async_client: AsyncClient): """Verify date format can be updated.""" - response = await async_client.put( - "/api/v1/settings/", - json={"date_format": "eu"} - ) + response = await async_client.put("/api/v1/settings/", json={"date_format": "eu"}) assert response.status_code == 200 assert response.json()["date_format"] == "eu" @@ -89,10 +80,7 @@ class TestSettingsAPI: @pytest.mark.integration async def test_update_time_format(self, async_client: AsyncClient): """Verify time format can be updated.""" - response = await async_client.put( - "/api/v1/settings/", - json={"time_format": "24h"} - ) + response = await async_client.put("/api/v1/settings/", json={"time_format": "24h"}) assert response.status_code == 200 assert response.json()["time_format"] == "24h" @@ -101,10 +89,7 @@ class TestSettingsAPI: @pytest.mark.integration async def test_update_filament_cost(self, async_client: AsyncClient): """Verify default filament cost can be updated.""" - response = await async_client.put( - "/api/v1/settings/", - json={"default_filament_cost": 30.0} - ) + response = await async_client.put("/api/v1/settings/", json={"default_filament_cost": 30.0}) assert response.status_code == 200 assert response.json()["default_filament_cost"] == 30.0 @@ -113,10 +98,7 @@ class TestSettingsAPI: @pytest.mark.integration async def test_update_energy_cost(self, async_client: AsyncClient): """Verify energy cost can be updated.""" - response = await async_client.put( - "/api/v1/settings/", - json={"energy_cost_per_kwh": 0.20} - ) + response = await async_client.put("/api/v1/settings/", json={"energy_cost_per_kwh": 0.20}) assert response.status_code == 200 assert response.json()["energy_cost_per_kwh"] == 0.20 @@ -132,7 +114,7 @@ class TestSettingsAPI: "date_format": "iso", "time_format": "12h", "save_thumbnails": False, - } + }, ) assert response.status_code == 200 @@ -152,7 +134,7 @@ class TestSettingsAPI: "spoolman_enabled": True, "spoolman_url": "http://localhost:7912", "spoolman_sync_mode": "manual", - } + }, ) assert response.status_code == 200 @@ -172,7 +154,7 @@ class TestSettingsAPI: "ams_humidity_fair": 55, "ams_temp_good": 25.0, "ams_temp_fair": 32.0, - } + }, ) assert response.status_code == 200 @@ -186,10 +168,7 @@ class TestSettingsAPI: @pytest.mark.integration async def test_update_notification_language(self, async_client: AsyncClient): """Verify notification language can be updated.""" - response = await async_client.put( - "/api/v1/settings/", - json={"notification_language": "de"} - ) + response = await async_client.put("/api/v1/settings/", json={"notification_language": "de"}) assert response.status_code == 200 assert response.json()["notification_language"] == "de" @@ -198,15 +177,37 @@ class TestSettingsAPI: # Settings persistence tests # ======================================================================== + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_theme_settings(self, async_client: AsyncClient): + """Verify theme settings can be updated.""" + response = await async_client.put( + "/api/v1/settings/", + json={ + "dark_style": "glow", + "dark_background": "forest", + "dark_accent": "teal", + "light_style": "vibrant", + "light_background": "warm", + "light_accent": "blue", + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["dark_style"] == "glow" + assert result["dark_background"] == "forest" + assert result["dark_accent"] == "teal" + assert result["light_style"] == "vibrant" + assert result["light_background"] == "warm" + assert result["light_accent"] == "blue" + @pytest.mark.asyncio @pytest.mark.integration async def test_settings_persist_after_update(self, async_client: AsyncClient): """CRITICAL: Verify settings changes persist across requests.""" # Update settings - await async_client.put( - "/api/v1/settings/", - json={"currency": "JPY", "check_updates": False} - ) + await async_client.put("/api/v1/settings/", json={"currency": "JPY", "check_updates": False}) # Verify persistence in new request response = await async_client.get("/api/v1/settings/") diff --git a/frontend/mockups/ams-redesign.html b/frontend/mockups/ams-redesign.html new file mode 100644 index 000000000..6df5e2405 --- /dev/null +++ b/frontend/mockups/ams-redesign.html @@ -0,0 +1,1299 @@ + + + + + + AMS Section Redesign Mockup + + + + + + + + +
+ +
+ + +
+ +
+
+ + + + + +
+
+
+ H2D-1 + +
+
H2D • 0.4mm • 632h
+
+
+ + +
+ + + + + + Connected + + -53dBm + + + + + + OK + + + + + + OK + +
+ + +
+
+
+ + + + + +
+
+
Status
+
Idle
+
+
Ready to print
+
+
+
+ + +
+
+ + + +
Left / Right
+
20°C / 19°C
+
+
+ + + +
Bed
+
20°C
+
+
+ + + +
Chamber
+
21°C
+
+
+ + +
+
+ +
+
+ + + + + + + + + +
+
+
AMS-A
+
PLA Basic · PETG HF · PLA Basic · PLA Basic
+
71% · 50% · 87% · 23%
+
+
+
+ + + + 21% +
+
20.7°C
+
+
+ + +
+
+ + + + + + + + + +
+
+
AMS-B
+
PETG HF · PLA · PLA-S · PLA-S
+
45% · 92% · 15% · 68%
+
+
+
+ + + + 16% +
+
22.7°C
+
+
+ + +
+
+ + + + + + + + + +
+
+
AMS-C
+
PLA-S · PLA-S · PETG · PLA
+
33% · 78% · 55% · 41%
+
+
+
+ + + + 16% +
+
22.9°C
+
+
+ + +
+
+ + + + + + +
+
+
HT-A
+
—
+
+
+
+ + + + 47% +
+
19.7°C
+
+
+
+
+ + +
+
+ + + + bamnbuswitch3 + ON + 18W +
+ + +
+ Auto-off +
+
+
+
+ +
+ +
4 AMS rows + 1 HT row = 5 rows in AMS section
+
+
+ + +
+ + +
+ +
+
+ + + + + +
+
+
+ H2D-1 + +
+
H2D • 0.4mm • 632h
+
+
+ + +
+ + + + + + Connected + + -53dBm + + + + + + OK + + + + + + OK + +
+ + +
+
+
+ + + + + +
+
+
Status
+
Idle
+
+
Ready to print
+
+
+
+ + +
+
+ + + +
Left / Right
+
20°C / 19°C
+
+
+ + + +
Bed
+
20°C
+
+
+ + + +
Chamber
+
21°C
+
+
+ + +
+ +
+ +
+
+
+ + + + + + + + + AMS-A +
+
+
+ + + + 21% +
+
20.6°
+
+
+
+
+
+
PLA Basic
+
71%
+
+
+
+
PETG HF
+
50%
+
+
+
+
PLA
+
87%
+
+
+
+
PLA Basic
+
23%
+
+
+
+ + +
+
+
+ + + + + + + + + AMS-B +
+
+
+ + + + 16% +
+
20.7°
+
+
+
+
+
+
PETG HF
+
45%
+
+
+
+
PLA
+
92%
+
+
+
+
PLA-S
+
15%
+
+
+
+
PLA-S
+
68%
+
+
+
+ + +
+
+
+ + + + + + + + + AMS-C +
+
+
+ + + + 17% +
+
22.0°
+
+
+
+
+
+
PLA-S
+
33%
+
+
+
+
PLA-S
+
78%
+
+
+
+
PETG
+
55%
+
+
+
+
PLA
+
41%
+
+
+
+ + +
+
+
+ + + + + + + + + AMS-D +
+
+
+ + + + 9% +
+
21.2°
+
+
+
+
+
+
PLA
+
88%
+
+
+
+
ABS
+
62%
+
+
+
+
PETG
+
29%
+
+
+
+
PLA
+
95%
+
+
+
+
+ + +
+ +
+ + + + + +
+
HT-A
+
Empty
+
+
+
+ + + + 44% +
+
+
+ + +
+ + + + + +
+
HT-B
+
PA-CF
+
+
+
+ + + + 12% +
+
+
+ + +
+
+
+
Ext-1
+
PLA
+
+
+ + +
+
+
+
Ext-2
+
TPU
+
+
+
+
+ + +
+
+ + + + bamnbuswitch3 + ON + 18W +
+ + +
+ Auto-off +
+
+
+
+ +
+ +
3 rows total: Row 1-2 for 4x AMS, Row 3 for 2x HT + 2x Ext (half-size)
+
+
+
+ + diff --git a/frontend/src/__tests__/components/AMSHistoryModal.test.tsx b/frontend/src/__tests__/components/AMSHistoryModal.test.tsx index daac6125b..fcf824f9d 100644 --- a/frontend/src/__tests__/components/AMSHistoryModal.test.tsx +++ b/frontend/src/__tests__/components/AMSHistoryModal.test.tsx @@ -12,6 +12,8 @@ import { api } from '../../api/client'; vi.mock('../../api/client', () => ({ api: { getAMSHistory: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), }, })); diff --git a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx index 8363b9034..8dec3fd4c 100644 --- a/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx +++ b/frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx @@ -16,6 +16,10 @@ import { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings' // Mock the API client vi.mock('../../api/client', () => ({ + api: { + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), + }, virtualPrinterApi: { getSettings: vi.fn(), updateSettings: vi.fn(), diff --git a/frontend/src/__tests__/pages/SystemInfoPage.test.tsx b/frontend/src/__tests__/pages/SystemInfoPage.test.tsx index 73fef248a..75bea562a 100644 --- a/frontend/src/__tests__/pages/SystemInfoPage.test.tsx +++ b/frontend/src/__tests__/pages/SystemInfoPage.test.tsx @@ -12,6 +12,8 @@ import { api } from '../../api/client'; vi.mock('../../api/client', () => ({ api: { getSystemInfo: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), }, })); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index eb03271ac..435bfaf6d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,7 +21,7 @@ async function request( throw new Error(message); } - return response.json(); + return await response.json(); } // Printer types @@ -55,7 +55,8 @@ export interface AMSTray { tray_id_name: string | null; // Bambu filament ID like "A00-Y2" (can decode to color) tray_info_idx: string | null; // Filament preset ID like "GFA00" - maps to cloud setting_id remain: number; - k: number | null; // Pressure advance value + k: number | null; // Pressure advance value (from tray or K-profile lookup) + cali_idx: number | null; // Calibration index for K-profile lookup tag_uid: string | null; // RFID tag UID (any tag) tray_uuid: string | null; // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools) nozzle_temp_min: number | null; // Min nozzle temperature @@ -544,6 +545,14 @@ export interface AppSettings { default_printer_id: number | null; // Telemetry telemetry_enabled: boolean; + // Dark mode theme settings + dark_style: 'classic' | 'glow' | 'vibrant'; + dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest'; + dark_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red'; + // Light mode theme settings + light_style: 'classic' | 'glow' | 'vibrant'; + light_background: 'neutral' | 'warm' | 'cool'; + light_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red'; } export type AppSettingsUpdate = Partial; @@ -1756,6 +1765,11 @@ export const api = { request(`/cloud/fields/${presetType}`), getAllCloudFields: () => request>('/cloud/fields'), + getFilamentInfo: (settingIds: string[]) => + request>('/cloud/filament-info', { + method: 'POST', + body: JSON.stringify(settingIds), + }), // Smart Plugs getSmartPlugs: () => request('/smart-plugs/'), diff --git a/frontend/src/components/AMSHistoryModal.tsx b/frontend/src/components/AMSHistoryModal.tsx index 576def789..6fabab41a 100644 --- a/frontend/src/components/AMSHistoryModal.tsx +++ b/frontend/src/components/AMSHistoryModal.tsx @@ -52,10 +52,10 @@ export function AMSHistoryModal({ thresholds, }: AMSHistoryModalProps) { const { t } = useTranslation(); - const { theme } = useTheme(); + const { mode: themeMode } = useTheme(); const [timeRange, setTimeRange] = useState('24h'); const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode); - const isDark = theme === 'dark'; + const isDark = themeMode === 'dark'; // Close on Escape key useEffect(() => { diff --git a/frontend/src/components/AddExternalLinkModal.tsx b/frontend/src/components/AddExternalLinkModal.tsx index 086508497..fa9417db1 100644 --- a/frontend/src/components/AddExternalLinkModal.tsx +++ b/frontend/src/components/AddExternalLinkModal.tsx @@ -14,7 +14,7 @@ interface AddExternalLinkModalProps { export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) { const queryClient = useQueryClient(); - const { theme } = useTheme(); + const { mode } = useTheme(); const isEditing = !!link; const fileInputRef = useRef(null); @@ -166,7 +166,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
{useCustomIcon && customIconPreview ? ( - + ) : ( )} @@ -233,7 +233,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp /> {useCustomIcon && customIconPreview ? (
- Custom icon + Custom icon Bambuddy @@ -326,7 +326,7 @@ export function Layout() { {/* Logo */}
Bambuddy @@ -379,7 +379,7 @@ export function Layout() { ) : ( LinkIcon && @@ -500,11 +500,11 @@ export function Layout() {
{/* Bottom row: version */} @@ -577,11 +577,11 @@ export function Layout() {
)} diff --git a/frontend/src/components/ModelViewerModal.tsx b/frontend/src/components/ModelViewerModal.tsx index 859cfe248..18c2917c5 100644 --- a/frontend/src/components/ModelViewerModal.tsx +++ b/frontend/src/components/ModelViewerModal.tsx @@ -4,6 +4,7 @@ import { ModelViewer } from './ModelViewer'; import { GcodeViewer } from './GcodeViewer'; import { Button } from './Button'; import { api } from '../api/client'; +import { openInSlicer } from '../utils/slicer'; type ViewTab = '3d' | 'gcode'; @@ -55,11 +56,10 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal }, [archiveId]); const handleOpenInSlicer = () => { - // Use bambustudioopen:// protocol like MakerWorld does // URL must include .3mf filename for Bambu Studio to recognize the format const filename = title || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`; - window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`; + openInSlicer(downloadUrl); }; return ( diff --git a/frontend/src/components/ThemeContext.tsx b/frontend/src/components/ThemeContext.tsx deleted file mode 100644 index a635552e8..000000000 --- a/frontend/src/components/ThemeContext.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; - -type Theme = 'light' | 'dark'; - -interface ThemeContextType { - theme: Theme; - toggleTheme: () => void; - setTheme: (theme: Theme) => void; -} - -const ThemeContext = createContext(undefined); - -export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setThemeState] = useState(() => { - const stored = localStorage.getItem('theme') as Theme | null; - if (stored) return stored; - // Default to dark theme - return 'dark'; - }); - - useEffect(() => { - const root = document.documentElement; - if (theme === 'dark') { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - localStorage.setItem('theme', theme); - }, [theme]); - - const toggleTheme = () => { - setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark')); - }; - - const setTheme = (newTheme: Theme) => { - setThemeState(newTheme); - }; - - return ( - - {children} - - ); -} - -export function useTheme() { - const context = useContext(ThemeContext); - if (!context) { - throw new Error('useTheme must be used within a ThemeProvider'); - } - return context; -} diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx index a635552e8..a87236961 100644 --- a/frontend/src/contexts/ThemeContext.tsx +++ b/frontend/src/contexts/ThemeContext.tsx @@ -1,43 +1,171 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { api } from '../api/client'; -type Theme = 'light' | 'dark'; +type ThemeMode = 'light' | 'dark'; +type ThemeStyle = 'classic' | 'glow' | 'vibrant'; +type DarkBackground = 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest'; +type LightBackground = 'neutral' | 'warm' | 'cool'; +type ThemeAccent = 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red'; interface ThemeContextType { - theme: Theme; - toggleTheme: () => void; - setTheme: (theme: Theme) => void; + mode: ThemeMode; + // Dark mode settings + darkStyle: ThemeStyle; + darkBackground: DarkBackground; + darkAccent: ThemeAccent; + // Light mode settings + lightStyle: ThemeStyle; + lightBackground: LightBackground; + lightAccent: ThemeAccent; + // Actions + toggleMode: () => void; + setMode: (mode: ThemeMode) => void; + setDarkStyle: (style: ThemeStyle) => void; + setDarkBackground: (background: DarkBackground) => void; + setDarkAccent: (accent: ThemeAccent) => void; + setLightStyle: (style: ThemeStyle) => void; + setLightBackground: (background: LightBackground) => void; + setLightAccent: (accent: ThemeAccent) => void; } const ThemeContext = createContext(undefined); export function ThemeProvider({ children }: { children: ReactNode }) { - const [theme, setThemeState] = useState(() => { - const stored = localStorage.getItem('theme') as Theme | null; - if (stored) return stored; - // Default to dark theme - return 'dark'; + // Mode + const [mode, setModeState] = useState(() => { + const stored = localStorage.getItem('theme-mode') as ThemeMode | null; + const legacy = localStorage.getItem('theme') as ThemeMode | null; + return stored || legacy || 'dark'; }); + // Dark mode settings + const [darkStyle, setDarkStyleState] = useState(() => { + return (localStorage.getItem('dark-style') as ThemeStyle) || 'classic'; + }); + const [darkBackground, setDarkBackgroundState] = useState(() => { + return (localStorage.getItem('dark-background') as DarkBackground) || 'neutral'; + }); + const [darkAccent, setDarkAccentState] = useState(() => { + return (localStorage.getItem('dark-accent') as ThemeAccent) || 'green'; + }); + + // Light mode settings + const [lightStyle, setLightStyleState] = useState(() => { + return (localStorage.getItem('light-style') as ThemeStyle) || 'classic'; + }); + const [lightBackground, setLightBackgroundState] = useState(() => { + return (localStorage.getItem('light-background') as LightBackground) || 'neutral'; + }); + const [lightAccent, setLightAccentState] = useState(() => { + return (localStorage.getItem('light-accent') as ThemeAccent) || 'green'; + }); + + // Sync from API on mount + useEffect(() => { + api.getSettings().then((settings) => { + // Dark settings + if (settings.dark_style) { + setDarkStyleState(settings.dark_style as ThemeStyle); + localStorage.setItem('dark-style', settings.dark_style); + } + if (settings.dark_background) { + setDarkBackgroundState(settings.dark_background as DarkBackground); + localStorage.setItem('dark-background', settings.dark_background); + } + if (settings.dark_accent) { + setDarkAccentState(settings.dark_accent as ThemeAccent); + localStorage.setItem('dark-accent', settings.dark_accent); + } + // Light settings + if (settings.light_style) { + setLightStyleState(settings.light_style as ThemeStyle); + localStorage.setItem('light-style', settings.light_style); + } + if (settings.light_background) { + setLightBackgroundState(settings.light_background as LightBackground); + localStorage.setItem('light-background', settings.light_background); + } + if (settings.light_accent) { + setLightAccentState(settings.light_accent as ThemeAccent); + localStorage.setItem('light-accent', settings.light_accent); + } + }).catch(() => {}); + }, []); + + // Apply theme classes based on current mode useEffect(() => { const root = document.documentElement; - if (theme === 'dark') { - root.classList.add('dark'); - } else { - root.classList.remove('dark'); - } - localStorage.setItem('theme', theme); - }, [theme]); - const toggleTheme = () => { - setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark')); + // Remove all theme classes + root.classList.remove( + 'dark', + 'style-classic', 'style-glow', 'style-vibrant', + 'bg-neutral', 'bg-warm', 'bg-cool', 'bg-oled', 'bg-slate', 'bg-forest', + 'accent-green', 'accent-teal', 'accent-blue', 'accent-orange', 'accent-purple', 'accent-red' + ); + + // Apply based on current mode + if (mode === 'dark') { + root.classList.add('dark'); + root.classList.add(`style-${darkStyle}`); + root.classList.add(`bg-${darkBackground}`); + root.classList.add(`accent-${darkAccent}`); + } else { + root.classList.add(`style-${lightStyle}`); + root.classList.add(`bg-${lightBackground}`); + root.classList.add(`accent-${lightAccent}`); + } + + localStorage.setItem('theme-mode', mode); + localStorage.removeItem('theme'); + }, [mode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]); + + const toggleMode = () => setModeState(prev => prev === 'dark' ? 'light' : 'dark'); + const setMode = (m: ThemeMode) => setModeState(m); + + // Dark setters + const setDarkStyle = (v: ThemeStyle) => { + setDarkStyleState(v); + localStorage.setItem('dark-style', v); + api.updateSettings({ dark_style: v }).catch(() => {}); + }; + const setDarkBackground = (v: DarkBackground) => { + setDarkBackgroundState(v); + localStorage.setItem('dark-background', v); + api.updateSettings({ dark_background: v }).catch(() => {}); + }; + const setDarkAccent = (v: ThemeAccent) => { + setDarkAccentState(v); + localStorage.setItem('dark-accent', v); + api.updateSettings({ dark_accent: v }).catch(() => {}); }; - const setTheme = (newTheme: Theme) => { - setThemeState(newTheme); + // Light setters + const setLightStyle = (v: ThemeStyle) => { + setLightStyleState(v); + localStorage.setItem('light-style', v); + api.updateSettings({ light_style: v }).catch(() => {}); + }; + const setLightBackground = (v: LightBackground) => { + setLightBackgroundState(v); + localStorage.setItem('light-background', v); + api.updateSettings({ light_background: v }).catch(() => {}); + }; + const setLightAccent = (v: ThemeAccent) => { + setLightAccentState(v); + localStorage.setItem('light-accent', v); + api.updateSettings({ light_accent: v }).catch(() => {}); }; return ( - + {children} ); @@ -45,8 +173,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) { export function useTheme() { const context = useContext(ThemeContext); - if (!context) { - throw new Error('useTheme must be used within a ThemeProvider'); - } + if (!context) throw new Error('useTheme must be used within ThemeProvider'); return context; } + +export type { ThemeMode, ThemeStyle, DarkBackground, LightBackground, ThemeAccent }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 32a23ad67..f5847012d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5,10 +5,10 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme { - /* Bambu Lab brand colors - always the same */ - --color-bambu-green: #00ae42; - --color-bambu-green-light: #00c64d; - --color-bambu-green-dark: #009438; + /* Accent colors - use CSS variables for theming */ + --color-bambu-green: var(--accent); + --color-bambu-green-light: var(--accent-light); + --color-bambu-green-dark: var(--accent-dark); /* Theme-aware colors via CSS variables */ --color-bambu-dark: var(--bg-primary); @@ -19,8 +19,17 @@ --color-bambu-gray-dark: var(--text-tertiary); } -/* Light mode (default) */ +/* ============================================ + BASE DEFAULTS + ============================================ */ + :root { + /* Default accent color (green) */ + --accent: #00ae42; + --accent-light: #00c64d; + --accent-dark: #009438; + + /* Default light mode background (neutral) */ --bg-primary: #f5f5f5; --bg-secondary: #ffffff; --bg-tertiary: #e5e5e5; @@ -30,6 +39,10 @@ --text-tertiary: #808080; --border-color: #d4d4d4; + /* Default style (classic) */ + --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + --glow-color: transparent; + font-family: 'Inter', system-ui, sans-serif; line-height: 1.5; font-weight: 400; @@ -39,7 +52,7 @@ -moz-osx-font-smoothing: grayscale; } -/* Dark mode */ +/* Dark mode base */ .dark { --bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; @@ -49,6 +62,172 @@ --text-muted: #808080; --text-tertiary: #4a4a4a; --border-color: #3d3d3d; + --card-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +/* ============================================ + LAYER 1: BACKGROUND PALETTES + ============================================ */ + +/* Light mode backgrounds */ +.bg-neutral { + /* Default - already set in :root */ +} + +.bg-warm { + --bg-primary: #faf8f5; + --bg-secondary: #fffefa; + --bg-tertiary: #e8e4dd; + --text-primary: #2d2a26; + --text-secondary: #5c5750; + --text-muted: #7a756c; + --text-tertiary: #9a9590; + --border-color: #d8d4cc; +} + +.bg-cool { + --bg-primary: #f0f4f8; + --bg-secondary: #ffffff; + --bg-tertiary: #dce4ec; + --text-primary: #1a2530; + --text-secondary: #4a5568; + --text-muted: #6b7a8a; + --text-tertiary: #8a9aaa; + --border-color: #c8d4e0; +} + +/* Dark mode backgrounds */ +.dark.bg-neutral { + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --bg-tertiary: #3d3d3d; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --text-muted: #808080; + --text-tertiary: #4a4a4a; + --border-color: #3d3d3d; +} + +.dark.bg-warm { + --bg-primary: #1c1a18; + --bg-secondary: #2e2a26; + --bg-tertiary: #3e3a36; + --text-primary: #f5f0ea; + --text-secondary: #b0a898; + --text-muted: #8a8278; + --text-tertiary: #5a5248; + --border-color: #3e3a36; +} + +.dark.bg-cool { + --bg-primary: #181c20; + --bg-secondary: #262c32; + --bg-tertiary: #363e46; + --text-primary: #f0f4f8; + --text-secondary: #98a8b8; + --text-muted: #788898; + --text-tertiary: #4a5a6a; + --border-color: #363e46; +} + +.dark.bg-oled { + --bg-primary: #000000; + --bg-secondary: #141414; + --bg-tertiary: #1f1f1f; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --text-muted: #707070; + --text-tertiary: #404040; + --border-color: #2a2a2a; +} + +.dark.bg-slate { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --text-tertiary: #475569; + --border-color: #334155; +} + +.dark.bg-forest { + --bg-primary: #121a16; + --bg-secondary: #1c2a22; + --bg-tertiary: #2a3d30; + --text-primary: #e8f5ec; + --text-secondary: #8aa894; + --text-muted: #6a8874; + --text-tertiary: #4a6854; + --border-color: #2a3d30; +} + +/* ============================================ + LAYER 2: STYLE EFFECTS + ============================================ */ + +/* Classic - default, clean minimal shadows */ +.style-classic { + /* Uses default shadows from :root and .dark */ +} + +/* Glow - accent-colored glow effects on cards */ +.style-glow { + --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 25px color-mix(in srgb, var(--accent) 12%, transparent); +} + +.dark.style-glow { + --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 40px color-mix(in srgb, var(--accent) 15%, transparent); +} + +/* Vibrant - dramatic deep shadows, more contrast */ +.style-vibrant { + --card-shadow: 0 8px 30px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.dark.style-vibrant { + --card-shadow: 0 10px 40px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4); +} + +/* ============================================ + LAYER 3: ACCENT COLORS + ============================================ */ + +.accent-green { + --accent: #00ae42; + --accent-light: #00c64d; + --accent-dark: #009438; +} + +.accent-teal { + --accent: #14b8a6; + --accent-light: #2dd4bf; + --accent-dark: #0d9488; +} + +.accent-blue { + --accent: #3b82f6; + --accent-light: #60a5fa; + --accent-dark: #2563eb; +} + +.accent-orange { + --accent: #f97316; + --accent-light: #fb923c; + --accent-dark: #ea580c; +} + +.accent-purple { + --accent: #8b5cf6; + --accent-light: #a78bfa; + --accent-dark: #7c3aed; +} + +.accent-red { + --accent: #ef4444; + --accent-light: #f87171; + --accent-dark: #dc2626; } body { @@ -181,3 +360,8 @@ body { .animate-slide-in-left { animation: slide-in-left 0.3s ease-out; } + +/* Card shadows - uses theme-specific shadow */ +.card-shadow { + box-shadow: var(--card-shadow); +} diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 975db81c5..671de713c 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -42,6 +42,7 @@ import { FolderKanban, } from 'lucide-react'; import { api } from '../api/client'; +import { openInSlicer } from '../utils/slicer'; import { useIsMobile } from '../hooks/useIsMobile'; import type { Archive, ProjectListItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; @@ -233,7 +234,7 @@ function ArchiveCard({ onClick: () => { const filename = archive.print_name || archive.filename || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`; - window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`; + openInSlicer(downloadUrl); }, }, ] : [ @@ -243,7 +244,7 @@ function ArchiveCard({ onClick: () => { const filename = archive.print_name || archive.filename || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`; - window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`; + openInSlicer(downloadUrl); }, }, ]), @@ -498,7 +499,7 @@ function ArchiveCard({ // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source'; const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`; - window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`; + openInSlicer(downloadUrl); }} title="Open source 3MF in Bambu Studio (right-click for more options)" > @@ -684,7 +685,7 @@ function ArchiveCard({ onClick={() => { const filename = archive.print_name || archive.filename || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`; - window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`; + openInSlicer(downloadUrl); }} title="Open in Bambu Studio" > @@ -700,7 +701,7 @@ function ArchiveCard({ onClick={() => { const filename = archive.print_name || archive.filename || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`; - window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`; + openInSlicer(downloadUrl); }} title="Open in Bambu Studio to slice" > @@ -1850,7 +1851,7 @@ export function ArchivesPage() { onClick={() => { const filename = archive.print_name || archive.filename || 'model'; const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`; - window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`; + openInSlicer(downloadUrl); }} title="Slice" > diff --git a/frontend/src/pages/CameraPage.tsx b/frontend/src/pages/CameraPage.tsx index 288b9a7cb..02887a503 100644 --- a/frontend/src/pages/CameraPage.tsx +++ b/frontend/src/pages/CameraPage.tsx @@ -100,6 +100,43 @@ export function CameraPage() { return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); + // Save window size and position when user resizes or moves (only for popup windows) + useEffect(() => { + if (!window.opener) return; + + let saveTimeout: NodeJS.Timeout; + const saveWindowState = () => { + // Debounce to avoid saving during drag + clearTimeout(saveTimeout); + saveTimeout = setTimeout(() => { + localStorage.setItem('cameraWindowState', JSON.stringify({ + width: window.outerWidth, + height: window.outerHeight, + left: window.screenX, + top: window.screenY, + })); + }, 500); + }; + + window.addEventListener('resize', saveWindowState); + // Use interval to detect position changes (no native 'move' event) + const positionInterval = setInterval(() => { + const saved = localStorage.getItem('cameraWindowState'); + if (saved) { + const state = JSON.parse(saved); + if (state.left !== window.screenX || state.top !== window.screenY) { + saveWindowState(); + } + } + }, 1000); + + return () => { + clearTimeout(saveTimeout); + clearInterval(positionInterval); + window.removeEventListener('resize', saveWindowState); + }; + }, []); + // Clean up reconnect timers on unmount useEffect(() => { return () => { @@ -178,6 +215,28 @@ export function CameraPage() { if (countdownIntervalRef.current) { clearInterval(countdownIntervalRef.current); } + + // Auto-resize popup window to fit video content (only if no saved preference) + if (window.opener && imgRef.current && !localStorage.getItem('cameraWindowState')) { + const img = imgRef.current; + const videoWidth = img.naturalWidth; + const videoHeight = img.naturalHeight; + + if (videoWidth > 0 && videoHeight > 0) { + // Add space for header bar (~45px) and some padding + const headerHeight = 45; + const padding = 16; + + // Calculate window size (outer size includes chrome) + const chromeWidth = window.outerWidth - window.innerWidth; + const chromeHeight = window.outerHeight - window.innerHeight; + + const targetWidth = videoWidth + padding + chromeWidth; + const targetHeight = videoHeight + headerHeight + padding + chromeHeight; + + window.resizeTo(targetWidth, targetHeight); + } + } }; const stopStream = () => { diff --git a/frontend/src/pages/ExternalLinkPage.tsx b/frontend/src/pages/ExternalLinkPage.tsx index 12c837b85..b67f7ac1f 100644 --- a/frontend/src/pages/ExternalLinkPage.tsx +++ b/frontend/src/pages/ExternalLinkPage.tsx @@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext'; export function ExternalLinkPage() { const { id } = useParams<{ id: string }>(); - const { theme } = useTheme(); + const { mode } = useTheme(); const { data: link, isLoading, error } = useQuery({ queryKey: ['external-link', id], @@ -35,7 +35,7 @@ export function ExternalLinkPage() {