Merge branch '0.2.0b' into main

This commit is contained in:
MartinNYHC
2026-02-13 08:16:52 +01:00
committed by GitHub
88 changed files with 20394 additions and 9726 deletions
+139
View File
@@ -0,0 +1,139 @@
# Beta Test Plan — Spool Inventory & Related Features
## Prerequisites
- At least one printer connected with AMS
- At least one Bambu Lab spool (RFID) loaded in AMS
- At least one spool without RFID tag
- At least one empty AMS slot (or slot with non-BL filament)
- A 3MF file ready to print (small/fast test print recommended)
---
## 1. Filament Tracking Mode Switching
**Location:** Settings > Filament
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 1.1 | Default mode | Open Settings > Filament | "Built-in Inventory" card is selected (green border), info panel shows RFID/usage/catalog bullet points |
| 1.2 | Switch to Spoolman | Click "Spoolman" card | Spoolman config appears (URL input, Sync Mode, connection status). Built-in info panel disappears |
| 1.3 | Switch back | Click "Built-in Inventory" card | Spoolman config disappears, built-in info panel reappears |
| 1.4 | Persistence | Switch mode, reload page | Selected mode persists after reload |
| 1.5 | Spoolman disabled state | Select Spoolman without URL, check Connect button | Connect button should be disabled when URL is empty |
---
## 2. Spool Management
**Location:** Inventory page (sidebar)
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 2.1 | Add spool | Click "+ Add Spool", fill material/brand/color/weight, save | Spool appears in table with correct info |
| 2.2 | Edit spool | Click a spool row, change fields, save | Changes reflected in table |
| 2.3 | Remaining weight | Edit spool > Additional section > adjust remaining weight | Remaining weight = label_weight - weight_used, slider/input updates correctly |
| 2.4 | Spool catalog | Edit spool > Additional > Empty Spool Weight dropdown | Pre-defined spool weights shown, selecting one updates the field |
| 2.5 | Color picker | Edit spool > Color section | Recent colors, brand palettes, and hex input all work |
| 2.6 | PA profile tab | Edit spool > PA Profile tab | Matching K-profiles shown grouped by printer/nozzle (requires calibration data) |
| 2.7 | Archive spool | Archive a spool from context menu or edit | Spool moves to "Archived" tab |
| 2.8 | Delete spool | Delete a spool | Spool removed from all views |
| 2.9 | Summary cards | Check top of inventory page | Total Inventory, Total Consumed, By Material, In Printer, Low Stock cards show correct values |
| 2.10 | Filters | Try Active/Archived/All tabs, material/brand dropdowns, search | Filtering works correctly |
| 2.11 | View modes | Toggle between Table and Card view | Both views show correct spool data |
---
## 3. AMS Slot Assignment
**Location:** Printers page, AMS hover cards
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 3.1 | Assign spool | Hover over an empty/non-BL AMS slot > click "Assign Spool" | Modal opens showing only manual (non-BL) spools |
| 3.2 | BL spools filtered | Open assign modal | Bambu Lab spools (with RFID tags) are NOT in the list |
| 3.3 | Assigned spools filtered | Assign spool A to slot 1, then open assign modal for slot 2 | Spool A is NOT in the list (already assigned) |
| 3.4 | Current slot spool visible | Open assign for slot that already has a spool | The currently assigned spool IS still shown (for reassignment) |
| 3.5 | Confirm assignment | Select spool, click "Assign Spool" | Slot shows the assigned spool info on hover |
| 3.6 | Unassign spool | Hover over assigned slot > click "Unassign" | Spool removed from slot, slot shows default info |
| 3.7 | BL slot — no buttons | Hover over a slot with a Bambu Lab spool (RFID) | No "Assign Spool" or "Unassign" buttons shown |
| 3.8 | Empty slot display | Check an empty AMS slot | Shows type from AMS data or "Empty" (localized) |
---
## 4. Auto-Unlink on BL Spool Insertion
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 4.1 | BL spool replaces manual | Assign a manual spool to a slot, then physically insert a BL spool into that slot | Assignment automatically removed, slot now shows BL spool info |
| 4.2 | Log message | Check backend logs after 4.1 | Log shows "Auto-unlink: spool X AMSY-TZ — Bambu Lab spool detected" |
---
## 5. Spool Tag Linking
**Location:** Printers page, AMS hover cards for BL spools
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 5.1 | Link modal | Hover over BL spool slot > click "Link to Spool" (if available) | Modal opens showing only untagged spools |
| 5.2 | Search filter | Type in search box | Spools filtered by material/brand/color |
| 5.3 | Link spool | Click a spool in the list | Success toast, modal closes, spool now linked to tag |
| 5.4 | Tagged spools hidden | After linking spool A, open link modal again | Spool A is no longer in the list |
---
## 6. Usage Tracking — BL Spools (AMS Remain%)
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 6.1 | Session capture | Start a print with a BL spool | Backend log shows "Captured start remain% for printer X (N trays)" |
| 6.2 | Completed print | Let print finish | Spool's weight_used increases by (delta% * label_weight). Check inventory page remaining weight |
| 6.3 | Failed/aborted print | Start and cancel a print mid-way | Usage still tracked based on remain% delta at time of stop |
| 6.4 | No double-tracking | Complete a print with BL spool | Only AMS delta tracking applies (no 3MF fallback for this spool) |
---
## 7. Usage Tracking — Non-BL Spools (3MF Estimates)
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 7.1 | Assign and print | Assign a manual spool to AMS slot, start a print using that slot | Backend log shows "3MF fallback available" at print start |
| 7.2 | Completed print | Let print finish | Spool's weight_used increases by the 3MF estimated used_g. Check inventory page |
| 7.3 | Failed print scaling | Start a print, cancel at ~50% | Usage = 3MF estimate * (progress/100). E.g., 100g estimate at 50% = ~50g tracked |
| 7.4 | Multi-slot print | Print using multiple filament slots (some BL, some manual) | BL spools tracked via remain% delta, manual spools via 3MF. No double-counting |
| 7.5 | Slot mapping | Use a spool in AMS slot 5 (second AMS unit) | Correctly maps to AMS 1, Tray 0. Check usage history shows correct ams_id/tray_id |
---
## 8. Edge Cases
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 8.1 | No AMS data | Print from external spool (no AMS) | No crash, usage tracking gracefully skipped |
| 8.2 | No archive | Print without 3MF archive available | AMS delta path still works for BL spools, 3MF path skipped gracefully |
| 8.3 | Spool refilled | If remain% goes UP between start and end (spool swapped/refilled) | Negative delta skipped, no negative usage recorded |
| 8.4 | Rapid print start/stop | Start and immediately cancel a print | No errors, minimal or zero usage tracked |
| 8.5 | Concurrent printers | Print on two printers simultaneously | Each printer tracked independently, no cross-contamination |
---
## 9. UI/UX Checks
| # | Test | Steps | Expected |
|---|------|-------|----------|
| 9.1 | Mobile layout | Open on mobile or narrow browser | Inventory page, assign modal, and settings all responsive |
| 9.2 | Dark/light mode | Toggle theme | All inventory UI elements properly themed |
| 9.3 | Language switching | Switch to German and Japanese | All inventory strings translated (no hardcoded English) |
| 9.4 | Keyboard nav | Use keyboard shortcuts on printers page | AMS slot interactions accessible |
---
## Reporting Issues
When reporting a bug, please include:
- Test case number (e.g., "Beta 9.1 failed")
- Browser + version
- Screenshot or screen recording
- Backend logs (if relevant — check Docker logs)
- Steps to reproduce if different from above
+24
View File
@@ -2,6 +2,29 @@
All notable changes to Bambuddy will be documented in this file.
## [0.2.0b] - Not released
### New Features
- **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.
- **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally.
- **Spool Inventory — 3MF-Based Usage Tracking for Non-BL Spools** — Non-Bambu-Lab spools (no RFID) cannot use AMS remain% for usage tracking. Now falls back to per-filament weight estimates from the archived 3MF file (`used_g` per filament slot). For completed prints, uses the full slicer estimate. For failed or aborted prints, scales by print progress percentage. Bambu Lab spools continue using AMS remain% delta tracking as before.
### Fixed
- **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
- **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
- **File Downloads Show Generic Filenames** ([#334](https://github.com/maziggy/bambuddy/issues/334)) — Downloaded files with special characters in their names (spaces, umlauts, parentheses) were saved as generic `file_1`, `file_2` instead of the original filename. The `Content-Disposition` header parser now handles RFC 5987 percent-encoded filenames (`filename*=utf-8''...`) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files).
- **Printer Card Cover Image Not Updating Between Prints** — The cover image on the printer card only refreshed on page reload. The `<img>` URL was always the same (`/printers/{id}/cover`) regardless of which print was active, so the browser served its cached image. Now appends the print name as a cache-busting query parameter so the browser fetches the new cover when a different print starts.
- **Telegram Bold Title Broken by Underscores in Message** ([#332](https://github.com/maziggy/bambuddy/issues/332)) — Telegram notifications showed literal `*Title*` asterisks instead of bold text when the message body contained underscores (e.g. job name `A1_plate_8`, error code `0300_0001`). The code was disabling Markdown parsing entirely when underscores were detected. Now escapes underscores in the body with `\_` so Markdown rendering stays enabled.
- **Queued Jobs Incorrectly Archived After Duplicate Execution Detection** ([#341](https://github.com/maziggy/bambuddy/issues/341)) — When the same file was added to the print queue multiple times, only the first job executed. All subsequent jobs were automatically skipped with "already printed X hours ago" because they shared the same archive reference, and a safety check incorrectly treated them as phantom reprints. The same issue also affected single queue items created from recently completed archives. Removed the overly broad 4-hour duplicate detection check — the crash recovery scenario it guarded against is already handled by the queue item status lifecycle.
### New Features
- **External Links: Open in New Tab** ([#338](https://github.com/maziggy/bambuddy/issues/338)) — External sidebar links can now optionally open in a new browser tab instead of an iframe. Sites behind reverse proxies (Traefik, nginx) that send `X-Frame-Options: SAMEORIGIN` or CSP `frame-ancestors` headers block iframe embedding, causing "refused to connect" errors. A new "Open in new tab" toggle in the add/edit link modal lets users choose per-link. Keyboard shortcuts (number keys) also respect the setting. Defaults to iframe (existing behavior) for backward compatibility.
- **Print Queue: Clear Plate Confirmation** — When a print finishes or fails and more items are queued, the printer card now shows a "Clear Plate & Start Next" button. The scheduler no longer auto-starts the next print while the printer is in FINISH or FAILED state — the user must confirm the build plate has been cleared first. This prevents prints from starting on a dirty plate. The button respects the `printers:control` permission and is available in all supported languages (en/de/ja).
### Improved
- **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329), [#333](https://github.com/maziggy/bambuddy/issues/333)) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.
- **Move Email Settings Under Authentication Tab** — Renamed the settings "Users" tab to "Authentication" and moved the standalone "Global Email" tab into it as an "Email Authentication" sub-tab. Groups email/SMTP configuration with user management where it logically belongs. Legacy `?tab=email` URLs are handled automatically.
## [0.1.9] - 2026-02-10
### New Features
@@ -37,6 +60,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Virtual Printer IP Override for Server Mode** ([#52](https://github.com/maziggy/bambuddy/issues/52)) — The `remote_interface_ip` setting (network interface override) was only used in proxy mode, but users with multiple network interfaces (LAN + Tailscale, Docker bridges) also needed it in server modes (immediate/review/print_queue). Auto-detected IP from `_get_local_ip()` followed the OS default route, causing wrong IP in TLS certificate SAN (handshake failures) and SSDP broadcasts (slicer can't discover printer). Now the interface override applies to all modes: included in certificate SAN, passed to SSDP server as advertise IP, and triggers service restart on change. UI dropdown shown for all modes when enabled (not just proxy).
- **Wrong Thumbnail When Reprinting Same Project** ([#314](https://github.com/maziggy/bambuddy/issues/314)) — Reprinting a project with the same name but a different bed layout showed the old thumbnail during printing. The cover image cache was keyed by `subtask_name` and never invalidated between prints, so a cache hit returned the stale first-print thumbnail. Now the cover cache is cleared on every print start.
- **Wrong Timelapse Attached to Archive** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — After a print, the archive could receive a timelapse from a previous print instead of the just-completed one. The auto-scan sorted MP4 files by mtime and grabbed the "most recent," but in LAN-only mode (no NTP) the printer's clock is wrong, making mtime unreliable. Replaced with a snapshot-diff approach: baseline existing files before waiting, then detect the new file that appears after encoding. Falls back to print-name matching if no new file is found after retries.
- **Timelapse Not Attached — Baseline Race Condition** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Follow-up to the snapshot-diff timelapse fix: the baseline of existing MP4 files was captured at print completion time inside a background task, but fast-encoding printers could finish writing the timelapse before the baseline was taken, causing the new file to appear in the baseline and never be detected as "new." Moved baseline capture to print start time, when the timelapse file cannot possibly exist yet. Falls back to completion-time baseline if the app was restarted mid-print.
- **Calibration Prints Archived** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — Standalone calibration prints (flow, vibration, bed leveling) were being archived as regular prints. The calibration gcode (`/usr/etc/print/auto_cali_for_user.gcode`) and other internal printer files under `/usr/` are now detected and skipped during print start.
- **Camera Stop 401 When Auth Enabled** — Camera stop requests (`sendBeacon`) failed with 401 Unauthorized when authentication was enabled because `sendBeacon` cannot send auth headers. Replaced with `fetch` + `keepalive: true` which supports Authorization headers while remaining reliable during page unload.
- **Spoolman Creates Duplicate Spools on Startup** ([#295](https://github.com/maziggy/bambuddy/pull/295)) — Each AMS tray independently fetched all spools from Spoolman, causing redundant API calls and duplicate spool creation with large databases (300+ spools). Now fetches spools once and reuses cached data across all tray operations. Added retry logic (3 attempts, 500ms delay) with connection recreation for transient network errors.
+1 -1
View File
@@ -92,7 +92,7 @@ docker compose pull && docker compose up -d
| Series | Models | Status |
|---|---|---|
| H2 | H2D | Tested |
| H2 | H2C, H2D, H2D Pro, H2S | Tested |
| X1 | X1 Carbon, X1E | Tested |
| P1 | P1P, P1S | Compatible |
| P2 | P2S | Compatible |
+6
View File
@@ -103,6 +103,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Per-printer AMS mapping (individual slot configuration for print farms)
- Scheduled prints (date/time)
- Queue Only mode (stage without auto-start)
- Clear plate confirmation between queued prints
- Smart plug integration (Tasmota, Home Assistant, MQTT)
- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
- Energy consumption tracking (per-print kWh and cost)
@@ -145,6 +146,11 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
- Build plate detection alerts
- Queue events (waiting, skipped, failed)
### 🧵 Spool Inventory
- Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management
- Automatic filament consumption tracking: AMS RFID for Bambu Lab spools, 3MF estimates for third-party spools
- Spool catalog, color catalog, PA profile matching, and low-stock alerts
### 🔧 Integrations
- [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
- MQTT publishing for Home Assistant, Node-RED, etc.
+19
View File
@@ -547,6 +547,25 @@ async def camera_snapshot(
printer = await get_printer_or_404(printer_id, db)
# Check for external camera first
if printer.external_camera_enabled and printer.external_camera_url:
from backend.app.services.external_camera import capture_frame
frame_data = await capture_frame(printer.external_camera_url, printer.external_camera_type, timeout=15)
if not frame_data:
raise HTTPException(
status_code=503,
detail="Failed to capture frame from external camera.",
)
return Response(
content=frame_data,
media_type="image/jpeg",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Content-Disposition": f'inline; filename="snapshot_{printer_id}.jpg"',
},
)
# Create temporary file for the snapshot
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
temp_path = Path(f.name)
+48 -3
View File
@@ -246,11 +246,12 @@ async def get_slicer_settings(
for api_key, our_type in type_mapping.items():
type_data = data.get(api_key, {})
# Combine public and private presets, private (user's own) first
all_settings = type_data.get("private", []) + type_data.get("public", [])
private_settings = type_data.get("private", [])
public_settings = type_data.get("public", [])
parsed = []
for s in all_settings:
# Private (custom) presets first
for s in private_settings:
parsed.append(
SlicerSetting(
setting_id=s.get("setting_id", s.get("id", "")),
@@ -259,6 +260,20 @@ async def get_slicer_settings(
version=s.get("version"),
user_id=s.get("user_id"),
updated_time=s.get("updated_time"),
is_custom=True,
)
)
# Public (default) presets
for s in public_settings:
parsed.append(
SlicerSetting(
setting_id=s.get("setting_id", s.get("id", "")),
name=s.get("name", "Unknown"),
type=our_type,
version=s.get("version"),
user_id=s.get("user_id"),
updated_time=s.get("updated_time"),
is_custom=False,
)
)
setattr(result, our_type, parsed)
@@ -302,6 +317,22 @@ async def get_setting_detail(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/filaments", response_model=list[SlicerSetting])
async def get_filament_presets(
version: str = "02.04.00.70",
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""
Get just filament presets (convenience endpoint).
Returns all filament presets with custom presets first.
Uses the same cache as get_slicer_settings.
"""
settings = await get_slicer_settings(version=version, db=db)
return settings.filament
# Cache for filament preset info (setting_id -> {name, k})
_filament_cache: dict[str, dict] = {}
_filament_cache_time: float = 0
@@ -844,6 +875,20 @@ def _load_fields(preset_type: str) -> dict:
return data
@router.get("/builtin-filaments")
async def get_builtin_filaments(
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
):
"""
Get built-in filament names as a fallback source.
Returns the static _BUILTIN_FILAMENT_NAMES table as a list of
{filament_id, name} objects. Used by the frontend when cloud
and local profiles are unavailable.
"""
return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
@router.get("/fields/{preset_type}")
async def get_preset_fields(
preset_type: Literal["filament", "print", "process", "printer"],
+946
View File
@@ -0,0 +1,946 @@
import json
import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.color_catalog import ColorCatalogEntry
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spool_catalog import SpoolCatalogEntry
from backend.app.models.spool_k_profile import SpoolKProfile
from backend.app.models.user import User
from backend.app.schemas.spool import (
SpoolAssignmentCreate,
SpoolAssignmentResponse,
SpoolCreate,
SpoolKProfileBase,
SpoolKProfileResponse,
SpoolResponse,
SpoolUpdate,
)
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/inventory", tags=["inventory"])
# Material temperature defaults (nozzle min/max)
MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
"PLA": (190, 230),
"PETG": (220, 260),
"ABS": (240, 270),
"ASA": (240, 270),
"TPU": (200, 240),
"PA": (260, 290),
"PC": (250, 280),
"PVA": (190, 210),
"PLA-CF": (210, 240),
"PETG-CF": (240, 270),
"PA-CF": (270, 300),
}
# FilamentColors.xyz API
FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
# ── Spool Catalog Schemas ──────────────────────────────────────────────────
class CatalogEntryResponse(BaseModel):
id: int
name: str
weight: int
is_default: bool
class Config:
from_attributes = True
class CatalogEntryCreate(BaseModel):
name: str
weight: int
class CatalogEntryUpdate(BaseModel):
name: str
weight: int
# ── Color Catalog Schemas ──────────────────────────────────────────────────
class ColorEntryResponse(BaseModel):
id: int
manufacturer: str
color_name: str
hex_color: str
material: str | None
is_default: bool
class Config:
from_attributes = True
class ColorEntryCreate(BaseModel):
manufacturer: str
color_name: str
hex_color: str
material: str | None = None
class ColorEntryUpdate(BaseModel):
manufacturer: str
color_name: str
hex_color: str
material: str | None = None
class ColorLookupResult(BaseModel):
found: bool
hex_color: str | None = None
material: str | None = None
# ── Spool Catalog CRUD ─────────────────────────────────────────────────────
@router.get("/catalog", response_model=list[CatalogEntryResponse])
async def get_spool_catalog(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Get all spool catalog entries."""
result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
return list(result.scalars().all())
@router.post("/catalog", response_model=CatalogEntryResponse)
async def add_catalog_entry(
entry: CatalogEntryCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Add a new spool catalog entry."""
row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
db.add(row)
await db.commit()
await db.refresh(row)
return row
@router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
async def update_catalog_entry(
entry_id: int,
entry: CatalogEntryUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Update a spool catalog entry."""
result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
row = result.scalar_one_or_none()
if not row:
raise HTTPException(404, "Entry not found")
row.name = entry.name
row.weight = entry.weight
await db.commit()
await db.refresh(row)
return row
@router.delete("/catalog/{entry_id}")
async def delete_catalog_entry(
entry_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Delete a spool catalog entry."""
result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
row = result.scalar_one_or_none()
if not row:
raise HTTPException(404, "Entry not found")
await db.delete(row)
await db.commit()
return {"status": "deleted"}
@router.post("/catalog/reset")
async def reset_spool_catalog(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Reset spool catalog to defaults."""
await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
# Delete all
result = await db.execute(select(SpoolCatalogEntry))
for row in result.scalars().all():
await db.delete(row)
# Re-seed defaults
for name, weight in DEFAULT_SPOOL_CATALOG:
db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
await db.commit()
return {"status": "reset"}
# ── Color Catalog CRUD ─────────────────────────────────────────────────────
@router.get("/colors", response_model=list[ColorEntryResponse])
async def get_color_catalog(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Get all color catalog entries."""
result = await db.execute(
select(ColorCatalogEntry).order_by(
ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
)
)
return list(result.scalars().all())
@router.post("/colors", response_model=ColorEntryResponse)
async def add_color_entry(
entry: ColorEntryCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Add a new color catalog entry."""
row = ColorCatalogEntry(
manufacturer=entry.manufacturer,
color_name=entry.color_name,
hex_color=entry.hex_color,
material=entry.material,
is_default=False,
)
db.add(row)
await db.commit()
await db.refresh(row)
return row
@router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
async def update_color_entry(
entry_id: int,
entry: ColorEntryUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Update a color catalog entry."""
result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
row = result.scalar_one_or_none()
if not row:
raise HTTPException(404, "Entry not found")
row.manufacturer = entry.manufacturer
row.color_name = entry.color_name
row.hex_color = entry.hex_color
row.material = entry.material
await db.commit()
await db.refresh(row)
return row
@router.delete("/colors/{entry_id}")
async def delete_color_entry(
entry_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Delete a color catalog entry."""
result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
row = result.scalar_one_or_none()
if not row:
raise HTTPException(404, "Entry not found")
await db.delete(row)
await db.commit()
return {"status": "deleted"}
@router.post("/colors/reset")
async def reset_color_catalog(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Reset color catalog to defaults."""
result = await db.execute(select(ColorCatalogEntry))
for row in result.scalars().all():
await db.delete(row)
for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
db.add(
ColorCatalogEntry(
manufacturer=manufacturer,
color_name=color_name,
hex_color=hex_color,
material=material,
is_default=True,
)
)
await db.commit()
return {"status": "reset"}
@router.get("/colors/lookup", response_model=ColorLookupResult)
async def lookup_color(
manufacturer: str,
color_name: str,
material: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Look up a color by manufacturer and color name."""
query = select(ColorCatalogEntry).where(
ColorCatalogEntry.manufacturer == manufacturer,
ColorCatalogEntry.color_name == color_name,
)
if material:
query = query.where(ColorCatalogEntry.material == material)
query = query.limit(1)
result = await db.execute(query)
row = result.scalar_one_or_none()
if row:
return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
return ColorLookupResult(found=False)
@router.get("/colors/search", response_model=list[ColorEntryResponse])
async def search_colors(
manufacturer: str | None = None,
material: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Search colors by manufacturer and/or material."""
query = select(ColorCatalogEntry)
if manufacturer:
query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
if material:
query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
result = await db.execute(query)
return list(result.scalars().all())
@router.post("/colors/sync")
async def sync_from_filamentcolors(
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Sync colors from FilamentColors.xyz API with progress streaming."""
async def generate():
from backend.app.core.database import async_session
added = 0
skipped = 0
total_fetched = 0
total_available = 0
try:
async with httpx.AsyncClient(timeout=120.0) as client:
page = 1
while True:
response = await client.get(
f"{FILAMENT_COLORS_API}/swatch/",
params={"page": page},
)
response.raise_for_status()
data = response.json()
total_available = data.get("count", total_available)
results = data.get("results", [])
if not results:
break
async with async_session() as db:
for swatch in results:
total_fetched += 1
manufacturer_data = swatch.get("manufacturer")
manufacturer_name = (
manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
)
filament_type_data = swatch.get("filament_type")
mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
color_name_val = swatch.get("color_name", "")
hex_color_val = swatch.get("hex_color", "")
if not manufacturer_name or not color_name_val or not hex_color_val:
skipped += 1
continue
if not hex_color_val.startswith("#"):
hex_color_val = f"#{hex_color_val}"
# Check if entry already exists
existing = await db.execute(
select(ColorCatalogEntry)
.where(
ColorCatalogEntry.manufacturer == manufacturer_name,
ColorCatalogEntry.color_name == color_name_val,
ColorCatalogEntry.material == mat,
)
.limit(1)
)
if existing.scalar_one_or_none():
skipped += 1
else:
db.add(
ColorCatalogEntry(
manufacturer=manufacturer_name,
color_name=color_name_val,
hex_color=hex_color_val.upper(),
material=mat,
is_default=False,
)
)
added += 1
await db.commit()
progress = {
"type": "progress",
"added": added,
"skipped": skipped,
"total_fetched": total_fetched,
"total_available": total_available,
}
yield f"data: {json.dumps(progress)}\n\n"
if not data.get("next") or total_fetched >= total_available:
break
page += 1
result = {
"type": "complete",
"added": added,
"skipped": skipped,
"total_fetched": total_fetched,
"total_available": total_available,
}
yield f"data: {json.dumps(result)}\n\n"
except httpx.HTTPError as e:
logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
except Exception as e:
logger.error("Error syncing from FilamentColors.xyz: %s", e)
yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
# ── Spool CRUD ───────────────────────────────────────────────────────────────
@router.get("/spools", response_model=list[SpoolResponse])
async def list_spools(
include_archived: bool = False,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""List all spools, excluding archived by default."""
query = select(Spool).options(selectinload(Spool.k_profiles))
if not include_archived:
query = query.where(Spool.archived_at.is_(None))
query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
result = await db.execute(query)
return list(result.scalars().all())
@router.get("/spools/{spool_id}", response_model=SpoolResponse)
async def get_spool(
spool_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Get a single spool with k_profiles."""
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
spool = result.scalar_one_or_none()
if not spool:
raise HTTPException(404, "Spool not found")
return spool
@router.post("/spools", response_model=SpoolResponse)
async def create_spool(
spool_data: SpoolCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Create a new spool."""
spool = Spool(**spool_data.model_dump())
db.add(spool)
await db.commit()
await db.refresh(spool)
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
return result.scalar_one()
@router.patch("/spools/{spool_id}", response_model=SpoolResponse)
async def update_spool(
spool_id: int,
spool_data: SpoolUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Update a spool."""
result = await db.execute(select(Spool).where(Spool.id == spool_id))
spool = result.scalar_one_or_none()
if not spool:
raise HTTPException(404, "Spool not found")
for field, value in spool_data.model_dump(exclude_unset=True).items():
setattr(spool, field, value)
await db.commit()
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
return result.scalar_one()
@router.delete("/spools/{spool_id}")
async def delete_spool(
spool_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Hard delete a spool."""
result = await db.execute(select(Spool).where(Spool.id == spool_id))
spool = result.scalar_one_or_none()
if not spool:
raise HTTPException(404, "Spool not found")
await db.delete(spool)
await db.commit()
return {"status": "deleted"}
@router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
async def archive_spool(
spool_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Soft-delete a spool by setting archived_at."""
from datetime import datetime, timezone
result = await db.execute(select(Spool).where(Spool.id == spool_id))
spool = result.scalar_one_or_none()
if not spool:
raise HTTPException(404, "Spool not found")
spool.archived_at = datetime.now(timezone.utc)
await db.commit()
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
return result.scalar_one()
@router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
async def restore_spool(
spool_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Restore an archived spool."""
result = await db.execute(select(Spool).where(Spool.id == spool_id))
spool = result.scalar_one_or_none()
if not spool:
raise HTTPException(404, "Spool not found")
spool.archived_at = None
await db.commit()
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
return result.scalar_one()
# ── K-Profiles ───────────────────────────────────────────────────────────────
@router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
async def list_k_profiles(
spool_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""List K-profiles for a spool."""
result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
return list(result.scalars().all())
@router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
async def replace_k_profiles(
spool_id: int,
profiles: list[SpoolKProfileBase],
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Replace all K-profiles for a spool (batch save)."""
# Verify spool exists
result = await db.execute(select(Spool).where(Spool.id == spool_id))
if not result.scalar_one_or_none():
raise HTTPException(404, "Spool not found")
# Delete existing
existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
for old in existing.scalars().all():
await db.delete(old)
# Create new
new_profiles = []
for p in profiles:
kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
db.add(kp)
new_profiles.append(kp)
await db.commit()
for kp in new_profiles:
await db.refresh(kp)
return new_profiles
# ── Spool Assignments ────────────────────────────────────────────────────────
@router.get("/assignments", response_model=list[SpoolAssignmentResponse])
async def list_assignments(
printer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""List spool assignments, optionally filtered by printer."""
query = select(SpoolAssignment).options(
selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
selectinload(SpoolAssignment.printer),
)
if printer_id is not None:
query = query.where(SpoolAssignment.printer_id == printer_id)
result = await db.execute(query)
return list(result.scalars().all())
@router.post("/assignments", response_model=SpoolAssignmentResponse)
async def assign_spool(
data: SpoolAssignmentCreate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Assign a spool to an AMS slot and auto-configure via MQTT."""
from backend.app.services.printer_manager import printer_manager
# 1. Validate spool exists and is not archived
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
spool = result.scalar_one_or_none()
if not spool:
raise HTTPException(404, "Spool not found")
if spool.archived_at:
raise HTTPException(400, "Cannot assign an archived spool")
# 2. Get current AMS tray state for fingerprint
fingerprint_color = None
fingerprint_type = None
state = printer_manager.get_status(data.printer_id)
if state and state.raw_data:
ams_data = state.raw_data.get("ams", {})
ams_list = (
ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
)
tray = _find_tray_in_ams_data(
ams_list,
data.ams_id,
data.tray_id,
)
if tray:
fingerprint_color = tray.get("tray_color", "")
fingerprint_type = tray.get("tray_type", "")
# 3. Upsert assignment (replace if same printer+ams+tray)
existing = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == data.printer_id,
SpoolAssignment.ams_id == data.ams_id,
SpoolAssignment.tray_id == data.tray_id,
)
)
old = existing.scalar_one_or_none()
if old:
await db.delete(old)
await db.flush()
assignment = SpoolAssignment(
spool_id=data.spool_id,
printer_id=data.printer_id,
ams_id=data.ams_id,
tray_id=data.tray_id,
fingerprint_color=fingerprint_color,
fingerprint_type=fingerprint_type,
)
db.add(assignment)
await db.commit()
await db.refresh(assignment)
# 4. Auto-configure AMS slot via MQTT
configured = False
try:
client = printer_manager.get_client(data.printer_id)
if client:
# Build filament setting from spool data
tray_type = spool.material
tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
tray_color = spool.rgba or "FFFFFFFF"
tray_info_idx = spool.slicer_filament or ""
setting_id = ""
# Temperature: use spool overrides if set, else material defaults
temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
if spool.nozzle_temp_min is not None:
temp_min = spool.nozzle_temp_min
if spool.nozzle_temp_max is not None:
temp_max = spool.nozzle_temp_max
# a. Set filament setting
client.ams_set_filament_setting(
ams_id=data.ams_id,
tray_id=data.tray_id,
tray_info_idx=tray_info_idx,
tray_type=tray_type,
tray_sub_brands=tray_sub_brands,
tray_color=tray_color,
nozzle_temp_min=temp_min,
nozzle_temp_max=temp_max,
setting_id=setting_id,
)
# b. Look up K-profile for this spool + printer + nozzle
nozzle_diameter = "0.4"
if state and state.nozzles:
nd = state.nozzles[0].nozzle_diameter
if nd:
nozzle_diameter = nd
matching_kp = None
for kp in spool.k_profiles:
if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
matching_kp = kp
break
if matching_kp and matching_kp.cali_idx is not None:
client.extrusion_cali_sel(
ams_id=data.ams_id,
tray_id=data.tray_id,
cali_idx=matching_kp.cali_idx,
filament_id=tray_info_idx,
nozzle_diameter=nozzle_diameter,
setting_id=matching_kp.setting_id,
)
configured = True
logger.info(
"Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
data.ams_id,
data.tray_id,
spool.id,
data.printer_id,
)
except Exception as e:
logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
# Return assignment with spool data
result = await db.execute(
select(SpoolAssignment)
.options(
selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
selectinload(SpoolAssignment.printer),
)
.where(SpoolAssignment.id == assignment.id)
)
resp = result.scalar_one()
response = SpoolAssignmentResponse.model_validate(resp)
response.configured = configured
return response
@router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
async def unassign_spool(
printer_id: int,
ams_id: int,
tray_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Unassign a spool from an AMS slot."""
result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(404, "Assignment not found")
await db.delete(assignment)
await db.commit()
return {"status": "deleted"}
# ── Tag Linking ───────────────────────────────────────────────────────────────
class LinkTagRequest(BaseModel):
tag_uid: str | None = None
tray_uuid: str | None = None
tag_type: str | None = None
data_origin: str | None = "nfc_link"
@router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
async def link_tag_to_spool(
spool_id: int,
data: LinkTagRequest,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Link an RFID tag_uid/tray_uuid to an existing spool."""
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
spool = result.scalar_one_or_none()
if not spool:
raise HTTPException(404, "Spool not found")
if spool.archived_at:
raise HTTPException(400, "Cannot link tag to archived spool")
# Check for conflicts: tag already linked to another active spool
if data.tag_uid:
conflict = await db.execute(
select(Spool).where(
Spool.tag_uid == data.tag_uid,
Spool.id != spool_id,
Spool.archived_at.is_(None),
)
)
if conflict.scalar_one_or_none():
raise HTTPException(409, "Tag UID already linked to another active spool")
# Auto-clear from archived spools (tag recycling)
archived_with_tag = await db.execute(
select(Spool).where(
Spool.tag_uid == data.tag_uid,
Spool.id != spool_id,
Spool.archived_at.is_not(None),
)
)
for old_spool in archived_with_tag.scalars().all():
old_spool.tag_uid = None
if data.tray_uuid:
conflict = await db.execute(
select(Spool).where(
Spool.tray_uuid == data.tray_uuid,
Spool.id != spool_id,
Spool.archived_at.is_(None),
)
)
if conflict.scalar_one_or_none():
raise HTTPException(409, "Tray UUID already linked to another active spool")
archived_with_uuid = await db.execute(
select(Spool).where(
Spool.tray_uuid == data.tray_uuid,
Spool.id != spool_id,
Spool.archived_at.is_not(None),
)
)
for old_spool in archived_with_uuid.scalars().all():
old_spool.tray_uuid = None
if data.tag_uid is not None:
spool.tag_uid = data.tag_uid
if data.tray_uuid is not None:
spool.tray_uuid = data.tray_uuid
if data.tag_type is not None:
spool.tag_type = data.tag_type
if data.data_origin is not None:
spool.data_origin = data.data_origin
await db.commit()
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
return result.scalar_one()
# ── Usage History ─────────────────────────────────────────────────────────────
@router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
async def get_spool_usage_history(
spool_id: int,
limit: int = 50,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Get usage history for a specific spool."""
from backend.app.models.spool_usage_history import SpoolUsageHistory
# Verify spool exists
spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
if not spool_result.scalar_one_or_none():
raise HTTPException(404, "Spool not found")
result = await db.execute(
select(SpoolUsageHistory)
.where(SpoolUsageHistory.spool_id == spool_id)
.order_by(SpoolUsageHistory.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
@router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
async def get_all_usage_history(
limit: int = 100,
printer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
):
"""Get global usage history, optionally filtered by printer."""
from backend.app.models.spool_usage_history import SpoolUsageHistory
query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
if printer_id is not None:
query = query.where(SpoolUsageHistory.printer_id == printer_id)
result = await db.execute(query)
return list(result.scalars().all())
@router.delete("/spools/{spool_id}/usage")
async def clear_spool_usage_history(
spool_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
):
"""Clear usage history for a spool."""
from backend.app.models.spool_usage_history import SpoolUsageHistory
result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
for row in result.scalars().all():
await db.delete(row)
await db.commit()
return {"status": "cleared"}
# ── Helpers ──────────────────────────────────────────────────────────────────
def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
"""Find a specific tray in the AMS data structure."""
if not ams_data:
return None
for ams_unit in ams_data:
if int(ams_unit.get("id", -1)) != ams_id:
continue
for tray in ams_unit.get("tray", []):
if int(tray.get("id", -1)) == tray_id:
return tray
return None
+31
View File
@@ -1821,6 +1821,37 @@ async def stop_print(
return {"success": True, "message": "Print stop command sent"}
@router.post("/{printer_id}/clear-plate")
async def clear_plate(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Acknowledge that the build plate has been cleared after a finished/failed print.
Sets a plate-cleared flag so the scheduler can start the next queued print.
No MQTT command is sent to the printer — the scheduler's start_print command
will override the FINISH/FAILED state when it sends the next job.
"""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
if not printer_manager.is_connected(printer_id):
raise HTTPException(400, "Printer not connected")
state = printer_manager.get_status(printer_id)
if not state or state.state not in ("FINISH", "FAILED"):
raise HTTPException(
400, f"Printer is not in FINISH or FAILED state (current: {state.state if state else 'unknown'})"
)
printer_manager.set_plate_cleared(printer_id)
return {"success": True, "message": "Plate cleared, next print will start shortly"}
@router.post("/{printer_id}/print/pause")
async def pause_print(
printer_id: int,
+47 -3
View File
@@ -6,12 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.user import User
from backend.app.services.printer_manager import printer_manager
from backend.app.services.spoolman import (
@@ -230,6 +232,22 @@ async def sync_printer_ams(
detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
)
# Load inventory weights as fallback (when AMS MQTT data lacks remain values)
inv_weights: dict[tuple[int, int], float] = {}
try:
assign_result = await db.execute(
select(SpoolAssignment)
.options(selectinload(SpoolAssignment.spool))
.where(SpoolAssignment.printer_id == printer_id)
)
for assignment in assign_result.scalars().all():
spool = assignment.spool
if spool and spool.label_weight > 0:
remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
except Exception as e:
logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
for ams_unit in ams_units:
if not isinstance(ams_unit, dict):
continue
@@ -270,11 +288,13 @@ async def sync_printer_ams(
current_tray_uuids.add(spool_tag.upper())
try:
inv_remaining = inv_weights.get((ams_id, tray.tray_id))
sync_result = await client.sync_ams_tray(
tray,
printer.name,
disable_weight_sync=disable_weight_sync,
cached_spools=cached_spools,
inventory_remaining=inv_remaining,
)
if sync_result:
synced += 1
@@ -345,6 +365,8 @@ async def sync_all_printers(
all_errors = []
# Track tray UUIDs per printer (for clearing removed spools)
printer_tray_uuids: dict[str, set[str]] = {}
# Track synced spool IDs per printer (for location-based cleanup when no UUIDs available)
printer_synced_ids: dict[str, set[int]] = {}
# OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
# This eliminates redundant API calls across all printers
@@ -359,6 +381,19 @@ async def sync_all_printers(
detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
)
# Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
# Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
inventory_weights: dict[tuple[int, int, int], float] = {}
try:
assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
for assignment in assign_result.scalars().all():
spool = assignment.spool
if spool and spool.label_weight > 0:
remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
except Exception as e:
logger.debug("Could not load inventory assignments for weight fallback: %s", e)
for printer in printers:
state = printer_manager.get_status(printer.id)
if not state or not state.raw_data:
@@ -368,8 +403,9 @@ async def sync_all_printers(
if not ams_data:
continue
# Initialize tray UUID set for this printer
# Initialize tracking sets for this printer
printer_tray_uuids[printer.name] = set()
printer_synced_ids[printer.name] = set()
# Handle different AMS data structures
# Traditional AMS: list of {"id": N, "tray": [...]} dicts
@@ -432,16 +468,21 @@ async def sync_all_printers(
printer_tray_uuids[printer.name].add(spool_tag.upper())
try:
# Look up inventory weight as fallback when AMS data is invalid
inv_remaining = inventory_weights.get((printer.id, ams_id, tray.tray_id))
sync_result = await client.sync_ams_tray(
tray,
printer.name,
disable_weight_sync=disable_weight_sync,
cached_spools=cached_spools,
inventory_remaining=inv_remaining,
)
if sync_result:
total_synced += 1
# Add newly created spool to cache
# Track synced spool ID for cleanup
if sync_result.get("id"):
printer_synced_ids[printer.name].add(sync_result["id"])
# Add newly created spool to cache
spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
if not spool_exists:
cached_spools.append(sync_result)
@@ -453,7 +494,10 @@ async def sync_all_printers(
for printer_name, current_tray_uuids in printer_tray_uuids.items():
try:
cleared = await client.clear_location_for_removed_spools(
printer_name, current_tray_uuids, cached_spools=cached_spools
printer_name,
current_tray_uuids,
cached_spools=cached_spools,
synced_spool_ids=printer_synced_ids.get(printer_name, set()),
)
if cleared > 0:
logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
+317
View File
@@ -0,0 +1,317 @@
"""Bambu Lab filament color code to color name mapping.
Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
Maps tray_id_name codes (e.g. "A06-D0") to human-readable color names (e.g. "Titan Gray").
"""
# Full color code → name mapping by material prefix
BAMBU_FILAMENT_COLORS: dict[str, str] = {
# PLA Basic (A00)
"A00-W1": "Jade White",
"A00-P0": "Beige",
"A00-D2": "Light Gray",
"A00-Y0": "Yellow",
"A00-Y2": "Sunflower Yellow",
"A00-A1": "Pumpkin Orange",
"A00-A0": "Orange",
"A00-Y4": "Gold",
"A00-G3": "Bright Green",
"A00-G1": "Bambu Green",
"A00-G2": "Mistletoe Green",
"A00-R3": "Hot Pink",
"A00-P6": "Magenta",
"A00-R0": "Red",
"A00-R2": "Maroon Red",
"A00-P5": "Purple",
"A00-P2": "Indigo Purple",
"A00-B5": "Turquoise",
"A00-B8": "Cyan",
"A00-B3": "Cobalt Blue",
"A00-N0": "Brown",
"A00-N1": "Cocoa Brown",
"A00-Y3": "Bronze",
"A00-D0": "Gray",
"A00-D1": "Silver",
"A00-B1": "Blue Grey",
"A00-D3": "Dark Gray",
"A00-K0": "Black",
# PLA Basic Gradient (A00-M*)
"A00-M3": "Pink Citrus",
"A00-M6": "Dusk Glare",
"A00-M0": "Arctic Whisper",
"A00-M1": "Solar Breeze",
"A00-M5": "Blueberry Bubblegum",
"A00-M4": "Mint Lime",
"A00-M2": "Ocean to Meadow",
"A00-M7": "Cotton Candy Cloud",
# PLA Lite (A18)
"A18-K0": "Black",
"A18-D0": "Gray",
"A18-W0": "White",
"A18-R0": "Red",
"A18-Y0": "Yellow",
"A18-B0": "Cyan",
"A18-B1": "Blue",
"A18-P0": "Matte Beige",
# PLA Matte (A01)
"A01-W2": "Ivory White",
"A01-W3": "Bone White",
"A01-Y2": "Lemon Yellow",
"A01-A2": "Mandarin Orange",
"A01-P3": "Sakura Pink",
"A01-P4": "Lilac Purple",
"A01-R3": "Plum",
"A01-R1": "Scarlet Red",
"A01-R4": "Dark Red",
"A01-G0": "Apple Green",
"A01-G1": "Grass Green",
"A01-G7": "Dark Green",
"A01-B4": "Ice Blue",
"A01-B0": "Sky Blue",
"A01-B3": "Marine Blue",
"A01-B6": "Dark Blue",
"A01-Y3": "Desert Tan",
"A01-N1": "Latte Brown",
"A01-N3": "Caramel",
"A01-R2": "Terracotta",
"A01-N2": "Dark Brown",
"A01-N0": "Dark Chocolate",
"A01-D3": "Ash Gray",
"A01-D0": "Nardo Gray",
"A01-K1": "Charcoal",
# PLA Glow (A12)
"A12-G0": "Green",
"A12-R0": "Pink",
"A12-A0": "Orange",
"A12-Y0": "Yellow",
"A12-B0": "Blue",
# PLA Marble (A07)
"A07-R5": "Red Granite",
"A07-D4": "White Marble",
# PLA Aero (A11)
"A11-W0": "White",
"A11-K0": "Black",
# PLA Sparkle (A08)
"A08-G3": "Alpine Green Sparkle",
"A08-D5": "Slate Gray Sparkle",
"A08-B7": "Royal Purple Sparkle",
"A08-R2": "Crimson Red Sparkle",
"A08-K2": "Onyx Black Sparkle",
"A08-Y1": "Classic Gold Sparkle",
# PLA Metal (A02)
"A02-B2": "Cobalt Blue Metallic",
"A02-G2": "Oxide Green Metallic",
"A02-Y1": "Iridium Gold Metallic",
"A02-D2": "Iron Gray Metallic",
# PLA Translucent (A17)
"A17-B1": "Blue",
"A17-A0": "Orange",
"A17-P0": "Purple",
# PLA Silk+ (A06)
"A06-Y1": "Gold",
"A06-D0": "Titan Gray",
"A06-D1": "Silver",
"A06-W0": "White",
"A06-R0": "Candy Red",
"A06-G0": "Candy Green",
"A06-G1": "Mint",
"A06-B1": "Blue",
"A06-B0": "Baby Blue",
"A06-P0": "Purple",
"A06-R1": "Rose Gold",
"A06-R2": "Pink",
"A06-Y0": "Champagne",
# PLA Silk Multi-Color (A05)
"A05-M8": "Dawn Radiance",
"A05-M4": "Aurora Purple",
"A05-M1": "South Beach",
"A05-T3": "Neon City",
"A05-T2": "Midnight Blaze",
"A05-T1": "Gilded Rose",
"A05-T4": "Blue Hawaii",
"A05-T5": "Velvet Eclipse",
# PLA Galaxy (A15)
"A15-B0": "Purple",
"A15-G0": "Green",
"A15-G1": "Nebulae",
"A15-R0": "Brown",
# PLA Wood (A16)
"A16-K0": "Black Walnut",
"A16-R0": "Rosewood",
"A16-N0": "Clay Brown",
"A16-G0": "Classic Birch",
"A16-W0": "White Oak",
"A16-Y0": "Ochre Yellow",
# PLA-CF (A50)
"A50-D6": "Lava Gray",
"A50-K0": "Black",
"A50-B6": "Royal Blue",
# PLA Tough+ (A10)
"A10-W0": "White",
"A10-D0": "Gray",
# PLA Tough (A09)
"A09-B5": "Lavender Blue",
"A09-B4": "Light Blue",
"A09-A0": "Orange",
"A09-D1": "Silver",
"A09-R3": "Vermilion Red",
"A09-Y0": "Yellow",
# PETG HF (G02)
"G02-K0": "Black",
"G02-W0": "White",
"G02-R0": "Red",
"G02-D0": "Gray",
"G02-D1": "Dark Gray",
"G02-Y1": "Cream",
"G02-Y0": "Yellow",
"G02-A0": "Orange",
"G02-N1": "Peanut Brown",
"G02-G1": "Lime Green",
"G02-G0": "Green",
"G02-G2": "Forest Green",
"G02-B1": "Lake Blue",
"G02-B0": "Blue",
# PETG Translucent (G01)
"G01-G1": "Translucent Teal",
"G01-B0": "Translucent Light Blue",
"G01-C0": "Clear",
"G01-D0": "Translucent Gray",
"G01-G0": "Translucent Olive",
"G01-N0": "Translucent Brown",
"G01-A0": "Translucent Orange",
"G01-P1": "Translucent Pink",
"G01-P0": "Translucent Purple",
# PETG-CF (G50)
"G50-P7": "Violet Purple",
"G50-K0": "Black",
# ABS (B00)
"B00-D1": "Silver",
"B00-K0": "Black",
"B00-W0": "White",
"B00-G6": "Bambu Green",
"B00-G7": "Olive",
"B00-Y1": "Tangerine Yellow",
"B00-A0": "Orange",
"B00-R0": "Red",
"B00-B4": "Azure",
"B00-B0": "Blue",
"B00-B6": "Navy Blue",
# ABS-GF (B50)
"B50-A0": "Orange",
"B50-K0": "Black",
# ASA (B01)
"B01-W0": "White",
"B01-K0": "Black",
"B01-D0": "Gray",
# ASA Aero (B02)
"B02-W0": "White",
# PC (C00)
"C00-C1": "Transparent",
"C00-C0": "Clear Black",
"C00-K0": "Black",
"C00-W0": "White",
# PC FR (C01)
"C01-K0": "Black",
# TPU for AMS (U02)
"U02-B0": "Blue",
"U02-D0": "Gray",
"U02-K0": "Black",
# PAHT-CF (N04)
"N04-K0": "Black",
# PA6-GF (N08)
"N08-K0": "Black",
# Support for PLA/PETG (S02, S05)
"S02-W0": "Nature",
"S02-W1": "White",
"S05-C0": "Black",
# Support for ABS (S06)
"S06-W0": "White",
# Support for PA/PET (S03)
"S03-G1": "Green",
# PVA (S04)
"S04-Y0": "Clear",
}
# Fallback: color code suffix → name (for unknown material prefixes)
BAMBU_COLOR_CODE_FALLBACK: dict[str, str] = {
"W0": "White",
"W1": "Jade White",
"W2": "Ivory White",
"W3": "Bone White",
"Y0": "Yellow",
"Y1": "Gold",
"Y2": "Sunflower Yellow",
"Y3": "Bronze",
"Y4": "Gold",
"A0": "Orange",
"A1": "Pumpkin Orange",
"A2": "Mandarin Orange",
"R0": "Red",
"R1": "Scarlet Red",
"R2": "Maroon Red",
"R3": "Hot Pink",
"R4": "Dark Red",
"R5": "Red Granite",
"P0": "Beige",
"P1": "Pink",
"P2": "Indigo Purple",
"P3": "Sakura Pink",
"P4": "Lilac Purple",
"P5": "Purple",
"P6": "Magenta",
"P7": "Violet Purple",
"B0": "Blue",
"B1": "Blue Grey",
"B2": "Cobalt Blue",
"B3": "Cobalt Blue",
"B4": "Ice Blue",
"B5": "Turquoise",
"B6": "Navy Blue",
"B7": "Royal Purple",
"B8": "Cyan",
"G0": "Green",
"G1": "Grass Green",
"G2": "Mistletoe Green",
"G3": "Bright Green",
"G6": "Bambu Green",
"G7": "Dark Green",
"N0": "Brown",
"N1": "Peanut Brown",
"N2": "Dark Brown",
"N3": "Caramel",
"D0": "Gray",
"D1": "Silver",
"D2": "Light Gray",
"D3": "Dark Gray",
"D4": "White Marble",
"D5": "Slate Gray",
"D6": "Lava Gray",
"K0": "Black",
"K1": "Charcoal",
"K2": "Onyx Black",
"C0": "Clear Black",
"C1": "Transparent",
}
def resolve_bambu_color_name(tray_id_name: str) -> str | None:
"""Resolve a Bambu Lab tray_id_name code to a human-readable color name.
Tries exact match first, then falls back to color code suffix lookup.
Returns None if the code cannot be resolved.
"""
if not tray_id_name:
return None
# Exact match
name = BAMBU_FILAMENT_COLORS.get(tray_id_name)
if name:
return name
# Fallback: use color code suffix (e.g. "D0" from "A06-D0")
parts = tray_id_name.split("-")
if len(parts) >= 2:
return BAMBU_COLOR_CODE_FALLBACK.get(parts[1])
return None
+357
View File
@@ -0,0 +1,357 @@
"""Default spool and color catalog entries."""
# (name, weight_in_grams)
DEFAULT_SPOOL_CATALOG: list[tuple[str, int]] = [
("3D FilaPrint - Cardboard", 210),
("3D FilaPrint - Plastic", 238),
("3D Fuel - Plastic", 264),
("3D Power - Plastic", 220),
("3D Solutech - Plastic", 173),
("3DE - Cardboard", 136),
("3DE - Plastic", 181),
("3DHOJOR - Cardboard", 157),
("3DJake - Cardboard", 209),
("3DJake - Plastic", 232),
("3DJake 250g - Plastic", 91),
("3DJake ecoPLA - Plastic", 210),
("3DXTech - Plastic", 258),
("Acccreate - Plastic", 161),
("Amazon Basics - Plastic", 234),
("Amolen - Plastic", 150),
("AMZ3D - Plastic", 233),
("Anycubic - Cardboard", 125),
("Anycubic - Plastic", 127),
("Atomic Filament - Plastic", 272),
("Aurapol - Plastic", 220),
("Azure Film - Plastic", 163),
("Bambu Lab - Plastic High Temp", 216),
("Bambu Lab - Plastic Low Temp", 250),
("Bambu Lab - Plastic White", 253),
("BQ - Plastic", 218),
("Colorfabb - Plastic", 236),
("Colorfabb 750g - Cardboard", 152),
("Colorfabb 750g - Plastic", 254),
("Comgrow - Cardboard", 166),
("Creality - Cardboard", 180),
("Creality - Plastic", 135),
("Das Filament - Plastic", 211),
("Devil Design - Plastic", 256),
("Duramic 3D - Cardboard", 136),
("Elegoo - Cardboard", 153),
("Elegoo - Plastic", 111),
("Eryone - Cardboard", 156),
("Eryone - Plastic", 187),
("eSUN - Cardboard", 147),
("eSUN - Plastic", 240),
("eSUN 2.5kg - Plastic", 634),
("Extrudr - Plastic", 244),
("Fiberlogy - Plastic", 260),
("Filament PM - Plastic", 224),
("Fillamentum - Plastic", 230),
("Flashforge - Plastic", 167),
("FormFutura - Cardboard", 155),
("FormFutura 750g - Plastic", 212),
("Geeetech - Plastic", 178),
("Gembird - Cardboard", 143),
("Hatchbox - Plastic", 225),
("Inland - Cardboard", 142),
("Inland - Plastic", 210),
("Jayo - Cardboard", 120),
("Jayo - Plastic", 126),
("Jayo 250g - Plastic", 58),
("Kingroon - Cardboard", 155),
("Kingroon - Plastic", 156),
("KVP - Plastic", 263),
("Matter Hackers - Plastic", 215),
("MG Chemicals - Cardboard", 150),
("MG Chemicals - Plastic", 239),
("Mika3D - Plastic", 175),
("MonoPrice - Plastic", 221),
("Overture - Cardboard", 150),
("Overture - Plastic", 237),
("PolyMaker - Cardboard", 137),
("PolyMaker - Plastic", 220),
("PolyMaker 3kg - Cardboard", 418),
("PolyTerra PLA - Cardboard", 147),
("PrimaSelect - Plastic", 222),
("ProtoPasta - Cardboard", 80),
("Prusament - Plastic", 201),
("Prusament - Plastic w/ Cardboard Core", 196),
("Rosa3D - Plastic", 245),
("Sakata3D - Plastic", 205),
("Snapmaker - Cardboard", 148),
("Sovol - Cardboard", 145),
("Spectrum - Cardboard", 180),
("Spectrum - Plastic", 257),
("Sunlu - Plastic", 117),
("Sunlu - Plastic V2", 165),
("Sunlu - Plastic V3", 179),
("Sunlu 250g - Plastic", 55),
("UltiMaker - Plastic", 235),
("Voolt3D - Plastic", 190),
("Voxelab - Plastic", 171),
("Wanhao - Plastic", 267),
("Ziro - Plastic", 166),
("ZYLtech - Plastic", 179),
]
# (manufacturer, color_name, hex_color, material)
DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
# Bambu Lab PLA Basic (from official hex code PDF)
("Bambu Lab", "Jade White", "#FFFFFF", "PLA Basic"),
("Bambu Lab", "Black", "#000000", "PLA Basic"),
("Bambu Lab", "Silver", "#A6A9AA", "PLA Basic"),
("Bambu Lab", "Light Gray", "#D1D3D5", "PLA Basic"),
("Bambu Lab", "Gray", "#8E9089", "PLA Basic"),
("Bambu Lab", "Dark Gray", "#545454", "PLA Basic"),
("Bambu Lab", "Red", "#C12E1F", "PLA Basic"),
("Bambu Lab", "Maroon Red", "#9D2235", "PLA Basic"),
("Bambu Lab", "Magenta", "#EC008C", "PLA Basic"),
("Bambu Lab", "Hot Pink", "#F5547C", "PLA Basic"),
("Bambu Lab", "Pink", "#F55A74", "PLA Basic"),
("Bambu Lab", "Beige", "#F7E6DE", "PLA Basic"),
("Bambu Lab", "Yellow", "#F4EE2A", "PLA Basic"),
("Bambu Lab", "Sunflower Yellow", "#FEC600", "PLA Basic"),
("Bambu Lab", "Gold", "#E4BD68", "PLA Basic"),
("Bambu Lab", "Orange", "#FF6A13", "PLA Basic"),
("Bambu Lab", "Pumpkin Orange", "#FF9016", "PLA Basic"),
("Bambu Lab", "Bright Green", "#BECF00", "PLA Basic"),
("Bambu Lab", "Bambu Green", "#00AE42", "PLA Basic"),
("Bambu Lab", "Mistletoe Green", "#3F8E43", "PLA Basic"),
("Bambu Lab", "Turquoise", "#00B1B7", "PLA Basic"),
("Bambu Lab", "Cyan", "#0086D6", "PLA Basic"),
("Bambu Lab", "Blue", "#0A2989", "PLA Basic"),
("Bambu Lab", "Blue Grey", "#5B6579", "PLA Basic"),
("Bambu Lab", "Cobalt Blue", "#0056B8", "PLA Basic"),
("Bambu Lab", "Purple", "#5E43B7", "PLA Basic"),
("Bambu Lab", "Indigo Purple", "#482960", "PLA Basic"),
("Bambu Lab", "Brown", "#9D432C", "PLA Basic"),
("Bambu Lab", "Cocoa Brown", "#6F5034", "PLA Basic"),
("Bambu Lab", "Bronze", "#847D48", "PLA Basic"),
# Bambu Lab PLA Matte (from official hex code PDF)
("Bambu Lab", "Ivory White", "#FFFFFF", "PLA Matte"),
("Bambu Lab", "Bone White", "#CBC6B8", "PLA Matte"),
("Bambu Lab", "Desert Tan", "#E8DBB7", "PLA Matte"),
("Bambu Lab", "Latte Brown", "#D3B7A7", "PLA Matte"),
("Bambu Lab", "Caramel", "#AE835B", "PLA Matte"),
("Bambu Lab", "Terracotta", "#B15533", "PLA Matte"),
("Bambu Lab", "Dark Brown", "#7D6556", "PLA Matte"),
("Bambu Lab", "Dark Chocolate", "#4D3324", "PLA Matte"),
("Bambu Lab", "Lilac Purple", "#AE96D4", "PLA Matte"),
("Bambu Lab", "Sakura Pink", "#E8AFCF", "PLA Matte"),
("Bambu Lab", "Mandarin Orange", "#F99963", "PLA Matte"),
("Bambu Lab", "Lemon Yellow", "#F7D959", "PLA Matte"),
("Bambu Lab", "Plum", "#950051", "PLA Matte"),
("Bambu Lab", "Scarlet Red", "#DE4343", "PLA Matte"),
("Bambu Lab", "Dark Red", "#BB3D43", "PLA Matte"),
("Bambu Lab", "Dark Green", "#68724D", "PLA Matte"),
("Bambu Lab", "Grass Green", "#61C680", "PLA Matte"),
("Bambu Lab", "Apple Green", "#C2E189", "PLA Matte"),
("Bambu Lab", "Ice Blue", "#A3D8E1", "PLA Matte"),
("Bambu Lab", "Sky Blue", "#56B7E6", "PLA Matte"),
("Bambu Lab", "Marine Blue", "#0078BF", "PLA Matte"),
("Bambu Lab", "Dark Blue", "#042F56", "PLA Matte"),
("Bambu Lab", "Ash Gray", "#9B9EA0", "PLA Matte"),
("Bambu Lab", "Nardo Gray", "#757575", "PLA Matte"),
("Bambu Lab", "Charcoal", "#000000", "PLA Matte"),
# Bambu Lab PLA Silk+ (from store page)
("Bambu Lab", "Gold", "#F4A925", "PLA Silk"),
("Bambu Lab", "Silver", "#C8C8C8", "PLA Silk"),
("Bambu Lab", "Titan Gray", "#5F6367", "PLA Silk"),
("Bambu Lab", "Blue", "#008BDA", "PLA Silk"),
("Bambu Lab", "Purple", "#8671CB", "PLA Silk"),
("Bambu Lab", "Candy Red", "#D02727", "PLA Silk"),
("Bambu Lab", "Candy Green", "#018814", "PLA Silk"),
("Bambu Lab", "Rose Gold", "#BA9594", "PLA Silk"),
("Bambu Lab", "Baby Blue", "#A8C6EE", "PLA Silk"),
("Bambu Lab", "Pink", "#F7ADA6", "PLA Silk"),
("Bambu Lab", "Mint", "#96DCB9", "PLA Silk"),
("Bambu Lab", "Champagne", "#F3CFB2", "PLA Silk"),
("Bambu Lab", "White", "#FFFFFF", "PLA Silk"),
# Bambu Lab PLA Sparkle (from store page)
("Bambu Lab", "Classic Gold Sparkle", "#CEA629", "PLA Sparkle"),
("Bambu Lab", "Slate Gray Sparkle", "#8E9089", "PLA Sparkle"),
("Bambu Lab", "Crimson Red Sparkle", "#792B36", "PLA Sparkle"),
("Bambu Lab", "Royal Purple Sparkle", "#483D8B", "PLA Sparkle"),
("Bambu Lab", "Alpine Green Sparkle", "#3F5443", "PLA Sparkle"),
("Bambu Lab", "Onyx Black Sparkle", "#2D2B28", "PLA Sparkle"),
# Bambu Lab PLA Translucent (from official hex code PDF)
("Bambu Lab", "Teal", "#009FA1", "PLA Translucent"),
("Bambu Lab", "Light Jade", "#96D8AF", "PLA Translucent"),
("Bambu Lab", "Blue", "#0047BB", "PLA Translucent"),
("Bambu Lab", "Mellow Yellow", "#F5DBAB", "PLA Translucent"),
("Bambu Lab", "Purple", "#8344B0", "PLA Translucent"),
("Bambu Lab", "Cherry Pink", "#F5B6CD", "PLA Translucent"),
("Bambu Lab", "Orange", "#F74E02", "PLA Translucent"),
("Bambu Lab", "Ice Blue", "#B8CDE9", "PLA Translucent"),
("Bambu Lab", "Red", "#B50011", "PLA Translucent"),
("Bambu Lab", "Lavender", "#B8ACD6", "PLA Translucent"),
# Bambu Lab PLA Glow (from store page)
("Bambu Lab", "Glow Green", "#A1FFAC", "PLA Glow"),
("Bambu Lab", "Glow Yellow", "#F8FF80", "PLA Glow"),
("Bambu Lab", "Glow Pink", "#F17B8F", "PLA Glow"),
("Bambu Lab", "Glow Blue", "#7AC0E9", "PLA Glow"),
("Bambu Lab", "Glow Orange", "#FF9D5B", "PLA Glow"),
# Bambu Lab PLA Galaxy (from store page)
("Bambu Lab", "Brown", "#684A43", "PLA Galaxy"),
("Bambu Lab", "Green", "#3B665E", "PLA Galaxy"),
("Bambu Lab", "Nebulae", "#424379", "PLA Galaxy"),
("Bambu Lab", "Purple", "#594177", "PLA Galaxy"),
# Bambu Lab PLA Metal (from store page)
("Bambu Lab", "Iridium Gold Metallic", "#B39B84", "PLA Metal"),
("Bambu Lab", "Copper Brown Metallic", "#AA6443", "PLA Metal"),
("Bambu Lab", "Oxide Green Metallic", "#1D7C6A", "PLA Metal"),
("Bambu Lab", "Cobalt Blue Metallic", "#39699E", "PLA Metal"),
("Bambu Lab", "Iron Gray Metallic", "#43403D", "PLA Metal"),
# Bambu Lab PLA Marble (from store page)
("Bambu Lab", "White Marble", "#F7F3F0", "PLA Marble"),
("Bambu Lab", "Red Granite", "#AD4E38", "PLA Marble"),
# Bambu Lab PLA Wood (from store page)
("Bambu Lab", "Black Walnut", "#4F3F24", "PLA Wood"),
("Bambu Lab", "Rosewood", "#4C241C", "PLA Wood"),
("Bambu Lab", "Clay Brown", "#995F11", "PLA Wood"),
("Bambu Lab", "Classic Birch", "#918669", "PLA Wood"),
("Bambu Lab", "White Oak", "#D6CCA3", "PLA Wood"),
("Bambu Lab", "Ochre Yellow", "#C98935", "PLA Wood"),
# Bambu Lab PLA Tough+ (from official hex code PDF)
("Bambu Lab", "White", "#FFFFFF", "PLA Tough"),
("Bambu Lab", "Gray", "#AFB1AE", "PLA Tough"),
("Bambu Lab", "Black", "#000000", "PLA Tough"),
("Bambu Lab", "Silver", "#959698", "PLA Tough"),
("Bambu Lab", "Yellow", "#F4D53F", "PLA Tough"),
("Bambu Lab", "Cyan", "#009BD8", "PLA Tough"),
("Bambu Lab", "Orange", "#DC3A27", "PLA Tough"),
# Bambu Lab PLA-CF (from official hex code PDF)
("Bambu Lab", "Burgundy Red", "#951E23", "PLA-CF"),
("Bambu Lab", "Iris Purple", "#69398E", "PLA-CF"),
("Bambu Lab", "Matcha Green", "#5C9748", "PLA-CF"),
("Bambu Lab", "Jeans Blue", "#6E88BC", "PLA-CF"),
("Bambu Lab", "Royal Blue", "#2842AD", "PLA-CF"),
("Bambu Lab", "Lava Gray", "#4D5054", "PLA-CF"),
("Bambu Lab", "Black", "#000000", "PLA-CF"),
# Bambu Lab ABS (from official hex code PDF)
("Bambu Lab", "White", "#FFFFFF", "ABS"),
("Bambu Lab", "Desert Tan", "#E8DBB7", "ABS"),
("Bambu Lab", "Olive", "#789D4A", "ABS"),
("Bambu Lab", "Azure", "#489FDF", "ABS"),
("Bambu Lab", "Navy Blue", "#0C2340", "ABS"),
("Bambu Lab", "Blue", "#0A2CA5", "ABS"),
("Bambu Lab", "Tangerine Yellow", "#FFC72C", "ABS"),
("Bambu Lab", "Orange", "#FF6A13", "ABS"),
("Bambu Lab", "Red", "#D32941", "ABS"),
("Bambu Lab", "Purple", "#AF1685", "ABS"),
("Bambu Lab", "Silver", "#87909A", "ABS"),
("Bambu Lab", "Black", "#000000", "ABS"),
# Bambu Lab ASA (from store page)
("Bambu Lab", "White", "#FFFAF2", "ASA"),
("Bambu Lab", "Gray", "#8A949E", "ASA"),
("Bambu Lab", "Red", "#E02928", "ASA"),
("Bambu Lab", "Green", "#00A6A0", "ASA"),
("Bambu Lab", "Blue", "#2140B4", "ASA"),
("Bambu Lab", "Black", "#000000", "ASA"),
# Bambu Lab PETG HF (from store page)
("Bambu Lab", "Yellow", "#FFD00B", "PETG HF"),
("Bambu Lab", "Orange", "#F75403", "PETG HF"),
("Bambu Lab", "Green", "#00AE42", "PETG HF"),
("Bambu Lab", "Red", "#EB3A3A", "PETG HF"),
("Bambu Lab", "Blue", "#002E96", "PETG HF"),
("Bambu Lab", "Black", "#000000", "PETG HF"),
("Bambu Lab", "White", "#FFFFFF", "PETG HF"),
("Bambu Lab", "Cream", "#F9DFB9", "PETG HF"),
("Bambu Lab", "Lime Green", "#6EE53C", "PETG HF"),
("Bambu Lab", "Forest Green", "#39541A", "PETG HF"),
("Bambu Lab", "Lake Blue", "#1F79E5", "PETG HF"),
("Bambu Lab", "Peanut Brown", "#875718", "PETG HF"),
("Bambu Lab", "Gray", "#ADB1B2", "PETG HF"),
("Bambu Lab", "Dark Gray", "#515151", "PETG HF"),
# Bambu Lab PETG Translucent (from store page)
("Bambu Lab", "Translucent Gray", "#8E8E8E", "PETG Translucent"),
("Bambu Lab", "Translucent Light Blue", "#61B0FF", "PETG Translucent"),
("Bambu Lab", "Translucent Olive", "#748C45", "PETG Translucent"),
("Bambu Lab", "Translucent Brown", "#C9A381", "PETG Translucent"),
("Bambu Lab", "Translucent Teal", "#77EDD7", "PETG Translucent"),
("Bambu Lab", "Translucent Orange", "#FF911A", "PETG Translucent"),
("Bambu Lab", "Translucent Purple", "#D6ABFF", "PETG Translucent"),
("Bambu Lab", "Translucent Pink", "#F9C1BD", "PETG Translucent"),
# Bambu Lab PETG-CF (from official hex code PDF)
("Bambu Lab", "Brick Red", "#9F332A", "PETG-CF"),
("Bambu Lab", "Violet Purple", "#583061", "PETG-CF"),
("Bambu Lab", "Indigo Blue", "#324585", "PETG-CF"),
("Bambu Lab", "Malachite Green", "#16B08E", "PETG-CF"),
("Bambu Lab", "Black", "#000000", "PETG-CF"),
("Bambu Lab", "Titan Gray", "#565656", "PETG-CF"),
# Bambu Lab TPU 95A HF (from store page)
("Bambu Lab", "White", "#FFFFFF", "TPU 95A"),
("Bambu Lab", "Yellow", "#F3E600", "TPU 95A"),
("Bambu Lab", "Blue", "#0072CE", "TPU 95A"),
("Bambu Lab", "Red", "#C8102E", "TPU 95A"),
("Bambu Lab", "Gray", "#898D8D", "TPU 95A"),
("Bambu Lab", "Black", "#101820", "TPU 95A"),
# Bambu Lab TPU 90A (from official hex code PDF)
("Bambu Lab", "Black", "#000000", "TPU 90A"),
("Bambu Lab", "White", "#FFFFFF", "TPU 90A"),
("Bambu Lab", "Grape Jelly", "#D6ABFF", "TPU 90A"),
("Bambu Lab", "Crystal Blue", "#7EB4E1", "TPU 90A"),
("Bambu Lab", "Cocoa Brown", "#5C4738", "TPU 90A"),
# Bambu Lab PAHT-CF
("Bambu Lab", "Black", "#1A1A1A", "PAHT-CF"),
# Bambu Lab Support Materials
("Bambu Lab", "Natural", "#F5F5DC", "PLA Support"),
("Bambu Lab", "Natural", "#F5F5DC", "PVA Support"),
# Polymaker PolyTerra PLA
("Polymaker", "Cotton White", "#F5F5F5", "PolyTerra PLA"),
("Polymaker", "Charcoal Black", "#2B2B2B", "PolyTerra PLA"),
("Polymaker", "Marble White", "#E8E8E8", "PolyTerra PLA"),
("Polymaker", "Fossil Grey", "#6B6B6B", "PolyTerra PLA"),
("Polymaker", "Shadow Black", "#1A1A1A", "PolyTerra PLA"),
("Polymaker", "Army Red", "#8B0000", "PolyTerra PLA"),
("Polymaker", "Lava Red", "#CF1020", "PolyTerra PLA"),
("Polymaker", "Sakura Pink", "#FFB7C5", "PolyTerra PLA"),
("Polymaker", "Rose", "#FF007F", "PolyTerra PLA"),
("Polymaker", "Peach", "#FFCBA4", "PolyTerra PLA"),
("Polymaker", "Banana", "#FFE135", "PolyTerra PLA"),
("Polymaker", "Savannah Yellow", "#F4C430", "PolyTerra PLA"),
("Polymaker", "Sunrise Orange", "#FF6600", "PolyTerra PLA"),
("Polymaker", "Muted Green", "#4F7942", "PolyTerra PLA"),
("Polymaker", "Forest Green", "#228B22", "PolyTerra PLA"),
("Polymaker", "Mint", "#98FF98", "PolyTerra PLA"),
("Polymaker", "Lavender Purple", "#B57EDC", "PolyTerra PLA"),
("Polymaker", "Sapphire Blue", "#0F52BA", "PolyTerra PLA"),
("Polymaker", "Ice", "#D6ECEF", "PolyTerra PLA"),
# Prusament PLA
("Prusament", "Jet Black", "#1A1A1A", "PLA"),
("Prusament", "Galaxy Black", "#1F1F1F", "PLA"),
("Prusament", "Pristine White", "#FFFFFF", "PLA"),
("Prusament", "Gentleman's Grey", "#5A5A5A", "PLA"),
("Prusament", "Lipstick Red", "#C21E1E", "PLA"),
("Prusament", "Orange", "#FF6600", "PLA"),
("Prusament", "Pineapple Yellow", "#FFD700", "PLA"),
("Prusament", "Jungle Green", "#29AB87", "PLA"),
("Prusament", "Azure Blue", "#007FFF", "PLA"),
("Prusament", "Royal Blue", "#4169E1", "PLA"),
("Prusament", "Mystic Purple", "#7B68EE", "PLA"),
# eSUN PLA+
("eSUN", "White", "#FFFFFF", "PLA+"),
("eSUN", "Black", "#000000", "PLA+"),
("eSUN", "Grey", "#808080", "PLA+"),
("eSUN", "Red", "#FF0000", "PLA+"),
("eSUN", "Blue", "#0000FF", "PLA+"),
("eSUN", "Green", "#00FF00", "PLA+"),
("eSUN", "Yellow", "#FFFF00", "PLA+"),
("eSUN", "Orange", "#FFA500", "PLA+"),
("eSUN", "Purple", "#800080", "PLA+"),
("eSUN", "Pink", "#FFC0CB", "PLA+"),
# Hatchbox PLA
("Hatchbox", "White", "#FFFFFF", "PLA"),
("Hatchbox", "Black", "#000000", "PLA"),
("Hatchbox", "Gray", "#808080", "PLA"),
("Hatchbox", "Red", "#FF0000", "PLA"),
("Hatchbox", "Blue", "#0000FF", "PLA"),
("Hatchbox", "Green", "#00FF00", "PLA"),
("Hatchbox", "Yellow", "#FFFF00", "PLA"),
("Hatchbox", "Orange", "#FFA500", "PLA"),
("Hatchbox", "Purple", "#800080", "PLA"),
("Hatchbox", "Pink", "#FFC0CB", "PLA"),
("Hatchbox", "True Blue", "#0073CF", "PLA"),
("Hatchbox", "True Green", "#008000", "PLA"),
]
+1 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from pydantic_settings import BaseSettings
# Application version - single source of truth
APP_VERSION = "0.1.9"
APP_VERSION = "0.2.0b"
GITHUB_REPO = "maziggy/bambuddy"
# App directory - where the application is installed (for static files)
+121
View File
@@ -59,6 +59,7 @@ async def init_db():
ams_history,
api_key,
archive,
color_catalog,
external_link,
filament,
github_backup,
@@ -78,6 +79,11 @@ async def init_db():
settings,
slot_preset,
smart_plug,
spool,
spool_assignment,
spool_catalog,
spool_k_profile,
spool_usage_history,
user,
)
@@ -93,6 +99,10 @@ async def init_db():
# Seed default groups and migrate existing users
await seed_default_groups()
# Seed default catalog entries
await seed_spool_catalog()
await seed_color_catalog()
async def run_migrations(conn):
"""Add new columns to existing tables if they don't exist."""
@@ -1118,6 +1128,63 @@ async def run_migrations(conn):
except OperationalError:
pass # Already applied
# Migration: Add inventory spool tracking columns
try:
await conn.execute(text("ALTER TABLE spool ADD COLUMN added_full BOOLEAN"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE spool ADD COLUMN last_used DATETIME"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE spool ADD COLUMN encode_time DATETIME"))
except OperationalError:
pass # Already applied
# Migration: Add RFID tag matching columns to spool
try:
await conn.execute(text("ALTER TABLE spool ADD COLUMN tag_uid VARCHAR(16)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE spool ADD COLUMN tray_uuid VARCHAR(32)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE spool ADD COLUMN data_origin VARCHAR(20)"))
except OperationalError:
pass # Already applied
try:
await conn.execute(text("ALTER TABLE spool ADD COLUMN tag_type VARCHAR(20)"))
except OperationalError:
pass # Already applied
# Migration: Create spool_usage_history table for filament consumption tracking
try:
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS spool_usage_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
print_name VARCHAR(500),
weight_used REAL NOT NULL DEFAULT 0,
percent_used INTEGER NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'completed',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
)
except OperationalError:
pass # Already applied
# Migration: Add open_in_new_tab column to external_links
try:
await conn.execute(text("ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0"))
except OperationalError:
pass # Already applied
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
@@ -1286,3 +1353,57 @@ async def seed_default_groups():
logger.info("Migrated user '%s' to Operators group", user.username)
await session.commit()
async def seed_spool_catalog():
"""Seed the spool catalog with default entries if empty."""
import logging
from sqlalchemy import func, select
from backend.app.core.catalog_defaults import DEFAULT_SPOOL_CATALOG
from backend.app.models.spool_catalog import SpoolCatalogEntry
logger = logging.getLogger(__name__)
async with async_session() as session:
result = await session.execute(select(func.count()).select_from(SpoolCatalogEntry))
count = result.scalar() or 0
if count > 0:
return # Already seeded
for name, weight in DEFAULT_SPOOL_CATALOG:
session.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
await session.commit()
logger.info("Seeded %d default spool catalog entries", len(DEFAULT_SPOOL_CATALOG))
async def seed_color_catalog():
"""Seed the color catalog with default entries if empty."""
import logging
from sqlalchemy import func, select
from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
from backend.app.models.color_catalog import ColorCatalogEntry
logger = logging.getLogger(__name__)
async with async_session() as session:
result = await session.execute(select(func.count()).select_from(ColorCatalogEntry))
count = result.scalar() or 0
if count > 0:
return # Already seeded
for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
session.add(
ColorCatalogEntry(
manufacturer=manufacturer,
color_name=color_name,
hex_color=hex_color,
material=material,
is_default=True,
)
)
await session.commit()
logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
+18
View File
@@ -62,6 +62,12 @@ class Permission(StrEnum):
FILAMENTS_UPDATE = "filaments:update"
FILAMENTS_DELETE = "filaments:delete"
# Inventory (Spool Inventory, Spool Catalog, Color Catalog)
INVENTORY_READ = "inventory:read"
INVENTORY_CREATE = "inventory:create"
INVENTORY_UPDATE = "inventory:update"
INVENTORY_DELETE = "inventory:delete"
# Smart Plugs
SMART_PLUGS_READ = "smart_plugs:read"
SMART_PLUGS_CREATE = "smart_plugs:create"
@@ -201,6 +207,12 @@ PERMISSION_CATEGORIES = {
Permission.FILAMENTS_UPDATE,
Permission.FILAMENTS_DELETE,
],
"Inventory": [
Permission.INVENTORY_READ,
Permission.INVENTORY_CREATE,
Permission.INVENTORY_UPDATE,
Permission.INVENTORY_DELETE,
],
"Smart Plugs": [
Permission.SMART_PLUGS_READ,
Permission.SMART_PLUGS_CREATE,
@@ -335,6 +347,11 @@ DEFAULT_GROUPS = {
Permission.FILAMENTS_CREATE.value,
Permission.FILAMENTS_UPDATE.value,
Permission.FILAMENTS_DELETE.value,
# Inventory - full access
Permission.INVENTORY_READ.value,
Permission.INVENTORY_CREATE.value,
Permission.INVENTORY_UPDATE.value,
Permission.INVENTORY_DELETE.value,
# Smart Plugs - full access
Permission.SMART_PLUGS_READ.value,
Permission.SMART_PLUGS_CREATE.value,
@@ -390,6 +407,7 @@ DEFAULT_GROUPS = {
Permission.LIBRARY_READ.value,
Permission.PROJECTS_READ.value,
Permission.FILAMENTS_READ.value,
Permission.INVENTORY_READ.value,
Permission.SMART_PLUGS_READ.value,
Permission.CAMERA_VIEW.value,
Permission.MAINTENANCE_READ.value,
+309 -15
View File
@@ -184,6 +184,7 @@ from backend.app.api.routes import (
firmware,
github_backup,
groups,
inventory,
kprofiles,
library,
local_presets,
@@ -216,6 +217,7 @@ from backend.app.services.bambu_mqtt import PrinterState
from backend.app.services.github_backup import github_backup_service
from backend.app.services.homeassistant import homeassistant_service
from backend.app.services.mqtt_relay import mqtt_relay
from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
from backend.app.services.notification_service import notification_service
from backend.app.services.print_scheduler import scheduler as print_scheduler
from backend.app.services.printer_manager import (
@@ -253,6 +255,10 @@ _last_progress_milestone: dict[int, int] = {}
# This prevents sending duplicate notifications for the same error
_notified_hms_errors: dict[int, set[str]] = {}
# Track timelapse file baselines at print start: {printer_id: set of MP4 filenames}
# Used for snapshot-diff detection at print completion
_timelapse_baselines: dict[int, set[str]] = {}
async def _get_plug_energy(plug, db) -> dict | None:
"""Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
@@ -410,6 +416,9 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
# Find new errors that haven't been notified yet
new_error_codes = current_error_codes - previously_notified
# Update tracking immediately to prevent duplicate notifications from concurrent callbacks
_notified_hms_errors[printer_id] = current_error_codes
if new_error_codes:
# Get the actual new errors for the notification
# Filter to severity >= 2 (skip informational/status messages like H2D sends)
@@ -480,8 +489,6 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
except Exception as e:
logging.getLogger(__name__).warning(f"HMS error notification failed: {e}")
# Update tracking with all current errors
_notified_hms_errors[printer_id] = current_error_codes
else:
# No HMS errors - clear tracking so future errors get notified
if printer_id in _notified_hms_errors:
@@ -493,6 +500,11 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
)
def _is_bambu_uuid(tray_uuid: str) -> bool:
"""Check if a tray UUID looks like a valid Bambu Lab RFID UUID (non-empty, non-zero)."""
return bool(tray_uuid) and tray_uuid not in ("", "0" * len(tray_uuid))
async def on_ams_change(printer_id: int, ams_data: list):
"""Handle AMS data changes - sync to Spoolman if enabled and auto mode."""
logger = logging.getLogger(__name__)
@@ -518,6 +530,196 @@ async def on_ams_change(printer_id: int, ams_data: list):
except Exception as e:
logger.warning("Failed to broadcast AMS change for printer %s: %s", printer_id, e)
# Auto-unlink spool assignments with stale fingerprints
try:
async with async_session() as db:
from sqlalchemy.orm import selectinload
from backend.app.api.routes.inventory import _find_tray_in_ams_data
from backend.app.models.spool_assignment import SpoolAssignment as SA
result = await db.execute(select(SA).where(SA.printer_id == printer_id).options(selectinload(SA.spool)))
stale = []
for assignment in result.scalars().all():
current_tray = _find_tray_in_ams_data(ams_data, assignment.ams_id, assignment.tray_id)
if not current_tray:
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — tray not found in AMS data (slot empty?)",
assignment.spool_id,
assignment.ams_id,
assignment.tray_id,
)
stale.append(assignment) # Slot empty
elif _is_bambu_uuid(current_tray.get("tray_uuid", "")):
# A Bambu Lab spool was inserted — always unlink manual assignments
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — Bambu Lab spool detected (uuid=%s)",
assignment.spool_id,
assignment.ams_id,
assignment.tray_id,
current_tray.get("tray_uuid", ""),
)
stale.append(assignment)
else:
cur_color = current_tray.get("tray_color", "")
cur_type = current_tray.get("tray_type", "")
fp_color = assignment.fingerprint_color or ""
fp_type = assignment.fingerprint_type or ""
if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper():
# Fingerprint mismatch — but check if tray now matches the
# assigned spool (e.g. auto-configure changed the tray).
spool = assignment.spool
if spool:
spool_color = (spool.rgba or "FFFFFFFF").upper()
spool_type = (spool.material or "").upper()
if cur_color.upper() == spool_color and cur_type.upper() == spool_type:
# Tray was reconfigured to match the spool — update fingerprint
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
assignment.spool_id,
assignment.ams_id,
assignment.tray_id,
)
assignment.fingerprint_color = cur_color
assignment.fingerprint_type = cur_type
continue
logger.info(
"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch (cur=%s/%s fp=%s/%s spool=%s/%s)",
assignment.spool_id,
assignment.ams_id,
assignment.tray_id,
cur_color,
cur_type,
fp_color,
fp_type,
spool.rgba if spool else "?",
spool.material if spool else "?",
)
stale.append(assignment) # Spool changed
for a in stale:
await db.delete(a)
if stale:
logger.info("Auto-unlinked %d stale spool assignments for printer %d", len(stale), printer_id)
# Commit any changes (stale deletions and/or fingerprint updates)
await db.commit()
except Exception as e:
logger.warning("Spool assignment cleanup failed: %s", e)
# Auto-manage inventory spools from AMS tray data (skip if Spoolman manages AMS)
try:
async with async_session() as db:
from backend.app.api.routes.settings import get_setting
from backend.app.models.spool_assignment import SpoolAssignment as SA
from backend.app.services.spool_tag_matcher import (
auto_assign_spool,
create_spool_from_tray,
get_spool_by_tag,
is_bambu_tag,
is_valid_tag,
)
_spoolman_on = await get_setting(db, "spoolman_enabled")
if not _spoolman_on or _spoolman_on.lower() != "true":
for ams_unit in ams_data:
if not isinstance(ams_unit, dict):
continue
ams_id = int(ams_unit.get("id", 0))
for tray in ams_unit.get("tray", []):
if not isinstance(tray, dict):
continue
tray_id = int(tray.get("id", 0))
tag_uid = tray.get("tag_uid", "")
tray_uuid = tray.get("tray_uuid", "")
tray_info_idx = tray.get("tray_info_idx", "")
if not tray.get("tray_type"):
continue # Empty slot
# Check if assignment already exists for this slot
existing = await db.execute(
select(SA)
.options(selectinload(SA.spool))
.where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id)
)
existing_assignment = existing.scalar_one_or_none()
if existing_assignment:
# Sync spool weight_used from AMS remain if valid
remain_raw = tray.get("remain")
if remain_raw is not None and existing_assignment.spool:
try:
remain_val = int(remain_raw)
except (TypeError, ValueError):
remain_val = -1
if 0 <= remain_val <= 100:
lw = existing_assignment.spool.label_weight or 1000
new_used = round(lw * (100 - remain_val) / 100.0, 1)
if abs((existing_assignment.spool.weight_used or 0) - new_used) > 1:
logger.info(
"Weight sync: spool %d weight_used %s -> %s (remain=%d)",
existing_assignment.spool_id,
existing_assignment.spool.weight_used,
new_used,
remain_val,
)
existing_assignment.spool.weight_used = new_used
await db.commit()
continue
if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
# BL spool with RFID tag: auto-match or auto-create
spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
if not spool:
spool = await create_spool_from_tray(db, tray)
await auto_assign_spool(
printer_id,
ams_id,
tray_id,
spool,
printer_manager,
db,
)
await db.commit()
await ws_manager.broadcast(
{
"type": "spool_auto_assigned",
"printer_id": printer_id,
"ams_id": ams_id,
"tray_id": tray_id,
"spool_id": spool.id,
}
)
logger.info(
"RFID auto-assigned spool %d to printer %d AMS%d-T%d",
spool.id,
printer_id,
ams_id,
tray_id,
)
elif is_valid_tag(tag_uid, tray_uuid):
# Non-BL spool with some tag — let user choose
await ws_manager.broadcast(
{
"type": "unknown_tag",
"printer_id": printer_id,
"ams_id": ams_id,
"tray_id": tray_id,
"tag_uid": tag_uid,
"tray_uuid": tray_uuid,
}
)
else:
# No tag at all — let user choose from inventory
await ws_manager.broadcast(
{
"type": "unknown_tag",
"printer_id": printer_id,
"ams_id": ams_id,
"tray_id": tray_id,
"tag_uid": "",
"tray_uuid": "",
}
)
except Exception as e:
logger.warning("RFID spool auto-assign failed: %s", e)
try:
async with async_session() as db:
from backend.app.api.routes.settings import get_setting
@@ -571,6 +773,26 @@ async def on_ams_change(printer_id: int, ams_data: list):
)
return
# Load inventory weights as fallback (when AMS MQTT data lacks remain values)
from sqlalchemy.orm import selectinload
from backend.app.models.spool_assignment import SpoolAssignment
inventory_weights: dict[tuple[int, int], float] = {}
try:
assign_result = await db.execute(
select(SpoolAssignment)
.options(selectinload(SpoolAssignment.spool))
.where(SpoolAssignment.printer_id == printer_id)
)
for assignment in assign_result.scalars().all():
spool = assignment.spool
if spool and spool.label_weight > 0:
remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
inventory_weights[(assignment.ams_id, assignment.tray_id)] = remaining
except Exception as e:
logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
# Sync each AMS tray
synced = 0
for ams_unit in ams_data:
@@ -583,11 +805,13 @@ async def on_ams_change(printer_id: int, ams_data: list):
continue # Empty tray
try:
inv_remaining = inventory_weights.get((ams_id, tray.tray_id))
result = await client.sync_ams_tray(
tray,
printer_name,
disable_weight_sync=disable_weight_sync,
cached_spools=cached_spools,
inventory_remaining=inv_remaining,
)
if result:
synced += 1
@@ -749,6 +973,19 @@ async def on_print_start(printer_id: int, data: dict):
except Exception:
pass # Don't fail print start callback if MQTT fails
# Capture AMS tray remain% for filament consumption tracking (skip if Spoolman handles usage)
try:
async with async_session() as db:
from backend.app.api.routes.settings import get_setting
_spoolman_on = await get_setting(db, "spoolman_enabled")
if not _spoolman_on or _spoolman_on.lower() != "true":
from backend.app.services.usage_tracker import on_print_start as usage_on_print_start
await usage_on_print_start(printer_id, data, printer_manager)
except Exception as e:
logger.warning("Usage tracker on_print_start failed: %s", e)
# Track if notification was sent (to avoid sending twice)
notification_sent = False
@@ -1404,6 +1641,18 @@ async def on_print_start(printer_id: int, data: dict):
await _store_spoolman_print_data(printer_id, archive.id, archive.file_path, db, printer_manager)
except Exception as e:
logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
# Capture timelapse file baseline for snapshot-diff on completion
try:
baseline_files, _ = await _list_timelapse_mp4s(printer)
_timelapse_baselines[printer_id] = {f.get("name", "") for f in baseline_files}
logger.info(
"[TIMELAPSE] Baseline at print start: %s MP4 files for printer %s",
len(_timelapse_baselines[printer_id]),
printer_id,
)
except Exception as e:
logger.warning("[TIMELAPSE] Failed to capture baseline at print start: %s", e)
finally:
if temp_path and temp_path.exists():
temp_path.unlink()
@@ -1435,7 +1684,7 @@ async def _list_timelapse_mp4s(printer) -> tuple[list[dict], str | None]:
return [], None
async def _scan_for_timelapse_with_retries(archive_id: int):
async def _scan_for_timelapse_with_retries(archive_id: int, baseline_names: set[str] | None = None):
"""
Scan for timelapse with retries using a snapshot-diff approach.
@@ -1443,6 +1692,10 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
clock is wrong in LAN-only mode), we snapshot existing MP4 filenames BEFORE
waiting, then look for any NEW filename that appears after each delay.
If baseline_names is provided (captured at print start), it is used directly.
Otherwise falls back to taking a baseline at completion time (best-effort
for prints started before app restart).
Falls back to name-matching (print name contained in MP4 filename) if no
new file appears after all retries.
"""
@@ -1468,18 +1721,28 @@ async def _scan_for_timelapse_with_retries(archive_id: int):
logger.warning("[TIMELAPSE] Archive %s has no printer, aborting", archive_id)
return
result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
printer = result.scalar_one_or_none()
if not printer:
logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
return
if baseline_names is not None:
# Use pre-captured baseline from print start (no race condition)
logger.info(
"[TIMELAPSE] Using print-start baseline: %s existing MP4 files for archive %s",
len(baseline_names),
archive_id,
)
else:
# Fallback: take baseline now (e.g. app restarted mid-print)
result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
printer = result.scalar_one_or_none()
if not printer:
logger.warning("[TIMELAPSE] Printer not found for archive %s, aborting", archive_id)
return
# Snapshot current MP4 filenames as baseline
baseline_files, _ = await _list_timelapse_mp4s(printer)
baseline_names: set[str] = {f.get("name", "") for f in baseline_files}
logger.info(
"[TIMELAPSE] Baseline snapshot: %s existing MP4 files for archive %s", len(baseline_names), archive_id
)
baseline_files, _ = await _list_timelapse_mp4s(printer)
baseline_names = {f.get("name", "") for f in baseline_files}
logger.info(
"[TIMELAPSE] Baseline snapshot (fallback): %s existing MP4 files for archive %s",
len(baseline_names),
archive_id,
)
# Derive base_name for name-matching fallback
base_name = Path(archive.filename).stem if archive.filename else ""
@@ -1837,6 +2100,31 @@ async def on_print_complete(printer_id: int, data: dict):
log_timing("Archive status update")
# Track filament consumption from AMS remain% deltas (skip if Spoolman handles usage)
try:
async with async_session() as db:
from backend.app.api.routes.settings import get_setting
_spoolman_on = await get_setting(db, "spoolman_enabled")
if not _spoolman_on or _spoolman_on.lower() != "true":
from backend.app.services.usage_tracker import on_print_complete as usage_on_print_complete
async with async_session() as db:
usage_results = await usage_on_print_complete(
printer_id, data, printer_manager, db, archive_id=archive_id
)
if usage_results:
await ws_manager.broadcast(
{
"type": "spool_usage_logged",
"printer_id": printer_id,
"usage": usage_results,
}
)
log_timing("Usage tracker")
except Exception as e:
logger.warning("Usage tracker on_print_complete failed: %s", e)
# Report filament usage to Spoolman if print completed successfully
if data.get("status") == "completed":
try:
@@ -2179,7 +2467,8 @@ async def on_print_complete(printer_id: int, data: dict):
logger.info("[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive %s", archive_id)
# Schedule timelapse scan as background task with retries
# The printer needs time to encode the video after print completion
asyncio.create_task(_scan_for_timelapse_with_retries(archive_id))
baseline = _timelapse_baselines.pop(printer_id, None)
asyncio.create_task(_scan_for_timelapse_with_retries(archive_id, baseline))
log_timing("Timelapse scan scheduled")
# Update queue item if this was a scheduled print
@@ -2746,6 +3035,10 @@ async def lifespan(app: FastAPI):
if virtual_printer_manager.is_enabled:
await virtual_printer_manager.configure(enabled=False)
await mqtt_smart_plug_service.disconnect(timeout=2)
await mqtt_relay.disconnect(timeout=2)
app = FastAPI(
title=app_settings.app_name,
@@ -2905,6 +3198,7 @@ app.include_router(groups.router, prefix=app_settings.api_prefix)
app.include_router(printers.router, prefix=app_settings.api_prefix)
app.include_router(archives.router, prefix=app_settings.api_prefix)
app.include_router(filaments.router, prefix=app_settings.api_prefix)
app.include_router(inventory.router, prefix=app_settings.api_prefix)
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
app.include_router(cloud.router, prefix=app_settings.api_prefix)
app.include_router(local_presets.router, prefix=app_settings.api_prefix)
+12
View File
@@ -1,6 +1,7 @@
from backend.app.models.ams_history import AMSSensorHistory
from backend.app.models.api_key import APIKey
from backend.app.models.archive import PrintArchive
from backend.app.models.color_catalog import ColorCatalogEntry
from backend.app.models.filament import Filament
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.models.group import Group, user_groups
@@ -16,6 +17,11 @@ from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spool_catalog import SpoolCatalogEntry
from backend.app.models.spool_k_profile import SpoolKProfile
from backend.app.models.spool_usage_history import SpoolUsageHistory
from backend.app.models.user import User
__all__ = [
@@ -43,4 +49,10 @@ __all__ = [
"GitHubBackupLog",
"LocalPreset",
"OrcaBaseProfile",
"Spool",
"SpoolKProfile",
"SpoolAssignment",
"SpoolCatalogEntry",
"SpoolUsageHistory",
"ColorCatalogEntry",
]
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class ColorCatalogEntry(Base):
"""Color catalog entry for automatic color lookup when adding spools."""
__tablename__ = "color_catalog"
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer: Mapped[str] = mapped_column(String(200))
color_name: Mapped[str] = mapped_column(String(200))
hex_color: Mapped[str] = mapped_column(String(7)) # #RRGGBB
material: Mapped[str | None] = mapped_column(String(100))
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+2 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
@@ -16,6 +16,7 @@ class ExternalLink(Base):
url: Mapped[str] = mapped_column(String(500))
icon: Mapped[str] = mapped_column(String(50), default="link")
custom_icon: Mapped[str | None] = mapped_column(String(255), nullable=True) # Filename of uploaded icon
open_in_new_tab: Mapped[bool] = mapped_column(Boolean, default=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class Spool(Base):
"""Spool inventory item for tracking filament spools and their properties."""
__tablename__ = "spool"
id: Mapped[int] = mapped_column(primary_key=True)
material: Mapped[str] = mapped_column(String(50)) # PLA, PETG, ABS, etc.
subtype: Mapped[str | None] = mapped_column(String(50)) # Basic, Matte, Silk, etc.
color_name: Mapped[str | None] = mapped_column(String(100)) # "Jade White"
rgba: Mapped[str | None] = mapped_column(String(8)) # RRGGBBAA hex
brand: Mapped[str | None] = mapped_column(String(100)) # "Polymaker"
label_weight: Mapped[int] = mapped_column(Integer, default=1000) # Advertised net weight (g)
core_weight: Mapped[int] = mapped_column(Integer, default=250) # Empty spool weight (g)
weight_used: Mapped[float] = mapped_column(Float, default=0) # Consumed grams
slicer_filament: Mapped[str | None] = mapped_column(String(50)) # Preset ID (e.g. "GFL99")
slicer_filament_name: Mapped[str | None] = mapped_column(String(100)) # Preset name for slicer
nozzle_temp_min: Mapped[int | None] = mapped_column() # Override min temp
nozzle_temp_max: Mapped[int | None] = mapped_column() # Override max temp
note: Mapped[str | None] = mapped_column(String(500))
added_full: Mapped[bool | None] = mapped_column() # Whether spool was added as full (unused)
last_used: Mapped[datetime | None] = mapped_column(DateTime) # Last time this spool was used in a print
encode_time: Mapped[datetime | None] = mapped_column(DateTime) # When spool was encoded/written to tag
tag_uid: Mapped[str | None] = mapped_column(String(16)) # RFID tag UID (16 hex chars)
tray_uuid: Mapped[str | None] = mapped_column(String(32)) # Bambu Lab spool UUID (32 hex chars)
data_origin: Mapped[str | None] = mapped_column(String(20)) # How data was populated: manual, rfid_auto, nfc_link
tag_type: Mapped[str | None] = mapped_column(String(20)) # Tag vendor: bambulab, generic, etc.
archived_at: Mapped[datetime | None] = mapped_column(DateTime) # NULL = active
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
k_profiles: Mapped[list["SpoolKProfile"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
from backend.app.models.spool_assignment import SpoolAssignment # noqa: E402
from backend.app.models.spool_k_profile import SpoolKProfile # noqa: E402
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class SpoolAssignment(Base):
"""Assignment of a spool to a specific AMS slot on a printer."""
__tablename__ = "spool_assignment"
id: Mapped[int] = mapped_column(primary_key=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"))
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
ams_id: Mapped[int] = mapped_column(Integer) # 0-3, 128+ (HT), 254/255 (ext)
tray_id: Mapped[int] = mapped_column(Integer) # 0-3
fingerprint_color: Mapped[str | None] = mapped_column(String(8)) # tray_color snapshot
fingerprint_type: Mapped[str | None] = mapped_column(String(50)) # tray_type snapshot
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
spool: Mapped["Spool"] = relationship(back_populates="assignments")
printer: Mapped["Printer"] = relationship()
__table_args__ = (UniqueConstraint("printer_id", "ams_id", "tray_id"),)
@property
def printer_name(self) -> str | None:
"""Get printer name from loaded relationship."""
return self.printer.name if self.printer else None
from backend.app.models.printer import Printer # noqa: E402, F401
from backend.app.models.spool import Spool # noqa: E402, F401
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class SpoolCatalogEntry(Base):
"""Spool weight catalog entry for weight lookup when adding spools."""
__tablename__ = "spool_catalog"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
weight: Mapped[int] = mapped_column(Integer)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class SpoolKProfile(Base):
"""K-value calibration profile for a spool on a specific printer/nozzle combo."""
__tablename__ = "spool_k_profile"
id: Mapped[int] = mapped_column(primary_key=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"))
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
extruder: Mapped[int] = mapped_column(Integer, default=0) # 0 or 1 (H2D)
nozzle_diameter: Mapped[str] = mapped_column(String(10), default="0.4") # "0.4", "0.6"
nozzle_type: Mapped[str | None] = mapped_column(String(50))
k_value: Mapped[float] = mapped_column(Float) # e.g. 0.020
name: Mapped[str | None] = mapped_column(String(100)) # Profile display name
cali_idx: Mapped[int | None] = mapped_column(Integer) # Calibration index on printer
setting_id: Mapped[str | None] = mapped_column(String(50)) # Full setting ID
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
spool: Mapped["Spool"] = relationship(back_populates="k_profiles")
printer: Mapped["Printer"] = relationship()
from backend.app.models.printer import Printer # noqa: E402, F401
from backend.app.models.spool import Spool # noqa: E402, F401
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class SpoolUsageHistory(Base):
"""Record of filament consumption for a spool during a print."""
__tablename__ = "spool_usage_history"
id: Mapped[int] = mapped_column(primary_key=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"))
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"))
print_name: Mapped[str | None] = mapped_column(String(500))
weight_used: Mapped[float] = mapped_column(Float, default=0)
percent_used: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(20), default="completed") # completed/failed/aborted
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+1
View File
@@ -49,6 +49,7 @@ class SlicerSetting(BaseModel):
version: str | None = None
user_id: str | None = None
updated_time: str | None = None
is_custom: bool = False
class SlicerSettingsResponse(BaseModel):
+3
View File
@@ -9,6 +9,7 @@ class ExternalLinkBase(BaseModel):
name: str = Field(..., min_length=1, max_length=50, description="Display name for the link")
url: str = Field(..., min_length=1, max_length=500, description="External URL")
icon: str = Field(default="link", max_length=50, description="Lucide icon name")
open_in_new_tab: bool = False
@field_validator("url")
@classmethod
@@ -31,6 +32,7 @@ class ExternalLinkUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=50)
url: str | None = Field(default=None, min_length=1, max_length=500)
icon: str | None = Field(default=None, max_length=50)
open_in_new_tab: bool | None = None
@field_validator("url")
@classmethod
@@ -45,6 +47,7 @@ class ExternalLinkResponse(ExternalLinkBase):
"""Response schema for external links."""
id: int
open_in_new_tab: bool
custom_icon: str | None = None
sort_order: int
created_at: datetime
+109
View File
@@ -0,0 +1,109 @@
from datetime import datetime
from pydantic import BaseModel, Field
class SpoolBase(BaseModel):
material: str = Field(..., min_length=1, max_length=50)
subtype: str | None = None
color_name: str | None = None
rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
brand: str | None = None
label_weight: int = 1000
core_weight: int = 250
weight_used: float = 0
slicer_filament: str | None = None
slicer_filament_name: str | None = None
nozzle_temp_min: int | None = None
nozzle_temp_max: int | None = None
note: str | None = None
tag_uid: str | None = None
tray_uuid: str | None = None
data_origin: str | None = None
tag_type: str | None = None
class SpoolCreate(SpoolBase):
pass
class SpoolUpdate(BaseModel):
material: str | None = None
subtype: str | None = None
color_name: str | None = None
rgba: str | None = None
brand: str | None = None
label_weight: int | None = None
core_weight: int | None = None
weight_used: float | None = None
slicer_filament: str | None = None
slicer_filament_name: str | None = None
nozzle_temp_min: int | None = None
nozzle_temp_max: int | None = None
note: str | None = None
tag_uid: str | None = None
tray_uuid: str | None = None
data_origin: str | None = None
tag_type: str | None = None
class SpoolKProfileBase(BaseModel):
printer_id: int
extruder: int = 0
nozzle_diameter: str = "0.4"
nozzle_type: str | None = None
k_value: float
name: str | None = None
cali_idx: int | None = None
setting_id: str | None = None
class SpoolKProfileResponse(SpoolKProfileBase):
id: int
spool_id: int
created_at: datetime
class Config:
from_attributes = True
class SpoolResponse(SpoolBase):
id: int
added_full: bool | None = None
last_used: datetime | None = None
encode_time: datetime | None = None
tag_uid: str | None = None
tray_uuid: str | None = None
data_origin: str | None = None
tag_type: str | None = None
archived_at: datetime | None = None
created_at: datetime
updated_at: datetime
k_profiles: list[SpoolKProfileResponse] = []
class Config:
from_attributes = True
class SpoolAssignmentCreate(BaseModel):
spool_id: int
printer_id: int
ams_id: int
tray_id: int
class SpoolAssignmentResponse(BaseModel):
id: int
spool_id: int
printer_id: int
printer_name: str | None = None
ams_id: int
tray_id: int
fingerprint_color: str | None = None
fingerprint_type: str | None = None
created_at: datetime
spool: SpoolResponse | None = None
configured: bool = False
class Config:
from_attributes = True
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime
from pydantic import BaseModel
class SpoolUsageHistoryResponse(BaseModel):
id: int
spool_id: int
printer_id: int | None = None
print_name: str | None = None
weight_used: float
percent_used: int
status: str
created_at: datetime
class Config:
from_attributes = True
+14 -5
View File
@@ -11,6 +11,7 @@ import asyncio
import json
import logging
import ssl
import threading
import time
from collections import deque
from collections.abc import Callable
@@ -279,6 +280,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._disconnection_event: threading.Event | None = None
self._previous_ams_hash: str | None = None # Track AMS changes
# K-profile command tracking
@@ -357,6 +359,8 @@ class BambuMQTTClient:
self.state.connected = False
if self.on_state_change:
self.on_state_change(self.state)
if self._disconnection_event:
self._disconnection_event.set()
def _on_message(self, client, userdata, msg):
try:
@@ -941,8 +945,9 @@ class BambuMQTTClient:
# Fields that should always be updated (even with empty/zero values):
# - remain, k, id, cali_idx: status indicators where 0 is valid
# - tray_type, tray_sub_brands, tag_uid, tray_uuid, tray_info_idx,
# tray_color, tray_id_name: slot content indicators that must be
# cleared when a spool is removed (fixes #147 - old AMS empty slot)
# tray_color, tray_id_name: slot content indicators that must
# be cleared when a spool is removed (fixes #147 - old AMS
# empty slot)
always_update_fields = (
"remain",
"k",
@@ -1062,7 +1067,9 @@ class BambuMQTTClient:
self._previous_ams_hash = ams_hash
if self.on_ams_change:
logger.info("[%s] AMS data changed, triggering sync callback", self.serial_number)
self.on_ams_change(ams_list)
# Pass merged AMS data (not raw ams_list) — partial MQTT updates
# may lack fields like 'remain' that the merged state preserves
self.on_ams_change(merged_ams)
def _update_state(self, data: dict):
"""Update printer state from message data."""
@@ -2371,11 +2378,13 @@ class BambuMQTTClient:
return True
def disconnect(self):
def disconnect(self, timeout: float = 0):
"""Disconnect from the printer."""
if self._client:
self._client.loop_stop()
self._disconnection_event = threading.Event()
self._client.disconnect()
self._disconnection_event.wait(timeout=timeout)
self._client.loop_stop()
self._client = None
self.state.connected = False
+36 -15
View File
@@ -362,7 +362,7 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
]
try:
print(f"[EXT-CAM] Running ffmpeg command: {' '.join(cmd[:6])}...")
logger.debug(f"Running ffmpeg command: {' '.join(cmd[:6])}...")
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
@@ -370,13 +370,12 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
print(
f"[EXT-CAM] ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
logger.debug(
f"ffmpeg returned: code={process.returncode}, stdout={len(stdout)} bytes, stderr={len(stderr)} bytes"
)
if process.returncode != 0:
logger.error("ffmpeg RTSP capture failed: %s", stderr.decode()[:200])
print(f"[EXT-CAM] ffmpeg error: {stderr.decode()[:300]}")
return None
if not stdout or len(stdout) < 100:
@@ -440,11 +439,9 @@ async def test_connection(url: str, camera_type: str) -> dict:
Returns:
Dict with {success: bool, error?: str, resolution?: str}
"""
print(f"[EXT-CAM] Testing camera connection: type={camera_type}, url={url[:50]}...")
logger.info("Testing camera connection: type=%s, url=%s...", camera_type, url[:50])
try:
frame = await capture_frame(url, camera_type, timeout=10)
print(f"[EXT-CAM] Capture result: {len(frame) if frame else 0} bytes")
logger.info("Capture result: %s bytes", len(frame) if frame else 0)
if frame:
@@ -490,17 +487,41 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
last_frame_time = 0.0
if camera_type == "mjpeg":
# Proxy MJPEG stream directly
async for frame in _stream_mjpeg(url):
current_time = asyncio.get_event_loop().time()
if current_time - last_frame_time >= frame_interval:
last_frame_time = current_time
yield _format_mjpeg_frame(frame)
# Proxy MJPEG stream directly, with reconnect on timeout
max_retries = 3
for attempt in range(max_retries + 1):
frame_yielded = False
async for frame in _stream_mjpeg(url):
frame_yielded = True
current_time = asyncio.get_event_loop().time()
if current_time - last_frame_time >= frame_interval:
last_frame_time = current_time
yield _format_mjpeg_frame(frame)
if not frame_yielded or attempt == max_retries:
break
logger.warning(
"External MJPEG stream ended, reconnecting (attempt %d/%d)...",
attempt + 1,
max_retries,
)
await asyncio.sleep(2)
elif camera_type == "rtsp":
# Use ffmpeg to convert RTSP to MJPEG
async for frame in _stream_rtsp(url, fps):
yield _format_mjpeg_frame(frame)
# Use ffmpeg to convert RTSP to MJPEG, with reconnect on timeout
max_retries = 3
for attempt in range(max_retries + 1):
frame_yielded = False
async for frame in _stream_rtsp(url, fps):
frame_yielded = True
yield _format_mjpeg_frame(frame)
if not frame_yielded or attempt == max_retries:
break
logger.warning(
"External RTSP stream ended, reconnecting (attempt %d/%d)...",
attempt + 1,
max_retries,
)
await asyncio.sleep(2)
elif camera_type == "usb":
# Use ffmpeg to stream from USB camera
+75 -11
View File
@@ -1,8 +1,9 @@
"""
Firmware Check Service
Checks for firmware updates by fetching from Bambu Lab's official firmware download page.
Also provides firmware download functionality for offline updates.
Checks for firmware updates by fetching from Bambu Lab's official wiki and firmware
download page. The wiki is used as the primary version source (always up-to-date),
while the download page provides firmware file URLs for offline updates.
"""
import logging
@@ -18,10 +19,13 @@ from backend.app.core.config import _data_dir
logger = logging.getLogger(__name__)
# Bambu Lab firmware download page
# Bambu Lab firmware download page (for download URLs)
BAMBU_FIRMWARE_BASE = "https://bambulab.com"
FIRMWARE_PAGE = "/en/support/firmware-download/all"
# Bambu Lab wiki (primary source for latest version detection)
BAMBU_WIKI_BASE = "https://wiki.bambulab.com"
# Cache TTL in seconds (1 hour)
CACHE_TTL = 3600
@@ -61,6 +65,20 @@ API_KEY_TO_DEV_MODEL = {
"h2d-pro": "O1E",
}
# Wiki firmware release history pages (primary version source)
API_KEY_TO_WIKI_PATH = {
"x1": "/en/x1/manual/X1-X1C-firmware-release-history",
"x1e": "/en/x1/manual/X1E-firmware-release-history",
"p1": "/en/p1/manual/p1p-firmware-release-history",
"a1": "/en/a1/manual/a1-firmware-release-history",
"a1-mini": "/en/a1-mini/manual/a1-mini-firmware-release-history",
"h2d": "/en/h2d/manual/h2d-firmware-release-history",
"h2c": "/en/h2c/manual/h2c-firmware-release-history",
"h2s": "/en/h2s/manual/h2s-firmware-release-history",
"p2s": "/en/p2s/manual/p2s-firmware-release-history",
"h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
}
@dataclass
class FirmwareVersion:
@@ -109,11 +127,34 @@ class FirmwareCheckService:
return self._build_id # Return cached value if available
async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
"""Fetch firmware versions for a specific printer from Bambu Lab API."""
async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
"""Fetch the latest firmware version from Bambu Lab's wiki release history page."""
wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
if not wiki_path:
return None
try:
url = f"{BAMBU_WIKI_BASE}{wiki_path}"
response = await self._client.get(url, follow_redirects=True)
if response.status_code == 200:
# Extract version strings (format: XX.XX.XX.XX), first match is the latest
versions = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})", response.text)
if versions:
logger.debug("Wiki firmware for %s: %s", api_key, versions[0])
return versions[0]
else:
logger.debug("Wiki firmware page for %s returned %s", api_key, response.status_code)
except Exception as e:
logger.debug("Error fetching wiki firmware for %s: %s", api_key, e)
return None
async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
"""Fetch firmware info from Bambu Lab's download page (has download URLs)."""
build_id = await self._get_build_id()
if not build_id:
logger.warning("No build ID available, cannot fetch firmware versions")
return None
try:
@@ -135,16 +176,39 @@ class FirmwareCheckService:
release_notes=latest.get("release_notes_en"),
release_time=latest.get("release_time"),
)
else:
# api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
logger.warning("Failed to fetch firmware for %s: %s", api_key, response.status_code)
except Exception as e:
# api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
logger.error("Error fetching firmware for %s: %s", api_key, e)
logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
return None
async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
"""Fetch firmware version info, using wiki as primary source and download page as fallback."""
# Try wiki first (always has the latest version)
wiki_version = await self._fetch_version_from_wiki(api_key)
# Try download page (has download URLs, may lag behind wiki)
download_info = await self._fetch_from_download_page(api_key)
if wiki_version:
# Wiki has the latest version — use it, attach download URL if available
download_url = ""
release_notes = None
if download_info and download_info.version == wiki_version:
download_url = download_info.download_url
release_notes = download_info.release_notes
return FirmwareVersion(
version=wiki_version,
download_url=download_url,
release_notes=release_notes,
)
if download_info:
return download_info
logger.warning("Could not fetch firmware info for %s from wiki or download page", api_key)
return None
async def get_latest_version(self, model: str) -> FirmwareVersion | None:
"""
Get the latest firmware version for a printer model.
+7 -2
View File
@@ -36,6 +36,7 @@ class MQTTRelayService:
self._last_printer_status: dict[int, float] = {} # printer_id -> last publish timestamp
self._smart_plug_service = None # Lazy import to avoid circular dependency
self._settings: dict = {} # Store settings for smart plug service
self._disconnection_event: threading.Event | None = None
async def configure(self, settings: dict) -> bool:
"""Configure MQTT connection from settings.
@@ -187,15 +188,19 @@ class MQTTRelayService:
logger.warning("MQTT relay disconnected: %s", rc)
else:
logger.info("MQTT relay disconnected cleanly")
if self._disconnection_event:
self._disconnection_event.set()
async def disconnect(self):
async def disconnect(self, timeout: float = 0):
"""Disconnect from MQTT broker."""
if self.client:
try:
# Publish offline status before disconnecting
self._publish_status("offline")
self.client.loop_stop()
self._disconnection_event = threading.Event()
self.client.disconnect()
await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
self.client.loop_stop()
except Exception as e:
logger.debug("MQTT disconnect error (ignored): %s", e)
finally:
+8 -2
View File
@@ -3,6 +3,7 @@
This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices.
"""
import asyncio
import json
import logging
import threading
@@ -52,6 +53,7 @@ class MQTTSmartPlugService:
self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {}
# plug_id -> latest data
self.plug_data: dict[int, SmartPlugMQTTData] = {}
self._disconnection_event: threading.Event | None = None
self._configured = False
self._broker = ""
self._port = 1883
@@ -209,6 +211,8 @@ class MQTTSmartPlugService:
logger.warning("MQTT smart plug service disconnected: %s", rc)
else:
logger.info("MQTT smart plug service disconnected cleanly")
if self._disconnection_event:
self._disconnection_event.set()
def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
"""Handle incoming MQTT message, extract data using JSON path."""
@@ -471,12 +475,14 @@ class MQTTSmartPlugService:
timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
return datetime.utcnow() - data.last_seen < timeout
async def disconnect(self):
async def disconnect(self, timeout: float = 0):
"""Disconnect from MQTT broker."""
if self.client:
try:
self.client.loop_stop()
self._disconnection_event = threading.Event()
self.client.disconnect()
await asyncio.to_thread(self._disconnection_event.wait, timeout=timeout)
self.client.loop_stop()
except Exception as e:
logger.debug("MQTT smart plug disconnect error (ignored): %s", e)
finally:
+29 -19
View File
@@ -267,19 +267,20 @@ class NotificationService:
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
# Check if message contains characters that break Markdown parsing
# URLs and error codes with underscores cause issues
has_url = "http://" in message or "https://" in message
# Check for underscores outside of the bold title (odd number of _ breaks markdown)
body_part = message.split("\n", 1)[1] if "\n" in message else ""
has_problematic_underscore = "_" in body_part
# Escape underscores in the message body so Telegram Markdown
# parsing doesn't break on job names like "A1_plate_8" or error
# codes like "0300_0001". The title is already wrapped in *bold*
# markers, so only escape after the first newline.
if "\n" in message:
title_part, body_part = message.split("\n", 1)
body_part = body_part.replace("_", "\\_")
message = f"{title_part}\n{body_part}"
data = {
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown",
}
if not has_url and not has_problematic_underscore:
data["parse_mode"] = "Markdown"
client = await self._get_client()
response = await client.post(url, json=data)
@@ -344,7 +345,9 @@ class NotificationService:
except Exception as e:
return False, f"Email error: {str(e)}"
async def _send_discord(self, config: dict, title: str, message: str) -> tuple[bool, str]:
async def _send_discord(
self, config: dict, title: str, message: str, image_data: bytes | None = None
) -> tuple[bool, str]:
"""Send notification via Discord webhook."""
webhook_url = config.get("webhook_url", "").strip()
@@ -355,18 +358,25 @@ class NotificationService:
return False, "Invalid Discord webhook URL"
# Discord embed format for nicer messages
data = {
"embeds": [
{
"title": title,
"description": message,
"color": 0x00AE42, # Bambu green
}
]
embed = {
"title": title,
"description": message,
"color": 0x00AE42, # Bambu green
}
client = await self._get_client()
response = await client.post(webhook_url, json=data)
if image_data:
# Attach image via multipart form-data and reference in embed
embed["image"] = {"url": "attachment://photo.jpg"}
payload = {"embeds": [embed]}
response = await client.post(
webhook_url,
data={"payload_json": json.dumps(payload)},
files={"files[0]": ("photo.jpg", image_data, "image/jpeg")},
)
else:
response = await client.post(webhook_url, json={"embeds": [embed]})
if response.status_code in (200, 204):
return True, "Message sent successfully"
@@ -449,7 +459,7 @@ class NotificationService:
elif provider.provider_type == "email":
return await self._send_email(config, title, message)
elif provider.provider_type == "discord":
return await self._send_discord(config, title, message)
return await self._send_discord(config, title, message, image_data=image_data)
elif provider.provider_type == "webhook":
return await self._send_webhook(config, title, message)
else:
+9 -25
View File
@@ -4,7 +4,7 @@ import asyncio
import json
import logging
import zipfile
from datetime import datetime, timedelta
from datetime import datetime
from pathlib import Path
import defusedxml.ElementTree as ET
@@ -694,9 +694,11 @@ class PrintScheduler:
if not state:
return False
# Printer is idle if state is IDLE, FINISH, FAILED, or unknown
# FAILED means previous print failed, printer is ready for new print
return state.state in ("IDLE", "FINISH", "FAILED", "unknown")
# IDLE = ready for next print
# FINISH/FAILED = ready only if user confirmed plate is cleared
return state.state == "IDLE" or (
state.state in ("FINISH", "FAILED") and printer_manager.is_plate_cleared(printer_id)
)
async def _get_smart_plug(self, db: AsyncSession, printer_id: int) -> SmartPlug | None:
"""Get the smart plug associated with a printer."""
@@ -860,27 +862,6 @@ class PrintScheduler:
await self._power_off_if_needed(db, item)
return
# Safety: Check if this archive was printed recently (within 4 hours)
# This prevents phantom reprints if a queue item got stuck in "pending"
# after its print already started due to a crash/restart
if archive.status == "completed" and archive.completed_at:
completed_at = (
archive.completed_at.replace(tzinfo=None) if archive.completed_at.tzinfo else archive.completed_at
)
time_since_completed = datetime.utcnow() - completed_at
if time_since_completed < timedelta(hours=4):
logger.warning(
f"Queue item {item.id}: Archive {item.archive_id} was already printed "
f"{time_since_completed.total_seconds() / 3600:.1f} hours ago, skipping to prevent duplicate"
)
item.status = "skipped"
item.error_message = (
f"Archive was already printed {time_since_completed.total_seconds() / 3600:.1f} hours ago"
)
item.completed_at = datetime.utcnow()
await db.commit()
return
file_path = settings.base_dir / archive.file_path
filename = archive.filename
@@ -1034,6 +1015,9 @@ class PrintScheduler:
item.status = "printing"
item.started_at = datetime.utcnow()
await db.commit()
# Consume the plate-cleared flag now that we're starting a print
printer_manager.consume_plate_cleared(item.printer_id)
logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
# Start the print with AMS mapping, plate_id and print options
+20 -6
View File
@@ -100,6 +100,8 @@ class PrinterManager:
self._loop: asyncio.AbstractEventLoop | None = None
# Track who started the current print (Issue #206)
self._current_print_user: dict[int, dict] = {} # {printer_id: {"user_id": int, "username": str}}
# Track plate-cleared acknowledgments for queue flow
self._plate_cleared: set[int] = set() # printer_ids where user confirmed plate is cleared
def get_printer(self, printer_id: int) -> PrinterInfo | None:
"""Get printer info by ID."""
@@ -117,6 +119,18 @@ class PrinterManager:
"""Clear the current print user when print completes (Issue #206)."""
self._current_print_user.pop(printer_id, None)
def set_plate_cleared(self, printer_id: int):
"""Mark that user has cleared the build plate for this printer."""
self._plate_cleared.add(printer_id)
def is_plate_cleared(self, printer_id: int) -> bool:
"""Check if user has confirmed the plate is cleared."""
return printer_id in self._plate_cleared
def consume_plate_cleared(self, printer_id: int):
"""Clear the plate-cleared flag (called when scheduler starts next print)."""
self._plate_cleared.discard(printer_id)
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
"""Set the event loop for async callbacks."""
self._loop = loop
@@ -209,18 +223,18 @@ class PrinterManager:
await asyncio.sleep(1)
return client.state.connected
def disconnect_printer(self, printer_id: int):
def disconnect_printer(self, printer_id: int, timeout: float = 0):
"""Disconnect from a printer."""
if printer_id in self._clients:
self._clients[printer_id].disconnect()
self._clients[printer_id].disconnect(timeout=timeout)
del self._clients[printer_id]
self._models.pop(printer_id, None) # Clean up model cache
self._printer_info.pop(printer_id, None) # Clean up printer info cache
def disconnect_all(self):
def disconnect_all(self, timeout: float = 0):
"""Disconnect from all printers."""
for printer_id in list(self._clients.keys()):
self.disconnect_printer(printer_id)
self.disconnect_printer(printer_id, timeout=timeout)
def get_status(self, printer_id: int) -> PrinterState | None:
"""Get the current status of a printer (checks for stale connections)."""
@@ -519,7 +533,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
trays.append(
{
"id": tray.get("id", 0),
"id": int(tray.get("id", 0)),
"tray_color": tray.get("tray_color"),
"tray_type": tray.get("tray_type"),
"tray_sub_brands": tray.get("tray_sub_brands"),
@@ -556,7 +570,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
ams_units.append(
{
"id": ams_data.get("id", 0),
"id": int(ams_data.get("id", 0)),
"humidity": humidity_value,
"temp": ams_data.get("temp"),
"is_ams_ht": is_ams_ht,
+310
View File
@@ -0,0 +1,310 @@
"""RFID tag matching and auto-assignment for spool inventory."""
import logging
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
logger = logging.getLogger(__name__)
# Zero-value constants for tag validation
ZERO_TAG_UID = "0000000000000000"
ZERO_TRAY_UUID = "00000000000000000000000000000000"
def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
"""Check if a tag/UUID pair contains a non-zero, non-empty value."""
uid_valid = bool(tag_uid) and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid)
uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
return uid_valid or uuid_valid
def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:
"""Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset)."""
uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
has_preset = bool(tray_info_idx)
return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)
async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
"""Create a new Spool inventory entry from AMS tray MQTT data.
Extracts material, subtype, color, temps, and tag info from the tray dict.
Looks up core_weight from the spool catalog if a Bambu Lab entry matches.
"""
from backend.app.models.color_catalog import ColorCatalogEntry
from backend.app.models.spool_catalog import SpoolCatalogEntry
tray_type = tray_data.get("tray_type", "") # "PLA"
tray_sub_brands = tray_data.get("tray_sub_brands", "") # "PLA Basic"
tray_color = tray_data.get("tray_color", "FFFFFFFF") # RRGGBBAA
tray_id_name = tray_data.get("tray_id_name", "") # Color name e.g. "Jade White"
tag_uid = tray_data.get("tag_uid", "")
tray_uuid = tray_data.get("tray_uuid", "")
tray_info_idx = tray_data.get("tray_info_idx", "")
nozzle_min = tray_data.get("nozzle_temp_min", 0)
nozzle_max = tray_data.get("nozzle_temp_max", 0)
label_weight = int(tray_data.get("tray_weight", 1000))
# Parse material and subtype from tray_sub_brands ("PLA Basic" → material="PLA", subtype="Basic")
material = tray_type or "PLA"
subtype = None
if tray_sub_brands and " " in tray_sub_brands:
parts = tray_sub_brands.split(" ", 1)
if parts[0].upper() == material.upper():
subtype = parts[1]
else:
# tray_sub_brands is the full material name (e.g. "PETG-HF")
material = tray_sub_brands
elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
material = tray_sub_brands
# Resolve color name from tray_id_name code, hex catalog, or raw tray_id_name
from backend.app.core.bambu_colors import resolve_bambu_color_name
rgba = tray_color if tray_color else None
color_name = None
# 1. Try Bambu color code mapping (e.g. "A06-D0" → "Titan Gray")
if tray_id_name:
color_name = resolve_bambu_color_name(tray_id_name)
logger.info("Color resolve: tray_id_name=%r → resolved=%r", tray_id_name, color_name)
# If not a known code, use tray_id_name directly (it may be a readable name)
if not color_name and "-" not in tray_id_name:
color_name = tray_id_name
else:
logger.info("Color resolve: tray_id_name is empty, rgba=%r", rgba)
# 2. Try color catalog lookup by hex color
if not color_name and rgba and len(rgba) >= 6:
hex_prefix = f"#{rgba[:6].upper()}"
cat_result = await db.execute(
select(ColorCatalogEntry)
.where(func.upper(ColorCatalogEntry.hex_color) == hex_prefix)
.where(func.upper(ColorCatalogEntry.manufacturer) == "BAMBU LAB")
.limit(1)
)
entry = cat_result.scalar_one_or_none()
if entry:
color_name = entry.color_name
# Look up core weight from spool catalog
core_weight = 250 # Default for Bambu Lab plastic spools
cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10))
for entry in cat_result.scalars().all():
# Pick the best match (prefer exact, fallback to first Bambu Lab entry)
core_weight = entry.weight
break
# Resolve slicer filament name from builtin table
slicer_filament_name = None
if tray_info_idx:
try:
from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
slicer_filament_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
except Exception:
pass
# Fallback: use tray_sub_brands as the display name
if not slicer_filament_name and tray_sub_brands:
slicer_filament_name = tray_sub_brands
# Calculate initial weight_used from AMS remain percentage
remain_raw = tray_data.get("remain")
try:
remain_pct = int(remain_raw) if remain_raw is not None else 100
except (TypeError, ValueError):
remain_pct = 100
# Clamp to valid range: negative means unknown, >100 is invalid
if remain_pct < 0 or remain_pct > 100:
remain_pct = 100 # Unknown → assume full
weight_used = round(label_weight * (100 - remain_pct) / 100.0, 1)
spool = Spool(
material=material,
subtype=subtype,
color_name=color_name,
rgba=rgba,
brand="Bambu Lab",
label_weight=label_weight,
core_weight=core_weight,
weight_used=weight_used,
slicer_filament=tray_info_idx or None,
slicer_filament_name=slicer_filament_name,
nozzle_temp_min=int(nozzle_min) if nozzle_min else None,
nozzle_temp_max=int(nozzle_max) if nozzle_max else None,
tag_uid=tag_uid if tag_uid and tag_uid != ZERO_TAG_UID else None,
tray_uuid=tray_uuid if tray_uuid and tray_uuid != ZERO_TRAY_UUID else None,
data_origin="rfid_auto",
tag_type="bambulab",
)
db.add(spool)
await db.flush()
logger.info(
"Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
spool.id,
material,
subtype or "",
color_name or "",
tag_uid,
tray_uuid,
)
return spool
async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
"""Look up an active spool by RFID tag UID or Bambu Lab tray UUID.
Prefers tray_uuid match over tag_uid (more reliable).
"""
# Try tray_uuid first (Bambu Lab spools — more reliable)
if tray_uuid and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid):
result = await db.execute(
select(Spool)
.options(selectinload(Spool.k_profiles))
.where(Spool.tray_uuid == tray_uuid, Spool.archived_at.is_(None))
.limit(1)
)
spool = result.scalar_one_or_none()
if spool:
return spool
# Fall back to tag_uid
if tag_uid and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid):
result = await db.execute(
select(Spool)
.options(selectinload(Spool.k_profiles))
.where(Spool.tag_uid == tag_uid, Spool.archived_at.is_(None))
.limit(1)
)
spool = result.scalar_one_or_none()
if spool:
return spool
return None
async def auto_assign_spool(
printer_id: int,
ams_id: int,
tray_id: int,
spool: Spool,
printer_manager,
db: AsyncSession,
) -> SpoolAssignment:
"""Create a SpoolAssignment and auto-configure the AMS slot via MQTT.
Reuses the same MQTT configuration logic as the manual assign endpoint.
"""
from backend.app.api.routes.inventory import MATERIAL_TEMPS
# Get current tray state for fingerprint
fingerprint_color = None
fingerprint_type = None
state = printer_manager.get_status(printer_id)
if state and state.raw_data:
from backend.app.api.routes.inventory import _find_tray_in_ams_data
ams = state.raw_data.get("ams", [])
if isinstance(ams, dict):
ams = ams.get("ams", [])
tray = _find_tray_in_ams_data(
ams,
ams_id,
tray_id,
)
if tray:
fingerprint_color = tray.get("tray_color", "")
fingerprint_type = tray.get("tray_type", "")
# Upsert: remove old assignment for this slot
existing = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
old = existing.scalar_one_or_none()
if old:
await db.delete(old)
await db.flush()
assignment = SpoolAssignment(
spool_id=spool.id,
printer_id=printer_id,
ams_id=ams_id,
tray_id=tray_id,
fingerprint_color=fingerprint_color,
fingerprint_type=fingerprint_type,
)
db.add(assignment)
await db.flush()
# Auto-configure AMS slot via MQTT
try:
client = printer_manager.get_client(printer_id)
if client:
tray_type = spool.material
tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
tray_color = spool.rgba or "FFFFFFFF"
tray_info_idx = spool.slicer_filament or ""
setting_id = ""
temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
if spool.nozzle_temp_min is not None:
temp_min = spool.nozzle_temp_min
if spool.nozzle_temp_max is not None:
temp_max = spool.nozzle_temp_max
client.ams_set_filament_setting(
ams_id=ams_id,
tray_id=tray_id,
tray_info_idx=tray_info_idx,
tray_type=tray_type,
tray_sub_brands=tray_sub_brands,
tray_color=tray_color,
nozzle_temp_min=temp_min,
nozzle_temp_max=temp_max,
setting_id=setting_id,
)
# Apply K-profile if available
nozzle_diameter = "0.4"
if state and state.nozzles:
nd = state.nozzles[0].nozzle_diameter
if nd:
nozzle_diameter = nd
matching_kp = None
for kp in spool.k_profiles:
if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
matching_kp = kp
break
if matching_kp and matching_kp.cali_idx is not None:
client.extrusion_cali_sel(
ams_id=ams_id,
tray_id=tray_id,
cali_idx=matching_kp.cali_idx,
filament_id=tray_info_idx,
nozzle_diameter=nozzle_diameter,
setting_id=matching_kp.setting_id,
)
logger.info(
"Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d (RFID match)",
ams_id,
tray_id,
spool.id,
printer_id,
)
except Exception as e:
logger.warning("MQTT auto-configure failed for spool %d (RFID match): %s", spool.id, e)
return assignment
+102 -40
View File
@@ -468,6 +468,26 @@ class SpoolmanClient:
return spool
return None
def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
"""Find a spool by exact location match.
Used as fallback when RFID tag data is unavailable (e.g., newer firmware
that doesn't expose tray_uuid/tag_uid via MQTT).
Args:
location: Exact location string (e.g., "H2D-1 - AMS A1")
cached_spools: Pre-fetched list of spools to search
Returns:
Spool dictionary or None if not found.
"""
if not cached_spools:
return None
for spool in cached_spools:
if spool.get("location") == location:
return spool
return None
async def find_spools_by_location_prefix(
self, location_prefix: str, cached_spools: list[dict] | None = None
) -> list[dict]:
@@ -494,17 +514,21 @@ class SpoolmanClient:
printer_name: str,
current_tray_uuids: set[str],
cached_spools: list[dict] | None = None,
synced_spool_ids: set[int] | None = None,
) -> int:
"""Clear location for spools that are no longer in the AMS.
When a spool is removed from the AMS, its location should be cleared
in Spoolman. This method finds all spools with locations for this printer
and clears the location for any that are not in the current_tray_uuids set.
and clears the location for any that are not in the current_tray_uuids set
and were not synced in this cycle (synced_spool_ids).
Args:
printer_name: The printer name used as location prefix
current_tray_uuids: Set of tray_uuids currently in the AMS
cached_spools: Optional pre-fetched list of spools to search (avoids API call)
synced_spool_ids: Set of spool IDs that were synced in this cycle
(protects location-matched spools when RFID data is unavailable)
Returns:
Number of spools whose location was cleared.
@@ -514,6 +538,12 @@ class SpoolmanClient:
cleared_count = 0
for spool in spools_at_printer:
spool_id = spool.get("id")
# Skip spools that were just synced (matched by location or tag)
if synced_spool_ids and spool_id in synced_spool_ids:
continue
# Get the tray_uuid (stored as "tag" in extra field)
extra = spool.get("extra", {}) or {}
stored_tag = extra.get("tag", "")
@@ -526,10 +556,10 @@ class SpoolmanClient:
# If this spool's UUID is not in the current AMS, clear its location
if spool_uuid not in current_tray_uuids:
logger.info(
f"Clearing location for spool {spool['id']} "
f"Clearing location for spool {spool_id} "
f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
)
result = await self.update_spool(spool_id=spool["id"], clear_location=True)
result = await self.update_spool(spool_id=spool_id, clear_location=True)
if result:
cleared_count += 1
@@ -628,8 +658,8 @@ class SpoolmanClient:
# Get tray_info_idx (Bambu filament preset ID like "GFA00")
tray_info_idx = tray_data.get("tray_info_idx", "") or ""
# Get remaining percentage, ensure non-negative
remain = max(0, int(tray_data.get("remain", 0)))
# Get remaining percentage (-1 means unknown/not read by AMS)
remain = int(tray_data.get("remain", -1))
return AMSTray(
ams_id=ams_id,
@@ -730,6 +760,7 @@ class SpoolmanClient:
printer_name: str,
disable_weight_sync: bool = False,
cached_spools: list[dict] | None = None,
inventory_remaining: float | None = None,
) -> dict | None:
"""Sync a single AMS tray to Spoolman.
@@ -747,6 +778,8 @@ class SpoolmanClient:
cached_spools: Optional pre-fetched list of spools to search (avoids API calls).
When provided, this cache is passed to find_spool_by_tag to avoid redundant
API calls during batch sync operations.
inventory_remaining: Optional fallback remaining weight (grams) from the built-in
inventory when AMS MQTT data has invalid remain/tray_weight values.
Returns:
Synced spool dictionary or None if skipped or failed.
@@ -770,53 +803,82 @@ class SpoolmanClient:
return None
# Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)
spool_tag = (
tray.tray_uuid if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000" else tray.tag_uid
)
# If no unique identifier available, we can't sync even if it's a Bambu Lab spool
if not spool_tag:
logger.warning(
f"Bambu Lab spool detected but no unique identifier for Spoolman: "
f"{printer_name} AMS {tray.ams_id} tray {tray.tray_id} (tray_info_idx={tray.tray_info_idx})"
)
return None
# Zero-filled values mean the AMS hasn't read the RFID tag — treat as no tag
zero_uuid = "00000000000000000000000000000000"
zero_tag = "0000000000000000"
spool_tag = None
if tray.tray_uuid and tray.tray_uuid != zero_uuid:
spool_tag = tray.tray_uuid
elif tray.tag_uid and tray.tag_uid != zero_tag:
spool_tag = tray.tag_uid
# Calculate remaining weight
remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
# Primary: AMS MQTT data (remain percentage + tray_weight)
# Fallback: Built-in inventory tracked weight (when firmware sends invalid remain/tray_weight)
if tray.remain >= 0 and tray.tray_weight > 0:
remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
elif inventory_remaining is not None:
remaining = inventory_remaining
logger.debug(
"Using inventory weight fallback for %s AMS %s tray %s: %.1fg",
printer_name,
tray.ams_id,
tray.tray_id,
remaining,
)
else:
remaining = None
location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
# Find existing spool by tag (tray_uuid or tag_uid, stored as "tag" in Spoolman)
existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
if spool_tag:
# Primary path: match by RFID tag
existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
if existing:
logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
return await self.update_spool(
spool_id=existing["id"],
remaining_weight=None if disable_weight_sync else remaining,
location=location,
)
# Spool not found by tag - auto-create it
logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
filament = await self._find_or_create_filament(tray)
if not filament:
logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
return None
import json
return await self.create_spool(
filament_id=filament["id"],
remaining_weight=remaining,
location=location,
comment="Created by Bambuddy",
extra={"tag": json.dumps(spool_tag)},
)
# Fallback path: no RFID tag available (newer firmware may not expose UUIDs)
# Only update existing spools matched by location — never create new ones without a tag
# to avoid duplicates when old spools exist from previous RFID-based syncs
existing = self._find_spool_by_location(location, cached_spools)
if existing:
# Update existing spool
logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
logger.info(
"Updating spool %s by location match '%s' (no RFID tag available)",
existing["id"],
location,
)
return await self.update_spool(
spool_id=existing["id"],
remaining_weight=None if disable_weight_sync else remaining,
location=location,
)
# Spool not found - auto-create it
logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
# First find or create the filament type
filament = await self._find_or_create_filament(tray)
if not filament:
logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
return None
# Create the spool with identifier stored as "tag" in extra field
# Note: Spoolman extra field values must be valid JSON, so we encode the string
import json
return await self.create_spool(
filament_id=filament["id"],
remaining_weight=remaining,
location=location,
comment="Created by Bambuddy",
extra={"tag": json.dumps(spool_tag)},
logger.info(
"No existing spool found at '%s' — skipping (no RFID tag to create with)",
location,
)
return None
async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
"""Find existing filament or create new one.
+328
View File
@@ -0,0 +1,328 @@
"""Automatic filament consumption tracking.
Captures AMS tray remain% at print start, then computes consumption
deltas at print complete to update spool weight_used and last_used.
For non-BL spools (no RFID, AMS reports remain=-1), falls back to
per-filament usage estimates from the archived 3MF file.
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spool_usage_history import SpoolUsageHistory
logger = logging.getLogger(__name__)
@dataclass
class PrintSession:
printer_id: int
print_name: str
started_at: datetime
tray_remain_start: dict[tuple[int, int], int] = field(default_factory=dict)
# Module-level storage, keyed by printer_id
_active_sessions: dict[int, PrintSession] = {}
async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
"""Capture AMS tray remain% at print start."""
state = printer_manager.get_status(printer_id)
if not state or not state.raw_data:
logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
return
ams_raw = state.raw_data.get("ams", [])
ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
if not ams_data:
logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
return
tray_remain_start: dict[tuple[int, int], int] = {}
for ams_unit in ams_data:
ams_id = int(ams_unit.get("id", 0))
for tray in ams_unit.get("tray", []):
tray_id = int(tray.get("id", 0))
remain = tray.get("remain", -1)
if isinstance(remain, int) and 0 <= remain <= 100:
tray_remain_start[(ams_id, tray_id)] = remain
print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
# Always create session (even without valid remain data) so print_name
# is available at completion for 3MF-based tracking
session = PrintSession(
printer_id=printer_id,
print_name=print_name,
started_at=datetime.now(timezone.utc),
tray_remain_start=tray_remain_start,
)
_active_sessions[printer_id] = session
if tray_remain_start:
logger.info(
"[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
printer_id,
len(tray_remain_start),
{f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
)
else:
logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
async def on_print_complete(
printer_id: int,
data: dict,
printer_manager,
db: AsyncSession,
archive_id: int | None = None,
) -> list[dict]:
"""Compute consumption deltas and update spool weight_used/last_used.
Uses two tracking strategies:
1. AMS remain% delta — for BL spools with valid RFID remain data
2. 3MF per-filament estimates — for non-BL spools without remain data
Returns a list of dicts describing what was logged (for WebSocket broadcast).
"""
session = _active_sessions.pop(printer_id, None)
status = data.get("status", "completed")
results = []
handled_trays: set[tuple[int, int]] = set()
# --- Path 1: AMS remain% delta (for spools with valid RFID remain data) ---
if session and session.tray_remain_start:
state = printer_manager.get_status(printer_id)
if state and state.raw_data:
ams_raw = state.raw_data.get("ams", [])
ams_data = (
ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
)
for ams_unit in ams_data:
ams_id = int(ams_unit.get("id", 0))
for tray in ams_unit.get("tray", []):
tray_id = int(tray.get("id", 0))
key = (ams_id, tray_id)
if key not in session.tray_remain_start:
continue
current_remain = tray.get("remain", -1)
if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
continue
start_remain = session.tray_remain_start[key]
delta_pct = start_remain - current_remain
if delta_pct <= 0:
continue # No consumption or tray was refilled
# Look up SpoolAssignment for this slot
result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
assignment = result.scalar_one_or_none()
if not assignment:
continue
# Load spool
spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
spool = spool_result.scalar_one_or_none()
if not spool:
continue
# Compute weight consumed
weight_grams = (delta_pct / 100.0) * spool.label_weight
# Update spool
spool.weight_used = (spool.weight_used or 0) + weight_grams
spool.last_used = datetime.now(timezone.utc)
# Insert usage history record
history = SpoolUsageHistory(
spool_id=spool.id,
printer_id=printer_id,
print_name=session.print_name,
weight_used=round(weight_grams, 1),
percent_used=delta_pct,
status=status,
)
db.add(history)
handled_trays.add(key)
results.append(
{
"spool_id": spool.id,
"weight_used": round(weight_grams, 1),
"percent_used": delta_pct,
"ams_id": ams_id,
"tray_id": tray_id,
}
)
logger.info(
"[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)",
spool.id,
weight_grams,
delta_pct,
printer_id,
ams_id,
tray_id,
status,
)
# --- Path 2: 3MF per-filament estimates (for non-BL spools without remain data) ---
if archive_id:
print_name = (
(session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
)
threemf_results = await _track_from_3mf(
printer_id, archive_id, status, print_name, handled_trays, printer_manager, db
)
results.extend(threemf_results)
if results:
await db.commit()
return results
async def _track_from_3mf(
printer_id: int,
archive_id: int,
status: str,
print_name: str,
handled_trays: set[tuple[int, int]],
printer_manager,
db: AsyncSession,
) -> list[dict]:
"""Track usage from 3MF per-filament data for non-BL spools.
Falls back to slicer-estimated filament weight when AMS remain% is
unavailable (non-RFID spools). For partial prints (failed/aborted),
scales the estimate by print progress.
"""
from backend.app.core.config import settings as app_settings
from backend.app.models.archive import PrintArchive
from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
archive = result.scalar_one_or_none()
if not archive or not archive.file_path:
return []
file_path = app_settings.base_dir / archive.file_path
if not file_path.exists():
return []
filament_usage = extract_filament_usage_from_3mf(file_path)
if not filament_usage:
return []
# Scale factor for partial prints (failed/aborted)
if status == "completed":
scale = 1.0
else:
state = printer_manager.get_status(printer_id)
progress = state.progress if state else 0
scale = max(0.0, min(progress / 100.0, 1.0))
results = []
for usage in filament_usage:
slot_id = usage.get("slot_id", 0)
used_g = usage.get("used_g", 0)
if used_g <= 0:
continue
# Map 3MF slot_id (1-based) to (ams_id, tray_id)
global_tray_id = slot_id - 1
if global_tray_id >= 128:
ams_id = global_tray_id
tray_id = 0
else:
ams_id = global_tray_id // 4
tray_id = global_tray_id % 4
key = (ams_id, tray_id)
if key in handled_trays:
continue # Already tracked via AMS remain% delta
# Find spool assignment for this tray
assign_result = await db.execute(
select(SpoolAssignment).where(
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
assignment = assign_result.scalar_one_or_none()
if not assignment:
continue
# Load spool
spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
spool = spool_result.scalar_one_or_none()
if not spool:
continue
# Only use 3MF tracking for non-BL spools (BL spools use AMS remain%)
if spool.tag_uid or spool.tray_uuid:
continue
weight_grams = used_g * scale
if weight_grams <= 0:
continue
# Update spool
spool.weight_used = (spool.weight_used or 0) + weight_grams
spool.last_used = datetime.now(timezone.utc)
percent = round(weight_grams / (spool.label_weight or 1000) * 100)
# Insert usage history record
history = SpoolUsageHistory(
spool_id=spool.id,
printer_id=printer_id,
print_name=print_name,
weight_used=round(weight_grams, 1),
percent_used=percent,
status=status,
)
db.add(history)
results.append(
{
"spool_id": spool.id,
"weight_used": round(weight_grams, 1),
"percent_used": percent,
"ams_id": ams_id,
"tray_id": tray_id,
}
)
logger.info(
"[UsageTracker] Spool %d consumed %.1fg (3MF estimate%s) on printer %d AMS%d-T%d (%s)",
spool.id,
weight_grams,
f" scaled to {scale:.0%}" if scale < 1 else "",
printer_id,
ams_id,
tray_id,
status,
)
return results
@@ -192,6 +192,49 @@ class TestCameraAPI:
assert response.status_code == 503
assert "Failed to capture" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_snapshot_external_camera_success(self, async_client: AsyncClient, printer_factory):
"""Verify snapshot uses external camera when configured."""
printer = await printer_factory(
external_camera_enabled=True,
external_camera_url="http://192.168.1.50/mjpeg",
external_camera_type="mjpeg",
)
fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
with patch(
"backend.app.services.external_camera.capture_frame",
new_callable=AsyncMock,
return_value=fake_jpeg,
):
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
assert response.content == fake_jpeg
@pytest.mark.asyncio
@pytest.mark.integration
async def test_camera_snapshot_external_camera_failure(self, async_client: AsyncClient, printer_factory):
"""Verify 503 when external camera capture fails."""
printer = await printer_factory(
external_camera_enabled=True,
external_camera_url="http://192.168.1.50/mjpeg",
external_camera_type="mjpeg",
)
with patch(
"backend.app.services.external_camera.capture_frame",
new_callable=AsyncMock,
return_value=None,
):
response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
assert response.status_code == 503
assert "external camera" in response.json()["detail"].lower()
# ========================================================================
# Camera Stream Endpoint
# ========================================================================
@@ -0,0 +1,388 @@
"""Unit tests for the filament usage tracker.
Tests both AMS remain% delta tracking (Path 1) and 3MF per-filament
fallback tracking (Path 2) for non-BL spools.
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.usage_tracker import (
PrintSession,
_active_sessions,
_track_from_3mf,
on_print_complete,
on_print_start,
)
def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
"""Create a mock Spool object."""
spool = MagicMock()
spool.id = id
spool.label_weight = label_weight
spool.weight_used = weight_used
spool.tag_uid = tag_uid
spool.tray_uuid = tray_uuid
spool.last_used = None
return spool
def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
"""Create a mock SpoolAssignment object."""
assignment = MagicMock()
assignment.spool_id = spool_id
assignment.printer_id = printer_id
assignment.ams_id = ams_id
assignment.tray_id = tray_id
return assignment
def _make_printer_state(ams_data, progress=0):
"""Create a mock printer state with AMS data."""
state = MagicMock()
state.raw_data = {"ams": ams_data}
state.progress = progress
return state
def _make_printer_manager(state=None):
"""Create a mock printer manager."""
pm = MagicMock()
pm.get_status.return_value = state
return pm
class TestOnPrintStart:
"""Tests for on_print_start — capturing AMS remain%."""
@pytest.fixture(autouse=True)
def _clear_sessions(self):
_active_sessions.clear()
yield
_active_sessions.clear()
@pytest.mark.asyncio
async def test_creates_session_with_valid_remain(self):
"""Session created with remain% data for trays reporting 0-100."""
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
await on_print_start(1, {"subtask_name": "test_print"}, pm)
assert 1 in _active_sessions
session = _active_sessions[1]
assert session.print_name == "test_print"
assert session.tray_remain_start == {(0, 0): 80}
@pytest.mark.asyncio
async def test_creates_session_even_without_valid_remain(self):
"""Session still created when remain=-1 (for 3MF fallback path)."""
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
await on_print_start(1, {"subtask_name": "test_print"}, pm)
assert 1 in _active_sessions
session = _active_sessions[1]
assert session.tray_remain_start == {} # Empty, no valid remain
@pytest.mark.asyncio
async def test_skips_without_ams_data(self):
"""No session created when no AMS data available."""
state = MagicMock()
state.raw_data = {"ams": []}
pm = _make_printer_manager(state)
await on_print_start(1, {"subtask_name": "test"}, pm)
assert 1 not in _active_sessions
class TestOnPrintCompleteAMSDelta:
"""Tests for Path 1: AMS remain% delta tracking."""
@pytest.fixture(autouse=True)
def _clear_sessions(self):
_active_sessions.clear()
yield
_active_sessions.clear()
@pytest.mark.asyncio
async def test_computes_delta_and_updates_spool(self):
"""Spool weight_used updated by remain% delta * label_weight."""
# Set up session with start remain = 80%
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="test",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 80},
)
# Current remain = 70% → 10% consumed → 100g on 1000g spool
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
spool = _make_spool(label_weight=1000, weight_used=50)
assignment = _make_assignment()
db = AsyncMock()
# First execute → assignment, second → spool
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
results = await on_print_complete(1, {"status": "completed"}, pm, db)
assert len(results) == 1
assert results[0]["weight_used"] == 100.0
assert results[0]["percent_used"] == 10
# weight_used should be old (50) + delta (100)
assert spool.weight_used == 150.0
db.commit.assert_called_once()
@pytest.mark.asyncio
async def test_skips_negative_delta(self):
"""No tracking when remain increased (spool refilled)."""
_active_sessions[1] = PrintSession(
printer_id=1,
print_name="test",
started_at=datetime.now(timezone.utc),
tray_remain_start={(0, 0): 50},
)
# Remain went UP: 50 → 80 (refilled)
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
pm = _make_printer_manager(_make_printer_state(ams_data))
db = AsyncMock()
results = await on_print_complete(1, {"status": "completed"}, pm, db)
assert results == []
db.commit.assert_not_called()
@pytest.mark.asyncio
async def test_no_session_falls_through_to_3mf(self):
"""When no session exists, AMS delta path skipped (3MF may still run)."""
pm = _make_printer_manager()
db = AsyncMock()
results = await on_print_complete(1, {"status": "completed"}, pm, db)
assert results == []
class TestTrackFrom3MF:
"""Tests for Path 2: 3MF per-filament fallback tracking."""
@pytest.mark.asyncio
async def test_updates_non_bl_spool_from_3mf(self):
"""Non-BL spool gets weight_used from 3MF used_g for completed print."""
spool = _make_spool(id=5, label_weight=1000, weight_used=100)
assignment = _make_assignment(spool_id=5)
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
# First execute → archive, second → assignment, third → spool
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
pm = _make_printer_manager()
filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PLA", "color": "#FF0000"}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="test_print",
handled_trays=set(),
printer_manager=pm,
db=db,
)
assert len(results) == 1
assert results[0]["spool_id"] == 5
assert results[0]["weight_used"] == 25.5
# weight_used = old (100) + 3MF (25.5)
assert spool.weight_used == 125.5
@pytest.mark.asyncio
async def test_scales_by_progress_for_failed_print(self):
"""Failed print scales 3MF estimate by progress percentage."""
spool = _make_spool(id=1, label_weight=1000, weight_used=0)
assignment = _make_assignment()
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
# Print failed at 50% progress → 50g consumed from 100g estimate
pm = _make_printer_manager(_make_printer_state([], progress=50))
filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="failed",
print_name="test",
handled_trays=set(),
printer_manager=pm,
db=db,
)
assert len(results) == 1
assert results[0]["weight_used"] == 50.0
assert spool.weight_used == 50.0
@pytest.mark.asyncio
async def test_skips_bl_spools(self):
"""BL spools (with tag_uid) are NOT tracked via 3MF — they use AMS remain%."""
spool = _make_spool(tag_uid="ABCD1234", tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4")
assignment = _make_assignment()
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
pm = _make_printer_manager()
filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="test",
handled_trays=set(),
printer_manager=pm,
db=db,
)
assert results == []
@pytest.mark.asyncio
async def test_skips_already_handled_trays(self):
"""Trays handled by AMS remain% delta are not double-tracked via 3MF."""
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
]
)
pm = _make_printer_manager()
filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="test",
handled_trays={(0, 0)}, # slot_id=1 → ams_id=0, tray_id=0
printer_manager=pm,
db=db,
)
assert results == []
@pytest.mark.asyncio
async def test_slot_to_tray_mapping(self):
"""3MF slot_id maps correctly to (ams_id, tray_id)."""
# slot 5 → global_tray_id 4 → ams_id=1, tray_id=0
spool = _make_spool(id=9)
assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0)
archive = MagicMock()
archive.file_path = "archives/test.3mf"
db = AsyncMock()
db.execute = AsyncMock(
side_effect=[
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
]
)
pm = _make_printer_manager()
filament_usage = [{"slot_id": 5, "used_g": 30.0, "type": "PETG", "color": ""}]
with (
patch("backend.app.core.config.settings") as mock_settings,
patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
):
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
results = await _track_from_3mf(
printer_id=1,
archive_id=10,
status="completed",
print_name="test",
handled_trays=set(),
printer_manager=pm,
db=db,
)
assert len(results) == 1
assert results[0]["ams_id"] == 1
assert results[0]["tray_id"] == 0
@@ -0,0 +1,122 @@
"""Tests for the clear plate queue flow in the print scheduler."""
from unittest.mock import MagicMock, patch
import pytest
from backend.app.services.print_scheduler import PrintScheduler
from backend.app.services.printer_manager import PrinterManager
class TestPrinterManagerPlateCleared:
"""Test the plate-cleared flag management in PrinterManager."""
@pytest.fixture
def manager(self):
return PrinterManager()
def test_plate_cleared_initially_false(self, manager):
"""No printers should have plate cleared by default."""
assert not manager.is_plate_cleared(1)
assert not manager.is_plate_cleared(999)
def test_set_plate_cleared(self, manager):
"""Setting plate cleared should make is_plate_cleared return True."""
manager.set_plate_cleared(1)
assert manager.is_plate_cleared(1)
assert not manager.is_plate_cleared(2)
def test_consume_plate_cleared(self, manager):
"""Consuming plate cleared should reset the flag."""
manager.set_plate_cleared(1)
assert manager.is_plate_cleared(1)
manager.consume_plate_cleared(1)
assert not manager.is_plate_cleared(1)
def test_consume_plate_cleared_idempotent(self, manager):
"""Consuming when not set should not raise."""
manager.consume_plate_cleared(1) # Should not raise
assert not manager.is_plate_cleared(1)
def test_set_plate_cleared_multiple_printers(self, manager):
"""Plate cleared should be tracked per printer."""
manager.set_plate_cleared(1)
manager.set_plate_cleared(3)
assert manager.is_plate_cleared(1)
assert not manager.is_plate_cleared(2)
assert manager.is_plate_cleared(3)
def test_consume_only_affects_target_printer(self, manager):
"""Consuming plate cleared for one printer should not affect others."""
manager.set_plate_cleared(1)
manager.set_plate_cleared(2)
manager.consume_plate_cleared(1)
assert not manager.is_plate_cleared(1)
assert manager.is_plate_cleared(2)
class TestSchedulerIdleCheckWithPlateCleared:
"""Test _is_printer_idle with plate-cleared flag interactions."""
@pytest.fixture
def scheduler(self):
return PrintScheduler()
@patch("backend.app.services.print_scheduler.printer_manager")
def test_idle_state_is_idle(self, mock_pm, scheduler):
"""Printer in IDLE state should be considered idle."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="IDLE")
assert scheduler._is_printer_idle(1) is True
@patch("backend.app.services.print_scheduler.printer_manager")
def test_running_state_not_idle(self, mock_pm, scheduler):
"""Printer in RUNNING state should not be idle."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="RUNNING")
assert scheduler._is_printer_idle(1) is False
@patch("backend.app.services.print_scheduler.printer_manager")
def test_finish_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
"""Printer in FINISH state should NOT be idle without plate cleared."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="FINISH")
mock_pm.is_plate_cleared.return_value = False
assert scheduler._is_printer_idle(1) is False
@patch("backend.app.services.print_scheduler.printer_manager")
def test_finish_state_idle_with_plate_cleared(self, mock_pm, scheduler):
"""Printer in FINISH state should be idle when plate is cleared."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="FINISH")
mock_pm.is_plate_cleared.return_value = True
assert scheduler._is_printer_idle(1) is True
@patch("backend.app.services.print_scheduler.printer_manager")
def test_failed_state_not_idle_without_plate_cleared(self, mock_pm, scheduler):
"""Printer in FAILED state should NOT be idle without plate cleared."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="FAILED")
mock_pm.is_plate_cleared.return_value = False
assert scheduler._is_printer_idle(1) is False
@patch("backend.app.services.print_scheduler.printer_manager")
def test_failed_state_idle_with_plate_cleared(self, mock_pm, scheduler):
"""Printer in FAILED state should be idle when plate is cleared."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = MagicMock(state="FAILED")
mock_pm.is_plate_cleared.return_value = True
assert scheduler._is_printer_idle(1) is True
@patch("backend.app.services.print_scheduler.printer_manager")
def test_disconnected_printer_not_idle(self, mock_pm, scheduler):
"""Disconnected printer should never be idle."""
mock_pm.is_connected.return_value = False
assert scheduler._is_printer_idle(1) is False
@patch("backend.app.services.print_scheduler.printer_manager")
def test_no_status_not_idle(self, mock_pm, scheduler):
"""Printer with no status should not be idle."""
mock_pm.is_connected.return_value = True
mock_pm.get_status.return_value = None
assert scheduler._is_printer_idle(1) is False
+193
View File
@@ -0,0 +1,193 @@
#!/bin/bash
# Build and push multi-architecture Docker image to GHCR (private beta)
#
# Usage:
# ./docker-publish-beta.sh [version] [--parallel]
#
# Examples:
# ./docker-publish-beta.sh 0.2.0b # Sequential build
# ./docker-publish-beta.sh 0.2.0b --parallel # Build both archs simultaneously
#
# All versions are also tagged as 'beta'
#
# Prerequisites:
# 1. Log in to ghcr.io:
# echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
#
# 2. Create a GitHub Personal Access Token with 'write:packages' scope:
# https://github.com/settings/tokens/new?scopes=write:packages
#
# 3. After first push, set package to Private in GitHub → Packages → Settings
# and add beta testers via Manage Access
#
# Supported architectures:
# - linux/amd64 (x86_64, most servers/desktops)
# - linux/arm64 (Raspberry Pi 4/5, Apple Silicon via emulation)
set -e
# Configuration
GHCR_REGISTRY="ghcr.io"
IMAGE_NAME="maziggy/bambuddy-beta"
GHCR_IMAGE="${GHCR_REGISTRY}/${IMAGE_NAME}"
PLATFORMS="linux/amd64,linux/arm64"
BUILDER_NAME="bambuddy-builder"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Parse arguments
VERSION=""
PARALLEL=false
for arg in "$@"; do
case $arg in
--parallel)
PARALLEL=true
;;
*)
if [ -z "$VERSION" ]; then
VERSION="$arg"
fi
;;
esac
done
if [ -z "$VERSION" ]; then
echo -e "${YELLOW}Usage: $0 <version> [--parallel]${NC}"
echo "Example: $0 0.2.0b"
echo " $0 0.2.0b --parallel # Build both architectures simultaneously"
exit 1
fi
# Get CPU count
CPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
echo -e "${GREEN}================================================${NC}"
echo -e "${GREEN} Building multi-arch BETA image${NC}"
echo -e "${GREEN} Version: ${VERSION}${NC}"
echo -e "${GREEN} Platforms: ${PLATFORMS}${NC}"
echo -e "${GREEN} CPU cores: ${CPU_COUNT}${NC}"
if [ "$PARALLEL" = true ]; then
echo -e "${GREEN} Mode: PARALLEL (both archs simultaneously)${NC}"
else
echo -e "${GREEN} Mode: Sequential (amd64 → arm64)${NC}"
fi
echo -e "${GREEN} Registry: ${GHCR_IMAGE}${NC}"
echo -e "${GREEN}================================================${NC}"
echo ""
# Check registry login
if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then
echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}"
echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin"
echo ""
fi
# Setup buildx builder if not exists
echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
echo "Creating new buildx builder: $BUILDER_NAME (optimized for ${CPU_COUNT} cores)"
docker buildx create \
--name "$BUILDER_NAME" \
--driver docker-container \
--driver-opt network=host \
--driver-opt "env.BUILDKIT_STEP_LOG_MAX_SIZE=10000000" \
--buildkitd-flags "--allow-insecure-entitlement network.host --oci-worker-gc=false" \
--config /dev/stdin <<EOF
[worker.oci]
max-parallelism = ${CPU_COUNT}
EOF
docker buildx inspect --bootstrap "$BUILDER_NAME"
fi
docker buildx use "$BUILDER_NAME"
# Verify builder supports multi-platform
echo -e "${BLUE}[2/4] Verifying multi-platform support...${NC}"
if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
echo -e "${YELLOW}Installing QEMU for cross-platform builds...${NC}"
docker run --privileged --rm tonistiigi/binfmt --install all
fi
# Build tags — version + beta (not latest)
TAGS="-t ${GHCR_IMAGE}:${VERSION} -t ${GHCR_IMAGE}:beta"
echo -e "${BLUE}[3/4] Building and pushing...${NC}"
# Common build args (no cache to ensure clean builds)
BUILD_ARGS="--provenance=false --sbom=false --no-cache --pull"
if [ "$PARALLEL" = true ]; then
# Parallel build: Build each architecture separately then combine manifests
echo -e "${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}"
# Build amd64 in background
(
echo -e "${BLUE}[amd64] Starting build...${NC}"
docker buildx build \
--platform linux/amd64 \
-t "${GHCR_IMAGE}:${VERSION}-amd64" \
${BUILD_ARGS} \
--push \
. 2>&1 | sed 's/^/[amd64] /'
echo -e "${GREEN}[amd64] Complete!${NC}"
) &
PID_AMD64=$!
# Build arm64 in background
(
echo -e "${BLUE}[arm64] Starting build...${NC}"
docker buildx build \
--platform linux/arm64 \
-t "${GHCR_IMAGE}:${VERSION}-arm64" \
${BUILD_ARGS} \
--push \
. 2>&1 | sed 's/^/[arm64] /'
echo -e "${GREEN}[arm64] Complete!${NC}"
) &
PID_ARM64=$!
# Wait for both builds
echo "Waiting for parallel builds to complete..."
wait $PID_AMD64
wait $PID_ARM64
# Create multi-arch manifest
echo -e "${BLUE}Creating multi-arch manifest...${NC}"
docker buildx imagetools create \
-t "${GHCR_IMAGE}:${VERSION}" -t "${GHCR_IMAGE}:beta" \
"${GHCR_IMAGE}:${VERSION}-amd64" \
"${GHCR_IMAGE}:${VERSION}-arm64"
else
# Sequential build (default): Build both platforms in one command
echo -e "${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}"
DOCKER_BUILDKIT=1 docker buildx build \
--platform "$PLATFORMS" \
${BUILD_ARGS} \
$TAGS \
--push \
.
fi
echo -e "${BLUE}[4/4] Verifying manifest...${NC}"
docker buildx imagetools inspect "${GHCR_IMAGE}:${VERSION}"
echo ""
echo -e "${GREEN}================================================${NC}"
echo -e "${GREEN} Successfully pushed multi-arch BETA image:${NC}"
echo -e "${GREEN}================================================${NC}"
echo " ${GHCR_IMAGE}:${VERSION}"
echo " ${GHCR_IMAGE}:beta"
echo ""
echo -e "${BLUE}Supported platforms:${NC}"
echo " - linux/amd64 (Intel/AMD servers, desktops)"
echo " - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)"
echo ""
echo -e "${GREEN}Beta testers can run:${NC}"
echo " docker pull ${GHCR_IMAGE}:${VERSION}"
echo " docker pull ${GHCR_IMAGE}:beta"
echo ""
echo -e "${YELLOW}Reminder: Set package to Private in GitHub → Packages → Settings${NC}"
+2
View File
@@ -14,6 +14,7 @@ import { FileManagerPage } from './pages/FileManagerPage';
import { CameraPage } from './pages/CameraPage';
import { StreamOverlayPage } from './pages/StreamOverlayPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import InventoryPage from './pages/InventoryPage';
import { SystemInfoPage } from './pages/SystemInfoPage';
import { LoginPage } from './pages/LoginPage';
import { SetupPage } from './pages/SetupPage';
@@ -122,6 +123,7 @@ function App() {
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetailPage />} />
<Route path="inventory" element={<InventoryPage />} />
<Route path="files" element={<FileManagerPage />} />
<Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
<Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { AssignSpoolModal } from '../../components/AssignSpoolModal';
import { api } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
getSpools: vi.fn(),
getAssignments: vi.fn(),
assignSpool: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
}));
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
printerId: 1,
amsId: 0,
trayId: 0,
trayInfo: { type: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' },
};
const manualSpool = {
id: 1,
material: 'PLA',
subtype: 'Basic',
brand: 'Polymaker',
color_name: 'Red',
rgba: 'FF0000FF',
label_weight: 1000,
weight_used: 0,
tag_uid: null,
tray_uuid: null,
};
const blSpool = {
id: 2,
material: 'PLA',
subtype: 'Basic',
brand: 'Bambu',
color_name: 'Jade White',
rgba: 'FFFFFFFE',
label_weight: 1000,
weight_used: 50,
tag_uid: '05CC1E0F00000100',
tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
};
const anotherManualSpool = {
id: 3,
material: 'PETG',
subtype: 'HF',
brand: 'Overture',
color_name: 'Black',
rgba: '000000FF',
label_weight: 1000,
weight_used: 200,
tag_uid: null,
tray_uuid: null,
};
describe('AssignSpoolModal', () => {
beforeEach(() => {
vi.clearAllMocks();
(api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, blSpool, anotherManualSpool]);
(api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
});
it('renders nothing when closed', () => {
render(<AssignSpoolModal {...defaultProps} isOpen={false} />);
expect(screen.queryByText('Assign Spool')).not.toBeInTheDocument();
});
it('filters out Bambu Lab spools (with tag_uid/tray_uuid)', async () => {
render(<AssignSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
});
// Manual spools should be visible
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
expect(screen.getByText(/Overture/)).toBeInTheDocument();
// BL spool should NOT be visible
expect(screen.queryByText(/Jade White/)).not.toBeInTheDocument();
});
it('filters out spools already assigned to other slots', async () => {
(api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 }, // spool 3 assigned to different slot
]);
render(<AssignSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
});
// Spool 1 (not assigned) should be visible
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
// Spool 3 (assigned to another slot) should NOT be visible
expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
});
it('keeps spool visible if assigned to the current slot', async () => {
(api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 0 }, // spool 1 assigned to THIS slot
]);
render(<AssignSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
});
// Spool 1 (assigned to current slot) should still be visible for re-assignment
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
});
it('shows noManualSpools message when all spools are BL or assigned', async () => {
(api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([blSpool]);
render(<AssignSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/No manually added spools/i)).toBeInTheDocument();
});
});
});
@@ -1,11 +1,11 @@
/**
* Tests for the LinkSpoolModal component.
*
* Tests the Spoolman link spool modal including:
* - Displaying unlinked spools
* - Selecting a spool to link
* - Link success with toast notification
* - Link error with toast notification
* Tests the inventory link-to-spool modal including:
* - Rendering modal with tag/tray info
* - Displaying untagged spools
* - Linking a spool via click
* - Search filtering
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -16,10 +16,10 @@ import { LinkSpoolModal } from '../../components/LinkSpoolModal';
// Mock the API client
vi.mock('../../api/client', () => ({
api: {
getUnlinkedSpools: vi.fn(),
linkSpool: vi.fn(),
getSpools: vi.fn(),
linkTagToSpool: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ enabled: false, configured: false }),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
}));
@@ -40,37 +40,56 @@ describe('LinkSpoolModal', () => {
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
tagUid: 'ABCD1234',
trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
trayInfo: {
type: 'PLA Basic',
color: 'FF0000',
location: 'AMS A1',
},
printerId: 1,
amsId: 0,
trayId: 0,
};
const mockUnlinkedSpools = [
const mockSpools = [
{
id: 1,
filament_name: 'PLA Red',
filament_material: 'PLA',
filament_color_hex: 'FF0000',
remaining_weight: 800,
location: 'Shelf A',
material: 'PLA',
brand: 'Generic',
subtype: '',
color_name: 'Red',
rgba: 'FF0000FF',
label_weight: 1000,
weight_used: 200,
tag_uid: null,
tray_uuid: null,
},
{
id: 2,
filament_name: 'PETG Blue',
filament_material: 'PETG',
filament_color_hex: '0000FF',
remaining_weight: 500,
location: null,
material: 'PETG',
brand: 'Bambu',
subtype: 'Basic',
color_name: 'Blue',
rgba: '0000FFFF',
label_weight: 1000,
weight_used: 500,
tag_uid: null,
tray_uuid: null,
},
{
id: 3,
material: 'ABS',
brand: 'Brand',
subtype: '',
color_name: 'White',
rgba: 'FFFFFFFF',
label_weight: 1000,
weight_used: 0,
tag_uid: 'EXISTING_TAG',
tray_uuid: 'EXISTING_UUID',
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockUnlinkedSpools);
vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'Linked' });
vi.mocked(api.getSpools).mockResolvedValue(mockSpools);
vi.mocked(api.linkTagToSpool).mockResolvedValue({});
});
describe('rendering', () => {
@@ -78,33 +97,21 @@ describe('LinkSpoolModal', () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
// Look for the title in h2 element
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
});
});
it('displays tray info', async () => {
it('displays printer and tray info', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('PLA Basic')).toBeInTheDocument();
expect(screen.getByText('(AMS A1)')).toBeInTheDocument();
});
});
it('displays tray UUID', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(defaultProps.trayUuid)).toBeInTheDocument();
expect(screen.getByText(/AMS 0 T0/)).toBeInTheDocument();
expect(screen.getByText(/Printer #1/)).toBeInTheDocument();
});
});
it('shows loading state while fetching spools', async () => {
// Delay the response
vi.mocked(api.getUnlinkedSpools).mockImplementation(
() => new Promise(() => {})
);
vi.mocked(api.getSpools).mockImplementation(() => new Promise(() => {}));
render(<LinkSpoolModal {...defaultProps} />);
@@ -113,132 +120,74 @@ describe('LinkSpoolModal', () => {
});
});
it('displays unlinked spools list', async () => {
it('displays untagged spools only', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('PLA Red')).toBeInTheDocument();
expect(screen.getByText('PETG Blue')).toBeInTheDocument();
// Spools 1 and 2 have no tag_uid/tray_uuid — should be shown
expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
});
});
it('shows message when no unlinked spools', async () => {
vi.mocked(api.getUnlinkedSpools).mockResolvedValue([]);
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('No unlinked spools available')).toBeInTheDocument();
});
// Spool 3 has tag_uid — should be filtered out
expect(screen.queryByText(/Brand ABS/)).not.toBeInTheDocument();
});
it('does not render when isOpen is false', () => {
render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
expect(screen.queryByRole('heading', { name: /link to spoolman/i })).not.toBeInTheDocument();
});
});
describe('spool selection', () => {
it('allows selecting a spool', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('PLA Red')).toBeInTheDocument();
});
// Click to select spool
fireEvent.click(screen.getByText('PLA Red'));
// Should show check mark (via visual styling)
const selectedButton = screen.getByText('PLA Red').closest('button');
expect(selectedButton).toHaveClass('border-bambu-green');
});
it('link button is disabled until spool is selected', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('PLA Red')).toBeInTheDocument();
});
const linkButton = screen.getByRole('button', { name: /link to spoolman/i });
expect(linkButton).toBeDisabled();
// Select a spool
fireEvent.click(screen.getByText('PLA Red'));
expect(linkButton).not.toBeDisabled();
expect(screen.queryByRole('heading', { name: /link to spool/i })).not.toBeInTheDocument();
});
});
describe('linking', () => {
it('calls linkSpool API on submit', async () => {
it('calls linkTagToSpool on spool click', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('PLA Red')).toBeInTheDocument();
expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
});
// Select a spool
fireEvent.click(screen.getByText('PLA Red'));
// Click link button
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
await waitFor(() => {
expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid);
expect(api.linkTagToSpool).toHaveBeenCalledWith(1, {
tag_uid: 'ABCD1234',
tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
tag_type: 'bambulab',
data_origin: 'nfc_link',
});
});
});
it('shows success toast on successful link', async () => {
it('shows success toast and calls onClose', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('PLA Red')).toBeInTheDocument();
expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
});
fireEvent.click(screen.getByText('PLA Red'));
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'Spool linked to Spoolman successfully',
'success'
);
});
});
it('calls onClose after successful link', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('PLA Red')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('PLA Red'));
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalled();
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
it('shows error toast on link failure', async () => {
const errorMessage = 'Failed to update spool';
vi.mocked(api.linkSpool).mockRejectedValue(new Error(errorMessage));
it('shows error toast on failure', async () => {
vi.mocked(api.linkTagToSpool).mockRejectedValue(new Error('Link failed'));
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('PLA Red')).toBeInTheDocument();
expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
});
fireEvent.click(screen.getByText('PLA Red'));
fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
`Failed to link spool: ${errorMessage}`,
expect.stringContaining('Link failed'),
'error'
);
});
@@ -246,25 +195,13 @@ describe('LinkSpoolModal', () => {
});
describe('modal actions', () => {
it('calls onClose when cancel button is clicked', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Cancel'));
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when backdrop is clicked', async () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
});
// Click the backdrop (the element with bg-black/60)
const backdrop = document.querySelector('.bg-black\\/60');
if (backdrop) {
fireEvent.click(backdrop);
@@ -276,10 +213,9 @@ describe('LinkSpoolModal', () => {
render(<LinkSpoolModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
});
// Find and click the X button in the header
const closeButtons = screen.getAllByRole('button');
const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
if (xButton) {
@@ -0,0 +1,177 @@
/**
* Tests for the PrinterQueueWidget clear plate behavior.
*
* When the printer is in FINISH or FAILED state and has pending queue items,
* the widget shows a "Clear Plate & Start Next" button instead of the
* passive queue link. After clicking, it shows a confirmation state.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PrinterQueueWidget } from '../../components/PrinterQueueWidget';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockQueueItems = [
{
id: 1,
printer_id: 1,
archive_id: 1,
position: 1,
status: 'pending',
archive_name: 'First Print',
printer_name: 'X1 Carbon',
print_time_seconds: 3600,
scheduled_time: null,
},
{
id: 2,
printer_id: 1,
archive_id: 2,
position: 2,
status: 'pending',
archive_name: 'Second Print',
printer_name: 'X1 Carbon',
print_time_seconds: 7200,
scheduled_time: null,
},
];
describe('PrinterQueueWidget - Clear Plate', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/queue/', ({ request }) => {
const url = new URL(request.url);
const printerId = url.searchParams.get('printer_id');
if (printerId === '1') {
return HttpResponse.json(mockQueueItems);
}
return HttpResponse.json([]);
}),
http.post('/api/v1/printers/:id/clear-plate', () => {
return HttpResponse.json({ success: true, message: 'Plate cleared' });
})
);
});
describe('clear plate button visibility', () => {
it('shows clear plate button when printer state is FINISH', async () => {
render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
});
it('shows clear plate button when printer state is FAILED', async () => {
render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
});
it('shows passive link when printer state is IDLE', async () => {
render(<PrinterQueueWidget printerId={1} printerState="IDLE" />);
await waitFor(() => {
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/queue');
});
expect(screen.queryByText('Clear Plate & Start Next')).not.toBeInTheDocument();
});
it('shows passive link when printer state is RUNNING', async () => {
render(<PrinterQueueWidget printerId={1} printerState="RUNNING" />);
await waitFor(() => {
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/queue');
});
});
it('shows passive link when printerState is not provided', async () => {
render(<PrinterQueueWidget printerId={1} />);
await waitFor(() => {
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/queue');
});
});
});
describe('clear plate button shows queue info', () => {
it('shows next item name in clear plate mode', async () => {
render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
await waitFor(() => {
expect(screen.getByText('First Print')).toBeInTheDocument();
});
});
it('shows additional items badge in clear plate mode', async () => {
render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
await waitFor(() => {
expect(screen.getByText('+1')).toBeInTheDocument();
});
});
});
describe('clear plate action', () => {
it('shows confirmation state after clicking clear plate', async () => {
const user = userEvent.setup();
render(<PrinterQueueWidget printerId={1} printerState="FINISH" />);
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
await user.click(screen.getByText('Clear Plate & Start Next'));
await waitFor(() => {
// Both the widget confirmation and the toast show this text
const elements = screen.getAllByText('Plate cleared — ready for next print');
expect(elements.length).toBeGreaterThanOrEqual(1);
});
});
it('shows error toast on API failure', async () => {
server.use(
http.post('/api/v1/printers/:id/clear-plate', () => {
return HttpResponse.json(
{ detail: 'Printer not connected' },
{ status: 400 }
);
})
);
const user = userEvent.setup();
render(<PrinterQueueWidget printerId={1} printerState="FAILED" />);
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
await user.click(screen.getByText('Clear Plate & Start Next'));
// Button should remain visible (not transition to success state)
await waitFor(() => {
expect(screen.getByText('Clear Plate & Start Next')).toBeInTheDocument();
});
});
});
describe('empty queue', () => {
it('renders nothing in FINISH state with no queue items', async () => {
const { container } = render(<PrinterQueueWidget printerId={999} printerState="FINISH" />);
await waitFor(() => {
expect(container.querySelector('button')).not.toBeInTheDocument();
});
});
});
});
@@ -1,15 +1,16 @@
/**
* Tests for the SpoolmanSettings component.
*
* Tests the Spoolman integration UI including:
* - Enable/disable toggle
* - URL configuration
* - Connection status
* - Sync functionality
* Tests the filament tracking mode selector and Spoolman integration UI:
* - Mode selector (Built-in Inventory vs Spoolman)
* - Built-in Inventory info panel
* - Spoolman URL, sync mode, connection status
* - Weight sync and partial usage toggles
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { SpoolmanSettings } from '../../components/SpoolmanSettings';
@@ -26,6 +27,7 @@ vi.mock('../../api/client', () => ({
syncAllPrintersAms: vi.fn(),
syncPrinterAms: vi.fn(),
getPrinters: vi.fn(),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
},
}));
@@ -36,7 +38,7 @@ describe('SpoolmanSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default API mocks
// Default API mocks — Spoolman disabled (Built-in Inventory mode)
vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
spoolman_enabled: 'false',
spoolman_url: '',
@@ -70,90 +72,61 @@ describe('SpoolmanSettings', () => {
describe('rendering', () => {
it('renders loading state initially', () => {
// Delay the API response to catch loading state
vi.mocked(api.getSpoolmanSettings).mockImplementation(() => new Promise(() => {}));
render(<SpoolmanSettings />);
// Should show loading spinner
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('renders component title', async () => {
it('renders filament tracking title', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
});
});
it('renders enable toggle', async () => {
it('renders mode selector cards', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('Enable Spoolman')).toBeInTheDocument();
});
});
it('renders URL input', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('Spoolman URL')).toBeInTheDocument();
expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
});
});
it('renders sync mode selector', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('Sync Mode')).toBeInTheDocument();
});
});
it('renders info banner about sync', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('How Sync Works')).toBeInTheDocument();
expect(screen.getByText(/Only official Bambu Lab spools/)).toBeInTheDocument();
expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();
expect(screen.getByText('Spoolman')).toBeInTheDocument();
});
});
});
describe('disabled state', () => {
it('URL input is disabled when Spoolman is disabled', async () => {
describe('built-in inventory mode (default)', () => {
it('shows built-in inventory as selected by default', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
expect(urlInput).toBeDisabled();
// Built-in Inventory card should have the active border
const builtInBtn = screen.getByText('Built-in Inventory').closest('button');
expect(builtInBtn).toHaveClass('border-bambu-green');
});
});
it('sync mode selector is disabled when Spoolman is disabled', async () => {
it('shows built-in info panel when selected', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
// Find the select by its display value
const selectElement = screen.getByDisplayValue('Automatic');
expect(selectElement).toBeDisabled();
expect(screen.getByText(/Automatically detects Bambu Lab RFID spools/)).toBeInTheDocument();
});
});
it('does not show connection status when disabled', async () => {
it('does not show Spoolman URL input', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
});
// Status section should not be visible when disabled
expect(screen.queryByText('Status:')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('http://192.168.1.100:7912')).not.toBeInTheDocument();
});
});
describe('enabled state', () => {
describe('spoolman mode', () => {
beforeEach(() => {
vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
spoolman_enabled: 'true',
@@ -171,16 +144,40 @@ describe('SpoolmanSettings', () => {
});
});
it('URL input is enabled when Spoolman is enabled', async () => {
it('shows Spoolman card as selected', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
expect(urlInput).not.toBeDisabled();
const spoolmanBtn = screen.getByText('Spoolman').closest('button');
expect(spoolmanBtn).toHaveClass('border-bambu-green');
});
});
it('shows connection status section when enabled', async () => {
it('shows URL input when Spoolman is selected', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
});
});
it('shows sync mode selector', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('Sync Mode')).toBeInTheDocument();
});
});
it('shows how sync works info', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('How Sync Works')).toBeInTheDocument();
});
});
it('shows connection status section', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
@@ -202,20 +199,6 @@ describe('SpoolmanSettings', () => {
});
});
it('shows Connect button when disconnected', async () => {
vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
enabled: true,
connected: false,
url: 'http://localhost:7912',
});
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('Connect')).toBeInTheDocument();
});
});
it('shows Connected and Disconnect button when connected', async () => {
vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
enabled: true,
@@ -242,27 +225,12 @@ describe('SpoolmanSettings', () => {
await waitFor(() => {
expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();
expect(screen.getByText('Sync')).toBeInTheDocument();
});
});
it('shows All Printers option in sync dropdown', async () => {
vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
enabled: true,
connected: true,
url: 'http://localhost:7912',
});
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByRole('option', { name: 'All Printers' })).toBeInTheDocument();
});
});
});
describe('weight sync toggle', () => {
it('shows weight sync toggle when sync mode is auto and enabled', async () => {
it('shows weight sync toggle when Spoolman enabled and sync mode is auto', async () => {
vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
spoolman_enabled: 'true',
spoolman_url: 'http://localhost:7912',
@@ -290,20 +258,11 @@ describe('SpoolmanSettings', () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
});
expect(screen.queryByText('Disable AMS Estimated Weight Sync')).not.toBeInTheDocument();
});
it('shows weight sync toggle in disabled state when sync mode is auto', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
// Toggle label is visible since sync mode defaults to auto
expect(screen.getByText('Disable AMS Estimated Weight Sync')).toBeInTheDocument();
});
});
});
describe('partial usage toggle', () => {
@@ -335,49 +294,28 @@ describe('SpoolmanSettings', () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
});
expect(screen.queryByText('Report Partial Usage for Failed Prints')).not.toBeInTheDocument();
});
});
describe('sync mode options', () => {
it('shows Automatic option', async () => {
describe('mode switching', () => {
it('can switch to Spoolman mode', async () => {
const user = userEvent.setup();
render(<SpoolmanSettings />);
await waitFor(() => {
expect(screen.getByRole('option', { name: 'Automatic' })).toBeInTheDocument();
expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();
});
});
it('shows Manual Only option', async () => {
render(<SpoolmanSettings />);
// Click Spoolman card
await user.click(screen.getByText('Spoolman').closest('button')!);
// Spoolman settings should now be visible
await waitFor(() => {
expect(screen.getByRole('option', { name: 'Manual Only' })).toBeInTheDocument();
});
});
});
describe('info text', () => {
it('shows URL help text', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(
screen.getByText('URL of your Spoolman server (e.g., http://localhost:7912)')
).toBeInTheDocument();
});
});
it('shows sync mode description for auto mode', async () => {
render(<SpoolmanSettings />);
await waitFor(() => {
expect(
screen.getByText('AMS data syncs automatically when changes are detected')
).toBeInTheDocument();
expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
});
});
});
@@ -86,7 +86,7 @@ describe('SettingsPage', () => {
expect(screen.getAllByText('General').length).toBeGreaterThan(0);
expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
expect(screen.getByText('Notifications')).toBeInTheDocument();
expect(screen.getByText('Filament')).toBeInTheDocument();
expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
expect(screen.getByText('Network')).toBeInTheDocument();
expect(screen.getByText('API Keys')).toBeInTheDocument();
});
@@ -207,10 +207,10 @@ describe('SettingsPage', () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByText('Filament')).toBeInTheDocument();
expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
});
await user.click(screen.getByText('Filament'));
await user.click(screen.getAllByText('Filament')[0]);
await waitFor(() => {
expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
@@ -46,7 +46,7 @@ const mockArchives = [
];
const mockSettings = {
currency: '$',
currency: 'USD',
check_updates: false,
check_printer_firmware: false,
};
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../../utils/currency';
describe('getCurrencySymbol', () => {
it('returns $ for USD', () => {
expect(getCurrencySymbol('USD')).toBe('$');
});
it('returns € for EUR', () => {
expect(getCurrencySymbol('EUR')).toBe('€');
});
it('returns £ for GBP', () => {
expect(getCurrencySymbol('GBP')).toBe('£');
});
it('returns ₹ for INR', () => {
expect(getCurrencySymbol('INR')).toBe('₹');
});
it('returns HK$ for HKD', () => {
expect(getCurrencySymbol('HKD')).toBe('HK$');
});
it('returns the code itself for unknown currencies', () => {
expect(getCurrencySymbol('XYZ')).toBe('XYZ');
});
it('is case-insensitive', () => {
expect(getCurrencySymbol('usd')).toBe('$');
expect(getCurrencySymbol('eur')).toBe('€');
});
});
describe('SUPPORTED_CURRENCIES', () => {
it('contains INR', () => {
expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'INR')).toBeDefined();
});
it('has 25 entries', () => {
expect(SUPPORTED_CURRENCIES).toHaveLength(25);
});
});
+209 -15
View File
@@ -18,6 +18,18 @@ export function getAuthToken(): string | null {
return authToken;
}
function parseContentDispositionFilename(header: string | null): string | null {
if (!header) return null;
// RFC 5987: filename*=utf-8''percent-encoded-name
const rfc5987Match = header.match(/filename\*=(?:UTF-8|utf-8)''(.+?)(?:;|$)/);
if (rfc5987Match) {
try { return decodeURIComponent(rfc5987Match[1]); } catch { /* fall through */ }
}
// Standard: filename="name" or filename=name
const standardMatch = header.match(/filename="?([^";\n]+)"?/);
return standardMatch?.[1] || null;
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
@@ -812,6 +824,29 @@ export interface SlicerSetting {
version: string | null;
user_id: string | null;
updated_time: string | null;
is_custom: boolean;
}
export interface SpoolCatalogEntry {
id: number;
name: string;
weight: number;
is_default: boolean;
}
export interface ColorCatalogEntry {
id: number;
manufacturer: string;
color_name: string;
hex_color: string;
material: string | null;
is_default: boolean;
}
export interface ColorLookupResult {
found: boolean;
hex_color: string | null;
material: string | null;
}
export interface SlicerSettingsResponse {
@@ -853,6 +888,12 @@ export interface SlicerSettingDeleteResponse {
message: string;
}
// Built-in filament fallback (static table from backend)
export interface BuiltinFilament {
filament_id: string;
name: string;
}
// Local preset types (OrcaSlicer imports)
export interface LocalPreset {
id: number;
@@ -1686,6 +1727,85 @@ export interface LinkedSpoolsMap {
linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info
}
// Inventory types
export interface InventorySpool {
id: number;
material: string;
subtype: string | null;
color_name: string | null;
rgba: string | null;
brand: string | null;
label_weight: number;
core_weight: number;
weight_used: number;
slicer_filament: string | null;
slicer_filament_name: string | null;
nozzle_temp_min: number | null;
nozzle_temp_max: number | null;
note: string | null;
added_full: boolean | null;
last_used: string | null;
encode_time: string | null;
tag_uid: string | null;
tray_uuid: string | null;
data_origin: string | null;
tag_type: string | null;
archived_at: string | null;
created_at: string;
updated_at: string;
k_profiles?: SpoolKProfile[];
}
export interface SpoolUsageRecord {
id: number;
spool_id: number;
printer_id: number | null;
print_name: string | null;
weight_used: number;
percent_used: number;
status: string;
created_at: string;
}
export interface SpoolKProfile {
id: number;
spool_id: number;
printer_id: number;
extruder: number;
nozzle_diameter: string;
nozzle_type: string | null;
k_value: number;
name: string | null;
cali_idx: number | null;
setting_id: string | null;
created_at: string;
}
export interface SpoolKProfileInput {
printer_id: number;
extruder?: number;
nozzle_diameter?: string;
nozzle_type?: string | null;
k_value: number;
name?: string | null;
cali_idx?: number | null;
setting_id?: string | null;
}
export interface SpoolAssignment {
id: number;
spool_id: number;
printer_id: number;
printer_name: string | null;
ams_id: number;
tray_id: number;
fingerprint_color: string | null;
fingerprint_type: string | null;
spool?: InventorySpool | null;
configured: boolean;
created_at: string;
}
// Update types
export interface VersionInfo {
version: string;
@@ -1792,6 +1912,7 @@ export interface ExternalLink {
name: string;
url: string;
icon: string;
open_in_new_tab: boolean;
custom_icon: string | null;
sort_order: number;
created_at: string;
@@ -1802,12 +1923,14 @@ export interface ExternalLinkCreate {
name: string;
url: string;
icon: string;
open_in_new_tab?: boolean;
}
export interface ExternalLinkUpdate {
name?: string;
url?: string;
icon?: string;
open_in_new_tab?: boolean;
}
// Permission type - all available permissions
@@ -2019,7 +2142,7 @@ export const api = {
request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
method: 'POST',
}),
// Advanced Authentication
testSMTP: (data: TestSMTPRequest) =>
request<TestSMTPResponse>('/auth/smtp/test', {
@@ -2155,6 +2278,10 @@ export const api = {
request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
method: 'POST',
}),
clearPlate: (printerId: number) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/clear-plate`, {
method: 'POST',
}),
// Get current print user (for reprint tracking - Issue #206)
getCurrentPrintUser: (printerId: number) =>
@@ -2263,8 +2390,7 @@ export const api = {
throw new Error(error.detail || `HTTP ${response.status}`);
}
const disposition = response.headers.get('Content-Disposition');
const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
const filename = filenameMatch?.[1] || path.split('/').pop() || 'download';
const filename = parseContentDispositionFilename(disposition) || path.split('/').pop() || 'download';
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -2464,8 +2590,7 @@ export const api = {
throw new Error(error.detail || `HTTP ${response.status}`);
}
const disposition = response.headers.get('Content-Disposition');
const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
const downloadFilename = filenameMatch?.[1] || filename || `archive_${id}.3mf`;
const downloadFilename = parseContentDispositionFilename(disposition) || filename || `archive_${id}.3mf`;
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -2605,8 +2730,7 @@ export const api = {
throw new Error(error.detail || `HTTP ${response.status}`);
}
const disposition = response.headers.get('Content-Disposition');
const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
const filename = filenameMatch?.[1] || `source_${archiveId}.3mf`;
const filename = parseContentDispositionFilename(disposition) || `source_${archiveId}.3mf`;
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -2655,8 +2779,7 @@ export const api = {
throw new Error(error.detail || `HTTP ${response.status}`);
}
const disposition = response.headers.get('Content-Disposition');
const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
const filename = filenameMatch?.[1] || `archive_${archiveId}.f3d`;
const filename = parseContentDispositionFilename(disposition) || `archive_${archiveId}.f3d`;
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -2903,6 +3026,8 @@ export const api = {
request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
getCloudSettings: (version = '02.04.00.70') =>
request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
getBuiltinFilaments: () =>
request<BuiltinFilament[]>('/cloud/builtin-filaments'),
getCloudSettingDetail: (settingId: string) =>
request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
createCloudSetting: (data: SlicerSettingCreate) =>
@@ -3242,6 +3367,78 @@ export const api = {
body: JSON.stringify(data),
}),
// Inventory
getSpools: (includeArchived = false) =>
request<InventorySpool[]>(`/inventory/spools?include_archived=${includeArchived}`),
getSpool: (id: number) => request<InventorySpool>(`/inventory/spools/${id}`),
createSpool: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>) =>
request<InventorySpool>('/inventory/spools', {
method: 'POST',
body: JSON.stringify(data),
}),
updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
request<InventorySpool>(`/inventory/spools/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteSpool: (id: number) =>
request<{ status: string }>(`/inventory/spools/${id}`, { method: 'DELETE' }),
archiveSpool: (id: number) =>
request<InventorySpool>(`/inventory/spools/${id}/archive`, { method: 'POST' }),
restoreSpool: (id: number) =>
request<InventorySpool>(`/inventory/spools/${id}/restore`, { method: 'POST' }),
getSpoolKProfiles: (spoolId: number) =>
request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`, {
method: 'PUT',
body: JSON.stringify(profiles),
}),
getAssignments: (printerId?: number) =>
request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
request<SpoolAssignment>('/inventory/assignments', {
method: 'POST',
body: JSON.stringify(data),
}),
unassignSpool: (printerId: number, amsId: number, trayId: number) =>
request<{ status: string }>(`/inventory/assignments/${printerId}/${amsId}/${trayId}`, { method: 'DELETE' }),
getSpoolCatalog: () =>
request<SpoolCatalogEntry[]>('/inventory/catalog'),
addCatalogEntry: (data: { name: string; weight: number }) =>
request<SpoolCatalogEntry>('/inventory/catalog', { method: 'POST', body: JSON.stringify(data) }),
updateCatalogEntry: (id: number, data: { name: string; weight: number }) =>
request<SpoolCatalogEntry>(`/inventory/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteCatalogEntry: (id: number) =>
request<{ status: string }>(`/inventory/catalog/${id}`, { method: 'DELETE' }),
resetSpoolCatalog: () =>
request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
getColorCatalog: () =>
request<ColorCatalogEntry[]>('/inventory/colors'),
addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),
updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteColorEntry: (id: number) =>
request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
resetColorCatalog: () =>
request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }),
lookupColor: (manufacturer: string, colorName: string, material?: string) =>
request<ColorLookupResult>(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(manufacturer)}&color_name=${encodeURIComponent(colorName)}${material ? `&material=${encodeURIComponent(material)}` : ''}`),
linkTagToSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string; tag_type?: string; data_origin?: string }) =>
request<InventorySpool>(`/inventory/spools/${spoolId}/link-tag`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
getSpoolUsageHistory: (spoolId: number, limit = 50) =>
request<SpoolUsageRecord[]>(`/inventory/spools/${spoolId}/usage?limit=${limit}`),
getAllUsageHistory: (limit = 100, printerId?: number) =>
request<SpoolUsageRecord[]>(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`),
clearSpoolUsageHistory: (spoolId: number) =>
request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
getFilamentPresets: () =>
request<SlicerSetting[]>('/cloud/filaments'),
// Updates
getVersion: () => request<VersionInfo>('/updates/version'),
checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
@@ -3539,8 +3736,7 @@ export const api = {
throw new Error(error.detail || `HTTP ${response.status}`);
}
const contentDisposition = response.headers.get('Content-Disposition');
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
const filename = filenameMatch?.[1] || `project_${projectId}.zip`;
const filename = parseContentDispositionFilename(contentDisposition) || `project_${projectId}.zip`;
const blob = await response.blob();
return { blob, filename };
},
@@ -3668,8 +3864,7 @@ export const api = {
throw new Error(error.detail || `HTTP ${response.status}`);
}
const disposition = response.headers.get('Content-Disposition');
const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
const downloadFilename = filenameMatch?.[1] || filename || `file_${id}`;
const downloadFilename = parseContentDispositionFilename(disposition) || filename || `file_${id}`;
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -4348,8 +4543,7 @@ export const supportApi = {
}
// Get filename from Content-Disposition header or use default
const disposition = response.headers.get('Content-Disposition');
const filenameMatch = disposition?.match(/filename=(.+)/);
const filename = filenameMatch ? filenameMatch[1] : 'bambuddy-support.zip';
const filename = parseContentDispositionFilename(disposition) || 'bambuddy-support.zip';
// Download the blob
const blob = await response.blob();
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
import { Button } from './Button';
@@ -11,6 +12,7 @@ interface AddExternalLinkModalProps {
}
export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const isEditing = !!link;
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -18,6 +20,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
const [name, setName] = useState(link?.name || '');
const [url, setUrl] = useState(link?.url || '');
const [icon, setIcon] = useState(link?.icon || 'link');
const [openInNewTab, setOpenInNewTab] = useState(link?.open_in_new_tab || false);
const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);
const [customIconPreview, setCustomIconPreview] = useState<string | null>(
link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null
@@ -137,6 +140,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
name: name.trim(),
url: url.trim(),
icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback
open_in_new_tab: openInNewTab,
};
if (isEditing) {
@@ -213,6 +217,24 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
/>
</div>
{/* Open in New Tab */}
<div className="flex items-center justify-between">
<label className="text-sm text-bambu-gray">{t('externalLinks.openInNewTab')}</label>
<button
type="button"
onClick={() => setOpenInNewTab(!openInNewTab)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
openInNewTab ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
openInNewTab ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Icon Section */}
<div className="space-y-3">
<label className="block text-sm text-bambu-gray">Icon</label>
@@ -0,0 +1,231 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Package, Check, Search } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool, SpoolAssignment } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface AssignSpoolModalProps {
isOpen: boolean;
onClose: () => void;
printerId: number;
amsId: number;
trayId: number;
trayInfo?: {
type: string;
color: string;
location: string;
};
}
export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo }: AssignSpoolModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
const [searchFilter, setSearchFilter] = useState('');
const { data: spools, isLoading } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(),
enabled: isOpen,
});
const { data: assignments } = useQuery({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
enabled: isOpen,
});
const assignMutation = useMutation({
mutationFn: (spoolId: number) =>
api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
onSuccess: (newAssignment) => {
// Immediately update cache so UI reflects the new assignment without waiting for refetch
queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {
const filtered = (old || []).filter(a =>
!(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)
);
filtered.push(newAssignment);
return filtered;
});
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
showToast(t('inventory.assignSuccess'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
},
});
if (!isOpen) return null;
// Filter out Bambu Lab spools (identified by RFID tag_uid or tray_uuid)
// and spools already assigned to other slots
const assignedSpoolIds = new Set(
(assignments || [])
.filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
.map(a => a.spool_id)
);
const manualSpools = spools?.filter((spool: InventorySpool) =>
!spool.tag_uid && !spool.tray_uuid && !assignedSpoolIds.has(spool.id)
);
const filteredSpools = manualSpools?.filter((spool: InventorySpool) => {
if (!searchFilter) return true;
const q = searchFilter.toLowerCase();
return (
spool.material.toLowerCase().includes(q) ||
(spool.brand?.toLowerCase().includes(q) ?? false) ||
(spool.color_name?.toLowerCase().includes(q) ?? false) ||
(spool.subtype?.toLowerCase().includes(q) ?? false)
);
});
const handleAssign = () => {
if (selectedSpoolId) {
assignMutation.mutate(selectedSpoolId);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Package className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('inventory.assignSpool')}</h2>
</div>
<button
onClick={onClose}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Tray info */}
{trayInfo && (
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">{t('inventory.selectSpool')}:</p>
<div className="flex items-center gap-2">
{trayInfo.color && (
<span
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: `#${trayInfo.color}` }}
/>
)}
<span className="text-white font-medium">{trayInfo.type || t('ams.emptySlot')}</span>
<span className="text-bambu-gray">({trayInfo.location})</span>
</div>
</div>
)}
{/* Search filter */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
placeholder={t('inventory.searchSpools')}
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
/>
</div>
{/* Spool list */}
<div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : filteredSpools && filteredSpools.length > 0 ? (
<div className="max-h-64 overflow-y-auto space-y-2">
{filteredSpools.map((spool: InventorySpool) => (
<button
key={spool.id}
onClick={() => setSelectedSpoolId(spool.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
selectedSpoolId === spool.id
? 'bg-bambu-green/20 border-bambu-green'
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="flex items-center gap-2">
{spool.rgba && (
<span
className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
style={{ backgroundColor: `#${spool.rgba.substring(0, 6)}` }}
/>
)}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">
{spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
</p>
<p className="text-xs text-bambu-gray">
{spool.color_name || ''}
{spool.label_weight ? ` - ${spool.label_weight}g` : ''}
{spool.weight_used > 0 ? ` (${Math.round(spool.weight_used)}g used)` : ''}
</p>
</div>
{selectedSpoolId === spool.id && (
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</div>
</button>
))}
</div>
) : manualSpools && manualSpools.length === 0 ? (
<div className="text-center py-8 text-bambu-gray">
<p>{t('inventory.noManualSpools')}</p>
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<p>{t('inventory.noSpoolsMatch')}</p>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
onClick={handleAssign}
disabled={!selectedSpoolId || assignMutation.isPending}
>
{assignMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('inventory.assigning')}
</>
) : (
<>
<Package className="w-4 h-4" />
{t('inventory.assignSpool')}
</>
)}
</Button>
</div>
{assignMutation.isError && (
<div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
{(assignMutation.error as Error).message}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,583 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Palette, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload, Cloud } from 'lucide-react';
import { api, getAuthToken } from '../api/client';
import type { ColorCatalogEntry } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
import { ConfirmModal } from './ConfirmModal';
export function ColorCatalogSettings() {
const { t } = useTranslation();
const { showToast } = useToast();
const [catalog, setCatalog] = useState<ColorCatalogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filterManufacturer, setFilterManufacturer] = useState<string>('Bambu Lab');
const fileInputRef = useRef<HTMLInputElement>(null);
// Add/Edit form state
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formManufacturer, setFormManufacturer] = useState('');
const [formColorName, setFormColorName] = useState('');
const [formHexColor, setFormHexColor] = useState('#FFFFFF');
const [formMaterial, setFormMaterial] = useState('');
const [saving, setSaving] = useState(false);
// Sync state
const [syncing, setSyncing] = useState(false);
const [syncProgress, setSyncProgress] = useState<{ fetched: number; total: number } | null>(null);
// Confirmation modals
const [deleteEntry, setDeleteEntry] = useState<ColorCatalogEntry | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const loadCatalog = useCallback(async () => {
try {
const entries = await api.getColorCatalog();
setCatalog(entries);
} catch {
showToast(t('settings.colorCatalog.loadFailed'), 'error');
} finally {
setLoading(false);
}
}, [showToast, t]);
useEffect(() => {
loadCatalog();
}, [loadCatalog]);
const manufacturers = [...new Set(catalog.map(e => e.manufacturer))].sort();
const filteredCatalog = catalog.filter(entry => {
const matchesSearch = search === '' ||
entry.manufacturer.toLowerCase().includes(search.toLowerCase()) ||
entry.color_name.toLowerCase().includes(search.toLowerCase()) ||
(entry.material?.toLowerCase().includes(search.toLowerCase()) ?? false);
const matchesManufacturer = filterManufacturer === '' || entry.manufacturer === filterManufacturer;
return matchesSearch && matchesManufacturer;
});
const resetForm = () => {
setFormManufacturer('');
setFormColorName('');
setFormHexColor('#FFFFFF');
setFormMaterial('');
};
const handleAdd = async () => {
if (!formManufacturer.trim() || !formColorName.trim() || !formHexColor) {
showToast(t('settings.colorCatalog.fieldsRequired'), 'error');
return;
}
setSaving(true);
try {
const entry = await api.addColorEntry({
manufacturer: formManufacturer.trim(),
color_name: formColorName.trim(),
hex_color: formHexColor,
material: formMaterial.trim() || null,
});
setCatalog(prev => [...prev, entry].sort((a, b) =>
a.manufacturer.localeCompare(b.manufacturer) ||
(a.material || '').localeCompare(b.material || '') ||
a.color_name.localeCompare(b.color_name)
));
setShowAddForm(false);
resetForm();
showToast(t('settings.colorCatalog.colorAdded'), 'success');
} catch {
showToast(t('settings.colorCatalog.addFailed'), 'error');
} finally {
setSaving(false);
}
};
const startEdit = (entry: ColorCatalogEntry) => {
setEditingId(entry.id);
setFormManufacturer(entry.manufacturer);
setFormColorName(entry.color_name);
setFormHexColor(entry.hex_color);
setFormMaterial(entry.material || '');
};
const cancelEdit = () => {
setEditingId(null);
resetForm();
};
const handleUpdate = async (id: number) => {
if (!formManufacturer.trim() || !formColorName.trim() || !formHexColor) {
showToast(t('settings.colorCatalog.fieldsRequired'), 'error');
return;
}
setSaving(true);
try {
const updated = await api.updateColorEntry(id, {
manufacturer: formManufacturer.trim(),
color_name: formColorName.trim(),
hex_color: formHexColor,
material: formMaterial.trim() || null,
});
setCatalog(prev =>
prev.map(e => e.id === id ? updated : e).sort((a, b) =>
a.manufacturer.localeCompare(b.manufacturer) ||
(a.material || '').localeCompare(b.material || '') ||
a.color_name.localeCompare(b.color_name)
)
);
setEditingId(null);
resetForm();
showToast(t('settings.colorCatalog.colorUpdated'), 'success');
} catch {
showToast(t('settings.colorCatalog.updateFailed'), 'error');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteEntry) return;
try {
await api.deleteColorEntry(deleteEntry.id);
setCatalog(prev => prev.filter(e => e.id !== deleteEntry.id));
showToast(t('settings.colorCatalog.colorDeleted'), 'success');
} catch {
showToast(t('settings.colorCatalog.deleteFailed'), 'error');
} finally {
setDeleteEntry(null);
}
};
const handleReset = async () => {
setShowResetConfirm(false);
setLoading(true);
try {
await api.resetColorCatalog();
await loadCatalog();
showToast(t('settings.colorCatalog.resetSuccess'), 'success');
} catch {
showToast(t('settings.colorCatalog.resetFailed'), 'error');
setLoading(false);
}
};
const handleSync = async () => {
setSyncing(true);
setSyncProgress(null);
try {
const headers: Record<string, string> = {};
const token = getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/v1/inventory/colors/sync', { method: 'POST', headers });
if (!response.ok) throw new Error('Failed to start sync');
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'progress') {
setSyncProgress({ fetched: data.total_fetched, total: data.total_available });
} else if (data.type === 'complete') {
if (data.added === 0) {
showToast(t('settings.colorCatalog.syncUpToDate', { count: data.total_fetched }), 'success');
} else {
showToast(t('settings.colorCatalog.syncComplete', { added: data.added, skipped: data.skipped }), 'success');
}
} else if (data.type === 'error') {
showToast(`${t('settings.colorCatalog.syncError')}: ${data.error}`, 'error');
}
} catch {
// Ignore parse errors
}
}
}
}
await loadCatalog();
} catch {
showToast(t('settings.colorCatalog.syncFailed'), 'error');
} finally {
setSyncing(false);
setSyncProgress(null);
}
};
const handleExport = () => {
const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({
manufacturer, color_name, hex_color, material,
}));
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'color-catalog.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(t('settings.colorCatalog.exported', { count: catalog.length }), 'success');
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text) as Array<{
manufacturer: string; color_name: string; hex_color: string; material?: string | null;
}>;
if (!Array.isArray(data)) throw new Error('Invalid format');
let added = 0;
let skipped = 0;
for (const item of data) {
if (!item.manufacturer || !item.color_name || !item.hex_color) { skipped++; continue; }
const exists = catalog.some(c =>
c.manufacturer.toLowerCase() === item.manufacturer.toLowerCase() &&
c.color_name.toLowerCase() === item.color_name.toLowerCase() &&
(c.material || '').toLowerCase() === (item.material || '').toLowerCase()
);
if (exists) { skipped++; continue; }
try {
const entry = await api.addColorEntry({
manufacturer: item.manufacturer,
color_name: item.color_name,
hex_color: item.hex_color,
material: item.material || null,
});
setCatalog(prev => [...prev, entry].sort((a, b) =>
a.manufacturer.localeCompare(b.manufacturer) ||
(a.material || '').localeCompare(b.material || '') ||
a.color_name.localeCompare(b.color_name)
));
added++;
} catch { skipped++; }
}
showToast(t('settings.colorCatalog.imported', { added, skipped }), 'success');
} catch {
showToast(t('settings.colorCatalog.importFailed'), 'error');
}
if (fileInputRef.current) fileInputRef.current.value = '';
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2 mb-3">
<Palette className="w-5 h-5 text-bambu-gray" />
<h2 className="text-lg font-semibold text-white">{t('settings.colorCatalog.title')}</h2>
<span className="text-sm text-bambu-gray">({catalog.length})</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleExport}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.export')}</span>
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.import')}</span>
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<button
onClick={handleSync}
disabled={syncing}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.colorCatalog.syncTooltip')}
>
{syncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Cloud className="w-4 h-4" />}
<span className="hidden sm:inline">
{syncing
? syncProgress
? `${Math.min(syncProgress.fetched, syncProgress.total)} / ${syncProgress.total}`
: t('settings.colorCatalog.starting')
: t('settings.colorCatalog.sync')}
</span>
</button>
<button
onClick={() => setShowResetConfirm(true)}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
>
<RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.reset')}</span>
</button>
<button
onClick={() => setShowAddForm(true)}
className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('settings.colorCatalog.description')}
</p>
{/* Search and filter */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.colorCatalog.searchColors')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={filterManufacturer}
onChange={(e) => setFilterManufacturer(e.target.value)}
>
<option value="">{t('settings.colorCatalog.allManufacturers')}</option>
{manufacturers.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
{/* Add form */}
{showAddForm && (
<div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<h3 className="text-sm font-medium text-white mb-3">{t('settings.colorCatalog.addNewColor')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-2 items-end">
<input
type="text"
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.colorCatalog.manufacturer')}
value={formManufacturer}
onChange={(e) => setFormManufacturer(e.target.value)}
/>
<input
type="text"
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.colorCatalog.colorName')}
value={formColorName}
onChange={(e) => setFormColorName(e.target.value)}
/>
<div className="flex items-center gap-2">
<input
type="color"
className="w-10 h-10 rounded cursor-pointer border border-bambu-dark-tertiary"
value={formHexColor}
onChange={(e) => setFormHexColor(e.target.value)}
/>
<input
type="text"
className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder="#FFFFFF"
value={formHexColor}
onChange={(e) => setFormHexColor(e.target.value)}
/>
</div>
<input
type="text"
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.colorCatalog.materialOptional')}
value={formMaterial}
onChange={(e) => setFormMaterial(e.target.value)}
/>
<div className="flex gap-2">
<button
onClick={handleAdd}
disabled={saving}
className="flex-1 px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center justify-center gap-1"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{t('common.add')}
</button>
<button
onClick={() => { setShowAddForm(false); resetForm(); }}
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
{/* Filter info */}
{(search || filterManufacturer) && (
<div className="text-xs text-bambu-gray">
{t('settings.colorCatalog.showing', { filtered: filteredCatalog.length, total: catalog.length })}
</div>
)}
{/* Catalog list */}
{loading ? (
<div className="flex items-center justify-center py-8 text-bambu-gray">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
{t('common.loading')}
</div>
) : (
<div className="max-h-[400px] overflow-auto border border-bambu-dark-tertiary rounded-lg">
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-bambu-gray font-medium w-12"></th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.manufacturer')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.colorName')}</th>
<th className="px-3 py-2 text-left text-bambu-gray font-medium w-24">{t('settings.colorCatalog.hex')}</th>
<th className="px-3 py-2 w-16"></th>
</tr>
</thead>
<tbody>
{filteredCatalog.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-bambu-gray">
{search || filterManufacturer ? t('settings.colorCatalog.noMatch') : t('settings.colorCatalog.empty')}
</td>
</tr>
) : (
filteredCatalog.map(entry => (
<tr key={entry.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
{editingId === entry.id ? (
<>
<td className="px-3 py-2">
<input
type="color"
className="w-8 h-8 rounded cursor-pointer border border-bambu-dark-tertiary"
value={formHexColor}
onChange={(e) => setFormHexColor(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formManufacturer}
onChange={(e) => setFormManufacturer(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formMaterial}
onChange={(e) => setFormMaterial(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formColorName}
onChange={(e) => setFormColorName(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
value={formHexColor}
onChange={(e) => setFormHexColor(e.target.value)}
/>
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => handleUpdate(entry.id)}
disabled={saving}
className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
</button>
<button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-3 py-2">
<div
className="w-8 h-8 rounded border border-bambu-dark-tertiary"
style={{ backgroundColor: entry.hex_color }}
title={entry.hex_color}
/>
</td>
<td className="px-3 py-2 text-white">{entry.manufacturer}</td>
<td className="px-3 py-2 text-bambu-gray">{entry.material || '-'}</td>
<td className="px-3 py-2 text-white">{entry.color_name}</td>
<td className="px-3 py-2 font-mono text-xs text-bambu-gray">{entry.hex_color}</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => startEdit(entry)}
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteEntry(entry)}
className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</>
)}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</CardContent>
{/* Delete confirmation */}
{deleteEntry && (
<ConfirmModal
title={t('settings.colorCatalog.deleteColor')}
message={t('settings.colorCatalog.deleteConfirm', { name: `${deleteEntry.manufacturer} - ${deleteEntry.color_name}` })}
confirmText={t('common.delete')}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setDeleteEntry(null)}
/>
)}
{/* Reset confirmation */}
{showResetConfirm && (
<ConfirmModal
title={t('settings.colorCatalog.resetCatalog')}
message={t('settings.colorCatalog.resetConfirm')}
confirmText={t('common.reset')}
variant="danger"
onConfirm={handleReset}
onCancel={() => setShowResetConfirm(false)}
/>
)}
</Card>
);
}
@@ -0,0 +1,187 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { GripVertical, Eye, EyeOff, ChevronUp, ChevronDown, RotateCcw } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
export interface ColumnConfig {
id: string;
label: string;
visible: boolean;
}
interface ColumnConfigModalProps {
isOpen: boolean;
onClose: () => void;
columns: ColumnConfig[];
defaultColumns: ColumnConfig[];
onSave: (columns: ColumnConfig[]) => void;
}
export function ColumnConfigModal({ isOpen, onClose, columns, defaultColumns, onSave }: ColumnConfigModalProps) {
const { t } = useTranslation();
const [localColumns, setLocalColumns] = useState<ColumnConfig[]>(columns);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
useEffect(() => {
if (isOpen) {
setLocalColumns(columns.map((c) => ({ ...c })));
}
}, [isOpen, columns]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const toggleVisibility = (index: number) => {
setLocalColumns((prev) =>
prev.map((col, i) => (i === index ? { ...col, visible: !col.visible } : col))
);
};
const moveColumn = (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= localColumns.length) return;
setLocalColumns((prev) => {
const newColumns = [...prev];
const [moved] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, moved);
return newColumns;
});
};
const handleDragStart = (e: React.DragEvent, index: number) => {
draggedIndexRef.current = index;
setDraggedIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const from = draggedIndexRef.current;
if (from !== null && from !== index) {
moveColumn(from, index);
draggedIndexRef.current = index;
setDraggedIndex(index);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDragEnd = () => {
draggedIndexRef.current = null;
setDraggedIndex(null);
};
const resetToDefaults = () => {
setLocalColumns(defaultColumns.map((c) => ({ ...c })));
};
const handleSave = () => {
onSave(localColumns);
onClose();
};
const visibleCount = localColumns.filter((c) => c.visible).length;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onClose}>
<Card className="w-full max-w-md max-h-[80vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent className="p-6 flex flex-col min-h-0">
{/* Header */}
<h3 className="text-lg font-semibold text-white mb-2">{t('inventory.configureColumns')}</h3>
<p className="text-sm text-bambu-gray mb-4">
{t('inventory.configureColumnsDesc')}
<span className="ml-2 text-bambu-gray/60">
({visibleCount} {t('inventory.of')} {localColumns.length} {t('inventory.visible')})
</span>
</p>
{/* Column list */}
<div className="space-y-1 overflow-y-auto flex-1 min-h-0 pr-1">
{localColumns.map((column, index) => (
<div
key={column.id}
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
draggedIndex === index
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark-tertiary/50'
} ${!column.visible ? 'opacity-50' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
>
{/* Drag Handle */}
<div className="cursor-grab text-bambu-gray/50 hover:text-bambu-gray">
<GripVertical className="w-4 h-4" />
</div>
{/* Column Name */}
<span className="flex-1 font-medium text-sm text-white">{column.label}</span>
{/* Move Buttons */}
<div className="flex items-center gap-0.5">
<button
onClick={() => moveColumn(index, index - 1)}
disabled={index === 0}
className="p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed"
title={t('inventory.moveUp')}
>
<ChevronUp className="w-4 h-4" />
</button>
<button
onClick={() => moveColumn(index, index + 1)}
disabled={index === localColumns.length - 1}
className="p-1 rounded text-bambu-gray hover:bg-bambu-dark-secondary disabled:opacity-30 disabled:cursor-not-allowed"
title={t('inventory.moveDown')}
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
{/* Visibility Toggle */}
<button
onClick={() => toggleVisibility(index)}
className={`p-1.5 rounded transition-colors ${
column.visible
? 'text-bambu-green hover:bg-bambu-green/10'
: 'text-bambu-gray/50 hover:bg-bambu-dark-secondary'
}`}
title={column.visible ? t('inventory.hideColumn') : t('inventory.showColumn')}
>
{column.visible ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
</button>
</div>
))}
</div>
{/* Footer */}
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={resetToDefaults} className="mr-auto">
<RotateCcw className="w-4 h-4" />
{t('inventory.reset')}
</Button>
<Button variant="secondary" onClick={onClose}>
{t('inventory.cancel')}
</Button>
<Button onClick={handleSave}>
{t('inventory.applyChanges')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -226,11 +226,12 @@ export function ConfigureAmsSlotModal({
const [showSuccess, setShowSuccess] = useState(false);
const [showExtendedColors, setShowExtendedColors] = useState(false);
// Fetch cloud settings
const { data: cloudSettings, isLoading: settingsLoading } = useQuery({
// Fetch cloud settings (gracefully handle 401 when logged out)
const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
queryKey: ['cloudSettings'],
queryFn: () => api.getCloudSettings(),
enabled: isOpen,
retry: false,
});
// Fetch local presets
@@ -240,6 +241,14 @@ export function ConfigureAmsSlotModal({
enabled: isOpen,
});
// Fetch built-in filament names (static fallback)
const { data: builtinFilaments, isLoading: builtinLoading } = useQuery({
queryKey: ['builtinFilaments'],
queryFn: () => api.getBuiltinFilaments(),
enabled: isOpen,
staleTime: Infinity,
});
// Fetch K profiles
const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
queryKey: ['kprofiles', printerId, nozzleDiameter],
@@ -252,23 +261,29 @@ export function ConfigureAmsSlotModal({
mutationFn: async () => {
if (!selectedPresetId) throw new Error('No filament preset selected');
// Check if this is a local preset
// Determine preset source
const isLocal = selectedPresetId.startsWith('local_');
const isBuiltin = selectedPresetId.startsWith('builtin_');
const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null;
const builtinFilamentId = isBuiltin ? selectedPresetId.replace('builtin_', '') : null;
const localPreset = isLocal
? localPresets?.filament.find(p => p.id === localId)
: null;
const builtinPreset = isBuiltin
? builtinFilaments?.find(b => b.filament_id === builtinFilamentId)
: null;
// Get the selected cloud preset details (null for local presets)
const selectedPreset = !isLocal
// Get the selected cloud preset details (null for local/builtin presets)
const selectedPreset = (!isLocal && !isBuiltin)
? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId)
: null;
if (!isLocal && !selectedPreset) throw new Error('Selected preset not found');
if (!isLocal && !isBuiltin && !selectedPreset) throw new Error('Selected preset not found');
if (isLocal && !localPreset) throw new Error('Selected local preset not found');
if (isBuiltin && !builtinPreset) throw new Error('Selected builtin preset not found');
// Parse the preset name for filament info
const presetName = isLocal ? localPreset!.name : selectedPreset!.name;
const presetName = isLocal ? localPreset!.name : isBuiltin ? builtinPreset!.name : selectedPreset!.name;
const parsed = parsePresetName(presetName);
// Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
@@ -287,6 +302,10 @@ export function ConfigureAmsSlotModal({
// Local presets have no Bambu Cloud mapping
trayInfoIdx = '';
settingId = '';
} else if (isBuiltin) {
// Built-in presets use the filament_id directly as tray_info_idx
trayInfoIdx = builtinFilamentId!;
settingId = '';
} else {
// Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
@@ -312,7 +331,7 @@ export function ConfigureAmsSlotModal({
let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190;
let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;
if (!isLocal || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
if (!isLocal || isBuiltin || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
// Fall back to material-based defaults
const material = (isLocal ? (localPreset?.filament_type || parsed.material) : parsed.material).toUpperCase();
if (material.includes('PLA')) {
@@ -368,8 +387,8 @@ export function ConfigureAmsSlotModal({
// Save the preset mapping so we can display the correct name in the UI
// This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
// which can't be resolved to a name via the filamentInfo API
const mappingPresetId = isLocal ? `local_${localId}` : selectedPresetId;
const mappingSource = isLocal ? 'local' : 'cloud';
const mappingPresetId = isLocal ? `local_${localId}` : isBuiltin ? `builtin_${builtinFilamentId}` : selectedPresetId;
const mappingSource = isLocal ? 'local' : isBuiltin ? 'builtin' : 'cloud';
try {
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
} catch (e) {
@@ -405,15 +424,28 @@ export function ConfigureAmsSlotModal({
},
});
// Unified preset item for the list (cloud + local)
type PresetItem = { id: string; name: string; source: 'cloud' | 'local'; isUser: boolean };
// Unified preset item for the list (cloud + local + builtin fallback)
type PresetItem = { id: string; name: string; source: 'cloud' | 'local' | 'builtin'; isUser: boolean };
// Filter filament presets based on search (merged cloud + local)
// Filter filament presets based on search (merged cloud + local + builtin)
const filteredPresets = useMemo(() => {
const query = searchQuery.toLowerCase();
const items: PresetItem[] = [];
// Add local presets first
// Collect IDs already covered by cloud and local to avoid duplicates in fallback
const coveredIds = new Set<string>();
// 1. Cloud presets
if (cloudSettings?.filament) {
for (const cp of cloudSettings.filament) {
coveredIds.add(cp.setting_id);
if (!query || cp.name.toLowerCase().includes(query)) {
items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
}
}
}
// 2. Local presets
if (localPresets?.filament) {
for (const lp of localPresets.filament) {
if (!query || lp.name.toLowerCase().includes(query)) {
@@ -422,35 +454,46 @@ export function ConfigureAmsSlotModal({
}
}
// Add cloud presets
if (cloudSettings?.filament) {
for (const cp of cloudSettings.filament) {
if (!query || cp.name.toLowerCase().includes(query)) {
items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
// 3. Built-in filament names (fallback — only add entries not already covered)
if (builtinFilaments) {
for (const bf of builtinFilaments) {
if (coveredIds.has(bf.filament_id)) continue;
// Convert filament_id to setting_id format for cloud compatibility
// e.g. "GFA00" → cloud setting_id would be "GFSA00" (insert S after GF)
const settingId = bf.filament_id.startsWith('GF')
? 'GFS' + bf.filament_id.slice(2)
: bf.filament_id;
if (coveredIds.has(settingId)) continue;
if (!query || bf.name.toLowerCase().includes(query)) {
items.push({ id: `builtin_${bf.filament_id}`, name: bf.name, source: 'builtin', isUser: false });
}
}
}
// Sort: local first, then user cloud presets, then built-in, alphabetically within groups
// Sort: cloud user presets first, then cloud built-in, then local, then builtin fallback
return items.sort((a, b) => {
if (a.source === 'local' && b.source !== 'local') return -1;
if (a.source !== 'local' && b.source === 'local') return 1;
const sourceOrder = { cloud: 0, local: 1, builtin: 2 };
if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source];
if (a.isUser && !b.isUser) return -1;
if (!a.isUser && b.isUser) return 1;
return a.name.localeCompare(b.name);
});
}, [cloudSettings?.filament, localPresets?.filament, searchQuery]);
}, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery]);
// Get full preset name for K profile filtering (brand + material, without printer suffix)
const selectedPresetInfo = useMemo(() => {
if (!selectedPresetId) return null;
// Resolve the name from either local or cloud presets
// Resolve the name from cloud, local, or builtin presets
let presetName: string | null = null;
if (selectedPresetId.startsWith('local_')) {
const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
const lp = localPresets?.filament.find(p => p.id === localId);
presetName = lp?.name || null;
} else if (selectedPresetId.startsWith('builtin_')) {
const filamentId = selectedPresetId.replace('builtin_', '');
const bf = builtinFilaments?.find(b => b.filament_id === filamentId);
presetName = bf?.name || null;
} else if (cloudSettings?.filament) {
const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
presetName = cp?.name || null;
@@ -470,7 +513,7 @@ export function ConfigureAmsSlotModal({
material: parsed.material,
brand: parsed.brand,
};
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament]);
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments]);
// For backwards compatibility with the label
const selectedMaterial = selectedPresetInfo?.fullName || '';
@@ -596,7 +639,7 @@ export function ConfigureAmsSlotModal({
if (!isOpen) return null;
const isLoading = settingsLoading || localLoading || kprofilesLoading;
const isLoading = (settingsLoading && !cloudError) || localLoading || builtinLoading || kprofilesLoading;
const canSave = selectedPresetId && !configureMutation.isPending;
// Get display color (custom or slot default)
@@ -703,6 +746,11 @@ export function ConfigureAmsSlotModal({
{t('profiles.localProfiles.badge')}
</span>
)}
{preset.source === 'builtin' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
{t('configureAmsSlot.builtin')}
</span>
)}
{preset.isUser && (
<span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
{t('configureAmsSlot.custom')}
+57 -3
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react';
import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react';
interface FilamentData {
vendor: 'Bambu Lab' | 'Generic';
@@ -10,7 +10,7 @@ interface FilamentData {
kFactor: string;
fillLevel: number | null; // null = unknown
trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
fillSource?: 'ams' | 'spoolman'; // Source of fill level data
fillSource?: 'ams' | 'spoolman' | 'inventory'; // Source of fill level data
}
interface SpoolmanConfig {
@@ -21,6 +21,12 @@ interface SpoolmanConfig {
spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link)
}
interface InventoryConfig {
onAssignSpool?: () => void;
onUnassignSpool?: () => void;
assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null } | null;
}
interface ConfigureSlotConfig {
enabled: boolean;
onConfigure?: () => void;
@@ -32,6 +38,7 @@ interface FilamentHoverCardProps {
disabled?: boolean;
className?: string;
spoolman?: SpoolmanConfig;
inventory?: InventoryConfig;
configureSlot?: ConfigureSlotConfig;
}
@@ -39,7 +46,7 @@ interface FilamentHoverCardProps {
* A hover card that displays filament details when hovering over AMS slots.
* Replaces the basic browser tooltip with a styled popover.
*/
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState<'top' | 'bottom'>('top');
@@ -235,6 +242,9 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
{data.fillSource === 'spoolman' && data.fillLevel !== null && (
<span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
)}
{data.fillSource === 'inventory' && data.fillLevel !== null && (
<span className="text-[9px] text-bambu-gray font-normal">{t('inventory.fillSourceLabel')}</span>
)}
</span>
</div>
{/* Fill bar */}
@@ -319,6 +329,50 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
</div>
)}
{/* Inventory section - only for non-Bambu spools */}
{inventory && data.vendor !== 'Bambu Lab' && (
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
{inventory.assignedSpool ? (
<>
<div className="flex items-center gap-1.5">
<Package className="w-3 h-3 text-bambu-green" />
<span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
{t('inventory.assigned')}
</span>
</div>
<p className="text-xs text-white truncate">
{inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''}
{inventory.assignedSpool.material}
{inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
</p>
{inventory.onUnassignSpool && (
<button
onClick={(e) => {
e.stopPropagation();
inventory.onUnassignSpool?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400"
>
<Unlink className="w-3.5 h-3.5" />
{t('inventory.unassignSpool')}
</button>
)}
</>
) : inventory.onAssignSpool ? (
<button
onClick={(e) => {
e.stopPropagation();
inventory.onAssignSpool?.();
}}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
>
<Package className="w-3.5 h-3.5" />
{t('inventory.assignSpool')}
</button>
) : null}
</div>
)}
{/* Configure slot section - always show if enabled */}
{configureSlot?.enabled && (
<div className={`${spoolman?.enabled && data.trayUuid ? '' : 'pt-2 mt-2 border-t border-bambu-dark-tertiary'}`}>
+73 -38
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Package, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -29,6 +29,7 @@ export const defaultNavItems: NavItem[] = [
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ id: 'inventory', to: '/inventory', icon: Package, labelKey: 'nav.inventory' },
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
@@ -118,6 +119,13 @@ export function Layout() {
refetchInterval: 60 * 60 * 1000, // Check every hour
});
// Fetch Spoolman settings to determine if inventory should be hidden
const { data: spoolmanSettings } = useQuery({
queryKey: ['spoolman-settings'],
queryFn: api.getSpoolmanSettings,
staleTime: 5 * 60 * 1000,
});
// Fetch external links for sidebar
const { data: externalLinks } = useQuery({
queryKey: ['external-links'],
@@ -189,13 +197,13 @@ export function Layout() {
// Determine if settings should be hidden (user role and auth enabled)
const hideSettings = authEnabled && user?.role === 'user';
// Hide inventory when Spoolman mode is active
const hideInventory = spoolmanSettings?.spoolman_enabled === 'true';
// Add items in stored order
for (const id of sidebarOrder) {
// Skip settings if user is not admin
if (hideSettings && id === 'settings') {
continue;
}
if (hideSettings && id === 'settings') continue;
if (hideInventory && id === 'inventory') continue;
if (navItemsMap.has(id) || extLinksMap.has(id)) {
result.push(id);
seen.add(id);
@@ -204,10 +212,8 @@ export function Layout() {
// Add any new internal nav items not in stored order
for (const item of defaultNavItems) {
// Skip settings if user is not admin
if (hideSettings && item.id === 'settings') {
continue;
}
if (hideSettings && item.id === 'settings') continue;
if (hideInventory && item.id === 'inventory') continue;
if (!seen.has(item.id)) {
result.push(item.id);
seen.add(item.id);
@@ -347,9 +353,14 @@ export function Layout() {
e.preventDefault();
if (isExternalLinkId(id)) {
// External link - navigate to iframe page
const linkId = id.replace('ext-', '');
navigate(`/external/${linkId}`);
// External link
const extLink = extLinksMap.get(id);
if (extLink?.open_in_new_tab) {
window.open(extLink.url, '_blank', 'noopener,noreferrer');
} else {
const linkId = id.replace('ext-', '');
navigate(`/external/${linkId}`);
}
} else {
// Internal nav item
const navItem = navItemsMap.get(id);
@@ -370,7 +381,7 @@ export function Layout() {
break;
}
}
}, [navigate, orderedSidebarIds, navItemsMap]);
}, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
@@ -451,31 +462,55 @@ export function Layout() {
: ''
}`}
>
<NavLink
to={`/external/${link.id}`}
className={({ isActive }) =>
`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!isMobile && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isMobile && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={`/api/v1/external-links/${link.id}/icon`}
alt=""
className="w-5 h-5 flex-shrink-0"
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
)}
{(isMobile || sidebarExpanded) && <span>{link.name}</span>}
</NavLink>
{link.open_in_new_tab ? (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
title={!isMobile && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isMobile && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={`/api/v1/external-links/${link.id}/icon`}
alt=""
className="w-5 h-5 flex-shrink-0"
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
)}
{(isMobile || sidebarExpanded) && <span>{link.name}</span>}
</a>
) : (
<NavLink
to={`/external/${link.id}`}
className={({ isActive }) =>
`flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!isMobile && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isMobile && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={`/api/v1/external-links/${link.id}/icon`}
alt=""
className="w-5 h-5 flex-shrink-0"
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
)}
{(isMobile || sidebarExpanded) && <span>{link.name}</span>}
</NavLink>
)}
</li>
);
} else {
+107 -143
View File
@@ -1,190 +1,154 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Link2, Check } from 'lucide-react';
import { X, Loader2, Search, Link } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface LinkSpoolModalProps {
isOpen: boolean;
onClose: () => void;
tagUid: string;
trayUuid: string;
trayInfo?: {
type: string;
color: string;
location: string;
};
printerId: number;
amsId: number;
trayId: number;
}
export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, amsId, trayId }: LinkSpoolModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
const [search, setSearch] = useState('');
// Fetch unlinked spools
const { data: unlinkedSpools, isLoading } = useQuery({
queryKey: ['unlinked-spools'],
queryFn: api.getUnlinkedSpools,
const { data: spools, isLoading } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(false),
enabled: isOpen,
});
// Link mutation
// Filter to untagged spools matching search
const filteredSpools = useMemo(() => {
if (!spools) return [];
return spools.filter((s: InventorySpool) => {
if (s.tag_uid || s.tray_uuid) return false; // Already tagged
if (!search) return true;
const q = search.toLowerCase();
return (
s.material.toLowerCase().includes(q) ||
(s.brand && s.brand.toLowerCase().includes(q)) ||
(s.color_name && s.color_name.toLowerCase().includes(q))
);
});
}, [spools, search]);
const linkMutation = useMutation({
mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid),
mutationFn: (spoolId: number) =>
api.linkTagToSpool(spoolId, {
tag_uid: tagUid || undefined,
tray_uuid: trayUuid || undefined,
tag_type: trayUuid ? 'bambulab' : 'generic',
data_origin: 'nfc_link',
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
showToast(t('spoolman.linkSuccess'), 'success');
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
queryClient.invalidateQueries({ queryKey: ['inventory-assignments'] });
showToast(t('inventory.tagLinked'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(`${t('spoolman.linkFailed')}: ${error.message}`, 'error');
onError: (err: Error) => {
showToast(err.message || t('inventory.tagLinkFailed'), 'error');
},
});
if (!isOpen) return null;
const handleLink = () => {
if (selectedSpoolId) {
linkMutation.mutate(selectedSpoolId);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-bambu-dark-secondary rounded-xl shadow-xl w-full max-w-md mx-4 max-h-[80vh] flex flex-col border border-bambu-dark-tertiary">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Link2 className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('spoolman.linkToSpoolman')}</h2>
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div>
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Link className="w-5 h-5 text-bambu-green" />
{t('inventory.linkToSpool')}
</h3>
<p className="text-xs text-bambu-gray mt-1">
AMS {amsId} T{trayId} &middot; Printer #{printerId}
</p>
</div>
<button
onClick={onClose}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<button onClick={onClose} className="p-1 text-bambu-gray hover:text-white rounded transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Tray info */}
{trayInfo && (
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">Linking AMS tray:</p>
<div className="flex items-center gap-2">
{trayInfo.color && (
<span
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: `#${trayInfo.color}` }}
/>
)}
<span className="text-white font-medium">{trayInfo.type}</span>
<span className="text-bambu-gray">({trayInfo.location})</span>
</div>
</div>
)}
{/* Spool UUID */}
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">{t('spoolman.spoolId')}:</p>
<code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
{/* Search */}
<div className="p-4 border-b border-white/10">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('inventory.searchSpools')}
className="w-full pl-9 pr-3 py-2 bg-bambu-dark rounded-lg border border-white/10 text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
/>
</div>
{/* Spool list */}
<div>
<p className="text-sm text-bambu-gray mb-2">
{t('spoolman.selectSpool')}:
{(tagUid || trayUuid) && (
<p className="text-xs text-bambu-gray mt-2 font-mono truncate" title={tagUid || trayUuid}>
Tag: {tagUid || trayUuid}
</p>
)}
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : unlinkedSpools && unlinkedSpools.length > 0 ? (
<div className="max-h-64 overflow-y-auto space-y-2">
{unlinkedSpools.map((spool) => (
<button
key={spool.id}
onClick={() => setSelectedSpoolId(spool.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
selectedSpoolId === spool.id
? 'bg-bambu-green/20 border-bambu-green'
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="flex items-center gap-2">
{spool.filament_color_hex && (
<span
className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
style={{ backgroundColor: `#${spool.filament_color_hex}` }}
/>
)}
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">
{spool.filament_name || 'Unknown filament'}
</p>
<p className="text-xs text-bambu-gray">
{spool.filament_material || 'Unknown'}
{spool.remaining_weight !== null && ` - ${Math.round(spool.remaining_weight)}g`}
{spool.location && ` - ${spool.location}`}
</p>
</div>
{selectedSpoolId === spool.id && (
<Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
)}
</div>
</button>
))}
</div>
) : (
<div className="text-center py-8 text-bambu-gray">
<p>{t('spoolman.noUnlinkedSpools')}</p>
</div>
)}
</div>
{/* Spool List */}
<div className="flex-1 overflow-y-auto p-2 min-h-0">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
</div>
) : filteredSpools.length === 0 ? (
<p className="text-center text-bambu-gray py-8 text-sm">
{t('inventory.noSpoolsMatch')}
</p>
) : (
filteredSpools.map((spool: InventorySpool) => (
<button
key={spool.id}
onClick={() => linkMutation.mutate(spool.id)}
disabled={linkMutation.isPending}
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-white/5 transition-colors text-left"
>
<span
className="w-6 h-6 rounded-full border border-white/20 flex-shrink-0"
style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-white font-medium truncate">
{spool.brand ? `${spool.brand} ` : ''}{spool.material}
{spool.subtype ? ` ${spool.subtype}` : ''}
</div>
<div className="text-xs text-bambu-gray truncate">
{spool.color_name || 'No color'} &middot; #{spool.id}
</div>
</div>
<span className="text-xs text-bambu-gray">
{Math.round(spool.label_weight - spool.weight_used)}g
</span>
</button>
))
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
onClick={handleLink}
disabled={!selectedSpoolId || linkMutation.isPending}
>
{linkMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('spoolman.syncing')}
</>
) : (
<>
<Link2 className="w-4 h-4" />
{t('spoolman.linkToSpoolman')}
</>
)}
<div className="p-4 border-t border-white/10 flex justify-end">
<Button variant="ghost" onClick={onClose}>
{t('inventory.cancel') || 'Cancel'}
</Button>
</div>
{/* Error */}
{linkMutation.isError && (
<div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
{(linkMutation.error as Error).message}
</div>
)}
</div>
</div>
);
+66 -4
View File
@@ -1,11 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { Clock, Calendar, ChevronRight } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Clock, Calendar, ChevronRight, Loader2, CircleCheck } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { parseUTCDate } from '../utils/date';
interface PrinterQueueWidgetProps {
printerId: number;
printerState?: string | null;
}
function formatRelativeTime(dateString: string | null): string {
@@ -22,13 +26,29 @@ function formatRelativeTime(dateString: string | null): string {
return date.toLocaleDateString();
}
export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidgetProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const { data: queue } = useQuery({
queryKey: ['queue', printerId, 'pending'],
queryFn: () => api.getQueue(printerId, 'pending'),
refetchInterval: 30000,
});
const clearPlateMutation = useMutation({
mutationFn: () => api.clearPlate(printerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue', printerId] });
queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
showToast(t('queue.clearPlateSuccess'), 'success');
},
onError: (err: Error) => {
showToast(err.message, 'error');
},
});
const nextItem = queue?.[0];
const totalPending = queue?.length || 0;
@@ -36,6 +56,48 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
return null;
}
const needsClearPlate = printerState === 'FINISH' || printerState === 'FAILED';
if (needsClearPlate) {
return (
<div className="mb-3 p-3 bg-bambu-dark rounded-lg border border-yellow-400/30">
<div className="flex items-center gap-3 mb-2">
<Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
<p className="text-sm text-white truncate">
{nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
</p>
</div>
{totalPending > 1 && (
<span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded flex-shrink-0">
+{totalPending - 1}
</span>
)}
</div>
{clearPlateMutation.isSuccess ? (
<div className="w-full py-2 px-3 rounded-lg bg-bambu-green/10 border border-bambu-green/20 text-bambu-green text-sm flex items-center justify-center gap-2">
<CircleCheck className="w-4 h-4" />
{t('queue.plateReady')}
</div>
) : (
<button
onClick={() => clearPlateMutation.mutate()}
disabled={clearPlateMutation.isPending || !hasPermission('printers:control')}
className="w-full py-2 px-3 rounded-lg bg-bambu-green/20 border border-bambu-green/40 text-bambu-green hover:bg-bambu-green/30 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50"
>
{clearPlateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CircleCheck className="w-4 h-4" />
)}
{t('queue.clearPlate')}
</button>
)}
</div>
);
}
return (
<Link
to="/queue"
@@ -45,7 +107,7 @@ export function PrinterQueueWidget({ printerId }: PrinterQueueWidgetProps) {
<div className="flex items-center gap-3 min-w-0 flex-1">
<Calendar className="w-5 h-5 text-yellow-400 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-bambu-gray">Next in queue</p>
<p className="text-xs text-bambu-gray">{t('queue.nextInQueue')}</p>
<p className="text-sm text-white truncate">
{nextItem?.archive_name || `Archive #${nextItem?.archive_id}`}
</p>
@@ -0,0 +1,397 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Database, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload } from 'lucide-react';
import { api } from '../api/client';
import type { SpoolCatalogEntry } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
import { ConfirmModal } from './ConfirmModal';
export function SpoolCatalogSettings() {
const { t } = useTranslation();
const { showToast } = useToast();
const [catalog, setCatalog] = useState<SpoolCatalogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Add/Edit form state
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formName, setFormName] = useState('');
const [formWeight, setFormWeight] = useState('');
const [saving, setSaving] = useState(false);
// Confirmation modals
const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const loadCatalog = useCallback(async () => {
try {
const entries = await api.getSpoolCatalog();
setCatalog(entries);
} catch {
showToast(t('settings.catalog.loadFailed'), 'error');
} finally {
setLoading(false);
}
}, [showToast, t]);
useEffect(() => {
loadCatalog();
}, [loadCatalog]);
const filteredCatalog = catalog.filter(entry =>
entry.name.toLowerCase().includes(search.toLowerCase())
);
const handleAdd = async () => {
if (!formName.trim() || !formWeight) {
showToast(t('settings.catalog.nameWeightRequired'), 'error');
return;
}
setSaving(true);
try {
const entry = await api.addCatalogEntry({ name: formName.trim(), weight: parseInt(formWeight) });
setCatalog(prev => [...prev, entry].sort((a, b) => a.name.localeCompare(b.name)));
setShowAddForm(false);
setFormName('');
setFormWeight('');
showToast(t('settings.catalog.entryAdded'), 'success');
} catch {
showToast(t('settings.catalog.addFailed'), 'error');
} finally {
setSaving(false);
}
};
const startEdit = (entry: SpoolCatalogEntry) => {
setEditingId(entry.id);
setFormName(entry.name);
setFormWeight(entry.weight.toString());
};
const cancelEdit = () => {
setEditingId(null);
setFormName('');
setFormWeight('');
};
const handleUpdate = async (id: number) => {
if (!formName.trim() || !formWeight) {
showToast(t('settings.catalog.nameWeightRequired'), 'error');
return;
}
setSaving(true);
try {
const updated = await api.updateCatalogEntry(id, { name: formName.trim(), weight: parseInt(formWeight) });
setCatalog(prev => prev.map(e => e.id === id ? updated : e).sort((a, b) => a.name.localeCompare(b.name)));
setEditingId(null);
setFormName('');
setFormWeight('');
showToast(t('settings.catalog.entryUpdated'), 'success');
} catch {
showToast(t('settings.catalog.updateFailed'), 'error');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteEntry) return;
try {
await api.deleteCatalogEntry(deleteEntry.id);
setCatalog(prev => prev.filter(e => e.id !== deleteEntry.id));
showToast(t('settings.catalog.entryDeleted'), 'success');
} catch {
showToast(t('settings.catalog.deleteFailed'), 'error');
} finally {
setDeleteEntry(null);
}
};
const handleReset = async () => {
setShowResetConfirm(false);
setLoading(true);
try {
await api.resetSpoolCatalog();
await loadCatalog();
showToast(t('settings.catalog.resetSuccess'), 'success');
} catch {
showToast(t('settings.catalog.resetFailed'), 'error');
setLoading(false);
}
};
const handleExport = () => {
const exportData = catalog.map(({ name, weight }) => ({ name, weight }));
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'spool-catalog.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(t('settings.catalog.exported', { count: catalog.length }), 'success');
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text) as Array<{ name: string; weight: number }>;
if (!Array.isArray(data)) throw new Error('Invalid format');
let added = 0;
let skipped = 0;
for (const item of data) {
if (!item.name || typeof item.weight !== 'number') { skipped++; continue; }
const exists = catalog.some(c => c.name.toLowerCase() === item.name.toLowerCase());
if (exists) { skipped++; continue; }
try {
const entry = await api.addCatalogEntry({ name: item.name, weight: item.weight });
setCatalog(prev => [...prev, entry].sort((a, b) => a.name.localeCompare(b.name)));
added++;
} catch { skipped++; }
}
showToast(t('settings.catalog.imported', { added, skipped }), 'success');
} catch {
showToast(t('settings.catalog.importFailed'), 'error');
}
if (fileInputRef.current) fileInputRef.current.value = '';
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2 mb-3">
<Database className="w-5 h-5 text-bambu-gray" />
<h2 className="text-lg font-semibold text-white">{t('settings.catalog.spoolCatalog')}</h2>
<span className="text-sm text-bambu-gray">({catalog.length})</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleExport}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.exportTooltip')}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.export')}</span>
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.importTooltip')}
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.import')}</span>
</button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<button
onClick={() => setShowResetConfirm(true)}
className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
title={t('settings.catalog.resetTooltip')}
>
<RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.reset')}</span>
</button>
<button
onClick={() => setShowAddForm(true)}
className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.add')}</span>
</button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('settings.catalog.spoolCatalogDescription')}
</p>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.catalog.searchCatalog')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Add form */}
{showAddForm && (
<div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
<div className="flex gap-2 items-center">
<div className="flex-1 min-w-0">
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
placeholder={t('settings.catalog.namePlaceholder')}
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<input
type="number"
className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
placeholder="g"
value={formWeight}
onChange={(e) => setFormWeight(e.target.value)}
/>
<span className="text-bambu-gray shrink-0">g</span>
<button
onClick={handleAdd}
disabled={saving}
className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
{t('common.add')}
</button>
<button
onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Catalog list */}
{loading ? (
<div className="flex items-center justify-center py-8 text-bambu-gray">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
{t('common.loading')}
</div>
) : (
<div className="max-h-[400px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
<table className="w-full text-sm">
<thead className="bg-bambu-dark sticky top-0">
<tr>
<th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
<th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
<th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
<th className="px-4 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{filteredCatalog.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-bambu-gray">
{search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
</td>
</tr>
) : (
filteredCatalog.map(entry => (
<tr key={entry.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
{editingId === entry.id ? (
<>
<td className="px-4 py-2">
<input
type="text"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</td>
<td className="px-4 py-2">
<input
type="number"
className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
value={formWeight}
onChange={(e) => setFormWeight(e.target.value)}
/>
</td>
<td className="px-4 py-2 text-center">
<span className="text-xs text-bambu-gray">-</span>
</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => handleUpdate(entry.id)}
disabled={saving}
className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
</button>
<button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-4 py-2 text-white">{entry.name}</td>
<td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
<td className="px-4 py-2 text-center">
{entry.is_default ? (
<span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
{t('settings.catalog.default')}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
{t('settings.catalog.custom')}
</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex justify-end gap-1">
<button
onClick={() => startEdit(entry)}
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteEntry(entry)}
className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</>
)}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</CardContent>
{/* Delete confirmation */}
{deleteEntry && (
<ConfirmModal
title={t('settings.catalog.deleteEntry')}
message={t('settings.catalog.deleteConfirm', { name: deleteEntry.name })}
confirmText={t('common.delete')}
variant="danger"
onConfirm={handleDelete}
onCancel={() => setDeleteEntry(null)}
/>
)}
{/* Reset confirmation */}
{showResetConfirm && (
<ConfirmModal
title={t('settings.catalog.resetCatalog')}
message={t('settings.catalog.resetConfirm')}
confirmText={t('common.reset')}
variant="danger"
onConfirm={handleReset}
onCancel={() => setShowResetConfirm(false)}
/>
)}
</Card>
);
}
+504
View File
@@ -0,0 +1,504 @@
import { useState, useEffect, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Save, Beaker, Palette } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
import { defaultFormData, validateForm } from './spool-form/types';
import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, saveRecentColor } from './spool-form/utils';
import { FilamentSection } from './spool-form/FilamentSection';
import { ColorSection } from './spool-form/ColorSection';
import { AdditionalSection } from './spool-form/AdditionalSection';
import { PAProfileSection } from './spool-form/PAProfileSection';
import { SpoolUsageHistory } from './SpoolUsageHistory';
type TabId = 'filament' | 'pa-profile';
interface SpoolFormModalProps {
isOpen: boolean;
onClose: () => void;
spool?: InventorySpool | null;
printersWithCalibrations?: PrinterWithCalibrations[];
}
export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [] }: SpoolFormModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const isEditing = !!spool;
// Form state
const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
const [activeTab, setActiveTab] = useState<TabId>('filament');
// Cloud presets
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
const [presetInputValue, setPresetInputValue] = useState('');
// Spool catalog
const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
// Local presets (OrcaSlicer imports)
const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
// Color catalog
const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
// Color state
const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
// PA Profile state
const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
// Use prop if provided, otherwise use self-fetched data
const resolvedCalibrations = printersWithCalibrations.length > 0
? printersWithCalibrations
: fetchedCalibrations;
// Count selected PA profiles for tab badge
const selectedProfileCount = useMemo(() => {
return selectedProfiles.size;
}, [selectedProfiles]);
// Load recent colors on mount
useEffect(() => {
setRecentColors(loadRecentColors());
}, []);
// Fetch cloud presets and catalog when modal opens
useEffect(() => {
if (isOpen) {
const fetchData = async () => {
setLoadingCloudPresets(true);
try {
const status = await api.getCloudStatus();
setCloudAuthenticated(status.is_authenticated);
if (status.is_authenticated) {
const presets = await api.getFilamentPresets();
setCloudPresets(presets);
}
} catch (e) {
console.error('Failed to fetch cloud presets:', e);
setCloudAuthenticated(false);
} finally {
setLoadingCloudPresets(false);
}
};
fetchData();
api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
api.getColorCatalog().then(setColorCatalog).catch(console.error);
api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
// Fetch printer calibrations if not provided via props
if (printersWithCalibrations.length === 0) {
(async () => {
try {
const printers = await api.getPrinters();
const statuses = await Promise.all(
printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
);
const results: PrinterWithCalibrations[] = [];
for (let i = 0; i < printers.length; i++) {
const printer = printers[i];
const status = statuses[i];
const connected = status?.connected ?? false;
let calibrations: PrinterWithCalibrations['calibrations'] = [];
if (connected) {
try {
const kRes = await api.getKProfiles(printer.id);
calibrations = kRes.profiles.map(p => ({
cali_idx: p.slot_id,
filament_id: p.filament_id,
setting_id: p.setting_id || '',
name: p.name,
k_value: parseFloat(p.k_value) || 0,
n_coef: parseFloat(p.n_coef) || 0,
extruder_id: p.extruder_id,
nozzle_diameter: p.nozzle_diameter,
}));
} catch {
// Printer may not support K-profiles
}
}
results.push({ printer: { ...printer, connected }, calibrations });
}
setFetchedCalibrations(results);
} catch (e) {
console.error('Failed to fetch printer calibrations:', e);
}
})();
}
}
}, [isOpen, printersWithCalibrations.length]);
// Build filament options: cloud → local → fallback
const filamentOptions = useMemo(
() => buildFilamentOptions(cloudPresets, new Set(), localPresets),
[cloudPresets, localPresets],
);
// Extract brands from presets
const availableBrands = useMemo(
() => extractBrandsFromPresets(cloudPresets, localPresets),
[cloudPresets, localPresets],
);
// Find selected preset option
const selectedPresetOption = useMemo(
() => findPresetOption(formData.slicer_filament, filamentOptions),
[formData.slicer_filament, filamentOptions],
);
// Reset form when modal opens/closes or spool changes
useEffect(() => {
if (isOpen) {
if (spool) {
setFormData({
material: spool.material || '',
subtype: spool.subtype || '',
brand: spool.brand || '',
color_name: spool.color_name || '',
rgba: spool.rgba || '808080FF',
label_weight: spool.label_weight || 1000,
core_weight: spool.core_weight || 250,
weight_used: spool.weight_used || 0,
slicer_filament: spool.slicer_filament || '',
note: spool.note || '',
});
setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
// Load K-profiles for this spool
if (spool.k_profiles && spool.k_profiles.length > 0) {
const profileKeys = new Set<string>();
for (const p of spool.k_profiles) {
if (p.cali_idx !== null && p.cali_idx !== undefined) {
profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
}
}
setSelectedProfiles(profileKeys);
} else {
setSelectedProfiles(new Set());
}
} else {
setFormData(defaultFormData);
setPresetInputValue('');
setSelectedProfiles(new Set());
}
setErrors({});
setActiveTab('filament');
}
}, [isOpen, spool]);
// Expand all printers in PA profile section when calibrations are available
useEffect(() => {
if (isOpen && resolvedCalibrations.length > 0) {
setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
}
}, [isOpen, resolvedCalibrations]);
// Update field helper
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
setFormData(prev => ({ ...prev, [key]: value }));
if (errors[key]) {
setErrors(prev => ({ ...prev, [key]: undefined }));
}
};
// Handle color selection
const handleColorUsed = (color: ColorPreset) => {
setRecentColors(prev => saveRecentColor(color, prev));
};
// Mutations
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) =>
api.createSpool(data as Parameters<typeof api.createSpool>[0]),
onSuccess: async (newSpool) => {
// Save K-profiles if any selected
if (selectedProfiles.size > 0 && newSpool?.id) {
await saveKProfiles(newSpool.id);
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
showToast(t('inventory.spoolCreated'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const updateMutation = useMutation({
mutationFn: (data: Record<string, unknown>) =>
api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
onSuccess: async () => {
// Save K-profiles
if (spool?.id) {
await saveKProfiles(spool.id);
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
showToast(t('inventory.spoolUpdated'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
// Save K-profiles for selected calibrations
const saveKProfiles = async (spoolId: number) => {
if (selectedProfiles.size === 0) {
// Clear existing K-profiles
try {
await api.saveSpoolKProfiles(spoolId, []);
} catch {
// Ignore
}
return;
}
const profiles = [];
for (const key of selectedProfiles) {
const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
const printerId = parseInt(printerIdStr);
const caliIdx = parseInt(caliIdxStr);
const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
// Find the matching calibration
const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
if (pc) {
const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
if (cal) {
profiles.push({
printer_id: printerId,
extruder,
nozzle_diameter: cal.nozzle_diameter || '0.4',
k_value: cal.k_value,
name: cal.name || null,
cali_idx: cal.cali_idx,
setting_id: cal.setting_id || null,
});
}
}
}
if (profiles.length > 0) {
try {
await api.saveSpoolKProfiles(spoolId, profiles);
} catch (e) {
console.error('Failed to save K-profiles:', e);
}
}
};
// Close on Escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleSubmit = () => {
const validation = validateForm(formData);
if (!validation.isValid) {
setErrors(validation.errors);
// Switch to filament tab if there are errors there
if (validation.errors.slicer_filament || validation.errors.material) {
setActiveTab('filament');
}
return;
}
// Find preset name from selected option
const presetName = selectedPresetOption?.displayName || presetInputValue || null;
const data: Record<string, unknown> = {
material: formData.material,
subtype: formData.subtype || null,
brand: formData.brand || null,
color_name: formData.color_name || null,
rgba: formData.rgba || null,
label_weight: formData.label_weight,
core_weight: formData.core_weight,
weight_used: formData.weight_used,
slicer_filament: formData.slicer_filament || null,
slicer_filament_name: presetName,
nozzle_temp_min: null,
nozzle_temp_max: null,
note: formData.note || null,
};
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<h2 className="text-lg font-semibold text-white">
{isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}
</h2>
<button
onClick={onClose}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
<button
onClick={() => setActiveTab('filament')}
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
activeTab === 'filament'
? 'text-bambu-green border-b-2 border-bambu-green'
: 'text-bambu-gray hover:text-white'
}`}
>
<Palette className="w-4 h-4" />
{t('inventory.filamentInfoTab')}
</button>
<button
onClick={() => setActiveTab('pa-profile')}
className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
activeTab === 'pa-profile'
? 'text-bambu-green border-b-2 border-bambu-green'
: 'text-bambu-gray hover:text-white'
}`}
>
<Beaker className="w-4 h-4" />
{t('inventory.paProfileTab')}
{selectedProfileCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
{selectedProfileCount}
</span>
)}
</button>
</div>
{/* Content */}
<div className="p-4 overflow-y-auto flex-1">
{activeTab === 'filament' ? (
<div className="space-y-6">
{/* Filament Info Section */}
<div>
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
{t('inventory.filamentInfo')}
</h3>
<FilamentSection
formData={formData}
updateField={updateField}
cloudAuthenticated={cloudAuthenticated}
loadingCloudPresets={loadingCloudPresets}
presetInputValue={presetInputValue}
setPresetInputValue={setPresetInputValue}
selectedPresetOption={selectedPresetOption}
filamentOptions={filamentOptions}
availableBrands={availableBrands}
/>
{errors.slicer_filament && (
<p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
)}
{errors.material && (
<p className="mt-1 text-xs text-red-400">{errors.material}</p>
)}
</div>
{/* Color Section */}
<div>
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
{t('inventory.color')}
</h3>
<ColorSection
formData={formData}
updateField={updateField}
recentColors={recentColors}
onColorUsed={handleColorUsed}
catalogColors={colorCatalog}
/>
</div>
{/* Additional Section */}
<div>
<h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
{t('inventory.additional')}
</h3>
<AdditionalSection
formData={formData}
updateField={updateField}
spoolCatalog={spoolCatalog}
/>
</div>
{/* Usage History (only when editing) */}
{isEditing && spool && (
<div>
<SpoolUsageHistory spoolId={spool.id} />
</div>
)}
</div>
) : (
<PAProfileSection
formData={formData}
updateField={updateField}
printersWithCalibrations={resolvedCalibrations}
selectedProfiles={selectedProfiles}
setSelectedProfiles={setSelectedProfiles}
expandedPrinters={expandedPrinters}
setExpandedPrinters={setExpandedPrinters}
/>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving')}
</>
) : (
<>
<Save className="w-4 h-4" />
{isEditing ? t('common.save') : t('inventory.addSpool')}
</>
)}
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,101 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Loader2, Trash2, Clock } from 'lucide-react';
import { api } from '../api/client';
import type { SpoolUsageRecord } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface SpoolUsageHistoryProps {
spoolId: number;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: '2-digit' }) +
' ' + date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
}
const STATUS_COLORS: Record<string, string> = {
completed: 'text-bambu-green',
failed: 'text-red-400',
aborted: 'text-yellow-400',
};
export function SpoolUsageHistory({ spoolId }: SpoolUsageHistoryProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { data: history, isLoading } = useQuery({
queryKey: ['spool-usage', spoolId],
queryFn: () => api.getSpoolUsageHistory(spoolId),
});
const clearMutation = useMutation({
mutationFn: () => api.clearSpoolUsageHistory(spoolId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spool-usage', spoolId] });
showToast(t('inventory.historyCleared'), 'success');
},
});
if (isLoading) {
return (
<div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-bambu-green" />
</div>
);
}
if (!history || history.length === 0) {
return (
<div className="text-center py-4 text-bambu-gray text-sm">
<Clock className="w-5 h-5 mx-auto mb-2 opacity-50" />
{t('inventory.noUsageHistory')}
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-white">{t('inventory.usageHistory')}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => clearMutation.mutate()}
disabled={clearMutation.isPending}
className="text-xs text-bambu-gray hover:text-red-400"
>
<Trash2 className="w-3 h-3 mr-1" />
{t('inventory.clearHistory')}
</Button>
</div>
<div className="max-h-48 overflow-y-auto space-y-1">
{history.map((record: SpoolUsageRecord) => (
<div
key={record.id}
className="flex items-center justify-between p-2 rounded bg-bambu-dark/50 text-xs"
>
<div className="flex-1 min-w-0">
<span className="text-bambu-gray">{formatDate(record.created_at)}</span>
{record.print_name && (
<span className="text-white ml-2 truncate" title={record.print_name}>
{record.print_name}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
<span className="text-white font-medium">{record.weight_used.toFixed(1)}g</span>
<span className="text-bambu-gray">({record.percent_used}%)</span>
<span className={STATUS_COLORS[record.status] || 'text-bambu-gray'}>
{record.status}
</span>
</div>
</div>
))}
</div>
</div>
);
}
+332 -279
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle } from 'lucide-react';
import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink } from 'lucide-react';
import { api } from '../api/client';
import type { SpoolmanSyncResult, Printer } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
@@ -146,7 +146,7 @@ export function SpoolmanSettings() {
<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>
<h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
</div>
</CardHeader>
<CardContent>
@@ -164,7 +164,7 @@ export function SpoolmanSettings() {
<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>
<h2 className="text-lg font-semibold text-white">{t('settings.filamentTracking')}</h2>
</div>
{saveMutation.isPending && (
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
@@ -173,303 +173,356 @@ export function SpoolmanSettings() {
</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.
{t('settings.filamentTrackingDesc')}
</p>
{/* Info banner about sync requirements */}
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<div className="flex gap-2">
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-blue-300">
<p className="font-medium mb-1">How Sync Works</p>
<ul className="list-disc list-inside space-y-0.5 text-blue-300/80">
<li>Only official Bambu Lab spools with RFID are synced</li>
<li>New spools are auto-created in Spoolman on first sync</li>
<li>Non-Bambu Lab spools (third-party, refilled) are skipped</li>
</ul>
<p className="font-medium mt-2 mb-1">Linking Existing Spools</p>
<p className="text-blue-300/80">
To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".
</p>
</div>
</div>
</div>
{/* 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"
{/* Mode selector cards */}
<div className="grid grid-cols-2 gap-3">
{/* Built-in Inventory */}
<button
type="button"
onClick={() => setLocalEnabled(false)}
className={`p-3 rounded-lg border-2 text-left transition-colors ${
!localEnabled
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/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>
{/* Disable Weight Sync toggle - only show when sync mode is auto */}
{localSyncMode === 'auto' && (
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('spoolman.disableWeightSync')}</p>
<p className="text-sm text-bambu-gray">
{t('spoolman.disableWeightSyncDesc')}
</p>
<div className="flex items-center gap-2 mb-1.5">
<Package className={`w-4 h-4 ${!localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
<span className={`text-sm font-medium ${!localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
{t('settings.trackingModeBuiltIn')}
</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localDisableWeightSync}
onChange={(e) => setLocalDisableWeightSync(e.target.checked)}
disabled={!localEnabled}
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>
)}
{/* Report Partial Usage toggle - only show when weight sync is disabled */}
{localDisableWeightSync && (
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('spoolman.reportPartialUsage')}</p>
<p className="text-sm text-bambu-gray">
{t('spoolman.reportPartialUsageDesc')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localReportPartialUsage}
onChange={(e) => setLocalReportPartialUsage(e.target.checked)}
disabled={!localEnabled}
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>
)}
{/* 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}
<p className={`text-xs ${!localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
{t('settings.trackingModeBuiltInDesc')}
</p>
{!localEnabled && (
<div className="flex items-center gap-1 mt-2">
<Check className="w-3 h-3 text-bambu-green" />
<span className="text-xs text-bambu-green">{t('common.enabled')}</span>
</div>
)}
</button>
{/* 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
{/* Spoolman */}
<button
type="button"
onClick={() => setLocalEnabled(true)}
className={`p-3 rounded-lg border-2 text-left transition-colors ${
localEnabled
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray/50'
}`}
>
<div className="flex items-center gap-2 mb-1.5">
<ExternalLink className={`w-4 h-4 ${localEnabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
<span className={`text-sm font-medium ${localEnabled ? 'text-white' : 'text-bambu-gray'}`}>
Spoolman
</span>
</div>
<p className={`text-xs ${localEnabled ? 'text-bambu-gray' : 'text-bambu-gray/60'}`}>
{t('settings.trackingModeSpoolmanDesc')}
</p>
{localEnabled && (
<div className="flex items-center gap-1 mt-2">
<Check className="w-3 h-3 text-bambu-green" />
<span className="text-xs text-bambu-green">{t('common.enabled')}</span>
</div>
)}
</button>
</div>
{/* Built-in Inventory details */}
{!localEnabled && (
<div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
<div className="flex gap-2">
<Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
<div className="text-xs text-bambu-gray">
<ul className="list-disc list-inside space-y-0.5">
<li>{t('settings.builtInFeatureRfid')}</li>
<li>{t('settings.builtInFeatureUsage')}</li>
<li>{t('settings.builtInFeatureCatalog')}</li>
</ul>
</div>
</div>
</div>
)}
{/* Spoolman settings - only shown when Spoolman mode is selected */}
{localEnabled && (
<div className="space-y-4">
{/* Info banner about sync requirements */}
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<div className="flex gap-2">
<Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-blue-300">
<p className="font-medium mb-1">{t('settings.howSyncWorks')}</p>
<ul className="list-disc list-inside space-y-0.5 text-blue-300/80">
<li>{t('settings.syncInfoRfidOnly')}</li>
<li>{t('settings.syncInfoAutoCreate')}</li>
<li>{t('settings.syncInfoThirdPartySkipped')}</li>
</ul>
<p className="font-medium mt-2 mb-1">{t('settings.linkingExistingSpools')}</p>
<p className="text-blue-300/80">
{t('settings.linkingExistingSpoolsDesc')}
</p>
</div>
</div>
</div>
{/* URL input */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.spoolmanUrl')}
</label>
<input
type="text"
placeholder="http://192.168.1.100:7912"
value={localUrl}
onChange={(e) => setLocalUrl(e.target.value)}
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"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.spoolmanUrlHint')}
</p>
</div>
{/* Sync mode */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.syncMode')}
</label>
<select
value={localSyncMode}
onChange={(e) => setLocalSyncMode(e.target.value)}
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"
>
<option value="auto">{t('settings.syncModeAuto')}</option>
<option value="manual">{t('settings.syncModeManual')}</option>
</select>
<p className="text-xs text-bambu-gray mt-1">
{localSyncMode === 'auto'
? t('settings.syncModeAutoDesc')
: t('settings.syncModeManualDesc')}
</p>
</div>
{/* Disable Weight Sync toggle - only show when sync mode is auto */}
{localSyncMode === 'auto' && (
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('spoolman.disableWeightSync')}</p>
<p className="text-sm text-bambu-gray">
{t('spoolman.disableWeightSyncDesc')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localDisableWeightSync}
onChange={(e) => setLocalDisableWeightSync(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>
)}
{/* Report Partial Usage toggle - only show when weight sync is disabled */}
{localDisableWeightSync && (
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('spoolman.reportPartialUsage')}</p>
<p className="text-sm text-bambu-gray">
{t('spoolman.reportPartialUsageDesc')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localReportPartialUsage}
onChange={(e) => setLocalReportPartialUsage(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>
)}
{/* Connection status */}
<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">
{/* 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"
<span className="text-sm text-bambu-gray">{t('settings.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" />
{t('settings.spoolmanConnected')}
</span>
) : (
<span className="flex items-center gap-1 text-sm text-red-500">
<X className="w-4 h-4" />
{t('settings.spoolmanDisconnected')}
</span>
)}
</div>
<div className="flex gap-2">
{status?.connected ? (
<Button
variant="secondary"
size="sm"
onClick={() => disconnectMutation.mutate()}
disabled={disconnectMutation.isPending}
>
<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>
{disconnectMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Link2Off className="w-4 h-4" />
)}
{t('settings.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" />
)}
{t('settings.connect')}
</Button>
)}
</div>
</div>
)}
{/* Sync result */}
{syncSuccess && syncResult && (
<div className="mt-3 space-y-2">
{/* Main result */}
<div
className={`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} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`
: `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
{/* 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>
)}
{/* Skipped spools */}
{syncResult.skipped_count > 0 && (
<div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm">
<div className="flex items-center justify-between text-amber-400 mb-1">
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="font-medium">
{syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
</span>
</div>
{syncResult.skipped_count > 5 && (
<button
onClick={() => setShowAllSkipped(!showAllSkipped)}
className="text-xs text-amber-400 hover:text-amber-300 underline"
>
{showAllSkipped ? 'Show less' : 'Show all'}
</button>
)}
{/* Manual sync section */}
{status?.connected && (
<div className="space-y-3">
<div>
<p className="text-sm text-white">{t('settings.syncAmsData')}</p>
<p className="text-xs text-bambu-gray">
{t('settings.syncAmsDataDesc')}
</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">{t('settings.allPrinters')}</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>
<ul className="text-xs text-amber-300/80 space-y-0.5">
{(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
<li key={i} className="flex items-center gap-2">
{s.color && (
<span
className="w-3 h-3 rounded-full border border-white/20"
style={{ backgroundColor: `#${s.color}` }}
/>
)}
<span>{s.location}</span>
<span className="text-amber-300/60">- {s.reason}</span>
</li>
))}
{!showAllSkipped && syncResult.skipped_count > 5 && (
<li className="text-amber-300/60 italic">
...and {syncResult.skipped_count - 5} more
</li>
{/* 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" />
)}
</ul>
{t('spoolman.sync')}
</Button>
</div>
)}
</div>
)}
{/* Errors */}
{syncResult.errors.length > 0 && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-sm">
<div className="text-red-400 font-medium mb-1">Errors:</div>
<ul className="text-xs text-red-300/80 space-y-0.5">
{syncResult.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
{/* Sync result */}
{syncSuccess && syncResult && (
<div className="mt-3 space-y-2">
{/* Main result */}
<div
className={`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} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`
: `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
</div>
)}
</div>
)}
{/* Skipped spools */}
{syncResult.skipped_count > 0 && (
<div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm">
<div className="flex items-center justify-between text-amber-400 mb-1">
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="font-medium">
{syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
</span>
</div>
{syncResult.skipped_count > 5 && (
<button
onClick={() => setShowAllSkipped(!showAllSkipped)}
className="text-xs text-amber-400 hover:text-amber-300 underline"
>
{showAllSkipped ? 'Show less' : 'Show all'}
</button>
)}
</div>
<ul className="text-xs text-amber-300/80 space-y-0.5">
{(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
<li key={i} className="flex items-center gap-2">
{s.color && (
<span
className="w-3 h-3 rounded-full border border-white/20"
style={{ backgroundColor: `#${s.color}` }}
/>
)}
<span>{s.location}</span>
<span className="text-amber-300/60">- {s.reason}</span>
</li>
))}
{!showAllSkipped && syncResult.skipped_count > 5 && (
<li className="text-amber-300/60 italic">
...and {syncResult.skipped_count - 5} more
</li>
)}
</ul>
</div>
)}
{/* Errors */}
{syncResult.errors.length > 0 && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-sm">
<div className="text-red-400 font-medium mb-1">Errors:</div>
<ul className="text-xs text-red-300/80 space-y-0.5">
{syncResult.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
)}
</CardContent>
@@ -0,0 +1,176 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Scale } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AdditionalSectionProps } from './types';
function SpoolWeightPicker({
catalog,
value,
onChange,
}: {
catalog: { id: number; name: string; weight: number }[];
value: number;
onChange: (weight: number) => void;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const filtered = useMemo(() => {
if (!search) return catalog;
const s = search.toLowerCase();
return catalog.filter(e =>
e.name.toLowerCase().includes(s) ||
e.weight.toString().includes(s),
);
}, [catalog, search]);
// Display value: show catalog name if selected, or the weight
const displayValue = useMemo(() => {
if (isOpen) return search;
if (selectedId) {
const entry = catalog.find(e => e.id === selectedId);
if (entry) return entry.name;
}
const match = catalog.find(e => e.weight === value);
if (match) return match.name;
return '';
}, [isOpen, search, selectedId, catalog, value]);
return (
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
<span className="flex items-center gap-2">
<Scale className="w-3.5 h-3.5 text-bambu-gray" />
{t('inventory.coreWeight')}
</span>
</label>
<div className="flex gap-2 items-center">
<div className="flex-1 min-w-0 relative" ref={dropdownRef}>
<input
ref={inputRef}
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.searchSpoolWeight')}
value={displayValue}
onFocus={() => {
setIsOpen(true);
setSearch('');
}}
onChange={(e) => {
setSearch(e.target.value);
setIsOpen(true);
}}
/>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
{filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
) : (
filtered.map(entry => (
<button
key={entry.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
(selectedId ? entry.id === selectedId : entry.weight === value)
? 'bg-bambu-green/10 text-bambu-green'
: 'text-white'
}`}
onClick={() => {
setSelectedId(entry.id);
onChange(entry.weight);
setIsOpen(false);
setSearch('');
}}
>
<span className="truncate">{entry.name}</span>
<span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{entry.weight}g</span>
</button>
))
)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<input
type="number"
className="w-16 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm text-center font-mono focus:outline-none focus:border-bambu-green"
value={value}
min={0}
max={2000}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 0) onChange(val);
}}
/>
<span className="text-bambu-gray text-sm">g</span>
</div>
</div>
</div>
);
}
export function AdditionalSection({
formData,
updateField,
spoolCatalog,
}: AdditionalSectionProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{/* Empty Spool Weight */}
<SpoolWeightPicker
catalog={spoolCatalog}
value={formData.core_weight}
onChange={(weight) => updateField('core_weight', weight)}
/>
{/* Current Weight (remaining filament) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.currentWeight')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="number"
value={Math.max(0, formData.label_weight - formData.weight_used)}
min={0}
max={formData.label_weight}
onChange={(e) => {
const remaining = parseInt(e.target.value) || 0;
updateField('weight_used', Math.max(0, formData.label_weight - remaining));
}}
className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
</div>
<span className="text-xs text-bambu-gray shrink-0">/ {formData.label_weight}g</span>
</div>
</div>
{/* Note */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
<textarea
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green resize-none min-h-[80px]"
placeholder={t('inventory.notePlaceholder')}
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
/>
</div>
</div>
);
}
@@ -0,0 +1,286 @@
import { useState, useMemo } from 'react';
import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ColorSectionProps } from './types';
import { QUICK_COLORS, ALL_COLORS } from './constants';
export function ColorSection({
formData,
updateField,
recentColors,
onColorUsed,
catalogColors,
}: ColorSectionProps) {
const { t } = useTranslation();
const [showAllColors, setShowAllColors] = useState(false);
const [colorSearch, setColorSearch] = useState('');
// Current hex without # prefix
const currentHex = formData.rgba.replace('#', '').substring(0, 6);
const isSelected = (hex: string) => {
return currentHex.toUpperCase() === hex.toUpperCase();
};
const selectColor = (hex: string, name: string) => {
// Store as RRGGBBAA (with FF alpha)
updateField('rgba', hex.toUpperCase() + 'FF');
updateField('color_name', name);
onColorUsed({ name, hex });
};
// Filter catalog colors by the selected brand + material + subtype
// Brand matching is word-based: "mz - Bambu" matches "Bambu Lab" because both contain "Bambu"
// Material matching: try exact "PETG Basic" first, fall back to base material "PETG" prefix
const matchedCatalogColors = useMemo(() => {
if (catalogColors.length === 0) return [];
const brand = formData.brand?.trim();
const material = formData.material?.toLowerCase().trim();
const subtype = formData.subtype?.toLowerCase().trim();
if (!brand && !material) return [];
// Split brand into words (>= 2 chars) for word-based matching
const brandWords = brand
? brand.toLowerCase().split(/[\s\-_]+/).filter(w => w.length >= 2)
: [];
const brandMatches = (manufacturer: string) => {
if (brandWords.length === 0) return true; // no brand filter
const mfrLower = manufacturer.toLowerCase();
// Any significant brand word found in manufacturer name
return brandWords.some(w => mfrLower.includes(w));
};
// Build the combined material+subtype string to match catalog entries
const fullMaterial = material && subtype ? `${material} ${subtype}` : '';
// First pass: try exact fullMaterial match (e.g. "PETG Basic")
if (fullMaterial) {
const exact = catalogColors.filter(c =>
brandMatches(c.manufacturer) &&
c.material?.toLowerCase() === fullMaterial,
);
if (exact.length > 0) {
return exact.map(c => ({
name: c.color_name,
hex: c.hex_color.replace('#', '').substring(0, 6),
}));
}
// Try without trailing "+" (e.g. "PLA Silk+" -> "PLA Silk")
const normalized = fullMaterial.replace(/\+$/, '');
if (normalized !== fullMaterial) {
const normMatch = catalogColors.filter(c =>
brandMatches(c.manufacturer) &&
c.material?.toLowerCase() === normalized,
);
if (normMatch.length > 0) {
return normMatch.map(c => ({
name: c.color_name,
hex: c.hex_color.replace('#', '').substring(0, 6),
}));
}
}
}
// Second pass: match base material prefix (e.g. "PETG" matches "PETG Basic", "PETG-HF")
if (material) {
const byMaterial = catalogColors.filter(c =>
brandMatches(c.manufacturer) &&
(!c.material || c.material.toLowerCase().startsWith(material)),
);
if (byMaterial.length > 0) {
return byMaterial.map(c => ({
name: c.color_name,
hex: c.hex_color.replace('#', '').substring(0, 6),
}));
}
}
return [];
}, [catalogColors, formData.brand, formData.material, formData.subtype]);
const hasCatalogMatch = matchedCatalogColors.length > 0;
// Search within matched catalog colors
const filteredCatalogColors = useMemo(() => {
if (!colorSearch) return matchedCatalogColors;
const q = colorSearch.toLowerCase();
return matchedCatalogColors.filter(c => c.name.toLowerCase().includes(q));
}, [matchedCatalogColors, colorSearch]);
// Fallback hardcoded colors for search/expand
const filteredFallbackColors = useMemo(() => {
if (colorSearch) {
return ALL_COLORS.filter(c =>
c.name.toLowerCase().includes(colorSearch.toLowerCase()),
);
}
return showAllColors ? ALL_COLORS : QUICK_COLORS;
}, [colorSearch, showAllColors]);
return (
<div className="space-y-3">
{/* Color preview banner */}
<div
className="h-10 rounded-lg border border-bambu-dark-tertiary"
style={{ backgroundColor: `#${currentHex}` }}
/>
{/* Recently Used Colors */}
{recentColors.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs text-bambu-gray shrink-0">
<Clock className="w-3 h-3" />
<span>{t('inventory.recentColors')}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{recentColors.map(color => (
<button
key={color.hex}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
/>
))}
</div>
</div>
)}
{/* Color Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
<input
type="text"
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.searchColors')}
value={colorSearch}
onChange={(e) => setColorSearch(e.target.value)}
/>
</div>
{/* Color Swatches */}
{hasCatalogMatch ? (
/* Catalog colors matching selected brand/material */
<div className="space-y-1.5">
<span className="text-xs text-bambu-gray">
{colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}
</span>
<div className="flex flex-wrap gap-1.5">
{filteredCatalogColors.map(color => (
<button
key={`${color.hex}-${color.name}`}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
>
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
{color.name}
</span>
</button>
))}
{filteredCatalogColors.length === 0 && (
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
)}
</div>
</div>
) : (
/* Fallback: hardcoded color palette (no brand/material selected or no catalog matches) */
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-bambu-gray">
<span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
{!colorSearch && (
<button
type="button"
onClick={() => setShowAllColors(!showAllColors)}
className="flex items-center gap-1 hover:text-white transition-colors"
>
{showAllColors ? (
<>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
) : (
<>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
)}
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{filteredFallbackColors.map(color => (
<button
key={color.hex}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
>
<span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
{color.name}
</span>
</button>
))}
{filteredFallbackColors.length === 0 && (
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
)}
</div>
</div>
)}
{/* Manual Color Input */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.colorName')}</label>
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.colorNamePlaceholder')}
value={formData.color_name}
onChange={(e) => updateField('color_name', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.hexColor')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray">#</span>
<input
type="text"
className="w-full pl-7 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono uppercase focus:outline-none focus:border-bambu-green"
placeholder="RRGGBB"
value={currentHex.toUpperCase()}
onChange={(e) => {
const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '');
if (val.length <= 8) updateField('rgba', val.toUpperCase() + (val.length <= 6 ? 'FF' : ''));
}}
/>
</div>
<input
type="color"
className="w-11 h-[38px] rounded-lg cursor-pointer border border-bambu-dark-tertiary shrink-0 bg-transparent"
value={`#${currentHex}`}
onChange={(e) => {
const hex = e.target.value.replace('#', '').toUpperCase();
updateField('rgba', hex + 'FF');
}}
title={t('inventory.pickColor')}
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,245 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { FilamentSectionProps, FilamentOption } from './types';
import { MATERIALS, KNOWN_VARIANTS } from './constants';
import { parsePresetName } from './utils';
export function FilamentSection({
formData,
updateField,
cloudAuthenticated,
loadingCloudPresets,
presetInputValue,
setPresetInputValue,
selectedPresetOption,
filamentOptions,
availableBrands,
}: FilamentSectionProps) {
const { t } = useTranslation();
const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
const [brandSearch, setBrandSearch] = useState('');
const presetRef = useRef<HTMLDivElement>(null);
const brandRef = useRef<HTMLDivElement>(null);
// Close dropdowns on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (presetRef.current && !presetRef.current.contains(e.target as Node)) {
setPresetDropdownOpen(false);
}
if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
setBrandDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// Filtered presets based on search
const filteredPresets = useMemo(() => {
if (!presetInputValue) return filamentOptions;
const search = presetInputValue.toLowerCase();
return filamentOptions.filter(o =>
o.displayName.toLowerCase().includes(search) ||
o.code.toLowerCase().includes(search),
);
}, [filamentOptions, presetInputValue]);
// Filtered brands
const filteredBrands = useMemo(() => {
if (!brandSearch) return availableBrands;
const search = brandSearch.toLowerCase();
return availableBrands.filter(b => b.toLowerCase().includes(search));
}, [availableBrands, brandSearch]);
// Handle preset selection
const handlePresetSelect = (option: FilamentOption) => {
updateField('slicer_filament', option.code);
setPresetInputValue(option.displayName);
setPresetDropdownOpen(false);
// Auto-fill material, brand, subtype from preset name
const parsed = parsePresetName(option.name);
if (parsed.material) updateField('material', parsed.material);
if (parsed.brand) updateField('brand', parsed.brand);
if (parsed.variant) updateField('subtype', parsed.variant);
};
return (
<div className="space-y-4">
{/* Cloud status indicator */}
<div className="flex items-center gap-2 text-xs text-bambu-gray">
{loadingCloudPresets ? (
<><Loader2 className="w-3 h-3 animate-spin" /> {t('inventory.loadingPresets')}</>
) : cloudAuthenticated ? (
<><Cloud className="w-3 h-3 text-bambu-green" /> {t('inventory.cloudConnected')}</>
) : (
<><CloudOff className="w-3 h-3" /> {t('inventory.cloudNotConnected')}</>
)}
</div>
{/* Slicer Preset (autocomplete) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
{t('inventory.slicerPreset')} *
</label>
<div className="relative" ref={presetRef}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
<input
type="text"
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.searchPresets')}
value={presetInputValue}
onChange={(e) => {
setPresetInputValue(e.target.value);
setPresetDropdownOpen(true);
}}
onFocus={() => {
setPresetDropdownOpen(true);
setPresetInputValue('');
}}
/>
{presetDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
{filteredPresets.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noPresetsFound')}</div>
) : (
filteredPresets.map(option => (
<button
key={option.code}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
selectedPresetOption?.code === option.code
? 'bg-bambu-green/10 text-bambu-green'
: 'text-white'
}`}
onClick={() => handlePresetSelect(option)}
>
<span className="truncate">{option.displayName}</span>
<span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{option.code}</span>
</button>
))
)}
</div>
)}
</div>
{selectedPresetOption && (
<div className="mt-1 text-xs text-bambu-gray">
{t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
</div>
)}
</div>
{/* Material */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
<select
value={formData.material}
onChange={(e) => updateField('material', e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
>
<option value="">{t('inventory.selectMaterial')}</option>
{MATERIALS.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
{/* Brand (dropdown with search) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')}</label>
<div className="relative" ref={brandRef}>
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
placeholder={t('inventory.searchBrand')}
value={brandDropdownOpen ? brandSearch : formData.brand}
onChange={(e) => {
setBrandSearch(e.target.value);
setBrandDropdownOpen(true);
}}
onFocus={() => {
setBrandDropdownOpen(true);
setBrandSearch('');
}}
/>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
{brandDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
{filteredBrands.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
) : (
filteredBrands.map(brand => (
<button
key={brand}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
}`}
onClick={() => {
updateField('brand', brand);
setBrandDropdownOpen(false);
setBrandSearch('');
}}
>
{brand}
</button>
))
)}
{/* Allow custom brand */}
{brandSearch && !filteredBrands.includes(brandSearch) && (
<button
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
onClick={() => {
updateField('brand', brandSearch);
setBrandDropdownOpen(false);
setBrandSearch('');
}}
>
{t('inventory.useCustomBrand', { brand: brandSearch })}
</button>
)}
</div>
)}
</div>
</div>
{/* Variant / Subtype */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
<div className="relative">
<input
type="text"
value={formData.subtype}
onChange={(e) => updateField('subtype', e.target.value)}
list="variant-suggestions"
placeholder="Basic, Matte, Silk..."
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
/>
<datalist id="variant-suggestions">
{KNOWN_VARIANTS.map(v => (
<option key={v} value={v} />
))}
</datalist>
</div>
</div>
{/* Label Weight */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.labelWeight')}</label>
<div className="relative">
<input
type="number"
value={formData.label_weight}
onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,268 @@
import { ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { CalibrationProfile, PAProfileSectionProps } from './types';
import { isMatchingCalibration } from './utils';
export function PAProfileSection({
formData,
printersWithCalibrations,
selectedProfiles,
setSelectedProfiles,
expandedPrinters,
setExpandedPrinters,
}: PAProfileSectionProps) {
const { t } = useTranslation();
const togglePrinterExpanded = (printerId: string) => {
setExpandedPrinters((prev) => {
const next = new Set(prev);
if (next.has(printerId)) next.delete(printerId);
else next.add(printerId);
return next;
});
};
const toggleProfileSelected = (printerId: string, caliIdx: number, extruderId?: number | null) => {
const key = `${printerId}:${caliIdx}:${extruderId ?? 'null'}`;
const printerNozzleKey = `${printerId}:${extruderId ?? 'null'}`;
setSelectedProfiles((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
// Remove existing profile for same printer/nozzle
for (const existingKey of Array.from(next)) {
const parts = existingKey.split(':');
const existingPrinterNozzle = `${parts[0]}:${parts[2]}`;
if (existingPrinterNozzle === printerNozzleKey) {
next.delete(existingKey);
}
}
next.add(key);
}
return next;
});
};
// Auto-select best matching profiles
const autoSelectProfiles = () => {
const newSelection = new Set<string>();
for (const { printer, calibrations } of printersWithCalibrations) {
if (!printer.connected) continue;
const matchingCals = calibrations.filter(cal =>
isMatchingCalibration(cal, formData),
);
// Group by extruder
const byExtruder = new Map<string, CalibrationProfile[]>();
for (const cal of matchingCals) {
const extKey = `${cal.extruder_id ?? 'null'}`;
if (!byExtruder.has(extKey)) byExtruder.set(extKey, []);
byExtruder.get(extKey)!.push(cal);
}
// Select best (highest K) for each extruder
for (const [extKey, cals] of byExtruder) {
if (cals.length > 0) {
const sorted = [...cals].sort((a, b) => b.k_value - a.k_value);
const best = sorted[0];
newSelection.add(`${printer.id}:${best.cali_idx}:${extKey}`);
}
}
}
setSelectedProfiles(newSelection);
};
if (!formData.material) {
return (
<div className="p-6 bg-bambu-dark rounded-lg text-center">
<p className="text-bambu-gray">
{t('inventory.selectMaterialFirst')}
</p>
</div>
);
}
if (printersWithCalibrations.length === 0) {
return (
<div className="p-6 bg-bambu-dark rounded-lg text-center">
<p className="text-bambu-gray">
{t('inventory.noPrintersConfigured')}
</p>
</div>
);
}
// Count total matching profiles
const totalMatching = printersWithCalibrations.reduce((sum, { printer, calibrations }) => {
if (!printer.connected) return sum;
return sum + calibrations.filter(cal => isMatchingCalibration(cal, formData)).length;
}, 0);
const renderProfile = (printer: { id: number }, cal: CalibrationProfile) => {
const key = `${printer.id}:${cal.cali_idx}:${cal.extruder_id ?? 'null'}`;
const isSelected = selectedProfiles.has(key);
return (
<label
key={`${cal.cali_idx}-${cal.extruder_id}`}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all border ${
isSelected
? 'bg-bambu-green/10 border-bambu-green/30'
: 'bg-bambu-dark border-transparent hover:bg-bambu-dark/80'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleProfileSelected(String(printer.id), cal.cali_idx, cal.extruder_id)}
className="w-4 h-4 rounded border-bambu-dark-tertiary text-bambu-green focus:ring-bambu-green"
/>
<div className="flex-1 min-w-0">
<span className={`text-sm font-medium ${isSelected ? 'text-bambu-green' : 'text-white'}`}>
{cal.name || cal.filament_id}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
K={cal.k_value.toFixed(3)}
</span>
</div>
</label>
);
};
return (
<div className="space-y-4">
{/* Header with auto-select */}
<div className="flex items-center justify-between">
<p className="text-xs text-bambu-gray">
{t('inventory.matchingFilter')}: {formData.brand || t('inventory.anyBrand')} / {formData.material} / {formData.subtype || t('inventory.anyVariant')}
</p>
{totalMatching > 0 && (
<button
type="button"
onClick={autoSelectProfiles}
className="flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
>
<Sparkles className="w-3.5 h-3.5" />
{t('inventory.autoSelect')} ({totalMatching})
</button>
)}
</div>
{/* Printer sections */}
<div className="space-y-3">
{printersWithCalibrations.map(({ printer, calibrations }) => {
const isExpanded = expandedPrinters.has(String(printer.id));
const matchingCals = calibrations.filter(cal => isMatchingCalibration(cal, formData));
const matchingCount = matchingCals.length;
// Multi-nozzle grouping
const isMultiNozzle = matchingCals.some(cal =>
cal.extruder_id !== undefined && cal.extruder_id !== null && cal.extruder_id > 0,
);
const leftNozzleCals = matchingCals.filter(cal => cal.extruder_id === 1);
const rightNozzleCals = matchingCals.filter(cal =>
cal.extruder_id === 0 || cal.extruder_id === undefined || cal.extruder_id === null,
);
return (
<div
key={printer.id}
className="border border-bambu-dark-tertiary rounded-lg overflow-hidden"
>
{/* Printer Header */}
<button
type="button"
onClick={() => togglePrinterExpanded(String(printer.id))}
className="w-full px-4 py-3 flex items-center justify-between bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-bambu-gray" />
) : (
<ChevronRight className="w-4 h-4 text-bambu-gray" />
)}
<span className="font-medium text-white">
{printer.name}
</span>
{matchingCount > 0 ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
{matchingCount} {matchingCount !== 1 ? t('inventory.matches') : t('inventory.match')}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray">
{t('inventory.noMatches')}
</span>
)}
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
printer.connected
? 'bg-green-500/20 text-green-500'
: 'bg-bambu-gray/20 text-bambu-gray'
}`}>
{printer.connected ? t('inventory.connected') : t('inventory.offline')}
</span>
</button>
{/* Calibration Profiles */}
{isExpanded && (
<div className="px-4 py-3 space-y-3 bg-bambu-dark border-t border-bambu-dark-tertiary">
{!printer.connected ? (
<p className="text-sm text-bambu-gray italic py-2">
{t('inventory.printerOffline')}
</p>
) : matchingCount === 0 ? (
<p className="text-sm text-bambu-gray italic py-2">
{t('inventory.noKProfilesMatch')}
</p>
) : isMultiNozzle ? (
<>
{leftNozzleCals.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
{t('inventory.leftNozzle')}
</p>
<div className="space-y-2">
{leftNozzleCals.map(cal => renderProfile(printer, cal))}
</div>
</div>
)}
{rightNozzleCals.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
{t('inventory.rightNozzle')}
</p>
<div className="space-y-2">
{rightNozzleCals.map(cal => renderProfile(printer, cal))}
</div>
</div>
)}
</>
) : (
<div className="space-y-2">
{matchingCals.map(cal => renderProfile(printer, cal))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Summary */}
{selectedProfiles.size > 0 && (
<div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
<p className="text-sm text-white">
<span className="font-semibold">{selectedProfiles.size}</span> {t('inventory.profilesSelected')}
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import type { ColorPreset } from './types';
// Material options
export const MATERIALS = [
'PLA', 'PETG', 'ABS', 'TPU', 'ASA', 'PC', 'PA', 'PVA', 'HIPS',
'PA-CF', 'PETG-CF', 'PLA-CF',
];
// Common spool weights
export const WEIGHTS = [250, 500, 750, 1000, 2000, 3000];
// Default brand options (will be augmented with cloud presets)
export const DEFAULT_BRANDS = [
'Bambu', 'PolyLite', 'PolyTerra', 'eSUN', 'Overture',
'Fiberon', 'SUNLU', 'Inland', 'Hatchbox', 'Generic',
];
// Known filament variants/subtypes
export const KNOWN_VARIANTS = [
'Basic', 'Matte', 'Silk', 'Tough', 'HF', 'High Flow', 'Engineering',
'Galaxy', 'Glow', 'Marble', 'Metal', 'Rainbow', 'Sparkle', 'Wood',
'Translucent', 'Transparent', 'Clear', 'Lite', 'Pro', 'Plus', 'Max',
'Super', 'Ultra', 'Flex', 'Soft', 'Hard', 'Strong', 'Impact',
'Heat Resistant', 'UV Resistant', 'ESD', 'Conductive', 'Magnetic',
'Gradient', 'Dual Color', 'Tri Color', 'Multicolor',
];
// Quick color swatches - most common colors (shown by default)
export const QUICK_COLORS: ColorPreset[] = [
{ name: 'Black', hex: '000000' },
{ name: 'White', hex: 'FFFFFF' },
{ name: 'Gray', hex: '808080' },
{ name: 'Red', hex: 'FF0000' },
{ name: 'Orange', hex: 'FFA500' },
{ name: 'Yellow', hex: 'FFFF00' },
{ name: 'Green', hex: '00AE42' },
{ name: 'Blue', hex: '0066FF' },
{ name: 'Purple', hex: '8B00FF' },
{ name: 'Pink', hex: 'FF69B4' },
{ name: 'Brown', hex: '8B4513' },
{ name: 'Silver', hex: 'C0C0C0' },
];
// Extended color palette (shown when expanded)
export const EXTENDED_COLORS: ColorPreset[] = [
// Reds
{ name: 'Dark Red', hex: '8B0000' },
{ name: 'Crimson', hex: 'DC143C' },
{ name: 'Coral', hex: 'FF7F50' },
{ name: 'Salmon', hex: 'FA8072' },
// Oranges
{ name: 'Dark Orange', hex: 'FF8C00' },
{ name: 'Peach', hex: 'FFDAB9' },
// Yellows
{ name: 'Gold', hex: 'FFD700' },
{ name: 'Khaki', hex: 'F0E68C' },
{ name: 'Lemon', hex: 'FFF44F' },
// Greens
{ name: 'Lime', hex: '32CD32' },
{ name: 'Forest Green', hex: '228B22' },
{ name: 'Olive', hex: '808000' },
{ name: 'Mint', hex: '98FF98' },
{ name: 'Teal', hex: '008080' },
// Blues
{ name: 'Navy', hex: '000080' },
{ name: 'Sky Blue', hex: '87CEEB' },
{ name: 'Royal Blue', hex: '4169E1' },
{ name: 'Cyan', hex: '00FFFF' },
{ name: 'Turquoise', hex: '40E0D0' },
// Purples
{ name: 'Violet', hex: 'EE82EE' },
{ name: 'Magenta', hex: 'FF00FF' },
{ name: 'Indigo', hex: '4B0082' },
{ name: 'Lavender', hex: 'E6E6FA' },
{ name: 'Plum', hex: 'DDA0DD' },
// Pinks
{ name: 'Hot Pink', hex: 'FF69B4' },
{ name: 'Rose', hex: 'FF007F' },
{ name: 'Blush', hex: 'FFB6C1' },
// Browns
{ name: 'Chocolate', hex: 'D2691E' },
{ name: 'Tan', hex: 'D2B48C' },
{ name: 'Beige', hex: 'F5F5DC' },
{ name: 'Maroon', hex: '800000' },
// Neutrals
{ name: 'Dark Gray', hex: '404040' },
{ name: 'Light Gray', hex: 'D3D3D3' },
{ name: 'Charcoal', hex: '36454F' },
{ name: 'Ivory', hex: 'FFFFF0' },
// Bambu specific
{ name: 'Bambu Green', hex: '00AE42' },
{ name: 'Jade White', hex: 'E8E8E8' },
{ name: 'Titan Gray', hex: '5A5A5A' },
];
// All colors combined
export const ALL_COLORS: ColorPreset[] = [...QUICK_COLORS, ...EXTENDED_COLORS];
// Local storage keys
export const RECENT_COLORS_KEY = 'bambuddy-recent-colors';
export const MAX_RECENT_COLORS = 8;
+127
View File
@@ -0,0 +1,127 @@
import type { Printer, SpoolKProfile } from '../../api/client';
// Form data structure
export interface SpoolFormData {
material: string;
subtype: string;
brand: string;
color_name: string;
rgba: string;
label_weight: number;
core_weight: number;
weight_used: number;
slicer_filament: string;
note: string;
}
export const defaultFormData: SpoolFormData = {
material: '',
subtype: '',
brand: '',
color_name: '',
rgba: '808080FF',
label_weight: 1000,
core_weight: 250,
weight_used: 0,
slicer_filament: '',
note: '',
};
// Printer with calibrations type
export interface PrinterWithCalibrations {
printer: Printer & { connected?: boolean };
calibrations: CalibrationProfile[];
}
// Calibration profile from printer status
export interface CalibrationProfile {
cali_idx: number;
filament_id: string;
setting_id: string;
name: string;
k_value: number;
n_coef: number;
extruder_id?: number | null;
nozzle_diameter?: string;
}
// Filament option from presets
export interface FilamentOption {
code: string;
name: string;
displayName: string;
isCustom: boolean;
allCodes: string[];
}
// Color preset
export interface ColorPreset {
name: string;
hex: string;
}
// Section props base
export interface SectionProps {
formData: SpoolFormData;
updateField: <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => void;
}
// Filament section props
export interface FilamentSectionProps extends SectionProps {
cloudAuthenticated: boolean;
loadingCloudPresets: boolean;
presetInputValue: string;
setPresetInputValue: (value: string) => void;
selectedPresetOption?: FilamentOption;
filamentOptions: FilamentOption[];
availableBrands: string[];
}
// Color section props
export interface ColorSectionProps extends SectionProps {
recentColors: ColorPreset[];
onColorUsed: (color: ColorPreset) => void;
catalogColors: { manufacturer: string; color_name: string; hex_color: string; material: string | null }[];
}
// Additional section props
export interface AdditionalSectionProps extends SectionProps {
spoolCatalog: { id: number; name: string; weight: number }[];
}
// PA Profile section props
export interface PAProfileSectionProps extends SectionProps {
printersWithCalibrations: PrinterWithCalibrations[];
selectedProfiles: Set<string>;
setSelectedProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
expandedPrinters: Set<string>;
setExpandedPrinters: React.Dispatch<React.SetStateAction<Set<string>>>;
}
// Validation result
export interface ValidationResult {
isValid: boolean;
errors: Partial<Record<keyof SpoolFormData, string>>;
}
export function validateForm(formData: SpoolFormData): ValidationResult {
const errors: Partial<Record<keyof SpoolFormData, string>> = {};
if (!formData.slicer_filament) {
errors.slicer_filament = 'Slicer preset is required';
}
if (!formData.material) {
errors.material = 'Material is required';
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
// Existing K-profile for a spool (from saved data)
export interface SavedKProfile extends SpoolKProfile {
printer_serial?: string;
}
+301
View File
@@ -0,0 +1,301 @@
import type { SlicerSetting, LocalPreset } from '../../api/client';
import type { ColorPreset, FilamentOption } from './types';
import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
// Fallback filament presets when cloud is not available
const FALLBACK_PRESETS: FilamentOption[] = [
{ code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },
{ code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'] },
{ code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'] },
{ code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'] },
{ code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'] },
{ code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'] },
{ code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'] },
{ code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'] },
{ code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'] },
{ code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'] },
{ code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'] },
{ code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'] },
{ code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'] },
{ code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'] },
{ code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'] },
{ code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'] },
{ code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'] },
];
// Parse a slicer preset name to extract brand, material, and variant
export function parsePresetName(name: string): { brand: string; material: string; variant: string } {
// Remove @printer suffix (e.g., "@Bambu Lab H2D 0.4 nozzle")
let cleanName = name.replace(/@.*$/, '').trim();
// Remove (Custom) tag
cleanName = cleanName.replace(/\(Custom\)/i, '').trim();
// Remove leading # or * markers
cleanName = cleanName.replace(/^[#*]+\s*/, '').trim();
// Materials list - order matters (longer/more specific first)
const materials = [
'PLA-CF', 'PETG-CF', 'ABS-GF', 'ASA-CF', 'PA-CF', 'PAHT-CF', 'PA6-CF', 'PA6-GF',
'PPA-CF', 'PPA-GF', 'PET-CF', 'PPS-CF', 'PC-CF', 'PC-ABS', 'ABS-GF',
'PETG', 'PLA', 'ABS', 'ASA', 'PC', 'PA', 'TPU', 'PVA', 'HIPS', 'BVOH', 'PPS', 'PCTG', 'PEEK', 'PEI',
];
// Find material in the name
let material = '';
let materialIdx = -1;
for (const m of materials) {
const idx = cleanName.toUpperCase().indexOf(m.toUpperCase());
if (idx !== -1) {
material = m;
materialIdx = idx;
break;
}
}
// Brand is everything before the material
let brand = '';
if (materialIdx > 0) {
brand = cleanName.substring(0, materialIdx).trim();
brand = brand.replace(/[-_\s]+$/, '');
}
// Everything after material is potential variant
let afterMaterial = '';
if (materialIdx !== -1 && material) {
afterMaterial = cleanName.substring(materialIdx + material.length).trim();
afterMaterial = afterMaterial.replace(/^[-_\s]+/, '');
}
// Check for known variant - could be before OR after material
let variant = '';
// First check after material (most common)
for (const v of KNOWN_VARIANTS) {
if (afterMaterial.toLowerCase().includes(v.toLowerCase())) {
variant = v;
break;
}
}
// If no variant found after material, check if brand contains a known variant
if (!variant && brand) {
for (const v of KNOWN_VARIANTS) {
const variantPattern = new RegExp(`\\s+${v}$`, 'i');
if (variantPattern.test(brand)) {
variant = v;
brand = brand.replace(variantPattern, '').trim();
break;
}
}
}
return { brand, material, variant };
}
// Extract unique brands from cloud presets and local presets
export function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?: LocalPreset[]): string[] {
const brandSet = new Set<string>(DEFAULT_BRANDS);
for (const preset of presets) {
const { brand } = parsePresetName(preset.name);
if (brand && brand.length > 1) {
brandSet.add(brand);
}
}
// Also extract brands from local presets
if (localPresets) {
for (const preset of localPresets) {
if (preset.filament_vendor && preset.filament_vendor.length > 1) {
brandSet.add(preset.filament_vendor);
} else {
const { brand } = parsePresetName(preset.name);
if (brand && brand.length > 1) {
brandSet.add(brand);
}
}
}
}
return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
}
// Build filament options from local presets (OrcaSlicer imports)
function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
if (filamentPresets.length === 0) return [];
const presetsMap = new Map<string, FilamentOption>();
for (const preset of filamentPresets) {
const baseName = preset.name.replace(/@.*$/, '').trim();
const existing = presetsMap.get(baseName);
if (existing) {
existing.allCodes.push(String(preset.id));
} else {
// Use filament_type as the code if available (e.g. "GFL00"), otherwise use the id
const code = preset.filament_type || String(preset.id);
presetsMap.set(baseName, {
code,
name: baseName,
displayName: baseName,
isCustom: false,
allCodes: [code],
});
}
}
return Array.from(presetsMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
}
// Build filament options: cloud presets → local presets → hardcoded fallback
export function buildFilamentOptions(
cloudPresets: SlicerSetting[],
configuredPrinterModels: Set<string>,
localPresets?: LocalPreset[],
): FilamentOption[] {
// 1. Cloud presets (highest priority)
if (cloudPresets.length > 0) {
const customPresets: FilamentOption[] = [];
const defaultPresetsMap = new Map<string, FilamentOption>();
for (const preset of cloudPresets) {
if (preset.is_custom) {
// Custom presets: include if matches configured printers or no printer filter
const presetNameUpper = preset.name.toUpperCase();
const matchesPrinter = configuredPrinterModels.size === 0 ||
Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
!presetNameUpper.includes('@');
if (matchesPrinter) {
customPresets.push({
code: preset.setting_id,
name: preset.name,
displayName: `${preset.name} (Custom)`,
isCustom: true,
allCodes: [preset.setting_id],
});
}
} else {
// Default presets: deduplicate by base name
const baseName = preset.name.replace(/@.*$/, '').trim();
const existing = defaultPresetsMap.get(baseName);
if (existing) {
existing.allCodes.push(preset.setting_id);
} else {
defaultPresetsMap.set(baseName, {
code: preset.setting_id,
name: baseName,
displayName: baseName,
isCustom: false,
allCodes: [preset.setting_id],
});
}
}
}
return [
...customPresets,
...Array.from(defaultPresetsMap.values()),
].sort((a, b) => a.displayName.localeCompare(b.displayName));
}
// 2. Local presets (OrcaSlicer imports)
if (localPresets && localPresets.length > 0) {
const localOptions = buildLocalFilamentOptions(localPresets);
if (localOptions.length > 0) return localOptions;
}
// 3. Hardcoded fallback
return FALLBACK_PRESETS;
}
// Find selected preset option
export function findPresetOption(
slicerFilament: string,
filamentOptions: FilamentOption[],
): FilamentOption | undefined {
if (!slicerFilament) return undefined;
// First try exact match on primary code
let option = filamentOptions.find(o => o.code === slicerFilament);
if (!option) {
// Try matching against any code in allCodes
option = filamentOptions.find(o => o.allCodes.includes(slicerFilament));
}
if (!option) {
// Try case-insensitive match
const slicerLower = slicerFilament.toLowerCase();
option = filamentOptions.find(o =>
o.code.toLowerCase() === slicerLower ||
o.allCodes.some(c => c.toLowerCase() === slicerLower),
);
}
return option;
}
// Recent colors management
export function loadRecentColors(): ColorPreset[] {
try {
const stored = localStorage.getItem(RECENT_COLORS_KEY);
if (stored) {
return JSON.parse(stored) as ColorPreset[];
}
} catch {
// Ignore errors
}
return [];
}
export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]): ColorPreset[] {
const filtered = currentRecent.filter(
c => c.hex.toUpperCase() !== color.hex.toUpperCase(),
);
const updated = [color, ...filtered].slice(0, MAX_RECENT_COLORS);
try {
localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated));
} catch {
// Ignore errors
}
return updated;
}
// Check if a calibration matches based on brand, material, and variant
export function isMatchingCalibration(
cal: { name?: string; filament_id?: string },
formData: { material: string; brand: string; subtype: string },
): boolean {
if (!formData.material) return false;
const profileName = cal.name || '';
// Remove flow type prefixes
const cleanName = profileName
.replace(/^High Flow[_\s]+/i, '')
.replace(/^Standard[_\s]+/i, '')
.replace(/^HF[_\s]+/i, '')
.replace(/^S[_\s]+/i, '')
.trim();
const parsed = parsePresetName(cleanName);
// Match material (required)
const materialMatch = parsed.material.toUpperCase() === formData.material.toUpperCase();
if (!materialMatch) return false;
// Match brand if specified in form
if (formData.brand) {
const brandMatch = parsed.brand.toLowerCase().includes(formData.brand.toLowerCase()) ||
formData.brand.toLowerCase().includes(parsed.brand.toLowerCase());
if (!brandMatch) return false;
}
// Match variant/subtype if specified in form
if (formData.subtype) {
const variantMatch = parsed.variant.toLowerCase().includes(formData.subtype.toLowerCase()) ||
formData.subtype.toLowerCase().includes(parsed.variant.toLowerCase()) ||
cleanName.toLowerCase().includes(formData.subtype.toLowerCase());
if (!variantMatch) return false;
}
return true;
}
+24
View File
@@ -226,6 +226,30 @@ export function useWebSocket() {
}
}));
break;
case 'spool_auto_assigned':
// RFID tag matched - refresh inventory and assignment data
debouncedInvalidate('inventory-spools');
debouncedInvalidate('spool-assignments');
break;
case 'spool_usage_logged':
// Filament consumption recorded - refresh spool data
debouncedInvalidate('inventory-spools');
break;
case 'unknown_tag':
// Unknown RFID tag detected - dispatch event for UI
window.dispatchEvent(new CustomEvent('unknown-tag', {
detail: {
printer_id: (message as unknown as { printer_id?: number }).printer_id,
ams_id: (message as unknown as { ams_id?: number }).ams_id,
tray_id: (message as unknown as { tray_id?: number }).tray_id,
tag_uid: (message as unknown as { tag_uid?: string }).tag_uid,
tray_uuid: (message as unknown as { tray_uuid?: string }).tray_uuid,
}
}));
break;
}
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
+253 -4
View File
@@ -8,6 +8,7 @@ export default {
profiles: 'Profile',
maintenance: 'Wartung',
projects: 'Projekte',
inventory: 'Filament',
files: 'Dateimanager',
settings: 'Einstellungen',
system: 'System',
@@ -74,6 +75,8 @@ export default {
dismiss: 'Schließen',
apply: 'Anwenden',
reset: 'Zurücksetzen',
export: 'Exportieren',
import: 'Importieren',
clear: 'Leeren',
selectAll: 'Alle auswählen',
deselectAll: 'Auswahl aufheben',
@@ -697,6 +700,10 @@ export default {
dragToReorder: 'Ziehen zum Neuordnen (nur Sofort)',
reorderHint: 'Position betrifft nur Sofort-Elemente. Geplante Elemente werden zur festgelegten Zeit ausgeführt.',
addedBy: 'Hinzugefügt von {{name}}',
nextInQueue: 'Nächster in der Warteschlange',
clearPlate: 'Druckplatte freigeben & Nächsten starten',
clearPlateSuccess: 'Druckplatte freigegeben — bereit für nächsten Druck',
plateReady: 'Druckplatte freigegeben — bereit für nächsten Druck',
// Sections
sections: {
currentlyPrinting: 'Aktuell druckend',
@@ -1019,9 +1026,9 @@ export default {
network: 'Netzwerk',
apiKeys: 'API-Schlüssel',
virtualPrinter: 'Virtueller Drucker',
users: 'Benutzer',
users: 'Authentifizierung',
backup: 'Sicherung',
globalEmail: 'Globale E-Mail',
emailAuth: 'E-Mail-Authentifizierung',
},
// Email settings
email: {
@@ -1142,11 +1149,37 @@ export default {
turnOn: 'Einschalten',
turnOff: 'Ausschalten',
},
// Spoolman
spoolmanEnabled: 'Spoolman-Integration aktivieren',
// Filament Tracking Mode
filamentTracking: 'Filament-Verfolgung',
filamentTrackingDesc: 'Wählen Sie, wie Sie Ihre Filamentspulen verfolgen möchten. Sie können das integrierte Inventar oder einen externen Spoolman-Server verwenden.',
trackingModeBuiltIn: 'Integriertes Inventar',
trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',
trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',
builtInFeatureRfid: 'Erkennt automatisch Bambu Lab RFID-Spulen im AMS',
builtInFeatureUsage: 'Erfasst den Filamentverbrauch pro Druck',
builtInFeatureCatalog: 'Spulen, Farben und K-Faktor-Profile verwalten',
// Spoolman settings
spoolmanUrl: 'Spoolman URL',
spoolmanUrlHint: 'URL Ihres Spoolman-Servers (z.B. http://localhost:7912)',
spoolmanConnected: 'Verbunden',
spoolmanDisconnected: 'Nicht verbunden',
status: 'Status',
connect: 'Verbinden',
disconnect: 'Trennen',
howSyncWorks: 'So funktioniert die Synchronisierung',
syncInfoRfidOnly: 'Nur offizielle Bambu Lab Spulen mit RFID werden synchronisiert',
syncInfoAutoCreate: 'Neue Spulen werden bei der ersten Synchronisierung automatisch in Spoolman erstellt',
syncInfoThirdPartySkipped: 'Nicht-Bambu-Lab-Spulen (Drittanbieter, nachgefüllt) werden übersprungen',
linkingExistingSpools: 'Vorhandene Spulen verknüpfen',
linkingExistingSpoolsDesc: 'Um vorhandene Spoolman-Spulen mit Ihrem AMS zu verknüpfen, fahren Sie über einen AMS-Slot und klicken Sie auf "Mit Spoolman verknüpfen".',
syncMode: 'Synchronisierungsmodus',
syncModeAuto: 'Automatisch',
syncModeManual: 'Nur manuell',
syncModeAutoDesc: 'AMS-Daten werden automatisch synchronisiert, wenn Änderungen erkannt werden',
syncModeManualDesc: 'Nur bei manueller Auslösung synchronisieren',
syncAmsData: 'AMS-Daten synchronisieren',
syncAmsDataDesc: 'AMS-Daten des Druckers manuell mit Spoolman synchronisieren',
allPrinters: 'Alle Drucker',
// Default printer
noDefaultPrinter: 'Kein Standard (jedes Mal fragen)',
// Sidebar
@@ -1376,6 +1409,77 @@ export default {
cameraConnected: 'Kamera verbunden{{resolution}}',
},
testConnection: 'Verbindung testen',
catalog: {
spoolCatalog: 'Spulenkatalog',
spoolCatalogDescription: 'Leerspulengewichte nach Marke/Typ. Wird für die automatische Gewichtssuche beim Hinzufügen von Spulen verwendet.',
searchCatalog: 'Katalog durchsuchen...',
addNewEntry: 'Neuen Eintrag hinzufügen',
namePlaceholder: 'Name (z.B. Bambu Lab - Plastik)',
weight: 'Gewicht',
type: 'Typ',
default: 'Standard',
custom: 'Benutzerdefiniert',
noMatch: 'Keine Einträge entsprechen Ihrer Suche',
empty: 'Keine Einträge im Katalog',
deleteEntry: 'Eintrag löschen',
deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
resetCatalog: 'Katalog zurücksetzen',
resetConfirm: 'Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Einträge werden entfernt.',
loadFailed: 'Spulenkatalog konnte nicht geladen werden',
nameWeightRequired: 'Name und Gewicht sind erforderlich',
entryAdded: 'Eintrag hinzugefügt',
addFailed: 'Eintrag konnte nicht hinzugefügt werden',
entryUpdated: 'Eintrag aktualisiert',
updateFailed: 'Eintrag konnte nicht aktualisiert werden',
entryDeleted: 'Eintrag gelöscht',
deleteFailed: 'Eintrag konnte nicht gelöscht werden',
resetSuccess: 'Katalog auf Standardwerte zurückgesetzt',
resetFailed: 'Katalog konnte nicht zurückgesetzt werden',
exported: '{{count}} Einträge exportiert',
imported: '{{added}} Einträge importiert ({{skipped}} übersprungen)',
importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',
exportTooltip: 'Katalog als JSON exportieren',
importTooltip: 'Katalog aus JSON importieren',
resetTooltip: 'Auf Standardwerte zurücksetzen',
},
colorCatalog: {
title: 'Farbkatalog',
description: 'Filamentfarben nach Hersteller/Material. Wird für die automatische Farbsuche beim Hinzufügen von Spulen verwendet.',
searchColors: 'Farben durchsuchen...',
allManufacturers: 'Alle Hersteller',
addNewColor: 'Neue Farbe hinzufügen',
manufacturer: 'Hersteller',
colorName: 'Farbname',
hex: 'Hex',
materialOptional: 'Material (optional)',
showing: '{{filtered}} von {{total}} Farben angezeigt',
noMatch: 'Keine Farben entsprechen Ihrer Suche',
empty: 'Keine Farben im Katalog',
deleteColor: 'Farbe löschen',
deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen?',
resetCatalog: 'Farbkatalog zurücksetzen',
resetConfirm: 'Katalog auf Standardwerte zurücksetzen? Alle benutzerdefinierten Farben werden entfernt.',
sync: 'Sync',
starting: 'Starten...',
syncTooltip: 'Von FilamentColors.xyz synchronisieren (2000+ Farben)',
loadFailed: 'Farbkatalog konnte nicht geladen werden',
fieldsRequired: 'Hersteller, Farbname und Hex-Farbe sind erforderlich',
colorAdded: 'Farbe hinzugefügt',
addFailed: 'Farbe konnte nicht hinzugefügt werden',
colorUpdated: 'Farbe aktualisiert',
updateFailed: 'Farbe konnte nicht aktualisiert werden',
colorDeleted: 'Farbe gelöscht',
deleteFailed: 'Farbe konnte nicht gelöscht werden',
resetSuccess: 'Farbkatalog auf Standardwerte zurückgesetzt',
resetFailed: 'Katalog konnte nicht zurückgesetzt werden',
syncUpToDate: 'Bereits aktuell ({{count}} Farben geprüft)',
syncComplete: '{{added}} neue Farben hinzugefügt ({{skipped}} bereits vorhanden)',
syncError: 'Sync-Fehler',
syncFailed: 'Synchronisierung von FilamentColors.xyz fehlgeschlagen',
exported: '{{count}} Farben exportiert',
imported: '{{added}} Farben importiert ({{skipped}} übersprungen)',
importFailed: 'Import fehlgeschlagen: ungültiges JSON-Format',
},
},
// Notifications (for push notifications)
@@ -2327,6 +2431,149 @@ export default {
reportPartialUsageDesc: 'Wenn ein Druck fehlschlägt oder abgebrochen wird, den geschätzten Filamentverbrauch bis zu diesem Zeitpunkt basierend auf dem Schichtfortschritt melden.',
},
// Inventar
inventory: {
title: 'Spulen-Inventar',
addSpool: 'Spule hinzufügen',
editSpool: 'Spule bearbeiten',
material: 'Material',
selectMaterial: 'Material auswählen...',
subtype: 'Untertyp',
brand: 'Marke',
searchBrand: 'Marke suchen...',
useCustomBrand: '"{{brand}}" verwenden',
colorName: 'Farbname',
colorNamePlaceholder: 'Jade White, Fire Red...',
color: 'Farbe',
hexColor: 'Hex-Farbe',
pickColor: 'Benutzerdefinierte Farbe wählen',
labelWeight: 'Nenngewicht',
coreWeight: 'Leergewicht der Spule',
searchSpoolWeight: 'Spulengewicht suchen...',
weightUsed: 'Verbraucht',
currentWeight: 'Restgewicht',
slicerFilament: 'Slicer-Filament',
slicerFilamentName: 'Slicer-Preset-Name',
slicerPreset: 'Slicer-Preset',
searchPresets: 'Filament-Presets suchen...',
selectedPreset: 'Ausgewählt',
noPresetsFound: 'Keine Presets gefunden',
tempOverrides: 'Temperatur-Überschreibungen',
note: 'Notiz',
notePlaceholder: 'Zusätzliche Notizen zu dieser Spule...',
archive: 'Archivieren',
restore: 'Wiederherstellen',
noSpools: 'Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.',
noManualSpools: 'Keine manuell hinzugefügten Spulen verfügbar. Fügen Sie zuerst eine Spule zum Inventar hinzu.',
kProfiles: 'K-Profile',
addKProfile: 'K-Profil hinzufügen',
assignSpool: 'Spule zuweisen',
unassignSpool: 'Zuweisung aufheben',
assignSuccess: 'Spule zugewiesen und AMS-Slot konfiguriert',
assignFailed: 'Spulenzuweisung fehlgeschlagen',
selectSpool: 'Wählen Sie eine Spule für diesen Slot',
assigned: 'Zugewiesen',
assigning: 'Wird zugewiesen...',
searchSpools: 'Spulen suchen...',
allMaterials: 'Alle Materialien',
filterByBrand: 'Nach Marke filtern...',
showArchived: 'Archivierte anzeigen',
spoolCreated: 'Spule erstellt',
spoolUpdated: 'Spule aktualisiert',
spoolDeleted: 'Spule gelöscht',
spoolArchived: 'Spule archiviert',
spoolRestored: 'Spule wiederhergestellt',
deleteConfirm: 'Möchten Sie diese Spule wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
advancedSettings: 'Erweiterte Einstellungen',
filamentInfoTab: 'Filament-Info',
paProfileTab: 'PA-Profil',
filamentInfo: 'Filament',
additional: 'Zusätzlich',
loadingPresets: 'Cloud-Presets werden geladen...',
cloudConnected: 'Cloud verbunden',
cloudNotConnected: 'Cloud nicht verbunden (Standardwerte)',
recentColors: 'Zuletzt',
searchColors: 'Farben suchen...',
searchResults: 'Suchergebnisse',
allColors: 'Alle Farben',
commonColors: 'Häufige Farben',
showLess: 'Weniger',
showAll: 'Alle',
noColorsFound: 'Keine Farben gefunden',
noResults: 'Keine Ergebnisse',
selectMaterialFirst: 'Bitte zuerst ein Material im Filament-Info Tab auswählen.',
noPrintersConfigured: 'Keine Drucker konfiguriert. Fügen Sie Drucker hinzu.',
matchingFilter: 'Filter',
anyBrand: 'Jede Marke',
anyVariant: 'Jede Variante',
autoSelect: 'Auto-Auswahl',
matches: 'Treffer',
match: 'Treffer',
noMatches: 'Keine Treffer',
connected: 'Verbunden',
offline: 'Offline',
printerOffline: 'Drucker ist offline. Verbinden Sie ihn, um Kalibrierungsprofile anzuzeigen.',
noKProfilesMatch: 'Keine K-Profile stimmen mit dem gewählten Filament überein.',
leftNozzle: 'Linke Düse',
rightNozzle: 'Rechte Düse',
profilesSelected: 'Kalibrierungsprofil(e) ausgewählt',
// Stats & enhanced table
totalInventory: 'Gesamtbestand',
totalConsumed: 'Gesamtverbrauch',
byMaterial: 'Nach Material',
inPrinter: 'Im Drucker',
lowStock: 'Niedriger Bestand',
sinceTracking: 'Seit Beginn der Erfassung',
loadedInAms: 'Im AMS/Ext geladen',
remaining: 'Verbleibend',
lowStockThreshold: '<20% verbleibend',
search: 'Spulen suchen...',
showing: 'Zeige',
to: 'bis',
of: 'von',
show: 'Zeige',
spools: 'Spulen',
spool: 'Spule',
page: 'Seite',
noSpoolsMatch: 'Keine Ergebnisse',
noSpoolsMatchDesc: 'Versuchen Sie, Ihre Suche oder Filter anzupassen.',
active: 'Aktiv',
archived: 'Archiviert',
all: 'Alle',
used: 'Verwendet',
new: 'Neu',
clearFilters: 'Filter löschen',
table: 'Tabelle',
cards: 'Karten',
net: 'Netto',
// Column config
columns: 'Spalten',
configureColumns: 'Spalten konfigurieren',
configureColumnsDesc: 'Ziehen zum Neuordnen oder Pfeile verwenden. Sichtbarkeit mit dem Augensymbol umschalten.',
visible: 'sichtbar',
reset: 'Zurücksetzen',
cancel: 'Abbrechen',
applyChanges: 'Änderungen anwenden',
moveUp: 'Nach oben',
moveDown: 'Nach unten',
hideColumn: 'Spalte ausblenden',
showColumn: 'Spalte einblenden',
// Tag-Verknüpfung
linkToSpool: 'Mit Spule verknüpfen',
tagLinked: 'Tag mit Spule verknüpft',
tagLinkFailed: 'Tag-Verknüpfung fehlgeschlagen',
tagAlreadyLinked: 'Tag bereits mit anderer Spule verknüpft',
unknownTag: 'Unbekannter RFID-Tag erkannt',
// Verbrauchshistorie
usageHistory: 'Verbrauchshistorie',
noUsageHistory: 'Noch kein Verbrauch erfasst',
printName: 'Druckname',
weightConsumed: 'Verbrauchtes Gewicht',
clearHistory: 'Löschen',
historyCleared: 'Verbrauchshistorie gelöscht',
fillSourceLabel: '(Inv)',
},
// Timelapse
timelapse: {
title: 'Zeitraffer',
@@ -2782,6 +3029,7 @@ export default {
noLinksConfigured: 'Keine externen Links konfiguriert',
deleteLink: 'Link löschen',
removeCustomIcon: 'Benutzerdefiniertes Symbol entfernen',
openInNewTab: 'In neuem Tab öffnen',
placeholders: {
linkName: 'Mein Link',
},
@@ -2944,6 +3192,7 @@ export default {
noCloudPresets: 'Keine Cloud-Voreinstellungen. Melden Sie sich bei Bambu Cloud an, um zu synchronisieren.',
noMatchingPresets: 'Keine passenden Voreinstellungen gefunden.',
custom: 'Benutzerdefiniert',
builtin: 'Integriert',
settingsSentToPrinter: 'Einstellungen an Drucker gesendet',
filamentProfile: 'Filamentprofil',
},
+257 -4
View File
@@ -8,6 +8,7 @@ export default {
profiles: 'Profiles',
maintenance: 'Maintenance',
projects: 'Projects',
inventory: 'Filament',
files: 'File Manager',
settings: 'Settings',
system: 'System',
@@ -74,6 +75,8 @@ export default {
dismiss: 'Dismiss',
apply: 'Apply',
reset: 'Reset',
export: 'Export',
import: 'Import',
clear: 'Clear',
selectAll: 'Select All',
deselectAll: 'Deselect All',
@@ -697,6 +700,10 @@ export default {
dragToReorder: 'Drag to reorder (ASAP only)',
reorderHint: 'Position only affects ASAP items. Scheduled items run at their set time.',
addedBy: 'Added by {{name}}',
nextInQueue: 'Next in queue',
clearPlate: 'Clear Plate & Start Next',
clearPlateSuccess: 'Plate cleared — ready for next print',
plateReady: 'Plate cleared — ready for next print',
// Sections
sections: {
currentlyPrinting: 'Currently Printing',
@@ -1019,9 +1026,9 @@ export default {
network: 'Network',
apiKeys: 'API Keys',
virtualPrinter: 'Virtual Printer',
users: 'Users',
users: 'Authentication',
backup: 'Backup',
globalEmail: 'Global Email',
emailAuth: 'Email Authentication',
},
// Email settings
email: {
@@ -1142,11 +1149,37 @@ export default {
turnOn: 'Turn On',
turnOff: 'Turn Off',
},
// Spoolman
spoolmanEnabled: 'Enable Spoolman Integration',
// Filament Tracking Mode
filamentTracking: 'Filament Tracking',
filamentTrackingDesc: 'Choose how to track your filament spools. You can use the built-in inventory or connect an external Spoolman server.',
trackingModeBuiltIn: 'Built-in Inventory',
trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',
trackingModeSpoolmanDesc: 'External filament management server',
builtInFeatureRfid: 'Automatically detects Bambu Lab RFID spools in AMS',
builtInFeatureUsage: 'Tracks filament consumption per print',
builtInFeatureCatalog: 'Manage spools, colors, and K-factor profiles',
// Spoolman settings
spoolmanUrl: 'Spoolman URL',
spoolmanUrlHint: 'URL of your Spoolman server (e.g., http://localhost:7912)',
spoolmanConnected: 'Connected',
spoolmanDisconnected: 'Disconnected',
status: 'Status',
connect: 'Connect',
disconnect: 'Disconnect',
howSyncWorks: 'How Sync Works',
syncInfoRfidOnly: 'Only official Bambu Lab spools with RFID are synced',
syncInfoAutoCreate: 'New spools are auto-created in Spoolman on first sync',
syncInfoThirdPartySkipped: 'Non-Bambu Lab spools (third-party, refilled) are skipped',
linkingExistingSpools: 'Linking Existing Spools',
linkingExistingSpoolsDesc: 'To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".',
syncMode: 'Sync Mode',
syncModeAuto: 'Automatic',
syncModeManual: 'Manual Only',
syncModeAutoDesc: 'AMS data syncs automatically when changes are detected',
syncModeManualDesc: 'Only sync when manually triggered',
syncAmsData: 'Sync AMS Data',
syncAmsDataDesc: 'Manually sync printer AMS data to Spoolman',
allPrinters: 'All Printers',
// Default printer
noDefaultPrinter: 'No default (ask each time)',
// Sidebar
@@ -1376,6 +1409,77 @@ export default {
cameraConnected: 'Camera connected{{resolution}}',
},
testConnection: 'Test Connection',
catalog: {
spoolCatalog: 'Spool Catalog',
spoolCatalogDescription: 'Empty spool weights by brand/type. Used for automatic weight lookup when adding spools.',
searchCatalog: 'Search catalog...',
addNewEntry: 'Add New Entry',
namePlaceholder: 'Name (e.g., Bambu Lab - Plastic)',
weight: 'Weight',
type: 'Type',
default: 'Default',
custom: 'Custom',
noMatch: 'No entries match your search',
empty: 'No entries in catalog',
deleteEntry: 'Delete Entry',
deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
resetCatalog: 'Reset Catalog',
resetConfirm: 'Reset catalog to defaults? This will remove all custom entries.',
loadFailed: 'Failed to load spool catalog',
nameWeightRequired: 'Name and weight are required',
entryAdded: 'Entry added',
addFailed: 'Failed to add entry',
entryUpdated: 'Entry updated',
updateFailed: 'Failed to update entry',
entryDeleted: 'Entry deleted',
deleteFailed: 'Failed to delete entry',
resetSuccess: 'Catalog reset to defaults',
resetFailed: 'Failed to reset catalog',
exported: 'Exported {{count}} entries',
imported: 'Imported {{added}} entries ({{skipped}} skipped)',
importFailed: 'Failed to import: invalid JSON format',
exportTooltip: 'Export catalog to JSON',
importTooltip: 'Import catalog from JSON',
resetTooltip: 'Reset to defaults',
},
colorCatalog: {
title: 'Color Catalog',
description: 'Filament colors by manufacturer/material. Used for automatic color lookup when adding spools.',
searchColors: 'Search colors...',
allManufacturers: 'All manufacturers',
addNewColor: 'Add New Color',
manufacturer: 'Manufacturer',
colorName: 'Color Name',
hex: 'Hex',
materialOptional: 'Material (optional)',
showing: 'Showing {{filtered}} of {{total}} colors',
noMatch: 'No colors match your search',
empty: 'No colors in catalog',
deleteColor: 'Delete Color',
deleteConfirm: 'Are you sure you want to delete "{{name}}"?',
resetCatalog: 'Reset Color Catalog',
resetConfirm: 'Reset catalog to defaults? This will remove all custom colors.',
sync: 'Sync',
starting: 'Starting...',
syncTooltip: 'Sync from FilamentColors.xyz (2000+ colors, may take a minute)',
loadFailed: 'Failed to load color catalog',
fieldsRequired: 'Manufacturer, color name, and hex color are required',
colorAdded: 'Color added',
addFailed: 'Failed to add color',
colorUpdated: 'Color updated',
updateFailed: 'Failed to update color',
colorDeleted: 'Color deleted',
deleteFailed: 'Failed to delete color',
resetSuccess: 'Color catalog reset to defaults',
resetFailed: 'Failed to reset catalog',
syncUpToDate: 'Already up to date ({{count}} colors checked)',
syncComplete: 'Added {{added}} new colors ({{skipped}} already existed)',
syncError: 'Sync error',
syncFailed: 'Failed to sync from FilamentColors.xyz',
exported: 'Exported {{count}} colors',
imported: 'Imported {{added}} colors ({{skipped}} skipped)',
importFailed: 'Failed to import: invalid JSON format',
},
},
// Notifications (for push notifications)
@@ -2327,6 +2431,153 @@ export default {
reportPartialUsageDesc: 'When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.',
},
// Inventory
inventory: {
title: 'Spool Inventory',
addSpool: 'Add Spool',
editSpool: 'Edit Spool',
material: 'Material',
selectMaterial: 'Select material...',
subtype: 'Subtype',
brand: 'Brand',
searchBrand: 'Search brand...',
useCustomBrand: 'Use "{{brand}}"',
colorName: 'Color Name',
colorNamePlaceholder: 'Jade White, Fire Red...',
color: 'Color',
hexColor: 'Hex Color',
pickColor: 'Pick custom color',
labelWeight: 'Label Weight',
coreWeight: 'Empty Spool Weight',
searchSpoolWeight: 'Search spool weight...',
weightUsed: 'Used',
currentWeight: 'Remaining Weight',
slicerFilament: 'Slicer Filament',
slicerFilamentName: 'Slicer Preset Name',
slicerPreset: 'Slicer Preset',
searchPresets: 'Search filament presets...',
selectedPreset: 'Selected',
noPresetsFound: 'No presets found',
tempOverrides: 'Temperature Overrides',
note: 'Note',
notePlaceholder: 'Any additional notes about this spool...',
archive: 'Archive',
restore: 'Restore',
noSpools: 'No spools yet. Add your first spool to get started.',
noManualSpools: 'No manually added spools available. Add a spool to your inventory first.',
kProfiles: 'K-Profiles',
addKProfile: 'Add K-Profile',
assignSpool: 'Assign Spool',
unassignSpool: 'Unassign',
assignSuccess: 'Spool assigned and AMS slot configured',
assignFailed: 'Failed to assign spool',
selectSpool: 'Select a spool to assign to this slot',
assigned: 'Assigned',
assigning: 'Assigning...',
searchSpools: 'Search spools...',
allMaterials: 'All Materials',
filterByBrand: 'Filter by brand...',
showArchived: 'Show archived',
spoolCreated: 'Spool created',
spoolUpdated: 'Spool updated',
spoolDeleted: 'Spool deleted',
spoolArchived: 'Spool archived',
spoolRestored: 'Spool restored',
deleteConfirm: 'Are you sure you want to delete this spool? This cannot be undone.',
advancedSettings: 'Advanced Settings',
// Tabs
filamentInfoTab: 'Filament Info',
paProfileTab: 'PA Profile',
filamentInfo: 'Filament',
additional: 'Additional',
// Cloud
loadingPresets: 'Loading cloud presets...',
cloudConnected: 'Cloud connected',
cloudNotConnected: 'Cloud not connected (using defaults)',
// Colors
recentColors: 'Recent',
searchColors: 'Search colors...',
searchResults: 'Search results',
allColors: 'All colors',
commonColors: 'Common colors',
showLess: 'Show less',
showAll: 'Show all',
noColorsFound: 'No colors match your search',
noResults: 'No matches found',
// PA Profiles
selectMaterialFirst: 'Please select a material first in the Filament Info tab.',
noPrintersConfigured: 'No printers configured. Add printers to use PA profiles.',
matchingFilter: 'Matching',
anyBrand: 'Any brand',
anyVariant: 'Any variant',
autoSelect: 'Auto-select',
matches: 'matches',
match: 'match',
noMatches: 'No matches',
connected: 'Connected',
offline: 'Offline',
printerOffline: 'Printer is offline. Connect to view calibration profiles.',
noKProfilesMatch: 'No K-profiles match the selected filament.',
leftNozzle: 'Left Nozzle',
rightNozzle: 'Right Nozzle',
profilesSelected: 'calibration profile(s) selected',
// Stats & enhanced table
totalInventory: 'Total Inventory',
totalConsumed: 'Total Consumed',
byMaterial: 'By Material',
inPrinter: 'In Printer',
lowStock: 'Low Stock',
sinceTracking: 'Since tracking started',
loadedInAms: 'Loaded in AMS/Ext',
remaining: 'Remaining',
lowStockThreshold: '<20% remaining',
search: 'Search spools...',
showing: 'Showing',
to: 'to',
of: 'of',
show: 'Show',
spools: 'spools',
spool: 'spool',
page: 'Page',
noSpoolsMatch: 'No results found',
noSpoolsMatchDesc: 'Try adjusting your search or filters to find what you\'re looking for.',
active: 'Active',
archived: 'Archived',
all: 'All',
used: 'Used',
new: 'New',
clearFilters: 'Clear filters',
table: 'Table',
cards: 'Cards',
net: 'Net',
// Column config
columns: 'Columns',
configureColumns: 'Configure Columns',
configureColumnsDesc: 'Drag to reorder columns or use arrows. Toggle visibility with the eye icon.',
visible: 'visible',
reset: 'Reset',
cancel: 'Cancel',
applyChanges: 'Apply Changes',
moveUp: 'Move up',
moveDown: 'Move down',
hideColumn: 'Hide column',
showColumn: 'Show column',
// Tag linking
linkToSpool: 'Link to Spool',
tagLinked: 'Tag linked to spool',
tagLinkFailed: 'Failed to link tag',
tagAlreadyLinked: 'Tag already linked to another spool',
unknownTag: 'Unknown RFID tag detected',
// Usage history
usageHistory: 'Usage History',
noUsageHistory: 'No usage recorded yet',
printName: 'Print Name',
weightConsumed: 'Weight Consumed',
clearHistory: 'Clear',
historyCleared: 'Usage history cleared',
fillSourceLabel: '(Inv)',
},
// Timelapse
timelapse: {
title: 'Timelapse',
@@ -2783,6 +3034,7 @@ export default {
noLinksConfigured: 'No external links configured',
deleteLink: 'Delete Link',
removeCustomIcon: 'Remove custom icon',
openInNewTab: 'Open in new tab',
placeholders: {
linkName: 'My Link',
},
@@ -2945,6 +3197,7 @@ export default {
noCloudPresets: 'No cloud presets. Login to Bambu Cloud to sync.',
noMatchingPresets: 'No matching presets found.',
custom: 'Custom',
builtin: 'Built-in',
settingsSentToPrinter: 'Settings sent to printer',
filamentProfile: 'Filament Profile',
},
+249 -5
View File
@@ -7,6 +7,7 @@ export default {
profiles: 'プロファイル',
maintenance: 'メンテナンス',
projects: 'プロジェクト',
inventory: 'フィラメント',
files: 'ファイル管理',
settings: '設定',
system: 'システム',
@@ -91,6 +92,8 @@ export default {
enable: '有効化',
linkNotFound: 'リンクが見つかりません',
reset: 'リセット',
export: 'エクスポート',
import: 'インポート',
selectAll: 'すべて選択',
deselectAll: 'すべて選択解除',
unchanged: '変更なし',
@@ -113,8 +116,6 @@ export default {
show: '表示',
hide: '非表示',
back: '戻る',
export: 'エクスポート',
import: 'インポート',
retry: 'リトライ',
model: 'モデル',
ok: 'OK',
@@ -769,6 +770,10 @@ export default {
itemCount: '{{count}}件',
dragToReorder: 'ドラッグして並べ替え(ASAPのみ)',
addedBy: '{{username}}が追加',
nextInQueue: '次のキュー',
clearPlate: 'プレートをクリアして次を開始',
clearPlateSuccess: 'プレートをクリアしました — 次の印刷の準備完了',
plateReady: 'プレートをクリアしました — 次の印刷の準備完了',
sections: {
currentlyPrinting: '印刷中',
queued: 'キュー中',
@@ -1063,12 +1068,12 @@ export default {
smartPlugs: 'スマートプラグ',
notifications: '通知',
apiKeys: 'APIキー',
users: 'ユーザー',
users: '認証',
backup: 'バックアップ',
filament: 'フィラメント',
network: 'ネットワーク',
virtualPrinter: '仮想プリンター',
globalEmail: 'グローバルメール',
emailAuth: 'メール認証',
},
appearance: '外観',
notifications: '通知',
@@ -1384,10 +1389,108 @@ export default {
leaveEmptyForAnonymous: '匿名の場合は空のまま',
leaveEmptyForNoAuth: '認証なしの場合は空のまま',
enterDescriptionOptional: '説明を入力(任意)',
spoolmanEnabled: 'Spoolman連携を有効化',
// フィラメント追跡モード
filamentTracking: 'フィラメント追跡',
filamentTrackingDesc: 'フィラメントスプールの追跡方法を選択してください。内蔵インベントリまたは外部Spoolmanサーバーを使用できます。',
trackingModeBuiltIn: '内蔵インベントリ',
trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',
trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',
builtInFeatureRfid: 'AMS内のBambu Lab RFIDスプールを自動検出',
builtInFeatureUsage: 'プリントごとのフィラメント消費量を追跡',
builtInFeatureCatalog: 'スプール、カラー、K値プロファイルを管理',
// Spoolman設定
spoolmanUrl: 'Spoolman URL',
spoolmanUrlHint: 'Spoolmanサーバーのurl(例:http://localhost:7912)',
spoolmanConnected: '接続中',
spoolmanDisconnected: '未接続',
status: 'ステータス',
connect: '接続',
disconnect: '切断',
howSyncWorks: '同期の仕組み',
syncInfoRfidOnly: 'RFIDを搭載した公式Bambu Labスプールのみ同期されます',
syncInfoAutoCreate: '新しいスプールは初回同期時にSpoolmanに自動作成されます',
syncInfoThirdPartySkipped: 'Bambu Lab以外のスプール(サードパーティ、リフィル)はスキップされます',
linkingExistingSpools: '既存スプールのリンク',
linkingExistingSpoolsDesc: '既存のSpoolmanスプールをAMSにリンクするには、AMSスロットにカーソルを合わせて「Spoolmanにリンク」をクリックしてください。',
syncMode: '同期モード',
syncModeAuto: '自動',
syncModeManual: '手動のみ',
syncModeAutoDesc: '変更が検出されるとAMSデータが自動的に同期されます',
syncModeManualDesc: '手動でトリガーした場合のみ同期',
syncAmsData: 'AMSデータを同期',
syncAmsDataDesc: 'プリンターのAMSデータをSpoolmanに手動同期',
allPrinters: '全プリンター',
catalog: {
spoolCatalog: 'スプールカタログ',
spoolCatalogDescription: 'ブランド/タイプ別の空スプール重量。スプール追加時の自動重量検索に使用されます。',
searchCatalog: 'カタログを検索...',
addNewEntry: '新しいエントリを追加',
namePlaceholder: '名前(例:Bambu Lab - プラスチック)',
weight: '重量',
type: 'タイプ',
default: 'デフォルト',
custom: 'カスタム',
noMatch: '検索に一致するエントリがありません',
empty: 'カタログにエントリがありません',
deleteEntry: 'エントリを削除',
deleteConfirm: '「{{name}}」を削除してもよろしいですか?',
resetCatalog: 'カタログをリセット',
resetConfirm: 'カタログをデフォルトにリセットしますか?カスタムエントリはすべて削除されます。',
loadFailed: 'スプールカタログの読み込みに失敗しました',
nameWeightRequired: '名前と重量は必須です',
entryAdded: 'エントリを追加しました',
addFailed: 'エントリの追加に失敗しました',
entryUpdated: 'エントリを更新しました',
updateFailed: 'エントリの更新に失敗しました',
entryDeleted: 'エントリを削除しました',
deleteFailed: 'エントリの削除に失敗しました',
resetSuccess: 'カタログをデフォルトにリセットしました',
resetFailed: 'カタログのリセットに失敗しました',
exported: '{{count}}件のエントリをエクスポートしました',
imported: '{{added}}件のエントリをインポートしました({{skipped}}件スキップ)',
importFailed: 'インポートに失敗しました:無効なJSON形式',
exportTooltip: 'カタログをJSONにエクスポート',
importTooltip: 'JSONからカタログをインポート',
resetTooltip: 'デフォルトにリセット',
},
colorCatalog: {
title: 'カラーカタログ',
description: 'メーカー/素材別のフィラメントカラー。スプール追加時の自動カラー検索に使用されます。',
searchColors: 'カラーを検索...',
allManufacturers: 'すべてのメーカー',
addNewColor: '新しいカラーを追加',
manufacturer: 'メーカー',
colorName: 'カラー名',
hex: 'Hex',
materialOptional: '素材(任意)',
showing: '{{total}}件中{{filtered}}件を表示',
noMatch: '検索に一致するカラーがありません',
empty: 'カタログにカラーがありません',
deleteColor: 'カラーを削除',
deleteConfirm: '「{{name}}」を削除してもよろしいですか?',
resetCatalog: 'カラーカタログをリセット',
resetConfirm: 'カタログをデフォルトにリセットしますか?カスタムカラーはすべて削除されます。',
sync: '同期',
starting: '開始中...',
syncTooltip: 'FilamentColors.xyzから同期(2000+カラー)',
loadFailed: 'カラーカタログの読み込みに失敗しました',
fieldsRequired: 'メーカー、カラー名、Hexカラーは必須です',
colorAdded: 'カラーを追加しました',
addFailed: 'カラーの追加に失敗しました',
colorUpdated: 'カラーを更新しました',
updateFailed: 'カラーの更新に失敗しました',
colorDeleted: 'カラーを削除しました',
deleteFailed: 'カラーの削除に失敗しました',
resetSuccess: 'カラーカタログをデフォルトにリセットしました',
resetFailed: 'カタログのリセットに失敗しました',
syncUpToDate: '最新の状態です({{count}}件のカラーを確認)',
syncComplete: '{{added}}件の新しいカラーを追加しました({{skipped}}件は既に存在)',
syncError: '同期エラー',
syncFailed: 'FilamentColors.xyzからの同期に失敗しました',
exported: '{{count}}件のカラーをエクスポートしました',
imported: '{{added}}件のカラーをインポートしました({{skipped}}件スキップ)',
importFailed: 'インポートに失敗しました:無効なJSON形式',
},
},
notification: {
printStarted: {
@@ -2260,6 +2363,145 @@ export default {
linkFailed: 'スプールのリンクに失敗しました',
spoolId: 'スプールID',
},
inventory: {
title: 'スプール在庫管理',
addSpool: 'スプールを追加',
editSpool: 'スプールを編集',
material: '素材',
selectMaterial: '素材を選択...',
subtype: 'サブタイプ',
brand: 'ブランド',
searchBrand: 'ブランドを検索...',
useCustomBrand: '「{{brand}}」を使用',
colorName: '色名',
colorNamePlaceholder: 'Jade White, Fire Red...',
color: '色',
hexColor: 'HEXカラー',
pickColor: 'カスタムカラーを選択',
labelWeight: '表示重量',
coreWeight: '空スプール重量',
searchSpoolWeight: 'スプール重量を検索...',
weightUsed: '使用量',
currentWeight: '残量',
slicerFilament: 'スライサーフィラメント',
slicerFilamentName: 'スライサープリセット名',
slicerPreset: 'スライサープリセット',
searchPresets: 'フィラメントプリセットを検索...',
selectedPreset: '選択済み',
noPresetsFound: 'プリセットが見つかりません',
tempOverrides: '温度オーバーライド',
note: 'メモ',
notePlaceholder: 'このスプールに関する追加メモ...',
archive: 'アーカイブ',
restore: '復元',
noSpools: 'スプールがありません。最初のスプールを追加してください。',
noManualSpools: '手動で追加されたスプールがありません。先にインベントリにスプールを追加してください。',
kProfiles: 'Kプロファイル',
addKProfile: 'Kプロファイルを追加',
assignSpool: 'スプールを割り当て',
unassignSpool: '割り当て解除',
assignSuccess: 'スプールを割り当て、AMSスロットを設定しました',
assignFailed: 'スプールの割り当てに失敗しました',
selectSpool: 'このスロットに割り当てるスプールを選択',
assigned: '割り当て済み',
assigning: '割り当て中...',
searchSpools: 'スプールを検索...',
allMaterials: 'すべての素材',
filterByBrand: 'ブランドで絞り込み...',
showArchived: 'アーカイブ済みを表示',
spoolCreated: 'スプールを作成しました',
spoolUpdated: 'スプールを更新しました',
spoolDeleted: 'スプールを削除しました',
spoolArchived: 'スプールをアーカイブしました',
spoolRestored: 'スプールを復元しました',
deleteConfirm: 'このスプールを削除しますか?この操作は元に戻せません。',
advancedSettings: '詳細設定',
filamentInfoTab: 'フィラメント情報',
paProfileTab: 'PAプロファイル',
filamentInfo: 'フィラメント',
additional: '追加情報',
loadingPresets: 'クラウドプリセットを読み込み中...',
cloudConnected: 'クラウド接続済み',
cloudNotConnected: 'クラウド未接続(デフォルト使用)',
recentColors: '最近',
searchColors: '色を検索...',
searchResults: '検索結果',
allColors: 'すべての色',
commonColors: '一般的な色',
showLess: '少なく表示',
showAll: 'すべて表示',
noColorsFound: '一致する色がありません',
noResults: '結果なし',
selectMaterialFirst: 'フィラメント情報タブで素材を選択してください。',
noPrintersConfigured: 'プリンターが設定されていません。プリンターを追加してください。',
matchingFilter: 'フィルター',
anyBrand: 'すべてのブランド',
anyVariant: 'すべてのバリアント',
autoSelect: '自動選択',
matches: '件一致',
match: '件一致',
noMatches: '一致なし',
connected: '接続済み',
offline: 'オフライン',
printerOffline: 'プリンターがオフラインです。接続してキャリブレーションプロファイルを表示してください。',
noKProfilesMatch: '選択したフィラメントに一致するKプロファイルがありません。',
leftNozzle: '左ノズル',
rightNozzle: '右ノズル',
profilesSelected: 'キャリブレーションプロファイル選択済み',
totalInventory: '在庫合計',
totalConsumed: '総消費量',
byMaterial: '素材別',
inPrinter: 'プリンター内',
lowStock: '残量少',
sinceTracking: '追跡開始以降',
loadedInAms: 'AMS/Extに装填中',
remaining: '残り',
lowStockThreshold: '残り20%未満',
search: 'スプールを検索...',
showing: '表示',
to: '〜',
of: '/',
show: '表示',
spools: 'スプール',
spool: 'スプール',
page: 'ページ',
noSpoolsMatch: '結果なし',
noSpoolsMatchDesc: '検索やフィルターを調整してみてください。',
active: 'アクティブ',
archived: 'アーカイブ済み',
all: 'すべて',
used: '使用済み',
new: '新規',
clearFilters: 'フィルターをクリア',
table: 'テーブル',
cards: 'カード',
net: '正味',
columns: '列',
configureColumns: '列の設定',
configureColumnsDesc: 'ドラッグして並べ替えるか、矢印を使用してください。目のアイコンで表示/非表示を切り替えます。',
visible: '表示中',
reset: 'リセット',
cancel: 'キャンセル',
applyChanges: '変更を適用',
moveUp: '上へ移動',
moveDown: '下へ移動',
hideColumn: '列を非表示',
showColumn: '列を表示',
// タグリンク
linkToSpool: 'スプールにリンク',
tagLinked: 'タグがスプールにリンクされました',
tagLinkFailed: 'タグのリンクに失敗しました',
tagAlreadyLinked: 'タグは既に別のスプールにリンクされています',
unknownTag: '不明なRFIDタグが検出されました',
// 使用履歴
usageHistory: '使用履歴',
noUsageHistory: 'まだ使用記録がありません',
printName: 'プリント名',
weightConsumed: '消費重量',
clearHistory: 'クリア',
historyCleared: '使用履歴がクリアされました',
fillSourceLabel: '(Inv)',
},
timelapse: {
download: 'ダウンロード',
preview: 'プレビュー',
@@ -2666,6 +2908,7 @@ export default {
noCloudPresets: 'クラウドプリセットがありません。Bambu Cloudにログインして同期してください。',
noMatchingPresets: '一致するプリセットが見つかりません。',
custom: 'カスタム',
builtin: '内蔵',
settingsSentToPrinter: '設定をプリンターに送信しました',
filamentProfile: 'フィラメントプロファイル',
},
@@ -2707,6 +2950,7 @@ export default {
noLinksConfigured: '外部リンクが設定されていません',
deleteLink: 'リンクを削除',
removeCustomIcon: 'カスタムアイコンを削除',
openInNewTab: '新しいタブで開く',
placeholders: {
linkName: 'マイリンク',
},
File diff suppressed because it is too large Load Diff
+207 -32
View File
@@ -47,7 +47,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { api, discoveryApi, firmwareApi } from '../api/client';
import { formatDateOnly } from '../utils/date';
import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo } from '../api/client';
import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
@@ -59,6 +59,7 @@ import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
import { AMSHistoryModal } from '../components/AMSHistoryModal';
import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
import { LinkSpoolModal } from '../components/LinkSpoolModal';
import { AssignSpoolModal } from '../components/AssignSpoolModal';
import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
import { useToast } from '../contexts/ToastContext';
import { ChamberLight } from '../components/icons/ChamberLight';
@@ -421,6 +422,14 @@ function parseFilamentColor(rgba: string): string | null {
return `rgba(${parseInt(r, 16)}, ${parseInt(g, 16)}, ${parseInt(b, 16)}, ${a})`;
}
function isLightFilamentColor(rgba: string): boolean {
if (!rgba || rgba.length < 6) return false;
const r = parseInt(rgba.slice(0, 2), 16);
const g = parseInt(rgba.slice(2, 4), 16);
const b = parseInt(rgba.slice(4, 6), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
}
// Expand nozzle type codes to material names
// Handles full text ("hardened_steel"), 2-char codes ("HS"/"HH"), and 4-char codes ("HS01")
// Material mapping: 00=stainless steel, 01=hardened steel, 05=tungsten carbide
@@ -798,6 +807,7 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
{rackSlots.map((slot, i) => {
const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
const lightBg = filamentBg ? isLightFilamentColor(slot.filament_color) : false;
return (
<NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
@@ -809,8 +819,8 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
}`}
style={filamentBg ? { backgroundColor: filamentBg } : undefined}
>
<span className={`text-[10px] font-semibold ${isEmpty ? 'text-bambu-gray/30' : 'text-white'}`}
style={filamentBg ? { textShadow: '0 1px 3px rgba(0,0,0,0.9)' } : undefined}
<span className={`text-[10px] font-semibold ${isEmpty ? 'text-bambu-gray/30' : lightBg ? 'text-black/80' : 'text-white'}`}
style={filamentBg && !lightBg ? { textShadow: '0 1px 3px rgba(0,0,0,0.9)' } : undefined}
>
{isEmpty ? '—' : (slot.nozzle_diameter || '?')}
</span>
@@ -1161,16 +1171,30 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
const [error, setError] = useState(false);
const [showOverlay, setShowOverlay] = useState(false);
// Cache-bust the image URL when the print name changes so the browser
// fetches the new cover instead of serving the stale cached image.
const cacheBustedUrl = useMemo(() => {
if (!url) return null;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}v=${encodeURIComponent(printName || Date.now().toString())}`;
}, [url, printName]);
// Reset loaded/error state when the image URL changes
useEffect(() => {
setLoaded(false);
setError(false);
}, [cacheBustedUrl]);
return (
<>
<div
className={`w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden bg-bambu-dark-tertiary flex items-center justify-center ${url && loaded ? 'cursor-pointer' : ''}`}
onClick={() => url && loaded && setShowOverlay(true)}
className={`w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden bg-bambu-dark-tertiary flex items-center justify-center ${cacheBustedUrl && loaded ? 'cursor-pointer' : ''}`}
onClick={() => cacheBustedUrl && loaded && setShowOverlay(true)}
>
{url && !error ? (
{cacheBustedUrl && !error ? (
<>
<img
src={url}
src={cacheBustedUrl}
alt={t('printers.printPreview')}
className={`w-full h-full object-cover ${loaded ? 'block' : 'hidden'}`}
onLoad={() => setLoaded(true)}
@@ -1184,14 +1208,14 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
</div>
{/* Cover Image Overlay */}
{showOverlay && url && (
{showOverlay && cacheBustedUrl && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8"
onClick={() => setShowOverlay(false)}
>
<div className="relative max-w-2xl max-h-full">
<img
src={url}
src={cacheBustedUrl}
alt={t('printers.printPreview')}
className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
/>
@@ -1330,6 +1354,8 @@ function PrinterCard({
hasUnlinkedSpools = false,
linkedSpools,
spoolmanUrl,
onGetAssignment,
onUnassignSpool,
timeFormat = 'system',
cameraViewMode = 'window',
onOpenEmbeddedCamera,
@@ -1350,6 +1376,9 @@ function PrinterCard({
hasUnlinkedSpools?: boolean;
linkedSpools?: Record<string, LinkedSpoolInfo>;
spoolmanUrl?: string | null;
spoolAssignments?: SpoolAssignment[];
onGetAssignment?: (printerId: number, amsId: number, trayId: number) => SpoolAssignment | undefined;
onUnassignSpool?: (printerId: number, amsId: number, trayId: number) => void;
timeFormat?: 'system' | '12h' | '24h';
cameraViewMode?: 'window' | 'embedded';
onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
@@ -1379,7 +1408,16 @@ function PrinterCard({
mode: 'humidity' | 'temperature';
} | null>(null);
const [linkSpoolModal, setLinkSpoolModal] = useState<{
tagUid: string;
trayUuid: string;
printerId: number;
amsId: number;
trayId: number;
} | null>(null);
const [assignSpoolModal, setAssignSpoolModal] = useState<{
printerId: number;
amsId: number;
trayId: number;
trayInfo: { type: string; color: string; location: string };
} | null>(null);
const [configureSlotModal, setConfigureSlotModal] = useState<{
@@ -2376,10 +2414,8 @@ function PrinterCard({
</div>
</div>
{/* Queue Widget - shows next scheduled print */}
{status.state !== 'RUNNING' && (
<PrinterQueueWidget printerId={printer.id} />
)}
{/* Queue Widget - always visible when there are pending items */}
<PrinterQueueWidget printerId={printer.id} printerState={status.state} />
</>
)}
@@ -2664,14 +2700,24 @@ function PrinterCard({
// Get saved slot preset mapping (for user-configured slots)
const slotPreset = slotPresets?.[globalTrayId];
// Spoolman fill level fallback (when AMS reports 0%)
// Fill level fallback chain: AMS remain → Spoolman → Inventory spool
const trayTag = tray?.tray_uuid?.toUpperCase();
const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;
const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
const inventoryFill = (() => {
const sp = inventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
})();
const effectiveFill = hasFillLevel && tray.remain > 0
? tray.remain
: (spoolmanFill ?? (hasFillLevel ? tray.remain : null));
const fillSource = (hasFillLevel && tray.remain === 0 && spoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
: (spoolmanFill ?? inventoryFill ?? (hasFillLevel ? tray.remain : null));
const fillSource = (hasFillLevel && tray.remain === 0 && (spoolmanFill !== null || inventoryFill !== null))
? (spoolmanFill !== null ? 'spoolman' as const : 'inventory' as const)
: 'ams' as const;
// Build filament data for hover card
const filamentData = tray?.tray_type ? {
@@ -2682,6 +2728,7 @@ function PrinterCard({
kFactor: formatKValue(tray.k),
fillLevel: effectiveFill,
trayUuid: tray.tray_uuid || null,
tagUid: tray.tag_uid || null,
fillSource,
} : null;
@@ -2782,15 +2829,36 @@ function PrinterCard({
spoolmanUrl,
onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
tagUid: filamentData.tagUid || '',
trayUuid: uuid,
printerId: printer.id,
amsId: ams.id,
trayId: slotIdx,
});
} : undefined,
}}
inventory={(() => {
const assignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
return {
assignedSpool: assignment?.spool ? {
id: assignment.spool.id,
material: assignment.spool.material,
brand: assignment.spool.brand,
color_name: assignment.spool.color_name,
} : null,
onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
printerId: printer.id,
amsId: ams.id,
trayId: slotIdx,
trayInfo: {
type: filamentData.profile,
color: filamentData.colorHex || '',
location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
},
});
} : undefined,
}}
}) : undefined,
onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
};
})()}
configureSlot={{
enabled: hasPermission('printers:control'),
onConfigure: () => setConfigureSlotModal({
@@ -2852,14 +2920,25 @@ function PrinterCard({
// Get saved slot preset mapping (for user-configured slots)
const slotPreset = slotPresets?.[globalTrayId];
// Spoolman fill level fallback (when AMS reports 0%)
// Fill level fallback chain: AMS remain → Spoolman → Inventory spool
const htTrayTag = tray?.tray_uuid?.toUpperCase();
const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;
const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);
const htTraySlotId = tray?.id ?? 0;
const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htTraySlotId);
const htInventoryFill = (() => {
const sp = htInventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
})();
const htEffectiveFill = hasFillLevel && tray.remain > 0
? tray.remain
: (htSpoolmanFill ?? (hasFillLevel ? tray.remain : null));
const htFillSource = (hasFillLevel && tray.remain === 0 && htSpoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
: (htSpoolmanFill ?? htInventoryFill ?? (hasFillLevel ? tray.remain : null));
const htFillSource = (hasFillLevel && tray.remain === 0 && (htSpoolmanFill !== null || htInventoryFill !== null))
? (htSpoolmanFill !== null ? 'spoolman' as const : 'inventory' as const)
: 'ams' as const;
// Build filament data for hover card
const filamentData = tray?.tray_type ? {
@@ -2870,6 +2949,7 @@ function PrinterCard({
kFactor: formatKValue(tray.k),
fillLevel: htEffectiveFill,
trayUuid: tray.tray_uuid || null,
tagUid: tray.tag_uid || null,
fillSource: htFillSource,
} : null;
@@ -2983,15 +3063,36 @@ function PrinterCard({
spoolmanUrl,
onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
tagUid: filamentData.tagUid || '',
trayUuid: uuid,
printerId: printer.id,
amsId: ams.id,
trayId: htSlotId,
});
} : undefined,
}}
inventory={(() => {
const assignment = onGetAssignment?.(printer.id, ams.id, htSlotId);
return {
assignedSpool: assignment?.spool ? {
id: assignment.spool.id,
material: assignment.spool.material,
brand: assignment.spool.brand,
color_name: assignment.spool.color_name,
} : null,
onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
printerId: printer.id,
amsId: ams.id,
trayId: htSlotId,
trayInfo: {
type: filamentData.profile,
color: filamentData.colorHex || '',
location: getAmsLabel(ams.id, ams.tray.length),
},
});
} : undefined,
}}
}) : undefined,
onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined,
};
})()}
configureSlot={{
enabled: hasPermission('printers:control'),
onConfigure: () => setConfigureSlotModal({
@@ -3067,10 +3168,19 @@ function PrinterCard({
// Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
const extSlotPreset = slotPresets?.[255 * 4 + 0];
// Spoolman fill level for external spool
// Fill level fallback chain: Spoolman → Inventory spool
const extTrayTag = extTray.tray_uuid?.toUpperCase();
const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
const extInventoryAssignment = onGetAssignment?.(printer.id, 255, 0);
const extInventoryFill = (() => {
const sp = extInventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
})();
const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null;
// Build filament data for hover card
const extFilamentData = {
@@ -3079,9 +3189,12 @@ function PrinterCard({
colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
colorHex: extTray.tray_color || null,
kFactor: formatKValue(extTray.k),
fillLevel: extSpoolmanFill, // Use Spoolman data if available
fillLevel: extEffectiveFill,
trayUuid: extTray.tray_uuid || null,
fillSource: extSpoolmanFill !== null ? 'spoolman' as const : undefined,
tagUid: extTray.tag_uid || null,
fillSource: extSpoolmanFill !== null ? 'spoolman' as const
: extInventoryFill !== null ? 'inventory' as const
: undefined,
};
const extSlotContent = (
@@ -3129,15 +3242,36 @@ function PrinterCard({
spoolmanUrl,
onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
setLinkSpoolModal({
tagUid: extFilamentData.tagUid || '',
trayUuid: uuid,
printerId: printer.id,
amsId: 255,
trayId: 0,
});
} : undefined,
}}
inventory={(() => {
const assignment = onGetAssignment?.(printer.id, 255, 0);
return {
assignedSpool: assignment?.spool ? {
id: assignment.spool.id,
material: assignment.spool.material,
brand: assignment.spool.brand,
color_name: assignment.spool.color_name,
} : null,
onAssignSpool: extFilamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
printerId: printer.id,
amsId: 255,
trayId: 0,
trayInfo: {
type: extFilamentData.profile,
color: extFilamentData.colorHex || '',
location: 'External Spool',
},
});
} : undefined,
}}
}) : undefined,
onUnassignSpool: assignment && extFilamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
};
})()}
configureSlot={{
enabled: hasPermission('printers:control'),
onConfigure: () => setConfigureSlotModal({
@@ -3767,8 +3901,23 @@ function PrinterCard({
<LinkSpoolModal
isOpen={!!linkSpoolModal}
onClose={() => setLinkSpoolModal(null)}
tagUid={linkSpoolModal.tagUid}
trayUuid={linkSpoolModal.trayUuid}
trayInfo={linkSpoolModal.trayInfo}
printerId={linkSpoolModal.printerId}
amsId={linkSpoolModal.amsId}
trayId={linkSpoolModal.trayId}
/>
)}
{/* Assign Spool Modal */}
{assignSpoolModal && (
<AssignSpoolModal
isOpen={!!assignSpoolModal}
onClose={() => setAssignSpoolModal(null)}
printerId={assignSpoolModal.printerId}
amsId={assignSpoolModal.amsId}
trayId={assignSpoolModal.trayId}
trayInfo={assignSpoolModal.trayInfo}
/>
)}
@@ -4795,6 +4944,28 @@ export function PrintersPage() {
});
const linkedSpools = linkedSpoolsData?.linked;
// Fetch spool assignments for inventory feature
const { data: spoolAssignments } = useQuery({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
staleTime: 30 * 1000,
});
const unassignMutation = useMutation({
mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) =>
api.unassignSpool(printerId, amsId, trayId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
},
});
// Helper to find assignment for a specific slot
const getAssignment = (printerId: number, amsId: number | string, trayId: number | string): SpoolAssignment | undefined => {
return spoolAssignments?.find(
(a) => a.printer_id === printerId && a.ams_id === Number(amsId) && a.tray_id === Number(trayId)
);
};
// Create a map of printer_id -> maintenance info for quick lookup
const maintenanceByPrinter = maintenanceOverview?.reduce(
(acc, overview) => {
@@ -5103,6 +5274,8 @@ export function PrintersPage() {
hasUnlinkedSpools={hasUnlinkedSpools}
linkedSpools={linkedSpools}
spoolmanUrl={spoolmanStatus?.url}
onGetAssignment={getAssignment}
onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
timeFormat={settings?.time_format || 'system'}
cameraViewMode={settings?.camera_view_mode || 'window'}
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
@@ -5128,6 +5301,8 @@ export function PrintersPage() {
hasUnlinkedSpools={hasUnlinkedSpools}
linkedSpools={linkedSpools}
spoolmanUrl={spoolmanStatus?.url}
onGetAssignment={getAssignment}
onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
amsThresholds={settings ? {
humidityGood: Number(settings.ams_humidity_good) || 40,
humidityFair: Number(settings.ams_humidity_fair) || 60,
+2 -1
View File
@@ -43,6 +43,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
// Project edit modal (reused from ProjectsPage)
import { ProjectModal } from './ProjectsPage';
import { getCurrencySymbol } from '../utils/currency';
function formatDuration(hours: number): string {
if (hours < 1) {
@@ -251,7 +252,7 @@ export function ProjectDetailPage() {
enabled: projectId > 0,
});
const currency = settings?.currency || '$';
const currency = getCurrencySymbol(settings?.currency || 'USD');
const timeFormat: TimeFormat = settings?.time_format || 'system';
const updateMutation = useMutation({
+106 -65
View File
@@ -5,6 +5,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { formatDateOnly } from '../utils/date';
import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, Group, GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
import { Button } from '../components/Button';
@@ -17,6 +18,8 @@ import { NotificationLogViewer } from '../components/NotificationLogViewer';
import { ConfirmModal } from '../components/ConfirmModal';
import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
import { SpoolmanSettings } from '../components/SpoolmanSettings';
import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
@@ -30,8 +33,9 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t
import { useState, useEffect, useRef, useCallback } from 'react';
import { Palette } from 'lucide-react';
const validTabs = ['general', 'network', 'plugs', 'email', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
type TabType = typeof validTabs[number];
type UsersSubTab = 'users' | 'email';
export function SettingsPage() {
const queryClient = useQueryClient();
@@ -56,14 +60,19 @@ export function SettingsPage() {
const [showLogViewer, setShowLogViewer] = useState(false);
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
// Initialize tab from URL params
// Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)
const tabParam = searchParams.get('tab');
const initialTab = tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general';
const isLegacyEmailTab = tabParam === 'email';
const initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
const [activeTab, setActiveTab] = useState<TabType>(initialTab);
const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(isLegacyEmailTab ? 'email' : 'users');
// Update URL when tab changes
const handleTabChange = (tab: TabType) => {
setActiveTab(tab);
if (tab === 'users') {
setUsersSubTab('users');
}
if (tab === 'general') {
searchParams.delete('tab');
} else {
@@ -986,20 +995,6 @@ export function SettingsPage() {
</span>
)}
</button>
<button
onClick={() => handleTabChange('email')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
activeTab === 'email'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
}`}
>
<Mail className="w-4 h-4" />
{t('settings.tabs.globalEmail') || 'Global Email'}
{advancedAuthStatus?.advanced_auth_enabled && (
<span className="w-2 h-2 rounded-full bg-green-400" />
)}
</button>
<button
onClick={() => handleTabChange('notifications')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
@@ -1557,21 +1552,6 @@ export function SettingsPage() {
<h2 className="text-lg font-semibold text-white">{t('settings.costTracking')}</h2>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">
Default filament cost (per kg)
</label>
<input
type="number"
step="0.01"
min="0"
value={localSettings.default_filament_cost}
onChange={(e) =>
updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
}
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"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Currency</label>
<select
@@ -1579,30 +1559,52 @@ export function SettingsPage() {
onChange={(e) => updateSetting('currency', e.target.value)}
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"
>
<option value="USD">USD ($)</option>
<option value="EUR">EUR (€)</option>
<option value="GBP">GBP (£)</option>
<option value="CHF">CHF (Fr.)</option>
<option value="JPY">JPY (¥)</option>
<option value="CNY">CNY (¥)</option>
<option value="CAD">CAD ($)</option>
<option value="AUD">AUD ($)</option>
{SUPPORTED_CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Default filament cost (per kg)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">
{getCurrencySymbol(localSettings.currency)}
</span>
<input
type="number"
step="0.01"
min="0"
value={localSettings.default_filament_cost}
onChange={(e) =>
updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
}
style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}
className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Electricity cost per kWh
</label>
<input
type="number"
step="0.01"
min="0"
value={localSettings.energy_cost_per_kwh}
onChange={(e) =>
updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
}
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"
/>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">
{getCurrencySymbol(localSettings.currency)}
</span>
<input
type="number"
step="0.01"
min="0"
value={localSettings.energy_cost_per_kwh}
onChange={(e) =>
updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
}
style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}
className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
@@ -2532,7 +2534,7 @@ export function SettingsPage() {
</div>
{(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
<div className="text-xs text-bambu-gray mt-1">
~{(plugEnergySummary.totalToday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {localSettings?.currency}
~{(plugEnergySummary.totalToday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
</div>
)}
</div>
@@ -2549,7 +2551,7 @@ export function SettingsPage() {
</div>
{(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
<div className="text-xs text-bambu-gray mt-1">
~{(plugEnergySummary.totalYesterday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {localSettings?.currency}
~{(plugEnergySummary.totalYesterday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
</div>
)}
</div>
@@ -2566,7 +2568,7 @@ export function SettingsPage() {
</div>
{(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
<div className="text-xs text-bambu-gray mt-1">
~{(plugEnergySummary.totalLifetime * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {localSettings?.currency}
~{(plugEnergySummary.totalLifetime * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
</div>
)}
</div>
@@ -3145,9 +3147,12 @@ export function SettingsPage() {
{/* Filament Tab */}
{activeTab === 'filament' && localSettings && (
<>
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
{/* Left Column - AMS Display Thresholds */}
<div className="flex-1 lg:max-w-xl">
{/* Left Column (1/3) - Mode Selector + AMS Thresholds */}
<div className="lg:w-1/3 space-y-6">
<SpoolmanSettings />
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('settings.amsDisplayThresholds')}</h2>
@@ -3306,11 +3311,13 @@ export function SettingsPage() {
</Card>
</div>
{/* Right Column - Spoolman Integration */}
<div className="flex-1 lg:max-w-xl">
<SpoolmanSettings />
{/* Right Column (2/3) - Spool Catalog + Color Catalog */}
<div className="lg:w-2/3 space-y-6">
<SpoolCatalogSettings />
<ColorCatalogSettings />
</div>
</div>
</>
)}
{/* Delete API Key Confirmation */}
@@ -3474,6 +3481,38 @@ export function SettingsPage() {
{/* Users Tab */}
{activeTab === 'users' && (
<div className="space-y-6">
{/* Sub-tab Navigation */}
<div className="flex gap-1 border-b border-bambu-dark-tertiary">
<button
onClick={() => setUsersSubTab('users')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
usersSubTab === 'users'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
}`}
>
<Users className="w-4 h-4" />
{t('settings.tabs.users')}
</button>
<button
onClick={() => setUsersSubTab('email')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
usersSubTab === 'email'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
}`}
>
<Mail className="w-4 h-4" />
{t('settings.tabs.emailAuth') || 'Email Authentication'}
{advancedAuthStatus?.advanced_auth_enabled && (
<span className="w-2 h-2 rounded-full bg-green-400" />
)}
</button>
</div>
{/* Users Sub-tab */}
{usersSubTab === 'users' && (
<>
{/* Auth Toggle Header */}
<Card>
<CardContent className="py-4">
@@ -3776,6 +3815,15 @@ export function SettingsPage() {
</CardContent>
</Card>
)}
</>
)}
{/* Email Auth Sub-tab */}
{usersSubTab === 'email' && (
<div className="max-w-2xl">
<EmailSettings />
</div>
)}
</div>
)}
@@ -4397,13 +4445,6 @@ export function SettingsPage() {
/>
)}
{/* Email Tab */}
{activeTab === 'email' && (
<div className="max-w-2xl">
<EmailSettings />
</div>
)}
{/* Backup Tab */}
{activeTab === 'backup' && (
<GitHubBackupSettings />
+2 -1
View File
@@ -26,6 +26,7 @@ import { api } from '../api/client';
import { PrintCalendar } from '../components/PrintCalendar';
import { FilamentTrends } from '../components/FilamentTrends';
import { Dashboard, type DashboardWidget } from '../components/Dashboard';
import { getCurrencySymbol } from '../utils/currency';
// Widget Components
function QuickStatsWidget({
@@ -603,7 +604,7 @@ export function StatsPage() {
}
};
const currency = settings?.currency || '$';
const currency = getCurrencySymbol(settings?.currency || 'USD');
const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
const printDates = archives?.map((a) => a.created_at) || [];
+21
View File
@@ -104,3 +104,24 @@ export function getColorName(hexColor: string): string {
}
return hexToColorName(hexColor);
}
/**
* Resolve a spool's display color name.
* Tries: stored color_name (if it's a readable name) → hex color database → HSL fallback.
* Detects Bambu internal codes (e.g. "A06-D0") and resolves them to names ("Titan Gray").
*/
export function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null {
// If color_name looks like a readable name (no pattern like "X00-Y0"), use it directly
if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) {
return colorName;
}
// Try hex color lookup from rgba
if (rgba && rgba.length >= 6) {
const hex = rgba.substring(0, 6).toLowerCase();
if (BAMBU_HEX_COLORS[hex]) {
return BAMBU_HEX_COLORS[hex];
}
}
// Return null (displayed as "-") — better than showing a code
return null;
}
+59
View File
@@ -0,0 +1,59 @@
const CURRENCY_SYMBOLS: Record<string, string> = {
USD: '$',
EUR: '€',
GBP: '£',
CHF: 'Fr.',
JPY: '¥',
CNY: '¥',
CAD: '$',
AUD: '$',
INR: '₹',
HKD: 'HK$',
KRW: '₩',
SEK: 'kr',
NOK: 'kr',
DKK: 'kr',
PLN: 'zł',
BRL: 'R$',
TWD: 'NT$',
SGD: 'S$',
NZD: 'NZ$',
MXN: 'MX$',
CZK: 'Kč',
THB: '฿',
ZAR: 'R',
TRY: '₺',
RUB: '₽',
};
export function getCurrencySymbol(currencyCode: string): string {
return CURRENCY_SYMBOLS[currencyCode.toUpperCase()] || currencyCode;
}
export const SUPPORTED_CURRENCIES = [
{ code: 'USD', label: 'USD ($)' },
{ code: 'EUR', label: 'EUR (€)' },
{ code: 'GBP', label: 'GBP (£)' },
{ code: 'CHF', label: 'CHF (Fr.)' },
{ code: 'JPY', label: 'JPY (¥)' },
{ code: 'CNY', label: 'CNY (¥)' },
{ code: 'CAD', label: 'CAD ($)' },
{ code: 'AUD', label: 'AUD ($)' },
{ code: 'INR', label: 'INR (₹)' },
{ code: 'HKD', label: 'HKD (HK$)' },
{ code: 'KRW', label: 'KRW (₩)' },
{ code: 'SEK', label: 'SEK (kr)' },
{ code: 'NOK', label: 'NOK (kr)' },
{ code: 'DKK', label: 'DKK (kr)' },
{ code: 'PLN', label: 'PLN (zł)' },
{ code: 'BRL', label: 'BRL (R$)' },
{ code: 'TWD', label: 'TWD (NT$)' },
{ code: 'SGD', label: 'SGD (S$)' },
{ code: 'NZD', label: 'NZD (NZ$)' },
{ code: 'MXN', label: 'MXN (MX$)' },
{ code: 'CZK', label: 'CZK (Kč)' },
{ code: 'THB', label: 'THB (฿)' },
{ code: 'ZAR', label: 'ZAR (R)' },
{ code: 'TRY', label: 'TRY (₺)' },
{ code: 'RUB', label: 'RUB (₽)' },
] as const;
+3 -1
View File
@@ -17,7 +17,7 @@ aioftp>=0.22.0
# Virtual Printer (emulates Bambu printer for slicer uploads)
pyftpdlib>=2.0.0
cryptography>=41.0.0
cryptography>=46.0.5
# 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
defusedxml>=0.7.0 # Safe XML parsing (prevents XXE attacks)
@@ -56,3 +56,5 @@ pytest>=8.0.0
pytest-asyncio>=0.23.0
httpx>=0.26.0
ruff>=0.2.0
pillow>=12.1.1
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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-GkrFU7v8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DLgJjh2G.css">
<script type="module" crossorigin src="/assets/index-CxS9CTuG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DMk3iz3Q.css">
</head>
<body>
<div id="root"></div>