| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- """API routes for K-profile (pressure advance) management."""
- import asyncio
- import logging
- from fastapi import APIRouter, Depends, HTTPException
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.auth import RequirePermissionIfAuthEnabled
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
- from backend.app.models.printer import Printer
- from backend.app.models.user import User
- from backend.app.schemas.kprofile import (
- KProfile,
- KProfileCreate,
- KProfileDelete,
- KProfileNote,
- KProfileNoteResponse,
- KProfilesResponse,
- )
- from backend.app.services.printer_manager import printer_manager
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/printers/{printer_id}/kprofiles", tags=["kprofiles"])
- @router.get("/", response_model=KProfilesResponse)
- async def get_kprofiles(
- printer_id: int,
- nozzle_diameter: str = "0.4",
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
- ):
- """Get K-profiles from a printer.
- Args:
- printer_id: ID of the printer
- nozzle_diameter: Filter by nozzle diameter (default: "0.4")
- """
- # Check printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Get MQTT client for printer
- client = printer_manager.get_client(printer_id)
- if not client or not client.state.connected:
- raise HTTPException(400, "Printer not connected")
- # Request K-profiles from printer
- profiles = await client.get_kprofiles(nozzle_diameter=nozzle_diameter)
- # Convert from MQTT dataclass to Pydantic schema
- return KProfilesResponse(
- profiles=[
- KProfile(
- slot_id=p.slot_id,
- extruder_id=p.extruder_id,
- nozzle_id=p.nozzle_id,
- nozzle_diameter=p.nozzle_diameter,
- filament_id=p.filament_id,
- name=p.name,
- k_value=p.k_value,
- n_coef=p.n_coef,
- ams_id=p.ams_id,
- tray_id=p.tray_id,
- setting_id=p.setting_id,
- )
- for p in profiles
- ],
- nozzle_diameter=nozzle_diameter,
- )
- @router.post("/", response_model=dict)
- async def set_kprofile(
- printer_id: int,
- profile: KProfileCreate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
- ):
- """Create or update a K-profile on the printer.
- For H2D edits (slot_id > 0), this performs an in-place edit using cali_idx.
- For other printers or new profiles, this adds a new profile.
- Args:
- printer_id: ID of the printer
- profile: K-profile data to set
- """
- is_edit = profile.slot_id > 0
- operation = "edit" if is_edit else "add"
- logger.info(
- f"[API] set_kprofile ({operation}): printer={printer_id}, slot_id={profile.slot_id}, "
- f"extruder_id={profile.extruder_id}, nozzle_id={profile.nozzle_id}, "
- f"name={profile.name}, filament_id={profile.filament_id}, k_value={profile.k_value}"
- )
- # Check printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Get MQTT client for printer
- client = printer_manager.get_client(printer_id)
- if not client or not client.state.connected:
- raise HTTPException(400, "Printer not connected")
- # Detect dual-nozzle for the in-place edit format. Runtime detection from
- # device.extruder.info beats serial-prefix heuristics — H2S shares prefix
- # "094" with H2D but is single-nozzle (#1386). Model name is the fallback
- # for the brief window after connect before push data arrives.
- from backend.app.utils.printer_models import is_dual_nozzle_model
- is_dual_nozzle = client._is_dual_nozzle or is_dual_nozzle_model(printer.model)
- if is_edit and is_dual_nozzle:
- # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
- logger.info("[API] Dual-nozzle in-place edit: cali_idx=%s", profile.slot_id)
- success = client.set_kprofile(
- filament_id=profile.filament_id,
- name=profile.name,
- k_value=profile.k_value,
- nozzle_diameter=profile.nozzle_diameter,
- nozzle_id=profile.nozzle_id,
- extruder_id=profile.extruder_id,
- setting_id=None,
- slot_id=0,
- cali_idx=profile.slot_id, # Pass the original slot for in-place edit
- )
- elif is_edit:
- # Single-nozzle edit: use delete + add approach
- logger.info("[API] Edit: deleting existing profile slot_id=%s", profile.slot_id)
- delete_success = client.delete_kprofile(
- cali_idx=profile.slot_id,
- filament_id=profile.filament_id,
- nozzle_id=profile.nozzle_id,
- nozzle_diameter=profile.nozzle_diameter,
- extruder_id=profile.extruder_id,
- setting_id=profile.setting_id,
- )
- if not delete_success:
- raise HTTPException(500, "Failed to delete existing K-profile for edit")
- # Wait for printer to process the delete before adding
- await asyncio.sleep(0.5)
- logger.info("[API] Edit: delete complete, now adding updated profile")
- success = client.set_kprofile(
- filament_id=profile.filament_id,
- name=profile.name,
- k_value=profile.k_value,
- nozzle_diameter=profile.nozzle_diameter,
- nozzle_id=profile.nozzle_id,
- extruder_id=profile.extruder_id,
- setting_id=None, # Generate new setting_id for add
- slot_id=0, # Always 0 for add (new profile)
- )
- else:
- # New profile: add with slot_id=0
- success = client.set_kprofile(
- filament_id=profile.filament_id,
- name=profile.name,
- k_value=profile.k_value,
- nozzle_diameter=profile.nozzle_diameter,
- nozzle_id=profile.nozzle_id,
- extruder_id=profile.extruder_id,
- setting_id=None, # Generate new setting_id for add
- slot_id=0, # Always 0 for add (new profile)
- )
- if not success:
- raise HTTPException(500, "Failed to send K-profile command")
- message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
- return {"success": True, "message": message}
- @router.post("/batch", response_model=dict)
- async def set_kprofiles_batch(
- printer_id: int,
- profiles: list[KProfileCreate],
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
- ):
- """Create multiple K-profiles in a single command (for dual-nozzle).
- This sends all profiles in one MQTT command, which is more reliable
- for dual-nozzle printers that may not handle sequential commands well.
- Args:
- printer_id: ID of the printer
- profiles: List of K-profiles to set
- """
- if not profiles:
- raise HTTPException(400, "No profiles provided")
- logger.info("[API] set_kprofiles_batch: printer=%s, %s profiles", printer_id, len(profiles))
- for p in profiles:
- logger.info(" - extruder_id=%s, name=%s, k_value=%s", p.extruder_id, p.name, p.k_value)
- # Check printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Get MQTT client for printer
- client = printer_manager.get_client(printer_id)
- if not client or not client.state.connected:
- raise HTTPException(400, "Printer not connected")
- # Build list of profile dicts for batch command
- profile_dicts = [
- {
- "filament_id": p.filament_id,
- "name": p.name,
- "k_value": p.k_value,
- "nozzle_id": p.nozzle_id,
- "extruder_id": p.extruder_id,
- "setting_id": p.setting_id,
- "slot_id": p.slot_id,
- }
- for p in profiles
- ]
- # Get nozzle_diameter from first profile (all should have same)
- nozzle_diameter = profiles[0].nozzle_diameter
- success = client.set_kprofiles_batch(profile_dicts, nozzle_diameter)
- if not success:
- raise HTTPException(500, "Failed to send K-profiles batch command")
- return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
- @router.delete("/", response_model=dict)
- async def delete_kprofile(
- printer_id: int,
- profile: KProfileDelete,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
- ):
- """Delete a K-profile from the printer.
- Args:
- printer_id: ID of the printer
- profile: K-profile identification data for deletion
- """
- # Check printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Get MQTT client for printer
- client = printer_manager.get_client(printer_id)
- if not client or not client.state.connected:
- raise HTTPException(400, "Printer not connected")
- # Send the delete command to printer
- logger.info(
- f"[API] delete_kprofile: printer={printer_id}, slot_id={profile.slot_id}, "
- f"setting_id={profile.setting_id}, filament_id={profile.filament_id}"
- )
- success = client.delete_kprofile(
- cali_idx=profile.slot_id,
- filament_id=profile.filament_id,
- nozzle_id=profile.nozzle_id,
- nozzle_diameter=profile.nozzle_diameter,
- extruder_id=profile.extruder_id,
- setting_id=profile.setting_id,
- )
- if not success:
- raise HTTPException(500, "Failed to send K-profile delete command")
- # Wait for printer to process the delete before frontend refetches
- await asyncio.sleep(0.5)
- return {"success": True, "message": "K-profile deleted successfully"}
- @router.get("/notes", response_model=KProfileNoteResponse)
- async def get_kprofile_notes(
- printer_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
- ):
- """Get all K-profile notes for a printer.
- Notes are stored locally since printers don't support notes.
- Args:
- printer_id: ID of the printer
- """
- # Check printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Get all notes for this printer
- result = await db.execute(select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id))
- notes = result.scalars().all()
- # Return as a dictionary mapping setting_id -> note
- return KProfileNoteResponse(notes={note.setting_id: note.note for note in notes})
- @router.put("/notes", response_model=dict)
- async def set_kprofile_note(
- printer_id: int,
- note_data: KProfileNote,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
- ):
- """Set or update a note for a K-profile.
- Args:
- printer_id: ID of the printer
- note_data: The note data (setting_id and note content)
- """
- # Check printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Find existing note or create new one
- result = await db.execute(
- select(KProfileNoteModel).where(
- KProfileNoteModel.printer_id == printer_id,
- KProfileNoteModel.setting_id == note_data.setting_id,
- )
- )
- existing_note = result.scalar_one_or_none()
- if note_data.note.strip():
- # Save or update note
- if existing_note:
- existing_note.note = note_data.note
- else:
- new_note = KProfileNoteModel(
- printer_id=printer_id,
- setting_id=note_data.setting_id,
- note=note_data.note,
- )
- db.add(new_note)
- await db.commit()
- return {"success": True, "message": "Note saved"}
- else:
- # Delete note if empty
- if existing_note:
- await db.delete(existing_note)
- await db.commit()
- return {"success": True, "message": "Note deleted"}
- @router.delete("/notes/{setting_id}", response_model=dict)
- async def delete_kprofile_note(
- printer_id: int,
- setting_id: str,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
- ):
- """Delete a note for a K-profile.
- Args:
- printer_id: ID of the printer
- setting_id: The setting_id of the K-profile
- """
- # Check printer exists
- result = await db.execute(select(Printer).where(Printer.id == printer_id))
- printer = result.scalar_one_or_none()
- if not printer:
- raise HTTPException(404, "Printer not found")
- # Find and delete the note
- result = await db.execute(
- select(KProfileNoteModel).where(
- KProfileNoteModel.printer_id == printer_id,
- KProfileNoteModel.setting_id == setting_id,
- )
- )
- existing_note = result.scalar_one_or_none()
- if existing_note:
- await db.delete(existing_note)
- await db.commit()
- return {"success": True, "message": "Note deleted"}
|