Browse Source

Fix maintenance tasks showing wrong rod type for printer models (#351)

H2/A1 series have linear rails, not carbon rods. X1/P1/P2S have carbon
rods, not linear rails. Split rod-specific maintenance into model-aware
pairs: "Lubricate/Clean Carbon Rods" for X1/P1/P2S and "Lubricate/Clean
Linear Rails" for A1/H2. Added rod type classification to printer_models
and startup cleanup for stale/duplicate system maintenance types.
maziggy 3 months ago
parent
commit
cffe7e62bc

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (`filament_nozzle_map` + `physical_extruder_map` in `project_settings.config`) and constrains filament matching to only AMS trays connected to the correct nozzle via `ams_extruder_map`. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it).
 
 ### Fixed
+- **Model-Specific Maintenance Tasks for Carbon Rods vs Linear Rails** ([#351](https://github.com/maziggy/bambuddy/issues/351)) — Maintenance tasks "Clean Carbon Rods" and "Lubricate Linear Rails" were shown for all printers regardless of motion system. H2 and A1 series use linear rails (not carbon rods), and X1/P1/P2S series use carbon rods (not linear rails). Maintenance types are now classified by rod/rail type: "Lubricate Carbon Rods" and "Clean Carbon Rods" for X1/P1/P2S, "Lubricate Linear Rails" and "Clean Linear Rails" for A1/H2. Stale and duplicate system types are automatically cleaned up on startup. Includes model-specific wiki links and i18n keys for all 4 locales.
 - **AMS Slot Configuration Overwritten on Startup** — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The `on_ams_change` callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending `ams_filament_setting` without a `setting_id`, which cleared the printer's filament preset. Now compares spool RFID identifiers (`tray_uuid` / `tag_uid`) before unlinking — if the same spool is still in the slot, the assignment is preserved and no `ams_filament_setting` command is sent.
 - **Bambu Lab Spool Detection False Positives** — The `is_bambu_lab_spool()` function (backend) and `isBambuLabSpool()` (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). The `tray_info_idx` field (e.g., "GFA00") identifies the filament *type*, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed `tray_info_idx` from detection logic; now uses only hardware RFID identifiers (`tray_uuid` and `tag_uid`) which are physically embedded in genuine Bambu Lab spools.
 - **FTP Disconnect Raises EOFError When Server Dies** — `BambuFTPClient.disconnect()` only caught `OSError` and `ftplib.Error`, but `quit()` raises `EOFError` when the server has closed the connection mid-session. `EOFError` is not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect.

+ 70 - 11
backend/app/api/routes/maintenance.py

@@ -26,6 +26,7 @@ from backend.app.schemas.maintenance import (
     PrinterMaintenanceUpdate,
 )
 from backend.app.services.notification_service import notification_service
+from backend.app.utils.printer_models import get_rod_type
 
 logger = logging.getLogger(__name__)
 
@@ -33,12 +34,33 @@ router = APIRouter(prefix="/maintenance", tags=["maintenance"])
 
 # Default maintenance types
 DEFAULT_MAINTENANCE_TYPES = [
+    # Carbon rod models only (X1/P1/P2S)
+    {
+        "name": "Lubricate Carbon Rods",
+        "description": "Apply lubricant to carbon rods for smooth motion",
+        "default_interval_hours": 50.0,
+        "icon": "Droplet",
+    },
+    {
+        "name": "Clean Carbon Rods",
+        "description": "Wipe carbon rods with a dry cloth",
+        "default_interval_hours": 100.0,
+        "icon": "Sparkles",
+    },
+    # Linear rail models only (A1/H2)
     {
         "name": "Lubricate Linear Rails",
-        "description": "Apply lubricant to linear rails and rods for smooth motion",
+        "description": "Apply lubricant to linear rails for smooth motion",
         "default_interval_hours": 50.0,
         "icon": "Droplet",
     },
+    {
+        "name": "Clean Linear Rails",
+        "description": "Wipe linear rails with a dry cloth to remove dust and debris",
+        "default_interval_hours": 100.0,
+        "icon": "Sparkles",
+    },
+    # Universal (all models)
     {
         "name": "Clean Nozzle/Hotend",
         "description": "Clean nozzle exterior and perform cold pull if needed",
@@ -51,12 +73,6 @@ DEFAULT_MAINTENANCE_TYPES = [
         "default_interval_hours": 200.0,
         "icon": "Ruler",
     },
-    {
-        "name": "Clean Carbon Rods",
-        "description": "Wipe carbon rods with a dry cloth",
-        "default_interval_hours": 100.0,
-        "icon": "Sparkles",
-    },
     {
         "name": "Clean Build Plate",
         "description": "Deep clean build plate with IPA or soap",
@@ -71,6 +87,30 @@ DEFAULT_MAINTENANCE_TYPES = [
     },
 ]
 
+# System types that only apply to printers with a specific rod/rail type.
+# "carbon" = X1/P1/P2S series (carbon rods), "linear_rail" = A1/H2 series.
+# Types not listed here apply to all printers.
+_ROD_TYPE_REQUIREMENTS: dict[str, str] = {
+    "Lubricate Carbon Rods": "carbon",
+    "Clean Carbon Rods": "carbon",
+    "Lubricate Linear Rails": "linear_rail",
+    "Clean Linear Rails": "linear_rail",
+}
+
+
+def _should_apply_to_printer(type_name: str, printer_model: str | None) -> bool:
+    """Check if a system maintenance type should apply to a given printer model."""
+    rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name)
+    if rod_requirement is None:
+        return True  # Not model-specific, applies to all
+
+    rod_type = get_rod_type(printer_model)
+    if rod_type is None:
+        # Unknown model — default to carbon rods (legacy behavior)
+        return rod_requirement == "carbon"
+
+    return rod_type == rod_requirement
+
 
 async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
     """Calculate total active hours for a printer from runtime counter plus offset.
@@ -94,13 +134,27 @@ async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
 
 
 async def ensure_default_types(db: AsyncSession) -> None:
-    """Ensure default maintenance types exist."""
-    result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_system.is_(True)))
+    """Ensure default maintenance types exist, remove stale/duplicate ones."""
+    result = await db.execute(
+        select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).order_by(MaintenanceType.id)
+    )
     existing = result.scalars().all()
-    existing_names = {t.name for t in existing}
 
+    default_names = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
+
+    # Remove stale system types no longer in defaults (e.g. renamed types)
+    # and deduplicate: if concurrent requests created the same type twice,
+    # keep only the first (lowest id) and delete the rest.
+    seen_names: set[str] = set()
+    for t in existing:
+        if t.name not in default_names or t.name in seen_names:
+            await db.delete(t)
+        else:
+            seen_names.add(t.name)
+
+    # Create any missing default types
     for type_def in DEFAULT_MAINTENANCE_TYPES:
-        if type_def["name"] not in existing_names:
+        if type_def["name"] not in seen_names:
             new_type = MaintenanceType(
                 name=type_def["name"],
                 description=type_def["description"],
@@ -228,6 +282,11 @@ async def _get_printer_maintenance_internal(
     now = datetime.utcnow()
 
     for maint_type in all_types:
+        # Skip system types that don't apply to this printer model
+        # (e.g., "Clean Carbon Rods" for H2D which has steel rods)
+        if maint_type.is_system and not _should_apply_to_printer(maint_type.name, printer.model):
+            continue
+
         item = existing_items.get(maint_type.id)
         default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
 

+ 63 - 0
backend/app/utils/printer_models.py

@@ -48,6 +48,69 @@ PRINTER_MODEL_ID_MAP = {
 }
 
 
+# Rod/rail type classification for maintenance tasks.
+# Carbon rods: X1, P1, P2S series (CoreXY with carbon fiber rods)
+# Linear rails: A1, H2 series (linear rail motion system)
+# Values must be uppercase with spaces stripped for normalized comparison.
+CARBON_ROD_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "X1",
+        "X1C",
+        "X1E",
+        "P1P",
+        "P1S",
+        "P2S",
+        # Internal codes
+        "C11",  # X1C
+        "C12",  # X1
+        "C13",  # X1E
+        "N7",  # P2S
+    ]
+)
+
+LINEAR_RAIL_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "A1",
+        "A1MINI",
+        "H2D",
+        "H2DPRO",
+        "H2C",
+        "H2S",
+        # Internal codes
+        "N1",  # A1
+        "N2S",  # A1 Mini
+        "A04",  # A1 Mini (alternate)
+        "A11",  # A1
+        "A12",  # A1 Mini
+        "O1D",  # H2D
+        "O1E",  # H2D Pro
+        "O2D",  # H2D Pro (alternate)
+        "O1C",  # H2C
+        "O1S",  # H2S
+    ]
+)
+
+
+def get_rod_type(model: str | None) -> str | None:
+    """Return the rod/rail type for a printer model.
+
+    Returns:
+        "carbon" for X1/P1/P2S series (carbon fiber rods),
+        "linear_rail" for A1/H2 series (linear rails),
+        None for unknown models.
+    """
+    if not model:
+        return None
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    if normalized in CARBON_ROD_MODELS:
+        return "carbon"
+    if normalized in LINEAR_RAIL_MODELS:
+        return "linear_rail"
+    return None
+
+
 def normalize_printer_model_id(model_id: str | None) -> str | None:
     """Convert printer_model_id (internal code) to normalized short name.
 

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -980,6 +980,7 @@ export default {
     removeFromPrinter: 'Von diesem Drucker entfernen',
     // Types
     types: {
+      lubricateCarbonRods: 'Karbonstäbe schmieren',
       lubricateRails: 'Linearschienen schmieren',
       cleanNozzle: 'Düse/Hotend reinigen',
       checkBelts: 'Riemenspannung prüfen',
@@ -988,6 +989,7 @@ export default {
       checkCooling: 'Kühlungslüfter prüfen',
       generalInspection: 'Allgemeine Inspektion',
       cleanCarbonRods: 'Kohlenstoffstangen reinigen',
+      cleanLinearRails: 'Linearschienen reinigen',
       checkPtfeTube: 'PTFE-Schlauch prüfen',
       replaceHepaFilter: 'HEPA-Filter ersetzen',
       replaceCarbonFilter: 'Aktivkohlefilter ersetzen',
@@ -2977,6 +2979,7 @@ export default {
 
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
+    lubricateCarbonRods: 'Schmiermittel auf Karbonstäbe für sanfte Bewegung auftragen',
     lubricateRails: 'Schmiermittel auf Linearschienen für sanfte Bewegung auftragen',
     cleanNozzle: 'Hotend und Düse reinigen, um Verstopfungen zu verhindern',
     checkBelts: 'Riemenspannung für präzise Drucke überprüfen',
@@ -2985,6 +2988,7 @@ export default {
     checkCooling: 'Sicherstellen, dass Lüfter ordnungsgemäß funktionieren',
     generalInspection: 'Allgemeine Druckerinspektion',
     cleanCarbonRods: 'Karbonstäbe reinigen, um Reibung zu reduzieren',
+    cleanLinearRails: 'Linearschienen abwischen, um Staub und Schmutz zu entfernen',
     checkPtfeTube: 'PTFE-Schlauch auf Verschleiß oder Beschädigung prüfen',
     replaceHepaFilter: 'HEPA-Filter für Luftqualität ersetzen',
     replaceCarbonFilter: 'Aktivkohlefilter ersetzen',

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -980,6 +980,7 @@ export default {
     removeFromPrinter: 'Remove from this printer',
     // Types
     types: {
+      lubricateCarbonRods: 'Lubricate Carbon Rods',
       lubricateRails: 'Lubricate Linear Rails',
       cleanNozzle: 'Clean Nozzle/Hotend',
       checkBelts: 'Check Belt Tension',
@@ -988,6 +989,7 @@ export default {
       checkCooling: 'Check Cooling Fans',
       generalInspection: 'General Inspection',
       cleanCarbonRods: 'Clean Carbon Rods',
+      cleanLinearRails: 'Clean Linear Rails',
       checkPtfeTube: 'Check PTFE Tube',
       replaceHepaFilter: 'Replace HEPA Filter',
       replaceCarbonFilter: 'Replace Carbon Filter',
@@ -2982,6 +2984,7 @@ export default {
 
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
+    lubricateCarbonRods: 'Apply lubricant to carbon rods for smooth motion',
     lubricateRails: 'Apply lubricant to linear rails for smooth motion',
     cleanNozzle: 'Clean hotend and nozzle to prevent clogs',
     checkBelts: 'Verify belt tension for accurate prints',
@@ -2990,6 +2993,7 @@ export default {
     checkCooling: 'Ensure cooling fans are working properly',
     generalInspection: 'General printer inspection',
     cleanCarbonRods: 'Clean carbon rods to reduce friction',
+    cleanLinearRails: 'Wipe linear rails to remove dust and debris',
     checkPtfeTube: 'Inspect PTFE tube for wear or damage',
     replaceHepaFilter: 'Replace HEPA filter for air quality',
     replaceCarbonFilter: 'Replace activated carbon filter',

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -967,6 +967,7 @@ export default {
     removeFromPrinter: 'Rimuovi da questa stampante',
     // Types
     types: {
+      lubricateCarbonRods: 'Lubrifica aste in carbonio',
       lubricateRails: 'Lubrifica guide lineari',
       cleanNozzle: 'Pulisci ugello/Hotend',
       checkBelts: 'Controlla tensione cinghie',
@@ -975,6 +976,7 @@ export default {
       checkCooling: 'Controlla ventole raffreddamento',
       generalInspection: 'Ispezione generale',
       cleanCarbonRods: 'Pulisci aste in carbonio',
+      cleanLinearRails: 'Pulisci guide lineari',
       checkPtfeTube: 'Controlla tubo PTFE',
       replaceHepaFilter: 'Sostituisci filtro HEPA',
       replaceCarbonFilter: 'Sostituisci filtro carbone',
@@ -2651,6 +2653,7 @@ export default {
 
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
+    lubricateCarbonRods: 'Applica lubrificante alle aste in carbonio per un movimento fluido',
     lubricateRails: 'Applica lubrificante alle guide lineari per un movimento fluido',
     cleanNozzle: 'Pulisci hotend e ugello per prevenire intasamenti',
     checkBelts: 'Verifica tensione cinghie per stampe accurate',
@@ -2659,6 +2662,7 @@ export default {
     checkCooling: 'Assicurati che le ventole di raffreddamento funzionino',
     generalInspection: 'Ispezione generale stampante',
     cleanCarbonRods: 'Pulisci le aste in carbonio per ridurre attrito',
+    cleanLinearRails: 'Pulisci le guide lineari per rimuovere polvere e detriti',
     checkPtfeTube: 'Ispeziona il tubo PTFE per usura o danni',
     replaceHepaFilter: 'Sostituisci filtro HEPA per qualità aria',
     replaceCarbonFilter: 'Sostituisci filtro a carbone attivo',

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -1015,6 +1015,7 @@ export default {
     noPrintersAssigned: 'プリンター未割り当て',
     removeFromPrinter: 'このプリンターから削除',
     types: {
+      lubricateCarbonRods: 'カーボンロッドの潤滑',
       lubricateRails: 'リニアレールの潤滑',
       cleanNozzle: 'ノズル/ホットエンドの清掃',
       checkBelts: 'ベルト張力の確認',
@@ -1023,6 +1024,7 @@ export default {
       checkCooling: '冷却ファンの確認',
       generalInspection: '総合点検',
       cleanCarbonRods: 'カーボンロッドの清掃',
+      cleanLinearRails: 'リニアレールの清掃',
       checkPtfeTube: 'PTFEチューブの確認',
       replaceHepaFilter: 'HEPAフィルター交換',
       replaceCarbonFilter: 'カーボンフィルター交換',
@@ -2860,6 +2862,7 @@ export default {
     },
   },
   maintenanceDescriptions: {
+    lubricateCarbonRods: 'カーボンロッドに潤滑剤を塗布してスムーズな動きを確保',
     lubricateRails: 'リニアレールの潤滑',
     cleanNozzle: 'ノズル/ホットエンドの清掃',
     checkBelts: 'ベルト張力の確認',
@@ -2868,6 +2871,7 @@ export default {
     checkCooling: '冷却ファンの確認',
     generalInspection: '総合点検',
     cleanCarbonRods: 'カーボンロッドの清掃',
+    cleanLinearRails: 'リニアレールを拭いてほこりや汚れを除去',
     checkPtfeTube: 'PTFEチューブの確認',
     replaceHepaFilter: 'HEPAフィルター交換',
     replaceCarbonFilter: 'カーボンフィルター交換',

+ 19 - 9
frontend/src/pages/MaintenancePage.tsx

@@ -140,14 +140,19 @@ function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): s
   const isP2S = model.includes('P2S');
 
   switch (typeName) {
-    case 'Lubricate Linear Rails':
+    case 'Lubricate Carbon Rods':
+      // X1, P1, P2S series have carbon rods
       if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/basic-maintenance';
       if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance';
+      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension';
+      return null;
+
+    case 'Lubricate Linear Rails':
+      // A1 and H2 series have linear rails
       if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';
       if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';
       if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
-      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension'; // P2S maintenance page
-      return 'https://wiki.bambulab.com/en/general/lead-screws-lubrication';
+      return null;
 
     case 'Clean Nozzle/Hotend':
       if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';
@@ -168,11 +173,16 @@ function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): s
       return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';
 
     case 'Clean Carbon Rods':
-      // Only X1 and P1 series have carbon rods
-      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
-      // A1, H2, P2S don't have carbon rods - return null
-      if (isA1Mini || isA1 || isH2 || isP2S) return null;
-      return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
+      // X1, P1, P2S series have carbon rods
+      if (isX1 || isP1 || isP2S) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
+      return null;
+
+    case 'Clean Linear Rails':
+      // A1 and H2 series have linear rails
+      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';
+      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
+      return null;
 
     case 'Clean Build Plate':
       // Same for all printers
@@ -381,7 +391,7 @@ function PrinterSection({
   hasPermission: (permission: Permission) => boolean;
   t: TFunction;
 }) {
-  const [expanded, setExpanded] = useState(true);
+  const [expanded, setExpanded] = useState(false);
   const [editingHours, setEditingHours] = useState(false);
   const [hoursInput, setHoursInput] = useState(overview.total_print_hours.toFixed(1));
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D1nHfvlC.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C7KLL0cs.js"></script>
+    <script type="module" crossorigin src="/assets/index-D1nHfvlC.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DMk3iz3Q.css">
   </head>
   <body>

+ 6 - 6
test_backend.sh

@@ -3,9 +3,9 @@
 cd backend
 ruff check && ruff format --check
 
-if [ "$1" = "--full" ]; then
-  ../venv/bin/python3 -m pytest tests/ -v -n 14
-else
-  ../venv/bin/python3 -m pytest tests/ -v -n 14 --ignore=tests/unit/services/test_bambu_ftp.py
-fi
-cd ..
+#if [ "$1" = "--full" ]; then
+#  ../venv/bin/python3 -m pytest tests/ -v -n 14
+#else
+../venv/bin/python3 -m pytest tests/ -v -n 14 --ignore=tests/unit/services/test_bambu_ftp.py
+#fi
+#cd ..

Some files were not shown because too many files changed in this diff