Added Spoolman support to add unknown Bambu Lab spools and track them

This commit is contained in:
Martin Ziegler
2025-11-30 19:30:14 +01:00
parent 597c1e2817
commit a862958deb
15 changed files with 2195 additions and 399 deletions
+67 -1
View File
@@ -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
+34 -1
View File
@@ -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)
+351
View File
@@ -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
View File
@@ -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)
+8
View File
@@ -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
+47
View File
@@ -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:
+12
View File
@@ -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()
+698
View File
@@ -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
+36
View File
@@ -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>
);
}
+3
View File
@@ -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
View File
@@ -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>