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.
This commit is contained in:
maziggy 2026-02-13 12:23:34 +01:00
parent f9b47282a1
commit cffe7e62bc
11 changed files with 267 additions and 118 deletions

View file

@ -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.

View file

@ -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 Linear Rails",
"description": "Apply lubricant to linear rails and rods for smooth motion",
"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 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"

View file

@ -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.

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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: 'カーボンフィルター交換',

View file

@ -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 one or more lines are too long

View file

@ -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>

View file

@ -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 ..