mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Added Spoolman support to add unknown Bambu Lab spools and track them
This commit is contained in:
@@ -88,6 +88,12 @@ v∆v
|
||||
- Configurable event triggers (start, complete, failed, stopped, progress milestones)
|
||||
- Quiet hours to suppress notifications during sleep
|
||||
- Per-printer filtering
|
||||
- **Spoolman Integration** - Sync AMS filament data with your Spoolman server
|
||||
- Automatic or manual sync modes
|
||||
- Per-printer or all-printer sync
|
||||
- Matches Bambu Lab spools by unique tray UUID
|
||||
- Tracks filament usage during prints
|
||||
- Third-party spools (SpoolEase, etc.) gracefully skipped
|
||||
- **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
|
||||
- **File Manager** - Browse and manage files on your printer's SD card
|
||||
- **Re-print** - Send archived prints back to any connected printer
|
||||
@@ -598,6 +604,66 @@ By default, notifications are sent for all printers. To limit notifications to a
|
||||
2. Select a printer from the **Printer** dropdown
|
||||
3. Only events from that printer will trigger notifications
|
||||
|
||||
### Spoolman Integration
|
||||
|
||||
Bambusy integrates with [Spoolman](https://github.com/Donkie/Spoolman) for filament inventory management. When enabled, AMS filament data syncs with your Spoolman server, allowing you to track remaining filament across all your spools.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- A running Spoolman server (self-hosted or Docker)
|
||||
- Bambu Lab spools with original RFID tags in your AMS
|
||||
- Spools registered in Spoolman with matching filament types
|
||||
|
||||
#### Setting Up Spoolman
|
||||
|
||||
1. Go to **Settings** > scroll to **Spoolman Integration**
|
||||
2. Enable the **Enable Spoolman** toggle
|
||||
3. Enter your Spoolman server URL (e.g., `http://192.168.1.100:7912`)
|
||||
4. Click **Save**
|
||||
5. Click **Connect** to establish the connection
|
||||
|
||||
#### Sync Modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| **Automatic** | AMS data syncs automatically when changes are detected (filament loaded/unloaded, usage during prints) |
|
||||
| **Manual Only** | Only sync when you click the Sync button |
|
||||
|
||||
#### Manual Sync
|
||||
|
||||
When connected:
|
||||
1. Select a specific printer from the dropdown, or "All Printers"
|
||||
2. Click **Sync** to sync AMS data to Spoolman
|
||||
3. Results show how many trays were synced
|
||||
|
||||
#### How Syncing Works
|
||||
|
||||
Bambusy matches AMS spools to Spoolman spools using the **tray UUID** - a unique 32-character identifier that Bambu Lab assigns to each original spool. This ensures consistent matching across different printer models.
|
||||
|
||||
**What gets synced:**
|
||||
- Remaining filament weight (from AMS sensor)
|
||||
- Filament usage during prints (deducted from Spoolman inventory)
|
||||
|
||||
**Limitations:**
|
||||
- Only **Bambu Lab original spools** can be synced (they have valid tray UUIDs)
|
||||
- Third-party spools (SpoolEase, refilled spools, etc.) are gracefully skipped - they won't cause errors
|
||||
- Spools must exist in Spoolman before syncing - Bambusy doesn't create new spools automatically
|
||||
|
||||
#### Troubleshooting Spoolman
|
||||
|
||||
**"Spool not found in Spoolman" errors:**
|
||||
- The Bambu Lab spool exists in your AMS but hasn't been added to Spoolman yet
|
||||
- Add the spool in Spoolman's web interface, matching the filament type and color
|
||||
|
||||
**Third-party spools showing in AMS:**
|
||||
- SpoolEase and other third-party spools are automatically skipped during sync
|
||||
- This is normal behavior - they don't have Bambu Lab tray UUIDs
|
||||
|
||||
**Connection issues:**
|
||||
- Verify the Spoolman URL is accessible from your Bambusy server
|
||||
- Check that no firewall is blocking port 7912 (or your custom port)
|
||||
- Ensure Spoolman is running and healthy
|
||||
|
||||
#### Provider Setup Guides
|
||||
|
||||
**WhatsApp (CallMeBot):**
|
||||
@@ -799,9 +865,9 @@ To fix the printer's clock:
|
||||
- [x] Automatic finish photo capture
|
||||
- [x] K-Profiles management (pressure advance)
|
||||
- [x] Push notifications (WhatsApp, ntfy, Pushover, Telegram, Email)
|
||||
- [x] Spoolman integration (filament inventory sync)
|
||||
- [ ] Maintenance tracker
|
||||
- [ ] Full printer control
|
||||
- [ ] Spoolman support
|
||||
- [ ] Mobile-optimized UI
|
||||
- [ ] docs: readme -> wiki
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
|
||||
for setting in db_settings:
|
||||
if setting.key in settings_dict:
|
||||
# Parse the value based on the expected type
|
||||
if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo"]:
|
||||
if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled"]:
|
||||
settings_dict[setting.key] = setting.value.lower() == "true"
|
||||
elif setting.key in ["default_filament_cost", "energy_cost_per_kwh"]:
|
||||
settings_dict[setting.key] = float(setting.value)
|
||||
@@ -99,3 +99,36 @@ async def check_ffmpeg():
|
||||
"installed": ffmpeg_path is not None,
|
||||
"path": ffmpeg_path,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/spoolman")
|
||||
async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
|
||||
"""Get Spoolman integration settings."""
|
||||
spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
|
||||
spoolman_url = await get_setting(db, "spoolman_url") or ""
|
||||
spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
|
||||
|
||||
return {
|
||||
"spoolman_enabled": spoolman_enabled,
|
||||
"spoolman_url": spoolman_url,
|
||||
"spoolman_sync_mode": spoolman_sync_mode,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/spoolman")
|
||||
async def update_spoolman_settings(
|
||||
settings: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update Spoolman integration settings."""
|
||||
if "spoolman_enabled" in settings:
|
||||
await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
|
||||
if "spoolman_url" in settings:
|
||||
await set_setting(db, "spoolman_url", settings["spoolman_url"])
|
||||
if "spoolman_sync_mode" in settings:
|
||||
await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Return updated settings
|
||||
return await get_spoolman_settings(db)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Spoolman integration API routes."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.services.spoolman import (
|
||||
SpoolmanClient,
|
||||
get_spoolman_client,
|
||||
init_spoolman_client,
|
||||
close_spoolman_client,
|
||||
)
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/spoolman", tags=["spoolman"])
|
||||
|
||||
|
||||
class SpoolmanStatus(BaseModel):
|
||||
"""Spoolman connection status."""
|
||||
|
||||
enabled: bool
|
||||
connected: bool
|
||||
url: str | None
|
||||
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
"""Result of a Spoolman sync operation."""
|
||||
|
||||
success: bool
|
||||
synced_count: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
|
||||
"""Get Spoolman settings from database.
|
||||
|
||||
Returns:
|
||||
Tuple of (enabled, url, sync_mode)
|
||||
"""
|
||||
enabled = False
|
||||
url = ""
|
||||
sync_mode = "auto"
|
||||
|
||||
result = await db.execute(select(Settings))
|
||||
for setting in result.scalars().all():
|
||||
if setting.key == "spoolman_enabled":
|
||||
enabled = setting.value.lower() == "true"
|
||||
elif setting.key == "spoolman_url":
|
||||
url = setting.value
|
||||
elif setting.key == "spoolman_sync_mode":
|
||||
sync_mode = setting.value
|
||||
|
||||
return enabled, url, sync_mode
|
||||
|
||||
|
||||
@router.get("/status", response_model=SpoolmanStatus)
|
||||
async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Get Spoolman integration status."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
|
||||
client = await get_spoolman_client()
|
||||
connected = False
|
||||
if client:
|
||||
connected = await client.health_check()
|
||||
|
||||
return SpoolmanStatus(
|
||||
enabled=enabled,
|
||||
connected=connected,
|
||||
url=url if url else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/connect")
|
||||
async def connect_spoolman(db: AsyncSession = Depends(get_db)):
|
||||
"""Connect to Spoolman server using configured URL."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
|
||||
if not enabled:
|
||||
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
||||
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
||||
|
||||
try:
|
||||
client = await init_spoolman_client(url)
|
||||
connected = await client.health_check()
|
||||
|
||||
if not connected:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Could not connect to Spoolman at {url}",
|
||||
)
|
||||
|
||||
return {"success": True, "message": f"Connected to Spoolman at {url}"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Spoolman: {e}")
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def disconnect_spoolman():
|
||||
"""Disconnect from Spoolman server."""
|
||||
await close_spoolman_client()
|
||||
return {"success": True, "message": "Disconnected from Spoolman"}
|
||||
|
||||
|
||||
@router.post("/sync/{printer_id}", response_model=SyncResult)
|
||||
async def sync_printer_ams(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Sync AMS data from a specific printer to Spoolman."""
|
||||
# Check if Spoolman is enabled and connected
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
||||
|
||||
client = await get_spoolman_client()
|
||||
if not client:
|
||||
# Try to connect
|
||||
if url:
|
||||
client = await init_spoolman_client(url)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
||||
|
||||
if not await client.health_check():
|
||||
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
||||
|
||||
# Get printer info
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
|
||||
# Get current printer state with AMS data
|
||||
state = printer_manager.get_status(printer_id)
|
||||
if not state:
|
||||
raise HTTPException(status_code=404, detail="Printer not connected")
|
||||
|
||||
if not state.raw_data:
|
||||
raise HTTPException(status_code=400, detail="No AMS data available")
|
||||
|
||||
ams_data = state.raw_data.get("ams")
|
||||
if not ams_data:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No AMS data in printer state. Try triggering a slot re-read on the printer.",
|
||||
)
|
||||
|
||||
# Sync each AMS tray to Spoolman
|
||||
synced = 0
|
||||
errors = []
|
||||
|
||||
# Handle different AMS data structures
|
||||
# Traditional AMS: list of {"id": N, "tray": [...]} dicts
|
||||
# H2D/newer printers: dict with different structure
|
||||
ams_units = []
|
||||
if isinstance(ams_data, list):
|
||||
ams_units = ams_data
|
||||
elif isinstance(ams_data, dict):
|
||||
# H2D format: check for "ams" key containing list, or "tray" key directly
|
||||
if "ams" in ams_data and isinstance(ams_data["ams"], list):
|
||||
ams_units = ams_data["ams"]
|
||||
elif "tray" in ams_data:
|
||||
# Single AMS unit format - wrap in list
|
||||
ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
|
||||
else:
|
||||
logger.info(f"AMS dict keys for debugging: {list(ams_data.keys())}")
|
||||
|
||||
if not ams_units:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
|
||||
)
|
||||
|
||||
for ams_unit in ams_units:
|
||||
if not isinstance(ams_unit, dict):
|
||||
continue
|
||||
|
||||
ams_id = int(ams_unit.get("id", 0))
|
||||
trays = ams_unit.get("tray", [])
|
||||
|
||||
for tray_data in trays:
|
||||
if not isinstance(tray_data, dict):
|
||||
continue
|
||||
|
||||
tray = client.parse_ams_tray(ams_id, tray_data)
|
||||
if not tray:
|
||||
continue # Empty tray
|
||||
|
||||
# Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
|
||||
if not client.is_bambu_lab_spool(tray.tray_uuid):
|
||||
continue
|
||||
|
||||
try:
|
||||
sync_result = await client.sync_ams_tray(tray, printer.name)
|
||||
if sync_result:
|
||||
synced += 1
|
||||
logger.info(
|
||||
f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}"
|
||||
)
|
||||
else:
|
||||
# Bambu Lab spool that wasn't synced (not found in Spoolman)
|
||||
errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
|
||||
except Exception as e:
|
||||
error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
|
||||
logger.error(error_msg)
|
||||
errors.append(error_msg)
|
||||
|
||||
return SyncResult(
|
||||
success=len(errors) == 0,
|
||||
synced_count=synced,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync-all", response_model=SyncResult)
|
||||
async def sync_all_printers(db: AsyncSession = Depends(get_db)):
|
||||
"""Sync AMS data from all connected printers to Spoolman."""
|
||||
# Check if Spoolman is enabled
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
||||
|
||||
client = await get_spoolman_client()
|
||||
if not client:
|
||||
if url:
|
||||
client = await init_spoolman_client(url)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
||||
|
||||
if not await client.health_check():
|
||||
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
||||
|
||||
# Get all active printers
|
||||
result = await db.execute(select(Printer).where(Printer.is_active == True))
|
||||
printers = result.scalars().all()
|
||||
|
||||
total_synced = 0
|
||||
all_errors = []
|
||||
|
||||
for printer in printers:
|
||||
state = printer_manager.get_status(printer.id)
|
||||
if not state or not state.raw_data:
|
||||
continue
|
||||
|
||||
ams_data = state.raw_data.get("ams")
|
||||
if not ams_data:
|
||||
continue
|
||||
|
||||
# Handle different AMS data structures
|
||||
# Traditional AMS: list of {"id": N, "tray": [...]} dicts
|
||||
# H2D/newer printers: dict with different structure
|
||||
ams_units = []
|
||||
if isinstance(ams_data, list):
|
||||
ams_units = ams_data
|
||||
elif isinstance(ams_data, dict):
|
||||
# H2D format: check for "ams" key containing list, or "tray" key directly
|
||||
if "ams" in ams_data and isinstance(ams_data["ams"], list):
|
||||
ams_units = ams_data["ams"]
|
||||
elif "tray" in ams_data:
|
||||
# Single AMS unit format - wrap in list
|
||||
ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
|
||||
else:
|
||||
logger.debug(f"Printer {printer.name} AMS dict keys: {list(ams_data.keys())}")
|
||||
|
||||
if not ams_units:
|
||||
logger.debug(f"Printer {printer.name} has no AMS units to sync (type: {type(ams_data).__name__})")
|
||||
continue
|
||||
|
||||
for ams_unit in ams_units:
|
||||
if not isinstance(ams_unit, dict):
|
||||
logger.debug(f"Skipping non-dict AMS unit: {type(ams_unit)}")
|
||||
continue
|
||||
|
||||
ams_id = int(ams_unit.get("id", 0))
|
||||
trays = ams_unit.get("tray", [])
|
||||
|
||||
for tray_data in trays:
|
||||
if not isinstance(tray_data, dict):
|
||||
continue
|
||||
|
||||
tray = client.parse_ams_tray(ams_id, tray_data)
|
||||
if not tray:
|
||||
continue
|
||||
|
||||
# Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
|
||||
if not client.is_bambu_lab_spool(tray.tray_uuid):
|
||||
continue
|
||||
|
||||
try:
|
||||
sync_result = await client.sync_ams_tray(tray, printer.name)
|
||||
if sync_result:
|
||||
total_synced += 1
|
||||
except Exception as e:
|
||||
all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
|
||||
|
||||
return SyncResult(
|
||||
success=len(all_errors) == 0,
|
||||
synced_count=total_synced,
|
||||
errors=all_errors,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/spools")
|
||||
async def get_spools(db: AsyncSession = Depends(get_db)):
|
||||
"""Get all spools from Spoolman."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
||||
|
||||
client = await get_spoolman_client()
|
||||
if not client:
|
||||
if url:
|
||||
client = await init_spoolman_client(url)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
||||
|
||||
if not await client.health_check():
|
||||
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
||||
|
||||
spools = await client.get_spools()
|
||||
return {"spools": spools}
|
||||
|
||||
|
||||
@router.get("/filaments")
|
||||
async def get_filaments(db: AsyncSession = Depends(get_db)):
|
||||
"""Get all filaments from Spoolman."""
|
||||
enabled, url, _ = await get_spoolman_settings(db)
|
||||
if not enabled:
|
||||
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
||||
|
||||
client = await get_spoolman_client()
|
||||
if not client:
|
||||
if url:
|
||||
client = await init_spoolman_client(url)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
||||
|
||||
if not await client.health_check():
|
||||
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
||||
|
||||
filaments = await client.get_filaments()
|
||||
return {"filaments": filaments}
|
||||
+165
-1
@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
|
||||
from backend.app.core.database import init_db, async_session
|
||||
from sqlalchemy import select, or_
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications
|
||||
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman
|
||||
from backend.app.api.routes import settings as settings_routes
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.printer_manager import (
|
||||
@@ -69,6 +69,7 @@ from backend.app.services.bambu_ftp import download_file_async
|
||||
from backend.app.services.smart_plug_manager import smart_plug_manager
|
||||
from backend.app.services.tasmota import tasmota_service
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
|
||||
|
||||
|
||||
# Track active prints: {(printer_id, filename): archive_id}
|
||||
@@ -99,6 +100,92 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int):
|
||||
_last_status_broadcast: dict[int, str] = {}
|
||||
_nozzle_count_updated: set[int] = set() # Track printers where we've updated nozzle_count
|
||||
|
||||
|
||||
async def _report_spoolman_usage(printer_id: int, archive_id: int, logger):
|
||||
"""Report filament usage to Spoolman after print completion.
|
||||
|
||||
This finds the spool by RFID tag_uid from current AMS state and reports
|
||||
the filament_used_grams from the archive metadata.
|
||||
"""
|
||||
async with async_session() as db:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
# Check if Spoolman is enabled
|
||||
spoolman_enabled = await get_setting(db, "spoolman_enabled")
|
||||
if not spoolman_enabled or spoolman_enabled.lower() != "true":
|
||||
return
|
||||
|
||||
# Get Spoolman URL
|
||||
spoolman_url = await get_setting(db, "spoolman_url")
|
||||
if not spoolman_url:
|
||||
return
|
||||
|
||||
# Get or create Spoolman client
|
||||
client = await get_spoolman_client()
|
||||
if not client:
|
||||
client = await init_spoolman_client(spoolman_url)
|
||||
|
||||
# Check if Spoolman is reachable
|
||||
if not await client.health_check():
|
||||
logger.warning(f"Spoolman not reachable for usage reporting")
|
||||
return
|
||||
|
||||
# Get archive to find filament usage
|
||||
result = await db.execute(
|
||||
select(PrintArchive).where(PrintArchive.id == archive_id)
|
||||
)
|
||||
archive = result.scalar_one_or_none()
|
||||
if not archive or not archive.filament_used_grams:
|
||||
logger.debug(f"No filament usage data for archive {archive_id}")
|
||||
return
|
||||
|
||||
filament_used = archive.filament_used_grams
|
||||
logger.info(f"[SPOOLMAN] Archive {archive_id} used {filament_used}g of filament")
|
||||
|
||||
# Get current AMS state from printer to find the active spool
|
||||
state = printer_manager.get_status(printer_id)
|
||||
if not state or not state.raw_data:
|
||||
logger.debug(f"No printer state available for usage reporting")
|
||||
return
|
||||
|
||||
ams_data = state.raw_data.get("ams")
|
||||
if not ams_data:
|
||||
logger.debug(f"No AMS data available for usage reporting")
|
||||
return
|
||||
|
||||
# Find spools with RFID tags in Spoolman and report usage
|
||||
# For now, we report usage to the first spool found with a matching tag
|
||||
# TODO: In future, track which specific trays were used during the print
|
||||
spools_updated = 0
|
||||
for ams_unit in ams_data:
|
||||
ams_id = int(ams_unit.get("id", 0))
|
||||
trays = ams_unit.get("tray", [])
|
||||
|
||||
for tray_data in trays:
|
||||
tag_uid = tray_data.get("tag_uid")
|
||||
if not tag_uid:
|
||||
continue
|
||||
|
||||
# Find spool in Spoolman by tag
|
||||
spool = await client.find_spool_by_tag(tag_uid)
|
||||
if spool:
|
||||
# Report usage to Spoolman
|
||||
result = await client.use_spool(spool["id"], filament_used)
|
||||
if result:
|
||||
logger.info(
|
||||
f"[SPOOLMAN] Reported {filament_used}g usage to spool {spool['id']} "
|
||||
f"(tag: {tag_uid})"
|
||||
)
|
||||
spools_updated += 1
|
||||
# Only report to one spool for single-material prints
|
||||
# Multi-material prints would need more sophisticated tracking
|
||||
return
|
||||
|
||||
if spools_updated == 0:
|
||||
logger.debug(f"No matching Spoolman spools found for printer {printer_id}")
|
||||
|
||||
|
||||
async def on_printer_status_change(printer_id: int, state: PrinterState):
|
||||
"""Handle printer status changes - broadcast via WebSocket."""
|
||||
# Only broadcast if something meaningful changed (reduce WebSocket spam)
|
||||
@@ -141,6 +228,74 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
|
||||
)
|
||||
|
||||
|
||||
async def on_ams_change(printer_id: int, ams_data: list):
|
||||
"""Handle AMS data changes - sync to Spoolman if enabled and auto mode."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
# Check if Spoolman is enabled
|
||||
spoolman_enabled = await get_setting(db, "spoolman_enabled")
|
||||
if not spoolman_enabled or spoolman_enabled.lower() != "true":
|
||||
return
|
||||
|
||||
# Check sync mode
|
||||
sync_mode = await get_setting(db, "spoolman_sync_mode")
|
||||
if sync_mode and sync_mode != "auto":
|
||||
return # Only sync on auto mode
|
||||
|
||||
# Get Spoolman URL
|
||||
spoolman_url = await get_setting(db, "spoolman_url")
|
||||
if not spoolman_url:
|
||||
return
|
||||
|
||||
# Get or create Spoolman client
|
||||
client = await get_spoolman_client()
|
||||
if not client:
|
||||
client = await init_spoolman_client(spoolman_url)
|
||||
|
||||
# Check if Spoolman is reachable
|
||||
if not await client.health_check():
|
||||
logger.warning(f"Spoolman not reachable at {spoolman_url}")
|
||||
return
|
||||
|
||||
# Get printer name for location
|
||||
result = await db.execute(
|
||||
select(Printer).where(Printer.id == printer_id)
|
||||
)
|
||||
printer = result.scalar_one_or_none()
|
||||
printer_name = printer.name if printer else f"Printer {printer_id}"
|
||||
|
||||
# Sync each AMS tray
|
||||
synced = 0
|
||||
for ams_unit in ams_data:
|
||||
ams_id = int(ams_unit.get("id", 0))
|
||||
trays = ams_unit.get("tray", [])
|
||||
|
||||
for tray_data in trays:
|
||||
tray = client.parse_ams_tray(ams_id, tray_data)
|
||||
if not tray:
|
||||
continue # Empty tray
|
||||
|
||||
try:
|
||||
result = await client.sync_ams_tray(tray, printer_name)
|
||||
if result:
|
||||
synced += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}")
|
||||
|
||||
if synced > 0:
|
||||
logger.info(f"Auto-synced {synced} AMS trays to Spoolman for printer {printer_id}")
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Spoolman AMS sync failed: {e}")
|
||||
|
||||
|
||||
async def on_print_start(printer_id: int, data: dict):
|
||||
"""Handle print start - archive the 3MF file immediately."""
|
||||
import logging
|
||||
@@ -570,6 +725,13 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
"status": status,
|
||||
})
|
||||
|
||||
# Report filament usage to Spoolman if print completed successfully
|
||||
if data.get("status") == "completed":
|
||||
try:
|
||||
await _report_spoolman_usage(printer_id, archive_id, logger)
|
||||
except Exception as e:
|
||||
logger.warning(f"Spoolman usage reporting failed: {e}")
|
||||
|
||||
# Calculate energy used for this print (always per-print: end - start)
|
||||
try:
|
||||
starting_kwh = _print_energy_start.pop(archive_id, None)
|
||||
@@ -765,6 +927,7 @@ async def lifespan(app: FastAPI):
|
||||
printer_manager.set_status_change_callback(on_printer_status_change)
|
||||
printer_manager.set_print_start_callback(on_print_start)
|
||||
printer_manager.set_print_complete_callback(on_print_complete)
|
||||
printer_manager.set_ams_change_callback(on_ams_change)
|
||||
|
||||
# Connect to all active printers
|
||||
async with async_session() as db:
|
||||
@@ -797,6 +960,7 @@ app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(notifications.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(spoolman.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(websocket.router, prefix=app_settings.api_prefix)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ class AppSettings(BaseModel):
|
||||
energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
|
||||
energy_tracking_mode: str = Field(default="total", description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption")
|
||||
|
||||
# Spoolman integration
|
||||
spoolman_enabled: bool = Field(default=False, description="Enable Spoolman integration for filament tracking")
|
||||
spoolman_url: str = Field(default="", description="Spoolman server URL (e.g., http://localhost:7912)")
|
||||
spoolman_sync_mode: str = Field(default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press")
|
||||
|
||||
|
||||
class AppSettingsUpdate(BaseModel):
|
||||
"""Schema for updating settings (all fields optional)."""
|
||||
@@ -23,3 +28,6 @@ class AppSettingsUpdate(BaseModel):
|
||||
currency: str | None = None
|
||||
energy_cost_per_kwh: float | None = None
|
||||
energy_tracking_mode: str | None = None
|
||||
spoolman_enabled: bool | None = None
|
||||
spoolman_url: str | None = None
|
||||
spoolman_sync_mode: str | None = None
|
||||
|
||||
@@ -78,6 +78,7 @@ class BambuMQTTClient:
|
||||
on_state_change: Callable[[PrinterState], None] | None = None,
|
||||
on_print_start: Callable[[dict], None] | None = None,
|
||||
on_print_complete: Callable[[dict], None] | None = None,
|
||||
on_ams_change: Callable[[list], None] | None = None,
|
||||
):
|
||||
self.ip_address = ip_address
|
||||
self.serial_number = serial_number
|
||||
@@ -85,6 +86,7 @@ class BambuMQTTClient:
|
||||
self.on_state_change = on_state_change
|
||||
self.on_print_start = on_print_start
|
||||
self.on_print_complete = on_print_complete
|
||||
self.on_ams_change = on_ams_change
|
||||
|
||||
self.state = PrinterState()
|
||||
self._client: mqtt.Client | None = None
|
||||
@@ -96,6 +98,7 @@ class BambuMQTTClient:
|
||||
self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
|
||||
self._logging_enabled: bool = False
|
||||
self._last_message_time: float = 0.0 # Track when we last received a message
|
||||
self._previous_ams_hash: str | None = None # Track AMS changes
|
||||
|
||||
# K-profile command tracking
|
||||
self._sequence_id: int = 0
|
||||
@@ -157,6 +160,14 @@ class BambuMQTTClient:
|
||||
|
||||
def _process_message(self, payload: dict):
|
||||
"""Process incoming MQTT message from printer."""
|
||||
# Handle top-level AMS data (comes outside of "print" key)
|
||||
# Wrap in try/except to prevent breaking the MQTT connection
|
||||
if "ams" in payload:
|
||||
try:
|
||||
self._handle_ams_data(payload["ams"])
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.serial_number}] Error handling AMS data: {e}")
|
||||
|
||||
if "print" in payload:
|
||||
print_data = payload["print"]
|
||||
# Log when we see gcode_state changes
|
||||
@@ -172,6 +183,38 @@ class BambuMQTTClient:
|
||||
|
||||
self._update_state(print_data)
|
||||
|
||||
def _handle_ams_data(self, ams_data: list):
|
||||
"""Handle AMS data changes for Spoolman integration.
|
||||
|
||||
This is called when we receive top-level AMS data in MQTT messages.
|
||||
It detects changes and triggers the callback for Spoolman sync.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
# Store AMS data in raw_data so it's accessible via API
|
||||
if "ams" not in self.state.raw_data:
|
||||
self.state.raw_data["ams"] = ams_data
|
||||
else:
|
||||
self.state.raw_data["ams"] = ams_data
|
||||
|
||||
# Create a hash of relevant AMS data to detect changes
|
||||
ams_hash_data = []
|
||||
for ams_unit in ams_data:
|
||||
for tray in ams_unit.get("tray", []):
|
||||
# Include fields that matter for filament tracking
|
||||
ams_hash_data.append(
|
||||
f"{ams_unit.get('id')}:{tray.get('id')}:"
|
||||
f"{tray.get('tray_type')}:{tray.get('tag_uid')}:{tray.get('remain')}"
|
||||
)
|
||||
ams_hash = hashlib.md5(":".join(ams_hash_data).encode()).hexdigest()
|
||||
|
||||
# Only trigger callback if AMS data actually changed
|
||||
if ams_hash != self._previous_ams_hash:
|
||||
self._previous_ams_hash = ams_hash
|
||||
if self.on_ams_change:
|
||||
logger.info(f"[{self.serial_number}] AMS data changed, triggering sync callback")
|
||||
self.on_ams_change(ams_data)
|
||||
|
||||
def _update_state(self, data: dict):
|
||||
"""Update printer state from message data."""
|
||||
previous_state = self.state.state
|
||||
@@ -259,7 +302,11 @@ class BambuMQTTClient:
|
||||
severity=severity if severity > 0 else 3,
|
||||
))
|
||||
|
||||
# Preserve AMS data when updating raw_data (AMS comes at top level, not in print)
|
||||
ams_data = self.state.raw_data.get("ams")
|
||||
self.state.raw_data = data
|
||||
if ams_data is not None:
|
||||
self.state.raw_data["ams"] = ams_data
|
||||
|
||||
# Log state transitions for debugging
|
||||
if "gcode_state" in data:
|
||||
|
||||
@@ -18,6 +18,7 @@ class PrinterManager:
|
||||
self._on_print_start: Callable[[int, dict], None] | None = None
|
||||
self._on_print_complete: Callable[[int, dict], None] | None = None
|
||||
self._on_status_change: Callable[[int, PrinterState], None] | None = None
|
||||
self._on_ams_change: Callable[[int, list], None] | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
|
||||
@@ -36,6 +37,10 @@ class PrinterManager:
|
||||
"""Set callback for status change events."""
|
||||
self._on_status_change = callback
|
||||
|
||||
def set_ams_change_callback(self, callback: Callable[[int, list], None]):
|
||||
"""Set callback for AMS data change events."""
|
||||
self._on_ams_change = callback
|
||||
|
||||
def _schedule_async(self, coro):
|
||||
"""Schedule an async coroutine from a sync context."""
|
||||
if self._loop and self._loop.is_running():
|
||||
@@ -66,6 +71,12 @@ class PrinterManager:
|
||||
self._on_print_complete(printer_id, data)
|
||||
)
|
||||
|
||||
def on_ams_change(ams_data: list):
|
||||
if self._on_ams_change:
|
||||
self._schedule_async(
|
||||
self._on_ams_change(printer_id, ams_data)
|
||||
)
|
||||
|
||||
client = BambuMQTTClient(
|
||||
ip_address=printer.ip_address,
|
||||
serial_number=printer.serial_number,
|
||||
@@ -73,6 +84,7 @@ class PrinterManager:
|
||||
on_state_change=on_state_change,
|
||||
on_print_start=on_print_start,
|
||||
on_print_complete=on_print_complete,
|
||||
on_ams_change=on_ams_change,
|
||||
)
|
||||
|
||||
client.connect()
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
"""Spoolman integration service for syncing AMS filament data."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpoolmanSpool:
|
||||
"""Represents a spool in Spoolman."""
|
||||
|
||||
id: int
|
||||
filament_id: int | None
|
||||
remaining_weight: float | None
|
||||
used_weight: float
|
||||
first_used: str | None
|
||||
last_used: str | None
|
||||
location: str | None
|
||||
lot_nr: str | None
|
||||
comment: str | None
|
||||
extra: dict | None # Contains tag_uid in extra.tag
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpoolmanFilament:
|
||||
"""Represents a filament type in Spoolman."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
vendor_id: int | None
|
||||
material: str | None
|
||||
color_hex: str | None
|
||||
weight: float | None # Net weight in grams
|
||||
|
||||
|
||||
@dataclass
|
||||
class AMSTray:
|
||||
"""Represents an AMS tray with filament data from Bambu printer."""
|
||||
|
||||
ams_id: int # 0-3 for regular AMS, 128-135 for external spool
|
||||
tray_id: int # 0-3
|
||||
tray_type: str # PLA, PETG, ABS, etc.
|
||||
tray_sub_brands: str # Full name like "PLA Basic", "PETG HF"
|
||||
tray_color: str # Hex color like "FEC600FF"
|
||||
remain: int # Remaining percentage (0-100)
|
||||
tag_uid: str # RFID tag UID
|
||||
tray_uuid: str # Spool UUID
|
||||
tray_weight: int # Spool weight in grams (usually 1000)
|
||||
|
||||
|
||||
class SpoolmanClient:
|
||||
"""Client for interacting with Spoolman API."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
"""Initialize the Spoolman client.
|
||||
|
||||
Args:
|
||||
base_url: The base URL of the Spoolman server (e.g., http://localhost:7912)
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_url = f"{self.base_url}/api/v1"
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._connected = False
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create the HTTP client."""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Spoolman server is reachable.
|
||||
|
||||
Returns:
|
||||
True if server is healthy, False otherwise.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"{self.api_url}/health")
|
||||
self._connected = response.status_code == 200
|
||||
return self._connected
|
||||
except Exception as e:
|
||||
logger.warning(f"Spoolman health check failed: {e}")
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if client is connected to Spoolman."""
|
||||
return self._connected
|
||||
|
||||
async def get_spools(self) -> list[dict]:
|
||||
"""Get all spools from Spoolman.
|
||||
|
||||
Returns:
|
||||
List of spool dictionaries.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"{self.api_url}/spool")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get spools from Spoolman: {e}")
|
||||
return []
|
||||
|
||||
async def get_filaments(self) -> list[dict]:
|
||||
"""Get all internal filaments from Spoolman.
|
||||
|
||||
Returns:
|
||||
List of filament dictionaries.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"{self.api_url}/filament")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get filaments from Spoolman: {e}")
|
||||
return []
|
||||
|
||||
async def get_external_filaments(self) -> list[dict]:
|
||||
"""Get external/library filaments from Spoolman.
|
||||
|
||||
Returns:
|
||||
List of external filament dictionaries.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"{self.api_url}/external/filament")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get external filaments from Spoolman: {e}")
|
||||
return []
|
||||
|
||||
async def get_vendors(self) -> list[dict]:
|
||||
"""Get all vendors from Spoolman.
|
||||
|
||||
Returns:
|
||||
List of vendor dictionaries.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(f"{self.api_url}/vendor")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get vendors from Spoolman: {e}")
|
||||
return []
|
||||
|
||||
async def create_vendor(self, name: str) -> dict | None:
|
||||
"""Create a new vendor in Spoolman.
|
||||
|
||||
Args:
|
||||
name: Vendor name (e.g., "Bambu Lab")
|
||||
|
||||
Returns:
|
||||
Created vendor dictionary or None on failure.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.post(
|
||||
f"{self.api_url}/vendor", json={"name": name}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create vendor in Spoolman: {e}")
|
||||
return None
|
||||
|
||||
def _get_material_density(self, material: str | None) -> float:
|
||||
"""Get typical density for a filament material type.
|
||||
|
||||
Args:
|
||||
material: Material type (PLA, PETG, ABS, etc.)
|
||||
|
||||
Returns:
|
||||
Density in g/cm³
|
||||
"""
|
||||
# Typical densities for common filament materials
|
||||
densities = {
|
||||
"PLA": 1.24,
|
||||
"PLA-CF": 1.29,
|
||||
"PLA-S": 1.24,
|
||||
"PETG": 1.27,
|
||||
"ABS": 1.04,
|
||||
"ASA": 1.07,
|
||||
"TPU": 1.21,
|
||||
"PA": 1.14, # Nylon
|
||||
"PA-CF": 1.20,
|
||||
"PC": 1.20,
|
||||
"PVA": 1.23,
|
||||
"HIPS": 1.04,
|
||||
"PP": 0.90,
|
||||
"PET": 1.38,
|
||||
}
|
||||
if material:
|
||||
# Try exact match first, then uppercase
|
||||
mat_upper = material.upper()
|
||||
for key, density in densities.items():
|
||||
if key.upper() == mat_upper or mat_upper.startswith(key.upper()):
|
||||
return density
|
||||
return 1.24 # Default to PLA density
|
||||
|
||||
async def create_filament(
|
||||
self,
|
||||
name: str,
|
||||
vendor_id: int | None = None,
|
||||
material: str | None = None,
|
||||
color_hex: str | None = None,
|
||||
weight: float | None = None,
|
||||
diameter: float = 1.75,
|
||||
density: float | None = None,
|
||||
) -> dict | None:
|
||||
"""Create a new filament in Spoolman.
|
||||
|
||||
Args:
|
||||
name: Filament name
|
||||
vendor_id: Vendor ID
|
||||
material: Material type (PLA, PETG, etc.)
|
||||
color_hex: Color in hex format (without #)
|
||||
weight: Net weight in grams
|
||||
diameter: Filament diameter in mm (default 1.75)
|
||||
density: Filament density in g/cm³ (auto-calculated if not provided)
|
||||
|
||||
Returns:
|
||||
Created filament dictionary or None on failure.
|
||||
"""
|
||||
# Validate required fields
|
||||
if not name or not name.strip():
|
||||
logger.error("Cannot create filament: name is required")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Calculate density from material if not provided
|
||||
if density is None:
|
||||
density = self._get_material_density(material)
|
||||
|
||||
data = {
|
||||
"name": name.strip(),
|
||||
"diameter": diameter,
|
||||
"density": density,
|
||||
}
|
||||
if vendor_id:
|
||||
data["vendor_id"] = vendor_id
|
||||
if material:
|
||||
data["material"] = material
|
||||
if color_hex:
|
||||
# Strip alpha channel if present (RRGGBBAA -> RRGGBB)
|
||||
color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
|
||||
data["color_hex"] = color_hex
|
||||
if weight:
|
||||
data["weight"] = weight
|
||||
|
||||
logger.debug(f"Creating filament in Spoolman: {data}")
|
||||
client = await self._get_client()
|
||||
response = await client.post(f"{self.api_url}/filament", json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Failed to create filament in Spoolman: {e}, response: {e.response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create filament in Spoolman: {e}")
|
||||
return None
|
||||
|
||||
async def create_spool(
|
||||
self,
|
||||
filament_id: int,
|
||||
remaining_weight: float | None = None,
|
||||
location: str | None = None,
|
||||
lot_nr: str | None = None,
|
||||
comment: str | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> dict | None:
|
||||
"""Create a new spool in Spoolman.
|
||||
|
||||
Args:
|
||||
filament_id: ID of the filament type
|
||||
remaining_weight: Remaining weight in grams
|
||||
location: Physical location description
|
||||
lot_nr: Lot/batch number
|
||||
comment: Optional comment
|
||||
extra: Extra fields (e.g., {"tag": "RFID_TAG_UID"})
|
||||
|
||||
Returns:
|
||||
Created spool dictionary or None on failure.
|
||||
"""
|
||||
try:
|
||||
data = {"filament_id": filament_id}
|
||||
if remaining_weight is not None:
|
||||
data["remaining_weight"] = remaining_weight
|
||||
if location:
|
||||
data["location"] = location
|
||||
if lot_nr:
|
||||
data["lot_nr"] = lot_nr
|
||||
if comment:
|
||||
data["comment"] = comment
|
||||
if extra:
|
||||
data["extra"] = extra
|
||||
|
||||
logger.debug(f"Creating spool in Spoolman: {data}")
|
||||
client = await self._get_client()
|
||||
response = await client.post(f"{self.api_url}/spool", json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Failed to create spool in Spoolman: {e}, response: {e.response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create spool in Spoolman: {e}")
|
||||
return None
|
||||
|
||||
async def update_spool(
|
||||
self,
|
||||
spool_id: int,
|
||||
remaining_weight: float | None = None,
|
||||
location: str | None = None,
|
||||
extra: dict | None = None,
|
||||
) -> dict | None:
|
||||
"""Update an existing spool in Spoolman.
|
||||
|
||||
Args:
|
||||
spool_id: ID of the spool to update
|
||||
remaining_weight: New remaining weight in grams
|
||||
location: New location
|
||||
extra: Extra fields to update
|
||||
|
||||
Returns:
|
||||
Updated spool dictionary or None on failure.
|
||||
"""
|
||||
try:
|
||||
data = {}
|
||||
if remaining_weight is not None:
|
||||
data["remaining_weight"] = remaining_weight
|
||||
if location:
|
||||
data["location"] = location
|
||||
if extra:
|
||||
data["extra"] = extra
|
||||
|
||||
# Always update last_used
|
||||
data["last_used"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
client = await self._get_client()
|
||||
response = await client.patch(
|
||||
f"{self.api_url}/spool/{spool_id}", json=data
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update spool in Spoolman: {e}")
|
||||
return None
|
||||
|
||||
async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
|
||||
"""Record filament usage for a spool.
|
||||
|
||||
Args:
|
||||
spool_id: ID of the spool
|
||||
used_weight: Amount of filament used in grams
|
||||
|
||||
Returns:
|
||||
Updated spool dictionary or None on failure.
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.put(
|
||||
f"{self.api_url}/spool/{spool_id}/use",
|
||||
json={"use_weight": used_weight},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record spool usage in Spoolman: {e}")
|
||||
return None
|
||||
|
||||
async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
|
||||
"""Find a spool by its RFID tag UID.
|
||||
|
||||
Args:
|
||||
tag_uid: The RFID tag UID to search for
|
||||
|
||||
Returns:
|
||||
Spool dictionary or None if not found.
|
||||
"""
|
||||
spools = await self.get_spools()
|
||||
# Normalize tag_uid for comparison (uppercase, strip quotes)
|
||||
search_tag = tag_uid.strip('"').upper()
|
||||
|
||||
for spool in spools:
|
||||
extra = spool.get("extra", {})
|
||||
if extra:
|
||||
stored_tag = extra.get("tag", "")
|
||||
# Normalize stored tag (strip quotes, uppercase)
|
||||
if stored_tag:
|
||||
normalized_tag = stored_tag.strip('"').upper()
|
||||
if normalized_tag == search_tag:
|
||||
logger.debug(f"Found spool {spool['id']} matching tag {tag_uid}")
|
||||
return spool
|
||||
return None
|
||||
|
||||
async def ensure_bambu_vendor(self) -> int | None:
|
||||
"""Ensure Bambu Lab vendor exists and return its ID.
|
||||
|
||||
Returns:
|
||||
Vendor ID or None on failure.
|
||||
"""
|
||||
vendors = await self.get_vendors()
|
||||
for vendor in vendors:
|
||||
if vendor.get("name", "").lower() == "bambu lab":
|
||||
return vendor["id"]
|
||||
|
||||
# Create Bambu Lab vendor if not exists
|
||||
vendor = await self.create_vendor("Bambu Lab")
|
||||
return vendor["id"] if vendor else None
|
||||
|
||||
def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
|
||||
"""Parse AMS tray data into AMSTray object.
|
||||
|
||||
Args:
|
||||
ams_id: The AMS unit ID (0-3 for regular, 128-135 for external)
|
||||
tray_data: Raw tray data from MQTT
|
||||
|
||||
Returns:
|
||||
AMSTray object or None if tray is empty or invalid.
|
||||
"""
|
||||
# Skip empty trays - check for valid tray_type
|
||||
tray_type = tray_data.get("tray_type", "")
|
||||
if not tray_type or tray_type.strip() == "":
|
||||
return None
|
||||
|
||||
# Also need valid color to create filament (000000FF = unset/empty)
|
||||
tray_color = tray_data.get("tray_color", "")
|
||||
if not tray_color or tray_color in ("", "000000FF", "00000000"):
|
||||
logger.debug(f"Skipping tray with invalid color: {tray_color}")
|
||||
return None
|
||||
|
||||
# Get sub_brands, falling back to tray_type
|
||||
tray_sub_brands = tray_data.get("tray_sub_brands", "")
|
||||
if not tray_sub_brands or tray_sub_brands.strip() == "":
|
||||
tray_sub_brands = tray_type
|
||||
|
||||
# Get tag_uid and tray_uuid, filtering out empty/invalid values
|
||||
tag_uid = tray_data.get("tag_uid", "")
|
||||
if tag_uid in ("", "0000000000000000"):
|
||||
tag_uid = ""
|
||||
tray_uuid = tray_data.get("tray_uuid", "")
|
||||
if tray_uuid in ("", "00000000000000000000000000000000"):
|
||||
tray_uuid = ""
|
||||
|
||||
# Get remaining percentage, ensure non-negative
|
||||
remain = max(0, int(tray_data.get("remain", 0)))
|
||||
|
||||
return AMSTray(
|
||||
ams_id=ams_id,
|
||||
tray_id=int(tray_data.get("id", 0)),
|
||||
tray_type=tray_type.strip(),
|
||||
tray_sub_brands=tray_sub_brands.strip(),
|
||||
tray_color=tray_color,
|
||||
remain=remain,
|
||||
tag_uid=tag_uid,
|
||||
tray_uuid=tray_uuid,
|
||||
tray_weight=int(tray_data.get("tray_weight", 1000)),
|
||||
)
|
||||
|
||||
def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
|
||||
"""Convert AMS ID and tray ID to human-readable location.
|
||||
|
||||
Args:
|
||||
ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for external)
|
||||
tray_id: Tray ID within the AMS (0-3)
|
||||
|
||||
Returns:
|
||||
Location string like "AMS A1", "AMS B2", "External"
|
||||
"""
|
||||
if ams_id >= 128:
|
||||
return "External Spool"
|
||||
|
||||
ams_letter = chr(ord("A") + ams_id)
|
||||
return f"AMS {ams_letter}{tray_id + 1}"
|
||||
|
||||
def is_bambu_lab_spool(self, tray_uuid: str) -> bool:
|
||||
"""Check if a tray has a valid Bambu Lab spool UUID.
|
||||
|
||||
Bambu Lab spools have a tray_uuid which is a 32-character hex string.
|
||||
This UUID is consistent across all printer models (unlike tag_uid which
|
||||
varies between X1C/H2D readers).
|
||||
|
||||
Non-Bambu Lab spools (SpoolEase, third-party) won't have a valid tray_uuid.
|
||||
|
||||
Args:
|
||||
tray_uuid: The tray UUID to check
|
||||
|
||||
Returns:
|
||||
True if the spool has a valid Bambu Lab UUID, False otherwise.
|
||||
"""
|
||||
if not tray_uuid:
|
||||
return False
|
||||
# Bambu Lab tray_uuid is always 32 hex characters
|
||||
uuid = tray_uuid.strip()
|
||||
if len(uuid) != 32:
|
||||
return False
|
||||
# Verify it's all hex characters and not empty/zero
|
||||
if uuid == "00000000000000000000000000000000":
|
||||
return False
|
||||
try:
|
||||
int(uuid, 16)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def calculate_remaining_weight(
|
||||
self, remain_percent: int, spool_weight: int
|
||||
) -> float:
|
||||
"""Calculate remaining weight from percentage.
|
||||
|
||||
Args:
|
||||
remain_percent: Remaining percentage (0-100)
|
||||
spool_weight: Total spool weight in grams
|
||||
|
||||
Returns:
|
||||
Remaining weight in grams.
|
||||
"""
|
||||
return (remain_percent / 100.0) * spool_weight
|
||||
|
||||
async def sync_ams_tray(
|
||||
self, tray: AMSTray, printer_name: str
|
||||
) -> dict | None:
|
||||
"""Sync a single AMS tray to Spoolman.
|
||||
|
||||
Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
|
||||
Non-Bambu Lab spools (SpoolEase/third-party) are skipped.
|
||||
|
||||
Uses tray_uuid for matching, as it's consistent across all printer models
|
||||
(unlike tag_uid which varies between X1C/H2D readers).
|
||||
|
||||
Args:
|
||||
tray: The AMSTray to sync
|
||||
printer_name: Name of the printer for location
|
||||
|
||||
Returns:
|
||||
Synced spool dictionary or None if skipped or failed.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
|
||||
f"type={tray.tray_type}, uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}..."
|
||||
)
|
||||
|
||||
# Only sync trays with valid Bambu Lab tray_uuid
|
||||
if not self.is_bambu_lab_spool(tray.tray_uuid):
|
||||
if tray.tray_uuid or tray.tag_uid:
|
||||
logger.info(
|
||||
f"Skipping non-Bambu Lab spool: {printer_name} AMS {tray.ams_id} tray {tray.tray_id} "
|
||||
f"(tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Calculate remaining weight
|
||||
remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
|
||||
location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
|
||||
|
||||
# Find existing spool by tray_uuid (stored as "tag" in Spoolman)
|
||||
existing = await self.find_spool_by_tag(tray.tray_uuid)
|
||||
if existing:
|
||||
# Update existing spool
|
||||
logger.info(
|
||||
f"Updating existing spool {existing['id']} for tray_uuid {tray.tray_uuid}"
|
||||
)
|
||||
return await self.update_spool(
|
||||
spool_id=existing["id"],
|
||||
remaining_weight=remaining,
|
||||
location=location,
|
||||
)
|
||||
|
||||
# Spool not found in Spoolman - log warning but don't create new
|
||||
logger.warning(
|
||||
f"Bambu Lab spool with tray_uuid {tray.tray_uuid} not found in Spoolman. "
|
||||
f"Add this spool to Spoolman first to enable syncing."
|
||||
)
|
||||
return None
|
||||
|
||||
async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
|
||||
"""Find existing filament or create new one.
|
||||
|
||||
Args:
|
||||
tray: The AMSTray containing filament info
|
||||
|
||||
Returns:
|
||||
Filament dictionary or None on failure.
|
||||
"""
|
||||
# Search internal filaments first
|
||||
filaments = await self.get_filaments()
|
||||
color_hex = tray.tray_color[:6] # Strip alpha channel
|
||||
|
||||
for filament in filaments:
|
||||
# Match by material and color (handle None values)
|
||||
fil_material = filament.get("material") or ""
|
||||
fil_color = filament.get("color_hex") or ""
|
||||
if (
|
||||
fil_material.upper() == tray.tray_type.upper()
|
||||
and fil_color.upper() == color_hex.upper()
|
||||
):
|
||||
return filament
|
||||
|
||||
# Search external filaments (Bambu library)
|
||||
external = await self.get_external_filaments()
|
||||
for filament in external:
|
||||
fil_material = filament.get("material") or ""
|
||||
fil_color = filament.get("color_hex") or ""
|
||||
if (
|
||||
fil_material.upper() == tray.tray_type.upper()
|
||||
and fil_color.upper() == color_hex.upper()
|
||||
):
|
||||
# Found in external library - need to create internal copy
|
||||
return await self._create_filament_from_external(filament, tray)
|
||||
|
||||
# Not found - create new filament
|
||||
vendor_id = await self.ensure_bambu_vendor()
|
||||
return await self.create_filament(
|
||||
name=tray.tray_sub_brands or tray.tray_type,
|
||||
vendor_id=vendor_id,
|
||||
material=tray.tray_type,
|
||||
color_hex=color_hex,
|
||||
weight=tray.tray_weight,
|
||||
)
|
||||
|
||||
async def _create_filament_from_external(
|
||||
self, external: dict, tray: AMSTray
|
||||
) -> dict | None:
|
||||
"""Create internal filament from external library entry.
|
||||
|
||||
Args:
|
||||
external: External filament dictionary
|
||||
tray: The AMSTray for additional info
|
||||
|
||||
Returns:
|
||||
Created filament dictionary or None on failure.
|
||||
"""
|
||||
vendor_id = await self.ensure_bambu_vendor()
|
||||
return await self.create_filament(
|
||||
name=external.get("name", tray.tray_sub_brands),
|
||||
vendor_id=vendor_id,
|
||||
material=external.get("material", tray.tray_type),
|
||||
color_hex=external.get("color_hex", tray.tray_color[:6]),
|
||||
weight=external.get("weight", tray.tray_weight),
|
||||
)
|
||||
|
||||
|
||||
# Global client instance (initialized when settings are loaded)
|
||||
_spoolman_client: SpoolmanClient | None = None
|
||||
|
||||
|
||||
async def get_spoolman_client() -> SpoolmanClient | None:
|
||||
"""Get the global Spoolman client instance.
|
||||
|
||||
Returns:
|
||||
SpoolmanClient instance or None if not configured.
|
||||
"""
|
||||
return _spoolman_client
|
||||
|
||||
|
||||
async def init_spoolman_client(url: str) -> SpoolmanClient:
|
||||
"""Initialize the global Spoolman client.
|
||||
|
||||
Args:
|
||||
url: Spoolman server URL
|
||||
|
||||
Returns:
|
||||
Initialized SpoolmanClient instance.
|
||||
"""
|
||||
global _spoolman_client
|
||||
if _spoolman_client:
|
||||
await _spoolman_client.close()
|
||||
|
||||
_spoolman_client = SpoolmanClient(url)
|
||||
return _spoolman_client
|
||||
|
||||
|
||||
async def close_spoolman_client():
|
||||
"""Close the global Spoolman client."""
|
||||
global _spoolman_client
|
||||
if _spoolman_client:
|
||||
await _spoolman_client.close()
|
||||
_spoolman_client = None
|
||||
@@ -479,6 +479,19 @@ export interface EmailConfig {
|
||||
use_tls?: boolean;
|
||||
}
|
||||
|
||||
// Spoolman types
|
||||
export interface SpoolmanStatus {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface SpoolmanSyncResult {
|
||||
success: boolean;
|
||||
synced_count: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// API functions
|
||||
export const api = {
|
||||
// Printers
|
||||
@@ -870,4 +883,27 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Spoolman Integration
|
||||
getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
|
||||
connectSpoolman: () =>
|
||||
request<{ success: boolean; message: string }>('/spoolman/connect', {
|
||||
method: 'POST',
|
||||
}),
|
||||
disconnectSpoolman: () =>
|
||||
request<{ success: boolean; message: string }>('/spoolman/disconnect', {
|
||||
method: 'POST',
|
||||
}),
|
||||
syncPrinterAms: (printerId: number) =>
|
||||
request<SpoolmanSyncResult>(`/spoolman/sync/${printerId}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
syncAllPrintersAms: () =>
|
||||
request<SpoolmanSyncResult>('/spoolman/sync-all', {
|
||||
method: 'POST',
|
||||
}),
|
||||
getSpoolmanSpools: () =>
|
||||
request<{ spools: unknown[] }>('/spoolman/spools'),
|
||||
getSpoolmanFilaments: () =>
|
||||
request<{ filaments: unknown[] }>('/spoolman/filaments'),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { SpoolmanSyncResult, Printer } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface SpoolmanSettingsData {
|
||||
spoolman_enabled: string;
|
||||
spoolman_url: string;
|
||||
spoolman_sync_mode: string;
|
||||
}
|
||||
|
||||
async function getSpoolmanSettings(): Promise<SpoolmanSettingsData> {
|
||||
const response = await fetch('/api/v1/settings/spoolman');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load Spoolman settings');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function updateSpoolmanSettings(data: Partial<SpoolmanSettingsData>): Promise<SpoolmanSettingsData> {
|
||||
const response = await fetch('/api/v1/settings/spoolman', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save Spoolman settings');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function SpoolmanSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [localEnabled, setLocalEnabled] = useState(false);
|
||||
const [localUrl, setLocalUrl] = useState('');
|
||||
const [localSyncMode, setLocalSyncMode] = useState('auto');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
|
||||
|
||||
// Fetch Spoolman settings
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['spoolman-settings'],
|
||||
queryFn: getSpoolmanSettings,
|
||||
});
|
||||
|
||||
// Fetch Spoolman status
|
||||
const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ['spoolman-status'],
|
||||
queryFn: api.getSpoolmanStatus,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
// Fetch printers for the dropdown
|
||||
const { data: printers } = useQuery({
|
||||
queryKey: ['printers'],
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
// Initialize local state from settings
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setLocalEnabled(settings.spoolman_enabled === 'true');
|
||||
setLocalUrl(settings.spoolman_url || '');
|
||||
setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const changed =
|
||||
(settings.spoolman_enabled === 'true') !== localEnabled ||
|
||||
(settings.spoolman_url || '') !== localUrl ||
|
||||
(settings.spoolman_sync_mode || 'auto') !== localSyncMode;
|
||||
setHasChanges(changed);
|
||||
}
|
||||
}, [settings, localEnabled, localUrl, localSyncMode]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateSpoolmanSettings({
|
||||
spoolman_enabled: localEnabled ? 'true' : 'false',
|
||||
spoolman_url: localUrl,
|
||||
spoolman_sync_mode: localSyncMode,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
|
||||
setHasChanges(false);
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
// Connect mutation
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: api.connectSpoolman,
|
||||
onSuccess: () => {
|
||||
refetchStatus();
|
||||
},
|
||||
});
|
||||
|
||||
// Disconnect mutation
|
||||
const disconnectMutation = useMutation({
|
||||
mutationFn: api.disconnectSpoolman,
|
||||
onSuccess: () => {
|
||||
refetchStatus();
|
||||
},
|
||||
});
|
||||
|
||||
// Sync all mutation
|
||||
const syncAllMutation = useMutation({
|
||||
mutationFn: api.syncAllPrintersAms,
|
||||
onSuccess: (data: SpoolmanSyncResult) => {
|
||||
if (data.success) {
|
||||
// Show success message
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Sync single printer mutation
|
||||
const syncPrinterMutation = useMutation({
|
||||
mutationFn: (printerId: number) => api.syncPrinterAms(printerId),
|
||||
onSuccess: (data: SpoolmanSyncResult) => {
|
||||
if (data.success) {
|
||||
// Show success message
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to handle sync based on selection
|
||||
const handleSync = () => {
|
||||
if (selectedPrinterId === 'all') {
|
||||
syncAllMutation.mutate();
|
||||
} else {
|
||||
syncPrinterMutation.mutate(selectedPrinterId);
|
||||
}
|
||||
};
|
||||
|
||||
// Combine mutation states
|
||||
const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
|
||||
const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
|
||||
const syncSuccess = selectedPrinterId === 'all' ? syncAllMutation.isSuccess : syncPrinterMutation.isSuccess;
|
||||
|
||||
if (settingsLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : showSaved ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Connect to Spoolman for filament inventory tracking. AMS data will sync automatically.
|
||||
</p>
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Enable Spoolman</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Sync filament data with Spoolman server
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localEnabled}
|
||||
onChange={(e) => setLocalEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* URL input */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Spoolman URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="http://192.168.1.100:7912"
|
||||
value={localUrl}
|
||||
onChange={(e) => setLocalUrl(e.target.value)}
|
||||
disabled={!localEnabled}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
URL of your Spoolman server (e.g., http://localhost:7912)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sync mode */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Sync Mode
|
||||
</label>
|
||||
<select
|
||||
value={localSyncMode}
|
||||
onChange={(e) => setLocalSyncMode(e.target.value)}
|
||||
disabled={!localEnabled}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="manual">Manual Only</option>
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{localSyncMode === 'auto'
|
||||
? 'AMS data syncs automatically when changes are detected'
|
||||
: 'Only sync when manually triggered'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connection status */}
|
||||
{localEnabled && (
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-bambu-gray">Status:</span>
|
||||
{statusLoading ? (
|
||||
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
|
||||
) : status?.connected ? (
|
||||
<span className="flex items-center gap-1 text-sm text-green-500">
|
||||
<Check className="w-4 h-4" />
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-sm text-red-500">
|
||||
<X className="w-4 h-4" />
|
||||
Disconnected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{status?.connected ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => disconnectMutation.mutate()}
|
||||
disabled={disconnectMutation.isPending}
|
||||
>
|
||||
{disconnectMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Link2Off className="w-4 h-4" />
|
||||
)}
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => connectMutation.mutate()}
|
||||
disabled={connectMutation.isPending || !localUrl}
|
||||
>
|
||||
{connectMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Link2 className="w-4 h-4" />
|
||||
)}
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{connectMutation.isError && (
|
||||
<div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
|
||||
{(connectMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual sync section */}
|
||||
{status?.connected && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-white">Sync AMS Data</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
Manually sync printer AMS data to Spoolman
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Printer selector */}
|
||||
<div className="relative flex-1">
|
||||
<select
|
||||
value={selectedPrinterId}
|
||||
onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
|
||||
className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="all">All Printers</option>
|
||||
{printers?.map((printer: Printer) => (
|
||||
<option key={printer.id} value={printer.id}>
|
||||
{printer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
|
||||
</div>
|
||||
{/* Sync button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSync}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
Sync
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync result */}
|
||||
{syncSuccess && syncResult && (
|
||||
<div
|
||||
className={`mt-2 p-2 rounded text-sm ${
|
||||
syncResult.success
|
||||
? 'bg-green-500/20 border border-green-500/50 text-green-400'
|
||||
: 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{syncResult.success
|
||||
? `Synced ${syncResult.synced_count} trays successfully`
|
||||
: `Synced ${syncResult.synced_count} trays with ${syncResult.errors.length} errors`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { SmartPlugCard } from '../components/SmartPlugCard';
|
||||
import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
|
||||
import { NotificationProviderCard } from '../components/NotificationProviderCard';
|
||||
import { AddNotificationModal } from '../components/AddNotificationModal';
|
||||
import { SpoolmanSettings } from '../components/SpoolmanSettings';
|
||||
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
@@ -326,6 +327,8 @@ export function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SpoolmanSettings />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">About</h2>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CP_GXmpo.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJNRCg8M.css">
|
||||
<script type="module" crossorigin src="/assets/index-zNwYBAHC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CfyzT_2B.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user